Weboberflaeche: Design-Fundament und bedienbare Auftraege
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 44s
CI / Sicherheitsprüfungen (push) Successful in 27s

Ausgangslage, gemessen statt geschaetzt: Von 99 fachlichen Endpunkten rief die
Oberflaeche 23 auf. Schreibend waren es neun, vier davon An- und Abmeldung.
Real verwaltbar war: einen Auftrag anlegen, einen Bericht erzeugen, eine
Meldung bestaetigen. Das ist ein Leseinstrument, keine Verwaltungskonsole.

Dieser Schritt legt das Fundament und macht den ersten Bereich vollstaendig
bedienbar.

Fundament:

- Tailwind v4 und Radix-Primitive (shadcn-Muster). Alles gebuendelt, keine
  externen Ressourcen — die CSP der Auslieferung laesst sie ohnehin nicht zu.
- Farbsystem nach PROMPT.md §106: Semantische Farben ausschliesslich fuer
  Status, sonst neutral. Die Zuordnung der Fachbegriffe auf die fuenf
  Bedeutungen steht an genau **einer** Stelle (StatusBadge). Verteilt ueber die
  Seiten erschiene frueher oder spaeter irgendwo "partial_failure" gruen, und
  ein Betreiber haelt einen Teilfehler dann fuer einen Erfolg. Ein unbekannter
  Zustand wird neutral dargestellt, nie gruen.
- Neue Seitenhuelle mit fuenf Bereichen, einklappbarer Seitenleiste, Schublade
  auf schmalen Geraeten und Dark Mode ueber ein Attribut am Wurzelelement (nicht
  allein ueber die Medienabfrage — eine Konsole, die nachts waehrend einer
  Stoerung von selbst umschaltet, ist laestig).
- `useMutation` fuer schreibende Aufrufe: Doppelklickschutz, Vorgangsnummer bis
  in die Meldung, kein setState nach dem Aushaengen. `describeApiError`
  uebersetzt die bekannten Fehlercodes in Saetze **mit Abhilfe**.
- Der API-Client sendet jetzt `Idempotency-Key`. Ohne ihn erzeugt ein
  Doppelklick zwei Auftraege — und bei einer Wiederherstellung zwei
  gleichzeitige Laeufe in dasselbe Ziel.
- Fehlermeldungen nennen immer die `request_id`, kopierbar.

Auftraege (Endpunkte, die vorher keine Oberflaeche hatten):

- Lauf anstossen, anhalten, fortsetzen, loeschen, laufenden Lauf abbrechen.
- Detailseite mit Laufhistorie: Fehlercode, Fehlerklasse und die Auskunft, ob
  eine Wiederholung ueberhaupt etwas bringt — ein Anmeldefehler behebt sich
  nicht durch Warten.
- **Ein zweiter Anstoss ist kein Fehler, sondern eine Auskunft.** Der 409 wird
  als Hinweis gezeigt, nicht als Fehlschlag: 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. Ohne diesen Hinweis loescht
  jemand einen Auftrag in der Annahme, Platz zu schaffen.

Der Wiederherstellungs-Assistent ist gebaut (vier Schritte, Vorabpruefung als
eigener Schritt, die drei Huerden vor dem Ueberschreiben sichtbar umgesetzt),
aber noch nicht in eine Seite eingebunden.

Drei Lint-Befunde behoben, alle dieselbe Sorte wie in Phase 8 und 12:
setState im Effektkoerper und ein Schreibzugriff auf eine Referenz waehrend des
Renderns. Der Bestaetigungsdialog haelt seinen Zustand jetzt im Portalinhalt —
beim Schliessen verschwindet er von selbst, ein Zuruecksetzen im Effekt
entfaellt, und die Huerde steht beim naechsten Oeffnen wieder.

Die noch nicht umgebauten Seiten behalten vorerst das alte Stylesheet. Es faellt
weg, sobald die letzte umgebaut ist.

69 Tests gruen, tsc sauber, eslint ohne Warnung, Bau 373 KB (115 KB gzip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jerrit Fritzsche 2026-08-18 09:25:42 +02:00
parent b6668c600d
commit b3f0a99243
28 changed files with 5969 additions and 362 deletions

File diff suppressed because it is too large Load Diff

View File

@ -13,8 +13,32 @@
"lint": "eslint . --max-warnings 0" "lint": "eslint . --max-warnings 0"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-avatar": "^1.2.6",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-radio-group": "^1.4.7",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-switch": "^1.3.7",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-toast": "^1.2.23",
"@radix-ui/react-tooltip": "^1.2.16",
"@tailwindcss/vite": "^4.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.31.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.0", "@eslint/js": "^9.39.0",

View File

@ -1,54 +1,65 @@
/** /**
* Wurzelkomponente der Syncova-Oberflaeche. * Wurzelkomponente der Syncova-Oberflaeche.
* *
* Die Anwendung entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und * Sie entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und verteilt
* verteilt die angemeldete Ansicht auf die Bereiche aus PROMPT.md §28. * letztere auf die Bereiche aus PROMPT.md §28.
* *
* Der Umgang mit unfertigen Bereichen ist die eine Entscheidung, die diese Datei * Die Navigation kennt zwei Ebenen: eine Seite und optional ein Objekt darauf
* traegt: Sie erscheinen im Menue, aber deaktiviert und mit der Angabe, was * (`jobs/<id>`). Das reicht fuer diese Konsole und kostet rund fuenfzig Zeilen —
* fehlt. Ein Menue nur aus fertigen Bereichen verschweigt den Ausbaustand; eines * eine Router-Bibliothek waere hier mehr Abhaengigkeit als Nutzen.
* mit leeren Masken taeuscht ihn vor (PROMPT.md §139).
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { setAccessTokenProvider } from './api/client'; import { setAccessTokenProvider } from './api/client';
import { SystemHealthPanel } from './features/health/SystemHealthPanel'; import { AppShell } from './components/layout/AppShell';
import { JobsPanel } from './features/jobs/JobsPanel'; import { ToastProvider } from './components/ui';
import { LoginPage } from './features/auth/LoginPage';
import { getAccessToken, logout } from './features/auth/authApi'; import { getAccessToken, logout } from './features/auth/authApi';
import { LoginPage } from './features/auth/LoginPage';
import { AlertsPage } from './features/alerts/AlertsPage';
import { DashboardPage } from './features/dashboard/DashboardPage'; import { DashboardPage } from './features/dashboard/DashboardPage';
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage'; import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
import { RolesPage, UsersPage } from './features/identity/IdentityPages';
import { import {
AgentsPage, AgentsPage,
EventsPage, EventsPage,
RepositoriesPage, RepositoriesPage,
RestoresPage, RestoresPage,
} from './features/inventory/InventoryPages'; } from './features/inventory/InventoryPages';
import { RolesPage, UsersPage } from './features/identity/IdentityPages'; import { JobDetailPage } from './features/jobs/JobDetailPage';
import { JobsPage } from './features/jobs/JobsPage';
import { MetricsPage } from './features/metrics/MetricsPage'; import { MetricsPage } from './features/metrics/MetricsPage';
import { AlertsPage } from './features/alerts/AlertsPage';
import { ReportsPage } from './features/reports/ReportsPage'; import { ReportsPage } from './features/reports/ReportsPage';
import { SecurityPage } from './features/security/SecurityPage'; import { SecurityPage } from './features/security/SecurityPage';
import { NavigationSidebar } from './navigation/NavigationSidebar';
import { UnavailablePage } from './navigation/UnavailablePage'; import { UnavailablePage } from './navigation/UnavailablePage';
import { DEFAULT_PAGE_ID, findPage, mayViewPage } from './navigation/pages'; import { DEFAULT_PAGE_ID, findPage, mayViewPage } from './navigation/pages';
import { useCurrentPage } from './navigation/useCurrentPage'; import { useCurrentPage } from './navigation/useCurrentPage';
import type { CurrentUser } from './types/auth'; import type { CurrentUser } from './types/auth';
import './styles/theme.css';
// Die noch nicht umgebauten Seiten nutzen weiterhin das alte Stylesheet. Es
// hier stehen zu lassen ist unschoen und die einzige Alternative zu einer
// Oberflaeche, die auf halbem Weg unformatiert erscheint. Es faellt weg,
// sobald die letzte Seite umgebaut ist.
import './styles/tokens.css';
import './App.css'; import './App.css';
// Der API-Client erhaelt seinen Tokenzugriff einmalig beim Laden des Moduls. // Der API-Client erhaelt seinen Tokenzugriff einmalig beim Laden des Moduls.
setAccessTokenProvider(getAccessToken); setAccessTokenProvider(getAccessToken);
/** Baut das Grundlayout der Anwendung. */
export function App(): React.JSX.Element { export function App(): React.JSX.Element {
const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null); const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null);
const { currentPageId, navigateToPage } = useCurrentPage(); const { currentPageId, navigateToPage } = useCurrentPage();
// Das gerade geoeffnete Objekt innerhalb einer Seite, etwa ein Auftrag.
const [openObjectIdentifier, setOpenObjectIdentifier] = useState<string | null>(null);
if (authenticatedUser === null) { if (authenticatedUser === null) {
return <LoginPage onAuthenticated={setAuthenticatedUser} />; return (
<ToastProvider>
<LoginPage onAuthenticated={setAuthenticatedUser} />
</ToastProvider>
);
} }
/** Meldet den Benutzer ab und kehrt zur Anmeldemaske zurueck. */
async function handleLogout(): Promise<void> { async function handleLogout(): Promise<void> {
await logout(); await logout();
setAuthenticatedUser(null); setAuthenticatedUser(null);
@ -56,59 +67,44 @@ export function App(): React.JSX.Element {
const grantedPermissions = authenticatedUser.permissions ?? []; const grantedPermissions = authenticatedUser.permissions ?? [];
/** Wechselt die Seite und schliesst ein offenes Objekt. */
function handleNavigate(pageIdentifier: string): void {
setOpenObjectIdentifier(null);
navigateToPage(pageIdentifier);
}
return ( return (
<div className="app-shell"> <ToastProvider>
<header className="app-shell__header"> <AppShell
<span className="app-shell__brand">Syncova</span> currentPageId={currentPageId}
onNavigate={handleNavigate}
<div className="app-shell__account"> grantedPermissions={grantedPermissions}
<span className="app-shell__username">{authenticatedUser.username}</span> userName={authenticatedUser.username}
onLogout={() => void handleLogout()}
{/* Ein fehlender zweiter Faktor ist ein Sicherheitsbefund und wird >
benannt, statt ihn zu verschweigen (PROMPT.md §90). */} <CurrentPageContent
{!authenticatedUser.mfa_enabled && (
<span
className="app-shell__warning"
title="Fuer dieses Konto ist kein zweiter Faktor eingerichtet."
>
MFA fehlt
</span>
)}
<button className="app-shell__logout" type="button" onClick={() => void handleLogout()}>
Abmelden
</button>
</div>
</header>
<div className="app-shell__body">
<NavigationSidebar
currentPageId={currentPageId} currentPageId={currentPageId}
grantedPermissions={grantedPermissions} grantedPermissions={grantedPermissions}
onNavigate={navigateToPage} openObjectIdentifier={openObjectIdentifier}
onOpenObject={setOpenObjectIdentifier}
/> />
</AppShell>
<main className="app-shell__main"> </ToastProvider>
<CurrentPageContent currentPageId={currentPageId} grantedPermissions={grantedPermissions} />
</main>
</div>
</div>
); );
} }
/** Eigenschaften des Seiteninhalts. */
interface CurrentPageContentProperties {
/** Bezeichner der angezeigten Seite. */
readonly currentPageId: string;
/** Berechtigungen des angemeldeten Benutzers. */
readonly grantedPermissions: readonly string[];
}
/** Zeigt den Inhalt der gewaehlten Seite. */ /** Zeigt den Inhalt der gewaehlten Seite. */
function CurrentPageContent({ function CurrentPageContent({
currentPageId, currentPageId,
grantedPermissions, grantedPermissions,
}: CurrentPageContentProperties): React.JSX.Element { openObjectIdentifier,
onOpenObject,
}: {
readonly currentPageId: string;
readonly grantedPermissions: readonly string[];
readonly openObjectIdentifier: string | null;
readonly onOpenObject: (objectIdentifier: string | null) => void;
}): React.JSX.Element {
const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID); const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID);
if (pageDefinition === undefined) { if (pageDefinition === undefined) {
@ -120,57 +116,80 @@ function CurrentPageContent({
// Auskunft (PROMPT.md §42). // Auskunft (PROMPT.md §42).
if (!mayViewPage(pageDefinition, grantedPermissions)) { if (!mayViewPage(pageDefinition, grantedPermissions)) {
return ( return (
<section className="page"> <UnavailablePage
<header className="page__header"> title={pageDefinition.label}
<h1 className="page__title">{pageDefinition.label}</h1> reason="Ihrer Rolle fehlt die Berechtigung fuer diesen Bereich. Die Pruefung erfolgt auf dem Server; diese Anzeige verhindert nur den Umweg ueber eine Fehlermeldung."
</header> />
<div className="notice notice--warning">
<p className="notice__text">
Fuer diesen Bereich fehlt die Berechtigung {pageDefinition.requiredPermission}.
</p>
</div>
</section>
); );
} }
if (!pageDefinition.available) { if (!pageDefinition.available) {
return <UnavailablePage pageDefinition={pageDefinition} />; return (
<UnavailablePage
title={pageDefinition.label}
reason={pageDefinition.unavailableReason ?? 'Dieser Bereich ist noch nicht verfuegbar.'}
/>
);
} }
switch (pageDefinition.id) { switch (pageDefinition.id) {
case 'dashboard': case 'dashboard':
return ( return <DashboardPage />;
<>
<DashboardPage />
<SystemHealthPanel />
</>
);
case 'jobs': case 'jobs':
return <JobsPanel />; return openObjectIdentifier ? (
case 'metrics': <JobDetailPage
return <MetricsPage />; jobIdentifier={openObjectIdentifier}
case 'alerts': grantedPermissions={grantedPermissions}
return <AlertsPage />; onBack={() => onOpenObject(null)}
case 'security': />
return <SecurityPage />; ) : (
case 'reports': <JobsPage grantedPermissions={grantedPermissions} onOpenJob={onOpenObject} />
return <ReportsPage />; );
case 'recovery-points': case 'recovery-points':
return <RecoveryPointsPage />; return <RecoveryPointsPage />;
case 'restores': case 'restores':
return <RestoresPage />; return <RestoresPage />;
case 'repositories': case 'repositories':
return <RepositoriesPage />; return <RepositoriesPage />;
case 'protected-systems':
case 'agents': case 'agents':
return <AgentsPage />; return <AgentsPage />;
case 'events': case 'events':
return <EventsPage />; return <EventsPage />;
case 'alerts':
return <AlertsPage />;
case 'metrics':
return <MetricsPage />;
case 'reports':
return <ReportsPage />;
case 'security':
return <SecurityPage />;
case 'users': case 'users':
return <UsersPage />; return <UsersPage />;
case 'roles': case 'roles':
return <RolesPage />; return <RolesPage />;
default: default:
return <DashboardPage />; // Eine Seite steht im Verzeichnis, hat aber noch keine Umsetzung. Das
// ist kein Fehler, sondern der Ausbaustand — und wird als solcher
// benannt, statt eine leere Maske zu zeigen.
return (
<UnavailablePage
title={pageDefinition.label}
reason="Dieser Bereich wird gerade gebaut. Die zugehoerigen API-Endpunkte sind vorhanden; es fehlt die Bedienoberflaeche."
/>
);
} }
} }

View File

@ -15,6 +15,16 @@ const API_BASE_PATH = '/api/v1';
/** Header, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */ /** Header, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */
const CORRELATION_ID_HEADER = 'X-Correlation-ID'; const CORRELATION_ID_HEADER = 'X-Correlation-ID';
/**
* Header, der eine Anfrage genau einmal wirken laesst (SYNCOVA_API.md §5).
*
* 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
* dasselbe Ziel.
*/
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
/** /**
* Fehler einer API-Anfrage. * Fehler einer API-Anfrage.
* *
@ -91,6 +101,14 @@ export interface RequestOptions {
body?: unknown; body?: unknown;
/** Signal zum Abbrechen der Anfrage. */ /** Signal zum Abbrechen der Anfrage. */
signal?: AbortSignal; signal?: AbortSignal;
/**
* Schluessel fuer Idempotenz.
*
* `true` erzeugt einen zufaelligen Schluessel; eine Zeichenkette wird
* unveraendert verwendet, damit ein Wiederholungsversuch derselben Handlung
* denselben Schluessel traegt.
*/
idempotencyKey?: string | true;
} }
/** /**
@ -116,6 +134,13 @@ export async function requestApi<TPayload>(
requestHeaders['Content-Type'] = 'application/json'; requestHeaders['Content-Type'] = 'application/json';
} }
if (requestOptions.idempotencyKey !== undefined) {
requestHeaders[IDEMPOTENCY_KEY_HEADER] =
requestOptions.idempotencyKey === true
? createCorrelationId()
: requestOptions.idempotencyKey;
}
// Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen // Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen
// unauthentifiziert - der Server entscheidet dann ueber den Zugriff. // unauthentifiziert - der Server entscheidet dann ueber den Zugriff.
const accessToken = accessTokenProvider(); const accessToken = accessTokenProvider();

View File

@ -0,0 +1,181 @@
/**
* Haken fuer 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:
*
* 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.
* 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.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError } from './client';
export interface MutationState {
/** Laeuft die Anfrage gerade? */
readonly isRunning: boolean;
/** Fehler des letzten Versuchs, sonst null. */
readonly error: ApiError | null;
}
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. */
readonly run: (...callArguments: TArguments) => Promise<TResult | null>;
/** Verwirft eine stehende Fehlermeldung. */
readonly reset: () => void;
}
/**
* Baut einen Aufrufer fuer eine veraendernde API-Handlung.
*
* @param performRequest Die eigentliche Anfrage.
* @param callbacks Rueckmeldungen bei Erfolg und Fehler.
*/
export function useMutation<TArguments extends unknown[], TResult>(
performRequest: (...callArguments: TArguments) => Promise<TResult>,
callbacks: {
readonly onSuccess?: (result: TResult) => void;
readonly onError?: (error: ApiError) => void;
} = {},
): MutationResult<TArguments, TResult> {
const [mutationState, setMutationState] = useState<MutationState>({
isRunning: false,
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.
const callbacksReference = useRef(callbacks);
// Die Zuweisung gehoert in einen Effekt, nicht in den Renderkoerper: Waehrend
// des Renderns eine Referenz zu beschreiben ist ein Nebeneffekt, und React
// darf ein Rendern verwerfen oder zweimal ausfuehren.
useEffect(() => {
callbacksReference.current = callbacks;
});
const isMountedReference = useRef(true);
useEffect(() => {
isMountedReference.current = true;
return () => {
isMountedReference.current = false;
};
}, []);
const run = useCallback(
async (...callArguments: TArguments): Promise<TResult | null> => {
setMutationState({ isRunning: true, error: null });
try {
const result = await performRequest(...callArguments);
if (isMountedReference.current) {
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
// sich trotzdem aktualisieren.
callbacksReference.current.onSuccess?.(result);
return result;
} catch (caughtError) {
// Ein Abbruch ist kein Fehler des Anwenders.
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
if (isMountedReference.current) {
setMutationState({ isRunning: false, error: null });
}
return null;
}
const apiError =
caughtError instanceof ApiError
? caughtError
: new ApiError({
code: 'UNEXPECTED_ERROR',
message:
caughtError instanceof Error
? caughtError.message
: 'Ein unerwarteter Fehler ist aufgetreten.',
statusCode: 0,
requestId: '',
});
if (isMountedReference.current) {
setMutationState({ isRunning: false, error: apiError });
}
callbacksReference.current.onError?.(apiError);
return null;
}
},
[performRequest],
);
const reset = useCallback(() => {
setMutationState({ isRunning: false, error: null });
}, []);
return {
isRunning: mutationState.isRunning,
error: mutationState.error,
run,
reset,
};
}
/**
* Uebersetzt einen Serverfehler in einen Satz, den ein Betreiber versteht.
*
* Die Fehlercodes der API sind praezise und fuer Menschen unbrauchbar. Wo ein
* Code eine bekannte Lage beschreibt, steht hier die Erklaerung **samt Abhilfe**
* — der Rest faellt auf die Servermeldung zurueck, die ohnehin auf Deutsch
* verfasst ist.
*/
export function describeApiError(apiError: ApiError): string {
const knownExplanations: Readonly<Record<string, string>> = {
// Diese Lage entsteht bei einem zweiten Anstoss und ist kein Fehler des
// Anwenders — der Auftrag laeuft bereits.
JOB_ALREADY_RUNNING:
'Dieser Auftrag laeuft bereits. Ein zweiter Lauf wuerde in dieselbe Kette schreiben.',
RESTORE_ALREADY_RUNNING:
'In dieses Ziel laeuft bereits eine Wiederherstellung. Zwei gleichzeitige wuerden sich gegenseitig ueberschreiben.',
VERIFICATION_ALREADY_RUNNING: 'Dieses Backup wird bereits geprueft.',
TARGET_NOT_EMPTY:
'Das Zielverzeichnis ist nicht leer. Ueberschreiben verlangt die ausdrueckliche Bestaetigung.',
RESTORE_TARGET_FORBIDDEN:
'Der Zielpfad liegt in einem Systemverzeichnis. Dorthin wird nicht zurueckgeschrieben.',
LAST_ADMINISTRATOR:
'Das ist der letzte Administrator. Er laesst sich weder loeschen noch deaktivieren — sonst waere die Anlage nicht mehr verwaltbar.',
BUILTIN_ROLE_IMMUTABLE:
'Mitgelieferte Rollen sind unveraenderlich. Eine Aenderung verschoebe die Bedeutung bestehender Zuweisungen.',
REPOSITORY_IN_USE:
'Auf dieses Repository verweisen noch Auftraege oder Sicherungen.',
REPOSITORY_LOCKED:
'Das Repository ist gesperrt — vermutlich laeuft eine Sicherung. Haelt der Zustand an, hilft "syncova-repo break-lock".',
REPOSITORY_FULL:
'Auf dem Ziel ist kein Platz mehr. Ein Wiederholungslauf wuerde die Lage verschaerfen.',
IMMUTABLE_UNTIL:
'Dieser Wiederherstellungspunkt steht unter Aufbewahrungsschutz und laesst sich bis zum Fristende nicht loeschen.',
LEGAL_HOLD:
'Auf diesem Wiederherstellungspunkt liegt ein Legal Hold. Er muss zuerst aufgehoben werden.',
SERVICE_UNAVAILABLE:
'Der Dienst ist derzeit nicht vollstaendig verfuegbar. Das ist kein Problem Ihrer Sitzung.',
FORBIDDEN: 'Ihrer Rolle fehlt die Berechtigung fuer diese Handlung.',
};
return knownExplanations[apiError.code] ?? apiError.message;
}

View File

@ -0,0 +1,263 @@
/**
* 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.
*
* Auf schmalen Geraeten 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 { useEffect, useState, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui';
import {
ALL_PAGES,
SECTION_LABELS,
mayViewPage,
type PageDefinition,
} from '@/navigation/pages';
/** Speicherort der Themenwahl. */
const THEME_STORAGE_KEY = 'syncova.theme';
const SIDEBAR_STORAGE_KEY = 'syncova.sidebar-collapsed';
type ThemePreference = 'light' | 'dark';
/** Liest die gespeicherte Themenwahl, sonst die des Betriebssystems. */
function readInitialTheme(): ThemePreference {
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === 'light' || storedTheme === 'dark') {
return storedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function AppShell({
currentPageId,
onNavigate,
grantedPermissions,
userName,
onLogout,
children,
}: {
readonly currentPageId: string;
readonly onNavigate: (pageIdentifier: string) => void;
readonly grantedPermissions: readonly string[];
readonly userName: string;
readonly onLogout: () => void;
readonly children: ReactNode;
}) {
const [themePreference, setThemePreference] = useState<ThemePreference>(readInitialTheme);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(
() => window.localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'ja',
);
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.
useEffect(() => {
document.documentElement.setAttribute('data-theme', themePreference);
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
}, [themePreference]);
const visiblePages = ALL_PAGES.filter((page) => mayViewPage(page, grantedPermissions));
const currentPage = ALL_PAGES.find((page) => page.id === currentPageId);
return (
<div className="flex min-h-screen bg-page">
{/* Abdunklung hinter der Schublade auf schmalen Geraeten. */}
{isMobileMenuOpen ? (
<button
type="button"
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setIsMobileMenuOpen(false)}
aria-label="Menue schliessen"
/>
) : null}
<aside
className={cn(
'fixed inset-y-0 left-0 z-40 flex flex-col border-r border-line bg-card transition-[width,transform]',
isSidebarCollapsed ? 'w-16' : 'w-60',
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
)}
>
<div className="flex h-14 items-center gap-2 border-b border-line px-4">
<div className="flex size-7 shrink-0 items-center justify-center rounded bg-accent text-xs font-bold text-accent-fg">
S
</div>
{!isSidebarCollapsed ? (
<span className="truncate text-sm font-semibold text-fg">Syncova</span>
) : null}
<button
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"
>
<X className="size-4" />
</button>
</div>
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Hauptnavigation">
{SECTION_LABELS.map(([sectionKey, sectionLabel]) => {
const pagesInSection = visiblePages.filter((page) => page.section === sectionKey);
if (pagesInSection.length === 0) {
return null;
}
return (
<div key={sectionKey} className="mb-4 last:mb-0">
{!isSidebarCollapsed ? (
<p className="mb-1 px-2 text-[11px] font-medium uppercase tracking-wider text-fg-subtle">
{sectionLabel}
</p>
) : null}
<ul className="space-y-0.5">
{pagesInSection.map((page) => (
<li key={page.id}>
<NavigationItem
page={page}
isActive={page.id === currentPageId}
isCollapsed={isSidebarCollapsed}
onSelect={() => {
// Die Schublade schliesst sich beim Auswaehlen, nicht
// in einem Effekt auf den Seitenwechsel: Der Anlass
// ist die Handlung, nicht der neue Zustand.
setIsMobileMenuOpen(false);
onNavigate(page.id);
}}
/>
</li>
))}
</ul>
</div>
);
})}
</nav>
<div className="border-t border-line p-2">
<button
type="button"
onClick={() =>
setIsSidebarCollapsed((wasCollapsed) => {
const nextState = !wasCollapsed;
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, nextState ? 'ja' : 'nein');
return nextState;
})
}
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'}
>
{isSidebarCollapsed ? (
<PanelLeft className="size-4 shrink-0" />
) : (
<>
<PanelLeftClose className="size-4 shrink-0" />
<span>Einklappen</span>
</>
)}
</button>
</div>
</aside>
<div
className={cn(
'flex min-w-0 flex-1 flex-col transition-[margin]',
isSidebarCollapsed ? 'lg:ml-16' : 'lg:ml-60',
)}
>
<header className="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-line bg-card/95 px-4 backdrop-blur">
<button
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"
>
<Menu className="size-5" />
</button>
<h1 className="truncate text-sm font-semibold text-fg">
{currentPage?.label ?? 'Syncova'}
</h1>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() =>
setThemePreference((currentTheme) =>
currentTheme === 'dark' ? 'light' : 'dark',
)
}
title={themePreference === 'dark' ? 'Helles Thema' : 'Dunkles Thema'}
aria-label="Thema wechseln"
>
{themePreference === 'dark' ? <Sun /> : <Moon />}
</Button>
<span className="hidden max-w-40 truncate px-2 text-sm text-fg-muted sm:inline">
{userName}
</span>
<Button variant="ghost" size="sm" onClick={onLogout}>
Abmelden
</Button>
</div>
</header>
<main className="min-w-0 flex-1 p-4 lg:p-6">{children}</main>
</div>
</div>
);
}
function NavigationItem({
page,
isActive,
isCollapsed,
onSelect,
}: {
readonly page: PageDefinition;
readonly isActive: boolean;
readonly isCollapsed: boolean;
readonly onSelect: () => void;
}) {
const IconComponent = page.icon;
return (
<button
type="button"
onClick={onSelect}
title={isCollapsed ? page.label : undefined}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors',
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.
!page.available && 'opacity-60',
)}
>
<IconComponent className="size-4 shrink-0" aria-hidden />
{!isCollapsed ? (
<>
<span className="truncate">{page.label}</span>
{!page.available ? (
<span className="ml-auto text-[10px] text-fg-subtle">bald</span>
) : null}
</>
) : null}
</button>
);
}

