/** * 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(null); const jobsResource = useApiResource( 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> = [ { key: 'name', header: 'Auftrag', render: (job) => (

{job.name}

{job.schedule_description} {job.backup_mode === 'always_full' ? ' · immer voll' : job.full_backup_weekday !== undefined ? ` · ${WEEKDAY_LABELS[job.full_backup_weekday]}s voll` : ''}

), }, { key: 'status', header: 'Zustand', render: (job) => , }, { key: 'last_outcome', header: 'Letzter Lauf', render: (job) => job.last_run_at ? (
{formatRelativeTime(job.last_run_at)}
) : ( // Kein Lauf ist nicht "erfolgreich". Ohne diesen Unterschied sähe ein // nie gelaufener Auftrag aus wie ein funktionierender. Noch nie gelaufen ), }, { key: 'next_run_at', header: 'Nächster Lauf', render: (job) => job.next_run_at ? ( {formatRelativeTime(job.next_run_at)} ) : ( {job.status === 'paused' ? 'Angehalten' : 'Nur manuell'} ), }, { key: 'sources', header: 'Quellen', numeric: true, render: (job) => {job.sources.length}, }, { key: 'actions', header: '', className: 'w-px', render: (job) => (
clickEvent.stopPropagation()} onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()} role="presentation" > {mayRunJobs ? ( <> ) : null} {mayWriteJobs ? ( ) : null}
), }, ]; if (jobsResource.loadState === 'failed' && jobsResource.loadError) { return ( <> ); } const writableRepositories = (repositoriesResource.data ?? []).filter( (repository) => repository.accepts_backups, ); return ( <> setIsWizardOpen(true)}> Auftrag anlegen ) : 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 ? ( Kein Ziel nimmt Sicherungen an. Prüfen Sie die Repositories. ) : null} job.id} onRowClick={(job) => onOpenJob(job.id)} isLoading={jobsResource.loadState === 'loading'} emptyTitle="Noch kein Sicherungsauftrag" emptyDescription="Ohne Auftrag wird nichts gesichert." emptyAction={ mayWriteJobs ? ( ) : null } /> {isWizardOpen ? ( setIsWizardOpen(false)} onJobCreated={(createdJob) => { setIsWizardOpen(false); jobsResource.reload(); toast.showSuccess('Auftrag angelegt', createdJob.name); }} /> ) : null} { 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:{' '} {jobPendingDeletion?.name} } onConfirm={() => { if (jobPendingDeletion) { void deleteMutation.run(jobPendingDeletion.id); } }} > Die Wiederherstellungspunkte bleiben bestehen — es wird kein Speicher frei. ); }