feat: add safe background job history panel
This commit is contained in:
parent
f67930aefb
commit
b648bbdf0b
@ -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,
|
||||
|
||||
4
TODO.md
4
TODO.md
@ -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
|
||||
|
||||
2
static/dist/main.min.js
vendored
2
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
113
static/js/jobs.js
Normal file
113
static/js/jobs.js
Normal 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);
|
||||
})();
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user