239 lines
11 KiB
HTML
239 lines
11 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="/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; }
|
||
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-проброс.
|
||
С галочкой «До браузера» данные идут дальше, через панель до этого браузера и обратно: так виден весь путь клиент - панель - браузер.
|
||
Замер занимает около 7 секунд и на это время нагружает канал.</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="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 rate(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 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 () {});
|
||
}
|
||
// Times what the client sends, from its first byte to the end of the stream.
|
||
function drain(onFirst) {
|
||
return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store'}).then(function (r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
var rd = r.body.getReader(), n = 0, start = 0;
|
||
function pump() {
|
||
return rd.read().then(function (c) {
|
||
if (c.done) return start && n ? n * 8000 / Math.max(1, performance.now() - start) : 0;
|
||
if (!start) { start = performance.now(); onFirst(); }
|
||
n += c.value.length;
|
||
return pump();
|
||
});
|
||
}
|
||
return pump();
|
||
});
|
||
}
|
||
// 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() {
|
||
return panelPing().then(function (ping) {
|
||
return post('/speed/relay').then(function (res) {
|
||
if (!res.ok) return res;
|
||
var stop = new AbortController();
|
||
var up = drain(function () { stop.abort(); });
|
||
up.catch(function () {});
|
||
feed(stop);
|
||
return post('/speed?relay=1').then(function (res) {
|
||
stop.abort();
|
||
if (!res.ok) return res;
|
||
return up.then(function (bps) {
|
||
res.data.ping_ms += ping;
|
||
res.data.up_bps = bps;
|
||
return res;
|
||
});
|
||
}, function (e) { stop.abort(); throw e; });
|
||
});
|
||
});
|
||
}
|
||
|
||
btn.onclick = function () {
|
||
var relay = $('speed-relay').checked;
|
||
btn.disabled = true;
|
||
note.textContent = 'Идёт замер…';
|
||
(relay ? relayTest() : post('/speed'))
|
||
.then(function (res) {
|
||
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
|
||
var d = res.data;
|
||
$('sp-ping').textContent = d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс';
|
||
$('sp-down').textContent = rate(d.down_bps);
|
||
$('sp-up').textContent = rate(d.up_bps);
|
||
$('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент';
|
||
$('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост';
|
||
$('speed').hidden = false;
|
||
note.textContent = '';
|
||
})
|
||
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
|
||
.then(function () { btn.disabled = false; });
|
||
};
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|