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>
308 lines
9.7 KiB
TypeScript
308 lines
9.7 KiB
TypeScript
/**
|
||
* 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',
|
||
});
|
||
}
|