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>
376 lines
12 KiB
Svelte
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>
|