View File

@ -0,0 +1,42 @@
/** Kopfbereich einer Seite: Titel, Erlaeuterung, Handlungen. */
import { ChevronLeft } from 'lucide-react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui';
import { cn } from '@/lib/utils';
export function PageHeader({
title,
description,
actions,
onBack,
backLabel = 'Zurueck',
className,
}: {
readonly title: ReactNode;
readonly description?: ReactNode | undefined;
readonly actions?: ReactNode | undefined;
readonly onBack?: () => void | undefined;
readonly backLabel?: string | undefined;
readonly className?: string | undefined;
}) {
return (
<div className={cn('mb-5', className)}>
{onBack ? (
<Button variant="ghost" size="sm" className="-ml-2 mb-2" onClick={onBack}>
<ChevronLeft />
{backLabel}
</Button>
) : null}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-xl font-semibold tracking-tight text-fg">{title}</h2>
{description ? (
<p className="mt-1 max-w-3xl text-sm text-fg-muted">{description}</p>
) : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap gap-2">{actions}</div> : null}
</div>
</div>
);
}

View File

@ -0,0 +1,79 @@
/**
* Schaltflaeche.
*
* 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.
*/
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
// Gemeinsame Grundlage aller Auspraegungen.
[
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md',
'text-sm font-medium transition-colors',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
].join(' '),
{
variants: {
variant: {
primary: 'bg-accent text-accent-fg hover:bg-accent-hover',
secondary: 'border border-line-strong bg-card text-fg hover:bg-hover',
ghost: 'text-fg-muted hover:bg-hover hover:text-fg',
destructive:
'border border-critical bg-critical text-white hover:brightness-110 dark:text-fg-inverted',
link: 'text-accent underline-offset-4 hover:underline',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-9 px-4',
lg: 'h-10 px-6',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'secondary',
size: 'md',
},
},
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
/** Rendert die Kindkomponente statt eines <button> (z. B. einen Link). */
readonly asChild?: boolean | undefined;
/** Zeigt einen Ladeanzeiger und sperrt die Schaltflaeche. */
readonly isLoading?: boolean | undefined;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, variant, size, asChild = false, isLoading = false, children, disabled, ...restProps },
forwardedReference,
) {
const RenderedComponent = asChild ? Slot : 'button';
return (
<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.
disabled={disabled || isLoading}
{...restProps}
>
{isLoading ? <Loader2 className="animate-spin" aria-hidden /> : null}
{children}
</RenderedComponent>
);
});
export { buttonVariants };

View File

@ -0,0 +1,59 @@
/** Karte — die Grundflaeche aller Inhalte. */
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils';
export function Card({ className, ...restProps }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('rounded-lg border border-line bg-card', className)}
{...restProps}
/>
);
}
export function CardHeader({
className,
title,
description,
action,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
readonly title?: ReactNode | undefined;
readonly description?: ReactNode | undefined;
readonly action?: ReactNode | undefined;
}) {
return (
<div
className={cn(
'flex items-start justify-between gap-4 border-b border-line px-5 py-4',
className,
)}
{...restProps}
>
<div className="min-w-0">
{title ? <h2 className="text-sm font-semibold text-fg">{title}</h2> : null}
{description ? (
<p className="mt-1 text-sm text-fg-muted">{description}</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}
export function CardContent({ className, ...restProps }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('px-5 py-4', className)} {...restProps} />;
}
export function CardFooter({ className, ...restProps }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'flex items-center justify-end gap-2 border-t border-line px-5 py-3',
className,
)}
{...restProps}
/>
);
}

View File

