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:
@@ -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`.
|
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`.
|
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 |
|
| Fisier | Ce e |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| `~/.claude-discord/env` | token + allowlist + limite (0600) |
|
| `~/.claude-discord/env` | token + allowlist + limite (0600) |
|
||||||
|
|||||||
@@ -21,12 +21,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -47,6 +50,120 @@ from limits import parse_cap # noqa: E402
|
|||||||
SERVICE = "claude-discord.service"
|
SERVICE = "claude-discord.service"
|
||||||
SELF_SERVICE = "claude-discord-dashboard.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()`
|
# 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).
|
# 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()})
|
return self.send_json({"checks": doctor()})
|
||||||
if path == "/api/cleanup":
|
if path == "/api/cleanup":
|
||||||
return self.send_json(orphans_report(dry_run=True))
|
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)
|
return self.send_json({"error": "ruta necunoscuta"}, 404)
|
||||||
|
|
||||||
# --- POST ----------------------------------------------------------
|
# --- POST ----------------------------------------------------------
|
||||||
@@ -547,6 +686,17 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
return self.send_json(orphans_report(dry_run=bool(data.get("dry_run", True))))
|
return self.send_json(orphans_report(dry_run=bool(data.get("dry_run", True))))
|
||||||
if path == "/api/approvals/decide":
|
if path == "/api/approvals/decide":
|
||||||
return self.handle_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":
|
if path == "/api/restart-self":
|
||||||
self.send_json({"ok": True, "message": "dashboard-ul reporneste in 1s"})
|
self.send_json({"ok": True, "message": "dashboard-ul reporneste in 1s"})
|
||||||
threading.Thread(target=lambda: (time.sleep(1), os._exit(0)), daemon=True).start()
|
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},
|
return self.send_json({"ok": bool(ok), "request_id": rid, "decision": decision},
|
||||||
200 if ok else 404)
|
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:
|
def main() -> None:
|
||||||
bind = config.get("DASHBOARD_BIND") or "127.0.0.1"
|
bind = config.get("DASHBOARD_BIND") or "127.0.0.1"
|
||||||
|
|||||||
@@ -71,6 +71,70 @@
|
|||||||
<pre class="log" id="log">se încarcă…</pre>
|
<pre class="log" id="log">se încarcă…</pre>
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
@@ -274,10 +338,125 @@ async function refreshLogs() {
|
|||||||
el.scrollTop = el.scrollHeight;
|
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 ───────────────────────────────────────────────────
|
// ── pornire ───────────────────────────────────────────────────
|
||||||
refresh(); refreshDoctor(); refreshLogs();
|
refresh(); refreshDoctor(); refreshLogs();
|
||||||
|
mRefresh(); mRefreshLogs();
|
||||||
setInterval(refresh, 5000);
|
setInterval(refresh, 5000);
|
||||||
setInterval(refreshDoctor, 30000);
|
setInterval(refreshDoctor, 30000);
|
||||||
|
setInterval(mRefresh, 4000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
Bot de suport ROA pe WhatsApp: puntea WhatsApp (Baileys) primeste mesajele,
|
Bot de suport ROA pe WhatsApp: puntea WhatsApp (Baileys) primeste mesajele,
|
||||||
consumer-ul RAG raspunde folosind DOAR informatiile dintr-un depozit de
|
consumer-ul RAG raspunde folosind DOAR informatiile dintr-un depozit de
|
||||||
documente indexat cu embeddings (Ollama), iar un dashboard web controleaza
|
documente indexat cu embeddings (Ollama). Controlul (start/stop/restart,
|
||||||
totul (start/stop/restart, documente, reindexare, sincronizare Google Drive).
|
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
|
Continua prototipul descris in
|
||||||
`claude-agent/docs/maria-whatsapp-rag-prototype.md` (construit initial 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/indexer.py -- chunking + embeddings Ollama -> rag_index.json
|
||||||
rag/sync.py -- rclone pull din Google Drive + reindexare conditionata
|
rag/sync.py -- rclone pull din Google Drive + reindexare conditionata
|
||||||
|
|
||||||
dashboard/api.py -- panou web (stdlib, fara dependinte), :18792
|
../discord-bridge/dashboard/api.py -- panou comun; sectiunea Maria controleaza
|
||||||
controleaza maria-whatsapp.service + maria-rag.service,
|
maria-whatsapp.service + maria-rag.service, gestioneaza
|
||||||
gestioneaza documentele si declanseaza sincronizare/reindexare
|
documentele si declanseaza sincronizare/reindexare
|
||||||
```
|
```
|
||||||
|
|
||||||
Servicii `systemctl --user` (vezi `ops/`):
|
Servicii `systemctl --user` (vezi `ops/`):
|
||||||
@@ -43,7 +46,6 @@ Servicii `systemctl --user` (vezi `ops/`):
|
|||||||
| `maria-whatsapp.service` | Puntea Baileys (Node), port 8099 |
|
| `maria-whatsapp.service` | Puntea Baileys (Node), port 8099 |
|
||||||
| `maria-rag.service` | Consumer RAG (Python), polling + raspunsuri |
|
| `maria-rag.service` | Consumer RAG (Python), polling + raspunsuri |
|
||||||
| `maria-sync.service` + `.timer` | Sincronizare Drive + reindexare, la 10 min |
|
| `maria-sync.service` + `.timer` | Sincronizare Drive + reindexare, la 10 min |
|
||||||
| `maria-dashboard.service` | Panou de control, port 18792 |
|
|
||||||
|
|
||||||
## Instalare
|
## Instalare
|
||||||
|
|
||||||
@@ -61,32 +63,26 @@ Bridge-ul WhatsApp NU porneste automat la instalare — cere scanarea unui cod Q
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./ops/install.sh --start
|
./ops/install.sh --start
|
||||||
# apoi deschide dashboard-ul (tunel SSH catre 127.0.0.1:18792) si scaneaza
|
# apoi deschide dashboard-ul puntii Discord si scaneaza codul QR din
|
||||||
# codul QR din cardul "Conectare WhatsApp"
|
# sectiunea "Maria — WhatsApp + RAG" -> cardul "Conectare WhatsApp"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard (comun cu puntea Discord)
|
||||||
|
|
||||||
```bash
|
Nu exista un dashboard separat pentru Maria. Controlul se face din dashboard-ul
|
||||||
ssh -L 18792:127.0.0.1:18792 -N claude@10.0.20.171 &
|
puntii Discord — vezi `../discord-bridge/README.md` pentru URL si autentificare
|
||||||
# apoi: http://localhost:18792
|
(`DASHBOARD_TOKEN` din `~/.claude-discord/env`, tunel SSH sau Tailscale la
|
||||||
```
|
`/claude`). Acolo, sectiunea "Maria — WhatsApp + RAG":
|
||||||
|
|
||||||
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:
|
|
||||||
- start/stop/restart pentru puntea WhatsApp si consumer-ul RAG
|
- start/stop/restart pentru puntea WhatsApp si consumer-ul RAG
|
||||||
- vezi starea conexiunii WhatsApp si codul QR de asociere (cand nu e conectat)
|
- starea conexiunii WhatsApp si codul QR de asociere (cand nu e conectat)
|
||||||
- listezi, adaugi si stergi documente din depozitul RAG
|
- listare, adaugare si stergere documente din depozitul RAG
|
||||||
- reconstruiesti indexul manual, sau declansezi o sincronizare Drive imediata
|
- reconstruire index manual, sau sincronizare Drive imediata
|
||||||
- citesti ultimele linii din logurile fiecarui serviciu
|
- ultimele linii din logurile fiecarui serviciu Maria (`whatsapp.log`/`rag.log`)
|
||||||
|
|
||||||
## Depozitul de documente
|
## Depozitul de documente
|
||||||
|
|
||||||
Fisiere `.txt`/`.md` in `~/.maria-bridge/documents/`. Se pot administra:
|
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.
|
2. **prin sincronizare Google Drive** — vezi mai jos.
|
||||||
|
|
||||||
## Sincronizare cu Google Drive
|
## Sincronizare cu Google Drive
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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 → 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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -32,11 +32,8 @@ POLL_INTERVAL_S=2
|
|||||||
# headless).
|
# headless).
|
||||||
DRIVE_REMOTE=
|
DRIVE_REMOTE=
|
||||||
|
|
||||||
# --- dashboard de control (dashboard/api.py) --------------------------------
|
# --- control -----------------------------------------------------------------
|
||||||
# Token de acces. Generat automat de ops/install.sh daca lipseste.
|
# Fara dashboard propriu: start/stop/restart, documente, reindexare si
|
||||||
DASHBOARD_TOKEN=
|
# sincronizare Drive se fac din dashboard-ul puntii Discord (discord-bridge/),
|
||||||
# `off` scoate complet login-ul. De pus DOAR daca panoul ramane strict pe
|
# sectiunea "Maria — WhatsApp + RAG" — un singur panou comun, o singura
|
||||||
# 127.0.0.1 / tailnet (acelasi motiv ca la discord-bridge).
|
# autentificare (DASHBOARD_TOKEN din ~/.claude-discord/env).
|
||||||
DASHBOARD_AUTH=
|
|
||||||
DASHBOARD_BIND=127.0.0.1
|
|
||||||
DASHBOARD_PORT=18792
|
|
||||||
|
|||||||
@@ -7,13 +7,18 @@
|
|||||||
# ./ops/install.sh # instaleaza / actualizeaza, NU porneste serviciile
|
# ./ops/install.sh # instaleaza / actualizeaza, NU porneste serviciile
|
||||||
# ./ops/install.sh --start # in plus porneste bridge+rag (bridge cere QR scanat)
|
# ./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:
|
# Ce face:
|
||||||
# 1. ~/.maria-bridge/ cu 0700, subdirectoare (logs/, documents/, whatsapp-auth/)
|
# 1. ~/.maria-bridge/ cu 0700, subdirectoare (logs/, documents/, whatsapp-auth/)
|
||||||
# 2. env 0600 din ops/env.example (doar daca lipseste)
|
# 2. env 0600 din ops/env.example (doar daca lipseste)
|
||||||
# 3. npm install pentru whatsapp/ (Baileys)
|
# 3. npm install pentru whatsapp/ (Baileys)
|
||||||
# 4. venv + pip install pentru rag/requirements.txt
|
# 4. venv + pip install pentru rag/requirements.txt
|
||||||
# 5. loginctl enable-linger (serviciile de utilizator supravietuiesc logout-ului)
|
# 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
|
# 7. systemd-analyze verify + enable
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -22,7 +27,6 @@ SRC="$(dirname "$HERE")" # .../maria-whatsapp-bridge
|
|||||||
STATE_DIR="$HOME/.maria-bridge"
|
STATE_DIR="$HOME/.maria-bridge"
|
||||||
UNIT_DIR="$HOME/.config/systemd/user"
|
UNIT_DIR="$HOME/.config/systemd/user"
|
||||||
UNITS=(maria-whatsapp.service maria-rag.service maria-sync.service maria-sync.timer)
|
UNITS=(maria-whatsapp.service maria-rag.service maria-sync.service maria-sync.timer)
|
||||||
DASH_UNIT="maria-dashboard.service"
|
|
||||||
DO_START=0
|
DO_START=0
|
||||||
[ "${1:-}" = "--start" ] && DO_START=1
|
[ "${1:-}" = "--start" ] && DO_START=1
|
||||||
|
|
||||||
@@ -96,16 +100,6 @@ for u in "${UNITS[@]}"; do
|
|||||||
ln -sfn "$HERE/$u" "$UNIT_DIR/$u"
|
ln -sfn "$HERE/$u" "$UNIT_DIR/$u"
|
||||||
info "unit legat: $UNIT_DIR/$u -> $HERE/$u"
|
info "unit legat: $UNIT_DIR/$u -> $HERE/$u"
|
||||||
done
|
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?)"
|
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)" \
|
&& info "$u enabled (porneste la boot)" \
|
||||||
|| warn "enable pentru $u a esuat"
|
|| warn "enable pentru $u a esuat"
|
||||||
done
|
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 \
|
systemctl --user enable --now maria-sync.timer >/dev/null 2>&1 \
|
||||||
&& info "timer de sincronizare Drive activat (la fiecare 10 min)" \
|
&& info "timer de sincronizare Drive activat (la fiecare 10 min)" \
|
||||||
|| warn "enable pentru maria-sync.timer a esuat"
|
|| 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
|
if [ "$DO_START" -eq 1 ]; then
|
||||||
systemctl --user restart maria-whatsapp.service && info "punte WhatsApp pornita"
|
systemctl --user restart maria-whatsapp.service && info "punte WhatsApp pornita"
|
||||||
echo
|
echo
|
||||||
echo " Deschide dashboard-ul (127.0.0.1:\$DASHBOARD_PORT, tunel SSH) si scaneaza"
|
echo " Deschide dashboard-ul puntii Discord (sectiunea 'Maria — WhatsApp + RAG')"
|
||||||
echo " codul QR din cardul 'Conectare WhatsApp' inainte sa pornesti maria-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 restart maria-rag.service && info "consumer RAG pornit"
|
||||||
systemctl --user --no-pager status maria-whatsapp.service | sed 's/^/ /'
|
systemctl --user --no-pager status maria-whatsapp.service | sed 's/^/ /'
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -34,8 +34,6 @@ DEFAULTS: dict[str, str] = {
|
|||||||
"MAX_TOKENS": "250",
|
"MAX_TOKENS": "250",
|
||||||
"POLL_INTERVAL_S": "2",
|
"POLL_INTERVAL_S": "2",
|
||||||
"TEST_MODE_SELF_CHAT_ONLY": "true",
|
"TEST_MODE_SELF_CHAT_ONLY": "true",
|
||||||
"DASHBOARD_BIND": "127.0.0.1",
|
|
||||||
"DASHBOARD_PORT": "18792",
|
|
||||||
# Tinta rclone pentru sincronizarea depozitului de documente, ex:
|
# Tinta rclone pentru sincronizarea depozitului de documente, ex:
|
||||||
# "gdrive:romfast/document_store" (dosarul D:\GoogleDrive\romfast\document_store
|
# "gdrive:romfast/document_store" (dosarul D:\GoogleDrive\romfast\document_store
|
||||||
# de pe Windows, vazut prin Google Drive API). Gol = sincronizare dezactivata,
|
# de pe Windows, vazut prin Google Drive API). Gol = sincronizare dezactivata,
|
||||||
|
|||||||
2607
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp/package-lock.json
generated
Normal file
2607
proxmox/lxc171-claude-agent/maria-whatsapp-bridge/whatsapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user