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>

View File

@@ -2,8 +2,11 @@
Bot de suport ROA pe WhatsApp: puntea WhatsApp (Baileys) primeste mesajele,
consumer-ul RAG raspunde folosind DOAR informatiile dintr-un depozit de
documente indexat cu embeddings (Ollama), iar un dashboard web controleaza
totul (start/stop/restart, documente, reindexare, sincronizare Google Drive).
documente indexat cu embeddings (Ollama). Controlul (start/stop/restart,
documente, reindexare, sincronizare Google Drive) se face din **dashboard-ul
puntii Discord** (`../discord-bridge/dashboard/`), sectiunea "Maria — WhatsApp
+ RAG" — un singur panou comun pentru ambele punti de pe acest container, nu
un dashboard separat per serviciu.
Continua prototipul descris in
`claude-agent/docs/maria-whatsapp-rag-prototype.md` (construit initial in
@@ -31,9 +34,9 @@ rag/store.py -- depozit documente (.txt/.md) in ~/.maria-bridge/documents/
rag/indexer.py -- chunking + embeddings Ollama -> rag_index.json
rag/sync.py -- rclone pull din Google Drive + reindexare conditionata
dashboard/api.py -- panou web (stdlib, fara dependinte), :18792
controleaza maria-whatsapp.service + maria-rag.service,
gestioneaza documentele si declanseaza sincronizare/reindexare
../discord-bridge/dashboard/api.py -- panou comun; sectiunea Maria controleaza
maria-whatsapp.service + maria-rag.service, gestioneaza
documentele si declanseaza sincronizare/reindexare
```
Servicii `systemctl --user` (vezi `ops/`):
@@ -43,7 +46,6 @@ Servicii `systemctl --user` (vezi `ops/`):
| `maria-whatsapp.service` | Puntea Baileys (Node), port 8099 |
| `maria-rag.service` | Consumer RAG (Python), polling + raspunsuri |
| `maria-sync.service` + `.timer` | Sincronizare Drive + reindexare, la 10 min |
| `maria-dashboard.service` | Panou de control, port 18792 |
## Instalare
@@ -61,32 +63,26 @@ Bridge-ul WhatsApp NU porneste automat la instalare — cere scanarea unui cod Q
```bash
./ops/install.sh --start
# apoi deschide dashboard-ul (tunel SSH catre 127.0.0.1:18792) si scaneaza
# codul QR din cardul "Conectare WhatsApp"
# apoi deschide dashboard-ul puntii Discord si scaneaza codul QR din
# sectiunea "Maria — WhatsApp + RAG" -> cardul "Conectare WhatsApp"
```
## Dashboard
## Dashboard (comun cu puntea Discord)
```bash
ssh -L 18792:127.0.0.1:18792 -N claude@10.0.20.171 &
# apoi: http://localhost:18792
```
Autentificare cu `DASHBOARD_TOKEN` din `~/.maria-bridge/env` (generat automat
de `install.sh`). `DASHBOARD_AUTH=off` in env dezactiveaza login-ul — foloseste
DOAR daca panoul ramane strict pe 127.0.0.1/tunel SSH.
Ce poti face din panou:
Nu exista un dashboard separat pentru Maria. Controlul se face din dashboard-ul
puntii Discord — vezi `../discord-bridge/README.md` pentru URL si autentificare
(`DASHBOARD_TOKEN` din `~/.claude-discord/env`, tunel SSH sau Tailscale la
`/claude`). Acolo, sectiunea "Maria — WhatsApp + RAG":
- start/stop/restart pentru puntea WhatsApp si consumer-ul RAG
- vezi starea conexiunii WhatsApp si codul QR de asociere (cand nu e conectat)
- listezi, adaugi si stergi documente din depozitul RAG
- reconstruiesti indexul manual, sau declansezi o sincronizare Drive imediata
- citesti ultimele linii din logurile fiecarui serviciu
- starea conexiunii WhatsApp si codul QR de asociere (cand nu e conectat)
- listare, adaugare si stergere documente din depozitul RAG
- reconstruire index manual, sau sincronizare Drive imediata
- ultimele linii din logurile fiecarui serviciu Maria (`whatsapp.log`/`rag.log`)
## Depozitul de documente
Fisiere `.txt`/`.md` in `~/.maria-bridge/documents/`. Se pot administra:
1. **manual din dashboard** (adaugare/stergere text, reindexare automata la salvare);
1. **manual din dashboard-ul comun** (adaugare/stergere text, reindexare automata la salvare);
2. **prin sincronizare Google Drive** — vezi mai jos.
## Sincronizare cu Google Drive

View File

@@ -1,419 +0,0 @@
#!/usr/bin/env python3
"""Dashboard de control pentru puntea WhatsApp+RAG a lui Maria (LXC 171).
Model: `discord-bridge/dashboard/api.py` de pe acelasi container. Diferente
deliberate fata de acela:
- controleaza DOUA unitati fixe (`maria-whatsapp.service`, `maria-rag.service`),
nu una singura — dar tot dintr-o lista fixa, nu dintr-un nume primit din
request (acelasi motiv: altfel dashboard-ul devine un `systemctl` remote
fara parola).
- expune si un mic depozit de documente (rag/store.py) — listare, adaugare,
stergere — plus un buton de reindexare (ruleaza rag/indexer.py) si starea
de conectare WhatsApp (proxy catre `/status` al puntii, inclusiv QR-ul de
asociere cand nu e inca legata).
- bind implicit pe 127.0.0.1, acelasi model de autentificare prin token/cookie
ca discord-bridge (vezi acolo comentariul complet despre motiv).
Fara dependinte in afara stdlib, ca sa ruleze fara venv-ul consumer-ului.
"""
from __future__ import annotations
import json
import secrets
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
from urllib.parse import parse_qs, urlparse
# Radacina proiectului (parintele lui dashboard/) si rag/ trebuie sa fie
# importabile: de acolo vin config.py si store.py.
_DASH = Path(__file__).resolve().parent
_ROOT = _DASH.parent
_RAG = _ROOT / "rag"
for _p in (str(_ROOT), str(_RAG), str(_DASH)):
if _p not in sys.path:
sys.path.insert(0, _p)
import config # noqa: E402
import store # noqa: E402
import sync as sync_mod # noqa: E402
# ── constante ───────────────────────────────────────────────────────────
UNITS = {
"bridge": "maria-whatsapp.service",
"rag": "maria-rag.service",
}
SELF_SERVICE = "maria-dashboard.service"
COOKIE_NAME = "maria-dashboard"
COOKIE_MAX_AGE = 60 * 60 * 24 * 30
def log_file(which: str) -> Path:
name = {"bridge": "whatsapp.log", "rag": "rag.log", "dashboard": "dashboard.log"}.get(which, "dashboard.log")
return config.LOG_DIR / name
def bridge_base_url() -> str:
return f"http://{config.get('BRIDGE_HOST')}:{config.get('BRIDGE_PORT')}"
_TOKEN: str | None = None
def reset_token_cache() -> None:
global _TOKEN
_TOKEN = None
def auth_disabled() -> bool:
return (config.get("DASHBOARD_AUTH") or "").strip().lower() in ("off", "none", "0", "false")
def dashboard_token() -> str:
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:
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()
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,
}
# ── puntea WhatsApp (proxy HTTP catre whatsapp/index.js) ──────────────────
def whatsapp_status() -> dict:
"""Interogheaza /status al puntii Node. Esec (proces oprit) = raspuns neutru."""
try:
req = urllib.request.Request(f"{bridge_base_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}
# ── depozitul de documente + index ─────────────────────────────────────────
def index_info() -> dict:
try:
raw = config.INDEX_FILE.read_text(encoding="utf-8")
entries = json.loads(raw)
sources = sorted({e.get("source") for e in entries if isinstance(e, dict) and e.get("source")})
return {
"chunks": len(entries),
"documents_indexed": len(sources),
"mtime": config.INDEX_FILE.stat().st_mtime,
}
except (OSError, ValueError):
return {"chunks": 0, "documents_indexed": 0, "mtime": None}
def _venv_python() -> str:
py = config.STATE_DIR / "venv" / "bin" / "python"
return str(py) if py.exists() else sys.executable
def run_reindex() -> dict:
"""Ruleaza rag/indexer.py cu python-ul din venv (daca exista) si intoarce output-ul."""
try:
r = subprocess.run(
[_venv_python(), str(_RAG / "indexer.py")],
capture_output=True, text=True, timeout=600,
cwd=str(_RAG),
)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "indexarea a depasit timpul (600s)"}
ok = r.returncode == 0
return {"ok": ok, "stdout": r.stdout[-4000:], "stderr": r.stderr[-4000:]}
def run_sync(force: bool = False) -> dict:
"""Ruleaza rag/sync.py (rclone pull + reindexare conditionata) intr-un proces
separat — acelasi motiv ca la reindex: nu blocam thread-ul dashboard-ului cu
embeddings, iar rclone poate lua cateva secunde bune pe retea."""
args = [_venv_python(), str(_RAG / "sync.py")]
if force:
args.append("--force")
try:
r = subprocess.run(args, capture_output=True, text=True, timeout=600, cwd=str(_RAG))
except subprocess.TimeoutExpired:
return {"ok": False, "error": "sincronizarea a depasit timpul (600s)"}
ok = r.returncode == 0
out = {"ok": ok, "stdout": r.stdout[-4000:], "stderr": r.stderr[-4000:]}
out["state"] = sync_mod.read_sync_state()
return out
# ── 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 = "maria-dashboard"
protocol_version = "HTTP/1.1"
def __init__(self, *a, **kw):
super().__init__(*a, directory=str(_DASH), **kw)
def log_message(self, format, *args): # 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:
if auth_disabled():
return True
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 == "/login.html" and auth_disabled():
self.send_response(302)
self.send_header("Location", "/")
self.send_header("Content-Length", "0")
self.end_headers()
return
return super().do_GET()
def route_get(self, path: str):
qs = parse_qs(urlparse(self.path).query)
if path == "/api/status":
return self.send_json({
"bridge": unit_info(UNITS["bridge"]),
"rag": unit_info(UNITS["rag"]),
"dashboard": unit_info(SELF_SERVICE),
"whatsapp": whatsapp_status(),
"index": index_info(),
"documents": len(store.list_documents()),
"sync": {
**sync_mod.read_sync_state(),
"drive_remote": config.get("DRIVE_REMOTE") or None,
},
"auth": not auth_disabled(),
"now": time.time(),
})
if path == "/api/documents":
return self.send_json({"documents": store.list_documents()})
if path == "/api/documents/read":
name = qs.get("name", [""])[0]
try:
return self.send_json({"name": name, "content": store.read_document(name)})
except (ValueError, OSError) as exc:
return self.send_json({"error": str(exc)}, 400)
if path == "/api/logs":
try:
n = min(max(int(qs.get("lines", ["200"])[0]), 1), 2000)
except ValueError:
n = 200
which = qs.get("service", ["bridge"])[0]
target = log_file(which)
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 ----------------------------------------------------------
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/documents":
return self.handle_document_write()
if path == "/api/documents/delete":
return self.handle_document_delete()
if path == "/api/reindex":
return self.send_json(run_reindex())
if path == "/api/sync":
data = self.read_json()
return self.send_json(run_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), __import__("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)
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 una din UNITATILE FIXE. Numele vine dintr-o
cheie (bridge/rag), niciodata dintr-un nume de unit arbitrar."""
data = self.read_json()
which = str(data.get("service") or "")
action = str(data.get("action") or "")
unit = UNITS.get(which)
if not unit:
return self.send_json({"ok": False, "error": f"serviciu necunoscut: {which}"}, 400)
if action not in ("start", "stop", "restart"):
return self.send_json({"ok": False, "error": f"actiune necunoscuta: {action}"}, 400)
print(f"[actiune] {action} pe {unit}", 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_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:
store.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_document_delete(self):
data = self.read_json()
name = str(data.get("name") or "")
try:
ok = store.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"
port = config.get_int("DASHBOARD_PORT", 18792)
dashboard_token()
srv = ThreadingHTTPServer((bind, port), Handler)
srv.daemon_threads = True
print(f"dashboard Maria pe http://{bind}:{port} (unitati: {list(UNITS.values())})",
file=sys.stderr, flush=True)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
finally:
srv.server_close()
if __name__ == "__main__":
main()

View File

@@ -1,211 +0,0 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Maria — WhatsApp + RAG</title>
<style>
:root{color-scheme:dark}
body{font-family:system-ui,sans-serif;background:#111;color:#eee;margin:0;padding:24px;
max-width:920px;margin-inline:auto}
h1{font-size:20px}
h2{font-size:15px;color:#aaa;margin-top:28px}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:12px}
.card{background:#1b1b1b;border-radius:10px;padding:16px}
.row{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:14px}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
.dot.on{background:#4caf50} .dot.off{background:#e57373}
button{padding:6px 12px;border-radius:6px;border:1px solid #333;background:#232323;
color:#eee;cursor:pointer;font-size:13px;margin-right:6px}
button:hover{background:#2e2e2e}
button.danger:hover{background:#5c2020}
table{width:100%;border-collapse:collapse;font-size:13px;margin-top:8px}
td,th{padding:6px 4px;border-bottom:1px solid #262626;text-align:left}
textarea{width:100%;min-height:120px;background:#0d0d0d;color:#eee;border:1px solid #333;
border-radius:6px;padding:8px;box-sizing:border-box;font-family:monospace}
input[type=text]{padding:6px;border-radius:6px;border:1px solid #333;background:#0d0d0d;color:#eee}
pre{background:#0d0d0d;padding:10px;border-radius:6px;max-height:280px;overflow:auto;font-size:12px}
img.qr{width:220px;height:220px;border-radius:8px;background:#fff;padding:8px}
.muted{color:#888;font-size:12px}
a.logout{color:#888;font-size:12px;float:right;cursor:pointer}
</style>
</head>
<body>
<h1>Maria — punte WhatsApp + RAG <a class="logout" id="logout">deconectare</a></h1>
<div class="grid">
<div class="card">
<div class="row"><b>Punte WhatsApp</b><span id="bridge-state"></span></div>
<div class="row muted" id="bridge-detail"></div>
<button onclick="svc('bridge','start')">start</button>
<button onclick="svc('bridge','stop')">stop</button>
<button onclick="svc('bridge','restart')">restart</button>
</div>
<div class="card">
<div class="row"><b>Consumer RAG</b><span id="rag-state"></span></div>
<div class="row muted" id="rag-detail"></div>
<button onclick="svc('rag','start')">start</button>
<button onclick="svc('rag','stop')">stop</button>
<button onclick="svc('rag','restart')">restart</button>
</div>
</div>
<h2>Conectare WhatsApp</h2>
<div class="card" id="wa-card">
<div class="row" id="wa-status">se incarca...</div>
<div id="wa-qr"></div>
</div>
<h2>Documente indexate (depozit RAG)</h2>
<div class="card">
<div class="row muted" id="index-detail"></div>
<div class="row muted" id="sync-detail"></div>
<button onclick="reindex()">reconstruieste indexul</button>
<button onclick="syncNow()">sincronizeaza din Drive acum</button>
<table id="docs-table"><thead><tr><th>nume</th><th>marime</th><th></th></tr></thead><tbody></tbody></table>
<h2 style="margin-top:16px">Adauga document (.txt / .md)</h2>
<input type="text" id="doc-name" placeholder="ex: politica-retururi.md" style="width:260px">
<br><br>
<textarea id="doc-content" placeholder="continutul documentului..."></textarea>
<br><br>
<button onclick="addDoc()">salveaza si reindexeaza</button>
<div class="muted" id="doc-msg"></div>
</div>
<h2>Loguri</h2>
<div class="card">
<select id="log-service" onchange="loadLogs()">
<option value="bridge">whatsapp</option>
<option value="rag">rag</option>
<option value="dashboard">dashboard</option>
</select>
<button onclick="loadLogs()">reincarca</button>
<pre id="logs"></pre>
</div>
<script>
async function api(path, opts) {
const r = await fetch(path, opts);
if (r.status === 401) { location.href = '/login.html'; throw new Error('neautentificat'); }
return r.json();
}
function fmtUptime(s) {
if (s == null) return '—';
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60);
return `${h}h${m}m`;
}
function renderUnit(prefix, info) {
document.getElementById(prefix+'-state').innerHTML =
`<span class="dot ${info.active?'on':'off'}"></span>${info.state}/${info.sub}`;
document.getElementById(prefix+'-detail').textContent =
info.active ? `pid ${info.pid ?? '?'} · sus de ${fmtUptime(info.uptime_s)} · ${info.restarts} restarturi`
: `${info.restarts} restarturi`;
}
async function refresh() {
const s = await api('/api/status');
renderUnit('bridge', s.bridge);
renderUnit('rag', s.rag);
const wa = s.whatsapp || {};
const waStatus = document.getElementById('wa-status');
const waQr = document.getElementById('wa-qr');
if (wa.connected) {
waStatus.innerHTML = `<span class="dot on"></span>conectat ca ${wa.phone || '?'}`;
waQr.innerHTML = '';
} else if (wa.qr) {
waStatus.innerHTML = `<span class="dot off"></span>neconectat — scaneaza codul QR din WhatsApp &rarr; Dispozitive conectate`;
waQr.innerHTML = `<img class="qr" src="${wa.qr}">`;
} else {
waStatus.innerHTML = `<span class="dot off"></span>${wa.reachable === false ? 'puntea nu ruleaza' : 'astept codul QR...'}`;
waQr.innerHTML = '';
}
document.getElementById('index-detail').textContent =
`${s.index.chunks} chunk-uri din ${s.index.documents_indexed} documente indexate · ${s.documents} documente in depozit` +
(s.index.mtime ? ` · indexat ${new Date(s.index.mtime*1000).toLocaleString('ro-RO')}` : '');
const sync = s.sync || {};
document.getElementById('sync-detail').textContent = sync.drive_remote
? `Drive: ${sync.drive_remote}` + (sync.synced_at ? ` · ultima sincronizare ${new Date(sync.synced_at*1000).toLocaleString('ro-RO')}` : ' · inca nesincronizat')
: 'Sincronizare Drive dezactivata (DRIVE_REMOTE nesetat in env) — doar upload manual';
const docs = await api('/api/documents');
const tbody = document.querySelector('#docs-table tbody');
tbody.innerHTML = '';
for (const d of docs.documents) {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${d.name}</td><td>${d.size} B</td><td><button class="danger" onclick="delDoc('${d.name}')">sterge</button></td>`;
tbody.appendChild(tr);
}
}
async function svc(which, action) {
const r = await api('/api/service', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({service: which, action}),
});
if (!r.ok) alert(r.error || 'a esuat');
refresh();
}
async function addDoc() {
const name = document.getElementById('doc-name').value.trim();
const content = document.getElementById('doc-content').value;
const msg = document.getElementById('doc-msg');
if (!name || !content) { msg.textContent = 'completeaza numele si continutul'; return; }
const r = await api('/api/documents', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name, content}),
});
if (!r.ok) { msg.textContent = r.error || 'a esuat'; return; }
msg.textContent = 'salvat, reindexez...';
const ri = await api('/api/reindex', {method: 'POST'});
msg.textContent = ri.ok ? 'salvat si reindexat' : ('salvat, reindexarea a esuat: ' + (ri.error || ri.stderr || ''));
document.getElementById('doc-content').value = '';
refresh();
}
async function delDoc(name) {
if (!confirm(`Stergi ${name}?`)) return;
await api('/api/documents/delete', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({name}),
});
await reindex();
}
async function reindex() {
const r = await api('/api/reindex', {method: 'POST'});
if (!r.ok) alert('reindexare esuata: ' + (r.error || r.stderr || ''));
refresh();
}
async function syncNow() {
const r = await api('/api/sync', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({force: true}),
});
if (!r.ok) alert('sincronizare esuata: ' + (r.error || r.stderr || ''));
refresh();
}
async function loadLogs() {
const which = document.getElementById('log-service').value;
const r = await api(`/api/logs?service=${which}&lines=200`);
document.getElementById('logs').textContent = (r.lines || []).join('\n');
}
document.getElementById('logout').addEventListener('click', async () => {
await fetch('/api/auth/logout', {method: 'POST'});
location.href = '/login.html';
});
refresh();
loadLogs();
setInterval(refresh, 4000);
</script>
</body>
</html>

