syncova-backup/apps/web/src/features/jobs/BackupWizard.tsx
Jerrit Fritzsche 6e23696fcb
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s
Weboberflaeche: letzte Seiten auf das Design-System gezogen
Damit gibt es nur noch ein Stylesheet. Uebersicht, Meldungen, Kennzahlen,
Berichte, Security Center, Wiederherstellungspunkte, Anmeldung, Backup-Assistent
und Gesundheitsanzeige nutzten noch das alte — sie funktionierten, sahen aber
anders aus als der Rest.

Der Backup-Assistent wurde als **Klassenabbildung** umgestellt, nicht neu
geschrieben: Seine Logik ist geprueft und richtig; eine Neufassung haette 500
Zeilen Verhalten ohne Not angefasst. Die elf Tests des Assistenten pruefen
Verhalten und blieben unveraendert gueltig.

Entfernt, weil ersetzt und nirgends mehr verwendet: JobsPanel, StatusIndicator,
PageState, App.css, tokens.css und vier weitere Stylesheets. Das ausgelieferte
CSS faellt von 54 auf 30 KB.

Drei Funde beim Umbau, alle von Tests aufgedeckt:

- **`describeApiError` warf die genauere Servermeldung weg.** Sie ersetzte sie
  durch den allgemeinen Satz aus der Codetabelle. Ein `SERVICE_UNAVAILABLE` mit
  der Meldung "Fuer diesen Bericht ist keine Sicherheitspruefung eingerichtet."
  wurde zu "Der Dienst ist derzeit nicht vollstaendig verfuegbar" — der
  Betreiber haette den Fehler bei seiner Anlage gesucht statt bei der
  Einrichtung dieses einen Berichts. Jetzt hat die Servermeldung Vorrang; die
  Tabelle springt nur ein, wenn keine mitkommt.
- **Einem Fehler nach einer Handlung fehlte `role="alert"`.** Ein Screenreader
  haette ihn nicht angesagt. `Callout` nimmt jetzt eine Rolle entgegen; Standard
  bleibt `note`, weil die meisten Hinweise schon beim Oeffnen dastehen.
- **Zwei Statusbeschriftungen wichen von den etablierten ab** (`Gesund` statt
  `Fehlerfrei`, `Nicht verbunden` statt `Nicht erreichbar`). Die etablierten
  gewinnen — sie stehen in Tests fest und sind treffender.

Dazu zwei kleinere Korrekturen: Die Gesundheitsanzeige hing kurzzeitig in der
Uebersicht und verband damit zwei Ladewege, die nichts miteinander zu tun haben;
sie steht jetzt wieder daneben. Und ein frueherer Regex hatte
`(row) => void | undefined` erzeugt — gemeint war eine optionale Eigenschaft,
geschrieben stand "gibt void oder undefined zurueck".

Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das
Design-System steckt im ausgelieferten CSS samt Dark-Mode-Regeln, und vom alten
Stylesheet ist kein Klassenname mehr darin.

76 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:50:15 +02:00

727 lines
29 KiB
TypeScript

