fix(backups): подтверждать операции в текущем контексте
This commit is contained in:
1 parent
714af7e872
commit
4c5604bf87
9 files changed
+174
-49
No files matched your search
+54
-38
@@ -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)}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user