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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user