Weboberflaeche: letzte Seiten auf das Design-System gezogen
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:
parent
ccfff87d3f
commit
6e23696fcb
1117
apps/web/src/App.css
1117
apps/web/src/App.css
File diff suppressed because it is too large
Load Diff
@ -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 ? (
|
||||||
|
|||||||
@ -137,45 +137,68 @@ export function useMutation<TArguments extends unknown[], TResult>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bekannte Lagen samt Abhilfe. */
|
||||||
|
const errorCodeExplanations: Readonly<Record<string, string>> = {
|
||||||
|
// Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des
|
||||||
|
// Anwenders — der Auftrag laeuft bereits.
|
||||||
|
JOB_ALREADY_RUNNING:
|
||||||
|
'Dieser Auftrag laeuft bereits. Ein zweiter Lauf wuerde in dieselbe Kette schreiben.',
|
||||||
|
RESTORE_ALREADY_RUNNING:
|
||||||
|
'In dieses Ziel laeuft bereits eine Wiederherstellung. Zwei gleichzeitige wuerden sich gegenseitig ueberschreiben.',
|
||||||
|
VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprueft.',
|
||||||
|
TARGET_NOT_EMPTY:
|
||||||
|
'Das Zielverzeichnis ist nicht leer. Ueberschreiben verlangt die ausdrueckliche Bestaetigung.',
|
||||||
|
RESTORE_TARGET_FORBIDDEN:
|
||||||
|
'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurueckgeschrieben.',
|
||||||
|
LAST_ADMINISTRATOR:
|
||||||
|
'Das ist der letzte Administrator. Er laesst sich weder loeschen noch deaktivieren — sonst waere die Anlage nicht mehr verwaltbar.',
|
||||||
|
BUILTIN_ROLE_IMMUTABLE:
|
||||||
|
'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.',
|
||||||
|
REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.',
|
||||||
|
REPOSITORY_LOCKED:
|
||||||
|
'Das Repository ist gesperrt — vermutlich laeuft eine Sicherung. Haelt der Zustand an, hilft "syncova-repo break-lock".',
|
||||||
|
REPOSITORY_FULL:
|
||||||
|
'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf wuerde die Lage verschaerfen.',
|
||||||
|
IMMUTABLE_UNTIL:
|
||||||
|
'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und laesst sich bis zum Fristende nicht loeschen.',
|
||||||
|
LEGAL_HOLD:
|
||||||
|
'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.',
|
||||||
|
SERVICE_UNAVAILABLE:
|
||||||
|
'Der Dienst ist derzeit nicht vollstaendig verfuegbar. Das ist kein Problem Ihrer Sitzung.',
|
||||||
|
FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung fuer diese Handlung.',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uebersetzt einen Serverfehler in einen Satz, den ein Betreiber versteht.
|
* Liefert einen Satz, den ein Betreiber versteht.
|
||||||
*
|
*
|
||||||
* Die Fehlercodes der API sind praezise und fuer Menschen unbrauchbar. Wo ein
|
* **Die Servermeldung hat Vorrang.** Sie kennt den Einzelfall — welcher Bericht,
|
||||||
* Code eine bekannte Lage beschreibt, steht hier die Erklaerung **samt Abhilfe**
|
* welches Repository, welche Datei —, und diese Genauigkeit ist mehr wert als
|
||||||
* — der Rest faellt auf die Servermeldung zurueck, die ohnehin auf Deutsch
|
* jede allgemeine Erklaerung. Die Tabelle springt nur ein, wenn der Server
|
||||||
* verfasst ist.
|
* 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 {
|
export function describeApiError(apiError: ApiError): string {
|
||||||
const knownExplanations: Readonly<Record<string, string>> = {
|
const serverMessage = apiError.message.trim();
|
||||||
// Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des
|
|
||||||
// Anwenders — der Auftrag laeuft bereits.
|
|
||||||
JOB_ALREADY_RUNNING:
|
|
||||||
'Dieser Auftrag laeuft bereits. Ein zweiter Lauf wuerde in dieselbe Kette schreiben.',
|
|
||||||
RESTORE_ALREADY_RUNNING:
|
|
||||||
'In dieses Ziel laeuft bereits eine Wiederherstellung. Zwei gleichzeitige wuerden sich gegenseitig ueberschreiben.',
|
|
||||||
VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprueft.',
|
|
||||||
TARGET_NOT_EMPTY:
|
|
||||||
'Das Zielverzeichnis ist nicht leer. Ueberschreiben verlangt die ausdrueckliche Bestaetigung.',
|
|
||||||
RESTORE_TARGET_FORBIDDEN:
|
|
||||||
'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurueckgeschrieben.',
|
|
||||||
LAST_ADMINISTRATOR:
|
|
||||||
'Das ist der letzte Administrator. Er laesst sich weder loeschen noch deaktivieren — sonst waere die Anlage nicht mehr verwaltbar.',
|
|
||||||
BUILTIN_ROLE_IMMUTABLE:
|
|
||||||
'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.',
|
|
||||||
REPOSITORY_IN_USE:
|
|
||||||
'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.',
|
|
||||||
REPOSITORY_LOCKED:
|
|
||||||
'Das Repository ist gesperrt — vermutlich laeuft eine Sicherung. Haelt der Zustand an, hilft "syncova-repo break-lock".',
|
|
||||||
REPOSITORY_FULL:
|
|
||||||
'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf wuerde die Lage verschaerfen.',
|
|
||||||
IMMUTABLE_UNTIL:
|
|
||||||
'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und laesst sich bis zum Fristende nicht loeschen.',
|
|
||||||
LEGAL_HOLD:
|
|
||||||
'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.',
|
|
||||||
SERVICE_UNAVAILABLE:
|
|
||||||
'Der Dienst ist derzeit nicht vollstaendig verfuegbar. Das ist kein Problem Ihrer Sitzung.',
|
|
||||||
FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung fuer diese Handlung.',
|
|
||||||
};
|
|
||||||
|
|
||||||
return knownExplanations[apiError.code] ?? apiError.message;
|
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];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>;
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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' },
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
setActionCounter((previousCounter) => previousCounter + 1);
|
'Zur Kenntnis genommen',
|
||||||
} catch (caughtError) {
|
'Die Meldung bleibt offen, bis ihre Ursache verschwindet.',
|
||||||
setActionError(caughtError instanceof Error ? caughtError.message : 'Unbekannter Fehler');
|
);
|
||||||
}
|
setActionCounter((previousCounter) => previousCounter + 1);
|
||||||
}
|
},
|
||||||
|
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.available_rule_count} von ${overviewResource.data.rules.length} Regeln werden geprueft.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{overviewResource.data !== null && (
|
{overviewResource.data !== null ? (
|
||||||
<span className="page__meta">
|
<SummaryBar overview={overviewResource.data} />
|
||||||
{overviewResource.data.available_rule_count} von {overviewResource.data.rules.length}{' '}
|
) : null}
|
||||||
Regeln werden geprueft
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{overviewResource.data !== null && <SummaryBar overview={overviewResource.data} />}
|
<Card className="mb-4">
|
||||||
|
<div className="border-b border-line px-5 py-3">
|
||||||
<div className="filter-bar">
|
<CheckboxField
|
||||||
<label className="filter-bar__checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={onlyActive}
|
checked={onlyActive}
|
||||||
onChange={(changeEvent) => setOnlyActive(changeEvent.target.checked)}
|
onCheckedChange={setOnlyActive}
|
||||||
|
label="Nur unerledigte"
|
||||||
/>
|
/>
|
||||||
Nur unerledigte
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{actionError !== null && (
|
|
||||||
<div className="notice notice--critical" role="alert">
|
|
||||||
<p className="notice__text">{actionError}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{loadState === 'loading' && <LoadingState what="Die Meldungen" />}
|
{loadState === 'loading' ? <LoadingState label="Die Meldungen werden geladen …" /> : null}
|
||||||
{loadState === 'failed' && loadError !== null && (
|
|
||||||
<ErrorState error={loadError} onRetry={reload} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loadState === 'loaded' && data !== null && data.length === 0 && (
|
{loadState === 'failed' && loadError !== null ? (
|
||||||
<EmptyState
|
<ErrorState
|
||||||
message={
|
message={describeApiError(loadError)}
|
||||||
onlyActive
|
requestId={loadError.requestId}
|
||||||
? 'Keine unerledigten Meldungen. Die Regeln unten sagen, worauf geachtet wird.'
|
onRetry={reload}
|
||||||
: 'Es gibt keine Meldungen.'
|
/>
|
||||||
}
|
) : null}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loadState === 'loaded' && data !== null && data.length > 0 && (
|
{loadState === 'loaded' && data !== null && data.length === 0 ? (
|
||||||
<ul className="alert-list">
|
<EmptyState
|
||||||
{data.map((alert) => (
|
title={onlyActive ? 'Keine unerledigten Meldungen' : 'Es gibt keine Meldungen'}
|
||||||
<AlertCard
|
description="Die Liste „Worauf geachtet wird“ unten sagt, was geprueft wird — erst damit ist eine leere Liste eine gute Nachricht."
|
||||||
key={alert.id}
|
/>
|
||||||
alert={alert}
|
) : null}
|
||||||
onAcknowledge={() => void runAlertAction(() => acknowledgeAlert(alert.id, ''))}
|
|
||||||
onResolve={() => void runAlertAction(() => resolveAlert(alert.id, 'Von Hand geschlossen.'))}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{overviewResource.data !== null && <RuleList overview={overviewResource.data} />}
|
{loadState === 'loaded' && data !== null && data.length > 0 ? (
|
||||||
</section>
|
<ul className="divide-y divide-line">
|
||||||
|
{data.map((alert) => (
|
||||||
|
<AlertRow
|
||||||
|
key={alert.id}
|
||||||
|
alert={alert}
|
||||||
|
isBusy={acknowledgeMutation.isRunning || resolveMutation.isRunning}
|
||||||
|
onAcknowledge={() => void acknowledgeMutation.run(alert.id)}
|
||||||
|
onResolve={() => void resolveMutation.run(alert.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{overviewResource.data !== null ? <RuleList overview={overviewResource.data} /> : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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">
|
||||||
|
<StatusBadge status={alert.severity} />
|
||||||
|
<h3 className="min-w-0 font-medium text-fg">{alert.title}</h3>
|
||||||
|
{/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall,
|
||||||
|
zwanzigmal ein Zustand. */}
|
||||||
|
{alert.occurrence_count > 1 ? (
|
||||||
|
<span className="rounded bg-sunken px-1.5 py-0.5 text-xs text-fg-muted">
|
||||||
|
{alert.occurrence_count}× aufgetreten
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 className="alert-card__title">{alert.title}</h2>
|
<p className="mt-1.5 text-sm text-fg">{alert.message}</p>
|
||||||
|
|
||||||
{/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall,
|
<p className="mt-1.5 text-xs text-fg-muted">
|
||||||
zwanzigmal ein Zustand. */}
|
Seit {formatDateTime(alert.first_seen_at)} · zuletzt{' '}
|
||||||
{alert.occurrence_count > 1 && (
|
{formatDateTime(alert.last_seen_at)} · Regel {alert.rule_name}
|
||||||
<span className="alert-card__count">{alert.occurrence_count}× aufgetreten</span>
|
</p>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="alert-card__message">{alert.message}</p>
|
{alert.status === 'acknowledged' ? (
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
Zur Kenntnis genommen am {formatDateTime(alert.acknowledged_at)}. Die
|
||||||
|
Meldung <strong>bleibt offen</strong>, bis ihre Ursache verschwindet.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<p className="alert-card__meta">
|
{isResolved ? (
|
||||||
Seit {formatTimestamp(alert.first_seen_at)} · zuletzt {formatTimestamp(alert.last_seen_at)} ·
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
Regel {alert.rule_name}
|
Erledigt am {formatDateTime(alert.resolved_at)}
|
||||||
</p>
|
{alert.resolution_note !== undefined && alert.resolution_note !== ''
|
||||||
|
? ` — ${alert.resolution_note}`
|
||||||
{alert.status === 'acknowledged' && (
|
: ''}
|
||||||
<p className="alert-card__meta">
|
</p>
|
||||||
Zur Kenntnis genommen am {formatTimestamp(alert.acknowledged_at)}. Die Meldung bleibt
|
) : null}
|
||||||
offen, bis ihre Ursache verschwindet.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isResolved && (
|
|
||||||
<p className="alert-card__meta">
|
|
||||||
Erledigt am {formatTimestamp(alert.resolved_at)}
|
|
||||||
{alert.resolution_note !== undefined && alert.resolution_note !== ''
|
|
||||||
? ` — ${alert.resolution_note}`
|
|
||||||
: ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isResolved && (
|
|
||||||
<div className="alert-card__actions">
|
|
||||||
{alert.status === 'open' && (
|
|
||||||
<button className="button button--secondary" type="button" onClick={onAcknowledge}>
|
|
||||||
Zur Kenntnis nehmen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button className="button button--secondary" type="button" onClick={onResolve}>
|
|
||||||
Schliessen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{!isResolved ? (
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
{alert.status === 'open' ? (
|
||||||
|
<Button size="sm" disabled={isBusy} onClick={onAcknowledge}>
|
||||||
|
Zur Kenntnis nehmen
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}>
|
||||||
|
Schliessen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</li>
|
</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."
|
||||||
{overview.rules.map((rule) => (
|
/>
|
||||||
<li
|
<CardContent>
|
||||||
className={rule.available ? 'rule-list__item' : 'rule-list__item rule-list__item--unavailable'}
|
<ul className="space-y-3">
|
||||||
key={rule.name}
|
{overview.rules.map((rule) => (
|
||||||
>
|
<li
|
||||||
<div className="rule-list__head">
|
key={rule.name}
|
||||||
<span className="rule-list__name">{rule.title}</span>
|
className={cn(
|
||||||
|
'rounded-md border border-line p-3',
|
||||||
{rule.available ? (
|
!rule.available && 'opacity-70',
|
||||||
<span className={`badge badge--${severityClassOf(rule.severity)}`}>{rule.severity}</span>
|
|
||||||
) : (
|
|
||||||
<span className="badge badge--neutral">wird nicht geprueft</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-fg">{rule.title}</span>
|
||||||
|
{rule.available ? (
|
||||||
|
<StatusBadge status={rule.severity} />
|
||||||
|
) : (
|
||||||
|
<StatusBadge status="neutral" label="wird nicht geprueft" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-fg-muted">
|
||||||
|
{rule.available ? rule.description : rule.unavailable_reason}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<p className="rule-list__description">
|
{overview.available_rule_count < overview.rules.length ? (
|
||||||
{rule.available ? rule.description : rule.unavailable_reason}
|
<Callout tone="warning" className="mt-4">
|
||||||
</p>
|
{overview.rules.length - overview.available_rule_count} Regeln werden
|
||||||
</li>
|
nicht ausgewertet. Rechnen Sie sie nicht als „in Ordnung" — sie
|
||||||
))}
|
schweigen, weil ihnen die Datengrundlage fehlt.
|
||||||
</ul>
|
</Callout>
|
||||||
</section>
|
) : 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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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');
|
||||||
|
|||||||
@ -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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{formatWidgetValue(widget.value)}
|
<span className="text-2xl font-semibold tabular text-fg">
|
||||||
{widget.unit !== undefined && <span className="widget__unit"> {widget.unit}</span>}
|
{formatWidgetValue(widget.value)}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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={includeDeleted}
|
||||||
checked={onlyProtected}
|
onCheckedChange={setIncludeDeleted}
|
||||||
onChange={(changeEvent) => setOnlyProtected(changeEvent.target.checked)}
|
label="Geloeschte einschliessen"
|
||||||
/>
|
/>
|
||||||
Nur geschuetzte
|
<CheckboxField
|
||||||
</label>
|
checked={onlyProtected}
|
||||||
|
onCheckedChange={setOnlyProtected}
|
||||||
{/* Geloeschte Punkte erscheinen nur auf Nachfrage — die Frage „warum ist
|
label="Nur geschuetzte"
|
||||||
das Backup von vorletzter Woche weg?" muss aber beantwortbar sein. */}
|
/>
|
||||||
<label className="filter-bar__checkbox">
|
</div>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={includeDeleted}
|
|
||||||
onChange={(changeEvent) => setIncludeDeleted(changeEvent.target.checked)}
|
|
||||||
/>
|
|
||||||
Geloeschte einbeziehen
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadState === 'loading' && <LoadingState what="Die Wiederherstellungspunkte" />}
|
|
||||||
{loadState === 'failed' && loadError !== null && (
|
|
||||||
<ErrorState error={loadError} onRetry={reload} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loadState === 'loaded' && data !== null && data.length === 0 && (
|
|
||||||
<EmptyState message="Zu diesen Filtern gibt es keinen Wiederherstellungspunkt." />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loadState === 'loaded' && data !== null && data.length > 0 && (
|
|
||||||
<div className="table-wrapper">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Zeitpunkt</th>
|
|
||||||
<th scope="col">Auftrag</th>
|
|
||||||
<th scope="col">Art</th>
|
|
||||||
<th scope="col">Groesse</th>
|
|
||||||
<th scope="col">Einstufung</th>
|
|
||||||
<th scope="col">Bewertung</th>
|
|
||||||
<th scope="col">Schutz</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{data.map((recoveryPoint) => (
|
|
||||||
<RecoveryPointRow
|
|
||||||
key={recoveryPoint.id}
|
|
||||||
recoveryPoint={recoveryPoint}
|
|
||||||
onOpen={onOpenBackup}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Eigenschaften einer Tabellenzeile. */
|
{loadState === 'failed' && loadError !== null ? (
|
||||||
interface RecoveryPointRowProperties {
|
<ErrorState
|
||||||
/** Der dargestellte Punkt. */
|
message={describeApiError(loadError)}
|
||||||
readonly recoveryPoint: RecoveryPoint;
|
requestId={loadError.requestId}
|
||||||
/** Oeffnet die Einzelheiten. */
|
onRetry={reload}
|
||||||
readonly onOpen?: ((backupIdentifier: string) => void) | undefined;
|
/>
|
||||||
}
|
) : (
|
||||||
|
<DataTable
|
||||||
/** Zeigt einen Wiederherstellungspunkt als Tabellenzeile. */
|
columns={tableColumns}
|
||||||
function RecoveryPointRow({ recoveryPoint, onOpen }: RecoveryPointRowProperties): React.JSX.Element {
|
rows={data ?? []}
|
||||||
const isDeleted = recoveryPoint.deleted_at !== undefined;
|
getRowKey={(recoveryPoint) => recoveryPoint.id}
|
||||||
|
onRowClick={
|
||||||
return (
|
onOpenBackup
|
||||||
<tr
|
? (recoveryPoint) => onOpenBackup(recoveryPoint.id)
|
||||||
className={isDeleted ? 'data-table__row--deleted' : undefined}
|
: 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
|
isLoading={loadState === 'loading'}
|
||||||
}
|
emptyTitle={
|
||||||
>
|
classificationFilter || onlyProtected
|
||||||
<td>
|
? 'Kein Punkt passt zum Filter'
|
||||||
{formatTimestamp(recoveryPoint.completed_at)}
|
: 'Noch kein Wiederherstellungspunkt'
|
||||||
<span className="data-table__secondary">{recoveryPoint.repository_name}</span>
|
}
|
||||||
</td>
|
emptyDescription={
|
||||||
|
classificationFilter || onlyProtected
|
||||||
<td>{recoveryPoint.job_name ?? '—'}</td>
|
? 'Lockern Sie den Filter, um mehr zu sehen.'
|
||||||
|
: 'Sobald ein Sicherungsauftrag durchgelaufen ist, erscheint hier ein Punkt.'
|
||||||
<td>{recoveryPoint.backup_type === 'full' ? 'Voll' : 'Zusatz'}</td>
|
}
|
||||||
|
/>
|
||||||
<td className="data-table__number">{formatBytes(recoveryPoint.logical_bytes)}</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
<ClassificationBadge classification={recoveryPoint.classification} />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="data-table__number">
|
|
||||||
{/* Keine Bewertung heisst nicht null Prozent. Die Unterscheidung ist der
|
|
||||||
Kern der Recovery Assurance (Phase 10). */}
|
|
||||||
{recoveryPoint.assurance_score === undefined ? (
|
|
||||||
<span className="data-table__unknown">nicht berechnet</span>
|
|
||||||
) : (
|
|
||||||
`${recoveryPoint.assurance_score} %`
|
|
||||||
)}
|
)}
|
||||||
</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}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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"
|
||||||
|
description={`Version ${systemHealth.version} · Umgebung ${systemHealth.environment}`}
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={systemHealth.status} />
|
||||||
|
<Button variant="ghost" size="sm" onClick={reload}>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="health-panel__overall">
|
{componentRows.length === 0 ? (
|
||||||
<StatusIndicator status={systemHealth.status} />
|
<CardContent>
|
||||||
<span className="health-panel__version">
|
<p className="text-sm text-fg-muted">Es sind keine Komponenten registriert.</p>
|
||||||
Version {systemHealth.version} · Umgebung {systemHealth.environment}
|
</CardContent>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{componentEntries.length === 0 ? (
|
|
||||||
<p className="health-panel__hint">Es sind keine Komponenten registriert.</p>
|
|
||||||
) : (
|
) : (
|
||||||
<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>
|
|
||||||
<td className="health-panel__latency">{componentHealth.latency_ms.toFixed(1)} ms</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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 <pfad> --name <name></code> angelegt.
|
wird mit <code>syncova-repo create --path <pfad> --name <name></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). */
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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"
|
||||||
disabled={exportState.runningFormat !== null}
|
isLoading={exportState.runningFormat === 'csv'}
|
||||||
onClick={() => onExport('csv')}
|
disabled={exportState.runningFormat !== null}
|
||||||
>
|
onClick={() => onExport('csv')}
|
||||||
{exportState.runningFormat === 'csv' ? 'CSV wird erzeugt …' : 'CSV herunterladen'}
|
>
|
||||||
</button>
|
CSV herunterladen
|
||||||
<button
|
</Button>
|
||||||
type="button"
|
<Button
|
||||||
className="button button--secondary"
|
size="sm"
|
||||||
disabled={exportState.runningFormat !== null}
|
isLoading={exportState.runningFormat === 'pdf'}
|
||||||
onClick={() => onExport('pdf')}
|
disabled={exportState.runningFormat !== null}
|
||||||
>
|
onClick={() => onExport('pdf')}
|
||||||
{exportState.runningFormat === 'pdf' ? 'PDF wird erzeugt …' : 'PDF herunterladen'}
|
>
|
||||||
</button>
|
PDF herunterladen
|
||||||
</div>
|
</Button>
|
||||||
</header>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
{exportState.exportError !== null ? (
|
||||||
|
<Callout tone="critical" role="alert" className="mb-4">
|
||||||
|
<p>{describeApiError(exportState.exportError)}</p>
|
||||||
|
<p className="mt-1 font-mono text-xs">
|
||||||
|
Vorgangsnummer: {exportState.exportError.requestId}
|
||||||
|
</p>
|
||||||
|
</Callout>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{exportState.exportError !== null && (
|
{previewReport === null ? (
|
||||||
<p className="reports__error" role="alert">
|
<LoadingState label="Der Bericht wird erzeugt …" />
|
||||||
{exportState.exportError.message}
|
) : (
|
||||||
<span className="reports__request-id">
|
<ReportPreview report={previewReport} />
|
||||||
Vorgang {exportState.exportError.requestId}
|
)}
|
||||||
</span>
|
</CardContent>
|
||||||
</p>
|
</Card>
|
||||||
)}
|
|
||||||
|
|
||||||
{previewReport === null ? (
|
|
||||||
<LoadingState what="den Bericht" />
|
|
||||||
) : (
|
|
||||||
<ReportPreview report={previewReport} />
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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"
|
||||||
className={
|
>
|
||||||
reportMetric.is_known
|
<dt className="text-xs text-fg-subtle">{reportMetric.label}</dt>
|
||||||
? 'reports__metric-value'
|
<dd className="mt-1">
|
||||||
: 'reports__metric-value reports__metric-value--unknown'
|
{/* Eine ungemessene Kennzahl erscheint niemals als Zahl.
|
||||||
}
|
Eine erfundene Null ueberlebt in einem Pruefordner jede
|
||||||
>
|
muendliche Erlaeuterung. */}
|
||||||
{formatMetricValue(reportMetric)}
|
<span
|
||||||
{!reportMetric.is_known && reportMetric.unknown_reason !== undefined && (
|
className={
|
||||||
<span className="reports__metric-reason">{reportMetric.unknown_reason}</span>
|
reportMetric.is_known
|
||||||
)}
|
? 'text-lg font-semibold tabular text-fg'
|
||||||
|
: 'text-sm text-fg-muted'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatMetricValue(reportMetric)}
|
||||||
|
</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>
|
||||||
|
|||||||
@ -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">
|
<PageHeader
|
||||||
<h1 className="page__title">Sicherheit</h1>
|
title="Security Center"
|
||||||
</header>
|
description="Was geprueft wurde — und was nicht."
|
||||||
|
/>
|
||||||
|
|
||||||
{loadState === 'loading' && <LoadingState what="Die Sicherheitslage" />}
|
{loadState === 'loading' ? (
|
||||||
{loadState === 'failed' && loadError !== null && (
|
<Card>
|
||||||
<ErrorState error={loadError} onRetry={reload} />
|
<LoadingState label="Die Sicherheitslage wird ermittelt …" />
|
||||||
)}
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{loadState === 'loaded' && data !== 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
|
||||||
</div>
|
className={cn(
|
||||||
|
'text-4xl font-semibold tabular',
|
||||||
<div className="score-card__text">
|
criticalCount > 0 ? 'text-critical' : 'text-fg',
|
||||||
<p className="score-card__grade">{assessment.grade}</p>
|
)}
|
||||||
<p className="score-card__summary">{assessment.summary}</p>
|
>
|
||||||
|
{assessment.score}
|
||||||
{/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die
|
<span className="ml-1 text-lg text-fg-muted">%</span>
|
||||||
Handlungsanweisung, nicht nur eine Einschraenkung. */}
|
|
||||||
{assessment.unchecked_areas.length > 0 && (
|
|
||||||
<p className="score-card__unchecked">
|
|
||||||
Nicht geprueft: {assessment.unchecked_areas.join(', ')}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<p className="mt-1 text-sm font-medium text-fg">{assessment.grade}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<p className="text-sm text-fg">{assessment.summary}</p>
|
||||||
|
|
||||||
|
{criticalCount > 0 ? (
|
||||||
|
<Callout tone="critical">
|
||||||
|
{criticalCount} kritische{criticalCount === 1 ? 'r' : ''} Befund
|
||||||
|
{criticalCount === 1 ? '' : 'e'} deckelt die Einstufung auf
|
||||||
|
„unzureichend" — unabhaengig von der Punktzahl.
|
||||||
|
</Callout>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die
|
||||||
|
Handlungsanweisung, nicht nur eine Einschraenkung. */}
|
||||||
|
{assessment.unchecked_areas.length > 0 ? (
|
||||||
|
<p className="text-xs text-fg-muted">
|
||||||
|
Nicht geprueft: {assessment.unchecked_areas.join(', ')}
|
||||||
|
{assessment.unchecked_areas.length >= 3
|
||||||
|
? ' — bei so vielen ungepruften Bereichen ist die Zahl eine Vermutung, keine Aussage.'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</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>
|
||||||
{orderedFindings.map((finding) => (
|
<CardHeader title={`Befunde (${orderedFindings.length})`} />
|
||||||
<li className={`finding finding--${finding.severity}`} key={`${finding.area}-${finding.title}`}>
|
<CardContent>
|
||||||
<div className="finding__head">
|
<ul className="space-y-3">
|
||||||
<span className={`badge badge--${severityClassOf(finding.severity)}`}>
|
{orderedFindings.map((finding) => (
|
||||||
{finding.severity}
|
<li
|
||||||
</span>
|
key={`${finding.area}-${finding.title}`}
|
||||||
<h2 className="finding__title">{finding.title}</h2>
|
className="rounded-md border border-line p-3"
|
||||||
</div>
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge status={finding.severity} />
|
||||||
|
<h3 className="font-medium text-fg">{finding.title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="finding__explanation">{finding.explanation}</p>
|
<p className="mt-1.5 text-sm text-fg-muted">{finding.explanation}</p>
|
||||||
|
|
||||||
<dl className="finding__details">
|
<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
|
||||||
</div>
|
</dt>
|
||||||
|
<dd className="min-w-0 break-all text-fg">{finding.affected_object}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum der
|
{/* 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
|
||||||
</div>
|
</dt>
|
||||||
</dl>
|
<dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd>
|
||||||
</li>
|
</div>
|
||||||
))}
|
</dl>
|
||||||
</ul>
|
</li>
|
||||||
|
))}
|
||||||
|
</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({
|
||||||
|
assessment,
|
||||||
|
}: {
|
||||||
|
readonly assessment: SecurityAssessment;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const areaColumns: ReadonlyArray<TableColumn<AreaResult>> = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
header: 'Bereich',
|
||||||
|
render: (areaResult) => <span className="font-medium text-fg">{areaResult.title}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'result',
|
||||||
|
header: 'Ergebnis',
|
||||||
|
render: (areaResult) =>
|
||||||
|
areaResult.available ? (
|
||||||
|
<span className="text-sm text-fg">{areaResult.summary}</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<StatusBadge status="neutral" label="nicht pruefbar" />
|
||||||
|
<span className="text-xs text-fg-muted">{areaResult.unavailable_reason}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'points',
|
||||||
|
header: 'Punkte',
|
||||||
|
numeric: true,
|
||||||
|
render: (areaResult) =>
|
||||||
|
areaResult.available ? (
|
||||||
|
`${areaResult.earned_points} / ${areaResult.weight}`
|
||||||
|
) : (
|
||||||
|
// Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder
|
||||||
|
// positiv noch negativ.
|
||||||
|
<span className="text-xs text-fg-subtle">zaehlt nicht</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="area-overview">
|
<Card>
|
||||||
<h2 className="area-overview__title">Geprueft wurde</h2>
|
<CardHeader
|
||||||
|
title="Geprueft wurde"
|
||||||
<div className="table-wrapper">
|
description={`Erreichbar sind ${assessment.maximum_score} Punkte.`}
|
||||||
<table className="data-table">
|
/>
|
||||||
<thead>
|
<DataTable
|
||||||
<tr>
|
columns={areaColumns}
|
||||||
<th scope="col">Bereich</th>
|
rows={assessment.areas}
|
||||||
<th scope="col">Ergebnis</th>
|
getRowKey={(areaResult) => areaResult.area}
|
||||||
<th scope="col">Punkte</th>
|
/>
|
||||||
</tr>
|
</Card>
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{assessment.areas.map((areaResult) => (
|
|
||||||
<tr key={areaResult.area}>
|
|
||||||
<td>{areaResult.title}</td>
|
|
||||||
|
|
||||||
<td>
|
|
||||||
{areaResult.available ? (
|
|
||||||
areaResult.summary
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="badge badge--neutral">nicht pruefbar</span>
|
|
||||||
<span className="data-table__secondary">{areaResult.unavailable_reason}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="data-table__number">
|
|
||||||
{areaResult.available ? (
|
|
||||||
`${areaResult.earned_points} / ${areaResult.weight}`
|
|
||||||
) : (
|
|
||||||
// Ein ungeprueft Bereich geht nicht in die Rechnung ein —
|
|
||||||
// weder positiv noch negativ.
|
|
||||||
<span className="data-table__unknown">zaehlt nicht</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 {
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user