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:
Claude Agent
2026-08-31 13:28:55 +00:00
parent cfe01135d1
commit 18977cd029
12 changed files with 3019 additions and 766 deletions

View File

@@ -202,6 +202,12 @@ https://claude-agent.tailf7372d.ts.net/claude # din tailnet (ca /echo la molt
Procesul e legat de `127.0.0.1:18790`; in tailnet il publica `tailscale serve`.
Fara Tailscale: `ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171`.
Acelasi panou include si sectiunea **"Maria — WhatsApp + RAG"**, care controleaza
proiectul sibling `../maria-whatsapp-bridge/` (punte WhatsApp Baileys + consumer
RAG): start/stop/restart, cod QR de asociere, depozit de documente, reindexare
si sincronizare Google Drive. Decizie deliberata: un singur dashboard comun
pentru ambele punti de pe acest container, nu un panou separat per serviciu.
| Fisier | Ce e |
|--------|------|
| `~/.claude-discord/env` | token + allowlist + limite (0600) |

View File

@@ -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"

View File

@@ -71,6 +71,70 @@
<pre class="log" id="log">se încarcă…</pre>
</section>
<!-- ── Maria: WhatsApp + RAG ──────────────────────────────── -->
<h2 style="margin-top:var(--space-6)">Maria — WhatsApp + RAG</h2>
<div class="grid-2">
<section class="card">
<h2>Punte WhatsApp <span class="count" id="mBridgeUnit"></span></h2>
<div class="metrics" id="mBridgeMetrics"></div>
<div class="actions" style="margin-top:var(--space-5)">
<button onclick="mSvc('bridge','start')">Pornește</button>
<button class="danger" onclick="mSvc('bridge','stop')">Oprește</button>
<button class="primary" onclick="mSvc('bridge','restart')">Repornește</button>
</div>
</section>
<section class="card">
<h2>Consumer RAG <span class="count" id="mRagUnit"></span></h2>
<div class="metrics" id="mRagMetrics"></div>
<div class="actions" style="margin-top:var(--space-5)">
<button onclick="mSvc('rag','start')">Pornește</button>
<button class="danger" onclick="mSvc('rag','stop')">Oprește</button>
<button class="primary" onclick="mSvc('rag','restart')">Repornește</button>
</div>
</section>
</div>
<section class="card">
<h2>Conectare WhatsApp</h2>
<div id="mWaStatus" class="empty">se încarcă…</div>
<div id="mWaQr"></div>
</section>
<section class="card">
<h2>Documente indexate <span class="count" id="mDocCount"></span></h2>
<div class="detail" id="mIndexDetail"></div>
<div class="detail" id="mSyncDetail"></div>
<div class="actions" style="margin:var(--space-5) 0">
<button onclick="mReindex()">Reconstruiește indexul</button>
<button onclick="mSyncNow()">Sincronizează din Drive acum</button>
</div>
<div class="table-scroll">
<table class="stack-mobil">
<thead><tr><th>Nume</th><th class="num">Mărime</th><th></th></tr></thead>
<tbody id="mDocs"></tbody>
</table>
</div>
<h2 style="margin-top:var(--space-5)">Adaugă document (.txt / .md)</h2>
<input type="text" id="mDocName" placeholder="ex: politica-retururi.md" style="width:100%;margin-bottom:var(--space-3)">
<textarea id="mDocContent" placeholder="conținutul documentului..." style="width:100%;min-height:120px;font-family:monospace"></textarea>
<div class="actions" style="margin-top:var(--space-3)">
<button class="primary" onclick="mAddDoc()">Salvează și reindexează</button>
</div>
<div class="detail" id="mDocMsg"></div>
</section>
<section class="card">
<h2>Jurnal Maria
<span class="count">
<button class="small" id="mTabBridge" onclick="mSetLog('bridge')">whatsapp.log</button>
<button class="small" id="mTabRag" onclick="mSetLog('rag')">rag.log</button>
<button class="small" onclick="mRefreshLogs()">↻</button>
</span>
</h2>
<pre class="log" id="mLog">se încarcă…</pre>
</section>
</div>
<div id="toast"></div>
@@ -274,10 +338,125 @@ async function refreshLogs() {
el.scrollTop = el.scrollHeight;
}
// ── Maria: WhatsApp + RAG ───────────────────────────────────────
var mBusy = false;
function mMetricsHtml(info) {
return [
['Stare', '<span class="pill ' + (info.active ? 'ok' : 'bad') + '">' + esc(info.state) + '/' + esc(info.sub) + '</span>'],
['Uptime', dur(info.uptime_s)],
['PID', info.pid || '—'],
['Restarturi', info.restarts]
].map(function (m) {
return '<div class="metric"><div class="label">' + m[0] + '</div><div class="value">' + m[1] + '</div></div>';
}).join('');
}
async function mRefresh() {
var res = await api('api/maria/status');
if (!res.ok) return;
var s = res.data;
document.getElementById('mBridgeUnit').textContent = s.bridge.unit;
document.getElementById('mBridgeMetrics').innerHTML = mMetricsHtml(s.bridge);
document.getElementById('mRagUnit').textContent = s.rag.unit;
document.getElementById('mRagMetrics').innerHTML = mMetricsHtml(s.rag);
var wa = s.whatsapp || {};
var waStatus = document.getElementById('mWaStatus');
var waQr = document.getElementById('mWaQr');
if (wa.connected) {
waStatus.innerHTML = '<span class="pill ok">conectat</span> ca ' + esc(wa.phone || '?');
waQr.innerHTML = '';
} else if (wa.qr) {
waStatus.innerHTML = '<span class="pill bad">neconectat</span> — scanează codul QR din WhatsApp → Dispozitive conectate';
waQr.innerHTML = '<img src="' + wa.qr + '" style="width:220px;height:220px;border-radius:8px;background:#fff;padding:8px;margin-top:var(--space-4)">';
} else {
waStatus.innerHTML = '<span class="pill bad">' + (wa.reachable === false ? 'puntea nu rulează' : 'aștept codul QR…') + '</span>';
waQr.innerHTML = '';
}
document.getElementById('mIndexDetail').textContent =
s.index.chunks + ' chunk-uri din ' + s.index.documents_indexed + ' documente indexate' +
(s.index.mtime ? ' · indexat ' + new Date(s.index.mtime * 1000).toLocaleString('ro-RO') : '');
document.getElementById('mDocCount').textContent = s.documents + ' în depozit';
var sync = s.sync || {};
document.getElementById('mSyncDetail').textContent = sync.drive_remote
? 'Drive: ' + sync.drive_remote + (sync.synced_at ? ' · ultima sincronizare ' + new Date(sync.synced_at * 1000).toLocaleString('ro-RO') : ' · încă nesincronizat')
: 'Sincronizare Drive dezactivată (DRIVE_REMOTE nesetat) — doar upload manual';
var docsRes = await api('api/maria/documents');
var docs = docsRes.ok ? (docsRes.data.documents || []) : [];
document.getElementById('mDocs').innerHTML = docs.length ? docs.map(function (d) {
return '<tr><td data-label="Nume" class="mono">' + esc(d.name) + '</td>' +
'<td data-label="Mărime" class="num">' + d.size + ' B</td>' +
'<td><button class="small danger" onclick="mDelDoc(\'' + esc(d.name) + '\')">Șterge</button></td></tr>';
}).join('') : '<tr><td colspan="3" class="empty" data-label="">niciun document</td></tr>';
}
async function mSvc(which, action) {
if (mBusy) return;
if (action !== 'start' && !confirm('Sigur „' + action + '” pe ' + which + '?')) return;
mBusy = true;
try {
var res = await post('api/maria/service', { service: which, action: action });
toast(res.ok ? 'Maria ' + which + ': ' + action + ' OK' : ('Eșuat: ' + (res.data.error || res.status)), res.ok ? 'ok' : 'bad');
} finally {
mBusy = false;
mRefresh();
}
}
async function mReindex() {
var res = await post('api/maria/reindex', {});
toast(res.ok && res.data.ok ? 'Index reconstruit' : ('Eșuat: ' + (res.data.error || res.data.stderr || res.status)), res.ok && res.data.ok ? 'ok' : 'bad');
mRefresh();
}
async function mSyncNow() {
var res = await post('api/maria/sync', { force: true });
toast(res.ok && res.data.ok ? 'Sincronizat' : ('Eșuat: ' + (res.data.error || res.data.stderr || res.status)), res.ok && res.data.ok ? 'ok' : 'bad');
mRefresh();
}
async function mAddDoc() {
var name = document.getElementById('mDocName').value.trim();
var content = document.getElementById('mDocContent').value;
var msg = document.getElementById('mDocMsg');
if (!name || !content) { msg.textContent = 'completează numele și conținutul'; return; }
var res = await post('api/maria/documents', { name: name, content: content });
if (!res.ok) { msg.textContent = 'eșuat: ' + (res.data.error || res.status); return; }
msg.textContent = 'salvat, reindexez…';
var ri = await post('api/maria/reindex', {});
msg.textContent = ri.ok && ri.data.ok ? 'salvat și reindexat' : ('salvat, reindexarea a eșuat: ' + (ri.data.error || ri.data.stderr || ''));
document.getElementById('mDocContent').value = '';
mRefresh();
}
async function mDelDoc(name) {
if (!confirm('Ștergi ' + name + '?')) return;
await post('api/maria/documents/delete', { name: name });
await mReindex();
}
var mLogFile = 'bridge';
function mSetLog(which) { mLogFile = which; mRefreshLogs(); }
async function mRefreshLogs() {
document.getElementById('mTabBridge').className = 'small' + (mLogFile === 'bridge' ? ' primary' : '');
document.getElementById('mTabRag').className = 'small' + (mLogFile === 'rag' ? ' primary' : '');
var res = await api('api/maria/logs?lines=300&service=' + mLogFile);
if (!res.ok) return;
var el = document.getElementById('mLog');
el.textContent = (res.data.lines || []).join('\n') || '(gol)';
el.scrollTop = el.scrollHeight;
}
// ── pornire ───────────────────────────────────────────────────
refresh(); refreshDoctor(); refreshLogs();
mRefresh(); mRefreshLogs();
setInterval(refresh, 5000);
setInterval(refreshDoctor, 30000);
setInterval(mRefresh, 4000);
</script>
</body>
</html>