@ -0,0 +1,141 @@
/**
* Tabelle.
*
* Eine Betriebskonsole besteht zu grossen Teilen aus Tabellen, deshalb steckt
* die Muehe 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
* jedem Filterwechsel aufblitzen.
* - **Breite Tabellen laufen in ihrem eigenen Rahmen**, nicht auf der Seite.
*/
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
export interface TableColumn<RowType> {
readonly key: string;
readonly header: ReactNode;
readonly render: (row: RowType) => ReactNode;
/** Rechtsbuendig — fuer Zahlen. */
readonly numeric?: boolean | undefined;
readonly className?: string | undefined;
}
export function DataTable<RowType>({
columns,
rows,
getRowKey,
onRowClick,
isLoading = false,
emptyTitle = 'Keine Eintraege',
emptyDescription,
emptyAction,
className,
}: {
readonly columns: ReadonlyArray<TableColumn<RowType>>;
readonly rows: readonly RowType[];
readonly getRowKey: (row: RowType) => string;
readonly onRowClick?: (row: RowType) => void | undefined;
readonly isLoading?: boolean | undefined;
readonly emptyTitle?: ReactNode | undefined;
readonly emptyDescription?: ReactNode | undefined;
readonly emptyAction?: ReactNode | undefined;
readonly className?: string | undefined;
}) {
if (rows.length === 0 && !isLoading) {
return (
<div className="flex flex-col items-center justify-center px-6 py-14 text-center">
<p className="text-sm font-medium text-fg">{emptyTitle}</p>
{emptyDescription ? (
<p className="mt-1 max-w-md text-sm text-fg-muted">{emptyDescription}</p>
) : null}
{emptyAction ? <div className="mt-4">{emptyAction}</div> : null}
</div>
);
}
return (
<div className={cn('w-full overflow-x-auto', className)}>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line">
{columns.map((column) => (
<th
key={column.key}
scope="col"
className={cn(
'whitespace-nowrap px-4 py-2.5 text-xs font-medium uppercase tracking-wide text-fg-subtle',
column.numeric ? 'text-right' : 'text-left',
column.className,
)}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody
className={cn(
'transition-opacity',
// Beim Nachladen abblenden statt ersetzen — sonst blitzt die
// Tabelle bei jedem Filterwechsel auf.
isLoading && rows.length > 0 ? 'opacity-50' : 'opacity-100',
)}
>
{rows.map((row) => (
<tr
key={getRowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
// Eine anklickbare Zeile muss auch mit der Tastatur erreichbar
// sein; sonst ist die halbe Konsole ohne Maus unbedienbar.
tabIndex={onRowClick ? 0 : undefined}
role={onRowClick ? 'button' : undefined}
onKeyDown={
onRowClick
? (keyboardEvent) => {
if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') {
keyboardEvent.preventDefault();
onRowClick(row);
}
}
: undefined
}
className={cn(
'border-b border-line/60 last:border-0',
onRowClick && 'cursor-pointer hover:bg-hover',
)}
>
{columns.map((column) => (
<td
key={column.key}
className={cn(
'px-4 py-2.5 align-middle text-fg',
column.numeric && 'tabular text-right',
column.className,
)}
>
{column.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
{isLoading && rows.length === 0 ? (
<div className="space-y-2 px-4 py-4">
{[0, 1, 2, 3, 4].map((placeholderIndex) => (
<div
key={placeholderIndex}
className="h-8 animate-pulse rounded bg-sunken"
/>
))}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,244 @@
/**
* Dialoge.
*
* 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
* versehentlich gesetztes Kennzeichen in einem Skript reicht dann nicht aus,
* und ein Klick aus Gewohnheit auch nicht.
*/
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Button } from './Button';
export const DialogRoot = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
export function DialogContent({
className,
children,
size = 'md',
}: {
readonly className?: string | undefined;
readonly children: ReactNode;
readonly size?: 'sm' | 'md' | 'lg' | 'xl' | undefined;
}) {
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
} as const;
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-[1px] data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2',
'max-h-[calc(100vh-4rem)] overflow-y-auto',
'rounded-lg border border-line bg-card shadow-xl',
sizeClasses[size],
className,
)}
>
{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"
>
<X className="size-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
export function DialogHeader({
title,
description,
}: {
readonly title: ReactNode;
readonly description?: ReactNode | undefined;
}) {
return (
<div className="border-b border-line px-5 py-4 pr-12">
<DialogPrimitive.Title className="text-base font-semibold text-fg">
{title}
</DialogPrimitive.Title>
{description ? (
<DialogPrimitive.Description className="mt-1 text-sm text-fg-muted">
{description}
</DialogPrimitive.Description>
) : null}
</div>
);
}
export function DialogBody({
className,
children,
}: {
readonly className?: string | undefined;
readonly children: ReactNode;
}) {
return <div className={cn('space-y-4 px-5 py-4', className)}>{children}</div>;
}
export function DialogFooter({ children }: { readonly children: ReactNode }) {
return (
<div className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
{children}
</div>
);
}
/**
* Bestaetigungsdialog fuer veraendernde 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.
*/
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Bestaetigen',
cancelLabel = 'Abbrechen',
confirmationWord,
confirmationHint,
isDestructive = false,
isLoading = false,
onConfirm,
children,
}: {
readonly open: boolean;
readonly onOpenChange: (isOpen: boolean) => void;
readonly title: ReactNode;
readonly description?: ReactNode | undefined;
readonly confirmLabel?: string | undefined;
readonly cancelLabel?: string | undefined;
/** Wird gesetzt, muss der Betreiber dieses Wort woertlich eintippen. */
readonly confirmationWord?: string | undefined;
readonly confirmationHint?: ReactNode | undefined;
readonly isDestructive?: boolean | undefined;
readonly isLoading?: boolean | undefined;
readonly onConfirm: () => void | Promise<void>;
readonly children?: ReactNode | undefined;
}) {
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. */}
<ConfirmDialogBody
title={title}
description={description}
confirmLabel={confirmLabel}
cancelLabel={cancelLabel}
confirmationWord={confirmationWord}
confirmationHint={confirmationHint}
isDestructive={isDestructive}
isLoading={isLoading}
onCancel={() => onOpenChange(false)}
onConfirm={onConfirm}
>
{children}
</ConfirmDialogBody>
</DialogContent>
</DialogRoot>
);
}
/** Inhalt des Bestaetigungsdialogs; haelt die eingetippte Bestaetigung. */
function ConfirmDialogBody({
title,
description,
confirmLabel,
cancelLabel,
confirmationWord,
confirmationHint,
isDestructive,
isLoading,
onCancel,
onConfirm,
children,
}: {
readonly title: ReactNode;
readonly description?: ReactNode | undefined;
readonly confirmLabel: string;
readonly cancelLabel: string;
readonly confirmationWord?: string | undefined;
readonly confirmationHint?: ReactNode | undefined;
readonly isDestructive: boolean;
readonly isLoading: boolean;
readonly onCancel: () => void;
readonly onConfirm: () => void | Promise<void>;
readonly children?: ReactNode | undefined;
}) {
const [typedConfirmation, setTypedConfirmation] = useState('');
const confirmationSatisfied =
!confirmationWord || typedConfirmation.trim() === confirmationWord;
return (
<>
<DialogHeader title={title} description={description} />
<DialogBody>
{children}
{confirmationWord ? (
<div className="space-y-2">
<label
htmlFor="confirmation-input"
className="block text-sm font-medium text-fg"
>
{confirmationHint ?? (
<>
Tippen Sie zur Bestaetigung{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{confirmationWord}
</code>
</>
)}
</label>
<input
id="confirmation-input"
type="text"
autoComplete="off"
value={typedConfirmation}
onChange={(changeEvent) => setTypedConfirmation(changeEvent.target.value)}
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 font-mono text-sm text-fg"
/>
</div>
) : null}
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={onCancel} disabled={isLoading}>
{cancelLabel}
</Button>
<Button
variant={isDestructive ? 'destructive' : 'primary'}
onClick={() => void onConfirm()}
disabled={!confirmationSatisfied}
isLoading={isLoading}
>
{confirmLabel}
</Button>
</DialogFooter>
</>
);
}

View File

@ -0,0 +1,156 @@
/**
* Zustandsanzeigen: Laden, Fehler, Leere, Hinweise.
*
* `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.
*/
import { AlertTriangle, Info, Loader2, TriangleAlert } from 'lucide-react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Button } from './Button';
export function LoadingState({ label = 'Wird geladen …' }: { readonly label?: string }) {
return (
<div className="flex items-center justify-center gap-2 px-6 py-14 text-sm text-fg-muted">
<Loader2 className="size-4 animate-spin" aria-hidden />
{label}
</div>
);
}
export function ErrorState({
title = 'Die Anfrage ist fehlgeschlagen',
message,
requestId,
onRetry,
className,
}: {
readonly title?: string | undefined;
readonly message?: string | undefined;
readonly requestId?: string | undefined;
readonly onRetry?: () => void | undefined;
readonly className?: string | undefined;
}) {
return (
<div
className={cn(
'flex flex-col items-center justify-center px-6 py-12 text-center',
className,
)}
role="alert"
>
<TriangleAlert className="size-6 text-critical" aria-hidden />
<p className="mt-3 text-sm font-medium text-fg">{title}</p>
{message ? <p className="mt-1 max-w-lg text-sm text-fg-muted">{message}</p> : null}
{requestId ? (
<p className="mt-3 font-mono text-xs text-fg-subtle">
Vorgangsnummer: {requestId}
</p>
) : null}
{onRetry ? (
<Button variant="secondary" size="sm" className="mt-4" onClick={onRetry}>
Erneut versuchen
</Button>
) : null}
</div>
);
}
export function EmptyState({
title,
description,
action,
icon,
}: {
readonly title: ReactNode;
readonly description?: ReactNode | undefined;
readonly action?: ReactNode | undefined;
readonly icon?: ReactNode | undefined;
}) {
return (
<div className="flex flex-col items-center justify-center px-6 py-14 text-center">
{icon ? <div className="mb-3 text-fg-subtle">{icon}</div> : null}
<p className="text-sm font-medium text-fg">{title}</p>
{description ? (
<p className="mt-1 max-w-md text-sm text-fg-muted">{description}</p>
) : null}
{action ? <div className="mt-4">{action}</div> : null}
</div>
);
}
/**
* Hinweis fuer eine Kennzahl ohne Datengrundlage.
*
* Der Grund gehoert dazu. "—" allein sieht nach einem Fehler der Oberflaeche
* aus; "nicht gemessen, weil kein Wiederherstellungstest lief" ist eine
* Handlungsanweisung.
*/
export function NotMeasured({ reason }: { readonly reason: ReactNode }) {
return (
<span className="inline-flex items-center gap-1.5 text-sm text-fg-muted">
<Info className="size-3.5 shrink-0" aria-hidden />
{reason}
</span>
);
}
/** Auffaelliger Hinweiskasten. */
export function Callout({
tone = 'info',
title,
children,
className,
}: {
readonly tone?: 'info' | 'warning' | 'critical' | 'healthy' | undefined;
readonly title?: ReactNode | undefined;
readonly children?: ReactNode | undefined;
readonly className?: string | undefined;
}) {
const toneClasses = {
info: 'border-info/30 bg-info-bg text-info',
warning: 'border-warning/30 bg-warning-bg text-warning',
critical: 'border-critical/30 bg-critical-bg text-critical',
healthy: 'border-healthy/30 bg-healthy-bg text-healthy',
} as const;
const IconComponent = tone === 'info' ? Info : tone === 'healthy' ? Info : AlertTriangle;
return (
<div className={cn('rounded-md border p-3', toneClasses[tone], className)} role="note">
<div className="flex gap-2.5">
<IconComponent className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="min-w-0 text-sm">
{title ? <p className="font-medium">{title}</p> : null}
{children ? (
<div className={cn('text-fg', title && 'mt-1')}>{children}</div>
) : null}
</div>
</div>
</div>
);
}
/** Beschriftetes Wertepaar fuer Detailseiten. */
export function DetailItem({
label,
children,
className,
}: {
readonly label: ReactNode;
readonly children: ReactNode;
readonly className?: string | undefined;
}) {
return (
<div className={cn('min-w-0', className)}>
<dt className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
{label}
</dt>
<dd className="mt-1 text-sm text-fg">{children}</dd>
</div>
);
}

View File

@ -0,0 +1,193 @@
/**
* Formularelemente.
*
* Jedes Feld traegt 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
* seiner Eingabe tut.
*/
import { forwardRef, useId, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
const fieldClasses = [
'w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg',
'placeholder:text-fg-subtle',
'disabled:cursor-not-allowed disabled:opacity-60',
].join(' ');
export function Field({
label,
hint,
error,
required,
htmlFor,
children,
className,
}: {
readonly label: ReactNode;
readonly hint?: ReactNode | undefined;
readonly error?: ReactNode | undefined;
readonly required?: boolean | undefined;
readonly htmlFor?: string | undefined;
readonly children: ReactNode;
readonly className?: string | undefined;
}) {
return (
<div className={cn('space-y-1.5', className)}>
<label htmlFor={htmlFor} className="block text-sm font-medium text-fg">
{label}
{required ? (
<span className="ml-1 text-critical" aria-label="Pflichtfeld">
*
</span>
) : null}
</label>
{children}
{error ? (
<p className="text-xs text-critical">{error}</p>
) : hint ? (
<p className="text-xs text-fg-muted">{hint}</p>
) : null}
</div>
);
}
export const TextInput = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement> & {
readonly label?: ReactNode | undefined;
readonly hint?: ReactNode | undefined;
readonly error?: ReactNode | undefined;
}
>(function TextInput({ label, hint, error, className, id, ...restProps }, forwardedReference) {
const generatedId = useId();
const inputId = id ?? generatedId;
const inputElement = (
<input
ref={forwardedReference}
id={inputId}
className={cn(fieldClasses, error && 'border-critical', className)}
aria-invalid={error ? true : undefined}
{...restProps}
/>
);
if (!label) {
return inputElement;
}
return (
<Field label={label} hint={hint} error={error} required={restProps.required} htmlFor={inputId}>
{inputElement}
</Field>
);
});
export const TextArea = forwardRef<
HTMLTextAreaElement,
TextareaHTMLAttributes<HTMLTextAreaElement> & {
readonly label?: ReactNode | undefined;
readonly hint?: ReactNode | undefined;
readonly error?: ReactNode | undefined;
}
>(function TextArea({ label, hint, error, className, id, ...restProps }, forwardedReference) {
const generatedId = useId();
const fieldId = id ?? generatedId;
const textAreaElement = (
<textarea
ref={forwardedReference}
id={fieldId}
className={cn(fieldClasses, 'min-h-20 resize-y', error && 'border-critical', className)}
{...restProps}
/>
);
if (!label) {
return textAreaElement;
}
return (
<Field label={label} hint={hint} error={error} required={restProps.required} htmlFor={fieldId}>
{textAreaElement}
</Field>
);
});
export const SelectInput = forwardRef<
HTMLSelectElement,
SelectHTMLAttributes<HTMLSelectElement> & {
readonly label?: ReactNode | undefined;
readonly hint?: ReactNode | undefined;
readonly error?: ReactNode | undefined;
}
>(function SelectInput({ label, hint, error, className, id, children, ...restProps }, forwardedReference) {
const generatedId = useId();
const fieldId = id ?? generatedId;
const selectElement = (
<select
ref={forwardedReference}
id={fieldId}
className={cn(fieldClasses, 'pr-8', error && 'border-critical', className)}
{...restProps}
>
{children}
</select>
);
if (!label) {
return selectElement;
}
return (
<Field label={label} hint={hint} error={error} required={restProps.required} htmlFor={fieldId}>
{selectElement}
</Field>
);
});
/** Kontrollkaestchen mit Beschriftung und Erlaeuterung. */
export function CheckboxField({
checked,
onCheckedChange,
label,
hint,
disabled,
id,
}: {
readonly checked: boolean;
readonly onCheckedChange: (isChecked: boolean) => void;
readonly label: ReactNode;
readonly hint?: ReactNode | undefined;
readonly disabled?: boolean | undefined;
readonly id?: string | undefined;
}) {
const generatedId = useId();
const fieldId = id ?? generatedId;
return (
<div className="flex gap-2.5">
<input
id={fieldId}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(changeEvent) => onCheckedChange(changeEvent.target.checked)}
className="mt-0.5 size-4 shrink-0 rounded border-line-strong accent-[hsl(var(--accent))]"
/>
<div className="min-w-0">
<label
htmlFor={fieldId}
className={cn('block text-sm text-fg', disabled && 'opacity-60')}
>
{label}
</label>
{hint ? <p className="mt-0.5 text-xs text-fg-muted">{hint}</p> : null}
</div>
</div>
);
}

View File

@ -0,0 +1,188 @@
/**
* Statusanzeige — die wichtigste Komponente der Konsole.
*
* Sie bildet die Fachbegriffe der API auf die fuenf 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.
*
* 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
* (Phase 10).
* - "corrupted" ist kritisch, nicht nur eine Warnung: Ein beschaedigtes Backup
* ist nicht zu 70 % wiederherstellbar, sondern gar nicht.
* - "advisory" beim Loeschschutz 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. */
export type StatusTone = 'healthy' | 'warning' | 'high' | 'critical' | 'info' | 'neutral';
interface StatusDefinition {
readonly tone: StatusTone;
readonly label: string;
}
/**
* 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
* noch nicht kennt, darf nicht als "in Ordnung" durchgehen.
*/
const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
// --- Laeufe ---
succeeded: { tone: 'healthy', label: 'Erfolgreich' },
running: { tone: 'info', label: 'Laeuft' },
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 ---
enabled: { tone: 'healthy', label: 'Aktiv' },
paused: { tone: 'warning', label: 'Angehalten' },
disabled: { tone: 'neutral', label: 'Deaktiviert' },
// --- Einstufung der Wiederherstellungspunkte (Phase 10) ---
recoverable: { tone: 'healthy', label: 'Wiederherstellbar' },
verified: { tone: 'info', label: 'Geprueft' },
unverified: { tone: 'neutral', label: 'Ungeprueft' },
corrupted: { tone: 'critical', label: 'Beschaedigt' },
// --- Pruefergebnisse ---
clean: { tone: 'healthy', label: 'Ohne Befund' },
findings: { tone: 'critical', label: 'Mit Befund' },
// --- Repositories ---
active: { tone: 'healthy', label: 'Aktiv' },
read_only: { tone: 'warning', label: 'Nur lesend' },
unavailable: { tone: 'critical', label: 'Nicht erreichbar' },
maintenance: { tone: 'warning', label: 'Wartung' },
// --- Durchsetzungsstufe des Loeschschutzes (Phase 11) ---
filesystem: { tone: 'healthy', label: 'Dateisystem' },
storage: { tone: 'healthy', label: 'Speichersystem' },
advisory: { tone: 'warning', label: 'Nur Software' },
none: { tone: 'critical', label: 'Kein Schutz' },
// --- Gesundheit ---
healthy: { tone: 'healthy', label: 'Gesund' },
degraded: { tone: 'warning', label: 'Beeintraechtigt' },
critical: { tone: 'critical', label: 'Kritisch' },
unknown: { tone: 'neutral', label: 'Unbekannt' },
// --- Meldungen ---
open: { tone: 'critical', label: 'Offen' },
acknowledged: { tone: 'warning', label: 'Bestaetigt' },
resolved: { tone: 'healthy', label: 'Erledigt' },
suppressed: { tone: 'neutral', label: 'Unterdrueckt' },
// --- Schweregrade ---
info: { tone: 'info', label: 'Information' },
low: { tone: 'info', label: 'Gering' },
medium: { tone: 'warning', label: 'Mittel' },
warning: { tone: 'warning', label: 'Warnung' },
high: { tone: 'high', label: 'Hoch' },
// --- Agenten ---
online: { tone: 'healthy', label: 'Verbunden' },
offline: { tone: 'critical', label: 'Nicht verbunden' },
stale: { tone: 'warning', label: 'Veraltet' },
revoked: { tone: 'neutral', label: 'Gesperrt' },
// --- Ransomware-Einstufung (Phase 16) ---
elevated: { tone: 'warning', label: 'Auffaellig' },
// "unknown" oben deckt den Fall unter fuenf Vergleichslaeufen mit ab.
};
const toneClasses: Readonly<Record<StatusTone, string>> = {
healthy: 'bg-healthy-bg text-healthy',
warning: 'bg-warning-bg text-warning',
high: 'bg-high-bg text-high',
critical: 'bg-critical-bg text-critical',
info: 'bg-info-bg text-info',
neutral: 'bg-neutral-bg text-neutral',
};
/** Liefert Ton und Beschriftung zu einem Fachbegriff. */
export function resolveStatus(rawStatus: string | null | undefined): StatusDefinition {
if (!rawStatus) {
return { tone: 'neutral', label: 'Unbekannt' };
}
const normalizedStatus = rawStatus.toLowerCase();
return (
statusDefinitions[normalizedStatus] ?? {
// Unbekannt bleibt neutral. Gruen waere hier der bequeme und gefaehrliche
// Standard: Ein neuer Fehlerzustand des Servers saehe aus wie Erfolg.
tone: 'neutral',
label: rawStatus,
}
);
}
export function StatusBadge({
status,
label,
className,
showDot = true,
}: {
readonly status: string | null | undefined;
/** Ueberschreibt die Beschriftung, der Ton bleibt. */
readonly label?: ReactNode | undefined;
readonly className?: string | undefined;
readonly showDot?: boolean | undefined;
}) {
const resolved = resolveStatus(status);
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium',
toneClasses[resolved.tone],
className,
)}
>
{showDot ? (
<span className="size-1.5 rounded-full bg-current" aria-hidden />
) : null}
{label ?? resolved.label}
</span>
);
}
/** Nur der farbige Punkt — fuer dichte Tabellen. */
export function StatusDot({
status,
className,
}: {
readonly status: string | null | undefined;
readonly className?: string | undefined;
}) {
const resolved = resolveStatus(status);
return (
<span
className={cn('inline-flex items-center gap-2', className)}
title={resolved.label}
>
<span
className={cn('size-2 rounded-full', toneClasses[resolved.tone].split(' ')[1])}
style={{ backgroundColor: 'currentColor' }}
aria-hidden
/>
<span className="sr-only">{resolved.label}</span>
</span>
);
}

View File

@ -0,0 +1,176 @@
/**
* Kurzmeldungen nach einer Handlung.
*
* 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
* abgetippt wird und dabei ein Zeichen verlorengeht.
*
* Erfolgsmeldungen verschwinden von selbst, Fehlermeldungen **nicht**. Wer
* gerade wegsieht, waehrend eine Loeschung fehlschlaegt, haette sonst keine
* Chance, es zu bemerken.
*/
import { Check, Copy, X } from 'lucide-react';
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react';
import { cn } from '@/lib/utils';
export interface ToastMessage {
readonly id: string;
readonly tone: 'success' | 'error' | 'info';
readonly title: string;
readonly description?: string | undefined;
readonly requestId?: string | undefined;
}
interface ToastContextValue {
readonly showSuccess: (title: string, description?: string) => void;
readonly showError: (title: string, description?: string, requestId?: string) => void;
readonly showInfo: (title: string, description?: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
/** Zugriff auf die Kurzmeldungen. */
export function useToast(): ToastContextValue {
const contextValue = useContext(ToastContext);
if (!contextValue) {
throw new Error('useToast benoetigt einen ToastProvider im Elternbaum.');
}
return contextValue;
}
export function ToastProvider({ children }: { readonly children: ReactNode }) {
const [activeMessages, setActiveMessages] = useState<readonly ToastMessage[]>([]);
const removeMessage = useCallback((messageId: string) => {
setActiveMessages((previousMessages) =>
previousMessages.filter((message) => message.id !== messageId),
);
}, []);
const addMessage = useCallback(
(message: Omit<ToastMessage, 'id'>) => {
const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setActiveMessages((previousMessages) => [
...previousMessages,
{ ...message, id: messageId },
]);
// Fehler bleiben stehen, bis sie jemand schliesst. Alles andere
// verschwindet nach fuenf Sekunden.
if (message.tone !== 'error') {
window.setTimeout(() => removeMessage(messageId), 5000);
}
},
[removeMessage],
);
const contextValue = useMemo<ToastContextValue>(
() => ({
showSuccess: (title, description) => addMessage({ tone: 'success', title, description }),
showError: (title, description, requestId) =>
addMessage({ tone: 'error', title, description, requestId }),
showInfo: (title, description) => addMessage({ tone: 'info', title, description }),
}),
[addMessage],
);
return (
<ToastContext.Provider value={contextValue}>
{children}
<div
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex w-[min(26rem,calc(100vw-2rem))] flex-col gap-2"
role="status"
aria-live="polite"
>
{activeMessages.map((message) => (
<ToastCard
key={message.id}
message={message}
onDismiss={() => removeMessage(message.id)}
/>
))}
</div>
</ToastContext.Provider>
);
}
function ToastCard({
message,
onDismiss,
}: {
readonly message: ToastMessage;
readonly onDismiss: () => void;
}) {
const [hasCopied, setHasCopied] = useState(false);
const toneClasses = {
success: 'border-l-healthy',
error: 'border-l-critical',
info: 'border-l-info',
} as const;
const copyRequestId = async () => {
if (!message.requestId) {
return;
}
try {
await navigator.clipboard.writeText(message.requestId);
setHasCopied(true);
window.setTimeout(() => setHasCopied(false), 2000);
} catch {
// Ohne Zwischenablage-Recht bleibt die Kennung lesbar am Bildschirm —
// das ist der Zweck, das Kopieren ist die Bequemlichkeit.
}
};
return (
<div
className={cn(
'pointer-events-auto rounded-lg border border-l-4 border-line bg-raised p-3 shadow-lg',
toneClasses[message.tone],
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-fg">{message.title}</p>
{message.description ? (
<p className="mt-0.5 text-sm text-fg-muted">{message.description}</p>
) : null}
{message.requestId ? (
<button
type="button"
onClick={() => void copyRequestId()}
className="mt-2 inline-flex items-center gap-1.5 rounded bg-sunken px-2 py-1 font-mono text-[11px] text-fg-muted transition-colors hover:text-fg"
title="Vorgangsnummer kopieren"
>
{hasCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
{message.requestId}
</button>
) : null}
</div>
<button
type="button"
onClick={onDismiss}
className="shrink-0 rounded p-0.5 text-fg-subtle transition-colors hover:text-fg"
aria-label="Meldung schliessen"
>
<X className="size-4" />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,19 @@
/** Sammelausfuhr der Bausteine — ein Importpfad statt zehn. */
export { Button, buttonVariants, type ButtonProps } from './Button';
export { Card, CardHeader, CardContent, CardFooter } from './Card';
export { DataTable, type TableColumn } from './DataTable';
export {
ConfirmDialog,
DialogBody,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTrigger,
} from './Dialog';
export { Callout, DetailItem, EmptyState, ErrorState, LoadingState, NotMeasured } from './Feedback';
export { CheckboxField, Field, SelectInput, TextArea, TextInput } from './Form';
export { resolveStatus, StatusBadge, StatusDot, type StatusTone } from './StatusBadge';
export { ToastProvider, useToast, type ToastMessage } from './Toast';

View File

@ -0,0 +1,454 @@
/**
* Einzelheiten eines Auftrags samt Laufhistorie.
*
* 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
* 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.
*/
import { Ban, Pause, Play, RefreshCw } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError, useMutation } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Button,
Callout,
Card,
CardContent,
CardHeader,
DataTable,
DetailItem,
ErrorState,
LoadingState,
StatusBadge,
useToast,
type TableColumn,
} from '@/components/ui';
import {
formatBytes,
formatDateTime,
formatDuration,
formatRelativeTime,
formatThroughput,
} from '@/lib/utils';
import {
cancelJobRun,
getJob,
listJobRuns,
pauseJob,
resumeJob,
runJob,
type BackupJobRun,
} from './jobsApi';
export function JobDetailPage({
jobIdentifier,
grantedPermissions,
onBack,
}: {
readonly jobIdentifier: string;
readonly grantedPermissions: readonly string[];
readonly onBack: () => void;
}) {
const toast = useToast();
const [selectedRun, setSelectedRun] = useState<BackupJobRun | null>(null);
const jobResource = useApiResource(
useCallback((abortSignal) => getJob(jobIdentifier, abortSignal), [jobIdentifier]),
jobIdentifier,
);
const runsResource = useApiResource(
useCallback((abortSignal) => listJobRuns(jobIdentifier, abortSignal), [jobIdentifier]),
jobIdentifier,
);
const mayRunJobs =
grantedPermissions.includes('*') || grantedPermissions.includes('jobs.run');
const reloadEverything = () => {
jobResource.reload();
runsResource.reload();
};
const runMutation = useMutation(runJob, {
onSuccess: () => {
toast.showSuccess('Lauf eingereiht');
reloadEverything();
},
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),
});
const pauseMutation = useMutation(pauseJob, {
onSuccess: () => {
toast.showSuccess('Auftrag angehalten');
reloadEverything();
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
});
const resumeMutation = useMutation(resumeJob, {
onSuccess: () => {
toast.showSuccess('Auftrag fortgesetzt');
reloadEverything();
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
});
const cancelRunMutation = useMutation(cancelJobRun, {
onSuccess: () => {
toast.showSuccess('Abbruch angefordert', 'Der Lauf endet, sobald die laufende Stufe abschliesst.');
reloadEverything();
},
onError: (apiError) =>
toast.showError('Der Lauf liess sich nicht abbrechen', describeApiError(apiError), apiError.requestId),
});
if (jobResource.loadState === 'loading') {
return <LoadingState />;
}
if (jobResource.loadState === 'failed' || !jobResource.data) {
return (
<>
<PageHeader title="Auftrag" onBack={onBack} backLabel="Zu den Auftraegen" />
<Card>
<ErrorState
message={
jobResource.loadError ? describeApiError(jobResource.loadError) : undefined
}
requestId={jobResource.loadError?.requestId}
onRetry={jobResource.reload}
/>
</Card>
</>
);
}
const job = jobResource.data;
const runs = runsResource.data ?? [];
const runColumns: ReadonlyArray<TableColumn<BackupJobRun>> = [
{
key: 'started_at',
header: 'Beginn',
render: (run) => (
<div>
<p className="text-sm text-fg">{formatDateTime(run.started_at)}</p>
<p className="text-xs text-fg-muted">{formatRelativeTime(run.started_at)}</p>
</div>
),
},
{
key: 'status',
header: 'Ausgang',
render: (run) => <StatusBadge status={run.status} />,
},
{
key: 'duration',
header: 'Dauer',
numeric: true,
render: (run) =>
run.started_at && run.completed_at
? formatDuration(
(new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()) / 1000,
)
: '—',
},
{
key: 'bytes_processed',
header: 'Gelesen',
numeric: true,
render: (run) => formatBytes(run.bytes_processed),
},
{
key: 'bytes_written',
header: 'Abgelegt',
numeric: true,
render: (run) => formatBytes(run.bytes_written),
},
{
key: 'files',
header: 'Objekte',
numeric: true,
render: (run) => (
<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.
<span className="ml-1 text-warning">
({run.files_skipped.toLocaleString('de-DE')} uebergangen)
</span>
) : null}
</span>
),
},
{
key: 'actions',
header: '',
className: 'w-px',
render: (run) =>
(run.status === 'running' || run.status === 'queued') && mayRunJobs ? (
<Button
variant="ghost"
size="sm"
onClick={(clickEvent) => {
clickEvent.stopPropagation();
void cancelRunMutation.run(run.id);
}}
>
<Ban />
Abbrechen
</Button>
) : null,
},
];
return (
<>
<PageHeader
title={job.name}
description={job.description || job.schedule_description}
onBack={onBack}
backLabel="Zu den Auftraegen"
actions={
mayRunJobs ? (
<>
<Button
variant="primary"
isLoading={runMutation.isRunning}
onClick={() => void runMutation.run(job.id)}
>
<Play />
Jetzt sichern
</Button>
{job.status === 'paused' ? (
<Button onClick={() => void resumeMutation.run(job.id)}>
<Play />
Fortsetzen
</Button>
) : (
<Button onClick={() => void pauseMutation.run(job.id)}>
<Pause />
Anhalten
</Button>
)}
</>
) : null
}
/>
{job.status === 'paused' ? (
<Callout tone="warning" title="Dieser Auftrag ist angehalten" className="mb-4">
Geplante Laeufe entfallen, bis er fortgesetzt wird. Bereits laufende
Sicherungen sind davon nicht betroffen.
</Callout>
) : null}
<div className="mb-4 grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader title="Konfiguration" />
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<DetailItem label="Zustand">
<StatusBadge status={job.status} />
</DetailItem>
<DetailItem label="Dringlichkeit">{job.priority}</DetailItem>
<DetailItem label="Zeitplan">{job.schedule_description}</DetailItem>
<DetailItem label="Naechster Lauf">
{job.next_run_at ? formatDateTime(job.next_run_at) : 'Nur manuell'}
</DetailItem>
<DetailItem label="Bandbreitengrenze">
{job.bandwidth_limit_bps
? formatThroughput(job.bandwidth_limit_bps)
: 'Ohne Grenze'}
</DetailItem>
<DetailItem label="Quellen">{job.sources.length}</DetailItem>
</dl>
<div className="mt-5 border-t border-line pt-4">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle">
Zu sichernde Quellen
</p>
<ul className="space-y-1.5">
{job.sources.map((source) => (
<li
key={`${source.type}:${source.id}`}
className="flex items-baseline gap-2 text-sm"
>
<span className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs text-fg-muted">
{source.type}
</span>
<span className="min-w-0 break-all text-fg">{source.id}</span>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
<Card>
<CardHeader title="Letzter Lauf" />
<CardContent>
{runs.length === 0 ? (
<p className="text-sm text-fg-muted">
Dieser Auftrag ist noch nie gelaufen. Es gibt daher keinen
Wiederherstellungspunkt.
</p>
) : (
<LastRunSummary run={runs[0]!} />
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader
title="Laufhistorie"
description="Die letzten 50 Laeufe."
action={
<Button variant="ghost" size="sm" onClick={runsResource.reload}>
<RefreshCw />
Aktualisieren
</Button>
}
/>
<DataTable
columns={runColumns}
rows={runs}
getRowKey={(run) => run.id}
onRowClick={(run) => setSelectedRun(run)}
isLoading={runsResource.loadState === 'loading'}
emptyTitle="Noch kein Lauf"
emptyDescription="Stossen Sie den ersten Lauf ueber die Schaltflaeche oben an."
/>
</Card>
{selectedRun ? (
<RunDetailCard run={selectedRun} onClose={() => setSelectedRun(null)} />
) : null}
</>
);
}
/** Kurzfassung des letzten Laufs. */
function LastRunSummary({ run }: { readonly run: BackupJobRun }) {
return (
<div className="space-y-3">
<StatusBadge status={run.status} />
<dl className="space-y-2 text-sm">
<div className="flex justify-between gap-2">
<dt className="text-fg-muted">Beginn</dt>
<dd className="tabular text-fg">{formatRelativeTime(run.started_at)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-fg-muted">Gelesen</dt>
<dd className="tabular text-fg">{formatBytes(run.bytes_processed)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-fg-muted">Abgelegt</dt>
<dd className="tabular text-fg">{formatBytes(run.bytes_written)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-fg-muted">Durchsatz</dt>
<dd className="tabular text-fg">{formatThroughput(run.throughput_bps)}</dd>
</div>
</dl>
{run.status === 'partial_failure' ? (
<Callout tone="warning" title="Teilfehler">
{run.files_skipped ?? 0} Objekte wurden uebergangen. Ein Teilfehler
gilt <strong>nicht</strong> als Erfolg und wird bewusst nicht
wiederholt — die uebergangenen Objekte waeren beim naechsten Versuch
dieselben.
</Callout>
) : null}
{run.error_code ? (
<Callout tone="critical" title={run.error_code}>
{run.error_message}
{run.error_class ? (
<p className="mt-1 text-xs">Klasse: {run.error_class}</p>
) : null}
</Callout>
) : null}
</div>
);
}
/** Einzelheiten eines ausgewaehlten Laufs. */
function RunDetailCard({
run,
onClose,
}: {
readonly run: BackupJobRun;
readonly onClose: () => void;
}) {
return (
<Card className="mt-4">
<CardHeader
title="Einzelheiten des Laufs"
description={formatDateTime(run.started_at)}
action={
<Button variant="ghost" size="sm" onClick={onClose}>
Schliessen
</Button>
}
/>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailItem label="Ausgang">
<StatusBadge status={run.status} />
</DetailItem>
<DetailItem label="Dauer">
{run.started_at && run.completed_at
? formatDuration(
(new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()) /
1000,
)
: '—'}
</DetailItem>
<DetailItem label="Objekte erfasst">
{run.files_processed?.toLocaleString('de-DE') ?? '—'}
</DetailItem>
<DetailItem label="Objekte uebergangen">
{run.files_skipped?.toLocaleString('de-DE') ?? '0'}
</DetailItem>
<DetailItem label="Gelesen">{formatBytes(run.bytes_processed)}</DetailItem>
<DetailItem label="Abgelegt">{formatBytes(run.bytes_written)}</DetailItem>
<DetailItem label="Durchsatz">{formatThroughput(run.throughput_bps)}</DetailItem>
<DetailItem label="Lauf-Kennung">
<code className="break-all font-mono text-xs">{run.id}</code>
</DetailItem>
</dl>
{run.error_code ? (
<Callout tone="critical" title={run.error_code} className="mt-4">
<p>{run.error_message}</p>
{run.error_class ? (
<p className="mt-2 text-xs">
Fehlerklasse: <strong>{run.error_class}</strong>
{['transient', 'network', 'repository', 'source'].includes(run.error_class)
? ' — ein Wiederholungslauf kann helfen.'
: ' — eine Wiederholung behebt das nicht; die Ursache muss beseitigt werden.'}
</p>
) : null}
</Callout>
) : null}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,359 @@
/**
* Sicherungsauftraege — 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.
*
* 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.
*/
import {
MoreHorizontal,
Pause,
Play,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useState } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError, useMutation } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Button,
Callout,
Card,
ConfirmDialog,
DataTable,
ErrorState,
StatusBadge,
useToast,
type TableColumn,
} from '@/components/ui';
import { formatRelativeTime } from '@/lib/utils';
import { BackupWizard } from './BackupWizard';
import {
deleteJob,
listJobs,
listRepositories,
pauseJob,
resumeJob,
runJob,
type BackupJob,
} from './jobsApi';
export function JobsPage({
grantedPermissions,
onOpenJob,
}: {
readonly grantedPermissions: readonly string[];
readonly onOpenJob: (jobIdentifier: string) => void;
}) {
const toast = useToast();
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [jobPendingDeletion, setJobPendingDeletion] = useState<BackupJob | null>(null);
const jobsResource = useApiResource<BackupJob[]>(
useCallback((abortSignal) => listJobs(abortSignal), []),
);
const repositoriesResource = useApiResource(
useCallback((abortSignal) => listRepositories(abortSignal), []),
);
const mayRunJobs =
grantedPermissions.includes('*') || grantedPermissions.includes('jobs.run');
const mayWriteJobs =
grantedPermissions.includes('*') || grantedPermissions.includes('jobs.write');
const runMutation = useMutation(runJob, {
onSuccess: () => {
// 202: eingereiht, nicht "gesichert". Die Meldung sagt genau das —
// "Sicherung erfolgreich" waere 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,
// nicht ein Fehler des Betreibers.
toast.showInfo('Dieser Auftrag laeuft bereits', describeApiError(apiError));
return;
}
toast.showError('Der Lauf liess sich nicht anstossen', describeApiError(apiError), apiError.requestId);
},
});
const pauseMutation = useMutation(pauseJob, {
onSuccess: () => {
toast.showSuccess('Auftrag angehalten', 'Geplante Laeufe entfallen, bis er fortgesetzt wird.');
jobsResource.reload();
},
onError: (apiError) =>
toast.showError('Der Auftrag liess sich nicht anhalten', describeApiError(apiError), apiError.requestId),
});
const resumeMutation = useMutation(resumeJob, {
onSuccess: () => {
toast.showSuccess('Auftrag fortgesetzt');
jobsResource.reload();
},
onError: (apiError) =>
toast.showError('Der Auftrag liess sich nicht fortsetzen', describeApiError(apiError), apiError.requestId),
});
const deleteMutation = useMutation(deleteJob, {
onSuccess: () => {
toast.showSuccess(
'Auftrag geloescht',
'Die vorhandenen Wiederherstellungspunkte bleiben bestehen.',
);
setJobPendingDeletion(null);
jobsResource.reload();
},
onError: (apiError) =>
toast.showError('Der Auftrag liess sich nicht loeschen', describeApiError(apiError), apiError.requestId),
});
const jobs = jobsResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<BackupJob>> = [
{
key: 'name',
header: 'Auftrag',
render: (job) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{job.name}</p>
<p className="truncate text-xs text-fg-muted">{job.schedule_description}</p>
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (job) => <StatusBadge status={job.status} />,
},
{
key: 'last_outcome',
header: 'Letzter Lauf',
render: (job) =>
job.last_run_at ? (
<div className="flex items-center gap-2">
<StatusBadge status={job.last_outcome} showDot={false} />
<span className="text-xs text-fg-muted">
{formatRelativeTime(job.last_run_at)}
</span>
</div>
) : (
// Kein Lauf ist nicht "erfolgreich". Ohne diesen Unterschied saehe 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',
render: (job) =>
job.next_run_at ? (
<span className="text-sm text-fg-muted">{formatRelativeTime(job.next_run_at)}</span>
) : (
<span className="text-xs text-fg-muted">
{job.status === 'paused' ? 'Angehalten' : 'Nur manuell'}
</span>
),
},
{
key: 'sources',
header: 'Quellen',
numeric: true,
render: (job) => <span className="text-sm">{job.sources.length}</span>,
},
{
key: 'actions',
header: '',
className: 'w-px',
render: (job) => (
<div
className="flex items-center justify-end gap-1"
// Ohne das oeffnete jeder Klick auf eine Schaltflaeche zusaetzlich die
// Detailseite der Zeile.
onClick={(clickEvent) => clickEvent.stopPropagation()}
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
role="presentation"
>
{mayRunJobs ? (
<>
<Button
variant="ghost"
size="icon"
title="Jetzt sichern"
aria-label={`Auftrag ${job.name} jetzt sichern`}
isLoading={runMutation.isRunning}
onClick={() => void runMutation.run(job.id)}
>
<Play />
</Button>
<Button
variant="ghost"
size="icon"
title={job.status === 'paused' ? 'Fortsetzen' : 'Anhalten'}
aria-label={
job.status === 'paused'
? `Auftrag ${job.name} fortsetzen`
: `Auftrag ${job.name} anhalten`
}
onClick={() =>
job.status === 'paused'
? void resumeMutation.run(job.id)
: void pauseMutation.run(job.id)
}
>
{job.status === 'paused' ? <Play /> : <Pause />}
</Button>
</>
) : null}
{mayWriteJobs ? (
<Button
variant="ghost"
size="icon"
title="Loeschen"
aria-label={`Auftrag ${job.name} loeschen`}
onClick={() => setJobPendingDeletion(job)}
>
<Trash2 />
</Button>
) : null}
<Button
variant="ghost"
size="icon"
title="Einzelheiten"
aria-label={`Einzelheiten zu ${job.name}`}
onClick={() => onOpenJob(job.id)}
>
<MoreHorizontal />
</Button>
</div>
),
},
];
if (jobsResource.loadState === 'failed' && jobsResource.loadError) {
return (
<>
<PageHeader title="Sicherungsauftraege" />
<Card>
<ErrorState
message={describeApiError(jobsResource.loadError)}
requestId={jobsResource.loadError.requestId}
onRetry={jobsResource.reload}
/>
</Card>
</>
);
}
const writableRepositories = (repositoriesResource.data ?? []).filter(
(repository) => repository.accepts_backups,
);
return (
<>
<PageHeader
title="Sicherungsauftraege"
description="Was wann wohin gesichert wird."
actions={
mayWriteJobs ? (
<Button variant="primary" onClick={() => setIsWizardOpen(true)}>
<Plus />
Auftrag anlegen
</Button>
) : null
}
/>
{/* Ohne beschreibbares Ziel ist kein Auftrag moeglich. Das erst beim
Absenden des Assistenten zu melden waere 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
vorhandenen.
</Callout>
) : null}
<Card>
<DataTable
columns={tableColumns}
rows={jobs}
getRowKey={(job) => job.id}
onRowClick={(job) => onOpenJob(job.id)}
isLoading={jobsResource.loadState === 'loading'}
emptyTitle="Noch kein Sicherungsauftrag"
emptyDescription="Ohne Auftrag wird nichts gesichert — auch nicht im Hintergrund."
emptyAction={
mayWriteJobs ? (
<Button variant="primary" onClick={() => setIsWizardOpen(true)}>
<Plus />
Ersten Auftrag anlegen
</Button>
) : null
}
/>
</Card>
{isWizardOpen ? (
<BackupWizard
onCancel={() => setIsWizardOpen(false)}
onJobCreated={(createdJob) => {
setIsWizardOpen(false);
jobsResource.reload();
toast.showSuccess('Auftrag angelegt', createdJob.name);
}}
/>
) : null}
<ConfirmDialog
open={jobPendingDeletion !== null}
onOpenChange={(isOpen) => {
if (!isOpen) {
setJobPendingDeletion(null);
}
}}
title="Auftrag loeschen"
description={`„${jobPendingDeletion?.name ?? ''}" wird entfernt.`}
confirmLabel="Loeschen"
isDestructive
isLoading={deleteMutation.isRunning}
confirmationWord={jobPendingDeletion?.name}
confirmationHint={
<>
Tippen Sie zur Bestaetigung den Namen des Auftrags:{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{jobPendingDeletion?.name}
</code>
</>
}
onConfirm={() => {
if (jobPendingDeletion) {
void deleteMutation.run(jobPendingDeletion.id);
}
}}
>
<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.
</Callout>
</ConfirmDialog>
</>
);
}

View File

@ -157,3 +157,115 @@ export async function listJobs(abortSignal?: AbortSignal): Promise<BackupJob[]>
export async function createJob(jobRequest: CreateJobRequest): Promise<BackupJob> { export async function createJob(jobRequest: CreateJobRequest): Promise<BackupJob> {
return requestApi<BackupJob>('/jobs', { method: 'POST', body: jobRequest }); return requestApi<BackupJob>('/jobs', { method: 'POST', body: jobRequest });
} }
// ---------------------------------------------------------------------------
// Bedienung der Auftraege
// ---------------------------------------------------------------------------
//
// Bis hierher konnte die Oberflaeche einen Auftrag anlegen und ansehen — mehr
// nicht. Eine Liste, deren Eintraege sich nicht bedienen lassen, ist ein
// Bericht, keine Konsole.
/** Ein einzelner Lauf eines Auftrags. */
export interface BackupJobRun {
/** Oeffentlicher Bezeichner. */
id: string;
/** Zugehoeriger Auftrag. */
job_id: string;
/** Zustand: queued, running, succeeded, partial_failure, failed, cancelled. */
status: string;
/** Beginn in UTC. */
started_at?: string;
/** Ende in UTC. */
completed_at?: string;
/** Gelesene Bytes. */
bytes_processed?: number;
/** Tatsaechlich abgelegte Bytes nach Deduplizierung. */
bytes_written?: number;
/** Anzahl erfasster Dateien. */
files_processed?: number;
/**
* Uebergangene Objekte.
*
* Groesser als null bedeutet Teilfehler — die Datenbank laesst
* "erfolgreich mit uebergangenen Objekten" per CHECK gar nicht zu.
*/
files_skipped?: number;
/** Durchsatz in Byte je Sekunde. */
throughput_bps?: number;
/** Fehlercode bei nicht erfolgreichem Ausgang. */
error_code?: string;
/** Fehlermeldung. */
error_message?: string;
/** Klasse des Fehlers: transient, permanent, integrity, auth, … */
error_class?: string;
}
/** Laedt einen einzelnen Auftrag. */
export async function getJob(jobIdentifier: string, abortSignal?: AbortSignal): Promise<BackupJob> {
return requestApi<BackupJob>(
`/jobs/${encodeURIComponent(jobIdentifier)}`,
abortSignal ? { signal: abortSignal } : {},
);
}
/** Laedt die Laufhistorie eines Auftrags. */
export async function listJobRuns(
jobIdentifier: string,
abortSignal?: AbortSignal,
): Promise<BackupJobRun[]> {
return requestApi<BackupJobRun[]>(
`/jobs/${encodeURIComponent(jobIdentifier)}/runs?page_size=50`,
abortSignal ? { signal: abortSignal } : {},
);
}
/**
* Stoesst 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
* gezeigt.
*/
export async function runJob(jobIdentifier: string): Promise<{ run_id?: string }> {
return requestApi<{ run_id?: string }>(`/jobs/${encodeURIComponent(jobIdentifier)}/run`, {
method: 'POST',
idempotencyKey: true,
});
}
/** Haelt einen Auftrag an. Laufende Sicherungen bleiben unberuehrt. */
export async function pauseJob(jobIdentifier: string): Promise<BackupJob> {
return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/pause`, {
method: 'POST',
});
}
/** Nimmt einen angehaltenen Auftrag wieder auf. */
export async function resumeJob(jobIdentifier: string): Promise<BackupJob> {
return requestApi<BackupJob>(`/jobs/${encodeURIComponent(jobIdentifier)}/resume`, {
method: 'POST',
});
}
/**
* Loescht 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.
*/
export async function deleteJob(jobIdentifier: string): Promise<void> {
return requestApi<void>(`/jobs/${encodeURIComponent(jobIdentifier)}`, {
method: 'DELETE',
idempotencyKey: true,
});
}
/** Bricht einen laufenden Sicherungslauf ab. */
export async function cancelJobRun(runIdentifier: string): Promise<void> {
return requestApi<void>(`/backup-runs/${encodeURIComponent(runIdentifier)}/cancel`, {
method: 'POST',
});
}

View File

@ -0,0 +1,560 @@
/**
* 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 —
* 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
* noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas
* gesichert hat.
*
* Die drei Huerden vor dem Ueberschreiben sind hier sichtbar umgesetzt:
*
* 1. Das Kennzeichen `overwrite_existing` muss gesetzt werden.
* 2. Die Berechtigung `restores.overwrite` prueft der Server; sie steckt nicht
* in `restores.execute`.
* 3. `confirm_overwrite` verlangt den **woertlich 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.
*/
import { AlertTriangle, ArrowLeft, ArrowRight, Check, ShieldAlert } from 'lucide-react';
import { useState } from 'react';
import { describeApiError, useMutation } from '@/api/useMutation';
import {
Button,
Callout,
CheckboxField,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
Field,
StatusBadge,
TextInput,
useToast,
} from '@/components/ui';
import { cn, formatBytes, formatDuration } from '@/lib/utils';
import {
createRestore,
validateRestore,
type CreateRestoreRequest,
type ValidationResponse,
} from './restoresApi';
/** Die vier Schritte in Reihenfolge. */
const WIZARD_STEPS = [
{ key: 'ziel', label: 'Ziel' },
{ key: 'umfang', label: 'Umfang' },
{ key: 'pruefung', label: 'Vorabpruefung' },
{ key: 'ausfuehren', label: 'Ausfuehren' },
] as const;
export function RestoreWizard({
backupIdentifier,
backupLabel,
grantedPermissions,
onClose,
onStarted,
}: {
readonly backupIdentifier: string;
/** Sprechende Bezeichnung des Wiederherstellungspunkts. */
readonly backupLabel?: string;
readonly grantedPermissions: readonly string[];
readonly onClose: () => void;
readonly onStarted: (restoreIdentifier: string) => void;
}) {
const toast = useToast();
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).
const [targetPath, setTargetPath] = useState('');
const [pathPrefix, setPathPrefix] = useState('');
const [overwriteExisting, setOverwriteExisting] = useState(false);
const [skipPermissions, setSkipPermissions] = useState(false);
const [skipDeepCheck, setSkipDeepCheck] = useState(false);
const [typedConfirmation, setTypedConfirmation] = useState('');
const [validationResult, setValidationResult] = useState<ValidationResponse | null>(null);
const mayOverwrite =
grantedPermissions.includes('*') || grantedPermissions.includes('restores.overwrite');
/** Baut die Anfrage aus dem Entwurf. */
function buildRequest(includeConfirmation: boolean): CreateRestoreRequest {
const request: CreateRestoreRequest = {
backup_id: backupIdentifier,
target_type: 'filesystem',
target_path: targetPath.trim(),
};
if (pathPrefix.trim()) {
request.path_prefix = pathPrefix.trim();
}
if (overwriteExisting) {
request.overwrite_existing = true;
}
if (skipPermissions) {
request.skip_permissions = true;
}
if (skipDeepCheck) {
request.skip_deep_check = true;
}
if (includeConfirmation && typedConfirmation) {
request.confirm_overwrite = typedConfirmation;
}
return request;
}
const validationMutation = useMutation(
() => validateRestore(buildRequest(false)),
{
onSuccess: (result) => {
setValidationResult(result);
setCurrentStepIndex(2);
},
onError: (apiError) =>
toast.showError(
'Die Vorabpruefung ist fehlgeschlagen',
describeApiError(apiError),
apiError.requestId,
),
},
);
const createMutation = useMutation(() => createRestore(buildRequest(true)), {
onSuccess: (session) => {
toast.showSuccess(
'Wiederherstellung gestartet',
'Der Fortschritt erscheint in der Uebersicht.',
);
onStarted(session.id);
},
onError: (apiError) =>
toast.showError(
'Die Wiederherstellung liess sich nicht starten',
describeApiError(apiError),
apiError.requestId,
),
});
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.
const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false;
const confirmationSatisfied =
!confirmationRequired || typedConfirmation.trim() === targetPath.trim();
return (
<DialogRoot open onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent size="lg">
<DialogHeader
title="Wiederherstellen"
description={backupLabel ?? backupIdentifier}
/>
<StepIndicator currentStepIndex={currentStepIndex} />
<DialogBody className="min-h-64">
{currentStepIndex === 0 ? (
<div className="space-y-4">
<TextInput
label="Zielverzeichnis"
required
placeholder="/srv/wiederherstellung"
value={targetPath}
onChange={(changeEvent) => setTargetPath(changeEvent.target.value)}
hint="Absoluter Pfad auf dem Server. Systemverzeichnisse wie /etc oder /usr werden abgelehnt."
error={
targetPath.trim() && !targetPath.trim().startsWith('/')
? 'Der Pfad muss absolut sein und mit / beginnen.'
: undefined
}
/>
<Callout tone="info" title="Wohin am besten?">
In ein <strong>leeres Verzeichnis</strong>. Zurueck an den
Ursprungsort zu schreiben ueberschreibt genau die Daten, deren
Zustand Sie vielleicht noch brauchen — etwa um zu vergleichen,
was gefehlt hat.
</Callout>
</div>
) : null}
{currentStepIndex === 1 ? (
<div className="space-y-4">
<TextInput
label="Nur ein Teilbaum (optional)"
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."
/>
<div className="space-y-3 border-t border-line pt-4">
<CheckboxField
checked={overwriteExisting}
onCheckedChange={setOverwriteExisting}
disabled={!mayOverwrite}
label="Vorhandene Dateien ueberschreiben"
hint={
mayOverwrite
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusaetzlich verlangt der Server danach den woertlich wiederholten Zielpfad.'
: 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.'
}
/>
<CheckboxField
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
label="Rechte nicht zurueckschreiben"
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."
/>
</div>
{skipDeepCheck ? (
<Callout tone="warning">
Ohne Blockpruefung sagt die Vorabpruefung nur, dass das
Manifest lesbar ist — nicht, dass die Daten dazu noch
existieren. Der Bericht weist das aus.
</Callout>
) : null}
</div>
) : null}
{currentStepIndex === 2 ? (
<ValidationSummary
validationResult={validationResult}
isRunning={validationMutation.isRunning}
/>
) : null}
{currentStepIndex === 3 ? (
<div className="space-y-4">
<Callout tone={confirmationRequired ? 'warning' : 'info'}>
{confirmationRequired ? (
<>
Im Zielverzeichnis liegen bereits Daten. Sie werden{' '}
<strong>ueberschrieben</strong>.
</>
) : (
<>
Das Ziel ist leer. Es wird nichts ueberschrieben.
</>
)}
</Callout>
<dl className="grid gap-3 rounded-md border border-line bg-sunken p-3 sm:grid-cols-2">
<div>
<dt className="text-xs text-fg-subtle">Ziel</dt>
<dd className="break-all font-mono text-sm text-fg">{targetPath}</dd>
</div>
<div>
<dt className="text-xs text-fg-subtle">Umfang</dt>
<dd className="text-sm text-fg">
{validationResult?.report
? `${validationResult.report.file_count.toLocaleString('de-DE')} Dateien, ${formatBytes(validationResult.report.total_bytes)}`
: '—'}
</dd>
</div>
</dl>
{confirmationRequired ? (
<Field
label="Zielpfad zur Bestaetigung wiederholen"
hint="Woertlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus."
required
htmlFor="restore-confirm"
>
<TextInput
id="restore-confirm"
autoComplete="off"
className="font-mono"
placeholder={targetPath}
value={typedConfirmation}
onChange={(changeEvent) => setTypedConfirmation(changeEvent.target.value)}
/>
</Field>
) : null}
</div>
) : null}
</DialogBody>
<DialogFooter>
{currentStepIndex > 0 ? (
<Button
variant="ghost"
onClick={() => setCurrentStepIndex((stepIndex) => stepIndex - 1)}
disabled={validationMutation.isRunning || createMutation.isRunning}
>
<ArrowLeft />
Zurueck
</Button>
) : (
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
)}
{currentStepIndex === 0 ? (
<Button
variant="primary"
disabled={!targetIsValid}
onClick={() => setCurrentStepIndex(1)}
>
Weiter
<ArrowRight />
</Button>
) : null}
{currentStepIndex === 1 ? (
<Button
variant="primary"
isLoading={validationMutation.isRunning}
onClick={() => void validationMutation.run()}
>
Vorabpruefung starten
<ArrowRight />
</Button>
) : null}
{currentStepIndex === 2 ? (
<Button
variant="primary"
disabled={!validationResult?.can_proceed}
onClick={() => setCurrentStepIndex(3)}
>
Weiter
<ArrowRight />
</Button>
) : null}
{currentStepIndex === 3 ? (
<Button
variant={confirmationRequired ? 'destructive' : 'primary'}
disabled={!confirmationSatisfied}
isLoading={createMutation.isRunning}
onClick={() => void createMutation.run()}
>
<Check />
Wiederherstellung starten
</Button>
) : null}
</DialogFooter>
</DialogContent>
</DialogRoot>
);
}
/** Schrittanzeige am Kopf des Assistenten. */
function StepIndicator({ currentStepIndex }: { readonly currentStepIndex: number }) {
return (
<ol className="flex items-center gap-1 border-b border-line px-5 py-3">
{WIZARD_STEPS.map((step, stepIndex) => (
<li key={step.key} className="flex items-center gap-1">
<span
className={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs',
stepIndex === currentStepIndex
? 'bg-accent-subtle font-medium text-accent'
: stepIndex < currentStepIndex
? 'text-healthy'
: 'text-fg-subtle',
)}
>
{stepIndex < currentStepIndex ? (
<Check className="size-3" aria-hidden />
) : (
<span className="tabular">{stepIndex + 1}.</span>
)}
{step.label}
</span>
{stepIndex < WIZARD_STEPS.length - 1 ? (
<span className="text-fg-subtle" aria-hidden>
/
</span>
) : null}
</li>
))}
</ol>
);
}
/**
* Ergebnis der Vorabpruefung.
*
* Fehlende Bloecke stehen ganz oben und in Rot. Sie sind der einzige Befund,
* bei dem feststeht, dass die Wiederherstellung **nicht vollstaendig** gelingen
* kann — alles andere ist ein Hinweis.
*/
function ValidationSummary({
validationResult,
isRunning,
}: {
readonly validationResult: ValidationResponse | null;
readonly isRunning: boolean;
}) {
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.
</div>
);
}
const report = validationResult.report;
const blockingFindings =
report?.findings.filter((finding) => finding.severity === 'blocking') ?? [];
const otherFindings =
report?.findings.filter((finding) => finding.severity !== 'blocking') ?? [];
return (
<div className="space-y-4">
<div
className={cn(
'flex items-start gap-3 rounded-md border p-3',
validationResult.can_proceed
? 'border-healthy/30 bg-healthy-bg'
: 'border-critical/30 bg-critical-bg',
)}
>
{validationResult.can_proceed ? (
<Check className="mt-0.5 size-5 shrink-0 text-healthy" aria-hidden />
) : (
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-critical" aria-hidden />
)}
<div className="min-w-0">
<p
className={cn(
'text-sm font-medium',
validationResult.can_proceed ? 'text-healthy' : 'text-critical',
)}
>
{validationResult.can_proceed
? 'Die Wiederherstellung kann beginnen'
: 'Die Wiederherstellung ist nicht moeglich'}
</p>
<p className="mt-0.5 text-sm text-fg">{validationResult.summary}</p>
</div>
</div>
{report ? (
<dl className="grid grid-cols-2 gap-3 rounded-md border border-line bg-sunken p-3 sm:grid-cols-4">
<div>
<dt className="text-xs text-fg-subtle">Dateien</dt>
<dd className="tabular text-sm text-fg">
{report.file_count.toLocaleString('de-DE')}
</dd>
</div>
<div>
<dt className="text-xs text-fg-subtle">Datenmenge</dt>
<dd className="tabular text-sm text-fg">{formatBytes(report.total_bytes)}</dd>
</div>
<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. */}
{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>
<dd className="tabular text-sm text-fg">
{formatDuration(report.duration_seconds)}
</dd>
</div>
</dl>
) : 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>
) : null}
{blockingFindings.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-critical">
Verhindernde Befunde
</p>
{blockingFindings.map((finding, findingIndex) => (
<FindingRow key={`${finding.code}-${findingIndex}`} finding={finding} />
))}
</div>
) : null}
{otherFindings.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Hinweise
</p>
{otherFindings.map((finding, findingIndex) => (
<FindingRow key={`${finding.code}-${findingIndex}`} finding={finding} />
))}
</div>
) : null}
</div>
);
}
/** Ein einzelner Befund mit Code, Meldung und betroffenem Objekt. */
function FindingRow({ finding }: { readonly finding: import('./restoresApi').ValidationFinding }) {
return (
<div className="flex items-start gap-2.5 rounded-md border border-line p-2.5">
<AlertTriangle
className={cn(
'mt-0.5 size-4 shrink-0',
finding.severity === 'blocking'
? 'text-critical'
: finding.severity === 'warning'
? 'text-warning'
: 'text-info',
)}
aria-hidden
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
status={finding.severity === 'blocking' ? 'critical' : finding.severity}
showDot={false}
/>
<code className="font-mono text-xs text-fg-muted">{finding.code}</code>
</div>
<p className="mt-1 text-sm text-fg">{finding.message}</p>
{/* Der Befund nennt die betroffene Datei, nicht nur eine Zahl. */}
{finding.detail ? (
<p className="mt-0.5 break-all font-mono text-xs text-fg-muted">
{finding.detail}
</p>
) : null}
</div>
</div>
);
}

View File

@ -0,0 +1,182 @@
/**
* API-Anbindung der Wiederherstellung (SYNCOVA_API.md §12).
*
* Die Vorabpruefung ist der Kern, nicht das Zurueckschreiben: `validate`
* schreibt nichts und stellt fest, ob eine Wiederherstellung gelingen *kann* —
* insbesondere, ob **jeder benoetigte 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. */
export type ValidationSeverity = 'blocking' | 'warning' | 'info';
/** Einzelner Befund der Vorabpruefung. */
export interface ValidationFinding {
/** Maschinenlesbarer Code. */
code: string;
/** Schweregrad; nur `blocking` verhindert die Ausfuehrung. */
severity: ValidationSeverity;
/** Meldung im Klartext. */
message: string;
/** Betroffenes Objekt, sofern benennbar. */
detail?: string;
}
/** Bericht der Vorabpruefung. */
export interface ValidationReport {
backup_id: string;
target_path: string;
findings: ValidationFinding[];
/** Zahl wiederherzustellender Objekte insgesamt. */
entry_count: number;
/** Zahl wiederherzustellender Dateien. */
file_count: number;
/** Zurueckzuschreibende Datenmenge. */
total_bytes: number;
/** Zahl benoetigter Bloecke. */
unique_chunk_count: number;
/** Zahl fehlender Bloecke — jeder einzelne verhindert die Wiederherstellung. */
missing_chunk_count: number;
/** Freier Platz am Ziel; -1 bedeutet unbekannt. */
available_target_bytes: number;
checked_at: string;
duration_seconds: number;
}
/** Antwort der Vorabpruefung. */
export interface ValidationResponse {
/** Darf die Wiederherstellung beginnen? */
can_proceed: boolean;
/** Verlangt das Ziel eine woertliche Bestaetigung? */
requires_overwrite_confirmation: boolean;
/** Zusammenfassung in einem Satz. */
summary: string;
report: ValidationReport | null;
}
/** Pruefpunkt einer unterbrochenen Wiederherstellung. */
export interface RestoreCheckpoint {
/** Zuletzt vollstaendig geschriebener Pfad. */
last_completed_path?: string;
files_restored?: number;
bytes_restored?: number;
updated_at?: string;
}
/** Eine Wiederherstellung. */
export interface RestoreSession {
id: string;
backup_id: string;
target_type: string;
target_path: string;
path_prefix?: string;
status: string;
overwrite_existing: boolean;
started_at?: string;
completed_at?: string;
duration_seconds?: number;
bytes_restored: number;
files_restored: number;
files_skipped: number;
error_code?: string;
error_message?: string;
validation_report?: ValidationReport;
checkpoint?: RestoreCheckpoint;
correlation_id: string;
created_at: string;
}
/** Rumpf beim Anlegen einer Wiederherstellung. */
export interface CreateRestoreRequest {
backup_id: string;
/** Zielart, etwa `filesystem`. */
target_type: string;
/** Zielverzeichnis. */
target_path: string;
/** Beschraenkung auf einen Teilbaum des Manifests. */
path_prefix?: string;
/** Erste Huerde: das Kennzeichen. */
overwrite_existing?: boolean;
/**
* Dritte Huerde: der Zielpfad, woertlich wiederholt.
*
* Die zweite Huerde 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. */
skip_permissions?: boolean;
/**
* Blockpruefung ueberspringen.
*
* Beschleunigt die Vorabpruefung erheblich und **senkt ihre Aussagekraft**:
* Ohne sie ist nicht belegt, dass jeder benoetigte Block noch da ist. Der
* Bericht weist das als Hinweis aus.
*/
skip_deep_check?: boolean;
}
/** Fuehrt die Vorabpruefung aus. Sie schreibt nichts. */
export async function validateRestore(
validationRequest: CreateRestoreRequest,
): Promise<ValidationResponse> {
return requestApi<ValidationResponse>('/restores/validate', {
method: 'POST',
body: validationRequest,
});
}
/** Legt eine Wiederherstellung an und startet sie. */
export async function createRestore(
restoreRequest: CreateRestoreRequest,
): Promise<RestoreSession> {
return requestApi<RestoreSession>('/restores', {
method: 'POST',
body: restoreRequest,
idempotencyKey: true,
});
}
/** Laedt die Wiederherstellungen. */
export async function listRestores(abortSignal?: AbortSignal): Promise<RestoreSession[]> {
return requestApi<RestoreSession[]>(
'/restores?page_size=50',
abortSignal ? { signal: abortSignal } : {},
);
}
/** Laedt eine einzelne Wiederherstellung. */
export async function getRestore(
restoreIdentifier: string,
abortSignal?: AbortSignal,
): Promise<RestoreSession> {
return requestApi<RestoreSession>(
`/restores/${encodeURIComponent(restoreIdentifier)}`,
abortSignal ? { signal: abortSignal } : {},
);
}
/** Bricht eine laufende Wiederherstellung ab. */
export async function cancelRestore(restoreIdentifier: string): Promise<RestoreSession> {
return requestApi<RestoreSession>(
`/restores/${encodeURIComponent(restoreIdentifier)}/cancel`,
{ method: 'POST' },
);
}
/**
* 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
* bereits am Platz hatte. Die Fortsetzung ist eine bewusste Entscheidung.
*/
export async function resumeRestore(restoreIdentifier: string): Promise<RestoreSession> {
return requestApi<RestoreSession>(
`/restores/${encodeURIComponent(restoreIdentifier)}/resume`,
{ method: 'POST', idempotencyKey: true },
);
}

162
apps/web/src/lib/utils.ts Normal file
View File

@ -0,0 +1,162 @@
/** Kleine Helfer, die quer durch die Oberflaeche gebraucht werden. */
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Fuehrt Klassennamen zusammen und loest 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.
*/
export function cn(...classValues: ClassValue[]): string {
return twMerge(clsx(classValues));
}
/**
* Formatiert eine Byte-Zahl fuer die Anzeige.
*
* Bewusst mit binaeren Einheiten (KiB, MiB): Ein Backup-Produkt, das GB und GiB
* vermischt, erzeugt Rueckfragen bei jeder Kapazitaetsplanung.
*/
export function formatBytes(byteCount: number | null | undefined): string {
// Unbekannt ist nicht null. Eine 0 laese sich wie "nichts abgelegt" —
// derselbe Grundsatz wie bei den Kennzahlen aus Phase 13.
if (byteCount === null || byteCount === undefined || Number.isNaN(byteCount)) {
return '—';
}
if (byteCount === 0) {
return '0 B';
}
const unitNames = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
const unitIndex = Math.min(
Math.floor(Math.log(Math.abs(byteCount)) / Math.log(1024)),
unitNames.length - 1,
);
const scaledValue = byteCount / Math.pow(1024, unitIndex);
// Unter 10 eine Nachkommastelle, darueber keine: "1,4 GiB" ist nuetzlich,
// "847,3 GiB" tut nur so genau.
const decimalPlaces = unitIndex === 0 ? 0 : scaledValue < 10 ? 1 : 0;
return `${scaledValue.toLocaleString('de-DE', {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
})} ${unitNames[unitIndex]}`;
}
/** Formatiert einen Zeitstempel als deutsches Datum mit Uhrzeit. */
export function formatDateTime(isoTimestamp: string | null | undefined): string {
if (!isoTimestamp) {
return '—';
}
const parsedDate = new Date(isoTimestamp);
if (Number.isNaN(parsedDate.getTime())) {
return '—';
}
return parsedDate.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/**
* Formatiert einen Zeitpunkt als Abstand zu jetzt ("vor 3 Stunden").
*
* Fuer 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.
*/
export function formatRelativeTime(isoTimestamp: string | null | undefined): string {
if (!isoTimestamp) {
return '—';
}
const parsedDate = new Date(isoTimestamp);
if (Number.isNaN(parsedDate.getTime())) {
return '—';
}
const elapsedSeconds = (Date.now() - parsedDate.getTime()) / 1000;
const isInFuture = elapsedSeconds < 0;
const absoluteSeconds = Math.abs(elapsedSeconds);
const relativeFormatter = new Intl.RelativeTimeFormat('de-DE', { numeric: 'auto' });
if (absoluteSeconds < 45) {
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
// eingeschobener Eintrag still die Zuordnung verschiebt.
const timeScales = [
{ limit: 3600, divisor: 60, unit: 'minute' },
{ limit: 86400, divisor: 3600, unit: 'hour' },
{ limit: 2592000, divisor: 86400, unit: 'day' },
{ limit: 31536000, divisor: 2592000, unit: 'month' },
{ limit: Number.POSITIVE_INFINITY, divisor: 31536000, unit: 'year' },
] as const satisfies ReadonlyArray<{
limit: number;
divisor: number;
unit: Intl.RelativeTimeFormatUnit;
}>;
for (const scale of timeScales) {
if (absoluteSeconds < scale.limit) {
const unitValue = Math.round(absoluteSeconds / scale.divisor);
return relativeFormatter.format(isInFuture ? unitValue : -unitValue, scale.unit);
}
}
return formatDateTime(isoTimestamp);
}
/** Formatiert eine Dauer in Sekunden als lesbare Angabe. */
export function formatDuration(totalSeconds: number | null | undefined): string {
if (totalSeconds === null || totalSeconds === undefined || Number.isNaN(totalSeconds)) {
return '—';
}
if (totalSeconds < 1) {
return `${Math.round(totalSeconds * 1000)} ms`;
}
if (totalSeconds < 60) {
return `${totalSeconds.toFixed(totalSeconds < 10 ? 1 : 0)} s`;
}
const wholeMinutes = Math.floor(totalSeconds / 60);
const remainingSeconds = Math.round(totalSeconds % 60);
if (wholeMinutes < 60) {
return `${wholeMinutes} min ${remainingSeconds} s`;
}
const wholeHours = Math.floor(wholeMinutes / 60);
const remainingMinutes = wholeMinutes % 60;
return `${wholeHours} h ${remainingMinutes} min`;
}
/**
* Formatiert einen Durchsatz in Byte je Sekunde.
*/
export function formatThroughput(bytesPerSecond: number | null | undefined): string {
if (bytesPerSecond === null || bytesPerSecond === undefined) {
return '—';
}
return `${formatBytes(bytesPerSecond)}/s`;
}

View File

@ -1,108 +0,0 @@
/**
* Seitenleiste mit der Bereichsnavigation.
*
* Eintraege ohne Backend erscheinen deaktiviert statt versteckt: Wer die Anlage
* bedient, soll den Ausbaustand sehen — und beim Klick erfahren, was fehlt,
* statt in eine leere Maske zu laufen (PROMPT.md §139).
*/
import { ALL_PAGES, SECTION_LABELS, SECTION_ORDER, mayViewPage } from './pages';
import type { PageDefinition } from './pages';
/** Eigenschaften der Seitenleiste. */
interface NavigationSidebarProperties {
/** Bezeichner der angezeigten Seite. */
readonly currentPageId: string;
/** Berechtigungen des angemeldeten Benutzers. */
readonly grantedPermissions: readonly string[];
/** Wird beim Wechsel auf eine andere Seite gerufen. */
readonly onNavigate: (pageIdentifier: string) => void;
}
/** Zeigt die Bereichsnavigation. */
export function NavigationSidebar({
currentPageId,
grantedPermissions,
onNavigate,
}: NavigationSidebarProperties): React.JSX.Element {
return (
<nav className="navigation" aria-label="Bereiche">
{SECTION_ORDER.map((sectionName) => {
// Seiten, die der Benutzer ohnehin nicht aufrufen darf, werden nicht
// gezeigt. Das ist keine Sicherheitsmassnahme — die liegt auf dem Server
// — sondern verhindert eine Oberflaeche voller Sackgassen.
const visiblePages = ALL_PAGES.filter(
(pageDefinition) =>
pageDefinition.section === sectionName && mayViewPage(pageDefinition, grantedPermissions),
);
if (visiblePages.length === 0) {
return null;
}
return (
<div className="navigation__section" key={sectionName}>
<h2 className="navigation__section-title">{SECTION_LABELS[sectionName]}</h2>
<ul className="navigation__list">
{visiblePages.map((pageDefinition) => (
<li key={pageDefinition.id}>
<NavigationEntry
pageDefinition={pageDefinition}
isCurrent={pageDefinition.id === currentPageId}
onNavigate={onNavigate}
/>
</li>
))}
</ul>
</div>
);
})}
</nav>
);
}
/** Eigenschaften eines Navigationseintrags. */
interface NavigationEntryProperties {
/** Die dargestellte Seite. */
readonly pageDefinition: PageDefinition;
/** Meldet die derzeit angezeigte Seite. */
readonly isCurrent: boolean;
/** Wird beim Wechsel gerufen. */
readonly onNavigate: (pageIdentifier: string) => void;
}
/** Zeigt einen einzelnen Navigationseintrag. */
function NavigationEntry({
pageDefinition,
isCurrent,
onNavigate,
}: NavigationEntryProperties): React.JSX.Element {
const entryClassNames = ['navigation__entry'];
if (isCurrent) {
entryClassNames.push('navigation__entry--current');
}
if (!pageDefinition.available) {
entryClassNames.push('navigation__entry--unavailable');
}
return (
<button
className={entryClassNames.join(' ')}
type="button"
aria-current={isCurrent ? 'page' : undefined}
onClick={() => onNavigate(pageDefinition.id)}
>
<span className="navigation__entry-label">{pageDefinition.label}</span>
{/* Der Vermerk steht am Eintrag, nicht nur auf der Seite dahinter: Sonst
muesste man jeden Bereich einzeln anklicken, um den Ausbaustand zu
erkennen. */}
{!pageDefinition.available && (
<span className="navigation__entry-badge">noch nicht verfuegbar</span>
)}
</button>
);
}

View File

@ -1,5 +1,5 @@
/** /**
* Seite fuer einen Bereich ohne Backend. * Seite fuer einen Bereich ohne Bedienoberflaeche.
* *
* Sie ist keine Fehlermeldung und keine Entschuldigung, sondern eine Auskunft: * Sie ist keine Fehlermeldung und keine Entschuldigung, sondern eine Auskunft:
* Was fehlt, und wie kommt man heute an dieselbe Information? Eine Seite, die * Was fehlt, und wie kommt man heute an dieselbe Information? Eine Seite, die
@ -7,31 +7,44 @@
* leere Tabelle zeigt, laesst ihn glauben, es gaebe nichts zu sehen. * leere Tabelle zeigt, laesst ihn glauben, es gaebe nichts zu sehen.
*/ */
import type { PageDefinition } from './pages'; import { Construction } from 'lucide-react';
import type { ReactNode } from 'react';
import { PageHeader } from '@/components/layout/PageHeader';
import { Callout, Card, CardContent } from '@/components/ui';
/** Eigenschaften der Hinweisseite. */ export function UnavailablePage({
interface UnavailablePageProperties { title,
/** Die betroffene Seite. */ reason,
readonly pageDefinition: PageDefinition; alternative,
} }: {
readonly title: ReactNode;
/** Zeigt den Ausbaustand eines Bereichs. */ readonly reason: ReactNode;
export function UnavailablePage({ pageDefinition }: UnavailablePageProperties): React.JSX.Element { /** Wo dieselbe Auskunft heute zu bekommen ist. */
readonly alternative?: ReactNode | undefined;
}): React.JSX.Element {
return ( return (
<section className="page"> <>
<header className="page__header"> <PageHeader title={title} />
<h1 className="page__title">{pageDefinition.label}</h1> <Card>
<span className="page__badge">noch nicht verfuegbar</span> <CardContent className="py-10">
</header> <div className="mx-auto flex max-w-xl flex-col items-center text-center">
<Construction className="size-7 text-fg-subtle" aria-hidden />
<p className="mt-3 text-sm text-fg">{reason}</p>
<div className="notice notice--information"> {alternative ? (
<p className="notice__text">{pageDefinition.unavailableReason}</p> <Callout tone="info" className="mt-5 w-full text-left">
</div> {alternative}
</Callout>
) : null}
<p className="page__hint"> <p className="mt-5 text-xs text-fg-muted">
Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten gefuellt. Eine Maske mit Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten
erfundenen Zahlen liesse sich im Betrieb nicht von einer echten unterscheiden. gefuellt. Eine Maske mit erfundenen Zahlen liesse sich im Betrieb
</p> nicht von einer echten unterscheiden.
</section> </p>
</div>
</CardContent>
</Card>
</>
); );
} }

View File

@ -1,18 +1,54 @@
/** /**
* Seitenverzeichnis der Oberflaeche. * Seitenverzeichnis der Oberflaeche.
* *
* Der Implementierungsplan (§14) nennt fuenfzehn Seiten. Nicht hinter jeder * Der Grundsatz aus PROMPT.md §139 gilt unveraendert: Ein Menue, das nur die
* steht heute ein Backend. Beide Faelle stehen hier nebeneinander — und das ist * fertigen Bereiche zeigt, verschweigt den Ausbaustand; eines, dessen Eintraege
* der Kern dieser Datei: * in leere Masken fuehren, taeuscht ihn vor. Jeder Eintrag erscheint, und ein
* noch nicht verfuegbarer nennt, was fehlt und was stattdessen gilt.
* *
* Ein Menue, das nur die fertigen Bereiche zeigt, verschweigt den Ausbaustand. * Die Gliederung folgt dem Weg durch die Anlage, nicht der Reihenfolge der
* Ein Menue, dessen Eintraege in leere Masken fuehren, taeuscht ihn vor. Beides * Umsetzung: Was taeglich beobachtet wird, steht oben; was einmal eingerichtet
* ist falsch (PROMPT.md §139). Deshalb erscheint jeder Eintrag, und ein noch * und dann selten angefasst wird, unten.
* nicht verfuegbarer nennt, was fehlt und was stattdessen gilt.
*/ */
import type { LucideIcon } from 'lucide-react';
import {
Activity,
Archive,
BadgeCheck,
Bell,
ChartLine,
Clock,
Database,
FileText,
HardDrive,
KeyRound,
LayoutDashboard,
MonitorCog,
RotateCcw,
ScrollText,
Server,
Settings,
ShieldCheck,
Users,
} from 'lucide-react';
/** Bereich der Navigation, dem eine Seite zugeordnet ist. */ /** Bereich der Navigation, dem eine Seite zugeordnet ist. */
export type NavigationSection = 'betrieb' | 'daten' | 'verwaltung'; export type NavigationSection =
| 'betrieb'
| 'daten'
| 'infrastruktur'
| 'analyse'
| 'verwaltung';
/** Beschriftung der Bereiche in Anzeigereihenfolge. */
export const SECTION_LABELS: ReadonlyArray<readonly [NavigationSection, string]> = [
['betrieb', 'Betrieb'],
['daten', 'Daten'],
['infrastruktur', 'Infrastruktur'],
['analyse', 'Analyse'],
['verwaltung', 'Verwaltung'],
];
/** Beschreibung einer Seite der Oberflaeche. */ /** Beschreibung einer Seite der Oberflaeche. */
export interface PageDefinition { export interface PageDefinition {
@ -22,6 +58,8 @@ export interface PageDefinition {
readonly label: string; readonly label: string;
/** Bereich, unter dem die Seite erscheint. */ /** Bereich, unter dem die Seite erscheint. */
readonly section: NavigationSection; readonly section: NavigationSection;
/** Sinnbild im Menue. */
readonly icon: LucideIcon;
/** /**
* Berechtigung, die der Zugriff verlangt. * Berechtigung, die der Zugriff verlangt.
* *
@ -43,10 +81,12 @@ export interface PageDefinition {
/** Alle Seiten in Anzeigereihenfolge. */ /** Alle Seiten in Anzeigereihenfolge. */
export const ALL_PAGES: readonly PageDefinition[] = [ export const ALL_PAGES: readonly PageDefinition[] = [
// --- Betrieb: was taeglich beobachtet und bedient wird ---
{ {
id: 'dashboard', id: 'dashboard',
label: 'Uebersicht', label: 'Uebersicht',
section: 'betrieb', section: 'betrieb',
icon: LayoutDashboard,
requiredPermission: 'backups.read', requiredPermission: 'backups.read',
available: true, available: true,
}, },
@ -54,69 +94,119 @@ export const ALL_PAGES: readonly PageDefinition[] = [
id: 'jobs', id: 'jobs',
label: 'Sicherungsauftraege', label: 'Sicherungsauftraege',
section: 'betrieb', section: 'betrieb',
icon: Clock,
requiredPermission: 'jobs.read', requiredPermission: 'jobs.read',
available: true, available: true,
}, },
{ {
id: 'metrics', id: 'restores',
label: 'Kennzahlen', label: 'Wiederherstellung',
section: 'betrieb', section: 'betrieb',
requiredPermission: 'monitoring.read', icon: RotateCcw,
requiredPermission: 'restores.read',
available: true, available: true,
}, },
{ {
id: 'alerts', id: 'alerts',
label: 'Meldungen', label: 'Meldungen',
section: 'betrieb', section: 'betrieb',
icon: Bell,
requiredPermission: 'alerts.read', requiredPermission: 'alerts.read',
available: true, available: true,
}, },
// --- Daten: die Sicherungen selbst und ihr Schutz ---
{ {
id: 'recovery-points', id: 'recovery-points',
label: 'Wiederherstellungspunkte', label: 'Wiederherstellungspunkte',
section: 'daten', section: 'daten',
icon: Archive,
requiredPermission: 'backups.read', requiredPermission: 'backups.read',
available: true, available: true,
}, },
{ {
id: 'restores', id: 'verification',
label: 'Wiederherstellungen', label: 'Pruefung',
section: 'daten', section: 'daten',
requiredPermission: 'restores.read', icon: BadgeCheck,
requiredPermission: 'verification.read',
available: true, available: true,
}, },
{ {
id: 'repositories', id: 'repositories',
label: 'Repositories', label: 'Repositories',
section: 'daten', section: 'daten',
icon: Database,
requiredPermission: 'repositories.read', requiredPermission: 'repositories.read',
available: true, available: true,
}, },
{
id: 'retention',
label: 'Aufbewahrung',
section: 'daten',
icon: HardDrive,
requiredPermission: 'repositories.read',
available: true,
},
// --- Infrastruktur: was gesichert wird ---
{ {
id: 'agents', id: 'agents',
label: 'Agenten', label: 'Agenten',
section: 'betrieb', section: 'infrastruktur',
icon: MonitorCog,
requiredPermission: 'agents.read', requiredPermission: 'agents.read',
available: true, available: true,
}, },
{ {
id: 'events', id: 'proxmox',
label: 'Ereignisse', label: 'Proxmox',
section: 'verwaltung', section: 'infrastruktur',
requiredPermission: 'audit.read', icon: Server,
requiredPermission: 'providers.read',
available: true,
},
{
id: 'protected-systems',
label: 'Geschuetzte Systeme',
section: 'infrastruktur',
icon: Activity,
requiredPermission: 'providers.read',
available: true,
},
// --- Analyse: Auswertung und Nachweis ---
{
id: 'metrics',
label: 'Kennzahlen',
section: 'analyse',
icon: ChartLine,
requiredPermission: 'monitoring.read',
available: true,
},
{
id: 'reports',
label: 'Berichte',
section: 'analyse',
icon: FileText,
requiredPermission: 'reports.read',
available: true, available: true,
}, },
{ {
id: 'security', id: 'security',
label: 'Sicherheit', label: 'Security Center',
section: 'verwaltung', section: 'analyse',
icon: ShieldCheck,
requiredPermission: 'security.read', requiredPermission: 'security.read',
available: true, available: true,
}, },
// --- Verwaltung: einmal einrichten, selten anfassen ---
{ {
id: 'users', id: 'users',
label: 'Benutzer', label: 'Benutzer',
section: 'verwaltung', section: 'verwaltung',
icon: Users,
requiredPermission: 'users.read', requiredPermission: 'users.read',
available: true, available: true,
}, },
@ -124,83 +214,54 @@ export const ALL_PAGES: readonly PageDefinition[] = [
id: 'roles', id: 'roles',
label: 'Rollen', label: 'Rollen',
section: 'verwaltung', section: 'verwaltung',
icon: KeyRound,
requiredPermission: 'roles.read', requiredPermission: 'roles.read',
available: true, available: true,
}, },
// Ab hier: Seiten aus dem Plan, hinter denen noch kein Backend steht.
{ {
id: 'protected-systems', id: 'events',
label: 'Geschuetzte Systeme', label: 'Ereignisprotokoll',
section: 'betrieb',
requiredPermission: 'jobs.read',
available: false,
unavailableReason:
'Eine Sicht je System gibt es noch nicht — die Anlage kennt Quellen innerhalb von ' +
'Auftraegen, aber kein System als eigenen Gegenstand. Bis dahin steht dieselbe ' +
'Auskunft unter Sicherungsauftraegen und Agenten.',
},
{
id: 'proxmox',
label: 'Proxmox',
section: 'betrieb',
requiredPermission: 'providers.read',
available: false,
unavailableReason:
'Der Proxmox-Provider ist gebaut, aber ohne API-Endpunkte und ohne den verpflichtenden ' +
'End-to-End-Nachweis (Phase 7). Bis dahin fuehrt der Weg ueber das Kommando ' +
'syncova-proxmox discover, das ausschliesslich liest.',
},
{
id: 'reports',
label: 'Berichte',
section: 'verwaltung', section: 'verwaltung',
requiredPermission: 'reports.read', icon: ScrollText,
requiredPermission: 'audit.read',
available: true, available: true,
}, },
{ {
id: 'settings', id: 'settings',
label: 'Einstellungen', label: 'Einstellungen',
section: 'verwaltung', section: 'verwaltung',
icon: Settings,
requiredPermission: 'settings.read', requiredPermission: 'settings.read',
available: false, available: true,
unavailableReason:
'Die Systemeinstellungen werden heute ueber Umgebungsvariablen gesetzt und beim Start ' +
'gelesen. Eine Maske, die Werte entgegennimmt, die der Dienst nicht liest, waere ein ' +
'vorgetaeuschtes Funktionsversprechen.',
}, },
]; ];
/** Beschriftungen der Navigationsbereiche. */ /** Die Seite, die nach der Anmeldung erscheint. */
export const SECTION_LABELS: Record<NavigationSection, string> = {
betrieb: 'Betrieb',
daten: 'Daten',
verwaltung: 'Verwaltung',
};
/** Reihenfolge der Navigationsbereiche. */
export const SECTION_ORDER: readonly NavigationSection[] = ['betrieb', 'daten', 'verwaltung'];
/** Bezeichner der Startseite. */
export const DEFAULT_PAGE_ID = 'dashboard'; export const DEFAULT_PAGE_ID = 'dashboard';
/** Findet eine Seite anhand ihres Bezeichners. */ /** Sucht eine Seite anhand ihres Bezeichners. */
export function findPage(pageIdentifier: string): PageDefinition | undefined { export function findPage(pageIdentifier: string): PageDefinition | undefined {
return ALL_PAGES.find((pageDefinition) => pageDefinition.id === pageIdentifier); return ALL_PAGES.find((page) => page.id === pageIdentifier);
} }
/** /**
* Meldet, ob ein Benutzer eine Seite sehen darf. * Meldet, ob ein Benutzer eine Seite sehen darf.
* *
* Eine Seite ohne Berechtigungsangabe ist fuer jeden Angemeldeten sichtbar. * Ausschliesslich zur Anzeige — sie verhindert Sackgassen im Menue. Die
* verbindliche Pruefung findet auf dem Server statt (PROMPT.md §42).
*/ */
export function mayViewPage( export function mayViewPage(
pageDefinition: PageDefinition, page: PageDefinition,
grantedPermissions: readonly string[], grantedPermissions: readonly string[],
): boolean { ): boolean {
if (pageDefinition.requiredPermission === null) { if (page.requiredPermission === null) {
return true; return true;
} }
return grantedPermissions.includes(pageDefinition.requiredPermission); // Der Super-Administrator traegt einen Platzhalter statt aller Einzelrechte.
if (grantedPermissions.includes('*')) {
return true;
}
return grantedPermissions.includes(page.requiredPermission);
} }

View File

@ -0,0 +1,188 @@
/*
* Design-System der Syncova-Oberflaeche (Tailwind v4 + shadcn/ui).
*
* Der Grundsatz aus PROMPT.md §106 gilt unveraendert und ist der Grund fuer den
* Zuschnitt dieser Datei: Semantische Farben ausschliesslich fuer Status. Die
* gesamte Oberflaeche ist neutral gehalten; wo Farbe erscheint, bedeutet sie
* etwas. Eine bunte Oberflaeche verschleiert, welche Information wirklich
* dringend ist — und in einer Backup-Konsole ist genau das die einzige Frage,
* die zaehlt.
*
* Deshalb gibt es hier keine "Markenfarbe" als Flaeche. Der Akzent ist ein
* gedecktes Blau und wird nur fuer Bedienelemente verwendet, nie fuer Zustaende.
*/
@import 'tailwindcss';
/* Dark Mode wird ueber ein Attribut geschaltet, nicht ueber die Medienabfrage
* allein: Der Betreiber soll die Wahl haben, und eine Konsole, die nachts von
* selbst umschaltet, waehrend jemand an einer Stoerung arbeitet, ist laestig. */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
@theme {
/* --- Schrift --- */
--font-sans:
system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
/* --- Radien --- */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
}
/*
* Die Farben liegen als CSS-Variablen vor, damit dieselbe Komponente in beiden
* Themen funktioniert, ohne dass jede Klasse eine dark:-Variante braucht.
*/
:root,
[data-theme='light'] {
/* Flaechen — vier Ebenen reichen: Seite, Karte, erhoben, Rand. */
--surface-page: 0 0% 98%;
--surface-card: 0 0% 100%;
--surface-raised: 0 0% 100%;
--surface-sunken: 240 5% 96%;
--surface-hover: 240 5% 94%;
--border-default: 240 6% 87%;
--border-strong: 240 5% 78%;
--text-primary: 240 10% 10%;
--text-secondary: 240 4% 42%;
--text-muted: 240 4% 56%;
--text-inverted: 0 0% 100%;
/* Akzent — Bedienelemente, niemals Zustand. */
--accent: 221 70% 45%;
--accent-hover: 221 70% 39%;
--accent-text: 0 0% 100%;
--accent-subtle: 221 70% 96%;
/* --- Statusfarben (PROMPT.md §106) ---
* Gruen = Healthy, Gelb = Warning, Orange = High, Rot = Critical,
* Blau/Neutral = Information. Sonst nichts. */
--status-healthy: 142 72% 29%;
--status-healthy-bg: 142 60% 95%;
--status-warning: 38 92% 33%;
--status-warning-bg: 45 90% 94%;
--status-high: 21 90% 40%;
--status-high-bg: 24 90% 95%;
--status-critical: 0 74% 42%;
--status-critical-bg: 0 85% 96%;
--status-info: 221 83% 45%;
--status-info-bg: 214 95% 96%;
--status-neutral: 240 4% 42%;
--status-neutral-bg: 240 5% 95%;
--focus-ring: 221 83% 53%;
}
[data-theme='dark'] {
--surface-page: 240 8% 8%;
--surface-card: 240 7% 11%;
--surface-raised: 240 6% 14%;
--surface-sunken: 240 9% 6%;
--surface-hover: 240 6% 17%;
--border-default: 240 5% 22%;
--border-strong: 240 5% 32%;
--text-primary: 0 0% 96%;
--text-secondary: 240 5% 68%;
--text-muted: 240 4% 52%;
--text-inverted: 240 10% 8%;
--accent: 217 85% 62%;
--accent-hover: 217 85% 70%;
--accent-text: 240 10% 8%;
--accent-subtle: 221 50% 18%;
/* Im dunklen Thema werden die Statusfarben aufgehellt. Die dunklen Toene des
* hellen Themas waeren auf dunklem Grund nicht mehr unterscheidbar — und ein
* nicht unterscheidbarer Statuston ist schlimmer als gar keiner. */
--status-healthy: 142 65% 55%;
--status-healthy-bg: 142 45% 15%;
--status-warning: 45 90% 60%;
--status-warning-bg: 40 50% 15%;
--status-high: 25 92% 60%;
--status-high-bg: 22 55% 15%;
--status-critical: 0 85% 65%;
--status-critical-bg: 0 50% 16%;
--status-info: 217 90% 65%;
--status-info-bg: 220 50% 17%;
--status-neutral: 240 5% 65%;
--status-neutral-bg: 240 5% 18%;
--focus-ring: 217 90% 62%;
}
/* Die Variablen werden Tailwind als Farben bekannt gemacht, damit
* bg-surface-card, text-status-critical usw. unmittelbar funktionieren. */
@theme inline {
--color-page: hsl(var(--surface-page));
--color-card: hsl(var(--surface-card));
--color-raised: hsl(var(--surface-raised));
--color-sunken: hsl(var(--surface-sunken));
--color-hover: hsl(var(--surface-hover));
--color-line: hsl(var(--border-default));
--color-line-strong: hsl(var(--border-strong));
--color-fg: hsl(var(--text-primary));
--color-fg-muted: hsl(var(--text-secondary));
--color-fg-subtle: hsl(var(--text-muted));
--color-fg-inverted: hsl(var(--text-inverted));
--color-accent: hsl(var(--accent));
--color-accent-hover: hsl(var(--accent-hover));
--color-accent-fg: hsl(var(--accent-text));
--color-accent-subtle: hsl(var(--accent-subtle));
--color-healthy: hsl(var(--status-healthy));
--color-healthy-bg: hsl(var(--status-healthy-bg));
--color-warning: hsl(var(--status-warning));
--color-warning-bg: hsl(var(--status-warning-bg));
--color-high: hsl(var(--status-high));
--color-high-bg: hsl(var(--status-high-bg));
--color-critical: hsl(var(--status-critical));
--color-critical-bg: hsl(var(--status-critical-bg));
--color-info: hsl(var(--status-info));
--color-info-bg: hsl(var(--status-info-bg));
--color-neutral: hsl(var(--status-neutral));
--color-neutral-bg: hsl(var(--status-neutral-bg));
--color-ring: hsl(var(--focus-ring));
}
@layer base {
* {
border-color: hsl(var(--border-default));
}
html {
/* Ohne diese Zeile springt das Layout beim Wechsel zwischen kurzen und
* langen Seiten um die Breite der Bildlaufleiste. */
scrollbar-gutter: stable;
}
body {
background-color: hsl(var(--surface-page));
color: hsl(var(--text-primary));
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Der Fokusring bleibt sichtbar. Ihn zu entfernen macht die Konsole fuer
* Tastaturbedienung unbrauchbar — und an einer Stoerung arbeitet niemand mit
* der Maus. */
:focus-visible {
outline: 2px solid hsl(var(--focus-ring));
outline-offset: 2px;
}
/* Zahlen in Tabellen sollen untereinander stehen. */
.tabular {
font-variant-numeric: tabular-nums;
}
}

View File

@ -1,5 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] },
"target": "ES2022", "target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",

View File

@ -1,6 +1,7 @@
/// <reference types="vitest/config" /> /// <reference types="vitest/config" />
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
/** /**
* Vite-Konfiguration der Syncova-Weboberflaeche. * Vite-Konfiguration der Syncova-Weboberflaeche.
@ -10,7 +11,13 @@ import react from '@vitejs/plugin-react';
* Oberflaeche und API aus (PROMPT.md §98: keine hardcoded Production-URLs). * Oberflaeche und API aus (PROMPT.md §98: keine hardcoded Production-URLs).
*/ */
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
resolve: {
// shadcn-uebliche Kurzform. Ohne sie wandern Importe in ../../.. -Ketten,
// die beim Verschieben einer Datei still brechen.
alias: { '@': new URL('./src', import.meta.url).pathname },
},
server: { server: {
// Der Entwicklungsserver bindet nur lokal. // Der Entwicklungsserver bindet nur lokal.