Files
2026-09-13 10:18:12 +00:00

1092 lines
54 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>
<nav class="tabs" aria-label="Secțiuni" id="tabNav">
<button class="tab" type="button" data-tab="punte">Punte</button>
<button class="tab" type="button" data-tab="maria">Maria</button>
<button class="tab" type="button" data-tab="infra">Infrastructură</button>
</nav>
<span class="spacer"></span>
<span class="meta" id="lastPoll">—</span>
<button class="small" id="btnToggleAll" onclick="toggleAllPanels()">Restrânge tot</button>
<button class="small" onclick="toggleTheme()" title="Schimbă tema">◐</button>
<button class="small" id="btnLogout" onclick="logout()" hidden>Ieși</button>
</header>
<!-- ══════════════════════════ PUNTE ══════════════════════════ -->
<div class="wrap tabpage" data-tab="punte">
<details class="card" open data-panel="serviciu">
<summary><span class="chev">▶</span><span class="ptitle">Serviciu</span><span class="count" id="unitName"></span></summary>
<div class="pbody">
<div class="metrics" id="metrics"></div>
<div class="actions">
<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>
</div>
</details>
<div class="grid-2">
<details class="card" open data-panel="fire">
<summary><span class="chev">▶</span><span class="ptitle">Fire active</span><span class="count" id="threadCount"></span></summary>
<div class="pbody">
<div class="table-scroll">
<table class="stack-mobil">
<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>
</div>
</details>
<details class="card" open data-panel="diagnostic">
<summary><span class="chev">▶</span><span class="ptitle">Diagnostic</span></summary>
<div class="pbody">
<div id="doctor"><div class="empty">se încarcă…</div></div>
</div>
</details>
</div>
<details class="card" open data-panel="aprobari">
<summary><span class="chev">▶</span><span class="ptitle">Confirmări în așteptare</span><span class="count" id="apprCount"></span></summary>
<div class="pbody">
<div id="approvals"><div class="empty">niciuna</div></div>
</div>
</details>
<details class="card" open data-panel="jurnal-bot">
<summary><span class="chev">▶</span><span class="ptitle">Jurnal</span>
<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>
</summary>
<div class="pbody">
<pre class="log" id="log">se încarcă…</pre>
</div>
</details>
</div>
<!-- ══════════════════════════ MARIA ══════════════════════════ -->
<div class="wrap tabpage" data-tab="maria">
<div class="grid-2">
<details class="card" open data-panel="maria-bridge">
<summary><span class="chev">▶</span><span class="ptitle">Punte WhatsApp</span><span class="count" id="mBridgeUnit"></span></summary>
<div class="pbody">
<div class="metrics" id="mBridgeMetrics"></div>
<div class="actions">
<button onclick="mSvc('bridge','start')">Pornește</button>
<button class="danger" onclick="mSvc('bridge','stop')">Oprește</button>
<button class="primary" onclick="mSvc('bridge','restart')">Repornește</button>
</div>
</div>
</details>
<details class="card" open data-panel="maria-rag">
<summary><span class="chev">▶</span><span class="ptitle">Consumer RAG</span><span class="count" id="mRagUnit"></span></summary>
<div class="pbody">
<div class="metrics" id="mRagMetrics"></div>
<div class="actions">
<button onclick="mSvc('rag','start')">Pornește</button>
<button class="danger" onclick="mSvc('rag','stop')">Oprește</button>
<button class="primary" onclick="mSvc('rag','restart')">Repornește</button>
</div>
</div>
</details>
</div>
<details class="card" open data-panel="maria-wa">
<summary><span class="chev">▶</span><span class="ptitle">Conectare WhatsApp</span></summary>
<div class="pbody">
<div id="mWaStatus" class="empty">se încarcă…</div>
<div id="mWaQr"></div>
<div id="mWaPair" hidden>
<div class="detail">
Nu poți scana codul QR? Cere un <strong>cod de asociere</strong> și
introdu-l pe telefon în WhatsApp → Dispozitive conectate → Conectează un
dispozitiv → <em>Conectează cu număr de telefon</em>.
</div>
<div class="actions">
<input type="text" id="mPairPhone" placeholder="40723197939 (internațional, fără +)"
style="flex:1;min-width:220px">
<button onclick="mPair()">Cere cod de asociere</button>
</div>
<div id="mPairOut" class="detail"></div>
</div>
</div>
</details>
<details class="card" open data-panel="maria-esc">
<summary><span class="chev">▶</span><span class="ptitle">Trimise la suport</span><span class="count" id="mEscCount"></span></summary>
<div class="pbody">
<div class="detail" id="mSupportDetail"></div>
<div id="mEsc" class="empty">se încarcă…</div>
</div>
</details>
<details class="card" open data-panel="maria-fire">
<summary><span class="chev">▶</span><span class="ptitle">Fire de discuție active</span><span class="count" id="mFirCount"></span></summary>
<div class="pbody">
<div class="table-scroll">
<table class="stack-mobil">
<thead><tr><th>JID</th><th>Subiect</th><th>Ultima activitate</th><th></th></tr></thead>
<tbody id="mFire"></tbody>
</table>
</div>
</div>
</details>
<details class="card" data-panel="maria-clienti">
<summary><span class="chev">▶</span><span class="ptitle">Clienți cunoscuți</span><span class="count" id="mClientCount"></span></summary>
<div class="pbody">
<div class="table-scroll">
<table class="stack-mobil">
<thead><tr><th>Nume</th><th>Numere</th><th></th><th></th></tr></thead>
<tbody id="mClients"></tbody>
</table>
</div>
<h3 id="mClientFormTitle">Adaugă client</h3>
<input type="text" id="mClientId" placeholder="id (ex: acme)" style="width:100%;margin-bottom:var(--space-3)">
<input type="text" id="mClientNume" placeholder="nume firmă" style="width:100%;margin-bottom:var(--space-3)">
<textarea id="mClientProfil" placeholder="profil (context liber despre client)..." style="width:100%;min-height:80px;margin-bottom:var(--space-3)"></textarea>
<textarea id="mClientNumere" placeholder="numere de telefon, unul pe linie" style="width:100%;min-height:80px;font-family:monospace"></textarea>
<div class="actions" style="margin-top:var(--space-3)">
<button class="primary" onclick="mSaveClient()">Salvează</button>
<button class="small" onclick="mResetClientForm()">Renunță</button>
</div>
<div class="detail" id="mClientMsg"></div>
</div>
</details>
<details class="card" data-panel="maria-docs">
<summary><span class="chev">▶</span><span class="ptitle">Documente indexate</span><span class="count" id="mDocCount"></span></summary>
<div class="pbody">
<div class="detail" id="mIndexDetail"></div>
<div class="detail" id="mSyncDetail"></div>
<div class="actions">
<button onclick="mReindex()">Reconstruiește indexul</button>
<button onclick="mSyncNow()">Sincronizează din Drive acum</button>
</div>
<div class="table-scroll">
<table class="stack-mobil">
<thead><tr><th>Nume</th><th class="num">Mărime</th><th></th></tr></thead>
<tbody id="mDocs"></tbody>
</table>
</div>
<h3>Adaugă document (.txt / .md)</h3>
<input type="text" id="mDocName" placeholder="ex: politica-retururi.md" style="width:100%;margin-bottom:var(--space-3)">
<textarea id="mDocContent" placeholder="conținutul documentului..." style="width:100%;min-height:120px;font-family:monospace"></textarea>
<div class="actions" style="margin-top:var(--space-3)">
<button class="primary" onclick="mAddDoc()">Salvează și reindexează</button>
</div>
<div class="detail" id="mDocMsg"></div>
</div>
</details>
<details class="card" data-panel="maria-log">
<summary><span class="chev">▶</span><span class="ptitle">Jurnal Maria</span>
<span class="count">
<button class="small" id="mTabBridge" onclick="mSetLog('bridge')">whatsapp.log</button>
<button class="small" id="mTabRag" onclick="mSetLog('rag')">rag.log</button>
<button class="small" onclick="mRefreshLogs()">↻</button>
</span>
</summary>
<div class="pbody">
<pre class="log" id="mLog">se încarcă…</pre>
</div>
</details>
</div>
<!-- ══════════════════════ INFRASTRUCTURĂ ══════════════════════ -->
<div class="wrap tabpage" data-tab="infra">
<div class="banner" id="infraBanner" hidden></div>
<details class="card" id="infraLogPanel" data-panel="infra-log" hidden>
<summary><span class="chev">▶</span><span class="ptitle" id="infraLogTitle">Jurnal</span>
<span class="spacer"></span>
<button class="small" onclick="infraPollLog()">↻</button>
<button class="small danger" onclick="infraStopJob()">Oprește</button>
<button class="small" onclick="infraHideLog()">Închide</button>
</summary>
<div class="pbody">
<pre class="log" id="infraLogPre">se încarcă…</pre>
</div>
</details>
<details class="card" open data-panel="infra-stare">
<summary><span class="chev">▶</span><span class="ptitle">Stare cluster</span>
<span id="infraPills"></span>
<span class="spacer"></span><span class="psub">citire · la 30 s</span></summary>
<div class="pbody">
<div class="strip" id="infraStrip"></div>
<div class="tscroll table-scroll"><table>
<thead><tr><th>Nod</th><th>Stare</th><th class="num">CPU</th><th class="num">RAM</th><th class="num">Guest</th><th class="num">Uptime</th></tr></thead>
<tbody id="infraNodes"></tbody>
</table></div>
<div class="hint" id="infraErrors"></div>
</div>
</details>
<div class="cols">
<div class="col">
<details class="card" open data-panel="infra-oprire">
<summary><span class="chev">▶</span><span class="ptitle">Oprire cluster</span><span class="psub">cluster-shutdown.sh · 15–40 min</span>
<span class="spacer"></span>
<button onclick="infraRun('oprire', true)">Simulează</button>
<button class="danger" id="btnOprire" onclick="infraOprire()">Oprește…</button>
</summary>
<div class="pbody">
<ol class="steps">
<li>Verificări: ping, ssh, cvorum, fără task-uri în curs</li>
<li>Salvează lista guest-urilor pornite (.cluster-state.txt)</li>
<li><span><b>Trimite instrucțiunile de pornire</b> cât încă merge totul: mesaj în Discord + email</span></li>
<li>HA pe freeze; pauză la alerte, la testul DR și la fereastra de patch</li>
<li>Oprește guest-urile, Oracle 108 ultimul (shutdown immediate)</li>
<li class="dies">LXC 171 se oprește odată cu pvemini, la final: dashboard-ul și Discord nu mai răspund</li>
<li>pveelite → pve1 → pvemini; raportul final vine pe email</li>
</ol>
<div class="hint" id="infraOprireReason"></div>
</div>
</details>
<details class="card" open data-panel="infra-pornire">
<summary><span class="chev">▶</span><span class="ptitle">Pornire cluster</span><span class="psub">trezire de pe telefon, apoi de aici</span>
<span class="spacer"></span>
<button onclick="infraRun('pornire', true)">Simulează</button>
<button class="primary" id="btnPornire" onclick="infraPornire()">Pornește guest-urile</button>
</summary>
<div class="pbody">
<p class="hint">Cu dashboard-ul oprit, instrucțiunile de pornire se găsesc în Discord (mesaj fixat la oprire) și pe email. Textul lor e mai jos.</p>
<pre class="log" id="infraStartupText">—</pre>
<div class="actions">
<button onclick="infraCopyStartup()">Copiază comenzile de pornire</button>
<button onclick="infraRun('wol', false)">Trimite WoL către nodurile căzute</button>
</div>
</div>
</details>
<details class="card" data-panel="infra-ups">
<summary><span class="chev">▶</span><span class="ptitle">UPS</span><span id="infraUpsPill"></span></summary>
<div class="pbody rows">
<div class="row"><div><div class="t">Test rapid baterie</div><div class="d">ups-monthly-test.sh · ~2 min pe baterie</div></div><button id="btnUpsTest" onclick="infraConfirmRun('ups-test', false, 'Rulezi testul lunar UPS (descărcare controlată pe baterie)?')">Rulează</button></div>
<div class="row"><div><div class="t">Simulează oprirea de urgență</div><div class="d">ups-shutdown-cluster.sh --dry-run --force, ca nut</div></div><button id="btnUpsSim" onclick="infraRun('ups-simulare', false)">Simulează</button></div>
<div class="row"><div><div class="t">Istoric autonomie</div><div class="d">ultimele rânduri din trendul de baterie</div></div><button id="btnUpsHist" onclick="infraShowUpsHistory()">Vezi</button></div>
</div>
</details>
</div>
<div class="col">
<details class="card" open data-panel="infra-dr">
<summary><span class="chev">▶</span><span class="ptitle">DR · VM 109</span><span id="infraVm109Pill"></span></summary>
<div class="pbody rows">
<div class="row"><div><div class="t">Test DR acum</div><div class="d">restaurează backup-ul RMAN pe VM 109, verifică, oprește · ~25 min</div></div><button id="btnDrTest" onclick="infraConfirmRun('dr-test', false, 'Rulezi testul DR pe VM 109 acum?')">Rulează</button></div>
<div class="row"><div><div class="t">Fereastră de patch Windows</div><div class="d">vm109-patch-window.sh --now · 30–60 min</div></div><button id="btnDrPatch" onclick="infraConfirmRun('dr-patch', false, 'Pornești fereastra de patch VM 109?')">Rulează</button></div>
<div class="row"><div><div class="t">VM 109 manual</div><div class="d">pentru depanare; watchdog-ul intră pe pauză</div></div><span class="actions">
<button id="btnVm109On" onclick="infraConfirmRun('vm109-pornire', false, 'Pornești manual VM 109?')">Pornește</button>
<button class="danger" id="btnVm109Off" onclick="infraConfirmRun('vm109-oprire', false, 'Oprești VM 109?')">Oprește</button>
</span></div>
</div>
</details>
<details class="card danger" open data-panel="infra-failover">
<summary><span class="chev">▶</span><span class="ptitle">Failover și failback</span><span class="pill bad">distructiv</span><span class="psub">când un nod a căzut; deschide o operație pentru instrucțiuni</span></summary>
<div class="pbody">
<details class="op" open>
<summary><span class="chev">▶</span><span class="t">Backup-urile Oracle trec pe pvemini<span class="d">pveelite a căzut și nu revine curând</span></span><span id="infraFoPill1"></span></summary>
<dl class="sheet">
<dt>Situația</dt><dd>Serverul de producție (10.0.20.36) își copiază în fiecare noapte backup-urile RMAN pe <b>pveelite</b>. De acolo le folosește VM 109 la testul DR. Dacă pveelite e jos, copia din noaptea asta nu mai are unde ajunge.</dd>
<dt>Ce face</dt><dd>1. Verifică de două ori că pveelite chiar nu răspunde.<br>2. Pe pvemini, copia backup-urilor devine activă, cu scriere, partajată prin NFS.<br>3. Pe 10.0.20.36, transfer_backups.ps1 e redirecționat spre pvemini.<br>4. Oprește replicarea pveelite → pvemini.</dd>
<dt>Ce pierzi</dt><dd>Nimic din producție. Backup-urile ajunse pe pveelite după ultima replicare (maximum 15 min) rămân doar acolo.</dd>
<dt>Cum revii</dt><dd>Operația de mai jos, după ce pveelite a repornit.</dd>
<div class="foot"><span class="hint">failover-dr-to-pvemini.sh · pe pvemini · ~2 min</span><button class="danger" id="btnFailoverPvemini" onclick="infraConfirmRun('backup-pe-pvemini', false, 'pveelite a căzut: treci backup-urile Oracle pe pvemini?')">Rulează…</button></div>
</dl>
</details>
<details class="op">
<summary><span class="chev">▶</span><span class="t">Backup-urile Oracle revin pe pveelite<span class="d">după ce pveelite a repornit</span></span><span id="infraFoPill2"></span></summary>
<dl class="sheet">
<dt>Situația</dt><dd>Operația de mai sus a rulat. Între timp pvemini a primit backup-uri noi, iar pveelite e din nou pornit, dar are date vechi.</dd>
<dt>Ce face</dt><dd>1. Face un snapshot pe pvemini și îl trimite pe pveelite, suprascriind ce e acolo.<br>2. pvemini redevine replica read-only, fără NFS.<br>3. Pe 10.0.20.36, transferul e redirecționat înapoi spre pveelite.<br>4. Repornește replicarea.</dd>
<dt>Ce pierzi</dt><dd class="risk">Tot ce e pe pveelite și nu e pe pvemini. Nu rula dacă pveelite a mai primit backup-uri după căderea lui.</dd>
<dt>Cum revii</dt><dd>Nu există un pas invers; suprascrierea e definitivă.</dd>
<div class="foot"><span class="hint">failback-dr-to-pveelite.sh · pe pvemini · durează după volumul datelor</span><button class="danger" id="btnFailoverPveelite" onclick="infraConfirmRun('backup-pe-pveelite', false, 'pveelite e din nou online: aduci backup-urile Oracle înapoi pe pveelite? Suprascrie definitiv ce e pe pveelite.')">Rulează…</button></div>
</dl>
</details>
<details class="op obsolete">
<summary><span class="chev">▶</span><span class="t">VM 201 (roacentral) pe alt nod<span class="d">arhivat — vezi proxmox/cluster/failover/arhiva/</span></span><span class="pill warn">învechit</span></summary>
<dl class="sheet">
<dt>De ce nu</dt><dd>Scripturile au fost scrise când VM 201 nu era în HA. Din 2026-04-25 e în HA (ha-prefer-pvemini): dacă pvemini cade, se mută <b>singur pe pve1</b> și revine singur. Un failover manual ar intra în conflict cu HA.</dd>
<dt>În schimb</dt><dd>Nu apar ca butoane aici. Panoul „Stare cluster" arată doar unde rulează VM 201 și starea HA. Scripturile sunt arhivate în repo.</dd>
</dl>
</details>
</div>
</details>
<details class="card" data-panel="infra-discord">
<summary><span class="chev">▶</span><span class="ptitle">Discord · /infra</span><span class="psub">aceleași acțiuni, confirmare cu butoane</span></summary>
<div class="pbody">
<div class="cmds">
<code>/infra stare</code><span>starea clusterului, ca embed</span>
<code>/infra oprire · pornire</code><span>simulare implicit</span>
<code>/infra ups-test · ups-simulare</code><span>test rapid · dry-run urgență</span>
<code>/infra dr-test · dr-patch · vm109</code><span>VM 109</span>
<code>/infra backup-pe-pvemini · backup-pe-pveelite</code><span>arată fișa de instrucțiuni, apoi butonul</span>
<code>/infra sarcini</code><span>ce rulează + ultimele linii din jurnal</span>
</div>
</div>
</details>
</div>
</div>
</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';
}
// ── tab-uri + panouri colapsabile ───────────────────────────────
function activeTab() {
var h = (location.hash || '').replace('#', '');
return ['punte', 'maria', 'infra'].indexOf(h) >= 0 ? h : 'punte';
}
function showTab(t) {
document.querySelectorAll('.tab').forEach(function (b) { b.classList.toggle('on', b.dataset.tab === t); });
document.querySelectorAll('.tabpage').forEach(function (p) { p.classList.toggle('on', p.dataset.tab === t); });
if (t === 'infra') { startInfraPolling(); } else { stopInfraPolling(); }
}
document.querySelectorAll('.tab').forEach(function (b) {
b.addEventListener('click', function () { location.hash = b.dataset.tab; });
});
window.addEventListener('hashchange', function () { showTab(activeTab()); });
document.querySelectorAll('details.card').forEach(function (d) {
var key = 'panel:' + (d.dataset.panel || '');
try {
var saved = localStorage.getItem(key);
if (saved !== null) d.open = saved === '1';
} catch (e) { /* localStorage indisponibil (mod privat) */ }
d.addEventListener('toggle', function () {
try { localStorage.setItem(key, d.open ? '1' : '0'); } catch (e) { /* ignorat */ }
});
});
// Butoanele din summary nu trebuie sa deschida/inchida panoul.
document.querySelectorAll('summary button').forEach(function (b) {
b.addEventListener('click', function (e) { e.preventDefault(); });
});
function toggleAllPanels() {
var panels = [].slice.call(document.querySelectorAll('details.card:not([hidden])'));
var anyOpen = panels.some(function (p) { return p.open; });
panels.forEach(function (p) { p.open = !anyOpen; });
document.getElementById('btnToggleAll').textContent = anyOpen ? 'Extinde tot' : 'Restrânge tot';
}
// ── stare ─────────────────────────────────────────────────────
var busy = false;
async function refresh() {
var res = await api('api/status');
if (!res.ok) return;
var s = res.data, svcInfo = s.service;
// fara autentificare, butonul "Ieși" nu are ce sa faca
document.getElementById('btnLogout').hidden = (s.auth === false);
document.getElementById('unitName').textContent = svcInfo.unit;
document.getElementById('dot').className = 'dot ' + (svcInfo.active ? 'on' : 'off');
document.getElementById('lastPoll').textContent =
(s.user ? s.user + ' · ' : '') + '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>';
// data-label: pe telefon capul de tabel dispare si eticheta vine din CSS
return '<tr><td class="mono" data-label="Fir">' + esc(t.thread_id) +
'</td><td data-label="Model">' + esc(t.model || '—') +
'</td><td class="mono" data-label="Director">' + esc(t.cwd || '—') +
'</td><td data-label="Stare">' + st +
'</td><td class="num" data-label="USD">' + Number(t.cost_usd || 0).toFixed(2) +
'</td></tr>';
}).join('') : '<tr><td colspan="5" class="empty" data-label="">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>' +
'<div class="check-actions">' +
'<button class="small primary" onclick="decide(\'' + esc(a.request_id) + '\',\'allow\')">Permite</button>' +
'<button class="small" title="Permite și nu mai întreba pentru operațiuni de același fel în acest fir" onclick="decide(\'' + esc(a.request_id) + '\',\'allow_session\')">Permite (tot firul)</button>' +
'<button class="small danger" onclick="decide(\'' + esc(a.request_id) + '\',\'deny\')">Refuză</button>' +
'</div></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;
}
// ── Maria: WhatsApp + RAG ───────────────────────────────────────
var mBusy = false;
function mMetricsHtml(info) {
return [
['Stare', '<span class="pill ' + (info.active ? 'ok' : 'bad') + '">' + esc(info.state) + '/' + esc(info.sub) + '</span>'],
['Uptime', dur(info.uptime_s)],
['PID', info.pid || '—'],
['Restarturi', info.restarts]
].map(function (m) {
return '<div class="metric"><div class="label">' + m[0] + '</div><div class="value">' + m[1] + '</div></div>';
}).join('');
}
function mShowPairCode(code, expiresIn) {
var pretty = code.length === 8 ? code.slice(0, 4) + '-' + code.slice(4) : code;
var out = document.getElementById('mPairOut');
out.dataset.code = code;
out.innerHTML =
'<div style="font-family:monospace;font-size:1.9rem;letter-spacing:.18em;margin:var(--space-3) 0">'
+ esc(pretty) + '</div><span class="muted">tastează-l pe telefon'
+ (expiresIn ? ' — expiră în ~' + Math.round(expiresIn / 60) + ' min' : '')
+ '. Dacă expiră, cere altul.</span>';
}
async function mPair() {
var phone = document.getElementById('mPairPhone').value.trim();
var out = document.getElementById('mPairOut');
out.textContent = 'cer codul…';
var res = await post('api/maria/pair', { phone: phone });
if (res.ok && res.data && res.data.code) {
mShowPairCode(res.data.code, res.data.expires_in);
} else {
out.innerHTML = '<span class="pill bad">eroare</span> '
+ esc((res.data && res.data.error) || 'nu am putut cere codul');
}
mRefresh();
}
async function mRefresh() {
var res = await api('api/maria/status');
if (!res.ok) return;
var s = res.data;
document.getElementById('mBridgeUnit').textContent = s.bridge.unit;
document.getElementById('mBridgeMetrics').innerHTML = mMetricsHtml(s.bridge);
document.getElementById('mRagUnit').textContent = s.rag.unit;
document.getElementById('mRagMetrics').innerHTML = mMetricsHtml(s.rag);
var wa = s.whatsapp || {};
var waStatus = document.getElementById('mWaStatus');
var waQr = document.getElementById('mWaQr');
// Cardul de asociere are rost doar cât timp puntea rulează și nu e conectată.
document.getElementById('mWaPair').hidden = !!wa.connected || wa.reachable === false;
var pairOut = document.getElementById('mPairOut');
if (wa.pairing && wa.pairing.code) {
mShowPairCode(wa.pairing.code, wa.pairing.expires_in);
} else if (!wa.connected && pairOut.dataset.code) {
// Codul afisat a expirat la punte. Lasat pe ecran, ar trimite omul sa
// tasteze un cod pe care WhatsApp il refuza fara sa spuna de ce.
pairOut.innerHTML = '<span class="muted">codul a expirat — cere altul</span>';
delete pairOut.dataset.code;
}
if (wa.connected) {
waStatus.innerHTML = '<span class="pill ok">conectat</span> ca ' + esc(wa.phone || '?');
waQr.innerHTML = '';
document.getElementById('mPairOut').innerHTML = '';
} else if (wa.qr) {
waStatus.innerHTML = '<span class="pill bad">neconectat</span> — scanează codul QR din WhatsApp → Dispozitive conectate';
waQr.innerHTML = '<img src="' + wa.qr + '" style="width:220px;height:220px;border-radius:8px;background:#fff;padding:8px;margin-top:var(--space-4)">';
} else {
waStatus.innerHTML = '<span class="pill bad">' + (wa.reachable === false ? 'puntea nu rulează' : 'aștept codul QR…') + '</span>';
waQr.innerHTML = '';
}
document.getElementById('mIndexDetail').textContent =
s.index.chunks + ' chunk-uri din ' + s.index.documents_indexed + ' documente indexate' +
(s.index.mtime ? ' · indexat ' + new Date(s.index.mtime * 1000).toLocaleString('ro-RO') : '');
var sync = s.sync || {};
document.getElementById('mSyncDetail').textContent = sync.drive_remote
? 'Drive: ' + sync.drive_remote + (sync.synced_at ? ' · ultima sincronizare ' + new Date(sync.synced_at * 1000).toLocaleString('ro-RO') : ' · încă nesincronizat')
: 'Sincronizare Drive dezactivată (DRIVE_REMOTE nesetat) — doar upload manual';
document.getElementById('mSupportDetail').textContent = s.support_jid
? 'Canal de suport: ' + s.support_jid
: 'SUPPORT_JID nesetat — escaladările se scriu doar în jurnal, nu anunță pe nimeni';
var escRes = await api('api/maria/escalations');
var escaladari = escRes.ok ? (escRes.data.escalations || []) : [];
document.getElementById('mEscCount').textContent = escaladari.length ? escaladari.length + ' recente' : '';
document.getElementById('mEsc').className = escaladari.length ? '' : 'empty';
document.getElementById('mEsc').innerHTML = escaladari.length ? escaladari.map(function (e) {
var stare = e.notified
? '<span class="pill ok">trimis</span>'
: '<span class="pill bad">netrimis' + (e.notify_error ? ' — ' + esc(e.notify_error) : '') + '</span>';
return '<div style="border-top:1px solid var(--border);padding:var(--space-4) 0">'
+ '<div>' + stare + ' <code>' + esc(e.ref || '') + '</code> <strong>' + esc(e.from || '?') + '</strong> '
+ '<span class="muted">' + (e.at ? new Date(e.at * 1000).toLocaleString('ro-RO') : '') + '</span>'
+ (e.had_image ? ' <span class="muted">· captură</span>' : '') + '</div>'
+ '<pre style="white-space:pre-wrap;margin:var(--space-3) 0;font-size:.85em">' + esc(e.text || '(fără text)') + '</pre>'
+ '<div class="muted">' + esc(e.reason || '') + '</div></div>';
}).join('') : 'nicio întrebare trimisă la suport';
var docsRes = await api('api/maria/documents');
var docs = docsRes.ok ? (docsRes.data.documents || []) : [];
var indexate = docs.filter(function (d) { return !d.shadowed_by; }).length;
document.getElementById('mDocCount').textContent =
docs.length + ' în depozit' + (indexate !== docs.length ? ' · ' + indexate + ' indexate' : '');
document.getElementById('mDocs').innerHTML = docs.length ? docs.map(function (d) {
var local = d.local ? ' <span class="muted" title="nu vine din Drive; sincronizarea nu il sterge">· local</span>' : '';
var umbrit = d.shadowed_by
? ' <span class="muted" title="acelaşi document exista intr-un format mai bogat">· umbrit de ' + esc(d.shadowed_by) + '</span>'
: '';
return '<tr><td data-label="Nume" class="mono">' + esc(d.name) + local + umbrit + '</td>' +
'<td data-label="Mărime" class="num">' + d.size + ' B</td>' +
'<td><button class="small danger" onclick="mDelDoc(\'' + esc(d.name) + '\')">Șterge</button></td></tr>';
}).join('') : '<tr><td colspan="3" class="empty" data-label="">niciun document</td></tr>';
}
async function mSvc(which, action) {
if (mBusy) return;
if (action !== 'start' && !confirm('Sigur „' + action + '” pe ' + which + '?')) return;
mBusy = true;
try {
var res = await post('api/maria/service', { service: which, action: action });
toast(res.ok ? 'Maria ' + which + ': ' + action + ' OK' : ('Eșuat: ' + (res.data.error || res.status)), res.ok ? 'ok' : 'bad');
} finally {
mBusy = false;
mRefresh();
}
}
async function mReindex() {
var res = await post('api/maria/reindex', {});
toast(res.ok && res.data.ok ? 'Index reconstruit' : ('Eșuat: ' + (res.data.error || res.data.stderr || res.status)), res.ok && res.data.ok ? 'ok' : 'bad');
mRefresh();
}
async function mSyncNow() {
var res = await post('api/maria/sync', { force: true });
toast(res.ok && res.data.ok ? 'Sincronizat' : ('Eșuat: ' + (res.data.error || res.data.stderr || res.status)), res.ok && res.data.ok ? 'ok' : 'bad');
mRefresh();
}
async function mAddDoc() {
var name = document.getElementById('mDocName').value.trim();
var content = document.getElementById('mDocContent').value;
var msg = document.getElementById('mDocMsg');
if (!name || !content) { msg.textContent = 'completează numele și conținutul'; return; }
var res = await post('api/maria/documents', { name: name, content: content });
if (!res.ok) { msg.textContent = 'eșuat: ' + (res.data.error || res.status); return; }
msg.textContent = 'salvat, reindexez…';
var ri = await post('api/maria/reindex', {});
msg.textContent = ri.ok && ri.data.ok ? 'salvat și reindexat' : ('salvat, reindexarea a eșuat: ' + (ri.data.error || ri.data.stderr || ''));
document.getElementById('mDocContent').value = '';
mRefresh();
}
async function mDelDoc(name) {
if (!confirm('Ștergi ' + name + '?')) return;
await post('api/maria/documents/delete', { name: name });
await mReindex();
}
var mClientsCache = [];
async function mLoadClients() {
var res = await api('api/maria/clients');
var clienti = res.ok ? (res.data.clients || []) : [];
mClientsCache = clienti;
document.getElementById('mClientCount').textContent = clienti.length ? clienti.length : '';
document.getElementById('mClients').innerHTML = clienti.length ? clienti.map(function (c) {
return '<tr><td data-label="Nume">' + esc(c.nume || c.id) + '</td>'
+ '<td data-label="Numere" class="mono">' + esc((c.numere || []).join(', ')) + '</td>'
+ '<td><button class="small" onclick="mEditClient(\'' + esc(c.id) + '\')">Editează</button></td>'
+ '<td><button class="small danger" onclick="mDelClient(\'' + esc(c.id) + '\')">Șterge</button></td></tr>';
}).join('') : '<tr><td colspan="4" class="empty" data-label="">niciun client</td></tr>';
}
function mEditClient(id) {
var c = mClientsCache.find(function (x) { return x.id === id; });
if (!c) return;
document.getElementById('mClientId').value = c.id;
document.getElementById('mClientId').disabled = true;
document.getElementById('mClientNume').value = c.nume || '';
document.getElementById('mClientProfil').value = c.profil || '';
document.getElementById('mClientNumere').value = (c.numere || []).join('\n');
document.getElementById('mClientFormTitle').textContent = 'Editează client: ' + (c.nume || c.id);
document.getElementById('mClientMsg').textContent = '';
}
function mResetClientForm() {
document.getElementById('mClientId').value = '';
document.getElementById('mClientId').disabled = false;
document.getElementById('mClientNume').value = '';
document.getElementById('mClientProfil').value = '';
document.getElementById('mClientNumere').value = '';
document.getElementById('mClientFormTitle').textContent = 'Adaugă client';
document.getElementById('mClientMsg').textContent = '';
}
async function mSaveClient() {
var id = document.getElementById('mClientId').value.trim();
var nume = document.getElementById('mClientNume').value.trim();
var profil = document.getElementById('mClientProfil').value;
var numere = document.getElementById('mClientNumere').value;
var msg = document.getElementById('mClientMsg');
if (!id || !nume) { msg.textContent = 'completează id și nume'; return; }
var res = await post('api/maria/clients', { id: id, nume: nume, profil: profil, numere: numere });
if (!res.ok) { msg.textContent = 'eșuat: ' + (res.data.error || res.status); return; }
mResetClientForm();
await mLoadClients();
}
async function mDelClient(id) {
if (!confirm('Ștergi clientul ' + id + '?')) return;
await post('api/maria/clients/delete', { id: id });
await mLoadClients();
}
async function mLoadFire() {
var res = await api('api/maria/fire');
var fire = res.ok ? (res.data.fire || []) : [];
document.getElementById('mFirCount').textContent = fire.length ? fire.length : '';
document.getElementById('mFire').innerHTML = fire.length ? fire.map(function (f) {
return '<tr><td data-label="JID" class="mono">' + esc(f.jid) + '</td>'
+ '<td data-label="Subiect">' + esc(f.ancora) + '</td>'
+ '<td data-label="Ultima activitate">' + (f.at ? new Date(f.at * 1000).toLocaleString('ro-RO') : '—') + '</td>'
+ '<td><button class="small danger" onclick="mDelFir(\'' + esc(f.jid) + '\')">Resetează</button></td></tr>';
}).join('') : '<tr><td colspan="4" class="empty" data-label="">niciun fir activ</td></tr>';
}
async function mDelFir(jid) {
if (!confirm('Resetezi firul cu ' + jid + '? Maria va porni o discutie noua la urmatorul mesaj.')) return;
await post('api/maria/fire/delete', { jid: jid });
await mLoadFire();
}
var mLogFile = 'bridge';
function mSetLog(which) { mLogFile = which; mRefreshLogs(); }
async function mRefreshLogs() {
document.getElementById('mTabBridge').className = 'small' + (mLogFile === 'bridge' ? ' primary' : '');
document.getElementById('mTabRag').className = 'small' + (mLogFile === 'rag' ? ' primary' : '');
var res = await api('api/maria/logs?lines=300&service=' + mLogFile);
if (!res.ok) return;
var el = document.getElementById('mLog');
el.textContent = (res.data.lines || []).join('\n') || '(gol)';
el.scrollTop = el.scrollHeight;
}
// ── Infrastructură ────────────────────────────────────────────
var infraVisible = false;
var infraTimer = null;
var infraLast = null;
var infraBusy = {};
function startInfraPolling() {
if (infraVisible) return;
infraVisible = true;
refreshInfra();
infraTimer = setInterval(refreshInfra, 30000);
}
function stopInfraPolling() {
infraVisible = false;
if (infraTimer) { clearInterval(infraTimer); infraTimer = null; }
}
async function refreshInfra(fresh) {
var res = await api('api/infra/status' + (fresh ? '?fresh=1' : ''));
if (!res.ok) return;
infraLast = res.data;
renderInfra(res.data);
}
function infraActionOk(id) {
var a = infraLast && infraLast.actions && infraLast.actions[id];
return !!(a && a.ok);
}
function infraActionReason(id) {
var a = infraLast && infraLast.actions && infraLast.actions[id];
return a && !a.ok ? a.reason : '';
}
function infraSetBtn(id, btnId) {
var el = document.getElementById(btnId);
if (!el) return;
var ok = infraActionOk(id);
el.disabled = !ok || !!infraBusy[id];
el.title = ok ? '' : infraActionReason(id);
}
function renderInfra(s) {
// banner joburi active
var jobs = (s.jobs || []).filter(function (j) { return j.active; });
var banner = document.getElementById('infraBanner');
if (jobs.length) {
banner.hidden = false;
banner.innerHTML = jobs.map(function (j) {
return '<span class="pill busy">● rulează</span><b>' + esc(j.id) + '</b>' +
'<span class="mono muted">@ ' + esc(j.host) + '</span>' +
'<span class="spacer"></span>' +
'<button onclick="infraShowLog(\'' + esc(j.id) + '\')">Jurnal</button>';
}).join('');
} else {
banner.hidden = true;
banner.innerHTML = '';
}
// pastile pe titlul "Stare cluster"
var q = s.quorum || {};
var g = s.guests || {};
var repl = s.replication || {};
var pills = [];
pills.push('<span class="pill ' + (q.quorate ? 'ok' : 'bad') + '">cvorum ' + (q.votes || 0) + '/' + (q.expected || 0) + '</span>');
pills.push('<span class="pill ' + (g.total && g.running === g.total ? 'ok' : 'warn') + '">' + (g.running || 0) + '/' + (g.total || 0) + ' guest-uri</span>');
if (repl.total) pills.push('<span class="pill ' + (repl.ok === repl.total ? 'ok' : 'bad') + '">replicare ' + repl.ok + '/' + repl.total + '</span>');
if (s.ssl) pills.push('<span class="pill ' + (s.ssl.min_days <= 14 ? 'warn' : 'ok') + '">SSL ' + s.ssl.min_days + ' zile</span>');
document.getElementById('infraPills').innerHTML = pills.join('');
// banda de metrici
var strip = [];
strip.push(['Cvorum', (q.votes || 0) + ' / ' + (q.expected || 0), 'voturi', false]);
strip.push(['Guest-uri', (g.running || 0) + ' / ' + (g.total || 0), '', false]);
if (s.vm109) strip.push(['VM 109', s.vm109.status || '—', s.vm109.node || '', false]);
if (repl.total) strip.push(['Replicare', repl.ok + ' / ' + repl.total, repl.failed && repl.failed.length ? repl.failed.join(', ') : '', repl.ok !== repl.total]);
if (s.ups) strip.push(['UPS', (s.ups.charge || 0) + '%', s.ups.status || '', s.ups.status !== 'OL']);
if (s.backup) strip.push(['Backup Oracle', s.backup.full_age_h != null ? s.backup.full_age_h.toFixed(1) + ' h' : '—', s.backup.cum_age_h != null ? 'CUM ' + s.backup.cum_age_h.toFixed(1) + ' h' : '', false]);
if (s.ssl) strip.push(['SSL minim', s.ssl.min_days + ' zile', s.ssl.domain, s.ssl.min_days <= 14]);
document.getElementById('infraStrip').innerHTML = strip.map(function (m) {
return '<div' + (m[3] ? ' class="warn"' : '') + '><div class="l">' + esc(m[0]) + '</div><div class="v">' + esc(m[1]) + '</div><div class="s">' + esc(m[2]) + '</div></div>';
}).join('');
// tabel noduri
document.getElementById('infraNodes').innerHTML = (s.nodes || []).map(function (n) {
return '<tr><td><b>' + esc(n.name) + '</b> <span class="mono muted">' + esc(n.ip) + '</span></td>' +
'<td><span class="pill ' + (n.online ? 'ok' : 'bad') + '">' + (n.online ? 'online' : 'offline') + '</span></td>' +
'<td class="num">' + (n.online ? (n.cpu * 100).toFixed(0) + '%' : '—') + '</td>' +
'<td class="num">' + (n.online && n.maxmem ? (n.mem / 1073741824).toFixed(0) + '/' + (n.maxmem / 1073741824).toFixed(0) + ' GB' : '—') + '</td>' +
'<td class="num">' + (n.online ? n.guests : '—') + '</td>' +
'<td class="num">' + (n.online ? dur(n.uptime) : '—') + '</td></tr>';
}).join('');
var errs = Object.keys(s.errors || {});
document.getElementById('infraErrors').textContent = errs.length
? 'Erori la citire: ' + errs.map(function (h) { return h + ' (' + s.errors[h] + ')'; }).join(', ')
: '';
// butoane oprire/pornire
infraSetBtn('oprire', 'btnOprire');
infraSetBtn('pornire', 'btnPornire');
document.getElementById('infraOprireReason').textContent = infraActionOk('oprire') ? '' : ('Blocat: ' + infraActionReason('oprire'));
document.getElementById('infraStartupText').textContent = s.startup_text || '—';
// UPS
var ups = s.ups;
document.getElementById('infraUpsPill').innerHTML = ups
? '<span class="pill ' + (ups.status === 'OL' ? 'ok' : 'bad') + '">' + esc(ups.status || '?') + ' ' + (ups.charge || 0) + '%</span>'
: '<span class="pill bad">necunoscut</span>';
infraSetBtn('ups-test', 'btnUpsTest');
infraSetBtn('ups-simulare', 'btnUpsSim');
infraSetBtn('ups-istoric', 'btnUpsHist');
// VM 109 / DR
var vm109 = s.vm109;
document.getElementById('infraVm109Pill').innerHTML = vm109
? '<span class="pill ' + (vm109.status === 'running' ? 'busy' : '') + '">' + esc(vm109.status || '?') + '</span>'
: '<span class="pill bad">necunoscut</span>';
infraSetBtn('dr-test', 'btnDrTest');
infraSetBtn('dr-patch', 'btnDrPatch');
infraSetBtn('vm109-pornire', 'btnVm109On');
infraSetBtn('vm109-oprire', 'btnVm109Off');
// failover/failback
infraSetBtn('backup-pe-pvemini', 'btnFailoverPvemini');
infraSetBtn('backup-pe-pveelite', 'btnFailoverPveelite');
document.getElementById('infraFoPill1').innerHTML = infraActionOk('backup-pe-pvemini')
? '<span class="pill ok">disponibil</span>' : '<span class="pill bad">' + esc(infraActionReason('backup-pe-pvemini')) + '</span>';
document.getElementById('infraFoPill2').innerHTML = infraActionOk('backup-pe-pveelite')
? '<span class="pill ok">disponibil</span>' : '<span class="pill bad">' + esc(infraActionReason('backup-pe-pveelite')) + '</span>';
// banner de job -> daca un job tocmai a pornit, deschide jurnalul automat
if (infraAutoOpenLog) { infraShowLog(infraAutoOpenLog); infraAutoOpenLog = null; }
}
async function infraRun(id, dryRun) {
if (infraBusy[id]) return;
infraBusy[id] = true;
try {
var res = await post('api/infra/run', { id: id, dry_run: !!dryRun });
if (res.ok) {
toast((dryRun ? 'Simulare pornită: ' : 'Pornit: ') + id + (res.data.log ? ' · jurnal disponibil' : ''), 'ok');
if (res.data.log) { infraAutoOpenLog = id; }
else if (res.data.output) { toast(String(res.data.output).slice(0, 300), ''); }
} else {
toast('Eșuat (' + res.status + '): ' + (res.data.error || ''), 'bad');
}
} finally {
infraBusy[id] = false;
refreshInfra(true);
}
}
var infraAutoOpenLog = null;
async function infraConfirmRun(id, dryRun, msg) {
if (!dryRun && !confirm(msg || ('Sigur rulezi „' + id + '”?'))) return;
return infraRun(id, dryRun);
}
async function infraOprire() {
if (!infraActionOk('oprire')) { toast('Blocat: ' + infraActionReason('oprire'), 'bad'); return; }
var typed = prompt('Oprești tot clusterul (LXC 171 se oprește la final; dashboard-ul devine inaccesibil). Scrie OPRESTE ca să confirmi:');
if (typed !== 'OPRESTE') { toast('Anulat', ''); return; }
return infraRun('oprire', false);
}
async function infraPornire() {
if (!infraActionOk('pornire')) { toast('Blocat: ' + infraActionReason('pornire'), 'bad'); return; }
if (!confirm('Pornești guest-urile clusterului (Oracle primul, crontab-urile la loc)?')) return;
return infraRun('pornire', false);
}
function infraCopyStartup() {
var text = (infraLast && infraLast.startup_text) || '';
try {
navigator.clipboard.writeText(text);
toast('Copiat în clipboard', 'ok');
} catch (e) {
toast('Nu am putut copia — selectează textul manual', 'bad');
}
}
async function infraShowUpsHistory() {
var res = await post('api/infra/run', { id: 'ups-istoric', dry_run: false });
if (res.ok && res.data.output) {
infraShowLog(null);
document.getElementById('infraLogTitle').textContent = 'Istoric autonomie UPS';
document.getElementById('infraLogPre').textContent = res.data.output;
document.getElementById('infraLogPanel').open = true;
} else {
toast('Eșuat: ' + (res.data.error || res.status), 'bad');
}
}
var infraLogTimer = null;
var infraLogId = null;
function infraShowLog(id) {
infraLogId = id;
var panel = document.getElementById('infraLogPanel');
panel.hidden = false;
panel.open = true;
document.getElementById('infraLogTitle').textContent = id ? ('Jurnal: ' + id) : 'Jurnal';
if (infraLogTimer) clearInterval(infraLogTimer);
if (id) {
infraPollLog();
infraLogTimer = setInterval(infraPollLog, 3000);
}
}
function infraHideLog() {
document.getElementById('infraLogPanel').hidden = true;
if (infraLogTimer) { clearInterval(infraLogTimer); infraLogTimer = null; }
infraLogId = null;
}
async function infraPollLog() {
if (!infraLogId) return;
var res = await api('api/infra/log?id=' + encodeURIComponent(infraLogId) + '&n=100');
if (!res.ok) return;
var el = document.getElementById('infraLogPre');
el.textContent = (res.data.lines || []).join('\n') || '(gol)';
el.scrollTop = el.scrollHeight;
}
async function infraStopJob() {
if (!infraLogId) { infraHideLog(); return; }
if (!confirm('Oprești jobul „' + infraLogId + '”? Dacă e o oprire/pornire de cluster în curs, clusterul poate rămâne pe jumătate configurat.')) return;
var res = await post('api/infra/stop', { id: infraLogId });
toast(res.ok ? 'Job oprit' : 'Eșuat: ' + (res.data.error || res.status), res.ok ? 'ok' : 'bad');
refreshInfra(true);
}
// ── pornire ───────────────────────────────────────────────────
showTab(activeTab());
refresh(); refreshDoctor(); refreshLogs();
mRefresh(); mRefreshLogs(); mLoadClients(); mLoadFire();
setInterval(refresh, 5000);
setInterval(refreshDoctor, 30000);
setInterval(mRefresh, 4000);
</script>
</body>
</html>