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>
307 lines
9.4 KiB
Svelte
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>
|