/** * 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(loadRepositories); return ( {data?.map((repository) => ( {repository.name} {repository.location} {repository.repository_type} ))} ); } /** 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 ( {repository.hardened ? 'gehaertet, ungemessen' : 'nicht gemessen'} ); } switch (repository.enforcement_level) { case 'filesystem': return ( Dateisystem ); case 'storage': return Speicherebene; case 'advisory': return ( nur Software ); default: return kein Schutz; } } /** Zeigt die Agenten. */ export function AgentsPage(): React.JSX.Element { const loadAgents = useCallback((abortSignal: AbortSignal) => fetchAgents(abortSignal), []); const { loadState, data, loadError, reload } = useApiResource(loadAgents); return ( {data?.map((agent) => ( {agent.name} {agent.operating_system ?? '—'} {agent.agent_version ?? '—'} {formatTimestamp(agent.last_seen_at)} ))} ); } /** Zeigt die Wiederherstellungen. */ export function RestoresPage(): React.JSX.Element { const loadRestores = useCallback((abortSignal: AbortSignal) => fetchRestores(abortSignal), []); const { loadState, data, loadError, reload } = useApiResource(loadRestores); return ( {data?.map((restoreJob) => ( {restoreJob.target_path} {restoreJob.overwrite_existing && ( ueberschreibend )} {restoreJob.error_message !== undefined && restoreJob.error_message !== '' && ( {restoreJob.error_message} )} {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 && ( {restoreJob.files_skipped} uebergangen )} {formatBytes(restoreJob.bytes_restored)} {formatTimestamp(restoreJob.completed_at)} ))} ); } /** Zeigt das Auditprotokoll. */ export function EventsPage(): React.JSX.Element { const loadEvents = useCallback((abortSignal: AbortSignal) => fetchAuditEvents(abortSignal), []); const { loadState, data, loadError, reload } = useApiResource(loadEvents); return ( {data?.map((auditEvent) => ( {formatTimestamp(auditEvent.created_at)} {auditEvent.action} {auditEvent.actor_username ?? 'System'} {auditEvent.entity_type ?? '—'} ))} ); } /** 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 (

{title}

{itemCount !== undefined && {itemCount} Eintraege}
{loadState === 'loading' && } {loadState === 'failed' && loadError !== null && ( )} {loadState === 'loaded' && itemCount === 0 && } {loadState === 'loaded' && itemCount !== undefined && itemCount > 0 && (
{columns.map((columnLabel) => ( ))} {children}
{columnLabel}
)}
); } /** 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 {status}; case 'degraded': case 'partial_failure': case 'paused': return {status}; case 'offline': case 'failed': case 'failure': case 'denied': case 'revoked': return {status}; case 'running': case 'queued': return {status}; default: return {status}; } } /** 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}`; }