syncova-backup/apps/web/src/features/jobs/JobsPage.tsx
Jerrit Fritzsche 8e98cc7510
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 28s
Sicherungsart je Auftrag, Agenten-Token und -Anleitung, update.sh
**Sicherungsart.** Bisher entschied der Executor allein: Liegt ein Elternbackup
vor, wird inkrementell gesichert. Jetzt waehlbar je Auftrag —

- `incremental` (Standard, bisheriges Verhalten),
- `always_full`, oder
- inkrementell **mit einem festen Volltag** ("immer freitags").

Migration 000014 mit drei CHECKs. Der dritte lehnt "immer voll" zusammen mit
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll, und die Regel gehoert in
die Datenbank, weil im Code jede Stelle sie einhalten muesste — eine vergisst
es. Real geprueft: der Widerspruch wird abgewiesen.

Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
Server in UTC, bekaeme ein Betreiber in Berlin seine Vollsicherung am
Donnerstagabend und wunderte sich, warum sie freitags fehlt. Vier Tests, der
entscheidende durch Mutation als fangend bestaetigt.

Zur Einordnung, weil es leicht verwechselt wird: Der Platzbedarf steigt bei
"immer voll" **nicht** nennenswert — unveraenderte Bloecke werden dedupliziert
und liegen weiterhin nur einmal im Repository. Was steigt, ist die Laufzeit.
Steht so in der Maske.

**Aufnahme-Token zeigte "undefined".** Das Feld heisst `token`, nicht
`enrollment_token` — Letzteres ist der Name im *Anfrage*koerper der
Registrierung. Der dritte Formfehler dieser Art; alle konsumierten Endpunkte
sind jetzt gegen den laufenden Dienst abgeglichen.

**Der Aufnahmedialog** hat jetzt eine vollstaendige Anleitung fuer Linux und
Windows mit fertig ausgefuellten Befehlen — Serveradresse und Token eingesetzt,
je Schritt einzeln kopierbar. Eine Anleitung mit Platzhaltern fuehrt
zuverlaessig dazu, dass jemand `<token>` woertlich einsetzt und dann eine
Fehlermeldung sucht, die nichts mit seinem Problem zu tun hat. Dazu die beiden
Stolperstellen: `--state` will eine Datei, und der Agent braucht Schreibzugriff
aufs Repository. Beim Windows-Weg steht dabei, dass der Dienst nie auf echter
Hardware lief.

**update.sh ruestet die Wiederherstellungsflaeche nach** — anlegen und in
ReadWritePaths eintragen. Ein Schritt, den man von Hand ausfuehren muss, wird
uebersehen und faellt erst im Ernstfall auf.

84 Tests im Frontend, alle Go-Tests gruen, shellcheck sauber.

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

364 lines
11 KiB
TypeScript

