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>
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
/**
|
|
* Bestandsseiten: Repositories, Agenten, Wiederherstellungen, Ereignisse.
|
|
*
|
|
* Vier Tabellen mit demselben Aufbau. Sie stehen zusammen, weil sie sich nur in
|
|
* Spalten und Ladefunktion unterscheiden — vier Dateien mit derselben Struktur
|
|
* waeren vier Orte, an denen die Fehlerbehandlung auseinanderlaufen kann.
|
|
*/
|
|
|
|
import { useCallback } from 'react';
|
|
import { useApiResource } from '../../api/useApiResource';
|
|
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
|
|
import {
|
|
fetchAgents,
|
|
fetchAuditEvents,
|
|
fetchRepositories,
|
|
fetchRestores,
|
|
} from './inventoryApi';
|
|
import type { Agent, AuditEvent, Repository, RestoreJob } from './inventoryApi';
|
|
|
|
/** Zeigt die Repositories. */
|
|
export function RepositoriesPage(): React.JSX.Element {
|
|
const loadRepositories = useCallback((abortSignal: AbortSignal) => fetchRepositories(abortSignal), []);
|
|
const { loadState, data, loadError, reload } = useApiResource<readonly Repository[]>(loadRepositories);
|
|
|
|
return (
|
|
<TablePage
|
|
title="Repositories"
|
|
what="Die Repositories"
|
|
emptyMessage="Es ist kein Repository eingerichtet. Ohne Repository kann nichts gesichert werden."
|
|
loadState={loadState}
|
|
loadError={loadError}
|
|
onRetry={reload}
|
|
itemCount={data?.length}
|
|
columns={['Name', 'Ablage', 'Zustand', 'Loeschschutz']}
|
|
>
|
|
{data?.map((repository) => (
|
|
<tr key={repository.id}>
|
|
<td>
|
|
{repository.name}
|
|
<span className="data-table__secondary">{repository.location}</span>
|
|
</td>
|
|
|
|
<td>{repository.repository_type}</td>
|
|
|
|
<td>
|
|
<StatusBadge status={repository.status} />
|
|
</td>
|
|
|
|
<td>
|
|
<EnforcementCell repository={repository} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</TablePage>
|
|
);
|
|
}
|
|
|
|
/** Eigenschaften der Loeschschutzanzeige. */
|
|
interface EnforcementCellProperties {
|
|
/** Das dargestellte Repository. */
|
|
readonly repository: Repository;
|
|
}
|
|
|
|
/**
|
|
* Zeigt den gemessenen Loeschschutz eines Repositorys.
|
|
*
|
|
* Der Unterschied zwischen „nie gemessen" und „nur diese Software" ist der Kern
|
|
* von Phase 11: Beides bedeutet, dass sich niemand auf den Schutz verlassen
|
|
* sollte — aber aus verschiedenen Gruenden.
|
|
*/
|
|
function EnforcementCell({ repository }: EnforcementCellProperties): React.JSX.Element {
|
|
if (repository.enforcement_level === undefined || repository.enforcement_level === '') {
|
|
return (
|
|
<span className="data-table__unknown" title="Es wurde nie geprueft, was dieser Speicher verhindert.">
|
|
{repository.hardened ? 'gehaertet, ungemessen' : 'nicht gemessen'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
switch (repository.enforcement_level) {
|
|
case 'filesystem':
|
|
return (
|
|
<span className="badge badge--healthy" title="Das Dateisystem verweigert die Loeschung.">
|
|
Dateisystem
|
|
</span>
|
|
);
|
|
case 'storage':
|
|
return <span className="badge badge--healthy">Speicherebene</span>;
|
|
case 'advisory':
|
|
return (
|
|
<span
|
|
className="badge badge--warning"
|
|
title="Nur diese Software haelt sich daran. Wer Dateizugriff hat, kann loeschen."
|
|
>
|
|
nur Software
|
|
</span>
|
|
);
|
|
default:
|
|
return <span className="badge badge--critical">kein Schutz</span>;
|
|
}
|
|
}
|
|
|
|
/** Zeigt die Agenten. */
|
|
export function AgentsPage(): React.JSX.Element {
|
|
const loadAgents = useCallback((abortSignal: AbortSignal) => fetchAgents(abortSignal), []);
|
|
const { loadState, data, loadError, reload } = useApiResource<readonly Agent[]>(loadAgents);
|
|
|
|
return (
|
|
<TablePage
|
|
title="Agenten"
|
|
what="Die Agenten"
|
|
emptyMessage="Es ist kein Agent aufgenommen."
|
|
loadState={loadState}
|
|
loadError={loadError}
|
|
onRetry={reload}
|
|
itemCount={data?.length}
|
|
columns={['Name', 'System', 'Version', 'Zustand', 'Zuletzt gesehen']}
|
|
>
|
|
{data?.map((agent) => (
|
|
<tr key={agent.id}>
|
|
<td>{agent.name}</td>
|
|
<td>{agent.operating_system ?? '—'}</td>
|
|
<td>{agent.agent_version ?? '—'}</td>
|
|
<td>
|
|
<StatusBadge status={agent.status} />
|
|
</td>
|
|
<td>{formatTimestamp(agent.last_seen_at)}</td>
|
|
</tr>
|
|
))}
|
|
</TablePage>
|
|
);
|
|
}
|
|
|
|
/** Zeigt die Wiederherstellungen. */
|
|
export function RestoresPage(): React.JSX.Element {
|
|
const loadRestores = useCallback((abortSignal: AbortSignal) => fetchRestores(abortSignal), []);
|
|
const { loadState, data, loadError, reload } = useApiResource<readonly RestoreJob[]>(loadRestores);
|
|
|
|
return (
|
|
<TablePage
|
|
title="Wiederherstellungen"
|
|
what="Die Wiederherstellungen"
|
|
emptyMessage="Es wurde noch keine Wiederherstellung angefordert."
|
|
loadState={loadState}
|
|
loadError={loadError}
|
|
onRetry={reload}
|
|
itemCount={data?.length}
|
|
columns={['Ziel', 'Zustand', 'Objekte', 'Datenmenge', 'Ende']}
|
|
>
|
|
{data?.map((restoreJob) => (
|
|
<tr key={restoreJob.id}>
|
|
<td>
|
|
{restoreJob.target_path}
|
|
{restoreJob.overwrite_existing && (
|
|
<span className="data-table__secondary">ueberschreibend</span>
|
|
)}
|
|
</td>
|
|
|
|
<td>
|
|
<StatusBadge status={restoreJob.status} />
|
|
{restoreJob.error_message !== undefined && restoreJob.error_message !== '' && (
|
|
<span className="data-table__secondary">{restoreJob.error_message}</span>
|
|
)}
|
|
</td>
|
|
|
|
<td className="data-table__number">
|
|
{restoreJob.files_restored.toLocaleString('de-DE')}
|
|
{/* Uebergangene Objekte machen einen Lauf zum Teilfehler. Sie
|
|
stehen deshalb neben der Zahl, nicht in einem Detailfenster. */}
|
|
{restoreJob.files_skipped > 0 && (
|
|
<span className="data-table__secondary data-table__secondary--warning">
|
|
{restoreJob.files_skipped} uebergangen
|
|
</span>
|
|
)}
|
|
</td>
|
|
|
|
<td className="data-table__number">{formatBytes(restoreJob.bytes_restored)}</td>
|
|
<td>{formatTimestamp(restoreJob.completed_at)}</td>
|
|
</tr>
|
|
))}
|
|
</TablePage>
|
|
);
|
|
}
|
|
|
|
/** Zeigt das Auditprotokoll. */
|
|
export function EventsPage(): React.JSX.Element {
|
|
const loadEvents = useCallback((abortSignal: AbortSignal) => fetchAuditEvents(abortSignal), []);
|
|
const { loadState, data, loadError, reload } = useApiResource<readonly AuditEvent[]>(loadEvents);
|
|
|
|
return (
|
|
<TablePage
|
|
title="Ereignisse"
|
|
what="Die Ereignisse"
|
|
emptyMessage="Das Auditprotokoll ist leer."
|
|
loadState={loadState}
|
|
loadError={loadError}
|
|
onRetry={reload}
|
|
itemCount={data?.length}
|
|
columns={['Zeitpunkt', 'Handlung', 'Benutzer', 'Gegenstand', 'Ausgang']}
|
|
>
|
|
{data?.map((auditEvent) => (
|
|
<tr key={auditEvent.id}>
|
|
<td>{formatTimestamp(auditEvent.created_at)}</td>
|
|
<td className="data-table__code">{auditEvent.action}</td>
|
|
<td>{auditEvent.actor_username ?? 'System'}</td>
|
|
<td>{auditEvent.entity_type ?? '—'}</td>
|
|
<td>
|
|
<StatusBadge status={auditEvent.result} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</TablePage>
|
|
);
|
|
}
|
|
|
|
/** Eigenschaften einer Tabellenseite. */
|
|
interface TablePageProperties {
|
|
/** Ueberschrift der Seite. */
|
|
readonly title: string;
|
|
/** Was geladen wird, fuer die Ladeanzeige. */
|
|
readonly what: string;
|
|
/** Text bei leerer Liste. */
|
|
readonly emptyMessage: string;
|
|
/** Ladezustand. */
|
|
readonly loadState: 'loading' | 'loaded' | 'failed';
|
|
/** Aufgetretener Fehler. */
|
|
readonly loadError: import('../../api/client').ApiError | null;
|
|
/** Laedt erneut. */
|
|
readonly onRetry: () => void;
|
|
/** Zahl der geladenen Eintraege. */
|
|
readonly itemCount: number | undefined;
|
|
/** Spaltenueberschriften. */
|
|
readonly columns: readonly string[];
|
|
/** Die Tabellenzeilen. */
|
|
readonly children: React.ReactNode;
|
|
}
|
|
|
|
/** Zeigt eine Seite mit einer Tabelle. */
|
|
function TablePage({
|
|
title,
|
|
what,
|
|
emptyMessage,
|
|
loadState,
|
|
loadError,
|
|
onRetry,
|
|
itemCount,
|
|
columns,
|
|
children,
|
|
}: TablePageProperties): React.JSX.Element {
|
|
return (
|
|
<section className="page">
|
|
<header className="page__header">
|
|
<h1 className="page__title">{title}</h1>
|
|
{itemCount !== undefined && <span className="page__meta">{itemCount} Eintraege</span>}
|
|
</header>
|
|
|
|
{loadState === 'loading' && <LoadingState what={what} />}
|
|
{loadState === 'failed' && loadError !== null && (
|
|
<ErrorState error={loadError} onRetry={onRetry} />
|
|
)}
|
|
|
|
{loadState === 'loaded' && itemCount === 0 && <EmptyState message={emptyMessage} />}
|
|
|
|
{loadState === 'loaded' && itemCount !== undefined && itemCount > 0 && (
|
|
<div className="table-wrapper">
|
|
<table className="data-table">
|
|
<thead>
|
|
<tr>
|
|
{columns.map((columnLabel) => (
|
|
<th scope="col" key={columnLabel}>
|
|
{columnLabel}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>{children}</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** Eigenschaften der Statusanzeige. */
|
|
interface StatusBadgeProperties {
|
|
/** Der darzustellende Zustand. */
|
|
readonly status: string;
|
|
}
|
|
|
|
/**
|
|
* Zeigt einen Zustand mit semantischer Farbe.
|
|
*
|
|
* Farbe ausschliesslich fuer Status, nie zur Dekoration (PROMPT.md §28). Ein
|
|
* unbekannter Zustand bekommt deshalb keine Farbe — und nicht etwa gruen.
|
|
*/
|
|
function StatusBadge({ status }: StatusBadgeProperties): React.JSX.Element {
|
|
switch (status) {
|
|
case 'active':
|
|
case 'succeeded':
|
|
case 'success':
|
|
case 'completed':
|
|
return <span className="badge badge--healthy">{status}</span>;
|
|
case 'degraded':
|
|
case 'partial_failure':
|
|
case 'paused':
|
|
return <span className="badge badge--high">{status}</span>;
|
|
case 'offline':
|
|
case 'failed':
|
|
case 'failure':
|
|
case 'denied':
|
|
case 'revoked':
|
|
return <span className="badge badge--critical">{status}</span>;
|
|
case 'running':
|
|
case 'queued':
|
|
return <span className="badge badge--info">{status}</span>;
|
|
default:
|
|
return <span className="badge badge--neutral">{status}</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}`;
|
|
}
|