**Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den
Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch
unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein
Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab),
begrenzt durch zwei Uhren:
- **Harte Obergrenze** von 30 Minuten ab Anmeldung, durch keine Interaktion
verschiebbar. Sonst waere "30 Minuten" keine Zusage.
- **Untaetigkeitsgrenze** von 30 Minuten.
- Der sofortige serverseitige Widerruf bleibt die eigentliche Absicherung — die
Tokens sind opak, kein JWT, und genau dafuer wurden sie gewaehlt.
Dazu die Sitzungsuhr oben rechts neben "Abmelden", unter fuenf Minuten
auffaellig. Umgesetzt mit `useSyncExternalStore`: Die Restzeit haengt an der Uhr
und am Speicher, also an zwei Dingen ausserhalb von React. Sie beim Rendern
auszurechnen waere ein unreiner Aufruf, sie in einem Effekt zu setzen eine
zweite Renderrunde je Sekunde — beides hat der Linter gemeldet.
Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
bestaetigt (Obergrenze mitverschieben schlaegt fehl).
**Fehlergrenze.** Ein Fehler in einer Komponente riss bisher den gesamten Baum
ab; uebrig blieb eine leere Seite — im dunklen Thema ein schwarzer Bildschirm
ohne jeden Hinweis. Die Grenze sitzt **um den Inhalt**: Menue und Kopfzeile
bleiben stehen, der Fehlertext ist lesbar und kopierbar.
**Umlaute.** Die Oberflaeche schrieb durchgehend ae/oe/ue/ss. Jetzt aeoeuess.
Dabei ein selbst verursachter Schaden, gefunden und behoben: Eine Regel
"ue → ü" ist falsch, weil die Buchstabenfolge nicht immer ein Umlaut ist. Sie
machte aus "Quelle" ein "Qülle", aus "neue" ein "neü", aus "aktuell" ein
"aktüll", aus "Dauer" ein "Daür". Die Abbildung laeuft jetzt ueber eine
gepruefte Wortliste mit Ausschluss englischer Bezeichner (`value`, `message`,
`session`, `queued`, `true`); die 23 zerstoerten Woerter sind einzeln
zurueckgesetzt. Ein alter Tippfehler ("geprueter") ist dabei mit aufgefallen.
82 Tests gruen, tsc sauber, eslint ohne Warnung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
561 lines
20 KiB
TypeScript
561 lines
20 KiB
TypeScript
/**
|
|
* Wiederherstellungs-Assistent — die Kernfunktion des Produkts.
|
|
*
|
|
* Bis hierher ließ sich eine Wiederherstellung über die Oberfläche gar nicht
|
|
* auslösen. Wer im Ernstfall Daten zurückbrauchte, musste `curl` bemühen —
|
|
* ausgerechnet in der Lage, in der niemand experimentieren will.
|
|
*
|
|
* Der Assistent hat vier Schritte, und der dritte ist der Grund für seine
|
|
* Existenz: **Die Vorabprüfung schreibt nichts und stellt fest, ob die
|
|
* Wiederherstellung gelingen kann** — insbesondere, ob jeder benötigte Block
|
|
* noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas
|
|
* gesichert hat.
|
|
*
|
|
* Die drei Hürden vor dem Überschreiben sind hier sichtbar umgesetzt:
|
|
*
|
|
* 1. Das Kennzeichen `overwrite_existing` muss gesetzt werden.
|
|
* 2. Die Berechtigung `restores.overwrite` prüft der Server; sie steckt nicht
|
|
* in `restores.execute`.
|
|
* 3. `confirm_overwrite` verlangt den **wörtlich wiederholten Zielpfad**.
|
|
*
|
|
* Läuft die dritte Hürde ins Leere — weil das Ziel leer ist und nichts
|
|
* überschrieben wird —, entfällt sie. Ein Ritual ohne Anlass gewöhnt das
|
|
* Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt.
|
|
*/
|
|
|
|
import { AlertTriangle, ArrowLeft, ArrowRight, Check, ShieldAlert } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { describeApiError, useMutation } from '@/api/useMutation';
|
|
import {
|
|
Button,
|
|
Callout,
|
|
CheckboxField,
|
|
DialogBody,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogRoot,
|
|
Field,
|
|
StatusBadge,
|
|
TextInput,
|
|
useToast,
|
|
} from '@/components/ui';
|
|
import { cn, formatBytes, formatDuration } from '@/lib/utils';
|
|
import {
|
|
createRestore,
|
|
validateRestore,
|
|
type CreateRestoreRequest,
|
|
type ValidationResponse,
|
|
} from './restoresApi';
|
|
|
|
/** Die vier Schritte in Reihenfolge. */
|
|
const WIZARD_STEPS = [
|
|
{ key: 'ziel', label: 'Ziel' },
|
|
{ key: 'umfang', label: 'Umfang' },
|
|
{ key: 'prüfung', label: 'Vorabprüfung' },
|
|
{ key: 'ausführen', label: 'Ausführen' },
|
|
] as const;
|
|
|
|
export function RestoreWizard({
|
|
backupIdentifier,
|
|
backupLabel,
|
|
grantedPermissions,
|
|
onClose,
|
|
onStarted,
|
|
}: {
|
|
readonly backupIdentifier: string;
|
|
/** Sprechende Bezeichnung des Wiederherstellungspunkts. */
|
|
readonly backupLabel?: string;
|
|
readonly grantedPermissions: readonly string[];
|
|
readonly onClose: () => void;
|
|
readonly onStarted: (restoreIdentifier: string) => void;
|
|
}) {
|
|
const toast = useToast();
|
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
|
|
|
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern — sonst
|
|
// wäre jeder Blick zurück ein Datenverlust (wie im Backup-Assistenten).
|
|
const [targetPath, setTargetPath] = useState('');
|
|
const [pathPrefix, setPathPrefix] = useState('');
|
|
const [overwriteExisting, setOverwriteExisting] = useState(false);
|
|
const [skipPermissions, setSkipPermissions] = useState(false);
|
|
const [skipDeepCheck, setSkipDeepCheck] = useState(false);
|
|
const [typedConfirmation, setTypedConfirmation] = useState('');
|
|
|
|
const [validationResult, setValidationResult] = useState<ValidationResponse | null>(null);
|
|
|
|
const mayOverwrite =
|
|
grantedPermissions.includes('*') || grantedPermissions.includes('restores.overwrite');
|
|
|
|
/** Baut die Anfrage aus dem Entwurf. */
|
|
function buildRequest(includeConfirmation: boolean): CreateRestoreRequest {
|
|
const request: CreateRestoreRequest = {
|
|
backup_id: backupIdentifier,
|
|
target_type: 'filesystem',
|
|
target_path: targetPath.trim(),
|
|
};
|
|
|
|
if (pathPrefix.trim()) {
|
|
request.path_prefix = pathPrefix.trim();
|
|
}
|
|
|
|
if (overwriteExisting) {
|
|
request.overwrite_existing = true;
|
|
}
|
|
|
|
if (skipPermissions) {
|
|
request.skip_permissions = true;
|
|
}
|
|
|
|
if (skipDeepCheck) {
|
|
request.skip_deep_check = true;
|
|
}
|
|
|
|
if (includeConfirmation && typedConfirmation) {
|
|
request.confirm_overwrite = typedConfirmation;
|
|
}
|
|
|
|
return request;
|
|
}
|
|
|
|
const validationMutation = useMutation(
|
|
() => validateRestore(buildRequest(false)),
|
|
{
|
|
onSuccess: (result) => {
|
|
setValidationResult(result);
|
|
setCurrentStepIndex(2);
|
|
},
|
|
onError: (apiError) =>
|
|
toast.showError(
|
|
'Die Vorabprüfung ist fehlgeschlagen',
|
|
describeApiError(apiError),
|
|
apiError.requestId,
|
|
),
|
|
},
|
|
);
|
|
|
|
const createMutation = useMutation(() => createRestore(buildRequest(true)), {
|
|
onSuccess: (session) => {
|
|
toast.showSuccess(
|
|
'Wiederherstellung gestartet',
|
|
'Der Fortschritt erscheint in der Übersicht.',
|
|
);
|
|
onStarted(session.id);
|
|
},
|
|
onError: (apiError) =>
|
|
toast.showError(
|
|
'Die Wiederherstellung ließ sich nicht starten',
|
|
describeApiError(apiError),
|
|
apiError.requestId,
|
|
),
|
|
});
|
|
|
|
const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/');
|
|
|
|
// Die wörtliche Bestätigung wird nur verlangt, wenn tatsächlich
|
|
// überschrieben wird — der Server sagt das nach der Prüfung.
|
|
const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false;
|
|
const confirmationSatisfied =
|
|
!confirmationRequired || typedConfirmation.trim() === targetPath.trim();
|
|
|
|
return (
|
|
<DialogRoot open onOpenChange={(isOpen) => !isOpen && onClose()}>
|
|
<DialogContent size="lg">
|
|
<DialogHeader
|
|
title="Wiederherstellen"
|
|
description={backupLabel ?? backupIdentifier}
|
|
/>
|
|
|
|
<StepIndicator currentStepIndex={currentStepIndex} />
|
|
|
|
<DialogBody className="min-h-64">
|
|
{currentStepIndex === 0 ? (
|
|
<div className="space-y-4">
|
|
<TextInput
|
|
label="Zielverzeichnis"
|
|
required
|
|
placeholder="/srv/wiederherstellung"
|
|
value={targetPath}
|
|
onChange={(changeEvent) => setTargetPath(changeEvent.target.value)}
|
|
hint="Absoluter Pfad auf dem Server. Systemverzeichnisse wie /etc oder /usr werden abgelehnt."
|
|
error={
|
|
targetPath.trim() && !targetPath.trim().startsWith('/')
|
|
? 'Der Pfad muss absolut sein und mit / beginnen.'
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
<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.
|
|
</Callout>
|
|
</div>
|
|
) : null}
|
|
|
|
{currentStepIndex === 1 ? (
|
|
<div className="space-y-4">
|
|
<TextInput
|
|
label="Nur ein Teilbaum (optional)"
|
|
placeholder="daten/projekte"
|
|
value={pathPrefix}
|
|
onChange={(changeEvent) => setPathPrefix(changeEvent.target.value)}
|
|
hint="Leer lassen, um alles zurückzuschreiben. Ein Teilbaum-Restore hebt die Einstufung des Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das Backup."
|
|
/>
|
|
|
|
<div className="space-y-3 border-t border-line pt-4">
|
|
<CheckboxField
|
|
checked={overwriteExisting}
|
|
onCheckedChange={setOverwriteExisting}
|
|
disabled={!mayOverwrite}
|
|
label="Vorhandene Dateien überschreiben"
|
|
hint={
|
|
mayOverwrite
|
|
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusätzlich verlangt der Server danach den wörtlich wiederholten Zielpfad.'
|
|
: 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.'
|
|
}
|
|
/>
|
|
|
|
<CheckboxField
|
|
checked={skipPermissions}
|
|
onCheckedChange={setSkipPermissions}
|
|
label="Rechte nicht zurückschreiben"
|
|
hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten."
|
|
/>
|
|
|
|
<CheckboxField
|
|
checked={skipDeepCheck}
|
|
onCheckedChange={setSkipDeepCheck}
|
|
label="Blockprüfung überspringen"
|
|
hint="Beschleunigt die Vorabprüfung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benötigte Block noch vorhanden ist."
|
|
/>
|
|
</div>
|
|
|
|
{skipDeepCheck ? (
|
|
<Callout tone="warning">
|
|
Ohne Blockprüfung sagt die Vorabprüfung nur, dass das
|
|
Manifest lesbar ist — nicht, dass die Daten dazu noch
|
|
existieren. Der Bericht weist das aus.
|
|
</Callout>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{currentStepIndex === 2 ? (
|
|
<ValidationSummary
|
|
validationResult={validationResult}
|
|
isRunning={validationMutation.isRunning}
|
|
/>
|
|
) : null}
|
|
|
|
{currentStepIndex === 3 ? (
|
|
<div className="space-y-4">
|
|
<Callout tone={confirmationRequired ? 'warning' : 'info'}>
|
|
{confirmationRequired ? (
|
|
<>
|
|
Im Zielverzeichnis liegen bereits Daten. Sie werden{' '}
|
|
<strong>überschrieben</strong>.
|
|
</>
|
|
) : (
|
|
<>
|
|
Das Ziel ist leer. Es wird nichts überschrieben.
|
|
</>
|
|
)}
|
|
</Callout>
|
|
|
|
<dl className="grid gap-3 rounded-md border border-line bg-sunken p-3 sm:grid-cols-2">
|
|
<div>
|
|
<dt className="text-xs text-fg-subtle">Ziel</dt>
|
|
<dd className="break-all font-mono text-sm text-fg">{targetPath}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-fg-subtle">Umfang</dt>
|
|
<dd className="text-sm text-fg">
|
|
{validationResult?.report
|
|
? `${validationResult.report.file_count.toLocaleString('de-DE')} Dateien, ${formatBytes(validationResult.report.total_bytes)}`
|
|
: '—'}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
{confirmationRequired ? (
|
|
<Field
|
|
label="Zielpfad zur Bestätigung wiederholen"
|
|
hint="Wörtlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus."
|
|
required
|
|
htmlFor="restore-confirm"
|
|
>
|
|
<TextInput
|
|
id="restore-confirm"
|
|
autoComplete="off"
|
|
className="font-mono"
|
|
placeholder={targetPath}
|
|
value={typedConfirmation}
|
|
onChange={(changeEvent) => setTypedConfirmation(changeEvent.target.value)}
|
|
/>
|
|
</Field>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</DialogBody>
|
|
|
|
<DialogFooter>
|
|
{currentStepIndex > 0 ? (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setCurrentStepIndex((stepIndex) => stepIndex - 1)}
|
|
disabled={validationMutation.isRunning || createMutation.isRunning}
|
|
>
|
|
<ArrowLeft />
|
|
Zurück
|
|
</Button>
|
|
) : (
|
|
<Button variant="ghost" onClick={onClose}>
|
|
Abbrechen
|
|
</Button>
|
|
)}
|
|
|
|
{currentStepIndex === 0 ? (
|
|
<Button
|
|
variant="primary"
|
|
disabled={!targetIsValid}
|
|
onClick={() => setCurrentStepIndex(1)}
|
|
>
|
|
Weiter
|
|
<ArrowRight />
|
|
</Button>
|
|
) : null}
|
|
|
|
{currentStepIndex === 1 ? (
|
|
<Button
|
|
variant="primary"
|
|
isLoading={validationMutation.isRunning}
|
|
onClick={() => void validationMutation.run()}
|
|
>
|
|
Vorabprüfung starten
|
|
<ArrowRight />
|
|
</Button>
|
|
) : null}
|
|
|
|
{currentStepIndex === 2 ? (
|
|
<Button
|
|
variant="primary"
|
|
disabled={!validationResult?.can_proceed}
|
|
onClick={() => setCurrentStepIndex(3)}
|
|
>
|
|
Weiter
|
|
<ArrowRight />
|
|
</Button>
|
|
) : null}
|
|
|
|
{currentStepIndex === 3 ? (
|
|
<Button
|
|
variant={confirmationRequired ? 'destructive' : 'primary'}
|
|
disabled={!confirmationSatisfied}
|
|
isLoading={createMutation.isRunning}
|
|
onClick={() => void createMutation.run()}
|
|
>
|
|
<Check />
|
|
Wiederherstellung starten
|
|
</Button>
|
|
) : null}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</DialogRoot>
|
|
);
|
|
}
|
|
|
|
/** Schrittanzeige am Kopf des Assistenten. */
|
|
function StepIndicator({ currentStepIndex }: { readonly currentStepIndex: number }) {
|
|
return (
|
|
<ol className="flex items-center gap-1 border-b border-line px-5 py-3">
|
|
{WIZARD_STEPS.map((step, stepIndex) => (
|
|
<li key={step.key} className="flex items-center gap-1">
|
|
<span
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs',
|
|
stepIndex === currentStepIndex
|
|
? 'bg-accent-subtle font-medium text-accent'
|
|
: stepIndex < currentStepIndex
|
|
? 'text-healthy'
|
|
: 'text-fg-subtle',
|
|
)}
|
|
>
|
|
{stepIndex < currentStepIndex ? (
|
|
<Check className="size-3" aria-hidden />
|
|
) : (
|
|
<span className="tabular">{stepIndex + 1}.</span>
|
|
)}
|
|
{step.label}
|
|
</span>
|
|
{stepIndex < WIZARD_STEPS.length - 1 ? (
|
|
<span className="text-fg-subtle" aria-hidden>
|
|
/
|
|
</span>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Ergebnis der Vorabprüfung.
|
|
*
|
|
* Fehlende Blöcke stehen ganz oben und in Rot. Sie sind der einzige Befund,
|
|
* bei dem feststeht, dass die Wiederherstellung **nicht vollständig** gelingen
|
|
* kann — alles andere ist ein Hinweis.
|
|
*/
|
|
function ValidationSummary({
|
|
validationResult,
|
|
isRunning,
|
|
}: {
|
|
readonly validationResult: ValidationResponse | null;
|
|
readonly isRunning: boolean;
|
|
}) {
|
|
if (isRunning || !validationResult) {
|
|
return (
|
|
<div className="py-10 text-center text-sm text-fg-muted">
|
|
Die Vorabprüfung läuft. Bei aktiver Blockprüfung wird jeder
|
|
benötigte Block gesucht — das dauert bei großen Beständen.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const report = validationResult.report;
|
|
const blockingFindings =
|
|
report?.findings.filter((finding) => finding.severity === 'blocking') ?? [];
|
|
const otherFindings =
|
|
report?.findings.filter((finding) => finding.severity !== 'blocking') ?? [];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div
|
|
className={cn(
|
|
'flex items-start gap-3 rounded-md border p-3',
|
|
validationResult.can_proceed
|
|
? 'border-healthy/30 bg-healthy-bg'
|
|
: 'border-critical/30 bg-critical-bg',
|
|
)}
|
|
>
|
|
{validationResult.can_proceed ? (
|
|
<Check className="mt-0.5 size-5 shrink-0 text-healthy" aria-hidden />
|
|
) : (
|
|
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-critical" aria-hidden />
|
|
)}
|
|
<div className="min-w-0">
|
|
<p
|
|
className={cn(
|
|
'text-sm font-medium',
|
|
validationResult.can_proceed ? 'text-healthy' : 'text-critical',
|
|
)}
|
|
>
|
|
{validationResult.can_proceed
|
|
? 'Die Wiederherstellung kann beginnen'
|
|
: 'Die Wiederherstellung ist nicht möglich'}
|
|
</p>
|
|
<p className="mt-0.5 text-sm text-fg">{validationResult.summary}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{report ? (
|
|
<dl className="grid grid-cols-2 gap-3 rounded-md border border-line bg-sunken p-3 sm:grid-cols-4">
|
|
<div>
|
|
<dt className="text-xs text-fg-subtle">Dateien</dt>
|
|
<dd className="tabular text-sm text-fg">
|
|
{report.file_count.toLocaleString('de-DE')}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-fg-subtle">Datenmenge</dt>
|
|
<dd className="tabular text-sm text-fg">{formatBytes(report.total_bytes)}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-fg-subtle">Freier Platz am Ziel</dt>
|
|
<dd className="tabular text-sm text-fg">
|
|
{/* -1 bedeutet unbekannt. Als 0 zu zeigen wäre das Gegenteil
|
|
der Wahrheit — es sähe nach "voll" aus. */}
|
|
{report.available_target_bytes < 0
|
|
? 'unbekannt'
|
|
: formatBytes(report.available_target_bytes)}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-fg-subtle">Dauer der Prüfung</dt>
|
|
<dd className="tabular text-sm text-fg">
|
|
{formatDuration(report.duration_seconds)}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
) : null}
|
|
|
|
{report && report.missing_chunk_count > 0 ? (
|
|
<Callout tone="critical" title={`${report.missing_chunk_count} Blöcke fehlen`}>
|
|
Von {report.unique_chunk_count.toLocaleString('de-DE')} benötigten
|
|
Blöcken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht
|
|
auffindbar. Die Wiederherstellung wäre unvollständig. Prüfen Sie das
|
|
Repository mit einem Integritätslauf.
|
|
</Callout>
|
|
) : null}
|
|
|
|
{blockingFindings.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-critical">
|
|
Verhindernde Befunde
|
|
</p>
|
|
{blockingFindings.map((finding, findingIndex) => (
|
|
<FindingRow key={`${finding.code}-${findingIndex}`} finding={finding} />
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{otherFindings.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
Hinweise
|
|
</p>
|
|
{otherFindings.map((finding, findingIndex) => (
|
|
<FindingRow key={`${finding.code}-${findingIndex}`} finding={finding} />
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Ein einzelner Befund mit Code, Meldung und betroffenem Objekt. */
|
|
function FindingRow({ finding }: { readonly finding: import('./restoresApi').ValidationFinding }) {
|
|
return (
|
|
<div className="flex items-start gap-2.5 rounded-md border border-line p-2.5">
|
|
<AlertTriangle
|
|
className={cn(
|
|
'mt-0.5 size-4 shrink-0',
|
|
finding.severity === 'blocking'
|
|
? 'text-critical'
|
|
: finding.severity === 'warning'
|
|
? 'text-warning'
|
|
: 'text-info',
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<StatusBadge
|
|
status={finding.severity === 'blocking' ? 'critical' : finding.severity}
|
|
showDot={false}
|
|
/>
|
|
<code className="font-mono text-xs text-fg-muted">{finding.code}</code>
|
|
</div>
|
|
<p className="mt-1 text-sm text-fg">{finding.message}</p>
|
|
{/* Der Befund nennt die betroffene Datei, nicht nur eine Zahl. */}
|
|
{finding.detail ? (
|
|
<p className="mt-0.5 break-all font-mono text-xs text-fg-muted">
|
|
{finding.detail}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|