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

+42 -78
View File
@@ -5,7 +5,6 @@ import { useSearchParams } from "next/navigation"
import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state" import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { FileImportDialog } from "@/components/file-import-dialog"
import { routerCertificates, servers as mockServers } from "@/lib/data" import { routerCertificates, servers as mockServers } from "@/lib/data"
import type { Server } from "@/lib/data" import type { Server } from "@/lib/data"
import type { CertificateDto } from "@mmapp/contracts/certificates" import type { CertificateDto } from "@mmapp/contracts/certificates"
@@ -35,10 +34,7 @@ import { can } from "@/lib/auth"
import { useCertificateCatalog } from "@/lib/use-certificate-catalog" import { useCertificateCatalog } from "@/lib/use-certificate-catalog"
import type { CertificateView } from "@/lib/certificate-view" import type { CertificateView } from "@/lib/certificate-view"
import { toFrontendServer } from "@/entities/server/model/mappers" import { toFrontendServer } from "@/entities/server/model/mappers"
import { import { useCertificateIssue } from "@/lib/use-certificate-issue"
createCertificateIssueJob,
getCertificateIssueJob,
} from "@/shared/api/certificates"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ShieldCheckIcon, ShieldCheckIcon,
@@ -250,6 +246,7 @@ function CertPartIssueForm({
<SectionTitle>Основные</SectionTitle> <SectionTitle>Основные</SectionTitle>
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат"> <FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
<select <select
aria-label="Сервер выпуска"
value={issueServerId} value={issueServerId}
onChange={(e) => setIssueServerId(e.target.value)} onChange={(e) => setIssueServerId(e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
@@ -267,6 +264,7 @@ function CertPartIssueForm({
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве"> <FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
<Input <Input
className="font-mono" className="font-mono"
aria-label="Имя сертификата"
value={issueCertName} value={issueCertName}
onChange={(e) => setIssueCertName(e.target.value)} onChange={(e) => setIssueCertName(e.target.value)}
placeholder="router-le" placeholder="router-le"
@@ -281,6 +279,7 @@ function CertPartIssueForm({
<FormField label="Common Name" required hint="Основное имя в сертификате"> <FormField label="Common Name" required hint="Основное имя в сертификате">
<Input <Input
className="font-mono" className="font-mono"
aria-label="Основной домен"
value={issueCommonName} value={issueCommonName}
onChange={(e) => setIssueCommonName(e.target.value)} onChange={(e) => setIssueCommonName(e.target.value)}
placeholder="vpn.example.com" placeholder="vpn.example.com"
@@ -289,6 +288,7 @@ function CertPartIssueForm({
<FormField label="SAN" hint="По одному имени в строке"> <FormField label="SAN" hint="По одному имени в строке">
<textarea <textarea
className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" className="min-h-24 w-full rounded-lg border border-input bg-background px-2.5 py-2 text-sm font-mono text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
aria-label="Дополнительные домены"
value={issueSans} value={issueSans}
onChange={(e) => setIssueSans(e.target.value)} onChange={(e) => setIssueSans(e.target.value)}
placeholder="www.example.com" placeholder="www.example.com"
@@ -373,8 +373,6 @@ function CertificatesContent() {
const [issueOpen, setIssueOpen] = useState(false) const [issueOpen, setIssueOpen] = useState(false)
const [issueStep, setIssueStep] = useState(1) const [issueStep, setIssueStep] = useState(1)
const [importOpen, setImportOpen] = useState(false)
const [issueBusy, setIssueBusy] = useState(false)
const [issueServerId, setIssueServerId] = useState("") const [issueServerId, setIssueServerId] = useState("")
const [issueCertName, setIssueCertName] = useState("") const [issueCertName, setIssueCertName] = useState("")
const [issueCommonName, setIssueCommonName] = useState("") const [issueCommonName, setIssueCommonName] = useState("")
@@ -418,6 +416,9 @@ function CertificatesContent() {
}, [isLive, serverList]) }, [isLive, serverList])
const loadLive = catalog.reload const loadLive = catalog.reload
const issue = useCertificateIssue(backendUrl, liveReady && canRead, liveReady && canWrite, loadLive, isLive)
const issueBusy = issue.busy
const issueTerminal = issue.checkpoint?.job?.status === "done" || issue.checkpoint?.job?.status === "failed"
const expiring = useMemo( const expiring = useMemo(
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30), () => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30),
@@ -447,51 +448,16 @@ function CertificatesContent() {
await loadLive() await loadLive()
} }
async function pollIssueJob(jobId: string) {
for (let i = 0; i < 120; i++) {
await new Promise((r) => setTimeout(r, 2000))
const job = await getCertificateIssueJob(backendUrl, jobId)
if (job.status === "done") {
toast.success("Сертификат выпущен и импортирован на роутер")
setIssueOpen(false)
await loadLive()
return
}
if (job.status === "failed") {
throw new Error(job.error ?? "Выпуск не удался")
}
}
throw new Error("Таймаут ожидания выпуска сертификата")
}
async function handleIssue() { async function handleIssue() {
if (!liveReady) return if (issue.checkpoint) { await issue.run(); return }
const domains = [ if (!liveReady || !canWrite || !canRead || urlError || !catalog.hasServers || serverError || !catalog.servers.some(server => String(server.id) === issueServerId && server.enabled)) return
issueCommonName.trim(), const domains = [issueCommonName.trim(), ...issueSans.split(/[\n,;]+/).map(value => value.trim())].filter(Boolean)
...issueSans.split(/[\n,;]+/).map((s) => s.trim()), if (!issueServerId || !issueCertName.trim() || domains.length === 0) { toast.error("Укажите сервер, имя сертификата и домены"); return }
].filter(Boolean) await issue.run({ serverId: issueServerId, certName: issueCertName.trim(), domainNames: domains, trustStore: [...(issueTrustWww ? ["www" as const] : []), ...(issueTrustApi ? ["api" as const] : [])] })
if (!issueServerId || !issueCertName.trim() || domains.length === 0) { }
toast.error("Укажите сервер, имя сертификата и домены") function startNewIssue() {
return if (!issue.reset()) return
} setIssueCertName(""); setIssueCommonName(""); setIssueSans(""); setIssueStep(1)
setIssueBusy(true)
try {
const trustStore: Array<"www" | "api"> = []
if (issueTrustWww) trustStore.push("www")
if (issueTrustApi) trustStore.push("api")
const { jobId } = await createCertificateIssueJob(backendUrl, {
serverId: issueServerId,
certName: issueCertName.trim(),
domainNames: domains,
trustStore,
})
toast.message("Выпуск сертификата запущен…")
await pollIssueJob(jobId)
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка выпуска")
} finally {
setIssueBusy(false)
}
} }
return ( return (
@@ -521,21 +487,21 @@ function CertificatesContent() {
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} /> <RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить Обновить
</Button> </Button>
<Button variant="outline" size="sm" disabled title="Импорт пока не подключён к API" onClick={() => setImportOpen(true)}> <Button variant="outline" size="sm" disabled title="Импорт сертификатов пока недоступен">
<UploadIcon className="size-4" /> <UploadIcon className="size-4" />
Импорт Импорт
</Button> </Button>
<Button <Button
size="sm" size="sm"
disabled={!liveReady || !canWrite || !catalog.hasServers || !!serverError || issueBusy} disabled={!issue.hydrated || !liveReady || !canRead || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
onClick={() => { onClick={() => {
setIssueStep(1) setIssueStep(issue.checkpoint ? 4 : 1)
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId) if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
setIssueOpen(true) setIssueOpen(true)
}} }}
> >
<PlusIcon className="size-4" /> <PlusIcon className="size-4" />
Выпустить сертификат {issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
</Button> </Button>
</> </>
} }
@@ -620,6 +586,11 @@ function CertificatesContent() {
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="px-6 pt-3 text-sm">
{(issue.error || issue.storageError) && <p role="alert" aria-label="Выпуск сертификата">{issue.error || issue.storageError}</p>}
{issue.checkpoint && <p className="text-xs text-muted-foreground">Закрытие формы не отменяет серверную задачу. Её можно проверить повторно.</p>}
{issue.checkpoint && <p role="status">Задача {issue.checkpoint.request.requestId}: {issue.checkpoint.job ? ({ queued: "В очереди", running: "Выполняется", done: "Завершена", failed: "Ошибка" }[issue.checkpoint.job.status]) + " · " + issue.checkpoint.job.step : issue.checkpoint.accepted ? "Принята; результат пока неизвестен" : "Принятие запроса пока не подтверждено"}. Сервер {issue.checkpoint.request.serverId}, сертификат {issue.checkpoint.request.certName}.</p>}
</div>
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5"> <Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
<StepperNav className="mb-5"> <StepperNav className="mb-5">
{[ {[
@@ -640,30 +611,33 @@ function CertificatesContent() {
<StepperPanel className="flex-1 overflow-y-auto"> <StepperPanel className="flex-1 overflow-y-auto">
{[1, 2, 3, 4].map((s) => ( {[1, 2, 3, 4].map((s) => (
<StepperContent key={s} value={s}> <StepperContent key={s} value={s}>
<fieldset disabled={issueBusy || !!issue.checkpoint} className="min-w-0">
<CertPartIssueForm <CertPartIssueForm
step={s as 1 | 2 | 3 | 4} step={s as 1 | 2 | 3 | 4}
serverList={displayServers} serverList={displayServers}
issueServerId={issueServerId} issueServerId={String(issue.checkpoint?.request.serverId ?? issueServerId)}
setIssueServerId={setIssueServerId} setIssueServerId={setIssueServerId}
issueCertName={issueCertName} issueCertName={issue.checkpoint?.request.certName ?? issueCertName}
setIssueCertName={setIssueCertName} setIssueCertName={setIssueCertName}
issueCommonName={issueCommonName} issueCommonName={issue.checkpoint?.request.domainNames[0] ?? issueCommonName}
setIssueCommonName={setIssueCommonName} setIssueCommonName={setIssueCommonName}
issueSans={issueSans} issueSans={issue.checkpoint ? issue.checkpoint.request.domainNames.slice(1).join("\n") : issueSans}
setIssueSans={setIssueSans} setIssueSans={setIssueSans}
issueTrustWww={issueTrustWww} issueTrustWww={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("www") ?? true : issueTrustWww}
setIssueTrustWww={setIssueTrustWww} setIssueTrustWww={setIssueTrustWww}
issueTrustApi={issueTrustApi} issueTrustApi={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("api") ?? true : issueTrustApi}
setIssueTrustApi={setIssueTrustApi} setIssueTrustApi={setIssueTrustApi}
/> />
</fieldset>
</StepperContent> </StepperContent>
))} ))}
</StepperPanel> </StepperPanel>
</Stepper> </Stepper>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2"> <SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}> {issueTerminal && canWrite && <Button variant="outline" disabled={issueBusy} onClick={startNewIssue}>Новый выпуск</Button>}
Отмена <SheetClose render={<Button variant="outline" className="flex-1" />}>
{issue.checkpoint ? "Закрыть" : "Отмена"}
</SheetClose> </SheetClose>
{issueStep > 1 && ( {issueStep > 1 && (
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}> <Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
@@ -673,7 +647,7 @@ function CertificatesContent() {
{issueStep < 4 ? ( {issueStep < 4 ? (
<Button <Button
className="flex-1" className="flex-1"
disabled={issueStep === 1 && (!issueServerId || !issueCertName)} disabled={issueBusy || !issue.checkpoint && issueStep === 1 && (!issueServerId || !issueCertName)}
onClick={() => setIssueStep((s) => s + 1)} onClick={() => setIssueStep((s) => s + 1)}
> >
Далее Далее
@@ -681,26 +655,16 @@ function CertificatesContent() {
) : ( ) : (
<Button <Button
className="flex-1" className="flex-1"
disabled={!liveReady || issueBusy} disabled={!issue.hydrated || !liveReady || !canRead || issueBusy || issueTerminal || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
onClick={() => { void handleIssue() }} onClick={() => { void handleIssue() }}
> >
{issueBusy ? "Выпуск…" : "Выпустить"} {issueBusy ? "Проверяем…" : issueTerminal ? "Задача завершена" : issue.checkpoint ? "Продолжить проверку" : "Выпустить"}
</Button> </Button>
)} )}
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<FileImportDialog
open={importOpen}
onOpenChange={setImportOpen}
title="Импорт сертификата"
description="Загрузите PEM, CRT или PKCS#12 для импорта на RouterOS"
accept=".pem,.crt,.cer,.p12,.pfx"
onImport={async (files) => {
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}}
/>
</> </>
) )
} }
+1 -1
View File
@@ -226,4 +226,4 @@ REST запрос → ожидание → подтверждённый output /
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) | | Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) | | Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
Актуальная точка продолжения: 11А.21.4 — frontend checkpoint с requestId, resume и отмена выпуска. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты. Актуальная точка продолжения: 11А.22 — визуальная и профильная приёмка сертификатов, затем резервные копии. State API, reader, snapshot merge и nullable представление подключены в 11А.14–11А.17.3; таблица требований — [этап 11А](ux-ui-stage-11a.md). Сертификаты и весь 11А ещё не приняты.
+10 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.21.3; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.21.4 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.21.4; пользователи/контейнеры приняты локально, сертификаты: state/URL/API/renew/ACME готовы; далее 11А.22 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,15 @@
## Блок 11А.21.4 — сохраняемая задача выпуска на странице (2026-10-01)
- До POST создаётся requestId и immutable цель, записывается checkpoint sessionStorage по backend URL. Невозможность первоначальной записи не отправляет выпуск. Reload восстанавливает ту же задачу; моковый режим не показывает live checkpoint. Повреждённая запись блокирует новый выпуск.
- Принятый job повторно наблюдается GET/poll без POST. Неподтверждённый запрос сначала проверяет известный ID; при 404 повторяет тот же POST/requestId, защищённый backend от дублей. Read/write права разделены. Ошибка GET/чужая цель не считаются успешным или failed выпуском.
- Sheet/Stepper сохраняют цель и блокируют её изменение после принятия; реальный failed и частичное выполнение показаны явно. Новый выпуск после terminal требует отдельного действия и начинается с пустого имени/доменов. Закрытие формы не объявляется отменой server job. После done ошибка перечитывания каталога не меняет результат задачи. Неподключённый FileImportDialog с ложным callback удалён.
- При unmount отменяются POST/poll/задержки; поздние успех/ошибка не меняют другой backend/mode и не показывают старый toast.
- 3 checkpoint + 5 poll unit, 7 issue E2E, 8 source/context E2E, TypeScript/ESLint и production frontend прошли. Реальный PostgreSQL/ACME/RouterOS выпуск не проверен; pending job восстановление после перезапуска backend требует отдельной инфраструктурной приёмки.
- Следующий блок 11А.22: семантические токены, клавиатура/ПК/390/320, снимки и профильная регрессия сертификатов. Импорт/экспорт приватного ключа остаются явно недоступны; безопасный JSON снимок каталога можно добавить отдельно. Затем резервные копии и 11Б/12.
## Блок 11А.21.3 — устойчивое создание issue задачи (2026-10-01) ## Блок 11А.21.3 — устойчивое создание issue задачи (2026-10-01)
- В запрос выпуска добавлен необязательный UUID requestId. Backend использует существующий primary key задачи и INSERT ON CONFLICT DO NOTHING: повтор идентичной цели возвращает тот же jobId без повторной очереди, другая цель/source с тем же ID получает 409. Старые клиенты без requestId продолжают получать независимые UUID. Миграция БД не нужна. - В запрос выпуска добавлен необязательный UUID requestId. Backend использует существующий primary key задачи и INSERT ON CONFLICT DO NOTHING: повтор идентичной цели возвращает тот же jobId без повторной очереди, другая цель/source с тем же ID получает 409. Старые клиенты без requestId продолжают получать независимые UUID. Миграция БД не нужна.
+9
View File
@@ -263,3 +263,12 @@
- Request ID сохраняется и проверяется frontend API; чужое подтверждение отклоняется. Server ID должен быть безопасным положительным целым, имя/домены не могут быть пустыми после trim. Контракт нормализует поля перед созданием. - Request ID сохраняется и проверяется frontend API; чужое подтверждение отклоняется. Server ID должен быть безопасным положительным целым, имя/домены не могут быть пустыми после trim. Контракт нормализует поля перед созданием.
- 4 request helper unit (включая конкурентный повтор), HTTP handler fault проверки, backend test:certificates/build, 6 frontend API unit, polling unit, frontend TypeScript и профильный lint прошли. Проверки helper/HTTP используют тестовый repository; реальный PostgreSQL INSERT и RouterOS выпуск остаются внешней приёмкой. Runner и сетевой порядок операций не менялись. - 4 request helper unit (включая конкурентный повтор), HTTP handler fault проверки, backend test:certificates/build, 6 frontend API unit, polling unit, frontend TypeScript и профильный lint прошли. Проверки helper/HTTP используют тестовый repository; реальный PostgreSQL INSERT и RouterOS выпуск остаются внешней приёмкой. Runner и сетевой порядок операций не менялись.
- Следующий блок 11А.21.4: frontend checkpoint с заранее созданным requestId и неизменной целью, resume GET/poll без нового ID, сохранение в пределах источника, отмена и HTTP fault/context проверки. - Следующий блок 11А.21.4: frontend checkpoint с заранее созданным requestId и неизменной целью, resume GET/poll без нового ID, сохранение в пределах источника, отмена и HTTP fault/context проверки.
## Блок 11А.21.4 — сохраняемая задача выпуска на странице (2026-10-01)
- До POST создаётся requestId и immutable цель, записывается checkpoint sessionStorage по backend URL. Невозможность первоначальной записи не отправляет выпуск. Reload восстанавливает ту же задачу; моковый режим не показывает live checkpoint. Повреждённая запись блокирует новый выпуск.
- Принятый job повторно наблюдается GET/poll без POST. Неподтверждённый запрос сначала проверяет известный ID; при 404 повторяет тот же POST/requestId, защищённый backend от дублей. Read/write права разделены. Ошибка GET/чужая цель не считаются успешным или failed выпуском.
- Sheet/Stepper сохраняют цель и блокируют её изменение после принятия; реальный failed и частичное выполнение показаны явно. Новый выпуск после terminal требует отдельного действия и начинается с пустого имени/доменов. Закрытие формы не объявляется отменой server job. После done ошибка перечитывания каталога не меняет результат задачи. Неподключённый FileImportDialog с ложным callback удалён.
- При unmount отменяются POST/poll/задержки; поздние успех/ошибка не меняют другой backend/mode и не показывают старый toast.
- 3 checkpoint + 5 poll unit, 7 issue E2E, 8 source/context E2E, TypeScript/ESLint и production frontend прошли. Реальный PostgreSQL/ACME/RouterOS выпуск не проверен; pending job восстановление после перезапуска backend требует отдельной инфраструктурной приёмки.
- Следующий блок 11А.22: семантические токены, клавиатура/ПК/390/320, снимки и профильная регрессия сертификатов. Импорт/экспорт приватного ключа остаются явно недоступны; безопасный JSON снимок каталога можно добавить отдельно. Затем резервные копии и 11Б/12.
+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 }
}
+29 -1
View File
@@ -1,6 +1,7 @@
import {test,expect} from "@playwright/test" import {test,expect} from "@playwright/test"
import {certificate as container,server,certificateState as state} from "./fixtures/certificates" import {certificate as container,server,certificateState as state} from "./fixtures/certificates"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"} import {openIssue,issueJob} from "./fixtures/certificate-issue"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,PUT,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
for(const change of ["backend","mode"])for(const fails of [false,true])test("Сертификаты: позднее чтение/"+change+"/"+fails,async({page})=>{ for(const change of ["backend","mode"])for(const fails of [false,true])test("Сертификаты: позднее чтение/"+change+"/"+fails,async({page})=>{
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r) let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[server]:p==="/api/certificates/state"?state():{directoryUrl:"https://acme.example.test/directory",tokenConfigured:false,enabled:false,intervalSec:300,renewBeforeDays:30}})}) await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[server]:p==="/api/certificates/state"?state():{directoryUrl:"https://acme.example.test/directory",tokenConfigured:false,enabled:false,intervalSec:300,renewBeforeDays:30}})})
@@ -51,3 +52,30 @@ for (const fails of [false, true]) test("Сертификаты: позднее
await expect(directory).toHaveValue("https://acme.example.test/directory"); await expect(page.getByText("СТАРОЕ СОХРАНЕНИЕ ACME ОТКАЗ", { exact: true })).toHaveCount(0) await expect(directory).toHaveValue("https://acme.example.test/directory"); await expect(page.getByText("СТАРОЕ СОХРАНЕНИЕ ACME ОТКАЗ", { exact: true })).toHaveCount(0)
await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toHaveCount(0) await expect(page.getByText("Настройки ACME сохранены", { exact: true })).toHaveCount(0)
}) })
for (const change of ["backend", "mode"]) for (const stage of ["POST", "poll"]) for (const fails of [false, true]) test("Сертификаты: поздний выпуск/" + change + "/" + stage + "/" + fails, async ({ page }) => {
let request: Record<string, unknown> = {}, started = false, completed = false, release!: () => void; const gate = new Promise<void>(r => release = r)
await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ headers, json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? state() : path === "/api/certificates/renew-settings" ? { enabled: true, intervalSec: 21600, renewBeforeDays: 30 } : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: true } })
})
await page.route("**/api/certificates/issue", async r => {
request = r.request().postDataJSON()
if (stage !== "POST") return r.fulfill({ headers, json: { jobId: request.requestId } })
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ ВЫПУСК ОТКАЗ" } } : { headers, json: { jobId: request.requestId } }) } catch {} finally { completed = true }
})
await page.route("**/api/certificates/issue/*", async r => {
if (stage !== "poll") return r.fulfill({ headers, json: issueJob(request) })
started = true; await gate
try { await r.fulfill(fails ? { headers, status: 503, json: { error: "СТАРЫЙ ВЫПУСК ОТКАЗ" } } : { headers, json: issueJob(request) }) } catch {} finally { completed = true }
})
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click(); await expect.poll(() => started).toBe(true)
await page.goto("/settings")
if (change === "backend") { const input = page.getByPlaceholder("http://localhost:8000"); await input.fill("http://localhost:8101"); await input.press("Enter") } else await page.getByRole("button", { name: "Моковые", exact: true }).click()
await page.goto("/certificates"); release(); await expect.poll(() => completed).toBe(true)
await expect(page.getByRole("button", { name: "Задача выпуска", exact: true })).toHaveCount(0)
await expect(page.getByText("API подтвердил завершение задачи выпуска", { exact: true })).toHaveCount(0)
await expect(page.getByText(/СТАРЫЙ ВЫПУСК ОТКАЗ/)).toHaveCount(0)
})
+63
View File
@@ -0,0 +1,63 @@
import { test, expect } from "@playwright/test"
import { certificateRoutes, issueJob, openIssue } from "./fixtures/certificate-issue"
test.beforeEach(async ({ page }) => certificateRoutes(page))
test("Выпуск: успех и отказ последующего каталога разделены", async ({ page }) => {
let request: Record<string, unknown> = {}; let writes = 0
await page.route("**/api/certificates/issue", r => { writes++; request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill({ json: issueJob(request) }))
await page.route("**/api/certificates/state", r => writes ? r.fulfill({ status: 503, json: { error: "Повторное чтение отказало" } }) : r.fallback())
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled()
await expect(page.getByText("API подтвердил завершение задачи выпуска", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Закрыть", exact: true }).last().click()
await expect(page.getByRole("alert", { name: "Каталог сертификатов", exact: true })).toContainText("Последние данные сохранены")
expect(writes).toBe(1)
})
test("Выпуск: GET ошибка и reload продолжают ту же задачу без POST", async ({ page }) => {
let request: Record<string, unknown> = {}, fail = true, writes = 0
await page.route("**/api/certificates/issue", r => { writes++; request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill(fail ? { status: 503, json: { error: "Опрос отказал" } } : { json: issueJob(request) }))
let dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Продолжите проверку той же задачи")
await page.reload(); await page.getByRole("button", { name: "Задача выпуска", exact: true }).click()
dialog = page.getByRole("dialog", { name: "Выпуск сертификата", exact: true }); fail = false
await dialog.getByRole("button", { name: "Продолжить проверку", exact: true }).click()
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled(); expect(writes).toBe(1)
})
for (const accepted of [false, true]) test("Выпуск: потерянное подтверждение POST/accepted=" + accepted, async ({ page }) => {
const requests: Record<string, unknown>[] = []; let exists = false
await page.route("**/api/certificates/issue", r => { const request = r.request().postDataJSON(); requests.push(request); exists = accepted || requests.length > 1; return r.fulfill(requests.length === 1 ? { status: 503, json: { error: "Ответ потерян" } } : { json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill(exists ? { json: issueJob(requests[0]) } : { status: 404, json: { error: "Задача не найдена" } }))
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Ответ потерян")
await dialog.getByRole("button", { name: "Продолжить проверку", exact: true }).click()
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled()
expect(requests.length).toBe(accepted ? 1 : 2); for (const request of requests) expect(request.requestId).toBe(requests[0].requestId)
})
test("Выпуск: реальный failed и новая форма не повторяют POST автоматически", async ({ page }) => {
let request: Record<string, unknown> = {}, writes = 0
await page.route("**/api/certificates/issue", r => { writes++; request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill({ json: issueJob(request, { status: "failed", step: "failed", error: "Импорт отказал" }) }))
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Импорт отказал")
await expect(dialog.getByRole("button", { name: "Задача завершена", exact: true })).toBeDisabled()
await dialog.getByRole("button", { name: "Новый выпуск", exact: true }).click()
await expect(dialog.getByRole("textbox", { name: "Имя сертификата", exact: true })).toHaveValue(""); expect(writes).toBe(1)
})
test("Выпуск: чужая цель в done не подтверждает успех", async ({ page }) => {
let request: Record<string, unknown> = {}
await page.route("**/api/certificates/issue", r => { request = r.request().postDataJSON(); return r.fulfill({ json: { jobId: request.requestId } }) })
await page.route("**/api/certificates/issue/*", r => r.fulfill({ json: issueJob(request, { serverId: "2" }) }))
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("не соответствует")
await expect(page.getByText("API подтвердил завершение задачи выпуска", { exact: true })).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Продолжить проверку", exact: true })).toBeEnabled()
})
test("Выпуск: отказ записи checkpoint не отправляет запрос", async ({ page }) => {
await page.addInitScript(() => { sessionStorage.setItem = () => { throw new Error("storage denied") } })
let writes = 0; await page.route("**/api/certificates/issue", r => { writes++; return r.fulfill({ json: {} }) })
const dialog = await openIssue(page); await dialog.getByRole("button", { name: "Выпустить", exact: true }).click()
await expect(dialog.getByRole("alert", { name: "Выпуск сертификата", exact: true })).toContainText("Не удалось сохранить задачу")
expect(writes).toBe(0)
})
+23
View File
@@ -0,0 +1,23 @@
import { expect, type Page } from "@playwright/test"
import { certificateState, server } from "./certificates"
export async function certificateRoutes(page: Page) {
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [server] : path === "/api/certificates/state" ? certificateState() : path === "/api/certificates/renew-settings" ? { enabled: false, intervalSec: 300, renewBeforeDays: 30 } : { directoryUrl: "https://acme.example.test/directory", tokenConfigured: true } })
})
}
export function issueJob(request: Record<string, unknown>, overrides: Record<string, unknown> = {}) {
return { id: request.requestId, serverId: String(request.serverId), certName: request.certName, domainNames: request.domainNames, status: "done", step: "done", requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", ...overrides }
}
export async function openIssue(page: Page) {
await page.goto("/certificates"); const action = page.getByRole("button", { name: "Выпустить сертификат", exact: true }); await expect(action).toBeEnabled(); await action.click()
const dialog = page.getByRole("dialog", { name: "Выпуск сертификата", exact: true })
await dialog.getByRole("combobox", { name: "Сервер выпуска", exact: true }).selectOption("1")
await dialog.getByRole("textbox", { name: "Имя сертификата", exact: true }).fill("new-cert")
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
await dialog.getByRole("textbox", { name: "Основной домен", exact: true }).fill("test.example")
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
await dialog.getByRole("button", { name: "Далее", exact: true }).click()
return dialog
}