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

@@ -43,7 +43,9 @@ import {
getCrawlerMetrics,
listCrawlerOps,
getAnalysisMetrics,
listAuditLog
listAuditLog,
getHealth,
updateHealthThresholds
} from './admin';
function ok(body: unknown, status = 200): Response {
@@ -506,6 +508,48 @@ describe('admin crawler api client', () => {
expect(url).toContain('days=7');
});
const healthFixture = {
status: 'ok' as const,
checks: [
{
id: 'memory',
label: 'Memory usage',
status: 'ok' as const,
value: '48%',
threshold: '90%',
hint: null,
action_href: null
}
],
thresholds: {
disk_pct: 90,
mem_pct: 90,
dead_jobs_max: 100,
crawl_fail_pct: 20,
analysis_fail_pct: 20,
cron_freshness_hours: 26,
missing_covers_max: 500
}
};
it('getHealth GETs /v1/admin/health', async () => {
fetchSpy.mockResolvedValueOnce(ok(healthFixture));
const r = await getHealth();
expect(r.status).toBe('ok');
expect(r.checks[0].id).toBe('memory');
expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/health$/);
});
it('updateHealthThresholds PUTs the thresholds body', async () => {
fetchSpy.mockResolvedValueOnce(ok(healthFixture));
await updateHealthThresholds(healthFixture.thresholds);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toMatch(/\/v1\/admin\/health\/thresholds$/);
const init = fetchSpy.mock.calls[0][1] as RequestInit;
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body as string).dead_jobs_max).toBe(100);
});
it('runCrawlerPass POSTs /v1/admin/crawler/run', async () => {
fetchSpy.mockResolvedValueOnce(ok({ started: true }));
const r = await runCrawlerPass();