Files
Mangalord/frontend/src/lib/components/analysis/AnalysisMetricsPanel.svelte
MechaCat02 5fa4442904
Some checks failed
deploy / test-frontend (pull_request) Waiting to run
deploy / build-and-push (pull_request) Blocked by required conditions
deploy / deploy (pull_request) Blocked by required conditions
deploy / test-backend (pull_request) Has been cancelled
feat(admin): observability — job history, live "now analyzing", durations & metrics
Crawler + analysis admin dashboards gain a Live / History / Metrics
segmented view.

History: searchable, filterable, paginated job log per subsystem
(crawler_jobs across all states/kinds; page_analysis terminal outcomes),
with inline dead-job requeue and a Duration column.

Live: enrich analysis SSE events with manga title + chapter number and add
a sticky "Now analyzing" banner that jumps to and highlights the page.

Metrics: new durable crawl_metrics table (migration 0028) timing every
crawl op (manga list walk, manga detail, cover, whole chapter; per-page
derived from chapter) plus page_analysis.duration_ms for analysis. New
endpoints serve per-type average durations + success rates and a recent-ops
log; a cron reaper (CRAWL_METRICS_RETENTION_DAYS) bounds growth.

Tested: repo + API integration tests, vitest for the API client and
fmtDuration, and Playwright for the History/Metrics tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:07:10 +02:00

172 lines
4.9 KiB
Svelte

<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>