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:
+107
-2
@@ -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)}
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/sport/static/shared/sidebar.css">
|
||||
<script src="/sport/static/shared/sidebar.js" defer data-active="dashboard"></script>
|
||||
<link rel="stylesheet" href="/static/shared/sidebar.css">
|
||||
<script src="/static/shared/sidebar.js" defer data-active="dashboard"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -743,6 +743,7 @@ async function loadAudit(){
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="enrich-worker-card"></div>
|
||||
${rows ? `
|
||||
<div class="card">
|
||||
<div class="card-h"><div class="card-t">📜 Audit zapisi</div></div>
|
||||
@@ -755,6 +756,108 @@ async function loadAudit(){
|
||||
</table></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 ===========
|
||||
@@ -1121,6 +1224,7 @@ function renderKluboviShell(){
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div id="sp-out"></div>
|
||||
|
||||
Reference in New Issue
Block a user