syncova-backup/apps/web/src/components/ui/StatusBadge.tsx
Jerrit Fritzsche 77e61dc2b3
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 44s
CI / Sicherheitsprüfungen (push) Successful in 26s
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>
2026-08-18 14:56:51 +02:00

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>
);
}