From 56027e2e6c224e1adbfe513065e6cb430f833b33 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Sun, 13 Sep 2026 10:18:12 +0000 Subject: [PATCH] feat(dashboard): tab Infrastructura, panouri colapsabile si compacte Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KddsXCqEbKMhdFJDYbAsx8 --- .../discord-bridge/dashboard/api.py | 37 + .../discord-bridge/dashboard/index.html | 712 ++++++++++++++---- .../discord-bridge/dashboard/static/app.css | 112 +++ .../discord-bridge/tests/test_dashboard.py | 34 + 4 files changed, 761 insertions(+), 134 deletions(-) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py index c825b64..8f4dd24 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py @@ -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 diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html index f909bc6..eb32717 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html @@ -11,178 +11,358 @@
+ — +
-
+ +
- -
-

Serviciu

-
-
- - - - - - - +
+ ▶Serviciu +
+
+
+ + + + + + + +
-
+
- -
-

Fire active

-
- - - -
FirModelDirectorStareUSD
+
+ ▶Fire active +
+
+ + + +
FirModelDirectorStareUSD
+
-
+ - -
-

Diagnostic

-
se încarcă…
-
+
+ ▶Diagnostic +
+
se încarcă…
+
+
- -
-

Confirmări în așteptare

-
niciuna
-
+
+ ▶Confirmări în așteptare +
+
niciuna
+
+
- -
-

Jurnal +
+ ▶Jurnal -

-
se încarcă…
-
+ +
+
se încarcă…
+
+ - -

Maria — WhatsApp + RAG

+
+ + +
-
-

Punte WhatsApp

-
-
- - - +
+ ▶Punte WhatsApp +
+
+
+ + + +
-
-
-

Consumer RAG

-
-
- - - + +
+ ▶Consumer RAG +
+
+
+ + + +
-
+
-
-

Conectare WhatsApp

-
se încarcă…
-
-
+ -
-

Trimise la suport

-
-
se încarcă…
-
+
+ ▶Documente indexate +
+
+
+
+ + +
+
+ + + +
NumeMărime
+
+

Adaugă document (.txt / .md)

+ + +
+ +
+
+
+
-
-

Fire de discuție active

-
- - - -
JIDSubiectUltima activitate
-
-
- -
-

Clienți cunoscuți

-
- - - -
NumeNumere
-
-

Adaugă client

- - - - -
- - -
-
-
- -
-

Documente indexate

-
-
-
- - -
-
- - - -
NumeMărime
-
-

Adaugă document (.txt / .md)

- - -
- -
-
-
- -
-

Jurnal Maria +
+ ▶Jurnal Maria -

-
se încarcă…
-
+ +
+
se încarcă…
+
+ + +
+ + +
+ + + + + +
+ ▶Stare cluster + + citire · la 30 s +
+
+
+ + +
NodStareCPURAMGuestUptime
+
+
+
+ +
+
+ +
+ ▶Oprire clustercluster-shutdown.sh · 15–40 min + + + + +
+
    +
  1. Verificări: ping, ssh, cvorum, fără task-uri în curs
  2. +
  3. Salvează lista guest-urilor pornite (.cluster-state.txt)
  4. +
  5. Trimite instrucțiunile de pornire cât încă merge totul: mesaj în Discord + email
  6. +
  7. HA pe freeze; pauză la alerte, la testul DR și la fereastra de patch
  8. +
  9. Oprește guest-urile, Oracle 108 ultimul (shutdown immediate)
  10. +
  11. LXC 171 se oprește odată cu pvemini, la final: dashboard-ul și Discord nu mai răspund
  12. +
  13. pveelite → pve1 → pvemini; raportul final vine pe email
  14. +
+
+
+
+ +
+ ▶Pornire clustertrezire de pe telefon, apoi de aici + + + + +
+

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.

