diff --git a/internal/server/frag.go b/internal/server/frag.go
new file mode 100644
index 0000000..d5160f8
--- /dev/null
+++ b/internal/server/frag.go
@@ -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)
+}
diff --git a/internal/server/frag_test.go b/internal/server/frag_test.go
new file mode 100644
index 0000000..28073f4
--- /dev/null
+++ b/internal/server/frag_test.go
@@ -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, " {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 => '
';
+ 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};
+})();
diff --git a/internal/server/web/static/pages.js b/internal/server/web/static/pages.js
new file mode 100644
index 0000000..38f834d
--- /dev/null
+++ b/internal/server/web/static/pages.js
@@ -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 = '';
+ 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; });
+ };
+
+ }
+ });
+})();
diff --git a/internal/server/web/static/users.js b/internal/server/web/static/users.js
index ad53961..d233a7c 100644
--- a/internal/server/web/static/users.js
+++ b/internal/server/web/static/users.js
@@ -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'; };
-})();
+}};
diff --git a/internal/server/web/templates/app.html b/internal/server/web/templates/app.html
new file mode 100644
index 0000000..c8a67b2
--- /dev/null
+++ b/internal/server/web/templates/app.html
@@ -0,0 +1,23 @@
+{{define "app.html"}}
+
+
+LPRA
+{{template "head" .}}
+
+
+
+{{template "topbar" .}}
+
+ {{if eq .View "dashboard"}}{{.Body}}{{end}}
+
+ {{if eq .View "settings"}}{{.Body}}{{end}}
+
+
+
+
+
+
+
+
+
+{{end}}
diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html
index 239a84d..0e5c015 100644
--- a/internal/server/web/templates/dashboard.html
+++ b/internal/server/web/templates/dashboard.html
@@ -1,22 +1,4 @@
-
-
-
-
-
-Машины - LPRA
-
-
-
-
-
-
+{{define "dashboard-body"}}
Машины-клиенты
@@ -68,39 +50,4 @@
{{end}}
-
-
-
-
+
{{end}}
diff --git a/internal/server/web/templates/edit.html b/internal/server/web/templates/edit.html
index 8113714..5709333 100644
--- a/internal/server/web/templates/edit.html
+++ b/internal/server/web/templates/edit.html
@@ -1,24 +1,4 @@
-
-
-
-
-
-Изменить машину - LPRA
-
-
-
-
-
-
+{{define "edit-body"}}
{{.Agent.Name}}
@@ -124,5 +104,4 @@
{{end}}
-
-
+{{end}}
diff --git a/internal/server/web/templates/forwards.html b/internal/server/web/templates/forwards.html
index 4aa5fa4..6945163 100644
--- a/internal/server/web/templates/forwards.html
+++ b/internal/server/web/templates/forwards.html
@@ -1,12 +1,4 @@
-
-
-
-
-
-Порты · {{.Agent.Name}} - LPRA
-
-
-
+{{define "forwards-body"}}
-
-
-
Проброс портов
Порт сервиса на машине-клиенте становится доступен на порту хоста.
@@ -135,15 +115,4 @@
Для tcp/udp агент держит исходящее соединение к host:{{.TunnelPort}}; несколько клиентов и портов мультиплексируются на нём.
-
-
-
+{{end}}
diff --git a/internal/server/web/templates/layout.html b/internal/server/web/templates/layout.html
new file mode 100644
index 0000000..d39aa9d
--- /dev/null
+++ b/internal/server/web/templates/layout.html
@@ -0,0 +1,16 @@
+{{define "head"}}
+
+
+
+{{end}}
+
+{{define "topbar"}}{{end}}
diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html
index 6a06524..5b3b1eb 100644
--- a/internal/server/web/templates/settings.html
+++ b/internal/server/web/templates/settings.html
@@ -1,12 +1,4 @@
-
-
-
-
-
-Настройки - LPRA
-
-
-
+{{define "settings-body"}}
-
-
-
Настройки
{{if .Error}}
{{.Error}}
{{end}}
@@ -280,33 +263,4 @@
LPRA сервер · версия {{.ServerVersion}}
-
-
-
-
-
+
{{end}}
diff --git a/internal/server/web/templates/status.html b/internal/server/web/templates/status.html
index 064302c..5922404 100644
--- a/internal/server/web/templates/status.html
+++ b/internal/server/web/templates/status.html
@@ -1,12 +1,4 @@
-
-
-
-
-
-Статус · {{.Agent.Name}} - LPRA
-
-
-
+{{define "status-body"}}
-
-
-
Статус
@@ -111,238 +91,4 @@
-
-
-
+{{end}}