syncova-backup/apps/web/src/features/dashboard/RecoveryPointsPage.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

241 lines
8.2 KiB
TypeScript

/**
* Wiederherstellungspunkte.
*
* Die zentrale Auskunft der Anlage. Sie beantwortet nicht „welche Backups gibt
* es", sondern „auf welche kann ich mich verlassen" — deshalb steht die
* Einstufung in jeder Zeile und nicht in einem Detailfenster.
*/
import { useCallback, useState } from 'react';
import { useApiResource } from '../../api/useApiResource';
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
import { fetchRecoveryPoints } from './dashboardApi';
import type { RecoveryPoint } from './dashboardApi';
/** Auswahlmoeglichkeiten des Einstufungsfilters. */
const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
{ value: '', label: 'Alle Einstufungen' },
{ value: 'recoverable', label: 'Nachweislich wiederherstellbar' },
{ value: 'verified', label: 'Geprueft' },
{ value: 'successful', label: 'Ungeprueft' },
{ value: 'corrupted', label: 'Beschaedigt' },
];
/** Zeigt die Wiederherstellungspunkte. */
export function RecoveryPointsPage(): React.JSX.Element {
const [classificationFilter, setClassificationFilter] = useState('');
const [includeDeleted, setIncludeDeleted] = useState(false);
const [onlyProtected, setOnlyProtected] = useState(false);
// Der Schluessel bildet die Filter ab: Aendert er sich, wird neu geladen.
const filterKey = `${classificationFilter}|${String(includeDeleted)}|${String(onlyProtected)}`;
const loadRecoveryPoints = useCallback(
(abortSignal: AbortSignal) =>
fetchRecoveryPoints({ classification: classificationFilter, includeDeleted, onlyProtected }, abortSignal),
[classificationFilter, includeDeleted, onlyProtected],
);
const { loadState, data, loadError, reload } = useApiResource<readonly RecoveryPoint[]>(
loadRecoveryPoints,
filterKey,
);
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">Wiederherstellungspunkte</h1>
{data !== null && <span className="page__meta">{data.length} Punkte</span>}
</header>
<div className="filter-bar">
<label className="filter-bar__field">
<span className="filter-bar__label">Einstufung</span>
<select
className="filter-bar__select"
value={classificationFilter}
onChange={(changeEvent) => setClassificationFilter(changeEvent.target.value)}
>
{CLASSIFICATION_OPTIONS.map((filterOption) => (
<option key={filterOption.value} value={filterOption.value}>
{filterOption.label}
</option>
))}
</select>
</label>
<label className="filter-bar__checkbox">
<input
type="checkbox"
checked={onlyProtected}
onChange={(changeEvent) => setOnlyProtected(changeEvent.target.checked)}
/>
Nur geschuetzte
</label>
{/* Geloeschte Punkte erscheinen nur auf Nachfrage — die Frage „warum ist
das Backup von vorletzter Woche weg?" muss aber beantwortbar sein. */}
<label className="filter-bar__checkbox">
<input
type="checkbox"
checked={includeDeleted}
onChange={(changeEvent) => setIncludeDeleted(changeEvent.target.checked)}
/>
Geloeschte einbeziehen
</label>
</div>
{loadState === 'loading' && <LoadingState what="Die Wiederherstellungspunkte" />}
{loadState === 'failed' && loadError !== null && (
<ErrorState error={loadError} onRetry={reload} />
)}
{loadState === 'loaded' && data !== null && data.length === 0 && (
<EmptyState message="Zu diesen Filtern gibt es keinen Wiederherstellungspunkt." />
)}
{loadState === 'loaded' && data !== null && data.length > 0 && (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th scope="col">Zeitpunkt</th>
<th scope="col">Auftrag</th>
<th scope="col">Art</th>
<th scope="col">Groesse</th>
<th scope="col">Einstufung</th>
<th scope="col">Bewertung</th>
<th scope="col">Schutz</th>
</tr>
</thead>
<tbody>
{data.map((recoveryPoint) => (
<RecoveryPointRow key={recoveryPoint.id} recoveryPoint={recoveryPoint} />
))}
</tbody>
</table>
</div>
)}
</section>
);
}
/** Eigenschaften einer Tabellenzeile. */
interface RecoveryPointRowProperties {
/** Der dargestellte Punkt. */
readonly recoveryPoint: RecoveryPoint;
}
/** Zeigt einen Wiederherstellungspunkt als Tabellenzeile. */
function RecoveryPointRow({ recoveryPoint }: RecoveryPointRowProperties): React.JSX.Element {
const isDeleted = recoveryPoint.deleted_at !== undefined;
return (
<tr className={isDeleted ? 'data-table__row--deleted' : undefined}>
<td>
{formatTimestamp(recoveryPoint.completed_at)}
<span className="data-table__secondary">{recoveryPoint.repository_name}</span>
</td>
<td>{recoveryPoint.job_name ?? '—'}</td>
<td>{recoveryPoint.backup_type === 'full' ? 'Voll' : 'Zusatz'}</td>
<td className="data-table__number">{formatBytes(recoveryPoint.logical_bytes)}</td>
<td>
<ClassificationBadge classification={recoveryPoint.classification} />
</td>
<td className="data-table__number">
{/* Keine Bewertung heisst nicht null Prozent. Die Unterscheidung ist der
Kern der Recovery Assurance (Phase 10). */}
{recoveryPoint.assurance_score === undefined ? (
<span className="data-table__unknown">nicht berechnet</span>
) : (
`${recoveryPoint.assurance_score} %`
)}
</td>
<td>
{isDeleted ? (
<span className="badge badge--neutral" title={recoveryPoint.deletion_reason}>
geloescht
</span>
) : recoveryPoint.legal_hold ? (
<span className="badge badge--high">Legal Hold</span>
) : recoveryPoint.is_protected ? (
<span className="badge badge--info" title={formatTimestamp(recoveryPoint.immutable_until)}>
geschuetzt
</span>
) : (
<span className="data-table__unknown">frei</span>
)}
</td>
</tr>
);
}
/** Eigenschaften der Einstufungsanzeige. */
interface ClassificationBadgeProperties {
/** Die Einstufung; fehlt bei ungeprueften Punkten. */
readonly classification: string | undefined;
}
/** Zeigt die Einstufung eines Punktes. */
function ClassificationBadge({ classification }: ClassificationBadgeProperties): React.JSX.Element {
switch (classification) {
case 'recoverable':
return <span className="badge badge--healthy">wiederherstellbar</span>;
case 'verified':
return <span className="badge badge--info">geprueft</span>;
case 'corrupted':
return <span className="badge badge--critical">beschaedigt</span>;
case 'failed':
return <span className="badge badge--critical">gescheitert</span>;
default:
// „Ungeprueft" ist eine Aussage, keine Luecke: Der Punkt wurde nie
// zurueckgeschrieben, und das soll man sehen.
return <span className="badge badge--warning">ungeprueft</span>;
}
}
/** 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',
});
}
/** Faktor zwischen zwei Groesseneinheiten. */
const BYTE_UNIT_STEP = 1024;
/** Schreibt eine Datenmenge lesbar. */
function formatBytes(byteCount: number): string {
if (byteCount < BYTE_UNIT_STEP) {
return `${byteCount} B`;
}
const unitNames = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
let remainingValue = byteCount;
let chosenUnit = unitNames[0];
for (const unitName of unitNames) {
remainingValue /= BYTE_UNIT_STEP;
chosenUnit = unitName;
if (remainingValue < BYTE_UNIT_STEP) {
break;
}
}
return `${remainingValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${chosenUnit}`;
}