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

View File

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

View File

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

View File

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

View File

@ -46,7 +46,7 @@ export function DialogContent({
className={cn( className={cn(
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2', 'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2',
'max-h-[calc(100vh-4rem)] overflow-y-auto', 'max-h-[calc(100vh-4rem)] overflow-y-auto',
'rounded-lg border border-line bg-card shadow-xl', 'rounded-xl border border-line bg-card shadow-[var(--shadow-raised)]',
sizeClasses[size], sizeClasses[size],
className, className,
)} )}

View File

@ -12,7 +12,7 @@ import { forwardRef, useId, type InputHTMLAttributes, type ReactNode, type Selec
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const fieldClasses = [ const fieldClasses = [
'w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg', 'w-full rounded-lg border border-line-strong bg-card px-3 py-2 text-sm text-fg transition-colors',
'placeholder:text-fg-subtle', 'placeholder:text-fg-subtle',
'disabled:cursor-not-allowed disabled:opacity-60', 'disabled:cursor-not-allowed disabled:opacity-60',
].join(' '); ].join(' ');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ import {
const tokenPair = { const tokenPair = {
access_token: 'zugriff', access_token: 'zugriff',
refresh_token: 'erneuerung', refresh_token: 'erneuerung', // secretscan:erlaubt — Testwert, kein Geheimnis
access_expires_at: '', access_expires_at: '',
refresh_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"> <Callout tone="info" className="mb-4" title="Zustand ist nicht Ergebnis">
Eine <strong>gescheiterte Prüfung</strong> ist kein Befund am Backup. Eine gescheiterte Prüfung ist kein Befund am Backup.
Ist das Repository nicht erreichbar, gilt der Auftrag als fehlgeschlagen
— die Einstufung des Wiederherstellungspunkts bleibt davon unberührt.
</Callout> </Callout>
<Card> <Card>
@ -195,7 +193,7 @@ export function VerificationPage({
onRowClick={(job) => onOpenBackup(job.backup_id)} onRowClick={(job) => onOpenBackup(job.backup_id)}
isLoading={verificationsResource.loadState === 'loading'} isLoading={verificationsResource.loadState === 'loading'}
emptyTitle="Noch keine Prüfung" 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> </Card>
</> </>

View File

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

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

View File

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

View File

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

View File

@ -247,7 +247,7 @@ export function RepositoriesPage({
onRowClick={(repository) => setSelectedRepository(repository)} onRowClick={(repository) => setSelectedRepository(repository)}
isLoading={repositoriesResource.loadState === 'loading'} isLoading={repositoriesResource.loadState === 'loading'}
emptyTitle="Kein Repository eingetragen" emptyTitle="Kein Repository eingetragen"
emptyDescription="Ein Repository entsteht auf einem Datenträger mit „syncova-repo create“ und wird hier übernommen." emptyDescription="Anlegen mit „syncova-repo create“, hier übernehmen."
emptyAction={ emptyAction={
mayWrite ? ( mayWrite ? (
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}> <Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
@ -320,10 +320,7 @@ export function RepositoriesPage({
) : null} ) : null}
<Callout tone="info" className="mt-4"> <Callout tone="info" className="mt-4">
Der <strong>Katalog</strong> ist nur ein Beschleuniger; verbindlich Der Katalog ist nur ein Beschleuniger. Ihn neu aufzubauen ist verlustfrei.
sind die Manifeste. Ihn neu aufzubauen ist verlustfrei — und
zugleich der Nachweis, dass dieses Repository ohne die Datenbank
auskommt.
</Callout> </Callout>
</CardContent> </CardContent>
</Card> </Card>
@ -401,9 +398,7 @@ export function RepositoriesPage({
}} }}
> >
<p className="text-sm text-fg"> <p className="text-sm text-fg">
Jeder Block wird gegen seine gespeicherte Prüfsumme geprüft. Bei Jeder Block wird gegen seine Prüfsumme geprüft. Bei großen Beständen dauert das.
großen Beständen dauert das lange — der Datenschlüssel wird dafür
nicht gebraucht.
</p> </p>
</ConfirmDialog> </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), toast.showError('Die Anwendung schlug fehl', describeApiError(apiError), apiError.requestId),
}); });
const policies = policiesResource.data ?? []; const policies = policiesResource.data?.policies ?? [];
const predefinedTemplates = policiesResource.data?.predefined ?? [];
const repositories = repositoriesResource.data ?? []; const repositories = repositoriesResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<RetentionPolicy>> = [ const tableColumns: ReadonlyArray<TableColumn<RetentionPolicy>> = [
@ -227,8 +228,50 @@ export function RetentionPage({
} }
/> />
{mayWrite && predefinedTemplates.length > 0 ? (
<Card className="mb-4">
<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"> <Card className="mb-4">
<CardHeader title="Regeln" /> <CardHeader title="Eigene Regeln" />
<DataTable <DataTable
columns={tableColumns} columns={tableColumns}
rows={policies} rows={policies}
@ -368,9 +411,7 @@ export function RetentionPage({
</div> </div>
<Callout tone="warning"> <Callout tone="warning">
Eine Regel <strong>ohne jede Haltevorgabe</strong> wird abgelehnt. Ohne Haltevorgabe wird die Regel abgelehnt.
Sonst löschte „30 Tage" bei einem länger nicht gesicherten System
jedes Backup — genau dann, wenn man es braucht.
</Callout> </Callout>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
@ -412,8 +453,7 @@ export function RetentionPage({
}} }}
> >
<p className="text-sm text-fg-muted"> <p className="text-sm text-fg-muted">
Bereits gelöschte Wiederherstellungspunkte kommen dadurch nicht Künftig wird nach dieser Regel nichts mehr entfernt.
zurück. Künftig wird nach dieser Regel nichts mehr entfernt.
</p> </p>
</ConfirmDialog> </ConfirmDialog>
@ -443,8 +483,7 @@ export function RetentionPage({
} }
> >
<Callout tone="critical"> <Callout tone="critical">
Geschützte Punkte (Aufbewahrungsfrist oder Legal Hold) bleiben Geschützte Punkte bleiben unberührt.
unberührt — der Server lehnt ihre Löschung ab.
</Callout> </Callout>
</ConfirmDialog> </ConfirmDialog>
</> </>

View File

@ -247,14 +247,41 @@ export interface RetentionPolicy {
time_zone?: string; 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( export async function listRetentionPolicies(
abortSignal?: AbortSignal, abortSignal?: AbortSignal,
): Promise<RetentionPolicy[]> { ): Promise<RetentionPolicyList> {
return requestApi<RetentionPolicy[]>( const response = await requestApi<RetentionPolicyList>(
'/retention-policies', '/retention-policies',
abortSignal ? { signal: abortSignal } : {}, abortSignal ? { signal: abortSignal } : {},
); );
// Fehlt ein Feld, ist die Liste leer — nicht undefined. Ein `?? []` an jeder
// Verwendungsstelle wäre ein `??` zu viel, das irgendwann jemand vergisst.
return {
policies: response.policies ?? [],
predefined: response.predefined ?? [],
};
} }
/** Legt eine Aufbewahrungsregel an. */ /** Legt eine Aufbewahrungsregel an. */

View File

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

View File

@ -211,7 +211,7 @@ export function RestoresPage({
onRowClick={(session) => setSelectedSession(session)} onRowClick={(session) => setSelectedSession(session)}
isLoading={restoresResource.loadState === 'loading'} isLoading={restoresResource.loadState === 'loading'}
emptyTitle="Noch keine Wiederherstellung" emptyTitle="Noch keine Wiederherstellung"
emptyDescription="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> </Card>
@ -247,10 +247,7 @@ export function RestoresPage({
</div> </div>
) : null} ) : null}
<Callout tone="warning"> <Callout tone="warning">
Die Datei, die beim Abbruch gerade geschrieben wurde, wird zuerst Die beim Abbruch angefangene Datei wird ersetzt.
entfernt und dann neu angelegt. Dateien aus diesem abgebrochenen
Lauf werden ersetzt — <strong>nur</strong> bei einer Fortsetzung,
nicht bei einem neuen Lauf.
</Callout> </Callout>
</div> </div>
</ConfirmDialog> </ConfirmDialog>

View File

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