**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>
198 lines
7.0 KiB
TypeScript
198 lines
7.0 KiB
TypeScript
/**
|
|
* Statusanzeige — die wichtigste Komponente der Konsole.
|
|
*
|
|
* Sie bildet die Fachbegriffe der API auf die fünf Bedeutungen aus PROMPT.md
|
|
* §106 ab. Dass diese Abbildung **an einer einzigen Stelle** steht, ist der
|
|
* eigentliche Punkt: Wäre sie über die Seiten verteilt, würde früher oder
|
|
* später irgendwo "partial_failure" grün erscheinen — und ein Betreiber hält
|
|
* einen Teilfehler dann für einen Erfolg.
|
|
*
|
|
* Die Zuordnungen sind keine Geschmacksfrage, sondern folgen den
|
|
* Entscheidungen der Phasen:
|
|
*
|
|
* - "partial_failure" ist **niemals** grün (Entwicklungsregel 1).
|
|
* - "unverified" ist neutral, nicht grün: Ohne Wiederherstellungstest ist die
|
|
* Wiederherstellbarkeit unbekannt, und Unbekanntes zählt nie als gut
|
|
* (Phase 10).
|
|
* - "corrupted" ist kritisch, nicht nur eine Warnung: Ein beschädigtes Backup
|
|
* ist nicht zu 70 % wiederherstellbar, sondern gar nicht.
|
|
* - "advisory" beim Löschschutz ist eine Warnung: Der Schutz ist eine
|
|
* Software-Regel, kein Schutz des Dateisystems (Phase 11).
|
|
*/
|
|
|
|
import type { ReactNode } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/** Die fünf Bedeutungen. Mehr gibt es nicht. */
|
|
export type StatusTone = 'healthy' | 'warning' | 'high' | 'critical' | 'info' | 'neutral';
|
|
|
|
interface StatusDefinition {
|
|
readonly tone: StatusTone;
|
|
readonly label: string;
|
|
}
|
|
|
|
/**
|
|
* Abbildung der Fachbegriffe auf Ton und deutsche Beschriftung.
|
|
*
|
|
* Ein unbekannter Schlüssel wird bewusst **neutral** dargestellt und mit dem
|
|
* Rohwert beschriftet — nicht grün. Ein neuer Serverzustand, den diese Tabelle
|
|
* noch nicht kennt, darf nicht als "in Ordnung" durchgehen.
|
|
*/
|
|
const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
|
|
// --- Läufe ---
|
|
succeeded: { tone: 'healthy', label: 'Erfolgreich' },
|
|
running: { tone: 'info', label: 'Läuft' },
|
|
queued: { tone: 'info', label: 'Eingereiht' },
|
|
pending: { tone: 'neutral', label: 'Ausstehend' },
|
|
partial_failure: { tone: 'warning', label: 'Teilfehler' },
|
|
failed: { tone: 'critical', label: 'Fehlgeschlagen' },
|
|
cancelled: { tone: 'neutral', label: 'Abgebrochen' },
|
|
|
|
// --- Aufträge ---
|
|
enabled: { tone: 'healthy', label: 'Aktiv' },
|
|
paused: { tone: 'warning', label: 'Angehalten' },
|
|
disabled: { tone: 'neutral', label: 'Deaktiviert' },
|
|
|
|
// --- Einstufung der Wiederherstellungspunkte (Phase 10) ---
|
|
//
|
|
// Das Vokabular lautet: failed, successful, verified, recoverable, corrupted.
|
|
//
|
|
// `successful` ist die Falle dieser Tabelle und deshalb **neutral**, nicht
|
|
// grün: Es bedeutet "der Sicherungslauf ist durchgelaufen" — nicht, dass
|
|
// sich daraus etwas wiederherstellen lässt. Ein grünes Abzeichen läse sich
|
|
// hier als "geprüft und in Ordnung", und genau diese Verwechslung soll die
|
|
// ganze Anlage verhindern. Erst ein Wiederherstellungstest hebt auf
|
|
// `recoverable`; alles davor ist ein Indiz.
|
|
recoverable: { tone: 'healthy', label: 'Wiederherstellbar' },
|
|
verified: { tone: 'info', label: 'Geprüft' },
|
|
successful: { tone: 'neutral', label: 'Gesichert, ungeprüft' },
|
|
corrupted: { tone: 'critical', label: 'Beschädigt' },
|
|
|
|
// --- Prüfergebnisse ---
|
|
clean: { tone: 'healthy', label: 'Ohne Befund' },
|
|
findings: { tone: 'critical', label: 'Mit Befund' },
|
|
|
|
// --- Repositories ---
|
|
active: { tone: 'healthy', label: 'Aktiv' },
|
|
read_only: { tone: 'warning', label: 'Nur lesend' },
|
|
unavailable: { tone: 'critical', label: 'Nicht erreichbar' },
|
|
maintenance: { tone: 'warning', label: 'Wartung' },
|
|
|
|
// --- Durchsetzungsstufe des Löschschutzes (Phase 11) ---
|
|
filesystem: { tone: 'healthy', label: 'Dateisystem' },
|
|
storage: { tone: 'healthy', label: 'Speichersystem' },
|
|
advisory: { tone: 'warning', label: 'Nur Software' },
|
|
none: { tone: 'critical', label: 'Kein Schutz' },
|
|
|
|
// --- Gesundheit ---
|
|
healthy: { tone: 'healthy', label: 'Fehlerfrei' },
|
|
degraded: { tone: 'warning', label: 'Eingeschränkt' },
|
|
critical: { tone: 'critical', label: 'Kritisch' },
|
|
unknown: { tone: 'neutral', label: 'Unbekannt' },
|
|
|
|
// --- Meldungen ---
|
|
open: { tone: 'critical', label: 'Offen' },
|
|
acknowledged: { tone: 'warning', label: 'Bestätigt' },
|
|
resolved: { tone: 'healthy', label: 'Erledigt' },
|
|
suppressed: { tone: 'neutral', label: 'Unterdrückt' },
|
|
|
|
// --- Schweregrade ---
|
|
info: { tone: 'info', label: 'Information' },
|
|
low: { tone: 'info', label: 'Gering' },
|
|
medium: { tone: 'warning', label: 'Mittel' },
|
|
warning: { tone: 'warning', label: 'Warnung' },
|
|
high: { tone: 'high', label: 'Hoch' },
|
|
|
|
// --- Agenten ---
|
|
online: { tone: 'healthy', label: 'Verbunden' },
|
|
offline: { tone: 'critical', label: 'Nicht erreichbar' },
|
|
stale: { tone: 'warning', label: 'Veraltet' },
|
|
revoked: { tone: 'neutral', label: 'Gesperrt' },
|
|
|
|
// --- Ransomware-Einstufung (Phase 16) ---
|
|
elevated: { tone: 'warning', label: 'Auffällig' },
|
|
// "unknown" oben deckt den Fall unter fünf Vergleichsläufen mit ab.
|
|
};
|
|
|
|
const toneClasses: Readonly<Record<StatusTone, string>> = {
|
|
healthy: 'bg-healthy-bg text-healthy',
|
|
warning: 'bg-warning-bg text-warning',
|
|
high: 'bg-high-bg text-high',
|
|
critical: 'bg-critical-bg text-critical',
|
|
info: 'bg-info-bg text-info',
|
|
neutral: 'bg-neutral-bg text-neutral',
|
|
};
|
|
|
|
/** Liefert Ton und Beschriftung zu einem Fachbegriff. */
|
|
export function resolveStatus(rawStatus: string | null | undefined): StatusDefinition {
|
|
if (!rawStatus) {
|
|
return { tone: 'neutral', label: 'Unbekannt' };
|
|
}
|
|
|
|
const normalizedStatus = rawStatus.toLowerCase();
|
|
|
|
return (
|
|
statusDefinitions[normalizedStatus] ?? {
|
|
// Unbekannt bleibt neutral. Grün wäre hier der bequeme und gefährliche
|
|
// Standard: Ein neuer Fehlerzustand des Servers sähe aus wie Erfolg.
|
|
tone: 'neutral',
|
|
label: rawStatus,
|
|
}
|
|
);
|
|
}
|
|
|
|
export function StatusBadge({
|
|
status,
|
|
label,
|
|
className,
|
|
showDot = true,
|
|
}: {
|
|
readonly status: string | null | undefined;
|
|
/** Überschreibt die Beschriftung, der Ton bleibt. */
|
|
readonly label?: ReactNode | undefined;
|
|
readonly className?: string | undefined;
|
|
readonly showDot?: boolean | undefined;
|
|
}) {
|
|
const resolved = resolveStatus(status);
|
|
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium',
|
|
toneClasses[resolved.tone],
|
|
className,
|
|
)}
|
|
>
|
|
{showDot ? (
|
|
<span className="size-1.5 rounded-full bg-current" aria-hidden />
|
|
) : null}
|
|
{label ?? resolved.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** Nur der farbige Punkt — für dichte Tabellen. */
|
|
export function StatusDot({
|
|
status,
|
|
className,
|
|
}: {
|
|
readonly status: string | null | undefined;
|
|
readonly className?: string | undefined;
|
|
}) {
|
|
const resolved = resolveStatus(status);
|
|
|
|
return (
|
|
<span
|
|
className={cn('inline-flex items-center gap-2', className)}
|
|
title={resolved.label}
|
|
>
|
|
<span
|
|
className={cn('size-2 rounded-full', toneClasses[resolved.tone].split(' ')[1])}
|
|
style={{ backgroundColor: 'currentColor' }}
|
|
aria-hidden
|
|
/>
|
|
<span className="sr-only">{resolved.label}</span>
|
|
</span>
|
|
);
|
|
}
|