Blackscreen auf /retention behoben, Aussehen ueberarbeitet
**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:
parent
b50ad2b9bc
commit
77e61dc2b3
@ -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>
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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(' ');
|
||||||
|
|||||||
@ -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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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: '',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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 …" />
|
||||||
|
|||||||
140
apps/web/src/features/dashboard/QuickActions.tsx
Normal file
140
apps/web/src/features/dashboard/QuickActions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
94
apps/web/src/features/repositories/RetentionPage.test.tsx
Normal file
94
apps/web/src/features/repositories/RetentionPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user