Blackscreen auf /retention behoben, Aussehen ueberarbeitet
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 44s
CI / Sicherheitsprüfungen (push) Successful in 26s

**Der schwarze Bildschirm ist gefunden.** `GET /retention-policies` liefert ein
Objekt `{ policies, predefined }` — als einziger von zehn geprueften
Listenendpunkten. Die Oberflaeche behandelte es als Array; `map` gibt es auf
einem Objekt nicht, React haengte den ganzen Baum aus, und uebrig blieb eine
leere Seite. Im dunklen Thema schwarz.

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

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

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

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

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

84 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jerrit Fritzsche 2026-08-18 14:56:51 +02:00
parent b50ad2b9bc
commit 77e61dc2b3
26 changed files with 424 additions and 143 deletions

View File

@ -147,6 +147,7 @@ export function App(): React.JSX.Element {
currentUser={authenticatedUser}
openObjectIdentifier={openObjectIdentifier}
onOpenObject={setOpenObjectIdentifier}
onNavigate={handleNavigate}
/>
</ErrorBoundary>
</AppShell>
@ -161,12 +162,14 @@ function CurrentPageContent({
currentUser,
openObjectIdentifier,
onOpenObject,
onNavigate,
}: {
readonly currentPageId: string;
readonly grantedPermissions: readonly string[];
readonly currentUser: CurrentUser;
readonly openObjectIdentifier: string | null;
readonly onOpenObject: (objectIdentifier: string | null) => void;
readonly onNavigate: (pageIdentifier: string) => void;
}): React.JSX.Element {
const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID);
@ -199,7 +202,10 @@ function CurrentPageContent({
case 'dashboard':
return (
<>
<DashboardPage />
<DashboardPage
grantedPermissions={grantedPermissions}
onNavigate={onNavigate}
/>
<div className="mt-4">
<SystemHealthPanel />
</div>

View File

@ -16,7 +16,7 @@ import { cn } from '@/lib/utils';
const buttonVariants = cva(
// Gemeinsame Grundlage aller Ausprägungen.
[
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg',
'text-sm font-medium transition-colors',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
@ -24,7 +24,7 @@ const buttonVariants = cva(
{
variants: {
variant: {
primary: 'bg-accent text-accent-fg hover:bg-accent-hover',
primary: 'bg-accent text-accent-fg shadow-[var(--shadow-card)] hover:bg-accent-hover',
secondary: 'border border-line-strong bg-card text-fg hover:bg-hover',
ghost: 'text-fg-muted hover:bg-hover hover:text-fg',
destructive:

View File

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

View File

@ -62,13 +62,13 @@ export function DataTable<RowType>({
<div className={cn('w-full overflow-x-auto', className)}>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line">
<tr className="border-b border-line bg-sunken/50">
{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',
'whitespace-nowrap px-4 py-2 text-[11px] font-medium uppercase tracking-wider text-fg-subtle',
column.numeric ? 'text-right' : 'text-left',
column.className,
)}

View File

@ -46,7 +46,7 @@ export function DialogContent({
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',
'rounded-xl border border-line bg-card shadow-[var(--shadow-raised)]',
sizeClasses[size],
className,
)}

View File

@ -12,7 +12,7 @@ import { forwardRef, useId, type InputHTMLAttributes, type ReactNode, type Selec
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',
'w-full rounded-lg border border-line-strong bg-card px-3 py-2 text-sm text-fg transition-colors',
'placeholder:text-fg-subtle',
'disabled:cursor-not-allowed disabled:opacity-60',
].join(' ');

View File

@ -158,7 +158,7 @@ export function StatusBadge({
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium',
'inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium',
toneClasses[resolved.tone],
className,
)}

View File

@ -180,9 +180,7 @@ export function EventsPage() {
/>
<Callout tone="info" className="mb-4">
Das Protokoll ist <strong>append-only per Datenbank-Trigger</strong>. Es
lässt sich weder hier noch über die API ändern oder löschen — auch
nicht von einem Administrator.
Append-only: weder hier noch über die API änderbar.
</Callout>
<Card>

View File

@ -249,10 +249,7 @@ export function RolesPage({
<div className="px-5 py-4">
{selectedRole.is_builtin ? (
<Callout tone="info" className="mb-4">
Mitgelieferte Rollen sind <strong>unveränderlich</strong>. Eine
Änderung verschöbe die Bedeutung bestehender Zuweisungen —
jemand behielte eine Rolle, die etwas anderes bedeutet als bei
ihrer Vergabe.
Mitgelieferte Rollen sind unveränderlich.
</Callout>
) : null}
<div className="flex flex-wrap gap-1.5">
@ -360,8 +357,7 @@ export function RolesPage({
}}
>
<Callout tone="warning">
Konten mit dieser Rolle verlieren die zugehörigen Rechte sofort. Ist
sie noch zugewiesen, lehnt der Server das Löschen ab.
Konten mit dieser Rolle verlieren die Rechte sofort.
</Callout>
</ConfirmDialog>
</>

View File

@ -283,7 +283,7 @@ export function SettingsPage({
getRowKey={(channel) => channel.id}
isLoading={channelsResource.loadState === 'loading'}
emptyTitle="Kein Benachrichtigungsweg"
emptyDescription="Ohne Weg erfährt niemand von einem Ausfall. Die Meldungen entstehen trotzdem und stehen unter „Meldungen“."
emptyDescription="Ohne Weg erfährt niemand von einem Ausfall."
emptyAction={
mayWriteSettings ? (
<Button variant="primary" onClick={() => setIsChannelDialogOpen(true)}>
@ -347,19 +347,12 @@ export function SettingsPage({
</SelectInput>
<Callout tone="info">
Eine zu niedrige Schwelle ist gefährlicher als eine zu hohe: Wer
täglich zwanzig Meldungen bekommt, schaltet sie nach einer Woche
ab — und dann kommt auch die kritische nicht mehr an.
Eine zu niedrige Schwelle ist gefährlicher als eine zu hohe.
</Callout>
{draftType === 'webhook' ? (
<Callout tone="warning">
Interne Adressen werden abgelehnt — der Metadatendienst einer
Cloud (<code className="font-mono text-xs">169.254.169.254</code>)
und die eigene Datenbank sind sonst mit einem Eintrag erreichbar.
Geprüft wird beim Anlegen <strong>und</strong> vor jedem
Verbindungsaufbau: Ein Name kann zwischenzeitlich auf eine andere
Adresse zeigen.
Interne Adressen werden abgelehnt — geprüft beim Anlegen und vor jedem Aufruf.
</Callout>
) : null}
</DialogBody>
@ -404,9 +397,7 @@ export function SettingsPage({
}}
>
<Callout tone="warning">
Über diesen Weg wird danach nichts mehr zugestellt. Ist es der
einzige, erfährt niemand mehr von einem Ausfall — die Meldungen
entstehen weiterhin und stehen unter „Meldungen“.
Danach wird über diesen Weg nichts mehr zugestellt.
</Callout>
</ConfirmDialog>
</>

View File

@ -382,9 +382,7 @@ export function UsersPage({
</div>
<Callout tone="info">
Dem <strong>letzten Administrator</strong> lassen sich die
Verwaltungsrechte nicht entziehen — der Server lehnt das ab. Sonst
wäre die Anlage nicht mehr verwaltbar.
Dem letzten Administrator lassen sich die Rechte nicht entziehen.
</Callout>
</DialogBody>
<DialogFooter>
@ -428,9 +426,7 @@ export function UsersPage({
}}
>
<Callout tone="info">
Die Einträge dieses Kontos im Ereignisprotokoll bleiben bestehen —
ein Protokoll, das sich durch Löschen eines Kontos bereinigen ließe,
wäre keines.
Die Einträge im Ereignisprotokoll bleiben bestehen.
</Callout>
</ConfirmDialog>
@ -449,10 +445,7 @@ export function UsersPage({
}}
>
<Callout tone="warning">
Danach genügt <strong>das Passwort allein</strong>. Der Vorgang wird
protokolliert. Vergewissern Sie sich auf anderem Weg, dass die
Anfrage tatsächlich von dieser Person stammt — das ist der übliche
Ansatzpunkt für eine Kontoübernahme.
Danach genügt das Passwort allein. Prüfen Sie die Anfrage auf anderem Weg.
</Callout>
</ConfirmDialog>
</>

View File

@ -20,7 +20,7 @@ import {
const tokenPair = {
access_token: 'zugriff',
refresh_token: 'erneuerung',
refresh_token: 'erneuerung', // secretscan:erlaubt — Testwert, kein Geheimnis
access_expires_at: '',
refresh_expires_at: '',
};

View File

@ -182,9 +182,7 @@ export function VerificationPage({
/>
<Callout tone="info" className="mb-4" title="Zustand ist nicht Ergebnis">
Eine <strong>gescheiterte Prüfung</strong> ist kein Befund am Backup.
Ist das Repository nicht erreichbar, gilt der Auftrag als fehlgeschlagen
— die Einstufung des Wiederherstellungspunkts bleibt davon unberührt.
Eine gescheiterte Prüfung ist kein Befund am Backup.
</Callout>
<Card>
@ -195,7 +193,7 @@ export function VerificationPage({
onRowClick={(job) => onOpenBackup(job.backup_id)}
isLoading={verificationsResource.loadState === 'loading'}
emptyTitle="Noch keine Prüfung"
emptyDescription="Eine Prüfung wird bei einem Wiederherstellungspunkt gestartet. Ohne sie bleibt jede Einstufung ein Indiz."
emptyDescription="Prüfungen starten Sie bei einem Wiederherstellungspunkt."
/>
</Card>
</>

View File

@ -17,10 +17,18 @@ import { describeApiError } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import { Card, ErrorState, LoadingState } from '@/components/ui';
import { cn } from '@/lib/utils';
import { QuickActions } from './QuickActions';
import { fetchDashboard } from './dashboardApi';
import type { Dashboard, DashboardWidget } from './dashboardApi';
export function DashboardPage(): React.JSX.Element {
export function DashboardPage({
grantedPermissions = [],
onNavigate,
}: {
readonly grantedPermissions?: readonly string[] | undefined;
/** Fehlt der Rückruf, entfallen die Schnellzugriffe. */
readonly onNavigate?: ((pageIdentifier: string) => void) | undefined;
} = {}): React.JSX.Element {
const loadDashboard = useCallback(
(abortSignal: AbortSignal) => fetchDashboard(abortSignal),
[],
@ -38,6 +46,10 @@ export function DashboardPage(): React.JSX.Element {
}
/>
{onNavigate ? (
<QuickActions grantedPermissions={grantedPermissions} onNavigate={onNavigate} />
) : null}
{loadState === 'loading' ? (
<Card>
<LoadingState label="Die Kennzahlen werden geladen …" />

View File

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

View File

@ -209,10 +209,7 @@ export function AgentsPage({
/>
<Callout tone="info" className="mb-4">
Der Agent <strong>holt seine Aufträge ab</strong>; der Server drückt
sie nicht. Ein Agent steht hinter einer Firewall und ist vom Server aus
nicht erreichbar — jedenfalls nicht ohne eingehende Portfreigabe auf
jedem gesicherten System.
Der Agent holt seine Aufträge ab; der Server drückt sie nicht.
</Callout>
<Card>
@ -222,7 +219,7 @@ export function AgentsPage({
getRowKey={(agent) => agent.id}
isLoading={agentsResource.loadState === 'loading'}
emptyTitle="Kein Agent aufgenommen"
emptyDescription="Ein Agent wird mit einem Aufnahme-Token registriert. Das Token gilt eine Stunde und nur einmal."
emptyDescription="Registrierung über ein Aufnahme-Token, gültig eine Stunde."
emptyAction={
mayEnroll ? (
<Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}>
@ -251,10 +248,7 @@ export function AgentsPage({
hint="Der Name steht im Token, nicht in der Hand des Agenten — sonst könnte er sich als ein anderes System ausgeben."
/>
<Callout tone="info">
Das Token gilt <strong>eine Stunde</strong> und nur für die
Registrierung. Danach erhält der Agent ein Betriebstoken mit
ausschließlich agentspezifischen Rechten, das einzeln widerrufbar
ist.
Gilt eine Stunde und nur zur Registrierung.
</Callout>
</DialogBody>
<DialogFooter>
@ -293,10 +287,7 @@ export function AgentsPage({
}}
>
<Callout tone="warning">
Das Betriebstoken gilt <strong>sofort</strong> nicht mehr. Der Agent
beendet sich beim nächsten Versuch — ein abgelehntes Token behebt
sich nicht durch Warten. Geplante Sicherungen dieses Systems fallen
danach aus.
Das Betriebstoken gilt sofort nicht mehr. Geplante Sicherungen fallen aus.
</Callout>
</ConfirmDialog>
</>

View File

@ -131,9 +131,7 @@ export function ProtectedSystemsPage() {
/>
<Callout tone="info" className="mb-4">
Diese Liste zeigt den <strong>Bestand</strong>, nicht den Sicherungsstand.
Dass ein System hier steht, heißt nicht, dass es einen Auftrag dafür
gibt — das steht unter „Sicherungsaufträge“.
Bestand, nicht Sicherungsstand — Aufträge stehen unter „Sicherungsaufträge“.
</Callout>
<Card className="mb-4">

View File

@ -228,10 +228,7 @@ export function ProxmoxPage({
/>
<Callout tone="warning" className="mb-4" title="Nicht auf echter Hardware freigegeben">
Sicherung und bitgenaues Zurückschreiben eines Gasts laufen gegen einen
Nachbau der API durch. <strong>Ob eine wiederhergestellte Maschine
startet, ist ungeprüft</strong> — dafür stand kein Verbund zur
Verfügung.
Ob eine wiederhergestellte Maschine startet, ist ungeprüft.
</Callout>
<Card>
@ -242,7 +239,7 @@ export function ProxmoxPage({
onRowClick={(cluster) => setSelectedCluster(cluster)}
isLoading={clustersResource.loadState === 'loading'}
emptyTitle="Kein Verbund eingerichtet"
emptyDescription="Ein Verbund braucht ein API-Token mit eigenen Rechten — kein Ticket, denn ein Token ist dauerhaft gültig und einzeln widerrufbar."
emptyDescription="Ein Verbund braucht ein API-Token mit eigenen Rechten."
emptyAction={
mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
@ -384,10 +381,7 @@ export function ProxmoxPage({
hint="Aus „ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub“ auf dem Knoten."
/>
<Callout tone="warning">
Einen Schalter „Wirtsschlüssel egal" gibt es nicht. Ein
Transport, der jeden annimmt, macht aus einem Zwischenangriff
eine Einladung — der Angreifer lieferte dann das Archiv, das
Syncova für ein Backup hält.
Einen Schalter „Wirtsschlüssel egal“ gibt es nicht.
</Callout>
</div>
)}
@ -461,8 +455,7 @@ export function ProxmoxPage({
}}
>
<Callout tone="info">
Die vorhandenen Sicherungen der Gäste bleiben im Repository. Verweisen
noch Quellen auf diesen Verbund, wird das Entfernen abgelehnt.
Die Sicherungen der Gäste bleiben im Repository.
</Callout>
</ConfirmDialog>
</>

View File

@ -284,9 +284,7 @@ export function JobsPage({
Absenden des Assistenten zu melden wäre die unfreundlichste Stelle. */}
{repositoriesResource.loadState === 'loaded' && writableRepositories.length === 0 ? (
<Callout tone="warning" title="Kein beschreibbares Repository" className="mb-4">
Ein Auftrag braucht ein Ziel, das Sicherungen annimmt. Legen Sie
zuerst unter „Repositories" eines an oder prüfen Sie den Zustand der
vorhandenen.
Kein Ziel nimmt Sicherungen an. Prüfen Sie die Repositories.
</Callout>
) : null}
@ -298,7 +296,7 @@ export function JobsPage({
onRowClick={(job) => onOpenJob(job.id)}
isLoading={jobsResource.loadState === 'loading'}
emptyTitle="Noch kein Sicherungsauftrag"
emptyDescription="Ohne Auftrag wird nichts gesichert — auch nicht im Hintergrund."
emptyDescription="Ohne Auftrag wird nichts gesichert."
emptyAction={
mayWriteJobs ? (
<Button variant="primary" onClick={() => setIsWizardOpen(true)}>
@ -349,9 +347,7 @@ export function JobsPage({
}}
>
<Callout tone="info">
Die bereits erzeugten Wiederherstellungspunkte <strong>bleiben
bestehen</strong>. Sie gehören zum Repository, nicht zum Auftrag —
durch das Löschen wird kein Speicher frei.
Die Wiederherstellungspunkte bleiben bestehen — es wird kein Speicher frei.
</Callout>
</ConfirmDialog>
</>

View File

@ -247,7 +247,7 @@ export function RepositoriesPage({
onRowClick={(repository) => setSelectedRepository(repository)}
isLoading={repositoriesResource.loadState === 'loading'}
emptyTitle="Kein Repository eingetragen"
emptyDescription="Ein Repository entsteht auf einem Datenträger mit „syncova-repo create“ und wird hier übernommen."
emptyDescription="Anlegen mit „syncova-repo create“, hier übernehmen."
emptyAction={
mayWrite ? (
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
@ -320,10 +320,7 @@ export function RepositoriesPage({
) : null}
<Callout tone="info" className="mt-4">
Der <strong>Katalog</strong> ist nur ein Beschleuniger; verbindlich
sind die Manifeste. Ihn neu aufzubauen ist verlustfrei — und
zugleich der Nachweis, dass dieses Repository ohne die Datenbank
auskommt.
Der Katalog ist nur ein Beschleuniger. Ihn neu aufzubauen ist verlustfrei.
</Callout>
</CardContent>
</Card>
@ -401,9 +398,7 @@ export function RepositoriesPage({
}}
>
<p className="text-sm text-fg">
Jeder Block wird gegen seine gespeicherte Prüfsumme geprüft. Bei
großen Beständen dauert das lange — der Datenschlüssel wird dafür
nicht gebraucht.
Jeder Block wird gegen seine Prüfsumme geprüft. Bei großen Beständen dauert das.
</p>
</ConfirmDialog>
</>

View File

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

View File

@ -141,7 +141,8 @@ export function RetentionPage({
toast.showError('Die Anwendung schlug fehl', describeApiError(apiError), apiError.requestId),
});
const policies = policiesResource.data ?? [];
const policies = policiesResource.data?.policies ?? [];
const predefinedTemplates = policiesResource.data?.predefined ?? [];
const repositories = repositoriesResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<RetentionPolicy>> = [
@ -227,8 +228,50 @@ export function RetentionPage({
}
/>
{mayWrite && predefinedTemplates.length > 0 ? (
<Card className="mb-4">
<CardHeader title="Regeln" />
<CardHeader
title="Vorlagen"
description="Mitgeliefert. Ein Klick legt eine eigene Regel daraus an."
/>
<CardContent>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{predefinedTemplates.map((template) => (
<button
key={template.name}
type="button"
disabled={createMutation.isRunning}
onClick={() =>
void createMutation.run({
name: template.name,
keep_within_seconds: template.keep_within_seconds ?? 0,
keep_last: template.keep_last ?? 1,
keep_daily: template.keep_daily ?? 0,
keep_weekly: template.keep_weekly ?? 0,
keep_monthly: template.keep_monthly ?? 0,
keep_yearly: template.keep_yearly ?? 0,
})
}
className="rounded-lg border border-line bg-card p-3 text-left transition-colors hover:border-accent hover:bg-hover disabled:opacity-50"
>
<span className="flex items-center gap-2">
<Plus className="size-3.5 text-accent" aria-hidden />
<span className="text-sm font-medium text-fg">{template.name}</span>
</span>
{template.description ? (
<span className="mt-1 block text-xs text-fg-muted">
{template.description}
</span>
) : null}
</button>
))}
</div>
</CardContent>
</Card>
) : null}
<Card className="mb-4">
<CardHeader title="Eigene Regeln" />
<DataTable
columns={tableColumns}
rows={policies}
@ -368,9 +411,7 @@ export function RetentionPage({
</div>
<Callout tone="warning">
Eine Regel <strong>ohne jede Haltevorgabe</strong> wird abgelehnt.
Sonst löschte „30 Tage" bei einem länger nicht gesicherten System
jedes Backup — genau dann, wenn man es braucht.
Ohne Haltevorgabe wird die Regel abgelehnt.
</Callout>
</DialogBody>
<DialogFooter>
@ -412,8 +453,7 @@ export function RetentionPage({
}}
>
<p className="text-sm text-fg-muted">
Bereits gelöschte Wiederherstellungspunkte kommen dadurch nicht
zurück. Künftig wird nach dieser Regel nichts mehr entfernt.
Künftig wird nach dieser Regel nichts mehr entfernt.
</p>
</ConfirmDialog>
@ -443,8 +483,7 @@ export function RetentionPage({
}
>
<Callout tone="critical">
Geschützte Punkte (Aufbewahrungsfrist oder Legal Hold) bleiben
unberührt — der Server lehnt ihre Löschung ab.
Geschützte Punkte bleiben unberührt.
</Callout>
</ConfirmDialog>
</>

View File

@ -247,14 +247,41 @@ export interface RetentionPolicy {
time_zone?: string;
}
/** Lädt die Aufbewahrungsregeln. */
/**
* Antwort auf die Regelliste.
*
* **Dieser Endpunkt liefert ein Objekt, keine Liste** — als einziger von zehn
* geprüften. Ihn als Array zu behandeln ließ die Seite abstürzen: `map` gibt es
* auf einem Objekt nicht, React hängte den ganzen Baum aus, und übrig blieb ein
* schwarzer Bildschirm.
*/
export interface RetentionPolicyList {
/** Selbst angelegte Regeln. */
policies: RetentionPolicy[];
/**
* Mitgelieferte Vorlagen.
*
* Sie tragen keine Kennung — es sind Vorschläge, keine Regeln. Wer eine
* übernimmt, legt damit eine eigene an.
*/
predefined: Array<Omit<RetentionPolicy, 'id'> & { description?: string }>;
}
/** Lädt die Aufbewahrungsregeln samt Vorlagen. */
export async function listRetentionPolicies(
abortSignal?: AbortSignal,
): Promise<RetentionPolicy[]> {
return requestApi<RetentionPolicy[]>(
): Promise<RetentionPolicyList> {
const response = await requestApi<RetentionPolicyList>(
'/retention-policies',
abortSignal ? { signal: abortSignal } : {},
);
// Fehlt ein Feld, ist die Liste leer — nicht undefined. Ein `?? []` an jeder
// Verwendungsstelle wäre ein `??` zu viel, das irgendwann jemand vergisst.
return {
policies: response.policies ?? [],
predefined: response.predefined ?? [],
};
}
/** Legt eine Aufbewahrungsregel an. */

View File

@ -186,10 +186,7 @@ export function RestoreWizard({
/>
<Callout tone="info" title="Wohin am besten?">
In ein <strong>leeres Verzeichnis</strong>. Zurück an den
Ursprungsort zu schreiben überschreibt genau die Daten, deren
Zustand Sie vielleicht noch brauchen — etwa um zu vergleichen,
was gefehlt hat.
Am besten in ein leeres Verzeichnis — der Ursprungsort wäre sonst überschrieben.
</Callout>
</div>
) : null}

View File

@ -211,7 +211,7 @@ export function RestoresPage({
onRowClick={(session) => setSelectedSession(session)}
isLoading={restoresResource.loadState === 'loading'}
emptyTitle="Noch keine Wiederherstellung"
emptyDescription="Führen Sie eine durch, bevor Sie eine brauchen. Erst ein durchgeführter Test hebt einen Wiederherstellungspunkt auf „wiederherstellbar“ — alles davor ist ein Indiz."
emptyDescription="Führen Sie eine durch, bevor Sie eine brauchen."
/>
</Card>
@ -247,10 +247,7 @@ export function RestoresPage({
</div>
) : null}
<Callout tone="warning">
Die Datei, die beim Abbruch gerade geschrieben wurde, wird zuerst
entfernt und dann neu angelegt. Dateien aus diesem abgebrochenen
Lauf werden ersetzt — <strong>nur</strong> bei einer Fortsetzung,
nicht bei einem neuen Lauf.
Die beim Abbruch angefangene Datei wird ersetzt.
</Callout>
</div>
</ConfirmDialog>

View File

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