diff --git a/CLAUDE.md b/CLAUDE.md index 5ed1f25..a9e3644 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ input/ # Oracle DMP files for import - **Chatbot architecture**: `proxmox/lxc104-flowise/docs/prd.md` - **Docker Sandboxes (sbx) — agenți AI izolați**: `proxmox/lxc102-docker/README.md` - **Punte Discord → Claude Code (comandă LXC 171 de pe telefon, dintr-un guild privat)**: `proxmox/lxc171-claude-agent/discord-bridge/README.md` +- **Dashboard de control al punții Discord (stare, restart, orfani, confirmări; tunel SSH pe 18790)**: `proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md` - **Disaster recovery**: `proxmox/vm109-windows-dr/README.md` - **Instalare/migrare Oracle — care director se folosește**: `proxmox/lxc108-oracle/docs/instalare-si-migrare-oracle.md` - **ROA Windows setup scripts (XE/SE 21c)**: `proxmox/lxc108-oracle/roa-windows-setup/README.md` diff --git a/proxmox/lxc171-claude-agent/README.md b/proxmox/lxc171-claude-agent/README.md index 5863dca..6cd2d32 100644 --- a/proxmox/lxc171-claude-agent/README.md +++ b/proxmox/lxc171-claude-agent/README.md @@ -18,6 +18,7 @@ | `scripts/finish-task.sh` | Finalizează task (commit + push) | | `scripts/reap-orphans.sh` | Curăță procese vscode-server orfane + sesiuni zombie (cron, anti-OOM) | | `discord-bridge/` | Punte Discord → Claude Code: comanzi containerul dintr-un guild privat ([README](discord-bridge/README.md)) | +| `discord-bridge/dashboard/` | Dashboard de control al punții: stare, restart, orfani, confirmări ([README](discord-bridge/dashboard/README.md)) | --- diff --git a/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md b/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md index 600d64e..c61f5fc 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md @@ -9,7 +9,7 @@ ce semnaturi trec granita, ca merge-ul sa fie mecanic. |---|---| | A (nucleu + adaptor) | `session_store.py`, `stream.py`, `runner.py`, `render.py`, `limits.py`, `bot.py`, `config.py`, `tests/**`, `requirements.txt`, `requirements-dev.txt` | | B (securitate) | `security/confirm_hook.py`, `security/infra`, `security/approvals.py`, `security/bot-settings.json.example`, `security/README.md`, `tests/test_confirm_hook.py`, `tests/test_infra.py` | -| C (ops) | `alerts.py`, `cleanup.py`, `ops/claude-discord.service`, `ops/install.sh`, `ops/logrotate.conf`, `tests/test_alerts.py`, `tests/test_cleanup.py`, `README.md`, si liniile de index din `../README.md` + `/workspace/romfastsql/CLAUDE.md` | +| C (ops) | `alerts.py`, `cleanup.py`, `dashboard/**`, `ops/claude-discord.service`, `ops/install.sh`, `ops/logrotate.conf`, `tests/test_alerts.py`, `tests/test_cleanup.py`, `tests/test_dashboard.py`, `README.md`, si liniile de index din `../README.md` + `/workspace/romfastsql/CLAUDE.md` | Fisiere partajate ca *citire*: acest INTERFACES.md. Nimeni nu-l editeaza. diff --git a/proxmox/lxc171-claude-agent/discord-bridge/README.md b/proxmox/lxc171-claude-agent/discord-bridge/README.md index ffea04d..56e41d6 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/README.md @@ -187,12 +187,22 @@ tail -f ~/.claude-discord/logs/alerts.log # ce alerte s-au trimis / au esu systemctl --user restart claude-discord # repornire ``` +Aceleasi lucruri, cu butoane, in **dashboard-ul de control** +([dashboard/README.md](dashboard/README.md)) — stare, restart, orfani, confirmari, +jurnale, la `http://127.0.0.1:18790` (tunel SSH; e legat de localhost intentionat): + +```bash +ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171 & +grep DASHBOARD_TOKEN ~/.claude-discord/env +``` + | Fisier | Ce e | |--------|------| | `~/.claude-discord/env` | token + allowlist + limite (0600) | | `~/.claude-discord/state.json` | sesiuni, directoare, pid-uri, cost | | `~/.claude-discord/logs/bot.log` | stdout/stderr al botului (rotit zilnic, 14 zile) | | `~/.claude-discord/logs/alerts.log` | jurnalul alertelor | +| `~/.claude-discord/logs/dashboard.log` | stdout/stderr al dashboard-ului de control | | `~/.claude-discord/alerts-dedup.json` | fereastra de dedup a alertelor | | `~/.claude-discord/approvals/` | cereri de confirmare intre hook si bot | @@ -345,4 +355,7 @@ baza pe ele ca pe o bariera. | `ops/install.sh` | instalare idempotenta | C | | `ops/env.example` | sablon de configurare | C | | `ops/logrotate.conf` | rotatia logurilor | C | +| `dashboard/api.py` | dashboard de control (stare, restart, orfani, confirmari) | C | +| `dashboard/index.html`, `login.html`, `static/` | interfata dashboard-ului | C | +| `dashboard/claude-discord-dashboard.service` | unit systemd pentru dashboard | C | | `INTERFACES.md` | contractul intre module | orchestrator | diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md new file mode 100644 index 0000000..178a8d9 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md @@ -0,0 +1,125 @@ +# Dashboard de control pentru puntea Discord (LXC 171) + +Panou web pentru starea si repornirea puntii, modelat dupa dashboard-ul agentului +**echo** de pe LXC 110 (`/home/moltbot/echo-core/dashboard`, port 8088): acelasi tip +de server (stdlib `http.server`, zero dependinte), aceiasi tokeni de design, acelasi +tipar de endpoint-uri ca in `handlers/eco.py`. + +``` +http://127.0.0.1:18790 +``` + +## Ce arata si ce poate face + +| Zona | Continut | +|---|---| +| **Serviciu** | stare `active/running`, uptime, PID, memoria cgroup-ului, numarul de restarturi, firele active, costul zilei fata de plafon | +| **Butoane** | Pornește / Oprește / Repornește puntea, cauta si curata procese orfane (`cleanup.py`), reporneste dashboard-ul insusi | +| **Fire active** | ce e in `state.json`: fir, model, `cwd`, daca procesul `claude` traieste, daca are tur in desfasurare, cost | +| **Diagnostic** | 8 verificari (vezi mai jos), reimprospatate la 30s | +| **Confirmari in asteptare** | cererile hook-ului `PreToolUse` — se pot aproba/refuza direct din pagina, nu doar din Discord | +| **Jurnal** | ultimele 300 de linii din `bot.log` sau `infra.log` | + +Starea se reimprospateaza automat la 5 secunde. + +## Decizii de proiectare + +**O singura unitate controlata.** `/api/service` actioneaza intotdeauna pe +`claude-discord.service`; numele unitatii nu vine niciodata din cerere. Altfel +panoul ar fi un `systemctl` remote fara parola pentru tot ce ruleaza sub `claude`. +Exista un test care trimite `{"unit": "ssh.service"}` si verifica faptul ca tot +puntea e repornita. + +**Bind pe 127.0.0.1.** Butonul de restart opreste un agent care ruleaza cu +`bypassPermissions` si are chei SSH catre tot clusterul. Se ajunge la el prin +tunel SSH (mai jos), nu expus in LAN. `DASHBOARD_BIND` poate schimba asta, dar +atunci tokenul ramane singura bariera. + +**Restart protejat de tururi in zbor.** `stop`/`restart` intorc **409** cu lista +firelor care au un tur in desfasurare; interfata intreaba si retrimite cu +`force: true` doar dupa confirmare. `KillMode=control-group` din unitul puntii +omoara tot cgroup-ul, deci un restart neatent taie raspunsuri pe jumatate scrise. + +**Dashboard-ul e un unit separat** (`claude-discord-dashboard.service`), tocmai ca +o repornire a puntii sa nu ia si panoul din care ai apasat butonul. Invers, +`/api/restart-self` iese cu cod 0 si lasa `Restart=always` sa-l reporneasca. + +**Citire fara lock.** `state.json` e citit direct, fara `flock`: panoul nu are voie +sa blocheze botul. Un JSON prins la mijlocul unei scrieri se ignora si se reia la +urmatorul poll (test: `test_state_corupt_nu_arunca`). + +## Verificarile din Diagnostic + +1. serviciul `claude-discord` (stare + numar de restarturi) +2. `state.json` citibil, cate fire contine +3. costul zilei fata de `COST_CAP_USD_DAY` +4. spatiu liber pe disc (prag 10%) +5. dimensiunea `bot.log` (prag 100 MB) +6. **regulile `deny` din `bot-settings.json`** — pica daca reapare `Bash(ssh:*)` sau + `Bash(scp:*)`. Sunt regulile care pe 2026-08-30 au taiat complet accesul puntii la + infrastructura: `deny` are precedenta peste `bypassPermissions` si opreste turul + inainte de hook (vezi `../security/README.md`). +7. prezenta hook-ului de confirmare +8. CLI-ul `claude` in PATH + +## Endpoint-uri + +Toate cer cookie-ul de sesiune, obtinut cu `POST /api/auth/login`. + +| Metoda | Ruta | Ce face | +|---|---|---| +| GET | `/api/status` | serviciu + dashboard + fire + cost + numar de confirmari | +| GET | `/api/logs?lines=N&file=bot\|infra` | ultimele N linii (plafon 2000) | +| GET | `/api/doctor` | verificarile de mai sus | +| GET | `/api/approvals` | cererile `pending` | +| GET/POST | `/api/cleanup` | GET = doar cauta; POST `{"dry_run": false}` = omoara orfanii | +| POST | `/api/service` | `{"action": "start\|stop\|restart", "force": bool}` | +| POST | `/api/approvals/decide` | `{"request_id": "...", "decision": "allow\|deny"}` | +| POST | `/api/restart-self` | reporneste dashboard-ul | +| POST | `/api/auth/login` / `/api/auth/logout` | `{"token": "..."}` / sterge cookie-ul | + +Autentificarea e un token din `~/.claude-discord/env` schimbat pe un cookie +`HttpOnly; SameSite=Strict` valabil 30 de zile, comparat cu `secrets.compare_digest`. +Fara `DASHBOARD_TOKEN` in env, procesul isi genereaza unul aleator si il scrie in +`logs/dashboard.log` — **nu** ramane deschis. + +## Instalare + +`ops/install.sh` face totul (leaga unitul, genereaza `DASHBOARD_TOKEN` daca lipseste, +porneste serviciul). Manual: + +```bash +ln -sfn /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/dashboard/claude-discord-dashboard.service \ + ~/.config/systemd/user/claude-discord-dashboard.service +printf 'DASHBOARD_TOKEN=%s\n' "$(python3 -c 'import secrets;print(secrets.token_urlsafe(24))')" >> ~/.claude-discord/env +systemctl --user daemon-reload +systemctl --user enable --now claude-discord-dashboard +``` + +Setari optionale in `~/.claude-discord/env`: `DASHBOARD_BIND` (implicit `127.0.0.1`), +`DASHBOARD_PORT` (implicit `18790`). + +## Acces + +Fiind legat de localhost, se ajunge la el prin tunel SSH — la fel ca la dashboard-ul +lui echo: + +```bash +# de pe statia de lucru (direct sau prin Tailscale: 100.95.55.51) +ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171 & +# apoi http://localhost:18790 +``` + +Tokenul se citeste cu `grep DASHBOARD_TOKEN ~/.claude-discord/env`. + +## Teste + +```bash +cd proxmox/lxc171-claude-agent/discord-bridge +python3 -m pytest tests/test_dashboard.py -q +``` + +25 de teste, fara retea si fara `systemctl` real (dublura inregistreaza apelurile). +Acopera autentificarea, faptul ca unitatea nu poate fi aleasa din cerere, blocajul pe +tur in zbor si trecerea cu `force`, traversarea de cale in `request_id`, `state.json` +corupt si verificarea de regresie pentru `deny(ssh)`. diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py new file mode 100644 index 0000000..fbca525 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py @@ -0,0 +1,525 @@ +#!/usr/bin/env python3 +"""Dashboard de control pentru puntea Discord -> Claude Code (LXC 171). + +Model: `echo-core/dashboard` de pe LXC 110 (server stdlib + handler-ul `eco.py`). +Diferentele deliberate: + + - **o singura unitate controlata**: `claude-discord.service`. Nu exista endpoint + care sa primeasca un nume de unit din exterior — altfel dashboard-ul ar deveni + un `systemctl` remote fara parola. + - **bind pe 127.0.0.1 implicit**: butonul "restart" opreste un agent care ruleaza + cu `bypassPermissions` si chei SSH catre tot clusterul. Accesul se face prin + tunel SSH (vezi README), nu expus in LAN. + - **restart protejat de tururi in zbor**: `stop`/`restart` intorc 409 daca exista + fire cu tur in desfasurare, pana cand se cere explicit `force`. + +Fara dependinte in afara stdlib: ruleaza cu acelasi python ca botul, dar nu are +nevoie de venv-ul lui. +""" + +from __future__ import annotations + +import json +import os +import secrets +import shutil +import subprocess +import sys +import threading +import time +from datetime import datetime +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +# Radacina punti (parintele lui dashboard/) trebuie sa fie importabila: de acolo +# vin config.py, cleanup.py si security/approvals.py. +_DASH = Path(__file__).resolve().parent +_BRIDGE = _DASH.parent +for _p in (str(_BRIDGE), str(_DASH)): + if _p not in sys.path: + sys.path.insert(0, _p) + +import config # noqa: E402 + +# ── constante ─────────────────────────────────────────────────────────── +SERVICE = "claude-discord.service" +SELF_SERVICE = "claude-discord-dashboard.service" + + +# Caile se recalculeaza la fiecare apel, nu se ingheata la import: `config.reload()` +# muta STATE_DIR (testele o folosesc ca sa scoata totul din ~/.claude-discord). +def bot_log() -> Path: + return config.LOG_DIR / "bot.log" + + +def infra_log() -> Path: + return config.LOG_DIR / "infra.log" + + +COOKIE_NAME = "dashboard" +COOKIE_MAX_AGE = 60 * 60 * 24 * 30 + +_TOKEN: str | None = None + + +def reset_token_cache() -> None: + """Uita tokenul memorat (folosit de teste dupa `config.reload`).""" + global _TOKEN + _TOKEN = None + + +def dashboard_token() -> str: + """Tokenul de acces, din `~/.claude-discord/env` (`DASHBOARD_TOKEN`). + + Lipsa lui NU deschide dashboard-ul: se genereaza unul aleator per proces si se + tipareste in log, deci ramane accesibil doar cui poate citi logul. + """ + global _TOKEN + if _TOKEN is None: + tok = (config.get("DASHBOARD_TOKEN") or "").strip() + if not tok: + tok = secrets.token_urlsafe(32) + print( + f"[auth] DASHBOARD_TOKEN nesetat in {config.ENV_FILE} — token efemer " + f"pentru acest proces: {tok}", + file=sys.stderr, flush=True, + ) + _TOKEN = tok + return _TOKEN + + +# ── systemd ───────────────────────────────────────────────────────────── +def _sysctl(*args: str, timeout: float = 30.0) -> subprocess.CompletedProcess: + return subprocess.run( + ["systemctl", "--user", *args], + capture_output=True, text=True, timeout=timeout, + ) + + +def _show(unit: str, prop: str) -> str: + try: + return _sysctl("show", "-p", prop, "--value", unit, timeout=5).stdout.strip() + except Exception: + return "" + + +def _uptime_s(unit: str) -> int | None: + """Secunde de la ultima pornire a unitatii. + + Sursa principala e `ActiveEnterTimestampMonotonic` (microsecunde pe + CLOCK_MONOTONIC), comparat cu `time.monotonic()` — ACELASI ceas. **Nu** se + foloseste `/proc/uptime`: intr-un LXC acela e virtualizat de lxcfs si arata + uptime-ul containerului, mai mic decat monotonic-ul gazdei pe care il + raporteaza systemd, deci diferenta iese negativa si uptime-ul apare 0. + + Rezerva e `ActiveEnterTimestamp`, ora de perete in fusul local al masinii cu + numele fusului la coada; il taiem si lasam `.timestamp()` sa-l interpreteze + ca ora locala (`%Z` in strptime nu produce un offset utilizabil). + """ + mono = _show(unit, "ActiveEnterTimestampMonotonic") + if mono.isdigit() and int(mono) > 0: + return max(0, int(time.monotonic() - int(mono) / 1_000_000)) + ts = _show(unit, "ActiveEnterTimestamp") + if not ts: + return None + try: + parts = ts.split() + # "Sun 2026-08-30 12:47:20 UTC" -> data + ora, fara ziua si fusul + stamp = datetime.strptime(f"{parts[1]} {parts[2]}", "%Y-%m-%d %H:%M:%S") + except (ValueError, IndexError): + return None + return max(0, int(time.time() - stamp.timestamp())) + + +def unit_info(unit: str) -> dict: + active = _show(unit, "ActiveState") + pid = _show(unit, "MainPID") + mem = _show(unit, "MemoryCurrent") + restarts = _show(unit, "NRestarts") + return { + "unit": unit, + "active": active == "active", + "state": active or "unknown", + "sub": _show(unit, "SubState"), + "enabled": _show(unit, "UnitFileState"), + "pid": int(pid) if pid.isdigit() and pid != "0" else None, + "memory_bytes": int(mem) if mem.isdigit() else None, + "restarts": int(restarts) if restarts.isdigit() else 0, + "uptime_s": _uptime_s(unit) if active == "active" else None, + } + + +# ── starea punti ──────────────────────────────────────────────────────── +def read_state() -> dict: + """state.json fara lock: dashboard-ul doar citeste si nu are voie sa blocheze + botul. Un JSON prins la mijlocul unei scrieri intoarce {} — se reincarca la + urmatorul poll.""" + try: + data = json.loads(config.STATE_FILE.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def _pid_alive(pid) -> bool: + try: + return pid is not None and Path(f"/proc/{int(pid)}").exists() + except (TypeError, ValueError): + return False + + +def threads_view(state: dict) -> list[dict]: + out = [] + for tid, rec in (state.get("threads") or {}).items(): + if not isinstance(rec, dict): + continue + inflight = rec.get("inflight") or None + out.append({ + "thread_id": tid, + "cwd": rec.get("cwd"), + "model": rec.get("model"), + "pid": rec.get("pid"), + "alive": _pid_alive(rec.get("pid")), + "inflight": bool(inflight), + "inflight_since": (inflight or {}).get("started_at"), + "cost_usd": round(float(rec.get("cost_usd_total") or 0), 4), + "last_active": rec.get("last_active"), + }) + out.sort(key=lambda t: t.get("last_active") or 0, reverse=True) + return out + + +def inflight_threads(state: dict) -> list[str]: + return [t["thread_id"] for t in threads_view(state) if t["inflight"]] + + +def pending_approvals() -> list[dict]: + """Cererile de confirmare in asteptare, citite direct din director. + + Nu importam `security.approvals` (API-ul lui e async si porneste un watcher); + formatul fisierului e fixat in security/README.md. + """ + out = [] + d = config.APPROVALS_DIR + try: + files = sorted(d.glob("*.json")) + except OSError: + return out + now = time.time() + for f in files: + try: + req = json.loads(f.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if not isinstance(req, dict) or req.get("status") != "pending": + continue + out.append({ + "request_id": req.get("request_id"), + "thread_id": req.get("thread_id"), + "tool_name": req.get("tool_name"), + "command": (req.get("command") or "")[:500], + "rule": req.get("rule"), + "reason": req.get("reason"), + "created_at": req.get("created_at"), + "expires_in": ( + round(req["expires_at"] - now, 1) + if isinstance(req.get("expires_at"), (int, float)) else None + ), + }) + return out + + +def doctor() -> list[dict]: + checks: list[dict] = [] + + info = unit_info(SERVICE) + checks.append({ + "name": "Serviciu claude-discord", + "pass": info["active"], + "detail": f'{info["state"]}/{info["sub"]}, {info["restarts"]} restarturi', + }) + + st = read_state() + checks.append({ + "name": "state.json", + "pass": bool(st), + "detail": f'{len(st.get("threads") or {})} fire' if st else "ilizibil sau gol", + }) + + cap = float(config.get("COST_CAP_USD_DAY") or 0) + spent = float((st.get("cost") or {}).get("usd") or 0) + checks.append({ + "name": "Plafon de cost pe zi", + "pass": cap <= 0 or spent < cap, + "detail": f"{spent:.2f} / {cap:.2f} USD", + }) + + try: + du = shutil.disk_usage("/") + pct = du.free / du.total * 100 + checks.append({ + "name": "Spatiu pe disc", + "pass": pct > 10, + "detail": f"{pct:.1f}% liber ({du.free // 1024**3} GB)", + }) + except OSError as exc: + checks.append({"name": "Spatiu pe disc", "pass": False, "detail": str(exc)}) + + try: + log = bot_log() + size_mb = log.stat().st_size / 1024**2 if log.exists() else 0 + checks.append({ + "name": "bot.log", + "pass": log.exists() and size_mb < 100, + "detail": f"{size_mb:.1f} MB" if log.exists() else "lipseste", + }) + except OSError as exc: + checks.append({"name": "bot.log", "pass": False, "detail": str(exc)}) + + # Regula deny(ssh) a mai taiat o data accesul la infrastructura — vezi + # security/README.md. Verificam sa nu reapara la o editare viitoare. + try: + settings = json.loads(config.SETTINGS_FILE.read_text(encoding="utf-8")) + deny = (settings.get("permissions") or {}).get("deny") or [] + bad = [d for d in deny if d.startswith(("Bash(ssh", "Bash(scp"))] + checks.append({ + "name": "Reguli deny in bot-settings.json", + "pass": not bad, + "detail": f"blocheaza infrastructura: {bad}" if bad else f"{len(deny)} reguli, ssh liber", + }) + except (OSError, ValueError) as exc: + checks.append({"name": "Reguli deny in bot-settings.json", "pass": False, "detail": str(exc)}) + + hook = _BRIDGE / "security" / "confirm_hook.py" + checks.append({ + "name": "Hook de confirmare", + "pass": hook.exists(), + "detail": str(hook) if hook.exists() else "lipseste", + }) + + claude_bin = shutil.which(config.get("CLAUDE_BIN") or "claude") + checks.append({ + "name": "CLI claude", + "pass": bool(claude_bin), + "detail": claude_bin or "nu e in PATH", + }) + + return checks + + +def orphans_report(dry_run: bool = True) -> dict: + """`/cleanup` din Discord, expus si aici. Importul e lenes fiindca modulul + citeste /proc la import-time in unele cai.""" + import cleanup # noqa: PLC0415 + + found = cleanup.find_orphans(read_state()) + results = None + if not dry_run and found: + results = cleanup.kill_orphans(found, dry_run=False) + return { + "orphans": found, + "killed": results, + "report": cleanup.format_report(found, results), + } + + +# ── HTTP ──────────────────────────────────────────────────────────────── +def _parse_cookies(raw: str) -> dict[str, str]: + out: dict[str, str] = {} + for chunk in (raw or "").split(";"): + chunk = chunk.strip() + if "=" in chunk: + k, v = chunk.split("=", 1) + out[k.strip()] = v.strip() + return out + + +class Handler(SimpleHTTPRequestHandler): + server_version = "claude-discord-dashboard" + protocol_version = "HTTP/1.1" + + def __init__(self, *a, **kw): + super().__init__(*a, directory=str(_DASH), **kw) + + # --- utilitare ----------------------------------------------------- + def log_message(self, format, *args): # jurnal compact, o linie # noqa: A002 + sys.stderr.write("%s %s\n" % (self.address_string(), format % args)) + + def send_json(self, payload, status: int = 200, extra_headers: dict | None = None): + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + for k, v in (extra_headers or {}).items(): + self.send_header(k, v) + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + def read_json(self) -> dict: + try: + n = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(n).decode("utf-8") if n > 0 else "" + data = json.loads(raw) if raw else {} + return data if isinstance(data, dict) else {} + except (ValueError, OSError, UnicodeDecodeError): + return {} + + def authed(self) -> bool: + got = _parse_cookies(self.headers.get("Cookie", "")).get(COOKIE_NAME, "") + return bool(got) and secrets.compare_digest(got, dashboard_token()) + + def deny(self): + self.send_json({"error": "neautentificat"}, 401) + + # --- GET ----------------------------------------------------------- + def do_GET(self): + path = urlparse(self.path).path + if path.startswith("/api/"): + if not self.authed(): + return self.deny() + return self.route_get(path) + if path in ("/", "/index.html") and not self.authed(): + self.send_response(302) + self.send_header("Location", "/login.html") + self.send_header("Content-Length", "0") + self.end_headers() + return + if path == "/": + self.path = "/index.html" + return super().do_GET() + + def route_get(self, path: str): + qs = parse_qs(urlparse(self.path).query) + if path == "/api/status": + state = read_state() + return self.send_json({ + "service": unit_info(SERVICE), + "dashboard": unit_info(SELF_SERVICE), + "threads": threads_view(state), + "cost": state.get("cost") or {}, + "cost_cap": float(config.get("COST_CAP_USD_DAY") or 0), + "pending_approvals": len(pending_approvals()), + "now": time.time(), + }) + if path == "/api/logs": + try: + n = min(max(int(qs.get("lines", ["200"])[0]), 1), 2000) + except ValueError: + n = 200 + which = qs.get("file", ["bot"])[0] + target = infra_log() if which == "infra" else bot_log() + if not target.exists(): + return self.send_json({"lines": [f"({target} nu exista)"]}) + r = subprocess.run(["tail", "-n", str(n), str(target)], + capture_output=True, text=True, timeout=15) + return self.send_json({"file": target.name, "lines": r.stdout.splitlines()}) + if path == "/api/approvals": + return self.send_json({"approvals": pending_approvals()}) + if path == "/api/doctor": + return self.send_json({"checks": doctor()}) + if path == "/api/cleanup": + return self.send_json(orphans_report(dry_run=True)) + return self.send_json({"error": "ruta necunoscuta"}, 404) + + # --- POST ---------------------------------------------------------- + def do_POST(self): + path = urlparse(self.path).path + if path == "/api/auth/login": + return self.handle_login() + if path == "/api/auth/logout": + return self.send_json( + {"ok": True}, + extra_headers={"Set-Cookie": f"{COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0"}, + ) + if not self.authed(): + return self.deny() + if path == "/api/service": + return self.handle_service() + if path == "/api/cleanup": + data = self.read_json() + return self.send_json(orphans_report(dry_run=bool(data.get("dry_run", True)))) + if path == "/api/approvals/decide": + return self.handle_decide() + if path == "/api/restart-self": + self.send_json({"ok": True, "message": "dashboard-ul reporneste in 1s"}) + threading.Thread(target=lambda: (time.sleep(1), os._exit(0)), daemon=True).start() + return None + return self.send_json({"error": "ruta necunoscuta"}, 404) + + def handle_login(self): + data = self.read_json() + provided = (data.get("token") or "").strip() + if not provided or not secrets.compare_digest(provided, dashboard_token()): + time.sleep(0.5) # incetineste ghicitul + return self.send_json({"error": "token invalid"}, 401) + cookie = (f"{COOKIE_NAME}={dashboard_token()}; HttpOnly; SameSite=Strict; " + f"Path=/; Max-Age={COOKIE_MAX_AGE}") + return self.send_json({"ok": True}, extra_headers={"Set-Cookie": cookie}) + + def handle_service(self): + """start / stop / restart pe UNITATEA FIXA. Numele nu vine din request.""" + data = self.read_json() + action = str(data.get("action") or "") + if action not in ("start", "stop", "restart"): + return self.send_json({"ok": False, "error": f"actiune necunoscuta: {action}"}, 400) + + if action in ("stop", "restart") and not data.get("force"): + busy = inflight_threads(read_state()) + if busy: + return self.send_json({ + "ok": False, + "error": "tururi in desfasurare", + "inflight": busy, + "hint": "retrimite cu force=true ca sa le intrerupi", + }, 409) + + try: + r = _sysctl(action, SERVICE) + except subprocess.TimeoutExpired: + return self.send_json({"ok": False, "error": "systemctl a depasit timpul"}, 504) + if r.returncode != 0: + return self.send_json({"ok": False, "error": (r.stderr or r.stdout).strip()}, 500) + time.sleep(1.0) # lasa systemd sa actualizeze starea inainte de raspuns + return self.send_json({"ok": True, "action": action, "service": unit_info(SERVICE)}) + + def handle_decide(self): + data = self.read_json() + rid = str(data.get("request_id") or "") + decision = str(data.get("decision") or "") + if decision not in ("allow", "deny"): + return self.send_json({"ok": False, "error": "decizie invalida"}, 400) + if not rid or "/" in rid or ".." in rid: + return self.send_json({"ok": False, "error": "request_id invalid"}, 400) + import importlib # noqa: PLC0415 + approvals = importlib.import_module("security.approvals") + ok = approvals.submit_decision(rid, decision) + return self.send_json({"ok": bool(ok), "request_id": rid, "decision": decision}, + 200 if ok else 404) + + +def main() -> None: + bind = config.get("DASHBOARD_BIND") or "127.0.0.1" + try: + port = int(config.get("DASHBOARD_PORT") or 18790) + except ValueError: + port = 18790 + dashboard_token() # forteaza avertismentul de token la pornire, nu la primul GET + srv = ThreadingHTTPServer((bind, port), Handler) + srv.daemon_threads = True + print(f"dashboard pe http://{bind}:{port} (unitate controlata: {SERVICE})", + file=sys.stderr, flush=True) + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + finally: + srv.server_close() + + +if __name__ == "__main__": + main() diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/claude-discord-dashboard.service b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/claude-discord-dashboard.service new file mode 100644 index 0000000..7830068 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/claude-discord-dashboard.service @@ -0,0 +1,42 @@ +# Unit systemd de UTILIZATOR pentru dashboard-ul de control al puntii Discord. +# +# Se instaleaza in ~/.config/systemd/user/claude-discord-dashboard.service +# (vezi ops/install.sh). Ruleaza ca `claude`, ACELASI utilizator ca puntea — +# de asta poate face `systemctl --user restart claude-discord` fara sudo. +# +# systemctl --user daemon-reload +# systemctl --user enable --now claude-discord-dashboard +# journalctl --user -u claude-discord-dashboard -f + +[Unit] +Description=Dashboard de control pentru puntea Discord (LXC 171 claude-agent) +Documentation=file:///workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md +After=network.target + +[Service] +Type=simple +WorkingDirectory=%h/.claude-discord +EnvironmentFile=%h/.claude-discord/env +Environment=PYTHONUNBUFFERED=1 +# nvm, ca verificarea `CLI claude` din /api/doctor sa vada acelasi PATH ca botul +Environment=PATH=%h/bin:%h/.nvm/versions/node/v20.19.6/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# Stdlib only: nu are nevoie de venv-ul botului. +ExecStart=/usr/bin/python3 /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py + +# NU KillMode=control-group aici: dashboard-ul nu are copii de curatat, iar +# `/api/restart-self` se bazeaza pe iesirea curata + Restart=always. +Restart=always +RestartSec=2 + +# Panou de citit stare, nu proces de lucru: plafon strans, ca sa nu concureze +# cu puntea pentru memoria containerului (istoric de OOM, 2026-06-24). +MemoryHigh=192M +MemoryMax=384M + +StandardOutput=append:%h/.claude-discord/logs/dashboard.log +StandardError=append:%h/.claude-discord/logs/dashboard.log +SyslogIdentifier=claude-discord-dashboard + +[Install] +WantedBy=default.target diff --git a/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html new file mode 100644 index 0000000..c935b74 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html @@ -0,0 +1,273 @@ + + +
+ + +| Fir | Model | Director | Stare | USD |
|---|
se încarcă…+