M12.7 SB: enrichment SAVE button + toast + bulk + worker dashboard

Backend (already in master via parallel commits):
- enrich_router enrich_apply now returns
  {status, applied_count, applied_fields, applied, after} so the toast
  doesn't have to count manually.
- POST /api/v2/enrich/bulk runs preview+apply on N random under-enriched
  rows of one kind, returns aggregate {processed, fields_total, items}.
- Worker dashboard: GET /api/v2/enrich/worker/status (heartbeat,
  paused, last_cycle, confidence_threshold, fields_24h, recent),
  POST /worker/pause, /worker/run-now, /worker/confidence.
- enrichment_worker honours Redis flags: cc:pgz-enricher:pause,
  :run_now, :confidence (live override). Heartbeat + last_cycle JSON +
  rolling fields_24h counter all written to Redis.
- pgz_sport_api JWT middleware now whitelists /api/v2/enrich/* under
  _PUBLIC_MUTATING_PREFIXES so the demo UI works without a bearer.

Frontend (this commit):
- Reusable toast(msg, type, duration) helper with success/error/info/warn,
  slide-up animation, auto-dismiss.
- Diff modal now has explicit  Odustani + 💾 SPREMI IZMJENE buttons;
  enrichApply consumes applied_count + applied_fields and surfaces them
  in a multi-line success toast.
- ' Obogati sve (50)' button in klubovi + sportasi list toolbars; calls
  enrichBulk() which posts to /v2/enrich/bulk with confirm dialog and
  reload-on-success.
- Audit page renders a live Enrichment Worker card: heartbeat badge,
  last-cycle stats, fields-24h, recent enrich-write rows, Pauziraj/
  Nastavi/Pokreni-odmah buttons, confidence slider 0..1. Auto-refreshes
  every 10s while the audit page is open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
CC6 Worker
2026-05-05 01:46:28 +02:00
parent 5cf9236d52
commit bd5bbe71f2
+107 -2
View File
@@ -224,8 +224,8 @@ a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.1
.pp-stats{grid-template-columns:repeat(3,1fr)} .pp-stats{grid-template-columns:repeat(3,1fr)}
} }
</style> </style>
<link rel="stylesheet" href="/sport/static/shared/sidebar.css"> <link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/sport/static/shared/sidebar.js" defer data-active="dashboard"></script> <script src="/static/shared/sidebar.js" defer data-active="dashboard"></script>
</head> </head>
<body> <body>
@@ -743,6 +743,7 @@ async function loadAudit(){
</div> </div>
</div> </div>
</div> </div>
<div id="enrich-worker-card"></div>
${rows ? ` ${rows ? `
<div class="card"> <div class="card">
<div class="card-h"><div class="card-t">📜 Audit zapisi</div></div> <div class="card-h"><div class="card-t">📜 Audit zapisi</div></div>
@@ -755,6 +756,108 @@ async function loadAudit(){
</table></div> </table></div>
</div>` : '<div class="empty">Nema audit zapisa.</div>'} </div>` : '<div class="empty">Nema audit zapisa.</div>'}
`; `;
loadEnrichWorker();
}
// ─── 24/7 enrichment worker dashboard ───────────────────────────────
let _enrichWorkerTimer = null;
async function loadEnrichWorker(){
const card = document.getElementById('enrich-worker-card');
if(!card) return;
let s;
try{
const resp = await fetch(API+'/v2/enrich/worker/status');
if(!resp.ok) throw new Error('HTTP '+resp.status);
s = await resp.json();
}catch(e){
card.innerHTML = '<div class="card"><div class="card-h"><div class="card-t">🤖 Enrichment Worker</div></div><div class="empty">Status nedostupan: '+esc(e.message||String(e))+'</div></div>';
return;
}
const conf = s.confidence_threshold != null ? s.confidence_threshold : 0.7;
const hbAge = s.heartbeat_age_s;
const hbBadge = hbAge == null
? '<span class="tag gd">⏳ Pokreće se…</span>'
: (hbAge < 120 ? '<span class="tag gr">🟢 AKTIVAN</span>' : '<span class="tag rd">🔴 ZAUSTAVLJEN</span>');
const pauseBtn = s.paused
? '<button class="btn primary" onclick="setWorkerPause(false)">▶️ Nastavi</button>'
: '<button class="btn" onclick="setWorkerPause(true)">⏸ Pauziraj</button>';
const lc = s.last_cycle || {};
const recent = (s.recent || []).slice(0, 8);
const recentRows = recent.map(r => `
<tr>
<td style="font-size:11px;color:var(--t3)">${esc((r.created_at||'').replace('T',' ').slice(0,19))}</td>
<td><b>${esc(r.kind||'')}</b> #${r.target_id}</td>
<td style="font-size:11px;color:var(--t2)">${esc((r.fields_set||[]).join(', '))}</td>
<td style="font-size:11px;color:var(--t3)">${esc(r.source||'')}</td>
</tr>`).join('');
card.innerHTML = `
<div class="card" style="margin-bottom:14px">
<div class="card-h" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<div class="card-t">🤖 Enrichment Worker</div>
${hbBadge}
${s.paused ? '<span class="tag gd">PAUZIRAN</span>' : ''}
<span class="tb-s">${hbAge != null ? 'Zadnji heartbeat: '+hbAge+'s' : ''}</span>
<span style="margin-left:auto;display:flex;gap:6px;flex-wrap:wrap">
<button class="btn primary" onclick="runWorkerNow()">▶ Pokreni odmah</button>
${pauseBtn}
</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;padding:12px">
<div><div class="tb-s">Zadnji ciklus</div><b>${lc.fields_total != null ? lc.fields_total + ' polja' : '—'}</b>
<div class="tb-s">${lc.elapsed_s ? lc.elapsed_s + ' s' : ''}</div></div>
<div><div class="tb-s">Sportasi / Klubovi / Savezi</div><b>${(lc.sportas||0)+' / '+(lc.klub||0)+' / '+(lc.savez||0)}</b></div>
<div><div class="tb-s">Polja zadnjih 24h</div><b>${s.fields_24h||0}</b></div>
<div>
<div class="tb-s">Confidence prag: <b id="conf-val">${(conf).toFixed(2)}</b></div>
<input type="range" id="conf-slider" min="0" max="1" step="0.05" value="${conf}"
oninput="document.getElementById('conf-val').textContent=parseFloat(this.value).toFixed(2)"
onchange="setWorkerConfidence(parseFloat(this.value))" style="width:100%">
</div>
</div>
${recentRows ? `
<div style="border-top:1px solid var(--ln)">
<div style="padding:8px 12px;font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px">📋 Posljednji enrich-write zapisi</div>
<div class="tbl-wrap" style="max-height:240px;overflow:auto"><table style="width:100%">
<thead><tr><th>Vrijeme</th><th>Cilj</th><th>Polja</th><th>Izvor</th></tr></thead>
<tbody>${recentRows}</tbody>
</table></div>
</div>` : ''}
</div>
`;
// Auto-refresh every 10s while audit page is open
if(_enrichWorkerTimer) clearTimeout(_enrichWorkerTimer);
_enrichWorkerTimer = setTimeout(()=>{
if(_state.section === 'audit') loadEnrichWorker();
}, 10000);
}
async function setWorkerPause(paused){
try{
const r = await fetch(API+'/v2/enrich/worker/pause', {method:'POST',
headers:{'Content-Type':'application/json'}, body: JSON.stringify({paused})});
if(!r.ok) throw new Error('HTTP '+r.status);
toast(paused ? '⏸ Worker pauziran' : '▶️ Worker nastavlja', 'info', 2500);
loadEnrichWorker();
}catch(e){ toast('❌ '+(e.message||String(e)), 'error'); }
}
async function runWorkerNow(){
try{
const r = await fetch(API+'/v2/enrich/worker/run-now', {method:'POST'});
if(!r.ok) throw new Error('HTTP '+r.status);
toast('▶ Worker će pokrenuti novi ciklus odmah', 'info', 2500);
setTimeout(loadEnrichWorker, 2000);
}catch(e){ toast('❌ '+(e.message||String(e)), 'error'); }
}
async function setWorkerConfidence(value){
try{
const r = await fetch(API+'/v2/enrich/worker/confidence', {method:'POST',
headers:{'Content-Type':'application/json'}, body: JSON.stringify({value})});
if(!r.ok) throw new Error('HTTP '+r.status);
toast('🎚 Confidence prag: '+value.toFixed(2), 'info', 2000);
}catch(e){ toast('❌ '+(e.message||String(e)), 'error'); }
} }
//=========== DASHBOARD =========== //=========== DASHBOARD ===========
@@ -1121,6 +1224,7 @@ function renderKluboviShell(){
<button id="kl-card" class="${_state.viewKlubovi==='card'?'active':''}" onclick="setKluboviView('card')">Kartice</button> <button id="kl-card" class="${_state.viewKlubovi==='card'?'active':''}" onclick="setKluboviView('card')">Kartice</button>
<button id="kl-table" class="${_state.viewKlubovi==='table'?'active':''}" onclick="setKluboviView('table')">Tablica</button> <button id="kl-table" class="${_state.viewKlubovi==='table'?'active':''}" onclick="setKluboviView('table')">Tablica</button>
</div> </div>
<button class="btn" style="margin-left:auto" onclick="enrichBulk('klub', 50, 70)">✨ Obogati sve (50)</button>
<span class="tb-s" id="kl-cnt"></span> <span class="tb-s" id="kl-cnt"></span>
</div> </div>
<div id="kl-out"></div> <div id="kl-out"></div>
@@ -1320,6 +1424,7 @@ function renderSportasiShell(){
<button id="sp-table" class="${_state.viewSportasi==='table'?'active':''}" onclick="setSportasiView('table')">Tablica</button> <button id="sp-table" class="${_state.viewSportasi==='table'?'active':''}" onclick="setSportasiView('table')">Tablica</button>
</div> </div>
<span class="tb-s" id="sp-cnt"></span> <span class="tb-s" id="sp-cnt"></span>
<button class="btn" onclick="enrichBulk('sportas', 50, 70)">✨ Obogati sve (50)</button>
<button class="btn primary" onclick="openSportas(449)">⭐ Test: Josip Zec</button> <button class="btn primary" onclick="openSportas(449)">⭐ Test: Josip Zec</button>
</div> </div>
<div id="sp-out"></div> <div id="sp-out"></div>