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 ---
|
// --- dashboard ---
|
||||||
|
|
||||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
agents, err := s.db.ListAgents()
|
s.renderPage(w, r, "dashboard", s.dashboardData(r))
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
type row struct {
|
||||||
Agent
|
Agent
|
||||||
Online bool
|
Online bool
|
||||||
@@ -131,12 +132,12 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
on := s.online(&a)
|
on := s.online(&a)
|
||||||
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
|
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,
|
"Admin": me,
|
||||||
"IsAdmin": me.IsAdmin(),
|
"IsAdmin": me.IsAdmin(),
|
||||||
"Agents": rows,
|
"Agents": rows,
|
||||||
"CSRF": csrfToken(r),
|
"CSRF": csrfToken(r),
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seenText is the "activity" cell: a connected agent is active now, whatever
|
// 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()
|
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,
|
// 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()
|
code := s.db.InstallCode()
|
||||||
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
|
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
|
||||||
ufwRules, _ := s.db.ListUfwRules()
|
ufwRules, _ := s.db.ListUfwRules()
|
||||||
s.render(w, "settings.html", map[string]any{
|
s.renderPage(w, r, "settings", map[string]any{
|
||||||
"Admin": s.currentAdmin(r),
|
"Admin": s.currentAdmin(r),
|
||||||
"Host": host,
|
"Host": host,
|
||||||
"Direct": direct,
|
"Direct": direct,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
"listen": "Не удалось занять порт на хосте (возможно, уже используется).",
|
"listen": "Не удалось занять порт на хосте (возможно, уже используется).",
|
||||||
"db": "Ошибка базы данных.",
|
"db": "Ошибка базы данных.",
|
||||||
}[r.URL.Query().Get("err")]
|
}[r.URL.Query().Get("err")]
|
||||||
s.render(w, "forwards.html", map[string]any{
|
s.renderPage(w, r, "forwards", map[string]any{
|
||||||
"Agent": a,
|
"Agent": a,
|
||||||
"Rules": rules,
|
"Rules": rules,
|
||||||
"CSRF": csrfToken(r),
|
"CSRF": csrfToken(r),
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func (s *Server) handleStatusPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
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.
|
// 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).
|
// Panel (admin-only).
|
||||||
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
|
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
|
||||||
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleTerminalPage))
|
mux.Handle("GET /terminal", s.requireAdmin(s.handleShell))
|
||||||
mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage))
|
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}/forwards", s.requireAdmin(s.handleForwardsPage))
|
||||||
mux.Handle("GET /agents/{id}/status", s.requireAdmin(s.handleStatusPage))
|
mux.Handle("GET /agents/{id}/status", s.requireAdmin(s.handleStatusPage))
|
||||||
mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit))
|
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) {
|
@supports not selector(::-webkit-scrollbar) {
|
||||||
* { scrollbar-width: thin; scrollbar-color: rgba(62, 68, 78, calc(var(--sb) * .9)) transparent; }
|
* { 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
|
// 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
|
// dialog with a list to tick), the password sign-in and whether the user
|
||||||
// may switch it themselves. Each change is saved at once.
|
// 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';
|
'use strict';
|
||||||
var table = document.querySelector('table.users');
|
var table = root.querySelector('table.users');
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
var $ = function (id) { return document.getElementById(id); };
|
var $ = function (id) { return root.querySelector('#' + id); };
|
||||||
|
|
||||||
function save(id, body) {
|
function save(id, body) {
|
||||||
return fetch('/api/panel/users/' + id, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(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 ----
|
// ---- a new user: the machines appear for a plain user ----
|
||||||
var role = $('newrole'), pick = $('newagents');
|
var role = $('newrole'), pick = $('newagents');
|
||||||
if (role) role.onchange = function () { pick.hidden = role.value !== 'user'; };
|
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>
|
{{define "dashboard-body"}}<div class="page" data-module="dashboard">
|
||||||
<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>
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>Машины-клиенты</h1>
|
<h1>Машины-клиенты</h1>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -68,39 +50,4 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
</div>{{end}}
|
||||||
// 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>
|
|
||||||
@@ -1,24 +1,4 @@
|
|||||||
<!doctype html>
|
{{define "edit-body"}}<div class="page" data-module="edit">
|
||||||
<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>
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>{{.Agent.Name}}</h1>
|
<h1>{{.Agent.Name}}</h1>
|
||||||
|
|
||||||
@@ -124,5 +104,4 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>{{end}}
|
</div>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</div>{{end}}
|
||||||
</html>
|
|
||||||
@@ -1,12 +1,4 @@
|
|||||||
<!doctype html>
|
{{define "forwards-body"}}<div class="page" data-module="forwards" data-panel-port="{{.PanelPort}}" data-tunnel-port="{{.TunnelPort}}">
|
||||||
<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>
|
|
||||||
<style>
|
<style>
|
||||||
/* One line: fixed narrow fields, the label takes what is left. */
|
/* One line: fixed narrow fields, the label takes what is left. */
|
||||||
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
|
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
|
||||||
@@ -36,18 +28,6 @@
|
|||||||
.under { margin-right:0; }
|
.under { margin-right:0; }
|
||||||
}
|
}
|
||||||
</style>
|
</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">
|
<div class="wrap">
|
||||||
<h1>Проброс портов</h1>
|
<h1>Проброс портов</h1>
|
||||||
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
|
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
|
||||||
@@ -135,15 +115,4 @@
|
|||||||
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
|
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
</div>{{end}}
|
||||||
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>
|
|
||||||
@@ -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>
|
{{define "settings-body"}}<div class="page" data-module="settings">
|
||||||
<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>
|
|
||||||
<style>
|
<style>
|
||||||
/* An install command, with a button to copy it at its right. */
|
/* An install command, with a button to copy it at its right. */
|
||||||
/* Users: role, machines, password sign-in. */
|
/* Users: role, machines, password sign-in. */
|
||||||
@@ -49,15 +41,6 @@
|
|||||||
.codeform input[type=number] { width:72px; height:30px; padding:4px 8px; }
|
.codeform input[type=number] { width:72px; height:30px; padding:4px 8px; }
|
||||||
.codeform .ico { display:inline-flex; align-items:center; gap:6px; height:30px; }
|
.codeform .ico { display:inline-flex; align-items:center; gap:6px; height:30px; }
|
||||||
</style>
|
</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">
|
<div class="wrap">
|
||||||
<h1>Настройки</h1>
|
<h1>Настройки</h1>
|
||||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
{{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>
|
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
</div>{{end}}
|
||||||
// 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>
|
|
||||||
@@ -1,12 +1,4 @@
|
|||||||
<!doctype html>
|
{{define "status-body"}}<div class="page" data-module="status" data-agent="{{.Agent.ID}}" data-relay-up-ms="{{.RelayUpMs}}">
|
||||||
<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>
|
|
||||||
<style>
|
<style>
|
||||||
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
|
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
|
||||||
.meters .card { margin:0; }
|
.meters .card { margin:0; }
|
||||||
@@ -41,18 +33,6 @@
|
|||||||
.live.up .spark polygon { fill:var(--ok); }
|
.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; }
|
label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
|
||||||
</style>
|
</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">
|
<div class="wrap">
|
||||||
<h1>Статус</h1>
|
<h1>Статус</h1>
|
||||||
<div class="err" id="err" hidden></div>
|
<div class="err" id="err" hidden></div>
|
||||||
@@ -111,238 +91,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
</div>{{end}}
|
||||||
(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>
|
|
||||||
Reference in new issue
Block a user