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>
172 lines
4.9 KiB
Svelte
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>
|