Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html
Claude Agent 7abefa2b46 feat(discord-bridge): dashboard de control si restart, dupa modelul agentului echo
Panou web pe 127.0.0.1:18790, unit systemd separat de al puntii. Server stdlib
(fara dependinte noi), tokenii de design si tiparul de endpoint-uri preluate din
/home/moltbot/echo-core/dashboard (handlers/eco.py) de pe LXC 110.

Arata: starea unitatii (uptime, PID, memoria cgroup, restarturi), firele din
state.json cu tur in zbor si cost, costul zilei fata de plafon, confirmarile
PreToolUse in asteptare (aprobabile direct din pagina), bot.log / infra.log si
opt verificari de diagnostic.

Face: start / stop / restart pe punte, cautarea si curatarea orfanilor prin
cleanup.py, repornirea propriului serviciu.

Garantii, cu teste:
- unitatea controlata e fixa in cod; un {"unit": "ssh.service"} in cerere nu
  schimba nimic, altfel panoul ar fi systemctl remote fara parola;
- stop/restart intorc 409 cu lista firelor active si cer force explicit, fiindca
  KillMode=control-group taie tururile in desfasurare;
- state.json se citeste fara lock: panoul nu are voie sa blocheze botul;
- diagnosticul pica daca reapare Bash(ssh:*) in deny (regresia de azi).

Uptime-ul se calculeaza din time.monotonic(), nu din /proc/uptime: in LXC acela
e virtualizat de lxcfs si da diferenta negativa fata de monotonic-ul systemd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
2026-08-30 13:18:38 +00:00

