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}
|
||||
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>
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -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(' ');
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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: '',
|
||||
};
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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 …" />
|
||||
|
||||
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">
|
||||
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>
|
||||
</>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
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="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="Regeln" />
|
||||
<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>
|
||||
</>
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user