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>
727 lines
29 KiB
TypeScript
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 <pfad> --name <name></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;
|
|
}
|