# 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 |
| **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\|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.
## 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)`.