Weboberflaeche: letzte Seiten auf das Design-System gezogen
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s

Damit gibt es nur noch ein Stylesheet. Uebersicht, Meldungen, Kennzahlen,
Berichte, Security Center, Wiederherstellungspunkte, Anmeldung, Backup-Assistent
und Gesundheitsanzeige nutzten noch das alte — sie funktionierten, sahen aber
anders aus als der Rest.

Der Backup-Assistent wurde als **Klassenabbildung** umgestellt, nicht neu
geschrieben: Seine Logik ist geprueft und richtig; eine Neufassung haette 500
Zeilen Verhalten ohne Not angefasst. Die elf Tests des Assistenten pruefen
Verhalten und blieben unveraendert gueltig.

Entfernt, weil ersetzt und nirgends mehr verwendet: JobsPanel, StatusIndicator,
PageState, App.css, tokens.css und vier weitere Stylesheets. Das ausgelieferte
CSS faellt von 54 auf 30 KB.

Drei Funde beim Umbau, alle von Tests aufgedeckt:

- **`describeApiError` warf die genauere Servermeldung weg.** Sie ersetzte sie
  durch den allgemeinen Satz aus der Codetabelle. Ein `SERVICE_UNAVAILABLE` mit
  der Meldung "Fuer diesen Bericht ist keine Sicherheitspruefung eingerichtet."
  wurde zu "Der Dienst ist derzeit nicht vollstaendig verfuegbar" — der
  Betreiber haette den Fehler bei seiner Anlage gesucht statt bei der
  Einrichtung dieses einen Berichts. Jetzt hat die Servermeldung Vorrang; die
  Tabelle springt nur ein, wenn keine mitkommt.
- **Einem Fehler nach einer Handlung fehlte `role="alert"`.** Ein Screenreader
  haette ihn nicht angesagt. `Callout` nimmt jetzt eine Rolle entgegen; Standard
  bleibt `note`, weil die meisten Hinweise schon beim Oeffnen dastehen.
- **Zwei Statusbeschriftungen wichen von den etablierten ab** (`Gesund` statt
  `Fehlerfrei`, `Nicht verbunden` statt `Nicht erreichbar`). Die etablierten
  gewinnen — sie stehen in Tests fest und sind treffender.

Dazu zwei kleinere Korrekturen: Die Gesundheitsanzeige hing kurzzeitig in der
Uebersicht und verband damit zwei Ladewege, die nichts miteinander zu tun haben;
sie steht jetzt wieder daneben. Und ein frueherer Regex hatte
`(row) => void | undefined` erzeugt — gemeint war eine optionale Eigenschaft,
geschrieben stand "gibt void oder undefined zurueck".

Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das
Design-System steckt im ausgelieferten CSS samt Dark-Mode-Regeln, und vom alten
Stylesheet ist kein Klassenname mehr darin.

