diff --git a/app/(main)/backups/page.tsx b/app/(main)/backups/page.tsx index 09be9267..80eb7cde 100644 --- a/app/(main)/backups/page.tsx +++ b/app/(main)/backups/page.tsx @@ -23,9 +23,7 @@ import { can } from "@/lib/auth" import { userApiError } from "@/shared/api/users" import { toFrontendServer } from "@/entities/server/model/mappers" import { - createBackupsAsync, deleteBackup, - getBackupJob, getBackupScheduleSettings, getBackupStorageSettings, listBackups, @@ -38,6 +36,8 @@ import { } from "@/shared/api/backups" import { ApiClientError, requestJson, requestBlob } from "@/shared/api/http-client" import { toast } from "sonner" +import { useBackupJob } from "@/lib/use-backup-job" +import { backupJobOutcome } from "@/lib/backup-job-poll" import { backupSchedulePayload, backupStoragePayload } from "@/lib/backup-settings" import { BackupsHistory } from "@/components/backups/backups-history" import { @@ -85,11 +85,10 @@ function BackupsContent() { const [search, setSearch] = useState("") const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery()) const [liveServers, setLiveServers] = useState(isLive ? [] : mockServers) - const canCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage") + const baseCanCreate = canWrite && canReadServers && liveServers.length > 0 && current("servers") && current("storage") const [loading, setLoading] = useState(false) const [opBusy, setOpBusy] = useState(false) const [opError, setOpError] = useState(null) - const [backupJobId, setBackupJobId] = useState(null) const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false) const [testBusy, setTestBusy] = useState(false) const [syncBusy, setSyncBusy] = useState(false) @@ -165,6 +164,12 @@ function BackupsContent() { return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort() } }, [loadLive]) + const job = useBackupJob(backendUrl, isLive, canRead, baseCanCreate && !opBusy && !scheduleSaveBusy, loadLive) + const checkpoint = job.checkpoint + const jobTerminal = !!checkpoint?.job && ["done", "failed"].includes(checkpoint.job.status) + const backupJobId = checkpoint && !jobTerminal ? checkpoint.request.requestId : null + const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError + function startSettingsMutation() { if (!alive.current || settingsLock.current || opBusy || backupJobId) return null settingsLock.current = true @@ -246,60 +251,12 @@ function BackupsContent() { } async function handleManualBackup() { - if (!canCreate || manualServers.size === 0 || opBusy) return - setOpError(null) - try { - const res = await createBackupsAsync(backendUrl, { - serverIds: [...manualServers], - notes: manualNotes || undefined, - }) - setBackupJobId(res.jobId) - setManualOpen(false) - setManualServers(new Set()) - setManualNotes("") - setTab("history") - } catch (e) { - setOpError(e instanceof Error ? e.message : "Ошибка создания бэкапа") - } + if (checkpoint) { await job.run(); return } + if (!canCreate || !manualServers.size || settingsLock.current || opBusy) return + if ([...manualServers].some(id => !liveServers.some(server => server.id === id))) { setOpError("Выбранный сервер отсутствует; обновите выбор"); return } + await job.run({ serverIds: [...manualServers], notes: manualNotes || undefined }) } - useEffect(() => { - if (!backupJobId) return - toast.info("Бэкап выполняется в фоне...") - let cancelled = false - const timer = setInterval(() => { - void (async () => { - try { - const job = await getBackupJob(backendUrl, backupJobId) - if (cancelled) return - if (job.status === "done") { - clearInterval(timer) - setBackupJobId(null) - if (job.failures.length > 0) { - setOpError(`Часть бэкапов не создалась: ${job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ")}`) - } else { - toast.success("Бэкап успешно завершён") - } - await loadLive() - } else if (job.status === "failed") { - clearInterval(timer) - setBackupJobId(null) - setOpError(job.failures.map((f) => `${f.serverId}: ${f.error}`).join("; ") || "Ошибка фоновой задачи бэкапа") - await loadLive() - } - } catch (e) { - clearInterval(timer) - setBackupJobId(null) - setOpError(e instanceof Error ? e.message : "Ошибка опроса задачи бэкапа") - } - })() - }, 1200) - return () => { - cancelled = true - clearInterval(timer) - } - }, [backendUrl, backupJobId, loadLive]) - useEffect(() => { if (opError) toast.error(opError) }, [opError]) @@ -377,6 +334,7 @@ function BackupsContent() { : "default" function openCreate(all: boolean) { + if (checkpoint) { setManualStep(3); setManualOpen(true); return } if (!canCreate) return setManualServers(all ? new Set(liveServers.map((s) => s.id)) : new Set()) setManualNotes("") @@ -395,7 +353,7 @@ function BackupsContent() { variant="outline" size="sm" onClick={() => openCreate(true)} - disabled={!canCreate || loading} + disabled={!canCreate || loading || job.busy} > Снять со всех @@ -404,10 +362,10 @@ function BackupsContent() { type="button" size="sm" onClick={() => openCreate(false)} - disabled={!canCreate || loading} + disabled={checkpoint ? !canRead : !canCreate || loading || job.busy} > - Новый бэкап + {checkpoint ? "Задача резервного копирования" : "Новый бэкап"} } @@ -428,6 +386,7 @@ function BackupsContent() { )} {opError && Операция не подтверждена{opError}} + {(job.error || job.storageError) && {jobTerminal ? "Задача завершена с ограничениями" : "Результат задачи пока неизвестен"}{job.error || job.storageError}} - {backupJobId ? "Задача выполняется" : schedSummary} + {backupJobId ? (checkpoint?.accepted ? "Задача наблюдается" : "Результат запроса пока неизвестен") : schedSummary} {backupJobId - ? "Снимок конфигурации снимается в фоне. Список обновится после завершения." + ? "Закрытие формы не отменяет задачу. Проверка продолжает чтение того же ID." : statusDetail} @@ -532,8 +491,8 @@ function BackupsContent() { scheduleKnown={known("schedule")} storageKnown={known("storage")} storageDirty={storageIsDirty} - scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers")} - storageWritable={canWrite && current("storage")} + scheduleWritable={canWrite && canReadServers && current("schedule") && current("servers") && !backupJobId} + storageWritable={canWrite && current("storage") && !backupJobId} allEnabled={allEnabled} onAllEnabledChange={value => { scheduleDirty.current = true; setAllEnabled(value) }} onTest={handleTestStorage} @@ -554,7 +513,7 @@ function BackupsContent() { step={manualStep} onStepChange={setManualStep} servers={liveServers} - selected={manualServers} + selected={checkpoint ? new Set(checkpoint.request.serverIds.map(String)) : manualServers} onToggle={(id) => { setManualServers((prev) => { const next = new Set(prev) @@ -565,10 +524,17 @@ function BackupsContent() { }} onSelectAll={() => setManualServers(new Set(liveServers.map((s) => s.id)))} onClear={() => setManualServers(new Set())} - notes={manualNotes} + notes={checkpoint?.request.notes ?? manualNotes} onNotesChange={setManualNotes} destinationLabel={destinationLabel} - busy={backupJobId !== null} + busy={job.busy} + locked={!!checkpoint} + terminal={jobTerminal} + canObserve={canRead} + jobId={checkpoint?.request.requestId} + jobStatus={checkpoint?.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Прервана" })[checkpoint.job.status] + ": " + checkpoint.job.completed + "/" + checkpoint.job.total : checkpoint?.accepted ? "Запрос принят, результат ещё неизвестен" : "Результат запроса ещё неизвестен"} + result={checkpoint?.job && jobTerminal ? "Создано " + backupJobOutcome(checkpoint.job).created + " из " + checkpoint.job.total : undefined} + onReset={() => { if (job.reset()) { setManualServers(new Set()); setManualNotes(""); setManualStep(1) } }} onSubmit={handleManualBackup} /> diff --git a/components/backups/backup-create-sheet.tsx b/components/backups/backup-create-sheet.tsx index f28e76d5..fd653dd0 100644 --- a/components/backups/backup-create-sheet.tsx +++ b/components/backups/backup-create-sheet.tsx @@ -43,6 +43,13 @@ export function BackupCreateSheet({ destinationLabel, busy, onSubmit, + locked = false, + terminal = false, + canObserve = true, + jobId, + jobStatus, + result, + onReset, }: { open: boolean onOpenChange: (open: boolean) => void @@ -57,16 +64,25 @@ export function BackupCreateSheet({ onNotesChange: (value: string) => void destinationLabel: string busy: boolean + locked?: boolean + terminal?: boolean + canObserve?: boolean + jobId?: string + jobStatus?: string + result?: string + onReset: () => void onSubmit: () => void }) { return ( { onOpenChange(v); if (!v) onStepChange(1) }}> - + - Новый бэкап + {locked ? "Задача резервного копирования" : "Новый бэкап"} Снять конфигурацию вручную с выбранных серверов + {locked &&

ID: {jobId}

{jobStatus}

{result &&

{result}

}

Закрытие формы не отменяет задачу. Повторная проверка не создаёт новые копии.

} +
@@ -141,6 +157,8 @@ export function BackupCreateSheet({ onNotesChange(e.target.value)} /> @@ -157,15 +175,16 @@ export function BackupCreateSheet({ +
- - }>Отмена - {step > 1 && ( + + }>Закрыть + {!locked && step > 1 && ( )} - {step < 3 ? ( + {locked ? <>{terminal && } : step < 3 ? (