Compare commits

..

2 Commits

Author SHA1 Message Date
77e61dc2b3 Blackscreen auf /retention behoben, Aussehen ueberarbeitet
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 44s
CI / Sicherheitsprüfungen (push) Successful in 26s
**Der schwarze Bildschirm ist gefunden.** `GET /retention-policies` liefert ein
Objekt `{ policies, predefined }` — als einziger von zehn geprueften
Listenendpunkten. Die Oberflaeche behandelte es als Array; `map` gibt es auf
einem Objekt nicht, React haengte den ganzen Baum aus, und uebrig blieb eine
leere Seite. Im dunklen Thema schwarz.

Der Regressionstest fuettert die **echte** Antwortform. Ein Test mit einem Array
haette den Fehler nie gefunden — und genau das war passiert. Durch Mutation als
fangend bestaetigt ("repositories.map is not a function").

Nebenbei ergab sich ein Modul: Der Endpunkt liefert **sechs mitgelieferte
Vorlagen** mit. Sie erscheinen jetzt als Kacheln, ein Klick legt daraus eine
eigene Regel an.

**Aussehen.** Kraeftigerer Akzent (Indigo statt gedecktem Blau), waermere
Neutraltoene, groessere Radien, Schatten statt Rahmen, wo Tiefe gemeint ist:
Ein Rahmen trennt, ein Schatten hebt — Karten sollen heben, Tabellenzeilen
trennen. Kompaktere Statusabzeichen und Tabellenkoepfe.

**Texte gekuerzt.** 33 Erlaeuterungen von Absaetzen auf einen Satz. Der Inhalt
bleibt richtig; er steht nur nicht mehr zwischen den Bedienelementen. Was
ausfuehrlich erklaert werden muss, gehoert in docs/.

**Schnellzugriffe auf der Uebersicht.** Acht Kacheln, die die Handlung nennen
statt des Bereichs ("Auftrag anlegen" statt "Sicherungsauftraege"). Gezeigt wird
nur, was die Rolle darf — eine Kachel, die in eine Fehlermeldung fuehrt, ist
schlimmer als keine. Als Schaltflaechen umgesetzt, nicht als div mit
Klick-Handler: Der Unterschied ist Tastaturbedienbarkeit.

84 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:56:51 +02:00
b50ad2b9bc Sitzung ueberlebt Neuladen, Umlaute, Fehlergrenze
**Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den
Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch
unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein
Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab),
begrenzt durch zwei Uhren:

- **Harte Obergrenze** von 30 Minuten ab Anmeldung, durch keine Interaktion
  verschiebbar. Sonst waere "30 Minuten" keine Zusage.
- **Untaetigkeitsgrenze** von 30 Minuten.
- Der sofortige serverseitige Widerruf bleibt die eigentliche Absicherung — die
  Tokens sind opak, kein JWT, und genau dafuer wurden sie gewaehlt.

Dazu die Sitzungsuhr oben rechts neben "Abmelden", unter fuenf Minuten
auffaellig. Umgesetzt mit `useSyncExternalStore`: Die Restzeit haengt an der Uhr
und am Speicher, also an zwei Dingen ausserhalb von React. Sie beim Rendern
auszurechnen waere ein unreiner Aufruf, sie in einem Effekt zu setzen eine
zweite Renderrunde je Sekunde — beides hat der Linter gemeldet.

Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
bestaetigt (Obergrenze mitverschieben schlaegt fehl).

**Fehlergrenze.** Ein Fehler in einer Komponente riss bisher den gesamten Baum
ab; uebrig blieb eine leere Seite — im dunklen Thema ein schwarzer Bildschirm
ohne jeden Hinweis. Die Grenze sitzt **um den Inhalt**: Menue und Kopfzeile
bleiben stehen, der Fehlertext ist lesbar und kopierbar.

**Umlaute.** Die Oberflaeche schrieb durchgehend ae/oe/ue/ss. Jetzt aeoeuess.

Dabei ein selbst verursachter Schaden, gefunden und behoben: Eine Regel
"ue → ü" ist falsch, weil die Buchstabenfolge nicht immer ein Umlaut ist. Sie
machte aus "Quelle" ein "Qülle", aus "neue" ein "neü", aus "aktuell" ein
"aktüll", aus "Dauer" ein "Daür". Die Abbildung laeuft jetzt ueber eine
gepruefte Wortliste mit Ausschluss englischer Bezeichner (`value`, `message`,
`session`, `queued`, `true`); die 23 zerstoerten Woerter sind einzeln
zurueckgesetzt. Ein alter Tippfehler ("geprueter") ist dabei mit aufgefallen.

82 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:42:37 +02:00
76 changed files with 2370 additions and 1412 deletions

View File

@ -1,19 +1,26 @@
/** /**
* Wurzelkomponente der Syncova-Oberflaeche. * Wurzelkomponente der Syncova-Oberfläche.
* *
* Sie entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und verteilt * Sie entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und verteilt
* letztere auf die Bereiche aus PROMPT.md §28. * letztere auf die Bereiche aus PROMPT.md §28.
* *
* Die Navigation kennt zwei Ebenen: eine Seite und optional ein Objekt darauf * Die Navigation kennt zwei Ebenen: eine Seite und optional ein Objekt darauf
* (`jobs/<id>`). Das reicht fuer diese Konsole und kostet rund fuenfzig Zeilen — * (`jobs/<id>`). Das reicht für diese Konsole und kostet rund fünfzig Zeilen —
* eine Router-Bibliothek waere hier mehr Abhaengigkeit als Nutzen. * eine Router-Bibliothek wäre hier mehr Abhängigkeit als Nutzen.
*/ */
import { useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { setAccessTokenProvider } from './api/client'; import { setAccessTokenProvider } from './api/client';
import { AppShell } from './components/layout/AppShell'; import { AppShell } from './components/layout/AppShell';
import { ToastProvider } from './components/ui'; import { ErrorBoundary } from './components/layout/ErrorBoundary';
import { getAccessToken, logout } from './features/auth/authApi'; import { LoadingState, ToastProvider } from './components/ui';
import {
fetchCurrentUser,
getAccessToken,
hasResumableSession,
logout,
} from './features/auth/authApi';
import { useSession } from './features/auth/useSession';
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';
@ -42,16 +49,64 @@ 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';
// Der API-Client erhaelt seinen Tokenzugriff einmalig beim Laden des Moduls. // Der API-Client erhält seinen Tokenzugriff einmalig beim Laden des Moduls.
setAccessTokenProvider(getAccessToken); setAccessTokenProvider(getAccessToken);
export function App(): React.JSX.Element { export function App(): React.JSX.Element {
const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null); const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null);
const { currentPageId, navigateToPage } = useCurrentPage(); const { currentPageId, navigateToPage } = useCurrentPage();
// Das gerade geoeffnete Objekt innerhalb einer Seite, etwa ein Auftrag. // Das gerade geöffnete Objekt innerhalb einer Seite, etwa ein Auftrag.
const [openObjectIdentifier, setOpenObjectIdentifier] = useState<string | null>(null); const [openObjectIdentifier, setOpenObjectIdentifier] = useState<string | null>(null);
// Beim Start wird geprüft, ob eine Sitzung fortgesetzt werden kann. Solange
// das läuft, erscheint weder Anmeldemaske noch Inhalt — sonst blitzte nach
// jedem Neuladen kurz die Anmeldung auf.
const [isResuming, setIsResuming] = useState(() => hasResumableSession());
useEffect(() => {
if (!isResuming) {
return;
}
let isCancelled = false;
void fetchCurrentUser()
.then((resumedUser) => {
if (!isCancelled) {
setAuthenticatedUser(resumedUser);
}
})
.catch(() => {
// Der Server hat das Token abgelehnt — etwa nach einer Kontosperre.
// Dann gilt die Sitzung als beendet, unabhängig von der lokalen Uhr.
})
.finally(() => {
if (!isCancelled) {
setIsResuming(false);
}
});
return () => {
isCancelled = true;
};
}, [isResuming]);
const handleSessionExpired = useCallback(() => {
setAuthenticatedUser(null);
void logout();
}, []);
const sessionClock = useSession(authenticatedUser !== null, handleSessionExpired);
if (isResuming) {
return (
<div className="flex min-h-screen items-center justify-center bg-page">
<LoadingState label="Sitzung wird fortgesetzt …" />
</div>
);
}
if (authenticatedUser === null) { if (authenticatedUser === null) {
return ( return (
<ToastProvider> <ToastProvider>
@ -67,7 +122,7 @@ export function App(): React.JSX.Element {
const grantedPermissions = authenticatedUser.permissions ?? []; const grantedPermissions = authenticatedUser.permissions ?? [];
/** Wechselt die Seite und schliesst ein offenes Objekt. */ /** Wechselt die Seite und schließt ein offenes Objekt. */
function handleNavigate(pageIdentifier: string): void { function handleNavigate(pageIdentifier: string): void {
setOpenObjectIdentifier(null); setOpenObjectIdentifier(null);
navigateToPage(pageIdentifier); navigateToPage(pageIdentifier);
@ -80,33 +135,41 @@ export function App(): React.JSX.Element {
onNavigate={handleNavigate} onNavigate={handleNavigate}
grantedPermissions={grantedPermissions} grantedPermissions={grantedPermissions}
userName={authenticatedUser.username} userName={authenticatedUser.username}
sessionClock={sessionClock}
onLogout={() => void handleLogout()} onLogout={() => void handleLogout()}
> >
{/* Die Grenze sitzt um den Inhalt: Ein Fehler auf einer Seite lässt
Menü und Kopfzeile stehen, statt die Konsole zu schwärzen. */}
<ErrorBoundary resetKey={`${currentPageId}|${openObjectIdentifier ?? ''}`}>
<CurrentPageContent <CurrentPageContent
currentPageId={currentPageId} currentPageId={currentPageId}
grantedPermissions={grantedPermissions} grantedPermissions={grantedPermissions}
currentUser={authenticatedUser} currentUser={authenticatedUser}
openObjectIdentifier={openObjectIdentifier} openObjectIdentifier={openObjectIdentifier}
onOpenObject={setOpenObjectIdentifier} onOpenObject={setOpenObjectIdentifier}
onNavigate={handleNavigate}
/> />
</ErrorBoundary>
</AppShell> </AppShell>
</ToastProvider> </ToastProvider>
); );
} }
/** Zeigt den Inhalt der gewaehlten Seite. */ /** Zeigt den Inhalt der gewählten Seite. */
function CurrentPageContent({ function CurrentPageContent({
currentPageId, currentPageId,
grantedPermissions, grantedPermissions,
currentUser, currentUser,
openObjectIdentifier, openObjectIdentifier,
onOpenObject, onOpenObject,
onNavigate,
}: { }: {
readonly currentPageId: string; readonly currentPageId: string;
readonly grantedPermissions: readonly string[]; readonly grantedPermissions: readonly string[];
readonly currentUser: CurrentUser; readonly currentUser: CurrentUser;
readonly openObjectIdentifier: string | null; readonly openObjectIdentifier: string | null;
readonly onOpenObject: (objectIdentifier: string | null) => void; readonly onOpenObject: (objectIdentifier: string | null) => void;
readonly onNavigate: (pageIdentifier: string) => void;
}): React.JSX.Element { }): React.JSX.Element {
const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID); const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID);
@ -114,14 +177,14 @@ function CurrentPageContent({
return <DashboardPage />; return <DashboardPage />;
} }
// Die Anzeigepruefung ersetzt die serverseitige nicht, sie ergaenzt sie: Ohne // Die Anzeigeprüfung ersetzt die serverseitige nicht, sie ergänzt sie: Ohne
// sie liefe der Aufruf in eine Fehlermeldung statt in eine verstaendliche // sie liefe der Aufruf in eine Fehlermeldung statt in eine verständliche
// Auskunft (PROMPT.md §42). // Auskunft (PROMPT.md §42).
if (!mayViewPage(pageDefinition, grantedPermissions)) { if (!mayViewPage(pageDefinition, grantedPermissions)) {
return ( return (
<UnavailablePage <UnavailablePage
title={pageDefinition.label} title={pageDefinition.label}
reason="Ihrer Rolle fehlt die Berechtigung fuer diesen Bereich. Die Pruefung erfolgt auf dem Server; diese Anzeige verhindert nur den Umweg ueber eine Fehlermeldung." reason="Ihrer Rolle fehlt die Berechtigung für diesen Bereich. Die Prüfung erfolgt auf dem Server; diese Anzeige verhindert nur den Umweg über eine Fehlermeldung."
/> />
); );
} }
@ -130,7 +193,7 @@ function CurrentPageContent({
return ( return (
<UnavailablePage <UnavailablePage
title={pageDefinition.label} title={pageDefinition.label}
reason={pageDefinition.unavailableReason ?? 'Dieser Bereich ist noch nicht verfuegbar.'} reason={pageDefinition.unavailableReason ?? 'Dieser Bereich ist noch nicht verfügbar.'}
/> />
); );
} }
@ -139,7 +202,10 @@ function CurrentPageContent({
case 'dashboard': case 'dashboard':
return ( return (
<> <>
<DashboardPage /> <DashboardPage
grantedPermissions={grantedPermissions}
onNavigate={onNavigate}
/>
<div className="mt-4"> <div className="mt-4">
<SystemHealthPanel /> <SystemHealthPanel />
</div> </div>
@ -238,7 +304,7 @@ function CurrentPageContent({
return ( return (
<UnavailablePage <UnavailablePage
title={pageDefinition.label} title={pageDefinition.label}
reason="Dieser Bereich wird gerade gebaut. Die zugehoerigen API-Endpunkte sind vorhanden; es fehlt die Bedienoberflaeche." reason="Dieser Bereich wird gerade gebaut. Die zugehörigen API-Endpunkte sind vorhanden; es fehlt die Bedienoberfläche."
/> />
); );
} }

View File

@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiError, MALFORMED_RESPONSE_CODE, NETWORK_ERROR_CODE, requestApi } from './client'; import { ApiError, MALFORMED_RESPONSE_CODE, NETWORK_ERROR_CODE, requestApi } from './client';
/** Baut eine Antwort, wie sie das Backend liefern wuerde. */ /** Baut eine Antwort, wie sie das Backend liefern würde. */
function buildJsonResponse(responseBody: unknown, statusCode: number): Response { function buildJsonResponse(responseBody: unknown, statusCode: number): Response {
return new Response(JSON.stringify(responseBody), { return new Response(JSON.stringify(responseBody), {
status: statusCode, status: statusCode,
@ -14,7 +14,7 @@ afterEach(() => {
}); });
describe('requestApi', () => { describe('requestApi', () => {
it('gibt die Nutzlast aus der Standard-Huelle zurueck', async () => { it('gibt die Nutzlast aus der Standard-Hülle zurück', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue( vi.fn().mockResolvedValue(
@ -35,7 +35,7 @@ describe('requestApi', () => {
await requestApi('/health'); await requestApi('/health');
// Ohne Correlation ID liesse sich eine Operation nicht Ende-zu-Ende verfolgen. // Ohne Correlation ID ließe sich eine Operation nicht Ende-zu-Ende verfolgen.
const firstCall = fetchMock.mock.calls[0]; const firstCall = fetchMock.mock.calls[0];
expect(firstCall).toBeDefined(); expect(firstCall).toBeDefined();
@ -69,7 +69,7 @@ describe('requestApi', () => {
}); });
}); });
it('meldet eine nicht erreichbare API verstaendlich', async () => { it('meldet eine nicht erreichbare API verständlich', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
// Ein Netzwerkfehler darf nicht als leere Antwort durchgehen. // Ein Netzwerkfehler darf nicht als leere Antwort durchgehen.
@ -79,24 +79,24 @@ describe('requestApi', () => {
it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => { it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ meta: {} }, 200))); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ meta: {} }, 200)));
// Eine Antwort ausserhalb des Vertrags darf nicht stillschweigend // Eine Antwort außerhalb des Vertrags darf nicht stillschweigend
// als leeres Ergebnis interpretiert werden (PROMPT.md §140). // als leeres Ergebnis interpretiert werden (PROMPT.md §140).
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE }); await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
}); });
it('lehnt eine Fehlerantwort ohne Fehlerkoerper ab', async () => { it('lehnt eine Fehlerantwort ohne Fehlerkörper ab', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ unerwartet: true }, 500))); vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ unerwartet: true }, 500)));
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE }); await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
}); });
it('reicht einen Abbruch unveraendert durch', async () => { it('reicht einen Abbruch unverändert durch', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')), vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')),
); );
// Ein Abbruch ist Folge des Aufraeumens und kein Fehlerfall. // Ein Abbruch ist Folge des Aufräumens und kein Fehlerfall.
await expect(requestApi('/health')).rejects.toSatisfy( await expect(requestApi('/health')).rejects.toSatisfy(
(thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError), (thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError),
); );

View File

@ -1,8 +1,8 @@
/** /**
* HTTP-Client fuer die Syncova-API. * HTTP-Client für die Syncova-API.
* *
* Der Client kapselt die Antworthuelle des Backends und liefert Fehler stets als * Der Client kapselt die Antworthülle des Backends und liefert Fehler stets als
* ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es * ApiError. Aufrufer müssen sich damit nicht mit HTTP-Details befassen und es
* kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden * kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden
* (PROMPT.md §140: keine stillen Fehler). * (PROMPT.md §140: keine stillen Fehler).
*/ */
@ -12,15 +12,15 @@ import type { ErrorResponse, SuccessResponse } from '../types/api';
/** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */ /** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */
const API_BASE_PATH = '/api/v1'; const API_BASE_PATH = '/api/v1';
/** Header, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */ /** Header, über den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */
const CORRELATION_ID_HEADER = 'X-Correlation-ID'; const CORRELATION_ID_HEADER = 'X-Correlation-ID';
/** /**
* Header, der eine Anfrage genau einmal wirken laesst (SYNCOVA_API.md §5). * Header, der eine Anfrage genau einmal wirken lässt (SYNCOVA_API.md §5).
* *
* Er gehoert an alle anlegenden und zerstoerenden Aufrufe. Ohne ihn erzeugt ein * Er gehört an alle anlegenden und zerstörenden Aufrufe. Ohne ihn erzeugt ein
* Doppelklick oder ein wiederholter Versuch nach einer Zeitueberschreitung zwei * Doppelklick oder ein wiederholter Versuch nach einer Zeitüberschreitung zwei
* Auftraege — und bei einer Wiederherstellung zwei gleichzeitige Laeufe in * Aufträge — und bei einer Wiederherstellung zwei gleichzeitige Läufe in
* dasselbe Ziel. * dasselbe Ziel.
*/ */
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key'; const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
@ -28,8 +28,8 @@ const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
/** /**
* Fehler einer API-Anfrage. * Fehler einer API-Anfrage.
* *
* Er traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender * Er trägt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
* einen Vorfall gegenueber dem Betreiber eindeutig benennen kann. * einen Vorfall gegenüber dem Betreiber eindeutig benennen kann.
*/ */
export class ApiError extends Error { export class ApiError extends Error {
/** Stabiler maschinenlesbarer Fehlercode. */ /** Stabiler maschinenlesbarer Fehlercode. */
@ -60,8 +60,8 @@ export class ApiError extends Error {
/** /**
* Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht. * Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht.
* *
* Der Client kennt die Anmeldelogik bewusst nicht, sondern erhaelt sie ueber * Der Client kennt die Anmeldelogik bewusst nicht, sondern erhält sie über
* diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und * diese Funktion. Andernfalls entstünde ein Zirkelbezug zwischen dem Client und
* dem Anmeldemodul, das seinerseits den Client verwendet. * dem Anmeldemodul, das seinerseits den Client verwendet.
*/ */
let accessTokenProvider: () => string | null = () => null; let accessTokenProvider: () => string | null = () => null;
@ -71,24 +71,24 @@ export function setAccessTokenProvider(tokenProvider: () => string | null): void
accessTokenProvider = tokenProvider; accessTokenProvider = tokenProvider;
} }
/** Fehlercode fuer eine nicht erreichbare API. */ /** Fehlercode für eine nicht erreichbare API. */
export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE'; export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE';
/** Fehlercode fuer eine unverstaendliche Antwort. */ /** Fehlercode für eine unverständliche Antwort. */
export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE'; export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE';
/** /**
* Erzeugt eine Correlation ID fuer einen Request. * Erzeugt eine Correlation ID für einen Request.
* *
* crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der * crypto.randomUUID ist in allen unterstützten Browsern verfügbar; der
* Rueckfall deckt aeltere Testumgebungen ab. * Rückfall deckt ältere Testumgebungen ab.
*/ */
function createCorrelationId(): string { function createCorrelationId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID(); return crypto.randomUUID();
} }
// Rueckfall ohne kryptografische Garantie - die Correlation ID dient allein // Rückfall ohne kryptografische Garantie - die Correlation ID dient allein
// der Nachvollziehbarkeit, nicht der Sicherheit. // der Nachvollziehbarkeit, nicht der Sicherheit.
return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`; return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`;
} }
@ -97,22 +97,22 @@ function createCorrelationId(): string {
export interface RequestOptions { export interface RequestOptions {
/** HTTP-Methode; Standard ist GET. */ /** HTTP-Methode; Standard ist GET. */
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
/** Optionaler Anfragekoerper, der als JSON gesendet wird. */ /** Optionaler Anfragekörper, der als JSON gesendet wird. */
body?: unknown; body?: unknown;
/** Signal zum Abbrechen der Anfrage. */ /** Signal zum Abbrechen der Anfrage. */
signal?: AbortSignal; signal?: AbortSignal;
/** /**
* Schluessel fuer Idempotenz. * Schlüssel für Idempotenz.
* *
* `true` erzeugt einen zufaelligen Schluessel; eine Zeichenkette wird * `true` erzeugt einen zufälligen Schlüssel; eine Zeichenkette wird
* unveraendert verwendet, damit ein Wiederholungsversuch derselben Handlung * unverändert verwendet, damit ein Wiederholungsversuch derselben Handlung
* denselben Schluessel traegt. * denselben Schlüssel trägt.
*/ */
idempotencyKey?: string | true; idempotencyKey?: string | true;
} }
/** /**
* Fuehrt eine Anfrage gegen die Syncova-API aus. * Führt eine Anfrage gegen die Syncova-API aus.
* *
* @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health". * @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health".
* @returns Die Nutzlast der Antwort. * @returns Die Nutzlast der Antwort.
@ -142,7 +142,7 @@ export async function requestApi<TPayload>(
} }
// Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen // Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen
// unauthentifiziert - der Server entscheidet dann ueber den Zugriff. // unauthentifiziert - der Server entscheidet dann über den Zugriff.
const accessToken = accessTokenProvider(); const accessToken = accessTokenProvider();
if (accessToken !== null) { if (accessToken !== null) {
requestHeaders['Authorization'] = `Bearer ${accessToken}`; requestHeaders['Authorization'] = `Bearer ${accessToken}`;
@ -154,7 +154,7 @@ export async function requestApi<TPayload>(
method: requestMethod, method: requestMethod,
headers: requestHeaders, headers: requestHeaders,
body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body), body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body),
// Die Sitzung laeuft ueber ein Cookie bzw. einen Token desselben Ursprungs. // Die Sitzung läuft über ein Cookie bzw. einen Token desselben Ursprungs.
credentials: 'same-origin', credentials: 'same-origin',
...(requestOptions.signal ? { signal: requestOptions.signal } : {}), ...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
}); });
@ -166,13 +166,13 @@ export async function requestApi<TPayload>(
throw new ApiError({ throw new ApiError({
code: NETWORK_ERROR_CODE, code: NETWORK_ERROR_CODE,
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.', message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus prüfen.',
statusCode: 0, statusCode: 0,
requestId: correlationId, requestId: correlationId,
}); });
} }
// 204 traegt per Definition keinen Koerper. // 204 trägt per Definition keinen Körper.
if (httpResponse.status === 204) { if (httpResponse.status === 204) {
return undefined as TPayload; return undefined as TPayload;
} }
@ -183,19 +183,19 @@ export async function requestApi<TPayload>(
} catch { } catch {
throw new ApiError({ throw new ApiError({
code: MALFORMED_RESPONSE_CODE, code: MALFORMED_RESPONSE_CODE,
message: 'Die Antwort des Servers war unverstaendlich.', message: 'Die Antwort des Servers war unverständlich.',
statusCode: httpResponse.status, statusCode: httpResponse.status,
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId, requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
}); });
} }
// Massgeblich ist die Antworthuelle, nicht allein der HTTP-Status. // Maßgeblich ist die Antworthülle, nicht allein der HTTP-Status.
// //
// Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den // Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den
// Betriebszustand, die Huelle den Inhalt. GET /api/v1/health nutzt genau diese // Betriebszustand, die Hülle den Inhalt. GET /api/v1/health nutzt genau diese
// Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei // Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei
// aber einen vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden // aber einen vollständigen Bericht als Nutzlast. Würde der Client jeden
// Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge // Status außerhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
// ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht. // ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht.
const errorResponse = parsedBody as Partial<ErrorResponse>; const errorResponse = parsedBody as Partial<ErrorResponse>;
if (errorResponse.error) { if (errorResponse.error) {
@ -224,17 +224,17 @@ export async function requestApi<TPayload>(
} }
/** /**
* Laedt eine Datei von der API herunter. * Lädt eine Datei von der API herunter.
* *
* Sie steht neben requestApi und nicht darin: Eine Datei traegt **keine** * Sie steht neben requestApi und nicht darin: Eine Datei trägt **keine**
* Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi * Antworthülle, sondern ist der Inhalt selbst. Würde man sie durch requestApi
* schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine * schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine
* unverstaendliche Antwort — obwohl alles in Ordnung ist. * unverständliche Antwort — obwohl alles in Ordnung ist.
* *
* Der Fehlerfall geht dagegen sehr wohl durch die Huelle: Scheitert die Anfrage, * Der Fehlerfall geht dagegen sehr wohl durch die Hülle: Scheitert die Anfrage,
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die * antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprüft, bevor die
* Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als * Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als
* „bericht.pdf" im Download-Ordner, und der Anwender saehe statt einer Meldung * „bericht.pdf" im Download-Ordner, und der Anwender sähe statt einer Meldung
* eine kaputte Datei. * eine kaputte Datei.
*/ */
export async function downloadApiFile( export async function downloadApiFile(
@ -272,7 +272,7 @@ export async function downloadApiFile(
throw new ApiError({ throw new ApiError({
code: NETWORK_ERROR_CODE, code: NETWORK_ERROR_CODE,
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.', message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus prüfen.',
statusCode: 0, statusCode: 0,
requestId: correlationId, requestId: correlationId,
}); });
@ -310,8 +310,8 @@ export async function downloadApiFile(
/** /**
* Liest den Dateinamen aus dem Content-Disposition-Kopf. * Liest den Dateinamen aus dem Content-Disposition-Kopf.
* *
* Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer waehlt einen — * Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer wählt einen —
* ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner. * ein erfundener Name aus dem Kopf zu lesen wäre schlimmer als keiner.
*/ */
function parseFileNameFromDisposition(dispositionHeader: string | null): string { function parseFileNameFromDisposition(dispositionHeader: string | null): string {
if (dispositionHeader === null) { if (dispositionHeader === null) {

View File

@ -1,14 +1,14 @@
/** /**
* Allgemeiner Lade-Hook fuer API-Ressourcen. * Allgemeiner Lade-Hook für API-Ressourcen.
* *
* Er folgt demselben Muster wie useSystemHealth: Solange kein echtes Ergebnis * Er folgt demselben Muster wie useSystemHealth: Solange kein echtes Ergebnis
* vorliegt, bleibt der Zustand ausdruecklich „laedt" oder „Fehler" — niemals ein * vorliegt, bleibt der Zustand ausdrücklich „lädt" oder „Fehler" — niemals ein
* leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden liesse * leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden ließe
* (PROMPT.md §139). * (PROMPT.md §139).
* *
* Der Hook ersetzt die Wiederholung derselben dreissig Zeilen in jeder Seite. * Der Hook ersetzt die Wiederholung derselben dreißig Zeilen in jeder Seite.
* Genau deshalb steht er hier und nicht in einer der Seiten: Ein zweiter Ort mit * Genau deshalb steht er hier und nicht in einer der Seiten: Ein zweiter Ort mit
* eigener Fehlerbehandlung waere ein zweiter Ort, an dem sie fehlen kann. * eigener Fehlerbehandlung wäre ein zweiter Ort, an dem sie fehlen kann.
*/ */
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
@ -25,17 +25,17 @@ export interface UseApiResourceResult<TPayload> {
readonly data: TPayload | null; readonly data: TPayload | null;
/** Aufgetretener Fehler; null, wenn keiner vorliegt. */ /** Aufgetretener Fehler; null, wenn keiner vorliegt. */
readonly loadError: ApiError | null; readonly loadError: ApiError | null;
/** Laedt die Ressource erneut. */ /** Lädt die Ressource erneut. */
readonly reload: () => void; readonly reload: () => void;
} }
/** /**
* Laedt eine Ressource und haelt ihren Zustand. * Lädt eine Ressource und hält ihren Zustand.
* *
* @param loadResource Ladefunktion; sie erhaelt ein Abbruchsignal. * @param loadResource Ladefunktion; sie erhält ein Abbruchsignal.
* @param dependencyKey Aendert sich dieser Wert, wird neu geladen. Ein einzelner * @param dependencyKey Ändert sich dieser Wert, wird neu geladen. Ein einzelner
* Schluessel statt eines Abhaengigkeitsarrays: Ein Array mit wechselnder Laenge * Schlüssel statt eines Abhängigkeitsarrays: Ein Array mit wechselnder Länge
* ist in React ein Fehler, und ein Objekt als Abhaengigkeit laedt bei jedem * ist in React ein Fehler, und ein Objekt als Abhängigkeit lädt bei jedem
* Rendern neu. * Rendern neu.
*/ */
export function useApiResource<TPayload>( export function useApiResource<TPayload>(
@ -45,11 +45,11 @@ export function useApiResource<TPayload>(
// reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen. // reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen.
const [reloadCounter, setReloadCounter] = useState(0); const [reloadCounter, setReloadCounter] = useState(0);
// Das Ergebnis traegt den Schluessel, unter dem es entstanden ist. Daraus // Das Ergebnis trägt den Schlüssel, unter dem es entstanden ist. Daraus
// laesst sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen: // lässt sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen:
// Passt der Schluessel nicht zum aktuellen, laeuft die Anfrage noch. Ein // Passt der Schlüssel nicht zum aktuellen, läuft die Anfrage noch. Ein
// setState im Effektkoerper loeste dagegen eine zweite Renderrunde aus, // setState im Effektkörper löste dagegen eine zweite Renderrunde aus,
// bevor ueberhaupt etwas geladen wurde. // bevor überhaupt etwas geladen wurde.
const [loadResult, setLoadResult] = useState<{ const [loadResult, setLoadResult] = useState<{
key: string; key: string;
data: TPayload | null; data: TPayload | null;
@ -71,7 +71,7 @@ export function useApiResource<TPayload>(
setLoadResult({ key: effectiveKey, data: loadedPayload, error: null }); setLoadResult({ key: effectiveKey, data: loadedPayload, error: null });
} catch (caughtError) { } catch (caughtError) {
// Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens. // Ein Abbruch ist kein Fehler, sondern Folge des Aufräumens.
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') { if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
return; return;
} }
@ -95,14 +95,14 @@ export function useApiResource<TPayload>(
void loadFromApi(); void loadFromApi();
return () => abortController.abort(); return () => abortController.abort();
// loadResource bewusst nicht in den Abhaengigkeiten: Eine bei jedem Rendern // loadResource bewusst nicht in den Abhängigkeiten: Eine bei jedem Rendern
// neu gebildete Funktion loeste sonst eine Endlosschleife aus. Der // neu gebildete Funktion löste sonst eine Endlosschleife aus. Der
// effectiveKey steuert das Neuladen ausdruecklich. // effectiveKey steuert das Neuladen ausdrücklich.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveKey]); }, [effectiveKey]);
// Solange kein Ergebnis zum aktuellen Schluessel vorliegt, wird geladen. Die // Solange kein Ergebnis zum aktuellen Schlüssel vorliegt, wird geladen. Die
// vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel laesst die // vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel lässt die
// Tabelle also nicht aufblitzen. // Tabelle also nicht aufblitzen.
if (loadResult === null || loadResult.key !== effectiveKey) { if (loadResult === null || loadResult.key !== effectiveKey) {
return { return {

View File

@ -1,24 +1,24 @@
/** /**
* Haken fuer schreibende Aufrufe. * Haken für schreibende Aufrufe.
* *
* Bisher gab es nur `useApiResource` zum Lesen — was daran lag, dass die * Bisher gab es nur `useApiResource` zum Lesen — was daran lag, dass die
* Oberflaeche kaum etwas schreiben konnte. Dieser Haken traegt die drei Dinge, * Oberfläche kaum etwas schreiben konnte. Dieser Haken trägt die drei Dinge,
* die eine veraendernde Handlung in einer Betriebskonsole braucht: * die eine verändernde Handlung in einer Betriebskonsole braucht:
* *
* 1. **Doppelklickschutz.** Solange die Anfrage laeuft, wird keine zweite * 1. **Doppelklickschutz.** Solange die Anfrage läuft, wird keine zweite
* ausgeloest. Bei einem Sicherungslauf waere die zweite ein 409, bei einer * ausgelöst. Bei einem Sicherungslauf wäre die zweite ein 409, bei einer
* Loeschung schlimmstenfalls mehr als beabsichtigt. * Löschung schlimmstenfalls mehr als beabsichtigt.
* 2. **Die Vorgangsnummer bis in die Meldung.** Sie ist die einzige Angabe, mit * 2. **Die Vorgangsnummer bis in die Meldung.** Sie ist die einzige Angabe, mit
* der sich ein Vorfall spaeter im Serverprotokoll wiederfinden laesst. * der sich ein Vorfall später im Serverprotokoll wiederfinden lässt.
* 3. **Kein `setState` nach dem Aushaengen.** Ein Dialog, der sich nach dem * 3. **Kein `setState` nach dem Aushängen.** Ein Dialog, der sich nach dem
* Absenden schliesst, ist weg, bevor die Antwort da ist. * Absenden schließt, ist weg, bevor die Antwort da ist.
*/ */
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError } from './client'; import { ApiError } from './client';
export interface MutationState { export interface MutationState {
/** Laeuft die Anfrage gerade? */ /** Läuft die Anfrage gerade? */
readonly isRunning: boolean; readonly isRunning: boolean;
/** Fehler des letzten Versuchs, sonst null. */ /** Fehler des letzten Versuchs, sonst null. */
readonly error: ApiError | null; readonly error: ApiError | null;
@ -27,17 +27,17 @@ export interface MutationState {
export interface MutationResult<TArguments extends unknown[], TResult> { export interface MutationResult<TArguments extends unknown[], TResult> {
readonly isRunning: boolean; readonly isRunning: boolean;
readonly error: ApiError | null; readonly error: ApiError | null;
/** Fuehrt die Handlung aus. Liefert das Ergebnis oder null bei Fehler. */ /** Führt die Handlung aus. Liefert das Ergebnis oder null bei Fehler. */
readonly run: (...callArguments: TArguments) => Promise<TResult | null>; readonly run: (...callArguments: TArguments) => Promise<TResult | null>;
/** Verwirft eine stehende Fehlermeldung. */ /** Verwirft eine stehende Fehlermeldung. */
readonly reset: () => void; readonly reset: () => void;
} }
/** /**
* Baut einen Aufrufer fuer eine veraendernde API-Handlung. * Baut einen Aufrufer für eine verändernde API-Handlung.
* *
* @param performRequest Die eigentliche Anfrage. * @param performRequest Die eigentliche Anfrage.
* @param callbacks Rueckmeldungen bei Erfolg und Fehler. * @param callbacks Rückmeldungen bei Erfolg und Fehler.
*/ */
export function useMutation<TArguments extends unknown[], TResult>( export function useMutation<TArguments extends unknown[], TResult>(
performRequest: (...callArguments: TArguments) => Promise<TResult>, performRequest: (...callArguments: TArguments) => Promise<TResult>,
@ -51,14 +51,14 @@ export function useMutation<TArguments extends unknown[], TResult>(
error: null, error: null,
}); });
// Die Rueckmeldungen liegen in einer Referenz, damit `run` seine Identitaet // Die Rückmeldungen liegen in einer Referenz, damit `run` seine Identität
// behaelt. Sonst loeste jede Renderrunde einen neuen Effekt aus, der `run` // behält. Sonst löste jede Renderrunde einen neuen Effekt aus, der `run`
// in seiner Abhaengigkeitsliste hat. // in seiner Abhängigkeitsliste hat.
const callbacksReference = useRef(callbacks); const callbacksReference = useRef(callbacks);
// Die Zuweisung gehoert in einen Effekt, nicht in den Renderkoerper: Waehrend // Die Zuweisung gehört in einen Effekt, nicht in den Renderkörper: Während
// des Renderns eine Referenz zu beschreiben ist ein Nebeneffekt, und React // des Renderns eine Referenz zu beschreiben ist ein Nebeneffekt, und React
// darf ein Rendern verwerfen oder zweimal ausfuehren. // darf ein Rendern verwerfen oder zweimal ausführen.
useEffect(() => { useEffect(() => {
callbacksReference.current = callbacks; callbacksReference.current = callbacks;
}); });
@ -84,8 +84,8 @@ export function useMutation<TArguments extends unknown[], TResult>(
setMutationState({ isRunning: false, error: null }); setMutationState({ isRunning: false, error: null });
} }
// Die Rueckmeldung laeuft auch dann, wenn die Komponente weg ist: Ein // Die Rückmeldung läuft auch dann, wenn die Komponente weg ist: Ein
// Dialog schliesst sich nach dem Absenden, und die Liste dahinter soll // Dialog schließt sich nach dem Absenden, und die Liste dahinter soll
// sich trotzdem aktualisieren. // sich trotzdem aktualisieren.
callbacksReference.current.onSuccess?.(result); callbacksReference.current.onSuccess?.(result);
@ -139,33 +139,33 @@ export function useMutation<TArguments extends unknown[], TResult>(
/** Bekannte Lagen samt Abhilfe. */ /** Bekannte Lagen samt Abhilfe. */
const errorCodeExplanations: Readonly<Record<string, string>> = { const errorCodeExplanations: Readonly<Record<string, string>> = {
// Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des // Diese Lage entsteht bei einem zweiten Anstoß und ist kein Fehler des
// Anwenders — der Auftrag laeuft bereits. // Anwenders — der Auftrag läuft bereits.
JOB_ALREADY_RUNNING: JOB_ALREADY_RUNNING:
'Dieser Auftrag laeuft bereits. Ein zweiter Lauf wuerde in dieselbe Kette schreiben.', 'Dieser Auftrag läuft bereits. Ein zweiter Lauf würde in dieselbe Kette schreiben.',
RESTORE_ALREADY_RUNNING: RESTORE_ALREADY_RUNNING:
'In dieses Ziel laeuft bereits eine Wiederherstellung. Zwei gleichzeitige wuerden sich gegenseitig ueberschreiben.', 'In dieses Ziel läuft bereits eine Wiederherstellung. Zwei gleichzeitige würden sich gegenseitig überschreiben.',
VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprueft.', VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprüft.',
TARGET_NOT_EMPTY: TARGET_NOT_EMPTY:
'Das Zielverzeichnis ist nicht leer. Ueberschreiben verlangt die ausdrueckliche Bestaetigung.', 'Das Zielverzeichnis ist nicht leer. Überschreiben verlangt die ausdrückliche Bestätigung.',
RESTORE_TARGET_FORBIDDEN: RESTORE_TARGET_FORBIDDEN:
'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurueckgeschrieben.', 'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurückgeschrieben.',
LAST_ADMINISTRATOR: LAST_ADMINISTRATOR:
'Das ist der letzte Administrator. Er laesst sich weder loeschen noch deaktivieren — sonst waere die Anlage nicht mehr verwaltbar.', 'Das ist der letzte Administrator. Er lässt sich weder löschen noch deaktivieren — sonst wäre die Anlage nicht mehr verwaltbar.',
BUILTIN_ROLE_IMMUTABLE: BUILTIN_ROLE_IMMUTABLE:
'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.', 'Mitgelieferte Rollen sind unveränderlich. Eine Änderung verschöbe die Bedeutung bestehender Zuweisungen.',
REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.', REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Aufträge oder Sicherungen.',
REPOSITORY_LOCKED: REPOSITORY_LOCKED:
'Das Repository ist gesperrt — vermutlich laeuft eine Sicherung. Haelt der Zustand an, hilft "syncova-repo break-lock".', 'Das Repository ist gesperrt — vermutlich läuft eine Sicherung. Hält der Zustand an, hilft "syncova-repo break-lock".',
REPOSITORY_FULL: REPOSITORY_FULL:
'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf wuerde die Lage verschaerfen.', 'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf würde die Lage verschärfen.',
IMMUTABLE_UNTIL: IMMUTABLE_UNTIL:
'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und laesst sich bis zum Fristende nicht loeschen.', 'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und lässt sich bis zum Fristende nicht löschen.',
LEGAL_HOLD: LEGAL_HOLD:
'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.', 'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.',
SERVICE_UNAVAILABLE: SERVICE_UNAVAILABLE:
'Der Dienst ist derzeit nicht vollstaendig verfuegbar. Das ist kein Problem Ihrer Sitzung.', 'Der Dienst ist derzeit nicht vollständig verfügbar. Das ist kein Problem Ihrer Sitzung.',
FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung fuer diese Handlung.', FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung für diese Handlung.',
}; };
/** /**
@ -173,14 +173,14 @@ const errorCodeExplanations: Readonly<Record<string, string>> = {
* *
* **Die Servermeldung hat Vorrang.** Sie kennt den Einzelfall — welcher Bericht, * **Die Servermeldung hat Vorrang.** Sie kennt den Einzelfall — welcher Bericht,
* welches Repository, welche Datei —, und diese Genauigkeit ist mehr wert als * welches Repository, welche Datei —, und diese Genauigkeit ist mehr wert als
* jede allgemeine Erklaerung. Die Tabelle springt nur ein, wenn der Server * jede allgemeine Erklärung. Die Tabelle springt nur ein, wenn der Server
* keine Meldung mitschickt. * keine Meldung mitschickt.
* *
* Das war zunaechst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein * Das war zunächst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein
* `SERVICE_UNAVAILABLE` mit der Meldung „Fuer diesen Bericht ist keine * `SERVICE_UNAVAILABLE` mit der Meldung „Für diesen Bericht ist keine
* Sicherheitspruefung eingerichtet." wurde durch den allgemeinen Satz „Der * Sicherheitsprüfung eingerichtet." wurde durch den allgemeinen Satz „Der
* Dienst ist derzeit nicht vollstaendig verfuegbar" ersetzt. Der Betreiber * Dienst ist derzeit nicht vollständig verfügbar" ersetzt. Der Betreiber
* haette den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses * hätte den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses
* einen Berichts. * einen Berichts.
*/ */
export function describeApiError(apiError: ApiError): string { export function describeApiError(apiError: ApiError): string {
@ -194,10 +194,10 @@ export function describeApiError(apiError: ApiError): string {
} }
/** /**
* Liefert die allgemeine Erklaerung zu einem Fehlercode, sofern es eine gibt. * Liefert die allgemeine Erklärung zu einem Fehlercode, sofern es eine gibt.
* *
* Gedacht als **Ergaenzung** zur Servermeldung, nicht als Ersatz: Wo eine Lage * Gedacht als **Ergänzung** zur Servermeldung, nicht als Ersatz: Wo eine Lage
* eine bekannte Abhilfe hat, laesst sie sich zusaetzlich anzeigen. * eine bekannte Abhilfe hat, lässt sie sich zusätzlich anzeigen.
*/ */
export function explainApiErrorCode(errorCode: string): string | undefined { export function explainApiErrorCode(errorCode: string): string | undefined {
return errorCodeExplanations[errorCode]; return errorCodeExplanations[errorCode];

View File

@ -1,19 +1,20 @@
/** /**
* Rahmen der angemeldeten Ansicht: Seitenleiste, Kopfzeile, Inhalt. * Rahmen der angemeldeten Ansicht: Seitenleiste, Kopfzeile, Inhalt.
* *
* Die Seitenleiste laesst sich einklappen und merkt sich das. Der Grund ist * Die Seitenleiste lässt sich einklappen und merkt sich das. Der Grund ist
* nicht Aesthetik: Wer eine Stoerung bearbeitet, hat oft eine breite Tabelle mit * nicht Ästhetik: Wer eine Störung bearbeitet, hat oft eine breite Tabelle mit
* Laufzeiten und Fehlercodes vor sich, und 240 Pixel Menue sind dann im Weg. * Laufzeiten und Fehlercodes vor sich, und 240 Pixel Menü sind dann im Weg.
* *
* Auf schmalen Geraeten wird sie zur Schublade. Der Plan verlangt Bedienbarkeit * Auf schmalen Geräten wird sie zur Schublade. Der Plan verlangt Bedienbarkeit
* auf Tablets (PROMPT.md §68) — und ein Bereitschaftsdienst sieht nachts als * auf Tablets (PROMPT.md §68) — und ein Bereitschaftsdienst sieht nachts als
* Erstes auf ein Telefon. * Erstes auf ein Telefon.
*/ */
import { Menu, Moon, PanelLeftClose, PanelLeft, Sun, X } from 'lucide-react'; import { Menu, Moon, PanelLeftClose, PanelLeft, Sun, Timer, X } from 'lucide-react';
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui'; import { Button } from '@/components/ui';
import { formatSessionClock, type SessionClock } from '@/features/auth/useSession';
import { import {
ALL_PAGES, ALL_PAGES,
SECTION_LABELS, SECTION_LABELS,
@ -43,6 +44,7 @@ export function AppShell({
onNavigate, onNavigate,
grantedPermissions, grantedPermissions,
userName, userName,
sessionClock,
onLogout, onLogout,
children, children,
}: { }: {
@ -50,6 +52,8 @@ export function AppShell({
readonly onNavigate: (pageIdentifier: string) => void; readonly onNavigate: (pageIdentifier: string) => void;
readonly grantedPermissions: readonly string[]; readonly grantedPermissions: readonly string[];
readonly userName: string; readonly userName: string;
/** Verbleibende Sitzungszeit für die Anzeige. */
readonly sessionClock: SessionClock;
readonly onLogout: () => void; readonly onLogout: () => void;
readonly children: ReactNode; readonly children: ReactNode;
}) { }) {
@ -60,7 +64,7 @@ export function AppShell({
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Das Thema wird am Wurzelelement gesetzt, weil die Farbvariablen dort // Das Thema wird am Wurzelelement gesetzt, weil die Farbvariablen dort
// haengen — und weil ein Dialog im Portal sonst im falschen Thema erschiene. // hängen — und weil ein Dialog im Portal sonst im falschen Thema erschiene.
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', themePreference); document.documentElement.setAttribute('data-theme', themePreference);
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference); window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
@ -71,13 +75,13 @@ export function AppShell({
return ( return (
<div className="flex min-h-screen bg-page"> <div className="flex min-h-screen bg-page">
{/* Abdunklung hinter der Schublade auf schmalen Geraeten. */} {/* Abdunklung hinter der Schublade auf schmalen Geräten. */}
{isMobileMenuOpen ? ( {isMobileMenuOpen ? (
<button <button
type="button" type="button"
className="fixed inset-0 z-30 bg-black/50 lg:hidden" className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
aria-label="Menue schliessen" aria-label="Menü schließen"
/> />
) : null} ) : null}
@ -99,7 +103,7 @@ export function AppShell({
type="button" type="button"
className="ml-auto rounded p-1 text-fg-subtle hover:bg-hover hover:text-fg lg:hidden" className="ml-auto rounded p-1 text-fg-subtle hover:bg-hover hover:text-fg lg:hidden"
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
aria-label="Menue schliessen" aria-label="Menü schließen"
> >
<X className="size-4" /> <X className="size-4" />
</button> </button>
@ -128,7 +132,7 @@ export function AppShell({
isActive={page.id === currentPageId} isActive={page.id === currentPageId}
isCollapsed={isSidebarCollapsed} isCollapsed={isSidebarCollapsed}
onSelect={() => { onSelect={() => {
// Die Schublade schliesst sich beim Auswaehlen, nicht // Die Schublade schließt sich beim Auswählen, nicht
// in einem Effekt auf den Seitenwechsel: Der Anlass // in einem Effekt auf den Seitenwechsel: Der Anlass
// ist die Handlung, nicht der neue Zustand. // ist die Handlung, nicht der neue Zustand.
setIsMobileMenuOpen(false); setIsMobileMenuOpen(false);
@ -155,7 +159,7 @@ export function AppShell({
}) })
} }
className="hidden w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-fg-muted hover:bg-hover hover:text-fg lg:flex" className="hidden w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-fg-muted hover:bg-hover hover:text-fg lg:flex"
title={isSidebarCollapsed ? 'Menue ausklappen' : 'Menue einklappen'} title={isSidebarCollapsed ? 'Menü ausklappen' : 'Menü einklappen'}
> >
{isSidebarCollapsed ? ( {isSidebarCollapsed ? (
<PanelLeft className="size-4 shrink-0" /> <PanelLeft className="size-4 shrink-0" />
@ -180,7 +184,7 @@ export function AppShell({
type="button" type="button"
className="rounded p-1.5 text-fg-muted hover:bg-hover hover:text-fg lg:hidden" className="rounded p-1.5 text-fg-muted hover:bg-hover hover:text-fg lg:hidden"
onClick={() => setIsMobileMenuOpen(true)} onClick={() => setIsMobileMenuOpen(true)}
aria-label="Menue oeffnen" aria-label="Menü öffnen"
> >
<Menu className="size-5" /> <Menu className="size-5" />
</button> </button>
@ -208,6 +212,23 @@ export function AppShell({
{userName} {userName}
</span> </span>
{/* Die Uhr steht neben „Abmelden", weil dort auch die Handlung
sitzt, die sie ankündigt. Unter fünf Minuten wird sie auffällig —
früher zu warnen erzeugt Gewöhnung, später bleibt keine Zeit. */}
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs tabular',
sessionClock.isExpiringSoon
? 'bg-warning-bg font-medium text-warning'
: 'text-fg-subtle',
)}
title={`Die Sitzung endet in ${formatSessionClock(sessionClock.remainingSeconds)}. Jede Eingabe verlängert sie, bis die harte Obergrenze von 30 Minuten erreicht ist.`}
>
<Timer className="size-3.5" aria-hidden />
<span className="sr-only">Verbleibende Sitzungszeit: </span>
{formatSessionClock(sessionClock.remainingSeconds)}
</span>
<Button variant="ghost" size="sm" onClick={onLogout}> <Button variant="ghost" size="sm" onClick={onLogout}>
Abmelden Abmelden
</Button> </Button>
@ -244,8 +265,8 @@ function NavigationItem({
isActive isActive
? 'bg-accent-subtle font-medium text-accent' ? 'bg-accent-subtle font-medium text-accent'
: 'text-fg-muted hover:bg-hover hover:text-fg', : 'text-fg-muted hover:bg-hover hover:text-fg',
// Ein noch nicht verfuegbarer Bereich bleibt anklickbar — die Zielseite // Ein noch nicht verfügbarer Bereich bleibt anklickbar — die Zielseite
// erklaert, was fehlt. Ihn zu sperren verschwiege den Ausbaustand. // erklärt, was fehlt. Ihn zu sperren verschwiege den Ausbaustand.
!page.available && 'opacity-60', !page.available && 'opacity-60',
)} )}
> >

View File

@ -0,0 +1,112 @@
/**
* Fehlergrenze um den Seiteninhalt.
*
* Ohne sie reißt ein einziger Fehler in einer Komponente den gesamten Baum ab —
* React hängt alles aus, und übrig bleibt eine leere Seite. Im dunklen Thema ist
* das ein schwarzer Bildschirm ohne jeden Hinweis, und ein Betreiber hat keine
* Möglichkeit zu melden, was schiefging.
*
* Die Grenze sitzt **um den Inhalt**, nicht um die ganze Anwendung: Seitenleiste
* und Kopfzeile bleiben stehen, und man kann auf eine andere Seite wechseln,
* statt neu laden zu müssen.
*
* Klassenkomponente, weil React für Fehlergrenzen keine Entsprechung als Haken
* anbietet.
*/
import { AlertTriangle } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button } from '@/components/ui';
interface ErrorBoundaryProps {
/** Wechselt der Wert, wird der Fehler verworfen — etwa beim Seitenwechsel. */
readonly resetKey: string;
readonly children: ReactNode;
}
interface ErrorBoundaryState {
readonly caughtError: Error | null;
readonly componentStack: string;
readonly resetKey: string;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
public constructor(properties: ErrorBoundaryProps) {
super(properties);
this.state = { caughtError: null, componentStack: '', resetKey: properties.resetKey };
}
public static getDerivedStateFromError(caughtError: Error): Partial<ErrorBoundaryState> {
return { caughtError };
}
/**
* Verwirft den Fehler beim Seitenwechsel.
*
* Abgeleitet statt in einem Effekt gesetzt: Sonst zeigte die neue Seite noch
* einen Wimpernschlag lang den Fehler der alten.
*/
public static getDerivedStateFromProps(
nextProperties: ErrorBoundaryProps,
currentState: ErrorBoundaryState,
): Partial<ErrorBoundaryState> | null {
if (nextProperties.resetKey !== currentState.resetKey) {
return { caughtError: null, componentStack: '', resetKey: nextProperties.resetKey };
}
return null;
}
public override componentDidCatch(caughtError: Error, errorInfo: ErrorInfo): void {
this.setState({ componentStack: errorInfo.componentStack ?? '' });
// Die Meldung gehört in die Entwicklerkonsole, damit sie sich kopieren
// lässt. Sie hier zu verschlucken wäre der stille Fehler, den die
// Entwicklungsregeln ausdrücklich verbieten.
console.error('Unbehandelter Fehler in der Oberfläche:', caughtError, errorInfo);
}
public override render(): ReactNode {
const { caughtError, componentStack } = this.state;
if (caughtError === null) {
return this.props.children;
}
return (
<div className="rounded-lg border border-critical/30 bg-card p-6" role="alert">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-critical" aria-hidden />
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-fg">
Diese Seite konnte nicht angezeigt werden
</h2>
<p className="mt-1 text-sm text-fg-muted">
Die übrige Konsole arbeitet weiter. Wechseln Sie den Bereich oder
laden Sie neu.
</p>
<pre className="mt-4 max-h-40 overflow-auto rounded-md bg-sunken p-3 text-xs text-fg">
{caughtError.message || caughtError.name}
{componentStack ? `\n${componentStack.trim().split('\n').slice(0, 6).join('\n')}` : ''}
</pre>
<div className="mt-4 flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => this.setState({ caughtError: null, componentStack: '' })}
>
Erneut versuchen
</Button>
<Button variant="ghost" size="sm" onClick={() => window.location.reload()}>
Seite neu laden
</Button>
</div>
</div>
</div>
</div>
);
}
}

View File

@ -1,4 +1,4 @@
/** Kopfbereich einer Seite: Titel, Erlaeuterung, Handlungen. */ /** Kopfbereich einer Seite: Titel, Erläuterung, Handlungen. */
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@ -10,7 +10,7 @@ export function PageHeader({
description, description,
actions, actions,
onBack, onBack,
backLabel = 'Zurueck', backLabel = 'Zurück',
className, className,
}: { }: {
readonly title: ReactNode; readonly title: ReactNode;

View File

@ -1,10 +1,10 @@
/** /**
* Schaltflaeche. * Schaltfläche.
* *
* Die Auspraegung "destructive" ist bewusst eigenstaendig und nicht nur eine * Die Ausprägung "destructive" ist bewusst eigenständig und nicht nur eine
* rote Farbe: Loeschende Handlungen sollen sich auch dann unterscheiden, wenn * rote Farbe: Löschende Handlungen sollen sich auch dann unterscheiden, wenn
* jemand die Oberflaeche in Graustufen betrachtet oder farbfehlsichtig ist. * jemand die Oberfläche in Graustufen betrachtet oder farbfehlsichtig ist.
* Deshalb traegt sie zusaetzlich einen kraeftigen Rand. * Deshalb trägt sie zusätzlich einen kräftigen Rand.
*/ */
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
@ -14,9 +14,9 @@ import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
// Gemeinsame Grundlage aller Auspraegungen. // Gemeinsame Grundlage aller Ausprägungen.
[ [
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md', 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg',
'text-sm font-medium transition-colors', 'text-sm font-medium transition-colors',
'disabled:pointer-events-none disabled:opacity-50', 'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
@ -24,7 +24,7 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
primary: 'bg-accent text-accent-fg hover:bg-accent-hover', primary: 'bg-accent text-accent-fg shadow-[var(--shadow-card)] hover:bg-accent-hover',
secondary: 'border border-line-strong bg-card text-fg hover:bg-hover', secondary: 'border border-line-strong bg-card text-fg hover:bg-hover',
ghost: 'text-fg-muted hover:bg-hover hover:text-fg', ghost: 'text-fg-muted hover:bg-hover hover:text-fg',
destructive: destructive:
@ -50,7 +50,7 @@ export interface ButtonProps
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
/** Rendert die Kindkomponente statt eines <button> (z. B. einen Link). */ /** Rendert die Kindkomponente statt eines <button> (z. B. einen Link). */
readonly asChild?: boolean | undefined; readonly asChild?: boolean | undefined;
/** Zeigt einen Ladeanzeiger und sperrt die Schaltflaeche. */ /** Zeigt einen Ladeanzeiger und sperrt die Schaltfläche. */
readonly isLoading?: boolean | undefined; readonly isLoading?: boolean | undefined;
} }
@ -64,9 +64,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
<RenderedComponent <RenderedComponent
ref={forwardedReference} ref={forwardedReference}
className={cn(buttonVariants({ variant, size }), className)} className={cn(buttonVariants({ variant, size }), className)}
// Waehrend eine Handlung laeuft, darf sie nicht ein zweites Mal ausgeloest // Während eine Handlung läuft, darf sie nicht ein zweites Mal ausgelöst
// werden. Bei einem Sicherungslauf waere der zweite Klick ein 409, bei // werden. Bei einem Sicherungslauf wäre der zweite Klick ein 409, bei
// einer Loeschung schlimmstenfalls mehr als beabsichtigt. // einer Löschung schlimmstenfalls mehr als beabsichtigt.
disabled={disabled || isLoading} disabled={disabled || isLoading}
{...restProps} {...restProps}
> >

View File

@ -1,12 +1,27 @@
/** Karte — die Grundflaeche aller Inhalte. */ /** Karte — die Grundfläche aller Inhalte. */
import { Slot } from '@radix-ui/react-slot';
import type { HTMLAttributes, ReactNode } from 'react'; import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export function Card({ className, ...restProps }: HTMLAttributes<HTMLDivElement>) { export function Card({
className,
asChild = false,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
/**
* Rendert die Kindkomponente statt eines <div>.
*
* Fuer anklickbare Kacheln: Eine Schaltflaeche ist mit der Tastatur
* erreichbar, ein div mit Klick-Handler nicht.
*/
readonly asChild?: boolean | undefined;
}) {
const RenderedComponent = asChild ? Slot : 'div';
return ( return (
<div <RenderedComponent
className={cn('rounded-lg border border-line bg-card', className)} className={cn('rounded-xl border border-line bg-card shadow-[var(--shadow-card)]', className)}
{...restProps} {...restProps}
/> />
); );
@ -26,7 +41,7 @@ export function CardHeader({
return ( return (
<div <div
className={cn( className={cn(
'flex items-start justify-between gap-4 border-b border-line px-5 py-4', 'flex items-start justify-between gap-4 border-b border-line px-5 py-3.5',
className, className,
)} )}
{...restProps} {...restProps}

View File

@ -1,14 +1,14 @@
/** /**
* Tabelle. * Tabelle.
* *
* Eine Betriebskonsole besteht zu grossen Teilen aus Tabellen, deshalb steckt * Eine Betriebskonsole besteht zu großen Teilen aus Tabellen, deshalb steckt
* die Muehe hier und nicht in Effekten: * die Mühe hier und nicht in Effekten:
* *
* - **Leer ist nicht gleich leer.** "Noch nichts angelegt" und "der Filter * - **Leer ist nicht gleich leer.** "Noch nichts angelegt" und "der Filter
* trifft nichts" sind verschiedene Lagen und brauchen verschiedene Texte; * trifft nichts" sind verschiedene Lagen und brauchen verschiedene Texte;
* die erste verlangt eine Schaltflaeche, die zweite nicht. * die erste verlangt eine Schaltfläche, die zweite nicht.
* - **Waehrend des Nachladens bleibt die alte Tabelle stehen** und wird nur * - **Während des Nachladens bleibt die alte Tabelle stehen** und wird nur
* abgeblendet. Sie durch einen Ladeanzeiger zu ersetzen laesst die Seite bei * abgeblendet. Sie durch einen Ladeanzeiger zu ersetzen lässt die Seite bei
* jedem Filterwechsel aufblitzen. * jedem Filterwechsel aufblitzen.
* - **Breite Tabellen laufen in ihrem eigenen Rahmen**, nicht auf der Seite. * - **Breite Tabellen laufen in ihrem eigenen Rahmen**, nicht auf der Seite.
*/ */
@ -20,7 +20,7 @@ export interface TableColumn<RowType> {
readonly key: string; readonly key: string;
readonly header: ReactNode; readonly header: ReactNode;
readonly render: (row: RowType) => ReactNode; readonly render: (row: RowType) => ReactNode;
/** Rechtsbuendig — fuer Zahlen. */ /** Rechtsbündig — für Zahlen. */
readonly numeric?: boolean | undefined; readonly numeric?: boolean | undefined;
readonly className?: string | undefined; readonly className?: string | undefined;
} }
@ -31,7 +31,7 @@ export function DataTable<RowType>({
getRowKey, getRowKey,
onRowClick, onRowClick,
isLoading = false, isLoading = false,
emptyTitle = 'Keine Eintraege', emptyTitle = 'Keine Einträge',
emptyDescription, emptyDescription,
emptyAction, emptyAction,
className, className,
@ -62,13 +62,13 @@ export function DataTable<RowType>({
<div className={cn('w-full overflow-x-auto', className)}> <div className={cn('w-full overflow-x-auto', className)}>
<table className="w-full border-collapse text-sm"> <table className="w-full border-collapse text-sm">
<thead> <thead>
<tr className="border-b border-line"> <tr className="border-b border-line bg-sunken/50">
{columns.map((column) => ( {columns.map((column) => (
<th <th
key={column.key} key={column.key}
scope="col" scope="col"
className={cn( className={cn(
'whitespace-nowrap px-4 py-2.5 text-xs font-medium uppercase tracking-wide text-fg-subtle', 'whitespace-nowrap px-4 py-2 text-[11px] font-medium uppercase tracking-wider text-fg-subtle',
column.numeric ? 'text-right' : 'text-left', column.numeric ? 'text-right' : 'text-left',
column.className, column.className,
)} )}

View File

@ -3,12 +3,12 @@
* *
* Zwei Bauarten, und der Unterschied ist fachlich: * Zwei Bauarten, und der Unterschied ist fachlich:
* *
* - `Dialog` fuer Eingaben (anlegen, bearbeiten). Er laesst sich mit Escape und * - `Dialog` für Eingaben (anlegen, bearbeiten). Er lässt sich mit Escape und
* einem Klick daneben schliessen. * einem Klick daneben schließen.
* - `ConfirmDialog` fuer Handlungen, die etwas veraendern. Er verlangt eine * - `ConfirmDialog` für Handlungen, die etwas verändern. Er verlangt eine
* ausdrueckliche Bestaetigung. Bei zerstoerenden Handlungen kann er zusaetzlich * ausdrückliche Bestätigung. Bei zerstörenden Handlungen kann er zusätzlich
* ein woertlich einzutippendes Wort verlangen — dasselbe Mittel, das * ein wörtlich einzutippendes Wort verlangen — dasselbe Mittel, das
* `uninstall.sh` und die Wiederherstellung mit Ueberschreiben verwenden. Ein * `uninstall.sh` und die Wiederherstellung mit Überschreiben verwenden. Ein
* versehentlich gesetztes Kennzeichen in einem Skript reicht dann nicht aus, * versehentlich gesetztes Kennzeichen in einem Skript reicht dann nicht aus,
* und ein Klick aus Gewohnheit auch nicht. * und ein Klick aus Gewohnheit auch nicht.
*/ */
@ -46,7 +46,7 @@ export function DialogContent({
className={cn( className={cn(
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2', 'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2',
'max-h-[calc(100vh-4rem)] overflow-y-auto', 'max-h-[calc(100vh-4rem)] overflow-y-auto',
'rounded-lg border border-line bg-card shadow-xl', 'rounded-xl border border-line bg-card shadow-[var(--shadow-raised)]',
sizeClasses[size], sizeClasses[size],
className, className,
)} )}
@ -54,7 +54,7 @@ export function DialogContent({
{children} {children}
<DialogPrimitive.Close <DialogPrimitive.Close
className="absolute right-4 top-4 rounded-md p-1 text-fg-muted transition-colors hover:bg-hover hover:text-fg" className="absolute right-4 top-4 rounded-md p-1 text-fg-muted transition-colors hover:bg-hover hover:text-fg"
aria-label="Schliessen" aria-label="Schließen"
> >
<X className="size-4" /> <X className="size-4" />
</DialogPrimitive.Close> </DialogPrimitive.Close>
@ -103,19 +103,19 @@ export function DialogFooter({ children }: { readonly children: ReactNode }) {
} }
/** /**
* Bestaetigungsdialog fuer veraendernde Handlungen. * Bestätigungsdialog für verändernde Handlungen.
* *
* `confirmationWord` ist die zweite Huerde: Wird es gesetzt, muss der Betreiber * `confirmationWord` ist die zweite Hürde: Wird es gesetzt, muss der Betreiber
* es woertlich eintippen. Das gehoert ausschliesslich an Handlungen, die Daten * es wörtlich eintippen. Das gehört ausschließlich an Handlungen, die Daten
* unwiederbringlich entfernen oder ueberschreiben — ein Ritual ohne Anlass * unwiederbringlich entfernen oder überschreiben — ein Ritual ohne Anlass
* gewoehnt das Wegklicken an, und dann wirkt es dort nicht mehr, wo es zaehlt. * gewöhnt das Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt.
*/ */
export function ConfirmDialog({ export function ConfirmDialog({
open, open,
onOpenChange, onOpenChange,
title, title,
description, description,
confirmLabel = 'Bestaetigen', confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen', cancelLabel = 'Abbrechen',
confirmationWord, confirmationWord,
confirmationHint, confirmationHint,
@ -130,7 +130,7 @@ export function ConfirmDialog({
readonly description?: ReactNode | undefined; readonly description?: ReactNode | undefined;
readonly confirmLabel?: string | undefined; readonly confirmLabel?: string | undefined;
readonly cancelLabel?: string | undefined; readonly cancelLabel?: string | undefined;
/** Wird gesetzt, muss der Betreiber dieses Wort woertlich eintippen. */ /** Wird gesetzt, muss der Betreiber dieses Wort wörtlich eintippen. */
readonly confirmationWord?: string | undefined; readonly confirmationWord?: string | undefined;
readonly confirmationHint?: ReactNode | undefined; readonly confirmationHint?: ReactNode | undefined;
readonly isDestructive?: boolean | undefined; readonly isDestructive?: boolean | undefined;
@ -141,10 +141,10 @@ export function ConfirmDialog({
return ( return (
<DialogRoot open={open} onOpenChange={onOpenChange}> <DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogContent size="sm"> <DialogContent size="sm">
{/* Der Inhalt haengt im Portal und wird beim Schliessen ausgehaengt. {/* Der Inhalt hängt im Portal und wird beim Schließen ausgehängt.
Damit verschwindet die eingetippte Bestaetigung von selbst — ein Damit verschwindet die eingetippte Bestätigung von selbst — ein
Zuruecksetzen im Effekt braucht es nicht, und die Huerde steht beim Zurücksetzen im Effekt braucht es nicht, und die Hürde steht beim
naechsten Oeffnen wieder. */} nächsten Öffnen wieder. */}
<ConfirmDialogBody <ConfirmDialogBody
title={title} title={title}
description={description} description={description}
@ -164,7 +164,7 @@ export function ConfirmDialog({
); );
} }
/** Inhalt des Bestaetigungsdialogs; haelt die eingetippte Bestaetigung. */ /** Inhalt des Bestätigungsdialogs; hält die eingetippte Bestätigung. */
function ConfirmDialogBody({ function ConfirmDialogBody({
title, title,
description, description,
@ -208,7 +208,7 @@ function ConfirmDialogBody({
> >
{confirmationHint ?? ( {confirmationHint ?? (
<> <>
Tippen Sie zur Bestaetigung{' '} Tippen Sie zur Bestätigung{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs"> <code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{confirmationWord} {confirmationWord}
</code> </code>

View File

@ -4,8 +4,8 @@
* `ErrorState` zeigt immer die Vorgangsnummer. `NotMeasured` ist die Antwort * `ErrorState` zeigt immer die Vorgangsnummer. `NotMeasured` ist die Antwort
* auf einen Grundsatz, der sich durch die ganze Anlage zieht: Unbekannt ist * auf einen Grundsatz, der sich durch die ganze Anlage zieht: Unbekannt ist
* nicht null. Eine Kennzahl ohne Datengrundlage erscheint als Satz mit * nicht null. Eine Kennzahl ohne Datengrundlage erscheint als Satz mit
* Begruendung, nie als "0" — eine Null liest sich wie "geprueft und in * Begründung, nie als "0" — eine Null liest sich wie "geprüft und in
* Ordnung", und genau das waere falsch. * Ordnung", und genau das wäre falsch.
*/ */
import { AlertTriangle, Info, Loader2, TriangleAlert } from 'lucide-react'; import { AlertTriangle, Info, Loader2, TriangleAlert } from 'lucide-react';
@ -84,9 +84,9 @@ export function EmptyState({
} }
/** /**
* Hinweis fuer eine Kennzahl ohne Datengrundlage. * Hinweis für eine Kennzahl ohne Datengrundlage.
* *
* Der Grund gehoert dazu. "—" allein sieht nach einem Fehler der Oberflaeche * Der Grund gehört dazu. "—" allein sieht nach einem Fehler der Oberfläche
* aus; "nicht gemessen, weil kein Wiederherstellungstest lief" ist eine * aus; "nicht gemessen, weil kein Wiederherstellungstest lief" ist eine
* Handlungsanweisung. * Handlungsanweisung.
*/ */
@ -99,7 +99,7 @@ export function NotMeasured({ reason }: { readonly reason: ReactNode }) {
); );
} }
/** Auffaelliger Hinweiskasten. */ /** Auffälliger Hinweiskasten. */
export function Callout({ export function Callout({
tone = 'info', tone = 'info',
title, title,
@ -114,9 +114,9 @@ export function Callout({
/** /**
* ARIA-Rolle. * ARIA-Rolle.
* *
* Standard ist `note` — die meisten Hinweise erklaeren etwas und stehen schon * Standard ist `note` — die meisten Hinweise erklären etwas und stehen schon
* beim Oeffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung** * beim Öffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung**
* erscheint, gehoert dagegen auf `alert`: Ein Screenreader sagt ihn dann an, * erscheint, gehört dagegen auf `alert`: Ein Screenreader sagt ihn dann an,
* statt ihn im Seiteninhalt untergehen zu lassen. * statt ihn im Seiteninhalt untergehen zu lassen.
*/ */
readonly role?: 'note' | 'alert' | 'status' | undefined; readonly role?: 'note' | 'alert' | 'status' | undefined;
@ -145,7 +145,7 @@ export function Callout({
); );
} }
/** Beschriftetes Wertepaar fuer Detailseiten. */ /** Beschriftetes Wertepaar für Detailseiten. */
export function DetailItem({ export function DetailItem({
label, label,
children, children,

View File

@ -1,10 +1,10 @@
/** /**
* Formularelemente. * Formularelemente.
* *
* Jedes Feld traegt eine Beschriftung, die mit ihm verbunden ist (`htmlFor`), * Jedes Feld trägt eine Beschriftung, die mit ihm verbunden ist (`htmlFor`),
* und optional einen Hinweistext. Der Hinweis ist keine Zierde: In einer * und optional einen Hinweistext. Der Hinweis ist keine Zierde: In einer
* Backup-Konsole entscheiden Felder wie "Aufbewahrung" oder "Zielpfad" ueber * Backup-Konsole entscheiden Felder wie "Aufbewahrung" oder "Zielpfad" über
* Datenverlust, und ein Betreiber soll nicht raten muessen, was die Anlage mit * Datenverlust, und ein Betreiber soll nicht raten müssen, was die Anlage mit
* seiner Eingabe tut. * seiner Eingabe tut.
*/ */
@ -12,7 +12,7 @@ import { forwardRef, useId, type InputHTMLAttributes, type ReactNode, type Selec
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const fieldClasses = [ const fieldClasses = [
'w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg', 'w-full rounded-lg border border-line-strong bg-card px-3 py-2 text-sm text-fg transition-colors',
'placeholder:text-fg-subtle', 'placeholder:text-fg-subtle',
'disabled:cursor-not-allowed disabled:opacity-60', 'disabled:cursor-not-allowed disabled:opacity-60',
].join(' '); ].join(' ');
@ -150,7 +150,7 @@ export const SelectInput = forwardRef<
); );
}); });
/** Kontrollkaestchen mit Beschriftung und Erlaeuterung. */ /** Kontrollkästchen mit Beschriftung und Erläuterung. */
export function CheckboxField({ export function CheckboxField({
checked, checked,
onCheckedChange, onCheckedChange,

View File

@ -1,48 +1,48 @@
/** /**
* Prueft die Zuordnung der Fachbegriffe auf die fuenf Bedeutungen. * Prüft die Zuordnung der Fachbegriffe auf die fünf Bedeutungen.
* *
* Diese Tabelle ist der eine Ort, an dem ein falscher Ton die Aussage der * Diese Tabelle ist der eine Ort, an dem ein falscher Ton die Aussage der
* ganzen Anlage umkehren kann: Ein gruenes Abzeichen an einem Teilfehler oder * ganzen Anlage umkehren kann: Ein grünes Abzeichen an einem Teilfehler oder
* an einem ungepruften Backup macht aus einer Warnung eine Entwarnung — und * an einem ungepruften Backup macht aus einer Warnung eine Entwarnung — und
* niemand sieht es, weil die Oberflaeche einwandfrei aussieht. * niemand sieht es, weil die Oberfläche einwandfrei aussieht.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { resolveStatus } from './StatusBadge'; import { resolveStatus } from './StatusBadge';
describe('Statuszuordnung', () => { describe('Statuszuordnung', () => {
it('faerbt einen Teilfehler niemals gruen', () => { it('färbt einen Teilfehler niemals grün', () => {
// Entwicklungsregel 1: Teilfehler heissen PARTIAL FAILURE, nie SUCCESS. // Entwicklungsregel 1: Teilfehler heißen PARTIAL FAILURE, nie SUCCESS.
expect(resolveStatus('partial_failure').tone).not.toBe('healthy'); expect(resolveStatus('partial_failure').tone).not.toBe('healthy');
expect(resolveStatus('partial_failure').tone).toBe('warning'); expect(resolveStatus('partial_failure').tone).toBe('warning');
}); });
it('faerbt ein gesichertes, aber ungepruftes Backup nicht gruen', () => { it('färbt ein gesichertes, aber ungepruftes Backup nicht grün', () => {
// `successful` heisst "der Lauf ist durchgelaufen" — nicht // `successful` heißt "der Lauf ist durchgelaufen" — nicht
// "wiederherstellbar". Erst ein Wiederherstellungstest hebt auf // "wiederherstellbar". Erst ein Wiederherstellungstest hebt auf
// `recoverable`. // `recoverable`.
expect(resolveStatus('successful').tone).toBe('neutral'); expect(resolveStatus('successful').tone).toBe('neutral');
expect(resolveStatus('recoverable').tone).toBe('healthy'); expect(resolveStatus('recoverable').tone).toBe('healthy');
}); });
it('behandelt ein beschaedigtes Backup als kritisch, nicht als Warnung', () => { it('behandelt ein beschädigtes Backup als kritisch, nicht als Warnung', () => {
// Ein Backup mit einem beschaedigten Block ist nicht zu 70 Prozent // Ein Backup mit einem beschädigten Block ist nicht zu 70 Prozent
// wiederherstellbar, sondern gar nicht. // wiederherstellbar, sondern gar nicht.
expect(resolveStatus('corrupted').tone).toBe('critical'); expect(resolveStatus('corrupted').tone).toBe('critical');
}); });
it('faerbt einen nur softwareseitig durchgesetzten Loeschschutz als Warnung', () => { it('färbt einen nur softwareseitig durchgesetzten Löschschutz als Warnung', () => {
// `advisory` bedeutet: Wer Zugriff auf die Dateien hat, kann die Backups // `advisory` bedeutet: Wer Zugriff auf die Dateien hat, kann die Backups
// loeschen. Das als "in Ordnung" zu zeigen waere die gefaehrlichste // löschen. Das als "in Ordnung" zu zeigen wäre die gefährlichste
// Beschoenigung der Anlage. // Beschönigung der Anlage.
expect(resolveStatus('advisory').tone).toBe('warning'); expect(resolveStatus('advisory').tone).toBe('warning');
expect(resolveStatus('filesystem').tone).toBe('healthy'); expect(resolveStatus('filesystem').tone).toBe('healthy');
}); });
it('zeigt einen unbekannten Zustand neutral und niemals gruen', () => { it('zeigt einen unbekannten Zustand neutral und niemals grün', () => {
// Ein neuer Serverzustand, den diese Tabelle noch nicht kennt, darf nicht // Ein neuer Serverzustand, den diese Tabelle noch nicht kennt, darf nicht
// als "in Ordnung" durchgehen. Gruen waere der bequeme Standard und der // als "in Ordnung" durchgehen. Grün wäre der bequeme Standard und der
// gefaehrlichste. // gefährlichste.
const unknownStatus = resolveStatus('ein_neuer_serverzustand'); const unknownStatus = resolveStatus('ein_neuer_serverzustand');
expect(unknownStatus.tone).toBe('neutral'); expect(unknownStatus.tone).toBe('neutral');
@ -57,7 +57,7 @@ describe('Statuszuordnung', () => {
it('kennt jede Einstufung, die der Server vergeben kann', () => { it('kennt jede Einstufung, die der Server vergeben kann', () => {
// Das Vokabular stammt aus packages/verification/assurance.go. Kommt dort // Das Vokabular stammt aus packages/verification/assurance.go. Kommt dort
// eine Einstufung hinzu, ohne dass sie hier auftaucht, erscheint sie in der // eine Einstufung hinzu, ohne dass sie hier auftaucht, erscheint sie in der
// Oberflaeche als englischer Rohwert. // Oberfläche als englischer Rohwert.
for (const classification of [ for (const classification of [
'failed', 'failed',
'corrupted', 'corrupted',
@ -67,7 +67,7 @@ describe('Statuszuordnung', () => {
]) { ]) {
expect( expect(
resolveStatus(classification).label, resolveStatus(classification).label,
`Einstufung ${classification} ist nicht uebersetzt`, `Einstufung ${classification} ist nicht übersetzt`,
).not.toBe(classification); ).not.toBe(classification);
} }
}); });

View File

@ -1,29 +1,29 @@
/** /**
* Statusanzeige — die wichtigste Komponente der Konsole. * Statusanzeige — die wichtigste Komponente der Konsole.
* *
* Sie bildet die Fachbegriffe der API auf die fuenf Bedeutungen aus PROMPT.md * Sie bildet die Fachbegriffe der API auf die fünf Bedeutungen aus PROMPT.md
* §106 ab. Dass diese Abbildung **an einer einzigen Stelle** steht, ist der * §106 ab. Dass diese Abbildung **an einer einzigen Stelle** steht, ist der
* eigentliche Punkt: Waere sie ueber die Seiten verteilt, wuerde frueher oder * eigentliche Punkt: Wäre sie über die Seiten verteilt, würde früher oder
* spaeter irgendwo "partial_failure" gruen erscheinen — und ein Betreiber haelt * später irgendwo "partial_failure" grün erscheinen — und ein Betreiber hält
* einen Teilfehler dann fuer einen Erfolg. * einen Teilfehler dann für einen Erfolg.
* *
* Die Zuordnungen sind keine Geschmacksfrage, sondern folgen den * Die Zuordnungen sind keine Geschmacksfrage, sondern folgen den
* Entscheidungen der Phasen: * Entscheidungen der Phasen:
* *
* - "partial_failure" ist **niemals** gruen (Entwicklungsregel 1). * - "partial_failure" ist **niemals** grün (Entwicklungsregel 1).
* - "unverified" ist neutral, nicht gruen: Ohne Wiederherstellungstest ist die * - "unverified" ist neutral, nicht grün: Ohne Wiederherstellungstest ist die
* Wiederherstellbarkeit unbekannt, und Unbekanntes zaehlt nie als gut * Wiederherstellbarkeit unbekannt, und Unbekanntes zählt nie als gut
* (Phase 10). * (Phase 10).
* - "corrupted" ist kritisch, nicht nur eine Warnung: Ein beschaedigtes Backup * - "corrupted" ist kritisch, nicht nur eine Warnung: Ein beschädigtes Backup
* ist nicht zu 70 % wiederherstellbar, sondern gar nicht. * ist nicht zu 70 % wiederherstellbar, sondern gar nicht.
* - "advisory" beim Loeschschutz ist eine Warnung: Der Schutz ist eine * - "advisory" beim Löschschutz ist eine Warnung: Der Schutz ist eine
* Software-Regel, kein Schutz des Dateisystems (Phase 11). * Software-Regel, kein Schutz des Dateisystems (Phase 11).
*/ */
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** Die fuenf Bedeutungen. Mehr gibt es nicht. */ /** Die fünf Bedeutungen. Mehr gibt es nicht. */
export type StatusTone = 'healthy' | 'warning' | 'high' | 'critical' | 'info' | 'neutral'; export type StatusTone = 'healthy' | 'warning' | 'high' | 'critical' | 'info' | 'neutral';
interface StatusDefinition { interface StatusDefinition {
@ -34,21 +34,21 @@ interface StatusDefinition {
/** /**
* Abbildung der Fachbegriffe auf Ton und deutsche Beschriftung. * Abbildung der Fachbegriffe auf Ton und deutsche Beschriftung.
* *
* Ein unbekannter Schluessel wird bewusst **neutral** dargestellt und mit dem * Ein unbekannter Schlüssel wird bewusst **neutral** dargestellt und mit dem
* Rohwert beschriftet — nicht gruen. Ein neuer Serverzustand, den diese Tabelle * Rohwert beschriftet — nicht grün. Ein neuer Serverzustand, den diese Tabelle
* noch nicht kennt, darf nicht als "in Ordnung" durchgehen. * noch nicht kennt, darf nicht als "in Ordnung" durchgehen.
*/ */
const statusDefinitions: Readonly<Record<string, StatusDefinition>> = { const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
// --- Laeufe --- // --- Läufe ---
succeeded: { tone: 'healthy', label: 'Erfolgreich' }, succeeded: { tone: 'healthy', label: 'Erfolgreich' },
running: { tone: 'info', label: 'Laeuft' }, running: { tone: 'info', label: 'Läuft' },
queued: { tone: 'info', label: 'Eingereiht' }, queued: { tone: 'info', label: 'Eingereiht' },
pending: { tone: 'neutral', label: 'Ausstehend' }, pending: { tone: 'neutral', label: 'Ausstehend' },
partial_failure: { tone: 'warning', label: 'Teilfehler' }, partial_failure: { tone: 'warning', label: 'Teilfehler' },
failed: { tone: 'critical', label: 'Fehlgeschlagen' }, failed: { tone: 'critical', label: 'Fehlgeschlagen' },
cancelled: { tone: 'neutral', label: 'Abgebrochen' }, cancelled: { tone: 'neutral', label: 'Abgebrochen' },
// --- Auftraege --- // --- Aufträge ---
enabled: { tone: 'healthy', label: 'Aktiv' }, enabled: { tone: 'healthy', label: 'Aktiv' },
paused: { tone: 'warning', label: 'Angehalten' }, paused: { tone: 'warning', label: 'Angehalten' },
disabled: { tone: 'neutral', label: 'Deaktiviert' }, disabled: { tone: 'neutral', label: 'Deaktiviert' },
@ -58,17 +58,17 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
// Das Vokabular lautet: failed, successful, verified, recoverable, corrupted. // Das Vokabular lautet: failed, successful, verified, recoverable, corrupted.
// //
// `successful` ist die Falle dieser Tabelle und deshalb **neutral**, nicht // `successful` ist die Falle dieser Tabelle und deshalb **neutral**, nicht
// gruen: Es bedeutet "der Sicherungslauf ist durchgelaufen" — nicht, dass // grün: Es bedeutet "der Sicherungslauf ist durchgelaufen" — nicht, dass
// sich daraus etwas wiederherstellen laesst. Ein gruenes Abzeichen laese sich // sich daraus etwas wiederherstellen lässt. Ein grünes Abzeichen läse sich
// hier als "geprueft und in Ordnung", und genau diese Verwechslung soll die // hier als "geprüft und in Ordnung", und genau diese Verwechslung soll die
// ganze Anlage verhindern. Erst ein Wiederherstellungstest hebt auf // ganze Anlage verhindern. Erst ein Wiederherstellungstest hebt auf
// `recoverable`; alles davor ist ein Indiz. // `recoverable`; alles davor ist ein Indiz.
recoverable: { tone: 'healthy', label: 'Wiederherstellbar' }, recoverable: { tone: 'healthy', label: 'Wiederherstellbar' },
verified: { tone: 'info', label: 'Geprueft' }, verified: { tone: 'info', label: 'Geprüft' },
successful: { tone: 'neutral', label: 'Gesichert, ungeprueft' }, successful: { tone: 'neutral', label: 'Gesichert, ungeprüft' },
corrupted: { tone: 'critical', label: 'Beschaedigt' }, corrupted: { tone: 'critical', label: 'Beschädigt' },
// --- Pruefergebnisse --- // --- Prüfergebnisse ---
clean: { tone: 'healthy', label: 'Ohne Befund' }, clean: { tone: 'healthy', label: 'Ohne Befund' },
findings: { tone: 'critical', label: 'Mit Befund' }, findings: { tone: 'critical', label: 'Mit Befund' },
@ -78,7 +78,7 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
unavailable: { tone: 'critical', label: 'Nicht erreichbar' }, unavailable: { tone: 'critical', label: 'Nicht erreichbar' },
maintenance: { tone: 'warning', label: 'Wartung' }, maintenance: { tone: 'warning', label: 'Wartung' },
// --- Durchsetzungsstufe des Loeschschutzes (Phase 11) --- // --- Durchsetzungsstufe des Löschschutzes (Phase 11) ---
filesystem: { tone: 'healthy', label: 'Dateisystem' }, filesystem: { tone: 'healthy', label: 'Dateisystem' },
storage: { tone: 'healthy', label: 'Speichersystem' }, storage: { tone: 'healthy', label: 'Speichersystem' },
advisory: { tone: 'warning', label: 'Nur Software' }, advisory: { tone: 'warning', label: 'Nur Software' },
@ -86,15 +86,15 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
// --- Gesundheit --- // --- Gesundheit ---
healthy: { tone: 'healthy', label: 'Fehlerfrei' }, healthy: { tone: 'healthy', label: 'Fehlerfrei' },
degraded: { tone: 'warning', label: 'Eingeschraenkt' }, degraded: { tone: 'warning', label: 'Eingeschränkt' },
critical: { tone: 'critical', label: 'Kritisch' }, critical: { tone: 'critical', label: 'Kritisch' },
unknown: { tone: 'neutral', label: 'Unbekannt' }, unknown: { tone: 'neutral', label: 'Unbekannt' },
// --- Meldungen --- // --- Meldungen ---
open: { tone: 'critical', label: 'Offen' }, open: { tone: 'critical', label: 'Offen' },
acknowledged: { tone: 'warning', label: 'Bestaetigt' }, acknowledged: { tone: 'warning', label: 'Bestätigt' },
resolved: { tone: 'healthy', label: 'Erledigt' }, resolved: { tone: 'healthy', label: 'Erledigt' },
suppressed: { tone: 'neutral', label: 'Unterdrueckt' }, suppressed: { tone: 'neutral', label: 'Unterdrückt' },
// --- Schweregrade --- // --- Schweregrade ---
info: { tone: 'info', label: 'Information' }, info: { tone: 'info', label: 'Information' },
@ -110,8 +110,8 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
revoked: { tone: 'neutral', label: 'Gesperrt' }, revoked: { tone: 'neutral', label: 'Gesperrt' },
// --- Ransomware-Einstufung (Phase 16) --- // --- Ransomware-Einstufung (Phase 16) ---
elevated: { tone: 'warning', label: 'Auffaellig' }, elevated: { tone: 'warning', label: 'Auffällig' },
// "unknown" oben deckt den Fall unter fuenf Vergleichslaeufen mit ab. // "unknown" oben deckt den Fall unter fünf Vergleichsläufen mit ab.
}; };
const toneClasses: Readonly<Record<StatusTone, string>> = { const toneClasses: Readonly<Record<StatusTone, string>> = {
@ -133,8 +133,8 @@ export function resolveStatus(rawStatus: string | null | undefined): StatusDefin
return ( return (
statusDefinitions[normalizedStatus] ?? { statusDefinitions[normalizedStatus] ?? {
// Unbekannt bleibt neutral. Gruen waere hier der bequeme und gefaehrliche // Unbekannt bleibt neutral. Grün wäre hier der bequeme und gefährliche
// Standard: Ein neuer Fehlerzustand des Servers saehe aus wie Erfolg. // Standard: Ein neuer Fehlerzustand des Servers sähe aus wie Erfolg.
tone: 'neutral', tone: 'neutral',
label: rawStatus, label: rawStatus,
} }
@ -148,7 +148,7 @@ export function StatusBadge({
showDot = true, showDot = true,
}: { }: {
readonly status: string | null | undefined; readonly status: string | null | undefined;
/** Ueberschreibt die Beschriftung, der Ton bleibt. */ /** Überschreibt die Beschriftung, der Ton bleibt. */
readonly label?: ReactNode | undefined; readonly label?: ReactNode | undefined;
readonly className?: string | undefined; readonly className?: string | undefined;
readonly showDot?: boolean | undefined; readonly showDot?: boolean | undefined;
@ -158,7 +158,7 @@ export function StatusBadge({
return ( return (
<span <span
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium', 'inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium',
toneClasses[resolved.tone], toneClasses[resolved.tone],
className, className,
)} )}
@ -171,7 +171,7 @@ export function StatusBadge({
); );
} }
/** Nur der farbige Punkt — fuer dichte Tabellen. */ /** Nur der farbige Punkt — für dichte Tabellen. */
export function StatusDot({ export function StatusDot({
status, status,
className, className,

View File

@ -3,12 +3,12 @@
* *
* Der wichtigste Teil ist die Fehlermeldung: Sie zeigt **immer** die * Der wichtigste Teil ist die Fehlermeldung: Sie zeigt **immer** die
* `request_id` mit. Ohne sie bleibt einem Betreiber nur "es hat nicht * `request_id` mit. Ohne sie bleibt einem Betreiber nur "es hat nicht
* funktioniert" — und damit ist weder ihm noch einer spaeteren Fehlersuche * funktioniert" — und damit ist weder ihm noch einer späteren Fehlersuche
* geholfen. Die Kennung laesst sich mit einem Klick kopieren, weil sie sonst * geholfen. Die Kennung lässt sich mit einem Klick kopieren, weil sie sonst
* abgetippt wird und dabei ein Zeichen verlorengeht. * abgetippt wird und dabei ein Zeichen verlorengeht.
* *
* Erfolgsmeldungen verschwinden von selbst, Fehlermeldungen **nicht**. Wer * Erfolgsmeldungen verschwinden von selbst, Fehlermeldungen **nicht**. Wer
* gerade wegsieht, waehrend eine Loeschung fehlschlaegt, haette sonst keine * gerade wegsieht, während eine Löschung fehlschlägt, hätte sonst keine
* Chance, es zu bemerken. * Chance, es zu bemerken.
*/ */
@ -44,7 +44,7 @@ export function useToast(): ToastContextValue {
const contextValue = useContext(ToastContext); const contextValue = useContext(ToastContext);
if (!contextValue) { if (!contextValue) {
throw new Error('useToast benoetigt einen ToastProvider im Elternbaum.'); throw new Error('useToast benötigt einen ToastProvider im Elternbaum.');
} }
return contextValue; return contextValue;
@ -68,8 +68,8 @@ export function ToastProvider({ children }: { readonly children: ReactNode }) {
{ ...message, id: messageId }, { ...message, id: messageId },
]); ]);
// Fehler bleiben stehen, bis sie jemand schliesst. Alles andere // Fehler bleiben stehen, bis sie jemand schließt. Alles andere
// verschwindet nach fuenf Sekunden. // verschwindet nach fünf Sekunden.
if (message.tone !== 'error') { if (message.tone !== 'error') {
window.setTimeout(() => removeMessage(messageId), 5000); window.setTimeout(() => removeMessage(messageId), 5000);
} }
@ -166,7 +166,7 @@ function ToastCard({
type="button" type="button"
onClick={onDismiss} onClick={onDismiss}
className="shrink-0 rounded p-0.5 text-fg-subtle transition-colors hover:text-fg" className="shrink-0 rounded p-0.5 text-fg-subtle transition-colors hover:text-fg"
aria-label="Meldung schliessen" aria-label="Meldung schließen"
> >
<X className="size-4" /> <X className="size-4" />
</button> </button>

View File

@ -2,13 +2,13 @@
* Ereignisprotokoll. * Ereignisprotokoll.
* *
* Das Protokoll ist **append-only per Datenbank-Trigger**, nicht nur per * Das Protokoll ist **append-only per Datenbank-Trigger**, nicht nur per
* Anwendungslogik. Es laesst sich weder aus der Oberflaeche noch ueber die API * Anwendungslogik. Es lässt sich weder aus der Oberfläche noch über die API
* aendern oder loeschen — ein Protokoll, das sich bereinigen liesse, waere * ändern oder löschen — ein Protokoll, das sich bereinigen ließe, wäre
* keines. Deshalb gibt es hier keine Handlungen, nur eine Suche. * keines. Deshalb gibt es hier keine Handlungen, nur eine Suche.
* *
* Die Adresse in der Spalte „Herkunft" ist die der tatsaechlichen Verbindung. * Die Adresse in der Spalte „Herkunft" ist die der tatsächlichen Verbindung.
* Weitergeleitete IP-Header werden bewusst ignoriert: Sie sind faelschbar, und * Weitergeleitete IP-Header werden bewusst ignoriert: Sie sind fälschbar, und
* im Audit stuende sonst eine beliebige Adresse. Hinter einem Reverse Proxy — * im Audit stünde sonst eine beliebige Adresse. Hinter einem Reverse Proxy —
* also im Normalfall dieser Anlage — steht dort dessen Adresse. * also im Normalfall dieser Anlage — steht dort dessen Adresse.
*/ */
@ -43,7 +43,7 @@ interface AuditEvent {
readonly correlation_id?: string; readonly correlation_id?: string;
} }
/** Laedt die letzten Ereignisse. */ /** Lädt die letzten Ereignisse. */
async function fetchAuditEvents(abortSignal: AbortSignal): Promise<readonly AuditEvent[]> { async function fetchAuditEvents(abortSignal: AbortSignal): Promise<readonly AuditEvent[]> {
return requestApi<readonly AuditEvent[]>('/audit-events?page_size=100', { return requestApi<readonly AuditEvent[]>('/audit-events?page_size=100', {
signal: abortSignal, signal: abortSignal,
@ -60,8 +60,8 @@ export function EventsPage() {
/** /**
* Filterung im Browser. * Filterung im Browser.
* *
* Die Liste umfasst hoechstens hundert Eintraege; eine serverseitige Suche * Die Liste umfasst höchstens hundert Einträge; eine serverseitige Suche
* waere hier mehr Vertrag als Nutzen. Bei groesseren Bestaenden gehoert sie * wäre hier mehr Vertrag als Nutzen. Bei größeren Beständen gehört sie
* auf den Server — das ist eine bewusste Grenze, keine Auslassung. * auf den Server — das ist eine bewusste Grenze, keine Auslassung.
*/ */
const filteredEvents = useMemo(() => { const filteredEvents = useMemo(() => {
@ -180,9 +180,7 @@ export function EventsPage() {
/> />
<Callout tone="info" className="mb-4"> <Callout tone="info" className="mb-4">
Das Protokoll ist <strong>append-only per Datenbank-Trigger</strong>. Es Append-only: weder hier noch über die API änderbar.
laesst sich weder hier noch ueber die API aendern oder loeschen — auch
nicht von einem Administrator.
</Callout> </Callout>
<Card> <Card>
@ -213,7 +211,7 @@ export function EventsPage() {
emptyDescription={ emptyDescription={
searchTerm searchTerm
? 'Der Suchbegriff kommt in den letzten 100 Ereignissen nicht vor.' ? 'Der Suchbegriff kommt in den letzten 100 Ereignissen nicht vor.'
: 'Sobald jemand eine sicherheitsrelevante Handlung ausfuehrt, erscheint sie hier.' : 'Sobald jemand eine sicherheitsrelevante Handlung ausführt, erscheint sie hier.'
} }
/> />
</Card> </Card>

View File

@ -2,16 +2,16 @@
* Rollen und Berechtigungen. * Rollen und Berechtigungen.
* *
* Die Berechtigungsauswahl ist nach Bereichen gruppiert, weil eine flache Liste * Die Berechtigungsauswahl ist nach Bereichen gruppiert, weil eine flache Liste
* mit 34 Eintraegen dazu fuehrt, dass jemand „alles" ankreuzt. Zwei Paare sind * mit 34 Einträgen dazu führt, dass jemand „alles" ankreuzt. Zwei Paare sind
* bewusst getrennt und werden hier ausdruecklich erklaert: * bewusst getrennt und werden hier ausdrücklich erklärt:
* *
* - `restores.execute` enthaelt **nicht** `restores.overwrite`. Wiederherstellen * - `restores.execute` enthält **nicht** `restores.overwrite`. Wiederherstellen
* ist etwas anderes als vorhandene Daten ueberschreiben. * ist etwas anderes als vorhandene Daten überschreiben.
* - `backups.delete` enthaelt **nicht** `immutability.manage`. Wer aufraeumen * - `backups.delete` enthält **nicht** `immutability.manage`. Wer aufräumen
* darf, darf keinen Loeschschutz aufheben — das ist der Schritt, der einem * darf, darf keinen Löschschutz aufheben — das ist der Schritt, der einem
* Angreifer den Weg oeffnet. * Angreifer den Weg öffnet.
* *
* Mitgelieferte Rollen sind unveraenderlich und werden als solche gezeigt, * Mitgelieferte Rollen sind unveränderlich und werden als solche gezeigt,
* statt eine Maske anzubieten, die der Server ablehnt. * statt eine Maske anzubieten, die der Server ablehnt.
*/ */
@ -46,16 +46,16 @@ import {
type Role, type Role,
} from './administrationApi'; } from './administrationApi';
/** Erklaerungen zu Berechtigungen, deren Zuschnitt nicht offensichtlich ist. */ /** Erklärungen zu Berechtigungen, deren Zuschnitt nicht offensichtlich ist. */
const PERMISSION_NOTES: Readonly<Record<string, string>> = { const PERMISSION_NOTES: Readonly<Record<string, string>> = {
'restores.overwrite': 'restores.overwrite':
'Nicht in restores.execute enthalten. Erlaubt das Ueberschreiben vorhandener Daten am Ziel.', 'Nicht in restores.execute enthalten. Erlaubt das Überschreiben vorhandener Daten am Ziel.',
'immutability.manage': 'immutability.manage':
'Nicht in backups.delete enthalten. Erlaubt das Aufheben von Loeschschutz und Legal Hold.', 'Nicht in backups.delete enthalten. Erlaubt das Aufheben von Löschschutz und Legal Hold.',
'verification.restore_test': 'verification.restore_test':
'Liest ein Backup vollstaendig und schreibt es versuchsweise zurueck — die einzige Pruefung, die ein Nachweis ist.', 'Liest ein Backup vollständig und schreibt es versuchsweise zurück — die einzige Prüfung, die ein Nachweis ist.',
'settings.write': 'settings.write':
'Umfasst die Benachrichtigungswege. Wer sie umleitet, kann erreichen, dass niemand mehr von einem Ausfall erfaehrt.', 'Umfasst die Benachrichtigungswege. Wer sie umleitet, kann erreichen, dass niemand mehr von einem Ausfall erfährt.',
}; };
export function RolesPage({ export function RolesPage({
@ -92,17 +92,17 @@ export function RolesPage({
rolesResource.reload(); rolesResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Die Rolle liess sich nicht anlegen', describeApiError(apiError), apiError.requestId), toast.showError('Die Rolle ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId),
}); });
const deleteMutation = useMutation(deleteRole, { const deleteMutation = useMutation(deleteRole, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Rolle geloescht'); toast.showSuccess('Rolle gelöscht');
setRolePendingDeletion(null); setRolePendingDeletion(null);
rolesResource.reload(); rolesResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId), toast.showError('Das Löschen schlug fehl', describeApiError(apiError), apiError.requestId),
}); });
const roles = rolesResource.data ?? []; const roles = rolesResource.data ?? [];
@ -110,7 +110,7 @@ export function RolesPage({
/** /**
* Berechtigungen nach Bereich gruppiert. * Berechtigungen nach Bereich gruppiert.
* *
* Der Rueckfall auf ein leeres Array steht **innerhalb** der Berechnung: Ein * Der Rückfall auf ein leeres Array steht **innerhalb** der Berechnung: Ein
* `?? []` davor erzeugte bei jedem Rendern ein neues Array, und die * `?? []` davor erzeugte bei jedem Rendern ein neues Array, und die
* Gruppierung liefe jedes Mal neu — bei 34 Berechtigungen unmerklich, als * Gruppierung liefe jedes Mal neu — bei 34 Berechtigungen unmerklich, als
* Muster trotzdem falsch. * Muster trotzdem falsch.
@ -119,8 +119,8 @@ export function RolesPage({
const grouped = new Map<string, Permission[]>(); const grouped = new Map<string, Permission[]>();
for (const permission of permissionsResource.data ?? []) { for (const permission of permissionsResource.data ?? []) {
// Ohne ausdrueckliche Kategorie dient der Teil vor dem Punkt als Bereich — // Ohne ausdrückliche Kategorie dient der Teil vor dem Punkt als Bereich —
// "backups.read" gehoert zu "backups". // "backups.read" gehört zu "backups".
const categoryName = const categoryName =
permission.category ?? permission.name.split('.')[0] ?? 'sonstige'; permission.category ?? permission.name.split('.')[0] ?? 'sonstige';
const existing = grouped.get(categoryName) ?? []; const existing = grouped.get(categoryName) ?? [];
@ -166,7 +166,7 @@ export function RolesPage({
header: 'Art', header: 'Art',
render: (role) => ( render: (role) => (
<span className="text-xs text-fg-muted"> <span className="text-xs text-fg-muted">
{role.is_builtin ? 'Mitgeliefert (unveraenderlich)' : 'Selbst angelegt'} {role.is_builtin ? 'Mitgeliefert (unveränderlich)' : 'Selbst angelegt'}
</span> </span>
), ),
}, },
@ -179,8 +179,8 @@ export function RolesPage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Loeschen" title="Löschen"
aria-label={`Rolle ${role.name} loeschen`} aria-label={`Rolle ${role.name} löschen`}
onClick={(clickEvent) => { onClick={(clickEvent) => {
clickEvent.stopPropagation(); clickEvent.stopPropagation();
setRolePendingDeletion(role); setRolePendingDeletion(role);
@ -211,7 +211,7 @@ export function RolesPage({
<> <>
<PageHeader <PageHeader
title="Rollen" title="Rollen"
description="Wer was darf. Geprueft wird immer auf dem Server." description="Wer was darf. Geprüft wird immer auf dem Server."
actions={ actions={
mayWrite ? ( mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}> <Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
@ -243,16 +243,13 @@ export function RolesPage({
</p> </p>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => setSelectedRole(null)}> <Button variant="ghost" size="sm" onClick={() => setSelectedRole(null)}>
Schliessen Schließen
</Button> </Button>
</div> </div>
<div className="px-5 py-4"> <div className="px-5 py-4">
{selectedRole.is_builtin ? ( {selectedRole.is_builtin ? (
<Callout tone="info" className="mb-4"> <Callout tone="info" className="mb-4">
Mitgelieferte Rollen sind <strong>unveraenderlich</strong>. Eine Mitgelieferte Rollen sind unveränderlich.
Aenderung verschoebe die Bedeutung bestehender Zuweisungen —
jemand behielte eine Rolle, die etwas anderes bedeutet als bei
ihrer Vergabe.
</Callout> </Callout>
) : null} ) : null}
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -289,7 +286,7 @@ export function RolesPage({
label="Beschreibung" label="Beschreibung"
value={draftDescription} value={draftDescription}
onChange={(changeEvent) => setDraftDescription(changeEvent.target.value)} onChange={(changeEvent) => setDraftDescription(changeEvent.target.value)}
placeholder="Darf Laeufe anstossen und Meldungen bestaetigen" placeholder="Darf Läufe anstoßen und Meldungen bestätigen"
/> />
<div className="space-y-4 border-t border-line pt-4"> <div className="space-y-4 border-t border-line pt-4">
@ -348,9 +345,9 @@ export function RolesPage({
<ConfirmDialog <ConfirmDialog
open={rolePendingDeletion !== null} open={rolePendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setRolePendingDeletion(null)} onOpenChange={(isOpen) => !isOpen && setRolePendingDeletion(null)}
title="Rolle loeschen" title="Rolle löschen"
description={rolePendingDeletion?.name} description={rolePendingDeletion?.name}
confirmLabel="Loeschen" confirmLabel="Löschen"
isDestructive isDestructive
isLoading={deleteMutation.isRunning} isLoading={deleteMutation.isRunning}
onConfirm={() => { onConfirm={() => {
@ -360,8 +357,7 @@ export function RolesPage({
}} }}
> >
<Callout tone="warning"> <Callout tone="warning">
Konten mit dieser Rolle verlieren die zugehoerigen Rechte sofort. Ist Konten mit dieser Rolle verlieren die Rechte sofort.
sie noch zugewiesen, lehnt der Server das Loeschen ab.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>

View File

@ -84,7 +84,7 @@ export function SettingsPage({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Der Weg liess sich nicht anlegen', 'Der Weg ließ sich nicht anlegen',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -104,7 +104,7 @@ export function SettingsPage({
onSuccess: (enrollment) => setMfaEnrollment(enrollment), onSuccess: (enrollment) => setMfaEnrollment(enrollment),
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Die Einrichtung liess sich nicht beginnen', 'Die Einrichtung ließ sich nicht beginnen',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -114,7 +114,7 @@ export function SettingsPage({
onSuccess: () => { onSuccess: () => {
toast.showSuccess( toast.showSuccess(
'Zweiter Faktor eingerichtet', 'Zweiter Faktor eingerichtet',
'Ab der naechsten Anmeldung wird er verlangt.', 'Ab der nächsten Anmeldung wird er verlangt.',
); );
setIsMfaConfirmed(true); setIsMfaConfirmed(true);
setMfaEnrollment(null); setMfaEnrollment(null);
@ -123,7 +123,7 @@ export function SettingsPage({
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Der Code wurde nicht anerkannt', 'Der Code wurde nicht anerkannt',
`${describeApiError(apiError)} — pruefen Sie die Uhrzeit des Geraets; TOTP haengt an der Zeit.`, `${describeApiError(apiError)} — prüfen Sie die Uhrzeit des Geräts; TOTP hängt an der Zeit.`,
apiError.requestId, apiError.requestId,
), ),
}); });
@ -182,7 +182,7 @@ export function SettingsPage({
<Card className="mb-4"> <Card className="mb-4">
<CardHeader <CardHeader
title="Zweiter Faktor" title="Zweiter Faktor"
description="Fuer das eigene Konto." description="Für das eigene Konto."
action={ action={
mfaIsActive ? ( mfaIsActive ? (
<StatusBadge status="healthy" label="Eingerichtet" /> <StatusBadge status="healthy" label="Eingerichtet" />
@ -202,9 +202,9 @@ export function SettingsPage({
<div className="space-y-4"> <div className="space-y-4">
<Callout tone="warning" title="Dieses Geheimnis erscheint nur jetzt"> <Callout tone="warning" title="Dieses Geheimnis erscheint nur jetzt">
Tragen Sie es in Ihre Authentifizierungs-App ein und Tragen Sie es in Ihre Authentifizierungs-App ein und
bestaetigen Sie mit dem angezeigten Code. Erst dann wird der bestätigen Sie mit dem angezeigten Code. Erst dann wird der
zweite Faktor scharf — sonst haetten Sie ein Geheimnis zweite Faktor scharf — sonst hätten Sie ein Geheimnis
hinterlegt, dessen App Sie nie geprueft haben. hinterlegt, dessen App Sie nie geprüft haben.
</Callout> </Callout>
<div className="rounded-md border border-line bg-sunken p-3"> <div className="rounded-md border border-line bg-sunken p-3">
@ -231,16 +231,16 @@ export function SettingsPage({
disabled={mfaCode.trim().length !== 6} disabled={mfaCode.trim().length !== 6}
onClick={() => void confirmMfaMutation.run(mfaCode.trim())} onClick={() => void confirmMfaMutation.run(mfaCode.trim())}
> >
Bestaetigen Bestätigen
</Button> </Button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-fg-muted"> <p className="text-sm text-fg-muted">
Ihr Konto ist nur durch sein Passwort geschuetzt. Bei einem Konto Ihr Konto ist nur durch sein Passwort geschützt. Bei einem Konto
mit Loeschrecht ist das der Punkt, an dem eine Uebernahme zum mit Löschrecht ist das der Punkt, an dem eine Übernahme zum
Datenverlust fuehrt. Datenverlust führt.
</p> </p>
<Button <Button
variant="primary" variant="primary"
@ -283,7 +283,7 @@ export function SettingsPage({
getRowKey={(channel) => channel.id} getRowKey={(channel) => channel.id}
isLoading={channelsResource.loadState === 'loading'} isLoading={channelsResource.loadState === 'loading'}
emptyTitle="Kein Benachrichtigungsweg" emptyTitle="Kein Benachrichtigungsweg"
emptyDescription="Ohne Weg erfaehrt niemand von einem Ausfall. Die Meldungen entstehen trotzdem und stehen unter „Meldungen“." emptyDescription="Ohne Weg erfährt niemand von einem Ausfall."
emptyAction={ emptyAction={
mayWriteSettings ? ( mayWriteSettings ? (
<Button variant="primary" onClick={() => setIsChannelDialogOpen(true)}> <Button variant="primary" onClick={() => setIsChannelDialogOpen(true)}>
@ -329,7 +329,7 @@ export function SettingsPage({
} }
error={ error={
draftType === 'webhook' && draftTarget.trim().length > 8 && !webhookIsSecure draftType === 'webhook' && draftTarget.trim().length > 8 && !webhookIsSecure
? 'Nur https. Ein Webhook traegt oft ein Token im Pfad.' ? 'Nur https. Ein Webhook trägt oft ein Token im Pfad.'
: undefined : undefined
} }
/> />
@ -347,19 +347,12 @@ export function SettingsPage({
</SelectInput> </SelectInput>
<Callout tone="info"> <Callout tone="info">
Eine zu niedrige Schwelle ist gefaehrlicher als eine zu hohe: Wer Eine zu niedrige Schwelle ist gefährlicher als eine zu hohe.
taeglich zwanzig Meldungen bekommt, schaltet sie nach einer Woche
ab — und dann kommt auch die kritische nicht mehr an.
</Callout> </Callout>
{draftType === 'webhook' ? ( {draftType === 'webhook' ? (
<Callout tone="warning"> <Callout tone="warning">
Interne Adressen werden abgelehnt — der Metadatendienst einer Interne Adressen werden abgelehnt — geprüft beim Anlegen und vor jedem Aufruf.
Cloud (<code className="font-mono text-xs">169.254.169.254</code>)
und die eigene Datenbank sind sonst mit einem Eintrag erreichbar.
Geprueft wird beim Anlegen <strong>und</strong> vor jedem
Verbindungsaufbau: Ein Name kann zwischenzeitlich auf eine andere
Adresse zeigen.
</Callout> </Callout>
) : null} ) : null}
</DialogBody> </DialogBody>
@ -404,9 +397,7 @@ export function SettingsPage({
}} }}
> >
<Callout tone="warning"> <Callout tone="warning">
Ueber diesen Weg wird danach nichts mehr zugestellt. Ist es der Danach wird über diesen Weg nichts mehr zugestellt.
einzige, erfaehrt niemand mehr von einem Ausfall — die Meldungen
entstehen weiterhin und stehen unter „Meldungen“.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>

View File

@ -1,13 +1,13 @@
/** /**
* Benutzer. * Benutzer.
* *
* Der Schutz des letzten Administrators liegt beim Server — die Oberflaeche * Der Schutz des letzten Administrators liegt beim Server — die Oberfläche
* kann ihn nicht durchsetzen, weil sie nicht weiss, wer sonst noch Administrator * kann ihn nicht durchsetzen, weil sie nicht weiß, wer sonst noch Administrator
* ist. Was sie tut: den Serverfehler in einen verstaendlichen Satz uebersetzen, * ist. Was sie tut: den Serverfehler in einen verständlichen Satz übersetzen,
* statt „LAST_ADMINISTRATOR" anzuzeigen. * statt „LAST_ADMINISTRATOR" anzuzeigen.
* *
* Ein fehlender zweiter Faktor wird benannt, nicht verschwiegen. Er ist ein * Ein fehlender zweiter Faktor wird benannt, nicht verschwiegen. Er ist ein
* Sicherheitsbefund, und die Liste ist der einzige Ort, an dem er auffaellt. * Sicherheitsbefund, und die Liste ist der einzige Ort, an dem er auffällt.
*/ */
import { Plus, ShieldOff, Trash2, UserCog } from 'lucide-react'; import { Plus, ShieldOff, Trash2, UserCog } from 'lucide-react';
@ -74,7 +74,7 @@ export function UsersPage({
onSuccess: (user) => { onSuccess: (user) => {
toast.showSuccess( toast.showSuccess(
'Konto angelegt', 'Konto angelegt',
`${user.username} — richten Sie als Naechstes den zweiten Faktor ein.`, `${user.username} — richten Sie als Nächstes den zweiten Faktor ein.`,
); );
setIsCreateDialogOpen(false); setIsCreateDialogOpen(false);
setDraftUsername(''); setDraftUsername('');
@ -84,34 +84,34 @@ export function UsersPage({
usersResource.reload(); usersResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Das Konto liess sich nicht anlegen', describeApiError(apiError), apiError.requestId), toast.showError('Das Konto ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId),
}); });
const updateMutation = useMutation(updateUser, { const updateMutation = useMutation(updateUser, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Konto geaendert'); toast.showSuccess('Konto geändert');
setUserBeingEdited(null); setUserBeingEdited(null);
usersResource.reload(); usersResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Die Aenderung schlug fehl', describeApiError(apiError), apiError.requestId), toast.showError('Die Änderung schlug fehl', describeApiError(apiError), apiError.requestId),
}); });
const deleteMutation = useMutation(deleteUser, { const deleteMutation = useMutation(deleteUser, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Konto geloescht'); toast.showSuccess('Konto gelöscht');
setUserPendingDeletion(null); setUserPendingDeletion(null);
usersResource.reload(); usersResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId), toast.showError('Das Löschen schlug fehl', describeApiError(apiError), apiError.requestId),
}); });
const mfaResetMutation = useMutation(disableUserMfa, { const mfaResetMutation = useMutation(disableUserMfa, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess( toast.showSuccess(
'Zweiter Faktor zurueckgesetzt', 'Zweiter Faktor zurückgesetzt',
'Das Konto ist jetzt nur noch durch sein Passwort geschuetzt.', 'Das Konto ist jetzt nur noch durch sein Passwort geschützt.',
); );
setUserPendingMfaReset(null); setUserPendingMfaReset(null);
usersResource.reload(); usersResource.reload();
@ -197,8 +197,8 @@ export function UsersPage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Zweiten Faktor zuruecksetzen" title="Zweiten Faktor zurücksetzen"
aria-label={`Zweiten Faktor von ${user.username} zuruecksetzen`} aria-label={`Zweiten Faktor von ${user.username} zurücksetzen`}
onClick={() => setUserPendingMfaReset(user)} onClick={() => setUserPendingMfaReset(user)}
> >
<ShieldOff /> <ShieldOff />
@ -207,8 +207,8 @@ export function UsersPage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Loeschen" title="Löschen"
aria-label={`${user.username} loeschen`} aria-label={`${user.username} löschen`}
onClick={() => setUserPendingDeletion(user)} onClick={() => setUserPendingDeletion(user)}
> >
<Trash2 /> <Trash2 />
@ -253,8 +253,8 @@ export function UsersPage({
{usersWithoutMfa > 0 ? ( {usersWithoutMfa > 0 ? (
<Callout tone="warning" className="mb-4" title="Zweiter Faktor fehlt"> <Callout tone="warning" className="mb-4" title="Zweiter Faktor fehlt">
{usersWithoutMfa} von {users.length} Konten sind nur durch ihr Passwort {usersWithoutMfa} von {users.length} Konten sind nur durch ihr Passwort
geschuetzt. Bei Konten mit Loeschrecht ist das der Punkt, an dem ein geschützt. Bei Konten mit Löschrecht ist das der Punkt, an dem ein
uebernommenes Konto zum Datenverlust fuehrt. übernommenes Konto zum Datenverlust führt.
</Callout> </Callout>
) : null} ) : null}
@ -293,7 +293,7 @@ export function UsersPage({
autoComplete="new-password" autoComplete="new-password"
value={draftPassword} value={draftPassword}
onChange={(changeEvent) => setDraftPassword(changeEvent.target.value)} onChange={(changeEvent) => setDraftPassword(changeEvent.target.value)}
hint="Wird mit Argon2id gehasht abgelegt. Der Klartext verlaesst diese Maske nur im Anfragekoerper." hint="Wird mit Argon2id gehasht abgelegt. Der Klartext verlässt diese Maske nur im Anfragekörper."
/> />
<div className="space-y-2"> <div className="space-y-2">
@ -382,9 +382,7 @@ export function UsersPage({
</div> </div>
<Callout tone="info"> <Callout tone="info">
Dem <strong>letzten Administrator</strong> lassen sich die Dem letzten Administrator lassen sich die Rechte nicht entziehen.
Verwaltungsrechte nicht entziehen — der Server lehnt das ab. Sonst
waere die Anlage nicht mehr verwaltbar.
</Callout> </Callout>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
@ -415,9 +413,9 @@ export function UsersPage({
<ConfirmDialog <ConfirmDialog
open={userPendingDeletion !== null} open={userPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setUserPendingDeletion(null)} onOpenChange={(isOpen) => !isOpen && setUserPendingDeletion(null)}
title="Konto loeschen" title="Konto löschen"
description={userPendingDeletion?.username} description={userPendingDeletion?.username}
confirmLabel="Loeschen" confirmLabel="Löschen"
isDestructive isDestructive
isLoading={deleteMutation.isRunning} isLoading={deleteMutation.isRunning}
confirmationWord={userPendingDeletion?.username} confirmationWord={userPendingDeletion?.username}
@ -428,18 +426,16 @@ export function UsersPage({
}} }}
> >
<Callout tone="info"> <Callout tone="info">
Die Eintraege dieses Kontos im Ereignisprotokoll bleiben bestehen — Die Einträge im Ereignisprotokoll bleiben bestehen.
ein Protokoll, das sich durch Loeschen eines Kontos bereinigen liesse,
waere keines.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={userPendingMfaReset !== null} open={userPendingMfaReset !== null}
onOpenChange={(isOpen) => !isOpen && setUserPendingMfaReset(null)} onOpenChange={(isOpen) => !isOpen && setUserPendingMfaReset(null)}
title="Zweiten Faktor zuruecksetzen" title="Zweiten Faktor zurücksetzen"
description={userPendingMfaReset?.username} description={userPendingMfaReset?.username}
confirmLabel="Zuruecksetzen" confirmLabel="Zurücksetzen"
isDestructive isDestructive
isLoading={mfaResetMutation.isRunning} isLoading={mfaResetMutation.isRunning}
onConfirm={() => { onConfirm={() => {
@ -449,10 +445,7 @@ export function UsersPage({
}} }}
> >
<Callout tone="warning"> <Callout tone="warning">
Danach genuegt <strong>das Passwort allein</strong>. Der Vorgang wird Danach genügt das Passwort allein. Prüfen Sie die Anfrage auf anderem Weg.
protokolliert. Vergewissern Sie sich auf anderem Weg, dass die
Anfrage tatsaechlich von dieser Person stammt — das ist der uebliche
Ansatzpunkt fuer eine Kontouebernahme.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>

View File

@ -1,12 +1,12 @@
/** /**
* API-Anbindung der Verwaltung: Benutzer, Rollen, Benachrichtigungswege, MFA. * API-Anbindung der Verwaltung: Benutzer, Rollen, Benachrichtigungswege, MFA.
* *
* Diese vier gehoeren zusammen, weil sie dieselbe Frage betreffen: **Wer darf * Diese vier gehören zusammen, weil sie dieselbe Frage betreffen: **Wer darf
* was, und wer erfaehrt davon?** Zwei Schutzmechanismen praegen die Masken: * was, und wer erfährt davon?** Zwei Schutzmechanismen prägen die Masken:
* *
* - **Der letzte Administrator ist geschuetzt** gegen Loeschung, Deaktivierung * - **Der letzte Administrator ist geschützt** gegen Löschung, Deaktivierung
* und Rollenentzug. Sonst waere die Anlage nicht mehr verwaltbar. * und Rollenentzug. Sonst wäre die Anlage nicht mehr verwaltbar.
* - **Mitgelieferte Rollen sind unveraenderlich.** Eine Aenderung verschoebe die * - **Mitgelieferte Rollen sind unveränderlich.** Eine Änderung verschöbe die
* Bedeutung bestehender Zuweisungen — jemand behielte eine Rolle, die etwas * Bedeutung bestehender Zuweisungen — jemand behielte eine Rolle, die etwas
* anderes bedeutet als bei ihrer Vergabe. * anderes bedeutet als bei ihrer Vergabe.
*/ */
@ -28,11 +28,11 @@ export interface User {
mfa_enabled: boolean; mfa_enabled: boolean;
last_login_at?: string; last_login_at?: string;
created_at?: string; created_at?: string;
/** Zaehler fehlgeschlagener Anmeldungen (Brute-Force-Schutz). */ /** Zähler fehlgeschlagener Anmeldungen (Brute-Force-Schutz). */
failed_login_count?: number; failed_login_count?: number;
} }
/** Laedt die Benutzer. */ /** Lädt die Benutzer. */
export async function listUsers(abortSignal?: AbortSignal): Promise<User[]> { export async function listUsers(abortSignal?: AbortSignal): Promise<User[]> {
return requestApi<User[]>('/users', abortSignal ? { signal: abortSignal } : {}); return requestApi<User[]>('/users', abortSignal ? { signal: abortSignal } : {});
} }
@ -40,8 +40,8 @@ export async function listUsers(abortSignal?: AbortSignal): Promise<User[]> {
/** /**
* Legt ein Benutzerkonto an. * Legt ein Benutzerkonto an.
* *
* Das Passwort geht ausschliesslich ueber den Anfragekoerper — niemals als * Das Passwort geht ausschließlich über den Anfragekörper — niemals als
* Abfrageparameter, sonst stuende es in jedem Zugriffsprotokoll. * Abfrageparameter, sonst stünde es in jedem Zugriffsprotokoll.
*/ */
export async function createUser(userInput: { export async function createUser(userInput: {
username: string; username: string;
@ -56,7 +56,7 @@ export async function createUser(userInput: {
}); });
} }
/** Aendert ein Benutzerkonto. */ /** Ändert ein Benutzerkonto. */
export async function updateUser( export async function updateUser(
userIdentifier: string, userIdentifier: string,
changes: { changes: {
@ -72,7 +72,7 @@ export async function updateUser(
}); });
} }
/** Loescht ein Benutzerkonto. */ /** Löscht ein Benutzerkonto. */
export async function deleteUser(userIdentifier: string): Promise<void> { export async function deleteUser(userIdentifier: string): Promise<void> {
return requestApi<void>(`/users/${encodeURIComponent(userIdentifier)}`, { return requestApi<void>(`/users/${encodeURIComponent(userIdentifier)}`, {
method: 'DELETE', method: 'DELETE',
@ -81,10 +81,10 @@ export async function deleteUser(userIdentifier: string): Promise<void> {
} }
/** /**
* Setzt den zweiten Faktor eines Kontos zurueck. * Setzt den zweiten Faktor eines Kontos zurück.
* *
* Der Weg fuer den Fall, dass jemand sein Geraet verloren hat. Es ist eine * Der Weg für den Fall, dass jemand sein Gerät verloren hat. Es ist eine
* sicherheitsrelevante Handlung und wird auditiert: Danach genuegt das * sicherheitsrelevante Handlung und wird auditiert: Danach genügt das
* Passwort allein. * Passwort allein.
*/ */
export async function disableUserMfa(userIdentifier: string): Promise<User> { export async function disableUserMfa(userIdentifier: string): Promise<User> {
@ -104,7 +104,7 @@ export interface Role {
name: string; name: string;
description?: string; description?: string;
permissions: string[]; permissions: string[];
/** Mitgelieferte Rollen sind unveraenderlich. */ /** Mitgelieferte Rollen sind unveränderlich. */
is_builtin?: boolean; is_builtin?: boolean;
user_count?: number; user_count?: number;
} }
@ -117,12 +117,12 @@ export interface Permission {
category?: string; category?: string;
} }
/** Laedt die Rollen. */ /** Lädt die Rollen. */
export async function listRoles(abortSignal?: AbortSignal): Promise<Role[]> { export async function listRoles(abortSignal?: AbortSignal): Promise<Role[]> {
return requestApi<Role[]>('/roles', abortSignal ? { signal: abortSignal } : {}); return requestApi<Role[]>('/roles', abortSignal ? { signal: abortSignal } : {});
} }
/** Laedt die vergebbaren Berechtigungen. */ /** Lädt die vergebbaren Berechtigungen. */
export async function listPermissions(abortSignal?: AbortSignal): Promise<Permission[]> { export async function listPermissions(abortSignal?: AbortSignal): Promise<Permission[]> {
return requestApi<Permission[]>('/permissions', abortSignal ? { signal: abortSignal } : {}); return requestApi<Permission[]>('/permissions', abortSignal ? { signal: abortSignal } : {});
} }
@ -140,7 +140,7 @@ export async function createRole(roleInput: {
}); });
} }
/** Aendert eine Rolle. */ /** Ändert eine Rolle. */
export async function updateRole( export async function updateRole(
roleIdentifier: string, roleIdentifier: string,
changes: { description?: string; permissions?: string[] }, changes: { description?: string; permissions?: string[] },
@ -151,7 +151,7 @@ export async function updateRole(
}); });
} }
/** Loescht eine Rolle. */ /** Löscht eine Rolle. */
export async function deleteRole(roleIdentifier: string): Promise<void> { export async function deleteRole(roleIdentifier: string): Promise<void> {
return requestApi<void>(`/roles/${encodeURIComponent(roleIdentifier)}`, { return requestApi<void>(`/roles/${encodeURIComponent(roleIdentifier)}`, {
method: 'DELETE', method: 'DELETE',
@ -163,7 +163,7 @@ export async function deleteRole(roleIdentifier: string): Promise<void> {
// Benachrichtigungswege // Benachrichtigungswege
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Ein Zustellweg fuer Meldungen. */ /** Ein Zustellweg für Meldungen. */
export interface NotificationChannel { export interface NotificationChannel {
id: string; id: string;
name: string; name: string;
@ -179,15 +179,15 @@ export interface NotificationChannel {
/** /**
* Konfiguration ohne Geheimnisse. * Konfiguration ohne Geheimnisse.
* *
* Bei einem Webhook steht die Adresse hier, und die traegt oft ein Token im * Bei einem Webhook steht die Adresse hier, und die trägt oft ein Token im
* Pfad — der Server liefert sie deshalb geschwaerzt oder gar nicht aus. * Pfad — der Server liefert sie deshalb geschwärzt oder gar nicht aus.
*/ */
configuration?: Record<string, unknown>; configuration?: Record<string, unknown>;
enabled?: boolean; enabled?: boolean;
created_at?: string; created_at?: string;
} }
/** Laedt die Benachrichtigungswege. */ /** Lädt die Benachrichtigungswege. */
export async function listNotificationChannels( export async function listNotificationChannels(
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<NotificationChannel[]> { ): Promise<NotificationChannel[]> {
@ -212,7 +212,7 @@ export async function createNotificationChannel(channelInput: {
}); });
} }
/** Loescht einen Benachrichtigungsweg. */ /** Löscht einen Benachrichtigungsweg. */
export async function deleteNotificationChannel(channelIdentifier: string): Promise<void> { export async function deleteNotificationChannel(channelIdentifier: string): Promise<void> {
return requestApi<void>( return requestApi<void>(
`/notification-channels/${encodeURIComponent(channelIdentifier)}`, `/notification-channels/${encodeURIComponent(channelIdentifier)}`,
@ -228,7 +228,7 @@ export async function deleteNotificationChannel(channelIdentifier: string): Prom
export interface MfaEnrollment { export interface MfaEnrollment {
/** Das gemeinsame Geheimnis in Base32 — einmalig. */ /** Das gemeinsame Geheimnis in Base32 — einmalig. */
secret: string; secret: string;
/** URI fuer die Authentifizierungs-App. */ /** URI für die Authentifizierungs-App. */
otpauth_url?: string; otpauth_url?: string;
} }
@ -238,11 +238,11 @@ export async function enrollMfa(): Promise<MfaEnrollment> {
} }
/** /**
* Schliesst die Einrichtung mit einem Code ab. * Schließt die Einrichtung mit einem Code ab.
* *
* Erst dieser Schritt macht den zweiten Faktor scharf. Ohne ihn haette jemand * Erst dieser Schritt macht den zweiten Faktor scharf. Ohne ihn hätte jemand
* ein Geheimnis hinterlegt, dessen App er nie geprueft hat — und sich beim * ein Geheimnis hinterlegt, dessen App er nie geprüft hat — und sich beim
* naechsten Anmelden ausgesperrt. * nächsten Anmelden ausgesperrt.
*/ */
export async function confirmMfa(code: string): Promise<{ mfa_enabled: boolean }> { export async function confirmMfa(code: string): Promise<{ mfa_enabled: boolean }> {
return requestApi<{ mfa_enabled: boolean }>('/me/mfa/confirm', { return requestApi<{ mfa_enabled: boolean }>('/me/mfa/confirm', {

View File

@ -2,13 +2,13 @@
* Meldungen. * Meldungen.
* *
* Das Regelwerk steht mit auf der Seite, und das ist die tragende Entscheidung: * Das Regelwerk steht mit auf der Seite, und das ist die tragende Entscheidung:
* **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiss, * **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiß,
* was ueberhaupt geprueft wird.** Ohne die Liste bliebe offen, ob „keine * was überhaupt geprüft wird.** Ohne die Liste bliebe offen, ob „keine
* Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprueft". * Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprüft".
* *
* „Zur Kenntnis nehmen" schliesst eine Meldung nicht. Sie bleibt offen und in * „Zur Kenntnis nehmen" schließt eine Meldung nicht. Sie bleibt offen und in
* der Liste, bis ihre Ursache verschwindet — sonst verschwaende der Zustand aus * der Liste, bis ihre Ursache verschwindet — sonst verschwände der Zustand aus
* der Uebersicht, obwohl er weiterbesteht. * der Übersicht, obwohl er weiterbesteht.
*/ */
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
@ -92,7 +92,7 @@ export function AlertsPage(): React.JSX.Element {
title="Meldungen" title="Meldungen"
description={ description={
overviewResource.data !== null overviewResource.data !== null
? `${overviewResource.data.available_rule_count} von ${overviewResource.data.rules.length} Regeln werden geprueft.` ? `${overviewResource.data.available_rule_count} von ${overviewResource.data.rules.length} Regeln werden geprüft.`
: undefined : undefined
} }
/> />
@ -123,7 +123,7 @@ export function AlertsPage(): React.JSX.Element {
{loadState === 'loaded' && data !== null && data.length === 0 ? ( {loadState === 'loaded' && data !== null && data.length === 0 ? (
<EmptyState <EmptyState
title={onlyActive ? 'Keine unerledigten Meldungen' : 'Es gibt keine Meldungen'} title={onlyActive ? 'Keine unerledigten Meldungen' : 'Es gibt keine Meldungen'}
description="Die Liste „Worauf geachtet wird“ unten sagt, was geprueft wird — erst damit ist eine leere Liste eine gute Nachricht." description="Die Liste „Worauf geachtet wird“ unten sagt, was geprüft wird — erst damit ist eine leere Liste eine gute Nachricht."
/> />
) : null} ) : null}
@ -166,8 +166,8 @@ function SummaryBar({ overview }: { readonly overview: AlertOverview }): React.J
<p <p
className={cn( className={cn(
'text-2xl font-semibold tabular', 'text-2xl font-semibold tabular',
// Farbe nur, wenn der Wert eine Aussage traegt: Null kritische // Farbe nur, wenn der Wert eine Aussage trägt: Null kritische
// Meldungen sind neutral, nicht gruen — gruen hiesse "geprueft // Meldungen sind neutral, nicht grün — grün hieße "geprüft
// und in Ordnung", und das sagt diese Zahl allein nicht. // und in Ordnung", und das sagt diese Zahl allein nicht.
figure.tone && figure.value > 0 figure.tone && figure.value > 0
? figure.tone === 'critical' ? figure.tone === 'critical'
@ -247,7 +247,7 @@ function AlertRow({
</Button> </Button>
) : null} ) : null}
<Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}> <Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}>
Schliessen Schließen
</Button> </Button>
</div> </div>
) : null} ) : null}
@ -259,9 +259,9 @@ function AlertRow({
/** /**
* Zeigt, worauf geachtet wird. * Zeigt, worauf geachtet wird.
* *
* Eine Regel, die dauerhaft schweigt, ist gefaehrlicher als keine — sie erweckt * Eine Regel, die dauerhaft schweigt, ist gefährlicher als keine — sie erweckt
* den Eindruck, es werde geprueft. Deshalb steht bei jeder nicht ausgewerteten * den Eindruck, es werde geprüft. Deshalb steht bei jeder nicht ausgewerteten
* Regel ausdruecklich, dass sie nicht greift, und warum. * Regel ausdrücklich, dass sie nicht greift, und warum.
*/ */
function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element { function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element {
return ( return (
@ -285,7 +285,7 @@ function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX
{rule.available ? ( {rule.available ? (
<StatusBadge status={rule.severity} /> <StatusBadge status={rule.severity} />
) : ( ) : (
<StatusBadge status="neutral" label="wird nicht geprueft" /> <StatusBadge status="neutral" label="wird nicht geprüft" />
)} )}
</div> </div>
<p className="mt-1 text-sm text-fg-muted"> <p className="mt-1 text-sm text-fg-muted">

View File

@ -1,5 +1,5 @@
/** /**
* Zugriff auf Meldungen und Benachrichtigungskanaele. * Zugriff auf Meldungen und Benachrichtigungskanäle.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
@ -12,17 +12,17 @@ export type AlertStatus = 'open' | 'acknowledged' | 'resolved';
/** Eine Meldung. */ /** Eine Meldung. */
export interface Alert { export interface Alert {
/** Oeffentlicher Bezeichner. */ /** Öffentlicher Bezeichner. */
readonly id: string; readonly id: string;
/** Ausloesende Regel. */ /** Auslösende Regel. */
readonly rule_name: string; readonly rule_name: string;
/** Schweregrad. */ /** Schweregrad. */
readonly severity: AlertSeverity; readonly severity: AlertSeverity;
/** Bearbeitungszustand. */ /** Bearbeitungszustand. */
readonly status: AlertStatus; readonly status: AlertStatus;
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Befund und naechste Handlung. */ /** Befund und nächste Handlung. */
readonly message: string; readonly message: string;
/** Art des betroffenen Gegenstands. */ /** Art des betroffenen Gegenstands. */
readonly entity_type?: string; readonly entity_type?: string;
@ -41,11 +41,11 @@ export interface Alert {
readonly last_seen_at: string; readonly last_seen_at: string;
/** Zeitpunkt der Kenntnisnahme in UTC. */ /** Zeitpunkt der Kenntnisnahme in UTC. */
readonly acknowledged_at?: string; readonly acknowledged_at?: string;
/** Bemerkung des Bestaetigenden. */ /** Bemerkung des Bestätigenden. */
readonly acknowledgement_note?: string; readonly acknowledgement_note?: string;
/** Zeitpunkt der Aufloesung in UTC. */ /** Zeitpunkt der Auflösung in UTC. */
readonly resolved_at?: string; readonly resolved_at?: string;
/** Begruendung der Aufloesung. */ /** Begründung der Auflösung. */
readonly resolution_note?: string; readonly resolution_note?: string;
} }
@ -55,13 +55,13 @@ export interface AlertRule {
readonly name: string; readonly name: string;
/** Bezeichnung. */ /** Bezeichnung. */
readonly title: string; readonly title: string;
/** Erklaerung. */ /** Erklärung. */
readonly description: string; readonly description: string;
/** Schweregrad ausgeloester Meldungen. */ /** Schweregrad ausgelöster Meldungen. */
readonly severity: AlertSeverity; readonly severity: AlertSeverity;
/** Meldet, ob die Regel ausloesen kann. */ /** Meldet, ob die Regel auslösen kann. */
readonly available: boolean; readonly available: boolean;
/** Erklaert eine Regel ohne Datengrundlage. */ /** Erklärt eine Regel ohne Datengrundlage. */
readonly unavailable_reason?: string; readonly unavailable_reason?: string;
} }
@ -79,17 +79,17 @@ export interface AlertSummary {
readonly resolved_last_day: number; readonly resolved_last_day: number;
} }
/** Antwort der Meldungsuebersicht. */ /** Antwort der Meldungsübersicht. */
export interface AlertOverview { export interface AlertOverview {
/** Die Meldungslage. */ /** Die Meldungslage. */
readonly summary: AlertSummary; readonly summary: AlertSummary;
/** Alle Regeln. */ /** Alle Regeln. */
readonly rules: readonly AlertRule[]; readonly rules: readonly AlertRule[];
/** Zahl der ausloesbaren Regeln. */ /** Zahl der auslösbaren Regeln. */
readonly available_rule_count: number; readonly available_rule_count: number;
} }
/** Laedt die Meldungen. */ /** Lädt die Meldungen. */
export async function fetchAlerts( export async function fetchAlerts(
onlyActive: boolean, onlyActive: boolean,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -106,7 +106,7 @@ export async function fetchAlerts(
); );
} }
/** Laedt die Meldungslage samt Regelwerk. */ /** Lädt die Meldungslage samt Regelwerk. */
export async function fetchAlertOverview(abortSignal?: AbortSignal): Promise<AlertOverview> { export async function fetchAlertOverview(abortSignal?: AbortSignal): Promise<AlertOverview> {
return requestApi<AlertOverview>('/alerts/summary', abortSignal ? { signal: abortSignal } : {}); return requestApi<AlertOverview>('/alerts/summary', abortSignal ? { signal: abortSignal } : {});
} }
@ -119,7 +119,7 @@ export async function acknowledgeAlert(alertIdentifier: string, note: string): P
}); });
} }
/** Schliesst eine Meldung von Hand. */ /** Schließt eine Meldung von Hand. */
export async function resolveAlert(alertIdentifier: string, note: string): Promise<void> { export async function resolveAlert(alertIdentifier: string, note: string): Promise<void> {
await requestApi<unknown>(`/alerts/${encodeURIComponent(alertIdentifier)}/resolve`, { await requestApi<unknown>(`/alerts/${encodeURIComponent(alertIdentifier)}/resolve`, {
method: 'POST', method: 'POST',

View File

@ -12,7 +12,7 @@ function buildResponse(responseBody: unknown, statusCode = 200): Response {
}); });
} }
/** Ein vollstaendiger Benutzer, wie ihn die API zurueckgibt. */ /** Ein vollständiger Benutzer, wie ihn die API zurückgibt. */
const testUser = { const testUser = {
id: '11111111-1111-4111-8111-111111111111', id: '11111111-1111-4111-8111-111111111111',
username: 'admin', username: 'admin',
@ -22,7 +22,7 @@ const testUser = {
permissions: ['users.read'], permissions: ['users.read'],
}; };
/** Ein Tokenpaar, wie es die API zurueckgibt. */ /** Ein Tokenpaar, wie es die API zurückgibt. */
const testTokens = { const testTokens = {
access_token: 'zugriffstoken', // secretscan:erlaubt: erfundener Testwert access_token: 'zugriffstoken', // secretscan:erlaubt: erfundener Testwert
refresh_token: 'erneuerungstoken', // secretscan:erlaubt: erfundener Testwert refresh_token: 'erneuerungstoken', // secretscan:erlaubt: erfundener Testwert
@ -85,7 +85,7 @@ describe('LoginPage', () => {
expect(handleAuthenticated).not.toHaveBeenCalled(); expect(handleAuthenticated).not.toHaveBeenCalled();
}); });
it('schliesst die Anmeldung nach gueltigem Code ab', async () => { it('schließt die Anmeldung nach gültigem Code ab', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValueOnce( .mockResolvedValueOnce(
@ -112,14 +112,14 @@ describe('LoginPage', () => {
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument()); await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
await userEvent.type(screen.getByLabelText('Code'), '123456'); await userEvent.type(screen.getByLabelText('Code'), '123456');
await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' })); await userEvent.click(screen.getByRole('button', { name: 'Bestätigen' }));
await waitFor(() => { await waitFor(() => {
expect(handleAuthenticated).toHaveBeenCalled(); expect(handleAuthenticated).toHaveBeenCalled();
}); });
}); });
it('zeigt die Fehlermeldung des Servers verstaendlich an', async () => { it('zeigt die Fehlermeldung des Servers verständlich an', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue( vi.fn().mockResolvedValue(
@ -166,14 +166,14 @@ describe('LoginPage', () => {
await userEvent.type(passwordField, 'falsch'); await userEvent.type(passwordField, 'falsch');
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' })); await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
// Ein stehengebliebenes Passwort waere auf einem gemeinsam genutzten // Ein stehengebliebenes Passwort wäre auf einem gemeinsam genutzten
// Bildschirm sichtbar. // Bildschirm sichtbar.
await waitFor(() => { await waitFor(() => {
expect(passwordField.value).toBe(''); expect(passwordField.value).toBe('');
}); });
}); });
it('kehrt bei abgelaufener Herausforderung zur Anmeldung zurueck', async () => { it('kehrt bei abgelaufener Herausforderung zur Anmeldung zurück', async () => {
const fetchMock = vi const fetchMock = vi
.fn() .fn()
.mockResolvedValueOnce( .mockResolvedValueOnce(
@ -205,9 +205,9 @@ describe('LoginPage', () => {
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument()); await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
await userEvent.type(screen.getByLabelText('Code'), '123456'); await userEvent.type(screen.getByLabelText('Code'), '123456');
await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' })); await userEvent.click(screen.getByRole('button', { name: 'Bestätigen' }));
// Ohne Rueckkehr sässe der Benutzer in einem Schritt fest, der nicht mehr gilt. // Ohne Rückkehr sässe der Benutzer in einem Schritt fest, der nicht mehr gilt.
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText('Benutzername')).toBeInTheDocument(); expect(screen.getByLabelText('Benutzername')).toBeInTheDocument();
}); });

View File

@ -29,7 +29,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
/** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */ /** Wandelt einen Fehler in eine verständliche Meldung (PROMPT.md §124). */
function describeError(caughtError: unknown): string { function describeError(caughtError: unknown): string {
if (caughtError instanceof ApiError) { if (caughtError instanceof ApiError) {
return caughtError.message; return caughtError.message;
@ -59,7 +59,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
} }
// Weder Tokens noch Herausforderung: die Antwort passt nicht zum Vertrag. // Weder Tokens noch Herausforderung: die Antwort passt nicht zum Vertrag.
setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.'); setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.');
} catch (caughtError) { } catch (caughtError) {
setErrorMessage(describeError(caughtError)); setErrorMessage(describeError(caughtError));
// Das Passwort wird nach einem Fehlversuch geleert. // Das Passwort wird nach einem Fehlversuch geleert.
@ -88,7 +88,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
return; return;
} }
setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.'); setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.');
} catch (caughtError) { } catch (caughtError) {
setErrorMessage(describeError(caughtError)); setErrorMessage(describeError(caughtError));
setMfaCode(''); setMfaCode('');
@ -147,7 +147,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
)} )}
<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}> <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 läuft …' : 'Anmelden'}
</button> </button>
</form> </form>
) : ( ) : (
@ -155,7 +155,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
<p className="mt-1 text-sm text-fg-muted">Zweiter Faktor</p> <p className="mt-1 text-sm text-fg-muted">Zweiter Faktor</p>
<p className="mt-1.5 text-xs text-fg-muted"> <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 möglich.
</p> </p>
<label className="block text-sm font-medium text-fg" htmlFor="mfa-code"> <label className="block text-sm font-medium text-fg" htmlFor="mfa-code">
@ -167,7 +167,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
type="text" type="text"
value={mfaCode} value={mfaCode}
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)} onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
// one-time-code laesst Mobilgeraete den Code aus der SMS/App vorschlagen. // one-time-code lässt Mobilgeräte den Code aus der SMS/App vorschlagen.
autoComplete="one-time-code" autoComplete="one-time-code"
inputMode="text" inputMode="text"
autoFocus autoFocus
@ -181,7 +181,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
)} )}
<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}> <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 ? 'Prüfung läuft …' : 'Bestätigen'}
</button> </button>
<button <button

View File

@ -1,13 +1,16 @@
/** /**
* Anmeldefunktionen gegen die Syncova-API. * Anmeldefunktionen gegen die Syncova-API.
* *
* Die Tokens werden ausschliesslich im Arbeitsspeicher gehalten und nicht in * Die Tokens liegen im Arbeitsspeicher **und** im Sitzungsspeicher des Tabs.
* localStorage abgelegt: dort waeren sie fuer jedes Skript der Seite lesbar und * Warum das eine Abkehr von der ursprünglichen Entscheidung ist und was sie
* ueberstuenden das Schliessen des Browsers (PROMPT.md §45). * trägt, steht in `sessionStore.ts` — kurz: Ohne Ablage warf jedes Neuladen den
* Betreiber auf die Anmeldemaske, und begrenzt wird die Sitzung ohnehin durch
* eine harte Obergrenze und eine Untätigkeitsgrenze.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
import type { CurrentUser, LoginResult, TokenPair } from '../../types/auth'; import type { CurrentUser, LoginResult, TokenPair } from '../../types/auth';
import { clearStoredSession, readStoredSession, writeStoredSession } from './sessionStore';
/** Im Arbeitsspeicher gehaltenes Zugriffstoken. */ /** Im Arbeitsspeicher gehaltenes Zugriffstoken. */
let currentAccessToken: string | null = null; let currentAccessToken: string | null = null;
@ -15,27 +18,53 @@ let currentAccessToken: string | null = null;
/** Im Arbeitsspeicher gehaltenes Erneuerungstoken. */ /** Im Arbeitsspeicher gehaltenes Erneuerungstoken. */
let currentRefreshToken: string | null = null; let currentRefreshToken: string | null = null;
/** Liefert das aktuelle Zugriffstoken. */ /**
* Liefert das aktuelle Zugriffstoken.
*
* Fehlt es im Arbeitsspeicher — der Fall nach einem Neuladen —, wird es aus dem
* Sitzungsspeicher geholt. Der liefert nur zurück, was beide Zeitgrenzen noch
* einhält.
*/
export function getAccessToken(): string | null { export function getAccessToken(): string | null {
if (currentAccessToken !== null) {
return currentAccessToken; return currentAccessToken;
} }
const storedSession = readStoredSession();
if (storedSession === null) {
return null;
}
currentAccessToken = storedSession.accessToken;
currentRefreshToken = storedSession.refreshToken;
return currentAccessToken;
}
/** Meldet, ob eine gültige Sitzung wiederaufgenommen werden kann. */
export function hasResumableSession(): boolean {
return readStoredSession() !== null;
}
/** Hinterlegt ein Tokenpaar nach erfolgreicher Anmeldung. */ /** Hinterlegt ein Tokenpaar nach erfolgreicher Anmeldung. */
export function storeTokens(tokenPair: TokenPair): void { export function storeTokens(tokenPair: TokenPair): void {
currentAccessToken = tokenPair.access_token; currentAccessToken = tokenPair.access_token;
currentRefreshToken = tokenPair.refresh_token; currentRefreshToken = tokenPair.refresh_token;
writeStoredSession(tokenPair);
} }
/** Verwirft die hinterlegten Tokens. */ /** Verwirft die hinterlegten Tokens. */
export function clearTokens(): void { export function clearTokens(): void {
currentAccessToken = null; currentAccessToken = null;
currentRefreshToken = null; currentRefreshToken = null;
clearStoredSession();
} }
/** /**
* Meldet einen Benutzer mit Name und Passwort an. * Meldet einen Benutzer mit Name und Passwort an.
* *
* Ist ein zweiter Faktor eingerichtet, enthaelt das Ergebnis eine * Ist ein zweiter Faktor eingerichtet, enthält das Ergebnis eine
* Herausforderung statt der Tokens. * Herausforderung statt der Tokens.
*/ */
export async function login(username: string, password: string): Promise<LoginResult> { export async function login(username: string, password: string): Promise<LoginResult> {
@ -51,7 +80,7 @@ export async function login(username: string, password: string): Promise<LoginRe
return loginResult; return loginResult;
} }
/** Schliesst eine Anmeldung mit dem zweiten Faktor ab. */ /** Schließt eine Anmeldung mit dem zweiten Faktor ab. */
export async function verifyMFA(challengeId: string, code: string): Promise<LoginResult> { export async function verifyMFA(challengeId: string, code: string): Promise<LoginResult> {
const loginResult = await requestApi<LoginResult>('/auth/mfa/verify', { const loginResult = await requestApi<LoginResult>('/auth/mfa/verify', {
method: 'POST', method: 'POST',
@ -76,7 +105,7 @@ export async function logout(): Promise<void> {
} }
} }
/** Laedt den aktuell angemeldeten Benutzer. */ /** Lädt den aktuell angemeldeten Benutzer. */
export async function fetchCurrentUser(): Promise<CurrentUser> { export async function fetchCurrentUser(): Promise<CurrentUser> {
return requestApi<CurrentUser>('/me'); return requestApi<CurrentUser>('/me');
} }

View File

@ -0,0 +1,102 @@
/**
* Prüft die beiden Grenzen der Sitzung.
*
* Beide sind eine Zusage an den Betreiber, und beide lassen sich mit einer
* falschen Zeile still aushebeln: Wer beim Vermerken einer Interaktion die
* harte Obergrenze mitverschiebt, macht aus „30 Minuten" ein „unbegrenzt,
* solange die Maus wackelt".
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SESSION_IDLE_MS,
SESSION_MAXIMUM_MS,
clearStoredSession,
readStoredSession,
remainingSessionMs,
touchStoredSession,
writeStoredSession,
} from './sessionStore';
const tokenPair = {
access_token: 'zugriff',
refresh_token: 'erneuerung', // secretscan:erlaubt — Testwert, kein Geheimnis
access_expires_at: '',
refresh_expires_at: '',
};
describe('Sitzungsablage', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-18T10:00:00Z'));
clearStoredSession();
});
afterEach(() => {
vi.useRealTimers();
});
it('überlebt ein Neuladen innerhalb der Frist', () => {
writeStoredSession(tokenPair);
// Ein Neuladen bedeutet: neuer Arbeitsspeicher, gleiche Ablage.
const resumed = readStoredSession();
expect(resumed).not.toBeNull();
expect(resumed?.accessToken).toBe('zugriff');
});
it('endet nach der harten Obergrenze, auch bei ständiger Interaktion', () => {
writeStoredSession(tokenPair);
// Alle fünf Minuten eine Interaktion — sieben Mal, also 35 Minuten.
for (let step = 0; step < 7; step += 1) {
vi.advanceTimersByTime(5 * 60 * 1000);
touchStoredSession();
}
// Die Untätigkeitsgrenze wäre nie erreicht worden. Die Obergrenze schon.
expect(readStoredSession()).toBeNull();
});
it('endet bei Untätigkeit vor der Obergrenze', () => {
writeStoredSession(tokenPair);
vi.advanceTimersByTime(SESSION_IDLE_MS + 1000);
expect(readStoredSession()).toBeNull();
});
it('verschiebt die Obergrenze durch eine Interaktion nicht', () => {
const created = writeStoredSession(tokenPair);
vi.advanceTimersByTime(60 * 1000);
const touched = touchStoredSession();
expect(touched?.absoluteExpiryMs).toBe(created.absoluteExpiryMs);
expect(touched?.lastActivityMs).toBeGreaterThan(created.lastActivityMs);
});
it('nennt die frühere der beiden Grenzen als Restzeit', () => {
const created = writeStoredSession(tokenPair);
// Direkt nach der Anmeldung fallen beide Grenzen zusammen.
expect(remainingSessionMs(created)).toBe(SESSION_MAXIMUM_MS);
vi.advanceTimersByTime(20 * 60 * 1000);
const stillValid = readStoredSession();
expect(stillValid).not.toBeNull();
// Zehn Minuten bis zur Obergrenze, dreißig bis zur Untätigkeitsgrenze —
// maßgeblich sind die zehn.
expect(remainingSessionMs(stillValid!)).toBe(10 * 60 * 1000);
});
it('verwirft einen unlesbaren Eintrag, statt ihn zu raten', () => {
window.sessionStorage.setItem('syncova.session', '{kein gültiges json');
expect(readStoredSession()).toBeNull();
expect(window.sessionStorage.getItem('syncova.session')).toBeNull();
});
});

View File

@ -0,0 +1,181 @@
/**
* Ablage der Sitzung.
*
* **Diese Datei kehrt eine frühere Entscheidung um, und zwar bewusst.**
*
* Bisher lagen die Tokens ausschließlich im Arbeitsspeicher. Das war die
* sicherste Variante und praktisch unbrauchbar: Jedes Neuladen — F5, ein
* versehentlicher Klick auf „Aktualisieren", ein Absturz des Tabs — warf den
* Betreiber auf die Anmeldemaske zurück. Mitten in einer Störung ist das kein
* Sicherheitsgewinn, sondern ein Hindernis.
*
* Gewählt ist `sessionStorage`, nicht `localStorage`:
*
* | | überlebt F5 | überlebt Tab-Schließen | überlebt Neustart |
* | --- | --- | --- | --- |
* | Arbeitsspeicher | nein | nein | nein |
* | **sessionStorage** | **ja** | **nein** | **nein** |
* | localStorage | ja | ja | ja |
*
* Was den Rest trägt, ist nicht der Speicherort, sondern die Begrenzung:
*
* 1. **Harte Obergrenze** von 30 Minuten ab Anmeldung — unabhängig von jeder
* Aktivität.
* 2. **Untätigkeitsgrenze**: Wer nichts tut, wird abgemeldet.
* 3. **Sofortiger Widerruf serverseitig.** Die Tokens sind opak, kein JWT —
* genau deshalb wurden sie so gewählt. Ein gesperrtes Konto ist mit der
* nächsten Anfrage draußen, nicht erst nach Ablauf.
*
* Bleibt das Restrisiko: Ein Skript, das über XSS auf der Seite läuft, kann
* `sessionStorage` lesen. Dagegen steht die Content-Security-Policy der
* Auslieferung — und die Feststellung, dass ein Angreifer mit Skriptausführung
* ohnehin jede Anfrage im Namen des Angemeldeten stellen könnte, auch bei
* Tokens im Arbeitsspeicher.
*/
import type { TokenPair } from '../../types/auth';
/** Schlüssel der Ablage. */
const SESSION_STORAGE_KEY = 'syncova.session';
/** Harte Obergrenze einer Sitzung ab Anmeldung. */
export const SESSION_MAXIMUM_MS = 30 * 60 * 1000;
/** Grenze der Untätigkeit. */
export const SESSION_IDLE_MS = 30 * 60 * 1000;
/** Der abgelegte Zustand einer Sitzung. */
export interface StoredSession {
readonly accessToken: string;
readonly refreshToken: string;
/** Zeitpunkt, zu dem die Sitzung endet — unabhängig von Aktivität. */
readonly absoluteExpiryMs: number;
/** Zeitpunkt der letzten Interaktion. */
readonly lastActivityMs: number;
}
/** Liest die abgelegte Sitzung; `null`, wenn keine gültige vorliegt. */
export function readStoredSession(): StoredSession | null {
let rawValue: string | null = null;
try {
rawValue = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
} catch {
// Ein Browser mit gesperrtem Speicher ist kein Fehlerfall — die Sitzung
// gilt dann eben nur für diese Seitenansicht.
return null;
}
if (rawValue === null) {
return null;
}
let parsedSession: Partial<StoredSession>;
try {
parsedSession = JSON.parse(rawValue) as Partial<StoredSession>;
} catch {
// Ein unlesbarer Eintrag wird verworfen, nicht geraten.
clearStoredSession();
return null;
}
if (
typeof parsedSession.accessToken !== 'string' ||
typeof parsedSession.refreshToken !== 'string' ||
typeof parsedSession.absoluteExpiryMs !== 'number' ||
typeof parsedSession.lastActivityMs !== 'number'
) {
clearStoredSession();
return null;
}
const currentTime = Date.now();
// Beide Grenzen werden schon beim Lesen geprüft. Eine abgelaufene Sitzung
// darf gar nicht erst in den Arbeitsspeicher zurückkehren.
if (
currentTime >= parsedSession.absoluteExpiryMs ||
currentTime - parsedSession.lastActivityMs >= SESSION_IDLE_MS
) {
clearStoredSession();
return null;
}
return parsedSession as StoredSession;
}
/** Legt eine neue Sitzung ab und setzt beide Uhren. */
export function writeStoredSession(tokenPair: TokenPair): StoredSession {
const currentTime = Date.now();
const storedSession: StoredSession = {
accessToken: tokenPair.access_token,
refreshToken: tokenPair.refresh_token,
absoluteExpiryMs: currentTime + SESSION_MAXIMUM_MS,
lastActivityMs: currentTime,
};
persist(storedSession);
return storedSession;
}
/**
* Vermerkt eine Interaktion.
*
* Die harte Obergrenze bleibt unberührt — sonst ließe sich eine Sitzung durch
* Mausbewegung endlos verlängern, und „30 Minuten" wäre keine Zusage.
*/
export function touchStoredSession(): StoredSession | null {
const storedSession = readStoredSession();
if (storedSession === null) {
return null;
}
const refreshedSession: StoredSession = {
...storedSession,
lastActivityMs: Date.now(),
};
persist(refreshedSession);
return refreshedSession;
}
/** Verwirft die abgelegte Sitzung. */
export function clearStoredSession(): void {
try {
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
} catch {
// Nichts zu tun: Es gibt keinen Speicher, aus dem etwas zu entfernen wäre.
}
}
/** Schreibt den Zustand in den Sitzungsspeicher. */
function persist(storedSession: StoredSession): void {
try {
window.sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(storedSession));
} catch {
// Ohne Speicher läuft die Sitzung weiter, überlebt aber kein Neuladen.
}
}
/**
* Liefert die verbleibende Zeit in Millisekunden.
*
* Maßgeblich ist die **frühere** der beiden Grenzen: Eine Sitzung endet, sobald
* eine von beiden erreicht ist.
*/
export function remainingSessionMs(storedSession: StoredSession): number {
const currentTime = Date.now();
const untilAbsolute = storedSession.absoluteExpiryMs - currentTime;
const untilIdle = storedSession.lastActivityMs + SESSION_IDLE_MS - currentTime;
return Math.max(0, Math.min(untilAbsolute, untilIdle));
}

View File

@ -0,0 +1,172 @@
/**
* Sitzungsuhr und Untätigkeitserkennung.
*
* Der Haken liefert die verbleibende Zeit sekundengenau für die Anzeige und
* meldet das Ende, sobald eine der beiden Grenzen erreicht ist.
*
* Umgesetzt mit `useSyncExternalStore`, und das ist keine Geschmacksfrage: Die
* verbleibende Zeit hängt an zwei Dingen außerhalb von React — der Uhr und dem
* Sitzungsspeicher. Sie beim Rendern auszurechnen wäre ein unreiner Aufruf, sie
* in einem Effekt in den Zustand zu schreiben eine zweite Renderrunde je
* Sekunde. `useSyncExternalStore` ist genau für diesen Fall gebaut.
*
* Die Interaktionen werden **gedrosselt** vermerkt: Jede Mausbewegung in den
* Sitzungsspeicher zu schreiben wären hunderte Schreibvorgänge je Minute, und
* `sessionStorage` schreibt synchron. Einmal alle zehn Sekunden genügt — die
* Grenze liegt bei dreißig Minuten.
*/
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
import {
readStoredSession,
remainingSessionMs,
touchStoredSession,
} from './sessionStore';
/** Abstand, in dem eine Interaktion frühestens erneut vermerkt wird. */
const ACTIVITY_THROTTLE_MS = 10_000;
/** Ereignisse, die als Interaktion gelten. */
const ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'wheel', 'touchstart'] as const;
export interface SessionClock {
/** Verbleibende Sekunden bis zum Ende der Sitzung. */
readonly remainingSeconds: number;
/** Fällt die Sitzung in Kürze aus? */
readonly isExpiringSoon: boolean;
}
/** Angemeldete Beobachter der Sitzungsuhr. */
const clockSubscribers = new Set<() => void>();
/** Zeitgeber, der einmal je Sekunde alle Beobachter weckt. */
let clockIntervalHandle: number | null = null;
/** Meldet einen Beobachter an und startet den Zeitgeber beim ersten. */
function subscribeToClock(notify: () => void): () => void {
clockSubscribers.add(notify);
if (clockIntervalHandle === null) {
clockIntervalHandle = window.setInterval(() => {
for (const subscriber of clockSubscribers) {
subscriber();
}
}, 1000);
}
return () => {
clockSubscribers.delete(notify);
// Ohne Beobachter läuft kein Zeitgeber weiter — sonst tickte er nach dem
// Abmelden bis zum Schließen des Tabs.
if (clockSubscribers.size === 0 && clockIntervalHandle !== null) {
window.clearInterval(clockIntervalHandle);
clockIntervalHandle = null;
}
};
}
/**
* Liefert die verbleibenden Sekunden.
*
* Der Wert ist ganzzahlig, damit `useSyncExternalStore` ihn zwischen zwei
* Sekunden als unverändert erkennt und kein Rendern auslöst.
*/
function readRemainingSeconds(): number {
const storedSession = readStoredSession();
if (storedSession === null) {
return 0;
}
return Math.ceil(remainingSessionMs(storedSession) / 1000);
}
/** Ohne Anmeldung läuft keine Uhr. */
function readZero(): number {
return 0;
}
/**
* Beobachtet die Sitzung und ruft `onExpired` genau einmal auf.
*
* @param isActive Nur bei bestehender Anmeldung läuft die Uhr.
* @param onExpired Wird beim Ablauf aufgerufen.
*/
export function useSession(isActive: boolean, onExpired: () => void): SessionClock {
const remainingSeconds = useSyncExternalStore(
isActive ? subscribeToClock : noopSubscribe,
isActive ? readRemainingSeconds : readZero,
readZero,
);
const onExpiredReference = useRef(onExpired);
const lastActivityWriteReference = useRef(0);
const hasExpiredReference = useRef(false);
useEffect(() => {
onExpiredReference.current = onExpired;
});
/** Vermerkt eine Interaktion, höchstens alle zehn Sekunden. */
const noteActivity = useCallback(() => {
const activityTime = Date.now();
if (activityTime - lastActivityWriteReference.current < ACTIVITY_THROTTLE_MS) {
return;
}
lastActivityWriteReference.current = activityTime;
touchStoredSession();
}, []);
useEffect(() => {
if (!isActive) {
hasExpiredReference.current = false;
return;
}
for (const eventName of ACTIVITY_EVENTS) {
// `passive` verhindert, dass die Erkennung das Scrollen ausbremst.
window.addEventListener(eventName, noteActivity, { passive: true });
}
return () => {
for (const eventName of ACTIVITY_EVENTS) {
window.removeEventListener(eventName, noteActivity);
}
};
}, [isActive, noteActivity]);
// Das Ende wird gemeldet, sobald der abgeleitete Wert null erreicht — als
// Folge einer Zustandsänderung, nicht als Nebeneffekt des Renderns.
useEffect(() => {
if (!isActive || remainingSeconds > 0 || hasExpiredReference.current) {
return;
}
hasExpiredReference.current = true;
onExpiredReference.current();
}, [isActive, remainingSeconds]);
return {
remainingSeconds,
// Fünf Minuten vorher wird die Anzeige auffällig. Früher zu warnen
// erzeugt Gewöhnung, später lässt keine Zeit zum Speichern.
isExpiringSoon: remainingSeconds > 0 && remainingSeconds <= 300,
};
}
/** Ohne Anmeldung gibt es nichts zu beobachten. */
function noopSubscribe(): () => void {
return () => undefined;
}
/** Formatiert eine Sekundenzahl als `mm:ss`. */
export function formatSessionClock(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}

View File

@ -1,18 +1,18 @@
/** /**
* Einzelheiten eines Wiederherstellungspunkts. * Einzelheiten eines Wiederherstellungspunkts.
* *
* Diese Seite beantwortet die einzige Frage, die bei einem Backup zaehlt: * Diese Seite beantwortet die einzige Frage, die bei einem Backup zählt:
* **Kann ich mich darauf verlassen?** Sie tut das in der Reihenfolge, in der * **Kann ich mich darauf verlassen?** Sie tut das in der Reihenfolge, in der
* die Antwort belastbar wird: * die Antwort belastbar wird:
* *
* 1. Die Bewertung nennt die Einstufung — und wie gut sie belegt ist. * 1. Die Bewertung nennt die Einstufung — und wie gut sie belegt ist.
* 2. Die fehlenden Messungen sind die Handlungsanweisung. * 2. Die fehlenden Messungen sind die Handlungsanweisung.
* 3. Die Pruefung erzeugt den Beleg; nur der Wiederherstellungstest ist einer. * 3. Die Prüfung erzeugt den Beleg; nur der Wiederherstellungstest ist einer.
* 4. Der Schutz sagt, ob jemand das alles loeschen koennte. * 4. Der Schutz sagt, ob jemand das alles löschen könnte.
* *
* Eine unbelastbare Prozentzahl wird als solche gekennzeichnet. "70 %" liest * Eine unbelastbare Prozentzahl wird als solche gekennzeichnet. "70 %" liest
* sich wie "weitgehend in Ordnung" — bei einem beschaedigten Backup waere das * sich wie "weitgehend in Ordnung" — bei einem beschädigten Backup wäre das
* grob irrefuehrend, denn es ist nicht zu 70 % wiederherstellbar, sondern gar * grob irreführend, denn es ist nicht zu 70 % wiederherstellbar, sondern gar
* nicht. * nicht.
*/ */
@ -116,19 +116,19 @@ export function BackupDetailPage({
const verifyMutation = useMutation(startVerification, { const verifyMutation = useMutation(startVerification, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess( toast.showSuccess(
'Pruefung gestartet', 'Prüfung gestartet',
'Das Ergebnis erscheint unter „Pruefung“, sobald sie durchgelaufen ist.', 'Das Ergebnis erscheint unter „Prüfung“, sobald sie durchgelaufen ist.',
); );
setIsVerifyDialogOpen(false); setIsVerifyDialogOpen(false);
reloadAll(); reloadAll();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Die Pruefung liess sich nicht starten', describeApiError(apiError), apiError.requestId), toast.showError('Die Prüfung ließ sich nicht starten', describeApiError(apiError), apiError.requestId),
}); });
const legalHoldMutation = useMutation(setLegalHold, { const legalHoldMutation = useMutation(setLegalHold, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Legal Hold gesetzt', 'Dieser Punkt laesst sich jetzt nicht loeschen.'); toast.showSuccess('Legal Hold gesetzt', 'Dieser Punkt lässt sich jetzt nicht löschen.');
setIsLegalHoldDialogOpen(false); setIsLegalHoldDialogOpen(false);
setHoldReason(''); setHoldReason('');
reloadAll(); reloadAll();
@ -150,7 +150,7 @@ export function BackupDetailPage({
const extendMutation = useMutation(extendRetention, { const extendMutation = useMutation(extendRetention, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Aufbewahrungsfrist verlaengert'); toast.showSuccess('Aufbewahrungsfrist verlängert');
setIsExtendDialogOpen(false); setIsExtendDialogOpen(false);
reloadAll(); reloadAll();
}, },
@ -161,13 +161,13 @@ export function BackupDetailPage({
const deleteMutation = useMutation(deleteBackup, { const deleteMutation = useMutation(deleteBackup, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess( toast.showSuccess(
'Wiederherstellungspunkt geloescht', 'Wiederherstellungspunkt gelöscht',
'Wird kein Speicher frei, liegt das an der Deduplizierung — die Bloecke werden von einem anderen Backup gebraucht.', 'Wird kein Speicher frei, liegt das an der Deduplizierung — die Blöcke werden von einem anderen Backup gebraucht.',
); );
onDeleted(); onDeleted();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId), toast.showError('Das Löschen schlug fehl', describeApiError(apiError), apiError.requestId),
}); });
if (assuranceResource.loadState === 'loading') { if (assuranceResource.loadState === 'loading') {
@ -215,7 +215,7 @@ export function BackupDetailPage({
{mayVerify ? ( {mayVerify ? (
<Button onClick={() => setIsVerifyDialogOpen(true)}> <Button onClick={() => setIsVerifyDialogOpen(true)}>
<BadgeCheck /> <BadgeCheck />
Pruefen Prüfen
</Button> </Button>
) : null} ) : null}
</> </>
@ -251,11 +251,11 @@ export function BackupDetailPage({
</div> </div>
{/* Eine Zahl, die auf zu vielen Vermutungen beruht, wird als {/* Eine Zahl, die auf zu vielen Vermutungen beruht, wird als
Vermutung gekennzeichnet — sonst wird sie zitiert, als waere Vermutung gekennzeichnet — sonst wird sie zitiert, als wäre
sie eine Messung. */} sie eine Messung. */}
{!assurance.is_trustworthy ? ( {!assurance.is_trustworthy ? (
<Callout tone="warning" title="Diese Zahl ist eine Vermutung" className="mt-4"> <Callout tone="warning" title="Diese Zahl ist eine Vermutung" className="mt-4">
{assurance.unknown_input_count} Eingangsgroessen wurden nicht {assurance.unknown_input_count} Eingangsgrößen wurden nicht
gemessen und gehen weder positiv noch negativ ein. Solange das so gemessen und gehen weder positiv noch negativ ein. Solange das so
ist, sagt der Prozentwert wenig. ist, sagt der Prozentwert wenig.
</Callout> </Callout>
@ -307,7 +307,7 @@ export function BackupDetailPage({
</div> </div>
<dl className="mt-5 grid gap-4 border-t border-line pt-4 sm:grid-cols-2"> <dl className="mt-5 grid gap-4 border-t border-line pt-4 sm:grid-cols-2">
<DetailItem label="Zuletzt geprueft"> <DetailItem label="Zuletzt geprüft">
{assurance.last_verified_at {assurance.last_verified_at
? formatDateTime(assurance.last_verified_at) ? formatDateTime(assurance.last_verified_at)
: 'Nie'} : 'Nie'}
@ -328,7 +328,7 @@ export function BackupDetailPage({
{/* --- Schutz --- */} {/* --- Schutz --- */}
<div className="space-y-4"> <div className="space-y-4">
<Card> <Card>
<CardHeader title="Loeschschutz" /> <CardHeader title="Löschschutz" />
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{protection ? ( {protection ? (
<> <>
@ -339,7 +339,7 @@ export function BackupDetailPage({
<ShieldOff className="size-4 text-fg-subtle" aria-hidden /> <ShieldOff className="size-4 text-fg-subtle" aria-hidden />
)} )}
<span className="text-sm text-fg"> <span className="text-sm text-fg">
{protection.is_protected ? 'Geschuetzt' : 'Nicht geschuetzt'} {protection.is_protected ? 'Geschützt' : 'Nicht geschützt'}
</span> </span>
</div> </div>
@ -353,7 +353,7 @@ export function BackupDetailPage({
{protection.legal_hold ? ( {protection.legal_hold ? (
<Callout tone="info" title="Legal Hold"> <Callout tone="info" title="Legal Hold">
{protection.legal_hold_reason || 'Ohne Begruendung gesetzt.'} {protection.legal_hold_reason || 'Ohne Begründung gesetzt.'}
</Callout> </Callout>
) : null} ) : null}
@ -383,7 +383,7 @@ export function BackupDetailPage({
</Button> </Button>
)} )}
<Button size="sm" onClick={() => setIsExtendDialogOpen(true)}> <Button size="sm" onClick={() => setIsExtendDialogOpen(true)}>
Frist verlaengern Frist verlängern
</Button> </Button>
</div> </div>
) : null} ) : null}
@ -397,7 +397,7 @@ export function BackupDetailPage({
{/* --- Ransomware --- */} {/* --- Ransomware --- */}
{ransomware ? ( {ransomware ? (
<Card> <Card>
<CardHeader title="Auffaelligkeiten" /> <CardHeader title="Auffälligkeiten" />
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<StatusBadge status={ransomware.severity} /> <StatusBadge status={ransomware.severity} />
{ransomware.summary ? ( {ransomware.summary ? (
@ -405,15 +405,15 @@ export function BackupDetailPage({
) : null} ) : null}
{ransomware.severity === 'unknown' ? ( {ransomware.severity === 'unknown' ? (
<p className="text-xs text-fg-muted"> <p className="text-xs text-fg-muted">
Unter fuenf Vergleichslaeufen gibt es keinen belastbaren Unter fünf Vergleichsläufen gibt es keinen belastbaren
Basiswert. Wer nicht messen kann, hat nichts gemessen — eine Basiswert. Wer nicht messen kann, hat nichts gemessen — eine
geratene Schwelle waere schlechter als keine. geratene Schwelle wäre schlechter als keine.
</p> </p>
) : null} ) : null}
{ransomware.triggered_signal_count !== undefined ? ( {ransomware.triggered_signal_count !== undefined ? (
<p className="text-xs text-fg-muted"> <p className="text-xs text-fg-muted">
{ransomware.triggered_signal_count} von 6 Signalen auffaellig. {ransomware.triggered_signal_count} von 6 Signalen auffällig.
Diese Einschaetzung <strong>meldet nur</strong> und handelt nie. Diese Einschätzung <strong>meldet nur</strong> und handelt nie.
</p> </p>
) : null} ) : null}
</CardContent> </CardContent>
@ -428,12 +428,12 @@ export function BackupDetailPage({
disabled={protection?.is_protected} disabled={protection?.is_protected}
title={ title={
protection?.is_protected protection?.is_protected
? 'Dieser Punkt steht unter Schutz und laesst sich nicht loeschen.' ? 'Dieser Punkt steht unter Schutz und lässt sich nicht löschen.'
: undefined : undefined
} }
> >
<Trash2 /> <Trash2 />
Loeschen Löschen
</Button> </Button>
) : null} ) : null}
</div> </div>
@ -453,14 +453,14 @@ export function BackupDetailPage({
<ConfirmDialog <ConfirmDialog
open={isVerifyDialogOpen} open={isVerifyDialogOpen}
onOpenChange={setIsVerifyDialogOpen} onOpenChange={setIsVerifyDialogOpen}
title="Pruefung starten" title="Prüfung starten"
confirmLabel="Pruefung starten" confirmLabel="Prüfung starten"
isLoading={verifyMutation.isRunning} isLoading={verifyMutation.isRunning}
onConfirm={() => void verifyMutation.run(backupIdentifier, verificationType)} onConfirm={() => void verifyMutation.run(backupIdentifier, verificationType)}
> >
<div className="space-y-3"> <div className="space-y-3">
<SelectInput <SelectInput
label="Pruefart" label="Prüfart"
value={verificationType} value={verificationType}
onChange={(changeEvent) => setVerificationType(changeEvent.target.value)} onChange={(changeEvent) => setVerificationType(changeEvent.target.value)}
> >
@ -482,7 +482,7 @@ export function BackupDetailPage({
{verificationType === 'restore_test' ? ( {verificationType === 'restore_test' ? (
<Callout tone="info"> <Callout tone="info">
Nur diese Pruefung ist ein <strong>Nachweis</strong>. Sie hebt die Nur diese Prüfung ist ein <strong>Nachweis</strong>. Sie hebt die
Einstufung auf „wiederherstellbar“ — alles andere sind Indizien. Einstufung auf „wiederherstellbar“ — alles andere sind Indizien.
</Callout> </Callout>
) : null} ) : null}
@ -493,17 +493,17 @@ export function BackupDetailPage({
open={isLegalHoldDialogOpen} open={isLegalHoldDialogOpen}
onOpenChange={setIsLegalHoldDialogOpen} onOpenChange={setIsLegalHoldDialogOpen}
title="Legal Hold setzen" title="Legal Hold setzen"
description="Der Punkt laesst sich danach nicht mehr loeschen — unbefristet." description="Der Punkt lässt sich danach nicht mehr löschen — unbefristet."
confirmLabel="Legal Hold setzen" confirmLabel="Legal Hold setzen"
isLoading={legalHoldMutation.isRunning} isLoading={legalHoldMutation.isRunning}
onConfirm={() => void legalHoldMutation.run(backupIdentifier, holdReason)} onConfirm={() => void legalHoldMutation.run(backupIdentifier, holdReason)}
> >
<TextArea <TextArea
label="Begruendung" label="Begründung"
required required
value={holdReason} value={holdReason}
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)} onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
hint="Pflichtangabe. Ohne sie traut sich spaeter niemand, den Schutz wieder aufzuheben." hint="Pflichtangabe. Ohne sie traut sich später niemand, den Schutz wieder aufzuheben."
placeholder="Rechtsstreit Az. 4 O 128/26, angeordnet am 18.08.2026" placeholder="Rechtsstreit Az. 4 O 128/26, angeordnet am 18.08.2026"
/> />
</ConfirmDialog> </ConfirmDialog>
@ -518,7 +518,7 @@ export function BackupDetailPage({
onConfirm={() => void releaseMutation.run(backupIdentifier, holdReason)} onConfirm={() => void releaseMutation.run(backupIdentifier, holdReason)}
> >
<TextArea <TextArea
label="Begruendung" label="Begründung"
required required
value={holdReason} value={holdReason}
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)} onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
@ -529,8 +529,8 @@ export function BackupDetailPage({
<ConfirmDialog <ConfirmDialog
open={isExtendDialogOpen} open={isExtendDialogOpen}
onOpenChange={setIsExtendDialogOpen} onOpenChange={setIsExtendDialogOpen}
title="Aufbewahrungsfrist verlaengern" title="Aufbewahrungsfrist verlängern"
confirmLabel="Verlaengern" confirmLabel="Verlängern"
isLoading={extendMutation.isRunning} isLoading={extendMutation.isRunning}
onConfirm={() => onConfirm={() =>
void extendMutation.run( void extendMutation.run(
@ -549,13 +549,13 @@ export function BackupDetailPage({
onChange={(changeEvent) => setNewRetentionDate(changeEvent.target.value)} onChange={(changeEvent) => setNewRetentionDate(changeEvent.target.value)}
/> />
<TextArea <TextArea
label="Begruendung" label="Begründung"
value={holdReason} value={holdReason}
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)} onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
/> />
<Callout tone="info"> <Callout tone="info">
Verlaengern ja, <strong>verkuerzen nie</strong> — auch nicht fuer Verlängern ja, <strong>verkürzen nie</strong> — auch nicht für
Administratoren. Ein frueheres Datum wird abgelehnt. Administratoren. Ein früheres Datum wird abgelehnt.
</Callout> </Callout>
</div> </div>
</ConfirmDialog> </ConfirmDialog>
@ -563,14 +563,14 @@ export function BackupDetailPage({
<ConfirmDialog <ConfirmDialog
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}
title="Wiederherstellungspunkt loeschen" title="Wiederherstellungspunkt löschen"
confirmLabel="Endgueltig loeschen" confirmLabel="Endgültig löschen"
isDestructive isDestructive
isLoading={deleteMutation.isRunning} isLoading={deleteMutation.isRunning}
confirmationWord={backupIdentifier} confirmationWord={backupIdentifier}
confirmationHint={ confirmationHint={
<> <>
Tippen Sie zur Bestaetigung die Kennung:{' '} Tippen Sie zur Bestätigung die Kennung:{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs"> <code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{backupIdentifier} {backupIdentifier}
</code> </code>
@ -582,7 +582,7 @@ export function BackupDetailPage({
> >
<Callout tone="warning"> <Callout tone="warning">
Wird danach kein Speicher frei, ist das <strong>kein Fehler</strong>, Wird danach kein Speicher frei, ist das <strong>kein Fehler</strong>,
sondern Deduplizierung: Die Bloecke werden von einem anderen Backup sondern Deduplizierung: Die Blöcke werden von einem anderen Backup
gebraucht. gebraucht.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>

View File

@ -1,15 +1,15 @@
/** /**
* Pruefungen — Uebersicht. * Prüfungen — Übersicht.
* *
* Die wichtigste Unterscheidung dieser Seite steht in zwei Spalten * Die wichtigste Unterscheidung dieser Seite steht in zwei Spalten
* nebeneinander und wird gern verwechselt: * nebeneinander und wird gern verwechselt:
* *
* - **Zustand** (`status`): Ist die Pruefung durchgelaufen? * - **Zustand** (`status`): Ist die Prüfung durchgelaufen?
* - **Ergebnis** (`result`): Was hat sie gefunden? * - **Ergebnis** (`result`): Was hat sie gefunden?
* *
* Eine gescheiterte Pruefung ist **kein Befund am Backup**. Repository nicht * Eine gescheiterte Prüfung ist **kein Befund am Backup**. Repository nicht
* erreichbar heisst: Auftrag `failed`, Einstufung unberuehrt. Wer beides * erreichbar heißt: Auftrag `failed`, Einstufung unberührt. Wer beides
* vermengt, baut ein Pruefwerkzeug, das grundlos Alarm schlaegt — und das wird * vermengt, baut ein Prüfwerkzeug, das grundlos Alarm schlägt — und das wird
* bald nicht mehr ernst genommen. * bald nicht mehr ernst genommen.
*/ */
@ -54,7 +54,7 @@ export function VerificationPage({
const cancelMutation = useMutation(cancelVerification, { const cancelMutation = useMutation(cancelVerification, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Pruefung abgebrochen'); toast.showSuccess('Prüfung abgebrochen');
verificationsResource.reload(); verificationsResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
@ -66,7 +66,7 @@ export function VerificationPage({
const tableColumns: ReadonlyArray<TableColumn<VerificationJob>> = [ const tableColumns: ReadonlyArray<TableColumn<VerificationJob>> = [
{ {
key: 'type', key: 'type',
header: 'Pruefart', header: 'Prüfart',
render: (job) => ( render: (job) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-fg"> <p className="font-medium text-fg">
@ -91,21 +91,21 @@ export function VerificationPage({
job.result ? ( job.result ? (
<StatusBadge status={job.result} /> <StatusBadge status={job.result} />
) : ( ) : (
// Kein Ergebnis heisst: Die Pruefung lief nicht durch. Das ist etwas // Kein Ergebnis heißt: Die Prüfung lief nicht durch. Das ist etwas
// anderes als "ohne Befund" und darf nicht danach aussehen. // anderes als "ohne Befund" und darf nicht danach aussehen.
<span className="text-xs text-fg-muted">—</span> <span className="text-xs text-fg-muted">—</span>
), ),
}, },
{ {
key: 'chunks', key: 'chunks',
header: 'Bloecke', header: 'Blöcke',
numeric: true, numeric: true,
render: (job) => ( render: (job) => (
<span> <span>
{job.chunks_checked.toLocaleString('de-DE')} {job.chunks_checked.toLocaleString('de-DE')}
{job.chunks_missing > 0 || job.chunks_corrupted > 0 ? ( {job.chunks_missing > 0 || job.chunks_corrupted > 0 ? (
<span className="ml-1 text-critical"> <span className="ml-1 text-critical">
({job.chunks_missing} fehlend, {job.chunks_corrupted} beschaedigt) ({job.chunks_missing} fehlend, {job.chunks_corrupted} beschädigt)
</span> </span>
) : null} ) : null}
</span> </span>
@ -156,7 +156,7 @@ export function VerificationPage({
if (verificationsResource.loadState === 'failed' && verificationsResource.loadError) { if (verificationsResource.loadState === 'failed' && verificationsResource.loadError) {
return ( return (
<> <>
<PageHeader title="Pruefung" /> <PageHeader title="Prüfung" />
<Card> <Card>
<ErrorState <ErrorState
message={describeApiError(verificationsResource.loadError)} message={describeApiError(verificationsResource.loadError)}
@ -171,8 +171,8 @@ export function VerificationPage({
return ( return (
<> <>
<PageHeader <PageHeader
title="Pruefung" title="Prüfung"
description="Nachweise ueber die Wiederherstellbarkeit." description="Nachweise über die Wiederherstellbarkeit."
actions={ actions={
<Button variant="secondary" onClick={verificationsResource.reload}> <Button variant="secondary" onClick={verificationsResource.reload}>
<RefreshCw /> <RefreshCw />
@ -182,9 +182,7 @@ export function VerificationPage({
/> />
<Callout tone="info" className="mb-4" title="Zustand ist nicht Ergebnis"> <Callout tone="info" className="mb-4" title="Zustand ist nicht Ergebnis">
Eine <strong>gescheiterte Pruefung</strong> ist kein Befund am Backup. Eine gescheiterte Prüfung ist kein Befund am Backup.
Ist das Repository nicht erreichbar, gilt der Auftrag als fehlgeschlagen
— die Einstufung des Wiederherstellungspunkts bleibt davon unberuehrt.
</Callout> </Callout>
<Card> <Card>
@ -194,8 +192,8 @@ export function VerificationPage({
getRowKey={(job) => job.id} getRowKey={(job) => job.id}
onRowClick={(job) => onOpenBackup(job.backup_id)} onRowClick={(job) => onOpenBackup(job.backup_id)}
isLoading={verificationsResource.loadState === 'loading'} isLoading={verificationsResource.loadState === 'loading'}
emptyTitle="Noch keine Pruefung" emptyTitle="Noch keine Prüfung"
emptyDescription="Eine Pruefung wird bei einem Wiederherstellungspunkt gestartet. Ohne sie bleibt jede Einstufung ein Indiz." emptyDescription="Prüfungen starten Sie bei einem Wiederherstellungspunkt."
/> />
</Card> </Card>
</> </>

View File

@ -1,12 +1,12 @@
/** /**
* API-Anbindung der Wiederherstellungspunkte: Bewertung, Schutz, Pruefung. * API-Anbindung der Wiederherstellungspunkte: Bewertung, Schutz, Prüfung.
* *
* Diese drei Dinge gehoeren zusammen, weil sie dieselbe Frage aus drei * Diese drei Dinge gehören zusammen, weil sie dieselbe Frage aus drei
* Richtungen beantworten: **Kann ich mich auf dieses Backup verlassen?** * Richtungen beantworten: **Kann ich mich auf dieses Backup verlassen?**
* *
* - Die Bewertung sagt, wie gut die Auskunft belegt ist. * - Die Bewertung sagt, wie gut die Auskunft belegt ist.
* - Der Schutz sagt, ob es jemand loeschen koennte. * - Der Schutz sagt, ob es jemand löschen könnte.
* - Die Pruefung erzeugt den Beleg. * - Die Prüfung erzeugt den Beleg.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
@ -15,17 +15,17 @@ import { requestApi } from '../../api/client';
// Bewertung (Phase 10) // Bewertung (Phase 10)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Eine Eingangsgroesse der Bewertung. */ /** Eine Eingangsgröße der Bewertung. */
export interface AssuranceInput { export interface AssuranceInput {
name: string; name: string;
/** Hoechstpunktzahl dieser Groesse. */ /** Höchstpunktzahl dieser Größe. */
weight: number; weight: number;
earned_points: number; earned_points: number;
/** /**
* Meldet, ob die Groesse gemessen wurde. * Meldet, ob die Größe gemessen wurde.
* *
* Unbekannt zaehlt **nie** als gut. Eine nicht gemessene Groesse wohlwollend * Unbekannt zählt **nie** als gut. Eine nicht gemessene Größe wohlwollend
* zu schaetzen waere die bequeme und falsche Entscheidung. * zu schätzen wäre die bequeme und falsche Entscheidung.
*/ */
is_known: boolean; is_known: boolean;
} }
@ -41,19 +41,19 @@ export interface AssuranceReport {
/** /**
* Meldet, ob die Zahl belastbar ist. * Meldet, ob die Zahl belastbar ist.
* *
* Bei zu vielen ungemessenen Groessen ist die Prozentzahl eine Vermutung, * Bei zu vielen ungemessenen Größen ist die Prozentzahl eine Vermutung,
* keine Aussage — und die Oberflaeche muss das sagen. * keine Aussage — und die Oberfläche muss das sagen.
*/ */
is_trustworthy: boolean; is_trustworthy: boolean;
summary: string; summary: string;
/** Was gemessen werden muesste. Das ist die Handlungsanweisung. */ /** Was gemessen werden müsste. Das ist die Handlungsanweisung. */
missing_measurements: string[]; missing_measurements: string[];
inputs: AssuranceInput[]; inputs: AssuranceInput[];
last_verified_at?: string; last_verified_at?: string;
last_restore_test_at?: string; last_restore_test_at?: string;
} }
/** Laedt die Bewertung eines Wiederherstellungspunkts. */ /** Lädt die Bewertung eines Wiederherstellungspunkts. */
export async function getAssurance( export async function getAssurance(
backupIdentifier: string, backupIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -88,7 +88,7 @@ export interface ProtectionStatus {
enforcement_explanation?: string; enforcement_explanation?: string;
} }
/** Laedt den Schutzstatus. */ /** Lädt den Schutzstatus. */
export async function getProtection( export async function getProtection(
backupIdentifier: string, backupIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -102,7 +102,7 @@ export async function getProtection(
/** /**
* Setzt einen Legal Hold. * Setzt einen Legal Hold.
* *
* Die Begruendung ist Pflicht. Ohne sie traut sich spaeter niemand, ihn wieder * Die Begründung ist Pflicht. Ohne sie traut sich später niemand, ihn wieder
* aufzuheben — und ein Schutz, den niemand mehr aufhebt, ist ein Datenfriedhof. * aufzuheben — und ein Schutz, den niemand mehr aufhebt, ist ein Datenfriedhof.
*/ */
export async function setLegalHold( export async function setLegalHold(
@ -127,10 +127,10 @@ export async function releaseLegalHold(
} }
/** /**
* Verlaengert die Aufbewahrungsfrist. * Verlängert die Aufbewahrungsfrist.
* *
* Verlaengern ja, verkuerzen nie — auch nicht fuer Administratoren. Der Server * Verlängern ja, verkürzen nie — auch nicht für Administratoren. Der Server
* lehnt ein frueheres Datum ab; die Oberflaeche sagt das vorher. * lehnt ein früheres Datum ab; die Oberfläche sagt das vorher.
*/ */
export async function extendRetention( export async function extendRetention(
backupIdentifier: string, backupIdentifier: string,
@ -144,10 +144,10 @@ export async function extendRetention(
} }
/** /**
* Loescht einen Wiederherstellungspunkt. * Löscht einen Wiederherstellungspunkt.
* *
* `confirm_backup_id` wiederholt die Kennung woertlich — dieselbe Huerde wie * `confirm_backup_id` wiederholt die Kennung wörtlich — dieselbe Hürde wie
* beim Ueberschreiben einer Wiederherstellung. * beim Überschreiben einer Wiederherstellung.
*/ */
export async function deleteBackup( export async function deleteBackup(
backupIdentifier: string, backupIdentifier: string,
@ -162,20 +162,20 @@ export async function deleteBackup(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Ransomware-Einschaetzung (Phase 16) // Ransomware-Einschätzung (Phase 16)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Einschaetzung auf Auffaelligkeiten. */ /** Einschätzung auf Auffälligkeiten. */
export interface RansomwareAssessment { export interface RansomwareAssessment {
backup_id: string; backup_id: string;
/** none, elevated, high oder unknown. */ /** none, elevated, high oder unknown. */
severity: string; severity: string;
/** /**
* Zahl der auffaelligen Signale von sechs. * Zahl der auffälligen Signale von sechs.
* *
* Zwei auffaellige ergeben `high`, nicht eines: Ein einzelnes hat viele * Zwei auffällige ergeben `high`, nicht eines: Ein einzelnes hat viele
* harmlose Ursachen; zwei zugleich sind das Muster massenhafter * harmlose Ursachen; zwei zugleich sind das Muster massenhafter
* Verschluesselung. * Verschlüsselung.
*/ */
triggered_signal_count?: number; triggered_signal_count?: number;
signals?: Array<{ signals?: Array<{
@ -187,7 +187,7 @@ export interface RansomwareAssessment {
baseline_sample_count?: number; baseline_sample_count?: number;
} }
/** Laedt die Ransomware-Einschaetzung. */ /** Lädt die Ransomware-Einschätzung. */
export async function getRansomwareAssessment( export async function getRansomwareAssessment(
backupIdentifier: string, backupIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -199,41 +199,41 @@ export async function getRansomwareAssessment(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Pruefung (Phase 10) // Prüfung (Phase 10)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Die fuenf Pruefarten. */ /** Die fünf Prüfarten. */
export const VERIFICATION_TYPES = [ export const VERIFICATION_TYPES = [
{ {
value: 'manifest', value: 'manifest',
label: 'Manifest', label: 'Manifest',
description: 'Prueft, ob das Manifest lesbar und in sich stimmig ist. Schnell, aber nur ein Indiz.', description: 'Prüft, ob das Manifest lesbar und in sich stimmig ist. Schnell, aber nur ein Indiz.',
}, },
{ {
value: 'chunk_integrity', value: 'chunk_integrity',
label: 'Blockpruefung', label: 'Blockprüfung',
description: description:
'Prueft jeden Block gegen seine gespeicherte Pruefsumme. Laeuft ohne Datenschluessel — die Kennung beschreibt den Klartext, geprueft wird die abgelegte Form.', 'Prüft jeden Block gegen seine gespeicherte Prüfsumme. Läuft ohne Datenschlüssel — die Kennung beschreibt den Klartext, geprüft wird die abgelegte Form.',
}, },
{ {
value: 'chain', value: 'chain',
label: 'Kette', label: 'Kette',
description: 'Prueft die Zusatzsicherungen gegen ihre Elternbackups.', description: 'Prüft die Zusatzsicherungen gegen ihre Elternbackups.',
}, },
{ {
value: 'metadata', value: 'metadata',
label: 'Metadaten', label: 'Metadaten',
description: 'Prueft die Angaben zu Groesse, Zeitpunkt und Umfang.', description: 'Prüft die Angaben zu Größe, Zeitpunkt und Umfang.',
}, },
{ {
value: 'restore_test', value: 'restore_test',
label: 'Wiederherstellungstest', label: 'Wiederherstellungstest',
description: description:
'Liest das gesamte Backup und schreibt es versuchsweise zurueck. Nur diese Pruefung ist ein Nachweis — alles andere sind Indizien. Sie verlangt ein eigenes Recht.', 'Liest das gesamte Backup und schreibt es versuchsweise zurück. Nur diese Prüfung ist ein Nachweis — alles andere sind Indizien. Sie verlangt ein eigenes Recht.',
}, },
] as const; ] as const;
/** Ein Pruefauftrag. */ /** Ein Prüfauftrag. */
export interface VerificationJob { export interface VerificationJob {
id: string; id: string;
backup_id: string; backup_id: string;
@ -254,7 +254,7 @@ export interface VerificationJob {
created_at: string; created_at: string;
} }
/** Laedt die Pruefauftraege. */ /** Lädt die Prüfaufträge. */
export async function listVerifications( export async function listVerifications(
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<VerificationJob[]> { ): Promise<VerificationJob[]> {
@ -264,7 +264,7 @@ export async function listVerifications(
); );
} }
/** Startet eine Pruefung. */ /** Startet eine Prüfung. */
export async function startVerification( export async function startVerification(
backupIdentifier: string, backupIdentifier: string,
verificationType: string, verificationType: string,
@ -276,7 +276,7 @@ export async function startVerification(
}); });
} }
/** Bricht eine laufende Pruefung ab. */ /** Bricht eine laufende Prüfung ab. */
export async function cancelVerification( export async function cancelVerification(
verificationIdentifier: string, verificationIdentifier: string,
): Promise<VerificationJob> { ): Promise<VerificationJob> {

View File

@ -1,5 +1,5 @@
/** /**
* Tests der Uebersicht. * Tests der Übersicht.
* *
* Der wichtigste Test ist der letzte: Eine Kennzahl ohne Datengrundlage darf * Der wichtigste Test ist der letzte: Eine Kennzahl ohne Datengrundlage darf
* niemals als Zahl erscheinen. Genau dort entstehen die erfundenen Statistiken, * niemals als Zahl erscheinen. Genau dort entstehen die erfundenen Statistiken,
@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DashboardPage } from './DashboardPage'; import { DashboardPage } from './DashboardPage';
import type { Dashboard } from './dashboardApi'; import type { Dashboard } from './dashboardApi';
/** Baut eine Antwort mit den uebergebenen Kennzahlen. */ /** Baut eine Antwort mit den übergebenen Kennzahlen. */
function buildDashboardResponse(dashboard: Dashboard): Response { function buildDashboardResponse(dashboard: Dashboard): Response {
return { return {
status: 200, status: 200,
@ -21,7 +21,7 @@ function buildDashboardResponse(dashboard: Dashboard): Response {
} as unknown as Response; } as unknown as Response;
} }
describe('Uebersicht', () => { describe('Übersicht', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('fetch', vi.fn()); vi.stubGlobal('fetch', vi.fn());
}); });
@ -30,7 +30,7 @@ describe('Uebersicht', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('zeigt eine Kennzahl samt Erlaeuterung', async () => { it('zeigt eine Kennzahl samt Erläuterung', async () => {
vi.mocked(fetch).mockResolvedValue( vi.mocked(fetch).mockResolvedValue(
buildDashboardResponse({ buildDashboardResponse({
available_count: 1, available_count: 1,
@ -42,7 +42,7 @@ describe('Uebersicht', () => {
available: true, available: true,
value: 87.5, value: 87.5,
unit: '%', unit: '%',
detail: '7 von 8 Laeufen vollstaendig erfolgreich.', detail: '7 von 8 Läufen vollständig erfolgreich.',
severity: 'high', severity: 'high',
}, },
], ],
@ -53,7 +53,7 @@ describe('Uebersicht', () => {
expect(await screen.findByText('Erfolgsquote (7 Tage)')).toBeInTheDocument(); expect(await screen.findByText('Erfolgsquote (7 Tage)')).toBeInTheDocument();
expect(screen.getByText('87,5')).toBeInTheDocument(); expect(screen.getByText('87,5')).toBeInTheDocument();
expect(screen.getByText('7 von 8 Laeufen vollstaendig erfolgreich.')).toBeInTheDocument(); expect(screen.getByText('7 von 8 Läufen vollständig erfolgreich.')).toBeInTheDocument();
}); });
it('nennt bei fehlender Datengrundlage den Grund statt einer Zahl', async () => { it('nennt bei fehlender Datengrundlage den Grund statt einer Zahl', async () => {
@ -78,10 +78,10 @@ describe('Uebersicht', () => {
expect(await screen.findByText('Kritische Meldungen')).toBeInTheDocument(); expect(await screen.findByText('Kritische Meldungen')).toBeInTheDocument();
expect(screen.getByText('Es gibt noch kein Meldungswesen (Phase 14).')).toBeInTheDocument(); expect(screen.getByText('Es gibt noch kein Meldungswesen (Phase 14).')).toBeInTheDocument();
// Der entscheidende Teil: keine Null. Eine Null hiesse „keine Probleme" und // Der entscheidende Teil: keine Null. Eine Null hieße „keine Probleme" und
// wuerde bedeuten „es wird nicht geprueft". // würde bedeuten „es wird nicht geprüft".
expect(screen.queryByText('0')).not.toBeInTheDocument(); expect(screen.queryByText('0')).not.toBeInTheDocument();
expect(screen.getByText('noch nicht verfuegbar')).toBeInTheDocument(); expect(screen.getByText('noch nicht verfügbar')).toBeInTheDocument();
}); });
it('meldet eine nicht bezifferbare Kennzahl als solche', async () => { it('meldet eine nicht bezifferbare Kennzahl als solche', async () => {
@ -94,7 +94,7 @@ describe('Uebersicht', () => {
key: 'storage', key: 'storage',
title: 'Speicherbelegung', title: 'Speicherbelegung',
available: true, available: true,
detail: 'Die Gesamtkapazitaet ist nicht hinterlegt.', detail: 'Die Gesamtkapazität ist nicht hinterlegt.',
severity: 'information', severity: 'information',
}, },
], ],
@ -103,7 +103,7 @@ describe('Uebersicht', () => {
render(<DashboardPage />); render(<DashboardPage />);
// Verfuegbar, aber ohne Wert: Auch hier darf keine Null stehen. // Verfügbar, aber ohne Wert: Auch hier darf keine Null stehen.
expect(await screen.findByText('nicht bezifferbar')).toBeInTheDocument(); expect(await screen.findByText('nicht bezifferbar')).toBeInTheDocument();
expect(screen.queryByText('0')).not.toBeInTheDocument(); expect(screen.queryByText('0')).not.toBeInTheDocument();
}); });

View File

@ -1,13 +1,13 @@
/** /**
* Uebersicht der Anlage. * Übersicht der Anlage.
* *
* Der Plan (§14) nennt zehn Kennzahlen. Jede erscheint — auch die ohne * Der Plan (§14) nennt zehn Kennzahlen. Jede erscheint — auch die ohne
* Datengrundlage, dann mit der Angabe, was fehlt. Ein weggelassenes Feld sieht * Datengrundlage, dann mit der Angabe, was fehlt. Ein weggelassenes Feld sieht
* aus wie ein vergessenes; ein mit einer Null gefuelltes waere eine erfundene * aus wie ein vergessenes; ein mit einer Null gefülltes wäre eine erfundene
* Statistik (PROMPT.md §139). * Statistik (PROMPT.md §139).
* *
* Der Schweregrad faerbt nur die Randlinie, nicht die Kachel. Zehn farbige * Der Schweregrad färbt nur die Randlinie, nicht die Kachel. Zehn farbige
* Flaechen nebeneinander ergeben ein Mosaik, in dem die eine kritische Zahl * Flächen nebeneinander ergeben ein Mosaik, in dem die eine kritische Zahl
* untergeht — und genau die soll auffallen. * untergeht — und genau die soll auffallen.
*/ */
@ -17,10 +17,18 @@ import { describeApiError } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader'; import { PageHeader } from '@/components/layout/PageHeader';
import { Card, ErrorState, LoadingState } from '@/components/ui'; import { Card, ErrorState, LoadingState } from '@/components/ui';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { QuickActions } from './QuickActions';
import { fetchDashboard } from './dashboardApi'; import { fetchDashboard } from './dashboardApi';
import type { Dashboard, DashboardWidget } from './dashboardApi'; import type { Dashboard, DashboardWidget } from './dashboardApi';
export function DashboardPage(): React.JSX.Element { export function DashboardPage({
grantedPermissions = [],
onNavigate,
}: {
readonly grantedPermissions?: readonly string[] | undefined;
/** Fehlt der Rückruf, entfallen die Schnellzugriffe. */
readonly onNavigate?: ((pageIdentifier: string) => void) | undefined;
} = {}): React.JSX.Element {
const loadDashboard = useCallback( const loadDashboard = useCallback(
(abortSignal: AbortSignal) => fetchDashboard(abortSignal), (abortSignal: AbortSignal) => fetchDashboard(abortSignal),
[], [],
@ -30,7 +38,7 @@ export function DashboardPage(): React.JSX.Element {
return ( return (
<> <>
<PageHeader <PageHeader
title="Uebersicht" title="Übersicht"
description={ description={
data !== null data !== null
? `${data.available_count} von ${data.widgets.length} Kennzahlen haben eine Datengrundlage.` ? `${data.available_count} von ${data.widgets.length} Kennzahlen haben eine Datengrundlage.`
@ -38,6 +46,10 @@ export function DashboardPage(): React.JSX.Element {
} }
/> />
{onNavigate ? (
<QuickActions grantedPermissions={grantedPermissions} onNavigate={onNavigate} />
) : null}
{loadState === 'loading' ? ( {loadState === 'loading' ? (
<Card> <Card>
<LoadingState label="Die Kennzahlen werden geladen …" /> <LoadingState label="Die Kennzahlen werden geladen …" />
@ -65,7 +77,7 @@ export function DashboardPage(): React.JSX.Element {
); );
} }
/** Randfarbe je Schweregrad. Nur die Linie, nicht die Flaeche. */ /** Randfarbe je Schweregrad. Nur die Linie, nicht die Fläche. */
const severityBorderClasses: Readonly<Record<string, string>> = { const severityBorderClasses: Readonly<Record<string, string>> = {
healthy: 'border-l-healthy', healthy: 'border-l-healthy',
info: 'border-l-info', info: 'border-l-info',
@ -80,7 +92,7 @@ function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX
return ( return (
<Card className="border-l-4 border-l-neutral p-4"> <Card className="border-l-4 border-l-neutral p-4">
<p className="text-sm font-medium text-fg-muted">{widget.title}</p> <p className="text-sm font-medium text-fg-muted">{widget.title}</p>
<p className="mt-2 text-sm text-fg-subtle">noch nicht verfuegbar</p> <p className="mt-2 text-sm text-fg-subtle">noch nicht verfügbar</p>
<p className="mt-1 text-xs text-fg-muted">{widget.unavailable_reason}</p> <p className="mt-1 text-xs text-fg-muted">{widget.unavailable_reason}</p>
</Card> </Card>
); );
@ -97,8 +109,8 @@ function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX
<p className="mt-2 flex items-baseline gap-1.5"> <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 heißt nicht null. Eine Kennzahl, die sich nicht bilden
// laesst, sagt das — der Grund steht im Detailtext darunter. // lässt, sagt das — der Grund steht im Detailtext darunter.
<span className="text-sm text-fg-muted">nicht bezifferbar</span> <span className="text-sm text-fg-muted">nicht bezifferbar</span>
) : ( ) : (
<> <>
@ -133,15 +145,15 @@ function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX
/** Formatiert die Hauptzahl einer Kennzahl. */ /** Formatiert die Hauptzahl einer Kennzahl. */
function formatWidgetValue(widgetValue: number): string { function formatWidgetValue(widgetValue: number): string {
// Ein kleiner Wert wird nicht auf null gerundet: „0 %" belegter Speicher liest // Ein kleiner Wert wird nicht auf null gerundet: „0 %" belegter Speicher liest
// sich wie „nichts abgelegt", obwohl Daten da sind. Dieselbe Ueberlegung wie // sich wie „nichts abgelegt", obwohl Daten da sind. Dieselbe Überlegung wie
// bei der Recovery Assurance — eine Zahl darf nicht mehr behaupten, als sie // bei der Recovery Assurance — eine Zahl darf nicht mehr behaupten, als sie
// weiss, und auch nicht weniger. // weiß, und auch nicht weniger.
if (widgetValue > 0 && widgetValue < 0.1) { if (widgetValue > 0 && widgetValue < 0.1) {
return '< 0,1'; return '< 0,1';
} }
// Ganze Zahlen ohne Nachkommastellen, gebrochene mit einer: „99,7 %" sagt // Ganze Zahlen ohne Nachkommastellen, gebrochene mit einer: „99,7 %" sagt
// mehr als „100 %", wenn drei von tausend Laeufen scheiterten. // mehr als „100 %", wenn drei von tausend Läufen scheiterten.
if (Number.isInteger(widgetValue)) { if (Number.isInteger(widgetValue)) {
return widgetValue.toLocaleString('de-DE'); return widgetValue.toLocaleString('de-DE');
} }
@ -149,7 +161,7 @@ function formatWidgetValue(widgetValue: number): string {
return widgetValue.toLocaleString('de-DE', { maximumFractionDigits: 1 }); return widgetValue.toLocaleString('de-DE', { maximumFractionDigits: 1 });
} }
/** Macht aus einem Schluessel der Aufschluesselung eine Beschriftung. */ /** Macht aus einem Schlüssel der Aufschlüsselung eine Beschriftung. */
function formatBreakdownLabel(breakdownKey: string): string { function formatBreakdownLabel(breakdownKey: string): string {
const readableLabel = breakdownKey.replace(/_/g, ' '); const readableLabel = breakdownKey.replace(/_/g, ' ');

View File

@ -0,0 +1,140 @@
/**
* Schnellzugriffe auf der Übersicht.
*
* Bisher endete die Übersicht bei Kennzahlen — wer etwas anlegen wollte, musste
* erst wissen, unter welchem Menüpunkt es steckt. Die Kacheln nennen die
* Handlung, nicht den Bereich: „Auftrag anlegen" statt „Sicherungsaufträge".
*
* Gezeigt wird nur, was die Rolle auch darf. Eine Kachel, die in eine
* Fehlermeldung führt, ist schlimmer als keine.
*/
import {
Archive,
BadgeCheck,
Clock,
Database,
MonitorCog,
RotateCcw,
Server,
UserPlus,
type LucideIcon,
} from 'lucide-react';
import { Card } from '@/components/ui';
interface QuickAction {
readonly label: string;
readonly hint: string;
readonly icon: LucideIcon;
readonly targetPageId: string;
/** Berechtigung, die die Handlung verlangt. */
readonly permission: string;
}
const QUICK_ACTIONS: readonly QuickAction[] = [
{
label: 'Auftrag anlegen',
hint: 'Was wann wohin',
icon: Clock,
targetPageId: 'jobs',
permission: 'jobs.write',
},
{
label: 'Wiederherstellen',
hint: 'Aus einem Punkt zurück',
icon: RotateCcw,
targetPageId: 'recovery-points',
permission: 'restores.execute',
},
{
label: 'Repository übernehmen',
hint: 'Vorhandene Ablage',
icon: Database,
targetPageId: 'repositories',
permission: 'repositories.write',
},
{
label: 'Prüfung starten',
hint: 'Nachweis erzeugen',
icon: BadgeCheck,
targetPageId: 'recovery-points',
permission: 'verification.write',
},
{
label: 'Agent aufnehmen',
hint: 'Token erzeugen',
icon: MonitorCog,
targetPageId: 'agents',
permission: 'agents.enroll',
},
{
label: 'Proxmox verbinden',
hint: 'Verbund einrichten',
icon: Server,
targetPageId: 'proxmox',
permission: 'providers.write',
},
{
label: 'Aufbewahrung',
hint: 'Regel anlegen',
icon: Archive,
targetPageId: 'retention',
permission: 'retention.write',
},
{
label: 'Benutzer anlegen',
hint: 'Konto und Rollen',
icon: UserPlus,
targetPageId: 'users',
permission: 'users.write',
},
];
export function QuickActions({
grantedPermissions,
onNavigate,
}: {
readonly grantedPermissions: readonly string[];
readonly onNavigate: (pageIdentifier: string) => void;
}) {
const isAllowed = (permission: string): boolean =>
grantedPermissions.includes('*') || grantedPermissions.includes(permission);
const visibleActions = QUICK_ACTIONS.filter((action) => isAllowed(action.permission));
if (visibleActions.length === 0) {
return null;
}
return (
<div className="mb-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{visibleActions.map((action) => {
const IconComponent = action.icon;
return (
<Card
key={action.label}
asChild
className="transition-colors hover:border-accent hover:bg-hover"
>
<button
type="button"
onClick={() => onNavigate(action.targetPageId)}
className="flex w-full items-center gap-3 p-3 text-left"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent">
<IconComponent className="size-4" aria-hidden />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-fg">
{action.label}
</span>
<span className="block truncate text-xs text-fg-muted">{action.hint}</span>
</span>
</button>
</Card>
);
})}
</div>
);
}

View File

@ -5,8 +5,8 @@
* 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 * Der Filter „Ungeprüft" heißt serverseitig `successful`. Das ist kein
* Uebersetzungsfehler, sondern der Kern: Ein erfolgreicher Lauf ist noch kein * Übersetzungsfehler, sondern der Kern: Ein erfolgreicher Lauf ist noch kein
* Nachweis der Wiederherstellbarkeit. * Nachweis der Wiederherstellbarkeit.
*/ */
@ -28,26 +28,26 @@ 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';
/** Auswahlmoeglichkeiten des Einstufungsfilters. */ /** Auswahlmöglichkeiten des Einstufungsfilters. */
const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [ const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
{ value: '', label: 'Alle Einstufungen' }, { value: '', label: 'Alle Einstufungen' },
{ value: 'recoverable', label: 'Nachweislich wiederherstellbar' }, { value: 'recoverable', label: 'Nachweislich wiederherstellbar' },
{ value: 'verified', label: 'Geprueft' }, { value: 'verified', label: 'Geprüft' },
{ value: 'successful', label: 'Ungeprueft' }, { value: 'successful', label: 'Ungeprüft' },
{ value: 'corrupted', label: 'Beschaedigt' }, { value: 'corrupted', label: 'Beschädigt' },
]; ];
export function RecoveryPointsPage({ export function RecoveryPointsPage({
onOpenBackup, onOpenBackup,
}: { }: {
/** Oeffnet die Einzelheiten eines Punktes. */ /** Öffnet die Einzelheiten eines Punktes. */
readonly onOpenBackup?: ((backupIdentifier: string) => void) | undefined; readonly onOpenBackup?: ((backupIdentifier: string) => void) | undefined;
} = {}): React.JSX.Element { } = {}): React.JSX.Element {
const [classificationFilter, setClassificationFilter] = useState(''); const [classificationFilter, setClassificationFilter] = useState('');
const [includeDeleted, setIncludeDeleted] = useState(false); const [includeDeleted, setIncludeDeleted] = useState(false);
const [onlyProtected, setOnlyProtected] = useState(false); const [onlyProtected, setOnlyProtected] = useState(false);
// Der Schluessel bildet die Filter ab: Aendert er sich, wird neu geladen. // Der Schlüssel bildet die Filter ab: Ändert er sich, wird neu geladen.
const filterKey = `${classificationFilter}|${String(includeDeleted)}|${String(onlyProtected)}`; const filterKey = `${classificationFilter}|${String(includeDeleted)}|${String(onlyProtected)}`;
const loadRecoveryPoints = useCallback( const loadRecoveryPoints = useCallback(
@ -131,9 +131,9 @@ export function RecoveryPointsPage({
header: '', header: '',
render: (recoveryPoint) => render: (recoveryPoint) =>
recoveryPoint.deleted_at !== undefined ? ( recoveryPoint.deleted_at !== undefined ? (
// Ein geloeschter Punkt bleibt sichtbar, damit die Historie // Ein gelöschter Punkt bleibt sichtbar, damit die Historie
// nachvollziehbar bleibt — er wird gekennzeichnet, nicht versteckt. // nachvollziehbar bleibt — er wird gekennzeichnet, nicht versteckt.
<span className="text-xs text-fg-subtle">geloescht</span> <span className="text-xs text-fg-subtle">gelöscht</span>
) : null, ) : null,
}, },
]; ];
@ -144,7 +144,7 @@ export function RecoveryPointsPage({
title="Wiederherstellungspunkte" title="Wiederherstellungspunkte"
description={ description={
data !== null data !== null
? `${data.length} Punkte. Nur ein durchgefuehrter Wiederherstellungstest hebt auf „wiederherstellbar“.` ? `${data.length} Punkte. Nur ein durchgeführter Wiederherstellungstest hebt auf „wiederherstellbar“.`
: undefined : undefined
} }
/> />
@ -168,12 +168,12 @@ export function RecoveryPointsPage({
<CheckboxField <CheckboxField
checked={includeDeleted} checked={includeDeleted}
onCheckedChange={setIncludeDeleted} onCheckedChange={setIncludeDeleted}
label="Geloeschte einschliessen" label="Gelöschte einschließen"
/> />
<CheckboxField <CheckboxField
checked={onlyProtected} checked={onlyProtected}
onCheckedChange={setOnlyProtected} onCheckedChange={setOnlyProtected}
label="Nur geschuetzte" label="Nur geschützte"
/> />
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
/** /**
* Zugriff auf die Uebersicht und die Wiederherstellungspunkte. * Zugriff auf die Übersicht und die Wiederherstellungspunkte.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
@ -7,29 +7,29 @@ import { requestApi } from '../../api/client';
/** Statusfarbe einer Kennzahl. */ /** Statusfarbe einer Kennzahl. */
export type WidgetSeverity = 'healthy' | 'warning' | 'high' | 'critical' | 'information'; export type WidgetSeverity = 'healthy' | 'warning' | 'high' | 'critical' | 'information';
/** Eine Kennzahl der Uebersicht. */ /** Eine Kennzahl der Übersicht. */
export interface DashboardWidget { export interface DashboardWidget {
/** Maschinenlesbarer Bezeichner. */ /** Maschinenlesbarer Bezeichner. */
readonly key: string; readonly key: string;
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Meldet, ob es eine Datengrundlage gibt. */ /** Meldet, ob es eine Datengrundlage gibt. */
readonly available: boolean; readonly available: boolean;
/** Erklaert eine fehlende Datengrundlage. */ /** Erklärt eine fehlende Datengrundlage. */
readonly unavailable_reason?: string; readonly unavailable_reason?: string;
/** Hauptzahl; fehlt, wenn sie sich nicht bilden laesst. */ /** Hauptzahl; fehlt, wenn sie sich nicht bilden lässt. */
readonly value?: number; readonly value?: number;
/** Einheit der Hauptzahl. */ /** Einheit der Hauptzahl. */
readonly unit?: string; readonly unit?: string;
/** Erlaeuterung in einem Satz. */ /** Erläuterung in einem Satz. */
readonly detail?: string; readonly detail?: string;
/** Statusfarbe. */ /** Statusfarbe. */
readonly severity: WidgetSeverity; readonly severity: WidgetSeverity;
/** Ergaenzende Einzelwerte. */ /** Ergänzende Einzelwerte. */
readonly breakdown?: Record<string, number>; readonly breakdown?: Record<string, number>;
} }
/** Die Uebersicht der Anlage. */ /** Die Übersicht der Anlage. */
export interface Dashboard { export interface Dashboard {
/** Kennzahlen in Anzeigereihenfolge. */ /** Kennzahlen in Anzeigereihenfolge. */
readonly widgets: readonly DashboardWidget[]; readonly widgets: readonly DashboardWidget[];
@ -39,9 +39,9 @@ export interface Dashboard {
readonly generated_at: string; readonly generated_at: string;
} }
/** Ein Wiederherstellungspunkt in der Uebersicht. */ /** Ein Wiederherstellungspunkt in der Übersicht. */
export interface RecoveryPoint { export interface RecoveryPoint {
/** Oeffentlicher Bezeichner. */ /** Öffentlicher Bezeichner. */
readonly id: string; readonly id: string;
/** Repository des Punktes. */ /** Repository des Punktes. */
readonly repository_id: string; readonly repository_id: string;
@ -67,7 +67,7 @@ export interface RecoveryPoint {
readonly completed_at?: string; readonly completed_at?: string;
/** Objektive Einstufung. */ /** Objektive Einstufung. */
readonly classification?: string; readonly classification?: string;
/** Letzte Integritaetspruefung in UTC. */ /** Letzte Integritätsprüfung in UTC. */
readonly last_verified_at?: string; readonly last_verified_at?: string;
/** Letzter Wiederherstellungstest in UTC. */ /** Letzter Wiederherstellungstest in UTC. */
readonly last_restore_test_at?: string; readonly last_restore_test_at?: string;
@ -77,34 +77,34 @@ export interface RecoveryPoint {
readonly immutable_until?: string; readonly immutable_until?: string;
/** Meldet einen unbefristeten Schutz. */ /** Meldet einen unbefristeten Schutz. */
readonly legal_hold: boolean; readonly legal_hold: boolean;
/** Meldet, ob eine Loeschung derzeit unzulaessig ist. */ /** Meldet, ob eine Löschung derzeit unzulässig ist. */
readonly is_protected: boolean; readonly is_protected: boolean;
/** Zeitpunkt der Loeschung in UTC. */ /** Zeitpunkt der Löschung in UTC. */
readonly deleted_at?: string; readonly deleted_at?: string;
/** Begruendung der Loeschung. */ /** Begründung der Löschung. */
readonly deletion_reason?: string; readonly deletion_reason?: string;
} }
/** Filter der Liste von Wiederherstellungspunkten. */ /** Filter der Liste von Wiederherstellungspunkten. */
export interface RecoveryPointFilter { export interface RecoveryPointFilter {
/** Beschraenkung auf ein Repository. */ /** Beschränkung auf ein Repository. */
readonly repositoryId?: string; readonly repositoryId?: string;
/** Beschraenkung auf eine Einstufung. */ /** Beschränkung auf eine Einstufung. */
readonly classification?: string; readonly classification?: string;
/** Nimmt geloeschte Punkte auf. */ /** Nimmt gelöschte Punkte auf. */
readonly includeDeleted?: boolean; readonly includeDeleted?: boolean;
/** Beschraenkt auf geschuetzte Punkte. */ /** Beschränkt auf geschützte Punkte. */
readonly onlyProtected?: boolean; readonly onlyProtected?: boolean;
/** Angeforderte Seite. */ /** Angeforderte Seite. */
readonly page?: number; readonly page?: number;
} }
/** Laedt die Uebersicht. */ /** Lädt die Übersicht. */
export async function fetchDashboard(abortSignal?: AbortSignal): Promise<Dashboard> { export async function fetchDashboard(abortSignal?: AbortSignal): Promise<Dashboard> {
return requestApi<Dashboard>('/dashboard', abortSignal ? { signal: abortSignal } : {}); return requestApi<Dashboard>('/dashboard', abortSignal ? { signal: abortSignal } : {});
} }
/** Laedt eine Seite von Wiederherstellungspunkten. */ /** Lädt eine Seite von Wiederherstellungspunkten. */
export async function fetchRecoveryPoints( export async function fetchRecoveryPoints(
filter: RecoveryPointFilter = {}, filter: RecoveryPointFilter = {},
abortSignal?: AbortSignal, abortSignal?: AbortSignal,

View File

@ -39,7 +39,7 @@ describe('SystemHealthPanel', () => {
expect(screen.getByText('database')).toBeInTheDocument(); expect(screen.getByText('database')).toBeInTheDocument();
}); });
// Der Zustand wird zusaetzlich zur Farbe als Text ausgegeben (PROMPT.md §107). // Der Zustand wird zusätzlich zur Farbe als Text ausgegeben (PROMPT.md §107).
expect(screen.getAllByText('Fehlerfrei').length).toBeGreaterThan(0); expect(screen.getAllByText('Fehlerfrei').length).toBeGreaterThan(0);
expect(screen.getByText(/0\.1\.0-test/)).toBeInTheDocument(); expect(screen.getByText(/0\.1\.0-test/)).toBeInTheDocument();
}); });
@ -72,7 +72,7 @@ describe('SystemHealthPanel', () => {
render(<SystemHealthPanel />); render(<SystemHealthPanel />);
// Ein Problem darf niemals beschoenigt werden (PROMPT.md §140). // Ein Problem darf niemals beschönigt werden (PROMPT.md §140).
await waitFor(() => { await waitFor(() => {
expect( expect(
screen.getByText('Syncova kann die Control-Plane-Datenbank nicht erreichen.'), screen.getByText('Syncova kann die Control-Plane-Datenbank nicht erreichen.'),
@ -82,7 +82,7 @@ describe('SystemHealthPanel', () => {
expect(screen.getAllByText('Nicht erreichbar').length).toBeGreaterThan(0); expect(screen.getAllByText('Nicht erreichbar').length).toBeGreaterThan(0);
}); });
it('zeigt eine verstaendliche Meldung, wenn die API nicht erreichbar ist', async () => { it('zeigt eine verständliche Meldung, wenn die API nicht erreichbar ist', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
render(<SystemHealthPanel />); render(<SystemHealthPanel />);
@ -91,13 +91,13 @@ describe('SystemHealthPanel', () => {
expect(screen.getByRole('alert')).toBeInTheDocument(); expect(screen.getByRole('alert')).toBeInTheDocument();
}); });
// Statt eines rohen Fehlercodes erscheint eine erklaerende Meldung (PROMPT.md §124). // Statt eines rohen Fehlercodes erscheint eine erklärende Meldung (PROMPT.md §124).
expect(screen.getByText(/nicht erreichbar/i)).toBeInTheDocument(); expect(screen.getByText(/nicht erreichbar/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Erneut versuchen' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Erneut versuchen' })).toBeInTheDocument();
}); });
it('zeigt keine Daten, solange der Zustand nicht geladen ist', () => { it('zeigt keine Daten, solange der Zustand nicht geladen ist', () => {
// Eine Anfrage, die nie antwortet, haelt die Komponente im Ladezustand. // Eine Anfrage, die nie antwortet, hält die Komponente im Ladezustand.
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {}))); vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
render(<SystemHealthPanel />); render(<SystemHealthPanel />);

View File

@ -2,12 +2,12 @@
* 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 * erhält der Anwender eine verständliche Erklärung und eine
* Handlungsempfehlung. * Handlungsempfehlung.
* *
* Dass eine kritische Komponente hier mit vollstaendigem Bericht erscheint, * Dass eine kritische Komponente hier mit vollständigem Bericht erscheint,
* obwohl der Endpunkt dabei mit 503 antwortet, ist Absicht: Das Monitoring * obwohl der Endpunkt dabei mit 503 antwortet, ist Absicht: Das Monitoring
* schlaegt an, und die Oberflaeche kann trotzdem zeigen, **was** kaputt ist. * schlägt an, und die Oberfläche kann trotzdem zeigen, **was** kaputt ist.
*/ */
import { useCallback } from 'react'; import { useCallback } from 'react';

View File

@ -2,7 +2,7 @@
* Hook zum Laden des Systemzustands. * Hook zum Laden des Systemzustands.
* *
* Der Hook zeigt niemals Platzhalter- oder Demo-Werte: solange kein echtes * Der Hook zeigt niemals Platzhalter- oder Demo-Werte: solange kein echtes
* Ergebnis vorliegt, bleibt der Zustand ausdruecklich "laedt" oder "Fehler" * Ergebnis vorliegt, bleibt der Zustand ausdrücklich "lädt" oder "Fehler"
* (PROMPT.md §138/§139). * (PROMPT.md §138/§139).
*/ */
@ -13,7 +13,7 @@ import type { SystemHealth } from '../../types/api';
/** Ladezustand der Systemzustandsabfrage. */ /** Ladezustand der Systemzustandsabfrage. */
export type HealthLoadState = 'loading' | 'loaded' | 'failed'; export type HealthLoadState = 'loading' | 'loaded' | 'failed';
/** Rueckgabewert des Hooks. */ /** Rückgabewert des Hooks. */
export interface UseSystemHealthResult { export interface UseSystemHealthResult {
/** Aktueller Ladezustand. */ /** Aktueller Ladezustand. */
loadState: HealthLoadState; loadState: HealthLoadState;
@ -21,12 +21,12 @@ export interface UseSystemHealthResult {
systemHealth: SystemHealth | null; systemHealth: SystemHealth | null;
/** Aufgetretener Fehler; null, wenn kein Fehler vorliegt. */ /** Aufgetretener Fehler; null, wenn kein Fehler vorliegt. */
loadError: ApiError | null; loadError: ApiError | null;
/** Laedt den Systemzustand erneut. */ /** Lädt den Systemzustand erneut. */
reload: () => void; reload: () => void;
} }
/** /**
* Laedt den Systemzustand von GET /api/v1/health. * Lädt den Systemzustand von GET /api/v1/health.
* *
* @param refreshIntervalMs Abstand automatischer Aktualisierungen in Millisekunden. * @param refreshIntervalMs Abstand automatischer Aktualisierungen in Millisekunden.
* Ein Wert von 0 deaktiviert die automatische Aktualisierung. * Ein Wert von 0 deaktiviert die automatische Aktualisierung.
@ -57,13 +57,13 @@ export function useSystemHealth(refreshIntervalMs = 0): UseSystemHealthResult {
setLoadError(null); setLoadError(null);
setLoadState('loaded'); setLoadState('loaded');
} catch (caughtError) { } catch (caughtError) {
// Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens. // Ein Abbruch ist kein Fehler, sondern Folge des Aufräumens.
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') { if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
return; return;
} }
// Der zuletzt bekannte Zustand wird verworfen: eine veraltete Anzeige // Der zuletzt bekannte Zustand wird verworfen: eine veraltete Anzeige
// als aktuellen Zustand auszugeben waere irrefuehrend. // als aktuellen Zustand auszugeben wäre irreführend.
setSystemHealth(null); setSystemHealth(null);
setLoadError( setLoadError(
caughtError instanceof ApiError caughtError instanceof ApiError

View File

@ -2,12 +2,12 @@
* Agenten — Aufnahme und Verwaltung. * Agenten — Aufnahme und Verwaltung.
* *
* Die heikle Stelle ist das Aufnahme-Token: Es wird **genau einmal** angezeigt * Die heikle Stelle ist das Aufnahme-Token: Es wird **genau einmal** angezeigt
* und danach nur noch als Hash gespeichert. Die Oberflaeche sagt das * und danach nur noch als Hash gespeichert. Die Oberfläche sagt das
* ausdruecklich und laesst sich nicht mit Escape schliessen, solange es zu * ausdrücklich und lässt sich nicht mit Escape schließen, solange es zu
* sehen ist — wer es wegklickt, muss ein neues erzeugen. * sehen ist — wer es wegklickt, muss ein neues erzeugen.
* *
* Der Agent bestimmt seinen Namen nicht selbst: Er steht **im Token**. Sonst * Der Agent bestimmt seinen Namen nicht selbst: Er steht **im Token**. Sonst
* koennte er sich als ein anderes System ausgeben. * könnte er sich als ein anderes System ausgeben.
*/ */
import { Ban, Copy, KeyRound, Plus, RefreshCw } from 'lucide-react'; import { Ban, Copy, KeyRound, Plus, RefreshCw } from 'lucide-react';
@ -71,7 +71,7 @@ export function AgentsPage({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Das Aufnahme-Token liess sich nicht erzeugen', 'Das Aufnahme-Token ließ sich nicht erzeugen',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -209,10 +209,7 @@ export function AgentsPage({
/> />
<Callout tone="info" className="mb-4"> <Callout tone="info" className="mb-4">
Der Agent <strong>holt seine Auftraege ab</strong>; der Server drueckt Der Agent holt seine Aufträge ab; der Server drückt sie nicht.
sie nicht. Ein Agent steht hinter einer Firewall und ist vom Server aus
nicht erreichbar — jedenfalls nicht ohne eingehende Portfreigabe auf
jedem gesicherten System.
</Callout> </Callout>
<Card> <Card>
@ -222,7 +219,7 @@ export function AgentsPage({
getRowKey={(agent) => agent.id} getRowKey={(agent) => agent.id}
isLoading={agentsResource.loadState === 'loading'} isLoading={agentsResource.loadState === 'loading'}
emptyTitle="Kein Agent aufgenommen" emptyTitle="Kein Agent aufgenommen"
emptyDescription="Ein Agent wird mit einem Aufnahme-Token registriert. Das Token gilt eine Stunde und nur einmal." emptyDescription="Registrierung über ein Aufnahme-Token, gültig eine Stunde."
emptyAction={ emptyAction={
mayEnroll ? ( mayEnroll ? (
<Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}> <Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}>
@ -239,7 +236,7 @@ export function AgentsPage({
<DialogContent> <DialogContent>
<DialogHeader <DialogHeader
title="Agent aufnehmen" title="Agent aufnehmen"
description="Erzeugt ein einmalig gueltiges Aufnahme-Token." description="Erzeugt ein einmalig gültiges Aufnahme-Token."
/> />
<DialogBody> <DialogBody>
<TextInput <TextInput
@ -248,13 +245,10 @@ export function AgentsPage({
value={draftAgentName} value={draftAgentName}
onChange={(changeEvent) => setDraftAgentName(changeEvent.target.value)} onChange={(changeEvent) => setDraftAgentName(changeEvent.target.value)}
placeholder="dateiserver-01" placeholder="dateiserver-01"
hint="Der Name steht im Token, nicht in der Hand des Agenten — sonst koennte er sich als ein anderes System ausgeben." hint="Der Name steht im Token, nicht in der Hand des Agenten — sonst könnte er sich als ein anderes System ausgeben."
/> />
<Callout tone="info"> <Callout tone="info">
Das Token gilt <strong>eine Stunde</strong> und nur fuer die Gilt eine Stunde und nur zur Registrierung.
Registrierung. Danach erhaelt der Agent ein Betriebstoken mit
ausschliesslich agentspezifischen Rechten, das einzeln widerrufbar
ist.
</Callout> </Callout>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
@ -293,10 +287,7 @@ export function AgentsPage({
}} }}
> >
<Callout tone="warning"> <Callout tone="warning">
Das Betriebstoken gilt <strong>sofort</strong> nicht mehr. Der Agent Das Betriebstoken gilt sofort nicht mehr. Geplante Sicherungen fallen aus.
beendet sich beim naechsten Versuch — ein abgelehntes Token behebt
sich nicht durch Warten. Geplante Sicherungen dieses Systems fallen
danach aus.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>
@ -306,8 +297,8 @@ export function AgentsPage({
/** /**
* Zeigt das Aufnahme-Token einmalig. * Zeigt das Aufnahme-Token einmalig.
* *
* Der Dialog laesst sich nicht versehentlich schliessen: Es gibt nur eine * Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
* Schaltflaeche, und sie sagt, was sie bewirkt. * Schaltfläche, und sie sagt, was sie bewirkt.
*/ */
function IssuedTokenDialog({ function IssuedTokenDialog({
token, token,
@ -326,7 +317,7 @@ function IssuedTokenDialog({
window.setTimeout(() => setHasCopied(false), 2000); window.setTimeout(() => setHasCopied(false), 2000);
} catch { } catch {
toast.showInfo( toast.showInfo(
'Kopieren nicht moeglich', 'Kopieren nicht möglich',
'Markieren Sie das Token und kopieren Sie es von Hand.', 'Markieren Sie das Token und kopieren Sie es von Hand.',
); );
} }
@ -337,13 +328,13 @@ function IssuedTokenDialog({
<DialogContent size="lg"> <DialogContent size="lg">
<DialogHeader <DialogHeader
title="Aufnahme-Token" title="Aufnahme-Token"
description={`Fuer den Agenten „${token.agent_name}“`} description={`Für den Agenten „${token.agent_name}“`}
/> />
<DialogBody> <DialogBody>
<Callout tone="warning" title="Dieses Token erscheint genau einmal"> <Callout tone="warning" title="Dieses Token erscheint genau einmal">
Es wird nur als Hash gespeichert und laesst sich nicht wieder Es wird nur als Hash gespeichert und lässt sich nicht wieder
abrufen. Schliessen Sie dieses Fenster erst, wenn Sie es sicher abrufen. Schließen Sie dieses Fenster erst, wenn Sie es sicher
hinterlegt haben — sonst muessen Sie ein neues erzeugen. hinterlegt haben — sonst müssen Sie ein neues erzeugen.
</Callout> </Callout>
<div className="rounded-md border border-line bg-sunken p-3"> <div className="rounded-md border border-line bg-sunken p-3">
@ -367,8 +358,8 @@ function IssuedTokenDialog({
</code> </code>
<p className="mt-1.5 text-xs text-fg-muted"> <p className="mt-1.5 text-xs text-fg-muted">
`--state` erwartet eine <strong>Datei</strong>, kein Verzeichnis. `--state` erwartet eine <strong>Datei</strong>, kein Verzeichnis.
Mit einem Verzeichnis haelt sich der Agent fuer registriert und Mit einem Verzeichnis hält sich der Agent für registriert und
laeuft ohne Token. läuft ohne Token.
</p> </p>
</div> </div>
</DialogBody> </DialogBody>

View File

@ -1,8 +1,8 @@
/** /**
* Geschuetzte Systeme — die verbundsuebergreifende Bestandsliste. * Geschützte Systeme — die verbundsübergreifende Bestandsliste.
* *
* Sie beantwortet die Frage, die keine der Einzelseiten beantwortet: **Was * Sie beantwortet die Frage, die keine der Einzelseiten beantwortet: **Was
* genau ist eigentlich geschuetzt?** Agenten und virtuelle Gaeste stehen * genau ist eigentlich geschützt?** Agenten und virtuelle Gäste stehen
* nebeneinander, weil sie aus Sicht eines Betreibers dasselbe sind — ein * nebeneinander, weil sie aus Sicht eines Betreibers dasselbe sind — ein
* System, von dem es Sicherungen geben soll. * System, von dem es Sicherungen geben soll.
*/ */
@ -111,7 +111,7 @@ export function ProtectedSystemsPage() {
if (agentsResource.loadState === 'failed' && agentsResource.loadError) { if (agentsResource.loadState === 'failed' && agentsResource.loadError) {
return ( return (
<> <>
<PageHeader title="Geschuetzte Systeme" /> <PageHeader title="Geschützte Systeme" />
<Card> <Card>
<ErrorState <ErrorState
message={describeApiError(agentsResource.loadError)} message={describeApiError(agentsResource.loadError)}
@ -126,14 +126,12 @@ export function ProtectedSystemsPage() {
return ( return (
<> <>
<PageHeader <PageHeader
title="Geschuetzte Systeme" title="Geschützte Systeme"
description="Was gesichert wird — Agenten und virtuelle Gaeste nebeneinander." description="Was gesichert wird — Agenten und virtuelle Gäste nebeneinander."
/> />
<Callout tone="info" className="mb-4"> <Callout tone="info" className="mb-4">
Diese Liste zeigt den <strong>Bestand</strong>, nicht den Sicherungsstand. Bestand, nicht Sicherungsstand — Aufträge stehen unter „Sicherungsaufträge“.
Dass ein System hier steht, heisst nicht, dass es einen Auftrag dafuer
gibt — das steht unter „Sicherungsauftraege“.
</Callout> </Callout>
<Card className="mb-4"> <Card className="mb-4">
@ -148,7 +146,7 @@ export function ProtectedSystemsPage() {
</Card> </Card>
<Card> <Card>
<CardHeader title="Virtuelle Gaeste" /> <CardHeader title="Virtuelle Gäste" />
<DataTable <DataTable
columns={machineColumns} columns={machineColumns}
rows={machinesResource.data ?? []} rows={machinesResource.data ?? []}

View File

@ -1,17 +1,17 @@
/** /**
* Proxmox-Verbuende. * Proxmox-Verbünde.
* *
* Dieser Bereich hatte bisher gar keine Oberflaeche — neun Endpunkte, die sich * Dieser Bereich hatte bisher gar keine Oberfläche — neun Endpunkte, die sich
* nur ueber die API bedienen liessen. * nur über die API bedienen ließen.
* *
* Zwei Stellen der Maske sind sicherheitsrelevant und deshalb ausfuehrlich * Zwei Stellen der Maske sind sicherheitsrelevant und deshalb ausführlich
* beschriftet: * beschriftet:
* *
* - **`http://` wird abgelehnt.** Ueber diese Verbindung geht ein API-Token. * - **`http://` wird abgelehnt.** Über diese Verbindung geht ein API-Token.
* - **Ohne hinterlegten Wirtsschluessel keine SSH-Verbindung.** Einen Schalter * - **Ohne hinterlegten Wirtsschlüssel keine SSH-Verbindung.** Einen Schalter
* „Wirtsschluessel egal" gibt es nicht: Ein Transport, der jeden annimmt, * „Wirtsschlüssel egal" gibt es nicht: Ein Transport, der jeden annimmt,
* macht aus einem Zwischenangriff eine Einladung — der Angreifer lieferte * macht aus einem Zwischenangriff eine Einladung — der Angreifer lieferte
* dann das Archiv, das Syncova fuer ein Backup haelt. * dann das Archiv, das Syncova für ein Backup hält.
*/ */
import { Network, Plus, RefreshCw, Search, Trash2 } from 'lucide-react'; import { Network, Plus, RefreshCw, Search, Trash2 } from 'lucide-react';
@ -90,7 +90,7 @@ export function ProxmoxPage({
onSuccess: (cluster) => { onSuccess: (cluster) => {
toast.showSuccess( toast.showSuccess(
'Verbund angelegt', 'Verbund angelegt',
`${cluster.name} — pruefen Sie als Naechstes die Verbindung.`, `${cluster.name} — prüfen Sie als Nächstes die Verbindung.`,
); );
setIsCreateDialogOpen(false); setIsCreateDialogOpen(false);
setDraftTokenSecret(''); setDraftTokenSecret('');
@ -98,7 +98,7 @@ export function ProxmoxPage({
clustersResource.reload(); clustersResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Der Verbund liess sich nicht anlegen', describeApiError(apiError), apiError.requestId), toast.showError('Der Verbund ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId),
}); });
const testMutation = useMutation(testCluster, { const testMutation = useMutation(testCluster, {
@ -110,11 +110,11 @@ export function ProxmoxPage({
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
apiError.statusCode === 503 ? 'Der Verbund ist nicht erreichbar' : 'Die Anmeldung wurde abgelehnt', apiError.statusCode === 503 ? 'Der Verbund ist nicht erreichbar' : 'Die Anmeldung wurde abgelehnt',
// Nicht erreichbar und nicht berechtigt sind zwei Lagen mit voellig // Nicht erreichbar und nicht berechtigt sind zwei Lagen mit völlig
// verschiedener Abhilfe — sie duerfen nicht dieselbe Meldung ergeben. // verschiedener Abhilfe — sie dürfen nicht dieselbe Meldung ergeben.
apiError.statusCode === 503 apiError.statusCode === 503
? `${describeApiError(apiError)} — pruefen Sie Netz, Adresse und Zertifikat.` ? `${describeApiError(apiError)} — prüfen Sie Netz, Adresse und Zertifikat.`
: `${describeApiError(apiError)} — pruefen Sie Token-Kennung und Geheimnis.`, : `${describeApiError(apiError)} — prüfen Sie Token-Kennung und Geheimnis.`,
apiError.requestId, apiError.requestId,
), ),
}); });
@ -124,7 +124,7 @@ export function ProxmoxPage({
toast.showSuccess( toast.showSuccess(
'Bestand erfasst', 'Bestand erfasst',
result.summary ?? result.summary ??
`${result.hosts_found ?? 0} Knoten, ${result.guests_found ?? 0} Gaeste.`, `${result.hosts_found ?? 0} Knoten, ${result.guests_found ?? 0} Gäste.`,
); );
clustersResource.reload(); clustersResource.reload();
}, },
@ -141,7 +141,7 @@ export function ProxmoxPage({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Der Verbund liess sich nicht entfernen', 'Der Verbund ließ sich nicht entfernen',
apiError.statusCode === 409 apiError.statusCode === 409
? 'Auf diesen Verbund verweisen noch Quellen oder Sicherungen.' ? 'Auf diesen Verbund verweisen noch Quellen oder Sicherungen.'
: describeApiError(apiError), : describeApiError(apiError),
@ -191,7 +191,7 @@ export function ProxmoxPage({
) : ( ) : (
// "Zuletzt erreicht" wird nur bei Erfolg fortgeschrieben. Bei jedem // "Zuletzt erreicht" wird nur bei Erfolg fortgeschrieben. Bei jedem
// Versuch zu setzen machte daraus ein "zuletzt versucht", und ein // Versuch zu setzen machte daraus ein "zuletzt versucht", und ein
// seit Wochen toter Verbund saehe frisch aus. // seit Wochen toter Verbund sähe frisch aus.
<span className="text-xs text-warning">Nie erreicht</span> <span className="text-xs text-warning">Nie erreicht</span>
), ),
}, },
@ -216,7 +216,7 @@ export function ProxmoxPage({
<> <>
<PageHeader <PageHeader
title="Proxmox" title="Proxmox"
description="Virtualisierungsverbuende und ihre Gaeste." description="Virtualisierungsverbünde und ihre Gäste."
actions={ actions={
mayWrite ? ( mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}> <Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
@ -228,10 +228,7 @@ export function ProxmoxPage({
/> />
<Callout tone="warning" className="mb-4" title="Nicht auf echter Hardware freigegeben"> <Callout tone="warning" className="mb-4" title="Nicht auf echter Hardware freigegeben">
Sicherung und bitgenaues Zurueckschreiben eines Gasts laufen gegen einen Ob eine wiederhergestellte Maschine startet, ist ungeprüft.
Nachbau der API durch. <strong>Ob eine wiederhergestellte Maschine
startet, ist ungeprueft</strong> — dafuer stand kein Verbund zur
Verfuegung.
</Callout> </Callout>
<Card> <Card>
@ -242,7 +239,7 @@ export function ProxmoxPage({
onRowClick={(cluster) => setSelectedCluster(cluster)} onRowClick={(cluster) => setSelectedCluster(cluster)}
isLoading={clustersResource.loadState === 'loading'} isLoading={clustersResource.loadState === 'loading'}
emptyTitle="Kein Verbund eingerichtet" emptyTitle="Kein Verbund eingerichtet"
emptyDescription="Ein Verbund braucht ein API-Token mit eigenen Rechten — kein Ticket, denn ein Token ist dauerhaft gueltig und einzeln widerrufbar." emptyDescription="Ein Verbund braucht ein API-Token mit eigenen Rechten."
emptyAction={ emptyAction={
mayWrite ? ( mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}> <Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
@ -272,7 +269,7 @@ export function ProxmoxPage({
<DialogContent size="lg"> <DialogContent size="lg">
<DialogHeader <DialogHeader
title="Proxmox-Verbund anlegen" title="Proxmox-Verbund anlegen"
description="Zugangsdaten werden verschluesselt abgelegt und nie wieder ausgeliefert." description="Zugangsdaten werden verschlüsselt abgelegt und nie wieder ausgeliefert."
/> />
<DialogBody> <DialogBody>
<TextInput <TextInput
@ -292,7 +289,7 @@ export function ProxmoxPage({
placeholder="https://pve.example:8006" placeholder="https://pve.example:8006"
error={ error={
draftEndpoint.trim().length > 8 && !endpointIsSecure draftEndpoint.trim().length > 8 && !endpointIsSecure
? 'Nur https. Ueber diese Verbindung geht ein API-Token.' ? 'Nur https. Über diese Verbindung geht ein API-Token.'
: undefined : undefined
} }
/> />
@ -323,11 +320,11 @@ export function ProxmoxPage({
value={draftFingerprint} value={draftFingerprint}
onChange={(changeEvent) => setDraftFingerprint(changeEvent.target.value)} onChange={(changeEvent) => setDraftFingerprint(changeEvent.target.value)}
placeholder="AB:CD:EF:…" placeholder="AB:CD:EF:…"
hint="Fingerabdruckbindung ist strenger als eine CA-Pruefung: Es gilt genau ein Zertifikat." hint="Fingerabdruckbindung ist strenger als eine CA-Prüfung: Es gilt genau ein Zertifikat."
/> />
<TextInput <TextInput
label="Speicherkennung fuer Sicherungen" label="Speicherkennung für Sicherungen"
required required
className="font-mono" className="font-mono"
value={draftStorageId} value={draftStorageId}
@ -342,7 +339,7 @@ export function ProxmoxPage({
onChange={(changeEvent) => onChange={(changeEvent) =>
setDraftTransport(changeEvent.target.value as TransportKind) setDraftTransport(changeEvent.target.value as TransportKind)
} }
hint="Proxmox kann eine Sicherung anstossen, die entstandene Datei aber nicht ueber die API herausgeben. Deshalb braucht es einen zweiten Weg." hint="Proxmox kann eine Sicherung anstoßen, die entstandene Datei aber nicht über die API herausgeben. Deshalb braucht es einen zweiten Weg."
> >
<option value="local">Dateizugriff (gemeinsame Ablage oder Freigabe)</option> <option value="local">Dateizugriff (gemeinsame Ablage oder Freigabe)</option>
<option value="ssh">SSH zum Knoten</option> <option value="ssh">SSH zum Knoten</option>
@ -367,15 +364,15 @@ export function ProxmoxPage({
/> />
</div> </div>
<TextArea <TextArea
label="Privater Schluessel (PEM)" label="Privater Schlüssel (PEM)"
required required
className="font-mono text-xs" className="font-mono text-xs"
value={draftSshKey} value={draftSshKey}
onChange={(changeEvent) => setDraftSshKey(changeEvent.target.value)} onChange={(changeEvent) => setDraftSshKey(changeEvent.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /* secretscan:erlaubt — Platzhaltertext, kein Schluessel */ placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /* secretscan:erlaubt — Platzhaltertext, kein Schlüssel */
/> />
<TextInput <TextInput
label="Wirtsschluessel-Fingerabdruck" label="Wirtsschlüssel-Fingerabdruck"
required required
className="font-mono text-xs" className="font-mono text-xs"
value={draftSshFingerprint} value={draftSshFingerprint}
@ -384,10 +381,7 @@ export function ProxmoxPage({
hint="Aus „ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub“ auf dem Knoten." hint="Aus „ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub“ auf dem Knoten."
/> />
<Callout tone="warning"> <Callout tone="warning">
Einen Schalter „Wirtsschluessel egal" gibt es nicht. Ein Einen Schalter „Wirtsschlüssel egal“ gibt es nicht.
Transport, der jeden annimmt, macht aus einem Zwischenangriff
eine Einladung — der Angreifer lieferte dann das Archiv, das
Syncova fuer ein Backup haelt.
</Callout> </Callout>
</div> </div>
)} )}
@ -396,7 +390,7 @@ export function ProxmoxPage({
checked={draftKeepArchive} checked={draftKeepArchive}
onCheckedChange={setDraftKeepArchive} onCheckedChange={setDraftKeepArchive}
label="Archiv auf dem Knoten behalten" label="Archiv auf dem Knoten behalten"
hint="Normalerweise wird es nach dem Einlesen entfernt. Behaelt man es, fuellt jede Sicherung den Proxmox-Speicher mit einer zweiten, unverwalteten Kopie, fuer die keine Aufbewahrungsregel gilt." hint="Normalerweise wird es nach dem Einlesen entfernt. Behält man es, füllt jede Sicherung den Proxmox-Speicher mit einer zweiten, unverwalteten Kopie, für die keine Aufbewahrungsregel gilt."
/> />
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
@ -461,8 +455,7 @@ export function ProxmoxPage({
}} }}
> >
<Callout tone="info"> <Callout tone="info">
Die vorhandenen Sicherungen der Gaeste bleiben im Repository. Verweisen Die Sicherungen der Gäste bleiben im Repository.
noch Quellen auf diesen Verbund, wird das Entfernen abgelehnt.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>
@ -515,8 +508,8 @@ function ClusterDetailCard({
header: 'Zustand', header: 'Zustand',
render: (machine) => render: (machine) =>
machine.missing_since ? ( machine.missing_since ? (
// Ein fehlender Gast wird vermerkt, nie geloescht — eine geloeschte // Ein fehlender Gast wird vermerkt, nie gelöscht — eine gelöschte
// Zeile naehme die Zuordnung zu vorhandenen Backups mit. // Zeile nähme die Zuordnung zu vorhandenen Backups mit.
<span className="text-xs text-warning"> <span className="text-xs text-warning">
Fehlt seit {formatRelativeTime(machine.missing_since)} Fehlt seit {formatRelativeTime(machine.missing_since)}
</span> </span>
@ -542,8 +535,8 @@ function ClusterDetailCard({
{machine.disk_count} {machine.disk_count}
{machine.excluded_disk_count > 0 ? ( {machine.excluded_disk_count > 0 ? (
// Eine Platte mit backup=0 macht den Lauf zum Teilfehler: Die // Eine Platte mit backup=0 macht den Lauf zum Teilfehler: Die
// Wiederherstellung liefert sonst eine unvollstaendige Maschine, // Wiederherstellung liefert sonst eine unvollständige Maschine,
// die jemand fuer vollstaendig haelt. // die jemand für vollständig hält.
<span className="ml-1 text-warning"> <span className="ml-1 text-warning">
({machine.excluded_disk_count} ausgenommen) ({machine.excluded_disk_count} ausgenommen)
</span> </span>
@ -560,7 +553,7 @@ function ClusterDetailCard({
description={cluster.api_endpoint} description={cluster.api_endpoint}
action={ action={
<Button variant="ghost" size="sm" onClick={onClose}> <Button variant="ghost" size="sm" onClick={onClose}>
Schliessen Schließen
</Button> </Button>
} }
/> />
@ -582,7 +575,7 @@ function ClusterDetailCard({
<div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4"> <div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4">
<Button size="sm" isLoading={isTesting} onClick={onTest}> <Button size="sm" isLoading={isTesting} onClick={onTest}>
<Network /> <Network />
Verbindung pruefen Verbindung prüfen
</Button> </Button>
<Button size="sm" isLoading={isDiscovering} onClick={onDiscover}> <Button size="sm" isLoading={isDiscovering} onClick={onDiscover}>
<Search /> <Search />
@ -601,7 +594,7 @@ function ClusterDetailCard({
<div className="mt-5 border-t border-line pt-4"> <div className="mt-5 border-t border-line pt-4">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle"> <p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle">
Gaeste Gäste
</p> </p>
<DataTable <DataTable
columns={machineColumns} columns={machineColumns}
@ -609,7 +602,7 @@ function ClusterDetailCard({
getRowKey={(machine) => machine.id} getRowKey={(machine) => machine.id}
isLoading={machinesResource.loadState === 'loading'} isLoading={machinesResource.loadState === 'loading'}
emptyTitle="Kein Gast erfasst" emptyTitle="Kein Gast erfasst"
emptyDescription="Der Bestand ist eine Momentaufnahme. Erfassen Sie ihn, um die Gaeste dieses Verbunds zu sehen." emptyDescription="Der Bestand ist eine Momentaufnahme. Erfassen Sie ihn, um die Gäste dieses Verbunds zu sehen."
/> />
</div> </div>
</CardContent> </CardContent>

View File

@ -1,20 +1,20 @@
/** /**
* API-Anbindung von Virtualisierungsverbuenden und Agenten. * API-Anbindung von Virtualisierungsverbünden und Agenten.
* *
* Beide teilen eine Eigenschaft, die die Masken praegt: **Geheimnisse gehen nur * Beide teilen eine Eigenschaft, die die Masken prägt: **Geheimnisse gehen nur
* hinein, nie heraus.** Ein API-Token eines Proxmox-Verbunds und das * hinein, nie heraus.** Ein API-Token eines Proxmox-Verbunds und das
* Betriebstoken eines Agenten lassen sich nach dem Anlegen nicht mehr abrufen — * Betriebstoken eines Agenten lassen sich nach dem Anlegen nicht mehr abrufen —
* die API gibt sie nicht zurueck, und das ist kein Mangel, sondern der Grund, * die API gibt sie nicht zurück, und das ist kein Mangel, sondern der Grund,
* warum ein Lesezugriff auf die Konfiguration ungefaehrlich ist. * warum ein Lesezugriff auf die Konfiguration ungefährlich ist.
* *
* Die Oberflaeche muss das sagen, sonst schliesst jemand das Fenster mit dem * Die Oberfläche muss das sagen, sonst schließt jemand das Fenster mit dem
* einmalig angezeigten Aufnahme-Token. * einmalig angezeigten Aufnahme-Token.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Proxmox-Verbuende // Proxmox-Verbünde
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Zugriffsweg auf die Sicherungsarchive. */ /** Zugriffsweg auf die Sicherungsarchive. */
@ -44,7 +44,7 @@ export interface ClusterInput {
name: string; name: string;
api_endpoint: string; api_endpoint: string;
api_token_id: string; api_token_id: string;
/** Wird verschluesselt abgelegt und nie wieder ausgeliefert. */ /** Wird verschlüsselt abgelegt und nie wieder ausgeliefert. */
api_token_secret: string; api_token_secret: string;
tls_fingerprint?: string; tls_fingerprint?: string;
backup_storage_id: string; backup_storage_id: string;
@ -53,7 +53,7 @@ export interface ClusterInput {
ssh_username?: string; ssh_username?: string;
ssh_port?: number; ssh_port?: number;
ssh_private_key_pem?: string; ssh_private_key_pem?: string;
/** Ohne hinterlegten Wirtsschluessel kommt keine SSH-Verbindung zustande. */ /** Ohne hinterlegten Wirtsschlüssel kommt keine SSH-Verbindung zustande. */
ssh_host_fingerprints?: Record<string, string>; ssh_host_fingerprints?: Record<string, string>;
keep_archive_on_node?: boolean; keep_archive_on_node?: boolean;
} }
@ -83,23 +83,23 @@ export interface VirtualMachine {
/** /**
* Platten mit `backup=0`. * Platten mit `backup=0`.
* *
* Groesser als null macht einen Sicherungslauf zum Teilfehler — nicht weil * Größer als null macht einen Sicherungslauf zum Teilfehler — nicht weil
* etwas schiefging, sondern weil die Wiederherstellung sonst eine * etwas schiefging, sondern weil die Wiederherstellung sonst eine
* unvollstaendige Maschine liefert, die jemand fuer vollstaendig haelt. * unvollständige Maschine liefert, die jemand für vollständig hält.
*/ */
excluded_disk_count: number; excluded_disk_count: number;
/** /**
* Zeitpunkt, seit dem der Gast im Bestand fehlt. * Zeitpunkt, seit dem der Gast im Bestand fehlt.
* *
* Ein fehlender Gast wird nie geloescht: Er koennte abgeschaltet oder * Ein fehlender Gast wird nie gelöscht: Er könnte abgeschaltet oder
* verschoben sein, und eine geloeschte Zeile naehme die Zuordnung zu * verschoben sein, und eine gelöschte Zeile nähme die Zuordnung zu
* vorhandenen Backups mit — die man genau dann braucht, wenn die Maschine * vorhandenen Backups mit — die man genau dann braucht, wenn die Maschine
* weg ist. * weg ist.
*/ */
missing_since?: string; missing_since?: string;
} }
/** Laedt die Verbuende. */ /** Lädt die Verbünde. */
export async function listClusters(abortSignal?: AbortSignal): Promise<Cluster[]> { export async function listClusters(abortSignal?: AbortSignal): Promise<Cluster[]> {
return requestApi<Cluster[]>( return requestApi<Cluster[]>(
'/proxmox/clusters', '/proxmox/clusters',
@ -116,7 +116,7 @@ export async function createCluster(clusterInput: ClusterInput): Promise<Cluster
}); });
} }
/** Loescht einen Verbund. */ /** Löscht einen Verbund. */
export async function deleteCluster(clusterIdentifier: string): Promise<void> { export async function deleteCluster(clusterIdentifier: string): Promise<void> {
return requestApi<void>(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}`, { return requestApi<void>(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}`, {
method: 'DELETE', method: 'DELETE',
@ -125,10 +125,10 @@ export async function deleteCluster(clusterIdentifier: string): Promise<void> {
} }
/** /**
* Prueft die Verbindung. * Prüft die Verbindung.
* *
* Ein nicht erreichbarer Verbund ist ein 503, kein 500 — und `unauthorized` ist * Ein nicht erreichbarer Verbund ist ein 503, kein 500 — und `unauthorized` ist
* von `unreachable` getrennt, weil die Abhilfe eine voellig andere ist. * von `unreachable` getrennt, weil die Abhilfe eine völlig andere ist.
*/ */
export async function testCluster( export async function testCluster(
clusterIdentifier: string, clusterIdentifier: string,
@ -148,7 +148,7 @@ export async function discoverCluster(
}); });
} }
/** Laedt die Knoten eines Verbunds. */ /** Lädt die Knoten eines Verbunds. */
export async function listClusterHosts( export async function listClusterHosts(
clusterIdentifier: string, clusterIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -159,7 +159,7 @@ export async function listClusterHosts(
); );
} }
/** Laedt die Gaeste eines Verbunds. */ /** Lädt die Gäste eines Verbunds. */
export async function listClusterMachines( export async function listClusterMachines(
clusterIdentifier: string, clusterIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -170,7 +170,7 @@ export async function listClusterMachines(
); );
} }
/** Laedt alle bekannten Gaeste, verbundsuebergreifend. */ /** Lädt alle bekannten Gäste, verbundsübergreifend. */
export async function listVirtualMachines( export async function listVirtualMachines(
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<VirtualMachine[]> { ): Promise<VirtualMachine[]> {
@ -202,15 +202,15 @@ export interface EnrollmentToken {
/** /**
* Das Token im Klartext — **einmalig**. * Das Token im Klartext — **einmalig**.
* *
* Es wird nur als Hash gespeichert und laesst sich nie wieder abrufen. Die * Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen. Die
* Oberflaeche muss das sagen, sonst schliesst jemand das Fenster. * Oberfläche muss das sagen, sonst schließt jemand das Fenster.
*/ */
enrollment_token: string; enrollment_token: string;
agent_name: string; agent_name: string;
expires_at?: string; expires_at?: string;
} }
/** Laedt die Agenten. */ /** Lädt die Agenten. */
export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> { export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> {
return requestApi<Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {}); return requestApi<Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {});
} }
@ -218,7 +218,7 @@ export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> {
/** /**
* Erzeugt ein Aufnahme-Token. * Erzeugt ein Aufnahme-Token.
* *
* Der Name steht **im Token**, nicht in der Hand des Agenten: Sonst koennte er * Der Name steht **im Token**, nicht in der Hand des Agenten: Sonst könnte er
* sich als ein anderes System ausgeben. * sich als ein anderes System ausgeben.
*/ */
export async function createEnrollmentToken( export async function createEnrollmentToken(
@ -250,7 +250,7 @@ export async function rotateAgentCredentials(
); );
} }
/** Laedt den Gesundheitszustand eines Agenten. */ /** Lädt den Gesundheitszustand eines Agenten. */
export async function getAgentHealth( export async function getAgentHealth(
agentIdentifier: string, agentIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,

View File

@ -1,8 +1,8 @@
/** /**
* Tests des Backup-Assistenten. * Tests des Backup-Assistenten.
* *
* Geprueft wird der Ablauf durch die Maske: dass unvollstaendige Schritte * Geprüft wird der Ablauf durch die Maske: dass unvollständige Schritte
* aufhalten, dass Rueckwaertsgehen keine Eingaben verliert und dass eine * aufhalten, dass Rückwärtsgehen keine Eingaben verliert und dass eine
* Fehlermeldung des Servers sichtbar wird statt zu verschwinden. * Fehlermeldung des Servers sichtbar wird statt zu verschwinden.
*/ */
@ -40,15 +40,15 @@ beforeEach(() => {
vi.spyOn(jobsApi, 'listRepositories').mockResolvedValue([writableRepository, readOnlyRepository]); vi.spyOn(jobsApi, 'listRepositories').mockResolvedValue([writableRepository, readOnlyRepository]);
}); });
/** Fuehrt den Assistenten bis zu einem Schritt und fuellt das Noetige aus. */ /** Führt den Assistenten bis zu einem Schritt und füllt das Nötige aus. */
async function fillUntilRepository(user: ReturnType<typeof userEvent.setup>): Promise<void> { async function fillUntilRepository(user: ReturnType<typeof userEvent.setup>): Promise<void> {
await user.type(screen.getByLabelText('Name des Auftrags'), 'Naechtliche Sicherung'); await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche Sicherung');
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
await user.type(screen.getByLabelText('Pfad'), '/daten'); await user.type(screen.getByLabelText('Pfad'), '/daten');
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
// Zeitplan: der Standard "taeglich 02:00" genuegt. // Zeitplan: der Standard "täglich 02:00" genügt.
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
} }
@ -57,7 +57,7 @@ describe('Backup-Assistent', () => {
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
// Auf das Laden der Ziele warten: Sonst setzt der Effekt seinen Zustand // Auf das Laden der Ziele warten: Sonst setzt der Effekt seinen Zustand
// erst nach dem Test, und React meldet eine Aktualisierung ausserhalb von act(). // erst nach dem Test, und React meldet eine Aktualisierung außerhalb von act().
await waitFor(() => expect(jobsApi.listRepositories).toHaveBeenCalled()); await waitFor(() => expect(jobsApi.listRepositories).toHaveBeenCalled());
for (const stepTitle of [ for (const stepTitle of [
@ -67,9 +67,9 @@ describe('Backup-Assistent', () => {
'Repository', 'Repository',
'Aufbewahrung', 'Aufbewahrung',
'Sicherheit', 'Sicherheit',
'Pruefung', 'Prüfung',
'Benachrichtigung', 'Benachrichtigung',
'Uebersicht', 'Übersicht',
'Anlegen', 'Anlegen',
]) { ]) {
expect(screen.getByRole('button', { name: new RegExp(stepTitle) })).toBeInTheDocument(); expect(screen.getByRole('button', { name: new RegExp(stepTitle) })).toBeInTheDocument();
@ -77,8 +77,8 @@ describe('Backup-Assistent', () => {
}); });
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang an // Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang an
// zu zeigen hiesse, ein leeres Formular als fehlerhaft zu markieren. // zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren.
it('haelt bei einem leeren Namen auf und erklaert warum', async () => { it('hält bei einem leeren Namen auf und erklärt warum', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
@ -90,7 +90,7 @@ describe('Backup-Assistent', () => {
expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument(); expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument();
}); });
it('haelt ohne Quelle auf', async () => { it('hält ohne Quelle auf', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
@ -104,17 +104,17 @@ describe('Backup-Assistent', () => {
expect(screen.getByRole('alert')).toHaveTextContent('mindestens eine Quelle'); expect(screen.getByRole('alert')).toHaveTextContent('mindestens eine Quelle');
}); });
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst waere // Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst wäre
// jeder Blick zurueck ein Datenverlust. // jeder Blick zurück ein Datenverlust.
it('bewahrt die Eingaben beim Zurueckgehen', async () => { it('bewahrt die Eingaben beim Zurückgehen', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
await user.type(screen.getByLabelText('Name des Auftrags'), 'Naechtliche Sicherung'); await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche Sicherung');
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
await user.click(screen.getByRole('button', { name: 'Zurueck' })); await user.click(screen.getByRole('button', { name: 'Zurück' }));
expect(screen.getByLabelText('Name des Auftrags')).toHaveValue('Naechtliche Sicherung'); expect(screen.getByLabelText('Name des Auftrags')).toHaveValue('Nächtliche Sicherung');
}); });
it('zeigt eine Vorschau des Zeitplans', async () => { it('zeigt eine Vorschau des Zeitplans', async () => {
@ -126,12 +126,12 @@ describe('Backup-Assistent', () => {
await user.type(screen.getByLabelText('Pfad'), '/daten'); await user.type(screen.getByLabelText('Pfad'), '/daten');
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
expect(screen.getByText(/Ergibt: taeglich um 02:00 Uhr/)).toBeInTheDocument(); expect(screen.getByText(/Ergibt: täglich um 02:00 Uhr/)).toBeInTheDocument();
}); });
// Ein Ziel, das keine Sicherungen annimmt, wird benannt statt stillschweigend // Ein Ziel, das keine Sicherungen annimmt, wird benannt statt stillschweigend
// weggelassen: Sonst suchte der Anwender ein Repository, das er nicht findet. // weggelassen: Sonst suchte der Anwender ein Repository, das er nicht findet.
it('zeigt gesperrte Repositories, laesst sie aber nicht waehlen', async () => { it('zeigt gesperrte Repositories, lässt sie aber nicht wählen', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
@ -148,7 +148,7 @@ describe('Backup-Assistent', () => {
}); });
// Eine Maske, die Werte sammelt, die niemand auswertet, ist ein // Eine Maske, die Werte sammelt, die niemand auswertet, ist ein
// vorgetaeuschtes Funktionsversprechen (PROMPT.md §138). // vorgetäuschtes Funktionsversprechen (PROMPT.md §138).
it('kennzeichnet die nicht umgesetzten Schritte, statt Eingaben zu sammeln', async () => { it('kennzeichnet die nicht umgesetzten Schritte, statt Eingaben zu sammeln', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
@ -160,15 +160,15 @@ describe('Backup-Assistent', () => {
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
// Schritt 5: Aufbewahrung. // Schritt 5: Aufbewahrung.
expect(screen.getByText('Noch nicht verfuegbar')).toBeInTheDocument(); expect(screen.getByText('Noch nicht verfügbar')).toBeInTheDocument();
expect(screen.getByText(/nicht automatisch geloescht/)).toBeInTheDocument(); expect(screen.getByText(/nicht automatisch gelöscht/)).toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
}); });
it('legt den Auftrag an und meldet ihn nach oben', async () => { it('legt den Auftrag an und meldet ihn nach oben', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const createdJob = { id: 'abc', name: 'Naechtliche Sicherung' } as jobsApi.BackupJob; const createdJob = { id: 'abc', name: 'Nächtliche Sicherung' } as jobsApi.BackupJob;
const createSpy = vi.spyOn(jobsApi, 'createJob').mockResolvedValue(createdJob); const createSpy = vi.spyOn(jobsApi, 'createJob').mockResolvedValue(createdJob);
const handleCreated = vi.fn(); const handleCreated = vi.fn();
@ -190,20 +190,20 @@ describe('Backup-Assistent', () => {
await waitFor(() => expect(handleCreated).toHaveBeenCalledWith(createdJob)); await waitFor(() => expect(handleCreated).toHaveBeenCalledWith(createdJob));
const sentRequest = createSpy.mock.calls[0]?.[0]; const sentRequest = createSpy.mock.calls[0]?.[0];
expect(sentRequest?.name).toBe('Naechtliche Sicherung'); expect(sentRequest?.name).toBe('Nächtliche Sicherung');
expect(sentRequest?.sources[0]?.id).toBe('/daten'); expect(sentRequest?.sources[0]?.id).toBe('/daten');
expect(sentRequest?.repository_id).toBe(writableRepository.id); expect(sentRequest?.repository_id).toBe(writableRepository.id);
}); });
// Die Meldung des Servers wird wortgetreu gezeigt: Sie nennt den // Die Meldung des Servers wird wortgetreu gezeigt: Sie nennt den
// eigentlichen Grund, den die Oberflaeche nicht kennen kann. // eigentlichen Grund, den die Oberfläche nicht kennen kann.
it('zeigt eine Fehlermeldung des Servers', async () => { it('zeigt eine Fehlermeldung des Servers', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
vi.spyOn(jobsApi, 'createJob').mockRejectedValue( vi.spyOn(jobsApi, 'createJob').mockRejectedValue(
new ApiError({ new ApiError({
code: 'VALIDATION_FAILED', code: 'VALIDATION_FAILED',
message: 'der zeitplan laeuft nur alle 1 Tage und kann den geforderten wiederherstellungspunkt nicht einhalten', message: 'der zeitplan läuft nur alle 1 Tage und kann den geforderten wiederherstellungspunkt nicht einhalten',
statusCode: 422, statusCode: 422,
requestId: 'req-1', requestId: 'req-1',
}), }),
@ -225,8 +225,8 @@ describe('Backup-Assistent', () => {
expect(screen.getByRole('alert')).toHaveTextContent('wiederherstellungspunkt'), expect(screen.getByRole('alert')).toHaveTextContent('wiederherstellungspunkt'),
); );
// Der Assistent bleibt stehen, damit der Anwender zurueckgehen und den // Der Assistent bleibt stehen, damit der Anwender zurückgehen und den
// Zeitplan aendern kann. // Zeitplan ändern kann.
expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument(); expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument();
}); });
@ -251,15 +251,15 @@ describe('Backup-Assistent', () => {
); );
}); });
it('erlaubt den Sprung zurueck ueber die Schrittleiste, nicht nach vorn', async () => { it('erlaubt den Sprung zurück über die Schrittleiste, nicht nach vorn', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />); render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test'); await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
await user.click(screen.getByRole('button', { name: 'Weiter' })); await user.click(screen.getByRole('button', { name: 'Weiter' }));
// Ein noch nicht erreichter Schritt ist nicht anklickbar: Er wuerde eine // Ein noch nicht erreichter Schritt ist nicht anklickbar: Er würde eine
// Pruefung ueberspringen, die der Assistent gerade fuehren soll. // Prüfung überspringen, die der Assistent gerade führen soll.
expect(screen.getByRole('button', { name: /Repository/ })).toBeDisabled(); expect(screen.getByRole('button', { name: /Repository/ })).toBeDisabled();
await user.click(screen.getByRole('button', { name: /1\s*Name/ })); await user.click(screen.getByRole('button', { name: /1\s*Name/ }));

View File

@ -1,9 +1,9 @@
/** /**
* Backup-Wizard in zehn Schritten (SYNCOVA_IMPLEMENTATION_PLAN.md §10). * Backup-Wizard in zehn Schritten (SYNCOVA_IMPLEMENTATION_PLAN.md §10).
* *
* Der Assistent fuehrt von Name bis Anlegen. Die Pruefung geschieht je Schritt, * Der Assistent führt von Name bis Anlegen. Die Prüfung geschieht je Schritt,
* damit ein Fehler dort auffaellt, wo er entsteht - nicht erst nach dem letzten * damit ein Fehler dort auffällt, wo er entsteht - nicht erst nach dem letzten
* Schritt. Rueckwaerts geht es ohne Datenverlust: Der Entwurf lebt in einem * Schritt. Rückwärts geht es ohne Datenverlust: Der Entwurf lebt in einem
* Zustand, nicht in den Eingabefeldern. * Zustand, nicht in den Eingabefeldern.
*/ */
@ -58,7 +58,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
const stepValidation = validateStep(currentStep, jobDraft); const stepValidation = validateStep(currentStep, jobDraft);
// Die Ziele werden einmal geladen. Ein Abbruchsignal verhindert, dass eine // Die Ziele werden einmal geladen. Ein Abbruchsignal verhindert, dass eine
// Antwort nach dem Schliessen des Assistenten noch Zustand setzt. // Antwort nach dem Schließen des Assistenten noch Zustand setzt.
useEffect(() => { useEffect(() => {
const abortController = new AbortController(); const abortController = new AbortController();
@ -75,17 +75,17 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
return () => abortController.abort(); return () => abortController.abort();
}, []); }, []);
/** Aendert ein Feld des Entwurfs. */ /** Ändert ein Feld des Entwurfs. */
function updateDraft(changedFields: Partial<JobDraft>): void { function updateDraft(changedFields: Partial<JobDraft>): void {
setJobDraft((previousDraft) => ({ ...previousDraft, ...changedFields })); setJobDraft((previousDraft) => ({ ...previousDraft, ...changedFields }));
setShowProblems(false); setShowProblems(false);
} }
/** Geht einen Schritt vor, sofern der aktuelle vollstaendig ist. */ /** Geht einen Schritt vor, sofern der aktuelle vollständig ist. */
function goToNextStep(): void { function goToNextStep(): void {
if (!stepValidation.isComplete) { if (!stepValidation.isComplete) {
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang // Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang
// an zu zeigen hiesse, ein leeres Formular als fehlerhaft zu markieren. // an zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren.
setShowProblems(true); setShowProblems(true);
return; return;
@ -95,7 +95,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
setCurrentStepIndex((previousIndex) => Math.min(previousIndex + 1, WIZARD_STEPS.length - 1)); setCurrentStepIndex((previousIndex) => Math.min(previousIndex + 1, WIZARD_STEPS.length - 1));
} }
/** Geht einen Schritt zurueck. */ /** Geht einen Schritt zurück. */
function goToPreviousStep(): void { function goToPreviousStep(): void {
setShowProblems(false); setShowProblems(false);
setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0)); setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0));
@ -171,7 +171,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<div className="flex gap-2"> <div className="flex gap-2">
{currentStepIndex > 0 && ( {currentStepIndex > 0 && (
<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}> <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 Zurück
</button> </button>
)} )}
@ -195,7 +195,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</section> </section>
); );
/** Waehlt den Inhalt des aktuellen Schrittes. */ /** Wählt den Inhalt des aktuellen Schrittes. */
function renderStepContent(): React.JSX.Element { function renderStepContent(): React.JSX.Element {
const unavailableExplanation = UNAVAILABLE_STEPS[currentStep]; const unavailableExplanation = UNAVAILABLE_STEPS[currentStep];
@ -227,14 +227,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
* Zeigt einen Schritt ohne Umsetzung im Backend. * Zeigt einen Schritt ohne Umsetzung im Backend.
* *
* Es werden keine Eingaben abgefragt. Eine Maske, die Werte sammelt, die * Es werden keine Eingaben abgefragt. Eine Maske, die Werte sammelt, die
* niemand auswertet, ist ein vorgetaeuschtes Funktionsversprechen * niemand auswertet, ist ein vorgetäuschtes Funktionsversprechen
* (PROMPT.md §138) - stattdessen steht hier, was ohne diese Einstellung * (PROMPT.md §138) - stattdessen steht hier, was ohne diese Einstellung
* tatsaechlich geschieht. * tatsächlich geschieht.
*/ */
function renderUnavailableStep(explanationText: string): React.JSX.Element { function renderUnavailableStep(explanationText: string): React.JSX.Element {
return ( return (
<div className="rounded-md border border-line bg-sunken p-4"> <div className="rounded-md border border-line bg-sunken p-4">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">Noch nicht verfuegbar</p> <p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">Noch nicht verfügbar</p>
<p className="mt-1 text-sm text-fg-muted">{explanationText}</p> <p className="mt-1 text-sm text-fg-muted">{explanationText}</p>
</div> </div>
); );
@ -251,7 +251,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
type="text" type="text"
value={jobDraft.name} value={jobDraft.name}
onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })} onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })}
placeholder="Naechtliche Sicherung Dateiserver" placeholder="Nächtliche Sicherung Dateiserver"
autoFocus autoFocus
/> />
</label> </label>
@ -271,7 +271,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 2: Quellen. */ /** Schritt 2: Quellen. */
function renderSourceStep(): React.JSX.Element { function renderSourceStep(): React.JSX.Element {
/** Aendert eine einzelne Quelle. */ /** Ändert eine einzelne Quelle. */
function updateSource(sourceIndex: number, changedFields: Partial<DraftSource>): void { function updateSource(sourceIndex: number, changedFields: Partial<DraftSource>): void {
updateDraft({ updateDraft({
sources: jobDraft.sources.map((existingSource, currentIndex) => sources: jobDraft.sources.map((existingSource, currentIndex) =>
@ -302,7 +302,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
disabled={!SUPPORTED_SOURCE_TYPES.includes(sourceType)} disabled={!SUPPORTED_SOURCE_TYPES.includes(sourceType)}
> >
{SOURCE_TYPE_LABELS[sourceType]} {SOURCE_TYPE_LABELS[sourceType]}
{!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfuegbar)' : ''} {!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfügbar)' : ''}
</option> </option>
))} ))}
</select> </select>
@ -332,7 +332,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</label> </label>
<label className="block space-y-1.5"> <label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Auszuschliessen (kommasepariert, optional)</span> <span className="block text-sm font-medium text-fg">Auszuschließen (kommasepariert, optional)</span>
<input <input
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle" 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"
@ -392,10 +392,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
} }
> >
<option value="manual">Nur auf Anforderung</option> <option value="manual">Nur auf Anforderung</option>
<option value="interval">In festen Abstaenden</option> <option value="interval">In festen Abständen</option>
<option value="hourly">Stuendlich</option> <option value="hourly">Stündlich</option>
<option value="daily">Taeglich</option> <option value="daily">Täglich</option>
<option value="weekly">Woechentlich</option> <option value="weekly">Wöchentlich</option>
<option value="monthly">Monatlich</option> <option value="monthly">Monatlich</option>
<option value="cron">Cron-Ausdruck</option> <option value="cron">Cron-Ausdruck</option>
</select> </select>
@ -476,10 +476,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</label> </label>
))} ))}
</div> </div>
{/* Der Monatsletzte ist kein Luxus: Wer "am 31." waehlt, bekommt in {/* Der Monatsletzte ist kein Luxus: Wer "am 31." wählt, bekommt in
vier Monaten des Jahres keine Sicherung. */} vier Monaten des Jahres keine Sicherung. */}
<p className="text-xs text-fg-muted"> <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 fällt nie aus - anders als etwa der 31.
</p> </p>
</fieldset> </fieldset>
)} )}
@ -529,7 +529,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
if (repositories.length === 0) { if (repositories.length === 0) {
return ( return (
<p className="text-xs text-fg-muted"> <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 Datenträger und
wird mit <code>syncova-repo create --path &lt;pfad&gt; --name &lt;name&gt;</code> angelegt. wird mit <code>syncova-repo create --path &lt;pfad&gt; --name &lt;name&gt;</code> angelegt.
</p> </p>
); );
@ -554,7 +554,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
{/* 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 wählen kann. */}
{!repositoryEntry.accepts_backups && ( {!repositoryEntry.accepts_backups && (
<span className="text-xs text-warning"> <span className="text-xs text-warning">
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
@ -571,12 +571,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
function renderSecurityStep(): React.JSX.Element { function renderSecurityStep(): React.JSX.Element {
return ( return (
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst {/* Verschlüsselung ist keine Wahl: Der Executor verweigert den Dienst
ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere ohne Schlüsselmaterial. Eine Schaltfläche zum Abschalten wäre
eine Einstellung, die es nicht gibt. */} eine Einstellung, die es nicht gibt. */}
<div className="mt-3 rounded-md border border-info/30 bg-info-bg p-3 text-sm"> <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>Verschlüsselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM
verschluesselt, bevor sie das System verlassen. Der Schluessel gehoert zum Repository und verschlüsselt, bevor sie das System verlassen. Der Schlüssel gehört zum Repository und
wird vom Dienst verwaltet. wird vom Dienst verwaltet.
</div> </div>
@ -595,7 +595,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<option value="low">Niedrig</option> <option value="low">Niedrig</option>
</select> </select>
<span className="text-xs text-fg-muted"> <span className="text-xs text-fg-muted">
Entscheidet die Reihenfolge, wenn mehrere Auftraege gleichzeitig faellig sind. Entscheidet die Reihenfolge, wenn mehrere Aufträge gleichzeitig fällig sind.
</span> </span>
</label> </label>
@ -616,7 +616,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
); );
} }
/** Schritt 9: Uebersicht. */ /** Schritt 9: Übersicht. */
function renderReviewStep(): React.JSX.Element { function renderReviewStep(): React.JSX.Element {
const selectedRepository = repositories.find( const selectedRepository = repositories.find(
(repositoryEntry) => repositoryEntry.id === jobDraft.repositoryId, (repositoryEntry) => repositoryEntry.id === jobDraft.repositoryId,
@ -664,12 +664,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<dt>Sicherheit</dt> <dt>Sicherheit</dt>
<dd> <dd>
verschluesselt, Dringlichkeit {jobDraft.priority} verschlüsselt, Dringlichkeit {jobDraft.priority}
{jobDraft.bandwidthLimit.trim() !== '' && `, hoechstens ${jobDraft.bandwidthLimit}`} {jobDraft.bandwidthLimit.trim() !== '' && `, höchstens ${jobDraft.bandwidthLimit}`}
</dd> </dd>
<dt>Pruefung</dt> <dt>Prüfung</dt>
<dd className="text-sm text-fg-muted">keine automatische Pruefung</dd> <dd className="text-sm text-fg-muted">keine automatische Prüfung</dd>
<dt>Benachrichtigung</dt> <dt>Benachrichtigung</dt>
<dd className="text-sm text-fg-muted">keine</dd> <dd className="text-sm text-fg-muted">keine</dd>
@ -682,8 +682,8 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
return ( return (
<div className="mt-4 space-y-4"> <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 läuft ab sofort nach Zeitplan. Der erste Lauf ist eine
vollstaendige Sicherung; jeder weitere liest nur, was sich geaendert hat. vollständige Sicherung; jeder weitere liest nur, was sich geändert hat.
</p> </p>
{submitError !== null && ( {submitError !== null && (
@ -699,8 +699,8 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** /**
* Bildet die Darstellung eines Schrittes in der Kopfzeile. * Bildet die Darstellung eines Schrittes in der Kopfzeile.
* *
* Ein noch nicht erreichter Schritt bleibt gedaempft und ist nicht anklickbar — * Ein noch nicht erreichter Schritt bleibt gedämpft und ist nicht anklickbar —
* er uebersprang eine Pruefung. * er übersprang eine Prüfung.
*/ */
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'; const baseClasses = 'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs';
@ -716,7 +716,7 @@ function buildStepClassName(stepIndex: number, currentStepIndex: number): string
return `${baseClasses} text-fg-subtle`; return `${baseClasses} text-fg-subtle`;
} }
/** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */ /** Wandelt einen Fehler in eine verständliche Meldung (PROMPT.md §124). */
function describeError(caughtError: unknown, fallbackMessage: string): string { function describeError(caughtError: unknown, fallbackMessage: string): string {
if (caughtError instanceof ApiError) { if (caughtError instanceof ApiError) {
return caughtError.message; return caughtError.message;

View File

@ -4,12 +4,12 @@
* Die Laufhistorie ist der Ort, an dem ein Betreiber herausfindet, **warum** * Die Laufhistorie ist der Ort, an dem ein Betreiber herausfindet, **warum**
* etwas nicht stimmt. Deshalb steht bei einem nicht erfolgreichen Lauf nicht * etwas nicht stimmt. Deshalb steht bei einem nicht erfolgreichen Lauf nicht
* nur "fehlgeschlagen", sondern Fehlercode, Fehlerklasse und Meldung — und die * nur "fehlgeschlagen", sondern Fehlercode, Fehlerklasse und Meldung — und die
* Klasse sagt zugleich, ob eine Wiederholung ueberhaupt etwas bringt: Ein * Klasse sagt zugleich, ob eine Wiederholung überhaupt etwas bringt: Ein
* Anmeldefehler behebt sich nicht durch Warten. * Anmeldefehler behebt sich nicht durch Warten.
* *
* Ein Teilfehler bekommt eine eigene Erlaeuterung. "Erfolgreich mit * Ein Teilfehler bekommt eine eigene Erläuterung. "Erfolgreich mit
* uebergangenen Objekten" gibt es nicht — die Datenbank laesst es per CHECK * übergangenen Objekten" gibt es nicht — die Datenbank lässt es per CHECK
* nicht zu, und die Oberflaeche darf es erst recht nicht suggerieren. * nicht zu, und die Oberfläche darf es erst recht nicht suggerieren.
*/ */
import { Ban, Pause, Play, RefreshCw } from 'lucide-react'; import { Ban, Pause, Play, RefreshCw } from 'lucide-react';
@ -84,8 +84,8 @@ export function JobDetailPage({
}, },
onError: (apiError) => onError: (apiError) =>
apiError.statusCode === 409 apiError.statusCode === 409
? toast.showInfo('Dieser Auftrag laeuft bereits', describeApiError(apiError)) ? toast.showInfo('Dieser Auftrag läuft bereits', describeApiError(apiError))
: toast.showError('Der Lauf liess sich nicht anstossen', describeApiError(apiError), apiError.requestId), : toast.showError('Der Lauf ließ sich nicht anstoßen', describeApiError(apiError), apiError.requestId),
}); });
const pauseMutation = useMutation(pauseJob, { const pauseMutation = useMutation(pauseJob, {
@ -108,11 +108,11 @@ export function JobDetailPage({
const cancelRunMutation = useMutation(cancelJobRun, { const cancelRunMutation = useMutation(cancelJobRun, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Abbruch angefordert', 'Der Lauf endet, sobald die laufende Stufe abschliesst.'); toast.showSuccess('Abbruch angefordert', 'Der Lauf endet, sobald die laufende Stufe abschließt.');
reloadEverything(); reloadEverything();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Der Lauf liess sich nicht abbrechen', describeApiError(apiError), apiError.requestId), toast.showError('Der Lauf ließ sich nicht abbrechen', describeApiError(apiError), apiError.requestId),
}); });
if (jobResource.loadState === 'loading') { if (jobResource.loadState === 'loading') {
@ -122,7 +122,7 @@ export function JobDetailPage({
if (jobResource.loadState === 'failed' || !jobResource.data) { if (jobResource.loadState === 'failed' || !jobResource.data) {
return ( return (
<> <>
<PageHeader title="Auftrag" onBack={onBack} backLabel="Zu den Auftraegen" /> <PageHeader title="Auftrag" onBack={onBack} backLabel="Zu den Aufträgen" />
<Card> <Card>
<ErrorState <ErrorState
message={ message={
@ -186,10 +186,10 @@ export function JobDetailPage({
<span> <span>
{run.files_processed?.toLocaleString('de-DE') ?? '—'} {run.files_processed?.toLocaleString('de-DE') ?? '—'}
{run.files_skipped ? ( {run.files_skipped ? (
// Uebergangene Objekte sind der Grund fuer den Teilfehler und // Übergangene Objekte sind der Grund für den Teilfehler und
// gehoeren deshalb in dieselbe Zelle, nicht in eine Fussnote. // gehören deshalb in dieselbe Zelle, nicht in eine Fußnote.
<span className="ml-1 text-warning"> <span className="ml-1 text-warning">
({run.files_skipped.toLocaleString('de-DE')} uebergangen) ({run.files_skipped.toLocaleString('de-DE')} übergangen)
</span> </span>
) : null} ) : null}
</span> </span>
@ -222,7 +222,7 @@ export function JobDetailPage({
title={job.name} title={job.name}
description={job.description || job.schedule_description} description={job.description || job.schedule_description}
onBack={onBack} onBack={onBack}
backLabel="Zu den Auftraegen" backLabel="Zu den Aufträgen"
actions={ actions={
mayRunJobs ? ( mayRunJobs ? (
<> <>
@ -252,7 +252,7 @@ export function JobDetailPage({
{job.status === 'paused' ? ( {job.status === 'paused' ? (
<Callout tone="warning" title="Dieser Auftrag ist angehalten" className="mb-4"> <Callout tone="warning" title="Dieser Auftrag ist angehalten" className="mb-4">
Geplante Laeufe entfallen, bis er fortgesetzt wird. Bereits laufende Geplante Läufe entfallen, bis er fortgesetzt wird. Bereits laufende
Sicherungen sind davon nicht betroffen. Sicherungen sind davon nicht betroffen.
</Callout> </Callout>
) : null} ) : null}
@ -267,7 +267,7 @@ export function JobDetailPage({
</DetailItem> </DetailItem>
<DetailItem label="Dringlichkeit">{job.priority}</DetailItem> <DetailItem label="Dringlichkeit">{job.priority}</DetailItem>
<DetailItem label="Zeitplan">{job.schedule_description}</DetailItem> <DetailItem label="Zeitplan">{job.schedule_description}</DetailItem>
<DetailItem label="Naechster Lauf"> <DetailItem label="Nächster Lauf">
{job.next_run_at ? formatDateTime(job.next_run_at) : 'Nur manuell'} {job.next_run_at ? formatDateTime(job.next_run_at) : 'Nur manuell'}
</DetailItem> </DetailItem>
<DetailItem label="Bandbreitengrenze"> <DetailItem label="Bandbreitengrenze">
@ -317,7 +317,7 @@ export function JobDetailPage({
<Card> <Card>
<CardHeader <CardHeader
title="Laufhistorie" title="Laufhistorie"
description="Die letzten 50 Laeufe." description="Die letzten 50 Läufe."
action={ action={
<Button variant="ghost" size="sm" onClick={runsResource.reload}> <Button variant="ghost" size="sm" onClick={runsResource.reload}>
<RefreshCw /> <RefreshCw />
@ -332,7 +332,7 @@ export function JobDetailPage({
onRowClick={(run) => setSelectedRun(run)} onRowClick={(run) => setSelectedRun(run)}
isLoading={runsResource.loadState === 'loading'} isLoading={runsResource.loadState === 'loading'}
emptyTitle="Noch kein Lauf" emptyTitle="Noch kein Lauf"
emptyDescription="Stossen Sie den ersten Lauf ueber die Schaltflaeche oben an." emptyDescription="Stoßen Sie den ersten Lauf über die Schaltfläche oben an."
/> />
</Card> </Card>
@ -370,9 +370,9 @@ function LastRunSummary({ run }: { readonly run: BackupJobRun }) {
{run.status === 'partial_failure' ? ( {run.status === 'partial_failure' ? (
<Callout tone="warning" title="Teilfehler"> <Callout tone="warning" title="Teilfehler">
{run.files_skipped ?? 0} Objekte wurden uebergangen. Ein Teilfehler {run.files_skipped ?? 0} Objekte wurden übergangen. Ein Teilfehler
gilt <strong>nicht</strong> als Erfolg und wird bewusst nicht gilt <strong>nicht</strong> als Erfolg und wird bewusst nicht
wiederholt — die uebergangenen Objekte waeren beim naechsten Versuch wiederholt — die übergangenen Objekte wären beim nächsten Versuch
dieselben. dieselben.
</Callout> </Callout>
) : null} ) : null}
@ -389,7 +389,7 @@ function LastRunSummary({ run }: { readonly run: BackupJobRun }) {
); );
} }
/** Einzelheiten eines ausgewaehlten Laufs. */ /** Einzelheiten eines ausgewählten Laufs. */
function RunDetailCard({ function RunDetailCard({
run, run,
onClose, onClose,
@ -404,7 +404,7 @@ function RunDetailCard({
description={formatDateTime(run.started_at)} description={formatDateTime(run.started_at)}
action={ action={
<Button variant="ghost" size="sm" onClick={onClose}> <Button variant="ghost" size="sm" onClick={onClose}>
Schliessen Schließen
</Button> </Button>
} }
/> />
@ -424,7 +424,7 @@ function RunDetailCard({
<DetailItem label="Objekte erfasst"> <DetailItem label="Objekte erfasst">
{run.files_processed?.toLocaleString('de-DE') ?? '—'} {run.files_processed?.toLocaleString('de-DE') ?? '—'}
</DetailItem> </DetailItem>
<DetailItem label="Objekte uebergangen"> <DetailItem label="Objekte übergangen">
{run.files_skipped?.toLocaleString('de-DE') ?? '0'} {run.files_skipped?.toLocaleString('de-DE') ?? '0'}
</DetailItem> </DetailItem>
<DetailItem label="Gelesen">{formatBytes(run.bytes_processed)}</DetailItem> <DetailItem label="Gelesen">{formatBytes(run.bytes_processed)}</DetailItem>

View File

@ -1,19 +1,19 @@
/** /**
* Sicherungsauftraege — Liste und Bedienung. * Sicherungsaufträge — Liste und Bedienung.
* *
* Bis hierher zeigte die Oberflaeche Auftraege an, die sich nicht bedienen * Bis hierher zeigte die Oberfläche Aufträge an, die sich nicht bedienen
* liessen. Ein Betreiber, der einen Lauf ausserhalb des Zeitplans anstossen * ließen. Ein Betreiber, der einen Lauf außerhalb des Zeitplans anstoßen
* wollte, brauchte dafuer die Kommandozeile. * wollte, brauchte dafür die Kommandozeile.
* *
* Zwei Entscheidungen tragen diese Seite: * Zwei Entscheidungen tragen diese Seite:
* *
* - **Ein zweiter Anstoss ist kein Fehler, sondern eine Auskunft.** Der Server * - **Ein zweiter Anstoß ist kein Fehler, sondern eine Auskunft.** Der Server
* antwortet mit 409, wenn der Auftrag bereits laeuft. Das als Fehler zu * antwortet mit 409, wenn der Auftrag bereits läuft. Das als Fehler zu
* melden waere technisch richtig und praktisch irrefuehrend — der Auftrag * melden wäre technisch richtig und praktisch irreführend — der Auftrag
* laeuft ja, und genau das wollte der Betreiber. * läuft ja, und genau das wollte der Betreiber.
* - **Loeschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben * - **Löschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben
* bestehen; sie gehoeren zum Repository, nicht zum Auftrag. Wer das nicht * bestehen; sie gehören zum Repository, nicht zum Auftrag. Wer das nicht
* weiss, loescht einen Auftrag in der Annahme, damit Platz zu schaffen. * weiß, löscht einen Auftrag in der Annahme, damit Platz zu schaffen.
*/ */
import { import {
@ -76,30 +76,30 @@ export function JobsPage({
const runMutation = useMutation(runJob, { const runMutation = useMutation(runJob, {
onSuccess: () => { onSuccess: () => {
// 202: eingereiht, nicht "gesichert". Die Meldung sagt genau das — // 202: eingereiht, nicht "gesichert". Die Meldung sagt genau das —
// "Sicherung erfolgreich" waere hier schlicht gelogen. // "Sicherung erfolgreich" wäre hier schlicht gelogen.
toast.showSuccess('Lauf eingereiht', 'Der Fortschritt erscheint in der Laufhistorie.'); toast.showSuccess('Lauf eingereiht', 'Der Fortschritt erscheint in der Laufhistorie.');
jobsResource.reload(); jobsResource.reload();
}, },
onError: (apiError) => { onError: (apiError) => {
if (apiError.statusCode === 409) { if (apiError.statusCode === 409) {
// Der Auftrag laeuft bereits — das ist die Antwort auf die Frage, // Der Auftrag läuft bereits — das ist die Antwort auf die Frage,
// nicht ein Fehler des Betreibers. // nicht ein Fehler des Betreibers.
toast.showInfo('Dieser Auftrag laeuft bereits', describeApiError(apiError)); toast.showInfo('Dieser Auftrag läuft bereits', describeApiError(apiError));
return; return;
} }
toast.showError('Der Lauf liess sich nicht anstossen', describeApiError(apiError), apiError.requestId); toast.showError('Der Lauf ließ sich nicht anstoßen', describeApiError(apiError), apiError.requestId);
}, },
}); });
const pauseMutation = useMutation(pauseJob, { const pauseMutation = useMutation(pauseJob, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Auftrag angehalten', 'Geplante Laeufe entfallen, bis er fortgesetzt wird.'); toast.showSuccess('Auftrag angehalten', 'Geplante Läufe entfallen, bis er fortgesetzt wird.');
jobsResource.reload(); jobsResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Der Auftrag liess sich nicht anhalten', describeApiError(apiError), apiError.requestId), toast.showError('Der Auftrag ließ sich nicht anhalten', describeApiError(apiError), apiError.requestId),
}); });
const resumeMutation = useMutation(resumeJob, { const resumeMutation = useMutation(resumeJob, {
@ -108,20 +108,20 @@ export function JobsPage({
jobsResource.reload(); jobsResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Der Auftrag liess sich nicht fortsetzen', describeApiError(apiError), apiError.requestId), toast.showError('Der Auftrag ließ sich nicht fortsetzen', describeApiError(apiError), apiError.requestId),
}); });
const deleteMutation = useMutation(deleteJob, { const deleteMutation = useMutation(deleteJob, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess( toast.showSuccess(
'Auftrag geloescht', 'Auftrag gelöscht',
'Die vorhandenen Wiederherstellungspunkte bleiben bestehen.', 'Die vorhandenen Wiederherstellungspunkte bleiben bestehen.',
); );
setJobPendingDeletion(null); setJobPendingDeletion(null);
jobsResource.reload(); jobsResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Der Auftrag liess sich nicht loeschen', describeApiError(apiError), apiError.requestId), toast.showError('Der Auftrag ließ sich nicht löschen', describeApiError(apiError), apiError.requestId),
}); });
const jobs = jobsResource.data ?? []; const jobs = jobsResource.data ?? [];
@ -154,14 +154,14 @@ export function JobsPage({
</span> </span>
</div> </div>
) : ( ) : (
// Kein Lauf ist nicht "erfolgreich". Ohne diesen Unterschied saehe ein // Kein Lauf ist nicht "erfolgreich". Ohne diesen Unterschied sähe ein
// nie gelaufener Auftrag aus wie ein funktionierender. // nie gelaufener Auftrag aus wie ein funktionierender.
<span className="text-xs text-fg-muted">Noch nie gelaufen</span> <span className="text-xs text-fg-muted">Noch nie gelaufen</span>
), ),
}, },
{ {
key: 'next_run_at', key: 'next_run_at',
header: 'Naechster Lauf', header: 'Nächster Lauf',
render: (job) => render: (job) =>
job.next_run_at ? ( job.next_run_at ? (
<span className="text-sm text-fg-muted">{formatRelativeTime(job.next_run_at)}</span> <span className="text-sm text-fg-muted">{formatRelativeTime(job.next_run_at)}</span>
@ -184,7 +184,7 @@ export function JobsPage({
render: (job) => ( render: (job) => (
<div <div
className="flex items-center justify-end gap-1" className="flex items-center justify-end gap-1"
// Ohne das oeffnete jeder Klick auf eine Schaltflaeche zusaetzlich die // Ohne das öffnete jeder Klick auf eine Schaltfläche zusätzlich die
// Detailseite der Zeile. // Detailseite der Zeile.
onClick={(clickEvent) => clickEvent.stopPropagation()} onClick={(clickEvent) => clickEvent.stopPropagation()}
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()} onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
@ -225,8 +225,8 @@ export function JobsPage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
title="Loeschen" title="Löschen"
aria-label={`Auftrag ${job.name} loeschen`} aria-label={`Auftrag ${job.name} löschen`}
onClick={() => setJobPendingDeletion(job)} onClick={() => setJobPendingDeletion(job)}
> >
<Trash2 /> <Trash2 />
@ -249,7 +249,7 @@ export function JobsPage({
if (jobsResource.loadState === 'failed' && jobsResource.loadError) { if (jobsResource.loadState === 'failed' && jobsResource.loadError) {
return ( return (
<> <>
<PageHeader title="Sicherungsauftraege" /> <PageHeader title="Sicherungsaufträge" />
<Card> <Card>
<ErrorState <ErrorState
message={describeApiError(jobsResource.loadError)} message={describeApiError(jobsResource.loadError)}
@ -268,7 +268,7 @@ export function JobsPage({
return ( return (
<> <>
<PageHeader <PageHeader
title="Sicherungsauftraege" title="Sicherungsaufträge"
description="Was wann wohin gesichert wird." description="Was wann wohin gesichert wird."
actions={ actions={
mayWriteJobs ? ( mayWriteJobs ? (
@ -280,13 +280,11 @@ export function JobsPage({
} }
/> />
{/* Ohne beschreibbares Ziel ist kein Auftrag moeglich. Das erst beim {/* Ohne beschreibbares Ziel ist kein Auftrag möglich. Das erst beim
Absenden des Assistenten zu melden waere die unfreundlichste Stelle. */} Absenden des Assistenten zu melden wäre die unfreundlichste Stelle. */}
{repositoriesResource.loadState === 'loaded' && writableRepositories.length === 0 ? ( {repositoriesResource.loadState === 'loaded' && writableRepositories.length === 0 ? (
<Callout tone="warning" title="Kein beschreibbares Repository" className="mb-4"> <Callout tone="warning" title="Kein beschreibbares Repository" className="mb-4">
Ein Auftrag braucht ein Ziel, das Sicherungen annimmt. Legen Sie Kein Ziel nimmt Sicherungen an. Prüfen Sie die Repositories.
zuerst unter „Repositories" eines an oder pruefen Sie den Zustand der
vorhandenen.
</Callout> </Callout>
) : null} ) : null}
@ -298,7 +296,7 @@ export function JobsPage({
onRowClick={(job) => onOpenJob(job.id)} onRowClick={(job) => onOpenJob(job.id)}
isLoading={jobsResource.loadState === 'loading'} isLoading={jobsResource.loadState === 'loading'}
emptyTitle="Noch kein Sicherungsauftrag" emptyTitle="Noch kein Sicherungsauftrag"
emptyDescription="Ohne Auftrag wird nichts gesichert — auch nicht im Hintergrund." emptyDescription="Ohne Auftrag wird nichts gesichert."
emptyAction={ emptyAction={
mayWriteJobs ? ( mayWriteJobs ? (
<Button variant="primary" onClick={() => setIsWizardOpen(true)}> <Button variant="primary" onClick={() => setIsWizardOpen(true)}>
@ -328,15 +326,15 @@ export function JobsPage({
setJobPendingDeletion(null); setJobPendingDeletion(null);
} }
}} }}
title="Auftrag loeschen" title="Auftrag löschen"
description={`„${jobPendingDeletion?.name ?? ''}" wird entfernt.`} description={`„${jobPendingDeletion?.name ?? ''}" wird entfernt.`}
confirmLabel="Loeschen" confirmLabel="Löschen"
isDestructive isDestructive
isLoading={deleteMutation.isRunning} isLoading={deleteMutation.isRunning}
confirmationWord={jobPendingDeletion?.name} confirmationWord={jobPendingDeletion?.name}
confirmationHint={ confirmationHint={
<> <>
Tippen Sie zur Bestaetigung den Namen des Auftrags:{' '} Tippen Sie zur Bestätigung den Namen des Auftrags:{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs"> <code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{jobPendingDeletion?.name} {jobPendingDeletion?.name}
</code> </code>
@ -349,9 +347,7 @@ export function JobsPage({
}} }}
> >
<Callout tone="info"> <Callout tone="info">
Die bereits erzeugten Wiederherstellungspunkte <strong>bleiben Die Wiederherstellungspunkte bleiben bestehen — es wird kein Speicher frei.
bestehen</strong>. Sie gehoeren zum Repository, nicht zum Auftrag —
durch das Loeschen wird kein Speicher frei.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>

View File

@ -1,10 +1,10 @@
/** /**
* API-Anbindung der Sicherungsauftraege (SYNCOVA_API.md §9). * API-Anbindung der Sicherungsaufträge (SYNCOVA_API.md §9).
* *
* Das Modul kennt nur den Vertrag nach aussen. Die Gestalt der Anfrage folgt * Das Modul kennt nur den Vertrag nach außen. Die Gestalt der Anfrage folgt
* dem Backend und nicht der Oberflaeche: Der Wizard fuehrt seinen eigenen * dem Backend und nicht der Oberfläche: Der Wizard führt seinen eigenen
* Entwurf und uebersetzt ihn erst beim Anlegen. Andernfalls muesste jede * Entwurf und übersetzt ihn erst beim Anlegen. Andernfalls müsste jede
* Aenderung an der API sofort die Maske umbauen. * Änderung an der API sofort die Maske umbauen.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
@ -56,9 +56,9 @@ export interface SourceDescriptor {
id: string; id: string;
/** Sprechende Bezeichnung. */ /** Sprechende Bezeichnung. */
name?: string; name?: string;
/** Einzuschliessende Muster. */ /** Einzuschließende Muster. */
include_patterns?: string[]; include_patterns?: string[];
/** Auszuschliessende Muster. */ /** Auszuschließende Muster. */
exclude_patterns?: string[]; exclude_patterns?: string[];
} }
@ -66,7 +66,7 @@ export interface SourceDescriptor {
export interface CreateJobRequest { export interface CreateJobRequest {
/** Eindeutige Bezeichnung. */ /** Eindeutige Bezeichnung. */
name: string; name: string;
/** Erlaeuterung des Zwecks. */ /** Erläuterung des Zwecks. */
description?: string; description?: string;
/** Dringlichkeit. */ /** Dringlichkeit. */
priority?: JobPriority; priority?: JobPriority;
@ -76,9 +76,9 @@ export interface CreateJobRequest {
sources: SourceDescriptor[]; sources: SourceDescriptor[];
/** Ziel-Repository. */ /** Ziel-Repository. */
repository_id: string; repository_id: string;
/** Zulaessiger Datenverlust in Sekunden. */ /** Zulässiger Datenverlust in Sekunden. */
rpo_seconds?: number; rpo_seconds?: number;
/** Zulaessige Wiederherstellungsdauer in Sekunden. */ /** Zulässige Wiederherstellungsdauer in Sekunden. */
rto_seconds?: number; rto_seconds?: number;
/** Bandbreitengrenze in Byte je Sekunde. */ /** Bandbreitengrenze in Byte je Sekunde. */
bandwidth_limit_bps?: number; bandwidth_limit_bps?: number;
@ -86,11 +86,11 @@ export interface CreateJobRequest {
/** Auftrag in der Antwort der API. */ /** Auftrag in der Antwort der API. */
export interface BackupJob { export interface BackupJob {
/** Oeffentlicher Bezeichner. */ /** Öffentlicher Bezeichner. */
id: string; id: string;
/** Bezeichnung. */ /** Bezeichnung. */
name: string; name: string;
/** Erlaeuterung. */ /** Erläuterung. */
description?: string; description?: string;
/** Zustand. */ /** Zustand. */
status: string; status: string;
@ -98,13 +98,13 @@ export interface BackupJob {
priority: JobPriority; priority: JobPriority;
/** Zeitplan. */ /** Zeitplan. */
schedule: ScheduleDescriptor; schedule: ScheduleDescriptor;
/** Erklaerung des Zeitplans in einem Satz. */ /** Erklärung des Zeitplans in einem Satz. */
schedule_description: string; schedule_description: string;
/** Quellen. */ /** Quellen. */
sources: SourceDescriptor[]; sources: SourceDescriptor[];
/** Ziel-Repository. */ /** Ziel-Repository. */
repository_id: string; repository_id: string;
/** Naechster Zeitpunkt in UTC. */ /** Nächster Zeitpunkt in UTC. */
next_run_at?: string; next_run_at?: string;
/** Beginn des letzten Laufs in UTC. */ /** Beginn des letzten Laufs in UTC. */
last_run_at?: string; last_run_at?: string;
@ -116,7 +116,7 @@ export interface BackupJob {
/** Sicherungsziel in der Antwort der API. */ /** Sicherungsziel in der Antwort der API. */
export interface BackupRepository { export interface BackupRepository {
/** Oeffentlicher Bezeichner. */ /** Öffentlicher Bezeichner. */
id: string; id: string;
/** Sprechende Bezeichnung. */ /** Sprechende Bezeichnung. */
name: string; name: string;
@ -129,26 +129,26 @@ export interface BackupRepository {
/** /**
* Meldet, ob dieses Ziel Sicherungen annimmt. * Meldet, ob dieses Ziel Sicherungen annimmt.
* *
* Die Auskunft kommt vom Server. Die Oberflaeche muesste sonst wissen, welche * Die Auskunft kommt vom Server. Die Oberfläche müsste sonst wissen, welche
* Zustaende schreibend sind - eine Regel, die dort nicht hingehoert. * Zustände schreibend sind - eine Regel, die dort nicht hingehört.
*/ */
accepts_backups: boolean; accepts_backups: boolean;
/** Meldet den gehaerteten Modus. */ /** Meldet den gehärteten Modus. */
hardened: boolean; hardened: boolean;
} }
/** /**
* Laedt die bekannten Sicherungsziele. * Lädt die bekannten Sicherungsziele.
* *
* Das Abbruchsignal wird nur gesetzt, wenn es vorliegt: Bei * Das Abbruchsignal wird nur gesetzt, wenn es vorliegt: Bei
* exactOptionalPropertyTypes ist ein ausdrueckliches undefined etwas anderes * exactOptionalPropertyTypes ist ein ausdrückliches undefined etwas anderes
* als ein fehlendes Feld. * als ein fehlendes Feld.
*/ */
export async function listRepositories(abortSignal?: AbortSignal): Promise<BackupRepository[]> { export async function listRepositories(abortSignal?: AbortSignal): Promise<BackupRepository[]> {
return requestApi<BackupRepository[]>('/repositories', abortSignal ? { signal: abortSignal } : {}); return requestApi<BackupRepository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
} }
/** Laedt die vorhandenen Sicherungsauftraege. */ /** Lädt die vorhandenen Sicherungsaufträge. */
export async function listJobs(abortSignal?: AbortSignal): Promise<BackupJob[]> { export async function listJobs(abortSignal?: AbortSignal): Promise<BackupJob[]> {
return requestApi<BackupJob[]>('/jobs?page_size=100', abortSignal ? { signal: abortSignal } : {}); return requestApi<BackupJob[]>('/jobs?page_size=100', abortSignal ? { signal: abortSignal } : {});
} }
@ -159,18 +159,18 @@ export async function createJob(jobRequest: CreateJobRequest): Promise<BackupJob
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Bedienung der Auftraege // Bedienung der Aufträge
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// //
// Bis hierher konnte die Oberflaeche einen Auftrag anlegen und ansehen — mehr // Bis hierher konnte die Oberfläche einen Auftrag anlegen und ansehen — mehr
// nicht. Eine Liste, deren Eintraege sich nicht bedienen lassen, ist ein // nicht. Eine Liste, deren Einträge sich nicht bedienen lassen, ist ein
// Bericht, keine Konsole. // Bericht, keine Konsole.
/** Ein einzelner Lauf eines Auftrags. */ /** Ein einzelner Lauf eines Auftrags. */
export interface BackupJobRun { export interface BackupJobRun {
/** Oeffentlicher Bezeichner. */ /** Öffentlicher Bezeichner. */
id: string; id: string;
/** Zugehoeriger Auftrag. */ /** Zugehöriger Auftrag. */
job_id: string; job_id: string;
/** Zustand: queued, running, succeeded, partial_failure, failed, cancelled. */ /** Zustand: queued, running, succeeded, partial_failure, failed, cancelled. */
status: string; status: string;
@ -180,15 +180,15 @@ export interface BackupJobRun {
completed_at?: string; completed_at?: string;
/** Gelesene Bytes. */ /** Gelesene Bytes. */
bytes_processed?: number; bytes_processed?: number;
/** Tatsaechlich abgelegte Bytes nach Deduplizierung. */ /** Tatsächlich abgelegte Bytes nach Deduplizierung. */
bytes_written?: number; bytes_written?: number;
/** Anzahl erfasster Dateien. */ /** Anzahl erfasster Dateien. */
files_processed?: number; files_processed?: number;
/** /**
* Uebergangene Objekte. * Übergangene Objekte.
* *
* Groesser als null bedeutet Teilfehler — die Datenbank laesst * Größer als null bedeutet Teilfehler — die Datenbank lässt
* "erfolgreich mit uebergangenen Objekten" per CHECK gar nicht zu. * "erfolgreich mit übergangenen Objekten" per CHECK gar nicht zu.
*/ */
files_skipped?: number; files_skipped?: number;
/** Durchsatz in Byte je Sekunde. */ /** Durchsatz in Byte je Sekunde. */
@ -201,7 +201,7 @@ export interface BackupJobRun {
error_class?: string; error_class?: string;
} }
/** Laedt einen einzelnen Auftrag. */ /** Lädt einen einzelnen Auftrag. */
export async function getJob(jobIdentifier: string, abortSignal?: AbortSignal): Promise<BackupJob> { export async function getJob(jobIdentifier: string, abortSignal?: AbortSignal): Promise<BackupJob> {
return requestApi<BackupJob>( return requestApi<BackupJob>(
`/jobs/${encodeURIComponent(jobIdentifier)}`, `/jobs/${encodeURIComponent(jobIdentifier)}`,
@ -209,7 +209,7 @@ export async function getJob(jobIdentifier: string, abortSignal?: AbortSignal):
); );
} }
/** Laedt die Laufhistorie eines Auftrags. */ /** Lädt die Laufhistorie eines Auftrags. */
export async function listJobRuns( export async function listJobRuns(
jobIdentifier: string, jobIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -221,11 +221,11 @@ export async function listJobRuns(
} }
/** /**
* Stoesst einen Lauf an. * Stößt einen Lauf an.
* *
* Die Antwort ist 202, nicht 201: Der Lauf ist eingereiht, die Sicherung hat * Die Antwort ist 202, nicht 201: Der Lauf ist eingereiht, die Sicherung hat
* nicht begonnen. Ein zweiter Anstoss bei laufendem Auftrag ergibt 409 — das * nicht begonnen. Ein zweiter Anstoß bei laufendem Auftrag ergibt 409 — das
* ist eine Auskunft, kein Fehler, und wird in der Oberflaeche als solche * ist eine Auskunft, kein Fehler, und wird in der Oberfläche als solche
* gezeigt. * gezeigt.
*/ */
export async function runJob(jobIdentifier: string): Promise<{ run_id?: string }> { export async function runJob(jobIdentifier: string): Promise<{ run_id?: string }> {
@ -235,7 +235,7 @@ export async function runJob(jobIdentifier: string): Promise<{ run_id?: string }
}); });
} }
/** Haelt einen Auftrag an. Laufende Sicherungen bleiben unberuehrt. */ /** Hält einen Auftrag an. Laufende Sicherungen bleiben unberührt. */
export async function pauseJob(jobIdentifier: string): Promise<BackupJob> { export async function pauseJob(jobIdentifier: string): Promise<BackupJob> {
return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/pause`, { return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/pause`, {
method: 'POST', method: 'POST',
@ -250,11 +250,11 @@ export async function resumeJob(jobIdentifier: string): Promise<BackupJob> {
} }
/** /**
* Loescht einen Auftrag. * Löscht einen Auftrag.
* *
* Die bereits erzeugten Wiederherstellungspunkte bleiben bestehen — sie * Die bereits erzeugten Wiederherstellungspunkte bleiben bestehen — sie
* gehoeren zum Repository, nicht zum Auftrag. Das muss die Oberflaeche sagen, * gehören zum Repository, nicht zum Auftrag. Das muss die Oberfläche sagen,
* sonst loescht jemand einen Auftrag in der Annahme, damit Platz zu schaffen. * sonst löscht jemand einen Auftrag in der Annahme, damit Platz zu schaffen.
*/ */
export async function deleteJob(jobIdentifier: string): Promise<void> { export async function deleteJob(jobIdentifier: string): Promise<void> {
return requestApi<void>(`/jobs/${encodeURIComponent(jobIdentifier)}`, { return requestApi<void>(`/jobs/${encodeURIComponent(jobIdentifier)}`, {

View File

@ -1,7 +1,7 @@
/** /**
* Tests des Wizard-Modells. * Tests des Wizard-Modells.
* *
* Geprueft wird die Logik ohne gerenderte Maske: welcher Schritt vollstaendig * Geprüft wird die Logik ohne gerenderte Maske: welcher Schritt vollständig
* ist, was in die Anfrage wandert und wie Bandbreitenangaben gedeutet werden. * ist, was in die Anfrage wandert und wie Bandbreitenangaben gedeutet werden.
*/ */
@ -19,11 +19,11 @@ import {
} from './wizardModel'; } from './wizardModel';
import type { JobDraft } from './wizardModel'; import type { JobDraft } from './wizardModel';
/** Liefert einen vollstaendig ausgefuellten Entwurf. */ /** Liefert einen vollständig ausgefüllten Entwurf. */
function buildCompleteDraft(): JobDraft { function buildCompleteDraft(): JobDraft {
return { return {
...createEmptyDraft(), ...createEmptyDraft(),
name: 'Naechtliche Sicherung', name: 'Nächtliche Sicherung',
sources: [ sources: [
{ sourceType: 'filesystem', sourceIdentifier: '/daten', sourceName: 'Dateiserver', excludePatterns: '*.tmp' }, { sourceType: 'filesystem', sourceIdentifier: '/daten', sourceName: 'Dateiserver', excludePatterns: '*.tmp' },
], ],
@ -43,8 +43,8 @@ describe('Schrittfolge', () => {
}); });
}); });
describe('Pruefung der Schritte', () => { describe('Prüfung der Schritte', () => {
it('nimmt einen vollstaendigen Entwurf an', () => { it('nimmt einen vollständigen Entwurf an', () => {
expect(findFirstIncompleteStep(buildCompleteDraft())).toBeNull(); expect(findFirstIncompleteStep(buildCompleteDraft())).toBeNull();
}); });
@ -56,7 +56,7 @@ describe('Pruefung der Schritte', () => {
}); });
// Ein Auftrag ohne Quelle liefe erfolgreich durch, ohne etwas zu sichern - // Ein Auftrag ohne Quelle liefe erfolgreich durch, ohne etwas zu sichern -
// die gefaehrlichste Fehlkonfiguration, weil sie wie ein Erfolg aussieht. // die gefährlichste Fehlkonfiguration, weil sie wie ein Erfolg aussieht.
it('verlangt mindestens eine Quelle', () => { it('verlangt mindestens eine Quelle', () => {
const sourcelessDraft = { const sourcelessDraft = {
...buildCompleteDraft(), ...buildCompleteDraft(),
@ -108,13 +108,13 @@ describe('Pruefung der Schritte', () => {
expect(validateStep('schedule', weeklyDraft).isComplete).toBe(true); expect(validateStep('schedule', weeklyDraft).isComplete).toBe(true);
}); });
it('weist eine unmoegliche Uhrzeit ab', () => { it('weist eine unmögliche Uhrzeit ab', () => {
const brokenTimeDraft: JobDraft = { ...buildCompleteDraft(), scheduleTime: '25:00' }; const brokenTimeDraft: JobDraft = { ...buildCompleteDraft(), scheduleTime: '25:00' };
expect(validateStep('schedule', brokenTimeDraft).isComplete).toBe(false); expect(validateStep('schedule', brokenTimeDraft).isComplete).toBe(false);
}); });
it('verlangt fuenf Felder in einem Cron-Ausdruck', () => { it('verlangt fünf Felder in einem Cron-Ausdruck', () => {
const cronDraft: JobDraft = { const cronDraft: JobDraft = {
...buildCompleteDraft(), ...buildCompleteDraft(),
scheduleType: 'cron', scheduleType: 'cron',
@ -128,9 +128,9 @@ describe('Pruefung der Schritte', () => {
expect(validateStep('schedule', cronDraft).isComplete).toBe(true); expect(validateStep('schedule', cronDraft).isComplete).toBe(true);
}); });
// Aufbewahrung, Pruefung und Benachrichtigung fragen nichts ab und duerfen // Aufbewahrung, Prüfung und Benachrichtigung fragen nichts ab und dürfen
// den Anwender deshalb auch nicht aufhalten. // den Anwender deshalb auch nicht aufhalten.
it('haelt bei den nicht umgesetzten Schritten nicht auf', () => { it('hält bei den nicht umgesetzten Schritten nicht auf', () => {
const emptyDraft = createEmptyDraft(); const emptyDraft = createEmptyDraft();
for (const wizardStep of ['retention', 'verification', 'notifications'] as const) { for (const wizardStep of ['retention', 'verification', 'notifications'] as const) {
@ -142,7 +142,7 @@ describe('Pruefung der Schritte', () => {
describe('Bandbreitenangabe', () => { describe('Bandbreitenangabe', () => {
// 100Mbit ist ein Achtel von 100MB. Wer das verwechselt, vergibt das // 100Mbit ist ein Achtel von 100MB. Wer das verwechselt, vergibt das
// Achtfache der beabsichtigten Rate. // Achtfache der beabsichtigten Rate.
it('haelt Bit und Byte auseinander', () => { it('hält Bit und Byte auseinander', () => {
const bitValue = parseBandwidthToBytesPerSecond('100Mbit'); const bitValue = parseBandwidthToBytesPerSecond('100Mbit');
const byteValue = parseBandwidthToBytesPerSecond('100MB'); const byteValue = parseBandwidthToBytesPerSecond('100MB');
@ -151,7 +151,7 @@ describe('Bandbreitenangabe', () => {
expect(bitValue).toBeLessThan(byteValue); expect(bitValue).toBeLessThan(byteValue);
}); });
it('nimmt die gebraeuchlichen Schreibweisen an', () => { it('nimmt die gebräuchlichen Schreibweisen an', () => {
expect(parseBandwidthToBytesPerSecond('50MB')).toBe(52_428_800); expect(parseBandwidthToBytesPerSecond('50MB')).toBe(52_428_800);
expect(parseBandwidthToBytesPerSecond('50 MB/s')).toBe(52_428_800); expect(parseBandwidthToBytesPerSecond('50 MB/s')).toBe(52_428_800);
expect(parseBandwidthToBytesPerSecond('1,5MB')).toBe(1_572_864); expect(parseBandwidthToBytesPerSecond('1,5MB')).toBe(1_572_864);
@ -170,10 +170,10 @@ describe('Bandbreitenangabe', () => {
}); });
describe('Anfrage an die API', () => { describe('Anfrage an die API', () => {
it('uebertraegt Name, Quelle, Zeitplan und Ziel', () => { it('überträgt Name, Quelle, Zeitplan und Ziel', () => {
const jobRequest = buildCreateRequest(buildCompleteDraft()); const jobRequest = buildCreateRequest(buildCompleteDraft());
expect(jobRequest.name).toBe('Naechtliche Sicherung'); expect(jobRequest.name).toBe('Nächtliche Sicherung');
expect(jobRequest.repository_id).toBe('11111111-1111-1111-1111-111111111111'); expect(jobRequest.repository_id).toBe('11111111-1111-1111-1111-111111111111');
expect(jobRequest.schedule.type).toBe('daily'); expect(jobRequest.schedule.type).toBe('daily');
expect(jobRequest.schedule.time).toBe('02:00'); expect(jobRequest.schedule.time).toBe('02:00');
@ -184,15 +184,15 @@ describe('Anfrage an die API', () => {
// Ohne Zeitzone rechnet der Server in UTC - derselbe Auftrag liefe dann je // Ohne Zeitzone rechnet der Server in UTC - derselbe Auftrag liefe dann je
// nach Standort zu einer anderen Uhrzeit. // nach Standort zu einer anderen Uhrzeit.
it('sendet die Zeitzone bei Zeitplaenen mit Uhrzeit', () => { it('sendet die Zeitzone bei Zeitplänen mit Uhrzeit', () => {
const jobRequest = buildCreateRequest(buildCompleteDraft()); const jobRequest = buildCreateRequest(buildCompleteDraft());
expect(jobRequest.schedule.time_zone).toBe('Europe/Berlin'); expect(jobRequest.schedule.time_zone).toBe('Europe/Berlin');
}); });
// Ein Intervallplan zaehlt Abstaende, keine Uhrzeiten. Eine Zeitzone waere // Ein Intervallplan zählt Abstände, keine Uhrzeiten. Eine Zeitzone wäre
// dort bedeutungslos. // dort bedeutungslos.
it('laesst die Zeitzone bei einem Intervallplan weg', () => { it('lässt die Zeitzone bei einem Intervallplan weg', () => {
const intervalDraft: JobDraft = { const intervalDraft: JobDraft = {
...buildCompleteDraft(), ...buildCompleteDraft(),
scheduleType: 'interval', scheduleType: 'interval',
@ -205,9 +205,9 @@ describe('Anfrage an die API', () => {
expect(jobRequest.schedule.interval_seconds).toBe(21_600); expect(jobRequest.schedule.interval_seconds).toBe(21_600);
}); });
// Ein leeres Feld mitzusenden ueberschriebe auf dem Server einen sinnvollen // Ein leeres Feld mitzusenden überschriebe auf dem Server einen sinnvollen
// Standard mit einem Leerwert. // Standard mit einem Leerwert.
it('laesst leere Felder weg', () => { it('lässt leere Felder weg', () => {
const sparseDraft: JobDraft = { ...buildCompleteDraft(), description: ' ', bandwidthLimit: '' }; const sparseDraft: JobDraft = { ...buildCompleteDraft(), description: ' ', bandwidthLimit: '' };
const jobRequest = buildCreateRequest(sparseDraft); const jobRequest = buildCreateRequest(sparseDraft);
@ -216,13 +216,13 @@ describe('Anfrage an die API', () => {
expect(jobRequest.bandwidth_limit_bps).toBeUndefined(); expect(jobRequest.bandwidth_limit_bps).toBeUndefined();
}); });
it('uebertraegt die Bandbreitengrenze in Byte je Sekunde', () => { it('überträgt die Bandbreitengrenze in Byte je Sekunde', () => {
const limitedDraft: JobDraft = { ...buildCompleteDraft(), bandwidthLimit: '50MB' }; const limitedDraft: JobDraft = { ...buildCompleteDraft(), bandwidthLimit: '50MB' };
expect(buildCreateRequest(limitedDraft).bandwidth_limit_bps).toBe(52_428_800); expect(buildCreateRequest(limitedDraft).bandwidth_limit_bps).toBe(52_428_800);
}); });
it('uebergeht leere Quellzeilen', () => { it('übergeht leere Quellzeilen', () => {
const mixedDraft: JobDraft = { const mixedDraft: JobDraft = {
...buildCompleteDraft(), ...buildCompleteDraft(),
sources: [ sources: [
@ -246,10 +246,10 @@ describe('Anfrage an die API', () => {
}); });
describe('Beschreibung des Zeitplans', () => { describe('Beschreibung des Zeitplans', () => {
it('beschreibt die Zeitplaene verstaendlich', () => { it('beschreibt die Zeitpläne verständlich', () => {
const dailyDraft = buildCompleteDraft(); const dailyDraft = buildCompleteDraft();
expect(describeDraftSchedule(dailyDraft)).toBe('taeglich um 02:00 Uhr (Europe/Berlin)'); expect(describeDraftSchedule(dailyDraft)).toBe('täglich um 02:00 Uhr (Europe/Berlin)');
expect(describeDraftSchedule({ ...dailyDraft, scheduleType: 'manual' })).toBe('nur auf Anforderung'); expect(describeDraftSchedule({ ...dailyDraft, scheduleType: 'manual' })).toBe('nur auf Anforderung');

View File

@ -1,9 +1,9 @@
/** /**
* Tests des Liniendiagramms. * Tests des Liniendiagramms.
* *
* Sie pruefen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben * Sie prüfen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben
* wurde: **Eine Luecke wird nicht ueberbrueckt und nicht auf null gezogen.** * wurde: **Eine Lücke wird nicht überbrückt und nicht auf null gezogen.**
* Beides ergaebe eine Kurve, die etwas anderes behauptet als die Daten hergeben. * Beides ergäbe eine Kurve, die etwas anderes behauptet als die Daten hergeben.
*/ */
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
@ -30,12 +30,12 @@ function pointWithoutValue(minuteOffset: number): DataPoint {
}; };
} }
/** Baut eine Reihe aus den uebergebenen Punkten. */ /** Baut eine Reihe aus den übergebenen Punkten. */
function seriesOf(points: readonly DataPoint[]): Series { function seriesOf(points: readonly DataPoint[]): Series {
return { name: 'test', label: 'Testreihe', unit: 'count', points }; return { name: 'test', label: 'Testreihe', unit: 'count', points };
} }
/** Liest alle gezeichneten Linienzuege aus dem Dokument. */ /** Liest alle gezeichneten Linienzüge aus dem Dokument. */
function readPathDefinitions(container: HTMLElement): string[] { function readPathDefinitions(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll('path')).map( return Array.from(container.querySelectorAll('path')).map(
(pathElement) => pathElement.getAttribute('d') ?? '', (pathElement) => pathElement.getAttribute('d') ?? '',
@ -43,7 +43,7 @@ function readPathDefinitions(container: HTMLElement): string[] {
} }
describe('Liniendiagramm', () => { describe('Liniendiagramm', () => {
it('unterbricht die Linie an einer Luecke, statt sie zu ueberbruecken', () => { it('unterbricht die Linie an einer Lücke, statt sie zu überbrücken', () => {
// Zwei Messungen, dazwischen ein Zeitfenster ohne Lauf. // Zwei Messungen, dazwischen ein Zeitfenster ohne Lauf.
const { container } = render( const { container } = render(
<LineChart <LineChart
@ -62,19 +62,19 @@ describe('Liniendiagramm', () => {
const pathDefinitions = readPathDefinitions(container); const pathDefinitions = readPathDefinitions(container);
// Zwei getrennte Linienzuege — nicht einer, der die Luecke ueberspringt. // Zwei getrennte Linienzüge — nicht einer, der die Lücke überspringt.
expect(pathDefinitions).toHaveLength(2); expect(pathDefinitions).toHaveLength(2);
for (const pathDefinition of pathDefinitions) { for (const pathDefinition of pathDefinitions) {
// Jeder Abschnitt beginnt mit einem eigenen Move-Befehl. Ein einziger // Jeder Abschnitt beginnt mit einem eigenen Move-Befehl. Ein einziger
// Linienzug mit zwei M-Befehlen waere zwar optisch gleich, liesse sich // Linienzug mit zwei M-Befehlen wäre zwar optisch gleich, ließe sich
// aber nicht mehr als getrennt erkennen. // aber nicht mehr als getrennt erkennen.
expect(pathDefinition.startsWith('M')).toBe(true); expect(pathDefinition.startsWith('M')).toBe(true);
expect(pathDefinition.split('M').length - 1).toBe(1); expect(pathDefinition.split('M').length - 1).toBe(1);
} }
}); });
it('zieht eine Luecke nicht auf den Nullpunkt', () => { it('zieht eine Lücke nicht auf den Nullpunkt', () => {
const { container } = render( const { container } = render(
<LineChart <LineChart
series={[seriesOf([pointWithValue(0, 100), pointWithoutValue(1), pointWithValue(2, 100)])]} series={[seriesOf([pointWithValue(0, 100), pointWithoutValue(1), pointWithValue(2, 100)])]}
@ -86,7 +86,7 @@ describe('Liniendiagramm', () => {
const allCoordinates = pathDefinitions.join(' '); const allCoordinates = pathDefinitions.join(' ');
// Bei einem Maximum von 100 liegt der Nullpunkt am unteren Rand der // Bei einem Maximum von 100 liegt der Nullpunkt am unteren Rand der
// Zeichenflaeche. Taucht er auf, wurde die Luecke als Null gezeichnet — und // Zeichenfläche. Taucht er auf, wurde die Lücke als Null gezeichnet — und
// die Kurve behauptet einen Einbruch, den es nicht gab. // die Kurve behauptet einen Einbruch, den es nicht gab.
const lowestDrawnPosition = Math.max( const lowestDrawnPosition = Math.max(
...Array.from(allCoordinates.matchAll(/,(\d+\.\d)/g)).map((match) => Number(match[1])), ...Array.from(allCoordinates.matchAll(/,(\d+\.\d)/g)).map((match) => Number(match[1])),
@ -96,8 +96,8 @@ describe('Liniendiagramm', () => {
}); });
it('zeichnet eine einzelne Messung als Punkt', () => { it('zeichnet eine einzelne Messung als Punkt', () => {
// Eine einzelne Messung ergaebe eine Linie der Laenge null und waere // Eine einzelne Messung ergäbe eine Linie der Länge null und wäre
// unsichtbar — die Kurve saehe aus wie „nichts gemessen". // unsichtbar — die Kurve sähe aus wie „nichts gemessen".
const { container } = render( const { container } = render(
<LineChart <LineChart
series={[seriesOf([pointWithoutValue(0), pointWithValue(1, 42), pointWithoutValue(2)])]} series={[seriesOf([pointWithoutValue(0), pointWithValue(1, 42), pointWithoutValue(2)])]}
@ -116,20 +116,20 @@ describe('Liniendiagramm', () => {
/>, />,
); );
// Kein leeres Achsenkreuz: Das saehe aus wie eine Kurve auf null. // Kein leeres Achsenkreuz: Das sähe aus wie eine Kurve auf null.
expect(screen.getByText(/keine Daten/)).toBeInTheDocument(); expect(screen.getByText(/keine Daten/)).toBeInTheDocument();
expect(screen.getByText(/nicht/)).toBeInTheDocument(); expect(screen.getByText(/nicht/)).toBeInTheDocument();
}); });
it('beginnt die Werteachse bei null', () => { it('beginnt die Werteachse bei null', () => {
// Eine abgeschnittene Achse laesst kleine Schwankungen wie Einbrueche // Eine abgeschnittene Achse lässt kleine Schwankungen wie Einbrüche
// aussehen — der haeufigste Weg, mit korrekten Zahlen etwas Falsches zu // aussehen — der häufigste Weg, mit korrekten Zahlen etwas Falsches zu
// zeigen. // zeigen.
render( render(
<LineChart series={[seriesOf([pointWithValue(0, 100), pointWithValue(1, 102)])]} unit="count" />, <LineChart series={[seriesOf([pointWithValue(0, 100), pointWithValue(1, 102)])]} unit="count" />,
); );
// Die Achse laeuft von null bis zum groessten Wert der Reihe. // Die Achse läuft von null bis zum größten Wert der Reihe.
expect(screen.getByText('0')).toBeInTheDocument(); expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('102')).toBeInTheDocument(); expect(screen.getByText('102')).toBeInTheDocument();
}); });

View File

@ -1,29 +1,29 @@
/** /**
* Liniendiagramm als SVG, ohne Diagrammbibliothek. * Liniendiagramm als SVG, ohne Diagrammbibliothek.
* *
* Warum keine? Die gaengigen Bibliotheken bringen mehr Code mit, als die ganze * Warum keine? Die gängigen Bibliotheken bringen mehr Code mit, als die ganze
* Oberflaeche heute hat — und die eine Eigenschaft, auf die es hier ankommt, * Oberfläche heute hat — und die eine Eigenschaft, auf die es hier ankommt,
* beherrschen sie standardmaessig falsch: **Sie zeichnen Luecken als Nullen.** * beherrschen sie standardmäßig falsch: **Sie zeichnen Lücken als Nullen.**
* *
* Ein Zeitfenster ohne Sicherungslauf hat keinen Durchsatz. Eine Kurve, die * Ein Zeitfenster ohne Sicherungslauf hat keinen Durchsatz. Eine Kurve, die
* dort auf den Nullpunkt faellt, laesst eine Anlage aussehen, als waere ihre * dort auf den Nullpunkt fällt, lässt eine Anlage aussehen, als wäre ihre
* Leistung eingebrochen, obwohl sie nur nichts zu tun hatte. Diese Datei * Leistung eingebrochen, obwohl sie nur nichts zu tun hatte. Diese Datei
* unterbricht die Linie stattdessen. * unterbricht die Linie stattdessen.
*/ */
import type { DataPoint, Series, SeriesUnit } from './metricsApi'; import type { DataPoint, Series, SeriesUnit } from './metricsApi';
/** Abmessungen der Zeichenflaeche. */ /** Abmessungen der Zeichenfläche. */
const CHART_VIEWBOX_WIDTH = 720; const CHART_VIEWBOX_WIDTH = 720;
/** Hoehe der Zeichenflaeche. */ /** Höhe der Zeichenfläche. */
const CHART_VIEWBOX_HEIGHT = 220; const CHART_VIEWBOX_HEIGHT = 220;
/** Innenabstand links fuer die Beschriftung der Werteachse. */ /** Innenabstand links für die Beschriftung der Werteachse. */
const CHART_PADDING_LEFT = 64; const CHART_PADDING_LEFT = 64;
/** Innenabstand rechts. */ /** Innenabstand rechts. */
const CHART_PADDING_RIGHT = 12; const CHART_PADDING_RIGHT = 12;
/** Innenabstand oben. */ /** Innenabstand oben. */
const CHART_PADDING_TOP = 12; const CHART_PADDING_TOP = 12;
/** Innenabstand unten fuer die Zeitachse. */ /** Innenabstand unten für die Zeitachse. */
const CHART_PADDING_BOTTOM = 28; const CHART_PADDING_BOTTOM = 28;
/** Farben der Reihen in Zeichenreihenfolge. */ /** Farben der Reihen in Zeichenreihenfolge. */
@ -46,19 +46,19 @@ interface LineChartProperties {
export function LineChart({ series, unit }: LineChartProperties): React.JSX.Element { export function LineChart({ series, unit }: LineChartProperties): React.JSX.Element {
const maximumValue = findMaximumValue(series); const maximumValue = findMaximumValue(series);
// Ohne einen einzigen Wert gibt es nichts zu zeichnen. Eine leere Flaeche mit // Ohne einen einzigen Wert gibt es nichts zu zeichnen. Eine leere Fläche mit
// Achsen sieht aus wie eine Kurve auf null — deshalb steht hier ein Satz. // Achsen sieht aus wie eine Kurve auf null — deshalb steht hier ein Satz.
if (maximumValue === null) { if (maximumValue === null) {
return ( return (
<p className="chart__empty"> <p className="chart__empty">
In diesem Zeitraum wurde nichts gemessen. Die leere Flaeche bedeutet „keine Daten", nicht In diesem Zeitraum wurde nichts gemessen. Die leere Fläche bedeutet „keine Daten", nicht
„Wert null". „Wert null".
</p> </p>
); );
} }
// Ab hier steht fest, dass ein Maximum vorliegt; TypeScript weiss das nach // Ab hier steht fest, dass ein Maximum vorliegt; TypeScript weiß das nach
// der Rueckgabe oben nicht mehr, weil die Hilfsfunktionen es einfangen. // der Rückgabe oben nicht mehr, weil die Hilfsfunktionen es einfangen.
const scaleMaximum: number = maximumValue; const scaleMaximum: number = maximumValue;
const plotWidth = CHART_VIEWBOX_WIDTH - CHART_PADDING_LEFT - CHART_PADDING_RIGHT; const plotWidth = CHART_VIEWBOX_WIDTH - CHART_PADDING_LEFT - CHART_PADDING_RIGHT;
@ -76,8 +76,8 @@ export function LineChart({ series, unit }: LineChartProperties): React.JSX.Elem
/** Rechnet einen Wert in eine Y-Koordinate um. */ /** Rechnet einen Wert in eine Y-Koordinate um. */
function verticalPositionOf(dataValue: number): number { function verticalPositionOf(dataValue: number): number {
// Die Werteachse beginnt immer bei null. Eine abgeschnittene Achse laesst // Die Werteachse beginnt immer bei null. Eine abgeschnittene Achse lässt
// kleine Schwankungen wie Einbrueche aussehen — der haeufigste Weg, mit // kleine Schwankungen wie Einbrüche aussehen — der häufigste Weg, mit
// einem korrekten Diagramm etwas Falsches zu zeigen. // einem korrekten Diagramm etwas Falsches zu zeigen.
return CHART_PADDING_TOP + plotHeight - (dataValue / scaleMaximum) * plotHeight; return CHART_PADDING_TOP + plotHeight - (dataValue / scaleMaximum) * plotHeight;
} }
@ -126,11 +126,11 @@ export function LineChart({ series, unit }: LineChartProperties): React.JSX.Elem
/** Eigenschaften der Hilfslinien. */ /** Eigenschaften der Hilfslinien. */
interface GridLinesProperties { interface GridLinesProperties {
/** Groesster Wert der Reihen. */ /** Größter Wert der Reihen. */
readonly maximumValue: number; readonly maximumValue: number;
/** Einheit der Werte. */ /** Einheit der Werte. */
readonly unit: SeriesUnit; readonly unit: SeriesUnit;
/** Breite der Zeichenflaeche. */ /** Breite der Zeichenfläche. */
readonly plotWidth: number; readonly plotWidth: number;
/** Rechnet einen Wert in eine Y-Koordinate um. */ /** Rechnet einen Wert in eine Y-Koordinate um. */
readonly verticalPositionOf: (dataValue: number) => number; readonly verticalPositionOf: (dataValue: number) => number;
@ -197,8 +197,8 @@ function SeriesPath({
/> />
))} ))}
{/* Ein einzelner Messpunkt ohne Nachbarn ergaebe eine Linie der Laenge {/* Ein einzelner Messpunkt ohne Nachbarn ergäbe eine Linie der Länge
null und waere unsichtbar. Er wird deshalb als Punkt gezeichnet. */} null und wäre unsichtbar. Er wird deshalb als Punkt gezeichnet. */}
{series.points.map((dataPoint, pointIndex) => {series.points.map((dataPoint, pointIndex) =>
dataPoint.has_value && isIsolatedPoint(series.points, pointIndex) ? ( dataPoint.has_value && isIsolatedPoint(series.points, pointIndex) ? (
<circle <circle
@ -218,8 +218,8 @@ function SeriesPath({
/** /**
* Baut die Linienabschnitte einer Reihe. * Baut die Linienabschnitte einer Reihe.
* *
* Jede zusammenhaengende Folge von Punkten mit Wert wird ein eigener Abschnitt. * Jede zusammenhängende Folge von Punkten mit Wert wird ein eigener Abschnitt.
* Eine Luecke unterbricht die Linie — sie wird nicht ueberbrueckt und nicht auf * Eine Lücke unterbricht die Linie — sie wird nicht überbrückt und nicht auf
* null gezogen. * null gezogen.
*/ */
function buildPathSegments( function buildPathSegments(
@ -263,7 +263,7 @@ function isIsolatedPoint(points: readonly DataPoint[], pointIndex: number): bool
return !previousHasValue && !nextHasValue; return !previousHasValue && !nextHasValue;
} }
/** Sucht den groessten Wert aller Reihen. */ /** Sucht den größten Wert aller Reihen. */
function findMaximumValue(series: readonly Series[]): number | null { function findMaximumValue(series: readonly Series[]): number | null {
let maximumValue: number | null = null; let maximumValue: number | null = null;
@ -281,8 +281,8 @@ function findMaximumValue(series: readonly Series[]): number | null {
} }
} }
// Ein Maximum von null macht die Division unmoeglich. Eine Reihe aus lauter // Ein Maximum von null macht die Division unmöglich. Eine Reihe aus lauter
// Nullen ist ein gueltiger Fall — etwa null Fehlschlaege. // Nullen ist ein gültiger Fall — etwa null Fehlschläge.
if (maximumValue !== null && maximumValue === 0) { if (maximumValue !== null && maximumValue === 0) {
return 1; return 1;
} }
@ -290,7 +290,7 @@ function findMaximumValue(series: readonly Series[]): number | null {
return maximumValue; return maximumValue;
} }
/** Faktor zwischen zwei Groesseneinheiten. */ /** Faktor zwischen zwei Größeneinheiten. */
const BYTE_UNIT_STEP = 1024; const BYTE_UNIT_STEP = 1024;
/** Schreibt einen Achsenwert lesbar. */ /** Schreibt einen Achsenwert lesbar. */

View File

@ -1,9 +1,9 @@
/** /**
* Kennzahlen und Diagramme. * Kennzahlen und Diagramme.
* *
* Alle zwoelf Diagramme aus dem Plan erscheinen. Das eine ohne Datengrundlage * Alle zwölf Diagramme aus dem Plan erscheinen. Das eine ohne Datengrundlage
* — die Ressourcenlast der Agenten — steht mit dabei und sagt, was fehlt. * — die Ressourcenlast der Agenten — steht mit dabei und sagt, was fehlt.
* Dieselbe Regel wie in der Uebersicht: Ein weggelassenes Diagramm sieht aus wie * Dieselbe Regel wie in der Übersicht: Ein weggelassenes Diagramm sieht aus wie
* ein vergessenes, ein leeres wie eine Anlage ohne Betrieb. * ein vergessenes, ein leeres wie eine Anlage ohne Betrieb.
*/ */
@ -16,7 +16,7 @@ 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';
/** Auswaehlbare Zeitraeume mit ihrer Beschriftung. */ /** Auswählbare Zeiträume mit ihrer Beschriftung. */
const RANGE_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [ const RANGE_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
{ value: '1h', label: 'Letzte Stunde' }, { value: '1h', label: 'Letzte Stunde' },
{ value: '24h', label: 'Letzte 24 Stunden' }, { value: '24h', label: 'Letzte 24 Stunden' },
@ -93,7 +93,7 @@ export function MetricsPage(): React.JSX.Element {
interface ChartCardProperties { interface ChartCardProperties {
/** Das darzustellende Diagramm. */ /** Das darzustellende Diagramm. */
readonly definition: ChartDefinition; readonly definition: ChartDefinition;
/** Der gewaehlte Zeitraum. */ /** Der gewählte Zeitraum. */
readonly timeRange: string; readonly timeRange: string;
} }
@ -105,8 +105,8 @@ function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.El
); );
// Ein Diagramm ohne Datengrundlage wird gar nicht erst abgerufen: Der Server // Ein Diagramm ohne Datengrundlage wird gar nicht erst abgerufen: Der Server
// antwortete mit 501, und ein Fehler in der Oberflaeche saehe aus wie eine // antwortete mit 501, und ein Fehler in der Oberfläche sähe aus wie eine
// Stoerung. Es ist keine — die Funktion gibt es nur noch nicht. // Störung. Es ist keine — die Funktion gibt es nur noch nicht.
const { loadState, data, loadError, reload } = useApiResource<Chart>( const { loadState, data, loadError, reload } = useApiResource<Chart>(
loadChart, loadChart,
definition.available ? `${definition.metric}|${timeRange}` : 'unavailable', definition.available ? `${definition.metric}|${timeRange}` : 'unavailable',
@ -115,7 +115,7 @@ function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.El
if (!definition.available) { if (!definition.available) {
return ( return (
<Card> <Card>
<CardHeader title={definition.title} description="noch nicht verfuegbar" /> <CardHeader title={definition.title} description="noch nicht verfügbar" />
<CardContent> <CardContent>
<p className="text-sm text-fg-muted">{definition.unavailable_reason}</p> <p className="text-sm text-fg-muted">{definition.unavailable_reason}</p>
</CardContent> </CardContent>

View File

@ -17,9 +17,9 @@ export type SeriesUnit =
export interface ChartDefinition { export interface ChartDefinition {
/** Bezeichner im Pfad. */ /** Bezeichner im Pfad. */
readonly metric: string; readonly metric: string;
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Erklaerung. */ /** Erklärung. */
readonly description: string; readonly description: string;
/** Einheit der Werte. */ /** Einheit der Werte. */
readonly unit: SeriesUnit; readonly unit: SeriesUnit;
@ -27,7 +27,7 @@ export interface ChartDefinition {
readonly source: string; readonly source: string;
/** Meldet, ob es eine Datengrundlage gibt. */ /** Meldet, ob es eine Datengrundlage gibt. */
readonly available: boolean; readonly available: boolean;
/** Erklaert eine fehlende Datengrundlage. */ /** Erklärt eine fehlende Datengrundlage. */
readonly unavailable_reason?: string; readonly unavailable_reason?: string;
} }
@ -37,7 +37,7 @@ export interface ChartCatalog {
readonly charts: readonly ChartDefinition[]; readonly charts: readonly ChartDefinition[];
/** Zahl der Diagramme mit Datengrundlage. */ /** Zahl der Diagramme mit Datengrundlage. */
readonly available_count: number; readonly available_count: number;
/** Waehlbare Zeitraeume. */ /** Wählbare Zeiträume. */
readonly ranges: readonly string[]; readonly ranges: readonly string[];
} }
@ -45,7 +45,7 @@ export interface ChartCatalog {
export interface DataPoint { export interface DataPoint {
/** Beginn des Zeitfensters in UTC. */ /** Beginn des Zeitfensters in UTC. */
readonly timestamp: string; readonly timestamp: string;
/** Wert; nur gueltig, wenn has_value gesetzt ist. */ /** Wert; nur gültig, wenn has_value gesetzt ist. */
readonly value?: number; readonly value?: number;
/** /**
* Meldet, ob in diesem Zeitfenster etwas gemessen wurde. * Meldet, ob in diesem Zeitfenster etwas gemessen wurde.
@ -70,13 +70,13 @@ export interface Series {
readonly points: readonly DataPoint[]; readonly points: readonly DataPoint[];
} }
/** Ein vollstaendiges Diagramm. */ /** Ein vollständiges Diagramm. */
export interface Chart { export interface Chart {
/** Bezeichner. */ /** Bezeichner. */
readonly metric: string; readonly metric: string;
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Erklaerung. */ /** Erklärung. */
readonly description: string; readonly description: string;
/** Ausgewerteter Zeitraum. */ /** Ausgewerteter Zeitraum. */
readonly window: { readonly window: {
@ -90,12 +90,12 @@ export interface Chart {
readonly note?: string; readonly note?: string;
} }
/** Laedt den Katalog aller Diagramme. */ /** Lädt den Katalog aller Diagramme. */
export async function fetchChartCatalog(abortSignal?: AbortSignal): Promise<ChartCatalog> { export async function fetchChartCatalog(abortSignal?: AbortSignal): Promise<ChartCatalog> {
return requestApi<ChartCatalog>('/metrics', abortSignal ? { signal: abortSignal } : {}); return requestApi<ChartCatalog>('/metrics', abortSignal ? { signal: abortSignal } : {});
} }
/** Laedt ein Diagramm fuer den angegebenen Zeitraum. */ /** Lädt ein Diagramm für den angegebenen Zeitraum. */
export async function fetchChart( export async function fetchChart(
metricName: string, metricName: string,
timeRange: string, timeRange: string,

View File

@ -3,9 +3,9 @@
* *
* Der wichtigste Test ist der dritte: Eine Kennzahl ohne Messung darf nirgends * Der wichtigste Test ist der dritte: Eine Kennzahl ohne Messung darf nirgends
* als Zahl erscheinen — weder in der Vorschau noch in der Datei. In einem * als Zahl erscheinen — weder in der Vorschau noch in der Datei. In einem
* Bericht wiegt dieser Fehler schwerer als anderswo: Der Bericht verlaesst die * Bericht wiegt dieser Fehler schwerer als anderswo: Der Bericht verlässt die
* Anlage, landet in einer Tabellenkalkulation und in einem Ordner, und dort * Anlage, landet in einer Tabellenkalkulation und in einem Ordner, und dort
* ueberlebt eine erfundene Null jede muendliche Erlaeuterung. * überlebt eine erfundene Null jede mündliche Erläuterung.
*/ */
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
@ -13,7 +13,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ReportsPage } from './ReportsPage'; import { ReportsPage } from './ReportsPage';
/** Baut eine Antwort in der Standardhuelle. */ /** Baut eine Antwort in der Standardhülle. */
function buildJsonResponse(payload: unknown): Response { function buildJsonResponse(payload: unknown): Response {
return { return {
status: 200, status: 200,
@ -28,14 +28,14 @@ const reportCatalog = [
{ {
type: 'daily_backup', type: 'daily_backup',
title: 'Tagesbericht Sicherungen', title: 'Tagesbericht Sicherungen',
description: 'Alle Sicherungslaeufe eines Tages.', description: 'Alle Sicherungsläufe eines Tages.',
period_kind: 'range', period_kind: 'range',
default_period: 'letzte 24 Stunden', default_period: 'letzte 24 Stunden',
formats: ['json', 'csv', 'pdf'], formats: ['json', 'csv', 'pdf'],
}, },
{ {
type: 'repository_capacity', type: 'repository_capacity',
title: 'Bericht Repository-Kapazitaet', title: 'Bericht Repository-Kapazität',
description: 'Belegung und Zustand aller Repositories.', description: 'Belegung und Zustand aller Repositories.',
period_kind: 'point_in_time', period_kind: 'point_in_time',
formats: ['json', 'csv', 'pdf'], formats: ['json', 'csv', 'pdf'],
@ -46,34 +46,34 @@ const reportCatalog = [
const reportWithUnknownMetric = { const reportWithUnknownMetric = {
type: 'daily_backup', type: 'daily_backup',
title: 'Tagesbericht Sicherungen', title: 'Tagesbericht Sicherungen',
description: 'Alle Sicherungslaeufe eines Tages.', description: 'Alle Sicherungsläufe eines Tages.',
period_from: '2026-08-12T00:00:00Z', period_from: '2026-08-12T00:00:00Z',
period_to: '2026-08-13T00:00:00Z', period_to: '2026-08-13T00:00:00Z',
generated_at: '2026-08-13T06:00:00Z', generated_at: '2026-08-13T06:00:00Z',
generated_by: 'pruefer', generated_by: 'prüfer',
sections: [ sections: [
{ {
title: 'Ueberblick', title: 'Überblick',
metrics: [ metrics: [
{ label: 'Laeufe insgesamt', value: 0, unit: 'count', is_known: true }, { label: 'Läufe insgesamt', value: 0, unit: 'count', is_known: true },
{ {
label: 'Erfolgsquote', label: 'Erfolgsquote',
unit: 'percent', unit: 'percent',
is_known: false, is_known: false,
unknown_reason: 'Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.', unknown_reason: 'Im gewählten Zeitraum wurde kein Lauf abgeschlossen.',
}, },
], ],
tables: [ tables: [
{ {
title: 'Einzelne Laeufe', title: 'Einzelne Läufe',
columns: ['Auftrag', 'Ergebnis'], columns: ['Auftrag', 'Ergebnis'],
rows: [], rows: [],
empty_notice: 'Im gewaehlten Zeitraum wurde kein Lauf begonnen.', empty_notice: 'Im gewählten Zeitraum wurde kein Lauf begonnen.',
}, },
], ],
}, },
], ],
notes: ['Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.'], notes: ['Im gewählten Zeitraum wurde kein Lauf abgeschlossen.'],
}; };
describe('Berichte', () => { describe('Berichte', () => {
@ -94,10 +94,10 @@ describe('Berichte', () => {
expect(screen.getByText('Tagesbericht Sicherungen')).toBeInTheDocument(); expect(screen.getByText('Tagesbericht Sicherungen')).toBeInTheDocument();
}); });
expect(screen.getByText('Bericht Repository-Kapazitaet')).toBeInTheDocument(); expect(screen.getByText('Bericht Repository-Kapazität')).toBeInTheDocument();
// Ein Zustandsbericht wird als solcher gekennzeichnet: Ein Zeitraum, den er // Ein Zustandsbericht wird als solcher gekennzeichnet: Ein Zeitraum, den er
// nicht auswertet, waere ein Versprechen, das er nicht einloest. // nicht auswertet, wäre ein Versprechen, das er nicht einlöst.
expect(screen.getByText('Zustandsbericht')).toBeInTheDocument(); expect(screen.getByText('Zustandsbericht')).toBeInTheDocument();
expect(screen.getByText('Zeitraum: letzte 24 Stunden')).toBeInTheDocument(); expect(screen.getByText('Zeitraum: letzte 24 Stunden')).toBeInTheDocument();
}); });
@ -119,18 +119,18 @@ describe('Berichte', () => {
expect(screen.getByText('Erfolgsquote')).toBeInTheDocument(); expect(screen.getByText('Erfolgsquote')).toBeInTheDocument();
}); });
// Die ungemessene Quote steht als „nicht gemessen" da — mit Begruendung. // Die ungemessene Quote steht als „nicht gemessen" da — mit Begründung.
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 gewählten Zeitraum wurde kein Lauf abgeschlossen.'),
).toBeInTheDocument(); ).toBeInTheDocument();
// Die gemessene Null bleibt dagegen sichtbar: „null Laeufe" ist eine // Die gemessene Null bleibt dagegen sichtbar: „null Läufe" 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 — // Die gemessene Null steht als Zahl da und trägt **keine** Begründung —
// sie braucht keine. Genau daran unterscheidet sie sich von der // sie braucht keine. Genau daran unterscheidet sie sich von der
// ungemessenen Quote daneben. // ungemessenen Quote daneben.
const measuredValue = screen.getByText('Laeufe insgesamt').nextElementSibling; const measuredValue = screen.getByText('Läufe insgesamt').nextElementSibling;
expect(measuredValue?.textContent).toBe('0'); expect(measuredValue?.textContent).toBe('0');
}); });
@ -148,11 +148,11 @@ describe('Berichte', () => {
await userEvent.click(screen.getByText('Tagesbericht Sicherungen')); await userEvent.click(screen.getByText('Tagesbericht Sicherungen'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Einzelne Laeufe')).toBeInTheDocument(); expect(screen.getByText('Einzelne Läufe')).toBeInTheDocument();
}); });
expect( expect(
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf begonnen.'), screen.getByText('Im gewählten Zeitraum wurde kein Lauf begonnen.'),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });

View File

@ -1,9 +1,9 @@
/** /**
* Berichte. * Berichte.
* *
* Die Seite, die in Phase 12 als „noch nicht verfuegbar" im Menue stand. * Die Seite, die in Phase 12 als „noch nicht verfügbar" im Menü stand.
* *
* Zwei Dinge unterscheidet sie von einer gewoehnlichen Berichtsmaske: * Zwei Dinge unterscheidet sie von einer gewöhnlichen Berichtsmaske:
* *
* Sie zeigt die Vorschau **im selben Modell**, in dem der Bericht auch als CSV * Sie zeigt die Vorschau **im selben Modell**, in dem der Bericht auch als CSV
* und PDF herausgeht — was hier steht, steht auch in der Datei. Und sie stellt * und PDF herausgeht — was hier steht, steht auch in der Datei. Und sie stellt
@ -41,13 +41,13 @@ interface ReportCatalogEntry {
readonly type: string; readonly type: string;
/** Bezeichnung. */ /** Bezeichnung. */
readonly title: string; readonly title: string;
/** Erklaert, welche Frage der Bericht beantwortet. */ /** Erklärt, welche Frage der Bericht beantwortet. */
readonly description: string; readonly description: string;
/** Art des Zeitbezugs. */ /** Art des Zeitbezugs. */
readonly period_kind: PeriodKind; readonly period_kind: PeriodKind;
/** Beschreibung des Standardzeitraums. */ /** Beschreibung des Standardzeitraums. */
readonly default_period?: string; readonly default_period?: string;
/** Verfuegbare Ausgabeformate. */ /** Verfügbare Ausgabeformate. */
readonly formats: readonly ReportFormat[]; readonly formats: readonly ReportFormat[];
} }
@ -55,7 +55,7 @@ interface ReportCatalogEntry {
interface ReportMetric { interface ReportMetric {
/** Beschriftung. */ /** Beschriftung. */
readonly label: string; readonly label: string;
/** Gemessener Wert; nur gueltig, wenn is_known gilt. */ /** Gemessener Wert; nur gültig, wenn is_known gilt. */
readonly value?: number; readonly value?: number;
/** Einheit. */ /** Einheit. */
readonly unit?: string; readonly unit?: string;
@ -63,13 +63,13 @@ interface ReportMetric {
readonly text?: string; readonly text?: string;
/** Meldet, ob der Wert gemessen wurde. */ /** Meldet, ob der Wert gemessen wurde. */
readonly is_known: boolean; readonly is_known: boolean;
/** Erklaert einen fehlenden Wert. */ /** Erklärt einen fehlenden Wert. */
readonly unknown_reason?: string; readonly unknown_reason?: string;
} }
/** Eine Tabelle eines Berichts. */ /** Eine Tabelle eines Berichts. */
interface ReportTable { interface ReportTable {
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Spaltenbeschriftungen. */ /** Spaltenbeschriftungen. */
readonly columns: readonly string[]; readonly columns: readonly string[];
@ -81,9 +81,9 @@ interface ReportTable {
/** Ein Abschnitt eines Berichts. */ /** Ein Abschnitt eines Berichts. */
interface ReportSection { interface ReportSection {
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Erklaerung. */ /** Erklärung. */
readonly description?: string; readonly description?: string;
/** Kennzahlen. */ /** Kennzahlen. */
readonly metrics?: readonly ReportMetric[]; readonly metrics?: readonly ReportMetric[];
@ -97,7 +97,7 @@ interface GeneratedReport {
readonly type: string; readonly type: string;
/** Bezeichnung. */ /** Bezeichnung. */
readonly title: string; readonly title: string;
/** Erklaerung. */ /** Erklärung. */
readonly description: string; readonly description: string;
/** Beginn des Zeitraums. */ /** Beginn des Zeitraums. */
readonly period_from: string; readonly period_from: string;
@ -109,19 +109,19 @@ interface GeneratedReport {
readonly generated_by?: string; readonly generated_by?: string;
/** Abschnitte. */ /** Abschnitte. */
readonly sections: readonly ReportSection[]; readonly sections: readonly ReportSection[];
/** Benannte Luecken und Einschraenkungen. */ /** Benannte Lücken und Einschränkungen. */
readonly notes?: readonly string[]; readonly notes?: readonly string[];
} }
/** Zustand einer laufenden Ausgabe. */ /** Zustand einer laufenden Ausgabe. */
interface ExportState { interface ExportState {
/** Format, das gerade erzeugt wird; null, wenn nichts laeuft. */ /** Format, das gerade erzeugt wird; null, wenn nichts läuft. */
readonly runningFormat: ReportFormat | null; readonly runningFormat: ReportFormat | null;
/** Fehler der letzten Ausgabe. */ /** Fehler der letzten Ausgabe. */
readonly exportError: ApiError | null; readonly exportError: ApiError | null;
} }
/** Laedt den Berichtskatalog. */ /** Lädt den Berichtskatalog. */
function loadReportCatalog(abortSignal: AbortSignal): Promise<readonly ReportCatalogEntry[]> { function loadReportCatalog(abortSignal: AbortSignal): Promise<readonly ReportCatalogEntry[]> {
return requestApi<readonly ReportCatalogEntry[]>('/reports', { signal: abortSignal }); return requestApi<readonly ReportCatalogEntry[]>('/reports', { signal: abortSignal });
} }
@ -202,11 +202,11 @@ export function ReportsPage(): React.JSX.Element {
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 Verfügung.
return ( return (
<Card> <Card>
<ErrorState <ErrorState
title="Der Berichtskatalog steht nicht zur Verfuegung." title="Der Berichtskatalog steht nicht zur Verfügung."
message={ message={
catalogResource.loadError ? describeApiError(catalogResource.loadError) : undefined catalogResource.loadError ? describeApiError(catalogResource.loadError) : undefined
} }
@ -221,7 +221,7 @@ export function ReportsPage(): React.JSX.Element {
<> <>
<PageHeader <PageHeader
title="Berichte" title="Berichte"
description="Neun Berichte in drei Formaten. Was in der Vorschau steht, steht auch in der Datei — einschliesslich der Angaben, die sich nicht messen liessen." description="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."
/> />
<div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
@ -246,7 +246,7 @@ export function ReportsPage(): React.JSX.Element {
nicht historisiert; "vom letzten Dienstag" kann es nicht geben. */} 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 ?? 'waehlbar'}`} : `Zeitraum: ${catalogEntry.default_period ?? 'wählbar'}`}
</span> </span>
</button> </button>
))} ))}
@ -264,7 +264,7 @@ export function ReportsPage(): React.JSX.Element {
); );
} }
/** Zeigt einen erzeugten Bericht mit seinen Ausgabemoeglichkeiten. */ /** Zeigt einen erzeugten Bericht mit seinen Ausgabemöglichkeiten. */
function ReportPanel(properties: { function ReportPanel(properties: {
readonly catalogEntry: ReportCatalogEntry; readonly catalogEntry: ReportCatalogEntry;
readonly previewReport: GeneratedReport | null; readonly previewReport: GeneratedReport | null;
@ -356,8 +356,8 @@ function ReportPreview(properties: { readonly report: GeneratedReport }): React.
<dt className="text-xs text-fg-subtle">{reportMetric.label}</dt> <dt className="text-xs text-fg-subtle">{reportMetric.label}</dt>
<dd className="mt-1"> <dd className="mt-1">
{/* Eine ungemessene Kennzahl erscheint niemals als Zahl. {/* Eine ungemessene Kennzahl erscheint niemals als Zahl.
Eine erfundene Null ueberlebt in einem Pruefordner jede Eine erfundene Null überlebt in einem Prüfordner jede
muendliche Erlaeuterung. */} mündliche Erläuterung. */}
<span <span
className={ className={
reportMetric.is_known reportMetric.is_known
@ -412,7 +412,7 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
<div className="mt-4"> <div className="mt-4">
<h4 className="text-sm font-medium text-fg">{table.title}</h4> <h4 className="text-sm font-medium text-fg">{table.title}</h4>
<p className="mt-1 text-sm text-fg-muted"> <p className="mt-1 text-sm text-fg-muted">
{table.empty_notice ?? 'Keine Eintraege.'} {table.empty_notice ?? 'Keine Einträge.'}
</p> </p>
</div> </div>
); );
@ -441,10 +441,10 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
</tr> </tr>
</thead> </thead>
{/* {/*
Die Position ist der Schluessel: Eine Berichtszeile traegt keine Die Position ist der Schlüssel: Eine Berichtszeile trägt keine
Kennung, und zwei Zeilen koennen Zeichen fuer Zeichen gleich sein. Kennung, und zwei Zeilen können Zeichen für Zeichen gleich sein.
Unbedenklich, weil die Tabelle statisch ist — sie wird weder Unbedenklich, weil die Tabelle statisch ist — sie wird weder
umsortiert noch ergaenzt, sondern mit dem Bericht neu aufgebaut. umsortiert noch ergänzt, sondern mit dem Bericht neu aufgebaut.
*/} */}
<tbody> <tbody>
{visibleRows.map((tableRow, rowIndex) => ( {visibleRows.map((tableRow, rowIndex) => (
@ -462,7 +462,7 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
{table.rows.length > visibleRows.length && ( {table.rows.length > visibleRows.length && (
<p className="mt-1.5 text-xs text-fg-muted"> <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 enthaelt alle. heruntergeladene Datei enthält alle.
</p> </p>
)} )}
</div> </div>
@ -539,7 +539,7 @@ function formatDuration(seconds: number): string {
* Stellt einen Anteil dar. * Stellt einen Anteil dar.
* *
* Ein kleiner Wert erscheint als „< 0,1 %", nicht als „0,0 %": Null Prozent * Ein kleiner Wert erscheint als „< 0,1 %", nicht als „0,0 %": Null Prozent
* liest sich wie „nichts vorhanden" (dieselbe Regel wie in der Uebersicht). * liest sich wie „nichts vorhanden" (dieselbe Regel wie in der Übersicht).
*/ */
function formatPercent(percentValue: number): string { function formatPercent(percentValue: number): string {
if (percentValue > 0 && percentValue < 0.1) { if (percentValue > 0 && percentValue < 0.1) {
@ -555,11 +555,11 @@ function formatTimestamp(isoTimestamp: string): string {
} }
/** /**
* Loest den Browser-Download einer Datei aus. * Löst den Browser-Download einer Datei aus.
* *
* Die Objekt-URL wird unmittelbar wieder freigegeben. Ohne das haelt der Browser * Die Objekt-URL wird unmittelbar wieder freigegeben. Ohne das hält der Browser
* jeden erzeugten Bericht im Speicher, bis die Seite neu geladen wird — bei * jeden erzeugten Bericht im Speicher, bis die Seite neu geladen wird — bei
* einem Monatsbericht ueber eine grosse Anlage sind das schnell einige Megabyte * einem Monatsbericht über eine große Anlage sind das schnell einige Megabyte
* je Klick. * je Klick.
*/ */
function triggerBrowserDownload(fileBlob: Blob, fileName: string): void { function triggerBrowserDownload(fileBlob: Blob, fileName: string): void {

View File

@ -1,13 +1,13 @@
/** /**
* Repositories — Liste, Uebernahme und Wartung. * Repositories — Liste, Übernahme und Wartung.
* *
* Die Maske zur Uebernahme sagt ausdruecklich, dass hier **nichts angelegt** * Die Maske zur Übernahme sagt ausdrücklich, dass hier **nichts angelegt**
* wird. Ein Repository entsteht auf einem Datentraeger; der Server oeffnet das * wird. Ein Repository entsteht auf einem Datenträger; der Server öffnet das
* vorhandene und liest die Kennung aus dem Descriptor. Ohne diesen Hinweis * vorhandene und liest die Kennung aus dem Descriptor. Ohne diesen Hinweis
* traegt jemand ein leeres Verzeichnis ein und sucht den Fehler bei sich. * trägt jemand ein leeres Verzeichnis ein und sucht den Fehler bei sich.
* *
* Der Integritaetslauf trennt zwei Lagen, die gern vermengt werden: „Die * Der Integritätslauf trennt zwei Lagen, die gern vermengt werden: „Die
* Pruefung schlug fehl" und „das Repository ist beschaedigt". Das eine ist ein * Prüfung schlug fehl" und „das Repository ist beschädigt". Das eine ist ein
* Problem des Werkzeugs, das andere ein Datenverlust. * Problem des Werkzeugs, das andere ein Datenverlust.
*/ */
@ -72,7 +72,7 @@ export function RepositoriesPage({
const adoptMutation = useMutation(adoptRepository, { const adoptMutation = useMutation(adoptRepository, {
onSuccess: (repository) => { onSuccess: (repository) => {
toast.showSuccess( toast.showSuccess(
'Repository uebernommen', 'Repository übernommen',
`${repository.name} ist jetzt in der Control Plane bekannt.`, `${repository.name} ist jetzt in der Control Plane bekannt.`,
); );
setIsAdoptDialogOpen(false); setIsAdoptDialogOpen(false);
@ -82,7 +82,7 @@ export function RepositoriesPage({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Die Uebernahme schlug fehl', 'Die Übernahme schlug fehl',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -94,17 +94,17 @@ export function RepositoriesPage({
setRepositoryPendingScan(null); setRepositoryPendingScan(null);
// Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung // Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung
// unterscheidet beides — ein Pruefwerkzeug, das grundlos Alarm schlaegt, // unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt,
// wird bald nicht mehr ernst genommen. // wird bald nicht mehr ernst genommen.
if (result.chunks_missing > 0 || result.chunks_corrupted > 0) { if (result.chunks_missing > 0 || result.chunks_corrupted > 0) {
toast.showError( toast.showError(
'Der Integritaetslauf hat Befunde', 'Der Integritätslauf hat Befunde',
`${result.chunks_missing} Bloecke fehlen, ${result.chunks_corrupted} sind beschaedigt.`, `${result.chunks_missing} Blöcke fehlen, ${result.chunks_corrupted} sind beschädigt.`,
); );
} else { } else {
toast.showSuccess( toast.showSuccess(
'Integritaetslauf ohne Befund', 'Integritätslauf ohne Befund',
`${result.chunks_checked.toLocaleString('de-DE')} Bloecke geprueft.`, `${result.chunks_checked.toLocaleString('de-DE')} Blöcke geprüft.`,
); );
} }
@ -112,8 +112,8 @@ export function RepositoriesPage({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Der Integritaetslauf liess sich nicht durchfuehren', 'Der Integritätslauf ließ sich nicht durchführen',
`${describeApiError(apiError)} — das ist ein Problem der Pruefung, kein Befund am Repository.`, `${describeApiError(apiError)} — das ist ein Problem der Prüfung, kein Befund am Repository.`,
apiError.requestId, apiError.requestId,
), ),
}); });
@ -121,7 +121,7 @@ export function RepositoriesPage({
const healthMutation = useMutation(checkRepositoryHealth, { const healthMutation = useMutation(checkRepositoryHealth, {
onSuccess: (health) => { onSuccess: (health) => {
toast.showSuccess( toast.showSuccess(
'Gesundheitspruefung abgeschlossen', 'Gesundheitsprüfung abgeschlossen',
health.message ?? `Zustand: ${health.status}`, health.message ?? `Zustand: ${health.status}`,
); );
repositoriesResource.reload(); repositoriesResource.reload();
@ -182,7 +182,7 @@ export function RepositoriesPage({
}, },
{ {
key: 'enforcement', key: 'enforcement',
header: 'Loeschschutz', header: 'Löschschutz',
render: (repository) => render: (repository) =>
repository.enforcement_level ? ( repository.enforcement_level ? (
<StatusBadge status={repository.enforcement_level} /> <StatusBadge status={repository.enforcement_level} />
@ -199,10 +199,10 @@ export function RepositoriesPage({
}, },
{ {
key: 'hardened', key: 'hardened',
header: 'Gehaertet', header: 'Gehärtet',
render: (repository) => render: (repository) =>
repository.hardened ? ( repository.hardened ? (
<ShieldCheck className="size-4 text-healthy" aria-label="gehaertet" /> <ShieldCheck className="size-4 text-healthy" aria-label="gehärtet" />
) : ( ) : (
<span className="text-xs text-fg-muted">nein</span> <span className="text-xs text-fg-muted">nein</span>
), ),
@ -233,7 +233,7 @@ export function RepositoriesPage({
mayWrite ? ( mayWrite ? (
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}> <Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
<Plus /> <Plus />
Repository uebernehmen Repository übernehmen
</Button> </Button>
) : null ) : null
} }
@ -247,12 +247,12 @@ export function RepositoriesPage({
onRowClick={(repository) => setSelectedRepository(repository)} onRowClick={(repository) => setSelectedRepository(repository)}
isLoading={repositoriesResource.loadState === 'loading'} isLoading={repositoriesResource.loadState === 'loading'}
emptyTitle="Kein Repository eingetragen" emptyTitle="Kein Repository eingetragen"
emptyDescription="Ein Repository entsteht auf einem Datentraeger mit „syncova-repo create“ und wird hier uebernommen." emptyDescription="Anlegen mit „syncova-repo create“, hier übernehmen."
emptyAction={ emptyAction={
mayWrite ? ( mayWrite ? (
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}> <Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
<Plus /> <Plus />
Repository uebernehmen Repository übernehmen
</Button> </Button>
) : null ) : null
} }
@ -266,7 +266,7 @@ export function RepositoriesPage({
description={selectedRepository.location} description={selectedRepository.location}
action={ action={
<Button variant="ghost" size="sm" onClick={() => setSelectedRepository(null)}> <Button variant="ghost" size="sm" onClick={() => setSelectedRepository(null)}>
Schliessen Schließen
</Button> </Button>
} }
/> />
@ -279,7 +279,7 @@ export function RepositoriesPage({
<DetailItem label="Belegt"> <DetailItem label="Belegt">
{formatBytes(selectedRepository.used_bytes)} {formatBytes(selectedRepository.used_bytes)}
</DetailItem> </DetailItem>
<DetailItem label="Zuletzt geprueft"> <DetailItem label="Zuletzt geprüft">
{selectedRepository.last_health_check_at {selectedRepository.last_health_check_at
? formatDateTime(selectedRepository.last_health_check_at) ? formatDateTime(selectedRepository.last_health_check_at)
: 'Nie'} : 'Nie'}
@ -294,7 +294,7 @@ export function RepositoriesPage({
onClick={() => void healthMutation.run(selectedRepository.id)} onClick={() => void healthMutation.run(selectedRepository.id)}
> >
<RefreshCw /> <RefreshCw />
Gesundheit pruefen Gesundheit prüfen
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -306,7 +306,7 @@ export function RepositoriesPage({
</Button> </Button>
<Button size="sm" onClick={() => setRepositoryPendingScan(selectedRepository)}> <Button size="sm" onClick={() => setRepositoryPendingScan(selectedRepository)}>
<Database /> <Database />
Integritaetslauf Integritätslauf
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -320,10 +320,7 @@ export function RepositoriesPage({
) : null} ) : null}
<Callout tone="info" className="mt-4"> <Callout tone="info" className="mt-4">
Der <strong>Katalog</strong> ist nur ein Beschleuniger; verbindlich Der Katalog ist nur ein Beschleuniger. Ihn neu aufzubauen ist verlustfrei.
sind die Manifeste. Ihn neu aufzubauen ist verlustfrei — und
zugleich der Nachweis, dass dieses Repository ohne die Datenbank
auskommt.
</Callout> </Callout>
</CardContent> </CardContent>
</Card> </Card>
@ -333,20 +330,20 @@ export function RepositoriesPage({
<IntegrityScanCard result={scanResult} onClose={() => setScanResult(null)} /> <IntegrityScanCard result={scanResult} onClose={() => setScanResult(null)} />
) : null} ) : null}
{/* --- Uebernahme --- */} {/* --- Übernahme --- */}
<DialogRoot open={isAdoptDialogOpen} onOpenChange={setIsAdoptDialogOpen}> <DialogRoot open={isAdoptDialogOpen} onOpenChange={setIsAdoptDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader <DialogHeader
title="Repository uebernehmen" title="Repository übernehmen"
description="Ein vorhandenes Repository in die Control Plane aufnehmen." description="Ein vorhandenes Repository in die Control Plane aufnehmen."
/> />
<DialogBody> <DialogBody>
<Callout tone="warning" title="Hier wird nichts angelegt"> <Callout tone="warning" title="Hier wird nichts angelegt">
Ein Repository entsteht auf einem Datentraeger: Ein Repository entsteht auf einem Datenträger:
<code className="mt-1 block rounded bg-sunken px-2 py-1 font-mono text-xs"> <code className="mt-1 block rounded bg-sunken px-2 py-1 font-mono text-xs">
sudo -u syncova syncova-repo create --path /srv/repo --name "Hauptziel" --hardened sudo -u syncova syncova-repo create --path /srv/repo --name "Hauptziel" --hardened
</code> </code>
Dieser Schritt oeffnet das vorhandene und liest seine Kennung aus Dieser Schritt öffnet das vorhandene und liest seine Kennung aus
dem Descriptor. dem Descriptor.
</Callout> </Callout>
@ -364,7 +361,7 @@ export function RepositoriesPage({
value={adoptLocation} value={adoptLocation}
onChange={(changeEvent) => setAdoptLocation(changeEvent.target.value)} onChange={(changeEvent) => setAdoptLocation(changeEvent.target.value)}
placeholder="/srv/syncova-repository" placeholder="/srv/syncova-repository"
hint="Absoluter Pfad auf dem Server. Der Ort muss eindeutig sein — zwei Eintraege auf dasselbe Verzeichnis ergaeben Wettlauf um die Schreibsperre." hint="Absoluter Pfad auf dem Server. Der Ort muss eindeutig sein — zwei Einträge auf dasselbe Verzeichnis ergäben Wettlauf um die Schreibsperre."
/> />
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
@ -382,7 +379,7 @@ export function RepositoriesPage({
}) })
} }
> >
Uebernehmen Übernehmen
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -391,7 +388,7 @@ export function RepositoriesPage({
<ConfirmDialog <ConfirmDialog
open={repositoryPendingScan !== null} open={repositoryPendingScan !== null}
onOpenChange={(isOpen) => !isOpen && setRepositoryPendingScan(null)} onOpenChange={(isOpen) => !isOpen && setRepositoryPendingScan(null)}
title="Integritaetslauf starten" title="Integritätslauf starten"
confirmLabel="Lauf starten" confirmLabel="Lauf starten"
isLoading={scanMutation.isRunning} isLoading={scanMutation.isRunning}
onConfirm={() => { onConfirm={() => {
@ -401,16 +398,14 @@ export function RepositoriesPage({
}} }}
> >
<p className="text-sm text-fg"> <p className="text-sm text-fg">
Jeder Block wird gegen seine gespeicherte Pruefsumme geprueft. Bei Jeder Block wird gegen seine Prüfsumme geprüft. Bei großen Beständen dauert das.
grossen Bestaenden dauert das lange — der Datenschluessel wird dafuer
nicht gebraucht.
</p> </p>
</ConfirmDialog> </ConfirmDialog>
</> </>
); );
} }
/** Ergebnis eines Integritaetslaufs. */ /** Ergebnis eines Integritätslaufs. */
function IntegrityScanCard({ function IntegrityScanCard({
result, result,
onClose, onClose,
@ -423,22 +418,22 @@ function IntegrityScanCard({
return ( return (
<Card className="mt-4"> <Card className="mt-4">
<CardHeader <CardHeader
title="Ergebnis des Integritaetslaufs" title="Ergebnis des Integritätslaufs"
action={ action={
<Button variant="ghost" size="sm" onClick={onClose}> <Button variant="ghost" size="sm" onClick={onClose}>
Schliessen Schließen
</Button> </Button>
} }
/> />
<CardContent> <CardContent>
<Callout tone={hasFindings ? 'critical' : 'healthy'}> <Callout tone={hasFindings ? 'critical' : 'healthy'}>
{hasFindings {hasFindings
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups waeren unvollstaendig.' ? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.'
: 'Ohne Befund. Jeder gepruefte Block stimmt mit seiner Pruefsumme ueberein.'} : 'Ohne Befund. Jeder geprüfte Block stimmt mit seiner Prüfsumme überein.'}
</Callout> </Callout>
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailItem label="Bloecke geprueft"> <DetailItem label="Blöcke geprüft">
{result.chunks_checked.toLocaleString('de-DE')} {result.chunks_checked.toLocaleString('de-DE')}
</DetailItem> </DetailItem>
<DetailItem label="Fehlend"> <DetailItem label="Fehlend">
@ -446,7 +441,7 @@ function IntegrityScanCard({
{result.chunks_missing.toLocaleString('de-DE')} {result.chunks_missing.toLocaleString('de-DE')}
</span> </span>
</DetailItem> </DetailItem>
<DetailItem label="Beschaedigt"> <DetailItem label="Beschädigt">
<span className={result.chunks_corrupted > 0 ? 'text-critical' : undefined}> <span className={result.chunks_corrupted > 0 ? 'text-critical' : undefined}>
{result.chunks_corrupted.toLocaleString('de-DE')} {result.chunks_corrupted.toLocaleString('de-DE')}
</span> </span>
@ -473,10 +468,10 @@ function IntegrityScanCard({
); );
} }
/** Kachel mit der Speicherbelegung, fuer die Uebersicht. */ /** Kachel mit der Speicherbelegung, für die Übersicht. */
export function RepositoryCapacityTile({ repository }: { readonly repository: Repository }) { export function RepositoryCapacityTile({ repository }: { readonly repository: Repository }) {
// Ohne hinterlegte Kapazitaet gibt es keinen Prozentsatz. Ihn zu erfinden // Ohne hinterlegte Kapazität gibt es keinen Prozentsatz. Ihn zu erfinden
// waere die haeufigste Art, mit richtigen Zahlen etwas Falsches zu zeigen. // wäre die häufigste Art, mit richtigen Zahlen etwas Falsches zu zeigen.
const hasCapacity = const hasCapacity =
repository.total_bytes !== undefined && repository.total_bytes > 0; repository.total_bytes !== undefined && repository.total_bytes > 0;
@ -488,7 +483,7 @@ export function RepositoryCapacityTile({ repository }: { readonly repository: Re
<p className="text-xs text-fg-muted"> <p className="text-xs text-fg-muted">
{hasCapacity {hasCapacity
? `${formatBytes(repository.used_bytes)} von ${formatBytes(repository.total_bytes)}` ? `${formatBytes(repository.used_bytes)} von ${formatBytes(repository.total_bytes)}`
: `${formatBytes(repository.used_bytes)} belegt — Gesamtkapazitaet nicht hinterlegt`} : `${formatBytes(repository.used_bytes)} belegt — Gesamtkapazität nicht hinterlegt`}
</p> </p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,94 @@
/**
* Regressionstest zum schwarzen Bildschirm auf /retention.
*
* `GET /retention-policies` liefert ein **Objekt** `{ policies, predefined }`,
* als einziger von zehn geprüften Listenendpunkten. Die Oberfläche behandelte
* es als Array; `map` gibt es auf einem Objekt nicht, React hängte den ganzen
* Baum aus, und übrig blieb eine leere Seite — im dunklen Thema schwarz.
*
* Der Test füttert die **echte** Antwortform. Ein Test mit einem Array hätte
* den Fehler nie gefunden — und genau das war passiert.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ToastProvider } from '@/components/ui';
import { RetentionPage } from './RetentionPage';
/** Baut eine Antwort in der Hülle der API. */
function apiResponse(payload: unknown): Response {
return {
ok: true,
status: 200,
headers: new Headers({ 'Content-Type': 'application/json' }),
json: async () => ({ data: payload, meta: { request_id: 'req-1' } }),
} as unknown as Response;
}
/** Die Antwortform, die der Server tatsächlich liefert. */
const echteAntwort = {
policies: [],
predefined: [
{
name: '7 Tage',
description: 'Behalten werden alles jünger als 7 Tage, die letzten 1 Backups.',
keep_within_seconds: 604800,
keep_last: 1,
keep_daily: 0,
keep_weekly: 0,
keep_monthly: 0,
keep_yearly: 0,
},
],
};
describe('Aufbewahrung', () => {
it('stürzt nicht ab, wenn die Regelliste ein Objekt ist', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (requestUrl: string) =>
String(requestUrl).includes('retention-policies')
? apiResponse(echteAntwort)
: apiResponse([]),
),
);
render(
<ToastProvider>
<RetentionPage grantedPermissions={['*']} />
</ToastProvider>,
);
// Die Seite steht, statt den Baum abzureißen.
await waitFor(() => {
expect(screen.getByText('Aufbewahrung')).toBeInTheDocument();
});
// Und die mitgelieferten Vorlagen erscheinen als anklickbare Kacheln.
expect(await screen.findByText('7 Tage')).toBeInTheDocument();
});
it('kommt ohne die beiden Felder zurecht', async () => {
// Ein Server, der beide Felder weglässt, darf die Seite nicht schwärzen.
vi.stubGlobal(
'fetch',
vi.fn(async (requestUrl: string) =>
String(requestUrl).includes('retention-policies')
? apiResponse({})
: apiResponse([]),
),
);
render(
<ToastProvider>
<RetentionPage grantedPermissions={['*']} />
</ToastProvider>,
);
await waitFor(() => {
expect(screen.getByText('Aufbewahrung')).toBeInTheDocument();
});
expect(screen.getByText('Keine Aufbewahrungsregel')).toBeInTheDocument();
});
});

View File

@ -3,16 +3,16 @@
* *
* Zwei Dinge tragen diese Seite: * Zwei Dinge tragen diese Seite:
* *
* - **`keep_last` schuetzt das letzte vorhandene Backup.** Ohne es loeschte * - **`keep_last` schützt das letzte vorhandene Backup.** Ohne es löschte
* „7 Tage" bei einem drei Wochen nicht gesicherten System *jedes* Backup — * „7 Tage" bei einem drei Wochen nicht gesicherten System *jedes* Backup —
* genau dann, wenn man es am dringendsten braucht. Eine Regel ganz ohne * genau dann, wenn man es am dringendsten braucht. Eine Regel ganz ohne
* Haltevorgabe wird abgelehnt; wird eine 1 ergaenzt, sagt die Oberflaeche das, * Haltevorgabe wird abgelehnt; wird eine 1 ergänzt, sagt die Oberfläche das,
* statt es still zu tun. * statt es still zu tun.
* - **Die Vorschau ist keine Formalie.** Sie loescht nichts und liefert die * - **Die Vorschau ist keine Formalie.** Sie löscht nichts und liefert die
* Zahl, die danach woertlich bestaetigt werden muss. * Zahl, die danach wörtlich bestätigt werden muss.
* *
* Dass beim Loeschen oft **kein Speicher frei wird**, ist kein Fehler, sondern * Dass beim Löschen oft **kein Speicher frei wird**, ist kein Fehler, sondern
* Deduplizierung. Ohne diesen Hinweis erzeugt jede Anwendung eine Rueckfrage. * Deduplizierung. Ohne diesen Hinweis erzeugt jede Anwendung eine Rückfrage.
*/ */
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
@ -85,12 +85,12 @@ export function RetentionPage({
const createMutation = useMutation(createRetentionPolicy, { const createMutation = useMutation(createRetentionPolicy, {
onSuccess: (policy) => { onSuccess: (policy) => {
// Eine stillschweigend ergaenzte Haltevorgabe waere eine Aenderung, die // Eine stillschweigend ergänzte Haltevorgabe wäre eine Änderung, die
// niemand sieht — und die spaeter niemand erklaeren kann. // niemand sieht — und die später niemand erklären kann.
if (!draftKeepLast || Number(draftKeepLast) < 1) { if (!draftKeepLast || Number(draftKeepLast) < 1) {
toast.showInfo( toast.showInfo(
'Regel angelegt', 'Regel angelegt',
`„${policy.name}" — es wurde eine Mindesthaltung von 1 ergaenzt, damit nie das letzte Backup entfaellt.`, `„${policy.name}" — es wurde eine Mindesthaltung von 1 ergänzt, damit nie das letzte Backup entfällt.`,
); );
} else { } else {
toast.showSuccess('Regel angelegt', policy.name); toast.showSuccess('Regel angelegt', policy.name);
@ -101,12 +101,12 @@ export function RetentionPage({
policiesResource.reload(); policiesResource.reload();
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError('Die Regel liess sich nicht anlegen', describeApiError(apiError), apiError.requestId), toast.showError('Die Regel ließ sich nicht anlegen', describeApiError(apiError), apiError.requestId),
}); });
const deleteMutation = useMutation(deleteRetentionPolicy, { const deleteMutation = useMutation(deleteRetentionPolicy, {
onSuccess: () => { onSuccess: () => {
toast.showSuccess('Regel geloescht'); toast.showSuccess('Regel gelöscht');
setPolicyPendingDeletion(null); setPolicyPendingDeletion(null);
policiesResource.reload(); policiesResource.reload();
}, },
@ -119,7 +119,7 @@ export function RetentionPage({
setPreviewResult(preview); setPreviewResult(preview);
toast.showInfo( toast.showInfo(
'Vorschau erstellt', 'Vorschau erstellt',
`${preview.backups_to_delete} Wiederherstellungspunkte wuerden entfallen. Es wurde nichts geloescht.`, `${preview.backups_to_delete} Wiederherstellungspunkte würden entfallen. Es wurde nichts gelöscht.`,
); );
}, },
onError: (apiError) => onError: (apiError) =>
@ -132,7 +132,7 @@ export function RetentionPage({
'Aufbewahrung angewendet', 'Aufbewahrung angewendet',
result.bytes_to_free result.bytes_to_free
? `${formatBytes(result.bytes_to_free)} frei geworden.` ? `${formatBytes(result.bytes_to_free)} frei geworden.`
: 'Es wurde kein Speicher frei — die Bloecke werden von anderen Backups gebraucht (Deduplizierung).', : 'Es wurde kein Speicher frei — die Blöcke werden von anderen Backups gebraucht (Deduplizierung).',
); );
setIsApplyDialogOpen(false); setIsApplyDialogOpen(false);
setPreviewResult(null); setPreviewResult(null);
@ -141,7 +141,8 @@ export function RetentionPage({
toast.showError('Die Anwendung schlug fehl', describeApiError(apiError), apiError.requestId), toast.showError('Die Anwendung schlug fehl', describeApiError(apiError), apiError.requestId),
}); });
const policies = policiesResource.data ?? []; const policies = policiesResource.data?.policies ?? [];
const predefinedTemplates = policiesResource.data?.predefined ?? [];
const repositories = repositoriesResource.data ?? []; const repositories = repositoriesResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<RetentionPolicy>> = [ const tableColumns: ReadonlyArray<TableColumn<RetentionPolicy>> = [
@ -163,14 +164,14 @@ export function RetentionPage({
header: 'Mindestens', header: 'Mindestens',
numeric: true, numeric: true,
render: (policy) => ( render: (policy) => (
<span title="Schuetzt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist."> <span title="Schützt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist.">
{policy.keep_last ?? '—'} {policy.keep_last ?? '—'}
</span> </span>
), ),
}, },
{ {
key: 'grid', key: 'grid',
header: 'Taeglich / Woechentlich / Monatlich / Jaehrlich', header: 'Täglich / Wöchentlich / Monatlich / Jährlich',
render: (policy) => render: (policy) =>
[policy.keep_daily, policy.keep_weekly, policy.keep_monthly, policy.keep_yearly] [policy.keep_daily, policy.keep_weekly, policy.keep_monthly, policy.keep_yearly]
.map((value) => value ?? '—') .map((value) => value ?? '—')
@ -185,7 +186,7 @@ export function RetentionPage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={`Regel ${policy.name} loeschen`} aria-label={`Regel ${policy.name} löschen`}
onClick={(clickEvent) => { onClick={(clickEvent) => {
clickEvent.stopPropagation(); clickEvent.stopPropagation();
setPolicyPendingDeletion(policy); setPolicyPendingDeletion(policy);
@ -227,15 +228,57 @@ export function RetentionPage({
} }
/> />
{mayWrite && predefinedTemplates.length > 0 ? (
<Card className="mb-4"> <Card className="mb-4">
<CardHeader title="Regeln" /> <CardHeader
title="Vorlagen"
description="Mitgeliefert. Ein Klick legt eine eigene Regel daraus an."
/>
<CardContent>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{predefinedTemplates.map((template) => (
<button
key={template.name}
type="button"
disabled={createMutation.isRunning}
onClick={() =>
void createMutation.run({
name: template.name,
keep_within_seconds: template.keep_within_seconds ?? 0,
keep_last: template.keep_last ?? 1,
keep_daily: template.keep_daily ?? 0,
keep_weekly: template.keep_weekly ?? 0,
keep_monthly: template.keep_monthly ?? 0,
keep_yearly: template.keep_yearly ?? 0,
})
}
className="rounded-lg border border-line bg-card p-3 text-left transition-colors hover:border-accent hover:bg-hover disabled:opacity-50"
>
<span className="flex items-center gap-2">
<Plus className="size-3.5 text-accent" aria-hidden />
<span className="text-sm font-medium text-fg">{template.name}</span>
</span>
{template.description ? (
<span className="mt-1 block text-xs text-fg-muted">
{template.description}
</span>
) : null}
</button>
))}
</div>
</CardContent>
</Card>
) : null}
<Card className="mb-4">
<CardHeader title="Eigene Regeln" />
<DataTable <DataTable
columns={tableColumns} columns={tableColumns}
rows={policies} rows={policies}
getRowKey={(policy) => policy.id} getRowKey={(policy) => policy.id}
isLoading={policiesResource.loadState === 'loading'} isLoading={policiesResource.loadState === 'loading'}
emptyTitle="Keine Aufbewahrungsregel" emptyTitle="Keine Aufbewahrungsregel"
emptyDescription="Ohne Regel bleiben alle Wiederherstellungspunkte erhalten, bis jemand sie von Hand loescht." emptyDescription="Ohne Regel bleiben alle Wiederherstellungspunkte erhalten, bis jemand sie von Hand löscht."
/> />
</Card> </Card>
@ -243,7 +286,7 @@ export function RetentionPage({
<Card> <Card>
<CardHeader <CardHeader
title="Regel anwenden" title="Regel anwenden"
description="Die Vorschau loescht nichts und zeigt, was entfiele." description="Die Vorschau löscht nichts und zeigt, was entfiele."
/> />
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
@ -252,7 +295,7 @@ export function RetentionPage({
value={selectedRepositoryId} value={selectedRepositoryId}
onChange={(changeEvent) => setSelectedRepositoryId(changeEvent.target.value)} onChange={(changeEvent) => setSelectedRepositoryId(changeEvent.target.value)}
> >
<option value="">Bitte waehlen</option> <option value="">Bitte wählen</option>
{repositories.map((repository) => ( {repositories.map((repository) => (
<option key={repository.id} value={repository.id}> <option key={repository.id} value={repository.id}>
{repository.name} {repository.name}
@ -265,7 +308,7 @@ export function RetentionPage({
value={selectedPolicyId} value={selectedPolicyId}
onChange={(changeEvent) => setSelectedPolicyId(changeEvent.target.value)} onChange={(changeEvent) => setSelectedPolicyId(changeEvent.target.value)}
> >
<option value="">Bitte waehlen</option> <option value="">Bitte wählen</option>
{policies.map((policy) => ( {policies.map((policy) => (
<option key={policy.id} value={policy.id}> <option key={policy.id} value={policy.id}>
{policy.name} {policy.name}
@ -294,7 +337,7 @@ export function RetentionPage({
: undefined : undefined
} }
> >
Anwenden und loeschen Anwenden und löschen
</Button> </Button>
</div> </div>
@ -302,18 +345,18 @@ export function RetentionPage({
<div className="rounded-md border border-line bg-sunken p-3"> <div className="rounded-md border border-line bg-sunken p-3">
<p className="text-sm text-fg"> <p className="text-sm text-fg">
<strong>{previewResult.backups_to_delete}</strong>{' '} <strong>{previewResult.backups_to_delete}</strong>{' '}
Wiederherstellungspunkte wuerden entfallen Wiederherstellungspunkte würden entfallen
{previewResult.backups_protected {previewResult.backups_protected
? `, ${previewResult.backups_protected} bleiben durch Schutz erhalten` ? `, ${previewResult.backups_protected} bleiben durch Schutz erhalten`
: ''} : ''}
. .
</p> </p>
<p className="mt-1 text-sm text-fg-muted"> <p className="mt-1 text-sm text-fg-muted">
Frei wuerden voraussichtlich {formatBytes(previewResult.bytes_to_free)}. Frei würden voraussichtlich {formatBytes(previewResult.bytes_to_free)}.
</p> </p>
<p className="mt-2 text-xs text-fg-muted"> <p className="mt-2 text-xs text-fg-muted">
Deutlich weniger als die Summe der Backupgroessen ist der Deutlich weniger als die Summe der Backupgrößen ist der
Normalfall: Deduplizierte Bloecke werden von anderen Normalfall: Deduplizierte Blöcke werden von anderen
Wiederherstellungspunkten weiter gebraucht. Wiederherstellungspunkten weiter gebraucht.
</p> </p>
</div> </div>
@ -349,10 +392,10 @@ export function RetentionPage({
min={1} min={1}
value={draftKeepLast} value={draftKeepLast}
onChange={(changeEvent) => setDraftKeepLast(changeEvent.target.value)} onChange={(changeEvent) => setDraftKeepLast(changeEvent.target.value)}
hint="Schuetzt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist." hint="Schützt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist."
/> />
<TextInput <TextInput
label="Taegliche behalten" label="Tägliche behalten"
type="number" type="number"
min={0} min={0}
value={draftKeepDaily} value={draftKeepDaily}
@ -368,9 +411,7 @@ export function RetentionPage({
</div> </div>
<Callout tone="warning"> <Callout tone="warning">
Eine Regel <strong>ohne jede Haltevorgabe</strong> wird abgelehnt. Ohne Haltevorgabe wird die Regel abgelehnt.
Sonst loeschte „30 Tage" bei einem laenger nicht gesicherten System
jedes Backup — genau dann, wenn man es braucht.
</Callout> </Callout>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
@ -400,9 +441,9 @@ export function RetentionPage({
<ConfirmDialog <ConfirmDialog
open={policyPendingDeletion !== null} open={policyPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setPolicyPendingDeletion(null)} onOpenChange={(isOpen) => !isOpen && setPolicyPendingDeletion(null)}
title="Regel loeschen" title="Regel löschen"
description={policyPendingDeletion?.name} description={policyPendingDeletion?.name}
confirmLabel="Loeschen" confirmLabel="Löschen"
isDestructive isDestructive
isLoading={deleteMutation.isRunning} isLoading={deleteMutation.isRunning}
onConfirm={() => { onConfirm={() => {
@ -412,8 +453,7 @@ export function RetentionPage({
}} }}
> >
<p className="text-sm text-fg-muted"> <p className="text-sm text-fg-muted">
Bereits geloeschte Wiederherstellungspunkte kommen dadurch nicht Künftig wird nach dieser Regel nichts mehr entfernt.
zurueck. Kuenftig wird nach dieser Regel nichts mehr entfernt.
</p> </p>
</ConfirmDialog> </ConfirmDialog>
@ -421,14 +461,14 @@ export function RetentionPage({
open={isApplyDialogOpen} open={isApplyDialogOpen}
onOpenChange={setIsApplyDialogOpen} onOpenChange={setIsApplyDialogOpen}
title="Aufbewahrung anwenden" title="Aufbewahrung anwenden"
description="Wiederherstellungspunkte werden endgueltig entfernt." description="Wiederherstellungspunkte werden endgültig entfernt."
confirmLabel="Endgueltig loeschen" confirmLabel="Endgültig löschen"
isDestructive isDestructive
isLoading={applyMutation.isRunning} isLoading={applyMutation.isRunning}
confirmationWord={String(previewResult?.backups_to_delete ?? '')} confirmationWord={String(previewResult?.backups_to_delete ?? '')}
confirmationHint={ confirmationHint={
<> <>
Tippen Sie zur Bestaetigung die Zahl der zu loeschenden Punkte:{' '} Tippen Sie zur Bestätigung die Zahl der zu löschenden Punkte:{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs"> <code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{previewResult?.backups_to_delete} {previewResult?.backups_to_delete}
</code> </code>
@ -443,8 +483,7 @@ export function RetentionPage({
} }
> >
<Callout tone="critical"> <Callout tone="critical">
Geschuetzte Punkte (Aufbewahrungsfrist oder Legal Hold) bleiben Geschützte Punkte bleiben unberührt.
unberuehrt — der Server lehnt ihre Loeschung ab.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>

View File

@ -1,12 +1,12 @@
/** /**
* API-Anbindung der Repositories (SYNCOVA_API.md §8). * API-Anbindung der Repositories (SYNCOVA_API.md §8).
* *
* Ein Punkt praegt die ganze Maske: **`POST /repositories` legt nichts an, es * Ein Punkt prägt die ganze Maske: **`POST /repositories` legt nichts an, es
* uebernimmt.** Ein Repository entsteht auf einem Datentraeger — mit * übernimmt.** Ein Repository entsteht auf einem Datenträger — mit
* `syncova-repo create` —, nicht in einer Datenbankzeile. Der Endpunkt oeffnet * `syncova-repo create` —, nicht in einer Datenbankzeile. Der Endpunkt öffnet
* ein vorhandenes und liest dessen Kennung aus dem Descriptor. * ein vorhandenes und liest dessen Kennung aus dem Descriptor.
* *
* Wer das nicht weiss, traegt einen Pfad ein, bekommt einen Fehler und sucht * Wer das nicht weiß, trägt einen Pfad ein, bekommt einen Fehler und sucht
* ihn an der falschen Stelle. * ihn an der falschen Stelle.
*/ */
@ -24,21 +24,21 @@ export interface Repository {
hardened: boolean; hardened: boolean;
/** Belegter Speicher; nicht immer bekannt. */ /** Belegter Speicher; nicht immer bekannt. */
used_bytes?: number; used_bytes?: number;
/** Gesamtkapazitaet, sofern hinterlegt. */ /** Gesamtkapazität, sofern hinterlegt. */
total_bytes?: number; total_bytes?: number;
/** Gemessene Durchsetzungsstufe des Loeschschutzes. */ /** Gemessene Durchsetzungsstufe des Löschschutzes. */
enforcement_level?: string; enforcement_level?: string;
last_health_check_at?: string; last_health_check_at?: string;
created_at?: string; created_at?: string;
} }
/** Ergebnis eines Integritaetslaufs. */ /** Ergebnis eines Integritätslaufs. */
export interface IntegrityScanResult { export interface IntegrityScanResult {
/** Geprueft. */ /** Geprüft. */
chunks_checked: number; chunks_checked: number;
/** Nicht auffindbar. */ /** Nicht auffindbar. */
chunks_missing: number; chunks_missing: number;
/** Pruefsumme stimmt nicht. */ /** Prüfsumme stimmt nicht. */
chunks_corrupted: number; chunks_corrupted: number;
manifests_checked?: number; manifests_checked?: number;
manifests_invalid?: number; manifests_invalid?: number;
@ -49,7 +49,7 @@ export interface IntegrityScanResult {
findings?: string[]; findings?: string[];
} }
/** Ergebnis einer Gesundheitspruefung. */ /** Ergebnis einer Gesundheitsprüfung. */
export interface RepositoryHealth { export interface RepositoryHealth {
status: string; status: string;
/** Freier Platz am Ablageort. */ /** Freier Platz am Ablageort. */
@ -68,9 +68,9 @@ export interface RetentionPreview {
/** Punkte, die durch Schutz erhalten bleiben. */ /** Punkte, die durch Schutz erhalten bleiben. */
backups_protected?: number; backups_protected?: number;
/** /**
* Speicher, der frei wuerde. * Speicher, der frei würde.
* *
* Oft deutlich weniger als die Summe der Backupgroessen — das ist * Oft deutlich weniger als die Summe der Backupgrößen — das ist
* Deduplizierung und kein Fehler. * Deduplizierung und kein Fehler.
*/ */
bytes_to_free?: number; bytes_to_free?: number;
@ -78,12 +78,12 @@ export interface RetentionPreview {
affected?: Array<{ backup_id: string; completed_at?: string; reason?: string }>; affected?: Array<{ backup_id: string; completed_at?: string; reason?: string }>;
} }
/** Laedt die Repositories. */ /** Lädt die Repositories. */
export async function listRepositories(abortSignal?: AbortSignal): Promise<Repository[]> { export async function listRepositories(abortSignal?: AbortSignal): Promise<Repository[]> {
return requestApi<Repository[]>('/repositories', abortSignal ? { signal: abortSignal } : {}); return requestApi<Repository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
} }
/** Laedt ein einzelnes Repository. */ /** Lädt ein einzelnes Repository. */
export async function getRepository( export async function getRepository(
repositoryIdentifier: string, repositoryIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -95,10 +95,10 @@ export async function getRepository(
} }
/** /**
* Uebernimmt ein vorhandenes Repository in die Control Plane. * Übernimmt ein vorhandenes Repository in die Control Plane.
* *
* Es wird **nicht angelegt**: Der Server oeffnet das Verzeichnis und liest die * Es wird **nicht angelegt**: Der Server öffnet das Verzeichnis und liest die
* Kennung aus dem Descriptor. Liegt dort keines, schlaegt der Aufruf fehl. * Kennung aus dem Descriptor. Liegt dort keines, schlägt der Aufruf fehl.
*/ */
export async function adoptRepository(repositoryRequest: { export async function adoptRepository(repositoryRequest: {
name: string; name: string;
@ -112,7 +112,7 @@ export async function adoptRepository(repositoryRequest: {
}); });
} }
/** Aendert Name oder Zustand eines Repositorys. */ /** Ändert Name oder Zustand eines Repositorys. */
export async function updateRepository( export async function updateRepository(
repositoryIdentifier: string, repositoryIdentifier: string,
changes: { name?: string; status?: string }, changes: { name?: string; status?: string },
@ -123,7 +123,7 @@ export async function updateRepository(
); );
} }
/** Prueft die Erreichbarkeit. */ /** Prüft die Erreichbarkeit. */
export async function testRepository( export async function testRepository(
repositoryIdentifier: string, repositoryIdentifier: string,
): Promise<RepositoryHealth> { ): Promise<RepositoryHealth> {
@ -133,7 +133,7 @@ export async function testRepository(
); );
} }
/** Fuehrt eine Gesundheitspruefung aus. */ /** Führt eine Gesundheitsprüfung aus. */
export async function checkRepositoryHealth( export async function checkRepositoryHealth(
repositoryIdentifier: string, repositoryIdentifier: string,
): Promise<RepositoryHealth> { ): Promise<RepositoryHealth> {
@ -144,11 +144,11 @@ export async function checkRepositoryHealth(
} }
/** /**
* Startet einen Integritaetslauf. * Startet einen Integritätslauf.
* *
* Er prueft jeden Block gegen die gespeicherte Pruefsumme der **abgelegten** * Er prüft jeden Block gegen die gespeicherte Prüfsumme der **abgelegten**
* Form — deshalb braucht er den Datenschluessel nicht. Ein Befund ist ein * Form — deshalb braucht er den Datenschlüssel nicht. Ein Befund ist ein
* Ergebnis, kein Fehler des Laufs; die Oberflaeche trennt beides. * Ergebnis, kein Fehler des Laufs; die Oberfläche trennt beides.
*/ */
export async function startIntegrityScan( export async function startIntegrityScan(
repositoryIdentifier: string, repositoryIdentifier: string,
@ -163,7 +163,7 @@ export async function startIntegrityScan(
* Baut den Katalog neu auf. * Baut den Katalog neu auf.
* *
* Der Katalog ist nur ein Beschleuniger; verbindlich sind die Manifeste. Er * Der Katalog ist nur ein Beschleuniger; verbindlich sind die Manifeste. Er
* laesst sich jederzeit verlustfrei neu erzeugen — genau das ist der Nachweis, * lässt sich jederzeit verlustfrei neu erzeugen — genau das ist der Nachweis,
* dass das Repository ohne die Datenbank auskommt. * dass das Repository ohne die Datenbank auskommt.
*/ */
export async function rebuildCatalog( export async function rebuildCatalog(
@ -176,10 +176,10 @@ export async function rebuildCatalog(
} }
/** /**
* Misst die Durchsetzungsstufe des Loeschschutzes. * Misst die Durchsetzungsstufe des Löschschutzes.
* *
* Gemessen, nicht behauptet: Der Server legt Probedateien an und versucht sie * Gemessen, nicht behauptet: Der Server legt Probedateien an und versucht sie
* zu loeschen. Gemeldet wird nur, was das Betriebssystem nachweislich * zu löschen. Gemeldet wird nur, was das Betriebssystem nachweislich
* verhindert. * verhindert.
*/ */
export async function measureEnforcement( export async function measureEnforcement(
@ -197,7 +197,7 @@ export async function measureEnforcement(
); );
} }
/** Zeigt, was eine Aufbewahrungsregel entfernen wuerde. Loescht nichts. */ /** Zeigt, was eine Aufbewahrungsregel entfernen würde. Löscht nichts. */
export async function previewRetention( export async function previewRetention(
repositoryIdentifier: string, repositoryIdentifier: string,
policyIdentifier: string, policyIdentifier: string,
@ -209,9 +209,9 @@ export async function previewRetention(
} }
/** /**
* Wendet eine Aufbewahrungsregel an und loescht. * Wendet eine Aufbewahrungsregel an und löscht.
* *
* `confirm_deletion` wiederholt die Zahl der zu loeschenden Punkte woertlich — * `confirm_deletion` wiederholt die Zahl der zu löschenden Punkte wörtlich —
* die Vorschau davor ist keine Formalie, sondern die Grundlage dieser Zahl. * die Vorschau davor ist keine Formalie, sondern die Grundlage dieser Zahl.
*/ */
export async function applyRetention( export async function applyRetention(
@ -238,7 +238,7 @@ export interface RetentionPolicy {
id: string; id: string;
name: string; name: string;
keep_within_seconds?: number; keep_within_seconds?: number;
/** Schuetzt das letzte vorhandene Backup. */ /** Schützt das letzte vorhandene Backup. */
keep_last?: number; keep_last?: number;
keep_daily?: number; keep_daily?: number;
keep_weekly?: number; keep_weekly?: number;
@ -247,14 +247,41 @@ export interface RetentionPolicy {
time_zone?: string; time_zone?: string;
} }
/** Laedt die Aufbewahrungsregeln. */ /**
* Antwort auf die Regelliste.
*
* **Dieser Endpunkt liefert ein Objekt, keine Liste** — als einziger von zehn
* geprüften. Ihn als Array zu behandeln ließ die Seite abstürzen: `map` gibt es
* auf einem Objekt nicht, React hängte den ganzen Baum aus, und übrig blieb ein
* schwarzer Bildschirm.
*/
export interface RetentionPolicyList {
/** Selbst angelegte Regeln. */
policies: RetentionPolicy[];
/**
* Mitgelieferte Vorlagen.
*
* Sie tragen keine Kennung — es sind Vorschläge, keine Regeln. Wer eine
* übernimmt, legt damit eine eigene an.
*/
predefined: Array<Omit<RetentionPolicy, 'id'> & { description?: string }>;
}
/** Lädt die Aufbewahrungsregeln samt Vorlagen. */
export async function listRetentionPolicies( export async function listRetentionPolicies(
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<RetentionPolicy[]> { ): Promise<RetentionPolicyList> {
return requestApi<RetentionPolicy[]>( const response = await requestApi<RetentionPolicyList>(
'/retention-policies', '/retention-policies',
abortSignal ? { signal: abortSignal } : {}, abortSignal ? { signal: abortSignal } : {},
); );
// Fehlt ein Feld, ist die Liste leer — nicht undefined. Ein `?? []` an jeder
// Verwendungsstelle wäre ein `??` zu viel, das irgendwann jemand vergisst.
return {
policies: response.policies ?? [],
predefined: response.predefined ?? [],
};
} }
/** Legt eine Aufbewahrungsregel an. */ /** Legt eine Aufbewahrungsregel an. */
@ -268,7 +295,7 @@ export async function createRetentionPolicy(
}); });
} }
/** Loescht eine Aufbewahrungsregel. */ /** Löscht eine Aufbewahrungsregel. */
export async function deleteRetentionPolicy(policyIdentifier: string): Promise<void> { export async function deleteRetentionPolicy(policyIdentifier: string): Promise<void> {
return requestApi<void>(`/retention-policies/${encodeURIComponent(policyIdentifier)}`, { return requestApi<void>(`/retention-policies/${encodeURIComponent(policyIdentifier)}`, {
method: 'DELETE', method: 'DELETE',

View File

@ -1,26 +1,26 @@
/** /**
* Wiederherstellungs-Assistent — die Kernfunktion des Produkts. * Wiederherstellungs-Assistent — die Kernfunktion des Produkts.
* *
* Bis hierher liess sich eine Wiederherstellung ueber die Oberflaeche gar nicht * Bis hierher ließ sich eine Wiederherstellung über die Oberfläche gar nicht
* ausloesen. Wer im Ernstfall Daten zurueckbrauchte, musste `curl` bemuehen — * auslösen. Wer im Ernstfall Daten zurückbrauchte, musste `curl` bemühen —
* ausgerechnet in der Lage, in der niemand experimentieren will. * ausgerechnet in der Lage, in der niemand experimentieren will.
* *
* Der Assistent hat vier Schritte, und der dritte ist der Grund fuer seine * Der Assistent hat vier Schritte, und der dritte ist der Grund für seine
* Existenz: **Die Vorabpruefung schreibt nichts und stellt fest, ob die * Existenz: **Die Vorabprüfung schreibt nichts und stellt fest, ob die
* Wiederherstellung gelingen kann** — insbesondere, ob jeder benoetigte Block * Wiederherstellung gelingen kann** — insbesondere, ob jeder benötigte Block
* noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas * noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas
* gesichert hat. * gesichert hat.
* *
* Die drei Huerden vor dem Ueberschreiben sind hier sichtbar umgesetzt: * Die drei Hürden vor dem Überschreiben sind hier sichtbar umgesetzt:
* *
* 1. Das Kennzeichen `overwrite_existing` muss gesetzt werden. * 1. Das Kennzeichen `overwrite_existing` muss gesetzt werden.
* 2. Die Berechtigung `restores.overwrite` prueft der Server; sie steckt nicht * 2. Die Berechtigung `restores.overwrite` prüft der Server; sie steckt nicht
* in `restores.execute`. * in `restores.execute`.
* 3. `confirm_overwrite` verlangt den **woertlich wiederholten Zielpfad**. * 3. `confirm_overwrite` verlangt den **wörtlich wiederholten Zielpfad**.
* *
* Laeuft die dritte Huerde ins Leere — weil das Ziel leer ist und nichts * Läuft die dritte Hürde ins Leere — weil das Ziel leer ist und nichts
* ueberschrieben wird —, entfaellt sie. Ein Ritual ohne Anlass gewoehnt das * überschrieben wird —, entfällt sie. Ein Ritual ohne Anlass gewöhnt das
* Wegklicken an, und dann wirkt es dort nicht mehr, wo es zaehlt. * Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt.
*/ */
import { AlertTriangle, ArrowLeft, ArrowRight, Check, ShieldAlert } from 'lucide-react'; import { AlertTriangle, ArrowLeft, ArrowRight, Check, ShieldAlert } from 'lucide-react';
@ -52,8 +52,8 @@ import {
const WIZARD_STEPS = [ const WIZARD_STEPS = [
{ key: 'ziel', label: 'Ziel' }, { key: 'ziel', label: 'Ziel' },
{ key: 'umfang', label: 'Umfang' }, { key: 'umfang', label: 'Umfang' },
{ key: 'pruefung', label: 'Vorabpruefung' }, { key: 'prüfung', label: 'Vorabprüfung' },
{ key: 'ausfuehren', label: 'Ausfuehren' }, { key: 'ausführen', label: 'Ausführen' },
] as const; ] as const;
export function RestoreWizard({ export function RestoreWizard({
@ -74,7 +74,7 @@ export function RestoreWizard({
const [currentStepIndex, setCurrentStepIndex] = useState(0); const [currentStepIndex, setCurrentStepIndex] = useState(0);
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern — sonst // Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern — sonst
// waere jeder Blick zurueck ein Datenverlust (wie im Backup-Assistenten). // wäre jeder Blick zurück ein Datenverlust (wie im Backup-Assistenten).
const [targetPath, setTargetPath] = useState(''); const [targetPath, setTargetPath] = useState('');
const [pathPrefix, setPathPrefix] = useState(''); const [pathPrefix, setPathPrefix] = useState('');
const [overwriteExisting, setOverwriteExisting] = useState(false); const [overwriteExisting, setOverwriteExisting] = useState(false);
@ -127,7 +127,7 @@ export function RestoreWizard({
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Die Vorabpruefung ist fehlgeschlagen', 'Die Vorabprüfung ist fehlgeschlagen',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -138,13 +138,13 @@ export function RestoreWizard({
onSuccess: (session) => { onSuccess: (session) => {
toast.showSuccess( toast.showSuccess(
'Wiederherstellung gestartet', 'Wiederherstellung gestartet',
'Der Fortschritt erscheint in der Uebersicht.', 'Der Fortschritt erscheint in der Übersicht.',
); );
onStarted(session.id); onStarted(session.id);
}, },
onError: (apiError) => onError: (apiError) =>
toast.showError( toast.showError(
'Die Wiederherstellung liess sich nicht starten', 'Die Wiederherstellung ließ sich nicht starten',
describeApiError(apiError), describeApiError(apiError),
apiError.requestId, apiError.requestId,
), ),
@ -152,8 +152,8 @@ export function RestoreWizard({
const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/'); const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/');
// Die woertliche Bestaetigung wird nur verlangt, wenn tatsaechlich // Die wörtliche Bestätigung wird nur verlangt, wenn tatsächlich
// ueberschrieben wird — der Server sagt das nach der Pruefung. // überschrieben wird — der Server sagt das nach der Prüfung.
const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false; const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false;
const confirmationSatisfied = const confirmationSatisfied =
!confirmationRequired || typedConfirmation.trim() === targetPath.trim(); !confirmationRequired || typedConfirmation.trim() === targetPath.trim();
@ -186,10 +186,7 @@ export function RestoreWizard({
/> />
<Callout tone="info" title="Wohin am besten?"> <Callout tone="info" title="Wohin am besten?">
In ein <strong>leeres Verzeichnis</strong>. Zurueck an den Am besten in ein leeres Verzeichnis — der Ursprungsort wäre sonst überschrieben.
Ursprungsort zu schreiben ueberschreibt genau die Daten, deren
Zustand Sie vielleicht noch brauchen — etwa um zu vergleichen,
was gefehlt hat.
</Callout> </Callout>
</div> </div>
) : null} ) : null}
@ -201,7 +198,7 @@ export function RestoreWizard({
placeholder="daten/projekte" placeholder="daten/projekte"
value={pathPrefix} value={pathPrefix}
onChange={(changeEvent) => setPathPrefix(changeEvent.target.value)} onChange={(changeEvent) => setPathPrefix(changeEvent.target.value)}
hint="Leer lassen, um alles zurueckzuschreiben. Ein Teilbaum-Restore hebt die Einstufung des Wiederherstellungspunkts nicht — er prueft einen Teil, nicht das Backup." hint="Leer lassen, um alles zurückzuschreiben. Ein Teilbaum-Restore hebt die Einstufung des Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das Backup."
/> />
<div className="space-y-3 border-t border-line pt-4"> <div className="space-y-3 border-t border-line pt-4">
@ -209,10 +206,10 @@ export function RestoreWizard({
checked={overwriteExisting} checked={overwriteExisting}
onCheckedChange={setOverwriteExisting} onCheckedChange={setOverwriteExisting}
disabled={!mayOverwrite} disabled={!mayOverwrite}
label="Vorhandene Dateien ueberschreiben" label="Vorhandene Dateien überschreiben"
hint={ hint={
mayOverwrite mayOverwrite
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusaetzlich verlangt der Server danach den woertlich wiederholten Zielpfad.' ? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusätzlich verlangt der Server danach den wörtlich wiederholten Zielpfad.'
: 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.' : 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.'
} }
/> />
@ -220,21 +217,21 @@ export function RestoreWizard({
<CheckboxField <CheckboxField
checked={skipPermissions} checked={skipPermissions}
onCheckedChange={setSkipPermissions} onCheckedChange={setSkipPermissions}
label="Rechte nicht zurueckschreiben" label="Rechte nicht zurückschreiben"
hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten." hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten."
/> />
<CheckboxField <CheckboxField
checked={skipDeepCheck} checked={skipDeepCheck}
onCheckedChange={setSkipDeepCheck} onCheckedChange={setSkipDeepCheck}
label="Blockpruefung ueberspringen" label="Blockprüfung überspringen"
hint="Beschleunigt die Vorabpruefung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benoetigte Block noch vorhanden ist." hint="Beschleunigt die Vorabprüfung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benötigte Block noch vorhanden ist."
/> />
</div> </div>
{skipDeepCheck ? ( {skipDeepCheck ? (
<Callout tone="warning"> <Callout tone="warning">
Ohne Blockpruefung sagt die Vorabpruefung nur, dass das Ohne Blockprüfung sagt die Vorabprüfung nur, dass das
Manifest lesbar ist — nicht, dass die Daten dazu noch Manifest lesbar ist — nicht, dass die Daten dazu noch
existieren. Der Bericht weist das aus. existieren. Der Bericht weist das aus.
</Callout> </Callout>
@ -255,11 +252,11 @@ export function RestoreWizard({
{confirmationRequired ? ( {confirmationRequired ? (
<> <>
Im Zielverzeichnis liegen bereits Daten. Sie werden{' '} Im Zielverzeichnis liegen bereits Daten. Sie werden{' '}
<strong>ueberschrieben</strong>. <strong>überschrieben</strong>.
</> </>
) : ( ) : (
<> <>
Das Ziel ist leer. Es wird nichts ueberschrieben. Das Ziel ist leer. Es wird nichts überschrieben.
</> </>
)} )}
</Callout> </Callout>
@ -281,8 +278,8 @@ export function RestoreWizard({
{confirmationRequired ? ( {confirmationRequired ? (
<Field <Field
label="Zielpfad zur Bestaetigung wiederholen" label="Zielpfad zur Bestätigung wiederholen"
hint="Woertlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus." hint="Wörtlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus."
required required
htmlFor="restore-confirm" htmlFor="restore-confirm"
> >
@ -308,7 +305,7 @@ export function RestoreWizard({
disabled={validationMutation.isRunning || createMutation.isRunning} disabled={validationMutation.isRunning || createMutation.isRunning}
> >
<ArrowLeft /> <ArrowLeft />
Zurueck Zurück
</Button> </Button>
) : ( ) : (
<Button variant="ghost" onClick={onClose}> <Button variant="ghost" onClick={onClose}>
@ -333,7 +330,7 @@ export function RestoreWizard({
isLoading={validationMutation.isRunning} isLoading={validationMutation.isRunning}
onClick={() => void validationMutation.run()} onClick={() => void validationMutation.run()}
> >
Vorabpruefung starten Vorabprüfung starten
<ArrowRight /> <ArrowRight />
</Button> </Button>
) : null} ) : null}
@ -401,10 +398,10 @@ function StepIndicator({ currentStepIndex }: { readonly currentStepIndex: number
} }
/** /**
* Ergebnis der Vorabpruefung. * Ergebnis der Vorabprüfung.
* *
* Fehlende Bloecke stehen ganz oben und in Rot. Sie sind der einzige Befund, * Fehlende Blöcke stehen ganz oben und in Rot. Sie sind der einzige Befund,
* bei dem feststeht, dass die Wiederherstellung **nicht vollstaendig** gelingen * bei dem feststeht, dass die Wiederherstellung **nicht vollständig** gelingen
* kann — alles andere ist ein Hinweis. * kann — alles andere ist ein Hinweis.
*/ */
function ValidationSummary({ function ValidationSummary({
@ -417,8 +414,8 @@ function ValidationSummary({
if (isRunning || !validationResult) { if (isRunning || !validationResult) {
return ( return (
<div className="py-10 text-center text-sm text-fg-muted"> <div className="py-10 text-center text-sm text-fg-muted">
Die Vorabpruefung laeuft. Bei aktiver Blockpruefung wird jeder Die Vorabprüfung läuft. Bei aktiver Blockprüfung wird jeder
benoetigte Block gesucht — das dauert bei grossen Bestaenden. benötigte Block gesucht — das dauert bei großen Beständen.
</div> </div>
); );
} }
@ -453,7 +450,7 @@ function ValidationSummary({
> >
{validationResult.can_proceed {validationResult.can_proceed
? 'Die Wiederherstellung kann beginnen' ? 'Die Wiederherstellung kann beginnen'
: 'Die Wiederherstellung ist nicht moeglich'} : 'Die Wiederherstellung ist nicht möglich'}
</p> </p>
<p className="mt-0.5 text-sm text-fg">{validationResult.summary}</p> <p className="mt-0.5 text-sm text-fg">{validationResult.summary}</p>
</div> </div>
@ -474,15 +471,15 @@ function ValidationSummary({
<div> <div>
<dt className="text-xs text-fg-subtle">Freier Platz am Ziel</dt> <dt className="text-xs text-fg-subtle">Freier Platz am Ziel</dt>
<dd className="tabular text-sm text-fg"> <dd className="tabular text-sm text-fg">
{/* -1 bedeutet unbekannt. Als 0 zu zeigen waere das Gegenteil {/* -1 bedeutet unbekannt. Als 0 zu zeigen wäre das Gegenteil
der Wahrheit — es saehe nach "voll" aus. */} der Wahrheit — es sähe nach "voll" aus. */}
{report.available_target_bytes < 0 {report.available_target_bytes < 0
? 'unbekannt' ? 'unbekannt'
: formatBytes(report.available_target_bytes)} : formatBytes(report.available_target_bytes)}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-xs text-fg-subtle">Dauer der Pruefung</dt> <dt className="text-xs text-fg-subtle">Dauer der Prüfung</dt>
<dd className="tabular text-sm text-fg"> <dd className="tabular text-sm text-fg">
{formatDuration(report.duration_seconds)} {formatDuration(report.duration_seconds)}
</dd> </dd>
@ -491,11 +488,11 @@ function ValidationSummary({
) : null} ) : null}
{report && report.missing_chunk_count > 0 ? ( {report && report.missing_chunk_count > 0 ? (
<Callout tone="critical" title={`${report.missing_chunk_count} Bloecke fehlen`}> <Callout tone="critical" title={`${report.missing_chunk_count} Blöcke fehlen`}>
Von {report.unique_chunk_count.toLocaleString('de-DE')} benoetigten Von {report.unique_chunk_count.toLocaleString('de-DE')} benötigten
Bloecken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht Blöcken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht
auffindbar. Die Wiederherstellung waere unvollstaendig. Pruefen Sie das auffindbar. Die Wiederherstellung wäre unvollständig. Prüfen Sie das
Repository mit einem Integritaetslauf. Repository mit einem Integritätslauf.
</Callout> </Callout>
) : null} ) : null}

View File

@ -1,14 +1,14 @@
/** /**
* Wiederherstellungen — Uebersicht und Bedienung. * Wiederherstellungen — Übersicht und Bedienung.
* *
* Der Zustand `failed` mit vorhandenem Pruefpunkt ist der interessanteste Fall * Der Zustand `failed` mit vorhandenem Prüfpunkt ist der interessanteste Fall
* dieser Seite: Er bedeutet nicht "verloren", sondern "unterbrochen". Die * dieser Seite: Er bedeutet nicht "verloren", sondern "unterbrochen". Die
* Fortsetzung schreibt ab dem letzten vollstaendig abgelegten Pfad weiter. * Fortsetzung schreibt ab dem letzten vollständig abgelegten Pfad weiter.
* *
* Dass es **keinen automatischen** Wiederholungsversuch gibt, ist Absicht: Ein * Dass es **keinen automatischen** Wiederholungsversuch gibt, ist Absicht: Ein
* zweiter Lauf in ein halb gefuelltes Ziel kann Daten beschaedigen, die der * zweiter Lauf in ein halb gefülltes Ziel kann Daten beschädigen, die der
* erste bereits am Platz hatte. Die Fortsetzung ist deshalb eine Handlung, die * erste bereits am Platz hatte. Die Fortsetzung ist deshalb eine Handlung, die
* jemand ausloest — und die Seite sagt, was dabei geschieht. * jemand auslöst — und die Seite sagt, was dabei geschieht.
*/ */
import { Ban, PlayCircle, RefreshCw } from 'lucide-react'; import { Ban, PlayCircle, RefreshCw } from 'lucide-react';
@ -103,7 +103,7 @@ export function RestoresPage({
render: (session) => ( render: (session) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusBadge status={session.status} /> <StatusBadge status={session.status} />
{/* Ein Pruefpunkt bei gescheitertem Lauf heisst "unterbrochen", {/* Ein Prüfpunkt bei gescheitertem Lauf heißt "unterbrochen",
nicht "verloren". Das ist der Unterschied zwischen einer nicht "verloren". Das ist der Unterschied zwischen einer
Fortsetzung und einem Neubeginn. */} Fortsetzung und einem Neubeginn. */}
{session.status === 'failed' && session.checkpoint ? ( {session.status === 'failed' && session.checkpoint ? (
@ -130,7 +130,7 @@ export function RestoresPage({
{session.files_restored.toLocaleString('de-DE')} {session.files_restored.toLocaleString('de-DE')}
{session.files_skipped > 0 ? ( {session.files_skipped > 0 ? (
<span className="ml-1 text-warning"> <span className="ml-1 text-warning">
({session.files_skipped.toLocaleString('de-DE')} uebergangen) ({session.files_skipped.toLocaleString('de-DE')} übergangen)
</span> </span>
) : null} ) : null}
</span> </span>
@ -138,7 +138,7 @@ export function RestoresPage({
}, },
{ {
key: 'bytes_restored', key: 'bytes_restored',
header: 'Zurueckgeschrieben', header: 'Zurückgeschrieben',
numeric: true, numeric: true,
render: (session) => formatBytes(session.bytes_restored), render: (session) => formatBytes(session.bytes_restored),
}, },
@ -200,7 +200,7 @@ export function RestoresPage({
<Callout tone="info" className="mb-4"> <Callout tone="info" className="mb-4">
Eine Wiederherstellung wird bei einem <strong>Wiederherstellungspunkt</strong>{' '} Eine Wiederherstellung wird bei einem <strong>Wiederherstellungspunkt</strong>{' '}
gestartet — dort steht, was gesichert wurde und ob es geprueft ist. gestartet — dort steht, was gesichert wurde und ob es geprüft ist.
</Callout> </Callout>
<Card> <Card>
@ -211,7 +211,7 @@ export function RestoresPage({
onRowClick={(session) => setSelectedSession(session)} onRowClick={(session) => setSelectedSession(session)}
isLoading={restoresResource.loadState === 'loading'} isLoading={restoresResource.loadState === 'loading'}
emptyTitle="Noch keine Wiederherstellung" emptyTitle="Noch keine Wiederherstellung"
emptyDescription="Fuehren Sie eine durch, bevor Sie eine brauchen. Erst ein durchgefuehrter Test hebt einen Wiederherstellungspunkt auf „wiederherstellbar“ — alles davor ist ein Indiz." emptyDescription="Führen Sie eine durch, bevor Sie eine brauchen."
/> />
</Card> </Card>
@ -236,7 +236,7 @@ export function RestoresPage({
> >
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<p className="text-fg"> <p className="text-fg">
Es wird ab dem letzten vollstaendig geschriebenen Pfad weitergearbeitet. Es wird ab dem letzten vollständig geschriebenen Pfad weitergearbeitet.
</p> </p>
{sessionPendingResume?.checkpoint?.last_completed_path ? ( {sessionPendingResume?.checkpoint?.last_completed_path ? (
<div className="rounded-md border border-line bg-sunken p-2.5"> <div className="rounded-md border border-line bg-sunken p-2.5">
@ -247,10 +247,7 @@ export function RestoresPage({
</div> </div>
) : null} ) : null}
<Callout tone="warning"> <Callout tone="warning">
Die Datei, die beim Abbruch gerade geschrieben wurde, wird zuerst Die beim Abbruch angefangene Datei wird ersetzt.
entfernt und dann neu angelegt. Dateien aus diesem abgebrochenen
Lauf werden ersetzt — <strong>nur</strong> bei einer Fortsetzung,
nicht bei einem neuen Lauf.
</Callout> </Callout>
</div> </div>
</ConfirmDialog> </ConfirmDialog>
@ -273,7 +270,7 @@ function RestoreDetailCard({
description={session.target_path} description={session.target_path}
action={ action={
<Button variant="ghost" size="sm" onClick={onClose}> <Button variant="ghost" size="sm" onClick={onClose}>
Schliessen Schließen
</Button> </Button>
} }
/> />
@ -288,11 +285,11 @@ function RestoreDetailCard({
<DetailItem label="Dateien"> <DetailItem label="Dateien">
{session.files_restored.toLocaleString('de-DE')} {session.files_restored.toLocaleString('de-DE')}
</DetailItem> </DetailItem>
<DetailItem label="Uebergangen"> <DetailItem label="Übergangen">
{session.files_skipped.toLocaleString('de-DE')} {session.files_skipped.toLocaleString('de-DE')}
</DetailItem> </DetailItem>
<DetailItem label="Datenmenge">{formatBytes(session.bytes_restored)}</DetailItem> <DetailItem label="Datenmenge">{formatBytes(session.bytes_restored)}</DetailItem>
<DetailItem label="Ueberschreiben erlaubt"> <DetailItem label="Überschreiben erlaubt">
{session.overwrite_existing ? 'ja' : 'nein'} {session.overwrite_existing ? 'ja' : 'nein'}
</DetailItem> </DetailItem>
</dl> </dl>
@ -306,15 +303,15 @@ function RestoreDetailCard({
{session.checkpoint ? ( {session.checkpoint ? (
<div className="mt-4 rounded-md border border-line bg-sunken p-3"> <div className="mt-4 rounded-md border border-line bg-sunken p-3">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle"> <p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Pruefpunkt Prüfpunkt
</p> </p>
<p className="mt-1 break-all font-mono text-xs text-fg"> <p className="mt-1 break-all font-mono text-xs text-fg">
{session.checkpoint.last_completed_path ?? '—'} {session.checkpoint.last_completed_path ?? '—'}
</p> </p>
<p className="mt-1 text-xs text-fg-muted"> <p className="mt-1 text-xs text-fg-muted">
Er entsteht erst, <strong>nachdem</strong> eine Datei vollstaendig Er entsteht erst, <strong>nachdem</strong> eine Datei vollständig
und umbenannt am Platz liegt. Ein Pruefpunkt auf eine halbe Datei und umbenannt am Platz liegt. Ein Prüfpunkt auf eine halbe Datei
waere schlimmer als keiner. wäre schlimmer als keiner.
</p> </p>
</div> </div>
) : null} ) : null}

View File

@ -1,22 +1,22 @@
/** /**
* API-Anbindung der Wiederherstellung (SYNCOVA_API.md §12). * API-Anbindung der Wiederherstellung (SYNCOVA_API.md §12).
* *
* Die Vorabpruefung ist der Kern, nicht das Zurueckschreiben: `validate` * Die Vorabprüfung ist der Kern, nicht das Zurückschreiben: `validate`
* schreibt nichts und stellt fest, ob eine Wiederherstellung gelingen *kann* — * schreibt nichts und stellt fest, ob eine Wiederherstellung gelingen *kann* —
* insbesondere, ob **jeder benoetigte Block noch da ist**. Ein Manifest allein * insbesondere, ob **jeder benötigte Block noch da ist**. Ein Manifest allein
* belegt nur, dass jemand einmal etwas gesichert hat. * belegt nur, dass jemand einmal etwas gesichert hat.
*/ */
import { requestApi } from '../../api/client'; import { requestApi } from '../../api/client';
/** Schweregrad eines Befundes der Vorabpruefung. */ /** Schweregrad eines Befundes der Vorabprüfung. */
export type ValidationSeverity = 'blocking' | 'warning' | 'info'; export type ValidationSeverity = 'blocking' | 'warning' | 'info';
/** Einzelner Befund der Vorabpruefung. */ /** Einzelner Befund der Vorabprüfung. */
export interface ValidationFinding { export interface ValidationFinding {
/** Maschinenlesbarer Code. */ /** Maschinenlesbarer Code. */
code: string; code: string;
/** Schweregrad; nur `blocking` verhindert die Ausfuehrung. */ /** Schweregrad; nur `blocking` verhindert die Ausführung. */
severity: ValidationSeverity; severity: ValidationSeverity;
/** Meldung im Klartext. */ /** Meldung im Klartext. */
message: string; message: string;
@ -24,7 +24,7 @@ export interface ValidationFinding {
detail?: string; detail?: string;
} }
/** Bericht der Vorabpruefung. */ /** Bericht der Vorabprüfung. */
export interface ValidationReport { export interface ValidationReport {
backup_id: string; backup_id: string;
target_path: string; target_path: string;
@ -33,11 +33,11 @@ export interface ValidationReport {
entry_count: number; entry_count: number;
/** Zahl wiederherzustellender Dateien. */ /** Zahl wiederherzustellender Dateien. */
file_count: number; file_count: number;
/** Zurueckzuschreibende Datenmenge. */ /** Zurückzuschreibende Datenmenge. */
total_bytes: number; total_bytes: number;
/** Zahl benoetigter Bloecke. */ /** Zahl benötigter Blöcke. */
unique_chunk_count: number; unique_chunk_count: number;
/** Zahl fehlender Bloecke — jeder einzelne verhindert die Wiederherstellung. */ /** Zahl fehlender Blöcke — jeder einzelne verhindert die Wiederherstellung. */
missing_chunk_count: number; missing_chunk_count: number;
/** Freier Platz am Ziel; -1 bedeutet unbekannt. */ /** Freier Platz am Ziel; -1 bedeutet unbekannt. */
available_target_bytes: number; available_target_bytes: number;
@ -45,20 +45,20 @@ export interface ValidationReport {
duration_seconds: number; duration_seconds: number;
} }
/** Antwort der Vorabpruefung. */ /** Antwort der Vorabprüfung. */
export interface ValidationResponse { export interface ValidationResponse {
/** Darf die Wiederherstellung beginnen? */ /** Darf die Wiederherstellung beginnen? */
can_proceed: boolean; can_proceed: boolean;
/** Verlangt das Ziel eine woertliche Bestaetigung? */ /** Verlangt das Ziel eine wörtliche Bestätigung? */
requires_overwrite_confirmation: boolean; requires_overwrite_confirmation: boolean;
/** Zusammenfassung in einem Satz. */ /** Zusammenfassung in einem Satz. */
summary: string; summary: string;
report: ValidationReport | null; report: ValidationReport | null;
} }
/** Pruefpunkt einer unterbrochenen Wiederherstellung. */ /** Prüfpunkt einer unterbrochenen Wiederherstellung. */
export interface RestoreCheckpoint { export interface RestoreCheckpoint {
/** Zuletzt vollstaendig geschriebener Pfad. */ /** Zuletzt vollständig geschriebener Pfad. */
last_completed_path?: string; last_completed_path?: string;
files_restored?: number; files_restored?: number;
bytes_restored?: number; bytes_restored?: number;
@ -95,31 +95,31 @@ export interface CreateRestoreRequest {
target_type: string; target_type: string;
/** Zielverzeichnis. */ /** Zielverzeichnis. */
target_path: string; target_path: string;
/** Beschraenkung auf einen Teilbaum des Manifests. */ /** Beschränkung auf einen Teilbaum des Manifests. */
path_prefix?: string; path_prefix?: string;
/** Erste Huerde: das Kennzeichen. */ /** Erste Hürde: das Kennzeichen. */
overwrite_existing?: boolean; overwrite_existing?: boolean;
/** /**
* Dritte Huerde: der Zielpfad, woertlich wiederholt. * Dritte Hürde: der Zielpfad, wörtlich wiederholt.
* *
* Die zweite Huerde ist die Berechtigung `restores.overwrite` und liegt beim * Die zweite Hürde ist die Berechtigung `restores.overwrite` und liegt beim
* Server. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht * Server. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht
* damit nicht aus. * damit nicht aus.
*/ */
confirm_overwrite?: string; confirm_overwrite?: string;
/** Rechte nicht mit zurueckschreiben. */ /** Rechte nicht mit zurückschreiben. */
skip_permissions?: boolean; skip_permissions?: boolean;
/** /**
* Blockpruefung ueberspringen. * Blockprüfung überspringen.
* *
* Beschleunigt die Vorabpruefung erheblich und **senkt ihre Aussagekraft**: * Beschleunigt die Vorabprüfung erheblich und **senkt ihre Aussagekraft**:
* Ohne sie ist nicht belegt, dass jeder benoetigte Block noch da ist. Der * Ohne sie ist nicht belegt, dass jeder benötigte Block noch da ist. Der
* Bericht weist das als Hinweis aus. * Bericht weist das als Hinweis aus.
*/ */
skip_deep_check?: boolean; skip_deep_check?: boolean;
} }
/** Fuehrt die Vorabpruefung aus. Sie schreibt nichts. */ /** Führt die Vorabprüfung aus. Sie schreibt nichts. */
export async function validateRestore( export async function validateRestore(
validationRequest: CreateRestoreRequest, validationRequest: CreateRestoreRequest,
): Promise<ValidationResponse> { ): Promise<ValidationResponse> {
@ -140,7 +140,7 @@ export async function createRestore(
}); });
} }
/** Laedt die Wiederherstellungen. */ /** Lädt die Wiederherstellungen. */
export async function listRestores(abortSignal?: AbortSignal): Promise<RestoreSession[]> { export async function listRestores(abortSignal?: AbortSignal): Promise<RestoreSession[]> {
return requestApi<RestoreSession[]>( return requestApi<RestoreSession[]>(
'/restores?page_size=50', '/restores?page_size=50',
@ -148,7 +148,7 @@ export async function listRestores(abortSignal?: AbortSignal): Promise<RestoreSe
); );
} }
/** Laedt eine einzelne Wiederherstellung. */ /** Lädt eine einzelne Wiederherstellung. */
export async function getRestore( export async function getRestore(
restoreIdentifier: string, restoreIdentifier: string,
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
@ -171,7 +171,7 @@ export async function cancelRestore(restoreIdentifier: string): Promise<RestoreS
* Setzt eine unterbrochene Wiederherstellung fort. * Setzt eine unterbrochene Wiederherstellung fort.
* *
* Es gibt bewusst **keinen** automatischen Wiederholungsversuch: Ein zweiter * Es gibt bewusst **keinen** automatischen Wiederholungsversuch: Ein zweiter
* Lauf in ein halb gefuelltes Ziel kann Daten beschaedigen, die der erste * Lauf in ein halb gefülltes Ziel kann Daten beschädigen, die der erste
* bereits am Platz hatte. Die Fortsetzung ist eine bewusste Entscheidung. * bereits am Platz hatte. Die Fortsetzung ist eine bewusste Entscheidung.
*/ */
export async function resumeRestore(restoreIdentifier: string): Promise<RestoreSession> { export async function resumeRestore(restoreIdentifier: string): Promise<RestoreSession> {

View File

@ -1,12 +1,12 @@
/** /**
* Security Center. * Security Center.
* *
* Die Seite, die in Phase 12 als „noch nicht verfuegbar" stand — mit dem * Die Seite, die in Phase 12 als „noch nicht verfügbar" stand — mit dem
* Hinweis, die Einzelangaben seien vorhanden, aber keine Gesamtbewertung. Jetzt * Hinweis, die Einzelangaben seien vorhanden, aber keine Gesamtbewertung. Jetzt
* gibt es beides. * gibt es beides.
* *
* Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund traegt * Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund trägt
* Schweregrad, Erklaerung, betroffenes Objekt und Empfehlung. Die Empfehlung ist * Schweregrad, Erklärung, betroffenes Objekt und Empfehlung. Die Empfehlung ist
* der wichtigste Teil — ein Befund ohne sie ist eine Beunruhigung. * der wichtigste Teil — ein Befund ohne sie ist eine Beunruhigung.
*/ */
@ -34,29 +34,29 @@ type FindingSeverity = 'information' | 'warning' | 'high' | 'critical';
/** Ein Sicherheitsbefund. */ /** Ein Sicherheitsbefund. */
interface SecurityFinding { interface SecurityFinding {
/** Pruefbereich. */ /** Prüfbereich. */
readonly area: string; readonly area: string;
/** Schweregrad. */ /** Schweregrad. */
readonly severity: FindingSeverity; readonly severity: FindingSeverity;
/** Ueberschrift. */ /** Überschrift. */
readonly title: string; readonly title: string;
/** Erklaerung, warum der Zustand ein Problem ist. */ /** Erklärung, warum der Zustand ein Problem ist. */
readonly explanation: string; readonly explanation: string;
/** Das betroffene Objekt. */ /** Das betroffene Objekt. */
readonly affected_object: string; readonly affected_object: string;
/** Die naechste Handlung. */ /** Die nächste Handlung. */
readonly recommendation: string; readonly recommendation: string;
} }
/** Ergebnis eines Pruefbereichs. */ /** Ergebnis eines Prüfbereichs. */
interface AreaResult { interface AreaResult {
/** Bezeichner. */ /** Bezeichner. */
readonly area: string; readonly area: string;
/** Bezeichnung. */ /** Bezeichnung. */
readonly title: string; readonly title: string;
/** Meldet, ob der Bereich geprueft werden konnte. */ /** Meldet, ob der Bereich geprüft werden konnte. */
readonly available: boolean; readonly available: boolean;
/** Erklaert einen nicht pruefbaren Bereich. */ /** Erklärt einen nicht prüfbaren Bereich. */
readonly unavailable_reason?: string; readonly unavailable_reason?: string;
/** Befunde des Bereichs. */ /** Befunde des Bereichs. */
readonly findings: readonly SecurityFinding[]; readonly findings: readonly SecurityFinding[];
@ -72,13 +72,13 @@ interface AreaResult {
interface SecurityAssessment { interface SecurityAssessment {
/** Gesamtbewertung in Prozent. */ /** Gesamtbewertung in Prozent. */
readonly score: number; readonly score: number;
/** Erreichbare Punkte der geprueften Bereiche. */ /** Erreichbare Punkte der geprüften Bereiche. */
readonly maximum_score: number; readonly maximum_score: number;
/** Einstufung. */ /** Einstufung. */
readonly grade: string; readonly grade: string;
/** Ergebnisse je Bereich. */ /** Ergebnisse je Bereich. */
readonly areas: readonly AreaResult[]; readonly areas: readonly AreaResult[];
/** Zahl nicht pruefbarer Bereiche. */ /** Zahl nicht prüfbarer Bereiche. */
readonly unchecked_area_count: number; readonly unchecked_area_count: number;
/** Zahl kritischer Befunde. */ /** Zahl kritischer Befunde. */
readonly critical_finding_count: number; readonly critical_finding_count: number;
@ -86,7 +86,7 @@ interface SecurityAssessment {
readonly high_finding_count: number; readonly high_finding_count: number;
/** Zusammenfassung. */ /** Zusammenfassung. */
readonly summary: string; readonly summary: string;
/** Nicht gepruefte Bereiche. */ /** Nicht geprüfte Bereiche. */
readonly unchecked_areas: readonly string[]; readonly unchecked_areas: readonly string[];
} }
@ -111,7 +111,7 @@ export function SecurityPage(): React.JSX.Element {
<> <>
<PageHeader <PageHeader
title="Security Center" title="Security Center"
description="Was geprueft wurde — und was nicht." description="Was geprüft wurde — und was nicht."
/> />
{loadState === 'loading' ? ( {loadState === 'loading' ? (
@ -144,12 +144,12 @@ export function SecurityPage(): React.JSX.Element {
/** /**
* Zeigt die Gesamtbewertung. * Zeigt die Gesamtbewertung.
* *
* Neben der Prozentzahl steht immer die erreichbare Hoechstpunktzahl und die * Neben der Prozentzahl steht immer die erreichbare Höchstpunktzahl und die
* Liste der ungepruften Bereiche. Ein ungeprufter Bereich geht **nicht** in die * Liste der ungepruften Bereiche. Ein ungeprufter Bereich geht **nicht** in die
* Rechnung ein — ihn als bestanden zu werten waere Schoenfaerberei, als * Rechnung ein — ihn als bestanden zu werten wäre Schönfärberei, als
* durchgefallen eine Behauptung. * durchgefallen eine Behauptung.
* *
* Ein kritischer Befund deckelt die Einstufung unabhaengig von der Zahl: 88 von * Ein kritischer Befund deckelt die Einstufung unabhängig von der Zahl: 88 von
* 100 Punkten mit einem kritischen Befund ergeben trotzdem „unzureichend". * 100 Punkten mit einem kritischen Befund ergeben trotzdem „unzureichend".
*/ */
function ScoreHeader({ response }: { readonly response: SecurityResponse }): React.JSX.Element { function ScoreHeader({ response }: { readonly response: SecurityResponse }): React.JSX.Element {
@ -181,15 +181,15 @@ function ScoreHeader({ response }: { readonly response: SecurityResponse }): Rea
<Callout tone="critical"> <Callout tone="critical">
{criticalCount} kritische{criticalCount === 1 ? 'r' : ''} Befund {criticalCount} kritische{criticalCount === 1 ? 'r' : ''} Befund
{criticalCount === 1 ? '' : 'e'} deckelt die Einstufung auf {criticalCount === 1 ? '' : 'e'} deckelt die Einstufung auf
„unzureichend" — unabhaengig von der Punktzahl. „unzureichend" — unabhängig von der Punktzahl.
</Callout> </Callout>
) : null} ) : null}
{/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die {/* Die nicht geprüfte Bereiche stehen namentlich da: Sie sind die
Handlungsanweisung, nicht nur eine Einschraenkung. */} Handlungsanweisung, nicht nur eine Einschränkung. */}
{assessment.unchecked_areas.length > 0 ? ( {assessment.unchecked_areas.length > 0 ? (
<p className="text-xs text-fg-muted"> <p className="text-xs text-fg-muted">
Nicht geprueft: {assessment.unchecked_areas.join(', ')} Nicht geprüft: {assessment.unchecked_areas.join(', ')}
{assessment.unchecked_areas.length >= 3 {assessment.unchecked_areas.length >= 3
? ' — bei so vielen ungepruften Bereichen ist die Zahl eine Vermutung, keine Aussage.' ? ' — bei so vielen ungepruften Bereichen ist die Zahl eine Vermutung, keine Aussage.'
: ''} : ''}
@ -204,9 +204,9 @@ function ScoreHeader({ response }: { readonly response: SecurityResponse }): Rea
/** /**
* Zeigt die Befunde nach Schweregrad geordnet. * Zeigt die Befunde nach Schweregrad geordnet.
* *
* Jeder Befund traegt Schweregrad, Erklaerung, betroffenes Objekt und * Jeder Befund trägt Schweregrad, Erklärung, betroffenes Objekt und
* Empfehlung. Ein Befund ohne Empfehlung ist eine Beunruhigung: Er sagt, dass * Empfehlung. Ein Befund ohne Empfehlung ist eine Beunruhigung: Er sagt, dass
* etwas nicht stimmt, und laesst den Betreiber damit allein. * etwas nicht stimmt, und lässt den Betreiber damit allein.
*/ */
function FindingList({ function FindingList({
assessment, assessment,
@ -224,8 +224,8 @@ function FindingList({
return ( return (
<Card> <Card>
<EmptyState <EmptyState
title="Keine Befunde in den geprueften Bereichen" title="Keine Befunde in den geprüften Bereichen"
description="Das gilt ausschliesslich fuer die geprueften Bereiche — die ungeprueften stehen oben." description="Das gilt ausschließlich für die geprüften Bereiche — die ungeprüften stehen oben."
/> />
</Card> </Card>
); );
@ -257,10 +257,10 @@ function FindingList({
</div> </div>
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum {/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum
der Befund ueberhaupt angezeigt wird. */} der Befund überhaupt angezeigt wird. */}
<div className="flex gap-2"> <div className="flex gap-2">
<dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle"> <dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
Naechster Schritt Nächster Schritt
</dt> </dt>
<dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd> <dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd>
</div> </div>
@ -273,7 +273,7 @@ function FindingList({
); );
} }
/** Zeigt die Ergebnisse je Pruefbereich. */ /** Zeigt die Ergebnisse je Prüfbereich. */
function AreaTable({ function AreaTable({
assessment, assessment,
}: { }: {
@ -293,7 +293,7 @@ function AreaTable({
<span className="text-sm text-fg">{areaResult.summary}</span> <span className="text-sm text-fg">{areaResult.summary}</span>
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<StatusBadge status="neutral" label="nicht pruefbar" /> <StatusBadge status="neutral" label="nicht prüfbar" />
<span className="text-xs text-fg-muted">{areaResult.unavailable_reason}</span> <span className="text-xs text-fg-muted">{areaResult.unavailable_reason}</span>
</div> </div>
), ),
@ -308,7 +308,7 @@ function AreaTable({
) : ( ) : (
// Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder // Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder
// positiv noch negativ. // positiv noch negativ.
<span className="text-xs text-fg-subtle">zaehlt nicht</span> <span className="text-xs text-fg-subtle">zählt nicht</span>
), ),
}, },
]; ];
@ -316,7 +316,7 @@ function AreaTable({
return ( return (
<Card> <Card>
<CardHeader <CardHeader
title="Geprueft wurde" title="Geprüft wurde"
description={`Erreichbar sind ${assessment.maximum_score} Punkte.`} description={`Erreichbar sind ${assessment.maximum_score} Punkte.`}
/> />
<DataTable <DataTable

View File

@ -1,27 +1,27 @@
/** Kleine Helfer, die quer durch die Oberflaeche gebraucht werden. */ /** Kleine Helfer, die quer durch die Oberfläche gebraucht werden. */
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
/** /**
* Fuehrt Klassennamen zusammen und loest Tailwind-Konflikte auf. * Führt Klassennamen zusammen und löst Tailwind-Konflikte auf.
* *
* Ohne twMerge gewaenne bei "p-2 p-4" die Reihenfolge im Stylesheet, nicht die * Ohne twMerge gewänne bei "p-2 p-4" die Reihenfolge im Stylesheet, nicht die
* im Aufruf — eine Komponente liesse sich dann von aussen nicht mehr anpassen, * im Aufruf — eine Komponente ließe sich dann von außen nicht mehr anpassen,
* und das faellt erst auf, wenn jemand es versucht. * und das fällt erst auf, wenn jemand es versucht.
*/ */
export function cn(...classValues: ClassValue[]): string { export function cn(...classValues: ClassValue[]): string {
return twMerge(clsx(classValues)); return twMerge(clsx(classValues));
} }
/** /**
* Formatiert eine Byte-Zahl fuer die Anzeige. * Formatiert eine Byte-Zahl für die Anzeige.
* *
* Bewusst mit binaeren Einheiten (KiB, MiB): Ein Backup-Produkt, das GB und GiB * Bewusst mit binären Einheiten (KiB, MiB): Ein Backup-Produkt, das GB und GiB
* vermischt, erzeugt Rueckfragen bei jeder Kapazitaetsplanung. * vermischt, erzeugt Rückfragen bei jeder Kapazitätsplanung.
*/ */
export function formatBytes(byteCount: number | null | undefined): string { export function formatBytes(byteCount: number | null | undefined): string {
// Unbekannt ist nicht null. Eine 0 laese sich wie "nichts abgelegt" — // Unbekannt ist nicht null. Eine 0 läse sich wie "nichts abgelegt" —
// derselbe Grundsatz wie bei den Kennzahlen aus Phase 13. // derselbe Grundsatz wie bei den Kennzahlen aus Phase 13.
if (byteCount === null || byteCount === undefined || Number.isNaN(byteCount)) { if (byteCount === null || byteCount === undefined || Number.isNaN(byteCount)) {
return '—'; return '—';
@ -38,7 +38,7 @@ export function formatBytes(byteCount: number | null | undefined): string {
); );
const scaledValue = byteCount / Math.pow(1024, unitIndex); const scaledValue = byteCount / Math.pow(1024, unitIndex);
// Unter 10 eine Nachkommastelle, darueber keine: "1,4 GiB" ist nuetzlich, // Unter 10 eine Nachkommastelle, darüber keine: "1,4 GiB" ist nützlich,
// "847,3 GiB" tut nur so genau. // "847,3 GiB" tut nur so genau.
const decimalPlaces = unitIndex === 0 ? 0 : scaledValue < 10 ? 1 : 0; const decimalPlaces = unitIndex === 0 ? 0 : scaledValue < 10 ? 1 : 0;
@ -72,7 +72,7 @@ export function formatDateTime(isoTimestamp: string | null | undefined): string
/** /**
* Formatiert einen Zeitpunkt als Abstand zu jetzt ("vor 3 Stunden"). * Formatiert einen Zeitpunkt als Abstand zu jetzt ("vor 3 Stunden").
* *
* Fuer eine Betriebskonsole ist der Abstand oft die wichtigere Angabe: Ob die * Für eine Betriebskonsole ist der Abstand oft die wichtigere Angabe: Ob die
* letzte Sicherung um 02:15 lief, sagt wenig — dass sie vor drei Tagen lief, * letzte Sicherung um 02:15 lief, sagt wenig — dass sie vor drei Tagen lief,
* sagt alles. * sagt alles.
*/ */
@ -97,8 +97,8 @@ export function formatRelativeTime(isoTimestamp: string | null | undefined): str
return isInFuture ? 'gleich' : 'gerade eben'; return isInFuture ? 'gleich' : 'gerade eben';
} }
// Schwelle, Teiler und Einheit gehoeren zusammen und stehen deshalb in einem // Schwelle, Teiler und Einheit gehören zusammen und stehen deshalb in einem
// Eintrag. Drei parallele Arrays waeren die Sorte Code, bei der ein // Eintrag. Drei parallele Arrays wären die Sorte Code, bei der ein
// eingeschobener Eintrag still die Zuordnung verschiebt. // eingeschobener Eintrag still die Zuordnung verschiebt.
const timeScales = [ const timeScales = [
{ limit: 3600, divisor: 60, unit: 'minute' }, { limit: 3600, divisor: 60, unit: 'minute' },

View File

@ -1,4 +1,4 @@
/** Einstiegspunkt der Syncova-Oberflaeche. */ /** Einstiegspunkt der Syncova-Oberfläche. */
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
@ -8,7 +8,7 @@ import './styles/theme.css';
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
// Fehlt der Wurzelknoten, ist das Dokument fehlerhaft ausgeliefert worden. // Fehlt der Wurzelknoten, ist das Dokument fehlerhaft ausgeliefert worden.
// Ein stiller Abbruch wuerde nur eine leere Seite hinterlassen. // Ein stiller Abbruch würde nur eine leere Seite hinterlassen.
if (!rootElement) { if (!rootElement) {
throw new Error('Das Wurzelelement #root wurde im Dokument nicht gefunden.'); throw new Error('Das Wurzelelement #root wurde im Dokument nicht gefunden.');
} }

View File

@ -1,10 +1,10 @@
/** /**
* Seite fuer einen Bereich ohne Bedienoberflaeche. * Seite für einen Bereich ohne Bedienoberfläche.
* *
* Sie ist keine Fehlermeldung und keine Entschuldigung, sondern eine Auskunft: * Sie ist keine Fehlermeldung und keine Entschuldigung, sondern eine Auskunft:
* Was fehlt, und wie kommt man heute an dieselbe Information? Eine Seite, die * Was fehlt, und wie kommt man heute an dieselbe Information? Eine Seite, die
* nur „in Arbeit" sagt, laesst den Bediener ratlos zurueck — und eine, die eine * nur „in Arbeit" sagt, lässt den Bediener ratlos zurück — und eine, die eine
* leere Tabelle zeigt, laesst ihn glauben, es gaebe nichts zu sehen. * leere Tabelle zeigt, lässt ihn glauben, es gäbe nichts zu sehen.
*/ */
import { Construction } from 'lucide-react'; import { Construction } from 'lucide-react';
@ -39,7 +39,7 @@ export function UnavailablePage({
<p className="mt-5 text-xs text-fg-muted"> <p className="mt-5 text-xs text-fg-muted">
Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten
gefuellt. Eine Maske mit erfundenen Zahlen liesse sich im Betrieb gefüllt. Eine Maske mit erfundenen Zahlen ließe sich im Betrieb
nicht von einer echten unterscheiden. nicht von einer echten unterscheiden.
</p> </p>
</div> </div>

View File

@ -1,17 +1,17 @@
/** /**
* Tests des Seitenverzeichnisses. * Tests des Seitenverzeichnisses.
* *
* Sie pruefen weniger die Technik als eine Zusage: Jede Seite aus dem * Sie prüfen weniger die Technik als eine Zusage: Jede Seite aus dem
* Implementierungsplan erscheint, und jede unfertige erklaert sich. * Implementierungsplan erscheint, und jede unfertige erklärt sich.
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { ALL_PAGES, findPage, mayViewPage } from './pages'; import { ALL_PAGES, findPage, mayViewPage } from './pages';
describe('Seitenverzeichnis', () => { describe('Seitenverzeichnis', () => {
it('nennt zu jeder nicht verfuegbaren Seite einen Grund', () => { it('nennt zu jeder nicht verfügbaren Seite einen Grund', () => {
// Ein Bereich, der nur „nicht verfuegbar" sagt, laesst den Bediener ratlos // Ein Bereich, der nur „nicht verfügbar" sagt, lässt den Bediener ratlos
// zurueck. Der Grund ist der eigentliche Inhalt dieser Seiten. // zurück. Der Grund ist der eigentliche Inhalt dieser Seiten.
for (const pageDefinition of ALL_PAGES) { for (const pageDefinition of ALL_PAGES) {
if (!pageDefinition.available) { if (!pageDefinition.available) {
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeTruthy(); expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeTruthy();
@ -20,7 +20,7 @@ describe('Seitenverzeichnis', () => {
} }
}); });
it('gibt keiner verfuegbaren Seite einen Nichtverfuegbarkeitsgrund', () => { it('gibt keiner verfügbaren Seite einen Nichtverfügbarkeitsgrund', () => {
for (const pageDefinition of ALL_PAGES) { for (const pageDefinition of ALL_PAGES) {
if (pageDefinition.available) { if (pageDefinition.available) {
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeUndefined(); expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeUndefined();
@ -35,7 +35,7 @@ describe('Seitenverzeichnis', () => {
}); });
it('deckt die im Implementierungsplan genannten Bereiche ab', () => { it('deckt die im Implementierungsplan genannten Bereiche ab', () => {
// Der Plan (§14) nennt fuenfzehn Seiten. Fehlt eine davon im Verzeichnis, // Der Plan (§14) nennt fünfzehn Seiten. Fehlt eine davon im Verzeichnis,
// ist sie nicht „noch nicht gebaut", sondern vergessen — und niemand sieht // ist sie nicht „noch nicht gebaut", sondern vergessen — und niemand sieht
// es. // es.
const expectedIdentifiers = [ const expectedIdentifiers = [
@ -61,7 +61,7 @@ describe('Seitenverzeichnis', () => {
} }
}); });
it('verbirgt Seiten ohne die noetige Berechtigung', () => { it('verbirgt Seiten ohne die nötige Berechtigung', () => {
const usersPage = findPage('users'); const usersPage = findPage('users');
expect(usersPage).toBeDefined(); expect(usersPage).toBeDefined();

View File

@ -1,13 +1,13 @@
/** /**
* Seitenverzeichnis der Oberflaeche. * Seitenverzeichnis der Oberfläche.
* *
* Der Grundsatz aus PROMPT.md §139 gilt unveraendert: Ein Menue, das nur die * Der Grundsatz aus PROMPT.md §139 gilt unverändert: Ein Menü, das nur die
* fertigen Bereiche zeigt, verschweigt den Ausbaustand; eines, dessen Eintraege * fertigen Bereiche zeigt, verschweigt den Ausbaustand; eines, dessen Einträge
* in leere Masken fuehren, taeuscht ihn vor. Jeder Eintrag erscheint, und ein * in leere Masken führen, täuscht ihn vor. Jeder Eintrag erscheint, und ein
* noch nicht verfuegbarer nennt, was fehlt und was stattdessen gilt. * noch nicht verfügbarer nennt, was fehlt und was stattdessen gilt.
* *
* Die Gliederung folgt dem Weg durch die Anlage, nicht der Reihenfolge der * Die Gliederung folgt dem Weg durch die Anlage, nicht der Reihenfolge der
* Umsetzung: Was taeglich beobachtet wird, steht oben; was einmal eingerichtet * Umsetzung: Was täglich beobachtet wird, steht oben; was einmal eingerichtet
* und dann selten angefasst wird, unten. * und dann selten angefasst wird, unten.
*/ */
@ -50,28 +50,28 @@ export const SECTION_LABELS: ReadonlyArray<readonly [NavigationSection, string]>
['verwaltung', 'Verwaltung'], ['verwaltung', 'Verwaltung'],
]; ];
/** Beschreibung einer Seite der Oberflaeche. */ /** Beschreibung einer Seite der Oberfläche. */
export interface PageDefinition { export interface PageDefinition {
/** Stabiler Bezeichner; er steht auch im Pfad. */ /** Stabiler Bezeichner; er steht auch im Pfad. */
readonly id: string; readonly id: string;
/** Beschriftung im Menue. */ /** Beschriftung im Menü. */
readonly label: string; readonly label: string;
/** Bereich, unter dem die Seite erscheint. */ /** Bereich, unter dem die Seite erscheint. */
readonly section: NavigationSection; readonly section: NavigationSection;
/** Sinnbild im Menue. */ /** Sinnbild im Menü. */
readonly icon: LucideIcon; readonly icon: LucideIcon;
/** /**
* Berechtigung, die der Zugriff verlangt. * Berechtigung, die der Zugriff verlangt.
* *
* Rein zur Anzeige: Die Pruefung erfolgt immer serverseitig (PROMPT.md §42). * Rein zur Anzeige: Die Prüfung erfolgt immer serverseitig (PROMPT.md §42).
* Ein Menuepunkt, den der Server ohnehin ablehnt, fuehrt sonst nur zu einer * Ein Menüpunkt, den der Server ohnehin ablehnt, führt sonst nur zu einer
* Fehlermeldung statt zu einer verstaendlichen Oberflaeche. * Fehlermeldung statt zu einer verständlichen Oberfläche.
*/ */
readonly requiredPermission: string | null; readonly requiredPermission: string | null;
/** Meldet, ob hinter der Seite ein Backend steht. */ /** Meldet, ob hinter der Seite ein Backend steht. */
readonly available: boolean; readonly available: boolean;
/** /**
* Erklaert eine fehlende Seite. * Erklärt eine fehlende Seite.
* *
* Nicht „in Arbeit", sondern: welche Funktion fehlt und wie man heute an die * Nicht „in Arbeit", sondern: welche Funktion fehlt und wie man heute an die
* Auskunft kommt. * Auskunft kommt.
@ -81,10 +81,10 @@ export interface PageDefinition {
/** Alle Seiten in Anzeigereihenfolge. */ /** Alle Seiten in Anzeigereihenfolge. */
export const ALL_PAGES: readonly PageDefinition[] = [ export const ALL_PAGES: readonly PageDefinition[] = [
// --- Betrieb: was taeglich beobachtet und bedient wird --- // --- Betrieb: was täglich beobachtet und bedient wird ---
{ {
id: 'dashboard', id: 'dashboard',
label: 'Uebersicht', label: 'Übersicht',
section: 'betrieb', section: 'betrieb',
icon: LayoutDashboard, icon: LayoutDashboard,
requiredPermission: 'backups.read', requiredPermission: 'backups.read',
@ -92,7 +92,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
}, },
{ {
id: 'jobs', id: 'jobs',
label: 'Sicherungsauftraege', label: 'Sicherungsaufträge',
section: 'betrieb', section: 'betrieb',
icon: Clock, icon: Clock,
requiredPermission: 'jobs.read', requiredPermission: 'jobs.read',
@ -126,7 +126,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
}, },
{ {
id: 'verification', id: 'verification',
label: 'Pruefung', label: 'Prüfung',
section: 'daten', section: 'daten',
icon: BadgeCheck, icon: BadgeCheck,
requiredPermission: 'verification.read', requiredPermission: 'verification.read',
@ -168,7 +168,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
}, },
{ {
id: 'protected-systems', id: 'protected-systems',
label: 'Geschuetzte Systeme', label: 'Geschützte Systeme',
section: 'infrastruktur', section: 'infrastruktur',
icon: Activity, icon: Activity,
requiredPermission: 'providers.read', requiredPermission: 'providers.read',
@ -247,8 +247,8 @@ export function findPage(pageIdentifier: string): PageDefinition | undefined {
/** /**
* Meldet, ob ein Benutzer eine Seite sehen darf. * Meldet, ob ein Benutzer eine Seite sehen darf.
* *
* Ausschliesslich zur Anzeige — sie verhindert Sackgassen im Menue. Die * Ausschließlich zur Anzeige — sie verhindert Sackgassen im Menü. Die
* verbindliche Pruefung findet auf dem Server statt (PROMPT.md §42). * verbindliche Prüfung findet auf dem Server statt (PROMPT.md §42).
*/ */
export function mayViewPage( export function mayViewPage(
page: PageDefinition, page: PageDefinition,
@ -258,7 +258,7 @@ export function mayViewPage(
return true; return true;
} }
// Der Super-Administrator traegt einen Platzhalter statt aller Einzelrechte. // Der Super-Administrator trägt einen Platzhalter statt aller Einzelrechte.
if (grantedPermissions.includes('*')) { if (grantedPermissions.includes('*')) {
return true; return true;
} }

View File

@ -3,29 +3,29 @@
* *
* Warum keine? Eine Anwendung mit einer Ebene flacher Seiten braucht kein * Warum keine? Eine Anwendung mit einer Ebene flacher Seiten braucht kein
* Routing-Framework; sie braucht Adressen, die man kopieren kann, und einen * Routing-Framework; sie braucht Adressen, die man kopieren kann, und einen
* funktionierenden Zurueck-Knopf. Beides leistet die History-API in etwa fuenfzig * funktionierenden Zurück-Knopf. Beides leistet die History-API in etwa fünfzig
* Zeilen. Eine Abhaengigkeit, die ein Vielfaches an Code mitbringt, waere hier * Zeilen. Eine Abhängigkeit, die ein Vielfaches an Code mitbringt, wäre hier
* Aufwand ohne Nutzen — und muesste dauerhaft gepflegt werden. * Aufwand ohne Nutzen — und müsste dauerhaft gepflegt werden.
* *
* Sobald verschachtelte Routen mit eigenen Unterseiten entstehen, kehrt sich die * Sobald verschachtelte Routen mit eigenen Unterseiten entstehen, kehrt sich die
* Rechnung um. Dann ist der Wechsel faellig, und diese Datei ist der Ort dafuer. * Rechnung um. Dann ist der Wechsel fällig, und diese Datei ist der Ort dafür.
*/ */
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { DEFAULT_PAGE_ID, findPage } from './pages'; import { DEFAULT_PAGE_ID, findPage } from './pages';
/** Praefix aller Seitenpfade. */ /** Präfix aller Seitenpfade. */
const PAGE_PATH_PREFIX = '/'; const PAGE_PATH_PREFIX = '/';
/** Liest den Seitenbezeichner aus der aktuellen Adresse. */ /** Liest den Seitenbezeichner aus der aktuellen Adresse. */
function readPageIdentifierFromLocation(): string { function readPageIdentifierFromLocation(): string {
// noUncheckedIndexedAccess: Der erste Eintrag eines split-Ergebnisses ist fuer // noUncheckedIndexedAccess: Der erste Eintrag eines split-Ergebnisses ist für
// TypeScript moeglicherweise undefined, auch wenn er es nie ist. // TypeScript möglicherweise undefined, auch wenn er es nie ist.
const pathSegment = window.location.pathname.replace(PAGE_PATH_PREFIX, '').split('/')[0] ?? ''; const pathSegment = window.location.pathname.replace(PAGE_PATH_PREFIX, '').split('/')[0] ?? '';
if (pathSegment === '' || findPage(pathSegment) === undefined) { if (pathSegment === '' || findPage(pathSegment) === undefined) {
// Eine unbekannte Adresse fuehrt zur Startseite statt zu einer leeren // Eine unbekannte Adresse führt zur Startseite statt zu einer leeren
// Ansicht. Ein Tippfehler im Pfad ist kein Grund fuer eine kaputte Seite. // Ansicht. Ein Tippfehler im Pfad ist kein Grund für eine kaputte Seite.
return DEFAULT_PAGE_ID; return DEFAULT_PAGE_ID;
} }
@ -43,15 +43,15 @@ export interface CurrentPageNavigation {
/** /**
* Verbindet den angezeigten Bereich mit der Adresszeile. * Verbindet den angezeigten Bereich mit der Adresszeile.
* *
* Der Zustand ist die Quelle der Wahrheit fuer die Anzeige, die Adresse deren * Der Zustand ist die Quelle der Wahrheit für die Anzeige, die Adresse deren
* Abbild. Ohne diese Anbindung ginge beim Neuladen die Seite verloren, und ein * Abbild. Ohne diese Anbindung ginge beim Neuladen die Seite verloren, und ein
* Link liesse sich nicht weitergeben. * Link ließe sich nicht weitergeben.
*/ */
export function useCurrentPage(): CurrentPageNavigation { export function useCurrentPage(): CurrentPageNavigation {
const [currentPageId, setCurrentPageId] = useState<string>(readPageIdentifierFromLocation); const [currentPageId, setCurrentPageId] = useState<string>(readPageIdentifierFromLocation);
useEffect(() => { useEffect(() => {
/** Uebernimmt eine Navigation ueber den Zurueck-Knopf des Browsers. */ /** Übernimmt eine Navigation über den Zurück-Knopf des Browsers. */
function handlePopState(): void { function handlePopState(): void {
setCurrentPageId(readPageIdentifierFromLocation()); setCurrentPageId(readPageIdentifierFromLocation());
} }
@ -66,8 +66,8 @@ export function useCurrentPage(): CurrentPageNavigation {
return; return;
} }
// pushState statt einer Zuweisung an location: Ein vollstaendiges Neuladen // pushState statt einer Zuweisung an location: Ein vollständiges Neuladen
// wuerfe die Sitzung im Speicher weg und liesse die Anwendung neu anlaufen. // würfe die Sitzung im Speicher weg und ließe die Anwendung neu anlaufen.
window.history.pushState(null, '', PAGE_PATH_PREFIX + pageIdentifier); window.history.pushState(null, '', PAGE_PATH_PREFIX + pageIdentifier);
setCurrentPageId(pageIdentifier); setCurrentPageId(pageIdentifier);
}, []); }, []);

View File

@ -26,10 +26,15 @@
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
/* --- Radien --- */ /* --- Radien --- */
--radius-sm: 0.25rem; --radius-sm: 0.375rem;
--radius-md: 0.375rem; --radius-md: 0.5rem;
--radius-lg: 0.5rem; --radius-lg: 0.75rem;
--radius-xl: 0.75rem; --radius-xl: 1rem;
/* Schatten statt Rahmen, wo Tiefe gemeint ist. Ein Rahmen trennt, ein
* Schatten hebt — Karten sollen heben, Tabellenzeilen trennen. */
--shadow-card: 0 1px 2px 0 hsl(240 20% 12% / 0.04), 0 1px 3px 0 hsl(240 20% 12% / 0.06);
--shadow-raised: 0 4px 12px -2px hsl(240 20% 12% / 0.10), 0 2px 4px -2px hsl(240 20% 12% / 0.06);
} }
/* /*
@ -39,25 +44,25 @@
:root, :root,
[data-theme='light'] { [data-theme='light'] {
/* Flaechen — vier Ebenen reichen: Seite, Karte, erhoben, Rand. */ /* Flaechen — vier Ebenen reichen: Seite, Karte, erhoben, Rand. */
--surface-page: 0 0% 98%; --surface-page: 240 20% 97%;
--surface-card: 0 0% 100%; --surface-card: 0 0% 100%;
--surface-raised: 0 0% 100%; --surface-raised: 0 0% 100%;
--surface-sunken: 240 5% 96%; --surface-sunken: 240 20% 96%;
--surface-hover: 240 5% 94%; --surface-hover: 240 20% 94%;
--border-default: 240 6% 87%; --border-default: 240 12% 90%;
--border-strong: 240 5% 78%; --border-strong: 240 10% 80%;
--text-primary: 240 10% 10%; --text-primary: 240 20% 12%;
--text-secondary: 240 4% 42%; --text-secondary: 240 8% 44%;
--text-muted: 240 4% 56%; --text-muted: 240 6% 58%;
--text-inverted: 0 0% 100%; --text-inverted: 0 0% 100%;
/* Akzent — Bedienelemente, niemals Zustand. */ /* Akzent — Bedienelemente, niemals Zustand. */
--accent: 221 70% 45%; --accent: 243 75% 58%;
--accent-hover: 221 70% 39%; --accent-hover: 243 75% 50%;
--accent-text: 0 0% 100%; --accent-text: 0 0% 100%;
--accent-subtle: 221 70% 96%; --accent-subtle: 243 75% 96%;
/* --- Statusfarben (PROMPT.md §106) --- /* --- Statusfarben (PROMPT.md §106) ---
* Gruen = Healthy, Gelb = Warning, Orange = High, Rot = Critical, * Gruen = Healthy, Gelb = Warning, Orange = High, Rot = Critical,
@ -79,24 +84,24 @@
} }
[data-theme='dark'] { [data-theme='dark'] {
--surface-page: 240 8% 8%; --surface-page: 240 15% 7%;
--surface-card: 240 7% 11%; --surface-card: 240 13% 10%;
--surface-raised: 240 6% 14%; --surface-raised: 240 12% 13%;
--surface-sunken: 240 9% 6%; --surface-sunken: 240 16% 5%;
--surface-hover: 240 6% 17%; --surface-hover: 240 12% 16%;
--border-default: 240 5% 22%; --border-default: 240 8% 20%;
--border-strong: 240 5% 32%; --border-strong: 240 8% 30%;
--text-primary: 0 0% 96%; --text-primary: 240 20% 97%;
--text-secondary: 240 5% 68%; --text-secondary: 240 8% 70%;
--text-muted: 240 4% 52%; --text-muted: 240 6% 54%;
--text-inverted: 240 10% 8%; --text-inverted: 240 15% 7%;
--accent: 217 85% 62%; --accent: 243 80% 70%;
--accent-hover: 217 85% 70%; --accent-hover: 243 80% 78%;
--accent-text: 240 10% 8%; --accent-text: 240 15% 7%;
--accent-subtle: 221 50% 18%; --accent-subtle: 243 45% 20%;
/* Im dunklen Thema werden die Statusfarben aufgehellt. Die dunklen Toene des /* Im dunklen Thema werden die Statusfarben aufgehellt. Die dunklen Toene des
* hellen Themas waeren auf dunklem Grund nicht mehr unterscheidbar — und ein * hellen Themas waeren auf dunklem Grund nicht mehr unterscheidbar — und ein

View File

@ -1,4 +1,4 @@
/** Gemeinsame Testeinrichtung fuer alle Frontend-Tests. */ /** Gemeinsame Testeinrichtung für alle Frontend-Tests. */
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react'; import { cleanup } from '@testing-library/react';

View File

@ -1,8 +1,8 @@
/** /**
* Typen des Syncova-API-Vertrags (SYNCOVA_API.md §1). * Typen des Syncova-API-Vertrags (SYNCOVA_API.md §1).
* *
* Die Struktur spiegelt exakt die Antworthuelle des Backends wider, damit * Die Struktur spiegelt exakt die Antworthülle des Backends wider, damit
* Abweichungen bereits beim Uebersetzen auffallen. * Abweichungen bereits beim Übersetzen auffallen.
*/ */
/** Kontextinformationen jeder Antwort. */ /** Kontextinformationen jeder Antwort. */
@ -11,13 +11,13 @@ export interface ResponseMeta {
request_id: string; request_id: string;
/** Aktuelle Seitennummer; nur bei paginierten Listen gesetzt. */ /** Aktuelle Seitennummer; nur bei paginierten Listen gesetzt. */
page?: number; page?: number;
/** Anzahl der Eintraege pro Seite; nur bei paginierten Listen gesetzt. */ /** Anzahl der Einträge pro Seite; nur bei paginierten Listen gesetzt. */
page_size?: number; page_size?: number;
/** Gesamtzahl verfuegbarer Eintraege; nur bei paginierten Listen gesetzt. */ /** Gesamtzahl verfügbarer Einträge; nur bei paginierten Listen gesetzt. */
total?: number; total?: number;
} }
/** Huelle einer erfolgreichen Antwort. */ /** Hülle einer erfolgreichen Antwort. */
export interface SuccessResponse<TPayload> { export interface SuccessResponse<TPayload> {
/** Die eigentliche Nutzlast. */ /** Die eigentliche Nutzlast. */
data: TPayload; data: TPayload;
@ -25,11 +25,11 @@ export interface SuccessResponse<TPayload> {
meta: ResponseMeta; meta: ResponseMeta;
} }
/** Fehlerkoerper einer fehlgeschlagenen Antwort. */ /** Fehlerkörper einer fehlgeschlagenen Antwort. */
export interface ErrorBody { export interface ErrorBody {
/** Stabiler maschinenlesbarer Fehlercode, z. B. REPOSITORY_UNAVAILABLE. */ /** Stabiler maschinenlesbarer Fehlercode, z. B. REPOSITORY_UNAVAILABLE. */
code: string; code: string;
/** Verstaendliche Erklaerung des Fehlers. */ /** Verständliche Erklärung des Fehlers. */
message: string; message: string;
/** Optionale unbedenkliche Zusatzinformationen. */ /** Optionale unbedenkliche Zusatzinformationen. */
details?: Record<string, unknown>; details?: Record<string, unknown>;
@ -37,7 +37,7 @@ export interface ErrorBody {
request_id: string; request_id: string;
} }
/** Huelle einer fehlgeschlagenen Antwort. */ /** Hülle einer fehlgeschlagenen Antwort. */
export interface ErrorResponse { export interface ErrorResponse {
/** Beschreibung des aufgetretenen Fehlers. */ /** Beschreibung des aufgetretenen Fehlers. */
error: ErrorBody; error: ErrorBody;
@ -50,25 +50,25 @@ export interface ErrorResponse {
*/ */
export type HealthStatus = 'healthy' | 'degraded' | 'warning' | 'critical' | 'offline'; export type HealthStatus = 'healthy' | 'degraded' | 'warning' | 'critical' | 'offline';
/** Ergebnis der Zustandspruefung einer einzelnen Komponente. */ /** Ergebnis der Zustandsprüfung einer einzelnen Komponente. */
export interface ComponentHealth { export interface ComponentHealth {
/** Ermittelter Zustand der Komponente. */ /** Ermittelter Zustand der Komponente. */
status: HealthStatus; status: HealthStatus;
/** Verstaendliche Erklaerung des Zustands. */ /** Verständliche Erklärung des Zustands. */
message?: string; message?: string;
/** Empfohlener naechster Schritt bei Problemen. */ /** Empfohlener nächster Schritt bei Problemen. */
recommended_action?: string; recommended_action?: string;
/** Dauer der Pruefung in Millisekunden. */ /** Dauer der Prüfung in Millisekunden. */
latency_ms: number; latency_ms: number;
/** Zeitpunkt der Pruefung in UTC (ISO 8601). */ /** Zeitpunkt der Prüfung in UTC (ISO 8601). */
checked_at: string; checked_at: string;
} }
/** Antwort von GET /api/v1/health. */ /** Antwort von GET /api/v1/health. */
export interface SystemHealth { export interface SystemHealth {
/** Schlechtester Zustand ueber alle Komponenten hinweg. */ /** Schlechtester Zustand über alle Komponenten hinweg. */
status: HealthStatus; status: HealthStatus;
/** Ergebnis je geprueter Komponente. */ /** Ergebnis je geprüfter Komponente. */
components: Record<string, ComponentHealth>; components: Record<string, ComponentHealth>;
/** Laufende Programmversion des Backends. */ /** Laufende Programmversion des Backends. */
version: string; version: string;

View File

@ -5,7 +5,7 @@ export type UserStatus = 'active' | 'disabled' | 'locked';
/** Ein Benutzerkonto. */ /** Ein Benutzerkonto. */
export interface CurrentUser { export interface CurrentUser {
/** Oeffentlicher Bezeichner. */ /** Öffentlicher Bezeichner. */
id: string; id: string;
/** Anmeldename. */ /** Anmeldename. */
username: string; username: string;
@ -13,7 +13,7 @@ export interface CurrentUser {
email?: string; email?: string;
/** Zustand des Kontos. */ /** Zustand des Kontos. */
status: UserStatus; status: UserStatus;
/** Meldet, ob ein bestaetigter zweiter Faktor vorliegt. */ /** Meldet, ob ein bestätigter zweiter Faktor vorliegt. */
mfa_enabled: boolean; mfa_enabled: boolean;
/** Zugewiesene Rollen. */ /** Zugewiesene Rollen. */
roles: string[]; roles: string[];
@ -27,7 +27,7 @@ export interface CurrentUser {
export interface TokenPair { export interface TokenPair {
/** Kurzlebiges Zugriffstoken. */ /** Kurzlebiges Zugriffstoken. */
access_token: string; access_token: string;
/** Laenger gueltiges Erneuerungstoken. */ /** Länger gültiges Erneuerungstoken. */
refresh_token: string; refresh_token: string;
/** Ablaufzeit des Zugriffstokens. */ /** Ablaufzeit des Zugriffstokens. */
access_expires_at: string; access_expires_at: string;
@ -42,7 +42,7 @@ export interface TokenPair {
* der zweite Faktor (mfa_required gesetzt). * der zweite Faktor (mfa_required gesetzt).
*/ */
export interface LoginResult { export interface LoginResult {
/** Meldet, dass noch ein zweiter Faktor noetig ist. */ /** Meldet, dass noch ein zweiter Faktor nötig ist. */
mfa_required: boolean; mfa_required: boolean;
/** Benennt die offene MFA-Herausforderung. */ /** Benennt die offene MFA-Herausforderung. */
challenge_id?: string; challenge_id?: string;