feat: add safe background job history panel

This commit is contained in:
mARTin-B78 2026-09-04 09:44:31 +02:00
parent f67930aefb
commit b648bbdf0b
9 changed files with 161 additions and 5 deletions

View File

@ -8,6 +8,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Added
- **Background Job History Panel.** The Benchmark section now shows sanitized, restart-safe background-job status,
progress, elapsed time, errors, and cancellation controls. Replay is deliberately limited to reopening the source
workflow because prompts, text, audio, and image inputs are never retained for implicit retries.
- **Protected Backup Recovery.** Added an authenticated backup inventory and restore API for character and rehearsal
snapshots, with path-safe file selection and atomic pre-mutation backups.
- **Voice Trash Recovery.** Voice deletes and group deletes now expose a path-safe trash inventory and restore API,

View File

@ -192,8 +192,8 @@ the first block before investing in architecture or polish.
- [ ] Add structured logs with request and job IDs.
- [ ] Report provider, endpoint, duration, and error reason clearly.
- [ ] Separate health status from “configured”.
- [ ] Add visible job history and retry controls; the authenticated `/api/jobs` metadata endpoint is available for the
UI, but a rendered history panel and safe retry action remain.
- [x] Add visible job history and safe retry state; the authenticated `/api/jobs` metadata endpoint now feeds a
rendered panel with status, elapsed time, errors, cancellation, and an explicit reopen-source retry boundary.
- [ ] Avoid logging prompts, API keys, or book contents by default.
## P1 — Resource limits and constrained-device testing

View File

@ -1 +1 @@
1.24.2
1.24.3

File diff suppressed because one or more lines are too long

113
static/js/jobs.js Normal file
View File

@ -0,0 +1,113 @@
// ── Background job history ───────────────────────────────────────────────
(function initBackgroundJobs() {
const list = document.getElementById('background-jobs-list');
const refresh = document.getElementById('background-jobs-refresh');
if (!list || !refresh) return;
const labels = { transcription: 'Transcription', voice_design: 'Voice design', casting: 'Voice casting', consistent_images: 'Consistent images' };
let loading = 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 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);
row.appendChild(main);
const actions = document.createElement('div');
actions.className = 'background-job-actions';
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.';
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;
}
}
refresh.addEventListener('click', load);
load();
setInterval(() => {
if (document.getElementById('s-performance')?.classList.contains('is-active')) load();
}, 10000);
})();

View File

@ -1,5 +1,5 @@
[
"voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser",
"voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "jobs",
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt",
"rehearser-parse", "rehearser", "reader", "audiobook", "audiobook/network", "character-sheets",
"characters-library", "sillytavern", "library", "character-columns", "library-characters", "studio"

View File

@ -221,6 +221,19 @@
<div class="perf-history-empty">No benchmark history yet. Run a benchmark above to start tracking.</div>
</div>
</div>
<div class="card" id="background-jobs-card">
<div class="bench-card-head">
<div>
<h2>Background jobs</h2>
<p class="card-subtitle">Recent transcription, voice design, casting, and image jobs. Payloads are never shown here.</p>
</div>
<button class="btn-secondary btn-sm" id="background-jobs-refresh" type="button"><span class="mdi mdi-refresh"></span> Refresh</button>
</div>
<div id="background-jobs-list" class="background-jobs-list" aria-live="polite">
<div class="perf-history-empty">Loading background jobs…</div>
</div>
</div>
</section>
<section class="bench-pane" id="bench-pane-turn" data-bench-pane="turn">

View File

@ -3734,6 +3734,18 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.bench-history-filter { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--subtext); cursor: pointer; }
.perf-history-list { display: flex; flex-direction: column; gap: 0; overflow-x: auto; }
.perf-history-empty { font-size: 13px; color: var(--subtext); padding: 16px 0; }
.background-jobs-list { display: flex; flex-direction: column; }
.background-job-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 4px; border-bottom: 1px solid var(--border); font-size: 12px; }
.background-job-row:last-child { border-bottom: none; }
.background-job-main { min-width: 0; display: grid; grid-template-columns: auto auto; gap: 2px 8px; align-items: center; }
.background-job-main strong { font-size: 13px; }
.background-job-main code { color: var(--subtext); font-size: 10px; }
.background-job-detail, .background-job-meta { grid-column: 1 / -1; color: var(--subtext); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.background-job-row.is-error .background-job-detail { color: var(--red); }
.background-job-row.is-done .background-job-meta { color: var(--green, #22c55e); }
.background-job-actions { display: flex; align-items: center; gap: 9px; flex-shrink: 0; }
.background-job-retry-note { color: var(--subtext); font-size: 11px; white-space: nowrap; }
@media (max-width: 640px) { .background-job-row { align-items: flex-start; flex-direction: column; gap: 8px; } .background-job-actions { width: 100%; justify-content: space-between; } }
.perf-history-row { display: grid; grid-template-columns: 118px minmax(96px,.8fr) 34px 72px 72px minmax(190px,1.4fr) 92px 82px 68px 70px 24px; min-width: 1010px; gap: 0 10px; padding: 7px 4px; border-bottom: 1px solid var(--border); font-size: 12px; align-items: center; }
.perf-history-row:last-child { border-bottom: none; }
.perf-history-head { font-size: 11px; color: var(--subtext); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }

View File

@ -68,3 +68,18 @@ test('PWA manifest and service worker are served', async ({ page, request }) =>
const sw = await request.get('/sw.js');
expect(sw.ok()).toBeTruthy();
});
test('background job history is rendered without payloads', async ({ page }) => {
await page.route('**/api/jobs?limit=50', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ jobs: [{ job_id: 'abc123456789', kind: 'transcription', stage: 'running', detail: 'Transcribing audio', done: false, elapsed: 2.4 }] }),
}));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await navTo(page, 's-performance');
await expect(page.locator('#background-jobs-card')).toBeVisible();
await expect(page.locator('#background-jobs-list')).toContainText('Transcription');
await expect(page.locator('#background-jobs-list')).toContainText('Transcribing audio');
await expect(page.locator('#background-jobs-list')).toContainText('Cancel');
await expect(page.locator('#background-jobs-list')).not.toContainText('prompt');
});