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>
This commit is contained in:
Jerrit Fritzsche 2026-08-18 14:42:37 +02:00
parent 698f3a17d9
commit b50ad2b9bc
73 changed files with 1997 additions and 1320 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
* letztere auf die Bereiche aus PROMPT.md §28.
*
* Die Navigation kennt zwei Ebenen: eine Seite und optional ein Objekt darauf
* (`jobs/<id>`). Das reicht fuer diese Konsole und kostet rund fuenfzig Zeilen —
* eine Router-Bibliothek waere hier mehr Abhaengigkeit als Nutzen.
* (`jobs/<id>`). Das reicht für diese Konsole und kostet rund fünfzig Zeilen —
* 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 { AppShell } from './components/layout/AppShell';
import { ToastProvider } from './components/ui';
import { getAccessToken, logout } from './features/auth/authApi';
import { ErrorBoundary } from './components/layout/ErrorBoundary';
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 { AlertsPage } from './features/alerts/AlertsPage';
import { DashboardPage } from './features/dashboard/DashboardPage';
@ -42,16 +49,64 @@ import { useCurrentPage } from './navigation/useCurrentPage';
import type { CurrentUser } from './types/auth';
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);
export function App(): React.JSX.Element {
const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null);
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);
// 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) {
return (
<ToastProvider>
@ -67,7 +122,7 @@ export function App(): React.JSX.Element {
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 {
setOpenObjectIdentifier(null);
navigateToPage(pageIdentifier);
@ -80,8 +135,12 @@ export function App(): React.JSX.Element {
onNavigate={handleNavigate}
grantedPermissions={grantedPermissions}
userName={authenticatedUser.username}
sessionClock={sessionClock}
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
currentPageId={currentPageId}
grantedPermissions={grantedPermissions}
@ -89,12 +148,13 @@ export function App(): React.JSX.Element {
openObjectIdentifier={openObjectIdentifier}
onOpenObject={setOpenObjectIdentifier}
/>
</ErrorBoundary>
</AppShell>
</ToastProvider>
);
}
/** Zeigt den Inhalt der gewaehlten Seite. */
/** Zeigt den Inhalt der gewählten Seite. */
function CurrentPageContent({
currentPageId,
grantedPermissions,
@ -114,14 +174,14 @@ function CurrentPageContent({
return <DashboardPage />;
}
// Die Anzeigepruefung ersetzt die serverseitige nicht, sie ergaenzt sie: Ohne
// sie liefe der Aufruf in eine Fehlermeldung statt in eine verstaendliche
// Die Anzeigeprüfung ersetzt die serverseitige nicht, sie ergänzt sie: Ohne
// sie liefe der Aufruf in eine Fehlermeldung statt in eine verständliche
// Auskunft (PROMPT.md §42).
if (!mayViewPage(pageDefinition, grantedPermissions)) {
return (
<UnavailablePage
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 +190,7 @@ function CurrentPageContent({
return (
<UnavailablePage
title={pageDefinition.label}
reason={pageDefinition.unavailableReason ?? 'Dieser Bereich ist noch nicht verfuegbar.'}
reason={pageDefinition.unavailableReason ?? 'Dieser Bereich ist noch nicht verfügbar.'}
/>
);
}
@ -238,7 +298,7 @@ function CurrentPageContent({
return (
<UnavailablePage
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 { 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 {
return new Response(JSON.stringify(responseBody), {
status: statusCode,
@ -14,7 +14,7 @@ afterEach(() => {
});
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(
'fetch',
vi.fn().mockResolvedValue(
@ -35,7 +35,7 @@ describe('requestApi', () => {
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];
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')));
// Ein Netzwerkfehler darf nicht als leere Antwort durchgehen.
@ -79,24 +79,24 @@ describe('requestApi', () => {
it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => {
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).
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)));
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(
'fetch',
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(
(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
* ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es
* Der Client kapselt die Antworthülle des Backends und liefert Fehler stets als
* ApiError. Aufrufer müssen sich damit nicht mit HTTP-Details befassen und es
* kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden
* (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). */
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';
/**
* 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
* Doppelklick oder ein wiederholter Versuch nach einer Zeitueberschreitung zwei
* Auftraege — und bei einer Wiederherstellung zwei gleichzeitige Laeufe in
* Er gehört an alle anlegenden und zerstörenden Aufrufe. Ohne ihn erzeugt ein
* Doppelklick oder ein wiederholter Versuch nach einer Zeitüberschreitung zwei
* Aufträge — und bei einer Wiederherstellung zwei gleichzeitige Läufe in
* dasselbe Ziel.
*/
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
@ -28,8 +28,8 @@ const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
/**
* Fehler einer API-Anfrage.
*
* Er traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
* einen Vorfall gegenueber dem Betreiber eindeutig benennen kann.
* Er trägt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
* einen Vorfall gegenüber dem Betreiber eindeutig benennen kann.
*/
export class ApiError extends Error {
/** Stabiler maschinenlesbarer Fehlercode. */
@ -60,8 +60,8 @@ export class ApiError extends Error {
/**
* Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht.
*
* Der Client kennt die Anmeldelogik bewusst nicht, sondern erhaelt sie ueber
* diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und
* Der Client kennt die Anmeldelogik bewusst nicht, sondern erhält sie über
* diese Funktion. Andernfalls entstünde ein Zirkelbezug zwischen dem Client und
* dem Anmeldemodul, das seinerseits den Client verwendet.
*/
let accessTokenProvider: () => string | null = () => null;
@ -71,24 +71,24 @@ export function setAccessTokenProvider(tokenProvider: () => string | null): void
accessTokenProvider = tokenProvider;
}
/** Fehlercode fuer eine nicht erreichbare API. */
/** Fehlercode für eine nicht erreichbare API. */
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';
/**
* Erzeugt eine Correlation ID fuer einen Request.
* Erzeugt eine Correlation ID für einen Request.
*
* crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der
* Rueckfall deckt aeltere Testumgebungen ab.
* crypto.randomUUID ist in allen unterstützten Browsern verfügbar; der
* Rückfall deckt ältere Testumgebungen ab.
*/
function createCorrelationId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
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.
return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`;
}
@ -97,22 +97,22 @@ function createCorrelationId(): string {
export interface RequestOptions {
/** HTTP-Methode; Standard ist GET. */
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
/** Optionaler Anfragekoerper, der als JSON gesendet wird. */
/** Optionaler Anfragekörper, der als JSON gesendet wird. */
body?: unknown;
/** Signal zum Abbrechen der Anfrage. */
signal?: AbortSignal;
/**
* Schluessel fuer Idempotenz.
* Schlüssel für Idempotenz.
*
* `true` erzeugt einen zufaelligen Schluessel; eine Zeichenkette wird
* unveraendert verwendet, damit ein Wiederholungsversuch derselben Handlung
* denselben Schluessel traegt.
* `true` erzeugt einen zufälligen Schlüssel; eine Zeichenkette wird
* unverändert verwendet, damit ein Wiederholungsversuch derselben Handlung
* denselben Schlüssel trägt.
*/
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".
* @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
// unauthentifiziert - der Server entscheidet dann ueber den Zugriff.
// unauthentifiziert - der Server entscheidet dann über den Zugriff.
const accessToken = accessTokenProvider();
if (accessToken !== null) {
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
@ -154,7 +154,7 @@ export async function requestApi<TPayload>(
method: requestMethod,
headers: requestHeaders,
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',
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
});
@ -166,13 +166,13 @@ export async function requestApi<TPayload>(
throw new ApiError({
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,
requestId: correlationId,
});
}
// 204 traegt per Definition keinen Koerper.
// 204 trägt per Definition keinen Körper.
if (httpResponse.status === 204) {
return undefined as TPayload;
}
@ -183,19 +183,19 @@ export async function requestApi<TPayload>(
} catch {
throw new ApiError({
code: MALFORMED_RESPONSE_CODE,
message: 'Die Antwort des Servers war unverstaendlich.',
message: 'Die Antwort des Servers war unverständlich.',
statusCode: httpResponse.status,
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
// 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
// aber einen vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden
// Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
// aber einen vollständigen Bericht als Nutzlast. Würde der Client jeden
// Status außerhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
// ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht.
const errorResponse = parsedBody as Partial<ErrorResponse>;
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**
* Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi
* Sie steht neben requestApi und nicht darin: Eine Datei trägt **keine**
* 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
* 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,
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die
* Der Fehlerfall geht dagegen sehr wohl durch die Hülle: Scheitert die Anfrage,
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprüft, bevor die
* 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.
*/
export async function downloadApiFile(
@ -272,7 +272,7 @@ export async function downloadApiFile(
throw new ApiError({
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,
requestId: correlationId,
});
@ -310,8 +310,8 @@ export async function downloadApiFile(
/**
* Liest den Dateinamen aus dem Content-Disposition-Kopf.
*
* Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer waehlt einen —
* ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner.
* Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer wählt einen —
* ein erfundener Name aus dem Kopf zu lesen wäre schlimmer als keiner.
*/
function parseFileNameFromDisposition(dispositionHeader: string | null): string {
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
* vorliegt, bleibt der Zustand ausdruecklich „laedt" oder „Fehler" — niemals ein
* leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden liesse
* vorliegt, bleibt der Zustand ausdrücklich „lädt" oder „Fehler" — niemals ein
* leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden ließe
* (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
* 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';
@ -25,17 +25,17 @@ export interface UseApiResourceResult<TPayload> {
readonly data: TPayload | null;
/** Aufgetretener Fehler; null, wenn keiner vorliegt. */
readonly loadError: ApiError | null;
/** Laedt die Ressource erneut. */
/** Lädt die Ressource erneut. */
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 dependencyKey Aendert sich dieser Wert, wird neu geladen. Ein einzelner
* Schluessel statt eines Abhaengigkeitsarrays: Ein Array mit wechselnder Laenge
* ist in React ein Fehler, und ein Objekt als Abhaengigkeit laedt bei jedem
* @param loadResource Ladefunktion; sie erhält ein Abbruchsignal.
* @param dependencyKey Ändert sich dieser Wert, wird neu geladen. Ein einzelner
* Schlüssel statt eines Abhängigkeitsarrays: Ein Array mit wechselnder Länge
* ist in React ein Fehler, und ein Objekt als Abhängigkeit lädt bei jedem
* Rendern neu.
*/
export function useApiResource<TPayload>(
@ -45,11 +45,11 @@ export function useApiResource<TPayload>(
// reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen.
const [reloadCounter, setReloadCounter] = useState(0);
// Das Ergebnis traegt den Schluessel, unter dem es entstanden ist. Daraus
// laesst sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen:
// Passt der Schluessel nicht zum aktuellen, laeuft die Anfrage noch. Ein
// setState im Effektkoerper loeste dagegen eine zweite Renderrunde aus,
// bevor ueberhaupt etwas geladen wurde.
// Das Ergebnis trägt den Schlüssel, unter dem es entstanden ist. Daraus
// lässt sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen:
// Passt der Schlüssel nicht zum aktuellen, läuft die Anfrage noch. Ein
// setState im Effektkörper löste dagegen eine zweite Renderrunde aus,
// bevor überhaupt etwas geladen wurde.
const [loadResult, setLoadResult] = useState<{
key: string;
data: TPayload | null;
@ -71,7 +71,7 @@ export function useApiResource<TPayload>(
setLoadResult({ key: effectiveKey, data: loadedPayload, error: null });
} 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') {
return;
}
@ -95,14 +95,14 @@ export function useApiResource<TPayload>(
void loadFromApi();
return () => abortController.abort();
// loadResource bewusst nicht in den Abhaengigkeiten: Eine bei jedem Rendern
// neu gebildete Funktion loeste sonst eine Endlosschleife aus. Der
// effectiveKey steuert das Neuladen ausdruecklich.
// loadResource bewusst nicht in den Abhängigkeiten: Eine bei jedem Rendern
// neu gebildete Funktion löste sonst eine Endlosschleife aus. Der
// effectiveKey steuert das Neuladen ausdrücklich.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveKey]);
// Solange kein Ergebnis zum aktuellen Schluessel vorliegt, wird geladen. Die
// vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel laesst die
// Solange kein Ergebnis zum aktuellen Schlüssel vorliegt, wird geladen. Die
// vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel lässt die
// Tabelle also nicht aufblitzen.
if (loadResult === null || loadResult.key !== effectiveKey) {
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
* Oberflaeche kaum etwas schreiben konnte. Dieser Haken traegt die drei Dinge,
* die eine veraendernde Handlung in einer Betriebskonsole braucht:
* Oberfläche kaum etwas schreiben konnte. Dieser Haken trägt die drei Dinge,
* die eine verändernde Handlung in einer Betriebskonsole braucht:
*
* 1. **Doppelklickschutz.** Solange die Anfrage laeuft, wird keine zweite
* ausgeloest. Bei einem Sicherungslauf waere die zweite ein 409, bei einer
* Loeschung schlimmstenfalls mehr als beabsichtigt.
* 1. **Doppelklickschutz.** Solange die Anfrage läuft, wird keine zweite
* ausgelöst. Bei einem Sicherungslauf wäre die zweite ein 409, bei einer
* Löschung schlimmstenfalls mehr als beabsichtigt.
* 2. **Die Vorgangsnummer bis in die Meldung.** Sie ist die einzige Angabe, mit
* der sich ein Vorfall spaeter im Serverprotokoll wiederfinden laesst.
* 3. **Kein `setState` nach dem Aushaengen.** Ein Dialog, der sich nach dem
* Absenden schliesst, ist weg, bevor die Antwort da ist.
* der sich ein Vorfall später im Serverprotokoll wiederfinden lässt.
* 3. **Kein `setState` nach dem Aushängen.** Ein Dialog, der sich nach dem
* Absenden schließt, ist weg, bevor die Antwort da ist.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError } from './client';
export interface MutationState {
/** Laeuft die Anfrage gerade? */
/** Läuft die Anfrage gerade? */
readonly isRunning: boolean;
/** Fehler des letzten Versuchs, sonst null. */
readonly error: ApiError | null;
@ -27,17 +27,17 @@ export interface MutationState {
export interface MutationResult<TArguments extends unknown[], TResult> {
readonly isRunning: boolean;
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>;
/** Verwirft eine stehende Fehlermeldung. */
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 callbacks Rueckmeldungen bei Erfolg und Fehler.
* @param callbacks Rückmeldungen bei Erfolg und Fehler.
*/
export function useMutation<TArguments extends unknown[], TResult>(
performRequest: (...callArguments: TArguments) => Promise<TResult>,
@ -51,14 +51,14 @@ export function useMutation<TArguments extends unknown[], TResult>(
error: null,
});
// Die Rueckmeldungen liegen in einer Referenz, damit `run` seine Identitaet
// behaelt. Sonst loeste jede Renderrunde einen neuen Effekt aus, der `run`
// in seiner Abhaengigkeitsliste hat.
// Die Rückmeldungen liegen in einer Referenz, damit `run` seine Identität
// behält. Sonst löste jede Renderrunde einen neuen Effekt aus, der `run`
// in seiner Abhängigkeitsliste hat.
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
// darf ein Rendern verwerfen oder zweimal ausfuehren.
// darf ein Rendern verwerfen oder zweimal ausführen.
useEffect(() => {
callbacksReference.current = callbacks;
});
@ -84,8 +84,8 @@ export function useMutation<TArguments extends unknown[], TResult>(
setMutationState({ isRunning: false, error: null });
}
// Die Rueckmeldung laeuft auch dann, wenn die Komponente weg ist: Ein
// Dialog schliesst sich nach dem Absenden, und die Liste dahinter soll
// Die Rückmeldung läuft auch dann, wenn die Komponente weg ist: Ein
// Dialog schließt sich nach dem Absenden, und die Liste dahinter soll
// sich trotzdem aktualisieren.
callbacksReference.current.onSuccess?.(result);
@ -139,33 +139,33 @@ export function useMutation<TArguments extends unknown[], TResult>(
/** Bekannte Lagen samt Abhilfe. */
const errorCodeExplanations: Readonly<Record<string, string>> = {
// Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des
// Anwenders — der Auftrag laeuft bereits.
// Diese Lage entsteht bei einem zweiten Anstoß und ist kein Fehler des
// Anwenders — der Auftrag läuft bereits.
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:
'In dieses Ziel laeuft bereits eine Wiederherstellung. Zwei gleichzeitige wuerden sich gegenseitig ueberschreiben.',
VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprueft.',
'In dieses Ziel läuft bereits eine Wiederherstellung. Zwei gleichzeitige würden sich gegenseitig überschreiben.',
VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprüft.',
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:
'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurueckgeschrieben.',
'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurückgeschrieben.',
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:
'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.',
REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.',
'Mitgelieferte Rollen sind unveränderlich. Eine Änderung verschöbe die Bedeutung bestehender Zuweisungen.',
REPOSITORY_IN_USE: 'Auf dieses Repository verweisen noch Aufträge oder Sicherungen.',
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:
'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:
'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:
'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.',
SERVICE_UNAVAILABLE:
'Der Dienst ist derzeit nicht vollstaendig verfuegbar. Das ist kein Problem Ihrer Sitzung.',
FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung fuer diese Handlung.',
'Der Dienst ist derzeit nicht vollständig verfügbar. Das ist kein Problem Ihrer Sitzung.',
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,
* 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.
*
* Das war zunaechst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein
* `SERVICE_UNAVAILABLE` mit der Meldung „Fuer diesen Bericht ist keine
* Sicherheitspruefung eingerichtet." wurde durch den allgemeinen Satz „Der
* Dienst ist derzeit nicht vollstaendig verfuegbar" ersetzt. Der Betreiber
* haette den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses
* Das war zunächst umgekehrt gebaut, und ein Test hat es aufgedeckt: Ein
* `SERVICE_UNAVAILABLE` mit der Meldung „Für diesen Bericht ist keine
* Sicherheitsprüfung eingerichtet." wurde durch den allgemeinen Satz „Der
* Dienst ist derzeit nicht vollständig verfügbar" ersetzt. Der Betreiber
* hätte den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses
* einen Berichts.
*/
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
* eine bekannte Abhilfe hat, laesst sie sich zusaetzlich anzeigen.
* Gedacht als **Ergänzung** zur Servermeldung, nicht als Ersatz: Wo eine Lage
* eine bekannte Abhilfe hat, lässt sie sich zusätzlich anzeigen.
*/
export function explainApiErrorCode(errorCode: string): string | undefined {
return errorCodeExplanations[errorCode];

View File

@ -1,19 +1,20 @@
/**
* Rahmen der angemeldeten Ansicht: Seitenleiste, Kopfzeile, Inhalt.
*
* Die Seitenleiste laesst sich einklappen und merkt sich das. Der Grund ist
* nicht Aesthetik: Wer eine Stoerung bearbeitet, hat oft eine breite Tabelle mit
* Laufzeiten und Fehlercodes vor sich, und 240 Pixel Menue sind dann im Weg.
* Die Seitenleiste lässt sich einklappen und merkt sich das. Der Grund ist
* nicht Ästhetik: Wer eine Störung bearbeitet, hat oft eine breite Tabelle mit
* 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
* 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 { cn } from '@/lib/utils';
import { Button } from '@/components/ui';
import { formatSessionClock, type SessionClock } from '@/features/auth/useSession';
import {
ALL_PAGES,
SECTION_LABELS,
@ -43,6 +44,7 @@ export function AppShell({
onNavigate,
grantedPermissions,
userName,
sessionClock,
onLogout,
children,
}: {
@ -50,6 +52,8 @@ export function AppShell({
readonly onNavigate: (pageIdentifier: string) => void;
readonly grantedPermissions: readonly string[];
readonly userName: string;
/** Verbleibende Sitzungszeit für die Anzeige. */
readonly sessionClock: SessionClock;
readonly onLogout: () => void;
readonly children: ReactNode;
}) {
@ -60,7 +64,7 @@ export function AppShell({
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// 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(() => {
document.documentElement.setAttribute('data-theme', themePreference);
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
@ -71,13 +75,13 @@ export function AppShell({
return (
<div className="flex min-h-screen bg-page">
{/* Abdunklung hinter der Schublade auf schmalen Geraeten. */}
{/* Abdunklung hinter der Schublade auf schmalen Geräten. */}
{isMobileMenuOpen ? (
<button
type="button"
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setIsMobileMenuOpen(false)}
aria-label="Menue schliessen"
aria-label="Menü schließen"
/>
) : null}
@ -99,7 +103,7 @@ export function AppShell({
type="button"
className="ml-auto rounded p-1 text-fg-subtle hover:bg-hover hover:text-fg lg:hidden"
onClick={() => setIsMobileMenuOpen(false)}
aria-label="Menue schliessen"
aria-label="Menü schließen"
>
<X className="size-4" />
</button>
@ -128,7 +132,7 @@ export function AppShell({
isActive={page.id === currentPageId}
isCollapsed={isSidebarCollapsed}
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
// ist die Handlung, nicht der neue Zustand.
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"
title={isSidebarCollapsed ? 'Menue ausklappen' : 'Menue einklappen'}
title={isSidebarCollapsed ? 'Menü ausklappen' : 'Menü einklappen'}
>
{isSidebarCollapsed ? (
<PanelLeft className="size-4 shrink-0" />
@ -180,7 +184,7 @@ export function AppShell({
type="button"
className="rounded p-1.5 text-fg-muted hover:bg-hover hover:text-fg lg:hidden"
onClick={() => setIsMobileMenuOpen(true)}
aria-label="Menue oeffnen"
aria-label="Menü öffnen"
>
<Menu className="size-5" />
</button>
@ -208,6 +212,23 @@ export function AppShell({
{userName}
</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}>
Abmelden
</Button>
@ -244,8 +265,8 @@ function NavigationItem({
isActive
? 'bg-accent-subtle font-medium text-accent'
: 'text-fg-muted hover:bg-hover hover:text-fg',
// Ein noch nicht verfuegbarer Bereich bleibt anklickbar — die Zielseite
// erklaert, was fehlt. Ihn zu sperren verschwiege den Ausbaustand.
// Ein noch nicht verfügbarer Bereich bleibt anklickbar — die Zielseite
// erklärt, was fehlt. Ihn zu sperren verschwiege den Ausbaustand.
!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 type { ReactNode } from 'react';
@ -10,7 +10,7 @@ export function PageHeader({
description,
actions,
onBack,
backLabel = 'Zurueck',
backLabel = 'Zurück',
className,
}: {
readonly title: ReactNode;

View File

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

View File

@ -1,4 +1,4 @@
/** Karte — die Grundflaeche aller Inhalte. */
/** Karte — die Grundfläche aller Inhalte. */
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils';

View File

@ -1,14 +1,14 @@
/**
* Tabelle.
*
* Eine Betriebskonsole besteht zu grossen Teilen aus Tabellen, deshalb steckt
* die Muehe hier und nicht in Effekten:
* Eine Betriebskonsole besteht zu großen Teilen aus Tabellen, deshalb steckt
* die Mühe hier und nicht in Effekten:
*
* - **Leer ist nicht gleich leer.** "Noch nichts angelegt" und "der Filter
* trifft nichts" sind verschiedene Lagen und brauchen verschiedene Texte;
* die erste verlangt eine Schaltflaeche, die zweite nicht.
* - **Waehrend des Nachladens bleibt die alte Tabelle stehen** und wird nur
* abgeblendet. Sie durch einen Ladeanzeiger zu ersetzen laesst die Seite bei
* die erste verlangt eine Schaltfläche, die zweite nicht.
* - **Während des Nachladens bleibt die alte Tabelle stehen** und wird nur
* abgeblendet. Sie durch einen Ladeanzeiger zu ersetzen lässt die Seite bei
* jedem Filterwechsel aufblitzen.
* - **Breite Tabellen laufen in ihrem eigenen Rahmen**, nicht auf der Seite.
*/
@ -20,7 +20,7 @@ export interface TableColumn<RowType> {
readonly key: string;
readonly header: ReactNode;
readonly render: (row: RowType) => ReactNode;
/** Rechtsbuendig — fuer Zahlen. */
/** Rechtsbündig — für Zahlen. */
readonly numeric?: boolean | undefined;
readonly className?: string | undefined;
}
@ -31,7 +31,7 @@ export function DataTable<RowType>({
getRowKey,
onRowClick,
isLoading = false,
emptyTitle = 'Keine Eintraege',
emptyTitle = 'Keine Einträge',
emptyDescription,
emptyAction,
className,

View File

@ -3,12 +3,12 @@
*
* Zwei Bauarten, und der Unterschied ist fachlich:
*
* - `Dialog` fuer Eingaben (anlegen, bearbeiten). Er laesst sich mit Escape und
* einem Klick daneben schliessen.
* - `ConfirmDialog` fuer Handlungen, die etwas veraendern. Er verlangt eine
* ausdrueckliche Bestaetigung. Bei zerstoerenden Handlungen kann er zusaetzlich
* ein woertlich einzutippendes Wort verlangen — dasselbe Mittel, das
* `uninstall.sh` und die Wiederherstellung mit Ueberschreiben verwenden. Ein
* - `Dialog` für Eingaben (anlegen, bearbeiten). Er lässt sich mit Escape und
* einem Klick daneben schließen.
* - `ConfirmDialog` für Handlungen, die etwas verändern. Er verlangt eine
* ausdrückliche Bestätigung. Bei zerstörenden Handlungen kann er zusätzlich
* ein wörtlich einzutippendes Wort verlangen — dasselbe Mittel, das
* `uninstall.sh` und die Wiederherstellung mit Überschreiben verwenden. Ein
* versehentlich gesetztes Kennzeichen in einem Skript reicht dann nicht aus,
* und ein Klick aus Gewohnheit auch nicht.
*/
@ -54,7 +54,7 @@ export function DialogContent({
{children}
<DialogPrimitive.Close
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" />
</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
* es woertlich eintippen. Das gehoert ausschliesslich an Handlungen, die Daten
* unwiederbringlich entfernen oder ueberschreiben — ein Ritual ohne Anlass
* gewoehnt das Wegklicken an, und dann wirkt es dort nicht mehr, wo es zaehlt.
* `confirmationWord` ist die zweite Hürde: Wird es gesetzt, muss der Betreiber
* es wörtlich eintippen. Das gehört ausschließlich an Handlungen, die Daten
* unwiederbringlich entfernen oder überschreiben — ein Ritual ohne Anlass
* gewöhnt das Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt.
*/
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Bestaetigen',
confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen',
confirmationWord,
confirmationHint,
@ -130,7 +130,7 @@ export function ConfirmDialog({
readonly description?: ReactNode | undefined;
readonly confirmLabel?: 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 confirmationHint?: ReactNode | undefined;
readonly isDestructive?: boolean | undefined;
@ -141,10 +141,10 @@ export function ConfirmDialog({
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogContent size="sm">
{/* Der Inhalt haengt im Portal und wird beim Schliessen ausgehaengt.
Damit verschwindet die eingetippte Bestaetigung von selbst — ein
Zuruecksetzen im Effekt braucht es nicht, und die Huerde steht beim
naechsten Oeffnen wieder. */}
{/* Der Inhalt hängt im Portal und wird beim Schließen ausgehängt.
Damit verschwindet die eingetippte Bestätigung von selbst — ein
Zurücksetzen im Effekt braucht es nicht, und die Hürde steht beim
nächsten Öffnen wieder. */}
<ConfirmDialogBody
title={title}
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({
title,
description,
@ -208,7 +208,7 @@ function ConfirmDialogBody({
>
{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">
{confirmationWord}
</code>

View File

@ -4,8 +4,8 @@
* `ErrorState` zeigt immer die Vorgangsnummer. `NotMeasured` ist die Antwort
* auf einen Grundsatz, der sich durch die ganze Anlage zieht: Unbekannt ist
* nicht null. Eine Kennzahl ohne Datengrundlage erscheint als Satz mit
* Begruendung, nie als "0" — eine Null liest sich wie "geprueft und in
* Ordnung", und genau das waere falsch.
* Begründung, nie als "0" — eine Null liest sich wie "geprüft und in
* Ordnung", und genau das wäre falsch.
*/
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
* Handlungsanweisung.
*/
@ -99,7 +99,7 @@ export function NotMeasured({ reason }: { readonly reason: ReactNode }) {
);
}
/** Auffaelliger Hinweiskasten. */
/** Auffälliger Hinweiskasten. */
export function Callout({
tone = 'info',
title,
@ -114,9 +114,9 @@ export function Callout({
/**
* ARIA-Rolle.
*
* Standard ist `note` — die meisten Hinweise erklaeren etwas und stehen schon
* beim Oeffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung**
* erscheint, gehoert dagegen auf `alert`: Ein Screenreader sagt ihn dann an,
* Standard ist `note` — die meisten Hinweise erklären etwas und stehen schon
* beim Öffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung**
* erscheint, gehört dagegen auf `alert`: Ein Screenreader sagt ihn dann an,
* statt ihn im Seiteninhalt untergehen zu lassen.
*/
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({
label,
children,

View File

@ -1,10 +1,10 @@
/**
* 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
* Backup-Konsole entscheiden Felder wie "Aufbewahrung" oder "Zielpfad" ueber
* Datenverlust, und ein Betreiber soll nicht raten muessen, was die Anlage mit
* Backup-Konsole entscheiden Felder wie "Aufbewahrung" oder "Zielpfad" über
* Datenverlust, und ein Betreiber soll nicht raten müssen, was die Anlage mit
* seiner Eingabe tut.
*/
@ -150,7 +150,7 @@ export const SelectInput = forwardRef<
);
});
/** Kontrollkaestchen mit Beschriftung und Erlaeuterung. */
/** Kontrollkästchen mit Beschriftung und Erläuterung. */
export function CheckboxField({
checked,
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
* 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
* niemand sieht es, weil die Oberflaeche einwandfrei aussieht.
* niemand sieht es, weil die Oberfläche einwandfrei aussieht.
*/
import { describe, expect, it } from 'vitest';
import { resolveStatus } from './StatusBadge';
describe('Statuszuordnung', () => {
it('faerbt einen Teilfehler niemals gruen', () => {
// Entwicklungsregel 1: Teilfehler heissen PARTIAL FAILURE, nie SUCCESS.
it('färbt einen Teilfehler niemals grün', () => {
// Entwicklungsregel 1: Teilfehler heißen PARTIAL FAILURE, nie SUCCESS.
expect(resolveStatus('partial_failure').tone).not.toBe('healthy');
expect(resolveStatus('partial_failure').tone).toBe('warning');
});
it('faerbt ein gesichertes, aber ungepruftes Backup nicht gruen', () => {
// `successful` heisst "der Lauf ist durchgelaufen" — nicht
it('färbt ein gesichertes, aber ungepruftes Backup nicht grün', () => {
// `successful` heißt "der Lauf ist durchgelaufen" — nicht
// "wiederherstellbar". Erst ein Wiederherstellungstest hebt auf
// `recoverable`.
expect(resolveStatus('successful').tone).toBe('neutral');
expect(resolveStatus('recoverable').tone).toBe('healthy');
});
it('behandelt ein beschaedigtes Backup als kritisch, nicht als Warnung', () => {
// Ein Backup mit einem beschaedigten Block ist nicht zu 70 Prozent
it('behandelt ein beschädigtes Backup als kritisch, nicht als Warnung', () => {
// Ein Backup mit einem beschädigten Block ist nicht zu 70 Prozent
// wiederherstellbar, sondern gar nicht.
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
// loeschen. Das als "in Ordnung" zu zeigen waere die gefaehrlichste
// Beschoenigung der Anlage.
// löschen. Das als "in Ordnung" zu zeigen wäre die gefährlichste
// Beschönigung der Anlage.
expect(resolveStatus('advisory').tone).toBe('warning');
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
// als "in Ordnung" durchgehen. Gruen waere der bequeme Standard und der
// gefaehrlichste.
// als "in Ordnung" durchgehen. Grün wäre der bequeme Standard und der
// gefährlichste.
const unknownStatus = resolveStatus('ein_neuer_serverzustand');
expect(unknownStatus.tone).toBe('neutral');
@ -57,7 +57,7 @@ describe('Statuszuordnung', () => {
it('kennt jede Einstufung, die der Server vergeben kann', () => {
// Das Vokabular stammt aus packages/verification/assurance.go. Kommt dort
// 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 [
'failed',
'corrupted',
@ -67,7 +67,7 @@ describe('Statuszuordnung', () => {
]) {
expect(
resolveStatus(classification).label,
`Einstufung ${classification} ist nicht uebersetzt`,
`Einstufung ${classification} ist nicht übersetzt`,
).not.toBe(classification);
}
});

View File

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

View File

@ -3,12 +3,12 @@
*
* Der wichtigste Teil ist die Fehlermeldung: Sie zeigt **immer** die
* `request_id` mit. Ohne sie bleibt einem Betreiber nur "es hat nicht
* funktioniert" — und damit ist weder ihm noch einer spaeteren Fehlersuche
* geholfen. Die Kennung laesst sich mit einem Klick kopieren, weil sie sonst
* funktioniert" — und damit ist weder ihm noch einer späteren Fehlersuche
* geholfen. Die Kennung lässt sich mit einem Klick kopieren, weil sie sonst
* abgetippt wird und dabei ein Zeichen verlorengeht.
*
* 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.
*/
@ -44,7 +44,7 @@ export function useToast(): ToastContextValue {
const contextValue = useContext(ToastContext);
if (!contextValue) {
throw new Error('useToast benoetigt einen ToastProvider im Elternbaum.');
throw new Error('useToast benötigt einen ToastProvider im Elternbaum.');
}
return contextValue;
@ -68,8 +68,8 @@ export function ToastProvider({ children }: { readonly children: ReactNode }) {
{ ...message, id: messageId },
]);
// Fehler bleiben stehen, bis sie jemand schliesst. Alles andere
// verschwindet nach fuenf Sekunden.
// Fehler bleiben stehen, bis sie jemand schließt. Alles andere
// verschwindet nach fünf Sekunden.
if (message.tone !== 'error') {
window.setTimeout(() => removeMessage(messageId), 5000);
}
@ -166,7 +166,7 @@ function ToastCard({
type="button"
onClick={onDismiss}
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" />
</button>

View File

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

View File

@ -2,16 +2,16 @@
* Rollen und Berechtigungen.
*
* Die Berechtigungsauswahl ist nach Bereichen gruppiert, weil eine flache Liste
* mit 34 Eintraegen dazu fuehrt, dass jemand „alles" ankreuzt. Zwei Paare sind
* bewusst getrennt und werden hier ausdruecklich erklaert:
* mit 34 Einträgen dazu führt, dass jemand „alles" ankreuzt. Zwei Paare sind
* bewusst getrennt und werden hier ausdrücklich erklärt:
*
* - `restores.execute` enthaelt **nicht** `restores.overwrite`. Wiederherstellen
* ist etwas anderes als vorhandene Daten ueberschreiben.
* - `backups.delete` enthaelt **nicht** `immutability.manage`. Wer aufraeumen
* darf, darf keinen Loeschschutz aufheben — das ist der Schritt, der einem
* Angreifer den Weg oeffnet.
* - `restores.execute` enthält **nicht** `restores.overwrite`. Wiederherstellen
* ist etwas anderes als vorhandene Daten überschreiben.
* - `backups.delete` enthält **nicht** `immutability.manage`. Wer aufräumen
* darf, darf keinen Löschschutz aufheben — das ist der Schritt, der einem
* 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.
*/
@ -46,16 +46,16 @@ import {
type Role,
} 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>> = {
'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':
'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':
'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':
'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({
@ -92,17 +92,17 @@ export function RolesPage({
rolesResource.reload();
},
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, {
onSuccess: () => {
toast.showSuccess('Rolle geloescht');
toast.showSuccess('Rolle gelöscht');
setRolePendingDeletion(null);
rolesResource.reload();
},
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 ?? [];
@ -110,7 +110,7 @@ export function RolesPage({
/**
* 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
* Gruppierung liefe jedes Mal neu — bei 34 Berechtigungen unmerklich, als
* Muster trotzdem falsch.
@ -119,8 +119,8 @@ export function RolesPage({
const grouped = new Map<string, Permission[]>();
for (const permission of permissionsResource.data ?? []) {
// Ohne ausdrueckliche Kategorie dient der Teil vor dem Punkt als Bereich —
// "backups.read" gehoert zu "backups".
// Ohne ausdrückliche Kategorie dient der Teil vor dem Punkt als Bereich —
// "backups.read" gehört zu "backups".
const categoryName =
permission.category ?? permission.name.split('.')[0] ?? 'sonstige';
const existing = grouped.get(categoryName) ?? [];
@ -166,7 +166,7 @@ export function RolesPage({
header: 'Art',
render: (role) => (
<span className="text-xs text-fg-muted">
{role.is_builtin ? 'Mitgeliefert (unveraenderlich)' : 'Selbst angelegt'}
{role.is_builtin ? 'Mitgeliefert (unveränderlich)' : 'Selbst angelegt'}
</span>
),
},
@ -179,8 +179,8 @@ export function RolesPage({
<Button
variant="ghost"
size="icon"
title="Loeschen"
aria-label={`Rolle ${role.name} loeschen`}
title="Löschen"
aria-label={`Rolle ${role.name} löschen`}
onClick={(clickEvent) => {
clickEvent.stopPropagation();
setRolePendingDeletion(role);
@ -211,7 +211,7 @@ export function RolesPage({
<>
<PageHeader
title="Rollen"
description="Wer was darf. Geprueft wird immer auf dem Server."
description="Wer was darf. Geprüft wird immer auf dem Server."
actions={
mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
@ -243,14 +243,14 @@ export function RolesPage({
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedRole(null)}>
Schliessen
Schließen
</Button>
</div>
<div className="px-5 py-4">
{selectedRole.is_builtin ? (
<Callout tone="info" className="mb-4">
Mitgelieferte Rollen sind <strong>unveraenderlich</strong>. Eine
Aenderung verschoebe die Bedeutung bestehender Zuweisungen —
Mitgelieferte Rollen sind <strong>unveränderlich</strong>. Eine
Änderung verschöbe die Bedeutung bestehender Zuweisungen —
jemand behielte eine Rolle, die etwas anderes bedeutet als bei
ihrer Vergabe.
</Callout>
@ -289,7 +289,7 @@ export function RolesPage({
label="Beschreibung"
value={draftDescription}
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">
@ -348,9 +348,9 @@ export function RolesPage({
<ConfirmDialog
open={rolePendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setRolePendingDeletion(null)}
title="Rolle loeschen"
title="Rolle löschen"
description={rolePendingDeletion?.name}
confirmLabel="Loeschen"
confirmLabel="Löschen"
isDestructive
isLoading={deleteMutation.isRunning}
onConfirm={() => {
@ -360,8 +360,8 @@ export function RolesPage({
}}
>
<Callout tone="warning">
Konten mit dieser Rolle verlieren die zugehoerigen Rechte sofort. Ist
sie noch zugewiesen, lehnt der Server das Loeschen ab.
Konten mit dieser Rolle verlieren die zugehörigen Rechte sofort. Ist
sie noch zugewiesen, lehnt der Server das Löschen ab.
</Callout>
</ConfirmDialog>
</>

View File

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

View File

@ -1,13 +1,13 @@
/**
* Benutzer.
*
* Der Schutz des letzten Administrators liegt beim Server — die Oberflaeche
* kann ihn nicht durchsetzen, weil sie nicht weiss, wer sonst noch Administrator
* ist. Was sie tut: den Serverfehler in einen verstaendlichen Satz uebersetzen,
* Der Schutz des letzten Administrators liegt beim Server — die Oberfläche
* kann ihn nicht durchsetzen, weil sie nicht weiß, wer sonst noch Administrator
* ist. Was sie tut: den Serverfehler in einen verständlichen Satz übersetzen,
* statt „LAST_ADMINISTRATOR" anzuzeigen.
*
* 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';
@ -74,7 +74,7 @@ export function UsersPage({
onSuccess: (user) => {
toast.showSuccess(
'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);
setDraftUsername('');
@ -84,34 +84,34 @@ export function UsersPage({
usersResource.reload();
},
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, {
onSuccess: () => {
toast.showSuccess('Konto geaendert');
toast.showSuccess('Konto geändert');
setUserBeingEdited(null);
usersResource.reload();
},
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, {
onSuccess: () => {
toast.showSuccess('Konto geloescht');
toast.showSuccess('Konto gelöscht');
setUserPendingDeletion(null);
usersResource.reload();
},
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, {
onSuccess: () => {
toast.showSuccess(
'Zweiter Faktor zurueckgesetzt',
'Das Konto ist jetzt nur noch durch sein Passwort geschuetzt.',
'Zweiter Faktor zurückgesetzt',
'Das Konto ist jetzt nur noch durch sein Passwort geschützt.',
);
setUserPendingMfaReset(null);
usersResource.reload();
@ -197,8 +197,8 @@ export function UsersPage({
<Button
variant="ghost"
size="icon"
title="Zweiten Faktor zuruecksetzen"
aria-label={`Zweiten Faktor von ${user.username} zuruecksetzen`}
title="Zweiten Faktor zurücksetzen"
aria-label={`Zweiten Faktor von ${user.username} zurücksetzen`}
onClick={() => setUserPendingMfaReset(user)}
>
<ShieldOff />
@ -207,8 +207,8 @@ export function UsersPage({
<Button
variant="ghost"
size="icon"
title="Loeschen"
aria-label={`${user.username} loeschen`}
title="Löschen"
aria-label={`${user.username} löschen`}
onClick={() => setUserPendingDeletion(user)}
>
<Trash2 />
@ -253,8 +253,8 @@ export function UsersPage({
{usersWithoutMfa > 0 ? (
<Callout tone="warning" className="mb-4" title="Zweiter Faktor fehlt">
{usersWithoutMfa} von {users.length} Konten sind nur durch ihr Passwort
geschuetzt. Bei Konten mit Loeschrecht ist das der Punkt, an dem ein
uebernommenes Konto zum Datenverlust fuehrt.
geschützt. Bei Konten mit Löschrecht ist das der Punkt, an dem ein
übernommenes Konto zum Datenverlust führt.
</Callout>
) : null}
@ -293,7 +293,7 @@ export function UsersPage({
autoComplete="new-password"
value={draftPassword}
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">
@ -384,7 +384,7 @@ export function UsersPage({
<Callout tone="info">
Dem <strong>letzten Administrator</strong> lassen sich die
Verwaltungsrechte nicht entziehen — der Server lehnt das ab. Sonst
waere die Anlage nicht mehr verwaltbar.
wäre die Anlage nicht mehr verwaltbar.
</Callout>
</DialogBody>
<DialogFooter>
@ -415,9 +415,9 @@ export function UsersPage({
<ConfirmDialog
open={userPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setUserPendingDeletion(null)}
title="Konto loeschen"
title="Konto löschen"
description={userPendingDeletion?.username}
confirmLabel="Loeschen"
confirmLabel="Löschen"
isDestructive
isLoading={deleteMutation.isRunning}
confirmationWord={userPendingDeletion?.username}
@ -428,18 +428,18 @@ export function UsersPage({
}}
>
<Callout tone="info">
Die Eintraege dieses Kontos im Ereignisprotokoll bleiben bestehen —
ein Protokoll, das sich durch Loeschen eines Kontos bereinigen liesse,
waere keines.
Die Einträge dieses Kontos im Ereignisprotokoll bleiben bestehen —
ein Protokoll, das sich durch Löschen eines Kontos bereinigen ließe,
wäre keines.
</Callout>
</ConfirmDialog>
<ConfirmDialog
open={userPendingMfaReset !== null}
onOpenChange={(isOpen) => !isOpen && setUserPendingMfaReset(null)}
title="Zweiten Faktor zuruecksetzen"
title="Zweiten Faktor zurücksetzen"
description={userPendingMfaReset?.username}
confirmLabel="Zuruecksetzen"
confirmLabel="Zurücksetzen"
isDestructive
isLoading={mfaResetMutation.isRunning}
onConfirm={() => {
@ -449,10 +449,10 @@ export function UsersPage({
}}
>
<Callout tone="warning">
Danach genuegt <strong>das Passwort allein</strong>. Der Vorgang wird
Danach genügt <strong>das Passwort allein</strong>. Der Vorgang wird
protokolliert. Vergewissern Sie sich auf anderem Weg, dass die
Anfrage tatsaechlich von dieser Person stammt — das ist der uebliche
Ansatzpunkt fuer eine Kontouebernahme.
Anfrage tatsächlich von dieser Person stammt — das ist der übliche
Ansatzpunkt für eine Kontoübernahme.
</Callout>
</ConfirmDialog>
</>

View File

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

View File

@ -2,13 +2,13 @@
* Meldungen.
*
* Das Regelwerk steht mit auf der Seite, und das ist die tragende Entscheidung:
* **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiss,
* was ueberhaupt geprueft wird.** Ohne die Liste bliebe offen, ob „keine
* Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprueft".
* **Eine leere Meldungsliste ist erst dann eine gute Nachricht, wenn man weiß,
* was überhaupt geprüft wird.** Ohne die Liste bliebe offen, ob „keine
* Meldungen" bedeutet „alles in Ordnung" oder „es wird nichts geprüft".
*
* „Zur Kenntnis nehmen" schliesst eine Meldung nicht. Sie bleibt offen und in
* der Liste, bis ihre Ursache verschwindet — sonst verschwaende der Zustand aus
* der Uebersicht, obwohl er weiterbesteht.
* „Zur Kenntnis nehmen" schließt eine Meldung nicht. Sie bleibt offen und in
* der Liste, bis ihre Ursache verschwindet — sonst verschwände der Zustand aus
* der Übersicht, obwohl er weiterbesteht.
*/
import { useCallback, useState } from 'react';
@ -92,7 +92,7 @@ export function AlertsPage(): React.JSX.Element {
title="Meldungen"
description={
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
}
/>
@ -123,7 +123,7 @@ export function AlertsPage(): React.JSX.Element {
{loadState === 'loaded' && data !== null && data.length === 0 ? (
<EmptyState
title={onlyActive ? 'Keine unerledigten Meldungen' : 'Es gibt keine Meldungen'}
description="Die Liste „Worauf geachtet wird“ unten sagt, was geprueft wird — erst damit ist eine leere Liste eine gute Nachricht."
description="Die Liste „Worauf geachtet wird“ unten sagt, was geprüft wird — erst damit ist eine leere Liste eine gute Nachricht."
/>
) : null}
@ -166,8 +166,8 @@ function SummaryBar({ overview }: { readonly overview: AlertOverview }): React.J
<p
className={cn(
'text-2xl font-semibold tabular',
// Farbe nur, wenn der Wert eine Aussage traegt: Null kritische
// Meldungen sind neutral, nicht gruen — gruen hiesse "geprueft
// Farbe nur, wenn der Wert eine Aussage trägt: Null kritische
// Meldungen sind neutral, nicht grün — grün hieße "geprüft
// und in Ordnung", und das sagt diese Zahl allein nicht.
figure.tone && figure.value > 0
? figure.tone === 'critical'
@ -247,7 +247,7 @@ function AlertRow({
</Button>
) : null}
<Button size="sm" variant="ghost" disabled={isBusy} onClick={onResolve}>
Schliessen
Schließen
</Button>
</div>
) : null}
@ -259,9 +259,9 @@ function AlertRow({
/**
* Zeigt, worauf geachtet wird.
*
* Eine Regel, die dauerhaft schweigt, ist gefaehrlicher als keine — sie erweckt
* den Eindruck, es werde geprueft. Deshalb steht bei jeder nicht ausgewerteten
* Regel ausdruecklich, dass sie nicht greift, und warum.
* Eine Regel, die dauerhaft schweigt, ist gefährlicher als keine — sie erweckt
* den Eindruck, es werde geprüft. Deshalb steht bei jeder nicht ausgewerteten
* Regel ausdrücklich, dass sie nicht greift, und warum.
*/
function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX.Element {
return (
@ -285,7 +285,7 @@ function RuleList({ overview }: { readonly overview: AlertOverview }): React.JSX
{rule.available ? (
<StatusBadge status={rule.severity} />
) : (
<StatusBadge status="neutral" label="wird nicht geprueft" />
<StatusBadge status="neutral" label="wird nicht geprüft" />
)}
</div>
<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';
@ -12,17 +12,17 @@ export type AlertStatus = 'open' | 'acknowledged' | 'resolved';
/** Eine Meldung. */
export interface Alert {
/** Oeffentlicher Bezeichner. */
/** Öffentlicher Bezeichner. */
readonly id: string;
/** Ausloesende Regel. */
/** Auslösende Regel. */
readonly rule_name: string;
/** Schweregrad. */
readonly severity: AlertSeverity;
/** Bearbeitungszustand. */
readonly status: AlertStatus;
/** Ueberschrift. */
/** Überschrift. */
readonly title: string;
/** Befund und naechste Handlung. */
/** Befund und nächste Handlung. */
readonly message: string;
/** Art des betroffenen Gegenstands. */
readonly entity_type?: string;
@ -41,11 +41,11 @@ export interface Alert {
readonly last_seen_at: string;
/** Zeitpunkt der Kenntnisnahme in UTC. */
readonly acknowledged_at?: string;
/** Bemerkung des Bestaetigenden. */
/** Bemerkung des Bestätigenden. */
readonly acknowledgement_note?: string;
/** Zeitpunkt der Aufloesung in UTC. */
/** Zeitpunkt der Auflösung in UTC. */
readonly resolved_at?: string;
/** Begruendung der Aufloesung. */
/** Begründung der Auflösung. */
readonly resolution_note?: string;
}
@ -55,13 +55,13 @@ export interface AlertRule {
readonly name: string;
/** Bezeichnung. */
readonly title: string;
/** Erklaerung. */
/** Erklärung. */
readonly description: string;
/** Schweregrad ausgeloester Meldungen. */
/** Schweregrad ausgelöster Meldungen. */
readonly severity: AlertSeverity;
/** Meldet, ob die Regel ausloesen kann. */
/** Meldet, ob die Regel auslösen kann. */
readonly available: boolean;
/** Erklaert eine Regel ohne Datengrundlage. */
/** Erklärt eine Regel ohne Datengrundlage. */
readonly unavailable_reason?: string;
}
@ -79,17 +79,17 @@ export interface AlertSummary {
readonly resolved_last_day: number;
}
/** Antwort der Meldungsuebersicht. */
/** Antwort der Meldungsübersicht. */
export interface AlertOverview {
/** Die Meldungslage. */
readonly summary: AlertSummary;
/** Alle Regeln. */
readonly rules: readonly AlertRule[];
/** Zahl der ausloesbaren Regeln. */
/** Zahl der auslösbaren Regeln. */
readonly available_rule_count: number;
}
/** Laedt die Meldungen. */
/** Lädt die Meldungen. */
export async function fetchAlerts(
onlyActive: boolean,
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> {
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> {
await requestApi<unknown>(`/alerts/${encodeURIComponent(alertIdentifier)}/resolve`, {
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 = {
id: '11111111-1111-4111-8111-111111111111',
username: 'admin',
@ -22,7 +22,7 @@ const testUser = {
permissions: ['users.read'],
};
/** Ein Tokenpaar, wie es die API zurueckgibt. */
/** Ein Tokenpaar, wie es die API zurückgibt. */
const testTokens = {
access_token: 'zugriffstoken', // secretscan:erlaubt: erfundener Testwert
refresh_token: 'erneuerungstoken', // secretscan:erlaubt: erfundener Testwert
@ -85,7 +85,7 @@ describe('LoginPage', () => {
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
.fn()
.mockResolvedValueOnce(
@ -112,14 +112,14 @@ describe('LoginPage', () => {
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
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(() => {
expect(handleAuthenticated).toHaveBeenCalled();
});
});
it('zeigt die Fehlermeldung des Servers verstaendlich an', async () => {
it('zeigt die Fehlermeldung des Servers verständlich an', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
@ -166,14 +166,14 @@ describe('LoginPage', () => {
await userEvent.type(passwordField, 'falsch');
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.
await waitFor(() => {
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
.fn()
.mockResolvedValueOnce(
@ -205,9 +205,9 @@ describe('LoginPage', () => {
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
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(() => {
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 [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 {
if (caughtError instanceof ApiError) {
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.
setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.');
setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.');
} catch (caughtError) {
setErrorMessage(describeError(caughtError));
// Das Passwort wird nach einem Fehlversuch geleert.
@ -88,7 +88,7 @@ export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Elemen
return;
}
setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.');
setErrorMessage('Die Antwort des Servers war unvollständig. Bitte erneut versuchen.');
} catch (caughtError) {
setErrorMessage(describeError(caughtError));
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}>
{isSubmitting ? 'Anmeldung laeuft …' : 'Anmelden'}
{isSubmitting ? 'Anmeldung läuft …' : 'Anmelden'}
</button>
</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.5 text-xs text-fg-muted">
Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein
Wiederherstellungscode moeglich.
Wiederherstellungscode möglich.
</p>
<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"
value={mfaCode}
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"
inputMode="text"
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}>
{isSubmitting ? 'Pruefung laeuft …' : 'Bestaetigen'}
{isSubmitting ? 'Prüfung läuft …' : 'Bestätigen'}
</button>
<button

View File

@ -1,13 +1,16 @@
/**
* Anmeldefunktionen gegen die Syncova-API.
*
* Die Tokens werden ausschliesslich im Arbeitsspeicher gehalten und nicht in
* localStorage abgelegt: dort waeren sie fuer jedes Skript der Seite lesbar und
* ueberstuenden das Schliessen des Browsers (PROMPT.md §45).
* Die Tokens liegen im Arbeitsspeicher **und** im Sitzungsspeicher des Tabs.
* Warum das eine Abkehr von der ursprünglichen Entscheidung ist und was sie
* 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 type { CurrentUser, LoginResult, TokenPair } from '../../types/auth';
import { clearStoredSession, readStoredSession, writeStoredSession } from './sessionStore';
/** Im Arbeitsspeicher gehaltenes Zugriffstoken. */
let currentAccessToken: string | null = null;
@ -15,27 +18,53 @@ let currentAccessToken: string | null = null;
/** Im Arbeitsspeicher gehaltenes Erneuerungstoken. */
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 {
if (currentAccessToken !== null) {
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. */
export function storeTokens(tokenPair: TokenPair): void {
currentAccessToken = tokenPair.access_token;
currentRefreshToken = tokenPair.refresh_token;
writeStoredSession(tokenPair);
}
/** Verwirft die hinterlegten Tokens. */
export function clearTokens(): void {
currentAccessToken = null;
currentRefreshToken = null;
clearStoredSession();
}
/**
* 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.
*/
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;
}
/** 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> {
const loginResult = await requestApi<LoginResult>('/auth/mfa/verify', {
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> {
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',
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.
*
* 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
* die Antwort belastbar wird:
*
* 1. Die Bewertung nennt die Einstufung — und wie gut sie belegt ist.
* 2. Die fehlenden Messungen sind die Handlungsanweisung.
* 3. Die Pruefung erzeugt den Beleg; nur der Wiederherstellungstest ist einer.
* 4. Der Schutz sagt, ob jemand das alles loeschen koennte.
* 3. Die Prüfung erzeugt den Beleg; nur der Wiederherstellungstest ist einer.
* 4. Der Schutz sagt, ob jemand das alles löschen könnte.
*
* Eine unbelastbare Prozentzahl wird als solche gekennzeichnet. "70 %" liest
* sich wie "weitgehend in Ordnung" — bei einem beschaedigten Backup waere das
* grob irrefuehrend, denn es ist nicht zu 70 % wiederherstellbar, sondern gar
* sich wie "weitgehend in Ordnung" — bei einem beschädigten Backup wäre das
* grob irreführend, denn es ist nicht zu 70 % wiederherstellbar, sondern gar
* nicht.
*/
@ -116,19 +116,19 @@ export function BackupDetailPage({
const verifyMutation = useMutation(startVerification, {
onSuccess: () => {
toast.showSuccess(
'Pruefung gestartet',
'Das Ergebnis erscheint unter „Pruefung“, sobald sie durchgelaufen ist.',
'Prüfung gestartet',
'Das Ergebnis erscheint unter „Prüfung“, sobald sie durchgelaufen ist.',
);
setIsVerifyDialogOpen(false);
reloadAll();
},
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, {
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);
setHoldReason('');
reloadAll();
@ -150,7 +150,7 @@ export function BackupDetailPage({
const extendMutation = useMutation(extendRetention, {
onSuccess: () => {
toast.showSuccess('Aufbewahrungsfrist verlaengert');
toast.showSuccess('Aufbewahrungsfrist verlängert');
setIsExtendDialogOpen(false);
reloadAll();
},
@ -161,13 +161,13 @@ export function BackupDetailPage({
const deleteMutation = useMutation(deleteBackup, {
onSuccess: () => {
toast.showSuccess(
'Wiederherstellungspunkt geloescht',
'Wird kein Speicher frei, liegt das an der Deduplizierung — die Bloecke werden von einem anderen Backup gebraucht.',
'Wiederherstellungspunkt gelöscht',
'Wird kein Speicher frei, liegt das an der Deduplizierung — die Blöcke werden von einem anderen Backup gebraucht.',
);
onDeleted();
},
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') {
@ -215,7 +215,7 @@ export function BackupDetailPage({
{mayVerify ? (
<Button onClick={() => setIsVerifyDialogOpen(true)}>
<BadgeCheck />
Pruefen
Prüfen
</Button>
) : null}
</>
@ -251,11 +251,11 @@ export function BackupDetailPage({
</div>
{/* 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. */}
{!assurance.is_trustworthy ? (
<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
ist, sagt der Prozentwert wenig.
</Callout>
@ -307,7 +307,7 @@ export function BackupDetailPage({
</div>
<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
? formatDateTime(assurance.last_verified_at)
: 'Nie'}
@ -328,7 +328,7 @@ export function BackupDetailPage({
{/* --- Schutz --- */}
<div className="space-y-4">
<Card>
<CardHeader title="Loeschschutz" />
<CardHeader title="Löschschutz" />
<CardContent className="space-y-3">
{protection ? (
<>
@ -339,7 +339,7 @@ export function BackupDetailPage({
<ShieldOff className="size-4 text-fg-subtle" aria-hidden />
)}
<span className="text-sm text-fg">
{protection.is_protected ? 'Geschuetzt' : 'Nicht geschuetzt'}
{protection.is_protected ? 'Geschützt' : 'Nicht geschützt'}
</span>
</div>
@ -353,7 +353,7 @@ export function BackupDetailPage({
{protection.legal_hold ? (
<Callout tone="info" title="Legal Hold">
{protection.legal_hold_reason || 'Ohne Begruendung gesetzt.'}
{protection.legal_hold_reason || 'Ohne Begründung gesetzt.'}
</Callout>
) : null}
@ -383,7 +383,7 @@ export function BackupDetailPage({
</Button>
)}
<Button size="sm" onClick={() => setIsExtendDialogOpen(true)}>
Frist verlaengern
Frist verlängern
</Button>
</div>
) : null}
@ -397,7 +397,7 @@ export function BackupDetailPage({
{/* --- Ransomware --- */}
{ransomware ? (
<Card>
<CardHeader title="Auffaelligkeiten" />
<CardHeader title="Auffälligkeiten" />
<CardContent className="space-y-2">
<StatusBadge status={ransomware.severity} />
{ransomware.summary ? (
@ -405,15 +405,15 @@ export function BackupDetailPage({
) : null}
{ransomware.severity === 'unknown' ? (
<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
geratene Schwelle waere schlechter als keine.
geratene Schwelle wäre schlechter als keine.
</p>
) : null}
{ransomware.triggered_signal_count !== undefined ? (
<p className="text-xs text-fg-muted">
{ransomware.triggered_signal_count} von 6 Signalen auffaellig.
Diese Einschaetzung <strong>meldet nur</strong> und handelt nie.
{ransomware.triggered_signal_count} von 6 Signalen auffällig.
Diese Einschätzung <strong>meldet nur</strong> und handelt nie.
</p>
) : null}
</CardContent>
@ -428,12 +428,12 @@ export function BackupDetailPage({
disabled={protection?.is_protected}
title={
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
}
>
<Trash2 />
Loeschen
Löschen
</Button>
) : null}
</div>
@ -453,14 +453,14 @@ export function BackupDetailPage({
<ConfirmDialog
open={isVerifyDialogOpen}
onOpenChange={setIsVerifyDialogOpen}
title="Pruefung starten"
confirmLabel="Pruefung starten"
title="Prüfung starten"
confirmLabel="Prüfung starten"
isLoading={verifyMutation.isRunning}
onConfirm={() => void verifyMutation.run(backupIdentifier, verificationType)}
>
<div className="space-y-3">
<SelectInput
label="Pruefart"
label="Prüfart"
value={verificationType}
onChange={(changeEvent) => setVerificationType(changeEvent.target.value)}
>
@ -482,7 +482,7 @@ export function BackupDetailPage({
{verificationType === 'restore_test' ? (
<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.
</Callout>
) : null}
@ -493,17 +493,17 @@ export function BackupDetailPage({
open={isLegalHoldDialogOpen}
onOpenChange={setIsLegalHoldDialogOpen}
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"
isLoading={legalHoldMutation.isRunning}
onConfirm={() => void legalHoldMutation.run(backupIdentifier, holdReason)}
>
<TextArea
label="Begruendung"
label="Begründung"
required
value={holdReason}
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"
/>
</ConfirmDialog>
@ -518,7 +518,7 @@ export function BackupDetailPage({
onConfirm={() => void releaseMutation.run(backupIdentifier, holdReason)}
>
<TextArea
label="Begruendung"
label="Begründung"
required
value={holdReason}
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
@ -529,8 +529,8 @@ export function BackupDetailPage({
<ConfirmDialog
open={isExtendDialogOpen}
onOpenChange={setIsExtendDialogOpen}
title="Aufbewahrungsfrist verlaengern"
confirmLabel="Verlaengern"
title="Aufbewahrungsfrist verlängern"
confirmLabel="Verlängern"
isLoading={extendMutation.isRunning}
onConfirm={() =>
void extendMutation.run(
@ -549,13 +549,13 @@ export function BackupDetailPage({
onChange={(changeEvent) => setNewRetentionDate(changeEvent.target.value)}
/>
<TextArea
label="Begruendung"
label="Begründung"
value={holdReason}
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
/>
<Callout tone="info">
Verlaengern ja, <strong>verkuerzen nie</strong> — auch nicht fuer
Administratoren. Ein frueheres Datum wird abgelehnt.
Verlängern ja, <strong>verkürzen nie</strong> — auch nicht für
Administratoren. Ein früheres Datum wird abgelehnt.
</Callout>
</div>
</ConfirmDialog>
@ -563,14 +563,14 @@ export function BackupDetailPage({
<ConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
title="Wiederherstellungspunkt loeschen"
confirmLabel="Endgueltig loeschen"
title="Wiederherstellungspunkt löschen"
confirmLabel="Endgültig löschen"
isDestructive
isLoading={deleteMutation.isRunning}
confirmationWord={backupIdentifier}
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">
{backupIdentifier}
</code>
@ -582,7 +582,7 @@ export function BackupDetailPage({
>
<Callout tone="warning">
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.
</Callout>
</ConfirmDialog>

View File

@ -1,15 +1,15 @@
/**
* Pruefungen — Uebersicht.
* Prüfungen — Übersicht.
*
* Die wichtigste Unterscheidung dieser Seite steht in zwei Spalten
* nebeneinander und wird gern verwechselt:
*
* - **Zustand** (`status`): Ist die Pruefung durchgelaufen?
* - **Zustand** (`status`): Ist die Prüfung durchgelaufen?
* - **Ergebnis** (`result`): Was hat sie gefunden?
*
* Eine gescheiterte Pruefung ist **kein Befund am Backup**. Repository nicht
* erreichbar heisst: Auftrag `failed`, Einstufung unberuehrt. Wer beides
* vermengt, baut ein Pruefwerkzeug, das grundlos Alarm schlaegt — und das wird
* Eine gescheiterte Prüfung ist **kein Befund am Backup**. Repository nicht
* erreichbar heißt: Auftrag `failed`, Einstufung unberührt. Wer beides
* vermengt, baut ein Prüfwerkzeug, das grundlos Alarm schlägt — und das wird
* bald nicht mehr ernst genommen.
*/
@ -54,7 +54,7 @@ export function VerificationPage({
const cancelMutation = useMutation(cancelVerification, {
onSuccess: () => {
toast.showSuccess('Pruefung abgebrochen');
toast.showSuccess('Prüfung abgebrochen');
verificationsResource.reload();
},
onError: (apiError) =>
@ -66,7 +66,7 @@ export function VerificationPage({
const tableColumns: ReadonlyArray<TableColumn<VerificationJob>> = [
{
key: 'type',
header: 'Pruefart',
header: 'Prüfart',
render: (job) => (
<div className="min-w-0">
<p className="font-medium text-fg">
@ -91,21 +91,21 @@ export function VerificationPage({
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.
<span className="text-xs text-fg-muted">—</span>
),
},
{
key: 'chunks',
header: 'Bloecke',
header: 'Blöcke',
numeric: true,
render: (job) => (
<span>
{job.chunks_checked.toLocaleString('de-DE')}
{job.chunks_missing > 0 || job.chunks_corrupted > 0 ? (
<span className="ml-1 text-critical">
({job.chunks_missing} fehlend, {job.chunks_corrupted} beschaedigt)
({job.chunks_missing} fehlend, {job.chunks_corrupted} beschädigt)
</span>
) : null}
</span>
@ -156,7 +156,7 @@ export function VerificationPage({
if (verificationsResource.loadState === 'failed' && verificationsResource.loadError) {
return (
<>
<PageHeader title="Pruefung" />
<PageHeader title="Prüfung" />
<Card>
<ErrorState
message={describeApiError(verificationsResource.loadError)}
@ -171,8 +171,8 @@ export function VerificationPage({
return (
<>
<PageHeader
title="Pruefung"
description="Nachweise ueber die Wiederherstellbarkeit."
title="Prüfung"
description="Nachweise über die Wiederherstellbarkeit."
actions={
<Button variant="secondary" onClick={verificationsResource.reload}>
<RefreshCw />
@ -182,9 +182,9 @@ export function VerificationPage({
/>
<Callout tone="info" className="mb-4" title="Zustand ist nicht Ergebnis">
Eine <strong>gescheiterte Pruefung</strong> ist kein Befund am Backup.
Eine <strong>gescheiterte Prüfung</strong> ist kein Befund am Backup.
Ist das Repository nicht erreichbar, gilt der Auftrag als fehlgeschlagen
— die Einstufung des Wiederherstellungspunkts bleibt davon unberuehrt.
— die Einstufung des Wiederherstellungspunkts bleibt davon unberührt.
</Callout>
<Card>
@ -194,8 +194,8 @@ export function VerificationPage({
getRowKey={(job) => job.id}
onRowClick={(job) => onOpenBackup(job.backup_id)}
isLoading={verificationsResource.loadState === 'loading'}
emptyTitle="Noch keine Pruefung"
emptyDescription="Eine Pruefung wird bei einem Wiederherstellungspunkt gestartet. Ohne sie bleibt jede Einstufung ein Indiz."
emptyTitle="Noch keine Prüfung"
emptyDescription="Eine Prüfung wird bei einem Wiederherstellungspunkt gestartet. Ohne sie bleibt jede Einstufung ein Indiz."
/>
</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?**
*
* - Die Bewertung sagt, wie gut die Auskunft belegt ist.
* - Der Schutz sagt, ob es jemand loeschen koennte.
* - Die Pruefung erzeugt den Beleg.
* - Der Schutz sagt, ob es jemand löschen könnte.
* - Die Prüfung erzeugt den Beleg.
*/
import { requestApi } from '../../api/client';
@ -15,17 +15,17 @@ import { requestApi } from '../../api/client';
// Bewertung (Phase 10)
// ---------------------------------------------------------------------------
/** Eine Eingangsgroesse der Bewertung. */
/** Eine Eingangsgröße der Bewertung. */
export interface AssuranceInput {
name: string;
/** Hoechstpunktzahl dieser Groesse. */
/** Höchstpunktzahl dieser Größe. */
weight: 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
* zu schaetzen waere die bequeme und falsche Entscheidung.
* Unbekannt zählt **nie** als gut. Eine nicht gemessene Größe wohlwollend
* zu schätzen wäre die bequeme und falsche Entscheidung.
*/
is_known: boolean;
}
@ -41,19 +41,19 @@ export interface AssuranceReport {
/**
* Meldet, ob die Zahl belastbar ist.
*
* Bei zu vielen ungemessenen Groessen ist die Prozentzahl eine Vermutung,
* keine Aussage — und die Oberflaeche muss das sagen.
* Bei zu vielen ungemessenen Größen ist die Prozentzahl eine Vermutung,
* keine Aussage — und die Oberfläche muss das sagen.
*/
is_trustworthy: boolean;
summary: string;
/** Was gemessen werden muesste. Das ist die Handlungsanweisung. */
/** Was gemessen werden müsste. Das ist die Handlungsanweisung. */
missing_measurements: string[];
inputs: AssuranceInput[];
last_verified_at?: string;
last_restore_test_at?: string;
}
/** Laedt die Bewertung eines Wiederherstellungspunkts. */
/** Lädt die Bewertung eines Wiederherstellungspunkts. */
export async function getAssurance(
backupIdentifier: string,
abortSignal?: AbortSignal,
@ -88,7 +88,7 @@ export interface ProtectionStatus {
enforcement_explanation?: string;
}
/** Laedt den Schutzstatus. */
/** Lädt den Schutzstatus. */
export async function getProtection(
backupIdentifier: string,
abortSignal?: AbortSignal,
@ -102,7 +102,7 @@ export async function getProtection(
/**
* 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.
*/
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
* lehnt ein frueheres Datum ab; die Oberflaeche sagt das vorher.
* Verlängern ja, verkürzen nie — auch nicht für Administratoren. Der Server
* lehnt ein früheres Datum ab; die Oberfläche sagt das vorher.
*/
export async function extendRetention(
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
* beim Ueberschreiben einer Wiederherstellung.
* `confirm_backup_id` wiederholt die Kennung wörtlich — dieselbe Hürde wie
* beim Überschreiben einer Wiederherstellung.
*/
export async function deleteBackup(
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 {
backup_id: string;
/** none, elevated, high oder unknown. */
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
* Verschluesselung.
* Verschlüsselung.
*/
triggered_signal_count?: number;
signals?: Array<{
@ -187,7 +187,7 @@ export interface RansomwareAssessment {
baseline_sample_count?: number;
}
/** Laedt die Ransomware-Einschaetzung. */
/** Lädt die Ransomware-Einschätzung. */
export async function getRansomwareAssessment(
backupIdentifier: string,
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 = [
{
value: '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',
label: 'Blockpruefung',
label: 'Blockprüfung',
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',
label: 'Kette',
description: 'Prueft die Zusatzsicherungen gegen ihre Elternbackups.',
description: 'Prüft die Zusatzsicherungen gegen ihre Elternbackups.',
},
{
value: 'metadata',
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',
label: 'Wiederherstellungstest',
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;
/** Ein Pruefauftrag. */
/** Ein Prüfauftrag. */
export interface VerificationJob {
id: string;
backup_id: string;
@ -254,7 +254,7 @@ export interface VerificationJob {
created_at: string;
}
/** Laedt die Pruefauftraege. */
/** Lädt die Prüfaufträge. */
export async function listVerifications(
abortSignal?: AbortSignal,
): Promise<VerificationJob[]> {
@ -264,7 +264,7 @@ export async function listVerifications(
);
}
/** Startet eine Pruefung. */
/** Startet eine Prüfung. */
export async function startVerification(
backupIdentifier: 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(
verificationIdentifier: string,
): 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
* 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 type { Dashboard } from './dashboardApi';
/** Baut eine Antwort mit den uebergebenen Kennzahlen. */
/** Baut eine Antwort mit den übergebenen Kennzahlen. */
function buildDashboardResponse(dashboard: Dashboard): Response {
return {
status: 200,
@ -21,7 +21,7 @@ function buildDashboardResponse(dashboard: Dashboard): Response {
} as unknown as Response;
}
describe('Uebersicht', () => {
describe('Übersicht', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
@ -30,7 +30,7 @@ describe('Uebersicht', () => {
vi.unstubAllGlobals();
});
it('zeigt eine Kennzahl samt Erlaeuterung', async () => {
it('zeigt eine Kennzahl samt Erläuterung', async () => {
vi.mocked(fetch).mockResolvedValue(
buildDashboardResponse({
available_count: 1,
@ -42,7 +42,7 @@ describe('Uebersicht', () => {
available: true,
value: 87.5,
unit: '%',
detail: '7 von 8 Laeufen vollstaendig erfolgreich.',
detail: '7 von 8 Läufen vollständig erfolgreich.',
severity: 'high',
},
],
@ -53,7 +53,7 @@ describe('Uebersicht', () => {
expect(await screen.findByText('Erfolgsquote (7 Tage)')).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 () => {
@ -78,10 +78,10 @@ describe('Uebersicht', () => {
expect(await screen.findByText('Kritische Meldungen')).toBeInTheDocument();
expect(screen.getByText('Es gibt noch kein Meldungswesen (Phase 14).')).toBeInTheDocument();
// Der entscheidende Teil: keine Null. Eine Null hiesse „keine Probleme" und
// wuerde bedeuten „es wird nicht geprueft".
// Der entscheidende Teil: keine Null. Eine Null hieße „keine Probleme" und
// würde bedeuten „es wird nicht geprüft".
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 () => {
@ -94,7 +94,7 @@ describe('Uebersicht', () => {
key: 'storage',
title: 'Speicherbelegung',
available: true,
detail: 'Die Gesamtkapazitaet ist nicht hinterlegt.',
detail: 'Die Gesamtkapazität ist nicht hinterlegt.',
severity: 'information',
},
],
@ -103,7 +103,7 @@ describe('Uebersicht', () => {
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(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
* 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).
*
* Der Schweregrad faerbt nur die Randlinie, nicht die Kachel. Zehn farbige
* Flaechen nebeneinander ergeben ein Mosaik, in dem die eine kritische Zahl
* Der Schweregrad färbt nur die Randlinie, nicht die Kachel. Zehn farbige
* Flächen nebeneinander ergeben ein Mosaik, in dem die eine kritische Zahl
* untergeht — und genau die soll auffallen.
*/
@ -30,7 +30,7 @@ export function DashboardPage(): React.JSX.Element {
return (
<>
<PageHeader
title="Uebersicht"
title="Übersicht"
description={
data !== null
? `${data.available_count} von ${data.widgets.length} Kennzahlen haben eine Datengrundlage.`
@ -65,7 +65,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>> = {
healthy: 'border-l-healthy',
info: 'border-l-info',
@ -80,7 +80,7 @@ function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX
return (
<Card className="border-l-4 border-l-neutral p-4">
<p className="text-sm font-medium text-fg-muted">{widget.title}</p>
<p className="mt-2 text-sm text-fg-subtle">noch nicht verfuegbar</p>
<p className="mt-2 text-sm text-fg-subtle">noch nicht verfügbar</p>
<p className="mt-1 text-xs text-fg-muted">{widget.unavailable_reason}</p>
</Card>
);
@ -97,8 +97,8 @@ function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX
<p className="mt-2 flex items-baseline gap-1.5">
{widget.value === undefined ? (
// Kein Wert heisst nicht null. Eine Kennzahl, die sich nicht bilden
// laesst, sagt das — der Grund steht im Detailtext darunter.
// Kein Wert heißt nicht null. Eine Kennzahl, die sich nicht bilden
// lässt, sagt das — der Grund steht im Detailtext darunter.
<span className="text-sm text-fg-muted">nicht bezifferbar</span>
) : (
<>
@ -133,15 +133,15 @@ function WidgetCard({ widget }: { readonly widget: DashboardWidget }): React.JSX
/** Formatiert die Hauptzahl einer Kennzahl. */
function formatWidgetValue(widgetValue: number): string {
// 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
// weiss, und auch nicht weniger.
// weiß, und auch nicht weniger.
if (widgetValue > 0 && widgetValue < 0.1) {
return '< 0,1';
}
// 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)) {
return widgetValue.toLocaleString('de-DE');
}
@ -149,7 +149,7 @@ function formatWidgetValue(widgetValue: number): string {
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 {
const readableLabel = breakdownKey.replace(/_/g, ' ');

View File

@ -5,8 +5,8 @@
* es", sondern „auf welche kann ich mich verlassen" — deshalb steht die
* Einstufung in jeder Zeile und nicht in einem Detailfenster.
*
* Der Filter „Ungeprueft" heisst serverseitig `successful`. Das ist kein
* Uebersetzungsfehler, sondern der Kern: Ein erfolgreicher Lauf ist noch kein
* Der Filter „Ungeprüft" heißt serverseitig `successful`. Das ist kein
* Übersetzungsfehler, sondern der Kern: Ein erfolgreicher Lauf ist noch kein
* Nachweis der Wiederherstellbarkeit.
*/
@ -28,26 +28,26 @@ import { formatBytes, formatDateTime, formatRelativeTime } from '@/lib/utils';
import { fetchRecoveryPoints } from './dashboardApi';
import type { RecoveryPoint } from './dashboardApi';
/** Auswahlmoeglichkeiten des Einstufungsfilters. */
/** Auswahlmöglichkeiten des Einstufungsfilters. */
const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
{ value: '', label: 'Alle Einstufungen' },
{ value: 'recoverable', label: 'Nachweislich wiederherstellbar' },
{ value: 'verified', label: 'Geprueft' },
{ value: 'successful', label: 'Ungeprueft' },
{ value: 'corrupted', label: 'Beschaedigt' },
{ value: 'verified', label: 'Geprüft' },
{ value: 'successful', label: 'Ungeprüft' },
{ value: 'corrupted', label: 'Beschädigt' },
];
export function RecoveryPointsPage({
onOpenBackup,
}: {
/** Oeffnet die Einzelheiten eines Punktes. */
/** Öffnet die Einzelheiten eines Punktes. */
readonly onOpenBackup?: ((backupIdentifier: string) => void) | undefined;
} = {}): React.JSX.Element {
const [classificationFilter, setClassificationFilter] = useState('');
const [includeDeleted, setIncludeDeleted] = 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 loadRecoveryPoints = useCallback(
@ -131,9 +131,9 @@ export function RecoveryPointsPage({
header: '',
render: (recoveryPoint) =>
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.
<span className="text-xs text-fg-subtle">geloescht</span>
<span className="text-xs text-fg-subtle">gelöscht</span>
) : null,
},
];
@ -144,7 +144,7 @@ export function RecoveryPointsPage({
title="Wiederherstellungspunkte"
description={
data !== null
? `${data.length} Punkte. Nur ein durchgefuehrter Wiederherstellungstest hebt auf „wiederherstellbar“.`
? `${data.length} Punkte. Nur ein durchgeführter Wiederherstellungstest hebt auf „wiederherstellbar“.`
: undefined
}
/>
@ -168,12 +168,12 @@ export function RecoveryPointsPage({
<CheckboxField
checked={includeDeleted}
onCheckedChange={setIncludeDeleted}
label="Geloeschte einschliessen"
label="Gelöschte einschließen"
/>
<CheckboxField
checked={onlyProtected}
onCheckedChange={setOnlyProtected}
label="Nur geschuetzte"
label="Nur geschützte"
/>
</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';
@ -7,29 +7,29 @@ import { requestApi } from '../../api/client';
/** Statusfarbe einer Kennzahl. */
export type WidgetSeverity = 'healthy' | 'warning' | 'high' | 'critical' | 'information';
/** Eine Kennzahl der Uebersicht. */
/** Eine Kennzahl der Übersicht. */
export interface DashboardWidget {
/** Maschinenlesbarer Bezeichner. */
readonly key: string;
/** Ueberschrift. */
/** Überschrift. */
readonly title: string;
/** Meldet, ob es eine Datengrundlage gibt. */
readonly available: boolean;
/** Erklaert eine fehlende Datengrundlage. */
/** Erklärt eine fehlende Datengrundlage. */
readonly unavailable_reason?: string;
/** Hauptzahl; fehlt, wenn sie sich nicht bilden laesst. */
/** Hauptzahl; fehlt, wenn sie sich nicht bilden lässt. */
readonly value?: number;
/** Einheit der Hauptzahl. */
readonly unit?: string;
/** Erlaeuterung in einem Satz. */
/** Erläuterung in einem Satz. */
readonly detail?: string;
/** Statusfarbe. */
readonly severity: WidgetSeverity;
/** Ergaenzende Einzelwerte. */
/** Ergänzende Einzelwerte. */
readonly breakdown?: Record<string, number>;
}
/** Die Uebersicht der Anlage. */
/** Die Übersicht der Anlage. */
export interface Dashboard {
/** Kennzahlen in Anzeigereihenfolge. */
readonly widgets: readonly DashboardWidget[];
@ -39,9 +39,9 @@ export interface Dashboard {
readonly generated_at: string;
}
/** Ein Wiederherstellungspunkt in der Uebersicht. */
/** Ein Wiederherstellungspunkt in der Übersicht. */
export interface RecoveryPoint {
/** Oeffentlicher Bezeichner. */
/** Öffentlicher Bezeichner. */
readonly id: string;
/** Repository des Punktes. */
readonly repository_id: string;
@ -67,7 +67,7 @@ export interface RecoveryPoint {
readonly completed_at?: string;
/** Objektive Einstufung. */
readonly classification?: string;
/** Letzte Integritaetspruefung in UTC. */
/** Letzte Integritätsprüfung in UTC. */
readonly last_verified_at?: string;
/** Letzter Wiederherstellungstest in UTC. */
readonly last_restore_test_at?: string;
@ -77,34 +77,34 @@ export interface RecoveryPoint {
readonly immutable_until?: string;
/** Meldet einen unbefristeten Schutz. */
readonly legal_hold: boolean;
/** Meldet, ob eine Loeschung derzeit unzulaessig ist. */
/** Meldet, ob eine Löschung derzeit unzulässig ist. */
readonly is_protected: boolean;
/** Zeitpunkt der Loeschung in UTC. */
/** Zeitpunkt der Löschung in UTC. */
readonly deleted_at?: string;
/** Begruendung der Loeschung. */
/** Begründung der Löschung. */
readonly deletion_reason?: string;
}
/** Filter der Liste von Wiederherstellungspunkten. */
export interface RecoveryPointFilter {
/** Beschraenkung auf ein Repository. */
/** Beschränkung auf ein Repository. */
readonly repositoryId?: string;
/** Beschraenkung auf eine Einstufung. */
/** Beschränkung auf eine Einstufung. */
readonly classification?: string;
/** Nimmt geloeschte Punkte auf. */
/** Nimmt gelöschte Punkte auf. */
readonly includeDeleted?: boolean;
/** Beschraenkt auf geschuetzte Punkte. */
/** Beschränkt auf geschützte Punkte. */
readonly onlyProtected?: boolean;
/** Angeforderte Seite. */
readonly page?: number;
}
/** Laedt die Uebersicht. */
/** Lädt die Übersicht. */
export async function fetchDashboard(abortSignal?: AbortSignal): Promise<Dashboard> {
return requestApi<Dashboard>('/dashboard', abortSignal ? { signal: abortSignal } : {});
}
/** Laedt eine Seite von Wiederherstellungspunkten. */
/** Lädt eine Seite von Wiederherstellungspunkten. */
export async function fetchRecoveryPoints(
filter: RecoveryPointFilter = {},
abortSignal?: AbortSignal,

View File

@ -39,7 +39,7 @@ describe('SystemHealthPanel', () => {
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.getByText(/0\.1\.0-test/)).toBeInTheDocument();
});
@ -72,7 +72,7 @@ describe('SystemHealthPanel', () => {
render(<SystemHealthPanel />);
// Ein Problem darf niemals beschoenigt werden (PROMPT.md §140).
// Ein Problem darf niemals beschönigt werden (PROMPT.md §140).
await waitFor(() => {
expect(
screen.getByText('Syncova kann die Control-Plane-Datenbank nicht erreichen.'),
@ -82,7 +82,7 @@ describe('SystemHealthPanel', () => {
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')));
render(<SystemHealthPanel />);
@ -91,13 +91,13 @@ describe('SystemHealthPanel', () => {
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.getByRole('button', { name: 'Erneut versuchen' })).toBeInTheDocument();
});
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(() => {})));
render(<SystemHealthPanel />);

View File

@ -2,12 +2,12 @@
* Anzeige des Systemzustands.
*
* 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.
*
* 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
* 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';

View File

@ -2,7 +2,7 @@
* Hook zum Laden des Systemzustands.
*
* 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).
*/
@ -13,7 +13,7 @@ import type { SystemHealth } from '../../types/api';
/** Ladezustand der Systemzustandsabfrage. */
export type HealthLoadState = 'loading' | 'loaded' | 'failed';
/** Rueckgabewert des Hooks. */
/** Rückgabewert des Hooks. */
export interface UseSystemHealthResult {
/** Aktueller Ladezustand. */
loadState: HealthLoadState;
@ -21,12 +21,12 @@ export interface UseSystemHealthResult {
systemHealth: SystemHealth | null;
/** Aufgetretener Fehler; null, wenn kein Fehler vorliegt. */
loadError: ApiError | null;
/** Laedt den Systemzustand erneut. */
/** Lädt den Systemzustand erneut. */
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.
* Ein Wert von 0 deaktiviert die automatische Aktualisierung.
@ -57,13 +57,13 @@ export function useSystemHealth(refreshIntervalMs = 0): UseSystemHealthResult {
setLoadError(null);
setLoadState('loaded');
} 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') {
return;
}
// 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);
setLoadError(
caughtError instanceof ApiError

View File

@ -2,12 +2,12 @@
* Agenten — Aufnahme und Verwaltung.
*
* Die heikle Stelle ist das Aufnahme-Token: Es wird **genau einmal** angezeigt
* und danach nur noch als Hash gespeichert. Die Oberflaeche sagt das
* ausdruecklich und laesst sich nicht mit Escape schliessen, solange es zu
* und danach nur noch als Hash gespeichert. Die Oberfläche sagt das
* ausdrücklich und lässt sich nicht mit Escape schließen, solange es zu
* sehen ist — wer es wegklickt, muss ein neues erzeugen.
*
* 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';
@ -71,7 +71,7 @@ export function AgentsPage({
},
onError: (apiError) =>
toast.showError(
'Das Aufnahme-Token liess sich nicht erzeugen',
'Das Aufnahme-Token ließ sich nicht erzeugen',
describeApiError(apiError),
apiError.requestId,
),
@ -209,7 +209,7 @@ export function AgentsPage({
/>
<Callout tone="info" className="mb-4">
Der Agent <strong>holt seine Auftraege ab</strong>; der Server drueckt
Der Agent <strong>holt seine Aufträge ab</strong>; der Server drückt
sie nicht. Ein Agent steht hinter einer Firewall und ist vom Server aus
nicht erreichbar — jedenfalls nicht ohne eingehende Portfreigabe auf
jedem gesicherten System.
@ -239,7 +239,7 @@ export function AgentsPage({
<DialogContent>
<DialogHeader
title="Agent aufnehmen"
description="Erzeugt ein einmalig gueltiges Aufnahme-Token."
description="Erzeugt ein einmalig gültiges Aufnahme-Token."
/>
<DialogBody>
<TextInput
@ -248,12 +248,12 @@ export function AgentsPage({
value={draftAgentName}
onChange={(changeEvent) => setDraftAgentName(changeEvent.target.value)}
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">
Das Token gilt <strong>eine Stunde</strong> und nur fuer die
Registrierung. Danach erhaelt der Agent ein Betriebstoken mit
ausschliesslich agentspezifischen Rechten, das einzeln widerrufbar
Das Token gilt <strong>eine Stunde</strong> und nur für die
Registrierung. Danach erhält der Agent ein Betriebstoken mit
ausschließlich agentspezifischen Rechten, das einzeln widerrufbar
ist.
</Callout>
</DialogBody>
@ -294,7 +294,7 @@ export function AgentsPage({
>
<Callout tone="warning">
Das Betriebstoken gilt <strong>sofort</strong> nicht mehr. Der Agent
beendet sich beim naechsten Versuch — ein abgelehntes Token behebt
beendet sich beim nächsten Versuch — ein abgelehntes Token behebt
sich nicht durch Warten. Geplante Sicherungen dieses Systems fallen
danach aus.
</Callout>
@ -306,8 +306,8 @@ export function AgentsPage({
/**
* Zeigt das Aufnahme-Token einmalig.
*
* Der Dialog laesst sich nicht versehentlich schliessen: Es gibt nur eine
* Schaltflaeche, und sie sagt, was sie bewirkt.
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
* Schaltfläche, und sie sagt, was sie bewirkt.
*/
function IssuedTokenDialog({
token,
@ -326,7 +326,7 @@ function IssuedTokenDialog({
window.setTimeout(() => setHasCopied(false), 2000);
} catch {
toast.showInfo(
'Kopieren nicht moeglich',
'Kopieren nicht möglich',
'Markieren Sie das Token und kopieren Sie es von Hand.',
);
}
@ -337,13 +337,13 @@ function IssuedTokenDialog({
<DialogContent size="lg">
<DialogHeader
title="Aufnahme-Token"
description={`Fuer den Agenten „${token.agent_name}“`}
description={`Für den Agenten „${token.agent_name}“`}
/>
<DialogBody>
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
Es wird nur als Hash gespeichert und laesst sich nicht wieder
abrufen. Schliessen Sie dieses Fenster erst, wenn Sie es sicher
hinterlegt haben — sonst muessen Sie ein neues erzeugen.
Es wird nur als Hash gespeichert und lässt sich nicht wieder
abrufen. Schließen Sie dieses Fenster erst, wenn Sie es sicher
hinterlegt haben — sonst müssen Sie ein neues erzeugen.
</Callout>
<div className="rounded-md border border-line bg-sunken p-3">
@ -367,8 +367,8 @@ function IssuedTokenDialog({
</code>
<p className="mt-1.5 text-xs text-fg-muted">
`--state` erwartet eine <strong>Datei</strong>, kein Verzeichnis.
Mit einem Verzeichnis haelt sich der Agent fuer registriert und
laeuft ohne Token.
Mit einem Verzeichnis hält sich der Agent für registriert und
läuft ohne Token.
</p>
</div>
</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
* 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
* System, von dem es Sicherungen geben soll.
*/
@ -111,7 +111,7 @@ export function ProtectedSystemsPage() {
if (agentsResource.loadState === 'failed' && agentsResource.loadError) {
return (
<>
<PageHeader title="Geschuetzte Systeme" />
<PageHeader title="Geschützte Systeme" />
<Card>
<ErrorState
message={describeApiError(agentsResource.loadError)}
@ -126,14 +126,14 @@ export function ProtectedSystemsPage() {
return (
<>
<PageHeader
title="Geschuetzte Systeme"
description="Was gesichert wird — Agenten und virtuelle Gaeste nebeneinander."
title="Geschützte Systeme"
description="Was gesichert wird — Agenten und virtuelle Gäste nebeneinander."
/>
<Callout tone="info" className="mb-4">
Diese Liste zeigt den <strong>Bestand</strong>, nicht den Sicherungsstand.
Dass ein System hier steht, heisst nicht, dass es einen Auftrag dafuer
gibt — das steht unter „Sicherungsauftraege“.
Dass ein System hier steht, heißt nicht, dass es einen Auftrag dafür
gibt — das steht unter „Sicherungsaufträge“.
</Callout>
<Card className="mb-4">
@ -148,7 +148,7 @@ export function ProtectedSystemsPage() {
</Card>
<Card>
<CardHeader title="Virtuelle Gaeste" />
<CardHeader title="Virtuelle Gäste" />
<DataTable
columns={machineColumns}
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
* nur ueber die API bedienen liessen.
* Dieser Bereich hatte bisher gar keine Oberfläche — neun Endpunkte, die sich
* 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:
*
* - **`http://` wird abgelehnt.** Ueber diese Verbindung geht ein API-Token.
* - **Ohne hinterlegten Wirtsschluessel keine SSH-Verbindung.** Einen Schalter
* „Wirtsschluessel egal" gibt es nicht: Ein Transport, der jeden annimmt,
* - **`http://` wird abgelehnt.** Über diese Verbindung geht ein API-Token.
* - **Ohne hinterlegten Wirtsschlüssel keine SSH-Verbindung.** Einen Schalter
* „Wirtsschlüssel egal" gibt es nicht: Ein Transport, der jeden annimmt,
* 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';
@ -90,7 +90,7 @@ export function ProxmoxPage({
onSuccess: (cluster) => {
toast.showSuccess(
'Verbund angelegt',
`${cluster.name} — pruefen Sie als Naechstes die Verbindung.`,
`${cluster.name} — prüfen Sie als Nächstes die Verbindung.`,
);
setIsCreateDialogOpen(false);
setDraftTokenSecret('');
@ -98,7 +98,7 @@ export function ProxmoxPage({
clustersResource.reload();
},
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, {
@ -110,11 +110,11 @@ export function ProxmoxPage({
onError: (apiError) =>
toast.showError(
apiError.statusCode === 503 ? 'Der Verbund ist nicht erreichbar' : 'Die Anmeldung wurde abgelehnt',
// Nicht erreichbar und nicht berechtigt sind zwei Lagen mit voellig
// verschiedener Abhilfe — sie duerfen nicht dieselbe Meldung ergeben.
// Nicht erreichbar und nicht berechtigt sind zwei Lagen mit völlig
// verschiedener Abhilfe — sie dürfen nicht dieselbe Meldung ergeben.
apiError.statusCode === 503
? `${describeApiError(apiError)} — pruefen Sie Netz, Adresse und Zertifikat.`
: `${describeApiError(apiError)} — pruefen Sie Token-Kennung und Geheimnis.`,
? `${describeApiError(apiError)} — prüfen Sie Netz, Adresse und Zertifikat.`
: `${describeApiError(apiError)} — prüfen Sie Token-Kennung und Geheimnis.`,
apiError.requestId,
),
});
@ -124,7 +124,7 @@ export function ProxmoxPage({
toast.showSuccess(
'Bestand erfasst',
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();
},
@ -141,7 +141,7 @@ export function ProxmoxPage({
},
onError: (apiError) =>
toast.showError(
'Der Verbund liess sich nicht entfernen',
'Der Verbund ließ sich nicht entfernen',
apiError.statusCode === 409
? 'Auf diesen Verbund verweisen noch Quellen oder Sicherungen.'
: describeApiError(apiError),
@ -191,7 +191,7 @@ export function ProxmoxPage({
) : (
// "Zuletzt erreicht" wird nur bei Erfolg fortgeschrieben. Bei jedem
// 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>
),
},
@ -216,7 +216,7 @@ export function ProxmoxPage({
<>
<PageHeader
title="Proxmox"
description="Virtualisierungsverbuende und ihre Gaeste."
description="Virtualisierungsverbünde und ihre Gäste."
actions={
mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
@ -228,10 +228,10 @@ export function ProxmoxPage({
/>
<Callout tone="warning" className="mb-4" title="Nicht auf echter Hardware freigegeben">
Sicherung und bitgenaues Zurueckschreiben eines Gasts laufen gegen einen
Sicherung und bitgenaues Zurückschreiben eines Gasts laufen gegen einen
Nachbau der API durch. <strong>Ob eine wiederhergestellte Maschine
startet, ist ungeprueft</strong> — dafuer stand kein Verbund zur
Verfuegung.
startet, ist ungeprüft</strong> — dafür stand kein Verbund zur
Verfügung.
</Callout>
<Card>
@ -242,7 +242,7 @@ export function ProxmoxPage({
onRowClick={(cluster) => setSelectedCluster(cluster)}
isLoading={clustersResource.loadState === 'loading'}
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 — kein Ticket, denn ein Token ist dauerhaft gültig und einzeln widerrufbar."
emptyAction={
mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
@ -272,7 +272,7 @@ export function ProxmoxPage({
<DialogContent size="lg">
<DialogHeader
title="Proxmox-Verbund anlegen"
description="Zugangsdaten werden verschluesselt abgelegt und nie wieder ausgeliefert."
description="Zugangsdaten werden verschlüsselt abgelegt und nie wieder ausgeliefert."
/>
<DialogBody>
<TextInput
@ -292,7 +292,7 @@ export function ProxmoxPage({
placeholder="https://pve.example:8006"
error={
draftEndpoint.trim().length > 8 && !endpointIsSecure
? 'Nur https. Ueber diese Verbindung geht ein API-Token.'
? 'Nur https. Über diese Verbindung geht ein API-Token.'
: undefined
}
/>
@ -323,11 +323,11 @@ export function ProxmoxPage({
value={draftFingerprint}
onChange={(changeEvent) => setDraftFingerprint(changeEvent.target.value)}
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
label="Speicherkennung fuer Sicherungen"
label="Speicherkennung für Sicherungen"
required
className="font-mono"
value={draftStorageId}
@ -342,7 +342,7 @@ export function ProxmoxPage({
onChange={(changeEvent) =>
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="ssh">SSH zum Knoten</option>
@ -367,15 +367,15 @@ export function ProxmoxPage({
/>
</div>
<TextArea
label="Privater Schluessel (PEM)"
label="Privater Schlüssel (PEM)"
required
className="font-mono text-xs"
value={draftSshKey}
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
label="Wirtsschluessel-Fingerabdruck"
label="Wirtsschlüssel-Fingerabdruck"
required
className="font-mono text-xs"
value={draftSshFingerprint}
@ -384,10 +384,10 @@ export function ProxmoxPage({
hint="Aus „ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub“ auf dem Knoten."
/>
<Callout tone="warning">
Einen Schalter „Wirtsschluessel egal" gibt es nicht. Ein
Einen Schalter „Wirtsschlüssel egal" gibt es nicht. Ein
Transport, der jeden annimmt, macht aus einem Zwischenangriff
eine Einladung — der Angreifer lieferte dann das Archiv, das
Syncova fuer ein Backup haelt.
Syncova für ein Backup hält.
</Callout>
</div>
)}
@ -396,7 +396,7 @@ export function ProxmoxPage({
checked={draftKeepArchive}
onCheckedChange={setDraftKeepArchive}
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>
<DialogFooter>
@ -461,7 +461,7 @@ export function ProxmoxPage({
}}
>
<Callout tone="info">
Die vorhandenen Sicherungen der Gaeste bleiben im Repository. Verweisen
Die vorhandenen Sicherungen der Gäste bleiben im Repository. Verweisen
noch Quellen auf diesen Verbund, wird das Entfernen abgelehnt.
</Callout>
</ConfirmDialog>
@ -515,8 +515,8 @@ function ClusterDetailCard({
header: 'Zustand',
render: (machine) =>
machine.missing_since ? (
// Ein fehlender Gast wird vermerkt, nie geloescht — eine geloeschte
// Zeile naehme die Zuordnung zu vorhandenen Backups mit.
// Ein fehlender Gast wird vermerkt, nie gelöscht — eine gelöschte
// Zeile nähme die Zuordnung zu vorhandenen Backups mit.
<span className="text-xs text-warning">
Fehlt seit {formatRelativeTime(machine.missing_since)}
</span>
@ -542,8 +542,8 @@ function ClusterDetailCard({
{machine.disk_count}
{machine.excluded_disk_count > 0 ? (
// Eine Platte mit backup=0 macht den Lauf zum Teilfehler: Die
// Wiederherstellung liefert sonst eine unvollstaendige Maschine,
// die jemand fuer vollstaendig haelt.
// Wiederherstellung liefert sonst eine unvollständige Maschine,
// die jemand für vollständig hält.
<span className="ml-1 text-warning">
({machine.excluded_disk_count} ausgenommen)
</span>
@ -560,7 +560,7 @@ function ClusterDetailCard({
description={cluster.api_endpoint}
action={
<Button variant="ghost" size="sm" onClick={onClose}>
Schliessen
Schließen
</Button>
}
/>
@ -582,7 +582,7 @@ function ClusterDetailCard({
<div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4">
<Button size="sm" isLoading={isTesting} onClick={onTest}>
<Network />
Verbindung pruefen
Verbindung prüfen
</Button>
<Button size="sm" isLoading={isDiscovering} onClick={onDiscover}>
<Search />
@ -601,7 +601,7 @@ function ClusterDetailCard({
<div className="mt-5 border-t border-line pt-4">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle">
Gaeste
Gäste
</p>
<DataTable
columns={machineColumns}
@ -609,7 +609,7 @@ function ClusterDetailCard({
getRowKey={(machine) => machine.id}
isLoading={machinesResource.loadState === 'loading'}
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>
</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
* 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,
* warum ein Lesezugriff auf die Konfiguration ungefaehrlich ist.
* die API gibt sie nicht zurück, und das ist kein Mangel, sondern der Grund,
* 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.
*/
import { requestApi } from '../../api/client';
// ---------------------------------------------------------------------------
// Proxmox-Verbuende
// Proxmox-Verbünde
// ---------------------------------------------------------------------------
/** Zugriffsweg auf die Sicherungsarchive. */
@ -44,7 +44,7 @@ export interface ClusterInput {
name: string;
api_endpoint: string;
api_token_id: string;
/** Wird verschluesselt abgelegt und nie wieder ausgeliefert. */
/** Wird verschlüsselt abgelegt und nie wieder ausgeliefert. */
api_token_secret: string;
tls_fingerprint?: string;
backup_storage_id: string;
@ -53,7 +53,7 @@ export interface ClusterInput {
ssh_username?: string;
ssh_port?: number;
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>;
keep_archive_on_node?: boolean;
}
@ -83,23 +83,23 @@ export interface VirtualMachine {
/**
* 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
* unvollstaendige Maschine liefert, die jemand fuer vollstaendig haelt.
* unvollständige Maschine liefert, die jemand für vollständig hält.
*/
excluded_disk_count: number;
/**
* Zeitpunkt, seit dem der Gast im Bestand fehlt.
*
* Ein fehlender Gast wird nie geloescht: Er koennte abgeschaltet oder
* verschoben sein, und eine geloeschte Zeile naehme die Zuordnung zu
* Ein fehlender Gast wird nie gelöscht: Er könnte abgeschaltet oder
* verschoben sein, und eine gelöschte Zeile nähme die Zuordnung zu
* vorhandenen Backups mit — die man genau dann braucht, wenn die Maschine
* weg ist.
*/
missing_since?: string;
}
/** Laedt die Verbuende. */
/** Lädt die Verbünde. */
export async function listClusters(abortSignal?: AbortSignal): Promise<Cluster[]> {
return requestApi<Cluster[]>(
'/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> {
return requestApi<void>(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}`, {
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
* 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(
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(
clusterIdentifier: string,
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(
clusterIdentifier: string,
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(
abortSignal?: AbortSignal,
): Promise<VirtualMachine[]> {
@ -202,15 +202,15 @@ export interface EnrollmentToken {
/**
* Das Token im Klartext — **einmalig**.
*
* Es wird nur als Hash gespeichert und laesst sich nie wieder abrufen. Die
* Oberflaeche muss das sagen, sonst schliesst jemand das Fenster.
* Es wird nur als Hash gespeichert und lässt sich nie wieder abrufen. Die
* Oberfläche muss das sagen, sonst schließt jemand das Fenster.
*/
enrollment_token: string;
agent_name: string;
expires_at?: string;
}
/** Laedt die Agenten. */
/** Lädt die Agenten. */
export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> {
return requestApi<Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {});
}
@ -218,7 +218,7 @@ export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> {
/**
* 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.
*/
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(
agentIdentifier: string,
abortSignal?: AbortSignal,

View File

@ -1,8 +1,8 @@
/**
* Tests des Backup-Assistenten.
*
* Geprueft wird der Ablauf durch die Maske: dass unvollstaendige Schritte
* aufhalten, dass Rueckwaertsgehen keine Eingaben verliert und dass eine
* Geprüft wird der Ablauf durch die Maske: dass unvollständige Schritte
* aufhalten, dass Rückwärtsgehen keine Eingaben verliert und dass eine
* Fehlermeldung des Servers sichtbar wird statt zu verschwinden.
*/
@ -40,15 +40,15 @@ beforeEach(() => {
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> {
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.type(screen.getByLabelText('Pfad'), '/daten');
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' }));
}
@ -57,7 +57,7 @@ describe('Backup-Assistent', () => {
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
// 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());
for (const stepTitle of [
@ -67,9 +67,9 @@ describe('Backup-Assistent', () => {
'Repository',
'Aufbewahrung',
'Sicherheit',
'Pruefung',
'Prüfung',
'Benachrichtigung',
'Uebersicht',
'Übersicht',
'Anlegen',
]) {
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
// zu zeigen hiesse, ein leeres Formular als fehlerhaft zu markieren.
it('haelt bei einem leeren Namen auf und erklaert warum', async () => {
// zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren.
it('hält bei einem leeren Namen auf und erklärt warum', async () => {
const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
@ -90,7 +90,7 @@ describe('Backup-Assistent', () => {
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();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
@ -104,17 +104,17 @@ describe('Backup-Assistent', () => {
expect(screen.getByRole('alert')).toHaveTextContent('mindestens eine Quelle');
});
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst waere
// jeder Blick zurueck ein Datenverlust.
it('bewahrt die Eingaben beim Zurueckgehen', async () => {
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst wäre
// jeder Blick zurück ein Datenverlust.
it('bewahrt die Eingaben beim Zurückgehen', async () => {
const user = userEvent.setup();
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: '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 () => {
@ -126,12 +126,12 @@ describe('Backup-Assistent', () => {
await user.type(screen.getByLabelText('Pfad'), '/daten');
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
// 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();
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
// vorgetaeuschtes Funktionsversprechen (PROMPT.md §138).
// vorgetäuschtes Funktionsversprechen (PROMPT.md §138).
it('kennzeichnet die nicht umgesetzten Schritte, statt Eingaben zu sammeln', async () => {
const user = userEvent.setup();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
@ -160,15 +160,15 @@ describe('Backup-Assistent', () => {
await user.click(screen.getByRole('button', { name: 'Weiter' }));
// Schritt 5: Aufbewahrung.
expect(screen.getByText('Noch nicht verfuegbar')).toBeInTheDocument();
expect(screen.getByText(/nicht automatisch geloescht/)).toBeInTheDocument();
expect(screen.getByText('Noch nicht verfügbar')).toBeInTheDocument();
expect(screen.getByText(/nicht automatisch gelöscht/)).toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
it('legt den Auftrag an und meldet ihn nach oben', async () => {
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 handleCreated = vi.fn();
@ -190,20 +190,20 @@ describe('Backup-Assistent', () => {
await waitFor(() => expect(handleCreated).toHaveBeenCalledWith(createdJob));
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?.repository_id).toBe(writableRepository.id);
});
// 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 () => {
const user = userEvent.setup();
vi.spyOn(jobsApi, 'createJob').mockRejectedValue(
new ApiError({
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,
requestId: 'req-1',
}),
@ -225,8 +225,8 @@ describe('Backup-Assistent', () => {
expect(screen.getByRole('alert')).toHaveTextContent('wiederherstellungspunkt'),
);
// Der Assistent bleibt stehen, damit der Anwender zurueckgehen und den
// Zeitplan aendern kann.
// Der Assistent bleibt stehen, damit der Anwender zurückgehen und den
// Zeitplan ändern kann.
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();
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
await user.click(screen.getByRole('button', { name: 'Weiter' }));
// Ein noch nicht erreichter Schritt ist nicht anklickbar: Er wuerde eine
// Pruefung ueberspringen, die der Assistent gerade fuehren soll.
// Ein noch nicht erreichter Schritt ist nicht anklickbar: Er würde eine
// Prüfung überspringen, die der Assistent gerade führen soll.
expect(screen.getByRole('button', { name: /Repository/ })).toBeDisabled();
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).
*
* Der Assistent fuehrt von Name bis Anlegen. Die Pruefung geschieht je Schritt,
* damit ein Fehler dort auffaellt, wo er entsteht - nicht erst nach dem letzten
* Schritt. Rueckwaerts geht es ohne Datenverlust: Der Entwurf lebt in einem
* Der Assistent führt von Name bis Anlegen. Die Prüfung geschieht je Schritt,
* damit ein Fehler dort auffällt, wo er entsteht - nicht erst nach dem letzten
* Schritt. Rückwärts geht es ohne Datenverlust: Der Entwurf lebt in einem
* Zustand, nicht in den Eingabefeldern.
*/
@ -58,7 +58,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
const stepValidation = validateStep(currentStep, jobDraft);
// 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(() => {
const abortController = new AbortController();
@ -75,17 +75,17 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
return () => abortController.abort();
}, []);
/** Aendert ein Feld des Entwurfs. */
/** Ändert ein Feld des Entwurfs. */
function updateDraft(changedFields: Partial<JobDraft>): void {
setJobDraft((previousDraft) => ({ ...previousDraft, ...changedFields }));
setShowProblems(false);
}
/** Geht einen Schritt vor, sofern der aktuelle vollstaendig ist. */
/** Geht einen Schritt vor, sofern der aktuelle vollständig ist. */
function goToNextStep(): void {
if (!stepValidation.isComplete) {
// 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);
return;
@ -95,7 +95,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
setCurrentStepIndex((previousIndex) => Math.min(previousIndex + 1, WIZARD_STEPS.length - 1));
}
/** Geht einen Schritt zurueck. */
/** Geht einen Schritt zurück. */
function goToPreviousStep(): void {
setShowProblems(false);
setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0));
@ -171,7 +171,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<div className="flex gap-2">
{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}>
Zurueck
Zurück
</button>
)}
@ -195,7 +195,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</section>
);
/** Waehlt den Inhalt des aktuellen Schrittes. */
/** Wählt den Inhalt des aktuellen Schrittes. */
function renderStepContent(): React.JSX.Element {
const unavailableExplanation = UNAVAILABLE_STEPS[currentStep];
@ -227,14 +227,14 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
* Zeigt einen Schritt ohne Umsetzung im Backend.
*
* 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
* tatsaechlich geschieht.
* tatsächlich geschieht.
*/
function renderUnavailableStep(explanationText: string): React.JSX.Element {
return (
<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>
</div>
);
@ -251,7 +251,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
type="text"
value={jobDraft.name}
onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })}
placeholder="Naechtliche Sicherung Dateiserver"
placeholder="Nächtliche Sicherung Dateiserver"
autoFocus
/>
</label>
@ -271,7 +271,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/** Schritt 2: Quellen. */
function renderSourceStep(): React.JSX.Element {
/** Aendert eine einzelne Quelle. */
/** Ändert eine einzelne Quelle. */
function updateSource(sourceIndex: number, changedFields: Partial<DraftSource>): void {
updateDraft({
sources: jobDraft.sources.map((existingSource, currentIndex) =>
@ -302,7 +302,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
disabled={!SUPPORTED_SOURCE_TYPES.includes(sourceType)}
>
{SOURCE_TYPE_LABELS[sourceType]}
{!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfuegbar)' : ''}
{!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfügbar)' : ''}
</option>
))}
</select>
@ -332,7 +332,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</label>
<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
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"
@ -392,10 +392,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
}
>
<option value="manual">Nur auf Anforderung</option>
<option value="interval">In festen Abstaenden</option>
<option value="hourly">Stuendlich</option>
<option value="daily">Taeglich</option>
<option value="weekly">Woechentlich</option>
<option value="interval">In festen Abständen</option>
<option value="hourly">Stündlich</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="monthly">Monatlich</option>
<option value="cron">Cron-Ausdruck</option>
</select>
@ -476,10 +476,10 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
</label>
))}
</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. */}
<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>
</fieldset>
)}
@ -529,7 +529,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
if (repositories.length === 0) {
return (
<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.
</p>
);
@ -554,7 +554,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
{/* Ein Ziel, das keine Sicherungen annimmt, wird benannt statt
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 && (
<span className="text-xs text-warning">
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
@ -571,12 +571,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
function renderSecurityStep(): React.JSX.Element {
return (
<div className="mt-4 space-y-4">
{/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst
ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere
{/* Verschlüsselung ist keine Wahl: Der Executor verweigert den Dienst
ohne Schlüsselmaterial. Eine Schaltfläche zum Abschalten wäre
eine Einstellung, die es nicht gibt. */}
<div className="mt-3 rounded-md border border-info/30 bg-info-bg p-3 text-sm">
<strong>Verschluesselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM
verschluesselt, bevor sie das System verlassen. Der Schluessel gehoert zum Repository und
<strong>Verschlüsselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM
verschlüsselt, bevor sie das System verlassen. Der Schlüssel gehört zum Repository und
wird vom Dienst verwaltet.
</div>
@ -595,7 +595,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<option value="low">Niedrig</option>
</select>
<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>
</label>
@ -616,7 +616,7 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
);
}
/** Schritt 9: Uebersicht. */
/** Schritt 9: Übersicht. */
function renderReviewStep(): React.JSX.Element {
const selectedRepository = repositories.find(
(repositoryEntry) => repositoryEntry.id === jobDraft.repositoryId,
@ -664,12 +664,12 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
<dt>Sicherheit</dt>
<dd>
verschluesselt, Dringlichkeit {jobDraft.priority}
{jobDraft.bandwidthLimit.trim() !== '' && `, hoechstens ${jobDraft.bandwidthLimit}`}
verschlüsselt, Dringlichkeit {jobDraft.priority}
{jobDraft.bandwidthLimit.trim() !== '' && `, höchstens ${jobDraft.bandwidthLimit}`}
</dd>
<dt>Pruefung</dt>
<dd className="text-sm text-fg-muted">keine automatische Pruefung</dd>
<dt>Prüfung</dt>
<dd className="text-sm text-fg-muted">keine automatische Prüfung</dd>
<dt>Benachrichtigung</dt>
<dd className="text-sm text-fg-muted">keine</dd>
@ -682,8 +682,8 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
return (
<div className="mt-4 space-y-4">
<p>
Der Auftrag wird angelegt und laeuft ab sofort nach Zeitplan. Der erste Lauf ist eine
vollstaendige Sicherung; jeder weitere liest nur, was sich geaendert hat.
Der Auftrag wird angelegt und läuft ab sofort nach Zeitplan. Der erste Lauf ist eine
vollständige Sicherung; jeder weitere liest nur, was sich geändert hat.
</p>
{submitError !== null && (
@ -699,8 +699,8 @@ export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): Rea
/**
* Bildet die Darstellung eines Schrittes in der Kopfzeile.
*
* Ein noch nicht erreichter Schritt bleibt gedaempft und ist nicht anklickbar —
* er uebersprang eine Pruefung.
* Ein noch nicht erreichter Schritt bleibt gedämpft und ist nicht anklickbar —
* er übersprang eine Prüfung.
*/
function buildStepClassName(stepIndex: number, currentStepIndex: number): string {
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`;
}
/** 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 {
if (caughtError instanceof ApiError) {
return caughtError.message;

View File

@ -4,12 +4,12 @@
* Die Laufhistorie ist der Ort, an dem ein Betreiber herausfindet, **warum**
* etwas nicht stimmt. Deshalb steht bei einem nicht erfolgreichen Lauf nicht
* 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.
*
* Ein Teilfehler bekommt eine eigene Erlaeuterung. "Erfolgreich mit
* uebergangenen Objekten" gibt es nicht — die Datenbank laesst es per CHECK
* nicht zu, und die Oberflaeche darf es erst recht nicht suggerieren.
* Ein Teilfehler bekommt eine eigene Erläuterung. "Erfolgreich mit
* übergangenen Objekten" gibt es nicht — die Datenbank lässt es per CHECK
* nicht zu, und die Oberfläche darf es erst recht nicht suggerieren.
*/
import { Ban, Pause, Play, RefreshCw } from 'lucide-react';
@ -84,8 +84,8 @@ export function JobDetailPage({
},
onError: (apiError) =>
apiError.statusCode === 409
? toast.showInfo('Dieser Auftrag laeuft bereits', describeApiError(apiError))
: toast.showError('Der Lauf liess sich nicht anstossen', describeApiError(apiError), apiError.requestId),
? toast.showInfo('Dieser Auftrag läuft bereits', describeApiError(apiError))
: toast.showError('Der Lauf ließ sich nicht anstoßen', describeApiError(apiError), apiError.requestId),
});
const pauseMutation = useMutation(pauseJob, {
@ -108,11 +108,11 @@ export function JobDetailPage({
const cancelRunMutation = useMutation(cancelJobRun, {
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();
},
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') {
@ -122,7 +122,7 @@ export function JobDetailPage({
if (jobResource.loadState === 'failed' || !jobResource.data) {
return (
<>
<PageHeader title="Auftrag" onBack={onBack} backLabel="Zu den Auftraegen" />
<PageHeader title="Auftrag" onBack={onBack} backLabel="Zu den Aufträgen" />
<Card>
<ErrorState
message={
@ -186,10 +186,10 @@ export function JobDetailPage({
<span>
{run.files_processed?.toLocaleString('de-DE') ?? '—'}
{run.files_skipped ? (
// Uebergangene Objekte sind der Grund fuer den Teilfehler und
// gehoeren deshalb in dieselbe Zelle, nicht in eine Fussnote.
// Übergangene Objekte sind der Grund für den Teilfehler und
// gehören deshalb in dieselbe Zelle, nicht in eine Fußnote.
<span className="ml-1 text-warning">
({run.files_skipped.toLocaleString('de-DE')} uebergangen)
({run.files_skipped.toLocaleString('de-DE')} übergangen)
</span>
) : null}
</span>
@ -222,7 +222,7 @@ export function JobDetailPage({
title={job.name}
description={job.description || job.schedule_description}
onBack={onBack}
backLabel="Zu den Auftraegen"
backLabel="Zu den Aufträgen"
actions={
mayRunJobs ? (
<>
@ -252,7 +252,7 @@ export function JobDetailPage({
{job.status === 'paused' ? (
<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.
</Callout>
) : null}
@ -267,7 +267,7 @@ export function JobDetailPage({
</DetailItem>
<DetailItem label="Dringlichkeit">{job.priority}</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'}
</DetailItem>
<DetailItem label="Bandbreitengrenze">
@ -317,7 +317,7 @@ export function JobDetailPage({
<Card>
<CardHeader
title="Laufhistorie"
description="Die letzten 50 Laeufe."
description="Die letzten 50 Läufe."
action={
<Button variant="ghost" size="sm" onClick={runsResource.reload}>
<RefreshCw />
@ -332,7 +332,7 @@ export function JobDetailPage({
onRowClick={(run) => setSelectedRun(run)}
isLoading={runsResource.loadState === 'loading'}
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>
@ -370,9 +370,9 @@ function LastRunSummary({ run }: { readonly run: BackupJobRun }) {
{run.status === 'partial_failure' ? (
<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
wiederholt — die uebergangenen Objekte waeren beim naechsten Versuch
wiederholt — die übergangenen Objekte wären beim nächsten Versuch
dieselben.
</Callout>
) : null}
@ -389,7 +389,7 @@ function LastRunSummary({ run }: { readonly run: BackupJobRun }) {
);
}
/** Einzelheiten eines ausgewaehlten Laufs. */
/** Einzelheiten eines ausgewählten Laufs. */
function RunDetailCard({
run,
onClose,
@ -404,7 +404,7 @@ function RunDetailCard({
description={formatDateTime(run.started_at)}
action={
<Button variant="ghost" size="sm" onClick={onClose}>
Schliessen
Schließen
</Button>
}
/>
@ -424,7 +424,7 @@ function RunDetailCard({
<DetailItem label="Objekte erfasst">
{run.files_processed?.toLocaleString('de-DE') ?? '—'}
</DetailItem>
<DetailItem label="Objekte uebergangen">
<DetailItem label="Objekte übergangen">
{run.files_skipped?.toLocaleString('de-DE') ?? '0'}
</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
* liessen. Ein Betreiber, der einen Lauf ausserhalb des Zeitplans anstossen
* wollte, brauchte dafuer die Kommandozeile.
* Bis hierher zeigte die Oberfläche Aufträge an, die sich nicht bedienen
* ließen. Ein Betreiber, der einen Lauf außerhalb des Zeitplans anstoßen
* wollte, brauchte dafür die Kommandozeile.
*
* Zwei Entscheidungen tragen diese Seite:
*
* - **Ein zweiter Anstoss ist kein Fehler, sondern eine Auskunft.** Der Server
* antwortet mit 409, wenn der Auftrag bereits laeuft. Das als Fehler zu
* melden waere technisch richtig und praktisch irrefuehrend — der Auftrag
* laeuft ja, und genau das wollte der Betreiber.
* - **Loeschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben
* bestehen; sie gehoeren zum Repository, nicht zum Auftrag. Wer das nicht
* weiss, loescht einen Auftrag in der Annahme, damit Platz zu schaffen.
* - **Ein zweiter Anstoß ist kein Fehler, sondern eine Auskunft.** Der Server
* antwortet mit 409, wenn der Auftrag bereits läuft. Das als Fehler zu
* melden wäre technisch richtig und praktisch irreführend — der Auftrag
* läuft ja, und genau das wollte der Betreiber.
* - **Löschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben
* bestehen; sie gehören zum Repository, nicht zum Auftrag. Wer das nicht
* weiß, löscht einen Auftrag in der Annahme, damit Platz zu schaffen.
*/
import {
@ -76,30 +76,30 @@ export function JobsPage({
const runMutation = useMutation(runJob, {
onSuccess: () => {
// 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.');
jobsResource.reload();
},
onError: (apiError) => {
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.
toast.showInfo('Dieser Auftrag laeuft bereits', describeApiError(apiError));
toast.showInfo('Dieser Auftrag läuft bereits', describeApiError(apiError));
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, {
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();
},
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, {
@ -108,20 +108,20 @@ export function JobsPage({
jobsResource.reload();
},
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, {
onSuccess: () => {
toast.showSuccess(
'Auftrag geloescht',
'Auftrag gelöscht',
'Die vorhandenen Wiederherstellungspunkte bleiben bestehen.',
);
setJobPendingDeletion(null);
jobsResource.reload();
},
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 ?? [];
@ -154,14 +154,14 @@ export function JobsPage({
</span>
</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.
<span className="text-xs text-fg-muted">Noch nie gelaufen</span>
),
},
{
key: 'next_run_at',
header: 'Naechster Lauf',
header: 'Nächster Lauf',
render: (job) =>
job.next_run_at ? (
<span className="text-sm text-fg-muted">{formatRelativeTime(job.next_run_at)}</span>
@ -184,7 +184,7 @@ export function JobsPage({
render: (job) => (
<div
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.
onClick={(clickEvent) => clickEvent.stopPropagation()}
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
@ -225,8 +225,8 @@ export function JobsPage({
<Button
variant="ghost"
size="icon"
title="Loeschen"
aria-label={`Auftrag ${job.name} loeschen`}
title="Löschen"
aria-label={`Auftrag ${job.name} löschen`}
onClick={() => setJobPendingDeletion(job)}
>
<Trash2 />
@ -249,7 +249,7 @@ export function JobsPage({
if (jobsResource.loadState === 'failed' && jobsResource.loadError) {
return (
<>
<PageHeader title="Sicherungsauftraege" />
<PageHeader title="Sicherungsaufträge" />
<Card>
<ErrorState
message={describeApiError(jobsResource.loadError)}
@ -268,7 +268,7 @@ export function JobsPage({
return (
<>
<PageHeader
title="Sicherungsauftraege"
title="Sicherungsaufträge"
description="Was wann wohin gesichert wird."
actions={
mayWriteJobs ? (
@ -280,12 +280,12 @@ export function JobsPage({
}
/>
{/* Ohne beschreibbares Ziel ist kein Auftrag moeglich. Das erst beim
Absenden des Assistenten zu melden waere die unfreundlichste Stelle. */}
{/* Ohne beschreibbares Ziel ist kein Auftrag möglich. Das erst beim
Absenden des Assistenten zu melden wäre die unfreundlichste Stelle. */}
{repositoriesResource.loadState === 'loaded' && writableRepositories.length === 0 ? (
<Callout tone="warning" title="Kein beschreibbares Repository" className="mb-4">
Ein Auftrag braucht ein Ziel, das Sicherungen annimmt. Legen Sie
zuerst unter „Repositories" eines an oder pruefen Sie den Zustand der
zuerst unter „Repositories" eines an oder prüfen Sie den Zustand der
vorhandenen.
</Callout>
) : null}
@ -328,15 +328,15 @@ export function JobsPage({
setJobPendingDeletion(null);
}
}}
title="Auftrag loeschen"
title="Auftrag löschen"
description={`„${jobPendingDeletion?.name ?? ''}" wird entfernt.`}
confirmLabel="Loeschen"
confirmLabel="Löschen"
isDestructive
isLoading={deleteMutation.isRunning}
confirmationWord={jobPendingDeletion?.name}
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">
{jobPendingDeletion?.name}
</code>
@ -350,8 +350,8 @@ export function JobsPage({
>
<Callout tone="info">
Die bereits erzeugten Wiederherstellungspunkte <strong>bleiben
bestehen</strong>. Sie gehoeren zum Repository, nicht zum Auftrag —
durch das Loeschen wird kein Speicher frei.
bestehen</strong>. Sie gehören zum Repository, nicht zum Auftrag —
durch das Löschen wird kein Speicher frei.
</Callout>
</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
* dem Backend und nicht der Oberflaeche: Der Wizard fuehrt seinen eigenen
* Entwurf und uebersetzt ihn erst beim Anlegen. Andernfalls muesste jede
* Aenderung an der API sofort die Maske umbauen.
* Das Modul kennt nur den Vertrag nach außen. Die Gestalt der Anfrage folgt
* dem Backend und nicht der Oberfläche: Der Wizard führt seinen eigenen
* Entwurf und übersetzt ihn erst beim Anlegen. Andernfalls müsste jede
* Änderung an der API sofort die Maske umbauen.
*/
import { requestApi } from '../../api/client';
@ -56,9 +56,9 @@ export interface SourceDescriptor {
id: string;
/** Sprechende Bezeichnung. */
name?: string;
/** Einzuschliessende Muster. */
/** Einzuschließende Muster. */
include_patterns?: string[];
/** Auszuschliessende Muster. */
/** Auszuschließende Muster. */
exclude_patterns?: string[];
}
@ -66,7 +66,7 @@ export interface SourceDescriptor {
export interface CreateJobRequest {
/** Eindeutige Bezeichnung. */
name: string;
/** Erlaeuterung des Zwecks. */
/** Erläuterung des Zwecks. */
description?: string;
/** Dringlichkeit. */
priority?: JobPriority;
@ -76,9 +76,9 @@ export interface CreateJobRequest {
sources: SourceDescriptor[];
/** Ziel-Repository. */
repository_id: string;
/** Zulaessiger Datenverlust in Sekunden. */
/** Zulässiger Datenverlust in Sekunden. */
rpo_seconds?: number;
/** Zulaessige Wiederherstellungsdauer in Sekunden. */
/** Zulässige Wiederherstellungsdauer in Sekunden. */
rto_seconds?: number;
/** Bandbreitengrenze in Byte je Sekunde. */
bandwidth_limit_bps?: number;
@ -86,11 +86,11 @@ export interface CreateJobRequest {
/** Auftrag in der Antwort der API. */
export interface BackupJob {
/** Oeffentlicher Bezeichner. */
/** Öffentlicher Bezeichner. */
id: string;
/** Bezeichnung. */
name: string;
/** Erlaeuterung. */
/** Erläuterung. */
description?: string;
/** Zustand. */
status: string;
@ -98,13 +98,13 @@ export interface BackupJob {
priority: JobPriority;
/** Zeitplan. */
schedule: ScheduleDescriptor;
/** Erklaerung des Zeitplans in einem Satz. */
/** Erklärung des Zeitplans in einem Satz. */
schedule_description: string;
/** Quellen. */
sources: SourceDescriptor[];
/** Ziel-Repository. */
repository_id: string;
/** Naechster Zeitpunkt in UTC. */
/** Nächster Zeitpunkt in UTC. */
next_run_at?: string;
/** Beginn des letzten Laufs in UTC. */
last_run_at?: string;
@ -116,7 +116,7 @@ export interface BackupJob {
/** Sicherungsziel in der Antwort der API. */
export interface BackupRepository {
/** Oeffentlicher Bezeichner. */
/** Öffentlicher Bezeichner. */
id: string;
/** Sprechende Bezeichnung. */
name: string;
@ -129,26 +129,26 @@ export interface BackupRepository {
/**
* Meldet, ob dieses Ziel Sicherungen annimmt.
*
* Die Auskunft kommt vom Server. Die Oberflaeche muesste sonst wissen, welche
* Zustaende schreibend sind - eine Regel, die dort nicht hingehoert.
* Die Auskunft kommt vom Server. Die Oberfläche müsste sonst wissen, welche
* Zustände schreibend sind - eine Regel, die dort nicht hingehört.
*/
accepts_backups: boolean;
/** Meldet den gehaerteten Modus. */
/** Meldet den gehärteten Modus. */
hardened: boolean;
}
/**
* Laedt die bekannten Sicherungsziele.
* Lädt die bekannten Sicherungsziele.
*
* 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.
*/
export async function listRepositories(abortSignal?: AbortSignal): Promise<BackupRepository[]> {
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[]> {
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
// nicht. Eine Liste, deren Eintraege sich nicht bedienen lassen, ist ein
// Bis hierher konnte die Oberfläche einen Auftrag anlegen und ansehen — mehr
// nicht. Eine Liste, deren Einträge sich nicht bedienen lassen, ist ein
// Bericht, keine Konsole.
/** Ein einzelner Lauf eines Auftrags. */
export interface BackupJobRun {
/** Oeffentlicher Bezeichner. */
/** Öffentlicher Bezeichner. */
id: string;
/** Zugehoeriger Auftrag. */
/** Zugehöriger Auftrag. */
job_id: string;
/** Zustand: queued, running, succeeded, partial_failure, failed, cancelled. */
status: string;
@ -180,15 +180,15 @@ export interface BackupJobRun {
completed_at?: string;
/** Gelesene Bytes. */
bytes_processed?: number;
/** Tatsaechlich abgelegte Bytes nach Deduplizierung. */
/** Tatsächlich abgelegte Bytes nach Deduplizierung. */
bytes_written?: number;
/** Anzahl erfasster Dateien. */
files_processed?: number;
/**
* Uebergangene Objekte.
* Übergangene Objekte.
*
* Groesser als null bedeutet Teilfehler — die Datenbank laesst
* "erfolgreich mit uebergangenen Objekten" per CHECK gar nicht zu.
* Größer als null bedeutet Teilfehler — die Datenbank lässt
* "erfolgreich mit übergangenen Objekten" per CHECK gar nicht zu.
*/
files_skipped?: number;
/** Durchsatz in Byte je Sekunde. */
@ -201,7 +201,7 @@ export interface BackupJobRun {
error_class?: string;
}
/** Laedt einen einzelnen Auftrag. */
/** Lädt einen einzelnen Auftrag. */
export async function getJob(jobIdentifier: string, abortSignal?: AbortSignal): Promise<BackupJob> {
return requestApi<BackupJob>(
`/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(
jobIdentifier: string,
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
* nicht begonnen. Ein zweiter Anstoss bei laufendem Auftrag ergibt 409 — das
* ist eine Auskunft, kein Fehler, und wird in der Oberflaeche als solche
* nicht begonnen. Ein zweiter Anstoß bei laufendem Auftrag ergibt 409 — das
* ist eine Auskunft, kein Fehler, und wird in der Oberfläche als solche
* gezeigt.
*/
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> {
return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/pause`, {
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
* gehoeren zum Repository, nicht zum Auftrag. Das muss die Oberflaeche sagen,
* sonst loescht jemand einen Auftrag in der Annahme, damit Platz zu schaffen.
* gehören zum Repository, nicht zum Auftrag. Das muss die Oberfläche sagen,
* sonst löscht jemand einen Auftrag in der Annahme, damit Platz zu schaffen.
*/
export async function deleteJob(jobIdentifier: string): Promise<void> {
return requestApi<void>(`/jobs/${encodeURIComponent(jobIdentifier)}`, {

View File

@ -1,7 +1,7 @@
/**
* 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.
*/
@ -19,11 +19,11 @@ import {
} from './wizardModel';
import type { JobDraft } from './wizardModel';
/** Liefert einen vollstaendig ausgefuellten Entwurf. */
/** Liefert einen vollständig ausgefüllten Entwurf. */
function buildCompleteDraft(): JobDraft {
return {
...createEmptyDraft(),
name: 'Naechtliche Sicherung',
name: 'Nächtliche Sicherung',
sources: [
{ sourceType: 'filesystem', sourceIdentifier: '/daten', sourceName: 'Dateiserver', excludePatterns: '*.tmp' },
],
@ -43,8 +43,8 @@ describe('Schrittfolge', () => {
});
});
describe('Pruefung der Schritte', () => {
it('nimmt einen vollstaendigen Entwurf an', () => {
describe('Prüfung der Schritte', () => {
it('nimmt einen vollständigen Entwurf an', () => {
expect(findFirstIncompleteStep(buildCompleteDraft())).toBeNull();
});
@ -56,7 +56,7 @@ describe('Pruefung der Schritte', () => {
});
// 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', () => {
const sourcelessDraft = {
...buildCompleteDraft(),
@ -108,13 +108,13 @@ describe('Pruefung der Schritte', () => {
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' };
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 = {
...buildCompleteDraft(),
scheduleType: 'cron',
@ -128,9 +128,9 @@ describe('Pruefung der Schritte', () => {
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.
it('haelt bei den nicht umgesetzten Schritten nicht auf', () => {
it('hält bei den nicht umgesetzten Schritten nicht auf', () => {
const emptyDraft = createEmptyDraft();
for (const wizardStep of ['retention', 'verification', 'notifications'] as const) {
@ -142,7 +142,7 @@ describe('Pruefung der Schritte', () => {
describe('Bandbreitenangabe', () => {
// 100Mbit ist ein Achtel von 100MB. Wer das verwechselt, vergibt das
// Achtfache der beabsichtigten Rate.
it('haelt Bit und Byte auseinander', () => {
it('hält Bit und Byte auseinander', () => {
const bitValue = parseBandwidthToBytesPerSecond('100Mbit');
const byteValue = parseBandwidthToBytesPerSecond('100MB');
@ -151,7 +151,7 @@ describe('Bandbreitenangabe', () => {
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('50 MB/s')).toBe(52_428_800);
expect(parseBandwidthToBytesPerSecond('1,5MB')).toBe(1_572_864);
@ -170,10 +170,10 @@ describe('Bandbreitenangabe', () => {
});
describe('Anfrage an die API', () => {
it('uebertraegt Name, Quelle, Zeitplan und Ziel', () => {
it('überträgt Name, Quelle, Zeitplan und Ziel', () => {
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.schedule.type).toBe('daily');
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
// 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());
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.
it('laesst die Zeitzone bei einem Intervallplan weg', () => {
it('lässt die Zeitzone bei einem Intervallplan weg', () => {
const intervalDraft: JobDraft = {
...buildCompleteDraft(),
scheduleType: 'interval',
@ -205,9 +205,9 @@ describe('Anfrage an die API', () => {
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.
it('laesst leere Felder weg', () => {
it('lässt leere Felder weg', () => {
const sparseDraft: JobDraft = { ...buildCompleteDraft(), description: ' ', bandwidthLimit: '' };
const jobRequest = buildCreateRequest(sparseDraft);
@ -216,13 +216,13 @@ describe('Anfrage an die API', () => {
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' };
expect(buildCreateRequest(limitedDraft).bandwidth_limit_bps).toBe(52_428_800);
});
it('uebergeht leere Quellzeilen', () => {
it('übergeht leere Quellzeilen', () => {
const mixedDraft: JobDraft = {
...buildCompleteDraft(),
sources: [
@ -246,10 +246,10 @@ describe('Anfrage an die API', () => {
});
describe('Beschreibung des Zeitplans', () => {
it('beschreibt die Zeitplaene verstaendlich', () => {
it('beschreibt die Zeitpläne verständlich', () => {
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');

View File

@ -1,9 +1,9 @@
/**
* Tests des Liniendiagramms.
*
* Sie pruefen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben
* wurde: **Eine Luecke wird nicht ueberbrueckt und nicht auf null gezogen.**
* Beides ergaebe eine Kurve, die etwas anderes behauptet als die Daten hergeben.
* Sie prüfen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben
* wurde: **Eine Lücke wird nicht überbrückt und nicht auf null gezogen.**
* Beides ergäbe eine Kurve, die etwas anderes behauptet als die Daten hergeben.
*/
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 {
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[] {
return Array.from(container.querySelectorAll('path')).map(
(pathElement) => pathElement.getAttribute('d') ?? '',
@ -43,7 +43,7 @@ function readPathDefinitions(container: HTMLElement): string[] {
}
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.
const { container } = render(
<LineChart
@ -62,19 +62,19 @@ describe('Liniendiagramm', () => {
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);
for (const pathDefinition of pathDefinitions) {
// 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.
expect(pathDefinition.startsWith('M')).toBe(true);
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(
<LineChart
series={[seriesOf([pointWithValue(0, 100), pointWithoutValue(1), pointWithValue(2, 100)])]}
@ -86,7 +86,7 @@ describe('Liniendiagramm', () => {
const allCoordinates = pathDefinitions.join(' ');
// 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.
const lowestDrawnPosition = Math.max(
...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', () => {
// Eine einzelne Messung ergaebe eine Linie der Laenge null und waere
// unsichtbar — die Kurve saehe aus wie „nichts gemessen".
// Eine einzelne Messung ergäbe eine Linie der Länge null und wäre
// unsichtbar — die Kurve sähe aus wie „nichts gemessen".
const { container } = render(
<LineChart
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(/nicht/)).toBeInTheDocument();
});
it('beginnt die Werteachse bei null', () => {
// Eine abgeschnittene Achse laesst kleine Schwankungen wie Einbrueche
// aussehen — der haeufigste Weg, mit korrekten Zahlen etwas Falsches zu
// Eine abgeschnittene Achse lässt kleine Schwankungen wie Einbrüche
// aussehen — der häufigste Weg, mit korrekten Zahlen etwas Falsches zu
// zeigen.
render(
<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('102')).toBeInTheDocument();
});

View File

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

View File

@ -1,9 +1,9 @@
/**
* 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.
* 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.
*/
@ -16,7 +16,7 @@ import { LineChart } from './LineChart';
import { fetchChart, fetchChartCatalog } 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 }[] = [
{ value: '1h', label: 'Letzte Stunde' },
{ value: '24h', label: 'Letzte 24 Stunden' },
@ -93,7 +93,7 @@ export function MetricsPage(): React.JSX.Element {
interface ChartCardProperties {
/** Das darzustellende Diagramm. */
readonly definition: ChartDefinition;
/** Der gewaehlte Zeitraum. */
/** Der gewählte Zeitraum. */
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
// antwortete mit 501, und ein Fehler in der Oberflaeche saehe aus wie eine
// Stoerung. Es ist keine — die Funktion gibt es nur noch nicht.
// antwortete mit 501, und ein Fehler in der Oberfläche sähe aus wie eine
// Störung. Es ist keine — die Funktion gibt es nur noch nicht.
const { loadState, data, loadError, reload } = useApiResource<Chart>(
loadChart,
definition.available ? `${definition.metric}|${timeRange}` : 'unavailable',
@ -115,7 +115,7 @@ function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.El
if (!definition.available) {
return (
<Card>
<CardHeader title={definition.title} description="noch nicht verfuegbar" />
<CardHeader title={definition.title} description="noch nicht verfügbar" />
<CardContent>
<p className="text-sm text-fg-muted">{definition.unavailable_reason}</p>
</CardContent>

View File

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

View File

@ -3,9 +3,9 @@
*
* 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
* 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
* ueberlebt eine erfundene Null jede muendliche Erlaeuterung.
* überlebt eine erfundene Null jede mündliche Erläuterung.
*/
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 { ReportsPage } from './ReportsPage';
/** Baut eine Antwort in der Standardhuelle. */
/** Baut eine Antwort in der Standardhülle. */
function buildJsonResponse(payload: unknown): Response {
return {
status: 200,
@ -28,14 +28,14 @@ const reportCatalog = [
{
type: 'daily_backup',
title: 'Tagesbericht Sicherungen',
description: 'Alle Sicherungslaeufe eines Tages.',
description: 'Alle Sicherungsläufe eines Tages.',
period_kind: 'range',
default_period: 'letzte 24 Stunden',
formats: ['json', 'csv', 'pdf'],
},
{
type: 'repository_capacity',
title: 'Bericht Repository-Kapazitaet',
title: 'Bericht Repository-Kapazität',
description: 'Belegung und Zustand aller Repositories.',
period_kind: 'point_in_time',
formats: ['json', 'csv', 'pdf'],
@ -46,34 +46,34 @@ const reportCatalog = [
const reportWithUnknownMetric = {
type: 'daily_backup',
title: 'Tagesbericht Sicherungen',
description: 'Alle Sicherungslaeufe eines Tages.',
description: 'Alle Sicherungsläufe eines Tages.',
period_from: '2026-08-12T00:00:00Z',
period_to: '2026-08-13T00:00:00Z',
generated_at: '2026-08-13T06:00:00Z',
generated_by: 'pruefer',
generated_by: 'prüfer',
sections: [
{
title: 'Ueberblick',
title: 'Überblick',
metrics: [
{ label: 'Laeufe insgesamt', value: 0, unit: 'count', is_known: true },
{ label: 'Läufe insgesamt', value: 0, unit: 'count', is_known: true },
{
label: 'Erfolgsquote',
unit: 'percent',
is_known: false,
unknown_reason: 'Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.',
unknown_reason: 'Im gewählten Zeitraum wurde kein Lauf abgeschlossen.',
},
],
tables: [
{
title: 'Einzelne Laeufe',
title: 'Einzelne Läufe',
columns: ['Auftrag', 'Ergebnis'],
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', () => {
@ -94,10 +94,10 @@ describe('Berichte', () => {
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
// 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('Zeitraum: letzte 24 Stunden')).toBeInTheDocument();
});
@ -119,18 +119,18 @@ describe('Berichte', () => {
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('Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.'),
screen.getByText('Im gewählten Zeitraum wurde kein Lauf abgeschlossen.'),
).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.
// 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
// ungemessenen Quote daneben.
const measuredValue = screen.getByText('Laeufe insgesamt').nextElementSibling;
const measuredValue = screen.getByText('Läufe insgesamt').nextElementSibling;
expect(measuredValue?.textContent).toBe('0');
});
@ -148,11 +148,11 @@ describe('Berichte', () => {
await userEvent.click(screen.getByText('Tagesbericht Sicherungen'));
await waitFor(() => {
expect(screen.getByText('Einzelne Laeufe')).toBeInTheDocument();
expect(screen.getByText('Einzelne Läufe')).toBeInTheDocument();
});
expect(
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf begonnen.'),
screen.getByText('Im gewählten Zeitraum wurde kein Lauf begonnen.'),
).toBeInTheDocument();
});

View File

@ -1,9 +1,9 @@
/**
* 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
* und PDF herausgeht — was hier steht, steht auch in der Datei. Und sie stellt
@ -41,13 +41,13 @@ interface ReportCatalogEntry {
readonly type: string;
/** Bezeichnung. */
readonly title: string;
/** Erklaert, welche Frage der Bericht beantwortet. */
/** Erklärt, welche Frage der Bericht beantwortet. */
readonly description: string;
/** Art des Zeitbezugs. */
readonly period_kind: PeriodKind;
/** Beschreibung des Standardzeitraums. */
readonly default_period?: string;
/** Verfuegbare Ausgabeformate. */
/** Verfügbare Ausgabeformate. */
readonly formats: readonly ReportFormat[];
}
@ -55,7 +55,7 @@ interface ReportCatalogEntry {
interface ReportMetric {
/** Beschriftung. */
readonly label: string;
/** Gemessener Wert; nur gueltig, wenn is_known gilt. */
/** Gemessener Wert; nur gültig, wenn is_known gilt. */
readonly value?: number;
/** Einheit. */
readonly unit?: string;
@ -63,13 +63,13 @@ interface ReportMetric {
readonly text?: string;
/** Meldet, ob der Wert gemessen wurde. */
readonly is_known: boolean;
/** Erklaert einen fehlenden Wert. */
/** Erklärt einen fehlenden Wert. */
readonly unknown_reason?: string;
}
/** Eine Tabelle eines Berichts. */
interface ReportTable {
/** Ueberschrift. */
/** Überschrift. */
readonly title: string;
/** Spaltenbeschriftungen. */
readonly columns: readonly string[];
@ -81,9 +81,9 @@ interface ReportTable {
/** Ein Abschnitt eines Berichts. */
interface ReportSection {
/** Ueberschrift. */
/** Überschrift. */
readonly title: string;
/** Erklaerung. */
/** Erklärung. */
readonly description?: string;
/** Kennzahlen. */
readonly metrics?: readonly ReportMetric[];
@ -97,7 +97,7 @@ interface GeneratedReport {
readonly type: string;
/** Bezeichnung. */
readonly title: string;
/** Erklaerung. */
/** Erklärung. */
readonly description: string;
/** Beginn des Zeitraums. */
readonly period_from: string;
@ -109,19 +109,19 @@ interface GeneratedReport {
readonly generated_by?: string;
/** Abschnitte. */
readonly sections: readonly ReportSection[];
/** Benannte Luecken und Einschraenkungen. */
/** Benannte Lücken und Einschränkungen. */
readonly notes?: readonly string[];
}
/** Zustand einer laufenden Ausgabe. */
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;
/** Fehler der letzten Ausgabe. */
readonly exportError: ApiError | null;
}
/** Laedt den Berichtskatalog. */
/** Lädt den Berichtskatalog. */
function loadReportCatalog(abortSignal: AbortSignal): Promise<readonly ReportCatalogEntry[]> {
return requestApi<readonly ReportCatalogEntry[]>('/reports', { signal: abortSignal });
}
@ -202,11 +202,11 @@ export function ReportsPage(): React.JSX.Element {
if (catalogResource.loadState === 'failed' || catalogResource.data === null) {
// loadError kann in diesem Zweig nur dann fehlen, wenn die Nutzlast leer
// blieb — dann steht der Katalog trotzdem nicht zur Verfuegung.
// blieb — dann steht der Katalog trotzdem nicht zur Verfügung.
return (
<Card>
<ErrorState
title="Der Berichtskatalog steht nicht zur Verfuegung."
title="Der Berichtskatalog steht nicht zur Verfügung."
message={
catalogResource.loadError ? describeApiError(catalogResource.loadError) : undefined
}
@ -221,7 +221,7 @@ export function ReportsPage(): React.JSX.Element {
<>
<PageHeader
title="Berichte"
description="Neun Berichte in drei Formaten. Was in der Vorschau steht, steht auch in der Datei — einschliesslich der Angaben, die sich nicht messen liessen."
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">
@ -246,7 +246,7 @@ export function ReportsPage(): React.JSX.Element {
nicht historisiert; "vom letzten Dienstag" kann es nicht geben. */}
{catalogEntry.period_kind === 'point_in_time'
? 'Zustandsbericht'
: `Zeitraum: ${catalogEntry.default_period ?? 'waehlbar'}`}
: `Zeitraum: ${catalogEntry.default_period ?? 'wählbar'}`}
</span>
</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: {
readonly catalogEntry: ReportCatalogEntry;
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>
<dd className="mt-1">
{/* Eine ungemessene Kennzahl erscheint niemals als Zahl.
Eine erfundene Null ueberlebt in einem Pruefordner jede
muendliche Erlaeuterung. */}
Eine erfundene Null überlebt in einem Prüfordner jede
mündliche Erläuterung. */}
<span
className={
reportMetric.is_known
@ -412,7 +412,7 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
<div className="mt-4">
<h4 className="text-sm font-medium text-fg">{table.title}</h4>
<p className="mt-1 text-sm text-fg-muted">
{table.empty_notice ?? 'Keine Eintraege.'}
{table.empty_notice ?? 'Keine Einträge.'}
</p>
</div>
);
@ -441,10 +441,10 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
</tr>
</thead>
{/*
Die Position ist der Schluessel: Eine Berichtszeile traegt keine
Kennung, und zwei Zeilen koennen Zeichen fuer Zeichen gleich sein.
Die Position ist der Schlüssel: Eine Berichtszeile trägt keine
Kennung, und zwei Zeilen können Zeichen für Zeichen gleich sein.
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>
{visibleRows.map((tableRow, rowIndex) => (
@ -462,7 +462,7 @@ function ReportTableView(properties: { readonly table: ReportTable }): React.JSX
{table.rows.length > visibleRows.length && (
<p className="mt-1.5 text-xs text-fg-muted">
Die Vorschau zeigt {visibleRows.length} von {table.rows.length} Zeilen. Die
heruntergeladene Datei enthaelt alle.
heruntergeladene Datei enthält alle.
</p>
)}
</div>
@ -539,7 +539,7 @@ function formatDuration(seconds: number): string {
* Stellt einen Anteil dar.
*
* 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 {
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
* 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.
*/
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**
* wird. Ein Repository entsteht auf einem Datentraeger; der Server oeffnet das
* Die Maske zur Übernahme sagt ausdrücklich, dass hier **nichts angelegt**
* wird. Ein Repository entsteht auf einem Datenträger; der Server öffnet das
* 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
* Pruefung schlug fehl" und „das Repository ist beschaedigt". Das eine ist ein
* Der Integritätslauf trennt zwei Lagen, die gern vermengt werden: „Die
* Prüfung schlug fehl" und „das Repository ist beschädigt". Das eine ist ein
* Problem des Werkzeugs, das andere ein Datenverlust.
*/
@ -72,7 +72,7 @@ export function RepositoriesPage({
const adoptMutation = useMutation(adoptRepository, {
onSuccess: (repository) => {
toast.showSuccess(
'Repository uebernommen',
'Repository übernommen',
`${repository.name} ist jetzt in der Control Plane bekannt.`,
);
setIsAdoptDialogOpen(false);
@ -82,7 +82,7 @@ export function RepositoriesPage({
},
onError: (apiError) =>
toast.showError(
'Die Uebernahme schlug fehl',
'Die Übernahme schlug fehl',
describeApiError(apiError),
apiError.requestId,
),
@ -94,17 +94,17 @@ export function RepositoriesPage({
setRepositoryPendingScan(null);
// 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.
if (result.chunks_missing > 0 || result.chunks_corrupted > 0) {
toast.showError(
'Der Integritaetslauf hat Befunde',
`${result.chunks_missing} Bloecke fehlen, ${result.chunks_corrupted} sind beschaedigt.`,
'Der Integritätslauf hat Befunde',
`${result.chunks_missing} Blöcke fehlen, ${result.chunks_corrupted} sind beschädigt.`,
);
} else {
toast.showSuccess(
'Integritaetslauf ohne Befund',
`${result.chunks_checked.toLocaleString('de-DE')} Bloecke geprueft.`,
'Integritätslauf ohne Befund',
`${result.chunks_checked.toLocaleString('de-DE')} Blöcke geprüft.`,
);
}
@ -112,8 +112,8 @@ export function RepositoriesPage({
},
onError: (apiError) =>
toast.showError(
'Der Integritaetslauf liess sich nicht durchfuehren',
`${describeApiError(apiError)} — das ist ein Problem der Pruefung, kein Befund am Repository.`,
'Der Integritätslauf ließ sich nicht durchführen',
`${describeApiError(apiError)} — das ist ein Problem der Prüfung, kein Befund am Repository.`,
apiError.requestId,
),
});
@ -121,7 +121,7 @@ export function RepositoriesPage({
const healthMutation = useMutation(checkRepositoryHealth, {
onSuccess: (health) => {
toast.showSuccess(
'Gesundheitspruefung abgeschlossen',
'Gesundheitsprüfung abgeschlossen',
health.message ?? `Zustand: ${health.status}`,
);
repositoriesResource.reload();
@ -182,7 +182,7 @@ export function RepositoriesPage({
},
{
key: 'enforcement',
header: 'Loeschschutz',
header: 'Löschschutz',
render: (repository) =>
repository.enforcement_level ? (
<StatusBadge status={repository.enforcement_level} />
@ -199,10 +199,10 @@ export function RepositoriesPage({
},
{
key: 'hardened',
header: 'Gehaertet',
header: 'Gehärtet',
render: (repository) =>
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>
),
@ -233,7 +233,7 @@ export function RepositoriesPage({
mayWrite ? (
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
<Plus />
Repository uebernehmen
Repository übernehmen
</Button>
) : null
}
@ -247,12 +247,12 @@ export function RepositoriesPage({
onRowClick={(repository) => setSelectedRepository(repository)}
isLoading={repositoriesResource.loadState === 'loading'}
emptyTitle="Kein Repository eingetragen"
emptyDescription="Ein Repository entsteht auf einem Datentraeger mit „syncova-repo create“ und wird hier uebernommen."
emptyDescription="Ein Repository entsteht auf einem Datenträger mit „syncova-repo create“ und wird hier übernommen."
emptyAction={
mayWrite ? (
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
<Plus />
Repository uebernehmen
Repository übernehmen
</Button>
) : null
}
@ -266,7 +266,7 @@ export function RepositoriesPage({
description={selectedRepository.location}
action={
<Button variant="ghost" size="sm" onClick={() => setSelectedRepository(null)}>
Schliessen
Schließen
</Button>
}
/>
@ -279,7 +279,7 @@ export function RepositoriesPage({
<DetailItem label="Belegt">
{formatBytes(selectedRepository.used_bytes)}
</DetailItem>
<DetailItem label="Zuletzt geprueft">
<DetailItem label="Zuletzt geprüft">
{selectedRepository.last_health_check_at
? formatDateTime(selectedRepository.last_health_check_at)
: 'Nie'}
@ -294,7 +294,7 @@ export function RepositoriesPage({
onClick={() => void healthMutation.run(selectedRepository.id)}
>
<RefreshCw />
Gesundheit pruefen
Gesundheit prüfen
</Button>
<Button
size="sm"
@ -306,7 +306,7 @@ export function RepositoriesPage({
</Button>
<Button size="sm" onClick={() => setRepositoryPendingScan(selectedRepository)}>
<Database />
Integritaetslauf
Integritätslauf
</Button>
<Button
size="sm"
@ -333,20 +333,20 @@ export function RepositoriesPage({
<IntegrityScanCard result={scanResult} onClose={() => setScanResult(null)} />
) : null}
{/* --- Uebernahme --- */}
{/* --- Übernahme --- */}
<DialogRoot open={isAdoptDialogOpen} onOpenChange={setIsAdoptDialogOpen}>
<DialogContent>
<DialogHeader
title="Repository uebernehmen"
title="Repository übernehmen"
description="Ein vorhandenes Repository in die Control Plane aufnehmen."
/>
<DialogBody>
<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">
sudo -u syncova syncova-repo create --path /srv/repo --name "Hauptziel" --hardened
</code>
Dieser Schritt oeffnet das vorhandene und liest seine Kennung aus
Dieser Schritt öffnet das vorhandene und liest seine Kennung aus
dem Descriptor.
</Callout>
@ -364,7 +364,7 @@ export function RepositoriesPage({
value={adoptLocation}
onChange={(changeEvent) => setAdoptLocation(changeEvent.target.value)}
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>
<DialogFooter>
@ -382,7 +382,7 @@ export function RepositoriesPage({
})
}
>
Uebernehmen
Übernehmen
</Button>
</DialogFooter>
</DialogContent>
@ -391,7 +391,7 @@ export function RepositoriesPage({
<ConfirmDialog
open={repositoryPendingScan !== null}
onOpenChange={(isOpen) => !isOpen && setRepositoryPendingScan(null)}
title="Integritaetslauf starten"
title="Integritätslauf starten"
confirmLabel="Lauf starten"
isLoading={scanMutation.isRunning}
onConfirm={() => {
@ -401,8 +401,8 @@ export function RepositoriesPage({
}}
>
<p className="text-sm text-fg">
Jeder Block wird gegen seine gespeicherte Pruefsumme geprueft. Bei
grossen Bestaenden dauert das lange — der Datenschluessel wird dafuer
Jeder Block wird gegen seine gespeicherte Prüfsumme geprüft. Bei
großen Beständen dauert das lange — der Datenschlüssel wird dafür
nicht gebraucht.
</p>
</ConfirmDialog>
@ -410,7 +410,7 @@ export function RepositoriesPage({
);
}
/** Ergebnis eines Integritaetslaufs. */
/** Ergebnis eines Integritätslaufs. */
function IntegrityScanCard({
result,
onClose,
@ -423,22 +423,22 @@ function IntegrityScanCard({
return (
<Card className="mt-4">
<CardHeader
title="Ergebnis des Integritaetslaufs"
title="Ergebnis des Integritätslaufs"
action={
<Button variant="ghost" size="sm" onClick={onClose}>
Schliessen
Schließen
</Button>
}
/>
<CardContent>
<Callout tone={hasFindings ? 'critical' : 'healthy'}>
{hasFindings
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups waeren unvollstaendig.'
: 'Ohne Befund. Jeder gepruefte Block stimmt mit seiner Pruefsumme ueberein.'}
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.'
: 'Ohne Befund. Jeder geprüfte Block stimmt mit seiner Prüfsumme überein.'}
</Callout>
<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')}
</DetailItem>
<DetailItem label="Fehlend">
@ -446,7 +446,7 @@ function IntegrityScanCard({
{result.chunks_missing.toLocaleString('de-DE')}
</span>
</DetailItem>
<DetailItem label="Beschaedigt">
<DetailItem label="Beschädigt">
<span className={result.chunks_corrupted > 0 ? 'text-critical' : undefined}>
{result.chunks_corrupted.toLocaleString('de-DE')}
</span>
@ -473,10 +473,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 }) {
// Ohne hinterlegte Kapazitaet gibt es keinen Prozentsatz. Ihn zu erfinden
// waere die haeufigste Art, mit richtigen Zahlen etwas Falsches zu zeigen.
// Ohne hinterlegte Kapazität gibt es keinen Prozentsatz. Ihn zu erfinden
// wäre die häufigste Art, mit richtigen Zahlen etwas Falsches zu zeigen.
const hasCapacity =
repository.total_bytes !== undefined && repository.total_bytes > 0;
@ -488,7 +488,7 @@ export function RepositoryCapacityTile({ repository }: { readonly repository: Re
<p className="text-xs text-fg-muted">
{hasCapacity
? `${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>
</div>
</div>

View File

@ -3,16 +3,16 @@
*
* 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 —
* 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.
* - **Die Vorschau ist keine Formalie.** Sie loescht nichts und liefert die
* Zahl, die danach woertlich bestaetigt werden muss.
* - **Die Vorschau ist keine Formalie.** Sie löscht nichts und liefert die
* Zahl, die danach wörtlich bestätigt werden muss.
*
* Dass beim Loeschen oft **kein Speicher frei wird**, ist kein Fehler, sondern
* Deduplizierung. Ohne diesen Hinweis erzeugt jede Anwendung eine Rueckfrage.
* Dass beim Löschen oft **kein Speicher frei wird**, ist kein Fehler, sondern
* Deduplizierung. Ohne diesen Hinweis erzeugt jede Anwendung eine Rückfrage.
*/
import { Plus, Trash2 } from 'lucide-react';
@ -85,12 +85,12 @@ export function RetentionPage({
const createMutation = useMutation(createRetentionPolicy, {
onSuccess: (policy) => {
// Eine stillschweigend ergaenzte Haltevorgabe waere eine Aenderung, die
// niemand sieht — und die spaeter niemand erklaeren kann.
// Eine stillschweigend ergänzte Haltevorgabe wäre eine Änderung, die
// niemand sieht — und die später niemand erklären kann.
if (!draftKeepLast || Number(draftKeepLast) < 1) {
toast.showInfo(
'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 {
toast.showSuccess('Regel angelegt', policy.name);
@ -101,12 +101,12 @@ export function RetentionPage({
policiesResource.reload();
},
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, {
onSuccess: () => {
toast.showSuccess('Regel geloescht');
toast.showSuccess('Regel gelöscht');
setPolicyPendingDeletion(null);
policiesResource.reload();
},
@ -119,7 +119,7 @@ export function RetentionPage({
setPreviewResult(preview);
toast.showInfo(
'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) =>
@ -132,7 +132,7 @@ export function RetentionPage({
'Aufbewahrung angewendet',
result.bytes_to_free
? `${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);
setPreviewResult(null);
@ -163,14 +163,14 @@ export function RetentionPage({
header: 'Mindestens',
numeric: true,
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 ?? '—'}
</span>
),
},
{
key: 'grid',
header: 'Taeglich / Woechentlich / Monatlich / Jaehrlich',
header: 'Täglich / Wöchentlich / Monatlich / Jährlich',
render: (policy) =>
[policy.keep_daily, policy.keep_weekly, policy.keep_monthly, policy.keep_yearly]
.map((value) => value ?? '—')
@ -185,7 +185,7 @@ export function RetentionPage({
<Button
variant="ghost"
size="icon"
aria-label={`Regel ${policy.name} loeschen`}
aria-label={`Regel ${policy.name} löschen`}
onClick={(clickEvent) => {
clickEvent.stopPropagation();
setPolicyPendingDeletion(policy);
@ -235,7 +235,7 @@ export function RetentionPage({
getRowKey={(policy) => policy.id}
isLoading={policiesResource.loadState === 'loading'}
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>
@ -243,7 +243,7 @@ export function RetentionPage({
<Card>
<CardHeader
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">
<div className="grid gap-3 sm:grid-cols-2">
@ -252,7 +252,7 @@ export function RetentionPage({
value={selectedRepositoryId}
onChange={(changeEvent) => setSelectedRepositoryId(changeEvent.target.value)}
>
<option value="">Bitte waehlen</option>
<option value="">Bitte wählen</option>
{repositories.map((repository) => (
<option key={repository.id} value={repository.id}>
{repository.name}
@ -265,7 +265,7 @@ export function RetentionPage({
value={selectedPolicyId}
onChange={(changeEvent) => setSelectedPolicyId(changeEvent.target.value)}
>
<option value="">Bitte waehlen</option>
<option value="">Bitte wählen</option>
{policies.map((policy) => (
<option key={policy.id} value={policy.id}>
{policy.name}
@ -294,7 +294,7 @@ export function RetentionPage({
: undefined
}
>
Anwenden und loeschen
Anwenden und löschen
</Button>
</div>
@ -302,18 +302,18 @@ export function RetentionPage({
<div className="rounded-md border border-line bg-sunken p-3">
<p className="text-sm text-fg">
<strong>{previewResult.backups_to_delete}</strong>{' '}
Wiederherstellungspunkte wuerden entfallen
Wiederherstellungspunkte würden entfallen
{previewResult.backups_protected
? `, ${previewResult.backups_protected} bleiben durch Schutz erhalten`
: ''}
.
</p>
<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 className="mt-2 text-xs text-fg-muted">
Deutlich weniger als die Summe der Backupgroessen ist der
Normalfall: Deduplizierte Bloecke werden von anderen
Deutlich weniger als die Summe der Backupgrößen ist der
Normalfall: Deduplizierte Blöcke werden von anderen
Wiederherstellungspunkten weiter gebraucht.
</p>
</div>
@ -349,10 +349,10 @@ export function RetentionPage({
min={1}
value={draftKeepLast}
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
label="Taegliche behalten"
label="Tägliche behalten"
type="number"
min={0}
value={draftKeepDaily}
@ -369,7 +369,7 @@ export function RetentionPage({
<Callout tone="warning">
Eine Regel <strong>ohne jede Haltevorgabe</strong> wird abgelehnt.
Sonst loeschte „30 Tage" bei einem laenger nicht gesicherten System
Sonst löschte „30 Tage" bei einem länger nicht gesicherten System
jedes Backup — genau dann, wenn man es braucht.
</Callout>
</DialogBody>
@ -400,9 +400,9 @@ export function RetentionPage({
<ConfirmDialog
open={policyPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setPolicyPendingDeletion(null)}
title="Regel loeschen"
title="Regel löschen"
description={policyPendingDeletion?.name}
confirmLabel="Loeschen"
confirmLabel="Löschen"
isDestructive
isLoading={deleteMutation.isRunning}
onConfirm={() => {
@ -412,8 +412,8 @@ export function RetentionPage({
}}
>
<p className="text-sm text-fg-muted">
Bereits geloeschte Wiederherstellungspunkte kommen dadurch nicht
zurueck. Kuenftig wird nach dieser Regel nichts mehr entfernt.
Bereits gelöschte Wiederherstellungspunkte kommen dadurch nicht
zurück. Künftig wird nach dieser Regel nichts mehr entfernt.
</p>
</ConfirmDialog>
@ -421,14 +421,14 @@ export function RetentionPage({
open={isApplyDialogOpen}
onOpenChange={setIsApplyDialogOpen}
title="Aufbewahrung anwenden"
description="Wiederherstellungspunkte werden endgueltig entfernt."
confirmLabel="Endgueltig loeschen"
description="Wiederherstellungspunkte werden endgültig entfernt."
confirmLabel="Endgültig löschen"
isDestructive
isLoading={applyMutation.isRunning}
confirmationWord={String(previewResult?.backups_to_delete ?? '')}
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">
{previewResult?.backups_to_delete}
</code>
@ -443,8 +443,8 @@ export function RetentionPage({
}
>
<Callout tone="critical">
Geschuetzte Punkte (Aufbewahrungsfrist oder Legal Hold) bleiben
unberuehrt — der Server lehnt ihre Loeschung ab.
Geschützte Punkte (Aufbewahrungsfrist oder Legal Hold) bleiben
unberührt — der Server lehnt ihre Löschung ab.
</Callout>
</ConfirmDialog>
</>

View File

@ -1,12 +1,12 @@
/**
* API-Anbindung der Repositories (SYNCOVA_API.md §8).
*
* Ein Punkt praegt die ganze Maske: **`POST /repositories` legt nichts an, es
* uebernimmt.** Ein Repository entsteht auf einem Datentraeger — mit
* `syncova-repo create` —, nicht in einer Datenbankzeile. Der Endpunkt oeffnet
* Ein Punkt prägt die ganze Maske: **`POST /repositories` legt nichts an, es
* übernimmt.** Ein Repository entsteht auf einem Datenträger — mit
* `syncova-repo create` —, nicht in einer Datenbankzeile. Der Endpunkt öffnet
* 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.
*/
@ -24,21 +24,21 @@ export interface Repository {
hardened: boolean;
/** Belegter Speicher; nicht immer bekannt. */
used_bytes?: number;
/** Gesamtkapazitaet, sofern hinterlegt. */
/** Gesamtkapazität, sofern hinterlegt. */
total_bytes?: number;
/** Gemessene Durchsetzungsstufe des Loeschschutzes. */
/** Gemessene Durchsetzungsstufe des Löschschutzes. */
enforcement_level?: string;
last_health_check_at?: string;
created_at?: string;
}
/** Ergebnis eines Integritaetslaufs. */
/** Ergebnis eines Integritätslaufs. */
export interface IntegrityScanResult {
/** Geprueft. */
/** Geprüft. */
chunks_checked: number;
/** Nicht auffindbar. */
chunks_missing: number;
/** Pruefsumme stimmt nicht. */
/** Prüfsumme stimmt nicht. */
chunks_corrupted: number;
manifests_checked?: number;
manifests_invalid?: number;
@ -49,7 +49,7 @@ export interface IntegrityScanResult {
findings?: string[];
}
/** Ergebnis einer Gesundheitspruefung. */
/** Ergebnis einer Gesundheitsprüfung. */
export interface RepositoryHealth {
status: string;
/** Freier Platz am Ablageort. */
@ -68,9 +68,9 @@ export interface RetentionPreview {
/** Punkte, die durch Schutz erhalten bleiben. */
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.
*/
bytes_to_free?: number;
@ -78,12 +78,12 @@ export interface RetentionPreview {
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[]> {
return requestApi<Repository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
}
/** Laedt ein einzelnes Repository. */
/** Lädt ein einzelnes Repository. */
export async function getRepository(
repositoryIdentifier: string,
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
* Kennung aus dem Descriptor. Liegt dort keines, schlaegt der Aufruf fehl.
* Es wird **nicht angelegt**: Der Server öffnet das Verzeichnis und liest die
* Kennung aus dem Descriptor. Liegt dort keines, schlägt der Aufruf fehl.
*/
export async function adoptRepository(repositoryRequest: {
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(
repositoryIdentifier: 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(
repositoryIdentifier: string,
): Promise<RepositoryHealth> {
@ -133,7 +133,7 @@ export async function testRepository(
);
}
/** Fuehrt eine Gesundheitspruefung aus. */
/** Führt eine Gesundheitsprüfung aus. */
export async function checkRepositoryHealth(
repositoryIdentifier: string,
): 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**
* Form — deshalb braucht er den Datenschluessel nicht. Ein Befund ist ein
* Ergebnis, kein Fehler des Laufs; die Oberflaeche trennt beides.
* Er prüft jeden Block gegen die gespeicherte Prüfsumme der **abgelegten**
* Form — deshalb braucht er den Datenschlüssel nicht. Ein Befund ist ein
* Ergebnis, kein Fehler des Laufs; die Oberfläche trennt beides.
*/
export async function startIntegrityScan(
repositoryIdentifier: string,
@ -163,7 +163,7 @@ export async function startIntegrityScan(
* Baut den Katalog neu auf.
*
* 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.
*/
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
* zu loeschen. Gemeldet wird nur, was das Betriebssystem nachweislich
* zu löschen. Gemeldet wird nur, was das Betriebssystem nachweislich
* verhindert.
*/
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(
repositoryIdentifier: 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.
*/
export async function applyRetention(
@ -238,7 +238,7 @@ export interface RetentionPolicy {
id: string;
name: string;
keep_within_seconds?: number;
/** Schuetzt das letzte vorhandene Backup. */
/** Schützt das letzte vorhandene Backup. */
keep_last?: number;
keep_daily?: number;
keep_weekly?: number;
@ -247,7 +247,7 @@ export interface RetentionPolicy {
time_zone?: string;
}
/** Laedt die Aufbewahrungsregeln. */
/** Lädt die Aufbewahrungsregeln. */
export async function listRetentionPolicies(
abortSignal?: AbortSignal,
): Promise<RetentionPolicy[]> {
@ -268,7 +268,7 @@ export async function createRetentionPolicy(
});
}
/** Loescht eine Aufbewahrungsregel. */
/** Löscht eine Aufbewahrungsregel. */
export async function deleteRetentionPolicy(policyIdentifier: string): Promise<void> {
return requestApi<void>(`/retention-policies/${encodeURIComponent(policyIdentifier)}`, {
method: 'DELETE',

View File

@ -1,26 +1,26 @@
/**
* Wiederherstellungs-Assistent — die Kernfunktion des Produkts.
*
* Bis hierher liess sich eine Wiederherstellung ueber die Oberflaeche gar nicht
* ausloesen. Wer im Ernstfall Daten zurueckbrauchte, musste `curl` bemuehen —
* Bis hierher ließ sich eine Wiederherstellung über die Oberfläche gar nicht
* auslösen. Wer im Ernstfall Daten zurückbrauchte, musste `curl` bemühen —
* ausgerechnet in der Lage, in der niemand experimentieren will.
*
* Der Assistent hat vier Schritte, und der dritte ist der Grund fuer seine
* Existenz: **Die Vorabpruefung schreibt nichts und stellt fest, ob die
* Wiederherstellung gelingen kann** — insbesondere, ob jeder benoetigte Block
* Der Assistent hat vier Schritte, und der dritte ist der Grund für seine
* Existenz: **Die Vorabprüfung schreibt nichts und stellt fest, ob die
* Wiederherstellung gelingen kann** — insbesondere, ob jeder benötigte Block
* noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas
* 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.
* 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`.
* 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
* ueberschrieben wird —, entfaellt sie. Ein Ritual ohne Anlass gewoehnt das
* Wegklicken an, und dann wirkt es dort nicht mehr, wo es zaehlt.
* Läuft die dritte Hürde ins Leere — weil das Ziel leer ist und nichts
* überschrieben wird —, entfällt sie. Ein Ritual ohne Anlass gewöhnt das
* Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt.
*/
import { AlertTriangle, ArrowLeft, ArrowRight, Check, ShieldAlert } from 'lucide-react';
@ -52,8 +52,8 @@ import {
const WIZARD_STEPS = [
{ key: 'ziel', label: 'Ziel' },
{ key: 'umfang', label: 'Umfang' },
{ key: 'pruefung', label: 'Vorabpruefung' },
{ key: 'ausfuehren', label: 'Ausfuehren' },
{ key: 'prüfung', label: 'Vorabprüfung' },
{ key: 'ausführen', label: 'Ausführen' },
] as const;
export function RestoreWizard({
@ -74,7 +74,7 @@ export function RestoreWizard({
const [currentStepIndex, setCurrentStepIndex] = useState(0);
// 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 [pathPrefix, setPathPrefix] = useState('');
const [overwriteExisting, setOverwriteExisting] = useState(false);
@ -127,7 +127,7 @@ export function RestoreWizard({
},
onError: (apiError) =>
toast.showError(
'Die Vorabpruefung ist fehlgeschlagen',
'Die Vorabprüfung ist fehlgeschlagen',
describeApiError(apiError),
apiError.requestId,
),
@ -138,13 +138,13 @@ export function RestoreWizard({
onSuccess: (session) => {
toast.showSuccess(
'Wiederherstellung gestartet',
'Der Fortschritt erscheint in der Uebersicht.',
'Der Fortschritt erscheint in der Übersicht.',
);
onStarted(session.id);
},
onError: (apiError) =>
toast.showError(
'Die Wiederherstellung liess sich nicht starten',
'Die Wiederherstellung ließ sich nicht starten',
describeApiError(apiError),
apiError.requestId,
),
@ -152,8 +152,8 @@ export function RestoreWizard({
const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/');
// Die woertliche Bestaetigung wird nur verlangt, wenn tatsaechlich
// ueberschrieben wird — der Server sagt das nach der Pruefung.
// Die wörtliche Bestätigung wird nur verlangt, wenn tatsächlich
// überschrieben wird — der Server sagt das nach der Prüfung.
const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false;
const confirmationSatisfied =
!confirmationRequired || typedConfirmation.trim() === targetPath.trim();
@ -186,8 +186,8 @@ export function RestoreWizard({
/>
<Callout tone="info" title="Wohin am besten?">
In ein <strong>leeres Verzeichnis</strong>. Zurueck an den
Ursprungsort zu schreiben ueberschreibt genau die Daten, deren
In ein <strong>leeres Verzeichnis</strong>. Zurück an den
Ursprungsort zu schreiben überschreibt genau die Daten, deren
Zustand Sie vielleicht noch brauchen — etwa um zu vergleichen,
was gefehlt hat.
</Callout>
@ -201,7 +201,7 @@ export function RestoreWizard({
placeholder="daten/projekte"
value={pathPrefix}
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">
@ -209,10 +209,10 @@ export function RestoreWizard({
checked={overwriteExisting}
onCheckedChange={setOverwriteExisting}
disabled={!mayOverwrite}
label="Vorhandene Dateien ueberschreiben"
label="Vorhandene Dateien überschreiben"
hint={
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.'
}
/>
@ -220,21 +220,21 @@ export function RestoreWizard({
<CheckboxField
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
label="Rechte nicht zurueckschreiben"
label="Rechte nicht zurückschreiben"
hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten."
/>
<CheckboxField
checked={skipDeepCheck}
onCheckedChange={setSkipDeepCheck}
label="Blockpruefung ueberspringen"
hint="Beschleunigt die Vorabpruefung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benoetigte Block noch vorhanden ist."
label="Blockprüfung überspringen"
hint="Beschleunigt die Vorabprüfung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benötigte Block noch vorhanden ist."
/>
</div>
{skipDeepCheck ? (
<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
existieren. Der Bericht weist das aus.
</Callout>
@ -255,11 +255,11 @@ export function RestoreWizard({
{confirmationRequired ? (
<>
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>
@ -281,8 +281,8 @@ export function RestoreWizard({
{confirmationRequired ? (
<Field
label="Zielpfad zur Bestaetigung wiederholen"
hint="Woertlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus."
label="Zielpfad zur Bestätigung wiederholen"
hint="Wörtlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus."
required
htmlFor="restore-confirm"
>
@ -308,7 +308,7 @@ export function RestoreWizard({
disabled={validationMutation.isRunning || createMutation.isRunning}
>
<ArrowLeft />
Zurueck
Zurück
</Button>
) : (
<Button variant="ghost" onClick={onClose}>
@ -333,7 +333,7 @@ export function RestoreWizard({
isLoading={validationMutation.isRunning}
onClick={() => void validationMutation.run()}
>
Vorabpruefung starten
Vorabprüfung starten
<ArrowRight />
</Button>
) : null}
@ -401,10 +401,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,
* bei dem feststeht, dass die Wiederherstellung **nicht vollstaendig** gelingen
* Fehlende Blöcke stehen ganz oben und in Rot. Sie sind der einzige Befund,
* bei dem feststeht, dass die Wiederherstellung **nicht vollständig** gelingen
* kann — alles andere ist ein Hinweis.
*/
function ValidationSummary({
@ -417,8 +417,8 @@ function ValidationSummary({
if (isRunning || !validationResult) {
return (
<div className="py-10 text-center text-sm text-fg-muted">
Die Vorabpruefung laeuft. Bei aktiver Blockpruefung wird jeder
benoetigte Block gesucht — das dauert bei grossen Bestaenden.
Die Vorabprüfung läuft. Bei aktiver Blockprüfung wird jeder
benötigte Block gesucht — das dauert bei großen Beständen.
</div>
);
}
@ -453,7 +453,7 @@ function ValidationSummary({
>
{validationResult.can_proceed
? 'Die Wiederherstellung kann beginnen'
: 'Die Wiederherstellung ist nicht moeglich'}
: 'Die Wiederherstellung ist nicht möglich'}
</p>
<p className="mt-0.5 text-sm text-fg">{validationResult.summary}</p>
</div>
@ -474,15 +474,15 @@ function ValidationSummary({
<div>
<dt className="text-xs text-fg-subtle">Freier Platz am Ziel</dt>
<dd className="tabular text-sm text-fg">
{/* -1 bedeutet unbekannt. Als 0 zu zeigen waere das Gegenteil
der Wahrheit — es saehe nach "voll" aus. */}
{/* -1 bedeutet unbekannt. Als 0 zu zeigen wäre das Gegenteil
der Wahrheit — es sähe nach "voll" aus. */}
{report.available_target_bytes < 0
? 'unbekannt'
: formatBytes(report.available_target_bytes)}
</dd>
</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">
{formatDuration(report.duration_seconds)}
</dd>
@ -491,11 +491,11 @@ function ValidationSummary({
) : null}
{report && report.missing_chunk_count > 0 ? (
<Callout tone="critical" title={`${report.missing_chunk_count} Bloecke fehlen`}>
Von {report.unique_chunk_count.toLocaleString('de-DE')} benoetigten
Bloecken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht
auffindbar. Die Wiederherstellung waere unvollstaendig. Pruefen Sie das
Repository mit einem Integritaetslauf.
<Callout tone="critical" title={`${report.missing_chunk_count} Blöcke fehlen`}>
Von {report.unique_chunk_count.toLocaleString('de-DE')} benötigten
Blöcken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht
auffindbar. Die Wiederherstellung wäre unvollständig. Prüfen Sie das
Repository mit einem Integritätslauf.
</Callout>
) : 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
* 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
* 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
* 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';
@ -103,7 +103,7 @@ export function RestoresPage({
render: (session) => (
<div className="flex items-center gap-2">
<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
Fortsetzung und einem Neubeginn. */}
{session.status === 'failed' && session.checkpoint ? (
@ -130,7 +130,7 @@ export function RestoresPage({
{session.files_restored.toLocaleString('de-DE')}
{session.files_skipped > 0 ? (
<span className="ml-1 text-warning">
({session.files_skipped.toLocaleString('de-DE')} uebergangen)
({session.files_skipped.toLocaleString('de-DE')} übergangen)
</span>
) : null}
</span>
@ -138,7 +138,7 @@ export function RestoresPage({
},
{
key: 'bytes_restored',
header: 'Zurueckgeschrieben',
header: 'Zurückgeschrieben',
numeric: true,
render: (session) => formatBytes(session.bytes_restored),
},
@ -200,7 +200,7 @@ export function RestoresPage({
<Callout tone="info" className="mb-4">
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>
<Card>
@ -211,7 +211,7 @@ export function RestoresPage({
onRowClick={(session) => setSelectedSession(session)}
isLoading={restoresResource.loadState === 'loading'}
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. Erst ein durchgeführter Test hebt einen Wiederherstellungspunkt auf „wiederherstellbar“ — alles davor ist ein Indiz."
/>
</Card>
@ -236,7 +236,7 @@ export function RestoresPage({
>
<div className="space-y-3 text-sm">
<p className="text-fg">
Es wird ab dem letzten vollstaendig geschriebenen Pfad weitergearbeitet.
Es wird ab dem letzten vollständig geschriebenen Pfad weitergearbeitet.
</p>
{sessionPendingResume?.checkpoint?.last_completed_path ? (
<div className="rounded-md border border-line bg-sunken p-2.5">
@ -273,7 +273,7 @@ function RestoreDetailCard({
description={session.target_path}
action={
<Button variant="ghost" size="sm" onClick={onClose}>
Schliessen
Schließen
</Button>
}
/>
@ -288,11 +288,11 @@ function RestoreDetailCard({
<DetailItem label="Dateien">
{session.files_restored.toLocaleString('de-DE')}
</DetailItem>
<DetailItem label="Uebergangen">
<DetailItem label="Übergangen">
{session.files_skipped.toLocaleString('de-DE')}
</DetailItem>
<DetailItem label="Datenmenge">{formatBytes(session.bytes_restored)}</DetailItem>
<DetailItem label="Ueberschreiben erlaubt">
<DetailItem label="Überschreiben erlaubt">
{session.overwrite_existing ? 'ja' : 'nein'}
</DetailItem>
</dl>
@ -306,15 +306,15 @@ function RestoreDetailCard({
{session.checkpoint ? (
<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">
Pruefpunkt
Prüfpunkt
</p>
<p className="mt-1 break-all font-mono text-xs text-fg">
{session.checkpoint.last_completed_path ?? '—'}
</p>
<p className="mt-1 text-xs text-fg-muted">
Er entsteht erst, <strong>nachdem</strong> eine Datei vollstaendig
und umbenannt am Platz liegt. Ein Pruefpunkt auf eine halbe Datei
waere schlimmer als keiner.
Er entsteht erst, <strong>nachdem</strong> eine Datei vollständig
und umbenannt am Platz liegt. Ein Prüfpunkt auf eine halbe Datei
wäre schlimmer als keiner.
</p>
</div>
) : null}

View File

@ -1,22 +1,22 @@
/**
* 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* —
* 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.
*/
import { requestApi } from '../../api/client';
/** Schweregrad eines Befundes der Vorabpruefung. */
/** Schweregrad eines Befundes der Vorabprüfung. */
export type ValidationSeverity = 'blocking' | 'warning' | 'info';
/** Einzelner Befund der Vorabpruefung. */
/** Einzelner Befund der Vorabprüfung. */
export interface ValidationFinding {
/** Maschinenlesbarer Code. */
code: string;
/** Schweregrad; nur `blocking` verhindert die Ausfuehrung. */
/** Schweregrad; nur `blocking` verhindert die Ausführung. */
severity: ValidationSeverity;
/** Meldung im Klartext. */
message: string;
@ -24,7 +24,7 @@ export interface ValidationFinding {
detail?: string;
}
/** Bericht der Vorabpruefung. */
/** Bericht der Vorabprüfung. */
export interface ValidationReport {
backup_id: string;
target_path: string;
@ -33,11 +33,11 @@ export interface ValidationReport {
entry_count: number;
/** Zahl wiederherzustellender Dateien. */
file_count: number;
/** Zurueckzuschreibende Datenmenge. */
/** Zurückzuschreibende Datenmenge. */
total_bytes: number;
/** Zahl benoetigter Bloecke. */
/** Zahl benötigter Blöcke. */
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;
/** Freier Platz am Ziel; -1 bedeutet unbekannt. */
available_target_bytes: number;
@ -45,20 +45,20 @@ export interface ValidationReport {
duration_seconds: number;
}
/** Antwort der Vorabpruefung. */
/** Antwort der Vorabprüfung. */
export interface ValidationResponse {
/** Darf die Wiederherstellung beginnen? */
can_proceed: boolean;
/** Verlangt das Ziel eine woertliche Bestaetigung? */
/** Verlangt das Ziel eine wörtliche Bestätigung? */
requires_overwrite_confirmation: boolean;
/** Zusammenfassung in einem Satz. */
summary: string;
report: ValidationReport | null;
}
/** Pruefpunkt einer unterbrochenen Wiederherstellung. */
/** Prüfpunkt einer unterbrochenen Wiederherstellung. */
export interface RestoreCheckpoint {
/** Zuletzt vollstaendig geschriebener Pfad. */
/** Zuletzt vollständig geschriebener Pfad. */
last_completed_path?: string;
files_restored?: number;
bytes_restored?: number;
@ -95,31 +95,31 @@ export interface CreateRestoreRequest {
target_type: string;
/** Zielverzeichnis. */
target_path: string;
/** Beschraenkung auf einen Teilbaum des Manifests. */
/** Beschränkung auf einen Teilbaum des Manifests. */
path_prefix?: string;
/** Erste Huerde: das Kennzeichen. */
/** Erste Hürde: das Kennzeichen. */
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
* damit nicht aus.
*/
confirm_overwrite?: string;
/** Rechte nicht mit zurueckschreiben. */
/** Rechte nicht mit zurückschreiben. */
skip_permissions?: boolean;
/**
* Blockpruefung ueberspringen.
* Blockprüfung überspringen.
*
* Beschleunigt die Vorabpruefung erheblich und **senkt ihre Aussagekraft**:
* Ohne sie ist nicht belegt, dass jeder benoetigte Block noch da ist. Der
* Beschleunigt die Vorabprüfung erheblich und **senkt ihre Aussagekraft**:
* Ohne sie ist nicht belegt, dass jeder benötigte Block noch da ist. Der
* Bericht weist das als Hinweis aus.
*/
skip_deep_check?: boolean;
}
/** Fuehrt die Vorabpruefung aus. Sie schreibt nichts. */
/** Führt die Vorabprüfung aus. Sie schreibt nichts. */
export async function validateRestore(
validationRequest: CreateRestoreRequest,
): 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[]> {
return requestApi<RestoreSession[]>(
'/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(
restoreIdentifier: string,
abortSignal?: AbortSignal,
@ -171,7 +171,7 @@ export async function cancelRestore(restoreIdentifier: string): Promise<RestoreS
* Setzt eine unterbrochene Wiederherstellung fort.
*
* 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.
*/
export async function resumeRestore(restoreIdentifier: string): Promise<RestoreSession> {

View File

@ -1,12 +1,12 @@
/**
* 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
* gibt es beides.
*
* Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund traegt
* Schweregrad, Erklaerung, betroffenes Objekt und Empfehlung. Die Empfehlung ist
* Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund trägt
* Schweregrad, Erklärung, betroffenes Objekt und Empfehlung. Die Empfehlung ist
* der wichtigste Teil — ein Befund ohne sie ist eine Beunruhigung.
*/
@ -34,29 +34,29 @@ type FindingSeverity = 'information' | 'warning' | 'high' | 'critical';
/** Ein Sicherheitsbefund. */
interface SecurityFinding {
/** Pruefbereich. */
/** Prüfbereich. */
readonly area: string;
/** Schweregrad. */
readonly severity: FindingSeverity;
/** Ueberschrift. */
/** Überschrift. */
readonly title: string;
/** Erklaerung, warum der Zustand ein Problem ist. */
/** Erklärung, warum der Zustand ein Problem ist. */
readonly explanation: string;
/** Das betroffene Objekt. */
readonly affected_object: string;
/** Die naechste Handlung. */
/** Die nächste Handlung. */
readonly recommendation: string;
}
/** Ergebnis eines Pruefbereichs. */
/** Ergebnis eines Prüfbereichs. */
interface AreaResult {
/** Bezeichner. */
readonly area: string;
/** Bezeichnung. */
readonly title: string;
/** Meldet, ob der Bereich geprueft werden konnte. */
/** Meldet, ob der Bereich geprüft werden konnte. */
readonly available: boolean;
/** Erklaert einen nicht pruefbaren Bereich. */
/** Erklärt einen nicht prüfbaren Bereich. */
readonly unavailable_reason?: string;
/** Befunde des Bereichs. */
readonly findings: readonly SecurityFinding[];
@ -72,13 +72,13 @@ interface AreaResult {
interface SecurityAssessment {
/** Gesamtbewertung in Prozent. */
readonly score: number;
/** Erreichbare Punkte der geprueften Bereiche. */
/** Erreichbare Punkte der geprüften Bereiche. */
readonly maximum_score: number;
/** Einstufung. */
readonly grade: string;
/** Ergebnisse je Bereich. */
readonly areas: readonly AreaResult[];
/** Zahl nicht pruefbarer Bereiche. */
/** Zahl nicht prüfbarer Bereiche. */
readonly unchecked_area_count: number;
/** Zahl kritischer Befunde. */
readonly critical_finding_count: number;
@ -86,7 +86,7 @@ interface SecurityAssessment {
readonly high_finding_count: number;
/** Zusammenfassung. */
readonly summary: string;
/** Nicht gepruefte Bereiche. */
/** Nicht geprüfte Bereiche. */
readonly unchecked_areas: readonly string[];
}
@ -111,7 +111,7 @@ export function SecurityPage(): React.JSX.Element {
<>
<PageHeader
title="Security Center"
description="Was geprueft wurde — und was nicht."
description="Was geprüft wurde — und was nicht."
/>
{loadState === 'loading' ? (
@ -144,12 +144,12 @@ export function SecurityPage(): React.JSX.Element {
/**
* 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
* 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.
*
* 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".
*/
function ScoreHeader({ response }: { readonly response: SecurityResponse }): React.JSX.Element {
@ -181,15 +181,15 @@ function ScoreHeader({ response }: { readonly response: SecurityResponse }): Rea
<Callout tone="critical">
{criticalCount} kritische{criticalCount === 1 ? 'r' : ''} Befund
{criticalCount === 1 ? '' : 'e'} deckelt die Einstufung auf
„unzureichend" — unabhaengig von der Punktzahl.
„unzureichend" — unabhängig von der Punktzahl.
</Callout>
) : null}
{/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die
Handlungsanweisung, nicht nur eine Einschraenkung. */}
{/* Die nicht geprüfte Bereiche stehen namentlich da: Sie sind die
Handlungsanweisung, nicht nur eine Einschränkung. */}
{assessment.unchecked_areas.length > 0 ? (
<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
? ' — 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.
*
* 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
* etwas nicht stimmt, und laesst den Betreiber damit allein.
* etwas nicht stimmt, und lässt den Betreiber damit allein.
*/
function FindingList({
assessment,
@ -224,8 +224,8 @@ function FindingList({
return (
<Card>
<EmptyState
title="Keine Befunde in den geprueften Bereichen"
description="Das gilt ausschliesslich fuer die geprueften Bereiche — die ungeprueften stehen oben."
title="Keine Befunde in den geprüften Bereichen"
description="Das gilt ausschließlich für die geprüften Bereiche — die ungeprüften stehen oben."
/>
</Card>
);
@ -257,10 +257,10 @@ function FindingList({
</div>
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum
der Befund ueberhaupt angezeigt wird. */}
der Befund überhaupt angezeigt wird. */}
<div className="flex gap-2">
<dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
Naechster Schritt
Nächster Schritt
</dt>
<dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd>
</div>
@ -273,7 +273,7 @@ function FindingList({
);
}
/** Zeigt die Ergebnisse je Pruefbereich. */
/** Zeigt die Ergebnisse je Prüfbereich. */
function AreaTable({
assessment,
}: {
@ -293,7 +293,7 @@ function AreaTable({
<span className="text-sm text-fg">{areaResult.summary}</span>
) : (
<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>
</div>
),
@ -308,7 +308,7 @@ function AreaTable({
) : (
// Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder
// 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 (
<Card>
<CardHeader
title="Geprueft wurde"
title="Geprüft wurde"
description={`Erreichbar sind ${assessment.maximum_score} Punkte.`}
/>
<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 { 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
* im Aufruf — eine Komponente liesse sich dann von aussen nicht mehr anpassen,
* und das faellt erst auf, wenn jemand es versucht.
* Ohne twMerge gewänne bei "p-2 p-4" die Reihenfolge im Stylesheet, nicht die
* im Aufruf — eine Komponente ließe sich dann von außen nicht mehr anpassen,
* und das fällt erst auf, wenn jemand es versucht.
*/
export function cn(...classValues: ClassValue[]): string {
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
* vermischt, erzeugt Rueckfragen bei jeder Kapazitaetsplanung.
* Bewusst mit binären Einheiten (KiB, MiB): Ein Backup-Produkt, das GB und GiB
* vermischt, erzeugt Rückfragen bei jeder Kapazitätsplanung.
*/
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.
if (byteCount === null || byteCount === undefined || Number.isNaN(byteCount)) {
return '—';
@ -38,7 +38,7 @@ export function formatBytes(byteCount: number | null | undefined): string {
);
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.
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").
*
* 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,
* sagt alles.
*/
@ -97,8 +97,8 @@ export function formatRelativeTime(isoTimestamp: string | null | undefined): str
return isInFuture ? 'gleich' : 'gerade eben';
}
// Schwelle, Teiler und Einheit gehoeren zusammen und stehen deshalb in einem
// Eintrag. Drei parallele Arrays waeren die Sorte Code, bei der ein
// Schwelle, Teiler und Einheit gehören zusammen und stehen deshalb in einem
// Eintrag. Drei parallele Arrays wären die Sorte Code, bei der ein
// eingeschobener Eintrag still die Zuordnung verschiebt.
const timeScales = [
{ 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 { createRoot } from 'react-dom/client';
@ -8,7 +8,7 @@ import './styles/theme.css';
const rootElement = document.getElementById('root');
// 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) {
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:
* 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
* leere Tabelle zeigt, laesst ihn glauben, es gaebe nichts zu sehen.
* nur „in Arbeit" sagt, lässt den Bediener ratlos zurück — und eine, die eine
* leere Tabelle zeigt, lässt ihn glauben, es gäbe nichts zu sehen.
*/
import { Construction } from 'lucide-react';
@ -39,7 +39,7 @@ export function UnavailablePage({
<p className="mt-5 text-xs text-fg-muted">
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.
</p>
</div>

View File

@ -1,17 +1,17 @@
/**
* Tests des Seitenverzeichnisses.
*
* Sie pruefen weniger die Technik als eine Zusage: Jede Seite aus dem
* Implementierungsplan erscheint, und jede unfertige erklaert sich.
* Sie prüfen weniger die Technik als eine Zusage: Jede Seite aus dem
* Implementierungsplan erscheint, und jede unfertige erklärt sich.
*/
import { describe, expect, it } from 'vitest';
import { ALL_PAGES, findPage, mayViewPage } from './pages';
describe('Seitenverzeichnis', () => {
it('nennt zu jeder nicht verfuegbaren Seite einen Grund', () => {
// Ein Bereich, der nur „nicht verfuegbar" sagt, laesst den Bediener ratlos
// zurueck. Der Grund ist der eigentliche Inhalt dieser Seiten.
it('nennt zu jeder nicht verfügbaren Seite einen Grund', () => {
// Ein Bereich, der nur „nicht verfügbar" sagt, lässt den Bediener ratlos
// zurück. Der Grund ist der eigentliche Inhalt dieser Seiten.
for (const pageDefinition of ALL_PAGES) {
if (!pageDefinition.available) {
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) {
if (pageDefinition.available) {
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeUndefined();
@ -35,7 +35,7 @@ describe('Seitenverzeichnis', () => {
});
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
// es.
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');
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
* fertigen Bereiche zeigt, verschweigt den Ausbaustand; eines, dessen Eintraege
* in leere Masken fuehren, taeuscht ihn vor. Jeder Eintrag erscheint, und ein
* noch nicht verfuegbarer nennt, was fehlt und was stattdessen gilt.
* Der Grundsatz aus PROMPT.md §139 gilt unverändert: Ein Menü, das nur die
* fertigen Bereiche zeigt, verschweigt den Ausbaustand; eines, dessen Einträge
* in leere Masken führen, täuscht ihn vor. Jeder Eintrag erscheint, und ein
* noch nicht verfügbarer nennt, was fehlt und was stattdessen gilt.
*
* 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.
*/
@ -50,28 +50,28 @@ export const SECTION_LABELS: ReadonlyArray<readonly [NavigationSection, string]>
['verwaltung', 'Verwaltung'],
];
/** Beschreibung einer Seite der Oberflaeche. */
/** Beschreibung einer Seite der Oberfläche. */
export interface PageDefinition {
/** Stabiler Bezeichner; er steht auch im Pfad. */
readonly id: string;
/** Beschriftung im Menue. */
/** Beschriftung im Menü. */
readonly label: string;
/** Bereich, unter dem die Seite erscheint. */
readonly section: NavigationSection;
/** Sinnbild im Menue. */
/** Sinnbild im Menü. */
readonly icon: LucideIcon;
/**
* Berechtigung, die der Zugriff verlangt.
*
* Rein zur Anzeige: Die Pruefung erfolgt immer serverseitig (PROMPT.md §42).
* Ein Menuepunkt, den der Server ohnehin ablehnt, fuehrt sonst nur zu einer
* Fehlermeldung statt zu einer verstaendlichen Oberflaeche.
* Rein zur Anzeige: Die Prüfung erfolgt immer serverseitig (PROMPT.md §42).
* Ein Menüpunkt, den der Server ohnehin ablehnt, führt sonst nur zu einer
* Fehlermeldung statt zu einer verständlichen Oberfläche.
*/
readonly requiredPermission: string | null;
/** Meldet, ob hinter der Seite ein Backend steht. */
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
* Auskunft kommt.
@ -81,10 +81,10 @@ export interface PageDefinition {
/** Alle Seiten in Anzeigereihenfolge. */
export const ALL_PAGES: readonly PageDefinition[] = [
// --- Betrieb: was taeglich beobachtet und bedient wird ---
// --- Betrieb: was täglich beobachtet und bedient wird ---
{
id: 'dashboard',
label: 'Uebersicht',
label: 'Übersicht',
section: 'betrieb',
icon: LayoutDashboard,
requiredPermission: 'backups.read',
@ -92,7 +92,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
},
{
id: 'jobs',
label: 'Sicherungsauftraege',
label: 'Sicherungsaufträge',
section: 'betrieb',
icon: Clock,
requiredPermission: 'jobs.read',
@ -126,7 +126,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
},
{
id: 'verification',
label: 'Pruefung',
label: 'Prüfung',
section: 'daten',
icon: BadgeCheck,
requiredPermission: 'verification.read',
@ -168,7 +168,7 @@ export const ALL_PAGES: readonly PageDefinition[] = [
},
{
id: 'protected-systems',
label: 'Geschuetzte Systeme',
label: 'Geschützte Systeme',
section: 'infrastruktur',
icon: Activity,
requiredPermission: 'providers.read',
@ -247,8 +247,8 @@ export function findPage(pageIdentifier: string): PageDefinition | undefined {
/**
* Meldet, ob ein Benutzer eine Seite sehen darf.
*
* Ausschliesslich zur Anzeige — sie verhindert Sackgassen im Menue. Die
* verbindliche Pruefung findet auf dem Server statt (PROMPT.md §42).
* Ausschließlich zur Anzeige — sie verhindert Sackgassen im Menü. Die
* verbindliche Prüfung findet auf dem Server statt (PROMPT.md §42).
*/
export function mayViewPage(
page: PageDefinition,
@ -258,7 +258,7 @@ export function mayViewPage(
return true;
}
// Der Super-Administrator traegt einen Platzhalter statt aller Einzelrechte.
// Der Super-Administrator trägt einen Platzhalter statt aller Einzelrechte.
if (grantedPermissions.includes('*')) {
return true;
}

View File

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

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 { cleanup } from '@testing-library/react';

View File

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

View File

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