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)}
|
.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>
|
||||||
|
|||||||
Reference in New Issue
Block a user