Ausgangslage, gemessen statt geschaetzt: Von 99 fachlichen Endpunkten rief die Oberflaeche 23 auf. Schreibend waren es neun, vier davon An- und Abmeldung. Real verwaltbar war: einen Auftrag anlegen, einen Bericht erzeugen, eine Meldung bestaetigen. Das ist ein Leseinstrument, keine Verwaltungskonsole. Dieser Schritt legt das Fundament und macht den ersten Bereich vollstaendig bedienbar. Fundament: - Tailwind v4 und Radix-Primitive (shadcn-Muster). Alles gebuendelt, keine externen Ressourcen — die CSP der Auslieferung laesst sie ohnehin nicht zu. - Farbsystem nach PROMPT.md §106: Semantische Farben ausschliesslich fuer Status, sonst neutral. Die Zuordnung der Fachbegriffe auf die fuenf Bedeutungen steht an genau **einer** Stelle (StatusBadge). Verteilt ueber die Seiten erschiene frueher oder spaeter irgendwo "partial_failure" gruen, und ein Betreiber haelt einen Teilfehler dann fuer einen Erfolg. Ein unbekannter Zustand wird neutral dargestellt, nie gruen. - Neue Seitenhuelle mit fuenf Bereichen, einklappbarer Seitenleiste, Schublade auf schmalen Geraeten und Dark Mode ueber ein Attribut am Wurzelelement (nicht allein ueber die Medienabfrage — eine Konsole, die nachts waehrend einer Stoerung von selbst umschaltet, ist laestig). - `useMutation` fuer schreibende Aufrufe: Doppelklickschutz, Vorgangsnummer bis in die Meldung, kein setState nach dem Aushaengen. `describeApiError` uebersetzt die bekannten Fehlercodes in Saetze **mit Abhilfe**. - Der API-Client sendet jetzt `Idempotency-Key`. Ohne ihn erzeugt ein Doppelklick zwei Auftraege — und bei einer Wiederherstellung zwei gleichzeitige Laeufe in dasselbe Ziel. - Fehlermeldungen nennen immer die `request_id`, kopierbar. Auftraege (Endpunkte, die vorher keine Oberflaeche hatten): - Lauf anstossen, anhalten, fortsetzen, loeschen, laufenden Lauf abbrechen. - Detailseite mit Laufhistorie: Fehlercode, Fehlerklasse und die Auskunft, ob eine Wiederholung ueberhaupt etwas bringt — ein Anmeldefehler behebt sich nicht durch Warten. - **Ein zweiter Anstoss ist kein Fehler, sondern eine Auskunft.** Der 409 wird als Hinweis gezeigt, nicht als Fehlschlag: Der Auftrag laeuft ja, und genau das wollte der Betreiber. - **Loeschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben bestehen; sie gehoeren zum Repository, nicht zum Auftrag. Ohne diesen Hinweis loescht jemand einen Auftrag in der Annahme, Platz zu schaffen. Der Wiederherstellungs-Assistent ist gebaut (vier Schritte, Vorabpruefung als eigener Schritt, die drei Huerden vor dem Ueberschreiben sichtbar umgesetzt), aber noch nicht in eine Seite eingebunden. Drei Lint-Befunde behoben, alle dieselbe Sorte wie in Phase 8 und 12: setState im Effektkoerper und ein Schreibzugriff auf eine Referenz waehrend des Renderns. Der Bestaetigungsdialog haelt seinen Zustand jetzt im Portalinhalt — beim Schliessen verschwindet er von selbst, ein Zuruecksetzen im Effekt entfaellt, und die Huerde steht beim naechsten Oeffnen wieder. Die noch nicht umgebauten Seiten behalten vorerst das alte Stylesheet. Es faellt weg, sobald die letzte umgebaut ist. 69 Tests gruen, tsc sauber, eslint ohne Warnung, Bau 373 KB (115 KB gzip). 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 liess sich eine Wiederherstellung ueber die Oberflaeche gar nicht
|
|
* ausloesen. Wer im Ernstfall Daten zurueckbrauchte, musste `curl` bemuehen —
|
|
* ausgerechnet in der Lage, in der niemand experimentieren will.
|
|
*
|
|
* Der Assistent hat vier Schritte, und der dritte ist der Grund fuer seine
|
|
* Existenz: **Die Vorabpruefung schreibt nichts und stellt fest, ob die
|
|
* Wiederherstellung gelingen kann** — insbesondere, ob jeder benoetigte Block
|
|
* noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas
|
|
* gesichert hat.
|
|
*
|
|
* Die drei Huerden vor dem Ueberschreiben sind hier sichtbar umgesetzt:
|
|
*
|
|
* 1. Das Kennzeichen `overwrite_existing` muss gesetzt werden.
|
|
* 2. Die Berechtigung `restores.overwrite` prueft der Server; sie steckt nicht
|
|
* in `restores.execute`.
|
|
* 3. `confirm_overwrite` verlangt den **woertlich wiederholten Zielpfad**.
|
|
*
|
|
* Laeuft die dritte Huerde ins Leere — weil das Ziel leer ist und nichts
|
|
* ueberschrieben wird —, entfaellt sie. Ein Ritual ohne Anlass gewoehnt das
|
|
* Wegklicken an, und dann wirkt es dort nicht mehr, wo es zaehlt.
|
|
*/
|
|
|
|
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: 'pruefung', label: 'Vorabpruefung' },
|
|
{ key: 'ausfuehren', label: 'Ausfuehren' },
|
|
] 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
|
|
// waere jeder Blick zurueck 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 Vorabpruefung ist fehlgeschlagen',
|
|
describeApiError(apiError),
|
|
apiError.requestId,
|
|
),
|
|
},
|
|
);
|
|
|
|
const createMutation = useMutation(() => createRestore(buildRequest(true)), {
|
|
onSuccess: (session) => {
|
|
toast.showSuccess(
|
|
'Wiederherstellung gestartet',
|
|
'Der Fortschritt erscheint in der Uebersicht.',
|
|
);
|
|
onStarted(session.id);
|
|
},
|
|
onError: (apiError) =>
|
|
toast.showError(
|
|
'Die Wiederherstellung liess sich nicht starten',
|
|
describeApiError(apiError),
|
|
apiError.requestId,
|
|
),
|
|
});
|
|
|
|
const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/');
|
|
|
|
// Die woertliche Bestaetigung wird nur verlangt, wenn tatsaechlich
|
|
// ueberschrieben wird — der Server sagt das nach der Pruefung.
|
|
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>. Zurueck an den
|
|
Ursprungsort zu schreiben ueberschreibt 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 zurueckzuschreiben. Ein Teilbaum-Restore hebt die Einstufung des Wiederherstellungspunkts nicht — er prueft 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 ueberschreiben"
|
|
hint={
|
|
mayOverwrite
|
|
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusaetzlich verlangt der Server danach den woertlich wiederholten Zielpfad.'
|
|
: 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.'
|
|
}
|
|
/>
|
|
|
|
<CheckboxField
|
|
checked={skipPermissions}
|
|
onCheckedChange={setSkipPermissions}
|
|
label="Rechte nicht zurueckschreiben"
|
|
hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten."
|
|
/>
|
|
|
|
<CheckboxField
|
|
checked={skipDeepCheck}
|
|
onCheckedChange={setSkipDeepCheck}
|
|
label="Blockpruefung ueberspringen"
|
|
hint="Beschleunigt die Vorabpruefung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benoetigte Block noch vorhanden ist."
|
|
/>
|
|
</div>
|
|
|
|
{skipDeepCheck ? (
|
|
<Callout tone="warning">
|
|
Ohne Blockpruefung sagt die Vorabpruefung 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>ueberschrieben</strong>.
|
|
</>
|
|
) : (
|
|
<>
|
|
Das Ziel ist leer. Es wird nichts ueberschrieben.
|
|
</>
|
|
)}
|
|
</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 Bestaetigung wiederholen"
|
|
hint="Woertlich. 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 />
|
|
Zurueck
|
|
</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()}
|
|
>
|
|
Vorabpruefung 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 Vorabpruefung.
|
|
*
|
|
* Fehlende Bloecke stehen ganz oben und in Rot. Sie sind der einzige Befund,
|
|
* bei dem feststeht, dass die Wiederherstellung **nicht vollstaendig** 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 Vorabpruefung laeuft. Bei aktiver Blockpruefung wird jeder
|
|
benoetigte Block gesucht — das dauert bei grossen Bestaenden.
|
|
</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 moeglich'}
|
|
</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 waere das Gegenteil
|
|
der Wahrheit — es saehe 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 Pruefung</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} Bloecke fehlen`}>
|
|
Von {report.unique_chunk_count.toLocaleString('de-DE')} benoetigten
|
|
Bloecken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht
|
|
auffindbar. Die Wiederherstellung waere unvollstaendig. Pruefen Sie das
|
|
Repository mit einem Integritaetslauf.
|
|
</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>
|
|
);
|
|
}
|