Files
pgz-sport/_backups/kpi.html.cc3_pre_redesign.1777937786
Damir Radulić f9ebcddf28 CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit
#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.
2026-05-05 01:42:53 +02:00

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>