f9ebcddf28
#1 JWT middleware extended: - Was: /api/admin/* only - Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT - Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password, /password/reset, /reset-password, /setup-password, /google; /api/gdpr/consent; any path ending /avatar - 14 mutating endpoints verified to return 401 without token #2 Avatar upload demo mode (routers/clan_panel_router.py): - Anonymous → returns {demo_mode:true, slika_url:null, message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'}, no FS write, no DB write - Authenticated (valid JWT, allowed role) → real save as before - Auth check now uses auth.auth_v2.decode_token (proper secret + revocation) instead of the broken local _resolve_role #3 Mock mailer (auth/mailer.py): - send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl - send_password_reset, send_invite helpers with HR text + HTML alt - Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default) - forgot-password and admin invite both call mailer; audit logs mail status #5 Rate limiting on /api/auth/login: - Per-user: 5 wrong attempts → 5-minute DB-backed lockout (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES. - Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429 Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful login clears the IP counter. - Failed attempts respond '(N/5) — račun je zaključan na 5 minuta' - New audit actions: login.ratelimit.ip; login.fail meta now includes fails count, locked, lock_minutes #4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14 mutating endpoints, public path whitelist, demo-mode avatar + real save, forgot-password e-mail to mailbox, no-leak unknown email, 5-fail lockout, 423 during lockout, audit coverage.
103 lines
5.5 KiB
Plaintext
103 lines
5.5 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="hr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>RINET KPI Dashboard</title>
|
|
<style>
|
|
body { font-family: -apple-system, sans-serif; background: #0a0e1a; color: #d0d8e8; margin: 0; padding: 20px; }
|
|
h1 { color: #4af; margin: 0 0 20px; font-size: 24px; }
|
|
h2 { color: #6cf; margin: 20px 0 8px; font-size: 16px; border-bottom: 1px solid #2a3a4a; padding-bottom: 4px; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
.card { background: #14192a; padding: 12px 16px; border-radius: 6px; border-left: 3px solid #4af; }
|
|
.card .label { color: #88a; font-size: 11px; text-transform: uppercase; }
|
|
.card .value { color: #fff; font-size: 22px; font-weight: bold; margin: 4px 0; }
|
|
.card .sub { color: #aab; font-size: 12px; }
|
|
.card.good { border-left-color: #4f4; }
|
|
.card.warn { border-left-color: #fa4; }
|
|
.card.bad { border-left-color: #f44; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { text-align: left; padding: 6px 12px; border-bottom: 1px solid #2a3a4a; font-size: 12px; }
|
|
th { color: #6cf; font-weight: normal; text-transform: uppercase; font-size: 10px; }
|
|
tr:hover { background: #1a2030; }
|
|
.updated { color: #678; font-size: 11px; }
|
|
.refresh { background: #4af; color: #fff; border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer; }
|
|
body{padding:20px}
|
|
</style>
|
|
<link rel="stylesheet" href="/sport/static/shared/sidebar.css">
|
|
<script src="/sport/static/shared/sidebar.js" defer data-active="kpi"></script>
|
|
</head>
|
|
<body>
|
|
<h1>RINET KPI Dashboard <span class="updated" id="updated"></span> <button class="refresh" onclick="load()">↻</button></h1>
|
|
<div id="root">Loading...</div>
|
|
|
|
<script>
|
|
async function load() {
|
|
document.getElementById('updated').textContent = '...';
|
|
try {
|
|
const r = await fetch('/admin/api/kpi');
|
|
const d = await r.json();
|
|
|
|
if (d.error) {
|
|
document.getElementById('root').innerHTML = '<div class="card bad">Error: ' + d.error + '</div>';
|
|
return;
|
|
}
|
|
|
|
const haluClass = d.queries.halu_pct > 5 ? 'bad' : d.queries.halu_pct > 1 ? 'warn' : 'good';
|
|
const clusterTotal = Object.values(d.cluster).reduce((a,b)=>a+b, 0);
|
|
const clusterUnhealthy = Object.entries(d.cluster).filter(([s,n]) => !['healthy','skipped'].includes(s)).reduce((a,[s,n])=>a+n, 0);
|
|
const clusterClass = clusterUnhealthy > 0 ? 'bad' : 'good';
|
|
const incClass = d.open_incidents > 0 ? 'warn' : 'good';
|
|
const embClass = d.knowledge.embed_pct >= 99 ? 'good' : d.knowledge.embed_pct >= 95 ? 'warn' : 'bad';
|
|
|
|
let html = `
|
|
<h2>Queries (Production)</h2>
|
|
<div class="grid">
|
|
<div class="card good"><div class="label">Last 1h</div><div class="value">${d.queries.h1}</div></div>
|
|
<div class="card good"><div class="label">Last 24h</div><div class="value">${d.queries.h24}</div></div>
|
|
<div class="card ${haluClass}"><div class="label">Halucinacije 24h</div><div class="value">${d.queries.halucinacije_h24}</div><div class="sub">${d.queries.halu_pct}%</div></div>
|
|
<div class="card good"><div class="label">Avg latency</div><div class="value">${d.queries.avg_latency_sec}s</div></div>
|
|
<div class="card good"><div class="label">Avg confidence</div><div class="value">${d.queries.avg_confidence}</div></div>
|
|
</div>
|
|
|
|
<h2>Knowledge Base</h2>
|
|
<div class="grid">
|
|
<div class="card good"><div class="label">Total facts</div><div class="value">${d.knowledge.total.toLocaleString()}</div></div>
|
|
<div class="card good"><div class="label">Added 1h / 24h</div><div class="value">+${d.knowledge.added_h1} / +${d.knowledge.added_h24}</div></div>
|
|
<div class="card ${embClass}"><div class="label">Embed coverage</div><div class="value">${d.knowledge.embed_pct}%</div><div class="sub">${d.knowledge.embed_pending} pending</div></div>
|
|
<div class="card good"><div class="label">Training Q&A</div><div class="value">${d.training.total.toLocaleString()}</div><div class="sub">+${d.training.added_h24} / 24h, ${d.training.from_capture} from capture</div></div>
|
|
</div>
|
|
|
|
<h2>Cluster Health</h2>
|
|
<div class="grid">
|
|
<div class="card ${clusterClass}"><div class="label">Healthy</div><div class="value">${d.cluster.healthy || 0} / ${clusterTotal}</div></div>
|
|
<div class="card ${incClass}"><div class="label">Open incidents</div><div class="value">${d.open_incidents}</div></div>
|
|
<div class="card good"><div class="label">Skipped</div><div class="value">${d.cluster.skipped || 0}</div><div class="sub">PG/Redis/cold by design</div></div>
|
|
<div class="card ${clusterUnhealthy>0?'bad':'good'}"><div class="label">Unhealthy</div><div class="value">${clusterUnhealthy}</div></div>
|
|
</div>
|
|
|
|
<h2>Top Sources (24h scrape)</h2>
|
|
<table>
|
|
<tr><th>Source</th><th>Count</th></tr>
|
|
${d.top_sources_h24.map(s => `<tr><td>${s.source}</td><td>${s.count.toLocaleString()}</td></tr>`).join('')}
|
|
</table>
|
|
|
|
<h2>Top Models (24h)</h2>
|
|
<table>
|
|
<tr><th>Model</th><th>Calls</th><th>Avg latency</th></tr>
|
|
${d.top_models_h24.map(m => `<tr><td>${m.model || '-'}</td><td>${m.count}</td><td>${m.avg_latency}s</td></tr>`).join('')}
|
|
</table>
|
|
`;
|
|
|
|
document.getElementById('root').innerHTML = html;
|
|
document.getElementById('updated').textContent = new Date().toLocaleTimeString();
|
|
} catch (e) {
|
|
document.getElementById('root').innerHTML = '<div class="card bad">Network error: ' + e.message + '</div>';
|
|
}
|
|
}
|
|
|
|
load();
|
|
setInterval(load, 30000); // 30s refresh
|
|
</script>
|
|
</body>
|
|
</html>
|