Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KddsXCqEbKMhdFJDYbAsx8
1092 lines
54 KiB
HTML
1092 lines
54 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>
|
||
<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, '&').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';
|
||
}
|
||
|
||
// ── 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>
|