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 { LoginPage } from './features/auth/LoginPage';
import { AlertsPage } from './features/alerts/AlertsPage'; import { AlertsPage } from './features/alerts/AlertsPage';
import { DashboardPage } from './features/dashboard/DashboardPage'; import { DashboardPage } from './features/dashboard/DashboardPage';
import { SystemHealthPanel } from './features/health/SystemHealthPanel';
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage'; import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
import { RolesPage } from './features/administration/RolesPage'; import { RolesPage } from './features/administration/RolesPage';
import { SettingsPage } from './features/administration/SettingsPage'; 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 { useCurrentPage } from './navigation/useCurrentPage';
import type { CurrentUser } from './types/auth'; import type { CurrentUser } from './types/auth';
import './styles/theme.css'; 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. // Der API-Client erhaelt seinen Tokenzugriff einmalig beim Laden des Moduls.
setAccessTokenProvider(getAccessToken); setAccessTokenProvider(getAccessToken);
@ -142,7 +137,14 @@ function CurrentPageContent({
switch (pageDefinition.id) { switch (pageDefinition.id) {
case 'dashboard': case 'dashboard':
return <DashboardPage />; return (
<>
<DashboardPage />
<div className="mt-4">
<SystemHealthPanel />
</div>
</>
);
case 'jobs': case 'jobs':
return openObjectIdentifier ? ( return openObjectIdentifier ? (

View File

@ -137,16 +137,8 @@ export function useMutation<TArguments extends unknown[], TResult>(
}; };
} }
/** /** Bekannte Lagen samt Abhilfe. */
* Uebersetzt einen Serverfehler in einen Satz, den ein Betreiber versteht. const errorCodeExplanations: Readonly<Record<string, string>> = {
*
* 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>> = {
// Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des // Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des
// Anwenders — der Auftrag laeuft bereits. // Anwenders — der Auftrag laeuft bereits.
JOB_ALREADY_RUNNING: 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.', 'Das ist der letzte Administrator. Er laesst sich weder loeschen noch deaktivieren — sonst waere die Anlage nicht mehr verwaltbar.',
BUILTIN_ROLE_IMMUTABLE: BUILTIN_ROLE_IMMUTABLE:
'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.', 'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.',
REPOSITORY_IN_USE: REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.',
'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.',
REPOSITORY_LOCKED: REPOSITORY_LOCKED:
'Das Repository ist gesperrt — vermutlich laeuft eine Sicherung. Haelt der Zustand an, hilft "syncova-repo break-lock".', 'Das Repository ist gesperrt — vermutlich laeuft eine Sicherung. Haelt der Zustand an, hilft "syncova-repo break-lock".',
REPOSITORY_FULL: REPOSITORY_FULL:
@ -177,5 +168,37 @@ export function describeApiError(apiError: ApiError): string {
FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung fuer diese Handlung.', 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 title: ReactNode;
readonly description?: ReactNode | undefined; readonly description?: ReactNode | undefined;
readonly actions?: ReactNode | undefined; readonly actions?: ReactNode | undefined;
readonly onBack?: () => void | undefined; readonly onBack?: (() => void) | undefined;
readonly backLabel?: string | undefined; readonly backLabel?: string | undefined;
readonly className?: string | undefined; readonly className?: string | undefined;
}) { }) {

View File

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

View File

@ -32,7 +32,7 @@ export function ErrorState({
readonly title?: string | undefined; readonly title?: string | undefined;
readonly message?: string | undefined; readonly message?: string | undefined;
readonly requestId?: string | undefined; readonly requestId?: string | undefined;
readonly onRetry?: () => void | undefined; readonly onRetry?: (() => void) | undefined;
readonly className?: string | undefined; readonly className?: string | undefined;
}) { }) {
return ( return (
@ -105,11 +105,21 @@ export function Callout({
title, title,
children, children,
className, className,
role = 'note',
}: { }: {
readonly tone?: 'info' | 'warning' | 'critical' | 'healthy' | undefined; readonly tone?: 'info' | 'warning' | 'critical' | 'healthy' | undefined;
readonly title?: ReactNode | undefined; readonly title?: ReactNode | undefined;
readonly children?: ReactNode | undefined; readonly children?: ReactNode | undefined;
readonly className?: string | 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 = { const toneClasses = {
info: 'border-info/30 bg-info-bg text-info', 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; const IconComponent = tone === 'info' ? Info : tone === 'healthy' ? Info : AlertTriangle;
return ( 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"> <div className="flex gap-2.5">
<IconComponent className="mt-0.5 size-4 shrink-0" aria-hidden /> <IconComponent className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="min-w-0 text-sm"> <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' }, none: { tone: 'critical', label: 'Kein Schutz' },
// --- Gesundheit --- // --- Gesundheit ---
healthy: { tone: 'healthy', label: 'Gesund' }, healthy: { tone: 'healthy', label: 'Fehlerfrei' },
degraded: { tone: 'warning', label: 'Beeintraechtigt' }, degraded: { tone: 'warning', label: 'Eingeschraenkt' },
critical: { tone: 'critical', label: 'Kritisch' }, critical: { tone: 'critical', label: 'Kritisch' },
unknown: { tone: 'neutral', label: 'Unbekannt' }, unknown: { tone: 'neutral', label: 'Unbekannt' },
@ -105,7 +105,7 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
// --- Agenten --- // --- Agenten ---
online: { tone: 'healthy', label: 'Verbunden' }, online: { tone: 'healthy', label: 'Verbunden' },
offline: { tone: 'critical', label: 'Nicht verbunden' }, offline: { tone: 'critical', label: 'Nicht erreichbar' },
stale: { tone: 'warning', label: 'Veraltet' }, stale: { tone: 'warning', label: 'Veraltet' },
revoked: { tone: 'neutral', label: 'Gesperrt' }, revoked: { tone: 'neutral', label: 'Gesperrt' },

View File

@ -1,31 +1,46 @@
/** /**
* Meldungen. * Meldungen.
* *
* Die Seite, die in Phase 12 noch als „noch nicht verfuegbar" stand — mit der * Das Regelwerk steht mit auf der Seite, und das ist die tragende Entscheidung:
* Begruendung, eine leere Liste hiesse „keine Probleme" und wuerde bedeuten „es * **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiss,
* wird nicht geprueft". Jetzt gibt es die Pruefung, und die Seite sagt, worauf * was ueberhaupt geprueft wird.** Ohne die Liste bliebe offen, ob „keine
* geachtet wird. * Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprueft".
* *
* Das Regelwerk steht deshalb mit auf der Seite: Eine leere Meldungsliste ist * „Zur Kenntnis nehmen" schliesst eine Meldung nicht. Sie bleibt offen und in
* erst dann eine gute Nachricht, wenn man weiss, was ueberhaupt geprueft wird. * der Liste, bis ihre Ursache verschwindet — sonst verschwaende der Zustand aus
* der Uebersicht, obwohl er weiterbesteht.
*/ */
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useApiResource } from '../../api/useApiResource'; import { useApiResource } from '@/api/useApiResource';
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState'; 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 { import {
acknowledgeAlert, acknowledgeAlert,
fetchAlertOverview, fetchAlertOverview,
fetchAlerts, fetchAlerts,
resolveAlert, resolveAlert,
} from './alertsApi'; } from './alertsApi';
import type { Alert, AlertOverview, AlertSeverity } from './alertsApi'; import type { Alert, AlertOverview } from './alertsApi';
/** Zeigt die Meldungen. */
export function AlertsPage(): React.JSX.Element { export function AlertsPage(): React.JSX.Element {
const toast = useToast();
const [onlyActive, setOnlyActive] = useState(true); const [onlyActive, setOnlyActive] = useState(true);
const [actionCounter, setActionCounter] = useState(0); const [actionCounter, setActionCounter] = useState(0);
const [actionError, setActionError] = useState<string | null>(null);
const loadAlerts = useCallback( const loadAlerts = useCallback(
(abortSignal: AbortSignal) => fetchAlerts(onlyActive, abortSignal), (abortSignal: AbortSignal) => fetchAlerts(onlyActive, abortSignal),
@ -44,264 +59,250 @@ export function AlertsPage(): React.JSX.Element {
const overviewResource = useApiResource<AlertOverview>(loadOverview, String(actionCounter)); const overviewResource = useApiResource<AlertOverview>(loadOverview, String(actionCounter));
/** Fuehrt eine Handlung an einer Meldung aus und laedt neu. */ const acknowledgeMutation = useMutation(
async function runAlertAction(action: () => Promise<void>): Promise<void> { (alertIdentifier: string) => acknowledgeAlert(alertIdentifier, ''),
try { {
await action(); onSuccess: () => {
setActionError(null); toast.showSuccess(
'Zur Kenntnis genommen',
'Die Meldung bleibt offen, bis ihre Ursache verschwindet.',
);
setActionCounter((previousCounter) => previousCounter + 1); 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 ( return (
<section className="page"> <>
<header className="page__header"> <PageHeader
<h1 className="page__title">Meldungen</h1> title="Meldungen"
description={
{overviewResource.data !== null && ( overviewResource.data !== null
<span className="page__meta"> ? `${overviewResource.data.available_rule_count} von ${overviewResource.data.rules.length} Regeln werden geprueft.`
{overviewResource.data.available_rule_count} von {overviewResource.data.rules.length}{' '} : undefined
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.'
} }
/> />
)}
{loadState === 'loaded' && data !== null && data.length > 0 && ( {overviewResource.data !== null ? (
<ul className="alert-list"> <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) => ( {data.map((alert) => (
<AlertCard <AlertRow
key={alert.id} key={alert.id}
alert={alert} alert={alert}
onAcknowledge={() => void runAlertAction(() => acknowledgeAlert(alert.id, ''))} isBusy={acknowledgeMutation.isRunning || resolveMutation.isRunning}
onResolve={() => void runAlertAction(() => resolveAlert(alert.id, 'Von Hand geschlossen.'))} onAcknowledge={() => void acknowledgeMutation.run(alert.id)}
onResolve={() => void resolveMutation.run(alert.id)}
/> />
))} ))}
</ul> </ul>
)} ) : null}
</Card>
{overviewResource.data !== null && <RuleList overview={overviewResource.data} />} {overviewResource.data !== null ? <RuleList overview={overviewResource.data} /> : null}
</section> </>
); );
} }
/** Eigenschaften der Zusammenfassung. */
interface SummaryBarProperties {
/** Die Meldungslage samt Regelwerk. */
readonly overview: AlertOverview;
}
/** Zeigt die Meldungslage in Zahlen. */ /** 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 { 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 ( return (
<div className="summary-bar"> <div className="mb-4 grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
<SummaryFigure {figures.map((figure) => (
label="Kritisch" <Card key={figure.label} className="p-4">
value={summary.critical_count} <p
severity={summary.critical_count > 0 ? 'critical' : undefined} className={cn(
/> 'text-2xl font-semibold tabular',
<SummaryFigure // Farbe nur, wenn der Wert eine Aussage traegt: Null kritische
label="Ernst" // Meldungen sind neutral, nicht gruen — gruen hiesse "geprueft
value={summary.high_count} // und in Ordnung", und das sagt diese Zahl allein nicht.
severity={summary.high_count > 0 ? 'high' : undefined} figure.tone && figure.value > 0
/> ? figure.tone === 'critical'
<SummaryFigure label="Unbearbeitet" value={summary.open_count} /> ? 'text-critical'
<SummaryFigure label="Zur Kenntnis genommen" value={summary.acknowledged_count} /> : 'text-high'
<SummaryFigure label="Erledigt (24 h)" value={summary.resolved_last_day} /> : 'text-fg',
)}
>
{figure.value}
</p>
<p className="mt-0.5 text-xs text-fg-muted">{figure.label}</p>
</Card>
))}
</div> </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. */ /** 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'; const isResolved = alert.status === 'resolved';
return ( return (
<li className={`alert-card alert-card--${alert.severity}${isResolved ? ' alert-card--resolved' : ''}`}> <li className={cn('px-5 py-4', isResolved && 'opacity-60')}>
<div className="alert-card__head"> <div className="flex flex-wrap items-start justify-between gap-3">
<span className={`badge badge--${severityClassOf(alert.severity)}`}>{alert.severity}</span> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="alert-card__title">{alert.title}</h2> <StatusBadge status={alert.severity} />
<h3 className="min-w-0 font-medium text-fg">{alert.title}</h3>
{/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall, {/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall,
zwanzigmal ein Zustand. */} zwanzigmal ein Zustand. */}
{alert.occurrence_count > 1 && ( {alert.occurrence_count > 1 ? (
<span className="alert-card__count">{alert.occurrence_count}× aufgetreten</span> <span className="rounded bg-sunken px-1.5 py-0.5 text-xs text-fg-muted">
)} {alert.occurrence_count}× aufgetreten
</span>
) : null}
</div> </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"> <p className="mt-1.5 text-xs text-fg-muted">
Seit {formatTimestamp(alert.first_seen_at)} · zuletzt {formatTimestamp(alert.last_seen_at)} · Seit {formatDateTime(alert.first_seen_at)} · zuletzt{' '}
Regel {alert.rule_name} {formatDateTime(alert.last_seen_at)} · Regel {alert.rule_name}
</p> </p>
{alert.status === 'acknowledged' && ( {alert.status === 'acknowledged' ? (
<p className="alert-card__meta"> <p className="mt-1 text-xs text-fg-muted">
Zur Kenntnis genommen am {formatTimestamp(alert.acknowledged_at)}. Die Meldung bleibt Zur Kenntnis genommen am {formatDateTime(alert.acknowledged_at)}. Die
offen, bis ihre Ursache verschwindet. Meldung <strong>bleibt offen</strong>, bis ihre Ursache verschwindet.
</p> </p>
)} ) : null}
{isResolved && ( {isResolved ? (
<p className="alert-card__meta"> <p className="mt-1 text-xs text-fg-muted">
Erledigt am {formatTimestamp(alert.resolved_at)} Erledigt am {formatDateTime(alert.resolved_at)}
{alert.resolution_note !== undefined && alert.resolution_note !== '' {alert.resolution_note !== undefined && alert.resolution_note !== ''
? ` — ${alert.resolution_note}` ? ` — ${alert.resolution_note}`
: ''} : ''}
</p> </p>
)} ) : null}
</div>
{!isResolved && (
<div className="alert-card__actions"> {!isResolved ? (
{alert.status === 'open' && ( <div className="flex shrink-0 gap-2">
<button className="button button--secondary" type="button" onClick={onAcknowledge}> {alert.status === 'open' ? (
Zur Kenntnis nehmen <Button size="sm" disabled={isBusy} onClick={onAcknowledge}>
</button> Zur Kenntnis nehmen
)} </Button>
) : null}
<button className="button button--secondary" type="button" onClick={onResolve}> <Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}>
Schliessen Schliessen
</button> </Button>
</div>
) : null}
</div> </div>
)}
</li> </li>
); );
} }
/** Eigenschaften der Regelliste. */
interface RuleListProperties {
/** Die Meldungslage samt Regelwerk. */
readonly overview: AlertOverview;
}
/** /**
* Zeigt, worauf geachtet wird. * Zeigt, worauf geachtet wird.
* *
* Ohne diese Liste bliebe offen, ob eine leere Meldungsliste bedeutet „alles in * Eine Regel, die dauerhaft schweigt, ist gefaehrlicher als keine — sie erweckt
* Ordnung" oder „es wird nichts geprueft". * 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 ( return (
<section className="rule-list"> <Card>
<h2 className="rule-list__title">Worauf geachtet wird</h2> <CardHeader
title="Worauf geachtet wird"
<ul className="rule-list__items"> description="Erst mit dieser Liste ist eine leere Meldungsliste eine gute Nachricht."
/>
<CardContent>
<ul className="space-y-3">
{overview.rules.map((rule) => ( {overview.rules.map((rule) => (
<li <li
className={rule.available ? 'rule-list__item' : 'rule-list__item rule-list__item--unavailable'}
key={rule.name} key={rule.name}
className={cn(
'rounded-md border border-line p-3',
!rule.available && 'opacity-70',
)}
> >
<div className="rule-list__head"> <div className="flex flex-wrap items-center gap-2">
<span className="rule-list__name">{rule.title}</span> <span className="font-medium text-fg">{rule.title}</span>
{rule.available ? ( {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> </div>
<p className="mt-1 text-sm text-fg-muted">
<p className="rule-list__description">
{rule.available ? rule.description : rule.unavailable_reason} {rule.available ? rule.description : rule.unavailable_reason}
</p> </p>
</li> </li>
))} ))}
</ul> </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 { ApiError } from '../../api/client';
import { login, verifyMFA } from './authApi'; import { login, verifyMFA } from './authApi';
import type { CurrentUser } from '../../types/auth'; import type { CurrentUser } from '../../types/auth';
import './LoginPage.css';
/** Eigenschaften der Anmeldemaske. */ /** Eigenschaften der Anmeldemaske. */
export interface LoginPageProps { export interface LoginPageProps {
@ -106,20 +105,20 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
} }
return ( return (
<div className="login-page"> <div className="flex min-h-screen items-center justify-center bg-page p-4">
<main className="login-card"> <main className="w-full max-w-sm rounded-lg border border-line bg-card p-6 shadow-sm">
<h1 className="login-card__title">Syncova</h1> <h1 className="text-lg font-semibold text-fg">Syncova</h1>
{loginStep === 'credentials' ? ( {loginStep === 'credentials' ? (
<form className="login-form" onSubmit={handleCredentialsSubmit}> <form className="mt-5 space-y-4" onSubmit={handleCredentialsSubmit}>
<p className="login-card__subtitle">Bitte anmelden</p> <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 Benutzername
</label> </label>
<input <input
id="username" 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" type="text"
value={username} value={username}
onChange={(changeEvent) => setUsername(changeEvent.target.value)} onChange={(changeEvent) => setUsername(changeEvent.target.value)}
@ -128,12 +127,12 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
required required
/> />
<label className="login-form__label" htmlFor="password"> <label className="block text-sm font-medium text-fg" htmlFor="password">
Passwort Passwort
</label> </label>
<input <input
id="password" 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" type="password"
value={password} value={password}
onChange={(changeEvent) => setPassword(changeEvent.target.value)} onChange={(changeEvent) => setPassword(changeEvent.target.value)}
@ -142,29 +141,29 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
/> />
{errorMessage && ( {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} {errorMessage}
</p> </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'} {isSubmitting ? 'Anmeldung laeuft …' : 'Anmelden'}
</button> </button>
</form> </form>
) : ( ) : (
<form className="login-form" onSubmit={handleMFASubmit}> <form className="mt-5 space-y-4" onSubmit={handleMFASubmit}>
<p className="login-card__subtitle">Zweiter Faktor</p> <p className="mt-1 text-sm text-fg-muted">Zweiter Faktor</p>
<p className="login-form__hint"> <p className="mt-1.5 text-xs text-fg-muted">
Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein
Wiederherstellungscode moeglich. Wiederherstellungscode moeglich.
</p> </p>
<label className="login-form__label" htmlFor="mfa-code"> <label className="block text-sm font-medium text-fg" htmlFor="mfa-code">
Code Code
</label> </label>
<input <input
id="mfa-code" 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" type="text"
value={mfaCode} value={mfaCode}
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)} onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
@ -176,17 +175,17 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
/> />
{errorMessage && ( {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} {errorMessage}
</p> </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'} {isSubmitting ? 'Pruefung laeuft …' : 'Bestaetigen'}
</button> </button>
<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" type="button"
onClick={() => { onClick={() => {
setLoginStep('credentials'); setLoginStep('credentials');

View File

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

View File

@ -4,11 +4,27 @@
* Die zentrale Auskunft der Anlage. Sie beantwortet nicht „welche Backups gibt * Die zentrale Auskunft der Anlage. Sie beantwortet nicht „welche Backups gibt
* es", sondern „auf welche kann ich mich verlassen" — deshalb steht die * es", sondern „auf welche kann ich mich verlassen" — deshalb steht die
* Einstufung in jeder Zeile und nicht in einem Detailfenster. * 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 { useCallback, useState } from 'react';
import { useApiResource } from '../../api/useApiResource'; import { useApiResource } from '@/api/useApiResource';
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState'; 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 { fetchRecoveryPoints } from './dashboardApi';
import type { RecoveryPoint } from './dashboardApi'; import type { RecoveryPoint } from './dashboardApi';
@ -21,7 +37,6 @@ const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label:
{ value: 'corrupted', label: 'Beschaedigt' }, { value: 'corrupted', label: 'Beschaedigt' },
]; ];
/** Zeigt die Wiederherstellungspunkte. */
export function RecoveryPointsPage({ export function RecoveryPointsPage({
onOpenBackup, onOpenBackup,
}: { }: {
@ -37,7 +52,10 @@ export function RecoveryPointsPage({
const loadRecoveryPoints = useCallback( const loadRecoveryPoints = useCallback(
(abortSignal: AbortSignal) => (abortSignal: AbortSignal) =>
fetchRecoveryPoints({ classification: classificationFilter, includeDeleted, onlyProtected }, abortSignal), fetchRecoveryPoints(
{ classification: classificationFilter, includeDeleted, onlyProtected },
abortSignal,
),
[classificationFilter, includeDeleted, onlyProtected], [classificationFilter, includeDeleted, onlyProtected],
); );
@ -46,222 +64,150 @@ export function RecoveryPointsPage({
filterKey, 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 ( return (
<section className="page"> <>
<header className="page__header"> <PageHeader
<h1 className="page__title">Wiederherstellungspunkte</h1> 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>} <Card>
</header> <div className="flex flex-wrap items-end gap-4 border-b border-line px-5 py-3">
<SelectInput
<div className="filter-bar"> label="Einstufung"
<label className="filter-bar__field"> className="w-64"
<span className="filter-bar__label">Einstufung</span>
<select
className="filter-bar__select"
value={classificationFilter} value={classificationFilter}
onChange={(changeEvent) => setClassificationFilter(changeEvent.target.value)} onChange={(changeEvent) => setClassificationFilter(changeEvent.target.value)}
> >
{CLASSIFICATION_OPTIONS.map((filterOption) => ( {CLASSIFICATION_OPTIONS.map((option) => (
<option key={filterOption.value} value={filterOption.value}> <option key={option.value} value={option.value}>
{filterOption.label} {option.label}
</option> </option>
))} ))}
</select> </SelectInput>
</label>
<label className="filter-bar__checkbox"> <div className="flex flex-wrap gap-4 pb-2">
<input <CheckboxField
type="checkbox"
checked={onlyProtected}
onChange={(changeEvent) => setOnlyProtected(changeEvent.target.checked)}
/>
Nur geschuetzte
</label>
{/* Geloeschte Punkte erscheinen nur auf Nachfrage — die Frage „warum ist
das Backup von vorletzter Woche weg?" muss aber beantwortbar sein. */}
<label className="filter-bar__checkbox">
<input
type="checkbox"
checked={includeDeleted} checked={includeDeleted}
onChange={(changeEvent) => setIncludeDeleted(changeEvent.target.checked)} onCheckedChange={setIncludeDeleted}
label="Geloeschte einschliessen"
/> />
Geloeschte einbeziehen <CheckboxField
</label> checked={onlyProtected}
onCheckedChange={setOnlyProtected}
label="Nur geschuetzte"
/>
</div>
</div> </div>
{loadState === 'loading' && <LoadingState what="Die Wiederherstellungspunkte" />} {loadState === 'failed' && loadError !== null ? (
{loadState === 'failed' && loadError !== null && ( <ErrorState
<ErrorState error={loadError} onRetry={reload} /> message={describeApiError(loadError)}
)} requestId={loadError.requestId}
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}
/> />
))} ) : (
</tbody> <DataTable
</table> columns={tableColumns}
</div> rows={data ?? []}
)} getRowKey={(recoveryPoint) => recoveryPoint.id}
</section> onRowClick={
); onOpenBackup
} ? (recoveryPoint) => onOpenBackup(recoveryPoint.id)
/** 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);
}
}
: undefined : undefined
} }
> isLoading={loadState === 'loading'}
<td> emptyTitle={
{formatTimestamp(recoveryPoint.completed_at)} classificationFilter || onlyProtected
<span className="data-table__secondary">{recoveryPoint.repository_name}</span> ? 'Kein Punkt passt zum Filter'
</td> : 'Noch kein Wiederherstellungspunkt'
}
<td>{recoveryPoint.job_name ?? '—'}</td> emptyDescription={
classificationFilter || onlyProtected
<td>{recoveryPoint.backup_type === 'full' ? 'Voll' : 'Zusatz'}</td> ? 'Lockern Sie den Filter, um mehr zu sehen.'
: 'Sobald ein Sicherungsauftrag durchgelaufen ist, erscheint hier ein Punkt.'
<td className="data-table__number">{formatBytes(recoveryPoint.logical_bytes)}</td> }
/>
<td>
<ClassificationBadge classification={recoveryPoint.classification} />
</td>
<td className="data-table__number">
{/* Keine Bewertung heisst nicht null Prozent. Die Unterscheidung ist der
Kern der Recovery Assurance (Phase 10). */}
{recoveryPoint.assurance_score === undefined ? (
<span className="data-table__unknown">nicht berechnet</span>
) : (
`${recoveryPoint.assurance_score} %`
)} )}
</td> </Card>
</>
<td>
{isDeleted ? (
<span className="badge badge--neutral" title={recoveryPoint.deletion_reason}>
geloescht
</span>
) : recoveryPoint.legal_hold ? (
<span className="badge badge--high">Legal Hold</span>
) : recoveryPoint.is_protected ? (
<span className="badge badge--info" title={formatTimestamp(recoveryPoint.immutable_until)}>
geschuetzt
</span>
) : (
<span className="data-table__unknown">frei</span>
)}
</td>
</tr>
); );
} }
/** Eigenschaften der Einstufungsanzeige. */
interface ClassificationBadgeProperties {
/** Die Einstufung; fehlt bei ungeprueften Punkten. */
readonly classification: string | undefined;
}
/** Zeigt die Einstufung eines Punktes. */
function ClassificationBadge({ classification }: ClassificationBadgeProperties): React.JSX.Element {
switch (classification) {
case 'recoverable':
return <span className="badge badge--healthy">wiederherstellbar</span>;
case 'verified':
return <span className="badge badge--info">geprueft</span>;
case 'corrupted':
return <span className="badge badge--critical">beschaedigt</span>;
case 'failed':
return <span className="badge badge--critical">gescheitert</span>;
default:
// „Ungeprueft" ist eine Aussage, keine Luecke: Der Punkt wurde nie
// zurueckgeschrieben, und das soll man sehen.
return <span className="badge badge--warning">ungeprueft</span>;
}
}
/** Schreibt einen Zeitstempel in deutscher Schreibweise. */
function formatTimestamp(isoTimestamp: string | undefined): string {
if (isoTimestamp === undefined) {
return '—';
}
return new Date(isoTimestamp).toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
});
}
/** Faktor zwischen zwei Groesseneinheiten. */
const BYTE_UNIT_STEP = 1024;
/** Schreibt eine Datenmenge lesbar. */
function formatBytes(byteCount: number): string {
if (byteCount < BYTE_UNIT_STEP) {
return `${byteCount} B`;
}
const unitNames = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
let remainingValue = byteCount;
let chosenUnit = unitNames[0];
for (const unitName of unitNames) {
remainingValue /= BYTE_UNIT_STEP;
chosenUnit = unitName;
if (remainingValue < BYTE_UNIT_STEP) {
break;
}
}
return `${remainingValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${chosenUnit}`;
}

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. * Anzeige des Systemzustands.
* *
* Die Darstellung folgt PROMPT.md §48/§124: statt eines rohen Fehlercodes * 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 { useSystemHealth } from './useSystemHealth';
import './SystemHealthPanel.css';
/** Abstand der automatischen Aktualisierung in Millisekunden. */ /** Abstand der automatischen Aktualisierung in Millisekunden. */
const REFRESH_INTERVAL_MS = 15_000; 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 { export function SystemHealthPanel(): React.JSX.Element {
const { loadState, systemHealth, loadError, reload } = useSystemHealth(REFRESH_INTERVAL_MS); 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') { if (loadState === 'loading') {
return ( return (
<section className="health-panel" aria-busy="true"> <Card aria-busy="true">
<h2 className="health-panel__title">Systemzustand</h2> <CardHeader title="Systemzustand" />
<p className="health-panel__hint">Der Systemzustand wird ermittelt …</p> <LoadingState label="Der Systemzustand wird ermittelt …" />
</section> </Card>
); );
} }
if (loadState === 'failed' || systemHealth === null) { if (loadState === 'failed' || systemHealth === null) {
return ( return (
<section className="health-panel"> <Card>
<h2 className="health-panel__title">Systemzustand</h2> <CardHeader title="Systemzustand" />
<ErrorState
{/* role="alert" sorgt dafuer, dass Screenreader den Fehler ansagen. */} title="Der Systemzustand konnte nicht ermittelt werden."
<div className="health-panel__error" role="alert"> message={loadError ? describeApiError(loadError) : undefined}
<p className="health-panel__error-message"> requestId={loadError && loadError.requestId !== '' ? loadError.requestId : undefined}
{loadError?.message ?? 'Der Systemzustand konnte nicht ermittelt werden.'} onRetry={reload}
</p> />
</Card>
{/* 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>
); );
} }
const componentEntries = Object.entries(systemHealth.components); const componentRows = buildRows();
return ( return (
<section className="health-panel"> <Card>
<h2 className="health-panel__title">Systemzustand</h2> <CardHeader
title="Systemzustand"
<div className="health-panel__overall"> description={`Version ${systemHealth.version} · Umgebung ${systemHealth.environment}`}
<StatusIndicator status={systemHealth.status} /> action={
<span className="health-panel__version"> <div className="flex items-center gap-2">
Version {systemHealth.version} · Umgebung {systemHealth.environment} <StatusBadge status={systemHealth.status} />
</span> <Button variant="ghost" size="sm" onClick={reload}>
Aktualisieren
</Button>
</div> </div>
}
/>
{componentEntries.length === 0 ? ( {componentRows.length === 0 ? (
<p className="health-panel__hint">Es sind keine Komponenten registriert.</p> <CardContent>
<p className="text-sm text-fg-muted">Es sind keine Komponenten registriert.</p>
</CardContent>
) : ( ) : (
<table className="health-panel__table"> <DataTable
<caption className="health-panel__caption">Zustand der einzelnen Komponenten</caption> columns={componentColumns}
<thead> rows={componentRows}
<tr> getRowKey={(component) => component.name}
<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>
)} )}
</td> </Card>
<td className="health-panel__latency">{componentHealth.latency_ms.toFixed(1)} ms</td>
</tr>
))}
</tbody>
</table>
)}
</section>
); );
} }

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, WIZARD_STEPS,
} from './wizardModel'; } from './wizardModel';
import type { DraftSource, JobDraft, WizardStep } from './wizardModel'; import type { DraftSource, JobDraft, WizardStep } from './wizardModel';
import './BackupWizard.css';
/** Eigenschaften des Assistenten. */ /** Eigenschaften des Assistenten. */
export interface BackupWizardProps { export interface BackupWizardProps {
@ -132,7 +131,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
return ( return (
<section className="wizard" aria-label="Backup-Assistent"> <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) => ( {WIZARD_STEPS.map((wizardStep, stepIndex) => (
<li key={wizardStep}> <li key={wizardStep}>
<button <button
@ -142,48 +141,48 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
disabled={stepIndex > currentStepIndex} disabled={stepIndex > currentStepIndex}
aria-current={stepIndex === currentStepIndex ? 'step' : undefined} aria-current={stepIndex === currentStepIndex ? 'step' : undefined}
> >
<span className="wizard__step-number">{stepIndex + 1}</span> <span className="tabular">{stepIndex + 1}</span>
<span className="wizard__step-title">{STEP_TITLES[wizardStep]}</span> <span className="">{STEP_TITLES[wizardStep]}</span>
</button> </button>
</li> </li>
))} ))}
</ol> </ol>
<div className="wizard__panel"> <div className="px-5 py-4">
<h2 className="wizard__heading"> <h2 className="text-base font-semibold text-fg">
Schritt {currentStepIndex + 1} von {WIZARD_STEPS.length}: {STEP_TITLES[currentStep]} Schritt {currentStepIndex + 1} von {WIZARD_STEPS.length}: {STEP_TITLES[currentStep]}
</h2> </h2>
{renderStepContent()} {renderStepContent()}
{showProblems && stepValidation.problems.length > 0 && ( {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) => ( {stepValidation.problems.map((problemText) => (
<li key={problemText}>{problemText}</li> <li key={problemText}>{problemText}</li>
))} ))}
</ul> </ul>
)} )}
<div className="wizard__actions"> <div className="flex flex-wrap items-center justify-between gap-2 border-t border-line px-5 py-3">
<button type="button" className="wizard__button" onClick={onCancel}> <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 Abbrechen
</button> </button>
<div className="wizard__navigation"> <div className="flex gap-2">
{currentStepIndex > 0 && ( {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 Zurueck
</button> </button>
)} )}
{currentStep !== 'create' ? ( {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 Weiter
</button> </button>
) : ( ) : (
<button <button
type="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()} onClick={() => void handleCreate()}
disabled={isSubmitting} disabled={isSubmitting}
> >
@ -234,9 +233,9 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
*/ */
function renderUnavailableStep(explanationText: string): React.JSX.Element { function renderUnavailableStep(explanationText: string): React.JSX.Element {
return ( return (
<div className="wizard__unavailable"> <div className="rounded-md border border-line bg-sunken p-4">
<p className="wizard__unavailable-badge">Noch nicht verfuegbar</p> <p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">Noch nicht verfuegbar</p>
<p className="wizard__unavailable-text">{explanationText}</p> <p className="mt-1 text-sm text-fg-muted">{explanationText}</p>
</div> </div>
); );
} }
@ -244,11 +243,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 1: Name und Beschreibung. */ /** Schritt 1: Name und Beschreibung. */
function renderNameStep(): React.JSX.Element { function renderNameStep(): React.JSX.Element {
return ( return (
<div className="wizard__fields"> <div className="mt-4 space-y-4">
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Name des Auftrags</span> <span className="block text-sm font-medium text-fg">Name des Auftrags</span>
<input <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" type="text"
value={jobDraft.name} value={jobDraft.name}
onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })} onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })}
@ -257,10 +256,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/> />
</label> </label>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Beschreibung (optional)</span> <span className="block text-sm font-medium text-fg">Beschreibung (optional)</span>
<textarea <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} value={jobDraft.description}
onChange={(changeEvent) => updateDraft({ description: changeEvent.target.value })} onChange={(changeEvent) => updateDraft({ description: changeEvent.target.value })}
rows={3} rows={3}
@ -282,15 +281,15 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
} }
return ( return (
<div className="wizard__fields"> <div className="mt-4 space-y-4">
{jobDraft.sources.map((draftSource, sourceIndex) => ( {jobDraft.sources.map((draftSource, sourceIndex) => (
<fieldset key={sourceIndex} className="wizard__source"> <fieldset key={sourceIndex} className="flex items-center justify-between gap-2 rounded-md border border-line px-3 py-2 text-sm">
<legend className="wizard__label">Quelle {sourceIndex + 1}</legend> <legend className="block text-sm font-medium text-fg">Quelle {sourceIndex + 1}</legend>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Art</span> <span className="block text-sm font-medium text-fg">Art</span>
<select <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} value={draftSource.sourceType}
onChange={(changeEvent) => onChange={(changeEvent) =>
updateSource(sourceIndex, { sourceType: changeEvent.target.value as SourceType }) updateSource(sourceIndex, { sourceType: changeEvent.target.value as SourceType })
@ -309,10 +308,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</select> </select>
</label> </label>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Pfad</span> <span className="block text-sm font-medium text-fg">Pfad</span>
<input <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" type="text"
value={draftSource.sourceIdentifier} value={draftSource.sourceIdentifier}
onChange={(changeEvent) => onChange={(changeEvent) =>
@ -322,20 +321,20 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/> />
</label> </label>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Bezeichnung (optional)</span> <span className="block text-sm font-medium text-fg">Bezeichnung (optional)</span>
<input <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" type="text"
value={draftSource.sourceName} value={draftSource.sourceName}
onChange={(changeEvent) => updateSource(sourceIndex, { sourceName: changeEvent.target.value })} onChange={(changeEvent) => updateSource(sourceIndex, { sourceName: changeEvent.target.value })}
/> />
</label> </label>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Auszuschliessen (kommasepariert, optional)</span> <span className="block text-sm font-medium text-fg">Auszuschliessen (kommasepariert, optional)</span>
<input <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" type="text"
value={draftSource.excludePatterns} value={draftSource.excludePatterns}
onChange={(changeEvent) => onChange={(changeEvent) =>
@ -348,7 +347,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
{jobDraft.sources.length > 1 && ( {jobDraft.sources.length > 1 && (
<button <button
type="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={() => onClick={() =>
updateDraft({ updateDraft({
sources: jobDraft.sources.filter((_, currentIndex) => currentIndex !== sourceIndex), sources: jobDraft.sources.filter((_, currentIndex) => currentIndex !== sourceIndex),
@ -363,7 +362,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<button <button
type="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={() => onClick={() =>
updateDraft({ updateDraft({
sources: [ sources: [
@ -382,11 +381,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 3: Zeitplan. */ /** Schritt 3: Zeitplan. */
function renderScheduleStep(): React.JSX.Element { function renderScheduleStep(): React.JSX.Element {
return ( return (
<div className="wizard__fields"> <div className="mt-4 space-y-4">
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Art des Zeitplans</span> <span className="block text-sm font-medium text-fg">Art des Zeitplans</span>
<select <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} value={jobDraft.scheduleType}
onChange={(changeEvent) => onChange={(changeEvent) =>
updateDraft({ scheduleType: changeEvent.target.value as JobDraft['scheduleType'] }) updateDraft({ scheduleType: changeEvent.target.value as JobDraft['scheduleType'] })
@ -403,10 +402,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</label> </label>
{jobDraft.scheduleType === 'interval' && ( {jobDraft.scheduleType === 'interval' && (
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Abstand in Stunden</span> <span className="block text-sm font-medium text-fg">Abstand in Stunden</span>
<input <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" type="number"
min={1} min={1}
value={jobDraft.scheduleIntervalHours} value={jobDraft.scheduleIntervalHours}
@ -419,12 +418,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' && {jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' &&
jobDraft.scheduleType !== 'cron' && ( jobDraft.scheduleType !== 'cron' && (
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label"> <span className="block text-sm font-medium text-fg">
{jobDraft.scheduleType === 'hourly' ? 'Minute (aus HH:MM)' : 'Uhrzeit'} {jobDraft.scheduleType === 'hourly' ? 'Minute (aus HH:MM)' : 'Uhrzeit'}
</span> </span>
<input <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" type="time"
value={jobDraft.scheduleTime} value={jobDraft.scheduleTime}
onChange={(changeEvent) => updateDraft({ scheduleTime: changeEvent.target.value })} onChange={(changeEvent) => updateDraft({ scheduleTime: changeEvent.target.value })}
@ -433,11 +432,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
)} )}
{jobDraft.scheduleType === 'weekly' && ( {jobDraft.scheduleType === 'weekly' && (
<fieldset className="wizard__field"> <fieldset className="block space-y-1.5">
<legend className="wizard__label">Wochentage</legend> <legend className="block text-sm font-medium text-fg">Wochentage</legend>
<div className="wizard__checkbox-row"> <div className="flex flex-wrap gap-4">
{WEEKDAY_NAMES.map((weekdayName, weekdayNumber) => ( {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 <input
type="checkbox" type="checkbox"
checked={jobDraft.scheduleWeekdays.includes(weekdayNumber)} checked={jobDraft.scheduleWeekdays.includes(weekdayNumber)}
@ -457,11 +456,11 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
)} )}
{jobDraft.scheduleType === 'monthly' && ( {jobDraft.scheduleType === 'monthly' && (
<fieldset className="wizard__field"> <fieldset className="block space-y-1.5">
<legend className="wizard__label">Tage des Monats</legend> <legend className="block text-sm font-medium text-fg">Tage des Monats</legend>
<div className="wizard__checkbox-row"> <div className="flex flex-wrap gap-4">
{[1, 15, -1].map((monthDay) => ( {[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 <input
type="checkbox" type="checkbox"
checked={jobDraft.scheduleMonthDays.includes(monthDay)} checked={jobDraft.scheduleMonthDays.includes(monthDay)}
@ -479,44 +478,44 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</div> </div>
{/* Der Monatsletzte ist kein Luxus: Wer "am 31." waehlt, bekommt in {/* Der Monatsletzte ist kein Luxus: Wer "am 31." waehlt, bekommt in
vier Monaten des Jahres keine Sicherung. */} 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. Der letzte Tag des Monats faellt nie aus - anders als etwa der 31.
</p> </p>
</fieldset> </fieldset>
)} )}
{jobDraft.scheduleType === 'cron' && ( {jobDraft.scheduleType === 'cron' && (
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Cron-Ausdruck</span> <span className="block text-sm font-medium text-fg">Cron-Ausdruck</span>
<input <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" type="text"
value={jobDraft.scheduleCronExpression} value={jobDraft.scheduleCronExpression}
onChange={(changeEvent) => updateDraft({ scheduleCronExpression: changeEvent.target.value })} onChange={(changeEvent) => updateDraft({ scheduleCronExpression: changeEvent.target.value })}
placeholder="0 2 * * *" 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> </label>
)} )}
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' && ( {jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' && (
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Zeitzone</span> <span className="block text-sm font-medium text-fg">Zeitzone</span>
<input <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" type="text"
value={jobDraft.scheduleTimeZone} value={jobDraft.scheduleTimeZone}
onChange={(changeEvent) => updateDraft({ scheduleTimeZone: changeEvent.target.value })} onChange={(changeEvent) => updateDraft({ scheduleTimeZone: changeEvent.target.value })}
/> />
{/* Ohne Zeitzone gilt auf dem Server UTC - derselbe Auftrag liefe {/* Ohne Zeitzone gilt auf dem Server UTC - derselbe Auftrag liefe
dann je nach Standort zu einer anderen Uhrzeit. */} 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. Ohne Angabe rechnet der Server in UTC.
</span> </span>
</label> </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> </div>
); );
} }
@ -524,12 +523,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 4: Repository. */ /** Schritt 4: Repository. */
function renderRepositoryStep(): React.JSX.Element { function renderRepositoryStep(): React.JSX.Element {
if (repositoryError !== null) { 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) { if (repositories.length === 0) {
return ( return (
<p className="wizard__hint"> <p className="text-xs text-fg-muted">
Es ist kein Sicherungsziel eingerichtet. Ein Repository entsteht auf einem Datentraeger und 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. wird mit <code>syncova-repo create --path &lt;pfad&gt; --name &lt;name&gt;</code> angelegt.
</p> </p>
@ -537,9 +536,9 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
} }
return ( return (
<div className="wizard__fields"> <div className="mt-4 space-y-4">
{repositories.map((repositoryEntry) => ( {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 <input
type="radio" type="radio"
name="repository" name="repository"
@ -549,15 +548,15 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
onChange={() => updateDraft({ repositoryId: repositoryEntry.id })} onChange={() => updateDraft({ repositoryId: repositoryEntry.id })}
/> />
<span className="wizard__radio-body"> <span className="flex min-w-0 flex-col">
<span className="wizard__radio-title">{repositoryEntry.name}</span> <span className="font-medium text-fg">{repositoryEntry.name}</span>
<span className="wizard__radio-detail">{repositoryEntry.location}</span> <span className="font-mono text-xs text-fg-muted">{repositoryEntry.location}</span>
{/* Ein Ziel, das keine Sicherungen annimmt, wird benannt statt {/* Ein Ziel, das keine Sicherungen annimmt, wird benannt statt
stillschweigend weggelassen: Sonst suchte der Anwender ein stillschweigend weggelassen: Sonst suchte der Anwender ein
Repository, das er sieht und nicht waehlen kann. */} Repository, das er sieht und nicht waehlen kann. */}
{!repositoryEntry.accepts_backups && ( {!repositoryEntry.accepts_backups && (
<span className="wizard__radio-warning"> <span className="text-xs text-warning">
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
</span> </span>
)} )}
@ -571,20 +570,20 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 6: Sicherheit. */ /** Schritt 6: Sicherheit. */
function renderSecurityStep(): React.JSX.Element { function renderSecurityStep(): React.JSX.Element {
return ( return (
<div className="wizard__fields"> <div className="mt-4 space-y-4">
{/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst {/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst
ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere
eine Einstellung, die es nicht gibt. */} 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 <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 verschluesselt, bevor sie das System verlassen. Der Schluessel gehoert zum Repository und
wird vom Dienst verwaltet. wird vom Dienst verwaltet.
</div> </div>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Dringlichkeit</span> <span className="block text-sm font-medium text-fg">Dringlichkeit</span>
<select <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} value={jobDraft.priority}
onChange={(changeEvent) => onChange={(changeEvent) =>
updateDraft({ priority: changeEvent.target.value as JobDraft['priority'] }) 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="normal">Normal</option>
<option value="low">Niedrig</option> <option value="low">Niedrig</option>
</select> </select>
<span className="wizard__hint"> <span className="text-xs text-fg-muted">
Entscheidet die Reihenfolge, wenn mehrere Auftraege gleichzeitig faellig sind. Entscheidet die Reihenfolge, wenn mehrere Auftraege gleichzeitig faellig sind.
</span> </span>
</label> </label>
<label className="wizard__field"> <label className="block space-y-1.5">
<span className="wizard__label">Bandbreitengrenze (optional)</span> <span className="block text-sm font-medium text-fg">Bandbreitengrenze (optional)</span>
<input <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" type="text"
value={jobDraft.bandwidthLimit} value={jobDraft.bandwidthLimit}
onChange={(changeEvent) => updateDraft({ bandwidthLimit: changeEvent.target.value })} onChange={(changeEvent) => updateDraft({ bandwidthLimit: changeEvent.target.value })}
placeholder="50MB" 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. Begrenzt das Lesen von der Quelle. 100Mbit ist ein Achtel von 100MB.
</span> </span>
</label> </label>
@ -624,7 +623,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
); );
return ( return (
<dl className="wizard__summary"> <dl className="mt-4 space-y-3">
<dt>Name</dt> <dt>Name</dt>
<dd>{jobDraft.name}</dd> <dd>{jobDraft.name}</dd>
@ -637,14 +636,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<dt>Quellen</dt> <dt>Quellen</dt>
<dd> <dd>
<ul className="wizard__summary-list"> <ul className="space-y-2">
{jobDraft.sources {jobDraft.sources
.filter((draftSource) => draftSource.sourceIdentifier.trim() !== '') .filter((draftSource) => draftSource.sourceIdentifier.trim() !== '')
.map((draftSource, sourceIndex) => ( .map((draftSource, sourceIndex) => (
<li key={sourceIndex}> <li key={sourceIndex}>
{draftSource.sourceIdentifier} {draftSource.sourceIdentifier}
{splitPatternList(draftSource.excludePatterns).length > 0 && ( {splitPatternList(draftSource.excludePatterns).length > 0 && (
<span className="wizard__summary-detail"> <span className="text-sm text-fg">
{' '} {' '}
ohne {splitPatternList(draftSource.excludePatterns).join(', ')} ohne {splitPatternList(draftSource.excludePatterns).join(', ')}
</span> </span>
@ -661,7 +660,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<dd>{selectedRepository?.name ?? jobDraft.repositoryId}</dd> <dd>{selectedRepository?.name ?? jobDraft.repositoryId}</dd>
<dt>Aufbewahrung</dt> <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> <dt>Sicherheit</dt>
<dd> <dd>
@ -670,10 +669,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</dd> </dd>
<dt>Pruefung</dt> <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> <dt>Benachrichtigung</dt>
<dd className="wizard__summary-unavailable">keine</dd> <dd className="text-sm text-fg-muted">keine</dd>
</dl> </dl>
); );
} }
@ -681,14 +680,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 10: Anlegen. */ /** Schritt 10: Anlegen. */
function renderCreateStep(): React.JSX.Element { function renderCreateStep(): React.JSX.Element {
return ( return (
<div className="wizard__fields"> <div className="mt-4 space-y-4">
<p> <p>
Der Auftrag wird angelegt und laeuft ab sofort nach Zeitplan. Der erste Lauf ist eine 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. vollstaendige Sicherung; jeder weitere liest nur, was sich geaendert hat.
</p> </p>
{submitError !== null && ( {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} {submitError}
</p> </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 { 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) { if (stepIndex === currentStepIndex) {
return 'wizard__step wizard__step--current'; return `${baseClasses} bg-accent-subtle font-medium text-accent`;
} }
if (stepIndex < currentStepIndex) { 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). */ /** 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 { useCallback, useState } from 'react';
import { useApiResource } from '../../api/useApiResource'; import { useApiResource } from '@/api/useApiResource';
import { ErrorState, LoadingState } from '../../components/PageState'; 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 { LineChart } from './LineChart';
import { fetchChart, fetchChartCatalog } from './metricsApi'; import { fetchChart, fetchChartCatalog } from './metricsApi';
import type { Chart, ChartCatalog, ChartDefinition } 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); const { loadState, data, loadError, reload } = useApiResource<ChartCatalog>(loadCatalog);
return ( return (
<section className="page"> <>
<header className="page__header"> <PageHeader
<h1 className="page__title">Kennzahlen</h1> title="Kennzahlen"
description={
{data !== null && ( data !== null
<span className="page__meta"> ? `${data.available_count} von ${data.charts.length} Diagrammen haben eine Datengrundlage.`
{data.available_count} von {data.charts.length} Diagrammen haben eine Datengrundlage : undefined
</span> }
)} actions={
</header> <SelectInput
aria-label="Zeitraum"
<div className="filter-bar"> className="w-52"
<label className="filter-bar__field">
<span className="filter-bar__label">Zeitraum</span>
<select
className="filter-bar__select"
value={selectedRange} value={selectedRange}
onChange={(changeEvent) => setSelectedRange(changeEvent.target.value)} onChange={(changeEvent) => setSelectedRange(changeEvent.target.value)}
> >
@ -56,17 +54,28 @@ export function MetricsPage(): React.JSX.Element {
{rangeOption.label} {rangeOption.label}
</option> </option>
))} ))}
</select> </SelectInput>
</label> }
</div> />
{loadState === 'loading' && <LoadingState what="Die Kennzahlen" />} {loadState === 'loading' ? (
{loadState === 'failed' && loadError !== null && ( <Card>
<ErrorState error={loadError} onRetry={reload} /> <LoadingState label="Die Kennzahlen werden geladen …" />
)} </Card>
) : null}
{loadState === 'loaded' && data !== null && ( {loadState === 'failed' && loadError !== null ? (
<div className="chart-list"> <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) => ( {data.charts.map((chartDefinition) => (
<ChartCard <ChartCard
key={chartDefinition.metric} key={chartDefinition.metric}
@ -75,8 +84,8 @@ export function MetricsPage(): React.JSX.Element {
/> />
))} ))}
</div> </div>
)} ) : null}
</section> </>
); );
} }
@ -105,35 +114,41 @@ function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.El
if (!definition.available) { if (!definition.available) {
return ( return (
<article className="chart-card chart-card--unavailable"> <Card>
<h2 className="chart-card__title">{definition.title}</h2> <CardHeader title={definition.title} description="noch nicht verfuegbar" />
<p className="chart-card__badge">noch nicht verfuegbar</p> <CardContent>
<p className="chart-card__description">{definition.unavailable_reason}</p> <p className="text-sm text-fg-muted">{definition.unavailable_reason}</p>
</article> </CardContent>
</Card>
); );
} }
return ( return (
<article className="chart-card"> <Card>
<h2 className="chart-card__title">{definition.title}</h2> <CardHeader title={definition.title} description={definition.description} />
<p className="chart-card__description">{definition.description}</p> <CardContent>
{loadState === 'loading' ? <LoadingState label="Die Reihe wird geladen …" /> : null}
{loadState === 'loading' && <LoadingState what="Die Reihe" />} {loadState === 'failed' && loadError !== null ? (
{loadState === 'failed' && loadError !== null && ( <ErrorState
<ErrorState error={loadError} onRetry={reload} /> message={describeApiError(loadError)}
)} requestId={loadError.requestId}
onRetry={reload}
/>
) : null}
{loadState === 'loaded' && data !== null && ( {loadState === 'loaded' && data !== null ? (
<> <>
<LineChart series={data.series} unit={definition.unit} /> <LineChart series={data.series} unit={definition.unit} />
{/* Der Hinweis ordnet ein, was die Kurve wert ist: Zwei Punkte sehen {/* Der Hinweis ordnet ein, was die Kurve wert ist: Zwei Punkte sehen
aus wie ein Trend und sind keiner. */} aus wie ein Trend und sind keiner. */}
{data.note !== undefined && data.note !== '' && ( {data.note !== undefined && data.note !== '' ? (
<p className="chart-card__note">{data.note}</p> <p className="mt-2 text-xs text-fg-muted">{data.note}</p>
)} ) : null}
</> </>
)} ) : null}
</article> </CardContent>
</Card>
); );
} }

View File

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

View File

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

View File

@ -11,9 +11,23 @@
*/ */
import { useCallback } from 'react'; import { useCallback } from 'react';
import { requestApi } from '../../api/client'; import { requestApi } from '@/api/client';
import { useApiResource } from '../../api/useApiResource'; import { useApiResource } from '@/api/useApiResource';
import { ErrorState, LoadingState } from '../../components/PageState'; 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. */ /** Schweregrad eines Befunds. */
type FindingSeverity = 'information' | 'warning' | 'high' | 'critical'; type FindingSeverity = 'information' | 'warning' | 'high' | 'critical';
@ -94,68 +108,111 @@ export function SecurityPage(): React.JSX.Element {
const { loadState, data, loadError, reload } = useApiResource<SecurityResponse>(loadAssessment); const { loadState, data, loadError, reload } = useApiResource<SecurityResponse>(loadAssessment);
return ( 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} /> <ScoreHeader response={data} />
<FindingList assessment={data.assessment} /> <FindingList assessment={data.assessment} />
<AreaTable assessment={data.assessment} /> <AreaTable assessment={data.assessment} />
</div>
) : null}
</> </>
)}
</section>
); );
} }
/** Eigenschaften der Gesamtbewertung. */ /**
interface ScoreHeaderProperties { * Zeigt die Gesamtbewertung.
/** Die Antwort des Security Center. */ *
readonly response: SecurityResponse; * 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
/** Zeigt die Gesamtbewertung. */ * durchgefallen eine Behauptung.
function ScoreHeader({ response }: ScoreHeaderProperties): React.JSX.Element { *
* 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 { assessment } = response;
const criticalCount = assessment.areas
.flatMap((areaResult) => areaResult.findings)
.filter((finding) => finding.severity === 'critical').length;
return ( return (
<div className={`score-card score-card--${gradeClassOf(assessment)}`}> <Card>
<div className="score-card__figure"> <CardContent className="flex flex-wrap items-start gap-6">
<span className="score-card__value">{assessment.score}</span> <div>
<span className="score-card__unit">%</span> <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>
<div className="score-card__text"> <div className="min-w-0 flex-1 space-y-2">
<p className="score-card__grade">{assessment.grade}</p> <p className="text-sm text-fg">{assessment.summary}</p>
<p className="score-card__summary">{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 {/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die
Handlungsanweisung, nicht nur eine Einschraenkung. */} Handlungsanweisung, nicht nur eine Einschraenkung. */}
{assessment.unchecked_areas.length > 0 && ( {assessment.unchecked_areas.length > 0 ? (
<p className="score-card__unchecked"> <p className="text-xs text-fg-muted">
Nicht geprueft: {assessment.unchecked_areas.join(', ')} Nicht geprueft: {assessment.unchecked_areas.join(', ')}
{assessment.unchecked_areas.length >= 3
? ' — bei so vielen ungepruften Bereichen ist die Zahl eine Vermutung, keine Aussage.'
: ''}
</p> </p>
)} ) : null}
</div>
</div> </div>
</CardContent>
</Card>
); );
} }
/** Eigenschaften der Befundliste. */ /**
interface FindingListProperties { * Zeigt die Befunde nach Schweregrad geordnet.
/** Die Sicherheitslage. */ *
* 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; readonly assessment: SecurityAssessment;
} }): React.JSX.Element {
/** Zeigt die Befunde nach Schweregrad geordnet. */
function FindingList({ assessment }: FindingListProperties): React.JSX.Element {
const allFindings = assessment.areas.flatMap((areaResult) => areaResult.findings); const allFindings = assessment.areas.flatMap((areaResult) => areaResult.findings);
const orderedFindings = [...allFindings].sort( const orderedFindings = [...allFindings].sort(
@ -165,130 +222,113 @@ function FindingList({ assessment }: FindingListProperties): React.JSX.Element {
if (orderedFindings.length === 0) { if (orderedFindings.length === 0) {
return ( return (
<p className="page__state page__state--empty"> <Card>
Keine Befunde in den geprueften Bereichen. <EmptyState
</p> title="Keine Befunde in den geprueften Bereichen"
description="Das gilt ausschliesslich fuer die geprueften Bereiche — die ungeprueften stehen oben."
/>
</Card>
); );
} }
return ( return (
<ul className="finding-list"> <Card>
<CardHeader title={`Befunde (${orderedFindings.length})`} />
<CardContent>
<ul className="space-y-3">
{orderedFindings.map((finding) => ( {orderedFindings.map((finding) => (
<li className={`finding finding--${finding.severity}`} key={`${finding.area}-${finding.title}`}> <li
<div className="finding__head"> key={`${finding.area}-${finding.title}`}
<span className={`badge badge--${severityClassOf(finding.severity)}`}> className="rounded-md border border-line p-3"
{finding.severity} >
</span> <div className="flex flex-wrap items-center gap-2">
<h2 className="finding__title">{finding.title}</h2> <StatusBadge status={finding.severity} />
<h3 className="font-medium text-fg">{finding.title}</h3>
</div> </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"> <dl className="mt-3 space-y-2 border-t border-line pt-2 text-sm">
<div className="finding__detail"> <div className="flex gap-2">
<dt>Betroffen</dt> <dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
<dd>{finding.affected_object}</dd> Betroffen
</dt>
<dd className="min-w-0 break-all text-fg">{finding.affected_object}</dd>
</div> </div>
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum der {/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum
Befund ueberhaupt angezeigt wird. */} der Befund ueberhaupt angezeigt wird. */}
<div className="finding__detail finding__detail--recommendation"> <div className="flex gap-2">
<dt>Naechster Schritt</dt> <dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
<dd>{finding.recommendation}</dd> Naechster Schritt
</dt>
<dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd>
</div> </div>
</dl> </dl>
</li> </li>
))} ))}
</ul> </ul>
</CardContent>
</Card>
); );
} }
/** Eigenschaften der Bereichsuebersicht. */
interface AreaTableProperties {
/** Die Sicherheitslage. */
readonly assessment: SecurityAssessment;
}
/** Zeigt die Ergebnisse je Pruefbereich. */ /** Zeigt die Ergebnisse je Pruefbereich. */
function AreaTable({ assessment }: AreaTableProperties): React.JSX.Element { function AreaTable({
return ( assessment,
<section className="area-overview"> }: {
<h2 className="area-overview__title">Geprueft wurde</h2> readonly assessment: SecurityAssessment;
}): React.JSX.Element {
<div className="table-wrapper"> const areaColumns: ReadonlyArray<TableColumn<AreaResult>> = [
<table className="data-table"> {
<thead> key: 'title',
<tr> header: 'Bereich',
<th scope="col">Bereich</th> render: (areaResult) => <span className="font-medium text-fg">{areaResult.title}</span>,
<th scope="col">Ergebnis</th> },
<th scope="col">Punkte</th> {
</tr> key: 'result',
</thead> header: 'Ergebnis',
render: (areaResult) =>
<tbody> areaResult.available ? (
{assessment.areas.map((areaResult) => ( <span className="text-sm text-fg">{areaResult.summary}</span>
<tr key={areaResult.area}>
<td>{areaResult.title}</td>
<td>
{areaResult.available ? (
areaResult.summary
) : ( ) : (
<> <div className="flex flex-col gap-1">
<span className="badge badge--neutral">nicht pruefbar</span> <StatusBadge status="neutral" label="nicht pruefbar" />
<span className="data-table__secondary">{areaResult.unavailable_reason}</span> <span className="text-xs text-fg-muted">{areaResult.unavailable_reason}</span>
</> </div>
)} ),
</td> },
{
<td className="data-table__number"> key: 'points',
{areaResult.available ? ( header: 'Punkte',
numeric: true,
render: (areaResult) =>
areaResult.available ? (
`${areaResult.earned_points} / ${areaResult.weight}` `${areaResult.earned_points} / ${areaResult.weight}`
) : ( ) : (
// Ein ungeprueft Bereich geht nicht in die Rechnung ein — // Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder
// weder positiv noch negativ. // positiv noch negativ.
<span className="data-table__unknown">zaehlt nicht</span> <span className="text-xs text-fg-subtle">zaehlt nicht</span>
)} ),
</td> },
</tr> ];
))}
</tbody> return (
</table> <Card>
</div> <CardHeader
</section> 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. */ /** Liefert die Ordnungszahl eines Schweregrads. */
function severityRankOf(severity: FindingSeverity): number { function severityRankOf(severity: FindingSeverity): number {

View File

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