348 lines
16 KiB
HTML
348 lines
16 KiB
HTML
<!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>
|
||
<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"><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>
|
||
|
||
<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>
|