feat(discord-bridge): dashboard de control si restart, dupa modelul agentului echo

Panou web pe 127.0.0.1:18790, unit systemd separat de al puntii. Server stdlib
(fara dependinte noi), tokenii de design si tiparul de endpoint-uri preluate din
/home/moltbot/echo-core/dashboard (handlers/eco.py) de pe LXC 110.

Arata: starea unitatii (uptime, PID, memoria cgroup, restarturi), firele din
state.json cu tur in zbor si cost, costul zilei fata de plafon, confirmarile
PreToolUse in asteptare (aprobabile direct din pagina), bot.log / infra.log si
opt verificari de diagnostic.

Face: start / stop / restart pe punte, cautarea si curatarea orfanilor prin
cleanup.py, repornirea propriului serviciu.

Garantii, cu teste:
- unitatea controlata e fixa in cod; un {"unit": "ssh.service"} in cerere nu
  schimba nimic, altfel panoul ar fi systemctl remote fara parola;
- stop/restart intorc 409 cu lista firelor active si cer force explicit, fiindca
  KillMode=control-group taie tururile in desfasurare;
- state.json se citeste fara lock: panoul nu are voie sa blocheze botul;
- diagnosticul pica daca reapare Bash(ssh:*) in deny (regresia de azi).

Uptime-ul se calculeaza din time.monotonic(), nu din /proc/uptime: in LXC acela
e virtualizat de lxcfs si da diferenta negativa fata de monotonic-ul systemd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
This commit is contained in:
Claude Agent
2026-08-30 13:17:57 +00:00
parent 3c769b1b53
commit 7abefa2b46
14 changed files with 1716 additions and 2 deletions

View File

@@ -47,6 +47,7 @@ input/ # Oracle DMP files for import
- **Chatbot architecture**: `proxmox/lxc104-flowise/docs/prd.md` - **Chatbot architecture**: `proxmox/lxc104-flowise/docs/prd.md`
- **Docker Sandboxes (sbx) — agenți AI izolați**: `proxmox/lxc102-docker/README.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` - **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` - **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` - **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` - **ROA Windows setup scripts (XE/SE 21c)**: `proxmox/lxc108-oracle/roa-windows-setup/README.md`

View File

@@ -18,6 +18,7 @@
| `scripts/finish-task.sh` | Finalizează task (commit + push) | | `scripts/finish-task.sh` | Finalizează task (commit + push) |
| `scripts/reap-orphans.sh` | Curăță procese vscode-server orfane + sesiuni zombie (cron, anti-OOM) | | `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/` | 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)) |
--- ---

View File

@@ -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` | | 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` | | 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. Fisiere partajate ca *citire*: acest INTERFACES.md. Nimeni nu-l editeaza.

View File

@@ -187,12 +187,22 @@ tail -f ~/.claude-discord/logs/alerts.log # ce alerte s-au trimis / au esu
systemctl --user restart claude-discord # repornire 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 | | Fisier | Ce e |
|--------|------| |--------|------|
| `~/.claude-discord/env` | token + allowlist + limite (0600) | | `~/.claude-discord/env` | token + allowlist + limite (0600) |
| `~/.claude-discord/state.json` | sesiuni, directoare, pid-uri, cost | | `~/.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/bot.log` | stdout/stderr al botului (rotit zilnic, 14 zile) |
| `~/.claude-discord/logs/alerts.log` | jurnalul alertelor | | `~/.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/alerts-dedup.json` | fereastra de dedup a alertelor |
| `~/.claude-discord/approvals/` | cereri de confirmare intre hook si bot | | `~/.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/install.sh` | instalare idempotenta | C |
| `ops/env.example` | sablon de configurare | C | | `ops/env.example` | sablon de configurare | C |
| `ops/logrotate.conf` | rotatia logurilor | 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 | | `INTERFACES.md` | contractul intre module | orchestrator |

View File

@@ -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)`.

View File

@@ -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()

View File

@@ -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

View File

