fix(backups): подтверждать операции в текущем контексте

This commit is contained in:
Denozordec committed 2026-10-01 16:11:26 +07:00
1 parent 714af7e872
commit 4c5604bf87
9 files changed
+174 -49

No files matched your search

+54 -38
View File
@@ -24,6 +24,7 @@ import { userApiError } from "@/shared/api/users"
import { toFrontendServer } from "@/entities/server/model/mappers"
import {
deleteBackup,
downloadBackup,
getBackupScheduleSettings,
getBackupStorageSettings,
listBackups,
@@ -34,7 +35,7 @@ import {
testBackupStorage,
type BackupItem,
} from "@/shared/api/backups"
import { ApiClientError, requestJson, requestBlob } from "@/shared/api/http-client"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
import { toast } from "sonner"
import { useBackupJob } from "@/lib/use-backup-job"
import { backupJobOutcome } from "@/lib/backup-job-poll"
@@ -75,6 +76,9 @@ function BackupsContent() {
const alive = useRef(true)
const scheduleDirty = useRef(false)
const storageDirty = useRef(false)
const operationLock = useRef(false)
const operationController = useRef<AbortController | null>(null)
const downloadControllers = useRef(new Map<string, AbortController>())
const settingsLock = useRef(false)
const settingsController = useRef<AbortController | null>(null)
const known = (resource: Resource) => !isLive || resources[resource].known
@@ -160,8 +164,9 @@ function BackupsContent() {
}, [backendUrl, isLive, canRead, canReadServers, mapApiBackupToUi])
useEffect(() => {
alive.current = true
const downloads = downloadControllers.current
queueMicrotask(() => { if (alive.current) void loadLive() })
return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort() }
return () => { alive.current = false; reader.current?.abort(); settingsController.current?.abort(); operationController.current?.abort(); downloads.forEach(controller => controller.abort()) }
}, [loadLive])
const job = useBackupJob(backendUrl, isLive, canRead, baseCanCreate && !opBusy && !scheduleSaveBusy, loadLive)
@@ -171,7 +176,7 @@ function BackupsContent() {
const canCreate = baseCanCreate && job.hydrated && !checkpoint && !job.storageError
function startSettingsMutation() {
if (!alive.current || settingsLock.current || opBusy || backupJobId) return null
if (!alive.current || settingsLock.current || operationLock.current || backupJobId) return null
settingsLock.current = true
const abort = new AbortController()
settingsController.current = abort
@@ -261,46 +266,55 @@ function BackupsContent() {
if (opError) toast.error(opError)
}, [opError])
function startOperation() {
if (!canWrite || !alive.current || operationLock.current || settingsLock.current || backupJobId) return null
operationLock.current = true
const abort = new AbortController(); operationController.current = abort
reader.current?.abort(); revision.current += 1; setLoading(false)
setOpBusy(true); setOpError(null)
return abort
}
function finishOperation(abort: AbortController) {
if (operationController.current !== abort) return
operationLock.current = false; operationController.current = null
if (alive.current) setOpBusy(false)
}
async function handleDelete() {
if (!canWrite || !deleteTarget) return
setOpBusy(true)
setOpError(null)
if (!deleteTarget) return
const target = deleteTarget, abort = startOperation(); if (!abort) return
try {
await deleteBackup(backendUrl, deleteTarget.id)
setDeleteTarget(null)
await deleteBackup(backendUrl, target.id, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
setBackupList(previous => previous.filter(backup => backup.id !== target.id)); setDeleteTarget(null)
toast.success("API подтвердил удаление резервной копии")
await loadLive()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка удаления бэкапа")
} finally {
setOpBusy(false)
}
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { finishOperation(abort) }
}
async function handleRestore() {
if (!canWrite || !restoreTarget) return
setOpBusy(true)
setOpError(null)
if (!restoreTarget || !restoreTarget.serverId) return
const target = restoreTarget, abort = startOperation(); if (!abort) return
try {
await restoreBackup(backendUrl, restoreTarget.id)
toast.success(`Конфигурация восстановлена на ${restoreTarget.server}`)
const result = await restoreBackup(backendUrl, target.id, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
if (result.filename !== target.filename) throw new Error("Ответ восстановления относится к другому файлу")
toast.success("API подтвердил импорт " + result.filename + " на " + result.serverName)
setRestoreTarget(null)
} catch (e) {
setOpError(e instanceof Error ? e.message : "Ошибка восстановления")
} finally {
setOpBusy(false)
}
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { finishOperation(abort) }
}
async function handleDownload(id: string, fallbackFilename: string) {
if (!isLive || !canRead) return
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = fallbackFilename
a.click()
URL.revokeObjectURL(url)
async function handleDownload(id: string, filename: string) {
if (!isLive || !canRead || !alive.current || downloadControllers.current.has(id)) return
const abort = new AbortController(); downloadControllers.current.set(id, abort)
try {
const blob = await downloadBackup(backendUrl, id, { signal: abort.signal })
if (!alive.current || abort.signal.aborted) return
const url = URL.createObjectURL(blob)
try {
const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click()
} finally { URL.revokeObjectURL(url) }
} catch (error) { if (alive.current && !abort.signal.aborted) setOpError(userApiError(error)) }
finally { if (downloadControllers.current.get(id) === abort) downloadControllers.current.delete(id) }
}
const autoCount = backupList.filter((b) => b.kind === "auto").length
@@ -457,11 +471,11 @@ function BackupsContent() {
filters={advancedFilters}
onFiltersChange={setAdvancedFilters}
onDownload={handleDownload}
onRestore={setRestoreTarget}
onDelete={setDeleteTarget}
onRestore={backup => { setOpError(null); setRestoreTarget(backup) }}
onDelete={backup => { setOpError(null); setDeleteTarget(backup) }}
onCreate={() => openCreate(false)}
canCreate={canCreate}
canWrite={canWrite}
canWrite={canWrite && !opBusy && !scheduleSaveBusy && !backupJobId}
canDownload={isLive && canRead}
/>}
</TabsContent>
@@ -541,12 +555,14 @@ function BackupsContent() {
<BackupDeleteDialog
backup={deleteTarget}
busy={opBusy}
error={opError}
onConfirm={() => void handleDelete()}
onCancel={() => setDeleteTarget(null)}
/>
<BackupRestoreDialog
backup={restoreTarget}
busy={opBusy}
error={opError}
onConfirm={() => void handleRestore()}
onCancel={() => setRestoreTarget(null)}
/>