feat: machine status page, speed test, live presence, forwards without ufw (0.0.5)
Status page per machine: CPU load, RAM and disk of the client, sampled by the agent only while the page is open and visible (one reading serves every open tab), plus a button that has the agent measure its link to the host: ping, down and up. Online/offline now comes from the control polls the hub holds open, so a dropped agent shows offline at once instead of 45 s later, and the dashboard keeps the status column live. A forward rule can be created "without ufw": the panel opens nothing in ufw for it and so revokes nothing when it is deleted. The checkbox is shown only while the ufw integration is on. Dashboard row actions are icons; a status action is added.
This commit is contained in:
1 parent
7f4d645f19
commit
86cb0226ab
22 files changed
+920
-34
No files matched your search
@@ -0,0 +1,156 @@
|
||||
<!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; }
|
||||
</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>
|
||||
</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>
|
||||
<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>хост → клиент</span></div>
|
||||
<div><b id="sp-up"></b><span>клиент → хост</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');
|
||||
btn.onclick = function () {
|
||||
btn.disabled = true;
|
||||
note.textContent = 'Идёт замер…';
|
||||
fetch(base + '/speed', {method: 'POST', credentials: 'same-origin'})
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.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);
|
||||
$('speed').hidden = false;
|
||||
note.textContent = '';
|
||||
})
|
||||
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
|
||||
.then(function () { btn.disabled = false; });
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user