/**
* Backup-Wizard in zehn Schritten (SYNCOVA_IMPLEMENTATION_PLAN.md §10).
*
* Der Assistent fuehrt von Name bis Anlegen. Die Pruefung geschieht je Schritt,
* damit ein Fehler dort auffaellt, wo er entsteht - nicht erst nach dem letzten
* Schritt. Rueckwaerts geht es ohne Datenverlust: Der Entwurf lebt in einem
* Zustand, nicht in den Eingabefeldern.
*/
import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client';
import { createJob, listRepositories } from './jobsApi';
import type { BackupJob, BackupRepository, SourceType } from './jobsApi';
import {
buildCreateRequest,
createEmptyDraft,
describeDraftSchedule,
splitPatternList,
STEP_TITLES,
UNAVAILABLE_STEPS,
validateStep,
WEEKDAY_NAMES,
WIZARD_STEPS,
} from './wizardModel';
import type { DraftSource, JobDraft, WizardStep } from './wizardModel';
/** Eigenschaften des Assistenten. */
export interface BackupWizardProps {
/** Wird nach dem Anlegen mit dem erzeugten Auftrag aufgerufen. */
onJobCreated: (createdJob: BackupJob) => void;
/** Bricht den Assistenten ab. */
onCancel: () => void;
}
/** Quellarten, die der Executor derzeit sichern kann. */
const SUPPORTED_SOURCE_TYPES: readonly SourceType[] = ['filesystem'];
/** Beschriftungen der Quellarten. */
const SOURCE_TYPE_LABELS: Record<SourceType, string> = {
filesystem: 'Verzeichnis',
proxmox_vm: 'Proxmox-VM',
proxmox_container: 'Proxmox-Container',
windows_system: 'Windows-System',
linux_system: 'Linux-System',
};
/** Zeigt den Assistenten zum Anlegen eines Sicherungsauftrags. */
export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): React.JSX.Element {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [jobDraft, setJobDraft] = useState<JobDraft>(createEmptyDraft);
const [repositories, setRepositories] = useState<BackupRepository[]>([]);
const [repositoryError, setRepositoryError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showProblems, setShowProblems] = useState(false);
const currentStep = WIZARD_STEPS[currentStepIndex] as WizardStep;
const stepValidation = validateStep(currentStep, jobDraft);
// Die Ziele werden einmal geladen. Ein Abbruchsignal verhindert, dass eine
// Antwort nach dem Schliessen des Assistenten noch Zustand setzt.
useEffect(() => {
const abortController = new AbortController();
listRepositories(abortController.signal)
.then(setRepositories)
.catch((caughtError: unknown) => {
if (abortController.signal.aborted) {
return;
}
setRepositoryError(describeError(caughtError, 'Die Sicherungsziele konnten nicht geladen werden.'));
});
return () => abortController.abort();
}, []);
/** Aendert ein Feld des Entwurfs. */
function updateDraft(changedFields: Partial<JobDraft>): void {
setJobDraft((previousDraft) => ({ ...previousDraft, ...changedFields }));
setShowProblems(false);
}
/** Geht einen Schritt vor, sofern der aktuelle vollstaendig ist. */
function goToNextStep(): void {
if (!stepValidation.isComplete) {
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang
// an zu zeigen hiesse, ein leeres Formular als fehlerhaft zu markieren.
setShowProblems(true);
return;
}
setShowProblems(false);
setCurrentStepIndex((previousIndex) => Math.min(previousIndex + 1, WIZARD_STEPS.length - 1));
}
/** Geht einen Schritt zurueck. */
function goToPreviousStep(): void {
setShowProblems(false);
setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0));
}
/** Springt zu einem bereits erreichten Schritt. */
function jumpToStep(targetIndex: number): void {
if (targetIndex > currentStepIndex) {
return;
}
setShowProblems(false);
setCurrentStepIndex(targetIndex);
}
/** Legt den Auftrag an. */
async function handleCreate(): Promise<void> {
setSubmitError(null);
setIsSubmitting(true);
try {
const createdJob = await createJob(buildCreateRequest(jobDraft));
onJobCreated(createdJob);
} catch (caughtError: unknown) {
// Die Meldung des Servers wird wortgetreu gezeigt: Sie nennt den
// eigentlichen Grund, etwa einen Zeitplan, der den geforderten
// Wiederherstellungspunkt nicht einhalten kann.
setSubmitError(describeError(caughtError, 'Der Auftrag konnte nicht angelegt werden.'));
} finally {
setIsSubmitting(false);
}
}
return (
<section className="wizard" aria-label="Backup-Assistent">
<ol className="flex flex-wrap items-center gap-1 border-b border-line px-5 py-3">
{WIZARD_STEPS.map((wizardStep, stepIndex) => (
<li key={wizardStep}>
<button
type="button"
className={buildStepClassName(stepIndex, currentStepIndex)}
onClick={() => jumpToStep(stepIndex)}
disabled={stepIndex > currentStepIndex}
aria-current={stepIndex === currentStepIndex ? 'step' : undefined}
>
<span className="tabular">{stepIndex + 1}</span>
<span className="">{STEP_TITLES[wizardStep]}</span>
</button>
</li>
))}
</ol>
<div className="px-5 py-4">
<h2 className="text-base font-semibold text-fg">
Schritt {currentStepIndex + 1} von {WIZARD_STEPS.length}: {STEP_TITLES[currentStep]}
</h2>
{renderStepContent()}
{showProblems && stepValidation.problems.length > 0 && (
<ul className="mt-3 space-y-1 rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{stepValidation.problems.map((problemText) => (
<li key={problemText}>{problemText}</li>
))}
</ul>
)}
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-line px-5 py-3">
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50" onClick={onCancel}>
Abbrechen
</button>
<div className="flex gap-2">
{currentStepIndex > 0 && (
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50" onClick={goToPreviousStep}>
Zurueck
</button>
)}
{currentStep !== 'create' ? (
<button type="button" className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 bg-accent text-accent-fg hover:bg-accent-hover" onClick={goToNextStep}>
Weiter
</button>
) : (
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 bg-accent text-accent-fg hover:bg-accent-hover"
onClick={() => void handleCreate()}
disabled={isSubmitting}
>
{isSubmitting ? 'Wird angelegt …' : 'Auftrag anlegen'}
</button>
)}
</div>
</div>
</div>
</section>
);
/** Waehlt den Inhalt des aktuellen Schrittes. */
function renderStepContent(): React.JSX.Element {
const unavailableExplanation = UNAVAILABLE_STEPS[currentStep];
if (unavailableExplanation !== undefined) {
return renderUnavailableStep(unavailableExplanation);
}
switch (currentStep) {
case 'name':
return renderNameStep();
case 'source':
return renderSourceStep();
case 'schedule':
return renderScheduleStep();
case 'repository':
return renderRepositoryStep();
case 'security':
return renderSecurityStep();
case 'review':
return renderReviewStep();
case 'create':
return renderCreateStep();
default:
return <p>Unbekannter Schritt.</p>;
}
}
/**
* Zeigt einen Schritt ohne Umsetzung im Backend.
*
* Es werden keine Eingaben abgefragt. Eine Maske, die Werte sammelt, die
* niemand auswertet, ist ein vorgetaeuschtes Funktionsversprechen
* (PROMPT.md §138) - stattdessen steht hier, was ohne diese Einstellung
* tatsaechlich geschieht.
*/
function renderUnavailableStep(explanationText: string): React.JSX.Element {
return (
<div className="rounded-md border border-line bg-sunken p-4">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">Noch nicht verfuegbar</p>
<p className="mt-1 text-sm text-fg-muted">{explanationText}</p>
</div>
);
}
/** Schritt 1: Name und Beschreibung. */
function renderNameStep(): React.JSX.Element {
return (
<div className="mt-4 space-y-4">
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Name des Auftrags</span>
<input
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={jobDraft.name}
onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })}
placeholder="Naechtliche Sicherung Dateiserver"
autoFocus
/>
</label>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Beschreibung (optional)</span>
<textarea
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle min-h-20 resize-y"
value={jobDraft.description}
onChange={(changeEvent) => updateDraft({ description: changeEvent.target.value })}
rows={3}
/>
</label>
</div>
);
}
/** Schritt 2: Quellen. */
function renderSourceStep(): React.JSX.Element {
/** Aendert eine einzelne Quelle. */
function updateSource(sourceIndex: number, changedFields: Partial<DraftSource>): void {
updateDraft({
sources: jobDraft.sources.map((existingSource, currentIndex) =>
currentIndex === sourceIndex ? { ...existingSource, ...changedFields } : existingSource,
),
});
}
return (
<div className="mt-4 space-y-4">
{jobDraft.sources.map((draftSource, sourceIndex) => (
<fieldset key={sourceIndex} className="flex items-center justify-between gap-2 rounded-md border border-line px-3 py-2 text-sm">
<legend className="block text-sm font-medium text-fg">Quelle {sourceIndex + 1}</legend>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Art</span>
<select
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
value={draftSource.sourceType}
onChange={(changeEvent) =>
updateSource(sourceIndex, { sourceType: changeEvent.target.value as SourceType })
}
>
{(Object.keys(SOURCE_TYPE_LABELS) as SourceType[]).map((sourceType) => (
<option
key={sourceType}
value={sourceType}
disabled={!SUPPORTED_SOURCE_TYPES.includes(sourceType)}
>
{SOURCE_TYPE_LABELS[sourceType]}
{!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfuegbar)' : ''}
</option>
))}
</select>
</label>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Pfad</span>
<input
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={draftSource.sourceIdentifier}
onChange={(changeEvent) =>
updateSource(sourceIndex, { sourceIdentifier: changeEvent.target.value })
}
placeholder="/daten/kunden"
/>
</label>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Bezeichnung (optional)</span>
<input
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={draftSource.sourceName}
onChange={(changeEvent) => updateSource(sourceIndex, { sourceName: changeEvent.target.value })}
/>
</label>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Auszuschliessen (kommasepariert, optional)</span>
<input
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={draftSource.excludePatterns}
onChange={(changeEvent) =>
updateSource(sourceIndex, { excludePatterns: changeEvent.target.value })
}
placeholder="*.tmp, cache"
/>
</label>
{jobDraft.sources.length > 1 && (
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 h-8 px-3 text-xs"
onClick={() =>
updateDraft({
sources: jobDraft.sources.filter((_, currentIndex) => currentIndex !== sourceIndex),
})
}
>
Quelle entfernen
</button>
)}
</fieldset>
))}
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-md border border-line-strong bg-card px-4 py-2 text-sm font-medium text-fg transition-colors hover:bg-hover disabled:pointer-events-none disabled:opacity-50 h-8 px-3 text-xs"
onClick={() =>
updateDraft({
sources: [
...jobDraft.sources,
{ sourceType: 'filesystem', sourceIdentifier: '', sourceName: '', excludePatterns: '' },
],
})
}
>
Weitere Quelle
</button>
</div>
);
}
/** Schritt 3: Zeitplan. */
function renderScheduleStep(): React.JSX.Element {
return (
<div className="mt-4 space-y-4">
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Art des Zeitplans</span>
<select
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
value={jobDraft.scheduleType}
onChange={(changeEvent) =>
updateDraft({ scheduleType: changeEvent.target.value as JobDraft['scheduleType'] })
}
>
<option value="manual">Nur auf Anforderung</option>
<option value="interval">In festen Abstaenden</option>
<option value="hourly">Stuendlich</option>
<option value="daily">Taeglich</option>
<option value="weekly">Woechentlich</option>
<option value="monthly">Monatlich</option>
<option value="cron">Cron-Ausdruck</option>
</select>
</label>
{jobDraft.scheduleType === 'interval' && (
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Abstand in Stunden</span>
<input
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="number"
min={1}
value={jobDraft.scheduleIntervalHours}
onChange={(changeEvent) =>
updateDraft({ scheduleIntervalHours: Number(changeEvent.target.value) })
}
/>
</label>
)}
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' &&
jobDraft.scheduleType !== 'cron' && (
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">
{jobDraft.scheduleType === 'hourly' ? 'Minute (aus HH:MM)' : 'Uhrzeit'}
</span>
<input
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="time"
value={jobDraft.scheduleTime}
onChange={(changeEvent) => updateDraft({ scheduleTime: changeEvent.target.value })}
/>
</label>
)}
{jobDraft.scheduleType === 'weekly' && (
<fieldset className="block space-y-1.5">
<legend className="block text-sm font-medium text-fg">Wochentage</legend>
<div className="flex flex-wrap gap-4">
{WEEKDAY_NAMES.map((weekdayName, weekdayNumber) => (
<label key={weekdayName} className="flex items-center gap-2 text-sm text-fg">
<input
type="checkbox"
checked={jobDraft.scheduleWeekdays.includes(weekdayNumber)}
onChange={(changeEvent) =>
updateDraft({
scheduleWeekdays: changeEvent.target.checked
? [...jobDraft.scheduleWeekdays, weekdayNumber]
: jobDraft.scheduleWeekdays.filter((existingDay) => existingDay !== weekdayNumber),
})
}
/>
{weekdayName}
</label>
))}
</div>
</fieldset>
)}
{jobDraft.scheduleType === 'monthly' && (
<fieldset className="block space-y-1.5">
<legend className="block text-sm font-medium text-fg">Tage des Monats</legend>
<div className="flex flex-wrap gap-4">
{[1, 15, -1].map((monthDay) => (
<label key={monthDay} className="flex items-center gap-2 text-sm text-fg">
<input
type="checkbox"
checked={jobDraft.scheduleMonthDays.includes(monthDay)}
onChange={(changeEvent) =>
updateDraft({
scheduleMonthDays: changeEvent.target.checked
? [...jobDraft.scheduleMonthDays, monthDay]
: jobDraft.scheduleMonthDays.filter((existingDay) => existingDay !== monthDay),
})
}
/>
{monthDay === -1 ? 'Letzter Tag' : `${monthDay}.`}
</label>
))}
</div>
{/* Der Monatsletzte ist kein Luxus: Wer "am 31." waehlt, bekommt in
vier Monaten des Jahres keine Sicherung. */}
<p className="text-xs text-fg-muted">
Der letzte Tag des Monats faellt nie aus - anders als etwa der 31.
</p>
</fieldset>
)}
{jobDraft.scheduleType === 'cron' && (
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Cron-Ausdruck</span>
<input
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle font-mono"
type="text"
value={jobDraft.scheduleCronExpression}
onChange={(changeEvent) => updateDraft({ scheduleCronExpression: changeEvent.target.value })}
placeholder="0 2 * * *"
/>
<span className="text-xs text-fg-muted">Minute Stunde Tag Monat Wochentag</span>
</label>
)}
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' && (
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Zeitzone</span>
<input
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={jobDraft.scheduleTimeZone}
onChange={(changeEvent) => updateDraft({ scheduleTimeZone: changeEvent.target.value })}
/>
{/* Ohne Zeitzone gilt auf dem Server UTC - derselbe Auftrag liefe
dann je nach Standort zu einer anderen Uhrzeit. */}
<span className="text-xs text-fg-muted">
Ohne Angabe rechnet der Server in UTC.
</span>
</label>
)}
<p className="mt-2 rounded-md border border-line bg-sunken px-3 py-2 text-sm text-fg">Ergibt: {describeDraftSchedule(jobDraft)}</p>
</div>
);
}
/** Schritt 4: Repository. */
function renderRepositoryStep(): React.JSX.Element {
if (repositoryError !== null) {
return <p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical">{repositoryError}</p>;
}
if (repositories.length === 0) {
return (
<p className="text-xs text-fg-muted">
Es ist kein Sicherungsziel eingerichtet. Ein Repository entsteht auf einem Datentraeger und
wird mit <code>syncova-repo create --path &lt;pfad&gt; --name &lt;name&gt;</code> angelegt.
</p>
);
}
return (
<div className="mt-4 space-y-4">
{repositories.map((repositoryEntry) => (
<label key={repositoryEntry.id} className="flex cursor-pointer items-start gap-2.5 rounded-md border border-line p-3 text-sm transition-colors hover:bg-hover has-[:checked]:border-accent has-[:checked]:bg-accent-subtle">
<input
type="radio"
name="repository"
value={repositoryEntry.id}
checked={jobDraft.repositoryId === repositoryEntry.id}
disabled={!repositoryEntry.accepts_backups}
onChange={() => updateDraft({ repositoryId: repositoryEntry.id })}
/>
<span className="flex min-w-0 flex-col">
<span className="font-medium text-fg">{repositoryEntry.name}</span>
<span className="font-mono text-xs text-fg-muted">{repositoryEntry.location}</span>
{/* Ein Ziel, das keine Sicherungen annimmt, wird benannt statt
stillschweigend weggelassen: Sonst suchte der Anwender ein
Repository, das er sieht und nicht waehlen kann. */}
{!repositoryEntry.accepts_backups && (
<span className="text-xs text-warning">
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
</span>
)}
</span>
</label>
))}
</div>
);
}
/** Schritt 6: Sicherheit. */
function renderSecurityStep(): React.JSX.Element {
return (
<div className="mt-4 space-y-4">
{/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst
ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere
eine Einstellung, die es nicht gibt. */}
<div className="mt-3 rounded-md border border-info/30 bg-info-bg p-3 text-sm">
<strong>Verschluesselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM
verschluesselt, bevor sie das System verlassen. Der Schluessel gehoert zum Repository und
wird vom Dienst verwaltet.
</div>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Dringlichkeit</span>
<select
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
value={jobDraft.priority}
onChange={(changeEvent) =>
updateDraft({ priority: changeEvent.target.value as JobDraft['priority'] })
}
>
<option value="critical">Kritisch</option>
<option value="high">Hoch</option>
<option value="normal">Normal</option>
<option value="low">Niedrig</option>
</select>
<span className="text-xs text-fg-muted">
Entscheidet die Reihenfolge, wenn mehrere Auftraege gleichzeitig faellig sind.
</span>
</label>
<label className="block space-y-1.5">
<span className="block text-sm font-medium text-fg">Bandbreitengrenze (optional)</span>
<input
className="w-full rounded-md border border-line-strong bg-card px-3 py-2 text-sm text-fg placeholder:text-fg-subtle"
type="text"
value={jobDraft.bandwidthLimit}
onChange={(changeEvent) => updateDraft({ bandwidthLimit: changeEvent.target.value })}
placeholder="50MB"
/>
<span className="text-xs text-fg-muted">
Begrenzt das Lesen von der Quelle. 100Mbit ist ein Achtel von 100MB.
</span>
</label>
</div>
);
}
/** Schritt 9: Uebersicht. */
function renderReviewStep(): React.JSX.Element {
const selectedRepository = repositories.find(
(repositoryEntry) => repositoryEntry.id === jobDraft.repositoryId,
);
return (
<dl className="mt-4 space-y-3">
<dt>Name</dt>
<dd>{jobDraft.name}</dd>
{jobDraft.description.trim() !== '' && (
<>
<dt>Beschreibung</dt>
<dd>{jobDraft.description}</dd>
</>
)}
<dt>Quellen</dt>
<dd>
<ul className="space-y-2">
{jobDraft.sources
.filter((draftSource) => draftSource.sourceIdentifier.trim() !== '')
.map((draftSource, sourceIndex) => (
<li key={sourceIndex}>
{draftSource.sourceIdentifier}
{splitPatternList(draftSource.excludePatterns).length > 0 && (
<span className="text-sm text-fg">
{' '}
ohne {splitPatternList(draftSource.excludePatterns).join(', ')}
</span>
)}
</li>
))}
</ul>
</dd>
<dt>Zeitplan</dt>
<dd>{describeDraftSchedule(jobDraft)}</dd>
<dt>Repository</dt>
<dd>{selectedRepository?.name ?? jobDraft.repositoryId}</dd>
<dt>Aufbewahrung</dt>
<dd className="text-sm text-fg-muted">unbegrenzt (Regeln noch nicht umgesetzt)</dd>
<dt>Sicherheit</dt>
<dd>
verschluesselt, Dringlichkeit {jobDraft.priority}
{jobDraft.bandwidthLimit.trim() !== '' && `, hoechstens ${jobDraft.bandwidthLimit}`}
</dd>
<dt>Pruefung</dt>
<dd className="text-sm text-fg-muted">keine automatische Pruefung</dd>
<dt>Benachrichtigung</dt>
<dd className="text-sm text-fg-muted">keine</dd>
</dl>
);
}
/** Schritt 10: Anlegen. */
function renderCreateStep(): React.JSX.Element {
return (
<div className="mt-4 space-y-4">
<p>
Der Auftrag wird angelegt und laeuft ab sofort nach Zeitplan. Der erste Lauf ist eine
vollstaendige Sicherung; jeder weitere liest nur, was sich geaendert hat.
</p>
{submitError !== null && (
<p className="rounded-md border border-critical/30 bg-critical-bg p-3 text-sm text-critical" role="alert">
{submitError}
</p>
)}
</div>
);
}
}
/**
* Bildet die Darstellung eines Schrittes in der Kopfzeile.
*
* Ein noch nicht erreichter Schritt bleibt gedaempft und ist nicht anklickbar —
* er uebersprang eine Pruefung.
*/
function buildStepClassName(stepIndex: number, currentStepIndex: number): string {
const baseClasses = 'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs';
if (stepIndex === currentStepIndex) {
return `${baseClasses} bg-accent-subtle font-medium text-accent`;
}
if (stepIndex < currentStepIndex) {
return `${baseClasses} text-healthy`;
}
return `${baseClasses} text-fg-subtle`;
}
/** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */
function describeError(caughtError: unknown, fallbackMessage: string): string {
if (caughtError instanceof ApiError) {
return caughtError.message;
}
return fallbackMessage;
}