Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
721 lines
25 KiB
TypeScript
721 lines
25 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';
|
|
import './BackupWizard.css';
|
|
|
|
/** 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="wizard__steps">
|
|
{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="wizard__step-number">{stepIndex + 1}</span>
|
|
<span className="wizard__step-title">{STEP_TITLES[wizardStep]}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
|
|
<div className="wizard__panel">
|
|
<h2 className="wizard__heading">
|
|
Schritt {currentStepIndex + 1} von {WIZARD_STEPS.length}: {STEP_TITLES[currentStep]}
|
|
</h2>
|
|
|
|
{renderStepContent()}
|
|
|
|
{showProblems && stepValidation.problems.length > 0 && (
|
|
<ul className="wizard__problems" role="alert">
|
|
{stepValidation.problems.map((problemText) => (
|
|
<li key={problemText}>{problemText}</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<div className="wizard__actions">
|
|
<button type="button" className="wizard__button" onClick={onCancel}>
|
|
Abbrechen
|
|
</button>
|
|
|
|
<div className="wizard__navigation">
|
|
{currentStepIndex > 0 && (
|
|
<button type="button" className="wizard__button" onClick={goToPreviousStep}>
|
|
Zurueck
|
|
</button>
|
|
)}
|
|
|
|
{currentStep !== 'create' ? (
|
|
<button type="button" className="wizard__button wizard__button--primary" onClick={goToNextStep}>
|
|
Weiter
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="wizard__button wizard__button--primary"
|
|
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="wizard__unavailable">
|
|
<p className="wizard__unavailable-badge">Noch nicht verfuegbar</p>
|
|
<p className="wizard__unavailable-text">{explanationText}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Schritt 1: Name und Beschreibung. */
|
|
function renderNameStep(): React.JSX.Element {
|
|
return (
|
|
<div className="wizard__fields">
|
|
<label className="wizard__field">
|
|
<span className="wizard__label">Name des Auftrags</span>
|
|
<input
|
|
className="wizard__input"
|
|
type="text"
|
|
value={jobDraft.name}
|
|
onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })}
|
|
placeholder="Naechtliche Sicherung Dateiserver"
|
|
autoFocus
|
|
/>
|
|
</label>
|
|
|
|
<label className="wizard__field">
|
|
<span className="wizard__label">Beschreibung (optional)</span>
|
|
<textarea
|
|
className="wizard__input wizard__input--area"
|
|
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="wizard__fields">
|
|
{jobDraft.sources.map((draftSource, sourceIndex) => (
|
|
<fieldset key={sourceIndex} className="wizard__source">
|
|
<legend className="wizard__label">Quelle {sourceIndex + 1}</legend>
|
|
|
|
<label className="wizard__field">
|
|
<span className="wizard__label">Art</span>
|
|
<select
|
|
className="wizard__input"
|
|
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="wizard__field">
|
|
<span className="wizard__label">Pfad</span>
|
|
<input
|
|
className="wizard__input"
|
|
type="text"
|
|
value={draftSource.sourceIdentifier}
|
|
onChange={(changeEvent) =>
|
|
updateSource(sourceIndex, { sourceIdentifier: changeEvent.target.value })
|
|
}
|
|
placeholder="/daten/kunden"
|
|
/>
|
|
</label>
|
|
|
|
<label className="wizard__field">
|
|
<span className="wizard__label">Bezeichnung (optional)</span>
|
|
<input
|
|
className="wizard__input"
|
|
type="text"
|
|
value={draftSource.sourceName}
|
|
onChange={(changeEvent) => updateSource(sourceIndex, { sourceName: changeEvent.target.value })}
|
|
/>
|
|
</label>
|
|
|
|
<label className="wizard__field">
|
|
<span className="wizard__label">Auszuschliessen (kommasepariert, optional)</span>
|
|
<input
|
|
className="wizard__input"
|
|
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="wizard__button wizard__button--small"
|
|
onClick={() =>
|
|
updateDraft({
|
|
sources: jobDraft.sources.filter((_, currentIndex) => currentIndex !== sourceIndex),
|
|
})
|
|
}
|
|
>
|
|
Quelle entfernen
|
|
</button>
|
|
)}
|
|
</fieldset>
|
|
))}
|
|
|
|
<button
|
|
type="button"
|
|
className="wizard__button wizard__button--small"
|
|
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="wizard__fields">
|
|
<label className="wizard__field">
|
|
<span className="wizard__label">Art des Zeitplans</span>
|
|
<select
|
|
className="wizard__input"
|
|
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="wizard__field">
|
|
<span className="wizard__label">Abstand in Stunden</span>
|
|
<input
|
|
className="wizard__input"
|
|
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="wizard__field">
|
|
<span className="wizard__label">
|
|
{jobDraft.scheduleType === 'hourly' ? 'Minute (aus HH:MM)' : 'Uhrzeit'}
|
|
</span>
|
|
<input
|
|
className="wizard__input"
|
|
type="time"
|
|
value={jobDraft.scheduleTime}
|
|
onChange={(changeEvent) => updateDraft({ scheduleTime: changeEvent.target.value })}
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
{jobDraft.scheduleType === 'weekly' && (
|
|
<fieldset className="wizard__field">
|
|
<legend className="wizard__label">Wochentage</legend>
|
|
<div className="wizard__checkbox-row">
|
|
{WEEKDAY_NAMES.map((weekdayName, weekdayNumber) => (
|
|
<label key={weekdayName} className="wizard__checkbox">
|
|
<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="wizard__field">
|
|
<legend className="wizard__label">Tage des Monats</legend>
|
|
<div className="wizard__checkbox-row">
|
|
{[1, 15, -1].map((monthDay) => (
|
|
<label key={monthDay} className="wizard__checkbox">
|
|
<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="wizard__hint">
|
|
Der letzte Tag des Monats faellt nie aus - anders als etwa der 31.
|
|
</p>
|
|
</fieldset>
|
|
)}
|
|
|
|
{jobDraft.scheduleType === 'cron' && (
|
|
<label className="wizard__field">
|
|
<span className="wizard__label">Cron-Ausdruck</span>
|
|
<input
|
|
className="wizard__input wizard__input--mono"
|
|
type="text"
|
|
value={jobDraft.scheduleCronExpression}
|
|
onChange={(changeEvent) => updateDraft({ scheduleCronExpression: changeEvent.target.value })}
|
|
placeholder="0 2 * * *"
|
|
/>
|
|
<span className="wizard__hint">Minute Stunde Tag Monat Wochentag</span>
|
|
</label>
|
|
)}
|
|
|
|
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' && (
|
|
<label className="wizard__field">
|
|
<span className="wizard__label">Zeitzone</span>
|
|
<input
|
|
className="wizard__input"
|
|
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="wizard__hint">
|
|
Ohne Angabe rechnet der Server in UTC.
|
|
</span>
|
|
</label>
|
|
)}
|
|
|
|
<p className="wizard__preview">Ergibt: {describeDraftSchedule(jobDraft)}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Schritt 4: Repository. */
|
|
function renderRepositoryStep(): React.JSX.Element {
|
|
if (repositoryError !== null) {
|
|
return <p className="wizard__error">{repositoryError}</p>;
|
|
}
|
|
|
|
if (repositories.length === 0) {
|
|
return (
|
|
<p className="wizard__hint">
|
|
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="wizard__fields">
|
|
{repositories.map((repositoryEntry) => (
|
|
<label key={repositoryEntry.id} className="wizard__radio">
|
|
<input
|
|
type="radio"
|
|
name="repository"
|
|
value={repositoryEntry.id}
|
|
checked={jobDraft.repositoryId === repositoryEntry.id}
|
|
disabled={!repositoryEntry.accepts_backups}
|
|
onChange={() => updateDraft({ repositoryId: repositoryEntry.id })}
|
|
/>
|
|
|
|
<span className="wizard__radio-body">
|
|
<span className="wizard__radio-title">{repositoryEntry.name}</span>
|
|
<span className="wizard__radio-detail">{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="wizard__radio-warning">
|
|
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
|
|
</span>
|
|
)}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Schritt 6: Sicherheit. */
|
|
function renderSecurityStep(): React.JSX.Element {
|
|
return (
|
|
<div className="wizard__fields">
|
|
{/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst
|
|
ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere
|
|
eine Einstellung, die es nicht gibt. */}
|
|
<div className="wizard__notice">
|
|
<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="wizard__field">
|
|
<span className="wizard__label">Dringlichkeit</span>
|
|
<select
|
|
className="wizard__input"
|
|
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="wizard__hint">
|
|
Entscheidet die Reihenfolge, wenn mehrere Auftraege gleichzeitig faellig sind.
|
|
</span>
|
|
</label>
|
|
|
|
<label className="wizard__field">
|
|
<span className="wizard__label">Bandbreitengrenze (optional)</span>
|
|
<input
|
|
className="wizard__input"
|
|
type="text"
|
|
value={jobDraft.bandwidthLimit}
|
|
onChange={(changeEvent) => updateDraft({ bandwidthLimit: changeEvent.target.value })}
|
|
placeholder="50MB"
|
|
/>
|
|
<span className="wizard__hint">
|
|
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="wizard__summary">
|
|
<dt>Name</dt>
|
|
<dd>{jobDraft.name}</dd>
|
|
|
|
{jobDraft.description.trim() !== '' && (
|
|
<>
|
|
<dt>Beschreibung</dt>
|
|
<dd>{jobDraft.description}</dd>
|
|
</>
|
|
)}
|
|
|
|
<dt>Quellen</dt>
|
|
<dd>
|
|
<ul className="wizard__summary-list">
|
|
{jobDraft.sources
|
|
.filter((draftSource) => draftSource.sourceIdentifier.trim() !== '')
|
|
.map((draftSource, sourceIndex) => (
|
|
<li key={sourceIndex}>
|
|
{draftSource.sourceIdentifier}
|
|
{splitPatternList(draftSource.excludePatterns).length > 0 && (
|
|
<span className="wizard__summary-detail">
|
|
{' '}
|
|
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="wizard__summary-unavailable">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="wizard__summary-unavailable">keine automatische Pruefung</dd>
|
|
|
|
<dt>Benachrichtigung</dt>
|
|
<dd className="wizard__summary-unavailable">keine</dd>
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
/** Schritt 10: Anlegen. */
|
|
function renderCreateStep(): React.JSX.Element {
|
|
return (
|
|
<div className="wizard__fields">
|
|
<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="wizard__error" role="alert">
|
|
{submitError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Bildet die CSS-Klasse eines Schrittes in der Kopfzeile. */
|
|
function buildStepClassName(stepIndex: number, currentStepIndex: number): string {
|
|
if (stepIndex === currentStepIndex) {
|
|
return 'wizard__step wizard__step--current';
|
|
}
|
|
|
|
if (stepIndex < currentStepIndex) {
|
|
return 'wizard__step wizard__step--done';
|
|
}
|
|
|
|
return 'wizard__step';
|
|
}
|
|
|
|
/** 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;
|
|
}
|