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:
@@ -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`
|
||||||
|
|||||||
@@ -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)) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
125
proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md
Normal file
125
proxmox/lxc171-claude-agent/discord-bridge/dashboard/README.md
Normal 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)`.
|
||||||
525
proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py
Normal file
525
proxmox/lxc171-claude-agent/discord-bridge/dashboard/api.py
Normal 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()
|
||||||
@@ -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
|
||||||
273
proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html
Normal file
273
proxmox/lxc171-claude-agent/discord-bridge/dashboard/index.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
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>
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user