Files
Mangalord/frontend/src/lib/components/admin/HealthChecks.svelte
MechaCat02 9f7dfe4d4e
All checks were successful
deploy / test-backend (push) Successful in 27m23s
deploy / test-frontend (push) Successful in 10m20s
deploy / build-and-push (push) Successful in 10m42s
deploy / deploy (push) Successful in 13s
fix(admin): keyboard-reachable audit payload toggle; drop dead step ternary
Review follow-ups: the audit row's expand was mouse-only (onclick on a bare
<tr>). Move the toggle to a real <button> in the actions cell with
aria-expanded + aria-label so keyboard/AT users can open a row's payload; the
row onclick stays as a mouse convenience (stopPropagation avoids a double
toggle). Also simplify a no-op `step={'%' ? '1' : '1'}` to step="1" on the
threshold inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:55:48 +02:00

307 lines
9.4 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import {
getHealth,
updateHealthThresholds,
type HealthReport,
type HealthThresholds,
type CheckStatus
} from '$lib/api/admin';
const POLL_MS = 30_000;
let report = $state<HealthReport | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
let inflight: AbortController | null = null;
// Threshold editor state.
let editing = $state(false);
let form = $state<HealthThresholds | null>(null);
let saving = $state(false);
let saveError = $state<string | null>(null);
let saved = $state(false);
async function load() {
inflight?.abort();
const ctrl = new AbortController();
inflight = ctrl;
try {
const r = await getHealth({ signal: ctrl.signal });
report = r;
error = null;
} catch (e) {
if ((e as Error)?.name === 'AbortError') return;
error = e instanceof Error ? e.message : 'Failed to load health.';
} finally {
if (inflight === ctrl) loading = false;
}
}
onMount(() => {
load();
timer = setInterval(load, POLL_MS);
});
onDestroy(() => {
if (timer) clearInterval(timer);
inflight?.abort();
});
function startEdit() {
// Snapshot current thresholds into an editable copy.
form = report ? { ...report.thresholds } : null;
saveError = null;
saved = false;
editing = true;
}
async function save() {
if (!form) return;
saving = true;
saveError = null;
saved = false;
try {
report = await updateHealthThresholds(form);
saved = true;
editing = false;
} catch (e) {
saveError = e instanceof Error ? e.message : 'Failed to save thresholds.';
} finally {
saving = false;
}
}
const STATUS_GLYPH: Record<CheckStatus, string> = {
ok: '✓',
warn: '⚠',
critical: '✗'
};
const STATUS_LABEL: Record<CheckStatus, string> = {
ok: 'Healthy',
warn: 'Needs attention',
critical: 'Critical'
};
const failing = $derived(
report ? report.checks.filter((c) => c.status !== 'ok').length : 0
);
// Editable threshold fields, with units for the form labels.
const FIELDS: { key: keyof HealthThresholds; label: string; unit: string }[] = [
{ key: 'disk_pct', label: 'Disk usage', unit: '%' },
{ key: 'mem_pct', label: 'Memory usage', unit: '%' },
{ key: 'dead_jobs_max', label: 'Dead jobs (max)', unit: '' },
{ key: 'crawl_fail_pct', label: 'Crawl failure rate', unit: '%' },
{ key: 'analysis_fail_pct', label: 'Analysis failure rate', unit: '%' },
{ key: 'cron_freshness_hours', label: 'Cron freshness', unit: 'h' },
{ key: 'missing_covers_max', label: 'Missing covers (max)', unit: '' }
];
</script>
<section class="health" data-testid="health-checks">
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
{#if loading && !report}
<p class="muted" data-testid="health-loading">Loading…</p>
{:else if report}
<header class="overall status-{report.status}" data-testid="health-overall">
<span class="glyph">{STATUS_GLYPH[report.status]}</span>
<span class="overall-label">{STATUS_LABEL[report.status]}</span>
{#if failing > 0}
<span class="muted">· {failing} check{failing === 1 ? '' : 's'} need attention</span>
{/if}
</header>
<ul class="checks">
{#each report.checks as c (c.id)}
<li class="check status-{c.status}" data-testid={`health-check-${c.id}`}>
<span class="glyph" aria-hidden="true">{STATUS_GLYPH[c.status]}</span>
<span class="label">{c.label}</span>
<span class="value">
{c.value}{#if c.threshold}<span class="muted"> / {c.threshold}</span>{/if}
</span>
<span class="meta">
{#if c.hint}<span class="hint muted">{c.hint}</span>{/if}
{#if c.action_href && c.status !== 'ok'}
<a class="fix" href={c.action_href}>Fix →</a>
{/if}
</span>
</li>
{/each}
</ul>
<div class="thresholds">
<button
type="button"
class="disclosure"
onclick={() => (editing ? (editing = false) : startEdit())}
aria-expanded={editing}
data-testid="health-thresholds-toggle"
>
{editing ? '▾' : '▸'} Configure thresholds
</button>
{#if saved && !editing}<span class="ok-note">Saved ✓</span>{/if}
{#if editing && form}
<form
class="threshold-form"
onsubmit={(e) => {
e.preventDefault();
save();
}}
>
{#each FIELDS as f (f.key)}
<label>
<span>{f.label}{f.unit ? ` (${f.unit})` : ''}</span>
<input
type="number"
step="1"
min="0"
bind:value={form[f.key]}
data-testid={`threshold-${f.key}`}
/>
</label>
{/each}
{#if saveError}<p class="error" role="alert">{saveError}</p>{/if}
<div class="actions">
<button type="submit" disabled={saving} data-testid="threshold-save">
{saving ? 'Saving…' : 'Save'}
</button>
<button type="button" class="secondary" onclick={() => (editing = false)}>
Cancel
</button>
</div>
</form>
{/if}
</div>
{/if}
</section>
<style>
.overall {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-md);
font-weight: var(--weight-semibold);
margin-bottom: var(--space-3);
}
.overall .glyph {
font-size: var(--font-lg);
}
.checks {
list-style: none;
margin: 0 0 var(--space-4) 0;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.check {
display: grid;
grid-template-columns: 1.4rem 1fr auto;
grid-template-areas: 'glyph label value' '. meta meta';
align-items: center;
gap: var(--space-1) var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border);
font-size: var(--font-sm);
}
.check:last-child {
border-bottom: none;
}
.check .glyph {
grid-area: glyph;
font-weight: var(--weight-semibold);
}
.label {
grid-area: label;
}
.value {
grid-area: value;
text-align: right;
font-variant-numeric: tabular-nums;
}
.meta {
grid-area: meta;
display: flex;
justify-content: space-between;
gap: var(--space-2);
font-size: var(--font-xs);
}
.hint {
font-size: var(--font-xs);
}
.fix {
white-space: nowrap;
}
/* Status colors on the glyph + left accent. */
.status-ok .glyph {
color: var(--success, #16a34a);
}
.status-warn {
box-shadow: inset 3px 0 0 var(--warning, #d97706);
}
.status-warn .glyph {
color: var(--warning, #d97706);
}
.status-critical {
box-shadow: inset 3px 0 0 var(--danger, #dc2626);
}
.status-critical .glyph {
color: var(--danger, #dc2626);
}
.muted {
color: var(--text-muted);
}
.disclosure {
background: none;
border: none;
color: var(--text);
cursor: pointer;
font-size: var(--font-sm);
padding: 0;
}
.ok-note {
margin-left: var(--space-2);
color: var(--success, #16a34a);
font-size: var(--font-sm);
}
.threshold-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: var(--space-3);
margin-top: var(--space-3);
max-width: 40rem;
}
.threshold-form label {
display: flex;
flex-direction: column;
gap: 2px;
font-size: var(--font-xs);
color: var(--text-muted);
}
.threshold-form input {
height: 34px;
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);
}
.threshold-form .actions {
grid-column: 1 / -1;
display: flex;
gap: var(--space-2);
}
button.secondary {
background: var(--surface-elevated);
}
.error {
color: var(--danger, #dc2626);
}
</style>