/**
* Sicherungsaufträge — Liste und Bedienung.
*
* Bis hierher zeigte die Oberfläche Aufträge an, die sich nicht bedienen
* ließen. Ein Betreiber, der einen Lauf außerhalb des Zeitplans anstoßen
* wollte, brauchte dafür die Kommandozeile.
*
* Zwei Entscheidungen tragen diese Seite:
*
* - **Ein zweiter Anstoß ist kein Fehler, sondern eine Auskunft.** Der Server
* antwortet mit 409, wenn der Auftrag bereits läuft. Das als Fehler zu
* melden wäre technisch richtig und praktisch irreführend — der Auftrag
* läuft ja, und genau das wollte der Betreiber.
* - **Löschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben
* bestehen; sie gehören zum Repository, nicht zum Auftrag. Wer das nicht
* weiß, löscht einen Auftrag in der Annahme, damit Platz zu schaffen.
*/
import {
MoreHorizontal,
Pause,
Play,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useState } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError, useMutation } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Button,
Callout,
Card,
ConfirmDialog,
DataTable,
ErrorState,
StatusBadge,
useToast,
type TableColumn,
} from '@/components/ui';
import { formatRelativeTime } from '@/lib/utils';
import { BackupWizard } from './BackupWizard';
import {
deleteJob,
listJobs,
listRepositories,
pauseJob,
resumeJob,
runJob,
WEEKDAY_LABELS,
type BackupJob,
} from './jobsApi';
export function JobsPage({
grantedPermissions,
onOpenJob,
}: {
readonly grantedPermissions: readonly string[];
readonly onOpenJob: (jobIdentifier: string) => void;
}) {
const toast = useToast();
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [jobPendingDeletion, setJobPendingDeletion] = useState<BackupJob | null>(null);
const jobsResource = useApiResource<BackupJob[]>(
useCallback((abortSignal) => listJobs(abortSignal), []),
);
const repositoriesResource = useApiResource(
useCallback((abortSignal) => listRepositories(abortSignal), []),
);
const mayRunJobs =
grantedPermissions.includes('*') || grantedPermissions.includes('jobs.run');
const mayWriteJobs =
grantedPermissions.includes('*') || grantedPermissions.includes('jobs.write');
const runMutation = useMutation(runJob, {
onSuccess: () => {
// 202: eingereiht, nicht "gesichert". Die Meldung sagt genau das —
// "Sicherung erfolgreich" wäre hier schlicht gelogen.
toast.showSuccess('Lauf eingereiht', 'Der Fortschritt erscheint in der Laufhistorie.');
jobsResource.reload();
},
onError: (apiError) => {
if (apiError.statusCode === 409) {
// Der Auftrag läuft bereits — das ist die Antwort auf die Frage,
// nicht ein Fehler des Betreibers.
toast.showInfo('Dieser Auftrag läuft bereits', describeApiError(apiError));
return;
}
toast.showError('Der Lauf ließ sich nicht anstoßen', describeApiError(apiError), apiError.requestId);
},
});
const pauseMutation = useMutation(pauseJob, {
onSuccess: () => {
toast.showSuccess('Auftrag angehalten', 'Geplante Läufe entfallen, bis er fortgesetzt wird.');
jobsResource.reload();
},
onError: (apiError) =>
toast.showError('Der Auftrag ließ sich nicht anhalten', describeApiError(apiError), apiError.requestId),
});
const resumeMutation = useMutation(resumeJob, {
onSuccess: () => {
toast.showSuccess('Auftrag fortgesetzt');
jobsResource.reload();
},
onError: (apiError) =>
toast.showError('Der Auftrag ließ sich nicht fortsetzen', describeApiError(apiError), apiError.requestId),
});
const deleteMutation = useMutation(deleteJob, {
onSuccess: () => {
toast.showSuccess(
'Auftrag gelöscht',
'Die vorhandenen Wiederherstellungspunkte bleiben bestehen.',
);
setJobPendingDeletion(null);
jobsResource.reload();
},
onError: (apiError) =>
toast.showError('Der Auftrag ließ sich nicht löschen', describeApiError(apiError), apiError.requestId),
});
const jobs = jobsResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<BackupJob>> = [
{
key: 'name',
header: 'Auftrag',
render: (job) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{job.name}</p>
<p className="truncate text-xs text-fg-muted">
{job.schedule_description}
{job.backup_mode === 'always_full'
? ' · immer voll'
: job.full_backup_weekday !== undefined
? ` · ${WEEKDAY_LABELS[job.full_backup_weekday]}s voll`
: ''}
</p>
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (job) => <StatusBadge status={job.status} />,
},
{
key: 'last_outcome',
header: 'Letzter Lauf',
render: (job) =>
job.last_run_at ? (
<div className="flex items-center gap-2">
<StatusBadge status={job.last_outcome} showDot={false} />
<span className="text-xs text-fg-muted">
{formatRelativeTime(job.last_run_at)}
</span>
</div>
) : (
// Kein Lauf ist nicht "erfolgreich". Ohne diesen Unterschied sähe ein
// nie gelaufener Auftrag aus wie ein funktionierender.
<span className="text-xs text-fg-muted">Noch nie gelaufen</span>
),
},
{
key: 'next_run_at',
header: 'Nächster Lauf',
render: (job) =>
job.next_run_at ? (
<span className="text-sm text-fg-muted">{formatRelativeTime(job.next_run_at)}</span>
) : (
<span className="text-xs text-fg-muted">
{job.status === 'paused' ? 'Angehalten' : 'Nur manuell'}
</span>
),
},
{
key: 'sources',
header: 'Quellen',
numeric: true,
render: (job) => <span className="text-sm">{job.sources.length}</span>,
},
{
key: 'actions',
header: '',
className: 'w-px',
render: (job) => (
<div
className="flex items-center justify-end gap-1"
// Ohne das öffnete jeder Klick auf eine Schaltfläche zusätzlich die
// Detailseite der Zeile.
onClick={(clickEvent) => clickEvent.stopPropagation()}
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
role="presentation"
>
{mayRunJobs ? (
<>
<Button
variant="ghost"
size="icon"
title="Jetzt sichern"
aria-label={`Auftrag ${job.name} jetzt sichern`}
isLoading={runMutation.isRunning}
onClick={() => void runMutation.run(job.id)}
>
<Play />
</Button>
<Button
variant="ghost"
size="icon"
title={job.status === 'paused' ? 'Fortsetzen' : 'Anhalten'}
aria-label={
job.status === 'paused'
? `Auftrag ${job.name} fortsetzen`
: `Auftrag ${job.name} anhalten`
}
onClick={() =>
job.status === 'paused'
? void resumeMutation.run(job.id)
: void pauseMutation.run(job.id)
}
>
{job.status === 'paused' ? <Play /> : <Pause />}
</Button>
</>
) : null}
{mayWriteJobs ? (
<Button
variant="ghost"
size="icon"
title="Löschen"
aria-label={`Auftrag ${job.name} löschen`}
onClick={() => setJobPendingDeletion(job)}
>
<Trash2 />
</Button>
) : null}
<Button
variant="ghost"
size="icon"
title="Einzelheiten"
aria-label={`Einzelheiten zu ${job.name}`}
onClick={() => onOpenJob(job.id)}
>
<MoreHorizontal />
</Button>
</div>
),
},
];
if (jobsResource.loadState === 'failed' && jobsResource.loadError) {
return (
<>
<PageHeader title="Sicherungsaufträge" />
<Card>
<ErrorState
message={describeApiError(jobsResource.loadError)}
requestId={jobsResource.loadError.requestId}
onRetry={jobsResource.reload}
/>
</Card>
</>
);
}
const writableRepositories = (repositoriesResource.data ?? []).filter(
(repository) => repository.accepts_backups,
);
return (
<>
<PageHeader
title="Sicherungsaufträge"
description="Was wann wohin gesichert wird."
actions={
mayWriteJobs ? (
<Button variant="primary" onClick={() => setIsWizardOpen(true)}>
<Plus />
Auftrag anlegen
</Button>
) : null
}
/>
{/* Ohne beschreibbares Ziel ist kein Auftrag möglich. Das erst beim
Absenden des Assistenten zu melden wäre die unfreundlichste Stelle. */}
{repositoriesResource.loadState === 'loaded' && writableRepositories.length === 0 ? (
<Callout tone="warning" title="Kein beschreibbares Repository" className="mb-4">
Kein Ziel nimmt Sicherungen an. Prüfen Sie die Repositories.
</Callout>
) : null}
<Card>
<DataTable
columns={tableColumns}
rows={jobs}
getRowKey={(job) => job.id}
onRowClick={(job) => onOpenJob(job.id)}
isLoading={jobsResource.loadState === 'loading'}
emptyTitle="Noch kein Sicherungsauftrag"
emptyDescription="Ohne Auftrag wird nichts gesichert."
emptyAction={
mayWriteJobs ? (
<Button variant="primary" onClick={() => setIsWizardOpen(true)}>
<Plus />
Ersten Auftrag anlegen
</Button>
) : null
}
/>
</Card>
{isWizardOpen ? (
<BackupWizard
onCancel={() => setIsWizardOpen(false)}
onJobCreated={(createdJob) => {
setIsWizardOpen(false);
jobsResource.reload();
toast.showSuccess('Auftrag angelegt', createdJob.name);
}}
/>
) : null}
<ConfirmDialog
open={jobPendingDeletion !== null}
onOpenChange={(isOpen) => {
if (!isOpen) {
setJobPendingDeletion(null);
}
}}
title="Auftrag löschen"
description={`„${jobPendingDeletion?.name ?? ''}" wird entfernt.`}
confirmLabel="Löschen"
isDestructive
isLoading={deleteMutation.isRunning}
confirmationWord={jobPendingDeletion?.name}
confirmationHint={
<>
Tippen Sie zur Bestätigung den Namen des Auftrags:{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{jobPendingDeletion?.name}
</code>
</>
}
onConfirm={() => {
if (jobPendingDeletion) {
void deleteMutation.run(jobPendingDeletion.id);
}
}}
>
<Callout tone="info">
Die Wiederherstellungspunkte bleiben bestehen — es wird kein Speicher frei.
</Callout>
</ConfirmDialog>
</>
);
}