https://claude-agent.tailf7372d.ts.net/punte — acelasi tipar ca /echo de pe moltbot: procesul ramane legat de 127.0.0.1, tailscaled il proxeaza si pune HTTPS. Montarea sub prefix a cerut doua schimbari: - toate URL-urile din pagini sunt acum relative, fiindca --set-path TAIE prefixul inainte de a proxa (serverul vede /api/status, browserul cere /punte/api/status). DASHBOARD_PREFIX ramane necesar doar pentru redirectul de login, si e acceptat si intact pe intrare, ca sa mearga si curl direct pe localhost. - adresa fara slash final (/punte) primeste 301 catre /punte/: altfel URL-urile relative s-ar rezolva la radacina hostului, unde proxy-ul nu trimite nimic incoace, si panoul ar arata gol fara nicio eroare vizibila. ops/install.sh configureaza serve-ul daca sudo permite; altfel spune comanda. Sase teste noi pentru montarea sub prefix (31 in total pe dashboard). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
274 lines
12 KiB
HTML
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
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>
|