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:
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
26
frontend/src/routes/admin/health/+page.svelte
Normal file
26
frontend/src/routes/admin/health/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user