feat(diarization-ui): live-poll job status on jobs page

Replace static server-rendered job cards with JS-driven polling of
/jobs/data every 3s (active jobs) or 10s (all idle). Cards are
re-rendered client-side, preserving checkbox selection. Polling
accelerates when queued/running jobs exist and resumes on tab focus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 10:03:01 +01:00
parent 5ad50413a0
commit 89affe41b0

109
app.py
View File

@@ -1190,7 +1190,7 @@ def jobs_page(queued: Optional[int] = None):
<span id='jobs-selected-count' class='text-secondary small ms-auto'>0 ausgewählt</span> <span id='jobs-selected-count' class='text-secondary small ms-auto'>0 ausgewählt</span>
</div> </div>
</div> </div>
<div id='jobs-status' class='text-secondary small mb-2'>Live-Update aktiv …</div> <div id='jobs-status' class='text-secondary small mb-2'></div>
<div id='jobs-root'>{pre}</div> <div id='jobs-root'>{pre}</div>
<script> <script>
function parseUtcish(ts) {{ function parseUtcish(ts) {{
@@ -1200,19 +1200,44 @@ function parseUtcish(ts) {{
}} }}
function since(ts, endTs=null) {{ function since(ts, endTs=null) {{
if(!ts) return '-'; if(!ts) return '-';
const startMs = parseUtcish(ts); const s = Math.max(0, Math.floor(((endTs ? parseUtcish(endTs) : Date.now()) - parseUtcish(ts)) / 1000));
const end = endTs ? parseUtcish(endTs) : Date.now();
const s = Math.max(0, Math.floor((end-startMs)/1000));
if(s<60) return s+'s'; if(s<60) return s+'s';
const m = Math.floor(s/60); if(m<60) return m+'m '+(s%60)+'s'; const m = Math.floor(s/60); if(m<60) return m+'m '+(s%60)+'s';
const h = Math.floor(m/60); return h+'h '+(m%60)+'m'; const h = Math.floor(m/60); return h+'h '+(m%60)+'m';
}} }}
function tickElapsed() {{ function badgeColor(s) {{
document.querySelectorAll('.elapsed').forEach(el => {{ return {{done:'success', running:'primary', queued:'primary', cancelled:'warning'}}[s] || 'danger';
const start = el.getAttribute('data-start') || ''; }}
const end = el.getAttribute('data-end') || ''; function renderCard(it) {{
el.textContent = since(start, end || null); const startTs = it.started_at || it.created_at || '';
}}); const endTs = it.finished_at || '';
let actions = '';
if(!['done','error','cancelled'].includes(it.status))
actions += `<button class='btn btn-outline-warning btn-sm' onclick='cancelJob(${{it.id}})'><i class='bi bi-x-circle'></i> Abbrechen</button> `;
actions += `<button class='btn btn-outline-danger btn-sm' onclick='deleteJob(${{it.id}})'><i class='bi bi-trash'></i> Löschen</button> `;
if(it.result_document_id)
actions += `<a class='btn btn-primary btn-sm' href='/document/${{it.result_document_id}}'><i class='bi bi-box-arrow-up-right'></i> Ergebnis</a>`;
const err = it.error ? `<div class='alert alert-danger mt-2 mb-0 py-2 small'>${{String(it.error).replace(/</g,'&lt;')}}</div>` : '';
const meta = [
it.project_name ? 'Projekt: '+it.project_name : '',
it.document_title? 'Dokument: '+it.document_title : '',
it.prompt_name ? 'Prompt: '+it.prompt_name : '',
].filter(Boolean).join('<br>');
return `<div class='card job-card' data-job-id='${{it.id}}'>
<div class='d-flex justify-content-between align-items-center flex-wrap gap-2'>
<div class='d-flex align-items-center gap-2'>
<input class='form-check-input job-select' type='checkbox' value='${{it.id}}'>
<div>
<div class='fw-semibold'>Job #${{it.id}} · ${{it.kind}}</div>
<div class='text-secondary small'>erstellt: ${{it.created_at}} · läuft: <span class='elapsed' data-start='${{startTs}}' data-end='${{endTs}}'>${{since(startTs, endTs||null)}}</span></div>
</div>
</div>
<span class='badge text-bg-${{badgeColor(it.status)}}'>${{it.status}}</span>
</div>
<div class='small mt-2'>${{meta}}</div>
<div class='d-flex flex-wrap gap-2 mt-3'>${{actions}}</div>
${{err}}
</div>`;
}} }}
async function post(url) {{ async function post(url) {{
const r = await fetch(url, {{method:'POST'}}); const r = await fetch(url, {{method:'POST'}});
@@ -1223,8 +1248,7 @@ function selectedJobIds() {{
}} }}
function updateSelectionCount() {{ function updateSelectionCount() {{
const el = document.getElementById('jobs-selected-count'); const el = document.getElementById('jobs-selected-count');
if(!el) return; if(el) el.textContent = `${{selectedJobIds().length}} ausgewählt`;
el.textContent = `${{selectedJobIds().length}} ausgewählt`;
}} }}
function selectAllJobs() {{ function selectAllJobs() {{
document.querySelectorAll('.job-select').forEach(el => el.checked = true); document.querySelectorAll('.job-select').forEach(el => el.checked = true);
@@ -1238,20 +1262,65 @@ async function deleteSelectedJobs() {{
const ids = selectedJobIds(); const ids = selectedJobIds();
if(!ids.length) return alert('Keine Jobs ausgewählt'); if(!ids.length) return alert('Keine Jobs ausgewählt');
if(!confirm(`${{ids.length}} Jobs wirklich löschen?`)) return; if(!confirm(`${{ids.length}} Jobs wirklich löschen?`)) return;
for (const id of ids) {{ await post('/jobs/'+id+'/delete'); }} for(const id of ids) await post('/jobs/'+id+'/delete');
location.reload(); await refreshJobs();
}} }}
async function deleteAllJobs() {{ async function deleteAllJobs() {{
const ids = Array.from(document.querySelectorAll('.job-select')).map(el => Number(el.value)); const ids = Array.from(document.querySelectorAll('.job-select')).map(el => Number(el.value));
if(!ids.length) return; if(!ids.length) return;
if(!confirm(`Wirklich ALLE ${{ids.length}} Jobs löschen?`)) return; if(!confirm(`Wirklich ALLE ${{ids.length}} Jobs löschen?`)) return;
for (const id of ids) {{ await post('/jobs/'+id+'/delete'); }} for(const id of ids) await post('/jobs/'+id+'/delete');
location.reload(); await refreshJobs();
}} }}
async function cancelJob(id) {{ if(!confirm('Job abbrechen?')) return; await post('/jobs/'+id+'/cancel'); location.reload(); }} async function cancelJob(id) {{
async function deleteJob(id) {{ if(!confirm('Job löschen?')) return; await post('/jobs/'+id+'/delete'); location.reload(); }} if(!confirm('Job abbrechen?')) return;
document.addEventListener('change', (e) => {{ if(e.target && e.target.classList && e.target.classList.contains('job-select')) updateSelectionCount(); }}); await post('/jobs/'+id+'/cancel');
setInterval(tickElapsed, 1000); tickElapsed(); updateSelectionCount(); await refreshJobs();
}}
async function deleteJob(id) {{
if(!confirm('Job löschen?')) return;
await post('/jobs/'+id+'/delete');
await refreshJobs();
}}
let _pollTimer = null;
async function refreshJobs() {{
try {{
const checked = new Set(selectedJobIds());
const r = await fetch('/jobs/data');
const {{items}} = await r.json();
const root = document.getElementById('jobs-root');
if(!items.length) {{
root.innerHTML = "<p class='text-secondary'>Keine Jobs.</p>";
}} else {{
root.innerHTML = items.map(renderCard).join('');
root.querySelectorAll('.job-select').forEach(el => {{
if(checked.has(Number(el.value))) el.checked = true;
}});
}}
updateSelectionCount();
const hasActive = items.some(it => ['queued','running'].includes(it.status));
document.getElementById('jobs-status').textContent = hasActive ? 'Live-Update aktiv …' : '';
schedulePoll(hasActive ? 3000 : 10000);
}} catch(e) {{
document.getElementById('jobs-status').textContent = 'Verbindungsfehler versuche erneut …';
schedulePoll(5000);
}}
}}
function schedulePoll(ms) {{
clearTimeout(_pollTimer);
_pollTimer = setTimeout(refreshJobs, ms);
}}
document.addEventListener('change', e => {{
if(e.target?.classList?.contains('job-select')) updateSelectionCount();
}});
document.addEventListener('visibilitychange', () => {{
if(document.visibilityState === 'visible') refreshJobs();
}});
setInterval(() => document.querySelectorAll('.elapsed').forEach(el => {{
el.textContent = since(el.dataset.start, el.dataset.end || null);
}}), 1000);
updateSelectionCount();
schedulePoll(3000);
</script> </script>
""" """
return layout("Jobs", body) return layout("Jobs", body)