syncova-backup/apps/web/src/features/alerts/AlertsPage.tsx
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

308 lines
9.7 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.
*
* Die Seite, die in Phase 12 noch als „noch nicht verfuegbar" stand — mit der
* Begruendung, eine leere Liste hiesse „keine Probleme" und wuerde bedeuten „es
* wird nicht geprueft". Jetzt gibt es die Pruefung, und die Seite sagt, worauf
* geachtet wird.
*
* Das Regelwerk steht deshalb mit auf der Seite: Eine leere Meldungsliste ist
* erst dann eine gute Nachricht, wenn man weiss, was ueberhaupt geprueft wird.
*/
import { useCallback, useState } from 'react';
import { useApiResource } from '../../api/useApiResource';
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
import {
acknowledgeAlert,
fetchAlertOverview,
fetchAlerts,
resolveAlert,
} from './alertsApi';
import type { Alert, AlertOverview, AlertSeverity } from './alertsApi';
/** Zeigt die Meldungen. */
export function AlertsPage(): React.JSX.Element {
const [onlyActive, setOnlyActive] = useState(true);
const [actionCounter, setActionCounter] = useState(0);
const [actionError, setActionError] = useState<string | null>(null);
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));
/** Fuehrt eine Handlung an einer Meldung aus und laedt neu. */
async function runAlertAction(action: () => Promise<void>): Promise<void> {
try {
await action();
setActionError(null);
setActionCounter((previousCounter) => previousCounter + 1);
} catch (caughtError) {
setActionError(caughtError instanceof Error ? caughtError.message : 'Unbekannter Fehler');
}
}
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">Meldungen</h1>
{overviewResource.data !== null && (
<span className="page__meta">
{overviewResource.data.available_rule_count} von {overviewResource.data.rules.length}{' '}
Regeln werden geprueft
</span>
)}
</header>
{overviewResource.data !== null && <SummaryBar overview={overviewResource.data} />}
<div className="filter-bar">
<label className="filter-bar__checkbox">
<input
type="checkbox"
checked={onlyActive}
onChange={(changeEvent) => setOnlyActive(changeEvent.target.checked)}
/>
Nur unerledigte
</label>
</div>
{actionError !== null && (
<div className="notice notice--critical" role="alert">
<p className="notice__text">{actionError}</p>
</div>
)}
{loadState === 'loading' && <LoadingState what="Die Meldungen" />}
{loadState === 'failed' && loadError !== null && (
<ErrorState error={loadError} onRetry={reload} />
)}
{loadState === 'loaded' && data !== null && data.length === 0 && (
<EmptyState
message={
onlyActive
? 'Keine unerledigten Meldungen. Die Regeln unten sagen, worauf geachtet wird.'
: 'Es gibt keine Meldungen.'
}
/>
)}
{loadState === 'loaded' && data !== null && data.length > 0 && (
<ul className="alert-list">
{data.map((alert) => (
<AlertCard
key={alert.id}
alert={alert}
onAcknowledge={() => void runAlertAction(() => acknowledgeAlert(alert.id, ''))}
onResolve={() => void runAlertAction(() => resolveAlert(alert.id, 'Von Hand geschlossen.'))}
/>
))}
</ul>
)}
{overviewResource.data !== null && <RuleList overview={overviewResource.data} />}
</section>
);
}
/** Eigenschaften der Zusammenfassung. */
interface SummaryBarProperties {
/** Die Meldungslage samt Regelwerk. */
readonly overview: AlertOverview;
}
/** Zeigt die Meldungslage in Zahlen. */
function SummaryBar({ overview }: SummaryBarProperties): React.JSX.Element {
const { summary } = overview;
return (
<div className="summary-bar">
<SummaryFigure
label="Kritisch"
value={summary.critical_count}
severity={summary.critical_count > 0 ? 'critical' : undefined}
/>
<SummaryFigure
label="Ernst"
value={summary.high_count}
severity={summary.high_count > 0 ? 'high' : undefined}
/>
<SummaryFigure label="Unbearbeitet" value={summary.open_count} />
<SummaryFigure label="Zur Kenntnis genommen" value={summary.acknowledged_count} />
<SummaryFigure label="Erledigt (24 h)" value={summary.resolved_last_day} />
</div>
);
}
/** Eigenschaften einer Kennzahl der Zusammenfassung. */
interface SummaryFigureProperties {
/** Beschriftung. */
readonly label: string;
/** Der Wert. */
readonly value: number;
/** Statusfarbe, sofern der Wert eine ist. */
readonly severity?: AlertSeverity | undefined;
}
/** Zeigt eine einzelne Kennzahl. */
function SummaryFigure({ label, value, severity }: SummaryFigureProperties): React.JSX.Element {
return (
<div className={severity === undefined ? 'summary-figure' : `summary-figure summary-figure--${severity}`}>
<span className="summary-figure__value">{value}</span>
<span className="summary-figure__label">{label}</span>
</div>
);
}
/** Eigenschaften einer Meldungskarte. */
interface AlertCardProperties {
/** Die dargestellte Meldung. */
readonly alert: Alert;
/** Nimmt die Meldung zur Kenntnis. */
readonly onAcknowledge: () => void;
/** Schliesst die Meldung. */
readonly onResolve: () => void;
}
/** Zeigt eine einzelne Meldung. */
function AlertCard({ alert, onAcknowledge, onResolve }: AlertCardProperties): React.JSX.Element {
const isResolved = alert.status === 'resolved';
return (
<li className={`alert-card alert-card--${alert.severity}${isResolved ? ' alert-card--resolved' : ''}`}>
<div className="alert-card__head">
<span className={`badge badge--${severityClassOf(alert.severity)}`}>{alert.severity}</span>
<h2 className="alert-card__title">{alert.title}</h2>
{/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall,
zwanzigmal ein Zustand. */}
{alert.occurrence_count > 1 && (
<span className="alert-card__count">{alert.occurrence_count}× aufgetreten</span>
)}
</div>
<p className="alert-card__message">{alert.message}</p>
<p className="alert-card__meta">
Seit {formatTimestamp(alert.first_seen_at)} · zuletzt {formatTimestamp(alert.last_seen_at)} ·
Regel {alert.rule_name}
</p>
{alert.status === 'acknowledged' && (
<p className="alert-card__meta">
Zur Kenntnis genommen am {formatTimestamp(alert.acknowledged_at)}. Die Meldung bleibt
offen, bis ihre Ursache verschwindet.
</p>
)}
{isResolved && (
<p className="alert-card__meta">
Erledigt am {formatTimestamp(alert.resolved_at)}
{alert.resolution_note !== undefined && alert.resolution_note !== ''
? ` — ${alert.resolution_note}`
: ''}
</p>
)}
{!isResolved && (
<div className="alert-card__actions">
{alert.status === 'open' && (
<button className="button button--secondary" type="button" onClick={onAcknowledge}>
Zur Kenntnis nehmen
</button>
)}
<button className="button button--secondary" type="button" onClick={onResolve}>
Schliessen
</button>
</div>
)}
</li>
);
}
/** Eigenschaften der Regelliste. */
interface RuleListProperties {
/** Die Meldungslage samt Regelwerk. */
readonly overview: AlertOverview;
}
/**
* Zeigt, worauf geachtet wird.
*
* Ohne diese Liste bliebe offen, ob eine leere Meldungsliste bedeutet „alles in
* Ordnung" oder „es wird nichts geprueft".
*/
function RuleList({ overview }: RuleListProperties): React.JSX.Element {
return (
<section className="rule-list">
<h2 className="rule-list__title">Worauf geachtet wird</h2>
<ul className="rule-list__items">
{overview.rules.map((rule) => (
<li
className={rule.available ? 'rule-list__item' : 'rule-list__item rule-list__item--unavailable'}
key={rule.name}
>
<div className="rule-list__head">
<span className="rule-list__name">{rule.title}</span>
{rule.available ? (
<span className={`badge badge--${severityClassOf(rule.severity)}`}>{rule.severity}</span>
) : (
<span className="badge badge--neutral">wird nicht geprueft</span>
)}
</div>
<p className="rule-list__description">
{rule.available ? rule.description : rule.unavailable_reason}
</p>
</li>
))}
</ul>
</section>
);
}
/** Bildet einen Schweregrad auf eine Statusfarbe ab. */
function severityClassOf(severity: AlertSeverity): string {
switch (severity) {
case 'critical':
return 'critical';
case 'high':
return 'high';
case 'warning':
return 'warning';
default:
return 'info';
}
}
/** Schreibt einen Zeitstempel in deutscher Schreibweise. */
function formatTimestamp(isoTimestamp: string | undefined): string {
if (isoTimestamp === undefined) {
return '—';
}
return new Date(isoTimestamp).toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
});
}