@@ -0,0 +1,273 @@
<!DOCTYPE html>
<html lang="ro" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Punte Discord — control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header class="header">
<span class="logo"><span class="dot" id="dot"></span> Punte Discord</span>
<span class="spacer"></span>
<span class="meta" id="lastPoll">—</span>
<button class="small" onclick="toggleTheme()" title="Schimbă tema">◐</button>
<button class="small" onclick="logout()">Ieși</button>
</header>
<div class="wrap">
<!-- ── 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>
</div>
</section>
<div class="grid-2">
<!-- ── fire ─────────────────────────────────────────────── -->
<section class="card">
<h2>Fire active <span class="count" id="threadCount"></span></h2>
<div class="table-scroll">
<table>
<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>
</section>
<!-- ── diagnostic ───────────────────────────────────────── -->
<section class="card">
<h2>Diagnostic</h2>
<div id="doctor"><div class="empty">se încarcă…</div></div>
</section>
</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>
<!-- ── jurnal ─────────────────────────────────────────────── -->
<section class="card">
<h2>Jurnal
<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>
</div>
<div id="toast"></div>
<script>
// ── tema ──────────────────────────────────────────────────────
(function () {
var saved = localStorage.getItem('theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
})();
function toggleTheme() {
var next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
// ── utilitare ─────────────────────────────────────────────────
function esc(s) {
return String(s === null || s === undefined ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function toast(msg, kind) {
var el = document.createElement('div');
el.className = 'toast ' + (kind || '');
el.textContent = msg;
document.getElementById('toast').appendChild(el);
setTimeout(function () { el.remove(); }, 6000);
}
async function api(path, opts) {
var r = await fetch(path, opts || {});
if (r.status === 401) { location.href = '/login.html'; throw new Error('neautentificat'); }
var data = null;
try { data = await r.json(); } catch (e) { data = {}; }
return { ok: r.ok, status: r.status, data: data };
}
function post(path, body) {
return api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
});
}
function dur(s) {
if (s === null || s === undefined) return '—';
s = Math.max(0, Math.floor(s));
var d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
if (d) return d + 'z ' + h + 'h';
if (h) return h + 'h ' + m + 'm';
if (m) return m + 'm';
return s + 's';
}
function mb(bytes) {
return bytes === null || bytes === undefined ? '—' : (bytes / 1048576).toFixed(0) + ' MB';
}
// ── stare ─────────────────────────────────────────────────────
var busy = false;
async function refresh() {
var res = await api('/api/status');
if (!res.ok) return;
var s = res.data, svcInfo = s.service;
document.getElementById('unitName').textContent = svcInfo.unit;
document.getElementById('dot').className = 'dot ' + (svcInfo.active ? 'on' : 'off');
document.getElementById('lastPoll').textContent = 'actualizat ' + new Date().toLocaleTimeString('ro-RO');
var capTxt = s.cost_cap > 0 ? ' / ' + s.cost_cap.toFixed(2) : '';
document.getElementById('metrics').innerHTML = [
['Stare', '<span class="pill ' + (svcInfo.active ? 'ok' : 'bad') + '">' + esc(svcInfo.state) + '/' + esc(svcInfo.sub) + '</span>'],
['Uptime', dur(svcInfo.uptime_s)],
['PID', svcInfo.pid || '—'],
['Memorie cgroup', mb(svcInfo.memory_bytes)],
['Restarturi', svcInfo.restarts],
['Fire', s.threads.length],
['Cost azi', (Number((s.cost || {}).usd || 0)).toFixed(2) + capTxt + ' USD']
].map(function (m) {
return '<div class="metric"><div class="label">' + m[0] + '</div><div class="value">' + m[1] + '</div></div>';
}).join('');
document.getElementById('btnStart').disabled = busy || svcInfo.active;
document.getElementById('btnStop').disabled = busy || !svcInfo.active;
document.getElementById('btnRestart').disabled = busy;
// fire
document.getElementById('threadCount').textContent = s.threads.length + ' în state.json';
document.getElementById('threads').innerHTML = s.threads.length ? s.threads.map(function (t) {
var st = t.inflight ? '<span class="pill busy">tur în lucru</span>'
: t.alive ? '<span class="pill ok">proces viu</span>'
: '<span class="pill">inactiv</span>';
return '<tr><td class="mono">' + esc(t.thread_id) + '</td><td>' + esc(t.model || '—') +
'</td><td class="mono">' + esc(t.cwd || '—') + '</td><td>' + st +
'</td><td class="num">' + Number(t.cost_usd || 0).toFixed(2) + '</td></tr>';
}).join('') : '<tr><td colspan="5" class="empty">niciun fir înregistrat</td></tr>';
// aprobări
document.getElementById('apprCount').textContent = s.pending_approvals || '';
if (s.pending_approvals) { refreshApprovals(); }
else { document.getElementById('approvals').innerHTML = '<div class="empty">niciuna</div>'; }
}
async function refreshApprovals() {
var res = await api('/api/approvals');
if (!res.ok) return;
var list = res.data.approvals || [];
document.getElementById('approvals').innerHTML = list.length ? list.map(function (a) {
return '<div class="check"><div style="flex:1">' +
'<div class="mono" style="color:var(--text-primary)">' + esc(a.command) + '</div>' +
'<div class="detail">' + esc(a.reason || a.rule || '') +
(a.expires_in !== null && a.expires_in !== undefined ? ' · expiră în ' + dur(a.expires_in) : '') +
' · fir ' + esc(a.thread_id) + '</div></div>' +
'<button class="small primary" onclick="decide(\'' + esc(a.request_id) + '\',\'allow\')">Permite</button> ' +
'<button class="small danger" onclick="decide(\'' + esc(a.request_id) + '\',\'deny\')">Refuză</button></div>';
}).join('') : '<div class="empty">niciuna</div>';
}
async function decide(id, d) {
var res = await post('/api/approvals/decide', { request_id: id, decision: d });
toast(res.ok ? 'Trimis: ' + d : ('Eșuat: ' + (res.data.error || res.status)), res.ok ? 'ok' : 'bad');
refresh();
}
async function refreshDoctor() {
var res = await api('/api/doctor');
if (!res.ok) return;
document.getElementById('doctor').innerHTML = (res.data.checks || []).map(function (c) {
return '<div class="check ' + (c.pass ? 'pass' : 'fail') + '"><span class="mark">' +
(c.pass ? '✓' : '✗') + '</span><span class="name">' + esc(c.name) +
'</span><span class="detail">' + esc(c.detail) + '</span></div>';
}).join('');
}
// ── acțiuni ───────────────────────────────────────────────────
async function svc(action, force) {
if (busy) return;
if (action !== 'start' && !force &&
!confirm('Sigur „' + action + '” pe serviciul punții?')) return;
busy = true;
try {
var res = await post('/api/service', { action: action, force: !!force });
if (res.status === 409) {
var n = (res.data.inflight || []).length;
if (confirm(n + ' fir(e) au tur în desfășurare. Le întrerupi?')) {
busy = false;
return svc(action, true);
}
toast('Anulat — tururile continuă.', '');
} else if (res.ok) {
toast('Serviciu: ' + action + ' OK', 'ok');
} else {
toast('Eșuat: ' + (res.data.error || res.status), 'bad');
}
} finally {
busy = false;
refresh();
refreshDoctor();
}
}
async function cleanup(dry) {
if (!dry && !confirm('Omor procesele orfane găsite?')) return;
var res = await post('/api/cleanup', { dry_run: dry });
if (!res.ok) { toast('Eșuat: ' + (res.data.error || res.status), 'bad'); return; }
var n = (res.data.orphans || []).length;
toast(dry ? (n + ' orfan(i) găsiți') : (n + ' orfan(i) tratați'), n ? 'bad' : 'ok');
document.getElementById('log').textContent = res.data.report || '(fără raport)';
}
async function restartSelf() {
if (!confirm('Repornesc dashboard-ul? Pagina se reîncarcă în câteva secunde.')) return;
await post('/api/restart-self', {});
toast('Dashboard-ul repornește…', '');
setTimeout(function () { location.reload(); }, 4000);
}
async function logout() {
await post('/api/auth/logout', {});
location.href = '/login.html';
}
// ── jurnal ────────────────────────────────────────────────────
var logFile = 'bot';
function setLog(which) { logFile = which; refreshLogs(); }
async function refreshLogs() {
document.getElementById('tabBot').className = 'small' + (logFile === 'bot' ? ' primary' : '');
document.getElementById('tabInfra').className = 'small' + (logFile === 'infra' ? ' primary' : '');
var res = await api('/api/logs?lines=300&file=' + logFile);
if (!res.ok) return;
var el = document.getElementById('log');
el.textContent = (res.data.lines || []).join('\n') || '(gol)';
el.scrollTop = el.scrollHeight;
}
// ── pornire ───────────────────────────────────────────────────
refresh(); refreshDoctor(); refreshLogs();
setInterval(refresh, 5000);
setInterval(refreshDoctor, 30000);
</script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="ro" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Autentificare — Punte Discord</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="login">
<form id="f" autocomplete="off">
<h1>Punte Discord → Claude Code</h1>
<p>Token din <span class="mono">~/.claude-discord/env</span> (<span class="mono">DASHBOARD_TOKEN</span>).</p>
<input type="password" id="token" placeholder="token" autofocus required>
<div class="error-msg" id="err"></div>
<button class="primary" type="submit">Intră</button>
</form>
</div>
<script>
(function () {
var saved = localStorage.getItem('theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
})();
document.getElementById('f').addEventListener('submit', async function (e) {
e.preventDefault();
var err = document.getElementById('err');
err.textContent = '';
try {
var r = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: document.getElementById('token').value })
});
if (r.ok) { location.href = '/'; return; }
err.textContent = 'Token invalid.';
} catch (ex) {
err.textContent = 'Serverul nu răspunde.';
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,258 @@
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg-base);
color: var(--text-secondary);
font-family: var(--font-sans);
font-size: var(--text-sm);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); }
/* ── antet ─────────────────────────────────────────────────────────── */
.header {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-4) var(--space-6);
background: var(--header-bg);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.logo {
display: flex;
align-items: center;
gap: var(--space-2);
font-weight: 700;
font-size: var(--text-lg);
color: var(--text-primary);
text-decoration: none;
}
.logo .dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--text-muted);
box-shadow: 0 0 0 4px var(--bg-surface);
}
.logo .dot.on { background: var(--success); }
.logo .dot.off { background: var(--error); }
.header .spacer { flex: 1; }
.header .meta { color: var(--text-muted); font-size: var(--text-xs); }
/* ── structura ─────────────────────────────────────────────────────── */
.wrap {
max-width: 1100px;
margin: 0 auto;
padding: var(--space-6);
display: grid;
gap: var(--space-6);
}
.grid-2 {
display: grid;
gap: var(--space-6);
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-5);
}
.card > h2 {
margin: 0 0 var(--space-4);
font-size: var(--text-base);
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.card > h2 .count {
font-weight: 400;
font-size: var(--text-xs);
color: var(--text-muted);
}
/* ── metrici ───────────────────────────────────────────────────────── */
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--space-3);
}
.metric {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
}
.metric .label {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metric .value {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
/* ── pastile de stare ──────────────────────────────────────────────── */
.pill {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 2px var(--space-3);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-secondary);
}
.pill.ok { color: var(--success); border-color: rgba(34, 197, 94, 0.5); }
.pill.bad { color: var(--error); border-color: rgba(239, 68, 68, 0.5); }
.pill.warn { color: var(--warning); border-color: rgba(234, 179, 8, 0.5); }
.pill.busy { color: var(--accent); border-color: var(--border-focus); }
/* ── butoane ───────────────────────────────────────────────────────── */
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
button {
font: inherit;
font-weight: 600;
color: var(--text-primary);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-2) var(--space-4);
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
button:hover:not(:disabled) { background: var(--bg-surface-hover); border-color: var(--border-focus); }
button:active:not(:disabled) { background: var(--bg-surface-active); }
button:disabled { opacity: 0.45; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
button.danger { color: var(--error); border-color: rgba(239, 68, 68, 0.5); }
button.small { padding: var(--space-1) var(--space-3); font-size: var(--text-xs); }
/* ── tabele ────────────────────────────────────────────────────────── */
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
th {
text-align: left;
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
font-weight: 600;
padding: 0 var(--space-3) var(--space-2);
white-space: nowrap;
}
td {
padding: var(--space-3);
border-top: 1px solid var(--border);
vertical-align: middle;
}
td.mono, .mono { font-family: var(--font-mono); font-size: var(--text-xs); }
td.num { font-variant-numeric: tabular-nums; text-align: right; }
.empty {
color: var(--text-muted);
font-style: italic;
padding: var(--space-4) 0;
}
/* ── verificari ────────────────────────────────────────────────────── */
.check {
display: flex;
align-items: baseline;
gap: var(--space-3);
padding: var(--space-2) 0;
border-top: 1px solid var(--border);
}
.check:first-child { border-top: 0; }
.check .name { font-weight: 500; color: var(--text-primary); min-width: 200px; }
.check .detail { color: var(--text-muted); font-size: var(--text-xs); }
.check .mark { font-weight: 700; }
.check.pass .mark { color: var(--success); }
.check.fail .mark { color: var(--error); }
/* ── jurnal ────────────────────────────────────────────────────────── */
pre.log {
margin: 0;
max-height: 420px;
overflow: auto;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-4);
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.6;
white-space: pre;
color: var(--text-secondary);
}
:root[data-theme="light"] pre.log { background: rgba(0, 0, 0, 0.04); }
/* ── notificari ────────────────────────────────────────────────────── */
#toast {
position: fixed;
right: var(--space-6);
bottom: var(--space-6);
z-index: 100;
display: grid;
gap: var(--space-2);
max-width: 380px;
}
.toast {
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
font-size: var(--text-sm);
}
.toast.ok { border-left-color: var(--success); }
.toast.bad { border-left-color: var(--error); }
/* ── autentificare ─────────────────────────────────────────────────── */
.login {
min-height: 100vh;
display: grid;
place-items: center;
padding: var(--space-6);
}
.login form {
width: min(380px, 100%);
display: grid;
gap: var(--space-4);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-8);
}
.login h1 { margin: 0; font-size: var(--text-xl); color: var(--text-primary); }
.login p { margin: 0; color: var(--text-muted); font-size: var(--text-xs); }
input[type="password"] {
font: inherit;
font-family: var(--font-mono);
width: 100%;
padding: var(--space-3);
color: var(--text-primary);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
input[type="password"]:focus { outline: none; border-color: var(--border-focus); }
.error-msg { color: var(--error); font-size: var(--text-xs); min-height: 1em; }

View File

@@ -0,0 +1,69 @@
/* Tokenii de design ai dashboard-ului puntii Discord.
Aceleasi valori ca in echo-core/dashboard/DESIGN.md, ca cele doua panouri sa
arate ca facute de aceeasi mana. Tema implicita: intunecata. */
:root {
/* suprafete */
--bg-base: #13131a;
--bg-surface: rgba(255, 255, 255, 0.12);
--bg-surface-hover: rgba(255, 255, 255, 0.16);
--bg-surface-active: rgba(255, 255, 255, 0.20);
--bg-elevated: rgba(255, 255, 255, 0.14);
--header-bg: rgba(19, 19, 26, 0.95);
/* text */
--text-primary: #ffffff;
--text-secondary: #f5f5f5;
--text-muted: #b9b9c2;
/* accent + contur */
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-subtle: rgba(59, 130, 246, 0.2);
--border: rgba(255, 255, 255, 0.3);
--border-focus: rgba(59, 130, 246, 0.7);
/* semantice */
--success: #22c55e;
--warning: #eab308;
--error: #ef4444;
/* tipografie */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
/* grila de 8px */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
}
:root[data-theme="light"] {
--bg-base: #f6f7f9;
--bg-surface: rgba(0, 0, 0, 0.045);
--bg-surface-hover: rgba(0, 0, 0, 0.07);
--bg-surface-active: rgba(0, 0, 0, 0.1);
--bg-elevated: #ffffff;
--header-bg: rgba(246, 247, 249, 0.95);
--text-primary: #14141a;
--text-secondary: #2c2c36;
--text-muted: #5c5c6b;
--border: rgba(0, 0, 0, 0.16);
--border-focus: rgba(59, 130, 246, 0.6);
}

View File

@@ -52,3 +52,11 @@ ALERT_RECIPIENT=root
# face din Discord sa aiba istoric separat de proiectele reale. `/cd <cale>` il # face din Discord sa aiba istoric separat de proiectele reale. `/cd <cale>` il
# schimba per fir, oriunde in /workspace. # schimba per fir, oriunde in /workspace.
DEFAULT_CWD=/workspace/claude-agent DEFAULT_CWD=/workspace/claude-agent
# --- dashboard de control (dashboard/api.py) --------------------------------
# Token de acces. Generat automat de ops/install.sh daca lipseste.
DASHBOARD_TOKEN=
# Implicit 127.0.0.1: butoanele opresc un agent cu bypassPermissions si chei SSH
# spre tot clusterul, deci accesul se face prin tunel SSH, nu expus in LAN.
DASHBOARD_BIND=127.0.0.1
DASHBOARD_PORT=18790

View File

@@ -12,7 +12,7 @@
# 2. env 0600 din ops/env.example (doar daca lipseste) # 2. env 0600 din ops/env.example (doar daca lipseste)
# 3. venv + dependinte din requirements.txt # 3. venv + dependinte din requirements.txt
# 4. loginctl enable-linger (serviciul de utilizator trebuie sa supravietuiasca logout-ului) # 4. loginctl enable-linger (serviciul de utilizator trebuie sa supravietuiasca logout-ului)
# 5. symlink unit -> ~/.config/systemd/user/claude-discord.service # 5. symlink-uri unit -> ~/.config/systemd/user/ (puntea + dashboard-ul de control)
# 6. intrare de crontab pentru logrotate # 6. intrare de crontab pentru logrotate
# 7. systemd-analyze verify + enable # 7. systemd-analyze verify + enable
set -uo pipefail set -uo pipefail
@@ -22,6 +22,7 @@ SRC="$(dirname "$HERE")" # .../discord-bridge
STATE_DIR="$HOME/.claude-discord" STATE_DIR="$HOME/.claude-discord"
UNIT_DIR="$HOME/.config/systemd/user" UNIT_DIR="$HOME/.config/systemd/user"
UNIT_NAME="claude-discord.service" UNIT_NAME="claude-discord.service"
DASH_UNIT="claude-discord-dashboard.service"
DO_START=0 DO_START=0
[ "${1:-}" = "--start" ] && DO_START=1 [ "${1:-}" = "--start" ] && DO_START=1
@@ -95,6 +96,22 @@ ln -sfn "$HERE/$UNIT_NAME" "$UNIT_DIR/$UNIT_NAME"
info "unit legat: $UNIT_DIR/$UNIT_NAME -> $HERE/$UNIT_NAME" info "unit legat: $UNIT_DIR/$UNIT_NAME -> $HERE/$UNIT_NAME"
systemctl --user daemon-reload 2>/dev/null || warn "daemon-reload a esuat (sesiune fara systemd de utilizator?)" systemctl --user daemon-reload 2>/dev/null || warn "daemon-reload a esuat (sesiune fara systemd de utilizator?)"
# Dashboard-ul de control (dashboard/api.py): unit separat, ca o repornire a
# puntii sa nu ia si panoul din care ai apasat butonul.
ln -sfn "$SRC/dashboard/$DASH_UNIT" "$UNIT_DIR/$DASH_UNIT"
info "unit legat: $UNIT_DIR/$DASH_UNIT -> $SRC/dashboard/$DASH_UNIT"
# Tokenul dashboard-ului: generat o singura data, niciodata rescris.
if grep -qE '^DASHBOARD_TOKEN=.+' "$STATE_DIR/env" 2>/dev/null; then
info "DASHBOARD_TOKEN exista deja in env"
else
printf 'DASHBOARD_TOKEN=%s\n' "$(python3 -c 'import secrets;print(secrets.token_urlsafe(24))')" \
>> "$STATE_DIR/env"
info "DASHBOARD_TOKEN generat si adaugat in $STATE_DIR/env"
fi
systemctl --user daemon-reload 2>/dev/null || true
if systemd-analyze verify "$UNIT_DIR/$UNIT_NAME" 2>&1 | grep -vE 'Unknown key|^$' | grep -q .; then if systemd-analyze verify "$UNIT_DIR/$UNIT_NAME" 2>&1 | grep -vE 'Unknown key|^$' | grep -q .; then
systemd-analyze verify "$UNIT_DIR/$UNIT_NAME" 2>&1 | sed 's/^/ /' systemd-analyze verify "$UNIT_DIR/$UNIT_NAME" 2>&1 | sed 's/^/ /'
warn "systemd-analyze verify a raportat probleme (vezi mai sus)" warn "systemd-analyze verify a raportat probleme (vezi mai sus)"
@@ -132,6 +149,9 @@ fi
systemctl --user enable "$UNIT_NAME" >/dev/null 2>&1 \ systemctl --user enable "$UNIT_NAME" >/dev/null 2>&1 \
&& info "serviciu enabled (porneste la boot)" \ && info "serviciu enabled (porneste la boot)" \
|| warn "enable a esuat" || warn "enable a esuat"
systemctl --user enable --now "$DASH_UNIT" >/dev/null 2>&1 \
&& info "dashboard enabled + pornit pe 127.0.0.1:${DASHBOARD_PORT:-18790}" \
|| warn "enable pentru $DASH_UNIT a esuat"
if [ "$DO_START" -eq 1 ]; then if [ "$DO_START" -eq 1 ]; then
if [ ! -f "$SRC/bot.py" ]; then if [ ! -f "$SRC/bot.py" ]; then

View File

@@ -0,0 +1,336 @@
"""Teste pentru dashboard-ul de control (dashboard/api.py).
Zero retea catre exterior si zero systemctl real: `_sysctl` e inlocuit in fiecare
test cu un dublu care inregistreaza argumentele. Serverul HTTP porneste pe un port
efemer legat de 127.0.0.1, exact cum ruleaza in productie.
"""
from __future__ import annotations
import json
import pathlib
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer
import pytest
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "dashboard"))
import config # noqa: E402
api = pytest.importorskip("api", reason="dashboard/api.py")
# --- ajutoare ---------------------------------------------------------------
def _cp(stdout: str = "", rc: int = 0, stderr: str = "") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(args=[], returncode=rc, stdout=stdout, stderr=stderr)
@pytest.fixture()
def systemctl_fals(monkeypatch):
"""Inlocuieste systemctl. `apeluri` retine ce s-ar fi executat."""
apeluri: list[tuple[str, ...]] = []
props = {
"ActiveState": "active",
"SubState": "running",
"UnitFileState": "enabled",
"MainPID": "4242",
"MemoryCurrent": "1048576",
"NRestarts": "3",
"ActiveEnterTimestampMonotonic": "0",
"ActiveEnterTimestamp": "",
}
def fake(*args, timeout=30.0):
apeluri.append(tuple(args))
if args[0] == "show":
return _cp(props.get(args[2], ""))
return _cp("")
monkeypatch.setattr(api, "_sysctl", fake)
fake.apeluri = apeluri # type: ignore[attr-defined]
fake.props = props # type: ignore[attr-defined]
return fake
@pytest.fixture()
def server(state_dir, systemctl_fals, monkeypatch):
"""Dashboard-ul pe un port efemer + un client mic cu cookie."""
(state_dir / "env").write_text("DASHBOARD_TOKEN=secret-de-test\n", encoding="utf-8")
config.reload(state_dir)
api.reset_token_cache()
srv = ThreadingHTTPServer(("127.0.0.1", 0), api.Handler)
srv.daemon_threads = True
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_address[1]}"
class Client:
def __init__(self):
self.cookie = ""
def call(self, path, data=None, method=None):
req = urllib.request.Request(
base + path,
data=json.dumps(data).encode() if data is not None else None,
method=method or ("POST" if data is not None else "GET"),
)
req.add_header("Content-Type", "application/json")
if self.cookie:
req.add_header("Cookie", self.cookie)
def _json(body: str):
"""Rutele /api/ intorc JSON; paginile intorc HTML — nu esuam pe ele."""
try:
return json.loads(body) if body else {}
except ValueError:
return {"_html": body}
try:
with urllib.request.urlopen(req, timeout=10) as r:
sc = r.headers.get("Set-Cookie")
if sc:
self.cookie = sc.split(";", 1)[0]
return r.status, _json(r.read().decode()), r
except urllib.error.HTTPError as e:
return e.code, _json(e.read().decode()), e
def login(self, token="secret-de-test"):
return self.call("/api/auth/login", {"token": token})
try:
yield Client()
finally:
srv.shutdown()
srv.server_close()
api.reset_token_cache()
def _scrie_stare(state_dir, threads: dict, cost: float = 1.5):
(state_dir / "state.json").write_text(json.dumps({
"version": 1,
"threads": threads,
"cost": {"day": "2026-08-30", "usd": cost},
}), encoding="utf-8")
# --- autentificare ----------------------------------------------------------
def test_api_fara_cookie_da_401(server):
assert server.call("/api/status")[0] == 401
def test_index_fara_cookie_redirectioneaza_la_login(server):
status, data, resp = server.call("/")
# urllib urmareste redirectul singur: ajungem pe pagina de login, nu pe index
assert status == 200
assert resp.url.endswith("/login.html")
assert "DASHBOARD_TOKEN" in data["_html"]
def test_token_gresit_e_refuzat(server):
assert server.login("gresit")[0] == 401
assert server.call("/api/status")[0] == 401
def test_login_apoi_status(server):
assert server.login()[0] == 200
status, data, _ = server.call("/api/status")
assert status == 200
assert data["service"]["unit"] == api.SERVICE
assert data["service"]["restarts"] == 3
assert data["service"]["memory_bytes"] == 1048576
def test_logout_invalideaza_cookie(server):
server.login()
server.call("/api/auth/logout", {})
server.cookie = "dashboard="
assert server.call("/api/status")[0] == 401
def test_token_lipsa_din_env_nu_deschide_dashboardul(state_dir, monkeypatch):
"""Fara DASHBOARD_TOKEN se genereaza unul aleator, nu se sare peste auth."""
(state_dir / "env").write_text("", encoding="utf-8")
config.reload(state_dir)
api.reset_token_cache()
try:
tok = api.dashboard_token()
assert len(tok) >= 20
assert api.dashboard_token() == tok # stabil in cadrul procesului
finally:
api.reset_token_cache()
# --- control de serviciu ----------------------------------------------------
def test_actiune_necunoscuta_e_respinsa(server):
server.login()
status, data, _ = server.call("/api/service", {"action": "mask"})
assert status == 400
assert not data["ok"]
def test_nu_se_poate_alege_unitatea_din_request(server, systemctl_fals):
"""Chiar daca cererea cere alt unit, se actioneaza tot pe puntea Discord."""
server.login()
server.call("/api/service", {"action": "restart", "service": "ssh.service",
"unit": "ssh.service"})
actiuni = [a for a in systemctl_fals.apeluri if a[0] == "restart"]
assert actiuni == [("restart", api.SERVICE)]
def test_restart_blocat_de_tur_in_zbor(server, state_dir, systemctl_fals):
server.login()
_scrie_stare(state_dir, {"111": {"inflight": {"turn_id": "t1", "started_at": 1.0}}})
status, data, _ = server.call("/api/service", {"action": "restart"})
assert status == 409
assert data["inflight"] == ["111"]
assert not [a for a in systemctl_fals.apeluri if a[0] == "restart"]
def test_restart_cu_force_trece_peste_tur(server, state_dir, systemctl_fals):
server.login()
_scrie_stare(state_dir, {"111": {"inflight": {"turn_id": "t1", "started_at": 1.0}}})
status, data, _ = server.call("/api/service", {"action": "restart", "force": True})
assert status == 200 and data["ok"]
assert ("restart", api.SERVICE) in systemctl_fals.apeluri
def test_start_nu_cere_force(server, state_dir, systemctl_fals):
"""`start` nu poate intrerupe nimic, deci nu are de ce sa fie blocat."""
server.login()
_scrie_stare(state_dir, {"111": {"inflight": {"turn_id": "t1", "started_at": 1.0}}})
assert server.call("/api/service", {"action": "start"})[0] == 200
def test_esecul_systemctl_ajunge_la_client(server, monkeypatch):
server.login()
monkeypatch.setattr(api, "_sysctl",
lambda *a, timeout=30.0: _cp(rc=1, stderr="Unit not found"))
status, data, _ = server.call("/api/service", {"action": "restart"})
assert status == 500 and "Unit not found" in data["error"]
# --- stare, jurnale, diagnostic --------------------------------------------
def test_threads_view_marcheaza_tur_in_zbor(state_dir):
_scrie_stare(state_dir, {
"a": {"cwd": "/workspace/x", "model": "sonnet", "cost_usd_total": 2.5,
"last_active": 10, "inflight": {"turn_id": "t"}},
"b": {"cwd": "/workspace/y", "model": "opus", "last_active": 20},
})
view = api.threads_view(api.read_state())
assert [t["thread_id"] for t in view] == ["b", "a"] # sortare desc dupa activitate
assert view[1]["inflight"] and not view[0]["inflight"]
assert view[1]["cost_usd"] == 2.5
def test_state_corupt_nu_arunca(state_dir):
(state_dir / "state.json").write_text("{ nu e json", encoding="utf-8")
assert api.read_state() == {}
assert api.threads_view(api.read_state()) == []
def test_logs_taie_si_plafoneaza(server, state_dir):
server.login()
(config.LOG_DIR).mkdir(parents=True, exist_ok=True)
api.bot_log().write_text("\n".join(f"linia {i}" for i in range(500)), encoding="utf-8")
_, data, _ = server.call("/api/logs?lines=5")
assert data["lines"] == [f"linia {i}" for i in range(495, 500)]
def test_logs_fisier_inexistent(server):
server.login()
_, data, _ = server.call("/api/logs?file=infra")
assert "nu exista" in data["lines"][0]
def test_doctor_prinde_deny_ul_care_taie_ssh(server, state_dir):
"""Regresia din 2026-08-30: Bash(ssh:*) in deny a taiat accesul la infra."""
server.login()
(state_dir / "bot-settings.json").write_text(json.dumps(
{"permissions": {"deny": ["Bash(ssh:*)", "Bash(qm destroy:*)"]}}), encoding="utf-8")
_, data, _ = server.call("/api/doctor")
check = next(c for c in data["checks"] if "deny" in c["name"])
assert not check["pass"] and "Bash(ssh:*)" in check["detail"]
def test_doctor_accepta_deny_ul_curatat(server, state_dir):
server.login()
(state_dir / "bot-settings.json").write_text(json.dumps(
{"permissions": {"deny": ["Bash(qm destroy:*)"]}}), encoding="utf-8")
_, data, _ = server.call("/api/doctor")
check = next(c for c in data["checks"] if "deny" in c["name"])
assert check["pass"]
# --- aprobari ---------------------------------------------------------------
def _cerere(state_dir, rid="abc123", status="pending"):
config.APPROVALS_DIR.mkdir(parents=True, exist_ok=True)
(config.APPROVALS_DIR / f"{rid}.json").write_text(json.dumps({
"request_id": rid, "thread_id": "111", "tool_name": "Bash",
"command": "rm -rf /var/tmp/x", "rule": "rm_recursiv",
"reason": "stergere recursiva", "created_at": time.time(),
"expires_at": time.time() + 300, "status": status,
}), encoding="utf-8")
def test_approvals_arata_doar_pending(server, state_dir):
server.login()
_cerere(state_dir, "aaa", "pending")
_cerere(state_dir, "bbb", "allow")
_, data, _ = server.call("/api/approvals")
assert [a["request_id"] for a in data["approvals"]] == ["aaa"]
assert data["approvals"][0]["expires_in"] > 0
def test_status_numara_aprobarile(server, state_dir):
server.login()
_cerere(state_dir, "aaa")
_, data, _ = server.call("/api/status")
assert data["pending_approvals"] == 1
def test_decizie_invalida_e_respinsa(server):
server.login()
assert server.call("/api/approvals/decide",
{"request_id": "aaa", "decision": "poate"})[0] == 400
def test_request_id_cu_traversare_e_respins(server):
server.login()
assert server.call("/api/approvals/decide",
{"request_id": "../../etc/passwd", "decision": "allow"})[0] == 400
def test_decizia_ajunge_in_fisierul_cererii(server, state_dir):
server.login()
_cerere(state_dir, "aaa")
status, data, _ = server.call("/api/approvals/decide",
{"request_id": "aaa", "decision": "allow"})
assert status == 200 and data["ok"]
scris = json.loads((config.APPROVALS_DIR / "aaa.json").read_text())
assert scris["status"] == "allow"
def test_cerere_inexistenta_da_404(server):
server.login()
assert server.call("/api/approvals/decide",
{"request_id": "nuexista", "decision": "allow"})[0] == 404
# --- rute -------------------------------------------------------------------
def test_ruta_necunoscuta(server):
server.login()
assert server.call("/api/nope")[0] == 404
assert server.call("/api/nope", {})[0] == 404