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:
113
app.py
113
app.py
@@ -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();
|
if(s<60) return s+'s';
|
||||||
const s = Math.max(0, Math.floor((end-startMs)/1000));
|
const m = Math.floor(s/60); if(m<60) return m+'m '+(s%60)+'s';
|
||||||
if (s<60) return s+'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,'<')}}</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)
|
||||||
|
|||||||
Reference in New Issue
Block a user