syncova-backup/apps/web/src/features/alerts/AlertsPage.tsx
Jerrit Fritzsche 6e23696fcb
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s
Weboberflaeche: letzte Seiten auf das Design-System gezogen
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>
2026-08-18 13:50:15 +02:00

309 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}