tts-voice-creator-clone-and.../static/js/jobs.js

190 lines
9.1 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,
})));
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)],
["Persisted jobs", server.background_jobs], ["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);
});
} 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);
})();