fix(backups): продолжать наблюдение без повторного создания
This commit is contained in:
1 parent
4aee2a34f1
commit
714af7e872
9 files changed
+265
-74
No files matched your search
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { CreateBackupRequest } from "@mmapp/contracts/backups"
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
import { createBackupsAsync } from "@/shared/api/backups"
|
||||
import { pollBackupJob, backupJobOutcome } from "@/lib/backup-job-poll"
|
||||
import { backupJobStorageKey, createBackupJobCheckpoint, readBackupJobCheckpoint, type BackupJobCheckpoint } from "@/lib/backup-job-checkpoint"
|
||||
import { userApiError } from "@/shared/api/users"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useBackupJob(backendUrl: string, live: boolean, canRead: boolean, canCreate: boolean, onDone: () => Promise<void>) {
|
||||
const [checkpoint, setCheckpoint] = useState<BackupJobCheckpoint | null>(null)
|
||||
const current = useRef<BackupJobCheckpoint | null>(null)
|
||||
const [hydrated, setHydrated] = useState(false), [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null), [storageError, setStorageError] = useState<string | null>(null)
|
||||
const alive = useRef(true), lock = useRef(false), controller = useRef<AbortController | null>(null)
|
||||
const key = backupJobStorageKey(backendUrl)
|
||||
function persist(value: BackupJobCheckpoint | null, required = false) {
|
||||
try { if (value) sessionStorage.setItem(key, JSON.stringify(value)); else sessionStorage.removeItem(key); setStorageError(null) }
|
||||
catch { if (required) throw new Error("Не удалось сохранить задачу в этой вкладке. Запрос не отправлен"); setStorageError("Не удалось обновить запись задачи. Держите вкладку открытой до подтверждения результата") }
|
||||
current.current = value; setCheckpoint(value)
|
||||
}
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
queueMicrotask(() => {
|
||||
if (!alive.current) return
|
||||
try { const saved = live ? readBackupJobCheckpoint(sessionStorage.getItem(key)) : null; current.current = saved; setCheckpoint(saved) }
|
||||
catch { setStorageError("Не удалось восстановить задачу. Новое создание заблокировано, чтобы не отправить повтор") }
|
||||
setHydrated(true)
|
||||
})
|
||||
return () => { alive.current = false; controller.current?.abort() }
|
||||
}, [key, live])
|
||||
async function run(request?: CreateBackupRequest) {
|
||||
if (!live || !hydrated || !canRead || lock.current || !alive.current) return
|
||||
if (!current.current && (!canCreate || storageError || !request)) return
|
||||
lock.current = true; setBusy(true); setError(null)
|
||||
const abort = new AbortController(); controller.current = abort
|
||||
const active = () => alive.current && !abort.signal.aborted
|
||||
let saved = current.current, creating = false
|
||||
try {
|
||||
if (!saved) {
|
||||
saved = createBackupJobCheckpoint(request!, crypto.randomUUID()); persist(saved, true); creating = true
|
||||
await createBackupsAsync(backendUrl, { ...saved.request, serverIds: saved.request.serverIds.map(String) }, { signal: abort.signal })
|
||||
if (!active()) return
|
||||
saved = { ...saved, accepted: true }; persist(saved)
|
||||
}
|
||||
// A restored/unknown request is only observed. In-memory backend records cannot safely justify another POST after 404.
|
||||
const target = saved.request
|
||||
const result = await pollBackupJob(backendUrl, target.requestId, target, { signal: abort.signal, onJob: job => { if (active()) persist({ request: target, accepted: true, job }) } })
|
||||
if (!active()) return
|
||||
const outcome = backupJobOutcome(result)
|
||||
if (outcome.fullSuccess) toast.success("API подтвердил создание всех резервных копий", { id: "backup-job-" + result.id })
|
||||
else {
|
||||
const failures = [...outcome.deviceFailures.map(failure => failure.serverId + ": " + failure.error), ...outcome.uploadFailures.map(file => file.filename + ": " + file.uploadError)]
|
||||
setError("Создано " + outcome.created + " из " + result.total + ". " + (failures.join("; ") || "Задача завершилась с ошибкой"))
|
||||
}
|
||||
await onDone()
|
||||
} catch (reason) {
|
||||
if (!active()) return
|
||||
if (creating && !saved?.accepted && reason instanceof ApiClientError && [400, 403, 404, 409].includes(reason.status)) persist(null)
|
||||
const message = reason instanceof ApiClientError && reason.status === 404 && current.current ? "Задача недоступна на сервере. После рестарта сведения могли быть потеряны; проверьте историю и устройства" : userApiError(reason)
|
||||
setError(message + (current.current ? ". Повторяется только чтение этой задачи; новое создание не запускается" : ""))
|
||||
} finally { lock.current = false; if (active()) setBusy(false) }
|
||||
}
|
||||
function reset() {
|
||||
if (lock.current || !checkpoint?.job || !["done", "failed"].includes(checkpoint.job.status)) return false
|
||||
try { persist(null, true); setError(null); return true } catch (reason) { setError(userApiError(reason)); return false }
|
||||
}
|
||||
return { checkpoint, hydrated, busy, error, storageError, run, reset }
|
||||
}
|
||||
Reference in new issue
Block a user