feat(dashboard): tab Infrastructura, panouri colapsabile si compacte

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KddsXCqEbKMhdFJDYbAsx8
This commit is contained in:
Claude Agent
2026-09-13 10:18:12 +00:00
parent dc61c43b32
commit 56027e2e6c
4 changed files with 761 additions and 134 deletions

View File

@@ -44,6 +44,7 @@ for _p in (str(_BRIDGE), str(_DASH)):
sys.path.insert(0, _p)
import config # noqa: E402
import infra_actions # noqa: E402
from limits import parse_cap # noqa: E402
# ── constante ───────────────────────────────────────────────────────────
@@ -847,6 +848,18 @@ class Handler(SimpleHTTPRequestHandler):
return self.send_json({"clients": maria_clients()})
if path == "/api/maria/fire":
return self.send_json({"fire": maria_fire()})
if path == "/api/infra/status":
fresh = qs.get("fresh", ["0"])[0] not in ("0", "", "false")
return self.send_json(infra_actions.status(fresh=fresh))
if path == "/api/infra/log":
action_id = qs.get("id", [""])[0]
if action_id not in infra_actions.ACTIONS:
return self.send_json({"error": "actiune necunoscuta"}, 400)
try:
n = min(max(int(qs.get("n", ["60"])[0]), 1), 2000)
except ValueError:
n = 60
return self.send_json(infra_actions.log_tail(action_id, n))
if path == "/api/maria/logs":
try:
n = min(max(int(qs.get("lines", ["200"])[0]), 1), 2000)
@@ -874,6 +887,10 @@ class Handler(SimpleHTTPRequestHandler):
return self.deny()
if path == "/api/service":
return self.handle_service()
if path == "/api/infra/run":
return self.handle_infra_run()
if path == "/api/infra/stop":
return self.handle_infra_stop()
if path == "/api/cleanup":
data = self.read_json()
return self.send_json(orphans_report(dry_run=bool(data.get("dry_run", True))))
@@ -959,6 +976,26 @@ class Handler(SimpleHTTPRequestHandler):
return self.send_json({"ok": bool(ok), "request_id": rid, "decision": decision},
200 if ok else 404)
def handle_infra_run(self):
data = self.read_json()
action_id = str(data.get("id") or "")
dry_run = bool(data.get("dry_run"))
who = tailnet_user(self.headers) or "local"
print(f"[actiune] infra {action_id} dry={dry_run}, cerut de {who}",
file=sys.stderr, flush=True)
res = infra_actions.run(action_id, dry_run)
return self.send_json(res, res["code"])
def handle_infra_stop(self):
data = self.read_json()
action_id = str(data.get("id") or "")
if action_id not in infra_actions.ACTIONS:
return self.send_json({"ok": False, "code": 400, "error": "actiune necunoscuta"}, 400)
who = tailnet_user(self.headers) or "local"
print(f"[actiune] infra stop {action_id}, cerut de {who}", file=sys.stderr, flush=True)
res = infra_actions.stop(action_id)
return self.send_json(res, res["code"])
def handle_maria_service(self):
"""start / stop / restart pe una din UNITATILE FIXE ale Mariei — acelasi
principiu ca handle_service: numele vine dintr-o cheie (bridge/rag), nu

View File

@@ -11,178 +11,358 @@
<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>
<div class="wrap">
<!-- ══════════════════════════ PUNTE ══════════════════════════ -->
<div class="wrap tabpage" data-tab="punte">
<!-- ── serviciu ───────────────────────────────────────────── -->
<section class="card">
<h2>Serviciu <span class="count" id="unitName"></span></h2>
<div class="metrics" id="metrics"></div>
<div class="actions" style="margin-top:var(--space-5)">
<button class="primary" id="btnRestart" onclick="svc('restart')">Repornește</button>
<button id="btnStart" onclick="svc('start')">Pornește</button>
<button class="danger" id="btnStop" onclick="svc('stop')">Oprește</button>
<span class="spacer"></span>
<button class="small" onclick="cleanup(true)">Caută orfani</button>
<button class="small danger" onclick="cleanup(false)">Curăță orfani</button>
<button class="small" onclick="restartSelf()">Repornește dashboard-ul</button>
<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>
</section>
</details>
<div class="grid-2">
<!-- ── fire ─────────────────────────────────────────────── -->
<section class="card">
<h2>Fire active <span class="count" id="threadCount"></span></h2>
<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>
<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>
</section>
</details>
<!-- ── diagnostic ───────────────────────────────────────── -->
<section class="card">
<h2>Diagnostic</h2>
<div id="doctor"><div class="empty">se încarcă…</div></div>
</section>
<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>
<!-- ── aprobări ───────────────────────────────────────────── -->
<section class="card">
<h2>Confirmări în așteptare <span class="count" id="apprCount"></span></h2>
<div id="approvals"><div class="empty">niciuna</div></div>
</section>
<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>
<!-- ── jurnal ─────────────────────────────────────────────── -->
<section class="card">
<h2>Jurnal
<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>
</h2>
<pre class="log" id="log">se încarcă…</pre>
</section>
</summary>
<div class="pbody">
<pre class="log" id="log">se încarcă…</pre>
</div>
</details>
<!-- ── Maria: WhatsApp + RAG ──────────────────────────────── -->
<h2 style="margin-top:var(--space-6)">Maria — WhatsApp + RAG</h2>
</div>
<!-- ══════════════════════════ MARIA ══════════════════════════ -->
<div class="wrap tabpage" data-tab="maria">
<div class="grid-2">
<section class="card">
<h2>Punte WhatsApp <span class="count" id="mBridgeUnit"></span></h2>
<div class="metrics" id="mBridgeMetrics"></div>
<div class="actions" style="margin-top:var(--space-5)">
<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>
<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>
</section>
<section class="card">
<h2>Consumer RAG <span class="count" id="mRagUnit"></span></h2>
<div class="metrics" id="mRagMetrics"></div>
<div class="actions" style="margin-top:var(--space-5)">
<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>
</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>
</section>
</details>
</div>
<section class="card">
<h2>Conectare WhatsApp</h2>
<div id="mWaStatus" class="empty">se încarcă…</div>
<div id="mWaQr"></div>
<div id="mWaPair" hidden>
<div class="detail" style="margin-top:var(--space-5)">
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>.
<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)">
<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>
<button class="primary" onclick="mSaveClient()">Salvează</button>
<button class="small" onclick="mResetClientForm()">Renunță</button>
</div>
<div id="mPairOut" class="detail" style="margin-top:var(--space-3)"></div>
<div class="detail" id="mClientMsg"></div>
</div>
</section>
</details>
<section class="card">
<h2>Trimise la suport <span class="count" id="mEscCount"></span></h2>
<div class="detail" id="mSupportDetail"></div>
<div id="mEsc" class="empty">se încarcă…</div>
</section>
<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>
<section class="card">
<h2>Fire de discuție active <span class="count" id="mFirCount"></span></h2>
<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>
</section>
<section class="card">
<h2>Clienți cunoscuți <span class="count" id="mClientCount"></span></h2>
<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>
<h2 style="margin-top:var(--space-5)" id="mClientFormTitle">Adaugă client</h2>
<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>
</section>
<section class="card">
<h2>Documente indexate <span class="count" id="mDocCount"></span></h2>
<div class="detail" id="mIndexDetail"></div>
<div class="detail" id="mSyncDetail"></div>
<div class="actions" style="margin:var(--space-5) 0">
<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>
<h2 style="margin-top:var(--space-5)">Adaugă document (.txt / .md)</h2>
<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>
</section>
<section class="card">
<h2>Jurnal Maria
<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>
</h2>
<pre class="log" id="mLog">se încarcă…</pre>
</section>
</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>
@@ -240,6 +420,42 @@ 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() {
@@ -636,7 +852,235 @@ async function mRefreshLogs() {
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);

View File

@@ -1,4 +1,5 @@
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
@@ -358,3 +359,114 @@ input[type="password"]:focus { outline: none; border-color: var(--border-focus);
.actions { grid-template-columns: 1fr; }
.metrics { grid-template-columns: 1fr 1fr; }
}
/* ── doua coloane (tab Infrastructura) ────────────────────────────────── */
.cols { display: grid; gap: var(--space-4); grid-template-columns: repeat(auto-fit, minmax(min(400px, 100%), 1fr)); align-items: start; }
.col { display: grid; gap: var(--space-4); min-width: 0; }
.tscroll { overflow-x: auto; }
.psub { color: var(--text-muted); font-size: var(--text-xs); }
/* ── tab-uri (Punte / Maria / Infrastructura) ─────────────────────────── */
.tabs { display: flex; gap: 2px; }
.tab {
padding: 2px var(--space-3);
border-radius: 999px;
color: var(--text-muted);
font-weight: 600;
font-size: var(--text-xs);
border: 1px solid transparent;
background: none;
cursor: pointer;
}
.tab.on { color: var(--text-primary); border-color: var(--border-focus); background: var(--accent-subtle); }
.tabpage { display: none; }
.tabpage.on { display: grid; }
/* ── panouri colapsabile (details.card inlocuieste vechiul section.card) ── */
details.card { padding: 0; }
details.card.danger { border-color: rgba(239, 68, 68, 0.5); }
details.card > summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2) var(--space-3);
padding: var(--space-4) var(--space-5);
user-select: none;
border-radius: var(--radius-lg);
margin: 0;
}
details.card > summary::-webkit-details-marker { display: none; }
details.card > summary:hover { background: var(--bg-surface-hover); }
details.card > summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -2px; }
details.card .chev { width: 14px; color: var(--text-muted); transition: transform .15s; flex: none; font-size: 10px; }
details.card[open] > summary .chev { transform: rotate(90deg); }
details.card .ptitle { font-weight: 600; color: var(--text-primary); font-size: var(--text-base); }
.pbody { padding: 0 var(--space-5) var(--space-5); display: grid; gap: var(--space-4); border-top: 1px solid var(--border); }
.pbody > :first-child { margin-top: var(--space-4); }
@media (prefers-reduced-motion: reduce) { details.card .chev { transition: none; } }
/* ── banda de metrici compacta (stare cluster) ────────────────────────── */
.strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.strip > div { padding: var(--space-2) var(--space-3); border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); margin: 0 -1px -1px 0; }
.strip .l { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); }
.strip .v { font-size: var(--text-base); font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; }
.strip .s { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.strip .warn .v { color: var(--warning); }
/* ── randuri de actiune (UPS, DR VM109) ───────────────────────────────── */
.rows { display: grid; }
.row { display: grid; grid-template-columns: 1fr auto; gap: 2px var(--space-3); align-items: center; padding: var(--space-2) 0; border-top: 1px solid var(--border); }
.row:first-child { border-top: 0; }
.row .t { font-weight: 600; color: var(--text-primary); }
.row .d { color: var(--text-muted); font-size: var(--text-xs); }
/* ── pasi numerotati (oprire/pornire) ─────────────────────────────────── */
ol.steps { margin: 0; padding: 0; list-style: none; counter-reset: s; display: grid; }
ol.steps li { counter-increment: s; display: grid; grid-template-columns: 18px 1fr; gap: var(--space-2); padding: 3px 0; font-size: var(--text-xs); }
ol.steps li::before { content: counter(s); color: var(--text-muted); font-family: var(--font-mono); text-align: right; }
ol.steps li.dies { color: var(--error); }
ol.steps li.dies::before { content: "!"; color: var(--error); font-weight: 700; }
ol.steps li.here::before { content: "▸"; color: var(--accent); }
.cmt { color: var(--text-muted); }
/* ── banner de job activ ──────────────────────────────────────────────── */
.banner { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-3); align-items: center; background: var(--accent-subtle); border: 1px solid var(--border-focus); border-radius: var(--radius-lg); padding: var(--space-2) var(--space-3); font-size: var(--text-xs); }
.hint { font-size: var(--text-xs); color: var(--text-muted); }
.hint.warn { color: var(--warning); }
/* ── fisa de operatie (failover/failback) ─────────────────────────────── */
details.op { border-top: 1px solid var(--border); }
details.op:first-of-type { border-top: 0; }
details.op > summary {
list-style: none; cursor: pointer;
display: grid; grid-template-columns: 14px 1fr auto; gap: var(--space-2); align-items: center;
padding: var(--space-3) 0; margin: 0;
}
details.op > summary::-webkit-details-marker { display: none; }
details.op > summary .t { font-weight: 600; color: var(--text-primary); }
details.op > summary .d { display: block; color: var(--text-muted); font-size: var(--text-xs); font-weight: 400; }
details.op > summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 2px; }
.sheet { margin: 0 0 var(--space-3) 22px; display: grid; grid-template-columns: 110px 1fr; gap: var(--space-2) var(--space-3); font-size: var(--text-xs); background: var(--bg-surface-hover); border-radius: var(--radius); padding: var(--space-3); }
.sheet dt { color: var(--text-muted); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; padding-top: 2px; margin: 0; }
.sheet dd { margin: 0; }
.sheet dd.risk { color: var(--error); }
.sheet .foot { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; justify-content: space-between; border-top: 1px solid var(--border); padding-top: var(--space-2); margin-top: 2px; }
.obsolete { background: rgba(239, 68, 68, 0.06); }
:root[data-theme="light"] .obsolete { background: rgba(220, 38, 38, 0.05); }
/* ── ajutor Discord /infra ─────────────────────────────────────────────── */
.cmds { display: grid; grid-template-columns: auto 1fr; gap: 2px var(--space-3); font-size: var(--text-xs); }
.cmds code { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-primary); white-space: nowrap; }
.cmds span { color: var(--text-muted); }
@media (max-width: 640px) {
.row { grid-template-columns: 1fr; }
.sheet { grid-template-columns: 1fr; margin-left: 0; }
.sheet dt { padding-top: var(--space-1); }
.cmds { grid-template-columns: 1fr; }
.cmds span { margin-bottom: var(--space-1); }
details.card > summary { padding: var(--space-3) var(--space-4); }
.pbody { padding: 0 var(--space-4) var(--space-4); }
}

View File

@@ -450,6 +450,40 @@ def test_valoare_necunoscuta_pentru_auth_nu_deschide_panoul(server, state_dir):
assert server.call("/api/status")[0] == 401
# --- tab Infrastructura ------------------------------------------------------
def test_infra_run_id_inexistent(server):
server.login()
status, data, _ = server.call("/api/infra/run", {"id": "nu-exista", "dry_run": False})
assert status == 400
assert not data["ok"]
def test_infra_run_ignora_campurile_in_plus(server, monkeypatch):
"""Payload-ul poate purta cmd/host straine -- api.py trebuie sa apeleze
infra_actions.run doar cu (id, dry_run), restul se ignora."""
server.login()
apeluri = []
def fake_run(action_id, dry_run):
apeluri.append((action_id, dry_run))
return {"ok": True, "code": 200, "host": "pvemini"}
import infra_actions
monkeypatch.setattr(infra_actions, "run", fake_run)
status, data, _ = server.call("/api/infra/run", {
"id": "oprire", "dry_run": True, "cmd": "rm -rf /", "host": "x",
})
assert status == 200 and data["ok"]
assert apeluri == [("oprire", True)]
def test_infra_log_id_invalid(server):
server.login()
status, _, _ = server.call("/api/infra/log?id=../x")
assert status == 400
def test_identitatea_tailscale_e_doar_pentru_jurnal(server_fara_auth):
"""Antetul pus de `tailscale serve` se raporteaza, dar nu decide accesul:
pe localhost lipseste, si totusi cererea trece."""