// ── Background job history ─────────────────────────────────────────────── (function initBackgroundJobs() { const list = document.getElementById('background-jobs-list'); const refresh = document.getElementById('background-jobs-refresh'); const diagnostics = document.getElementById('resource-diagnostics'); const diagnosticsRefresh = document.getElementById('resource-diagnostics-refresh'); if (!list || !refresh) return; const labels = { transcription: 'Transcription', voice_design: 'Voice design', casting: 'Voice casting', consistent_images: 'Consistent images' }; let loading = false; let diagnosticsLoading = false; function node(value, fallback = '—') { const item = document.createElement('span'); item.textContent = value == null || value === '' ? fallback : String(value); return item; } function statusClass(job) { if (job.cancelled || job.stage === 'cancelled') return 'is-cancelled'; if (job.error || job.stage === 'error' || job.stage === 'failed') return 'is-error'; if (job.done) return 'is-done'; return 'is-active'; } function statusLabel(job) { if (job.cancelled || job.stage === 'cancelled') return 'Cancelled'; if (job.error || job.stage === 'error' || job.stage === 'failed') return 'Failed'; if (job.done) return 'Completed'; return job.stage === 'queued' ? 'Queued' : 'Running'; } function cancelEndpoint(kind, id) { const paths = { transcription: '/api/transcribe/cancel/', voice_design: '/api/voice-design/cancel/', casting: '/api/match-characters-voices/cancel/', consistent_images: '/api/character-generate-consistent-images/cancel/' }; return paths[kind] ? paths[kind] + encodeURIComponent(id) : ''; } function retryEndpoint(kind, id) { const paths = { transcription: '/api/transcribe/retry/', voice_design: '/api/voice-design/retry/', casting: '/api/match-characters-voices/retry/', consistent_images: '/api/character-generate-consistent-images/retry/' }; return paths[kind] && id ? paths[kind] + encodeURIComponent(id) : ''; } function render(jobs) { list.replaceChildren(); if (!jobs.length) { const empty = node('No background jobs yet. Start a transcription, voice design, cast, or image workflow to see it here.'); empty.className = 'perf-history-empty'; list.appendChild(empty); return; } jobs.forEach(job => { const row = document.createElement('div'); row.className = 'background-job-row ' + statusClass(job); const main = document.createElement('div'); main.className = 'background-job-main'; const title = document.createElement('strong'); title.textContent = labels[job.kind] || job.kind || 'Background job'; main.appendChild(title); const id = document.createElement('code'); id.textContent = String(job.job_id || '').slice(0, 12); main.appendChild(id); const detail = node(job.error || job.detail || job.stage); detail.className = 'background-job-detail'; main.appendChild(detail); const meta = node(`${statusLabel(job)} · ${Number(job.elapsed || 0).toFixed(1)}s`); meta.className = 'background-job-meta'; main.appendChild(meta); const storedResult = job.result && typeof job.result === 'object' ? job.result : {}; const artifactList = []; if (storedResult.artifact) artifactList.push(storedResult.artifact); if (Array.isArray(storedResult.artifacts)) artifactList.push(...storedResult.artifacts); artifactList.filter(item => item && typeof item.url === 'string' && item.url.startsWith('/api/jobs/')).forEach(item => { const link = document.createElement('a'); link.className = 'background-job-artifact'; link.href = item.url; link.target = '_blank'; link.rel = 'noopener'; link.textContent = `Download ${item.kind || 'result'}`; main.appendChild(link); }); row.appendChild(main); const actions = document.createElement('div'); actions.className = 'background-job-actions'; if (job.done && job.error && job.retry_available) { const button = document.createElement('button'); button.type = 'button'; button.className = 'btn-secondary btn-sm'; button.textContent = 'Retry'; button.addEventListener('click', async () => { button.disabled = true; try { const response = await fetch(retryEndpoint(job.kind, job.job_id), { method: 'POST' }); if (!response.ok) { const detail = await response.json().catch(() => ({})); throw new Error(detail.detail || `HTTP ${response.status}`); } await load(); } catch (error) { button.disabled = false; if (typeof toast === 'function') toast('Could not retry job: ' + error.message, 'error'); } }); actions.appendChild(button); } if (!job.done && !job.cancelled && job.job_id) { const endpoint = cancelEndpoint(job.kind, job.job_id); if (endpoint) { const button = document.createElement('button'); button.type = 'button'; button.className = 'btn-secondary btn-sm'; button.textContent = 'Cancel'; button.addEventListener('click', async () => { button.disabled = true; try { const response = await fetch(endpoint, { method: 'POST' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); await load(); } catch (error) { button.disabled = false; if (typeof toast === 'function') toast('Could not cancel job: ' + error.message, 'error'); } }); actions.appendChild(button); } } const note = node('Reopen source to retry'); note.className = 'background-job-retry-note'; note.title = 'Inputs are not retained for replay, by design.'; if (!job.done || !job.error || !job.retry_available) actions.appendChild(note); row.appendChild(actions); list.appendChild(row); }); } async function load() { if (loading) return; loading = true; refresh.disabled = true; try { const response = await fetch('/api/jobs?limit=50', { cache: 'no-store' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); render(Array.isArray(payload.jobs) ? payload.jobs : []); } catch (error) { list.replaceChildren(); const empty = node('Background job history unavailable: ' + error.message); empty.className = 'perf-history-empty'; list.appendChild(empty); } finally { loading = false; refresh.disabled = false; } } function formatBytes(value) { const bytes = Number(value); if (!Number.isFinite(bytes)) return '—'; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; return `${(bytes / 1024 / 1024).toFixed(1)} MiB`; } async function loadDiagnostics() { if (!diagnostics || diagnosticsLoading) return; diagnosticsLoading = true; if (diagnosticsRefresh) diagnosticsRefresh.disabled = true; try { const response = await fetch('/api/diagnostics', { cache: 'no-store' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const server = await response.json(); const browser = await new Promise(resolve => requestAnimationFrame(() => resolve({ domNodes: document.getElementsByTagName('*').length, audioElements: document.querySelectorAll('audio').length, jsHeapBytes: performance.memory?.usedJSHeapSize || null, imageCacheEntries: window.TTVC?.imageCache?.size?.() ?? null, rehearserAudioEntries: window.rehState?.synthCache?.size ?? null, voiceRecords: Array.isArray(window._voices) ? window._voices.length : null, }))); diagnostics.replaceChildren(); [["Server RSS", `${server.server_rss_mb} MiB`], ["Config data", formatBytes(server.config_disk?.used_bytes)], ["Config free", formatBytes(server.config_disk?.free_bytes)], ["Temp data", formatBytes(server.temp_disk?.used_bytes)], ["Container memory", `${formatBytes(server.container_memory?.current_bytes)} / ${formatBytes(server.container_memory?.max_bytes)}`], ["Artifact cache", formatBytes(server.cache_storage?.background_artifacts_bytes)], ["Persisted jobs", server.background_jobs], ["Image cache entries", browser.imageCacheEntries], ["Rehearser audio entries", browser.rehearserAudioEntries], ["Voice records", browser.voiceRecords], ["DOM nodes", browser.domNodes], ["Audio elements", browser.audioElements], ["JS heap", formatBytes(browser.jsHeapBytes)]].forEach(([label, value]) => { const item = document.createElement('div'); item.className = 'resource-diagnostic-item'; const name = document.createElement('span'); name.textContent = label; const valueNode = document.createElement('strong'); valueNode.textContent = value == null ? '—' : String(value); item.append(name, valueNode); diagnostics.appendChild(item); }); const operations = Array.isArray(server.largest_operations) ? server.largest_operations : []; if (operations.length) { const heading = document.createElement('div'); heading.className = 'resource-diagnostic-subhead'; heading.textContent = 'Largest recent operations'; diagnostics.appendChild(heading); operations.forEach(operation => { const item = document.createElement('div'); item.className = 'resource-diagnostic-item'; const name = document.createElement('span'); name.textContent = `${operation.kind} · ${operation.status}`; const valueNode = document.createElement('strong'); valueNode.textContent = `${Number(operation.elapsed || 0).toFixed(1)}s`; item.append(name, valueNode); diagnostics.appendChild(item); }); } } catch (error) { diagnostics.replaceChildren(); const item = node('Diagnostics unavailable: ' + error.message); item.className = 'perf-history-empty'; diagnostics.appendChild(item); } finally { diagnosticsLoading = false; if (diagnosticsRefresh) diagnosticsRefresh.disabled = false; } } refresh.addEventListener('click', load); diagnosticsRefresh?.addEventListener('click', loadDiagnostics); load(); loadDiagnostics(); setInterval(() => { if (document.getElementById('s-performance')?.classList.contains('is-active')) { load(); loadDiagnostics(); } }, 10000); })();