Files
Mangalord/frontend/src/lib/components/crawler/CrawlerMetricsPanel.svelte
MechaCat02 c6a6d1690d feat(admin): time-series trend charts on the metrics tabs (0.87.0)
Turn the dormant timing data in crawl_metrics / page_analysis into "is it
healthy over time" views. New bucketed series queries (GROUP BY date_trunc,
hour|day via a closed Bucket enum so the unit can't be attacker-controlled)
behind GET /v1/admin/{crawler,analysis}/metrics/series, with a shared
SeriesParams/resolve_bucket helper (bad bucket → 400) and migration 0030
indexing page_analysis(analyzed_at) for the analysis scan.

Frontend: a dependency-free SVG TrendChart (line+area, null buckets render as
gaps, empty-state, role=img) embedded above the per-op tables in Crawler and
Analysis → Metrics, driven by each panel's existing window selector with
AbortController-cancelled fetches. A buildSeries() util fills the continuous
bucket axis (throughput 0 for empty buckets, success/duration null) — unit
tested alongside the chart and the series API client.

Closes the Phase-1 observability set (audit log · health checks · trends).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:48:56 +02:00

376 lines
12 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import Pager from '$lib/components/Pager.svelte';
import { fmtDuration } from '$lib/format';
import TrendChart from '$lib/components/charts/TrendChart.svelte';
import { buildSeries, type TrendSeries } from '$lib/admin/series';
import {
getCrawlerMetrics,
getCrawlerMetricsSeries,
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 series = $state<TrendSeries | null>(null);
let seriesCtrl: AbortController | null = null;
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 loadSeries() {
seriesCtrl?.abort();
const ctrl = new AbortController();
seriesCtrl = ctrl;
try {
const resp = await getCrawlerMetricsSeries(days, { signal: ctrl.signal });
series = buildSeries(resp.buckets, days);
} catch (e) {
if ((e as Error)?.name === 'AbortError') return;
// A chart failure shouldn't blank the whole panel; leave prior series.
}
}
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();
loadSeries();
loadOps();
});
function onWindowChange() {
page = 1;
loadSummary();
loadSeries();
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}
{#if series}
<div class="charts" data-testid="crawler-metrics-charts">
<TrendChart points={series.throughput} label="Throughput (ops/bucket)" />
<TrendChart
points={series.success}
label="Success rate"
format={(v) => `${v.toFixed(0)}%`}
accent="var(--success, #16a34a)"
/>
<TrendChart
points={series.duration}
label="Avg duration"
format={(v) => fmtDuration(v)}
accent="var(--warning, #d97706)"
/>
</div>
{/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);
}
.charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
max-width: 60rem;
}
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>