fix(ui): восстанавливать задачу выпуска без повторной операции
This commit is contained in:
1 parent
97526bf06f
commit
f5a71728ac
10 files changed
+322
-81
No files matched your search
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { createCertificateIssueCheckpoint, readCertificateIssueCheckpoint, certificateIssueStorageKey } from "./certificate-issue-checkpoint"
|
||||
const id = "11111111-1111-4111-8111-111111111111"
|
||||
const target = { serverId: "1", certName: "cert", domainNames: ["test.example"] }
|
||||
test("checkpoint persists ID and immutable target before acceptance", () => {
|
||||
const value = createCertificateIssueCheckpoint(target, id)
|
||||
target.domainNames.push("later.example")
|
||||
assert.deepEqual(value.request.domainNames, ["test.example"])
|
||||
assert.equal(value.accepted, false); assert.equal(value.job, null)
|
||||
assert.deepEqual(readCertificateIssueCheckpoint(JSON.stringify(value)), value)
|
||||
})
|
||||
test("malformed and foreign restored observations are rejected", () => {
|
||||
assert.throws(() => readCertificateIssueCheckpoint("bad"))
|
||||
assert.throws(() => readCertificateIssueCheckpoint("{}"))
|
||||
const value = createCertificateIssueCheckpoint({ ...target, domainNames: ["test.example"] }, id)
|
||||
const job = { id, serverId: "2", certName: "cert", domainNames: ["test.example"], status: "done", step: "done", requestedAt: "2026-10-01T00:00:00Z" }
|
||||
assert.throws(() => readCertificateIssueCheckpoint(JSON.stringify({ ...value, accepted: true, job })))
|
||||
assert.throws(() => readCertificateIssueCheckpoint(JSON.stringify({ ...value, job: { ...job, serverId: "1" } })))
|
||||
})
|
||||
test("storage context is isolated per backend and absent checkpoint stays absent", () => {
|
||||
assert.notEqual(certificateIssueStorageKey("https://one.example.test"), certificateIssueStorageKey("https://two.example.test"))
|
||||
assert.equal(readCertificateIssueCheckpoint(null), null)
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod"
|
||||
import { certificateIssueRequestSchema, certificateIssueJobSchema, type CertificateIssueRequest } from "@mmapp/contracts/certificates"
|
||||
|
||||
export const certificateIssueCheckpointSchema = z.object({
|
||||
request: certificateIssueRequestSchema.extend({ requestId: z.string().uuid() }),
|
||||
accepted: z.boolean(),
|
||||
job: certificateIssueJobSchema.nullable(),
|
||||
}).superRefine((value, ctx) => {
|
||||
const job = value.job, request = value.request
|
||||
if (job && (job.id !== request.requestId || job.serverId !== String(request.serverId) || job.certName !== request.certName || JSON.stringify(job.domainNames) !== JSON.stringify(request.domainNames))) ctx.addIssue({ code: "custom", message: "Сохранённая задача относится к другой цели" })
|
||||
if (job && !value.accepted) ctx.addIssue({ code: "custom", message: "Наблюдение задачи не подтверждает принятие запроса" })
|
||||
})
|
||||
export type CertificateIssueCheckpoint = z.infer<typeof certificateIssueCheckpointSchema>
|
||||
export function createCertificateIssueCheckpoint(request: CertificateIssueRequest, requestId: string) {
|
||||
return certificateIssueCheckpointSchema.parse({ request: { ...request, requestId }, accepted: false, job: null })
|
||||
}
|
||||
export function readCertificateIssueCheckpoint(raw: string | null) {
|
||||
return raw === null ? null : certificateIssueCheckpointSchema.parse(JSON.parse(raw))
|
||||
}
|
||||
export function certificateIssueStorageKey(backendUrl: string) { return "mmapp:certificate-issue:" + backendUrl }
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { CertificateIssueRequest } from "@mmapp/contracts/certificates"
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
import { createCertificateIssueJob, getCertificateIssueJob } from "@/shared/api/certificates"
|
||||
import { pollCertificateIssue } from "@/lib/certificate-issue-poll"
|
||||
import { certificateIssueStorageKey, createCertificateIssueCheckpoint, readCertificateIssueCheckpoint, type CertificateIssueCheckpoint } from "@/lib/certificate-issue-checkpoint"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function useCertificateIssue(backendUrl: string, canRead: boolean, canWrite: boolean, onDone: () => Promise<void>, live = true) {
|
||||
const [checkpoint, setCheckpoint] = useState<CertificateIssueCheckpoint | null>(null)
|
||||
const current = useRef<CertificateIssueCheckpoint | null>(null)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [storageError, setStorageError] = useState<string | null>(null)
|
||||
const alive = useRef(true), lock = useRef(false)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const key = certificateIssueStorageKey(backendUrl)
|
||||
|
||||
function persist(value: CertificateIssueCheckpoint | null, requireStorage = false) {
|
||||
try {
|
||||
if (value) sessionStorage.setItem(key, JSON.stringify(value)); else sessionStorage.removeItem(key)
|
||||
setStorageError(null)
|
||||
} catch {
|
||||
if (requireStorage) throw new Error("Не удалось сохранить задачу в этой вкладке.")
|
||||
setStorageError("Не удалось обновить сохранённую задачу. Держите вкладку открытой до подтверждения результата.")
|
||||
}
|
||||
current.current = value
|
||||
setCheckpoint(value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
queueMicrotask(() => {
|
||||
if (!alive.current) return
|
||||
try {
|
||||
const saved = live ? readCertificateIssueCheckpoint(sessionStorage.getItem(key)) : null
|
||||
current.current = saved; setCheckpoint(saved)
|
||||
} catch { setStorageError("Не удалось восстановить сохранённую задачу. Новый выпуск заблокирован, чтобы не создать повторную задачу.") }
|
||||
setHydrated(true)
|
||||
})
|
||||
const cancel = () => { alive.current = false; controller.current?.abort() }
|
||||
return cancel
|
||||
}, [key, live])
|
||||
|
||||
async function run(request?: CertificateIssueRequest) {
|
||||
if (!hydrated || !canRead || lock.current || !alive.current) return
|
||||
if (!current.current && (!canWrite || 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 = createCertificateIssueCheckpoint(request!, crypto.randomUUID())
|
||||
persist(saved, true)
|
||||
creating = true
|
||||
} else if (!saved.accepted) {
|
||||
try {
|
||||
const job = await getCertificateIssueJob(backendUrl, saved.request.requestId, { signal: abort.signal })
|
||||
if (!active()) return
|
||||
// pollCertificateIssue проверит цель до записи наблюдения.
|
||||
if (job.id !== saved.request.requestId) throw new Error("Получена другая задача")
|
||||
} catch (reason) {
|
||||
if (!(reason instanceof ApiClientError) || reason.status !== 404) throw reason
|
||||
creating = true
|
||||
}
|
||||
}
|
||||
if (creating) {
|
||||
if (!canWrite) throw new Error("Нет права mm:certificates:write для повторного подтверждения запроса")
|
||||
await createCertificateIssueJob(backendUrl, saved.request, { signal: abort.signal })
|
||||
if (!active()) return
|
||||
saved = { ...saved, accepted: true }; persist(saved)
|
||||
}
|
||||
const target = saved.request
|
||||
const result = await pollCertificateIssue(backendUrl, target.requestId, target, { signal: abort.signal, onJob: job => {
|
||||
if (active()) persist({ request: target, accepted: true, job })
|
||||
} })
|
||||
if (!active()) return
|
||||
if (result.status === "failed") setError((result.error ?? "Задача завершилась ошибкой") + ". Частичное выполнение возможно; проверьте состояние устройства перед новым выпуском.")
|
||||
else {
|
||||
toast.success("API подтвердил завершение задачи выпуска", { id: "certificate-issue-" + result.id })
|
||||
await onDone()
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!active()) return
|
||||
if (creating && !saved?.accepted && reason instanceof ApiClientError && [400, 403, 404].includes(reason.status)) persist(null)
|
||||
setError((reason instanceof Error ? reason.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(reason instanceof Error ? reason.message : "Не удалось очистить запись"); return false }
|
||||
}
|
||||
return { checkpoint, hydrated, busy, error, storageError, run, reset }
|
||||
}
|
||||
Reference in new issue
Block a user