fix(ui): восстанавливать задачу выпуска без повторной операции
This commit is contained in:
1 parent
97526bf06f
commit
f5a71728ac
10 files changed
+322
-81
No files matched your search
@@ -5,7 +5,6 @@ import { useSearchParams } from "next/navigation"
|
||||
import { readCertificatesUrl, patchCertificatesUrl, type CertificateStatusFilter } from "@/lib/certificates-url-state"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||
import type { Server } from "@/lib/data"
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
@@ -35,10 +34,7 @@ import { can } from "@/lib/auth"
|
||||
import { useCertificateCatalog } from "@/lib/use-certificate-catalog"
|
||||
import type { CertificateView } from "@/lib/certificate-view"
|
||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||
import {
|
||||
createCertificateIssueJob,
|
||||
getCertificateIssueJob,
|
||||
} from "@/shared/api/certificates"
|
||||
import { useCertificateIssue } from "@/lib/use-certificate-issue"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ShieldCheckIcon,
|
||||
@@ -250,6 +246,7 @@ function CertPartIssueForm({
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
||||
<select
|
||||
aria-label="Сервер выпуска"
|
||||
value={issueServerId}
|
||||
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"
|
||||
@@ -267,6 +264,7 @@ function CertPartIssueForm({
|
||||
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="Имя сертификата"
|
||||
value={issueCertName}
|
||||
onChange={(e) => setIssueCertName(e.target.value)}
|
||||
placeholder="router-le"
|
||||
@@ -281,6 +279,7 @@ function CertPartIssueForm({
|
||||
<FormField label="Common Name" required hint="Основное имя в сертификате">
|
||||
<Input
|
||||
className="font-mono"
|
||||
aria-label="Основной домен"
|
||||
value={issueCommonName}
|
||||
onChange={(e) => setIssueCommonName(e.target.value)}
|
||||
placeholder="vpn.example.com"
|
||||
@@ -289,6 +288,7 @@ function CertPartIssueForm({
|
||||
<FormField label="SAN" hint="По одному имени в строке">
|
||||
<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"
|
||||
aria-label="Дополнительные домены"
|
||||
value={issueSans}
|
||||
onChange={(e) => setIssueSans(e.target.value)}
|
||||
placeholder="www.example.com"
|
||||
@@ -373,8 +373,6 @@ function CertificatesContent() {
|
||||
|
||||
const [issueOpen, setIssueOpen] = useState(false)
|
||||
const [issueStep, setIssueStep] = useState(1)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [issueBusy, setIssueBusy] = useState(false)
|
||||
const [issueServerId, setIssueServerId] = useState("")
|
||||
const [issueCertName, setIssueCertName] = useState("")
|
||||
const [issueCommonName, setIssueCommonName] = useState("")
|
||||
@@ -418,6 +416,9 @@ function CertificatesContent() {
|
||||
}, [isLive, serverList])
|
||||
|
||||
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(
|
||||
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||
@@ -447,51 +448,16 @@ function CertificatesContent() {
|
||||
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() {
|
||||
if (!liveReady) return
|
||||
const domains = [
|
||||
issueCommonName.trim(),
|
||||
...issueSans.split(/[\n,;]+/).map((s) => s.trim()),
|
||||
].filter(Boolean)
|
||||
if (!issueServerId || !issueCertName.trim() || domains.length === 0) {
|
||||
toast.error("Укажите сервер, имя сертификата и домены")
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
if (issue.checkpoint) { await issue.run(); return }
|
||||
if (!liveReady || !canWrite || !canRead || urlError || !catalog.hasServers || serverError || !catalog.servers.some(server => String(server.id) === issueServerId && server.enabled)) return
|
||||
const domains = [issueCommonName.trim(), ...issueSans.split(/[\n,;]+/).map(value => value.trim())].filter(Boolean)
|
||||
if (!issueServerId || !issueCertName.trim() || domains.length === 0) { toast.error("Укажите сервер, имя сертификата и домены"); return }
|
||||
await issue.run({ serverId: issueServerId, certName: issueCertName.trim(), domainNames: domains, trustStore: [...(issueTrustWww ? ["www" as const] : []), ...(issueTrustApi ? ["api" as const] : [])] })
|
||||
}
|
||||
function startNewIssue() {
|
||||
if (!issue.reset()) return
|
||||
setIssueCertName(""); setIssueCommonName(""); setIssueSans(""); setIssueStep(1)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -521,21 +487,21 @@ function CertificatesContent() {
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled title="Импорт пока не подключён к API" onClick={() => setImportOpen(true)}>
|
||||
<Button variant="outline" size="sm" disabled title="Импорт сертификатов пока недоступен">
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!liveReady || !canWrite || !catalog.hasServers || !!serverError || issueBusy}
|
||||
disabled={!issue.hydrated || !liveReady || !canRead || (!issue.checkpoint && (!canWrite || !!urlError || !catalog.hasServers || !!serverError || !!issue.storageError))}
|
||||
onClick={() => {
|
||||
setIssueStep(1)
|
||||
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||||
setIssueStep(issue.checkpoint ? 4 : 1)
|
||||
if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||||
setIssueOpen(true)
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Выпустить сертификат
|
||||
{issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
@@ -620,6 +586,11 @@ function CertificatesContent() {
|
||||
</SheetDescription>
|
||||
</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">
|
||||
<StepperNav className="mb-5">
|
||||
{[
|
||||
@@ -640,30 +611,33 @@ function CertificatesContent() {
|
||||
<StepperPanel className="flex-1 overflow-y-auto">
|
||||
{[1, 2, 3, 4].map((s) => (
|
||||
<StepperContent key={s} value={s}>
|
||||
<fieldset disabled={issueBusy || !!issue.checkpoint} className="min-w-0">
|
||||
<CertPartIssueForm
|
||||
step={s as 1 | 2 | 3 | 4}
|
||||
serverList={displayServers}
|
||||
issueServerId={issueServerId}
|
||||
issueServerId={String(issue.checkpoint?.request.serverId ?? issueServerId)}
|
||||
setIssueServerId={setIssueServerId}
|
||||
issueCertName={issueCertName}
|
||||
issueCertName={issue.checkpoint?.request.certName ?? issueCertName}
|
||||
setIssueCertName={setIssueCertName}
|
||||
issueCommonName={issueCommonName}
|
||||
issueCommonName={issue.checkpoint?.request.domainNames[0] ?? issueCommonName}
|
||||
setIssueCommonName={setIssueCommonName}
|
||||
issueSans={issueSans}
|
||||
issueSans={issue.checkpoint ? issue.checkpoint.request.domainNames.slice(1).join("\n") : issueSans}
|
||||
setIssueSans={setIssueSans}
|
||||
issueTrustWww={issueTrustWww}
|
||||
issueTrustWww={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("www") ?? true : issueTrustWww}
|
||||
setIssueTrustWww={setIssueTrustWww}
|
||||
issueTrustApi={issueTrustApi}
|
||||
issueTrustApi={issue.checkpoint ? issue.checkpoint.request.trustStore?.includes("api") ?? true : issueTrustApi}
|
||||
setIssueTrustApi={setIssueTrustApi}
|
||||
/>
|
||||
</fieldset>
|
||||
</StepperContent>
|
||||
))}
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={issueBusy} />}>
|
||||
Отмена
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
||||
{issueTerminal && canWrite && <Button variant="outline" disabled={issueBusy} onClick={startNewIssue}>Новый выпуск</Button>}
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>
|
||||
{issue.checkpoint ? "Закрыть" : "Отмена"}
|
||||
</SheetClose>
|
||||
{issueStep > 1 && (
|
||||
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
|
||||
@@ -673,7 +647,7 @@ function CertificatesContent() {
|
||||
{issueStep < 4 ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={issueStep === 1 && (!issueServerId || !issueCertName)}
|
||||
disabled={issueBusy || !issue.checkpoint && issueStep === 1 && (!issueServerId || !issueCertName)}
|
||||
onClick={() => setIssueStep((s) => s + 1)}
|
||||
>
|
||||
Далее
|
||||
@@ -681,26 +655,16 @@ function CertificatesContent() {
|
||||
) : (
|
||||
<Button
|
||||
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() }}
|
||||
>
|
||||
{issueBusy ? "Выпуск…" : "Выпустить"}
|
||||
{issueBusy ? "Проверяем…" : issueTerminal ? "Задача завершена" : issue.checkpoint ? "Продолжить проверку" : "Выпустить"}
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</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} готов к импорту на роутер`)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user