Files
lpra/internal/server/web/templates/status.html
T

347 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="/static/app.css">
<style>
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
.meters .card { margin:0; }
.meter .head { display:flex; align-items:baseline; justify-content:space-between; }
.meter .name { color:var(--muted); font-size:13px; }
.meter .val { font-size:26px; font-weight:600; font-variant-numeric:tabular-nums; }
.meter .sub { color:var(--muted); font-size:13px; margin-top:8px; min-height:20px; font-variant-numeric:tabular-nums; }
.bar { height:8px; border-radius:4px; background:#0c0f13; border:1px solid var(--border); overflow:hidden; margin-top:10px; }
.bar i { display:block; height:100%; width:100%; background:var(--accent); transform-origin:left; transform:scaleX(0); transition:transform .4s; }
.bar i.warn { background:#e6a23c; }
.bar i.crit { background:var(--danger); }
.speed { display:flex; gap:28px; flex-wrap:wrap; margin-top:16px; }
.speed b { display:block; font-size:20px; font-variant-numeric:tabular-nums; }
.speed span { color:var(--muted); font-size:13px; }
.live { display:flex; align-items:center; gap:28px; flex-wrap:wrap; margin-top:20px; }
.gauge { position:relative; width:240px; flex:none; }
.gauge svg { display:block; width:100%; }
.gauge path { fill:none; stroke-width:14; stroke-linecap:round; }
.gauge .track { stroke:#0c0f13; }
.gauge .fill { stroke:var(--accent); stroke-dasharray:251.4; stroke-dashoffset:251.4; transition:stroke-dashoffset .25s linear, stroke .3s; }
.gauge .num { position:absolute; left:0; right:0; bottom:26px; text-align:center; font-size:38px; font-weight:600; line-height:1; font-variant-numeric:tabular-nums; }
.gauge .unit { position:absolute; left:0; right:0; bottom:4px; text-align:center; color:var(--muted); font-size:13px; }
.live .side { flex:1; min-width:220px; }
.live .phase { font-size:15px; margin-bottom:8px; }
.live .phase i { font-style:normal; color:var(--accent); margin-right:6px; }
.live.up .phase i { color:var(--ok); }
.live.up .gauge .fill { stroke:var(--ok); }
.spark { display:block; width:100%; height:64px; }
.spark polyline { fill:none; stroke:var(--accent); stroke-width:2; vector-effect:non-scaling-stroke; stroke-linejoin:round; }
.spark polygon { fill:var(--accent); opacity:.15; }
.live.up .spark polyline { stroke: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; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand">LPRA · Статус - {{.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>
<div class="meters">
<div class="card meter">
<div class="head"><span class="name">Процессор</span><span class="val" id="cpu-val">-</span></div>
<div class="bar"><i id="cpu-bar"></i></div>
<div class="sub" id="cpu-sub"></div>
</div>
<div class="card meter">
<div class="head"><span class="name">Оперативная память</span><span class="val" id="mem-val">-</span></div>
<div class="bar"><i id="mem-bar"></i></div>
<div class="sub" id="mem-sub"></div>
</div>
<div class="card meter">
<div class="head"><span class="name">Диск (/)</span><span class="val" id="disk-val">-</span></div>
<div class="bar"><i id="disk-bar"></i></div>
<div class="sub" id="disk-sub"></div>
</div>
</div>
<div class="card">
<h2>Скорость соединения</h2>
<p class="muted">Клиент замеряет свой канал до хоста панели - тот же, по которому идут терминал, файлы и http-проброс.
С галочкой «До браузера» данные идут дальше, через панель до этого браузера и обратно: так виден весь путь клиент - панель - браузер.
Замер занимает около 15 секунд, не дольше 40, и на это время нагружает канал.</p>
<div class="row">
<button id="speed-btn" type="button">Замерить скорость</button>
<label class="check" title="Данные пойдут не только между клиентом и хостом панели, а через панель до этого браузера и обратно">
<input type="checkbox" id="speed-relay"> До браузера
</label>
<span class="muted" id="speed-note"></span>
</div>
<div class="live" id="live" hidden>
<div class="gauge">
<svg viewBox="0 0 200 110">
<path class="track" d="M20 100 A80 80 0 0 1 180 100"/>
<path class="fill" id="lv-arc" d="M20 100 A80 80 0 0 1 180 100"/>
</svg>
<div class="num" id="lv-num">-</div>
<div class="unit" id="lv-unit"></div>
</div>
<div class="side">
<div class="phase"><i id="lv-ico"></i><span id="lv-phase"></span></div>
<svg class="spark" viewBox="0 0 100 100" preserveAspectRatio="none">
<polygon id="lv-area" points=""/>
<polyline id="lv-line" points=""/>
</svg>
</div>
</div>
<div class="speed" id="speed" hidden>
<div><b id="sp-ping"></b><span>задержка</span></div>
<div><b id="sp-down"></b><span id="sp-down-lbl"></span></div>
<div><b id="sp-up"></b><span id="sp-up-lbl"></span></div>
</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>