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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user