Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3bb19892c |
@@ -403,6 +403,7 @@
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "AI Jobs",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Errors",
|
||||
"admin_nav_analytics": "Analytics",
|
||||
|
||||
@@ -403,6 +403,7 @@
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tâches IA",
|
||||
"admin_nav_errors": "Erreurs",
|
||||
"admin_nav_analytics": "Analytique",
|
||||
"admin_nav_logs": "Journaux",
|
||||
|
||||
@@ -403,6 +403,7 @@
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tugas AI",
|
||||
"admin_nav_errors": "Kesalahan",
|
||||
"admin_nav_analytics": "Analitik",
|
||||
"admin_nav_logs": "Log",
|
||||
|
||||
@@ -403,6 +403,7 @@
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tarefas de IA",
|
||||
"admin_nav_errors": "Erros",
|
||||
"admin_nav_analytics": "Análise",
|
||||
"admin_nav_logs": "Logs",
|
||||
|
||||
@@ -403,6 +403,7 @@
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Задачи ИИ",
|
||||
"admin_nav_errors": "Ошибки",
|
||||
"admin_nav_analytics": "Аналитика",
|
||||
"admin_nav_logs": "Логи",
|
||||
|
||||
@@ -374,6 +374,7 @@ export * from './admin_nav_changelog.js'
|
||||
export * from './admin_nav_image_gen.js'
|
||||
export * from './admin_nav_text_gen.js'
|
||||
export * from './admin_nav_catalogue_tools.js'
|
||||
export * from './admin_nav_ai_jobs.js'
|
||||
export * from './admin_nav_feedback.js'
|
||||
export * from './admin_nav_errors.js'
|
||||
export * from './admin_nav_analytics.js'
|
||||
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_ai_jobs.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_ai_jobs.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_Ai_JobsInputs */
|
||||
|
||||
const en_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`AI Jobs`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Задачи ИИ`)
|
||||
};
|
||||
|
||||
const id_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Tugas AI`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Tarefas de IA`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_ai_jobs = /** @type {(inputs: Admin_Nav_Ai_JobsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Tâches IA`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "AI Jobs" |
|
||||
*
|
||||
* @param {Admin_Nav_Ai_JobsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_ai_jobs = /** @type {((inputs?: Admin_Nav_Ai_JobsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Ai_JobsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_ai_jobs(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_ai_jobs(inputs)
|
||||
if (locale === "id") return id_admin_nav_ai_jobs(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_ai_jobs(inputs)
|
||||
return fr_admin_nav_ai_jobs(inputs)
|
||||
});
|
||||
@@ -14,6 +14,23 @@ const PB_PASSWORD = env.POCKETBASE_ADMIN_PASSWORD ?? 'changeme123';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AIJob {
|
||||
id: string;
|
||||
kind: string;
|
||||
slug: string;
|
||||
status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled';
|
||||
from_item: number;
|
||||
to_item: number;
|
||||
items_done: number;
|
||||
items_total: number;
|
||||
model: string;
|
||||
payload: string;
|
||||
error_message?: string;
|
||||
started?: string;
|
||||
finished?: string;
|
||||
heartbeat_at?: string;
|
||||
}
|
||||
|
||||
export interface Book {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -2162,3 +2179,13 @@ export async function getUserStats(
|
||||
streak
|
||||
};
|
||||
}
|
||||
|
||||
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all AI jobs from PocketBase, sorted by started descending.
|
||||
* No caching — admin views always want fresh data.
|
||||
*/
|
||||
export async function listAIJobs(): Promise<AIJob[]> {
|
||||
return listAll<AIJob>('ai_jobs', '', '-started');
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import * as cache from '$lib/server/cache';
|
||||
|
||||
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
@@ -33,6 +34,50 @@ export async function backendFetch(path: string, init?: RequestInit): Promise<Re
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cached admin model lists ─────────────────────────────────────────────────
|
||||
|
||||
const MODELS_CACHE_TTL = 10 * 60; // 10 minutes — model lists rarely change
|
||||
|
||||
/**
|
||||
* Fetch image-gen model list from the Go backend with a 10-minute cache.
|
||||
* Returns an empty array on error (callers should surface a warning).
|
||||
*/
|
||||
export async function listImageModels<T>(): Promise<T[]> {
|
||||
const key = 'backend:models:image-gen';
|
||||
const cached = await cache.get<T[]>(key);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const models = (data.models ?? []) as T[];
|
||||
await cache.set(key, models, MODELS_CACHE_TTL);
|
||||
return models;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch text-gen model list from the Go backend with a 10-minute cache.
|
||||
* Returns an empty array on error.
|
||||
*/
|
||||
export async function listTextModels<T>(): Promise<T[]> {
|
||||
const key = 'backend:models:text-gen';
|
||||
const cached = await cache.get<T[]>(key);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/text-gen/models');
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
const models = (data.models ?? []) as T[];
|
||||
await cache.set(key, models, MODELS_CACHE_TTL);
|
||||
return models;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Response types ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() },
|
||||
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() },
|
||||
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() },
|
||||
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() }
|
||||
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() },
|
||||
{ href: '/admin/ai-jobs', label: () => m.admin_nav_ai_jobs() }
|
||||
];
|
||||
|
||||
const externalLinks = [
|
||||
|
||||
14
ui/src/routes/admin/ai-jobs/+page.server.ts
Normal file
14
ui/src/routes/admin/ai-jobs/+page.server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { listAIJobs, type AIJob } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export type { AIJob };
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
const jobs = await listAIJobs().catch((e): AIJob[] => {
|
||||
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
return { jobs };
|
||||
};
|
||||
326
ui/src/routes/admin/ai-jobs/+page.svelte
Normal file
326
ui/src/routes/admin/ai-jobs/+page.svelte
Normal file
@@ -0,0 +1,326 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { AIJob } from '$lib/server/pocketbase';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let jobs = $state<AIJob[]>(untrack(() => data.jobs));
|
||||
|
||||
// Keep in sync on server reloads
|
||||
$effect(() => {
|
||||
jobs = data.jobs;
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'running'));
|
||||
|
||||
$effect(() => {
|
||||
if (!hasInFlight) return;
|
||||
const id = setInterval(() => {
|
||||
invalidateAll();
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// ── Filter / search ───────────────────────────────────────────────────────────
|
||||
let query = $state('');
|
||||
let statusFilter = $state<string>('all');
|
||||
|
||||
const STATUS_OPTIONS = ['all', 'pending', 'running', 'done', 'failed', 'cancelled'] as const;
|
||||
|
||||
let filteredJobs = $derived(
|
||||
jobs.filter((j) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesQ =
|
||||
!q ||
|
||||
j.slug.toLowerCase().includes(q) ||
|
||||
j.kind.toLowerCase().includes(q) ||
|
||||
j.model.toLowerCase().includes(q) ||
|
||||
(j.error_message ?? '').toLowerCase().includes(q);
|
||||
const matchesStatus = statusFilter === 'all' || j.status === statusFilter;
|
||||
return matchesQ && matchesStatus;
|
||||
})
|
||||
);
|
||||
|
||||
// ── Stats ────────────────────────────────────────────────────────────────────
|
||||
let stats = $derived({
|
||||
total: jobs.length,
|
||||
running: jobs.filter((j) => j.status === 'running').length,
|
||||
pending: jobs.filter((j) => j.status === 'pending').length,
|
||||
done: jobs.filter((j) => j.status === 'done').length,
|
||||
failed: jobs.filter((j) => j.status === 'failed').length
|
||||
});
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────────────────────────────
|
||||
let cancellingId = $state<string | null>(null);
|
||||
let cancelError = $state('');
|
||||
|
||||
async function cancelJob(id: string) {
|
||||
if (cancellingId) return;
|
||||
cancellingId = id;
|
||||
cancelError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
cancelError = body.error ?? `Error ${res.status}`;
|
||||
} else {
|
||||
await invalidateAll();
|
||||
}
|
||||
} catch {
|
||||
cancelError = 'Network error.';
|
||||
} finally {
|
||||
cancellingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function statusColor(status: string) {
|
||||
if (status === 'done') return 'text-green-400';
|
||||
if (status === 'running') return 'text-(--color-brand) animate-pulse';
|
||||
if (status === 'pending') return 'text-sky-400 animate-pulse';
|
||||
if (status === 'failed') return 'text-(--color-danger)';
|
||||
if (status === 'cancelled') return 'text-(--color-muted)';
|
||||
return 'text-(--color-text)';
|
||||
}
|
||||
|
||||
function statusBg(status: string) {
|
||||
if (status === 'done') return 'bg-green-400/10 text-green-400';
|
||||
if (status === 'running') return 'bg-(--color-brand)/10 text-(--color-brand)';
|
||||
if (status === 'pending') return 'bg-sky-400/10 text-sky-400';
|
||||
if (status === 'failed') return 'bg-(--color-danger)/10 text-(--color-danger)';
|
||||
if (status === 'cancelled') return 'bg-zinc-700/50 text-(--color-muted)';
|
||||
return 'bg-zinc-700/50 text-(--color-text)';
|
||||
}
|
||||
|
||||
function kindLabel(kind: string) {
|
||||
const labels: Record<string, string> = {
|
||||
'chapter-names': 'Chapter Names',
|
||||
'batch-covers': 'Batch Covers',
|
||||
'chapter-covers': 'Chapter Covers',
|
||||
'refresh-metadata': 'Refresh Metadata'
|
||||
};
|
||||
return labels[kind] ?? kind;
|
||||
}
|
||||
|
||||
function fmtDate(s: string | undefined) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function duration(started: string | undefined, finished: string | undefined) {
|
||||
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`;
|
||||
}
|
||||
|
||||
function progress(job: AIJob) {
|
||||
if (!job.items_total) return null;
|
||||
return Math.round((job.items_done / job.items_total) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">AI Jobs</h1>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">Background AI generation tasks</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => invalidateAll()}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
{#each [
|
||||
{ label: 'Total', value: stats.total, color: 'text-(--color-text)' },
|
||||
{ label: 'Running', value: stats.running, color: 'text-(--color-brand)' },
|
||||
{ label: 'Pending', value: stats.pending, color: 'text-sky-400' },
|
||||
{ label: 'Done', value: stats.done, color: 'text-green-400' },
|
||||
{ label: 'Failed', value: stats.failed, color: 'text-(--color-danger)' }
|
||||
] as stat}
|
||||
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) px-4 py-3">
|
||||
<p class="text-xs text-(--color-muted) mb-1">{stat.label}</p>
|
||||
<p class={cn('text-xl font-bold tabular-nums', stat.color)}>{stat.value}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search slug, kind, model…"
|
||||
bind:value={query}
|
||||
class="flex-1 min-w-48 px-3 py-1.5 rounded-md bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
{#each STATUS_OPTIONS as s}
|
||||
<button
|
||||
onclick={() => (statusFilter = s)}
|
||||
class={cn(
|
||||
'px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize',
|
||||
statusFilter === s
|
||||
? 'bg-(--color-brand) text-black'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel error -->
|
||||
{#if cancelError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{cancelError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Jobs table -->
|
||||
{#if filteredJobs.length === 0}
|
||||
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) px-6 py-12 text-center">
|
||||
<p class="text-(--color-muted) text-sm">
|
||||
{jobs.length === 0 ? 'No AI jobs found.' : 'No jobs match your filters.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-(--color-border) overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-(--color-border) bg-(--color-surface-2)">
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Kind</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Slug</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden sm:table-cell">Model</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden md:table-cell">Progress</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden lg:table-cell">Started</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-semibold text-(--color-muted) uppercase tracking-wider hidden lg:table-cell">Duration</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-(--color-border)">
|
||||
{#each filteredJobs as job (job.id)}
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/40 transition-colors">
|
||||
<!-- Status badge -->
|
||||
<td class="px-4 py-3">
|
||||
<span class={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', statusBg(job.status))}>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Kind -->
|
||||
<td class="px-4 py-3 font-medium text-(--color-text)">
|
||||
{kindLabel(job.kind)}
|
||||
</td>
|
||||
|
||||
<!-- Slug -->
|
||||
<td class="px-4 py-3 max-w-[12rem]">
|
||||
{#if job.slug}
|
||||
<a
|
||||
href="/admin/image-gen?slug={job.slug}"
|
||||
class="text-(--color-brand) hover:underline truncate block font-mono text-xs"
|
||||
>
|
||||
{job.slug}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-(--color-muted) text-xs">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Model -->
|
||||
<td class="px-4 py-3 hidden sm:table-cell">
|
||||
<span class="font-mono text-xs text-(--color-muted) truncate block max-w-[10rem]" title={job.model}>
|
||||
{job.model || '—'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Progress -->
|
||||
<td class="px-4 py-3 hidden md:table-cell">
|
||||
{#if job.items_total > 0}
|
||||
{@const pct = progress(job)}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-20 h-1.5 rounded-full bg-(--color-border) overflow-hidden">
|
||||
<div
|
||||
class={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
job.status === 'done' ? 'bg-green-400' :
|
||||
job.status === 'failed' ? 'bg-(--color-danger)' :
|
||||
'bg-(--color-brand)'
|
||||
)}
|
||||
style="width: {pct}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-(--color-muted) tabular-nums whitespace-nowrap">
|
||||
{job.items_done}/{job.items_total}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-(--color-muted) text-xs">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Started -->
|
||||
<td class="px-4 py-3 hidden lg:table-cell">
|
||||
<span class="text-xs text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</span>
|
||||
</td>
|
||||
|
||||
<!-- Duration -->
|
||||
<td class="px-4 py-3 hidden lg:table-cell">
|
||||
<span class="text-xs text-(--color-muted) whitespace-nowrap tabular-nums">
|
||||
{duration(job.started, job.finished)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
{#if job.status === 'pending' || job.status === 'running'}
|
||||
<button
|
||||
onclick={() => cancelJob(job.id)}
|
||||
disabled={cancellingId === job.id}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{cancellingId === job.id ? 'Cancelling…' : 'Cancel'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.error_message}
|
||||
<span
|
||||
class="text-xs text-(--color-danger) max-w-[12rem] truncate"
|
||||
title={job.error_message}
|
||||
>
|
||||
{job.error_message}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Showing {filteredJobs.length} of {jobs.length} jobs
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { listImageModels } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export interface ImageModelInfo {
|
||||
@@ -12,13 +12,10 @@ export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
let imgModels: ImageModelInfo[] = [];
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
imgModels = (data.models ?? []) as ImageModelInfo[];
|
||||
}
|
||||
imgModels = await listImageModels<ImageModelInfo>();
|
||||
} catch (e) {
|
||||
log.warn('admin/catalogue-tools', 'failed to load image models', { err: String(e) });
|
||||
}
|
||||
return { imgModels };
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import * as cache from '$lib/server/cache';
|
||||
|
||||
export interface Release {
|
||||
id: number;
|
||||
@@ -13,7 +14,15 @@ export interface Release {
|
||||
const GITEA_RELEASES_URL =
|
||||
'https://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1';
|
||||
|
||||
const CACHE_KEY = 'admin:changelog:releases';
|
||||
const CACHE_TTL = 5 * 60; // 5 minutes
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const cached = await cache.get<Release[]>(CACHE_KEY);
|
||||
if (cached) {
|
||||
return { releases: cached };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(GITEA_RELEASES_URL, {
|
||||
headers: { Accept: 'application/json' }
|
||||
@@ -22,7 +31,9 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
||||
return { releases: [], error: `Gitea API returned ${res.status}` };
|
||||
}
|
||||
const releases: Release[] = await res.json();
|
||||
return { releases: releases.filter((r) => !r.draft) };
|
||||
const filtered = releases.filter((r) => !r.draft);
|
||||
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
|
||||
return { releases: filtered };
|
||||
} catch (e) {
|
||||
return { releases: [], error: String(e) };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { listImageModels } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { listBooks } from '$lib/server/pocketbase';
|
||||
|
||||
@@ -21,23 +21,18 @@ export interface BookSummary {
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// parent layout already guards admin role
|
||||
const [modelsResult, books] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const res = await backendFetch('/api/admin/image-gen/models');
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models ?? []) as ImageModelInfo[];
|
||||
})(),
|
||||
const [models, booksResult] = await Promise.allSettled([
|
||||
listImageModels<ImageModelInfo>(),
|
||||
listBooks()
|
||||
]);
|
||||
|
||||
if (modelsResult.status === 'rejected') {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(modelsResult.reason) });
|
||||
if (models.status === 'rejected') {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(models.reason) });
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as ImageModelInfo[]),
|
||||
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
|
||||
models: models.status === 'fulfilled' ? models.value : ([] as ImageModelInfo[]),
|
||||
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
summary: b.summary ?? '',
|
||||
|
||||
@@ -355,7 +355,13 @@
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
genError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
if (res.status === 502 || res.status === 504) {
|
||||
genError =
|
||||
body.error ??
|
||||
`Generation timed out (${res.status}). FLUX models can take 60–120 s on Cloudflare Workers AI — try reducing steps or switching to a faster model.`;
|
||||
} else {
|
||||
genError = body.error ?? body.message ?? `Error ${res.status}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -754,6 +760,13 @@
|
||||
|
||||
{#if showAdvanced}
|
||||
<div class="px-4 py-4 bg-(--color-surface) space-y-4">
|
||||
<!-- Cloudflare AI timeout warning -->
|
||||
{#if selectedModelInfo?.provider === 'cloudflare' || selectedModelInfo?.id.toLowerCase().includes('flux')}
|
||||
<p class="text-xs text-amber-400/80 bg-amber-400/10 rounded px-2.5 py-1.5">
|
||||
Cloudflare Workers AI has a ~100 s timeout. High step counts on FLUX models may result in a 502 error. Keep steps ≤ 20 to stay within limits.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- num_steps -->
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { listTextModels } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { listBooks } from '$lib/server/pocketbase';
|
||||
|
||||
@@ -18,23 +18,18 @@ export interface TextModelInfo {
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
const [modelsResult, books] = await Promise.allSettled([
|
||||
(async () => {
|
||||
const res = await backendFetch('/api/admin/text-gen/models');
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const data = await res.json();
|
||||
return (data.models ?? []) as TextModelInfo[];
|
||||
})(),
|
||||
const [models, booksResult] = await Promise.allSettled([
|
||||
listTextModels<TextModelInfo>(),
|
||||
listBooks()
|
||||
]);
|
||||
|
||||
if (modelsResult.status === 'rejected') {
|
||||
log.warn('admin/text-gen', 'failed to load models', { err: String(modelsResult.reason) });
|
||||
if (models.status === 'rejected') {
|
||||
log.warn('admin/text-gen', 'failed to load models', { err: String(models.reason) });
|
||||
}
|
||||
|
||||
return {
|
||||
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as TextModelInfo[]),
|
||||
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
|
||||
models: models.status === 'fulfilled' ? models.value : ([] as TextModelInfo[]),
|
||||
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title
|
||||
})) as BookSummary[]
|
||||
|
||||
29
ui/src/routes/api/admin/ai-jobs/[id]/cancel/+server.ts
Normal file
29
ui/src/routes/api/admin/ai-jobs/[id]/cancel/+server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* POST /api/admin/ai-jobs/[id]/cancel
|
||||
*
|
||||
* Admin-only proxy to the Go backend's AI job cancel endpoint.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' });
|
||||
} catch (e) {
|
||||
log.error('admin/ai-jobs/cancel', 'backend proxy error', { id, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
Reference in New Issue
Block a user