Consolidate Maria control into the shared Discord-bridge dashboard
Drop the standalone maria-dashboard.service — one common panel now controls both bridges, at the operator's request. discord-bridge's dashboard/api.py gains a Maria section (fixed-unit start/stop/restart for maria-whatsapp/maria-rag, WhatsApp status+QR proxy, document CRUD, reindex, Drive sync trigger, log tail) reached via subprocess/HTTP — no cross-module imports, to avoid colliding with discord-bridge's own `config` module name. index.html gets a matching "Maria — WhatsApp + RAG" section. maria-whatsapp-bridge loses its dashboard/ folder and DASHBOARD_* env keys; install.sh/README point at the shared panel. Co-Authored-By: Claude Agent <noreply@anthropic.com>
This commit is contained in:
@@ -21,12 +21,15 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
@@ -47,6 +50,120 @@ from limits import parse_cap # noqa: E402
|
||||
SERVICE = "claude-discord.service"
|
||||
SELF_SERVICE = "claude-discord-dashboard.service"
|
||||
|
||||
# ── Maria (WhatsApp+RAG, proiect sibling in acelasi repo) ────────────────
|
||||
# Panou unic la cererea operatorului: acest dashboard controleaza si serviciile
|
||||
# Mariei, nu doar puntea Discord. NU importam rag/config.py de acolo (ar coliza
|
||||
# pe numele de modul "config" cu discord-bridge/config.py, deja importat mai
|
||||
# sus) — citim direct caile si folosim subprocess/HTTP, exact ca la SERVICE.
|
||||
MARIA_UNITS = {"bridge": "maria-whatsapp.service", "rag": "maria-rag.service"}
|
||||
MARIA_STATE_DIR = Path(os.environ.get("MARIA_BRIDGE_DIR") or (Path.home() / ".maria-bridge"))
|
||||
MARIA_DOCS_DIR = MARIA_STATE_DIR / "documents"
|
||||
MARIA_INDEX_FILE = MARIA_STATE_DIR / "rag_index.json"
|
||||
MARIA_ENV_FILE = MARIA_STATE_DIR / "env"
|
||||
MARIA_LOG_DIR = MARIA_STATE_DIR / "logs"
|
||||
MARIA_VENV_PY = MARIA_STATE_DIR / "venv" / "bin" / "python"
|
||||
MARIA_RAG_DIR = _BRIDGE.parent / "maria-whatsapp-bridge" / "rag"
|
||||
_MARIA_SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,200}$")
|
||||
|
||||
|
||||
def maria_env() -> dict:
|
||||
try:
|
||||
return config.parse_env(MARIA_ENV_FILE.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
return {}
|
||||
|
||||
|
||||
def maria_get(key: str, default: str = "") -> str:
|
||||
return maria_env().get(key, default)
|
||||
|
||||
|
||||
def maria_bridge_url() -> str:
|
||||
return f"http://{maria_get('BRIDGE_HOST', '127.0.0.1')}:{maria_get('BRIDGE_PORT', '8099')}"
|
||||
|
||||
|
||||
def maria_log(which: str) -> Path:
|
||||
name = {"bridge": "whatsapp.log", "rag": "rag.log"}.get(which, "rag.log")
|
||||
return MARIA_LOG_DIR / name
|
||||
|
||||
|
||||
def maria_whatsapp_status() -> dict:
|
||||
try:
|
||||
req = urllib.request.Request(f"{maria_bridge_url()}/status")
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
|
||||
return {"connected": False, "phone": None, "qr": None, "reachable": False}
|
||||
|
||||
|
||||
def maria_index_info() -> dict:
|
||||
try:
|
||||
entries = json.loads(MARIA_INDEX_FILE.read_text(encoding="utf-8"))
|
||||
sources = {e.get("source") for e in entries if isinstance(e, dict) and e.get("source")}
|
||||
return {"chunks": len(entries), "documents_indexed": len(sources),
|
||||
"mtime": MARIA_INDEX_FILE.stat().st_mtime}
|
||||
except (OSError, ValueError):
|
||||
return {"chunks": 0, "documents_indexed": 0, "mtime": None}
|
||||
|
||||
|
||||
def maria_sync_state() -> dict:
|
||||
try:
|
||||
return json.loads((MARIA_STATE_DIR / ".sync_state.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def maria_documents() -> list[dict]:
|
||||
MARIA_DOCS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
out = []
|
||||
for f in sorted(MARIA_DOCS_DIR.glob("*")):
|
||||
if f.is_file() and f.suffix in (".txt", ".md"):
|
||||
st = f.stat()
|
||||
out.append({"name": f.name, "size": st.st_size, "mtime": st.st_mtime})
|
||||
return out
|
||||
|
||||
|
||||
def _maria_validate_name(name: str) -> str:
|
||||
if not name or not _MARIA_SAFE_NAME.match(name) or ".." in name or "/" in name:
|
||||
raise ValueError(f"nume de document invalid: {name!r}")
|
||||
if not name.endswith((".txt", ".md")):
|
||||
raise ValueError("doar fisiere .txt sau .md")
|
||||
return name
|
||||
|
||||
|
||||
def maria_write_document(name: str, content: str) -> None:
|
||||
name = _maria_validate_name(name)
|
||||
MARIA_DOCS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(MARIA_DOCS_DIR / name).write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def maria_delete_document(name: str) -> bool:
|
||||
name = _maria_validate_name(name)
|
||||
path = MARIA_DOCS_DIR / name
|
||||
if not path.exists():
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
|
||||
def _maria_run(script: str, timeout: float = 600.0, extra_args: list[str] | None = None) -> dict:
|
||||
interpreter = str(MARIA_VENV_PY) if MARIA_VENV_PY.exists() else sys.executable
|
||||
args = [interpreter, str(MARIA_RAG_DIR / script), *(extra_args or [])]
|
||||
try:
|
||||
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout, cwd=str(MARIA_RAG_DIR))
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "error": f"{script} a depasit timpul ({timeout:.0f}s)"}
|
||||
return {"ok": r.returncode == 0, "stdout": r.stdout[-4000:], "stderr": r.stderr[-4000:]}
|
||||
|
||||
|
||||
def maria_reindex() -> dict:
|
||||
return _maria_run("indexer.py")
|
||||
|
||||
|
||||
def maria_sync(force: bool = False) -> dict:
|
||||
out = _maria_run("sync.py", extra_args=(["--force"] if force else []))
|
||||
out["state"] = maria_sync_state()
|
||||
return out
|
||||
|
||||
|
||||
# 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).
|
||||
@@ -526,6 +643,28 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
return self.send_json({"checks": doctor()})
|
||||
if path == "/api/cleanup":
|
||||
return self.send_json(orphans_report(dry_run=True))
|
||||
if path == "/api/maria/status":
|
||||
return self.send_json({
|
||||
"bridge": unit_info(MARIA_UNITS["bridge"]),
|
||||
"rag": unit_info(MARIA_UNITS["rag"]),
|
||||
"whatsapp": maria_whatsapp_status(),
|
||||
"index": maria_index_info(),
|
||||
"documents": len(maria_documents()),
|
||||
"sync": {**maria_sync_state(), "drive_remote": maria_get("DRIVE_REMOTE") or None},
|
||||
})
|
||||
if path == "/api/maria/documents":
|
||||
return self.send_json({"documents": maria_documents()})
|
||||
if path == "/api/maria/logs":
|
||||
try:
|
||||
n = min(max(int(qs.get("lines", ["200"])[0]), 1), 2000)
|
||||
except ValueError:
|
||||
n = 200
|
||||
target = maria_log(qs.get("service", ["bridge"])[0])
|
||||
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()})
|
||||
return self.send_json({"error": "ruta necunoscuta"}, 404)
|
||||
|
||||
# --- POST ----------------------------------------------------------
|
||||
@@ -547,6 +686,17 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
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/maria/service":
|
||||
return self.handle_maria_service()
|
||||
if path == "/api/maria/documents":
|
||||
return self.handle_maria_document_write()
|
||||
if path == "/api/maria/documents/delete":
|
||||
return self.handle_maria_document_delete()
|
||||
if path == "/api/maria/reindex":
|
||||
return self.send_json(maria_reindex())
|
||||
if path == "/api/maria/sync":
|
||||
data = self.read_json()
|
||||
return self.send_json(maria_sync(force=bool(data.get("force"))))
|
||||
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()
|
||||
@@ -608,6 +758,50 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
return self.send_json({"ok": bool(ok), "request_id": rid, "decision": decision},
|
||||
200 if ok else 404)
|
||||
|
||||
def handle_maria_service(self):
|
||||
"""start / stop / restart pe una din UNITATILE FIXE ale Mariei — acelasi
|
||||
principiu ca handle_service: numele vine dintr-o cheie (bridge/rag), nu
|
||||
dintr-un nume de unit arbitrar din request."""
|
||||
data = self.read_json()
|
||||
which = str(data.get("service") or "")
|
||||
action = str(data.get("action") or "")
|
||||
unit = MARIA_UNITS.get(which)
|
||||
if not unit:
|
||||
return self.send_json({"ok": False, "error": f"serviciu Maria necunoscut: {which}"}, 400)
|
||||
if action not in ("start", "stop", "restart"):
|
||||
return self.send_json({"ok": False, "error": f"actiune necunoscuta: {action}"}, 400)
|
||||
who = tailnet_user(self.headers) or "local"
|
||||
print(f"[actiune] {action} pe {unit}, cerut de {who}", file=sys.stderr, flush=True)
|
||||
try:
|
||||
r = _sysctl(action, unit)
|
||||
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)
|
||||
return self.send_json({"ok": True, "action": action, "service": unit_info(unit)})
|
||||
|
||||
def handle_maria_document_write(self):
|
||||
data = self.read_json()
|
||||
name = str(data.get("name") or "")
|
||||
content = data.get("content")
|
||||
if content is None:
|
||||
return self.send_json({"ok": False, "error": "lipseste 'content'"}, 400)
|
||||
try:
|
||||
maria_write_document(name, str(content))
|
||||
except (ValueError, OSError) as exc:
|
||||
return self.send_json({"ok": False, "error": str(exc)}, 400)
|
||||
return self.send_json({"ok": True, "name": name})
|
||||
|
||||
def handle_maria_document_delete(self):
|
||||
data = self.read_json()
|
||||
name = str(data.get("name") or "")
|
||||
try:
|
||||
ok = maria_delete_document(name)
|
||||
except (ValueError, OSError) as exc:
|
||||
return self.send_json({"ok": False, "error": str(exc)}, 400)
|
||||
return self.send_json({"ok": ok, "name": name}, 200 if ok else 404)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
bind = config.get("DASHBOARD_BIND") or "127.0.0.1"
|
||||
|
||||
Reference in New Issue
Block a user