feat(admin): operational health checks page with editable thresholds

New /admin/health rolls up signals already collected — disk/memory sensors,
dead-job + missing-cover backlogs, 24h crawl/analysis failure rates, metadata
cron freshness, and the live crawler session/browser flags — into a flat
checks list (ok/warn/critical) with an overall status and remediation links.
The overview gains a compact Health summary banner linking in.

GET /v1/admin/health evaluates the checks (DB sub-queries run concurrently via
try_join; all hit existing indexes). PUT /v1/admin/health/thresholds persists
the thresholds to app_settings (merged over compiled defaults, forward-compat
via serde(default)), validates ranges (400 on bad input), and audit-logs the
change in one transaction. New repo::crawler::last_metadata_tick_at getter and
a lightweight system::memory_percent_used (no CPU settle delay).

Pure status helpers (over_limit for gauges, exceeds for backlog counts so a
0-limit doesn't false-alarm at 0) are unit-tested; endpoint tests cover shape,
gating, persistence+audit, and validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-19 11:38:18 +02:00
parent 31013cc893
commit 314fc8738b
12 changed files with 1265 additions and 2 deletions

View File

@@ -4,6 +4,7 @@
const tabs = [
{ href: '/admin', label: 'Overview' },
{ href: '/admin/health', label: 'Health' },
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/mangas', label: 'Mangas' },
{ href: '/admin/crawler', label: 'Crawler' },

View File

@@ -9,11 +9,13 @@
analysisStatusStreamUrl,
getCrawlerSettings,
getAnalysisSettings,
getHealth,
type SystemStats,
type OverviewStats,
type CrawlerStatus,
type AnalysisMetrics,
type AnalysisEvent
type AnalysisEvent,
type HealthReport
} from '$lib/api/admin';
import { formatBytes as fmtBytes } from '$lib/upload-validation';
import type { LayoutData } from './$types';
@@ -44,6 +46,8 @@
let liveCrawler = $state(false);
let liveAnalysis = $state(false);
let health = $state<HealthReport | null>(null);
let sysTimer: ReturnType<typeof setInterval> | null = null;
let overviewTimer: ReturnType<typeof setInterval> | null = null;
let crawlerSource: EventSource | null = null;
@@ -86,6 +90,17 @@
// ignore
}
}
async function refreshHealth() {
try {
health = await getHealth();
} catch {
// keep the last good snapshot; retried next tick
}
}
const healthFailing = $derived(
health ? health.checks.filter((c) => c.status !== 'ok').length : 0
);
async function loadSettings() {
try {
const c = await getCrawlerSettings();
@@ -183,6 +198,7 @@
refreshSys();
refreshOverview();
refreshMetrics();
refreshHealth();
loadSettings();
getCrawlerStatus()
.then((s) => (crawler = s))
@@ -196,6 +212,7 @@
overviewTimer = setInterval(() => {
refreshOverview();
refreshMetrics();
refreshHealth();
}, 60000);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibilityChange);
@@ -237,6 +254,26 @@
<h1>Overview</h1>
{#if health}
<a
class="health-summary status-{health.status}"
href="/admin/health"
data-testid="overview-health"
>
<span class="glyph" aria-hidden="true"
>{health.status === 'ok' ? '✓' : health.status === 'warn' ? '⚠' : '✗'}</span
>
<span class="hs-label">
{#if health.status === 'ok'}
All health checks passing
{:else}
{healthFailing} health check{healthFailing === 1 ? '' : 's'} need attention
{/if}
</span>
<span class="hs-view">View →</span>
</a>
{/if}
{#if sys.alerts.length > 0 || extraAlerts.length > 0}
<section class="alerts" data-testid="admin-alerts">
{#each sys.alerts as a (a.message)}
@@ -434,6 +471,44 @@
h1 {
margin: 0 0 var(--space-4) 0;
}
.health-summary {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
font-size: var(--font-sm);
}
.health-summary:hover {
background: var(--surface-elevated);
text-decoration: none;
}
.health-summary .glyph {
font-weight: var(--weight-semibold);
}
.health-summary .hs-view {
margin-left: auto;
color: var(--text-muted);
}
.health-summary.status-ok .glyph {
color: var(--success, #16a34a);
}
.health-summary.status-warn {
border-left: 4px solid var(--warning, #d97706);
}
.health-summary.status-warn .glyph {
color: var(--warning, #d97706);
}
.health-summary.status-critical {
border-left: 4px solid var(--danger, #dc2626);
}
.health-summary.status-critical .glyph {
color: var(--danger, #dc2626);
}
.alerts {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import HealthChecks from '$lib/components/admin/HealthChecks.svelte';
</script>
<svelte:head><title>Health · Admin</title></svelte:head>
<h1>Health</h1>
<p class="lead">
Operational checks rolled up from system sensors, the job queue, recent
failure rates and the crawler daemon. Thresholds are editable below and
apply immediately.
</p>
<HealthChecks />
<style>
h1 {
margin-bottom: var(--space-2);
}
.lead {
color: var(--text-muted);
font-size: var(--font-sm);
margin-bottom: var(--space-4);
max-width: 44rem;
}
</style>