feat(admin): observability — job history, live now-analyzing, durations & metrics (0.84.0) (#6)
This commit was merged in pull request #6.
This commit is contained in:
229
frontend/src/lib/components/analysis/AnalysisHistoryTable.svelte
Normal file
229
frontend/src/lib/components/analysis/AnalysisHistoryTable.svelte
Normal file
@@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Pager from '$lib/components/Pager.svelte';
|
||||
import { fmtDuration } from '$lib/format';
|
||||
import {
|
||||
listAnalysisHistory,
|
||||
type AnalysisHistoryRow
|
||||
} from '$lib/api/admin';
|
||||
|
||||
// Row click opens the parent's existing page-detail modal — history
|
||||
// reuses the same drill-down inspector as the coverage grid.
|
||||
let {
|
||||
onOpenDetail
|
||||
}: {
|
||||
onOpenDetail: (pageId: string) => void;
|
||||
} = $props();
|
||||
|
||||
const LIMIT = 25;
|
||||
|
||||
let rows = $state<AnalysisHistoryRow[]>([]);
|
||||
let total = $state(0);
|
||||
let page = $state(1);
|
||||
let statusFilter = $state<'' | 'done' | 'failed'>('');
|
||||
let nsfwOnly = $state(false);
|
||||
let search = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(total / LIMIT)));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const resp = await listAnalysisHistory({
|
||||
status: statusFilter || undefined,
|
||||
nsfw: nsfwOnly,
|
||||
search: search.trim() || undefined,
|
||||
limit: LIMIT,
|
||||
offset: (page - 1) * LIMIT
|
||||
});
|
||||
rows = resp.items;
|
||||
total = resp.page.total ?? resp.items.length;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load history.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function applyFilters() {
|
||||
page = 1;
|
||||
load();
|
||||
}
|
||||
function onPageChange(p: number) {
|
||||
page = p;
|
||||
load();
|
||||
}
|
||||
function onSearchKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') applyFilters();
|
||||
}
|
||||
|
||||
function fmtAgo(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const secs = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (secs < 45) return 'just now';
|
||||
const mins = Math.round(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="history" data-testid="analysis-history">
|
||||
<div class="toolbar">
|
||||
<select
|
||||
bind:value={statusFilter}
|
||||
onchange={applyFilters}
|
||||
aria-label="Filter by status"
|
||||
data-testid="analysis-history-status"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="done">done</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
<label class="nsfw">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={nsfwOnly}
|
||||
onchange={applyFilters}
|
||||
data-testid="analysis-history-nsfw"
|
||||
/>
|
||||
NSFW only
|
||||
</label>
|
||||
<input
|
||||
class="search"
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder="Search manga title…"
|
||||
onkeydown={onSearchKey}
|
||||
data-testid="analysis-history-search"
|
||||
/>
|
||||
<button type="button" onclick={applyFilters}>Search</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted" data-testid="analysis-history-loading">Loading…</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="muted" data-testid="analysis-history-empty">No analyses match.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Page</th>
|
||||
<th>Model</th>
|
||||
<th class="num">Dur</th>
|
||||
<th>Analyzed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as r (r.page_id)}
|
||||
<tr
|
||||
class="clickable"
|
||||
onclick={() => onOpenDetail(r.page_id)}
|
||||
data-testid={`analysis-history-row-${r.page_id}`}
|
||||
title={r.error ?? ''}
|
||||
>
|
||||
<td><span class="status-pill {r.status}">{r.status}</span></td>
|
||||
<td>
|
||||
{r.manga_title} · Ch {r.chapter_number} · p{r.page_number}
|
||||
{#if r.is_nsfw}<span class="nsfw-tag">⚠ NSFW</span>{/if}
|
||||
</td>
|
||||
<td class="muted">{r.model ?? '—'}</td>
|
||||
<td class="num">{fmtDuration(r.duration_ms)}</td>
|
||||
<td title={r.analyzed_at ? new Date(r.analyzed_at).toLocaleString() : ''}
|
||||
>{fmtAgo(r.analyzed_at)}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager {page} {totalPages} onChange={onPageChange} testid="analysis-history-pager" />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
select,
|
||||
.search {
|
||||
height: 36px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.nsfw {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
max-width: 52rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
tr.clickable:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.status-pill {
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: var(--font-xs);
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
text-transform: uppercase;
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.status-pill.done {
|
||||
background: color-mix(in srgb, #2e7d32 16%, transparent);
|
||||
color: #2e7d32;
|
||||
}
|
||||
.status-pill.failed {
|
||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
.nsfw-tag {
|
||||
font-size: var(--font-xs);
|
||||
color: #b85e1a;
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
171
frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte
Normal file
171
frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fmtDuration } from '$lib/format';
|
||||
import { getAnalysisMetrics, type AnalysisMetrics } from '$lib/api/admin';
|
||||
|
||||
let days = $state(7);
|
||||
let metrics = $state<AnalysisMetrics | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const successPct = $derived(
|
||||
metrics && metrics.n > 0 ? Math.round((metrics.ok / metrics.n) * 100) : null
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
metrics = await getAnalysisMetrics(days);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load metrics.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<section data-testid="analysis-metrics">
|
||||
<div class="winrow">
|
||||
<label>
|
||||
Window
|
||||
<select bind:value={days} onchange={load} data-testid="analysis-metrics-window">
|
||||
<option value={1}>24 hours</option>
|
||||
<option value={7}>7 days</option>
|
||||
<option value={30}>30 days</option>
|
||||
<option value={0}>All time</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{:else if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if metrics}
|
||||
<div class="tiles">
|
||||
<div class="tile">
|
||||
<span class="label">Pages analyzed</span>
|
||||
<span class="value" data-testid="analysis-metrics-n">{metrics.n}</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="label">Avg duration</span>
|
||||
<span class="value" data-testid="analysis-metrics-avg"
|
||||
>{fmtDuration(metrics.avg_ms)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="label">Success</span>
|
||||
<span class="value">{successPct == null ? '—' : `${successPct}%`}</span>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<span class="label">Failed</span>
|
||||
<span class="value">{metrics.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>By model</h2>
|
||||
{#if metrics.by_model.length === 0}
|
||||
<p class="muted">No completed analyses in this window.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th class="num">Avg</th>
|
||||
<th class="num">N</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each metrics.by_model as m (m.model)}
|
||||
<tr>
|
||||
<td>{m.model ?? '(unknown)'}</td>
|
||||
<td class="num">{fmtDuration(m.avg_ms)}</td>
|
||||
<td class="num">{m.n}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.winrow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
select {
|
||||
height: 32px;
|
||||
margin-left: var(--space-1);
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
max-width: 44rem;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
.tile .label {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.tile .value {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
max-width: 32rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
293
frontend/src/lib/components/crawler/CrawlerHistoryTable.svelte
Normal file
293
frontend/src/lib/components/crawler/CrawlerHistoryTable.svelte
Normal file
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Pager from '$lib/components/Pager.svelte';
|
||||
import { fmtDuration } from '$lib/format';
|
||||
import SearchBar from './SearchBar.svelte';
|
||||
import {
|
||||
listCrawlerJobHistory,
|
||||
type CrawlerHistoryRow,
|
||||
type CrawlerJobState,
|
||||
type CrawlerJobKind,
|
||||
type RequeueScope
|
||||
} from '$lib/api/admin';
|
||||
|
||||
// Requeue is owned by the parent (shared with the dead-jobs table); we
|
||||
// call it then reload so the row's new state shows. `busy` disables the
|
||||
// inline button during a parent-driven action.
|
||||
let {
|
||||
onRequeue,
|
||||
busy = false
|
||||
}: {
|
||||
onRequeue: (scope: RequeueScope) => Promise<void>;
|
||||
busy?: boolean;
|
||||
} = $props();
|
||||
|
||||
const LIMIT = 25;
|
||||
|
||||
let rows = $state<CrawlerHistoryRow[]>([]);
|
||||
let total = $state(0);
|
||||
let page = $state(1);
|
||||
let stateFilter = $state<'' | CrawlerJobState>('');
|
||||
let kindFilter = $state<'' | CrawlerJobKind>('');
|
||||
let search = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let expanded = $state<string | null>(null);
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(total / LIMIT)));
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const resp = await listCrawlerJobHistory({
|
||||
state: stateFilter || undefined,
|
||||
kind: kindFilter || undefined,
|
||||
search: search.trim() || undefined,
|
||||
limit: LIMIT,
|
||||
offset: (page - 1) * LIMIT
|
||||
});
|
||||
rows = resp.items;
|
||||
total = resp.page.total ?? resp.items.length;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load history.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
function applyFilters() {
|
||||
page = 1;
|
||||
load();
|
||||
}
|
||||
function onPageChange(p: number) {
|
||||
page = p;
|
||||
load();
|
||||
}
|
||||
async function requeue(scope: RequeueScope) {
|
||||
await onRequeue(scope);
|
||||
await load();
|
||||
}
|
||||
|
||||
/** A human "Manga · Ch N · pP" target, or the source key / em-dash. */
|
||||
function target(r: CrawlerHistoryRow): string {
|
||||
if (r.manga_title) {
|
||||
let s = r.manga_title;
|
||||
if (r.chapter_number != null) s += ` · Ch ${r.chapter_number}`;
|
||||
if (r.page_number != null) s += ` · p${r.page_number}`;
|
||||
return s;
|
||||
}
|
||||
return r.source_key ?? '—';
|
||||
}
|
||||
|
||||
function fmtAgo(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const secs = Math.round((Date.now() - then) / 1000);
|
||||
if (secs < 45) return 'just now';
|
||||
if (secs < 90) return '1m ago';
|
||||
const mins = Math.round(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
const KINDS: { value: '' | CrawlerJobKind; label: string }[] = [
|
||||
{ value: '', label: 'All types' },
|
||||
{ value: 'sync_manga', label: 'sync_manga' },
|
||||
{ value: 'sync_chapter_list', label: 'sync_chapter_list' },
|
||||
{ value: 'sync_chapter_content', label: 'sync_chapter' },
|
||||
{ value: 'analyze_page', label: 'analyze_page' }
|
||||
];
|
||||
const STATES: { value: '' | CrawlerJobState; label: string }[] = [
|
||||
{ value: '', label: 'All states' },
|
||||
{ value: 'done', label: 'done' },
|
||||
{ value: 'dead', label: 'dead' },
|
||||
{ value: 'running', label: 'running' },
|
||||
{ value: 'pending', label: 'pending' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="history" data-testid="crawler-history">
|
||||
<div class="toolbar">
|
||||
<select
|
||||
bind:value={stateFilter}
|
||||
onchange={applyFilters}
|
||||
aria-label="Filter by state"
|
||||
data-testid="crawler-history-state"
|
||||
>
|
||||
{#each STATES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
|
||||
</select>
|
||||
<select
|
||||
bind:value={kindFilter}
|
||||
onchange={applyFilters}
|
||||
aria-label="Filter by type"
|
||||
data-testid="crawler-history-kind"
|
||||
>
|
||||
{#each KINDS as k (k.value)}<option value={k.value}>{k.label}</option>{/each}
|
||||
</select>
|
||||
<SearchBar
|
||||
bind:value={search}
|
||||
placeholder="Search manga / chapter…"
|
||||
onSearch={applyFilters}
|
||||
/>
|
||||
</div>
|
||||
<p class="muted note">
|
||||
Showing recent jobs — completed jobs are pruned after the retention
|
||||
window; dead jobs persist until requeued.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted" data-testid="crawler-history-loading">Loading…</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="muted" data-testid="crawler-history-empty">No jobs match.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>State</th>
|
||||
<th>Type</th>
|
||||
<th>Target</th>
|
||||
<th class="num">Dur</th>
|
||||
<th>Att.</th>
|
||||
<th>Updated</th>
|
||||
<th class="actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as r (r.id)}
|
||||
<tr
|
||||
class:clickable={!!r.last_error}
|
||||
onclick={() =>
|
||||
r.last_error && (expanded = expanded === r.id ? null : r.id)}
|
||||
data-testid={`crawler-history-row-${r.id}`}
|
||||
>
|
||||
<td>
|
||||
<span class="badge state-{r.state}">{r.state}</span>
|
||||
</td>
|
||||
<td class="kind">{r.kind ?? '—'}</td>
|
||||
<td>{target(r)}</td>
|
||||
<td class="num">{fmtDuration(r.duration_ms)}</td>
|
||||
<td>{r.attempts}/{r.max_attempts}</td>
|
||||
<td title={new Date(r.updated_at).toLocaleString()}
|
||||
>{fmtAgo(r.updated_at)}</td
|
||||
>
|
||||
<td class="actions">
|
||||
{#if r.state === 'dead'}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
requeue({ scope: 'job', job_id: r.id });
|
||||
}}
|
||||
data-testid={`crawler-history-requeue-${r.id}`}
|
||||
>
|
||||
⤴ Requeue
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{#if expanded === r.id && r.last_error}
|
||||
<tr class="errrow">
|
||||
<td colspan="7"><code>{r.last_error}</code></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager {page} {totalPages} onChange={onPageChange} testid="crawler-history-pager" />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
select {
|
||||
height: 36px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.note {
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.kind {
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.actions {
|
||||
text-align: right;
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
tr.clickable:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.errrow td {
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.errrow code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger, #dc2626);
|
||||
}
|
||||
/* State dots reuse the page's shared badge palette. */
|
||||
.badge {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.state-done {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border-color: #86efac;
|
||||
}
|
||||
.state-dead {
|
||||
background: color-mix(in srgb, var(--danger, #dc2626) 16%, transparent);
|
||||
color: var(--danger, #dc2626);
|
||||
border-color: color-mix(in srgb, var(--danger, #dc2626) 45%, transparent);
|
||||
}
|
||||
.state-running,
|
||||
.state-pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
</style>
|
||||
330
frontend/src/lib/components/crawler/CrawlerMetricsPanel.svelte
Normal file
330
frontend/src/lib/components/crawler/CrawlerMetricsPanel.svelte
Normal file
@@ -0,0 +1,330 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Pager from '$lib/components/Pager.svelte';
|
||||
import { fmtDuration } from '$lib/format';
|
||||
import {
|
||||
getCrawlerMetrics,
|
||||
listCrawlerOps,
|
||||
type OpSummary,
|
||||
type OpRow,
|
||||
type CrawlOp
|
||||
} from '$lib/api/admin';
|
||||
|
||||
const LIMIT = 25;
|
||||
|
||||
let days = $state(7);
|
||||
let summary = $state<OpSummary[]>([]);
|
||||
let summaryLoading = $state(true);
|
||||
|
||||
let rows = $state<OpRow[]>([]);
|
||||
let total = $state(0);
|
||||
let page = $state(1);
|
||||
let opFilter = $state<'' | CrawlOp>('');
|
||||
let outcomeFilter = $state<'' | 'ok' | 'failed'>('');
|
||||
let opsLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let expanded = $state<string | null>(null);
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(total / LIMIT)));
|
||||
|
||||
// Human labels + canonical order for the summary table.
|
||||
const OP_LABELS: Record<CrawlOp, string> = {
|
||||
manga_list: 'manga list walk',
|
||||
manga_detail: 'manga detail',
|
||||
manga_cover: 'manga cover',
|
||||
chapter: 'whole chapter'
|
||||
};
|
||||
const OP_ORDER: CrawlOp[] = ['manga_list', 'manga_detail', 'manga_cover', 'chapter'];
|
||||
const orderedSummary = $derived(
|
||||
OP_ORDER.map((op) => summary.find((s) => s.op === op)).filter(
|
||||
(s): s is OpSummary => s != null
|
||||
)
|
||||
);
|
||||
// Derived per-page crawl time from the chapter roll-up (Σms ÷ Σpages ≈
|
||||
// avg_ms ÷ avg_items). Shown as an indented sub-row under "whole chapter".
|
||||
const chapter = $derived(summary.find((s) => s.op === 'chapter'));
|
||||
const perPageMs = $derived(
|
||||
chapter && chapter.avg_ms != null && chapter.avg_items && chapter.avg_items > 0
|
||||
? chapter.avg_ms / chapter.avg_items
|
||||
: null
|
||||
);
|
||||
|
||||
function successPct(s: OpSummary): string {
|
||||
if (s.n === 0) return '—';
|
||||
return `${Math.round((s.ok / s.n) * 100)}%`;
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
summaryLoading = true;
|
||||
try {
|
||||
summary = (await getCrawlerMetrics(days)).summary;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load metrics.';
|
||||
} finally {
|
||||
summaryLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOps() {
|
||||
opsLoading = true;
|
||||
try {
|
||||
const resp = await listCrawlerOps({
|
||||
op: opFilter || undefined,
|
||||
outcome: outcomeFilter || undefined,
|
||||
days,
|
||||
limit: LIMIT,
|
||||
offset: (page - 1) * LIMIT
|
||||
});
|
||||
rows = resp.items;
|
||||
total = resp.page.total ?? resp.items.length;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load operations.';
|
||||
} finally {
|
||||
opsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadSummary();
|
||||
loadOps();
|
||||
});
|
||||
|
||||
function onWindowChange() {
|
||||
page = 1;
|
||||
loadSummary();
|
||||
loadOps();
|
||||
}
|
||||
function onOpsFilter() {
|
||||
page = 1;
|
||||
loadOps();
|
||||
}
|
||||
function onPageChange(p: number) {
|
||||
page = p;
|
||||
loadOps();
|
||||
}
|
||||
|
||||
function opLabel(op: CrawlOp): string {
|
||||
return OP_LABELS[op] ?? op;
|
||||
}
|
||||
function fmtAgo(iso: string): string {
|
||||
const secs = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (secs < 45) return 'just now';
|
||||
const mins = Math.round(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section data-testid="crawler-metrics">
|
||||
<div class="winrow">
|
||||
<label>
|
||||
Window
|
||||
<select
|
||||
bind:value={days}
|
||||
onchange={onWindowChange}
|
||||
data-testid="crawler-metrics-window"
|
||||
>
|
||||
<option value={1}>24 hours</option>
|
||||
<option value={7}>7 days</option>
|
||||
<option value={30}>30 days</option>
|
||||
<option value={0}>All time</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<h2>Average durations by type</h2>
|
||||
{#if summaryLoading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if orderedSummary.length === 0}
|
||||
<p class="muted" data-testid="crawler-metrics-empty">No operations recorded yet.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th class="num">Avg</th>
|
||||
<th class="num">N</th>
|
||||
<th class="num">OK / Fail</th>
|
||||
<th class="num">Success</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each orderedSummary as s (s.op)}
|
||||
<tr data-testid={`crawler-metrics-row-${s.op}`}>
|
||||
<td>{opLabel(s.op)}</td>
|
||||
<td class="num">{fmtDuration(s.avg_ms)}</td>
|
||||
<td class="num">{s.n}</td>
|
||||
<td class="num">{s.ok} / {s.failed}</td>
|
||||
<td class="num">{successPct(s)}</td>
|
||||
</tr>
|
||||
{#if s.op === 'chapter' && perPageMs != null}
|
||||
<tr class="derived" data-testid="crawler-metrics-perpage">
|
||||
<td>└ per page (≈)</td>
|
||||
<td class="num">{fmtDuration(perPageMs)}</td>
|
||||
<td class="num" colspan="3"
|
||||
>≈{Math.round(chapter?.avg_items ?? 0)} pages/chapter</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<h2>Recent operations</h2>
|
||||
<div class="toolbar">
|
||||
<select bind:value={opFilter} onchange={onOpsFilter} aria-label="Filter by type">
|
||||
<option value="">All types</option>
|
||||
{#each OP_ORDER as op (op)}<option value={op}>{opLabel(op)}</option>{/each}
|
||||
</select>
|
||||
<select
|
||||
bind:value={outcomeFilter}
|
||||
onchange={onOpsFilter}
|
||||
aria-label="Filter by outcome"
|
||||
>
|
||||
<option value="">All outcomes</option>
|
||||
<option value="ok">ok</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if opsLoading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="muted" data-testid="crawler-metrics-ops-empty">No operations match.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Target</th>
|
||||
<th class="num">Dur</th>
|
||||
<th>Outcome</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as r (r.id)}
|
||||
<tr
|
||||
class:clickable={!!r.error}
|
||||
onclick={() => r.error && (expanded = expanded === r.id ? null : r.id)}
|
||||
data-testid={`crawler-op-${r.id}`}
|
||||
>
|
||||
<td>{opLabel(r.op)}</td>
|
||||
<td>
|
||||
{r.manga_title
|
||||
? `${r.manga_title}${r.chapter_number != null ? ` · Ch ${r.chapter_number}` : ''}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td class="num">{fmtDuration(r.duration_ms)}</td>
|
||||
<td>
|
||||
<span class="dot {r.outcome}"></span>{r.outcome}
|
||||
</td>
|
||||
<td title={new Date(r.finished_at).toLocaleString()}
|
||||
>{fmtAgo(r.finished_at)}</td
|
||||
>
|
||||
</tr>
|
||||
{#if expanded === r.id && r.error}
|
||||
<tr class="errrow"><td colspan="5"><code>{r.error}</code></td></tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager {page} {totalPages} onChange={onPageChange} testid="crawler-metrics-pager" />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.winrow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
select {
|
||||
height: 32px;
|
||||
margin-left: var(--space-1);
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
h2 {
|
||||
margin: var(--space-4) 0 var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
max-width: 48rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.derived td {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
tr.clickable:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.errrow td {
|
||||
background: var(--surface);
|
||||
}
|
||||
.errrow code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dot.ok {
|
||||
background: #2e7d32;
|
||||
}
|
||||
.dot.failed {
|
||||
background: var(--danger);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user