+
—
+
+ + +
+
+
+ +
+ ▶UPS +
+
Test rapid baterie
ups-monthly-test.sh · ~2 min pe baterie
+
Simulează oprirea de urgență
ups-shutdown-cluster.sh --dry-run --force, ca nut
+
Istoric autonomie
ultimele rânduri din trendul de baterie
+
+
+ +
+
+ +
+ ▶DR · VM 109 +
+
Test DR acum
restaurează backup-ul RMAN pe VM 109, verifică, oprește · ~25 min
+
Fereastră de patch Windows
vm109-patch-window.sh --now · 30–60 min
+
VM 109 manual
pentru depanare; watchdog-ul intră pe pauză
+ + +
+
+
+ +
+ ▶Failover și failbackdistructivcând un nod a căzut; deschide o operație pentru instrucțiuni +
+ +
+ ▶Backup-urile Oracle trec pe pveminipveelite a căzut și nu revine curând +
+
Situația
Serverul de producție (10.0.20.36) își copiază în fiecare noapte backup-urile RMAN pe pveelite. De acolo le folosește VM 109 la testul DR. Dacă pveelite e jos, copia din noaptea asta nu mai are unde ajunge.
+
Ce face
1. Verifică de două ori că pveelite chiar nu răspunde.
2. Pe pvemini, copia backup-urilor devine activă, cu scriere, partajată prin NFS.
3. Pe 10.0.20.36, transfer_backups.ps1 e redirecționat spre pvemini.
4. Oprește replicarea pveelite → pvemini.
+
Ce pierzi
Nimic din producție. Backup-urile ajunse pe pveelite după ultima replicare (maximum 15 min) rămân doar acolo.
+
Cum revii
Operația de mai jos, după ce pveelite a repornit.
+
failover-dr-to-pvemini.sh · pe pvemini · ~2 min
+
+
+ +
+ ▶Backup-urile Oracle revin pe pveelitedupă ce pveelite a repornit +
+
Situația
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.
+
Ce face
1. Face un snapshot pe pvemini și îl trimite pe pveelite, suprascriind ce e acolo.
2. pvemini redevine replica read-only, fără NFS.
3. Pe 10.0.20.36, transferul e redirecționat înapoi spre pveelite.
4. Repornește replicarea.
+
Ce pierzi
Tot ce e pe pveelite și nu e pe pvemini. Nu rula dacă pveelite a mai primit backup-uri după căderea lui.
+
Cum revii
Nu există un pas invers; suprascrierea e definitivă.
+
failback-dr-to-pveelite.sh · pe pvemini · durează după volumul datelor
+
+
+ +
+ ▶VM 201 (roacentral) pe alt nodarhivat — vezi proxmox/cluster/failover/arhiva/învechit +
+
De ce nu
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ă singur pe pve1 și revine singur. Un failover manual ar intra în conflict cu HA.
+
În schimb
Nu apar ca butoane aici. Panoul „Stare cluster" arată doar unde rulează VM 201 și starea HA. Scripturile sunt arhivate în repo.
+
+
+ +
+
+ +
+ ▶Discord · /infraaceleași acțiuni, confirmare cu butoane +
+
+ /infra starestarea clusterului, ca embed + /infra oprire · porniresimulare implicit + /infra ups-test · ups-simularetest rapid · dry-run urgență + /infra dr-test · dr-patch · vm109VM 109 + /infra backup-pe-pvemini · backup-pe-pveelitearată fișa de instrucțiuni, apoi butonul + /infra sarcinice rulează + ultimele linii din jurnal +
+
+
+ +
+
@@ -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 '● rulează' + esc(j.id) + '' + + '@ ' + esc(j.host) + '' + + '' + + ''; + }).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('cvorum ' + (q.votes || 0) + '/' + (q.expected || 0) + ''); + pills.push('' + (g.running || 0) + '/' + (g.total || 0) + ' guest-uri'); + if (repl.total) pills.push('replicare ' + repl.ok + '/' + repl.total + ''); + if (s.ssl) pills.push('SSL ' + s.ssl.min_days + ' zile'); + 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 '
' + esc(m[0]) + '
' + esc(m[1]) + '
' + esc(m[2]) + '
'; + }).join(''); + + // tabel noduri + document.getElementById('infraNodes').innerHTML = (s.nodes || []).map(function (n) { + return '' + esc(n.name) + ' ' + esc(n.ip) + '' + + '' + (n.online ? 'online' : 'offline') + '' + + '' + (n.online ? (n.cpu * 100).toFixed(0) + '%' : '—') + '' + + '' + (n.online && n.maxmem ? (n.mem / 1073741824).toFixed(0) + '/' + (n.maxmem / 1073741824).toFixed(0) + ' GB' : '—') + '' + + '' + (n.online ? n.guests : '—') + '' + + '' + (n.online ? dur(n.uptime) : '—') + ''; + }).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 + ? '' + esc(ups.status || '?') + ' ' + (ups.charge || 0) + '%' + : 'necunoscut'; + infraSetBtn('ups-test', 'btnUpsTest'); + infraSetBtn('ups-simulare', 'btnUpsSim'); + infraSetBtn('ups-istoric', 'btnUpsHist'); + + // VM 109 / DR + var vm109 = s.vm109; + document.getElementById('infraVm109Pill').innerHTML = vm109 + ? '' + esc(vm109.status || '?') + '' + : 'necunoscut'; + 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') + ? 'disponibil' : '' + esc(infraActionReason('backup-pe-pvemini')) + ''; + document.getElementById('infraFoPill2').innerHTML = infraActionOk('backup-pe-pveelite') + ? 'disponibil' : '' + esc(infraActionReason('backup-pe-pveelite')) + ''; + + // 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); diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/static/app.css b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/static/app.css index 5db2bfe..ef05c5c 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/static/app.css +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/static/app.css @@ -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); } +} diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py index 7d63864..b00091d 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_dashboard.py @@ -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."""