209 lines
11 KiB
JavaScript
209 lines
11 KiB
JavaScript
// ── 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);
|
|
// Lazy: the history list (hundreds of DOM nodes) is only fetched/rendered
|
|
// while Performance is shown, never at startup (DOM-node budget).
|
|
window.jobsOnShow = function () { load(); loadDiagnostics(); };
|
|
if (document.getElementById('s-performance')?.classList.contains('is-active')) window.jobsOnShow();
|
|
setInterval(() => {
|
|
if (document.getElementById('s-performance')?.classList.contains('is-active')) { load(); loadDiagnostics(); }
|
|
}, 10000);
|
|
})();
|