Add /admin/audio-jobs page for audio generation job history
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Failing after 6s
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Failing after 6s
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
New page mirrors the scrape tasks pattern: loads audio_jobs from PocketBase via a new listAudioJobs() helper, shows a filterable table (slug, chapter, voice, status, started, duration, error), and live-polls every 3s while any job is pending or generating. Also fixes the /admin/audio nav active-state check (was startsWith, now exact match) to prevent it from matching /admin/audio-jobs.
This commit is contained in:
@@ -657,6 +657,24 @@ export async function listScrapingTasks(): Promise<ScrapingTask[]> {
|
|||||||
return listAll<ScrapingTask>('scraping_tasks', '', '-started');
|
return listAll<ScrapingTask>('scraping_tasks', '', '-started');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Audio jobs ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AudioJob {
|
||||||
|
id: string;
|
||||||
|
cache_key: string; // "slug/chapter/voice"
|
||||||
|
slug: string;
|
||||||
|
chapter: number;
|
||||||
|
voice: string;
|
||||||
|
status: string; // "pending" | "generating" | "done" | "failed"
|
||||||
|
error_message: string;
|
||||||
|
started: string;
|
||||||
|
finished: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAudioJobs(): Promise<AudioJob[]> {
|
||||||
|
return listAll<AudioJob>('audio_jobs', '', '-started');
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAudioTime(
|
export async function getAudioTime(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
slug: string,
|
slug: string,
|
||||||
|
|||||||
@@ -247,10 +247,16 @@
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/admin/audio"
|
href="/admin/audio"
|
||||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/audio') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
class="hidden sm:block text-sm transition-colors {page.url.pathname === '/admin/audio' ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||||
>
|
>
|
||||||
Audio cache
|
Audio cache
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/audio-jobs"
|
||||||
|
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/audio-jobs') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||||
|
>
|
||||||
|
Audio jobs
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
<a
|
<a
|
||||||
href="/profile"
|
href="/profile"
|
||||||
@@ -333,10 +339,17 @@
|
|||||||
<a
|
<a
|
||||||
href="/admin/audio"
|
href="/admin/audio"
|
||||||
onclick={() => (menuOpen = false)}
|
onclick={() => (menuOpen = false)}
|
||||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/audio') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/admin/audio' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||||
>
|
>
|
||||||
Audio cache
|
Audio cache
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/audio-jobs"
|
||||||
|
onclick={() => (menuOpen = false)}
|
||||||
|
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/audio-jobs') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||||
|
>
|
||||||
|
Audio jobs
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="my-1 border-t border-zinc-700/60"></div>
|
<div class="my-1 border-t border-zinc-700/60"></div>
|
||||||
<form method="POST" action="/logout">
|
<form method="POST" action="/logout">
|
||||||
|
|||||||
17
ui/src/routes/admin/audio-jobs/+page.server.ts
Normal file
17
ui/src/routes/admin/audio-jobs/+page.server.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { listAudioJobs } from '$lib/server/pocketbase';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
if (locals.user?.role !== 'admin') {
|
||||||
|
redirect(302, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = await listAudioJobs().catch((e) => {
|
||||||
|
log.warn('admin/audio-jobs', 'failed to load audio jobs', { err: String(e) });
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { jobs };
|
||||||
|
};
|
||||||
152
ui/src/routes/admin/audio-jobs/+page.svelte
Normal file
152
ui/src/routes/admin/audio-jobs/+page.svelte
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let jobs = $state(data.jobs);
|
||||||
|
|
||||||
|
// ── Live-poll: refresh while any job is in-flight ────────────────────────────
|
||||||
|
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'generating'));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!hasInFlight) return;
|
||||||
|
const id = setInterval(async () => {
|
||||||
|
const res = await fetch('/admin/audio-jobs?__data=1').catch(() => null);
|
||||||
|
if (res?.ok) {
|
||||||
|
// SvelteKit invalidateAll is cleaner — just trigger a soft navigation reload.
|
||||||
|
import('$app/navigation').then(({ invalidateAll }) => invalidateAll());
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep local state in sync when server re-loads
|
||||||
|
$effect(() => {
|
||||||
|
jobs = data.jobs;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function statusColor(status: string) {
|
||||||
|
if (status === 'done') return 'text-green-400';
|
||||||
|
if (status === 'generating') return 'text-amber-400 animate-pulse';
|
||||||
|
if (status === 'pending') return 'text-sky-400 animate-pulse';
|
||||||
|
if (status === 'failed') return 'text-red-400';
|
||||||
|
return 'text-zinc-300';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(s: string) {
|
||||||
|
if (!s) return '—';
|
||||||
|
return new Date(s).toLocaleString(undefined, {
|
||||||
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function duration(started: string, finished: string) {
|
||||||
|
if (!started || !finished) return '—';
|
||||||
|
const ms = new Date(finished).getTime() - new Date(started).getTime();
|
||||||
|
if (ms < 0) return '—';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
return `${m}m ${s % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search ───────────────────────────────────────────────────────────────────
|
||||||
|
let q = $state('');
|
||||||
|
let filtered = $derived(
|
||||||
|
q.trim()
|
||||||
|
? jobs.filter(
|
||||||
|
(j) =>
|
||||||
|
j.slug.toLowerCase().includes(q.toLowerCase().trim()) ||
|
||||||
|
j.voice.toLowerCase().includes(q.toLowerCase().trim()) ||
|
||||||
|
j.status.toLowerCase().includes(q.toLowerCase().trim())
|
||||||
|
)
|
||||||
|
: jobs
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Stats ────────────────────────────────────────────────────────────────────
|
||||||
|
let stats = $derived({
|
||||||
|
total: jobs.length,
|
||||||
|
done: jobs.filter((j) => j.status === 'done').length,
|
||||||
|
failed: jobs.filter((j) => j.status === 'failed').length,
|
||||||
|
inFlight: jobs.filter((j) => j.status === 'pending' || j.status === 'generating').length
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Audio jobs — libnovel admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-start justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-zinc-100">Audio jobs</h1>
|
||||||
|
<p class="text-zinc-400 text-sm mt-1">
|
||||||
|
{stats.total} total ·
|
||||||
|
<span class="text-green-400">{stats.done} done</span> ·
|
||||||
|
{#if stats.failed > 0}
|
||||||
|
<span class="text-red-400">{stats.failed} failed</span> ·
|
||||||
|
{/if}
|
||||||
|
{#if stats.inFlight > 0}
|
||||||
|
<span class="text-amber-400 animate-pulse">{stats.inFlight} in-flight</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-zinc-500">0 in-flight</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
bind:value={q}
|
||||||
|
placeholder="Filter by slug, voice or status…"
|
||||||
|
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if filtered.length === 0}
|
||||||
|
<p class="text-zinc-500 text-sm py-8 text-center">
|
||||||
|
{q.trim() ? 'No results.' : 'No audio jobs yet.'}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-zinc-700">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left">Book</th>
|
||||||
|
<th class="px-4 py-3 text-right">Ch.</th>
|
||||||
|
<th class="px-4 py-3 text-left">Voice</th>
|
||||||
|
<th class="px-4 py-3 text-left">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left">Started</th>
|
||||||
|
<th class="px-4 py-3 text-left">Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-700/50">
|
||||||
|
{#each filtered as job}
|
||||||
|
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||||
|
<td class="px-4 py-3 text-zinc-200 font-medium">
|
||||||
|
<a href="/books/{job.slug}" class="hover:text-amber-400 transition-colors">
|
||||||
|
{job.slug}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right text-zinc-400">{job.chapter}</td>
|
||||||
|
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{job.voice}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="font-medium {statusColor(job.status)}">{job.status}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-zinc-400">{fmtDate(job.started)}</td>
|
||||||
|
<td class="px-4 py-3 text-zinc-400">{duration(job.started, job.finished)}</td>
|
||||||
|
</tr>
|
||||||
|
{#if job.error_message}
|
||||||
|
<tr class="bg-red-950/20">
|
||||||
|
<td colspan="6" class="px-4 py-2 text-xs text-red-400 font-mono"
|
||||||
|
>{job.error_message}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user