Un client poate avea mai multi angajati care scriu pe WhatsApp de pe numere diferite; toti gasesc acum acelasi profil (rag/client.py, ~/.maria-bridge/clients.json). Profilul intra in prompt sub o sectiune DESPRE CLIENT, la fiecare mesaj, stateless ca restul Mariei — dar nu atinge cautarea/scorul din rag/rank.py, ca sa nu strice pragurile deja calibrate. Administrare din dashboard (sectiune noua "Clienti cunoscuti"), nu din chat, ca sa ramana pe canalul autentificat. 117/117 teste maria-whatsapp-bridge + teste noi pentru rutele de dashboard. Co-Authored-By: Claude Agent <noreply@anthropic.com>
620 lines
29 KiB
HTML
620 lines
29 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ro" data-theme="dark">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Punte Discord — control</title>
|
|
<link rel="stylesheet" href="static/tokens.css">
|
|
<link rel="stylesheet" href="static/app.css">
|
|
</head>
|
|
<body>
|
|
|
|
<header class="header">
|
|
<span class="logo"><span class="dot" id="dot"></span> Punte Discord</span>
|
|
<span class="spacer"></span>
|
|
<span class="meta" id="lastPoll">—</span>
|
|
<button class="small" onclick="toggleTheme()" title="Schimbă tema">◐</button>
|
|
<button class="small" id="btnLogout" onclick="logout()" hidden>Ieși</button>
|
|
</header>
|
|
|
|
<div class="wrap">
|
|
|
|
<!-- ── serviciu ───────────────────────────────────────────── -->
|
|
<section class="card">
|
|
<h2>Serviciu <span class="count" id="unitName"></span></h2>
|
|
<div class="metrics" id="metrics"></div>
|
|
<div class="actions" style="margin-top:var(--space-5)">
|
|
<button class="primary" id="btnRestart" onclick="svc('restart')">Repornește</button>
|
|
<button id="btnStart" onclick="svc('start')">Pornește</button>
|
|
<button class="danger" id="btnStop" onclick="svc('stop')">Oprește</button>
|
|
<span class="spacer"></span>
|
|
<button class="small" onclick="cleanup(true)">Caută orfani</button>
|
|
<button class="small danger" onclick="cleanup(false)">Curăță orfani</button>
|
|
<button class="small" onclick="restartSelf()">Repornește dashboard-ul</button>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="grid-2">
|
|
<!-- ── fire ─────────────────────────────────────────────── -->
|
|
<section class="card">
|
|
<h2>Fire active <span class="count" id="threadCount"></span></h2>
|
|
<div class="table-scroll">
|
|
<table class="stack-mobil">
|
|
<thead><tr><th>Fir</th><th>Model</th><th>Director</th><th>Stare</th><th class="num">USD</th></tr></thead>
|
|
<tbody id="threads"></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── diagnostic ───────────────────────────────────────── -->
|
|
<section class="card">
|
|
<h2>Diagnostic</h2>
|
|
<div id="doctor"><div class="empty">se încarcă…</div></div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- ── aprobări ───────────────────────────────────────────── -->
|
|
<section class="card">
|
|
<h2>Confirmări în așteptare <span class="count" id="apprCount"></span></h2>
|
|
<div id="approvals"><div class="empty">niciuna</div></div>
|
|
</section>
|
|
|
|
<!-- ── jurnal ─────────────────────────────────────────────── -->
|
|
<section class="card">
|
|
<h2>Jurnal
|
|
<span class="count">
|
|
<button class="small" id="tabBot" onclick="setLog('bot')">bot.log</button>
|
|
<button class="small" id="tabInfra" onclick="setLog('infra')">infra.log</button>
|
|
<button class="small" onclick="refreshLogs()">↻</button>
|
|
</span>
|
|
</h2>
|
|
<pre class="log" id="log">se încarcă…</pre>
|
|
</section>
|
|
|
|
<!-- ── 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>
|
|
<div id="mWaPair" hidden>
|
|
<div class="detail" style="margin-top:var(--space-5)">
|
|
Nu poți scana codul QR? Cere un <strong>cod de asociere</strong> și
|
|
introdu-l pe telefon în WhatsApp → Dispozitive conectate → Conectează un
|
|
dispozitiv → <em>Conectează cu număr de telefon</em>.
|
|
</div>
|
|
<div class="actions" style="margin-top:var(--space-3)">
|
|
<input type="text" id="mPairPhone" placeholder="40723197939 (internațional, fără +)"
|
|
style="flex:1;min-width:220px">
|
|
<button onclick="mPair()">Cere cod de asociere</button>
|
|
</div>
|
|
<div id="mPairOut" class="detail" style="margin-top:var(--space-3)"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Trimise la suport <span class="count" id="mEscCount"></span></h2>
|
|
<div class="detail" id="mSupportDetail"></div>
|
|
<div id="mEsc" class="empty">se încarcă…</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Clienți cunoscuți <span class="count" id="mClientCount"></span></h2>
|
|
<div class="table-scroll">
|
|
<table class="stack-mobil">
|
|
<thead><tr><th>Nume</th><th>Numere</th><th></th><th></th></tr></thead>
|
|
<tbody id="mClients"></tbody>
|
|
</table>
|
|
</div>
|
|
<h2 style="margin-top:var(--space-5)" id="mClientFormTitle">Adaugă client</h2>
|
|
<input type="text" id="mClientId" placeholder="id (ex: acme)" style="width:100%;margin-bottom:var(--space-3)">
|
|
<input type="text" id="mClientNume" placeholder="nume firmă" style="width:100%;margin-bottom:var(--space-3)">
|
|
<textarea id="mClientProfil" placeholder="profil (context liber despre client)..." style="width:100%;min-height:80px;margin-bottom:var(--space-3)"></textarea>
|
|
<textarea id="mClientNumere" placeholder="numere de telefon, unul pe linie" style="width:100%;min-height:80px;font-family:monospace"></textarea>
|
|
<div class="actions" style="margin-top:var(--space-3)">
|
|
<button class="primary" onclick="mSaveClient()">Salvează</button>
|
|
<button class="small" onclick="mResetClientForm()">Renunță</button>
|
|
</div>
|
|
<div class="detail" id="mClientMsg"></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>
|
|
|
|
<script>
|
|
// ── tema ──────────────────────────────────────────────────────
|
|
(function () {
|
|
var saved = localStorage.getItem('theme');
|
|
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
|
})();
|
|
function toggleTheme() {
|
|
var next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
|
|
document.documentElement.setAttribute('data-theme', next);
|
|
localStorage.setItem('theme', next);
|
|
}
|
|
|
|
// ── utilitare ─────────────────────────────────────────────────
|
|
function esc(s) {
|
|
return String(s === null || s === undefined ? '' : s)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
function toast(msg, kind) {
|
|
var el = document.createElement('div');
|
|
el.className = 'toast ' + (kind || '');
|
|
el.textContent = msg;
|
|
document.getElementById('toast').appendChild(el);
|
|
setTimeout(function () { el.remove(); }, 6000);
|
|
}
|
|
async function api(path, opts) {
|
|
var r = await fetch(path, opts || {});
|
|
if (r.status === 401) { location.href = 'login.html'; throw new Error('neautentificat'); }
|
|
var data = null;
|
|
try { data = await r.json(); } catch (e) { data = {}; }
|
|
return { ok: r.ok, status: r.status, data: data };
|
|
}
|
|
function post(path, body) {
|
|
return api(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body || {})
|
|
});
|
|
}
|
|
function dur(s) {
|
|
if (s === null || s === undefined) return '—';
|
|
s = Math.max(0, Math.floor(s));
|
|
var d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
|
|
if (d) return d + 'z ' + h + 'h';
|
|
if (h) return h + 'h ' + m + 'm';
|
|
if (m) return m + 'm';
|
|
return s + 's';
|
|
}
|
|
function mb(bytes) {
|
|
return bytes === null || bytes === undefined ? '—' : (bytes / 1048576).toFixed(0) + ' MB';
|
|
}
|
|
|
|
// ── stare ─────────────────────────────────────────────────────
|
|
var busy = false;
|
|
async function refresh() {
|
|
var res = await api('api/status');
|
|
if (!res.ok) return;
|
|
var s = res.data, svcInfo = s.service;
|
|
|
|
// fara autentificare, butonul "Ieși" nu are ce sa faca
|
|
document.getElementById('btnLogout').hidden = (s.auth === false);
|
|
document.getElementById('unitName').textContent = svcInfo.unit;
|
|
document.getElementById('dot').className = 'dot ' + (svcInfo.active ? 'on' : 'off');
|
|
document.getElementById('lastPoll').textContent =
|
|
(s.user ? s.user + ' · ' : '') + 'actualizat ' + new Date().toLocaleTimeString('ro-RO');
|
|
|
|
var capTxt = s.cost_cap > 0 ? ' / ' + s.cost_cap.toFixed(2) : '';
|
|
document.getElementById('metrics').innerHTML = [
|
|
['Stare', '<span class="pill ' + (svcInfo.active ? 'ok' : 'bad') + '">' + esc(svcInfo.state) + '/' + esc(svcInfo.sub) + '</span>'],
|
|
['Uptime', dur(svcInfo.uptime_s)],
|
|
['PID', svcInfo.pid || '—'],
|
|
['Memorie cgroup', mb(svcInfo.memory_bytes)],
|
|
['Restarturi', svcInfo.restarts],
|
|
['Fire', s.threads.length],
|
|
['Cost azi', (Number((s.cost || {}).usd || 0)).toFixed(2) + capTxt + ' USD']
|
|
].map(function (m) {
|
|
return '<div class="metric"><div class="label">' + m[0] + '</div><div class="value">' + m[1] + '</div></div>';
|
|
}).join('');
|
|
|
|
document.getElementById('btnStart').disabled = busy || svcInfo.active;
|
|
document.getElementById('btnStop').disabled = busy || !svcInfo.active;
|
|
document.getElementById('btnRestart').disabled = busy;
|
|
|
|
// fire
|
|
document.getElementById('threadCount').textContent = s.threads.length + ' în state.json';
|
|
document.getElementById('threads').innerHTML = s.threads.length ? s.threads.map(function (t) {
|
|
var st = t.inflight ? '<span class="pill busy">tur în lucru</span>'
|
|
: t.alive ? '<span class="pill ok">proces viu</span>'
|
|
: '<span class="pill">inactiv</span>';
|
|
// data-label: pe telefon capul de tabel dispare si eticheta vine din CSS
|
|
return '<tr><td class="mono" data-label="Fir">' + esc(t.thread_id) +
|
|
'</td><td data-label="Model">' + esc(t.model || '—') +
|
|
'</td><td class="mono" data-label="Director">' + esc(t.cwd || '—') +
|
|
'</td><td data-label="Stare">' + st +
|
|
'</td><td class="num" data-label="USD">' + Number(t.cost_usd || 0).toFixed(2) +
|
|
'</td></tr>';
|
|
}).join('') : '<tr><td colspan="5" class="empty" data-label="">niciun fir înregistrat</td></tr>';
|
|
|
|
// aprobări
|
|
document.getElementById('apprCount').textContent = s.pending_approvals || '';
|
|
if (s.pending_approvals) { refreshApprovals(); }
|
|
else { document.getElementById('approvals').innerHTML = '<div class="empty">niciuna</div>'; }
|
|
}
|
|
|
|
async function refreshApprovals() {
|
|
var res = await api('api/approvals');
|
|
if (!res.ok) return;
|
|
var list = res.data.approvals || [];
|
|
document.getElementById('approvals').innerHTML = list.length ? list.map(function (a) {
|
|
return '<div class="check"><div style="flex:1">' +
|
|
'<div class="mono" style="color:var(--text-primary)">' + esc(a.command) + '</div>' +
|
|
'<div class="detail">' + esc(a.reason || a.rule || '') +
|
|
(a.expires_in !== null && a.expires_in !== undefined ? ' · expiră în ' + dur(a.expires_in) : '') +
|
|
' · fir ' + esc(a.thread_id) + '</div></div>' +
|
|
'<div class="check-actions">' +
|
|
'<button class="small primary" onclick="decide(\'' + esc(a.request_id) + '\',\'allow\')">Permite</button>' +
|
|
'<button class="small" title="Permite și nu mai întreba pentru operațiuni de același fel în acest fir" onclick="decide(\'' + esc(a.request_id) + '\',\'allow_session\')">Permite (tot firul)</button>' +
|
|
'<button class="small danger" onclick="decide(\'' + esc(a.request_id) + '\',\'deny\')">Refuză</button>' +
|
|
'</div></div>';
|
|
}).join('') : '<div class="empty">niciuna</div>';
|
|
}
|
|
|
|
async function decide(id, d) {
|
|
var res = await post('api/approvals/decide', { request_id: id, decision: d });
|
|
toast(res.ok ? 'Trimis: ' + d : ('Eșuat: ' + (res.data.error || res.status)), res.ok ? 'ok' : 'bad');
|
|
refresh();
|
|
}
|
|
|
|
async function refreshDoctor() {
|
|
var res = await api('api/doctor');
|
|
if (!res.ok) return;
|
|
document.getElementById('doctor').innerHTML = (res.data.checks || []).map(function (c) {
|
|
return '<div class="check ' + (c.pass ? 'pass' : 'fail') + '"><span class="mark">' +
|
|
(c.pass ? '✓' : '✗') + '</span><span class="name">' + esc(c.name) +
|
|
'</span><span class="detail">' + esc(c.detail) + '</span></div>';
|
|
}).join('');
|
|
}
|
|
|
|
// ── acțiuni ───────────────────────────────────────────────────
|
|
async function svc(action, force) {
|
|
if (busy) return;
|
|
if (action !== 'start' && !force &&
|
|
!confirm('Sigur „' + action + '” pe serviciul punții?')) return;
|
|
busy = true;
|
|
try {
|
|
var res = await post('api/service', { action: action, force: !!force });
|
|
if (res.status === 409) {
|
|
var n = (res.data.inflight || []).length;
|
|
if (confirm(n + ' fir(e) au tur în desfășurare. Le întrerupi?')) {
|
|
busy = false;
|
|
return svc(action, true);
|
|
}
|
|
toast('Anulat — tururile continuă.', '');
|
|
} else if (res.ok) {
|
|
toast('Serviciu: ' + action + ' OK', 'ok');
|
|
} else {
|
|
toast('Eșuat: ' + (res.data.error || res.status), 'bad');
|
|
}
|
|
} finally {
|
|
busy = false;
|
|
refresh();
|
|
refreshDoctor();
|
|
}
|
|
}
|
|
|
|
async function cleanup(dry) {
|
|
if (!dry && !confirm('Omor procesele orfane găsite?')) return;
|
|
var res = await post('api/cleanup', { dry_run: dry });
|
|
if (!res.ok) { toast('Eșuat: ' + (res.data.error || res.status), 'bad'); return; }
|
|
var n = (res.data.orphans || []).length;
|
|
toast(dry ? (n + ' orfan(i) găsiți') : (n + ' orfan(i) tratați'), n ? 'bad' : 'ok');
|
|
document.getElementById('log').textContent = res.data.report || '(fără raport)';
|
|
}
|
|
|
|
async function restartSelf() {
|
|
if (!confirm('Repornesc dashboard-ul? Pagina se reîncarcă în câteva secunde.')) return;
|
|
await post('api/restart-self', {});
|
|
toast('Dashboard-ul repornește…', '');
|
|
setTimeout(function () { location.reload(); }, 4000);
|
|
}
|
|
|
|
async function logout() {
|
|
await post('api/auth/logout', {});
|
|
location.href = 'login.html';
|
|
}
|
|
|
|
// ── jurnal ────────────────────────────────────────────────────
|
|
var logFile = 'bot';
|
|
function setLog(which) { logFile = which; refreshLogs(); }
|
|
async function refreshLogs() {
|
|
document.getElementById('tabBot').className = 'small' + (logFile === 'bot' ? ' primary' : '');
|
|
document.getElementById('tabInfra').className = 'small' + (logFile === 'infra' ? ' primary' : '');
|
|
var res = await api('api/logs?lines=300&file=' + logFile);
|
|
if (!res.ok) return;
|
|
var el = document.getElementById('log');
|
|
el.textContent = (res.data.lines || []).join('\n') || '(gol)';
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
|
|
// ── 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('');
|
|
}
|
|
|
|
function mShowPairCode(code, expiresIn) {
|
|
var pretty = code.length === 8 ? code.slice(0, 4) + '-' + code.slice(4) : code;
|
|
var out = document.getElementById('mPairOut');
|
|
out.dataset.code = code;
|
|
out.innerHTML =
|
|
'<div style="font-family:monospace;font-size:1.9rem;letter-spacing:.18em;margin:var(--space-3) 0">'
|
|
+ esc(pretty) + '</div><span class="muted">tastează-l pe telefon'
|
|
+ (expiresIn ? ' — expiră în ~' + Math.round(expiresIn / 60) + ' min' : '')
|
|
+ '. Dacă expiră, cere altul.</span>';
|
|
}
|
|
|
|
async function mPair() {
|
|
var phone = document.getElementById('mPairPhone').value.trim();
|
|
var out = document.getElementById('mPairOut');
|
|
out.textContent = 'cer codul…';
|
|
var res = await post('api/maria/pair', { phone: phone });
|
|
if (res.ok && res.data && res.data.code) {
|
|
mShowPairCode(res.data.code, res.data.expires_in);
|
|
} else {
|
|
out.innerHTML = '<span class="pill bad">eroare</span> '
|
|
+ esc((res.data && res.data.error) || 'nu am putut cere codul');
|
|
}
|
|
mRefresh();
|
|
}
|
|
|
|
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');
|
|
// Cardul de asociere are rost doar cât timp puntea rulează și nu e conectată.
|
|
document.getElementById('mWaPair').hidden = !!wa.connected || wa.reachable === false;
|
|
var pairOut = document.getElementById('mPairOut');
|
|
if (wa.pairing && wa.pairing.code) {
|
|
mShowPairCode(wa.pairing.code, wa.pairing.expires_in);
|
|
} else if (!wa.connected && pairOut.dataset.code) {
|
|
// Codul afisat a expirat la punte. Lasat pe ecran, ar trimite omul sa
|
|
// tasteze un cod pe care WhatsApp il refuza fara sa spuna de ce.
|
|
pairOut.innerHTML = '<span class="muted">codul a expirat — cere altul</span>';
|
|
delete pairOut.dataset.code;
|
|
}
|
|
if (wa.connected) {
|
|
waStatus.innerHTML = '<span class="pill ok">conectat</span> ca ' + esc(wa.phone || '?');
|
|
waQr.innerHTML = '';
|
|
document.getElementById('mPairOut').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') : '');
|
|
|
|
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';
|
|
|
|
document.getElementById('mSupportDetail').textContent = s.support_jid
|
|
? 'Canal de suport: ' + s.support_jid
|
|
: 'SUPPORT_JID nesetat — escaladările se scriu doar în jurnal, nu anunță pe nimeni';
|
|
|
|
var escRes = await api('api/maria/escalations');
|
|
var escaladari = escRes.ok ? (escRes.data.escalations || []) : [];
|
|
document.getElementById('mEscCount').textContent = escaladari.length ? escaladari.length + ' recente' : '';
|
|
document.getElementById('mEsc').className = escaladari.length ? '' : 'empty';
|
|
document.getElementById('mEsc').innerHTML = escaladari.length ? escaladari.map(function (e) {
|
|
var stare = e.notified
|
|
? '<span class="pill ok">trimis</span>'
|
|
: '<span class="pill bad">netrimis' + (e.notify_error ? ' — ' + esc(e.notify_error) : '') + '</span>';
|
|
return '<div style="border-top:1px solid var(--border);padding:var(--space-4) 0">'
|
|
+ '<div>' + stare + ' <code>' + esc(e.ref || '') + '</code> <strong>' + esc(e.from || '?') + '</strong> '
|
|
+ '<span class="muted">' + (e.at ? new Date(e.at * 1000).toLocaleString('ro-RO') : '') + '</span>'
|
|
+ (e.had_image ? ' <span class="muted">· captură</span>' : '') + '</div>'
|
|
+ '<pre style="white-space:pre-wrap;margin:var(--space-3) 0;font-size:.85em">' + esc(e.text || '(fără text)') + '</pre>'
|
|
+ '<div class="muted">' + esc(e.reason || '') + '</div></div>';
|
|
}).join('') : 'nicio întrebare trimisă la suport';
|
|
|
|
var docsRes = await api('api/maria/documents');
|
|
var docs = docsRes.ok ? (docsRes.data.documents || []) : [];
|
|
var indexate = docs.filter(function (d) { return !d.shadowed_by; }).length;
|
|
document.getElementById('mDocCount').textContent =
|
|
docs.length + ' în depozit' + (indexate !== docs.length ? ' · ' + indexate + ' indexate' : '');
|
|
document.getElementById('mDocs').innerHTML = docs.length ? docs.map(function (d) {
|
|
var local = d.local ? ' <span class="muted" title="nu vine din Drive; sincronizarea nu il sterge">· local</span>' : '';
|
|
var umbrit = d.shadowed_by
|
|
? ' <span class="muted" title="acelaşi document exista intr-un format mai bogat">· umbrit de ' + esc(d.shadowed_by) + '</span>'
|
|
: '';
|
|
return '<tr><td data-label="Nume" class="mono">' + esc(d.name) + local + umbrit + '</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 mClientsCache = [];
|
|
async function mLoadClients() {
|
|
var res = await api('api/maria/clients');
|
|
var clienti = res.ok ? (res.data.clients || []) : [];
|
|
mClientsCache = clienti;
|
|
document.getElementById('mClientCount').textContent = clienti.length ? clienti.length : '';
|
|
document.getElementById('mClients').innerHTML = clienti.length ? clienti.map(function (c) {
|
|
return '<tr><td data-label="Nume">' + esc(c.nume || c.id) + '</td>'
|
|
+ '<td data-label="Numere" class="mono">' + esc((c.numere || []).join(', ')) + '</td>'
|
|
+ '<td><button class="small" onclick="mEditClient(\'' + esc(c.id) + '\')">Editează</button></td>'
|
|
+ '<td><button class="small danger" onclick="mDelClient(\'' + esc(c.id) + '\')">Șterge</button></td></tr>';
|
|
}).join('') : '<tr><td colspan="4" class="empty" data-label="">niciun client</td></tr>';
|
|
}
|
|
|
|
function mEditClient(id) {
|
|
var c = mClientsCache.find(function (x) { return x.id === id; });
|
|
if (!c) return;
|
|
document.getElementById('mClientId').value = c.id;
|
|
document.getElementById('mClientId').disabled = true;
|
|
document.getElementById('mClientNume').value = c.nume || '';
|
|
document.getElementById('mClientProfil').value = c.profil || '';
|
|
document.getElementById('mClientNumere').value = (c.numere || []).join('\n');
|
|
document.getElementById('mClientFormTitle').textContent = 'Editează client: ' + (c.nume || c.id);
|
|
document.getElementById('mClientMsg').textContent = '';
|
|
}
|
|
|
|
function mResetClientForm() {
|
|
document.getElementById('mClientId').value = '';
|
|
document.getElementById('mClientId').disabled = false;
|
|
document.getElementById('mClientNume').value = '';
|
|
document.getElementById('mClientProfil').value = '';
|
|
document.getElementById('mClientNumere').value = '';
|
|
document.getElementById('mClientFormTitle').textContent = 'Adaugă client';
|
|
document.getElementById('mClientMsg').textContent = '';
|
|
}
|
|
|
|
async function mSaveClient() {
|
|
var id = document.getElementById('mClientId').value.trim();
|
|
var nume = document.getElementById('mClientNume').value.trim();
|
|
var profil = document.getElementById('mClientProfil').value;
|
|
var numere = document.getElementById('mClientNumere').value;
|
|
var msg = document.getElementById('mClientMsg');
|
|
if (!id || !nume) { msg.textContent = 'completează id și nume'; return; }
|
|
var res = await post('api/maria/clients', { id: id, nume: nume, profil: profil, numere: numere });
|
|
if (!res.ok) { msg.textContent = 'eșuat: ' + (res.data.error || res.status); return; }
|
|
mResetClientForm();
|
|
await mLoadClients();
|
|
}
|
|
|
|
async function mDelClient(id) {
|
|
if (!confirm('Ștergi clientul ' + id + '?')) return;
|
|
await post('api/maria/clients/delete', { id: id });
|
|
await mLoadClients();
|
|
}
|
|
|
|
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(); mLoadClients();
|
|
setInterval(refresh, 5000);
|
|
setInterval(refreshDoctor, 30000);
|
|
setInterval(mRefresh, 4000);
|
|
</script>
|
|
</body>
|
|
</html>
|