feat: one-page panel - shell with Машины, Терминал and Настройки, pages as fragments, forms sent in place
This commit is contained in:
1 parent
7b9daa524e
commit
113c63fd1b
17 files changed
+677
-431
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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); }
|
||||
@@ -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};
|
||||
})();
|
||||
@@ -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; });
|
||||
};
|
||||
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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'; };
|
||||
})();
|
||||
}};
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in new issue
Block a user