Damit gibt es nur noch ein Stylesheet. Uebersicht, Meldungen, Kennzahlen, Berichte, Security Center, Wiederherstellungspunkte, Anmeldung, Backup-Assistent und Gesundheitsanzeige nutzten noch das alte — sie funktionierten, sahen aber anders aus als der Rest. Der Backup-Assistent wurde als **Klassenabbildung** umgestellt, nicht neu geschrieben: Seine Logik ist geprueft und richtig; eine Neufassung haette 500 Zeilen Verhalten ohne Not angefasst. Die elf Tests des Assistenten pruefen Verhalten und blieben unveraendert gueltig. Entfernt, weil ersetzt und nirgends mehr verwendet: JobsPanel, StatusIndicator, PageState, App.css, tokens.css und vier weitere Stylesheets. Das ausgelieferte CSS faellt von 54 auf 30 KB. Drei Funde beim Umbau, alle von Tests aufgedeckt: - **`describeApiError` warf die genauere Servermeldung weg.** Sie ersetzte sie durch den allgemeinen Satz aus der Codetabelle. Ein `SERVICE_UNAVAILABLE` mit der Meldung "Fuer diesen Bericht ist keine Sicherheitspruefung eingerichtet." wurde zu "Der Dienst ist derzeit nicht vollstaendig verfuegbar" — der Betreiber haette den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses einen Berichts. Jetzt hat die Servermeldung Vorrang; die Tabelle springt nur ein, wenn keine mitkommt. - **Einem Fehler nach einer Handlung fehlte `role="alert"`.** Ein Screenreader haette ihn nicht angesagt. `Callout` nimmt jetzt eine Rolle entgegen; Standard bleibt `note`, weil die meisten Hinweise schon beim Oeffnen dastehen. - **Zwei Statusbeschriftungen wichen von den etablierten ab** (`Gesund` statt `Fehlerfrei`, `Nicht verbunden` statt `Nicht erreichbar`). Die etablierten gewinnen — sie stehen in Tests fest und sind treffender. Dazu zwei kleinere Korrekturen: Die Gesundheitsanzeige hing kurzzeitig in der Uebersicht und verband damit zwei Ladewege, die nichts miteinander zu tun haben; sie steht jetzt wieder daneben. Und ein frueherer Regex hatte `(row) => void | undefined` erzeugt — gemeint war eine optionale Eigenschaft, geschrieben stand "gibt void oder undefined zurueck". Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das Design-System steckt im ausgelieferten CSS samt Dark-Mode-Regeln, und vom alten Stylesheet ist kein Klassenname mehr darin. 76 Tests gruen, tsc sauber, eslint ohne Warnung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
/**
|
||
* Meldungen.
|
||
*
|
||
* Das Regelwerk steht mit auf der Seite, und das ist die tragende Entscheidung:
|
||
* **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiss,
|
||
* was ueberhaupt geprueft wird.** Ohne die Liste bliebe offen, ob „keine
|
||
* Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprueft".
|
||
*
|
||
* „Zur Kenntnis nehmen" schliesst eine Meldung nicht. Sie bleibt offen und in
|
||
* der Liste, bis ihre Ursache verschwindet — sonst verschwaende der Zustand aus
|
||
* der Uebersicht, obwohl er weiterbesteht.
|
||
*/
|
||
|
||
import { useCallback, useState } from 'react';
|
||
import { useApiResource } from '@/api/useApiResource';
|
||
import { describeApiError, useMutation } from '@/api/useMutation';
|
||
import { PageHeader } from '@/components/layout/PageHeader';
|
||
import {
|
||
Button,
|
||
Callout,
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CheckboxField,
|
||
EmptyState,
|
||
ErrorState,
|
||
LoadingState,
|
||
StatusBadge,
|
||
useToast,
|
||
} from '@/components/ui';
|
||
import { cn, formatDateTime } from '@/lib/utils';
|
||
import {
|
||
acknowledgeAlert,
|
||
fetchAlertOverview,
|
||
fetchAlerts,
|
||
resolveAlert,
|
||
} from './alertsApi';
|
||
import type { Alert, AlertOverview } from './alertsApi';
|
||
|
||
export function AlertsPage(): React.JSX.Element {
|
||
const toast = useToast();
|
||
const [onlyActive, setOnlyActive] = useState(true);
|
||
const [actionCounter, setActionCounter] = useState(0);
|
||
|
||
const loadAlerts = useCallback(
|
||
(abortSignal: AbortSignal) => fetchAlerts(onlyActive, abortSignal),
|
||
[onlyActive],
|
||
);
|
||
|
||
const { loadState, data, loadError, reload } = useApiResource<readonly Alert[]>(
|
||
loadAlerts,
|
||
`${String(onlyActive)}|${String(actionCounter)}`,
|
||
);
|
||
|
||
const loadOverview = useCallback(
|
||
(abortSignal: AbortSignal) => fetchAlertOverview(abortSignal),
|
||
[],
|
||
);
|
||
|
||
const overviewResource = useApiResource<AlertOverview>(loadOverview, String(actionCounter));
|
||
|
||
const acknowledgeMutation = useMutation(
|
||
(alertIdentifier: string) => acknowledgeAlert(alertIdentifier, ''),
|
||
{
|
||
onSuccess: () => {
|
||
toast.showSuccess(
|
||
'Zur Kenntnis genommen',
|
||
'Die Meldung bleibt offen, bis ihre Ursache verschwindet.',
|
||
);
|
||
setActionCounter((previousCounter) => previousCounter + 1);
|
||
},
|
||
onError: (apiError) =>
|
||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
||
},
|
||
);
|
||
|
||
const resolveMutation = useMutation(
|
||
(alertIdentifier: string) => resolveAlert(alertIdentifier, 'Von Hand geschlossen.'),
|
||
{
|
||
onSuccess: () => {
|
||
toast.showSuccess('Meldung geschlossen');
|
||
setActionCounter((previousCounter) => previousCounter + 1);
|
||
},
|
||
onError: (apiError) =>
|
||
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
|
||
},
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title="Meldungen"
|
||
description={
|
||
overviewResource.data !== null
|
||
? `${overviewResource.data.available_rule_count} von ${overviewResource.data.rules.length} Regeln werden geprueft.`
|
||
: undefined
|
||
}
|
||
/>
|
||
|
||
{overviewResource.data !== null ? (
|
||
<SummaryBar overview={overviewResource.data} />
|
||
) : null}
|
||
|
||
<Card className="mb-4">
|
||
<div className="border-b border-line px-5 py-3">
|
||
<CheckboxField
|
||
checked={onlyActive}
|
||
onCheckedChange={setOnlyActive}
|
||
label="Nur unerledigte"
|
||
/>
|
||
</div>
|
||
|
||
{loadState === 'loading' ? <LoadingState label="Die Meldungen werden geladen …" /> : null}
|
||
|
||
{loadState === 'failed' && loadError !== null ? (
|
||
<ErrorState
|
||
message={describeApiError(loadError)}
|
||
requestId={loadError.requestId}
|
||
onRetry={reload}
|
||
/>
|
||
) : null}
|
||
|
||
{loadState === 'loaded' && data !== null && data.length === 0 ? (
|
||
<EmptyState
|
||
title={onlyActive ? 'Keine unerledigten Meldungen' : 'Es gibt keine Meldungen'}
|
||
description="Die Liste „Worauf geachtet wird“ unten sagt, was geprueft wird — erst damit ist eine leere Liste eine gute Nachricht."
|
||
/>
|
||
) : null}
|
||
|
||
{loadState === 'loaded' && data !== null && data.length > 0 ? (
|
||
<ul className="divide-y divide-line">
|
||
{data.map((alert) => (
|
||
<AlertRow
|
||
key={alert.id}
|
||
alert={alert}
|
||
isBusy={acknowledgeMutation.isRunning || resolveMutation.isRunning}
|
||
onAcknowledge={() => void acknowledgeMutation.run(alert.id)}
|
||
onResolve={() => void resolveMutation.run(alert.id)}
|
||
/>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</Card>
|
||
|
||
{overviewResource.data !== null ? <RuleList overview={overviewResource.data} /> : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** Zeigt die Meldungslage in Zahlen. */
|
||
function SummaryBar({ overview }: { readonly overview: AlertOverview }): React.JSX.Element {
|
||
const { summary } = overview;
|
||
|
||
const figures = [
|
||
{ label: 'Kritisch', value: summary.critical_count, tone: 'critical' as const },
|
||
{ label: 'Ernst', value: summary.high_count, tone: 'high' as const },
|
||
{ label: 'Unbearbeitet', value: summary.open_count, tone: null },
|
||
{ label: 'Zur Kenntnis genommen', value: summary.acknowledged_count, tone: null },
|
||
{ label: 'Erledigt (24 h)', value: summary.resolved_last_day, tone: null },
|
||
];
|
||
|
||
return (
|
||
<div className="mb-4 grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||
{figures.map((figure) => (
|
||
<Card key={figure.label} className="p-4">
|
||
<p
|
||
className={cn(
|
||
'text-2xl font-semibold tabular',
|
||
// Farbe nur, wenn der Wert eine Aussage traegt: Null kritische
|
||
// Meldungen sind neutral, nicht gruen — gruen hiesse "geprueft
|
||
// und in Ordnung", und das sagt diese Zahl allein nicht.
|
||
figure.tone && figure.value > 0
|
||
? figure.tone === 'critical'
|
||
? 'text-critical'
|
||
: 'text-high'
|
||
: 'text-fg',
|
||
)}
|
||
>
|
||
{figure.value}
|
||
</p>
|
||
<p className="mt-0.5 text-xs text-fg-muted">{figure.label}</p>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Zeigt eine einzelne Meldung. */
|
||
function AlertRow({
|
||
alert,
|
||
isBusy,
|
||
onAcknowledge,
|
||
onResolve,
|
||
}: {
|
||
readonly alert: Alert;
|
||
readonly isBusy: boolean;
|
||
readonly onAcknowledge: () => void;
|
||
readonly onResolve: () => void;
|
||
}): React.JSX.Element {
|
||
const isResolved = alert.status === 'resolved';
|
||
|
||
return (
|
||
<li className={cn('px-5 py-4', isResolved && 'opacity-60')}>
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<StatusBadge status={alert.severity} />
|
||
<h3 className="min-w-0 font-medium text-fg">{alert.title}</h3>
|
||
{/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall,
|
||
zwanzigmal ein Zustand. */}
|
||
{alert.occurrence_count > 1 ? (
|
||
<span className="rounded bg-sunken px-1.5 py-0.5 text-xs text-fg-muted">
|
||
{alert.occurrence_count}× aufgetreten
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<p className="mt-1.5 text-sm text-fg">{alert.message}</p>
|
||
|
||
<p className="mt-1.5 text-xs text-fg-muted">
|
||
Seit {formatDateTime(alert.first_seen_at)} · zuletzt{' '}
|
||
{formatDateTime(alert.last_seen_at)} · Regel {alert.rule_name}
|
||
</p>
|
||
|
||
{alert.status === 'acknowledged' ? (
|
||
<p className="mt-1 text-xs text-fg-muted">
|
||
Zur Kenntnis genommen am {formatDateTime(alert.acknowledged_at)}. Die
|
||
Meldung <strong>bleibt offen</strong>, bis ihre Ursache verschwindet.
|
||
</p>
|
||
) : null}
|
||
|
||
{isResolved ? (
|
||
<p className="mt-1 text-xs text-fg-muted">
|
||
Erledigt am {formatDateTime(alert.resolved_at)}
|
||
{alert.resolution_note !== undefined && alert.resolution_note !== ''
|
||
? ` — ${alert.resolution_note}`
|
||
: ''}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
|
||
{!isResolved ? (
|
||
<div className="flex shrink-0 gap-2">
|
||
{alert.status === 'open' ? (
|
||
<Button size="sm" disabled={isBusy} onClick={onAcknowledge}>
|
||
Zur Kenntnis nehmen
|
||
</Button>
|
||
) : null}
|
||
<Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}>
|
||
Schliessen
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Zeigt, worauf geachtet wird.
|
||
*
|
||
* Eine Regel, die dauerhaft schweigt, ist gefaehrlicher als keine — sie erweckt
|
||
* den Eindruck, es werde geprueft. Deshalb steht bei jeder nicht ausgewerteten
|
||
* Regel ausdruecklich, dass sie nicht greift, und warum.
|
||
*/
|
||
function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element {
|
||
return (
|
||
<Card>
|
||
<CardHeader
|
||
title="Worauf geachtet wird"
|
||
description="Erst mit dieser Liste ist eine leere Meldungsliste eine gute Nachricht."
|
||
/>
|
||
<CardContent>
|
||
<ul className="space-y-3">
|
||
{overview.rules.map((rule) => (
|
||
<li
|
||
key={rule.name}
|
||
className={cn(
|
||
'rounded-md border border-line p-3',
|
||
!rule.available && 'opacity-70',
|
||
)}
|
||
>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-medium text-fg">{rule.title}</span>
|
||
{rule.available ? (
|
||
<StatusBadge status={rule.severity} />
|
||
) : (
|
||
<StatusBadge status="neutral" label="wird nicht geprueft" />
|
||
)}
|
||
</div>
|
||
<p className="mt-1 text-sm text-fg-muted">
|
||
{rule.available ? rule.description : rule.unavailable_reason}
|
||
</p>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
{overview.available_rule_count < overview.rules.length ? (
|
||
<Callout tone="warning" className="mt-4">
|
||
{overview.rules.length - overview.available_rule_count} Regeln werden
|
||
nicht ausgewertet. Rechnen Sie sie nicht als „in Ordnung" — sie
|
||
schweigen, weil ihnen die Datengrundlage fehlt.
|
||
</Callout>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|