feat: one-page panel - shell with Машины, Терминал and Настройки, pages as fragments, forms sent in place

This commit is contained in:
redtea committed 2026-10-15 20:05:20 +03:00
1 parent 7b9daa524e
commit 113c63fd1b
17 files changed
+678 -432

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
package server
import (
"bytes"
"html/template"
"log"
"net/http"
)
// A panel page is either a fragment - its content alone, for the shell to put
// in place without reloading - or the shell with that content already in it.
func isFrag(r *http.Request) bool { return r.URL.Query().Get("frag") == "1" }
// pageView is the shell's view a page is shown in; the machine pages (Порты,
// Статус, Изменить) open as a dialog over the machines view.
var pageView = map[string]string{"dashboard": "dashboard", "settings": "settings"}
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if data == nil {
data = map[string]any{}
}
data["IsAdmin"] = s.currentAdmin(r).IsAdmin()
if !isFrag(r) {
if _, ok := pageView[name]; !ok {
// A machine page asked for whole: the machines view with it in
// a dialog.
s.shell(w, r, map[string]any{"View": "dashboard", "Dialog": r.URL.Path})
return
}
}
var body bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&body, name+"-body", data); err != nil {
log.Printf("template %s: %v", name, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if isFrag(r) {
_, _ = w.Write(body.Bytes())
return
}
data["View"] = pageView[name]
data["Body"] = template.HTML(body.String())
s.shell(w, r, data)
}
// shell writes the shell page with data (View, Body, Open, Dialog).
func (s *Server) shell(w http.ResponseWriter, r *http.Request, data map[string]any) {
data["IsAdmin"] = s.currentAdmin(r).IsAdmin()
if data["View"] == "dashboard" && data["Body"] == nil {
// The machines list under a dialog: draw it too.
var body bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&body, "dashboard-body", s.dashboardData(r)); err == nil {
data["Body"] = template.HTML(body.String())
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := s.tmpl.ExecuteTemplate(w, "app.html", data); err != nil {
log.Printf("template app.html: %v", err)
}
}
// handleShell serves the shell alone: the terminal view, or with a pane of
// a machine opened at once (/agents/{id}/terminal, /agents/{id}/files).
func (s *Server) handleShell(w http.ResponseWriter, r *http.Request) {
data := map[string]any{"View": "terminal"}
if id := r.PathValue("id"); id != "" {
a, _ := s.db.GetAgent(id)
if a == nil {
http.NotFound(w, r)
return
}
kind := "term"
switch r.PathValue("kind") {
case "", "terminal":
case "files":
kind = "files"
default:
http.NotFound(w, r)
return
}
data["Open"] = map[string]any{"kind": kind, "agent": map[string]string{"id": a.ID, "name": a.Name, "host": a.Hostname, "user": a.TerminalUser()}}
}
s.shell(w, r, data)
}
+65
View File
@@ -0,0 +1,65 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newTestServer is a server on a fresh database with an admin signed in (the
// cookie) and one machine, x1.
func newTestServer(t *testing.T) (*Server, *http.Cookie) {
t.Helper()
db, err := OpenDB(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
s, err := New(Options{DB: db, DistDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("Passw0rd12345")
if err := db.CreateAdmin("adm", hash); err != nil {
t.Fatal(err)
}
_ = db.SetConfig(cfgSetupDone, "1")
a, _ := db.AdminByUsername("adm")
if err := db.CreateSession("sid-test", a.ID, sessionTTL); err != nil {
t.Fatal(err)
}
if err := db.CreateAgent(Agent{ID: "x1", Name: "t", Hostname: "h", OS: "linux", Arch: "amd64", TokenHash: "x"}); err != nil {
t.Fatal(err)
}
return s, &http.Cookie{Name: sessionCookie, Value: "sid-test"}
}
func get(t *testing.T, s *Server, c *http.Cookie, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
return w
}
func TestFragmentHasNoChrome(t *testing.T) {
s, cookie := newTestServer(t)
w := get(t, s, cookie, "/settings?frag=1")
body := w.Body.String()
if w.Code != 200 || strings.Contains(body, "<html") || strings.Contains(body, "topbar") {
t.Fatalf("fragment: code %d, has chrome: %q", w.Code, body[:min(200, len(body))])
}
if !strings.Contains(body, `data-module="settings"`) {
t.Fatalf("fragment has no module root")
}
}
func TestShellRoutes(t *testing.T) {
s, cookie := newTestServer(t)
for _, p := range []string{"/", "/terminal", "/settings", "/agents/x1/files", "/agents/x1/terminal"} {
if w := get(t, s, cookie, p); !strings.Contains(w.Body.String(), `id="app"`) {
t.Errorf("%s: not the shell (code %d)", p, w.Code)
}
}
}
+10 -9
View File
@@ -112,11 +112,12 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
// --- dashboard ---
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
agents, err := s.db.ListAgents()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.renderPage(w, r, "dashboard", s.dashboardData(r))
}
// dashboardData is the machines list as the user may see it.
func (s *Server) dashboardData(r *http.Request) map[string]any {
agents, _ := s.db.ListAgents()
type row struct {
Agent
Online bool
@@ -131,12 +132,12 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
on := s.online(&a)
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
}
s.render(w, "dashboard.html", map[string]any{
return map[string]any{
"Admin": me,
"IsAdmin": me.IsAdmin(),
"Agents": rows,
"CSRF": csrfToken(r),
})
}
}
// seenText is the "activity" cell: a connected agent is active now, whatever
@@ -239,7 +240,7 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
data["UsersErr"] = err.Error()
}
}
s.render(w, "edit.html", data)
s.renderPage(w, r, "edit", data)
}
// POST /agents/{id}/transport - how the agent is to reach the server: auto,
@@ -434,7 +435,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
code := s.db.InstallCode()
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
ufwRules, _ := s.db.ListUfwRules()
s.render(w, "settings.html", map[string]any{
s.renderPage(w, r, "settings", map[string]any{
"Admin": s.currentAdmin(r),
"Host": host,
"Direct": direct,
+1 -1
View File
@@ -23,7 +23,7 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
"listen": "Не удалось занять порт на хосте (возможно, уже используется).",
"db": "Ошибка базы данных.",
}[r.URL.Query().Get("err")]
s.render(w, "forwards.html", map[string]any{
s.renderPage(w, r, "forwards", map[string]any{
"Agent": a,
"Rules": rules,
"CSRF": csrfToken(r),
+1 -1
View File
@@ -74,7 +74,7 @@ func (s *Server) handleStatusPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
s.render(w, "status.html", map[string]any{"Agent": a, "RelayUpMs": speedRelayUpFor.Milliseconds()})
s.renderPage(w, r, "status", map[string]any{"Agent": a, "RelayUpMs": speedRelayUpFor.Milliseconds()})
}
// runTask runs a one-shot task on the agent and waits for its result.
+3 -2
View File
@@ -227,8 +227,9 @@ func (s *Server) Handler() http.Handler {
// Panel (admin-only).
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleTerminalPage))
mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage))
mux.Handle("GET /terminal", s.requireAdmin(s.handleShell))
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleShell))
mux.Handle("GET /agents/{id}/{kind}", s.requireAdmin(s.handleShell)) // files
mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage))
mux.Handle("GET /agents/{id}/status", s.requireAdmin(s.handleStatusPage))
mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit))
+3
View File
@@ -259,3 +259,6 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
@supports not selector(::-webkit-scrollbar) {
* { scrollbar-width: thin; scrollbar-color: rgba(62, 68, 78, calc(var(--sb) * .9)) transparent; }
}
/* The one-page shell: the header's current view. */
header.topbar nav a.on { color: var(--text); }
+133
View File
@@ -0,0 +1,133 @@
// The panel is one page: the header and three views (Машины, Терминал,
// Настройки). Moving between them changes the address without a reload, so
// the terminal view - and every session in it - lives as long as the tab.
// Машины and Настройки come from the server as fragments; their forms are
// sent from here and the fragment drawn again from the answer. A fragment's
// script is a module registered here, mounted on the fragment's root and
// unmounted - its timers, requests and listeners dropped - when it goes.
(function () {
'use strict';
const views = {'/': 'machines', '/terminal': 'terminal', '/settings': 'settings'};
const modules = {};
const mounted = new Map(); // area -> {mod, scope}
const $ = s => document.querySelector(s);
const viewOf = path => views[path] || null;
function register(name, mod) { modules[name] = mod; }
// scope gives a module timers, requests and listeners that end with it.
function scope() {
const timers = new Set(), offs = [], ac = new AbortController();
return {
timeout(fn, ms) { const id = setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); return id; },
interval(fn, ms) { const id = setInterval(fn, ms); timers.add(id); return id; },
clear(id) { clearTimeout(id); clearInterval(id); timers.delete(id); },
fetch(url, opt) {
const o = Object.assign({credentials: 'same-origin'}, opt || {});
// A request with its own signal ends on either.
o.signal = o.signal && AbortSignal.any ? AbortSignal.any([o.signal, ac.signal]) : (o.signal || ac.signal);
return fetch(url, o);
},
on(target, ev, fn, opt) { target.addEventListener(ev, fn, opt); offs.push(() => target.removeEventListener(ev, fn, opt)); },
signal: ac.signal,
end() { timers.forEach(id => { clearTimeout(id); clearInterval(id); }); timers.clear(); offs.forEach(f => f()); ac.abort(); }
};
}
function unmount(area) {
const m = mounted.get(area);
if (!m) return;
mounted.delete(area);
try { if (m.mod.unmount) m.mod.unmount(m.root); } finally { m.scope.end(); }
}
function mount(area) {
const root = area.querySelector('[data-module]');
const mod = root && modules[root.dataset.module];
if (!mod) return;
const sc = scope();
mounted.set(area, {mod, scope: sc, root});
mod.mount(root, sc);
}
// put draws html in area, the old module unmounted and the new one mounted.
function put(area, html) {
unmount(area);
area.innerHTML = html;
mount(area);
}
const failHTML = msg => '<div class="wrap"><p class="err">' + msg + ' <button type="button" class="btn ghost small" data-retry>Повторить</button></p></div>';
function withFrag(url) { const u = new URL(url, location.href); u.searchParams.set('frag', '1'); return u; }
// A request that ended on the sign-in page: the session is over.
function signedOut(r) { return r.redirected && new URL(r.url).pathname === '/login'; }
async function load(area, url) {
let r;
try { r = await fetch(withFrag(url), {credentials: 'same-origin', cache: 'no-store'}); } catch (e) { r = null; }
if (r && signedOut(r)) { location.href = '/login'; return; }
if (!r || !r.ok) {
put(area, failHTML(r ? 'не загрузилось: ошибка ' + r.status : 'нет связи с сервером'));
area.querySelector('[data-retry]').onclick = () => load(area, url);
return;
}
put(area, await r.text());
}
function show(view) {
document.querySelectorAll('main#app > .view').forEach(v => { v.hidden = v.dataset.view !== view; });
document.querySelectorAll('[data-nav]').forEach(a => a.classList.toggle('on', a.dataset.nav === view));
$('main#app').dataset.view = view;
document.dispatchEvent(new CustomEvent('app:view', {detail: view}));
}
async function go(url, push = true) {
const u = new URL(url, location.href);
const view = viewOf(u.pathname);
if (!view || u.origin !== location.origin) { location.href = u.href; return; }
if (push && u.pathname + u.search !== location.pathname + location.search) history.pushState({}, '', u.pathname + u.search + u.hash);
show(view);
if (view !== 'terminal') await load($('.view[data-view="' + view + '"]'), u.pathname + u.search);
}
// Links to a view move to it; any other link behaves as a link.
document.addEventListener('click', ev => {
const a = ev.target.closest('a[href]');
if (!a || ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || a.target || a.hasAttribute('download')) return;
const u = new URL(a.href, location.href);
if (u.origin === location.origin && viewOf(u.pathname)) { ev.preventDefault(); go(u.href); }
});
window.addEventListener('popstate', () => go(location.href, false));
// A form in a fragment is sent from here: the answer's address says what
// to draw. /logout and forms outside the views go the usual way; a form
// whose onsubmit said no (a confirm) is not sent.
document.addEventListener('submit', async ev => {
const f = ev.target;
const area = f.closest('.view, .frag-area');
if (!area || ev.defaultPrevented || (f.method || '').toLowerCase() !== 'post' || /\/logout$/.test(f.action)) return;
ev.preventDefault();
let r;
try { r = await fetch(f.action, {method: 'POST', body: new FormData(f), credentials: 'same-origin'}); }
catch (e) { alert('Нет связи с сервером'); return; }
if (signedOut(r)) { location.href = '/login'; return; }
const to = new URL(r.url);
if (area.classList.contains('view')) {
const v = viewOf(to.pathname);
if (v && v !== area.dataset.view) { go(to.href); return; }
history.replaceState({}, '', to.pathname + to.search + to.hash);
}
// The answer is the page whole (the shell): ask for its fragment.
await load(area, to.pathname + to.search);
if (to.hash) { const t = area.querySelector(to.hash); if (t) t.scrollIntoView(); }
});
// Leaving the page drops the terminals: the browser asks while one is alive.
window.addEventListener('beforeunload', ev => {
if (window.Workspace && window.Workspace.hasLiveSessions()) { ev.preventDefault(); ev.returnValue = ''; }
});
// start mounts what the server drew, once every script has registered.
function start() {
document.querySelectorAll('main#app > .view').forEach(mount);
show({dashboard: 'machines', settings: 'settings'}[$('main#app').dataset.view] || 'terminal');
}
window.App = {go, load, register, put, start, scope};
})();
+320
View File
@@ -0,0 +1,320 @@
// Scripts of the pages the shell shows as fragments, as modules: mount(root,
// S) runs when the fragment is put in place, with S the module's scope -
// timers, requests and listeners that end when it is replaced or closed.
(function () {
'use strict';
const CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
function copy(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
const ta = document.createElement('textarea');
ta.value = text; ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta); ta.select();
const ok = document.execCommand('copy'); ta.remove();
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
}
App.register('settings', {
mount(root) {
root.querySelectorAll('.copybox .copy').forEach(b => {
const icon = b.innerHTML;
b.onclick = () => copy(root.querySelector('#' + b.dataset.copy).textContent.trim()).then(() => {
b.innerHTML = CHECK; b.classList.add('done'); b.title = 'Скопировано';
setTimeout(() => { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500);
}, e => alert('Не скопировалось: ' + e.message));
});
if (window.UsersTable) window.UsersTable.mount(root);
}
});
// The machines list keeps its status column live; nothing is asked while
// the tab is hidden.
App.register('dashboard', {
mount(root, S) {
var setTimeout = S.timeout, clearTimeout = S.clear, fetch = S.fetch;
var rows = {};
root.querySelectorAll('tr[data-agent]').forEach(function (tr) { rows[tr.dataset.agent] = tr; });
var timer = null, busy = false;
function schedule() {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, 5000);
}
function load() {
if (busy || document.hidden) return;
busy = true;
fetch('/api/panel/agents/presence', {credentials: 'same-origin'})
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
list.forEach(function (a) {
var tr = rows[a.id];
if (!tr) return;
tr.querySelector('.dot').className = 'dot ' + (a.online ? 'on' : 'off');
tr.querySelector('.state').textContent = a.online ? 'online' : 'offline';
tr.querySelector('.seen').textContent = a.seen;
});
})
.catch(function () {})
.then(function () { busy = false; schedule(); });
}
S.on(document, 'visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
if (Object.keys(rows).length) schedule();
}
});
// Ports: the note under the mode follows the chosen mode.
App.register('forwards', {
mount(root) {
var notes = {
http: 'Трафик идёт через порт панели ' + root.dataset.panelPort + ' (long-poll). Порт ' + root.dataset.tunnelPort + ' не требуется.',
tcp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/tcp на хосте. Самая низкая задержка.',
udp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/udp на хосте.'
};
var sel = root.querySelector('#proto'), note = root.querySelector('#note');
if (!sel || !note) return;
function upd() { note.textContent = notes[sel.value] || ''; }
sel.onchange = upd; upd();
}
});
// Status: meters, live speed and the speed test of a machine.
App.register('status', {
mount(root, S) {
// Timers, requests and listeners end with the page.
var setTimeout = S.timeout, setInterval = S.interval, clearTimeout = S.clear, clearInterval = S.clear, fetch = S.fetch;
var base = '/api/panel/agents/' + encodeURIComponent(root.dataset.agent);
var $ = function (id) { return root.querySelector('#' + id); };
var errBox = $('err');
function size(n) {
var u = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'], i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
}
function rateParts(bps) {
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
return [bps.toFixed(bps < 100 && i > 0 ? 1 : 0), u[i]];
}
function rate(bps) { return rateParts(bps).join(' '); }
function meter(name, pct, sub) {
pct = Math.max(0, Math.min(100, pct));
$(name + '-val').textContent = Math.round(pct) + '%';
var bar = $(name + '-bar');
bar.style.transform = 'scaleX(' + pct / 100 + ')';
bar.className = pct >= 90 ? 'crit' : pct >= 75 ? 'warn' : '';
$(name + '-sub').textContent = sub;
}
function showErr(msg) {
errBox.textContent = msg || '';
errBox.hidden = !msg;
}
function cores(n) {
var d = n % 10, h = n % 100;
return n + (d === 1 && h !== 11 ? ' ядро' : d >= 2 && d <= 4 && (h < 12 || h > 14) ? ' ядра' : ' ядер');
}
// One request at a time, and none while the tab is hidden: the client is
// sampled only for a page somebody is looking at.
var timer = null, busy = false;
function schedule(ms) {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, ms);
}
function load() {
if (busy || document.hidden) return;
busy = true;
var next = 3000;
fetch(base + '/stat', {credentials: 'same-origin'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (res) {
if (!res.ok) { showErr(res.error || 'ошибка'); next = 10000; return; }
showErr('');
var d = res.data;
meter('cpu', d.cpu, cores(d.ncpu));
meter('mem', d.mem_total ? 100 * d.mem_used / d.mem_total : 0,
'занято ' + size(d.mem_used) + ' из ' + size(d.mem_total) + ' · свободно ' + size(d.mem_total - d.mem_used));
meter('disk', d.disk_total ? 100 * (d.disk_total - d.disk_free) / d.disk_total : 0,
'свободно ' + size(d.disk_free) + ' из ' + size(d.disk_total));
})
.catch(function (e) { showErr('Нет связи с панелью: ' + e.message); next = 10000; })
.then(function () { busy = false; schedule(next); });
}
S.on(document, 'visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
load();
var btn = $('speed-btn'), note = $('speed-note');
function json(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }
function post(path) { return fetch(base + path, {method: 'POST', credentials: 'same-origin'}).then(json); }
// The browser's round trip to the panel: the first request may pay for a
// new connection, so keep the best.
function panelPing() {
var best = Infinity, left = 4;
function one() {
var t = performance.now();
return fetch('/api/panel/ping', {credentials: 'same-origin', cache: 'no-store'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
.then(function () {
best = Math.min(best, performance.now() - t);
return --left > 0 ? one() : best;
});
}
return one();
}
// Keeps the client's download fed until stopped or the panel says the
// client has read enough; the pieces grow while they go through quickly.
function feed(stop) {
var block = new Uint8Array(65536), n = 4;
crypto.getRandomValues(block);
function one() {
var parts = [], t = performance.now();
for (var i = 0; i < n; i++) parts.push(block);
return fetch(base + '/speed/down', {method: 'POST', body: new Blob(parts), credentials: 'same-origin', signal: stop.signal})
.then(function (r) {
if (r.status !== 204) return;
if (performance.now() - t < 500 && n < 256) n *= 2;
return one();
});
}
one().catch(function () {});
}
// Reads what the client sends and times it from the first byte. It stops by
// itself a little after the client's window, so bytes still queued on a slow
// link don't hold the result back. st.n is the count so far, for the live
// reading.
function drain(st, onFirst) {
var ac = new AbortController(), last = 0;
st.stop = function () { ac.abort(); };
function done() { return st.start && last > st.start ? st.n * 8000 / (last - st.start) : 0; }
return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store', signal: ac.signal}).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
var rd = r.body.getReader();
function pump() {
return rd.read().then(function (c) {
if (c.done) return done();
last = performance.now();
if (!st.start) { st.start = last; setTimeout(st.stop, Number(root.dataset.relayUpMs)); onFirst(); }
else st.n += c.value.length;
return pump();
});
}
return pump();
}).catch(function (e) { if (st.start) return done(); throw e; });
}
// The test through the browser: the client's download is what this page
// uploads, and its upload is timed here, where it arrives.
function relayTest(st) {
return panelPing().then(function (ping) {
return post('/speed/relay').then(function (res) {
if (!res.ok) return res;
var stop = new AbortController();
var up = drain(st, function () { stop.abort(); });
up.catch(function () {});
feed(stop);
return post('/speed?relay=1').then(function (res) {
stop.abort();
// No upload under way by now: none is coming.
if (!res.ok || !st.start) { st.stop(); }
if (!res.ok) return res;
return up.catch(function () { return 0; }).then(function (bps) {
res.data.ping_ms += ping;
if (bps) res.data.up_bps = bps;
return res;
});
}, function (e) { stop.abort(); st.stop(); throw e; });
});
});
}
// The live reading: the rate over the last second, from the byte counts the
// panel keeps for the running test - or, for the client's upload in a test
// through the browser, from what has arrived here.
var ARC = 251.4, lv = null;
function liveStart(relay, st) {
lv = {relay: relay, st: st, phase: '', pts: [], hist: [], busy: false};
$('live').hidden = false;
liveShow('ping', 0);
lv.timer = setInterval(liveTick, 200);
}
function liveStop() {
if (!lv) return;
clearInterval(lv.timer);
lv = null;
$('live').hidden = true;
}
function liveTick() {
var cur = lv;
if (cur.relay && cur.st.start) { livePoint(cur, 'up', cur.st.n, performance.now()); return; }
if (cur.busy) return;
cur.busy = true;
fetch(base + '/speed/progress', {credentials: 'same-origin', cache: 'no-store'})
.then(json)
.then(function (d) {
if (cur !== lv || !d.phase || (cur.relay && cur.st.start)) return;
livePoint(cur, d.phase, d.phase === 'up' ? d.up : d.down, d.ms);
})
.catch(function () {})
.then(function () { cur.busy = false; });
}
function livePoint(cur, phase, bytes, ms) {
if (phase !== cur.phase) { cur.phase = phase; cur.pts = []; cur.hist = []; }
if (phase === 'ping') { liveShow(phase, 0); return; }
var pts = cur.pts;
pts.push([ms, bytes]);
while (pts.length > 2 && ms - pts[0][0] > 1000) pts.shift();
if (pts.length < 2 || ms <= pts[0][0]) return;
var bps = (bytes - pts[0][1]) * 8000 / (ms - pts[0][0]);
cur.hist.push(bps);
liveShow(phase, bps, cur);
}
function liveShow(phase, bps, cur) {
var relay = lv && lv.relay, p = rateParts(bps);
$('live').className = 'live' + (phase === 'up' ? ' up' : '');
$('lv-ico').textContent = phase === 'up' ? '↑' : phase === 'down' ? '↓' : '';
$('lv-phase').textContent = phase === 'ping' ? 'Задержка…'
: phase === 'up' ? (relay ? 'клиент → браузер' : 'клиент → хост')
: (relay ? 'браузер → клиент' : 'хост → клиент');
$('lv-num').textContent = phase === 'ping' ? '…' : p[0];
$('lv-unit').textContent = phase === 'ping' ? '' : p[1];
// A log scale up to 1 Гбит/с, so a slow link moves the needle too.
var f = Math.min(1, Math.log(1 + bps / 1e6) / Math.log(1001));
$('lv-arc').style.strokeDashoffset = ARC * (1 - f);
var h = cur ? cur.hist : [], max = 1.25 * Math.max.apply(null, h.concat(1)), line = '';
for (var i = 0; i < h.length; i++) {
line += (h.length > 1 ? 100 * i / (h.length - 1) : 0).toFixed(1) + ',' + (98 - 94 * h[i] / max).toFixed(1) + ' ';
}
$('lv-line').setAttribute('points', line);
$('lv-area').setAttribute('points', h.length > 1 ? '0,100 ' + line + '100,100' : '');
}
btn.onclick = function () {
var relay = $('speed-relay').checked, st = {n: 0, start: 0, stop: function () {}};
btn.disabled = true;
note.textContent = 'Идёт замер…';
$('speed').hidden = true;
liveStart(relay, st);
(relay ? relayTest(st) : post('/speed'))
.then(function (res) {
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
var d = res.data;
$('sp-ping').textContent = d.ping_ms ? d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс' : '-';
$('sp-down').textContent = rate(d.down_bps);
$('sp-up').textContent = d.up_bps ? rate(d.up_bps) : '-';
$('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент';
$('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост';
$('speed').hidden = false;
note.textContent = d.partial ? 'Замер остановлен по времени - показано то, что успело набраться' : '';
})
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
.then(function () { liveStop(); btn.disabled = false; });
};
}
});
})();
+5 -4
View File
@@ -1,11 +1,12 @@
// The users table in Settings: the role, the machines a user may use (a
// dialog with a list to tick), the password sign-in and whether the user
// may switch it themselves. Each change is saved at once.
(function () {
// It is mounted on the settings fragment each time the shell puts it in place.
window.UsersTable = {mount: function (root) {
'use strict';
var table = document.querySelector('table.users');
var table = root.querySelector('table.users');
if (!table) return;
var $ = function (id) { return document.getElementById(id); };
var $ = function (id) { return root.querySelector('#' + id); };
function save(id, body) {
return fetch('/api/panel/users/' + id, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)})
@@ -102,4 +103,4 @@
// ---- a new user: the machines appear for a plain user ----
var role = $('newrole'), pick = $('newagents');
if (role) role.onchange = function () { pick.hidden = role.value !== 'user'; };
})();
}};
+23
View File
@@ -0,0 +1,23 @@
{{define "app.html"}}<!doctype html>
<html lang="ru">
<head>
<title>LPRA</title>
{{template "head" .}}
<link rel="stylesheet" href="{{asset "xterm.css"}}">
</head>
<body class="app">
{{template "topbar" .}}
<main id="app" data-view="{{.View}}">
<section class="view" data-view="machines"{{if ne .View "dashboard"}} hidden{{end}}>{{if eq .View "dashboard"}}{{.Body}}{{end}}</section>
<section class="view view-term" data-view="terminal"{{if ne .View "terminal"}} hidden{{end}}></section>
<section class="view" data-view="settings"{{if ne .View "settings"}} hidden{{end}}>{{if eq .View "settings"}}{{.Body}}{{end}}</section>
</main>
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}};</script>
<script src="{{asset "wsutil.js"}}"></script>
<script src="{{asset "app.js"}}"></script>
<script src="{{asset "pages.js"}}"></script>
<script src="{{asset "users.js"}}"></script>
<script src="{{asset "account.js"}}"></script>
<script>App.start();</script>
</body>
</html>{{end}}
+2 -55
View File
@@ -1,22 +1,4 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Машины - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
</head>
<body>
<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
<nav>
<a href="/">Машины</a>
{{if .IsAdmin}}<a href="/settings">Настройки</a>{{end}}
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
</nav>
</header>
{{define "dashboard-body"}}<div class="page" data-module="dashboard">
<div class="wrap">
<h1>Машины-клиенты</h1>
<div class="card">
@@ -68,39 +50,4 @@
{{end}}
</div>
</div>
<script>
// Keep the status column live; nothing is asked while the tab is hidden.
(function () {
var rows = {};
document.querySelectorAll('tr[data-agent]').forEach(function (tr) { rows[tr.dataset.agent] = tr; });
var timer = null, busy = false;
function schedule() {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, 5000);
}
function load() {
if (busy || document.hidden) return;
busy = true;
fetch('/api/panel/agents/presence', {credentials: 'same-origin'})
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
list.forEach(function (a) {
var tr = rows[a.id];
if (!tr) return;
tr.querySelector('.dot').className = 'dot ' + (a.online ? 'on' : 'off');
tr.querySelector('.state').textContent = a.online ? 'online' : 'offline';
tr.querySelector('.seen').textContent = a.seen;
});
})
.catch(function () {})
.then(function () { busy = false; schedule(); });
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
if (Object.keys(rows).length) schedule();
})();
</script>
<script src="{{asset "account.js"}}"></script>
</body>
</html>
</div>{{end}}
+2 -23
View File
@@ -1,24 +1,4 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Изменить машину - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
</head>
<body>
<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Изменить машину</div>
<nav>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
</nav>
</header>
{{define "edit-body"}}<div class="page" data-module="edit">
<div class="wrap">
<h1>{{.Agent.Name}}</h1>
@@ -124,5 +104,4 @@
</form>
</div>{{end}}
</div>
</body>
</html>
</div>{{end}}
+2 -33
View File
@@ -1,12 +1,4 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Порты · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
{{define "forwards-body"}}<div class="page" data-module="forwards" data-panel-port="{{.PanelPort}}" data-tunnel-port="{{.TunnelPort}}">
<style>
/* One line: fixed narrow fields, the label takes what is left. */
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
@@ -36,18 +28,6 @@
.under { margin-right:0; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
</nav>
</header>
<div class="wrap">
<h1>Проброс портов</h1>
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
@@ -135,15 +115,4 @@
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
</div>
</div>
<script>
var notes = {
http: 'Трафик идёт через порт панели {{.PanelPort}} (long-poll). Порт {{.TunnelPort}} не требуется.',
tcp: 'Требуется открытый порт {{.TunnelPort}}/tcp на хосте. Самая низкая задержка.',
udp: 'Требуется открытый порт {{.TunnelPort}}/udp на хосте.'
};
var sel = document.getElementById('proto'), note = document.getElementById('note');
function upd(){ note.textContent = notes[sel.value] || ''; }
sel.onchange = upd; upd();
</script>
</body>
</html>
</div>{{end}}
+16
View File
@@ -0,0 +1,16 @@
{{define "head"}}<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>{{end}}
{{define "topbar"}}<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · <span id="brandtitle">Удалённый доступ</span></div>
<nav>
<a href="/" data-nav="machines">Машины</a>
<a href="/terminal" data-nav="terminal">Терминал</a>
{{if .IsAdmin}}<a href="/settings" data-nav="settings">Настройки</a>{{end}}
<span id="status" class="muted"></span>
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
</nav>
</header>{{end}}
+2 -48
View File
@@ -1,12 +1,4 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройки - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
{{define "settings-body"}}<div class="page" data-module="settings">
<style>
/* An install command, with a button to copy it at its right. */
/* Users: role, machines, password sign-in. */
@@ -49,15 +41,6 @@
.codeform input[type=number] { width:72px; height:30px; padding:4px 8px; }
.codeform .ico { display:inline-flex; align-items:center; gap:6px; height:30px; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
<nav>
<a href="/">Машины</a>
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
</nav>
</header>
<div class="wrap">
<h1>Настройки</h1>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
@@ -280,33 +263,4 @@
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
</div>
<script>
// Copy an install command: the clipboard API on https, the old way otherwise.
(function () {
var CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
function copy(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
ta.remove();
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
}
document.querySelectorAll('.copybox .copy').forEach(function (b) {
var icon = b.innerHTML;
b.onclick = function () {
copy(document.getElementById(b.dataset.copy).textContent.trim()).then(function () {
b.innerHTML = CHECK; b.classList.add('done'); b.title = 'Скопировано';
setTimeout(function () { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500);
}, function (e) { alert('Не скопировалось: ' + e.message); });
};
});
})();
</script>
<script src="{{asset "users.js"}}"></script>
<script src="{{asset "account.js"}}"></script>
</body>
</html>
</div>{{end}}
+2 -256
View File
@@ -1,12 +1,4 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Статус · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
{{define "status-body"}}<div class="page" data-module="status" data-agent="{{.Agent.ID}}" data-relay-up-ms="{{.RelayUpMs}}">
<style>
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
.meters .card { margin:0; }
@@ -41,18 +33,6 @@
.live.up .spark polygon { fill:var(--ok); }
label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Статус - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
</nav>
</header>
<div class="wrap">
<h1>Статус</h1>
<div class="err" id="err" hidden></div>
@@ -111,238 +91,4 @@
</div>
</div>
</div>
<script>
(function () {
var base = '/api/panel/agents/' + encodeURIComponent({{.Agent.ID}});
var $ = function (id) { return document.getElementById(id); };
var errBox = $('err');
function size(n) {
var u = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'], i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
}
function rateParts(bps) {
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
return [bps.toFixed(bps < 100 && i > 0 ? 1 : 0), u[i]];
}
function rate(bps) { return rateParts(bps).join(' '); }
function meter(name, pct, sub) {
pct = Math.max(0, Math.min(100, pct));
$(name + '-val').textContent = Math.round(pct) + '%';
var bar = $(name + '-bar');
bar.style.transform = 'scaleX(' + pct / 100 + ')';
bar.className = pct >= 90 ? 'crit' : pct >= 75 ? 'warn' : '';
$(name + '-sub').textContent = sub;
}
function showErr(msg) {
errBox.textContent = msg || '';
errBox.hidden = !msg;
}
function cores(n) {
var d = n % 10, h = n % 100;
return n + (d === 1 && h !== 11 ? ' ядро' : d >= 2 && d <= 4 && (h < 12 || h > 14) ? ' ядра' : ' ядер');
}
// One request at a time, and none while the tab is hidden: the client is
// sampled only for a page somebody is looking at.
var timer = null, busy = false;
function schedule(ms) {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, ms);
}
function load() {
if (busy || document.hidden) return;
busy = true;
var next = 3000;
fetch(base + '/stat', {credentials: 'same-origin'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (res) {
if (!res.ok) { showErr(res.error || 'ошибка'); next = 10000; return; }
showErr('');
var d = res.data;
meter('cpu', d.cpu, cores(d.ncpu));
meter('mem', d.mem_total ? 100 * d.mem_used / d.mem_total : 0,
'занято ' + size(d.mem_used) + ' из ' + size(d.mem_total) + ' · свободно ' + size(d.mem_total - d.mem_used));
meter('disk', d.disk_total ? 100 * (d.disk_total - d.disk_free) / d.disk_total : 0,
'свободно ' + size(d.disk_free) + ' из ' + size(d.disk_total));
})
.catch(function (e) { showErr('Нет связи с панелью: ' + e.message); next = 10000; })
.then(function () { busy = false; schedule(next); });
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
load();
var btn = $('speed-btn'), note = $('speed-note');
function json(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }
function post(path) { return fetch(base + path, {method: 'POST', credentials: 'same-origin'}).then(json); }
// The browser's round trip to the panel: the first request may pay for a
// new connection, so keep the best.
function panelPing() {
var best = Infinity, left = 4;
function one() {
var t = performance.now();
return fetch('/api/panel/ping', {credentials: 'same-origin', cache: 'no-store'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
.then(function () {
best = Math.min(best, performance.now() - t);
return --left > 0 ? one() : best;
});
}
return one();
}
// Keeps the client's download fed until stopped or the panel says the
// client has read enough; the pieces grow while they go through quickly.
function feed(stop) {
var block = new Uint8Array(65536), n = 4;
crypto.getRandomValues(block);
function one() {
var parts = [], t = performance.now();
for (var i = 0; i < n; i++) parts.push(block);
return fetch(base + '/speed/down', {method: 'POST', body: new Blob(parts), credentials: 'same-origin', signal: stop.signal})
.then(function (r) {
if (r.status !== 204) return;
if (performance.now() - t < 500 && n < 256) n *= 2;
return one();
});
}
one().catch(function () {});
}
// Reads what the client sends and times it from the first byte. It stops by
// itself a little after the client's window, so bytes still queued on a slow
// link don't hold the result back. st.n is the count so far, for the live
// reading.
function drain(st, onFirst) {
var ac = new AbortController(), last = 0;
st.stop = function () { ac.abort(); };
function done() { return st.start && last > st.start ? st.n * 8000 / (last - st.start) : 0; }
return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store', signal: ac.signal}).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
var rd = r.body.getReader();
function pump() {
return rd.read().then(function (c) {
if (c.done) return done();
last = performance.now();
if (!st.start) { st.start = last; setTimeout(st.stop, {{.RelayUpMs}}); onFirst(); }
else st.n += c.value.length;
return pump();
});
}
return pump();
}).catch(function (e) { if (st.start) return done(); throw e; });
}
// The test through the browser: the client's download is what this page
// uploads, and its upload is timed here, where it arrives.
function relayTest(st) {
return panelPing().then(function (ping) {
return post('/speed/relay').then(function (res) {
if (!res.ok) return res;
var stop = new AbortController();
var up = drain(st, function () { stop.abort(); });
up.catch(function () {});
feed(stop);
return post('/speed?relay=1').then(function (res) {
stop.abort();
// No upload under way by now: none is coming.
if (!res.ok || !st.start) { st.stop(); }
if (!res.ok) return res;
return up.catch(function () { return 0; }).then(function (bps) {
res.data.ping_ms += ping;
if (bps) res.data.up_bps = bps;
return res;
});
}, function (e) { stop.abort(); st.stop(); throw e; });
});
});
}
// The live reading: the rate over the last second, from the byte counts the
// panel keeps for the running test - or, for the client's upload in a test
// through the browser, from what has arrived here.
var ARC = 251.4, lv = null;
function liveStart(relay, st) {
lv = {relay: relay, st: st, phase: '', pts: [], hist: [], busy: false};
$('live').hidden = false;
liveShow('ping', 0);
lv.timer = setInterval(liveTick, 200);
}
function liveStop() {
if (!lv) return;
clearInterval(lv.timer);
lv = null;
$('live').hidden = true;
}
function liveTick() {
var cur = lv;
if (cur.relay && cur.st.start) { livePoint(cur, 'up', cur.st.n, performance.now()); return; }
if (cur.busy) return;
cur.busy = true;
fetch(base + '/speed/progress', {credentials: 'same-origin', cache: 'no-store'})
.then(json)
.then(function (d) {
if (cur !== lv || !d.phase || (cur.relay && cur.st.start)) return;
livePoint(cur, d.phase, d.phase === 'up' ? d.up : d.down, d.ms);
})
.catch(function () {})
.then(function () { cur.busy = false; });
}
function livePoint(cur, phase, bytes, ms) {
if (phase !== cur.phase) { cur.phase = phase; cur.pts = []; cur.hist = []; }
if (phase === 'ping') { liveShow(phase, 0); return; }
var pts = cur.pts;
pts.push([ms, bytes]);
while (pts.length > 2 && ms - pts[0][0] > 1000) pts.shift();
if (pts.length < 2 || ms <= pts[0][0]) return;
var bps = (bytes - pts[0][1]) * 8000 / (ms - pts[0][0]);
cur.hist.push(bps);
liveShow(phase, bps, cur);
}
function liveShow(phase, bps, cur) {
var relay = lv && lv.relay, p = rateParts(bps);
$('live').className = 'live' + (phase === 'up' ? ' up' : '');
$('lv-ico').textContent = phase === 'up' ? '↑' : phase === 'down' ? '↓' : '';
$('lv-phase').textContent = phase === 'ping' ? 'Задержка…'
: phase === 'up' ? (relay ? 'клиент → браузер' : 'клиент → хост')
: (relay ? 'браузер → клиент' : 'хост → клиент');
$('lv-num').textContent = phase === 'ping' ? '…' : p[0];
$('lv-unit').textContent = phase === 'ping' ? '' : p[1];
// A log scale up to 1 Гбит/с, so a slow link moves the needle too.
var f = Math.min(1, Math.log(1 + bps / 1e6) / Math.log(1001));
$('lv-arc').style.strokeDashoffset = ARC * (1 - f);
var h = cur ? cur.hist : [], max = 1.25 * Math.max.apply(null, h.concat(1)), line = '';
for (var i = 0; i < h.length; i++) {
line += (h.length > 1 ? 100 * i / (h.length - 1) : 0).toFixed(1) + ',' + (98 - 94 * h[i] / max).toFixed(1) + ' ';
}
$('lv-line').setAttribute('points', line);
$('lv-area').setAttribute('points', h.length > 1 ? '0,100 ' + line + '100,100' : '');
}
btn.onclick = function () {
var relay = $('speed-relay').checked, st = {n: 0, start: 0, stop: function () {}};
btn.disabled = true;
note.textContent = 'Идёт замер…';
$('speed').hidden = true;
liveStart(relay, st);
(relay ? relayTest(st) : post('/speed'))
.then(function (res) {
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
var d = res.data;
$('sp-ping').textContent = d.ping_ms ? d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс' : '-';
$('sp-down').textContent = rate(d.down_bps);
$('sp-up').textContent = d.up_bps ? rate(d.up_bps) : '-';
$('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент';
$('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост';
$('speed').hidden = false;
note.textContent = d.partial ? 'Замер остановлен по времени - показано то, что успело набраться' : '';
})
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
.then(function () { liveStop(); btn.disabled = false; });
};
})();
</script>
</body>
</html>
</div>{{end}}