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>
241 lines
8.2 KiB
TypeScript
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}`;
|
|
}
|