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]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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
|
- **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.
|
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,
|
- **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.
|
- [ ] Add structured logs with request and job IDs.
|
||||||
- [ ] Report provider, endpoint, duration, and error reason clearly.
|
- [ ] Report provider, endpoint, duration, and error reason clearly.
|
||||||
- [ ] Separate health status from “configured”.
|
- [ ] Separate health status from “configured”.
|
||||||
- [ ] Add visible job history and retry controls; the authenticated `/api/jobs` metadata endpoint is available for the
|
- [x] Add visible job history and safe retry state; the authenticated `/api/jobs` metadata endpoint now feeds a
|
||||||
UI, but a rendered history panel and safe retry action remain.
|
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.
|
- [ ] Avoid logging prompts, API keys, or book contents by default.
|
||||||
|
|
||||||
## P1 — Resource limits and constrained-device testing
|
## 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",
|
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt",
|
||||||
"rehearser-parse", "rehearser", "reader", "audiobook", "audiobook/network", "character-sheets",
|
"rehearser-parse", "rehearser", "reader", "audiobook", "audiobook/network", "character-sheets",
|
||||||
"characters-library", "sillytavern", "library", "character-columns", "library-characters", "studio"
|
"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 class="perf-history-empty">No benchmark history yet. Run a benchmark above to start tracking.</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section class="bench-pane" id="bench-pane-turn" data-bench-pane="turn">
|
<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; }
|
.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-list { display: flex; flex-direction: column; gap: 0; overflow-x: auto; }
|
||||||
.perf-history-empty { font-size: 13px; color: var(--subtext); padding: 16px 0; }
|
.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 { 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-row:last-child { border-bottom: none; }
|
||||||
.perf-history-head { font-size: 11px; color: var(--subtext); text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
|
.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');
|
const sw = await request.get('/sw.js');
|
||||||
expect(sw.ok()).toBeTruthy();
|
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