fix(ui): восстанавливать задачу выпуска без повторной операции

This commit is contained in:
Denozordec committed 2026-10-01 14:29:56 +07:00
1 parent 97526bf06f
commit f5a71728ac
10 files changed
+322 -81

No files matched your search

+24
View File
@@ -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)
})
+20
View File
@@ -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 }
+101
View File
@@ -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 }
}