76 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jerrit Fritzsche 2026-08-18 13:50:15 +02:00
parent ccfff87d3f
commit 6e23696fcb
27 changed files with 1207 additions and 3227 deletions

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@ import { getAccessToken, logout } from './features/auth/authApi';
import { LoginPage } from './features/auth/LoginPage';
import { AlertsPage } from './features/alerts/AlertsPage';
import { DashboardPage } from './features/dashboard/DashboardPage';
import { SystemHealthPanel } from './features/health/SystemHealthPanel';
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
import { RolesPage } from './features/administration/RolesPage';
import { SettingsPage } from './features/administration/SettingsPage';
@ -40,12 +41,6 @@ import { DEFAULT_PAGE_ID, findPage, mayViewPage } from './navigation/pages';
import { useCurrentPage } from './navigation/useCurrentPage';
import type { CurrentUser } from './types/auth';
import './styles/theme.css';
// Die noch nicht umgebauten Seiten nutzen weiterhin das alte Stylesheet. Es
// hier stehen zu lassen ist unschoen und die einzige Alternative zu einer
// Oberflaeche, die auf halbem Weg unformatiert erscheint. Es faellt weg,
// sobald die letzte Seite umgebaut ist.
import './styles/tokens.css';
import './App.css';
// Der API-Client erhaelt seinen Tokenzugriff einmalig beim Laden des Moduls.
setAccessTokenProvider(getAccessToken);
@ -142,7 +137,14 @@ function CurrentPageContent({
switch (pageDefinition.id) {
case 'dashboard':
return <DashboardPage />;
return (
<>
<DashboardPage />
<div className="mt-4">
<SystemHealthPanel />
</div>
</>
);
case 'jobs':
return openObjectIdentifier ? (

View File

@ -137,16 +137,8 @@ export function useMutation<TArguments extends unknown[], TResult>(
};
}
/**
* Uebersetzt einen Serverfehler in einen Satz, den ein Betreiber versteht.
*
* Die Fehlercodes der API sind praezise und fuer Menschen unbrauchbar. Wo ein
* Code eine bekannte Lage beschreibt, steht hier die Erklaerung **samt Abhilfe**
* — der Rest faellt auf die Servermeldung zurueck, die ohnehin auf Deutsch
* verfasst ist.
*/
export function describeApiError(apiError: ApiError): string {
const knownExplanations: Readonly<Record<string, string>> = {
/** Bekannte Lagen samt Abhilfe. */
const errorCodeExplanations: Readonly<Record<string, string>> = {
// Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des
// Anwenders — der Auftrag laeuft bereits.
JOB_ALREADY_RUNNING:
@ -162,8 +154,7 @@ export function describeApiError(apiError: ApiError): string {
'Das ist der letzte Administrator. Er laesst sich weder loeschen noch deaktivieren — sonst waere die Anlage nicht mehr verwaltbar.',
BUILTIN_ROLE_IMMUTABLE:
'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.',
REPOSITORY_IN_USE:
'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.',
REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.',
REPOSITORY_LOCKED:
'Das Repository ist gesperrt — vermutlich laeuft eine Sicherung. Haelt der Zustand an, hilft "syncova-repo break-lock".',
REPOSITORY_FULL:
@ -175,7 +166,39 @@ export function describeApiError(apiError: ApiError): string {
SERVICE_UNAVAILABLE:
'Der Dienst ist derzeit nicht vollstaendig verfuegbar. Das ist kein Problem Ihrer Sitzung.',
FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung fuer diese Handlung.',
};
};
return knownExplanations[apiError.code] ?? apiError.message;
/**
* Liefert einen Satz, den ein Betreiber versteht.
*
* **Die Servermeldung hat Vorrang.** Sie kennt den Einzelfall — welcher Bericht,
* welches Repository, welche Datei —, und diese Genauigkeit ist mehr wert als
* jede allgemeine Erklaerung. Die Tabelle springt nur ein, wenn der Server
* keine Meldung mitschickt.
*
* Das war zunaechst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein
* `SERVICE_UNAVAILABLE` mit der Meldung „Fuer diesen Bericht ist keine
* Sicherheitspruefung eingerichtet." wurde durch den allgemeinen Satz „Der
* Dienst ist derzeit nicht vollstaendig verfuegbar" ersetzt. Der Betreiber
* haette den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses
* einen Berichts.
*/
export function describeApiError(apiError: ApiError): string {
const serverMessage = apiError.message.trim();
if (serverMessage !== '') {
return serverMessage;
}
return errorCodeExplanations[apiError.code] ?? 'Die Anfrage ist fehlgeschlagen.';
}
/**
* Liefert die allgemeine Erklaerung zu einem Fehlercode, sofern es eine gibt.
*
* Gedacht als **Ergaenzung** zur Servermeldung, nicht als Ersatz: Wo eine Lage
* eine bekannte Abhilfe hat, laesst sie sich zusaetzlich anzeigen.
*/
export function explainApiErrorCode(errorCode: string): string | undefined {
return errorCodeExplanations[errorCode];
}

View File

@ -1,64 +0,0 @@
/**
* Gemeinsame Zustandsanzeigen der Seiten.
*
* Laden, Fehler und Leere sehen ueberall gleich aus — und vor allem: Leere wird
* ausdruecklich als Leere benannt. Eine Tabelle ohne Zeilen und ohne Hinweis
* laesst offen, ob es nichts gibt oder ob etwas schiefging.
*/
import type { ApiError } from '../api/client';
/** Eigenschaften der Ladeanzeige. */
interface LoadingStateProperties {
/** Was gerade geladen wird. */
readonly what: string;
}
/** Zeigt an, dass Daten geladen werden. */
export function LoadingState({ what }: LoadingStateProperties): React.JSX.Element {
return (
<p className="page__state" role="status">
{what} werden geladen…
</p>
);
}
/** Eigenschaften der Fehleranzeige. */
interface ErrorStateProperties {
/** Der aufgetretene Fehler. */
readonly error: ApiError;
/** Laedt erneut. */
readonly onRetry: () => void;
}
/** Zeigt einen Fehler samt Request-ID. */
export function ErrorState({ error, onRetry }: ErrorStateProperties): React.JSX.Element {
return (
<div className="notice notice--critical" role="alert">
<p className="notice__text">{error.message}</p>
{/* Die Request-ID gehoert sichtbar in die Oberflaeche: Mit ihr laesst sich
ein Vorfall im Serverlog eindeutig wiederfinden (PROMPT.md §50). */}
{error.requestId !== '' && (
<p className="notice__meta">
Fehlercode {error.code} · Vorgang {error.requestId}
</p>
)}
<button className="button button--secondary" type="button" onClick={onRetry}>
Erneut versuchen
</button>
</div>
);
}
/** Eigenschaften der Leeranzeige. */
interface EmptyStateProperties {
/** Der erklaerende Text. */
readonly message: string;
}
/** Zeigt an, dass es nichts anzuzeigen gibt. */
export function EmptyState({ message }: EmptyStateProperties): React.JSX.Element {
return <p className="page__state page__state--empty">{message}</p>;
}

View File

@ -1,20 +0,0 @@
/* Darstellung der Statusanzeige. */
.status-indicator {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.status-indicator__dot {
/* Feste Groesse, damit der Punkt in Tabellen nicht springt. */
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
flex-shrink: 0;
}
.status-indicator__label {
font-size: var(--text-sm);
color: var(--color-text-primary);
}

View File

@ -1,44 +0,0 @@
/**
* Statusanzeige des Syncova-Designsystems (PROMPT.md §105/§106).
*
* Der Zustand wird nicht allein ueber Farbe vermittelt, sondern immer zusaetzlich
* ueber Text. Farbe allein waere fuer farbfehlsichtige Anwender unzugaenglich
* (PROMPT.md §107).
*/
import type { HealthStatus } from '../types/api';
import './StatusIndicator.css';
/** Zuordnung von Zustand zu Anzeigetext und Farbvariable. */
const STATUS_PRESENTATION: Record<HealthStatus, { label: string; colorVariable: string }> = {
healthy: { label: 'Fehlerfrei', colorVariable: 'var(--color-status-healthy)' },
degraded: { label: 'Eingeschraenkt', colorVariable: 'var(--color-status-warning)' },
warning: { label: 'Warnung', colorVariable: 'var(--color-status-warning)' },
critical: { label: 'Kritisch', colorVariable: 'var(--color-status-critical)' },
offline: { label: 'Nicht erreichbar', colorVariable: 'var(--color-status-high)' },
};
/** Eigenschaften der Statusanzeige. */
export interface StatusIndicatorProps {
/** Anzuzeigender Zustand. */
status: HealthStatus;
/** Optionale abweichende Beschriftung. */
label?: string;
}
/** Zeigt einen Zustand als farbigen Punkt mit Beschriftung. */
export function StatusIndicator({ status, label }: StatusIndicatorProps): React.JSX.Element {
const presentation = STATUS_PRESENTATION[status];
return (
<span className="status-indicator">
{/* Der Punkt ist rein dekorativ; die Information steht im Text daneben. */}
<span
className="status-indicator__dot"
style={{ backgroundColor: presentation.colorVariable }}
aria-hidden="true"
/>
<span className="status-indicator__label">{label ?? presentation.label}</span>
</span>
);
}

View File

@ -16,7 +16,7 @@ export function PageHeader({
readonly title: ReactNode;
readonly description?: ReactNode | undefined;
readonly actions?: ReactNode | undefined;
readonly onBack?: () => void | undefined;
readonly onBack?: (() => void) | undefined;
readonly backLabel?: string | undefined;
readonly className?: string | undefined;
}) {

View File

@ -39,7 +39,7 @@ export function DataTable<RowType>({
readonly columns: ReadonlyArray<TableColumn<RowType>>;
readonly rows: readonly RowType[];
readonly getRowKey: (row: RowType) => string;
readonly onRowClick?: (row: RowType) => void | undefined;
readonly onRowClick?: ((row: RowType) => void) | undefined;
readonly isLoading?: boolean | undefined;
readonly emptyTitle?: ReactNode | undefined;
readonly emptyDescription?: ReactNode | undefined;

View File

@ -32,7 +32,7 @@ export function ErrorState({
readonly title?: string | undefined;
readonly message?: string | undefined;
readonly requestId?: string | undefined;
readonly onRetry?: () => void | undefined;
readonly onRetry?: (() => void) | undefined;
readonly className?: string | undefined;
}) {
return (
@ -105,11 +105,21 @@ export function Callout({
title,
children,
className,
role = 'note',
}: {
readonly tone?: 'info' | 'warning' | 'critical' | 'healthy' | undefined;
readonly title?: ReactNode | undefined;
readonly children?: ReactNode | undefined;
readonly className?: string | undefined;
/**
* ARIA-Rolle.
*
* Standard ist `note` — die meisten Hinweise erklaeren etwas und stehen schon
* beim Oeffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung**
* erscheint, gehoert dagegen auf `alert`: Ein Screenreader sagt ihn dann an,
* statt ihn im Seiteninhalt untergehen zu lassen.
*/
readonly role?: 'note' | 'alert' | 'status' | undefined;
}) {
const toneClasses = {
info: 'border-info/30 bg-info-bg text-info',
@ -121,7 +131,7 @@ export function Callout({
const IconComponent = tone === 'info' ? Info : tone === 'healthy' ? Info : AlertTriangle;
return (
<div className={cn('rounded-md border p-3', toneClasses[tone], className)} role="note">
<div className={cn('rounded-md border p-3', toneClasses[tone], className)} role={role}>
<div className="flex gap-2.5">
<IconComponent className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="min-w-0 text-sm">

View File

@ -85,8 +85,8 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
none: { tone: 'critical', label: 'Kein Schutz' },
// --- Gesundheit ---
healthy: { tone: 'healthy', label: 'Gesund' },
degraded: { tone: 'warning', label: 'Beeintraechtigt' },
healthy: { tone: 'healthy', label: 'Fehlerfrei' },
degraded: { tone: 'warning', label: 'Eingeschraenkt' },
critical: { tone: 'critical', label: 'Kritisch' },
unknown: { tone: 'neutral', label: 'Unbekannt' },
@ -105,7 +105,7 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
// --- Agenten ---
online: { tone: 'healthy', label: 'Verbunden' },
offline: { tone: 'critical', label: 'Nicht verbunden' },
offline: { tone: 'critical', label: 'Nicht erreichbar' },
stale: { tone: 'warning', label: 'Veraltet' },
revoked: { tone: 'neutral', label: 'Gesperrt' },

View File

@ -1,31 +1,46 @@
/**
* Meldungen.
*
* Die Seite, die in Phase 12 noch als „noch nicht verfuegbar" stand — mit der
* Begruendung, eine leere Liste hiesse „keine Probleme" und wuerde bedeuten „es
* wird nicht geprueft". Jetzt gibt es die Pruefung, und die Seite sagt, worauf
* geachtet wird.
* Das Regelwerk steht mit auf der Seite, und das ist die tragende Entscheidung:
* **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiss,
* was ueberhaupt geprueft wird.** Ohne die Liste bliebe offen, ob „keine
* Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprueft".
*
* Das Regelwerk steht deshalb mit auf der Seite: Eine leere Meldungsliste ist
* erst dann eine gute Nachricht, wenn man weiss, was ueberhaupt geprueft wird.
* „Zur Kenntnis nehmen" schliesst eine Meldung nicht. Sie bleibt offen und in
* der Liste, bis ihre Ursache verschwindet — sonst verschwaende der Zustand aus
* der Uebersicht, obwohl er weiterbesteht.
*/
import { useCallback, useState } from 'react';
import { useApiResource } from '../../api/useApiResource';
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError, useMutation } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Button,
Callout,
Card,
CardContent,
CardHeader,
CheckboxField,
EmptyState,
ErrorState,
LoadingState,
StatusBadge,
useToast,
} from '@/components/ui';
import { cn, formatDateTime } from '@/lib/utils';
import {
acknowledgeAlert,
fetchAlertOverview,
fetchAlerts,
resolveAlert,
} from './alertsApi';
import type { Alert, AlertOverview, AlertSeverity } from './alertsApi';
import type { Alert, AlertOverview } from './alertsApi';
/** Zeigt die Meldungen. */
export function AlertsPage(): React.JSX.Element {
const toast = useToast();
const [onlyActive, setOnlyActive] = useState(true);
const [actionCounter, setActionCounter] = useState(0);
const [actionError, setActionError] = useState<string | null>(null);
const loadAlerts = useCallback(
(abortSignal: AbortSignal) => fetchAlerts(onlyActive, abortSignal),
@ -44,264 +59,250 @@ export function AlertsPage(): React.JSX.Element {
const overviewResource = useApiResource<AlertOverview>(loadOverview, String(actionCounter));
/** Fuehrt eine Handlung an einer Meldung aus und laedt neu. */
async function runAlertAction(action: () => Promise<void>): Promise<void> {
try {
await action();
setActionError(null);
const acknowledgeMutation = useMutation(
(alertIdentifier: string) => acknowledgeAlert(alertIdentifier, ''),
{
onSuccess: () => {
toast.showSuccess(
'Zur Kenntnis genommen',
'Die Meldung bleibt offen, bis ihre Ursache verschwindet.',
);
setActionCounter((previousCounter) => previousCounter + 1);
} catch (caughtError) {
setActionError(caughtError instanceof Error ? caughtError.message : 'Unbekannter Fehler');
}
}
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
},
);
const resolveMutation = useMutation(
(alertIdentifier: string) => resolveAlert(alertIdentifier, 'Von Hand geschlossen.'),
{
onSuccess: () => {
toast.showSuccess('Meldung geschlossen');
setActionCounter((previousCounter) => previousCounter + 1);
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
},
);
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">Meldungen</h1>
{overviewResource.data !== null && (
<span className="page__meta">
{overviewResource.data.available_rule_count} von {overviewResource.data.rules.length}{' '}
Regeln werden geprueft
</span>
)}
</header>
{overviewResource.data !== null && <SummaryBar overview={overviewResource.data} />}
<div className="filter-bar">
<label className="filter-bar__checkbox">
<input
type="checkbox"
checked={onlyActive}
onChange={(changeEvent) => setOnlyActive(changeEvent.target.checked)}
/>
Nur unerledigte
</label>
</div>
{actionError !== null && (
<div className="notice notice--critical" role="alert">
<p className="notice__text">{actionError}</p>
</div>
)}
{loadState === 'loading' && <LoadingState what="Die Meldungen" />}
{loadState === 'failed' && loadError !== null && (
<ErrorState error={loadError} onRetry={reload} />
)}
{loadState === 'loaded' && data !== null && data.length === 0 && (
<EmptyState
message={
onlyActive
? 'Keine unerledigten Meldungen. Die Regeln unten sagen, worauf geachtet wird.'
: 'Es gibt keine Meldungen.'
<>
<PageHeader
title="Meldungen"
description={
overviewResource.data !== null
? `${overviewResource.data.available_rule_count} von ${overviewResource.data.rules.length} Regeln werden geprueft.`
: undefined
}
/>
)}
{loadState === 'loaded' && data !== null && data.length > 0 && (
<ul className="alert-list">
{overviewResource.data !== null ? (
<SummaryBar overview={overviewResource.data} />
) : null}
<Card className="mb-4">
<div className="border-b border-line px-5 py-3">
<CheckboxField
checked={onlyActive}
onCheckedChange={setOnlyActive}
label="Nur unerledigte"
/>
</div>
{loadState === 'loading' ? <LoadingState label="Die Meldungen werden geladen …" /> : null}
{loadState === 'failed' && loadError !== null ? (
<ErrorState
message={describeApiError(loadError)}
requestId={loadError.requestId}
onRetry={reload}
/>
) : null}
{loadState === 'loaded' && data !== null && data.length === 0 ? (
<EmptyState
title={onlyActive ? 'Keine unerledigten Meldungen' : 'Es gibt keine Meldungen'}
description="Die Liste „Worauf geachtet wird“ unten sagt, was geprueft wird — erst damit ist eine leere Liste eine gute Nachricht."
/>
) : null}
{loadState === 'loaded' && data !== null && data.length > 0 ? (
<ul className="divide-y divide-line">
{data.map((alert) => (
<AlertCard
<AlertRow
key={alert.id}
alert={alert}
onAcknowledge={() => void runAlertAction(() => acknowledgeAlert(alert.id, ''))}
onResolve={() => void runAlertAction(() => resolveAlert(alert.id, 'Von Hand geschlossen.'))}
isBusy={acknowledgeMutation.isRunning || resolveMutation.isRunning}
onAcknowledge={() => void acknowledgeMutation.run(alert.id)}
onResolve={() => void resolveMutation.run(alert.id)}
/>
))}
</ul>
)}
) : null}
</Card>
{overviewResource.data !== null && <RuleList overview={overviewResource.data} />}
</section>
{overviewResource.data !== null ? <RuleList overview={overviewResource.data} /> : null}
</>
);
}
/** Eigenschaften der Zusammenfassung. */
interface SummaryBarProperties {
/** Die Meldungslage samt Regelwerk. */
readonly overview: AlertOverview;
}
/** Zeigt die Meldungslage in Zahlen. */
function SummaryBar({ overview }: SummaryBarProperties): React.JSX.Element {
function SummaryBar({ overview }: { readonly overview: AlertOverview }): React.JSX.Element {
const { summary } = overview;
const figures = [
{ label: 'Kritisch', value: summary.critical_count, tone: 'critical' as const },
{ label: 'Ernst', value: summary.high_count, tone: 'high' as const },
{ label: 'Unbearbeitet', value: summary.open_count, tone: null },
{ label: 'Zur Kenntnis genommen', value: summary.acknowledged_count, tone: null },
{ label: 'Erledigt (24 h)', value: summary.resolved_last_day, tone: null },
];
return (
<div className="summary-bar">
<SummaryFigure
label="Kritisch"
value={summary.critical_count}
severity={summary.critical_count > 0 ? 'critical' : undefined}
/>
<SummaryFigure
label="Ernst"
value={summary.high_count}
severity={summary.high_count > 0 ? 'high' : undefined}
/>
<SummaryFigure label="Unbearbeitet" value={summary.open_count} />
<SummaryFigure label="Zur Kenntnis genommen" value={summary.acknowledged_count} />
<SummaryFigure label="Erledigt (24 h)" value={summary.resolved_last_day} />
<div className="mb-4 grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
{figures.map((figure) => (
<Card key={figure.label} className="p-4">
<p
className={cn(
'text-2xl font-semibold tabular',
// Farbe nur, wenn der Wert eine Aussage traegt: Null kritische
// Meldungen sind neutral, nicht gruen — gruen hiesse "geprueft
// und in Ordnung", und das sagt diese Zahl allein nicht.
figure.tone && figure.value > 0
? figure.tone === 'critical'
? 'text-critical'
: 'text-high'
: 'text-fg',
)}
>
{figure.value}
</p>
<p className="mt-0.5 text-xs text-fg-muted">{figure.label}</p>
</Card>
))}
</div>
);
}
/** Eigenschaften einer Kennzahl der Zusammenfassung. */
interface SummaryFigureProperties {
/** Beschriftung. */
readonly label: string;
/** Der Wert. */
readonly value: number;
/** Statusfarbe, sofern der Wert eine ist. */
readonly severity?: AlertSeverity | undefined;
}
/** Zeigt eine einzelne Kennzahl. */
function SummaryFigure({ label, value, severity }: SummaryFigureProperties): React.JSX.Element {
return (
<div className={severity === undefined ? 'summary-figure' : `summary-figure summary-figure--${severity}`}>
<span className="summary-figure__value">{value}</span>
<span className="summary-figure__label">{label}</span>
</div>
);
}
/** Eigenschaften einer Meldungskarte. */
interface AlertCardProperties {
/** Die dargestellte Meldung. */
readonly alert: Alert;
/** Nimmt die Meldung zur Kenntnis. */
readonly onAcknowledge: () => void;
/** Schliesst die Meldung. */
readonly onResolve: () => void;
}
/** Zeigt eine einzelne Meldung. */
function AlertCard({ alert, onAcknowledge, onResolve }: AlertCardProperties): React.JSX.Element {
function AlertRow({
alert,
isBusy,
onAcknowledge,
onResolve,
}: {
readonly alert: Alert;
readonly isBusy: boolean;
readonly onAcknowledge: () => void;
readonly onResolve: () => void;
}): React.JSX.Element {
const isResolved = alert.status === 'resolved';
return (
<li className={`alert-card alert-card--${alert.severity}${isResolved ? ' alert-card--resolved' : ''}`}>
<div className="alert-card__head">
<span className={`badge badge--${severityClassOf(alert.severity)}`}>{alert.severity}</span>
<h2 className="alert-card__title">{alert.title}</h2>
<li className={cn('px-5 py-4', isResolved && 'opacity-60')}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={alert.severity} />
<h3 className="min-w-0 font-medium text-fg">{alert.title}</h3>
{/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall,
zwanzigmal ein Zustand. */}
{alert.occurrence_count > 1 && (
<span className="alert-card__count">{alert.occurrence_count}× aufgetreten</span>
)}
{alert.occurrence_count > 1 ? (
<span className="rounded bg-sunken px-1.5 py-0.5 text-xs text-fg-muted">
{alert.occurrence_count}× aufgetreten
</span>
) : null}
</div>
<p className="alert-card__message">{alert.message}</p>
<p className="mt-1.5 text-sm text-fg">{alert.message}</p>
<p className="alert-card__meta">
Seit {formatTimestamp(alert.first_seen_at)} · zuletzt {formatTimestamp(alert.last_seen_at)} ·
Regel {alert.rule_name}
<p className="mt-1.5 text-xs text-fg-muted">
Seit {formatDateTime(alert.first_seen_at)} · zuletzt{' '}
{formatDateTime(alert.last_seen_at)} · Regel {alert.rule_name}
</p>
{alert.status === 'acknowledged' && (
<p className="alert-card__meta">
Zur Kenntnis genommen am {formatTimestamp(alert.acknowledged_at)}. Die Meldung bleibt
offen, bis ihre Ursache verschwindet.
{alert.status === 'acknowledged' ? (
<p className="mt-1 text-xs text-fg-muted">
Zur Kenntnis genommen am {formatDateTime(alert.acknowledged_at)}. Die
Meldung <strong>bleibt offen</strong>, bis ihre Ursache verschwindet.
</p>
)}
) : null}
{isResolved && (
<p className="alert-card__meta">
Erledigt am {formatTimestamp(alert.resolved_at)}
{isResolved ? (
<p className="mt-1 text-xs text-fg-muted">
Erledigt am {formatDateTime(alert.resolved_at)}
{alert.resolution_note !== undefined && alert.resolution_note !== ''
? ` — ${alert.resolution_note}`
: ''}
</p>
)}
{!isResolved && (
<div className="alert-card__actions">
{alert.status === 'open' && (
<button className="button button--secondary" type="button" onClick={onAcknowledge}>
Zur Kenntnis nehmen
</button>
)}
<button className="button button--secondary" type="button" onClick={onResolve}>
Schliessen
</button>
) : null}
</div>
{!isResolved ? (
<div className="flex shrink-0 gap-2">
{alert.status === 'open' ? (
<Button size="sm" disabled={isBusy} onClick={onAcknowledge}>
Zur Kenntnis nehmen
</Button>
) : null}
<Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}>
Schliessen
</Button>
</div>
) : null}
</div>
)}
</li>
);
}
/** Eigenschaften der Regelliste. */
interface RuleListProperties {
/** Die Meldungslage samt Regelwerk. */
readonly overview: AlertOverview;
}
/**
* Zeigt, worauf geachtet wird.
*
* Ohne diese Liste bliebe offen, ob eine leere Meldungsliste bedeutet „alles in
* Ordnung" oder „es wird nichts geprueft".
* Eine Regel, die dauerhaft schweigt, ist gefaehrlicher als keine — sie erweckt
* den Eindruck, es werde geprueft. Deshalb steht bei jeder nicht ausgewerteten
* Regel ausdruecklich, dass sie nicht greift, und warum.
*/
function RuleList({ overview }: RuleListProperties): React.JSX.Element {
function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element {
return (
<section className="rule-list">
<h2 className="rule-list__title">Worauf geachtet wird</h2>
<ul className="rule-list__items">
<Card>
<CardHeader
title="Worauf geachtet wird"
description="Erst mit dieser Liste ist eine leere Meldungsliste eine gute Nachricht."
/>
<CardContent>
<ul className="space-y-3">
{overview.rules.map((rule) => (
<li
className={rule.available ? 'rule-list__item' : 'rule-list__item rule-list__item--unavailable'}
key={rule.name}
className={cn(
'rounded-md border border-line p-3',
!rule.available && 'opacity-70',
)}
>
<div className="rule-list__head">
<span className="rule-list__name">{rule.title}</span>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-fg">{rule.title}</span>
{rule.available ? (
<span className={`badge badge--${severityClassOf(rule.severity)}`}>{rule.severity}</span>
<StatusBadge status={rule.severity} />
) : (
<span className="badge badge--neutral">wird nicht geprueft</span>
<StatusBadge status="neutral" label="wird nicht geprueft" />
)}
</div>
<p className="rule-list__description">
<p className="mt-1 text-sm text-fg-muted">
{rule.available ? rule.description : rule.unavailable_reason}
</p>
</li>
))}
</ul>
</section>
{overview.available_rule_count < overview.rules.length ? (
<Callout tone="warning" className="mt-4">
{overview.rules.length - overview.available_rule_count} Regeln werden
nicht ausgewertet. Rechnen Sie sie nicht als „in Ordnung" — sie
schweigen, weil ihnen die Datengrundlage fehlt.
</Callout>
) : null}
</CardContent>
</Card>
);
}
/** Bildet einen Schweregrad auf eine Statusfarbe ab. */
function severityClassOf(severity: AlertSeverity): string {
switch (severity) {
case 'critical':
return 'critical';
case 'high':
return 'high';
case 'warning':
return 'warning';
default:
return 'info';
}
}
/** 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',
});
}

View File

@ -1,102 +0,0 @@
/* Darstellung der Anmeldemaske. */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
}
.login-card {
width: 100%;
max-width: 22rem;
background-color: var(--color-surface-raised);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
padding: var(--space-8) var(--space-6);
}
.login-card__title {
margin: 0 0 var(--space-1);
font-size: var(--text-xl);
font-weight: 600;
letter-spacing: -0.02em;
}
.login-card__subtitle {
margin: 0 0 var(--space-6);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.login-form {
display: flex;
flex-direction: column;
}
.login-form__label {
font-size: var(--text-sm);
font-weight: 500;
margin-bottom: var(--space-1);
}
.login-form__input {
font: inherit;
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-4);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
background-color: var(--color-surface-page);
color: var(--color-text-primary);
}
/* Der Einmalcode wird in Ziffernbreite dargestellt, damit er gut lesbar ist. */
.login-form__input--code {
font-family: var(--font-mono);
font-size: var(--text-lg);
letter-spacing: 0.15em;
text-align: center;
}
.login-form__hint {
margin: 0 0 var(--space-4);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.login-form__error {
/* Der farbige Rand betont den Fehler, ohne die Flaeche einzufaerben. */
border-left: 3px solid var(--color-status-critical);
padding-left: var(--space-3);
margin: 0 0 var(--space-4);
font-size: var(--text-sm);
}
.login-form__button {
font: inherit;
font-weight: 500;
padding: var(--space-3);
border: var(--border-width) solid var(--color-text-primary);
border-radius: var(--radius);
background-color: var(--color-text-primary);
color: var(--color-surface-raised);
cursor: pointer;
}
.login-form__button:disabled {
opacity: 0.6;
cursor: default;
}
.login-form__secondary {
font: inherit;
font-size: var(--text-sm);
margin-top: var(--space-3);
padding: var(--space-2);
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
text-decoration: underline;
}

View File

@ -9,7 +9,6 @@ import { useState } from 'react';
import { ApiError } from '../../api/client';
import { login, verifyMFA } from './authApi';
import type { CurrentUser } from '../../types/auth';
import './LoginPage.css';
/** Eigenschaften der Anmeldemaske. */
export interface LoginPageProps {
@ -106,20 +105,20 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
}
return (
<div className="login-page">
<main className="login-card">
<h1 className="login-card__title">Syncova</h1>
<div className="flex min-h-screen items-center justify-center bg-page p-4">
<main className="w-full max-w-sm rounded-lg border border-line bg-card p-6 shadow-sm">
<h1 className="text-lg font-semibold text-fg">Syncova</h1>
{loginStep === 'credentials' ? (
<form className="login-form" onSubmit={handleCredentialsSubmit}>
<p className="login-card__subtitle">Bitte anmelden</p>
<form className="mt-5 space-y-4" onSubmit={handleCredentialsSubmit}>
<p className="mt-1 text-sm text-fg-muted">Bitte anmelden</p>
<label className="login-form__label" htmlFor="username">
<label className="block text-sm font-medium text-fg" htmlFor="username">
Benutzername
</label>
<input
id="username"
className="login-form__input"
className="mt-1.5 w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={username}
onChange={(changeEvent) => setUsername(changeEvent.target.value)}
@ -128,12 +127,12 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
required
/>
<label className="login-form__label" htmlFor="password">
<label className="block text-sm font-medium text-fg" htmlFor="password">
Passwort
</label>
<input
id="password"
className="login-form__input"
className="mt-1.5 w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="password"
value={password}
onChange={(changeEvent) => setPassword(changeEvent.target.value)}
@ -142,29 +141,29 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
/>
{errorMessage && (
<p className="login-form__error" role="alert">
<p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{errorMessage}
</p>
)}
<button className="login-form__button" type="submit" disabled={isSubmitting}>
<button className="w-full rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-50" type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Anmeldung laeuft …' : 'Anmelden'}
</button>
</form>
) : (
<form className="login-form" onSubmit={handleMFASubmit}>
<p className="login-card__subtitle">Zweiter Faktor</p>
<p className="login-form__hint">
<form className="mt-5 space-y-4" onSubmit={handleMFASubmit}>
<p className="mt-1 text-sm text-fg-muted">Zweiter Faktor</p>
<p className="mt-1.5 text-xs text-fg-muted">
Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein
Wiederherstellungscode moeglich.
</p>
<label className="login-form__label" htmlFor="mfa-code">
<label className="block text-sm font-medium text-fg" htmlFor="mfa-code">
Code
</label>
<input
id="mfa-code"
className="login-form__input login-form__input--code"
className="mt-1.5 w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle text-center font-mono text-lg tracking-[0.4em]"
type="text"
value={mfaCode}
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
@ -176,17 +175,17 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
/>
{errorMessage && (
<p className="login-form__error" role="alert">
<p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{errorMessage}
</p>
)}
<button className="login-form__button" type="submit" disabled={isSubmitting}>
<button className="w-full rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-50" type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Pruefung laeuft …' : 'Bestaetigen'}
</button>
<button
className="login-form__secondary"
className="w-full rounded-md px-4 py-2 text-sm text-fg-muted transition-colors hover:bg-hover hover:text-fg"
type="button"
onClick={() => {
setLoginStep('credentials');

View File

@ -1,99 +1,132 @@
/**
* Uebersicht der Anlage.
*
* Der Plan (§14) nennt zehn Kennzahlen. Sieben haben eine Datengrundlage, drei
* nicht — und die drei erscheinen trotzdem, mit der Angabe, was fehlt. Ein
* weggelassenes Feld sieht aus wie ein vergessenes; ein mit einer Null
* gefuelltes waere eine erfundene Statistik (PROMPT.md §139).
* Der Plan (§14) nennt zehn Kennzahlen. Jede erscheint — auch die ohne
* Datengrundlage, dann mit der Angabe, was fehlt. Ein weggelassenes Feld sieht
* aus wie ein vergessenes; ein mit einer Null gefuelltes waere eine erfundene
* Statistik (PROMPT.md §139).
*
* Der Schweregrad faerbt nur die Randlinie, nicht die Kachel. Zehn farbige
* Flaechen nebeneinander ergeben ein Mosaik, in dem die eine kritische Zahl
* untergeht — und genau die soll auffallen.
*/
import { useCallback } from 'react';
import { useApiResource } from '../../api/useApiResource';
import { ErrorState, LoadingState } from '../../components/PageState';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card, ErrorState, LoadingState } from '@/components/ui';
import { cn } from '@/lib/utils';
import { fetchDashboard } from './dashboardApi';
import type { Dashboard, DashboardWidget } from './dashboardApi';
/** Zeigt die Uebersicht. */
export function DashboardPage(): React.JSX.Element {
const loadDashboard = useCallback((abortSignal: AbortSignal) => fetchDashboard(abortSignal), []);
const loadDashboard = useCallback(
(abortSignal: AbortSignal) => fetchDashboard(abortSignal),
[],
);
const { loadState, data, loadError, reload } = useApiResource<Dashboard>(loadDashboard);
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">Uebersicht</h1>
<>
<PageHeader
title="Uebersicht"
description={
data !== null
? `${data.available_count} von ${data.widgets.length} Kennzahlen haben eine Datengrundlage.`
: undefined
}
/>
{data !== null && (
<span className="page__meta">
{data.available_count} von {data.widgets.length} Kennzahlen haben eine Datengrundlage
</span>
)}
</header>
{loadState === 'loading' ? (
<Card>
<LoadingState label="Die Kennzahlen werden geladen …" />
</Card>
) : null}
{loadState === 'loading' && <LoadingState what="Die Kennzahlen" />}
{loadState === 'failed' && loadError !== null && (
<ErrorState error={loadError} onRetry={reload} />
)}
{loadState === 'failed' && loadError !== null ? (
<Card>
<ErrorState
message={describeApiError(loadError)}
requestId={loadError.requestId}
onRetry={reload}
/>
</Card>
) : null}
{loadState === 'loaded' && data !== null && (
<div className="widget-grid">
{loadState === 'loaded' && data !== null ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{data.widgets.map((dashboardWidget) => (
<WidgetCard key={dashboardWidget.key} widget={dashboardWidget} />
))}
</div>
)}
</section>
) : null}
</>
);
}
/** Eigenschaften einer Kennzahlkachel. */
interface WidgetCardProperties {
/** Die dargestellte Kennzahl. */
readonly widget: DashboardWidget;
}
/** Randfarbe je Schweregrad. Nur die Linie, nicht die Flaeche. */
const severityBorderClasses: Readonly<Record<string, string>> = {
healthy: 'border-l-healthy',
info: 'border-l-info',
warning: 'border-l-warning',
high: 'border-l-high',
critical: 'border-l-critical',
};
/** Zeigt eine einzelne Kennzahl. */
function WidgetCard({ widget }: WidgetCardProperties): React.JSX.Element {
function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX.Element {
if (!widget.available) {
return (
<article className="widget widget--unavailable">
<h2 className="widget__title">{widget.title}</h2>
<p className="widget__unavailable">noch nicht verfuegbar</p>
<p className="widget__detail">{widget.unavailable_reason}</p>
</article>
<Card className="border-l-4 border-l-neutral p-4">
<p className="text-sm font-medium text-fg-muted">{widget.title}</p>
<p className="mt-2 text-sm text-fg-subtle">noch nicht verfuegbar</p>
<p className="mt-1 text-xs text-fg-muted">{widget.unavailable_reason}</p>
</Card>
);
}
return (
<article className={`widget widget--${widget.severity}`}>
<h2 className="widget__title">{widget.title}</h2>
<Card
className={cn(
'border-l-4 p-4',
severityBorderClasses[widget.severity] ?? 'border-l-neutral',
)}
>
<p className="text-sm font-medium text-fg-muted">{widget.title}</p>
<p className="widget__value">
<p className="mt-2 flex items-baseline gap-1.5">
{widget.value === undefined ? (
// Kein Wert heisst nicht null. Eine Kennzahl, die sich nicht bilden
// laesst, sagt das — der Grund steht im Detailtext darunter.
<span className="widget__value-missing">nicht bezifferbar</span>
<span className="text-sm text-fg-muted">nicht bezifferbar</span>
) : (
<>
<span className="text-2xl font-semibold tabular text-fg">
{formatWidgetValue(widget.value)}
{widget.unit !== undefined && <span className="widget__unit"> {widget.unit}</span>}
</span>
{widget.unit !== undefined ? (
<span className="text-sm text-fg-muted">{widget.unit}</span>
) : null}
</>
)}
</p>
{widget.detail !== undefined && <p className="widget__detail">{widget.detail}</p>}
{widget.detail !== undefined ? (
<p className="mt-1 text-xs text-fg-muted">{widget.detail}</p>
) : null}
{widget.breakdown !== undefined && (
<dl className="widget__breakdown">
{widget.breakdown !== undefined ? (
<dl className="mt-3 space-y-1 border-t border-line pt-2">
{Object.entries(widget.breakdown).map(([breakdownKey, breakdownValue]) => (
<div className="widget__breakdown-item" key={breakdownKey}>
<dt>{formatBreakdownLabel(breakdownKey)}</dt>
<dd>{breakdownValue.toLocaleString('de-DE')}</dd>
<div className="flex justify-between gap-2 text-xs" key={breakdownKey}>
<dt className="truncate text-fg-muted">{formatBreakdownLabel(breakdownKey)}</dt>
<dd className="tabular text-fg">{breakdownValue.toLocaleString('de-DE')}</dd>
</div>
))}
</dl>
)}
</article>
) : null}
</Card>
);
}

View File

@ -4,11 +4,27 @@
* 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.
*
* Der Filter „Ungeprueft" heisst serverseitig `successful`. Das ist kein
* Uebersetzungsfehler, sondern der Kern: Ein erfolgreicher Lauf ist noch kein
* Nachweis der Wiederherstellbarkeit.
*/
import { ShieldCheck } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useApiResource } from '../../api/useApiResource';
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Card,
CheckboxField,
DataTable,
ErrorState,
SelectInput,
StatusBadge,
type TableColumn,
} from '@/components/ui';
import { formatBytes, formatDateTime, formatRelativeTime } from '@/lib/utils';
import { fetchRecoveryPoints } from './dashboardApi';
import type { RecoveryPoint } from './dashboardApi';
@ -21,7 +37,6 @@ const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label:
{ value: 'corrupted', label: 'Beschaedigt' },
];
/** Zeigt die Wiederherstellungspunkte. */
export function RecoveryPointsPage({
onOpenBackup,
}: {
@ -37,7 +52,10 @@ export function RecoveryPointsPage({
const loadRecoveryPoints = useCallback(
(abortSignal: AbortSignal) =>
fetchRecoveryPoints({ classification: classificationFilter, includeDeleted, onlyProtected }, abortSignal),
fetchRecoveryPoints(
{ classification: classificationFilter, includeDeleted, onlyProtected },
abortSignal,
),
[classificationFilter, includeDeleted, onlyProtected],
);
@ -46,222 +64,150 @@ export function RecoveryPointsPage({
filterKey,
);
const tableColumns: ReadonlyArray<TableColumn<RecoveryPoint>> = [
{
key: 'completed_at',
header: 'Zeitpunkt',
render: (recoveryPoint) => (
<div className="min-w-0">
<p className="text-sm text-fg">{formatDateTime(recoveryPoint.completed_at)}</p>
<p className="truncate text-xs text-fg-muted">
{formatRelativeTime(recoveryPoint.completed_at)} · {recoveryPoint.repository_name}
</p>
</div>
),
},
{
key: 'job',
header: 'Auftrag',
render: (recoveryPoint) => (
<span className="text-sm text-fg">{recoveryPoint.job_name ?? '—'}</span>
),
},
{
key: 'backup_type',
header: 'Art',
render: (recoveryPoint) => (
<span className="text-sm text-fg-muted">
{recoveryPoint.backup_type === 'full' ? 'Voll' : 'Zusatz'}
</span>
),
},
{
key: 'logical_bytes',
header: 'Umfang',
numeric: true,
render: (recoveryPoint) => formatBytes(recoveryPoint.logical_bytes),
},
{
key: 'classification',
header: 'Einstufung',
render: (recoveryPoint) => (
<div className="flex flex-col gap-1">
<StatusBadge status={recoveryPoint.classification} />
{recoveryPoint.assurance_score !== undefined ? (
<span className="text-xs tabular text-fg-muted">
{recoveryPoint.assurance_score} %
</span>
) : null}
</div>
),
},
{
key: 'protection',
header: 'Schutz',
render: (recoveryPoint) =>
recoveryPoint.is_protected ? (
<span className="inline-flex items-center gap-1.5 text-xs text-healthy">
<ShieldCheck className="size-3.5" aria-hidden />
{recoveryPoint.legal_hold ? 'Legal Hold' : 'Aufbewahrung'}
</span>
) : (
<span className="text-xs text-fg-muted">—</span>
),
},
{
key: 'deleted',
header: '',
render: (recoveryPoint) =>
recoveryPoint.deleted_at !== undefined ? (
// Ein geloeschter Punkt bleibt sichtbar, damit die Historie
// nachvollziehbar bleibt — er wird gekennzeichnet, nicht versteckt.
<span className="text-xs text-fg-subtle">geloescht</span>
) : null,
},
];
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">Wiederherstellungspunkte</h1>
<>
<PageHeader
title="Wiederherstellungspunkte"
description={
data !== null
? `${data.length} Punkte. Nur ein durchgefuehrter Wiederherstellungstest hebt auf „wiederherstellbar“.`
: undefined
}
/>
{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"
<Card>
<div className="flex flex-wrap items-end gap-4 border-b border-line px-5 py-3">
<SelectInput
label="Einstufung"
className="w-64"
value={classificationFilter}
onChange={(changeEvent) => setClassificationFilter(changeEvent.target.value)}
>
{CLASSIFICATION_OPTIONS.map((filterOption) => (
<option key={filterOption.value} value={filterOption.value}>
{filterOption.label}
{CLASSIFICATION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</SelectInput>
<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"
<div className="flex flex-wrap gap-4 pb-2">
<CheckboxField
checked={includeDeleted}
onChange={(changeEvent) => setIncludeDeleted(changeEvent.target.checked)}
onCheckedChange={setIncludeDeleted}
label="Geloeschte einschliessen"
/>
Geloeschte einbeziehen
</label>
<CheckboxField
checked={onlyProtected}
onCheckedChange={setOnlyProtected}
label="Nur geschuetzte"
/>
</div>
</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}
onOpen={onOpenBackup}
{loadState === 'failed' && loadError !== null ? (
<ErrorState
message={describeApiError(loadError)}
requestId={loadError.requestId}
onRetry={reload}
/>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
/** Eigenschaften einer Tabellenzeile. */
interface RecoveryPointRowProperties {
/** Der dargestellte Punkt. */
readonly recoveryPoint: RecoveryPoint;
/** Oeffnet die Einzelheiten. */
readonly onOpen?: ((backupIdentifier: string) => void) | undefined;
}
/** Zeigt einen Wiederherstellungspunkt als Tabellenzeile. */
function RecoveryPointRow({ recoveryPoint, onOpen }: RecoveryPointRowProperties): React.JSX.Element {
const isDeleted = recoveryPoint.deleted_at !== undefined;
return (
<tr
className={isDeleted ? 'data-table__row--deleted' : undefined}
onClick={onOpen ? () => onOpen(recoveryPoint.id) : undefined}
tabIndex={onOpen ? 0 : undefined}
role={onOpen ? 'button' : undefined}
style={onOpen ? { cursor: 'pointer' } : undefined}
onKeyDown={
onOpen
? (keyboardEvent) => {
if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') {
keyboardEvent.preventDefault();
onOpen(recoveryPoint.id);
}
}
) : (
<DataTable
columns={tableColumns}
rows={data ?? []}
getRowKey={(recoveryPoint) => recoveryPoint.id}
onRowClick={
onOpenBackup
? (recoveryPoint) => onOpenBackup(recoveryPoint.id)
: 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} %`
isLoading={loadState === 'loading'}
emptyTitle={
classificationFilter || onlyProtected
? 'Kein Punkt passt zum Filter'
: 'Noch kein Wiederherstellungspunkt'
}
emptyDescription={
classificationFilter || onlyProtected
? 'Lockern Sie den Filter, um mehr zu sehen.'
: 'Sobald ein Sicherungsauftrag durchgelaufen ist, erscheint hier ein Punkt.'
}
/>
)}
</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>
</Card>
</>
);
}
/** 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}`;
}

View File

@ -1,111 +0,0 @@
/* Darstellung der Systemzustandsanzeige. */
.health-panel {
background-color: var(--color-surface-raised);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
padding: var(--space-6);
}
.health-panel__title {
margin: 0 0 var(--space-4);
font-size: var(--text-lg);
font-weight: 600;
}
.health-panel__overall {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.health-panel__version {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.health-panel__hint {
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.health-panel__error {
/* Der farbige Rand links betont die Meldung, ohne die Flaeche einzufaerben. */
border-left: 3px solid var(--color-status-critical);
padding-left: var(--space-3);
margin-bottom: var(--space-4);
}
.health-panel__error-message {
margin: 0 0 var(--space-2);
}
.health-panel__error-detail {
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.health-panel__error-detail code {
font-family: var(--font-mono);
}
.health-panel__button {
font: inherit;
font-size: var(--text-sm);
padding: var(--space-2) var(--space-4);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
background-color: var(--color-surface-page);
color: var(--color-text-primary);
cursor: pointer;
}
.health-panel__button:hover {
border-color: var(--color-text-secondary);
}
.health-panel__table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.health-panel__caption {
/* Die Beschriftung dient Screenreadern und bleibt visuell verborgen. */
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.health-panel__table th,
.health-panel__table td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: var(--border-width) solid var(--color-border);
vertical-align: top;
}
.health-panel__table thead th {
font-weight: 600;
color: var(--color-text-secondary);
}
.health-panel__component-message {
display: block;
margin-top: var(--space-1);
color: var(--color-text-secondary);
}
.health-panel__latency {
font-family: var(--font-mono);
/* Rechtsbuendig, damit Zahlen vergleichbar untereinander stehen. */
text-align: right;
white-space: nowrap;
}

View File

@ -2,96 +2,133 @@
* Anzeige des Systemzustands.
*
* Die Darstellung folgt PROMPT.md §48/§124: statt eines rohen Fehlercodes
* erhaelt der Anwender eine verstaendliche Erklaerung und eine Handlungsempfehlung.
* erhaelt der Anwender eine verstaendliche Erklaerung und eine
* Handlungsempfehlung.
*
* Dass eine kritische Komponente hier mit vollstaendigem Bericht erscheint,
* obwohl der Endpunkt dabei mit 503 antwortet, ist Absicht: Das Monitoring
* schlaegt an, und die Oberflaeche kann trotzdem zeigen, **was** kaputt ist.
*/
import { StatusIndicator } from '../../components/StatusIndicator';
import { useCallback } from 'react';
import { describeApiError } from '@/api/useMutation';
import {
Button,
Card,
CardContent,
CardHeader,
DataTable,
ErrorState,
LoadingState,
StatusBadge,
type TableColumn,
} from '@/components/ui';
import { useSystemHealth } from './useSystemHealth';
import './SystemHealthPanel.css';
/** Abstand der automatischen Aktualisierung in Millisekunden. */
const REFRESH_INTERVAL_MS = 15_000;
/** Zeigt den Zustand des Backends und seiner Komponenten. */
/** Ein Eintrag der Komponententabelle. */
interface ComponentRow {
readonly name: string;
readonly status: string;
readonly message?: string | undefined;
readonly latencyMs: number;
}
export function SystemHealthPanel(): React.JSX.Element {
const { loadState, systemHealth, loadError, reload } = useSystemHealth(REFRESH_INTERVAL_MS);
const componentColumns: ReadonlyArray<TableColumn<ComponentRow>> = [
{
key: 'name',
header: 'Komponente',
render: (component) => <span className="font-medium text-fg">{component.name}</span>,
},
{
key: 'status',
header: 'Zustand',
render: (component) => (
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={component.status} />
{component.message ? (
<span className="text-xs text-fg-muted">{component.message}</span>
) : null}
</div>
),
},
{
key: 'latency',
header: 'Antwortzeit',
numeric: true,
render: (component) => `${component.latencyMs.toFixed(1)} ms`,
},
];
const buildRows = useCallback((): ComponentRow[] => {
if (systemHealth === null) {
return [];
}
return Object.entries(systemHealth.components).map(([componentName, componentHealth]) => ({
name: componentName,
status: componentHealth.status,
message: componentHealth.message,
latencyMs: componentHealth.latency_ms,
}));
}, [systemHealth]);
if (loadState === 'loading') {
return (
<section className="health-panel" aria-busy="true">
<h2 className="health-panel__title">Systemzustand</h2>
<p className="health-panel__hint">Der Systemzustand wird ermittelt …</p>
</section>
<Card aria-busy="true">
<CardHeader title="Systemzustand" />
<LoadingState label="Der Systemzustand wird ermittelt …" />
</Card>
);
}
if (loadState === 'failed' || systemHealth === null) {
return (
<section className="health-panel">
<h2 className="health-panel__title">Systemzustand</h2>
{/* role="alert" sorgt dafuer, dass Screenreader den Fehler ansagen. */}
<div className="health-panel__error" role="alert">
<p className="health-panel__error-message">
{loadError?.message ?? 'Der Systemzustand konnte nicht ermittelt werden.'}
</p>
{/* Die technischen Angaben helfen beim Melden eines Vorfalls. */}
{loadError && loadError.requestId !== '' && (
<p className="health-panel__error-detail">
Fehlercode: <code>{loadError.code}</code> · Request-ID: <code>{loadError.requestId}</code>
</p>
)}
</div>
<button type="button" className="health-panel__button" onClick={reload}>
Erneut versuchen
</button>
</section>
<Card>
<CardHeader title="Systemzustand" />
<ErrorState
title="Der Systemzustand konnte nicht ermittelt werden."
message={loadError ? describeApiError(loadError) : undefined}
requestId={loadError && loadError.requestId !== '' ? loadError.requestId : undefined}
onRetry={reload}
/>
</Card>
);
}
const componentEntries = Object.entries(systemHealth.components);
const componentRows = buildRows();
return (
<section className="health-panel">
<h2 className="health-panel__title">Systemzustand</h2>
<div className="health-panel__overall">
<StatusIndicator status={systemHealth.status} />
<span className="health-panel__version">
Version {systemHealth.version} · Umgebung {systemHealth.environment}
</span>
<Card>
<CardHeader
title="Systemzustand"
description={`Version ${systemHealth.version} · Umgebung ${systemHealth.environment}`}
action={
<div className="flex items-center gap-2">
<StatusBadge status={systemHealth.status} />
<Button variant="ghost" size="sm" onClick={reload}>
Aktualisieren
</Button>
</div>
}
/>
{componentEntries.length === 0 ? (
<p className="health-panel__hint">Es sind keine Komponenten registriert.</p>
{componentRows.length === 0 ? (
<CardContent>
<p className="text-sm text-fg-muted">Es sind keine Komponenten registriert.</p>
</CardContent>
) : (
<table className="health-panel__table">
<caption className="health-panel__caption">Zustand der einzelnen Komponenten</caption>
<thead>
<tr>
<th scope="col">Komponente</th>
<th scope="col">Zustand</th>
<th scope="col">Antwortzeit</th>
</tr>
</thead>
<tbody>
{componentEntries.map(([componentName, componentHealth]) => (
<tr key={componentName}>
<th scope="row">{componentName}</th>
<td>
<StatusIndicator status={componentHealth.status} />
{componentHealth.message && (
<span className="health-panel__component-message">{componentHealth.message}</span>
<DataTable
columns={componentColumns}
rows={componentRows}
getRowKey={(component) => component.name}
/>
)}
</td>
<td className="health-panel__latency">{componentHealth.latency_ms.toFixed(1)} ms</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</Card>
);
}

View File

@ -1,324 +0,0 @@
/**
* Darstellung des Backup-Assistenten.
*
* Semantische Farben stehen ausschliesslich fuer Zustand (PROMPT.md §28):
* Rot fuer Fehler, Gelb fuer nicht verfuegbare Faehigkeiten, Blau fuer
* Hinweise. Alles Uebrige bleibt neutral - Farbe als Schmuck macht die
* Statusfarben bedeutungslos.
*/
.wizard {
display: grid;
grid-template-columns: 14rem 1fr;
gap: var(--space-6);
align-items: start;
}
/* Auf schmalen Fenstern liegt die Schrittleiste ueber dem Inhalt statt daneben. */
@media (max-width: 48rem) {
.wizard {
grid-template-columns: 1fr;
}
.wizard__steps {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
}
.wizard__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.wizard__step {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: none;
color: var(--color-text-secondary);
font-size: var(--text-sm);
text-align: left;
cursor: pointer;
}
/* Ein noch nicht erreichter Schritt ist nicht anklickbar: Er wuerde eine
Prüfung überspringen, die der Assistent gerade führen soll. */
.wizard__step:disabled {
cursor: default;
opacity: 0.5;
}
.wizard__step--current {
border-color: var(--color-border);
background: var(--color-surface-raised);
color: var(--color-text-primary);
font-weight: 600;
}
.wizard__step--done {
color: var(--color-status-healthy);
}
.wizard__step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
border-radius: 50%;
border: var(--border-width) solid currentColor;
font-size: 0.75rem;
}
.wizard__panel {
padding: var(--space-6);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface-raised);
}
.wizard__heading {
margin: 0 0 var(--space-6);
font-size: var(--text-lg);
color: var(--color-text-primary);
}
.wizard__fields {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.wizard__field {
display: flex;
flex-direction: column;
gap: var(--space-1);
border: none;
margin: 0;
padding: 0;
}
.wizard__label {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.wizard__input {
padding: var(--space-2) var(--space-3);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface-page);
color: var(--color-text-primary);
font-family: var(--font-sans);
font-size: var(--text-base);
}
.wizard__input--area {
resize: vertical;
}
.wizard__input--mono {
font-family: var(--font-mono);
}
.wizard__hint {
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin: 0;
}
.wizard__preview {
padding: var(--space-3);
border-left: 3px solid var(--color-status-info);
background: var(--color-surface-page);
color: var(--color-text-primary);
font-size: var(--text-sm);
margin: 0;
}
.wizard__source {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
margin: 0;
}
.wizard__checkbox-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.wizard__checkbox,
.wizard__radio {
display: flex;
align-items: flex-start;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-primary);
cursor: pointer;
}
.wizard__radio {
padding: var(--space-3);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
}
.wizard__radio-body {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.wizard__radio-title {
font-weight: 600;
}
.wizard__radio-detail {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.wizard__radio-warning {
font-size: var(--text-sm);
color: var(--color-status-warning);
}
/* Ein nicht umgesetzter Schritt wird als solcher gekennzeichnet, statt Werte
abzufragen, die niemand auswertet (PROMPT.md §138). */
.wizard__unavailable {
padding: var(--space-4);
border-left: 3px solid var(--color-status-warning);
background: var(--color-surface-page);
}
.wizard__unavailable-badge {
margin: 0 0 var(--space-2);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-status-warning);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.wizard__unavailable-text {
margin: 0;
color: var(--color-text-primary);
}
.wizard__notice {
padding: var(--space-3);
border-left: 3px solid var(--color-status-healthy);
background: var(--color-surface-page);
font-size: var(--text-sm);
color: var(--color-text-primary);
}
.wizard__problems {
margin: var(--space-4) 0 0;
padding: var(--space-3) var(--space-3) var(--space-3) var(--space-6);
border-left: 3px solid var(--color-status-critical);
background: var(--color-surface-page);
color: var(--color-status-critical);
font-size: var(--text-sm);
}
.wizard__error {
margin: 0;
padding: var(--space-3);
border-left: 3px solid var(--color-status-critical);
background: var(--color-surface-page);
color: var(--color-status-critical);
font-size: var(--text-sm);
}
.wizard__summary {
display: grid;
grid-template-columns: 12rem 1fr;
gap: var(--space-2) var(--space-4);
margin: 0;
}
.wizard__summary dt {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.wizard__summary dd {
margin: 0;
color: var(--color-text-primary);
}
/* Was nicht eingerichtet ist, wird in der Uebersicht als solches benannt.
Eine leere Zeile liesse offen, ob nichts eingestellt oder nichts moeglich ist. */
.wizard__summary-unavailable {
color: var(--color-status-warning);
font-size: var(--text-sm);
}
.wizard__summary-list {
margin: 0;
padding-left: var(--space-4);
}
.wizard__summary-detail {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.wizard__actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-8);
padding-top: var(--space-4);
border-top: var(--border-width) solid var(--color-border);
}
.wizard__navigation {
display: flex;
gap: var(--space-2);
}
.wizard__button {
padding: var(--space-2) var(--space-4);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface-page);
color: var(--color-text-primary);
font-size: var(--text-base);
cursor: pointer;
}
.wizard__button--primary {
border-color: var(--color-text-primary);
background: var(--color-text-primary);
color: var(--color-surface-raised);
}
.wizard__button--small {
align-self: flex-start;
font-size: var(--text-sm);
}
.wizard__button:disabled {
cursor: default;
opacity: 0.6;
}

View File

@ -23,7 +23,6 @@ import {
WIZARD_STEPS,
} from './wizardModel';
import type { DraftSource, JobDraft, WizardStep } from './wizardModel';
import './BackupWizard.css';
/** Eigenschaften des Assistenten. */
export interface BackupWizardProps {
@ -132,7 +131,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
return (
<section className="wizard" aria-label="Backup-Assistent">
<ol className="wizard__steps">
<ol className="flex flex-wrap items-center gap-1 border-b border-line px-5 py-3">
{WIZARD_STEPS.map((wizardStep, stepIndex) => (
<li key={wizardStep}>
<button
@ -142,48 +141,48 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
disabled={stepIndex > currentStepIndex}
aria-current={stepIndex === currentStepIndex ? 'step' : undefined}
>
<span className="wizard__step-number">{stepIndex + 1}</span>
<span className="wizard__step-title">{STEP_TITLES[wizardStep]}</span>
<span className="tabular">{stepIndex + 1}</span>
<span className="">{STEP_TITLES[wizardStep]}</span>
</button>
</li>
))}
</ol>
<div className="wizard__panel">
<h2 className="wizard__heading">
<div className="px-5 py-4">
<h2 className="text-base font-semibold text-fg">
Schritt {currentStepIndex + 1} von {WIZARD_STEPS.length}: {STEP_TITLES[currentStep]}
</h2>
{renderStepContent()}
{showProblems && stepValidation.problems.length > 0 && (
<ul className="wizard__problems" role="alert">
<ul className="mt-3 space-y-1 rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{stepValidation.problems.map((problemText) => (
<li key={problemText}>{problemText}</li>
))}
</ul>
)}
<div className="wizard__actions">
<button type="button" className="wizard__button" onClick={onCancel}>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-line px-5 py-3">
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50" onClick={onCancel}>
Abbrechen
</button>
<div className="wizard__navigation">
<div className="flex gap-2">
{currentStepIndex > 0 && (
<button type="button" className="wizard__button" onClick={goToPreviousStep}>
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50" onClick={goToPreviousStep}>
Zurueck
</button>
)}
{currentStep !== 'create' ? (
<button type="button" className="wizard__button wizard__button--primary" onClick={goToNextStep}>
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 bg-accent text-accent-fg hover:bg-accent-hover" onClick={goToNextStep}>
Weiter
</button>
) : (
<button
type="button"
className="wizard__button wizard__button--primary"
className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 bg-accent text-accent-fg hover:bg-accent-hover"
onClick={() => void handleCreate()}
disabled={isSubmitting}
>
@ -234,9 +233,9 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
*/
function renderUnavailableStep(explanationText: string): React.JSX.Element {
return (
<div className="wizard__unavailable">
<p className="wizard__unavailable-badge">Noch nicht verfuegbar</p>
<p className="wizard__unavailable-text">{explanationText}</p>
<div className="rounded-md border border-line bg-sunken p-4">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">Noch nicht verfuegbar</p>
<p className="mt-1 text-sm text-fg-muted">{explanationText}</p>
</div>
);
}
@ -244,11 +243,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 1: Name und Beschreibung. */
function renderNameStep(): React.JSX.Element {
return (
<div className="wizard__fields">
<label className="wizard__field">
<span className="wizard__label">Name des Auftrags</span>
<div className="mt-4 space-y-4">
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Name des Auftrags</span>
<input
className="wizard__input"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={jobDraft.name}
onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })}
@ -257,10 +256,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/>
</label>
<label className="wizard__field">
<span className="wizard__label">Beschreibung (optional)</span>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Beschreibung (optional)</span>
<textarea
className="wizard__input wizard__input--area"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle min-h-20 resize-y"
value={jobDraft.description}
onChange={(changeEvent) => updateDraft({ description: changeEvent.target.value })}
rows={3}
@ -282,15 +281,15 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
}
return (
<div className="wizard__fields">
<div className="mt-4 space-y-4">
{jobDraft.sources.map((draftSource, sourceIndex) => (
<fieldset key={sourceIndex} className="wizard__source">
<legend className="wizard__label">Quelle {sourceIndex + 1}</legend>
<fieldset key={sourceIndex} className="flex items-center justify-between gap-2 rounded-md border border-line px-3 py-2 text-sm">
<legend className="block text-sm font-medium text-fg">Quelle {sourceIndex + 1}</legend>
<label className="wizard__field">
<span className="wizard__label">Art</span>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Art</span>
<select
className="wizard__input"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
value={draftSource.sourceType}
onChange={(changeEvent) =>
updateSource(sourceIndex, { sourceType: changeEvent.target.value as SourceType })
@ -309,10 +308,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</select>
</label>
<label className="wizard__field">
<span className="wizard__label">Pfad</span>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Pfad</span>
<input
className="wizard__input"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={draftSource.sourceIdentifier}
onChange={(changeEvent) =>
@ -322,20 +321,20 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/>
</label>
<label className="wizard__field">
<span className="wizard__label">Bezeichnung (optional)</span>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Bezeichnung (optional)</span>
<input
className="wizard__input"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={draftSource.sourceName}
onChange={(changeEvent) => updateSource(sourceIndex, { sourceName: changeEvent.target.value })}
/>
</label>
<label className="wizard__field">
<span className="wizard__label">Auszuschliessen (kommasepariert, optional)</span>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Auszuschliessen (kommasepariert, optional)</span>
<input
className="wizard__input"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={draftSource.excludePatterns}
onChange={(changeEvent) =>
@ -348,7 +347,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
{jobDraft.sources.length > 1 && (
<button
type="button"
className="wizard__button wizard__button--small"
className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 h-8 px-3 text-xs"
onClick={() =>
updateDraft({
sources: jobDraft.sources.filter((_, currentIndex) => currentIndex !== sourceIndex),
@ -363,7 +362,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<button
type="button"
className="wizard__button wizard__button--small"
className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 h-8 px-3 text-xs"
onClick={() =>
updateDraft({
sources: [
@ -382,11 +381,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 3: Zeitplan. */
function renderScheduleStep(): React.JSX.Element {
return (
<div className="wizard__fields">
<label className="wizard__field">
<span className="wizard__label">Art des Zeitplans</span>
<div className="mt-4 space-y-4">
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Art des Zeitplans</span>
<select
className="wizard__input"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
value={jobDraft.scheduleType}
onChange={(changeEvent) =>
updateDraft({ scheduleType: changeEvent.target.value as JobDraft['scheduleType'] })
@ -403,10 +402,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</label>
{jobDraft.scheduleType === 'interval' && (
<label className="wizard__field">
<span className="wizard__label">Abstand in Stunden</span>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Abstand in Stunden</span>
<input
className="wizard__input"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="number"
min={1}
value={jobDraft.scheduleIntervalHours}
@ -419,12 +418,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' &&
jobDraft.scheduleType !== 'cron' && (
<label className="wizard__field">
<span className="wizard__label">
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">
{jobDraft.scheduleType === 'hourly' ? 'Minute (aus HH:MM)' : 'Uhrzeit'}
</span>
<input
className="wizard__input"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="time"
value={jobDraft.scheduleTime}
onChange={(changeEvent) => updateDraft({ scheduleTime: changeEvent.target.value })}
@ -433,11 +432,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
)}
{jobDraft.scheduleType === 'weekly' && (
<fieldset className="wizard__field">
<legend className="wizard__label">Wochentage</legend>
<div className="wizard__checkbox-row">
<fieldset className="block space-y-1.5">
<legend className="block text-sm font-medium text-fg">Wochentage</legend>
<div className="flex flex-wrap gap-4">
{WEEKDAY_NAMES.map((weekdayName, weekdayNumber) => (
<label key={weekdayName} className="wizard__checkbox">
<label key={weekdayName} className="flex items-center gap-2 text-sm text-fg">
<input
type="checkbox"
checked={jobDraft.scheduleWeekdays.includes(weekdayNumber)}
@ -457,11 +456,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
)}
{jobDraft.scheduleType === 'monthly' && (
<fieldset className="wizard__field">
<legend className="wizard__label">Tage des Monats</legend>
<div className="wizard__checkbox-row">
<fieldset className="block space-y-1.5">
<legend className="block text-sm font-medium text-fg">Tage des Monats</legend>
<div className="flex flex-wrap gap-4">
{[1, 15, -1].map((monthDay) => (
<label key={monthDay} className="wizard__checkbox">
<label key={monthDay} className="flex items-center gap-2 text-sm text-fg">
<input
type="checkbox"
checked={jobDraft.scheduleMonthDays.includes(monthDay)}
@ -479,44 +478,44 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</div>
{/* Der Monatsletzte ist kein Luxus: Wer "am 31." waehlt, bekommt in
vier Monaten des Jahres keine Sicherung. */}
<p className="wizard__hint">
<p className="text-xs text-fg-muted">
Der letzte Tag des Monats faellt nie aus - anders als etwa der 31.
</p>
</fieldset>
)}
{jobDraft.scheduleType === 'cron' && (
<label className="wizard__field">
<span className="wizard__label">Cron-Ausdruck</span>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Cron-Ausdruck</span>
<input
className="wizard__input wizard__input--mono"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle font-mono"
type="text"
value={jobDraft.scheduleCronExpression}
onChange={(changeEvent) => updateDraft({ scheduleCronExpression: changeEvent.target.value })}
placeholder="0 2 * * *"
/>
<span className="wizard__hint">Minute Stunde Tag Monat Wochentag</span>
<span className="text-xs text-fg-muted">Minute Stunde Tag Monat Wochentag</span>
</label>
)}
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' && (
<label className="wizard__field">
<span className="wizard__label">Zeitzone</span>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Zeitzone</span>
<input
className="wizard__input"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={jobDraft.scheduleTimeZone}
onChange={(changeEvent) => updateDraft({ scheduleTimeZone: changeEvent.target.value })}
/>
{/* Ohne Zeitzone gilt auf dem Server UTC - derselbe Auftrag liefe
dann je nach Standort zu einer anderen Uhrzeit. */}
<span className="wizard__hint">
<span className="text-xs text-fg-muted">
Ohne Angabe rechnet der Server in UTC.
</span>
</label>
)}
<p className="wizard__preview">Ergibt: {describeDraftSchedule(jobDraft)}</p>
<p className="mt-2 rounded-md border border-line bg-sunken px-3 py-2 text-sm text-fg">Ergibt: {describeDraftSchedule(jobDraft)}</p>
</div>
);
}
@ -524,12 +523,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 4: Repository. */
function renderRepositoryStep(): React.JSX.Element {
if (repositoryError !== null) {
return <p className="wizard__error">{repositoryError}</p>;
return <p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical">{repositoryError}</p>;
}
if (repositories.length === 0) {
return (
<p className="wizard__hint">
<p className="text-xs text-fg-muted">
Es ist kein Sicherungsziel eingerichtet. Ein Repository entsteht auf einem Datentraeger und
wird mit <code>syncova-repo create --path &lt;pfad&gt; --name &lt;name&gt;</code> angelegt.
</p>
@ -537,9 +536,9 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
}
return (
<div className="wizard__fields">
<div className="mt-4 space-y-4">
{repositories.map((repositoryEntry) => (
<label key={repositoryEntry.id} className="wizard__radio">
<label key={repositoryEntry.id} className="flex cursor-pointer items-start gap-2.5 rounded-md border border-line p-3 text-sm transition-colors hover:bg-hover has-[:checked]:border-accent has-[:checked]:bg-accent-subtle">
<input
type="radio"
name="repository"
@ -549,15 +548,15 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
onChange={() => updateDraft({ repositoryId: repositoryEntry.id })}
/>
<span className="wizard__radio-body">
<span className="wizard__radio-title">{repositoryEntry.name}</span>
<span className="wizard__radio-detail">{repositoryEntry.location}</span>
<span className="flex min-w-0 flex-col">
<span className="font-medium text-fg">{repositoryEntry.name}</span>
<span className="font-mono text-xs text-fg-muted">{repositoryEntry.location}</span>
{/* Ein Ziel, das keine Sicherungen annimmt, wird benannt statt
stillschweigend weggelassen: Sonst suchte der Anwender ein
Repository, das er sieht und nicht waehlen kann. */}
{!repositoryEntry.accepts_backups && (
<span className="wizard__radio-warning">
<span className="text-xs text-warning">
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
</span>
)}
@ -571,20 +570,20 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 6: Sicherheit. */
function renderSecurityStep(): React.JSX.Element {
return (
<div className="wizard__fields">
<div className="mt-4 space-y-4">
{/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst
ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere
eine Einstellung, die es nicht gibt. */}
<div className="wizard__notice">
<div className="mt-3 rounded-md border border-info/30 bg-info-bg p-3 text-sm">
<strong>Verschluesselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM
verschluesselt, bevor sie das System verlassen. Der Schluessel gehoert zum Repository und
wird vom Dienst verwaltet.
</div>
<label className="wizard__field">
<span className="wizard__label">Dringlichkeit</span>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Dringlichkeit</span>
<select
className="wizard__input"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
value={jobDraft.priority}
onChange={(changeEvent) =>
updateDraft({ priority: changeEvent.target.value as JobDraft['priority'] })
@ -595,21 +594,21 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<option value="normal">Normal</option>
<option value="low">Niedrig</option>
</select>
<span className="wizard__hint">
<span className="text-xs text-fg-muted">
Entscheidet die Reihenfolge, wenn mehrere Auftraege gleichzeitig faellig sind.
</span>
</label>
<label className="wizard__field">
<span className="wizard__label">Bandbreitengrenze (optional)</span>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Bandbreitengrenze (optional)</span>
<input
className="wizard__input"
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={jobDraft.bandwidthLimit}
onChange={(changeEvent) => updateDraft({ bandwidthLimit: changeEvent.target.value })}
placeholder="50MB"
/>
<span className="wizard__hint">
<span className="text-xs text-fg-muted">
Begrenzt das Lesen von der Quelle. 100Mbit ist ein Achtel von 100MB.
</span>
</label>
@ -624,7 +623,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
);
return (
<dl className="wizard__summary">
<dl className="mt-4 space-y-3">
<dt>Name</dt>
<dd>{jobDraft.name}</dd>
@ -637,14 +636,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<dt>Quellen</dt>
<dd>
<ul className="wizard__summary-list">
<ul className="space-y-2">
{jobDraft.sources
.filter((draftSource) => draftSource.sourceIdentifier.trim() !== '')
.map((draftSource, sourceIndex) => (
<li key={sourceIndex}>
{draftSource.sourceIdentifier}
{splitPatternList(draftSource.excludePatterns).length > 0 && (
<span className="wizard__summary-detail">
<span className="text-sm text-fg">
{' '}
ohne {splitPatternList(draftSource.excludePatterns).join(', ')}
</span>
@ -661,7 +660,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<dd>{selectedRepository?.name ?? jobDraft.repositoryId}</dd>
<dt>Aufbewahrung</dt>
<dd className="wizard__summary-unavailable">unbegrenzt (Regeln noch nicht umgesetzt)</dd>
<dd className="text-sm text-fg-muted">unbegrenzt (Regeln noch nicht umgesetzt)</dd>
<dt>Sicherheit</dt>
<dd>
@ -670,10 +669,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</dd>
<dt>Pruefung</dt>
<dd className="wizard__summary-unavailable">keine automatische Pruefung</dd>
<dd className="text-sm text-fg-muted">keine automatische Pruefung</dd>
<dt>Benachrichtigung</dt>
<dd className="wizard__summary-unavailable">keine</dd>
<dd className="text-sm text-fg-muted">keine</dd>
</dl>
);
}
@ -681,14 +680,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 10: Anlegen. */
function renderCreateStep(): React.JSX.Element {
return (
<div className="wizard__fields">
<div className="mt-4 space-y-4">
<p>
Der Auftrag wird angelegt und laeuft ab sofort nach Zeitplan. Der erste Lauf ist eine
vollstaendige Sicherung; jeder weitere liest nur, was sich geaendert hat.
</p>
{submitError !== null && (
<p className="wizard__error" role="alert">
<p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{submitError}
</p>
)}
@ -697,17 +696,24 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
}
}
/** Bildet die CSS-Klasse eines Schrittes in der Kopfzeile. */
/**
* Bildet die Darstellung eines Schrittes in der Kopfzeile.
*
* Ein noch nicht erreichter Schritt bleibt gedaempft und ist nicht anklickbar —
* er uebersprang eine Pruefung.
*/
function buildStepClassName(stepIndex: number, currentStepIndex: number): string {
const baseClasses = 'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs';
if (stepIndex === currentStepIndex) {
return 'wizard__step wizard__step--current';
return `${baseClasses} bg-accent-subtle font-medium text-accent`;
}
if (stepIndex < currentStepIndex) {
return 'wizard__step wizard__step--done';
return `${baseClasses} text-healthy`;
}
return 'wizard__step';
return `${baseClasses} text-fg-subtle`;
}
/** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */

View File

@ -1,108 +0,0 @@
/**
* Darstellung der Auftragsuebersicht.
*
* Semantische Farben ausschliesslich fuer Status (PROMPT.md §28). Ein
* Teilfehler bekommt Gelb, kein Gruen: Er ist kein Erfolg.
*/
.jobs {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.jobs__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.jobs__heading {
margin: 0;
font-size: var(--text-lg);
color: var(--color-text-primary);
}
.jobs__create {
padding: var(--space-2) var(--space-4);
border: var(--border-width) solid var(--color-text-primary);
border-radius: var(--radius);
background: var(--color-text-primary);
color: var(--color-surface-raised);
font-size: var(--text-base);
cursor: pointer;
}
.jobs__hint {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.jobs__error {
margin: 0;
padding: var(--space-3);
border-left: 3px solid var(--color-status-critical);
background: var(--color-surface-page);
color: var(--color-status-critical);
font-size: var(--text-sm);
}
.jobs__table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.jobs__table th,
.jobs__table td {
padding: var(--space-2) var(--space-3);
border-bottom: var(--border-width) solid var(--color-border);
text-align: left;
}
.jobs__table th {
color: var(--color-text-secondary);
font-weight: 600;
}
.jobs__table td {
color: var(--color-text-primary);
}
.jobs__badge {
margin-left: var(--space-2);
padding: 0 var(--space-2);
border-radius: var(--radius);
font-size: 0.75rem;
}
.jobs__badge--paused {
background: var(--color-surface-page);
color: var(--color-status-warning);
border: var(--border-width) solid var(--color-status-warning);
}
.jobs__outcome {
color: var(--color-text-secondary);
}
.jobs__outcome--healthy {
color: var(--color-status-healthy);
}
/* Ein Teilfehler ist kein Erfolg und bekommt deshalb Gelb, nicht Gruen. */
.jobs__outcome--warning {
color: var(--color-status-warning);
font-weight: 600;
}
.jobs__outcome--critical {
color: var(--color-status-critical);
font-weight: 600;
}
.jobs__outcome--neutral {
color: var(--color-status-neutral);
}

View File

@ -1,173 +0,0 @@
/**
* Uebersicht der Sicherungsauftraege mit Zugang zum Assistenten.
*
* Die Liste zeigt, was tatsaechlich hinterlegt ist. Kennzahlen, die es noch
* nicht gibt - Erfolgsquoten, Speicherbedarf, Trends -, erscheinen nicht:
* Erfundene Zahlen in einer Uebersicht sind schlimmer als eine leere Spalte
* (PROMPT.md §138).
*/
import { useCallback, useEffect, useState } from 'react';
import { ApiError } from '../../api/client';
import { BackupWizard } from './BackupWizard';
import { listJobs } from './jobsApi';
import type { BackupJob } from './jobsApi';
import './JobsPanel.css';
/** Zeigt die Auftraege und den Assistenten zum Anlegen. */
export function JobsPanel(): React.JSX.Element {
const [jobs, setJobs] = useState<BackupJob[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isWizardOpen, setIsWizardOpen] = useState(false);
// reloadCounter erzwingt einen erneuten Lauf des Effekts, nachdem ein Auftrag
// angelegt wurde. Die Ladelogik bleibt damit an einer einzigen Stelle.
const [reloadCounter, setReloadCounter] = useState(0);
const reload = useCallback(() => {
setReloadCounter((previousCounter) => previousCounter + 1);
}, []);
useEffect(() => {
// Der Controller bricht die Anfrage ab, wenn die Komponente verschwindet.
const abortController = new AbortController();
async function loadJobs(): Promise<void> {
try {
const loadedJobs = await listJobs(abortController.signal);
setJobs(loadedJobs);
setLoadError(null);
} catch (caughtError: unknown) {
// Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens.
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
return;
}
// Die bisherige Liste wird verworfen: Eine veraltete Anzeige als
// aktuellen Stand auszugeben waere irrefuehrend.
setJobs([]);
setLoadError(
caughtError instanceof ApiError
? caughtError.message
: 'Die Auftraege konnten nicht geladen werden.',
);
} finally {
setIsLoading(false);
}
}
void loadJobs();
return () => abortController.abort();
}, [reloadCounter]);
if (isWizardOpen) {
return (
<BackupWizard
onCancel={() => setIsWizardOpen(false)}
onJobCreated={() => {
setIsWizardOpen(false);
reload();
}}
/>
);
}
return (
<section className="jobs" aria-label="Sicherungsauftraege">
<header className="jobs__header">
<h2 className="jobs__heading">Sicherungsauftraege</h2>
<button type="button" className="jobs__create" onClick={() => setIsWizardOpen(true)}>
Auftrag anlegen
</button>
</header>
{loadError !== null && (
<p className="jobs__error" role="alert">
{loadError}
</p>
)}
{isLoading && <p className="jobs__hint">Wird geladen …</p>}
{!isLoading && loadError === null && jobs.length === 0 && (
<p className="jobs__hint">
Es ist kein Auftrag eingerichtet. Ohne Auftrag wird nichts gesichert.
</p>
)}
{jobs.length > 0 && (
<table className="jobs__table">
<thead>
<tr>
<th>Name</th>
<th>Zeitplan</th>
<th>Naechster Lauf</th>
<th>Letzter Ausgang</th>
</tr>
</thead>
<tbody>
{jobs.map((backupJob) => (
<tr key={backupJob.id}>
<td>
{backupJob.name}
{backupJob.status === 'paused' && (
<span className="jobs__badge jobs__badge--paused">ausgesetzt</span>
)}
</td>
<td>{backupJob.schedule_description}</td>
<td>{formatTimestamp(backupJob.next_run_at)}</td>
<td>{renderOutcome(backupJob.last_outcome)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}
/** Gibt einen Zeitstempel in Ortszeit aus. */
function formatTimestamp(isoTimestamp: string | undefined): string {
if (isoTimestamp === undefined || isoTimestamp === '') {
return '—';
}
const parsedDate = new Date(isoTimestamp);
if (Number.isNaN(parsedDate.getTime())) {
return '—';
}
return parsedDate.toLocaleString();
}
/**
* Stellt den Ausgang des letzten Laufs dar.
*
* Ein Teilfehler bekommt eine eigene Farbe und eine eigene Beschriftung. Ihn
* gruen als Erfolg zu zeigen waere genau die Beschoenigung, die PROMPT.md §138
* verbietet: Der Lauf hat Objekte uebergangen.
*/
function renderOutcome(lastOutcome: string | undefined): React.JSX.Element {
if (lastOutcome === undefined || lastOutcome === '') {
return <span className="jobs__outcome">noch nie gelaufen</span>;
}
const outcomeLabels: Record<string, { label: string; modifier: string }> = {
succeeded: { label: 'erfolgreich', modifier: 'healthy' },
partial_failure: { label: 'TEILWEISE FEHLGESCHLAGEN', modifier: 'warning' },
failed: { label: 'gescheitert', modifier: 'critical' },
cancelled: { label: 'abgebrochen', modifier: 'neutral' },
};
const outcomeInfo = outcomeLabels[lastOutcome] ?? { label: lastOutcome, modifier: 'neutral' };
return (
<span className={`jobs__outcome jobs__outcome--${outcomeInfo.modifier}`}>{outcomeInfo.label}</span>
);
}

View File

@ -8,8 +8,10 @@
*/
import { useCallback, useState } from 'react';
import { useApiResource } from '../../api/useApiResource';
import { ErrorState, LoadingState } from '../../components/PageState';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card, CardContent, CardHeader, ErrorState, LoadingState, SelectInput } from '@/components/ui';
import { LineChart } from './LineChart';
import { fetchChart, fetchChartCatalog } from './metricsApi';
import type { Chart, ChartCatalog, ChartDefinition } from './metricsApi';
@ -32,22 +34,18 @@ export function MetricsPage(): React.JSX.Element {
const { loadState, data, loadError, reload } = useApiResource<ChartCatalog>(loadCatalog);
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">Kennzahlen</h1>
{data !== null && (
<span className="page__meta">
{data.available_count} von {data.charts.length} Diagrammen haben eine Datengrundlage
</span>
)}
</header>
<div className="filter-bar">
<label className="filter-bar__field">
<span className="filter-bar__label">Zeitraum</span>
<select
className="filter-bar__select"
<>
<PageHeader
title="Kennzahlen"
description={
data !== null
? `${data.available_count} von ${data.charts.length} Diagrammen haben eine Datengrundlage.`
: undefined
}
actions={
<SelectInput
aria-label="Zeitraum"
className="w-52"
value={selectedRange}
onChange={(changeEvent) => setSelectedRange(changeEvent.target.value)}
>
@ -56,17 +54,28 @@ export function MetricsPage(): React.JSX.Element {
{rangeOption.label}
</option>
))}
</select>
</label>
</div>
</SelectInput>
}
/>
{loadState === 'loading' && <LoadingState what="Die Kennzahlen" />}
{loadState === 'failed' && loadError !== null && (
<ErrorState error={loadError} onRetry={reload} />
)}
{loadState === 'loading' ? (
<Card>
<LoadingState label="Die Kennzahlen werden geladen …" />
</Card>
) : null}
{loadState === 'loaded' && data !== null && (
<div className="chart-list">
{loadState === 'failed' && loadError !== null ? (
<Card>
<ErrorState
message={describeApiError(loadError)}
requestId={loadError.requestId}
onRetry={reload}
/>
</Card>
) : null}
{loadState === 'loaded' && data !== null ? (
<div className="grid gap-4 xl:grid-cols-2">
{data.charts.map((chartDefinition) => (
<ChartCard
key={chartDefinition.metric}
@ -75,8 +84,8 @@ export function MetricsPage(): React.JSX.Element {
/>
))}
</div>
)}
</section>
) : null}
</>
);
}
@ -105,35 +114,41 @@ function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.El
if (!definition.available) {
return (
<article className="chart-card chart-card--unavailable">
<h2 className="chart-card__title">{definition.title}</h2>
<p className="chart-card__badge">noch nicht verfuegbar</p>
<p className="chart-card__description">{definition.unavailable_reason}</p>
</article>
<Card>
<CardHeader title={definition.title} description="noch nicht verfuegbar" />
<CardContent>
<p className="text-sm text-fg-muted">{definition.unavailable_reason}</p>
</CardContent>
</Card>
);
}
return (
<article className="chart-card">
<h2 className="chart-card__title">{definition.title}</h2>
<p className="chart-card__description">{definition.description}</p>
<Card>
<CardHeader title={definition.title} description={definition.description} />
<CardContent>
{loadState === 'loading' ? <LoadingState label="Die Reihe wird geladen …" /> : null}
{loadState === 'loading' && <LoadingState what="Die Reihe" />}
{loadState === 'failed' && loadError !== null && (
<ErrorState error={loadError} onRetry={reload} />
)}
{loadState === 'failed' && loadError !== null ? (
<ErrorState
message={describeApiError(loadError)}
requestId={loadError.requestId}
onRetry={reload}
/>
) : null}
{loadState === 'loaded' && data !== null && (
{loadState === 'loaded' && data !== null ? (
<>
<LineChart series={data.series} unit={definition.unit} />
{/* Der Hinweis ordnet ein, was die Kurve wert ist: Zwei Punkte sehen
aus wie ein Trend und sind keiner. */}
{data.note !== undefined && data.note !== '' && (
<p className="chart-card__note">{data.note}</p>
)}
{data.note !== undefined && data.note !== '' ? (
<p className="mt-2 text-xs text-fg-muted">{data.note}</p>
) : null}
</>
)}
</article>
) : null}
</CardContent>
</Card>
);
}

View File

@ -122,16 +122,16 @@ describe('Berichte', () => {
// Die ungemessene Quote steht als „nicht gemessen" da — mit Begruendung.
expect(screen.getByText('nicht gemessen')).toBeInTheDocument();
expect(
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.', {
selector: '.reports__metric-reason',
}),
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.'),
).toBeInTheDocument();
// Die gemessene Null bleibt dagegen sichtbar: „null Laeufe" ist eine
// Aussage, und sie darf nicht mit „nicht gemessen" verwechselt werden.
// Die gemessene Null steht als Zahl da und traegt **keine** Begruendung —
// sie braucht keine. Genau daran unterscheidet sie sich von der
// ungemessenen Quote daneben.
const measuredValue = screen.getByText('Laeufe insgesamt').nextElementSibling;
expect(measuredValue?.textContent).toBe('0');
expect(measuredValue?.className).not.toContain('unknown');
});
it('benennt eine leere Tabelle, statt sie wegzulassen', async () => {

View File

@ -16,7 +16,18 @@
import { useCallback, useState } from 'react';
import { ApiError, downloadApiFile, requestApi } from '../../api/client';
import { useApiResource } from '../../api/useApiResource';
import { ErrorState, LoadingState } from '../../components/PageState';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Button,
Callout,
Card,
CardContent,
CardHeader,
ErrorState,
LoadingState,
} from '@/components/ui';
import { describeApiError } from '@/api/useMutation';
import { cn } from '@/lib/utils';
/** Art des Zeitbezugs eines Berichts. */
type PeriodKind = 'range' | 'point_in_time';
@ -182,61 +193,74 @@ export function ReportsPage(): React.JSX.Element {
);
if (catalogResource.loadState === 'loading') {
return <LoadingState what="die Berichte" />;
return (
<Card>
<LoadingState label="Die Berichte werden geladen …" />
</Card>
);
}
if (catalogResource.loadState === 'failed' || catalogResource.data === null) {
// loadError kann in diesem Zweig nur dann fehlen, wenn die Nutzlast leer
// blieb — dann steht der Katalog trotzdem nicht zur Verfuegung.
return catalogResource.loadError === null ? (
<p className="page__state">Der Berichtskatalog steht nicht zur Verfügung.</p>
) : (
<ErrorState error={catalogResource.loadError} onRetry={catalogResource.reload} />
return (
<Card>
<ErrorState
title="Der Berichtskatalog steht nicht zur Verfuegung."
message={
catalogResource.loadError ? describeApiError(catalogResource.loadError) : undefined
}
requestId={catalogResource.loadError?.requestId}
onRetry={catalogResource.reload}
/>
</Card>
);
}
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">Berichte</h1>
<p className="page__subtitle">
Neun Berichte in drei Formaten. Was in der Vorschau steht, steht auch in der Datei —
einschließlich der Angaben, die sich nicht messen ließen.
</p>
</header>
<>
<PageHeader
title="Berichte"
description="Neun Berichte in drei Formaten. Was in der Vorschau steht, steht auch in der Datei — einschliesslich der Angaben, die sich nicht messen liessen."
/>
<div className="reports__catalog">
<div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{catalogResource.data.map((catalogEntry) => (
<button
key={catalogEntry.type}
type="button"
className={
catalogEntry.type === selectedType
? 'reports__entry reports__entry--selected'
: 'reports__entry'
}
onClick={() => selectReport(catalogEntry.type)}
className={cn(
'rounded-lg border p-4 text-left transition-colors',
catalogEntry.type === selectedType
? 'border-accent bg-accent-subtle'
: 'border-line bg-card hover:bg-hover',
)}
>
<span className="reports__entry-title">{catalogEntry.title}</span>
<span className="reports__entry-description">{catalogEntry.description}</span>
<span className="reports__entry-period">
<span className="block font-medium text-fg">{catalogEntry.title}</span>
<span className="mt-1 block text-sm text-fg-muted">
{catalogEntry.description}
</span>
<span className="mt-2 block text-xs text-fg-subtle">
{/* Ein Zustandsbericht bekommt keinen Zeitraum. Die Belegung wird
nicht historisiert; "vom letzten Dienstag" kann es nicht geben. */}
{catalogEntry.period_kind === 'point_in_time'
? 'Zustandsbericht'
: `Zeitraum: ${catalogEntry.default_period ?? 'wählbar'}`}
: `Zeitraum: ${catalogEntry.default_period ?? 'waehlbar'}`}
</span>
</button>
))}
</div>
{selectedEntry !== null && (
{selectedEntry !== null ? (
<ReportPanel
catalogEntry={selectedEntry}
previewReport={previewReport}
exportState={exportState}
onExport={(outputFormat) => void generateReport(selectedEntry.type, outputFormat)}
/>
)}
</section>
) : null}
</>
);
}
@ -250,44 +274,47 @@ function ReportPanel(properties: {
const { catalogEntry, previewReport, exportState, onExport } = properties;
return (
<article className="reports__panel">
<header className="reports__panel-header">
<h2 className="reports__panel-title">{catalogEntry.title}</h2>
<div className="reports__actions">
<button
type="button"
className="button button--secondary"
<Card>
<CardHeader
title={catalogEntry.title}
action={
<div className="flex gap-2">
<Button
size="sm"
isLoading={exportState.runningFormat === 'csv'}
disabled={exportState.runningFormat !== null}
onClick={() => onExport('csv')}
>
{exportState.runningFormat === 'csv' ? 'CSV wird erzeugt …' : 'CSV herunterladen'}
</button>
<button
type="button"
className="button button--secondary"
CSV herunterladen
</Button>
<Button
size="sm"
isLoading={exportState.runningFormat === 'pdf'}
disabled={exportState.runningFormat !== null}
onClick={() => onExport('pdf')}
>
{exportState.runningFormat === 'pdf' ? 'PDF wird erzeugt …' : 'PDF herunterladen'}
</button>
PDF herunterladen
</Button>
</div>
</header>
{exportState.exportError !== null && (
<p className="reports__error" role="alert">
{exportState.exportError.message}
<span className="reports__request-id">
Vorgang {exportState.exportError.requestId}
</span>
}
/>
<CardContent>
{exportState.exportError !== null ? (
<Callout tone="critical" role="alert" className="mb-4">
<p>{describeApiError(exportState.exportError)}</p>
<p className="mt-1 font-mono text-xs">
Vorgangsnummer: {exportState.exportError.requestId}
</p>
)}
</Callout>
) : null}
{previewReport === null ? (
<LoadingState what="den Bericht" />
<LoadingState label="Der Bericht wird erzeugt …" />
) : (
<ReportPreview report={previewReport} />
)}
</article>
</CardContent>
</Card>
);
}
@ -300,44 +327,56 @@ function ReportPreview(properties: { readonly report: GeneratedReport }): React.
const isPointInTime = report.period_from === report.period_to;
return (
<div className="reports__preview">
<p className="reports__meta">
<div className="space-y-5">
<p className="text-xs text-fg-muted">
{isPointInTime
? `Zustand zum ${formatTimestamp(report.period_to)}`
: `Zeitraum ${formatTimestamp(report.period_from)} bis ${formatTimestamp(report.period_to)}`}
{' · '}
Erzeugt am {formatTimestamp(report.generated_at)}
{report.generated_by !== undefined && report.generated_by !== '' && ` von ${report.generated_by}`}
{report.generated_by !== undefined && report.generated_by !== ''
? ` von ${report.generated_by}`
: ''}
</p>
{report.sections.map((reportSection) => (
<section key={reportSection.title} className="reports__section">
<h3 className="reports__section-title">{reportSection.title}</h3>
{reportSection.description !== undefined && (
<p className="reports__section-description">{reportSection.description}</p>
)}
<section key={reportSection.title}>
<h3 className="text-sm font-semibold text-fg">{reportSection.title}</h3>
{reportSection.description !== undefined ? (
<p className="mt-0.5 text-sm text-fg-muted">{reportSection.description}</p>
) : null}
{reportSection.metrics !== undefined && reportSection.metrics.length > 0 && (
<dl className="reports__metrics">
{reportSection.metrics !== undefined && reportSection.metrics.length > 0 ? (
<dl className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{reportSection.metrics.map((reportMetric) => (
<div key={reportMetric.label} className="reports__metric">
<dt className="reports__metric-label">{reportMetric.label}</dt>
<dd
<div
key={reportMetric.label}
className="rounded-md border border-line bg-sunken p-3"
>
<dt className="text-xs text-fg-subtle">{reportMetric.label}</dt>
<dd className="mt-1">
{/* Eine ungemessene Kennzahl erscheint niemals als Zahl.
Eine erfundene Null ueberlebt in einem Pruefordner jede
muendliche Erlaeuterung. */}
<span
className={
reportMetric.is_known
? 'reports__metric-value'
: 'reports__metric-value reports__metric-value--unknown'
? 'text-lg font-semibold tabular text-fg'
: 'text-sm text-fg-muted'
}
>
{formatMetricValue(reportMetric)}
{!reportMetric.is_known && reportMetric.unknown_reason !== undefined && (
<span className="reports__metric-reason">{reportMetric.unknown_reason}</span>
)}
</span>
{!reportMetric.is_known && reportMetric.unknown_reason !== undefined ? (
<span className="mt-0.5 block text-xs text-fg-muted">
{reportMetric.unknown_reason}
</span>
) : null}
</dd>
</div>
))}
</dl>
)}
) : null}
{reportSection.tables?.map((reportTable) => (
<ReportTableView key={reportTable.title} table={reportTable} />
@ -345,16 +384,18 @@ function ReportPreview(properties: { readonly report: GeneratedReport }): React.
</section>
))}
{report.notes !== undefined && report.notes.length > 0 && (
<section className="reports__notes">
<h3 className="reports__section-title">Hinweise</h3>
<ul className="reports__note-list">
{report.notes !== undefined && report.notes.length > 0 ? (
<section className="border-t border-line pt-4">
<h3 className="text-sm font-semibold text-fg">Hinweise</h3>
<ul className="mt-2 space-y-1">
{report.notes.map((note) => (
<li key={note}>{note}</li>
<li key={note} className="text-sm text-fg-muted">
• {note}
</li>
))}
</ul>
</section>
)}
) : null}
</div>
);
}
@ -368,9 +409,11 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
if (table.rows.length === 0) {
return (
<div className="reports__table">
<h4 className="reports__table-title">{table.title}</h4>
<p className="reports__empty">{table.empty_notice ?? 'Keine Einträge.'}</p>
<div className="mt-4">
<h4 className="text-sm font-medium text-fg">{table.title}</h4>
<p className="mt-1 text-sm text-fg-muted">
{table.empty_notice ?? 'Keine Eintraege.'}
</p>
</div>
);
}
@ -378,14 +421,22 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
const visibleRows = table.rows.slice(0, previewRowLimit);
return (
<div className="reports__table">
<h4 className="reports__table-title">{table.title}</h4>
<div className="reports__table-scroll">
<table className="data-table">
<div className="mt-4">
<h4 className="text-sm font-medium text-fg">{table.title}</h4>
{/* Breite Tabellen laufen in ihrem eigenen Rahmen, nicht auf der Seite. */}
<div className="mt-2 overflow-x-auto rounded-md border border-line">
<table className="w-full border-collapse text-sm">
<thead>
<tr>
<tr className="border-b border-line">
{table.columns.map((columnLabel) => (
<th key={columnLabel}>{columnLabel}</th>
<th
key={columnLabel}
scope="col"
className="whitespace-nowrap px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-fg-subtle"
>
{columnLabel}
</th>
))}
</tr>
</thead>
@ -397,9 +448,11 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
*/}
<tbody>
{visibleRows.map((tableRow, rowIndex) => (
<tr key={rowIndex}>
<tr key={rowIndex} className="border-b border-line/60 last:border-0">
{tableRow.map((cellValue, cellIndex) => (
<td key={cellIndex}>{cellValue}</td>
<td key={cellIndex} className="px-3 py-2 align-top text-fg">
{cellValue}
</td>
))}
</tr>
))}
@ -407,9 +460,9 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
</table>
</div>
{table.rows.length > visibleRows.length && (
<p className="reports__truncation">
<p className="mt-1.5 text-xs text-fg-muted">
Die Vorschau zeigt {visibleRows.length} von {table.rows.length} Zeilen. Die
heruntergeladene Datei enthält alle.
heruntergeladene Datei enthaelt alle.
</p>
)}
</div>

View File

@ -11,9 +11,23 @@
*/
import { useCallback } from 'react';
import { requestApi } from '../../api/client';
import { useApiResource } from '../../api/useApiResource';
import { ErrorState, LoadingState } from '../../components/PageState';
import { requestApi } from '@/api/client';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Callout,
Card,
CardContent,
CardHeader,
DataTable,
EmptyState,
ErrorState,
LoadingState,
StatusBadge,
type TableColumn,
} from '@/components/ui';
import { cn } from '@/lib/utils';
/** Schweregrad eines Befunds. */
type FindingSeverity = 'information' | 'warning' | 'high' | 'critical';
@ -94,68 +108,111 @@ export function SecurityPage(): React.JSX.Element {
const { loadState, data, loadError, reload } = useApiResource<SecurityResponse>(loadAssessment);
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">Sicherheit</h1>
</header>
{loadState === 'loading' && <LoadingState what="Die Sicherheitslage" />}
{loadState === 'failed' && loadError !== null && (
<ErrorState error={loadError} onRetry={reload} />
)}
{loadState === 'loaded' && data !== null && (
<>
<PageHeader
title="Security Center"
description="Was geprueft wurde — und was nicht."
/>
{loadState === 'loading' ? (
<Card>
<LoadingState label="Die Sicherheitslage wird ermittelt …" />
</Card>
) : null}
{loadState === 'failed' && loadError !== null ? (
<Card>
<ErrorState
message={describeApiError(loadError)}
requestId={loadError.requestId}
onRetry={reload}
/>
</Card>
) : null}
{loadState === 'loaded' && data !== null ? (
<div className="space-y-4">
<ScoreHeader response={data} />
<FindingList assessment={data.assessment} />
<AreaTable assessment={data.assessment} />
</div>
) : null}
</>
)}
</section>
);
}
/** Eigenschaften der Gesamtbewertung. */
interface ScoreHeaderProperties {
/** Die Antwort des Security Center. */
readonly response: SecurityResponse;
}
/** Zeigt die Gesamtbewertung. */
function ScoreHeader({ response }: ScoreHeaderProperties): React.JSX.Element {
/**
* Zeigt die Gesamtbewertung.
*
* Neben der Prozentzahl steht immer die erreichbare Hoechstpunktzahl und die
* Liste der ungepruften Bereiche. Ein ungeprufter Bereich geht **nicht** in die
* Rechnung ein — ihn als bestanden zu werten waere Schoenfaerberei, als
* durchgefallen eine Behauptung.
*
* Ein kritischer Befund deckelt die Einstufung unabhaengig von der Zahl: 88 von
* 100 Punkten mit einem kritischen Befund ergeben trotzdem „unzureichend".
*/
function ScoreHeader({ response }: { readonly response: SecurityResponse }): React.JSX.Element {
const { assessment } = response;
const criticalCount = assessment.areas
.flatMap((areaResult) => areaResult.findings)
.filter((finding) => finding.severity === 'critical').length;
return (
<div className={`score-card score-card--${gradeClassOf(assessment)}`}>
<div className="score-card__figure">
<span className="score-card__value">{assessment.score}</span>
<span className="score-card__unit">%</span>
<Card>
<CardContent className="flex flex-wrap items-start gap-6">
<div>
<p
className={cn(
'text-4xl font-semibold tabular',
criticalCount > 0 ? 'text-critical' : 'text-fg',
)}
>
{assessment.score}
<span className="ml-1 text-lg text-fg-muted">%</span>
</p>
<p className="mt-1 text-sm font-medium text-fg">{assessment.grade}</p>
</div>
<div className="score-card__text">
<p className="score-card__grade">{assessment.grade}</p>
<p className="score-card__summary">{assessment.summary}</p>
<div className="min-w-0 flex-1 space-y-2">
<p className="text-sm text-fg">{assessment.summary}</p>
{criticalCount > 0 ? (
<Callout tone="critical">
{criticalCount} kritische{criticalCount === 1 ? 'r' : ''} Befund
{criticalCount === 1 ? '' : 'e'} deckelt die Einstufung auf
„unzureichend" — unabhaengig von der Punktzahl.
</Callout>
) : null}
{/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die
Handlungsanweisung, nicht nur eine Einschraenkung. */}
{assessment.unchecked_areas.length > 0 && (
<p className="score-card__unchecked">
{assessment.unchecked_areas.length > 0 ? (
<p className="text-xs text-fg-muted">
Nicht geprueft: {assessment.unchecked_areas.join(', ')}
{assessment.unchecked_areas.length >= 3
? ' — bei so vielen ungepruften Bereichen ist die Zahl eine Vermutung, keine Aussage.'
: ''}
</p>
)}
</div>
) : null}
</div>
</CardContent>
</Card>
);
}
/** Eigenschaften der Befundliste. */
interface FindingListProperties {
/** Die Sicherheitslage. */
/**
* Zeigt die Befunde nach Schweregrad geordnet.
*
* Jeder Befund traegt Schweregrad, Erklaerung, betroffenes Objekt und
* Empfehlung. Ein Befund ohne Empfehlung ist eine Beunruhigung: Er sagt, dass
* etwas nicht stimmt, und laesst den Betreiber damit allein.
*/
function FindingList({
assessment,
}: {
readonly assessment: SecurityAssessment;
}
/** Zeigt die Befunde nach Schweregrad geordnet. */
function FindingList({ assessment }: FindingListProperties): React.JSX.Element {
}): React.JSX.Element {
const allFindings = assessment.areas.flatMap((areaResult) => areaResult.findings);
const orderedFindings = [...allFindings].sort(
@ -165,130 +222,113 @@ function FindingList({ assessment }: FindingListProperties): React.JSX.Element {
if (orderedFindings.length === 0) {
return (
<p className="page__state page__state--empty">
Keine Befunde in den geprueften Bereichen.
</p>
<Card>
<EmptyState
title="Keine Befunde in den geprueften Bereichen"
description="Das gilt ausschliesslich fuer die geprueften Bereiche — die ungeprueften stehen oben."
/>
</Card>
);
}
return (
<ul className="finding-list">
<Card>
<CardHeader title={`Befunde (${orderedFindings.length})`} />
<CardContent>
<ul className="space-y-3">
{orderedFindings.map((finding) => (
<li className={`finding finding--${finding.severity}`} key={`${finding.area}-${finding.title}`}>
<div className="finding__head">
<span className={`badge badge--${severityClassOf(finding.severity)}`}>
{finding.severity}
</span>
<h2 className="finding__title">{finding.title}</h2>
<li
key={`${finding.area}-${finding.title}`}
className="rounded-md border border-line p-3"
>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={finding.severity} />
<h3 className="font-medium text-fg">{finding.title}</h3>
</div>
<p className="finding__explanation">{finding.explanation}</p>
<p className="mt-1.5 text-sm text-fg-muted">{finding.explanation}</p>
<dl className="finding__details">
<div className="finding__detail">
<dt>Betroffen</dt>
<dd>{finding.affected_object}</dd>
<dl className="mt-3 space-y-2 border-t border-line pt-2 text-sm">
<div className="flex gap-2">
<dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
Betroffen
</dt>
<dd className="min-w-0 break-all text-fg">{finding.affected_object}</dd>
</div>
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum der
Befund ueberhaupt angezeigt wird. */}
<div className="finding__detail finding__detail--recommendation">
<dt>Naechster Schritt</dt>
<dd>{finding.recommendation}</dd>
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum
der Befund ueberhaupt angezeigt wird. */}
<div className="flex gap-2">
<dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
Naechster Schritt
</dt>
<dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd>
</div>
</dl>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
/** Eigenschaften der Bereichsuebersicht. */
interface AreaTableProperties {
/** Die Sicherheitslage. */
readonly assessment: SecurityAssessment;
}
/** Zeigt die Ergebnisse je Pruefbereich. */
function AreaTable({ assessment }: AreaTableProperties): React.JSX.Element {
return (
<section className="area-overview">
<h2 className="area-overview__title">Geprueft wurde</h2>
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th scope="col">Bereich</th>
<th scope="col">Ergebnis</th>
<th scope="col">Punkte</th>
</tr>
</thead>
<tbody>
{assessment.areas.map((areaResult) => (
<tr key={areaResult.area}>
<td>{areaResult.title}</td>
<td>
{areaResult.available ? (
areaResult.summary
function AreaTable({
assessment,
}: {
readonly assessment: SecurityAssessment;
}): React.JSX.Element {
const areaColumns: ReadonlyArray<TableColumn<AreaResult>> = [
{
key: 'title',
header: 'Bereich',
render: (areaResult) => <span className="font-medium text-fg">{areaResult.title}</span>,
},
{
key: 'result',
header: 'Ergebnis',
render: (areaResult) =>
areaResult.available ? (
<span className="text-sm text-fg">{areaResult.summary}</span>
) : (
<>
<span className="badge badge--neutral">nicht pruefbar</span>
<span className="data-table__secondary">{areaResult.unavailable_reason}</span>
</>
)}
</td>
<td className="data-table__number">
{areaResult.available ? (
<div className="flex flex-col gap-1">
<StatusBadge status="neutral" label="nicht pruefbar" />
<span className="text-xs text-fg-muted">{areaResult.unavailable_reason}</span>
</div>
),
},
{
key: 'points',
header: 'Punkte',
numeric: true,
render: (areaResult) =>
areaResult.available ? (
`${areaResult.earned_points} / ${areaResult.weight}`
) : (
// Ein ungeprueft Bereich geht nicht in die Rechnung ein —
// weder positiv noch negativ.
<span className="data-table__unknown">zaehlt nicht</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
// Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder
// positiv noch negativ.
<span className="text-xs text-fg-subtle">zaehlt nicht</span>
),
},
];
return (
<Card>
<CardHeader
title="Geprueft wurde"
description={`Erreichbar sind ${assessment.maximum_score} Punkte.`}
/>
<DataTable
columns={areaColumns}
rows={assessment.areas}
getRowKey={(areaResult) => areaResult.area}
/>
</Card>
);
}
/** Bildet die Einstufung auf eine Statusfarbe ab. */
function gradeClassOf(assessment: SecurityAssessment): string {
if (assessment.critical_finding_count > 0) {
return 'critical';
}
if (assessment.score >= 90) {
return 'healthy';
}
if (assessment.score >= 70) {
return 'warning';
}
return 'high';
}
/** Bildet einen Schweregrad auf eine Statusfarbe ab. */
function severityClassOf(severity: FindingSeverity): string {
switch (severity) {
case 'critical':
return 'critical';
case 'high':
return 'high';
case 'warning':
return 'warning';
default:
return 'info';
}
}
/** Liefert die Ordnungszahl eines Schweregrads. */
function severityRankOf(severity: FindingSeverity): number {

View File

@ -3,7 +3,7 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles/tokens.css';
import './styles/theme.css';
const rootElement = document.getElementById('root');

View File

@ -1,122 +0,0 @@
/*
* Design-Token der Syncova-Oberflaeche.
*
* Grundsatz aus PROMPT.md §106: Semantische Farben ausschliesslich fuer Status.
* Alles andere bleibt neutral - eine bunte Oberflaeche verschleiert, welche
* Information wirklich dringend ist.
*
* Die Oberflaeche unterstuetzt Light, Dark und System (PROMPT.md §68).
*/
:root {
/* --- Statusfarben (nur fuer Zustaende verwenden) --- */
--color-status-healthy: #15803d;
--color-status-warning: #a16207;
--color-status-high: #c2410c;
--color-status-critical: #b91c1c;
--color-status-info: #1d4ed8;
--color-status-neutral: #52525b;
/* --- Flaechen und Text (neutral) --- */
--color-surface-page: #f8f8f7;
--color-surface-raised: #ffffff;
--color-border: #d9d9d6;
--color-text-primary: #1a1a19;
--color-text-secondary: #5c5c57;
/* --- Abstaende --- */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
/* --- Typografie --- */
--font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.5rem;
/* --- Sonstiges --- */
--radius: 8px;
--border-width: 1px;
}
/*
* Dunkles Farbschema.
*
* Die Statusfarben werden aufgehellt, damit der Kontrast auf dunklem Grund
* die Anforderungen aus PROMPT.md §107 erfuellt.
*/
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--color-status-healthy: #4ade80;
--color-status-warning: #fbbf24;
--color-status-high: #fb923c;
--color-status-critical: #f87171;
--color-status-info: #60a5fa;
--color-status-neutral: #a1a1aa;
--color-surface-page: #17171a;
--color-surface-raised: #202024;
--color-border: #35353b;
--color-text-primary: #f2f2f0;
--color-text-secondary: #a3a39e;
}
}
/* Ausdrueckliche Wahl des dunklen Schemas ueber die Einstellung. */
:root[data-theme='dark'] {
--color-status-healthy: #4ade80;
--color-status-warning: #fbbf24;
--color-status-high: #fb923c;
--color-status-critical: #f87171;
--color-status-info: #60a5fa;
--color-status-neutral: #a1a1aa;
--color-surface-page: #17171a;
--color-surface-raised: #202024;
--color-border: #35353b;
--color-text-primary: #f2f2f0;
--color-text-secondary: #a3a39e;
}
/* --- Grundlayout --- */
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: 1.5;
color: var(--color-text-primary);
background-color: var(--color-surface-page);
-webkit-font-smoothing: antialiased;
}
/*
* Deutlich sichtbarer Fokusrahmen fuer die Tastaturbedienung (PROMPT.md §107).
* Er wird bewusst nicht entfernt, auch wenn er optisch auffaellt.
*/
:focus-visible {
outline: 2px solid var(--color-status-info);
outline-offset: 2px;
}
/* Nutzer mit reduzierter Bewegungspraeferenz erhalten keine Animationen. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}