274 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="ro" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Punte Discord — control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header class="header">
<span class="logo"><span class="dot" id="dot"></span> Punte Discord</span>
<span class="spacer"></span>
<span class="meta" id="lastPoll">—</span>
<button class="small" onclick="toggleTheme()" title="Schimbă tema">◐</button>
<button class="small" onclick="logout()">Ieși</button>
</header>
<div class="wrap">
<!-- ── serviciu ───────────────────────────────────────────── -->
<section class="card">
<h2>Serviciu <span class="count" id="unitName"></span></h2>
<div class="metrics" id="metrics"></div>
<div class="actions" style="margin-top:var(--space-5)">
<button class="primary" id="btnRestart" onclick="svc('restart')">Repornește</button>
<button id="btnStart" onclick="svc('start')">Pornește</button>
<button class="danger" id="btnStop" onclick="svc('stop')">Oprește</button>
<span class="spacer"></span>
<button class="small" onclick="cleanup(true)">Caută orfani</button>
<button class="small danger" onclick="cleanup(false)">Curăță orfani</button>
<button class="small" onclick="restartSelf()">Repornește dashboard-ul</button>
</div>
</section>
<div class="grid-2">
<!-- ── fire ─────────────────────────────────────────────── -->
<section class="card">
<h2>Fire active <span class="count" id="threadCount"></span></h2>
<div class="table-scroll">
<table>
<thead><tr><th>Fir</th><th>Model</th><th>Director</th><th>Stare</th><th class="num">USD</th></tr></thead>
<tbody id="threads"></tbody>
</table>
</div>
</section>
<!-- ── diagnostic ───────────────────────────────────────── -->
<section class="card">
<h2>Diagnostic</h2>
<div id="doctor"><div class="empty">se încarcă…</div></div>
</section>
</div>
<!-- ── aprobări ───────────────────────────────────────────── -->
<section class="card">
<h2>Confirmări în așteptare <span class="count" id="apprCount"></span></h2>
<div id="approvals"><div class="empty">niciuna</div></div>
</section>
<!-- ── jurnal ─────────────────────────────────────────────── -->
<section class="card">
<h2>Jurnal
<span class="count">
<button class="small" id="tabBot" onclick="setLog('bot')">bot.log</button>
<button class="small" id="tabInfra" onclick="setLog('infra')">infra.log</button>
<button class="small" onclick="refreshLogs()">↻</button>
</span>
</h2>
<pre class="log" id="log">se încarcă…</pre>
</section>
</div>
<div id="toast"></div>
<script>
// ── tema ──────────────────────────────────────────────────────
(function () {
var saved = localStorage.getItem('theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
})();
function toggleTheme() {
var next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
// ── utilitare ─────────────────────────────────────────────────
function esc(s) {
return String(s === null || s === undefined ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function toast(msg, kind) {
var el = document.createElement('div');
el.className = 'toast ' + (kind || '');
el.textContent = msg;
document.getElementById('toast').appendChild(el);
setTimeout(function () { el.remove(); }, 6000);
}
async function api(path, opts) {
var r = await fetch(path, opts || {});
if (r.status === 401) { location.href = '/login.html'; throw new Error('neautentificat'); }
var data = null;
try { data = await r.json(); } catch (e) { data = {}; }
return { ok: r.ok, status: r.status, data: data };
}
function post(path, body) {
return api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
});
}
function dur(s) {
if (s === null || s === undefined) return '—';
s = Math.max(0, Math.floor(s));
var d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
if (d) return d + 'z ' + h + 'h';
if (h) return h + 'h ' + m + 'm';
if (m) return m + 'm';
return s + 's';
}
function mb(bytes) {
return bytes === null || bytes === undefined ? '—' : (bytes / 1048576).toFixed(0) + ' MB';
}
// ── stare ─────────────────────────────────────────────────────
var busy = false;
async function refresh() {
var res = await api('/api/status');
if (!res.ok) return;
var s = res.data, svcInfo = s.service;
document.getElementById('unitName').textContent = svcInfo.unit;
document.getElementById('dot').className = 'dot ' + (svcInfo.active ? 'on' : 'off');
document.getElementById('lastPoll').textContent = 'actualizat ' + new Date().toLocaleTimeString('ro-RO');
var capTxt = s.cost_cap > 0 ? ' / ' + s.cost_cap.toFixed(2) : '';
document.getElementById('metrics').innerHTML = [
['Stare', '<span class="pill ' + (svcInfo.active ? 'ok' : 'bad') + '">' + esc(svcInfo.state) + '/' + esc(svcInfo.sub) + '</span>'],
['Uptime', dur(svcInfo.uptime_s)],
['PID', svcInfo.pid || '—'],
['Memorie cgroup', mb(svcInfo.memory_bytes)],
['Restarturi', svcInfo.restarts],
['Fire', s.threads.length],
['Cost azi', (Number((s.cost || {}).usd || 0)).toFixed(2) + capTxt + ' USD']
].map(function (m) {
return '<div class="metric"><div class="label">' + m[0] + '</div><div class="value">' + m[1] + '</div></div>';
}).join('');
document.getElementById('btnStart').disabled = busy || svcInfo.active;
document.getElementById('btnStop').disabled = busy || !svcInfo.active;
document.getElementById('btnRestart').disabled = busy;
// fire
document.getElementById('threadCount').textContent = s.threads.length + ' în state.json';
document.getElementById('threads').innerHTML = s.threads.length ? s.threads.map(function (t) {
var st = t.inflight ? '<span class="pill busy">tur în lucru</span>'
: t.alive ? '<span class="pill ok">proces viu</span>'
: '<span class="pill">inactiv</span>';
return '<tr><td class="mono">' + esc(t.thread_id) + '</td><td>' + esc(t.model || '—') +
'</td><td class="mono">' + esc(t.cwd || '—') + '</td><td>' + st +
'</td><td class="num">' + Number(t.cost_usd || 0).toFixed(2) + '</td></tr>';
}).join('') : '<tr><td colspan="5" class="empty">niciun fir înregistrat</td></tr>';
// aprobări
document.getElementById('apprCount').textContent = s.pending_approvals || '';
if (s.pending_approvals) { refreshApprovals(); }
else { document.getElementById('approvals').innerHTML = '<div class="empty">niciuna</div>'; }
}
async function refreshApprovals() {
var res = await api('/api/approvals');
if (!res.ok) return;
var list = res.data.approvals || [];
document.getElementById('approvals').innerHTML = list.length ? list.map(function (a) {
return '<div class="check"><div style="flex:1">' +
'<div class="mono" style="color:var(--text-primary)">' + esc(a.command) + '</div>' +
'<div class="detail">' + esc(a.reason || a.rule || '') +
(a.expires_in !== null && a.expires_in !== undefined ? ' · expiră în ' + dur(a.expires_in) : '') +
' · fir ' + esc(a.thread_id) + '</div></div>' +
'<button class="small primary" onclick="decide(\'' + esc(a.request_id) + '\',\'allow\')">Permite</button> ' +
'<button class="small danger" onclick="decide(\'' + esc(a.request_id) + '\',\'deny\')">Refuză</button></div>';
}).join('') : '<div class="empty">niciuna</div>';
}
async function decide(id, d) {
var res = await post('/api/approvals/decide', { request_id: id, decision: d });
toast(res.ok ? 'Trimis: ' + d : ('Eșuat: ' + (res.data.error || res.status)), res.ok ? 'ok' : 'bad');
refresh();
}
async function refreshDoctor() {
var res = await api('/api/doctor');
if (!res.ok) return;
document.getElementById('doctor').innerHTML = (res.data.checks || []).map(function (c) {
return '<div class="check ' + (c.pass ? 'pass' : 'fail') + '"><span class="mark">' +
(c.pass ? '✓' : '✗') + '</span><span class="name">' + esc(c.name) +
'</span><span class="detail">' + esc(c.detail) + '</span></div>';
}).join('');
}
// ── acțiuni ───────────────────────────────────────────────────
async function svc(action, force) {
if (busy) return;
if (action !== 'start' && !force &&
!confirm('Sigur „' + action + '” pe serviciul punții?')) return;
busy = true;
try {
var res = await post('/api/service', { action: action, force: !!force });
if (res.status === 409) {
var n = (res.data.inflight || []).length;
if (confirm(n + ' fir(e) au tur în desfășurare. Le întrerupi?')) {
busy = false;
return svc(action, true);
}
toast('Anulat — tururile continuă.', '');
} else if (res.ok) {
toast('Serviciu: ' + action + ' OK', 'ok');
} else {
toast('Eșuat: ' + (res.data.error || res.status), 'bad');
}
} finally {
busy = false;
refresh();
refreshDoctor();
}
}
async function cleanup(dry) {
if (!dry && !confirm('Omor procesele orfane găsite?')) return;
var res = await post('/api/cleanup', { dry_run: dry });
if (!res.ok) { toast('Eșuat: ' + (res.data.error || res.status), 'bad'); return; }
var n = (res.data.orphans || []).length;
toast(dry ? (n + ' orfan(i) găsiți') : (n + ' orfan(i) tratați'), n ? 'bad' : 'ok');
document.getElementById('log').textContent = res.data.report || '(fără raport)';
}
async function restartSelf() {
if (!confirm('Repornesc dashboard-ul? Pagina se reîncarcă în câteva secunde.')) return;
await post('/api/restart-self', {});
toast('Dashboard-ul repornește…', '');
setTimeout(function () { location.reload(); }, 4000);
}
async function logout() {
await post('/api/auth/logout', {});
location.href = '/login.html';
}
// ── jurnal ────────────────────────────────────────────────────
var logFile = 'bot';
function setLog(which) { logFile = which; refreshLogs(); }
async function refreshLogs() {
document.getElementById('tabBot').className = 'small' + (logFile === 'bot' ? ' primary' : '');
document.getElementById('tabInfra').className = 'small' + (logFile === 'infra' ? ' primary' : '');
var res = await api('/api/logs?lines=300&file=' + logFile);
if (!res.ok) return;
var el = document.getElementById('log');
el.textContent = (res.data.lines || []).join('\n') || '(gol)';
el.scrollTop = el.scrollHeight;
}
// ── pornire ───────────────────────────────────────────────────
refresh(); refreshDoctor(); refreshLogs();
setInterval(refresh, 5000);
setInterval(refreshDoctor, 30000);
</script>
</body>
</html>