From 113c63fd1b3c6242feae7f0703abc84f569a913b Mon Sep 17 00:00:00 2001 From: iRedTea Date: Thu, 15 Oct 2026 20:05:20 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20one-page=20panel=20-=20shell=20with=20?= =?UTF-8?q?=D0=9C=D0=B0=D1=88=D0=B8=D0=BD=D1=8B,=20=D0=A2=D0=B5=D1=80?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B0=D0=BB=20and=20=D0=9D=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D0=BA=D0=B8,=20pages=20as=20fragments,=20for?= =?UTF-8?q?ms=20sent=20in=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/frag.go | 88 +++++ internal/server/frag_test.go | 65 ++++ internal/server/handlers_panel.go | 19 +- internal/server/handlers_panel_fwd.go | 2 +- internal/server/handlers_panel_stat.go | 2 +- internal/server/server.go | 5 +- internal/server/web/static/app.css | 3 + internal/server/web/static/app.js | 133 ++++++++ internal/server/web/static/pages.js | 320 +++++++++++++++++++ internal/server/web/static/users.js | 9 +- internal/server/web/templates/app.html | 23 ++ internal/server/web/templates/dashboard.html | 57 +--- internal/server/web/templates/edit.html | 25 +- internal/server/web/templates/forwards.html | 35 +- internal/server/web/templates/layout.html | 16 + internal/server/web/templates/settings.html | 50 +-- internal/server/web/templates/status.html | 258 +-------------- 17 files changed, 678 insertions(+), 432 deletions(-) create mode 100644 internal/server/frag.go create mode 100644 internal/server/frag_test.go create mode 100644 internal/server/web/static/app.js create mode 100644 internal/server/web/static/pages.js create mode 100644 internal/server/web/templates/app.html create mode 100644 internal/server/web/templates/layout.html 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 => '

' + 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 - - - - - -
-
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 - - - - - -
-
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"}}
- - -
-
LPRA · Порты - {{.Agent.Name}} ({{.Agent.Hostname}})
- -

Проброс портов

Порт сервиса на машине-клиенте становится доступен на порту хоста. @@ -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"}}
+
LPRA · Удалённый доступ
+ +
{{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"}}
- - -
-
LPRA · Удалённый доступ
- -

Настройки

{{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"}}
- - -
-
LPRA · Статус - {{.Agent.Name}} ({{.Agent.Hostname}})
- -

Статус

@@ -111,238 +91,4 @@
- - - +{{end}}