View File

@@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Maria — autentificare</title>
<style>
body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;
align-items:center;justify-content:center;min-height:100vh;margin:0}
form{background:#1b1b1b;padding:32px;border-radius:12px;width:320px}
h1{font-size:18px;margin:0 0 16px}
input{width:100%;padding:10px;border-radius:6px;border:1px solid #333;
background:#0d0d0d;color:#eee;box-sizing:border-box;margin-bottom:12px}
button{width:100%;padding:10px;border-radius:6px;border:none;background:#2e7d32;
color:#fff;font-weight:600;cursor:pointer}
button:hover{background:#256428}
.err{color:#e57373;font-size:13px;min-height:18px}
</style>
</head>
<body>
<form id="f">
<h1>Maria — dashboard WhatsApp+RAG</h1>
<input type="password" id="token" placeholder="DASHBOARD_TOKEN" autofocus>
<button type="submit">Intra</button>
<div class="err" id="err"></div>
</form>
<script>
document.getElementById('f').addEventListener('submit', async (e) => {
e.preventDefault();
const token = document.getElementById('token').value;
const err = document.getElementById('err');
err.textContent = '';
try {
const r = await fetch('/api/auth/login', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({token}),
});
if (r.ok) { location.href = '/'; return; }
const d = await r.json().catch(() => ({}));
err.textContent = d.error || 'token invalid';
} catch (ex) {
err.textContent = 'eroare de retea';
}
});
</script>
</body>
</html>

View File

@@ -32,11 +32,8 @@ POLL_INTERVAL_S=2
# headless).
DRIVE_REMOTE=
# --- dashboard de control (dashboard/api.py) --------------------------------
# Token de acces. Generat automat de ops/install.sh daca lipseste.
DASHBOARD_TOKEN=
# `off` scoate complet login-ul. De pus DOAR daca panoul ramane strict pe
# 127.0.0.1 / tailnet (acelasi motiv ca la discord-bridge).
DASHBOARD_AUTH=
DASHBOARD_BIND=127.0.0.1
DASHBOARD_PORT=18792
# --- control -----------------------------------------------------------------
# Fara dashboard propriu: start/stop/restart, documente, reindexare si
# sincronizare Drive se fac din dashboard-ul puntii Discord (discord-bridge/),
# sectiunea "Maria — WhatsApp + RAG" — un singur panou comun, o singura
# autentificare (DASHBOARD_TOKEN din ~/.claude-discord/env).

View File

@@ -7,13 +7,18 @@
# ./ops/install.sh # instaleaza / actualizeaza, NU porneste serviciile
# ./ops/install.sh --start # in plus porneste bridge+rag (bridge cere QR scanat)
#
# Controlul (start/stop/restart, documente, reindexare, sincronizare Drive) se
# face din dashboard-ul PUNTII DISCORD (discord-bridge/dashboard/), sectiunea
# "Maria — WhatsApp + RAG" — decizie deliberata: un singur panou comun, nu un
# dashboard separat per serviciu. Vezi discord-bridge/README.md.
#
# Ce face:
# 1. ~/.maria-bridge/ cu 0700, subdirectoare (logs/, documents/, whatsapp-auth/)
# 2. env 0600 din ops/env.example (doar daca lipseste)
# 3. npm install pentru whatsapp/ (Baileys)
# 4. venv + pip install pentru rag/requirements.txt
# 5. loginctl enable-linger (serviciile de utilizator supravietuiesc logout-ului)
# 6. symlink-uri unit -> ~/.config/systemd/user/ (bridge, rag, dashboard, timer sync)
# 6. symlink-uri unit -> ~/.config/systemd/user/ (bridge, rag, timer sync)
# 7. systemd-analyze verify + enable
set -uo pipefail
@@ -22,7 +27,6 @@ SRC="$(dirname "$HERE")" # .../maria-whatsapp-bridge
STATE_DIR="$HOME/.maria-bridge"
UNIT_DIR="$HOME/.config/systemd/user"
UNITS=(maria-whatsapp.service maria-rag.service maria-sync.service maria-sync.timer)
DASH_UNIT="maria-dashboard.service"
DO_START=0
[ "${1:-}" = "--start" ] && DO_START=1
@@ -96,16 +100,6 @@ for u in "${UNITS[@]}"; do
ln -sfn "$HERE/$u" "$UNIT_DIR/$u"
info "unit legat: $UNIT_DIR/$u -> $HERE/$u"
done
ln -sfn "$HERE/$DASH_UNIT" "$UNIT_DIR/$DASH_UNIT"
info "unit legat: $UNIT_DIR/$DASH_UNIT -> $HERE/$DASH_UNIT"
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 || warn "daemon-reload a esuat (sesiune fara systemd de utilizator?)"
@@ -134,9 +128,6 @@ for u in maria-whatsapp.service maria-rag.service; do
&& info "$u enabled (porneste la boot)" \
|| warn "enable pentru $u a esuat"
done
systemctl --user enable --now "$DASH_UNIT" >/dev/null 2>&1 \
&& info "dashboard enabled + pornit pe 127.0.0.1:$(grep -E '^DASHBOARD_PORT=' "$STATE_DIR/env" | cut -d= -f2- || echo 18792)" \
|| warn "enable pentru $DASH_UNIT a esuat"
systemctl --user enable --now maria-sync.timer >/dev/null 2>&1 \
&& info "timer de sincronizare Drive activat (la fiecare 10 min)" \
|| warn "enable pentru maria-sync.timer a esuat"
@@ -144,8 +135,8 @@ systemctl --user enable --now maria-sync.timer >/dev/null 2>&1 \
if [ "$DO_START" -eq 1 ]; then
systemctl --user restart maria-whatsapp.service && info "punte WhatsApp pornita"
echo
echo " Deschide dashboard-ul (127.0.0.1:\$DASHBOARD_PORT, tunel SSH) si scaneaza"
echo " codul QR din cardul 'Conectare WhatsApp' inainte sa pornesti maria-rag."
echo " Deschide dashboard-ul puntii Discord (sectiunea 'Maria — WhatsApp + RAG')"
echo " si scaneaza codul QR din cardul 'Conectare WhatsApp' inainte sa pornesti maria-rag."
systemctl --user restart maria-rag.service && info "consumer RAG pornit"
systemctl --user --no-pager status maria-whatsapp.service | sed 's/^/ /'
else

View File

@@ -1,38 +0,0 @@
# Unit systemd de UTILIZATOR pentru dashboard-ul de control al puntii Maria.
#
# Se instaleaza in ~/.config/systemd/user/maria-dashboard.service (vezi install.sh).
# Unit SEPARAT de bridge/rag, ca o repornire a lor sa nu ia si panoul din care
# ai apasat butonul (acelasi motiv ca la claude-discord-dashboard.service).
#
# systemctl --user daemon-reload
# systemctl --user enable --now maria-dashboard
# journalctl --user -u maria-dashboard -f
[Unit]
Description=Dashboard de control pentru puntea WhatsApp+RAG a lui Maria (LXC 171)
Documentation=file:///workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/README.md
After=network.target
[Service]
Type=simple
WorkingDirectory=%h/.maria-bridge
EnvironmentFile=%h/.maria-bridge/env
Environment=MARIA_BRIDGE_DIR=%h/.maria-bridge
Environment=PYTHONUNBUFFERED=1
# Stdlib only, dar are nevoie sa dea `systemctl --user` — acelasi utilizator ca
# bridge/rag, deci merge fara sudo.
ExecStart=/usr/bin/python3 /workspace/romfastsql/proxmox/lxc171-claude-agent/maria-whatsapp-bridge/dashboard/api.py
Restart=always
RestartSec=2
MemoryHigh=192M
MemoryMax=384M
StandardOutput=append:%h/.maria-bridge/logs/dashboard.log
StandardError=append:%h/.maria-bridge/logs/dashboard.log
SyslogIdentifier=maria-dashboard
[Install]
WantedBy=default.target

View File

@@ -34,8 +34,6 @@ DEFAULTS: dict[str, str] = {
"MAX_TOKENS": "250",
"POLL_INTERVAL_S": "2",
"TEST_MODE_SELF_CHAT_ONLY": "true",
"DASHBOARD_BIND": "127.0.0.1",
"DASHBOARD_PORT": "18792",
# Tinta rclone pentru sincronizarea depozitului de documente, ex:
# "gdrive:romfast/document_store" (dosarul D:\GoogleDrive\romfast\document_store
# de pe Windows, vazut prin Google Drive API). Gol = sincronizare dezactivata,

File diff suppressed because it is too large Load Diff