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

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}`;
}