# 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`. ``` https://claude-agent.tailf7372d.ts.net/claude <- din tailnet, ca /echo la moltbot http://127.0.0.1:18790 <- local / prin tunel SSH ``` ## 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. *Permite (tot firul)* memoreaza tiparul si nu mai intreaba in firul respectiv (vezi [`../security/README.md`](../security/README.md)) | | **Jurnal** | ultimele 300 de linii din `bot.log` sau `infra.log` | Starea se reimprospateaza automat la 5 secunde. ## Pe telefon Panoul e gandit sa fie folosit de pe telefon (de acolo vine si comanda in Discord), deci sub 640px se schimba trei lucruri: - **tabelul de fire devine carduri stivuite** — capul de tabel dispare, iar eticheta fiecarei celule vine din `data-label`; un tabel de 5 coloane pe 390px ar fi insemnat derulare orizontala la fiecare rand; - **butoanele stau doua pe rand, cu tinta de atins de 44px** (sub 380px, unul pe rand); - **verificarile si confirmarile se aseaza pe verticala**, iar comenzile lungi se rup in loc sa impinga pagina. Nimic nu iese din viewport pe orizontala: jurnalul si tabelele deruleaza in propriul container. Capcana care rupsese pagina (2621px latime pe un ecran de 390px) era `min-width: auto` pe copiii de grid — liniile lungi din `pre.log` dilatau coloana, si odata cu ea toata pagina; de aceea exista `min-width: 0` explicit in `static/app.css`. ## 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` — sau nimic, cand `DASHBOARD_AUTH=off` (vezi mai jos). | 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\|allow_session\|deny"}` | | POST | `/api/restart-self` | reporneste dashboard-ul | | POST | `/api/auth/login` / `/api/auth/logout` | `{"token": "..."}` / sterge cookie-ul (inutile cu `DASHBOARD_AUTH=off`) | 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 din neatentie. ### Fara token (`DASHBOARD_AUTH=off`) Configuratia curenta pe LXC 171. Nu mai exista login: `/login.html` duce inapoi la panou, iar butonul „Ieși" dispare. Se sprijina pe doua lucruri, si **nu are sens fara ele**: - serviciul e legat de `127.0.0.1`, deci din retea ajunge la el doar `tailscaled`; - `tailscale serve` publica panoul `tainet only`, iar accesul in tailnet e deja autentificat de Tailscale. Ce se pierde: orice dispozitiv din tailnet, si orice proces din container care ajunge la port, poate opri sau reporni puntea. Ce ramane: fiecare start/stop/restart se scrie in `logs/dashboard.log` cu identitatea din antetul `Tailscale-User-Login` pus de `tailscale serve` (`local` cand cererea vine de pe 127.0.0.1). Antetul e **doar pentru jurnal** — nu decide accesul, fiindca un proces local l-ar putea fabrica. Inapoi la token: scoate `DASHBOARD_AUTH=off` din `~/.claude-discord/env` si reporneste serviciul. Doar `off`, `none`, `0` si `false` scot login-ul; orice alta valoare il pastreaza. ## Tab Infrastructură Al treilea tab (lângă Punte și Maria), aceeași logică folosită și de comanda Discord `/infra` — un singur registru de acțiuni, `infra_actions.py`, cu doi consumatori. ### Registrul de acțiuni — `infra_actions.py` `ACTIONS: dict[str, dict]` e sursa unică de adevăr: id, `label`/`desc`, `host` (`local`/`pvemini`/`pveelite`/`vm109`/`oracle-prod-admin`), `cmd`/`dry` (comanda reală și, dacă există, varianta `--dry-run`), `job` (True → lansată prin `systemd-run`, detașată; False → ssh sincron, timeout 120s), `confirm` (cere confirmare explicită în UI/Discord) și `pre` (text descriptiv al precondiției). Adăugarea unei acțiuni noi înseamnă o singură intrare aici — dashboard-ul și botul o preiau automat (dashboard prin id, bot prin `app_commands.choices` generat din `ACTIONS`). Funcțiile publice: `status(fresh=False)` (stare cluster, cache 30s), `check(id, st, dry_run)` (precondiții, funcție pură), `run(id, dry_run)`, `stop(id)`, `log_tail(id, n=60)`, `maintenance_pending(st)`, `post_startup_instructions()`. ### Rute dashboard | Metodă | Rută | Ce face | |---|---|---| | GET | `/api/infra/status?fresh=1` | `infra_actions.status()`; `fresh=1` ocolește cache-ul de 30s | | GET | `/api/infra/log?id=&n=` | ultimele linii din jurnalul acțiunii (`log_tail`) | | POST | `/api/infra/run` | `{"id": "...", "dry_run": bool}` → `infra_actions.run()` | | POST | `/api/infra/stop` | `{"id": "..."}` → oprește job-ul systemd-run al acțiunii | Fiecare `run`/`stop` scrie o linie în `infra.log` (`[actiune] infra dry=, cerut de `) — identitatea vine din antetul Tailscale, ca la restul dashboard-ului. **Citirea de stare ocolește `security/infra`** (ssh direct, read-only, ca să nu umple jurnalul de acțiuni la fiecare 30 de secunde); **acțiunile** trec toate prin `security/infra ""`, deci apar în `infra.log` cu jurnalizarea obișnuită a wrapper-ului. ### Joburi lungi — `systemd-run` pe nod, nu tmux tmux nu e instalat pe niciun nod Proxmox, așa că o acțiune cu `job: True` pornește prin `security/infra "systemd-run --unit=infra- --collect ... /bin/bash -c ''"` — detașată de conexiunea ssh, cu stdout/stderr redirectate în `/var/log/infra-actions/-.log`. `status()` listează job-urile active (`systemctl list-units 'infra-*'`) și jurnalele recente din acel director; tab-ul Infra le arată într-un banner cu buton Jurnal (poll la 3s) și Oprește (`systemctl stop infra-`). ### Precondiții Fiecare acțiune reală (nu `stare`/`sarcini`) are o precondiție verificată de `check()` înainte de executare — de exemplu `oprire` cere cele 3 noduri online + cvorum + niciun job activ; `backup-pe-pvemini` cere pveelite offline și failover-ul încă neactiv. Un `run()` cu precondiția nefăcută întoarce `412` fără să atingă rețeaua. `status()` include deja rezultatul `check()` pentru fiecare id (`actions: {id: {ok, reason}}`), ca butoanele să se dezactiveze direct din prima cerere de stare. ### Fără oprire UPS reală Acțiunea `ups-simulare` rulează scriptul de shutdown la panică UPS cu `--dry-run --force` — nimic nu se oprește cu adevărat. Nu există în registru nicio acțiune care să declanșeze un test de baterie cu oprire efectivă a clusterului; `ups-test` (testul lunar) descarcă bateria controlat, dar nu oprește noduri. ### Permisiunea Discord „Pin Messages" La `oprire` (nu în dry-run), `post_startup_instructions()` postează mesajul cu instrucțiunile de pornire în primul canal configurat (#claude-agent) și îl fixează (pin). Permisiunea `Pin Messages` pe canal a fost dată botului pe 2026-09-13 și e verificată (post → pin → unpin → ștergere, toate 204). Dacă permisiunea se pierde, mesajul se postează oricum, iar pin-ul întoarce 403 și apare ca `warning` în răspunsul acțiunii. ### 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`), `DASHBOARD_PREFIX` (implicit gol; `/claude` cand e publicat prin `tailscale serve --set-path`), `DASHBOARD_AUTH` (`off` scoate login-ul). ## Acces ### Prin Tailscale (recomandat) Exact tiparul de la echo (`https://moltbot.tailf7372d.ts.net/echo/`): procesul ramane legat de `127.0.0.1`, iar `tailscaled` e singurul care ajunge la el si il publica in tailnet, cu HTTPS si certificat de la Tailscale. ```bash sudo tailscale serve --bg --set-path /claude http://127.0.0.1:18790 tailscale serve status ``` ``` https://claude-agent.tailf7372d.ts.net/claude ``` Vizibil doar in tailnet (`tailnet only`) — nu e `funnel`, deci nu iese in internet. Configuratia e persistata de `tailscaled`, deci supravietuieste repornirilor. **Montarea sub prefix**, cele doua capcane si cum sunt rezolvate: - `tailscale serve --set-path` **taie** prefixul inainte de a proxa, deci serverul vede `/api/status`, nu `/claude/api/status`. URL-urile din pagini sunt relative, iar `` se pune **la servire**, din `DASHBOARD_PREFIX`. Prefixul e acceptat si intact pe intrare, deci `curl` direct pe localhost cu `/claude/...` functioneaza la fel. - adresa **fara slash final** (`/claude`) ajunge la server tot ca `/`, exact ca `/claude/`: proxy-ul taie prefixul si nu lasa nicio urma a formei originale. Fara ``, `api/status` s-ar rezolva atunci la radacina hostului, unde proxy-ul nu mai trimite nimic incoace — pagina se incarca si **ramane goala, fara nicio eroare vizibila**. Exact asta s-a intamplat la prima folosire reala. - paginile se servesc cu `Cache-Control: no-store`: poarta de acum configuratie (`base href`), iar o copie veche din cache ar trimite cererile in alta parte. ### Prin tunel SSH ```bash ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171 & # apoi http://localhost:18790 ``` Cu `DASHBOARD_AUTH=off` (configuratia curenta) nu se cere nimic la intrare. Daca tokenul e reactivat, 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 ``` 41 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, montarea sub prefix (cu si fara slash final) si verificarea de regresie pentru `deny(ssh)`.