Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
858 lines
31 KiB
TypeScript
858 lines
31 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||
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 { CertStatus, Server } from "@/lib/data"
|
||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||
import { OpsPanel } from "@/components/ops-panel"
|
||
import { DataPageCard } from "@/components/data-page-card"
|
||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
SheetDescription,
|
||
SheetFooter,
|
||
SheetClose,
|
||
} from "@/components/ui/sheet"
|
||
import { cn } from "@/lib/utils"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { listServers } from "@/shared/api/servers"
|
||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||
import {
|
||
createCertificateIssueJob,
|
||
getAcmeSettings,
|
||
getCertificateIssueJob,
|
||
listCertificates,
|
||
putAcmeSettings,
|
||
refreshCertificates,
|
||
testAcmeSettings,
|
||
} from "@/shared/api/certificates"
|
||
import { toast } from "sonner"
|
||
import {
|
||
ShieldCheckIcon,
|
||
BadgeCheckIcon,
|
||
AlertTriangleIcon,
|
||
AlertCircleIcon,
|
||
PlusIcon,
|
||
RefreshCwIcon,
|
||
UploadIcon,
|
||
} from "lucide-react"
|
||
import {
|
||
Stepper,
|
||
StepperContent,
|
||
StepperIndicator,
|
||
StepperItem,
|
||
StepperNav,
|
||
StepperPanel,
|
||
StepperSeparator,
|
||
StepperTitle,
|
||
StepperTrigger,
|
||
} from "@/components/reui/stepper"
|
||
|
||
function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto {
|
||
return {
|
||
id: cert.id,
|
||
name: cert.name,
|
||
serverId: cert.serverId,
|
||
commonName: cert.commonName,
|
||
sans: cert.sans,
|
||
issuedBy: cert.issuedBy,
|
||
validFrom: cert.validFrom,
|
||
validUntil: cert.validUntil,
|
||
daysLeft: cert.daysLeft,
|
||
keySize: cert.keySize,
|
||
usage: cert.usage,
|
||
trusted: cert.trusted,
|
||
status: cert.status,
|
||
}
|
||
}
|
||
|
||
function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) {
|
||
return (
|
||
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
|
||
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="font-medium text-red-600 dark:text-red-400">
|
||
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
|
||
</p>
|
||
<p className="text-muted-foreground text-xs mt-0.5">
|
||
{expired.map((c) => c.name).join(", ")} — требуют обновления
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CertPartAlertExpiring({ expiring }: { expiring: CertificateDto[] }) {
|
||
return (
|
||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
|
||
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
|
||
</p>
|
||
<p className="text-muted-foreground text-xs mt-0.5">
|
||
{expiring.map((c) => `${c.name} (${c.daysLeft}д)`).join(", ")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CertPartKpi({
|
||
displayCerts,
|
||
expiring,
|
||
expired,
|
||
}: {
|
||
displayCerts: CertificateDto[]
|
||
expiring: CertificateDto[]
|
||
expired: CertificateDto[]
|
||
}) {
|
||
return (
|
||
<KpiStatGrid
|
||
aria-label="Сводка сертификатов"
|
||
items={[
|
||
{
|
||
id: "all",
|
||
label: "Всего",
|
||
value: displayCerts.length,
|
||
icon: <ShieldCheckIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "valid",
|
||
label: "Действующих",
|
||
value: displayCerts.filter((c) => c.status === "valid").length,
|
||
icon: <BadgeCheckIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "expiring",
|
||
label: "Истекают",
|
||
value: expiring.length,
|
||
icon: <AlertTriangleIcon className="size-4" />,
|
||
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
|
||
variant: expiring.length > 0 ? "warning" : "default",
|
||
},
|
||
{
|
||
id: "expired",
|
||
label: "Истёкших",
|
||
value: expired.length,
|
||
icon: <AlertCircleIcon className="size-4" />,
|
||
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
|
||
variant: expired.length > 0 ? "destructive" : "default",
|
||
},
|
||
]}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function CertPartAcmeSettings({
|
||
acmeDirectoryUrl,
|
||
setAcmeDirectoryUrl,
|
||
acmeZoneId,
|
||
setAcmeZoneId,
|
||
acmeTokenDraft,
|
||
setAcmeTokenDraft,
|
||
acmeTokenConfigured,
|
||
acmeSaveBusy,
|
||
onTest,
|
||
onSave,
|
||
}: {
|
||
acmeDirectoryUrl: string
|
||
setAcmeDirectoryUrl: (v: string) => void
|
||
acmeZoneId: string
|
||
setAcmeZoneId: (v: string) => void
|
||
acmeTokenDraft: string
|
||
setAcmeTokenDraft: (v: string) => void
|
||
acmeTokenConfigured: boolean
|
||
acmeSaveBusy: boolean
|
||
onTest: () => void
|
||
onSave: () => void
|
||
}) {
|
||
return (
|
||
<OpsPanel
|
||
title="ACME · Cloudflare DNS-01"
|
||
description="Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+."
|
||
contentClassName="px-5 py-4 flex flex-col gap-3"
|
||
>
|
||
<form
|
||
className="grid gap-3 md:grid-cols-2"
|
||
autoComplete="off"
|
||
onSubmit={(event) => event.preventDefault()}
|
||
>
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-sm font-medium">ACME directory URL</label>
|
||
<Input
|
||
autoComplete="off"
|
||
name="acme-directory-url"
|
||
value={acmeDirectoryUrl}
|
||
onChange={(e) => setAcmeDirectoryUrl(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-sm font-medium">Cloudflare zone id (опционально)</label>
|
||
<Input
|
||
autoComplete="off"
|
||
name="cloudflare-zone-id"
|
||
value={acmeZoneId}
|
||
onChange={(e) => setAcmeZoneId(e.target.value)}
|
||
placeholder="Авто по домену"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-col gap-1.5 md:col-span-2">
|
||
<label className="text-sm font-medium">Cloudflare API token</label>
|
||
<Input
|
||
type="password"
|
||
autoComplete="new-password"
|
||
name="cloudflare-api-token"
|
||
value={acmeTokenDraft}
|
||
onChange={(e) => setAcmeTokenDraft(e.target.value)}
|
||
placeholder={
|
||
acmeTokenConfigured
|
||
? "Токен сохранён — введите новый для замены"
|
||
: "API token с правом DNS"
|
||
}
|
||
/>
|
||
</div>
|
||
</form>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" size="sm" disabled={acmeSaveBusy} onClick={onTest}>
|
||
Проверить Cloudflare
|
||
</Button>
|
||
<Button size="sm" disabled={acmeSaveBusy} onClick={onSave}>
|
||
Сохранить настройки
|
||
</Button>
|
||
</div>
|
||
</OpsPanel>
|
||
)
|
||
}
|
||
|
||
function CertPartReference() {
|
||
return (
|
||
<OpsPanel
|
||
title="RouterOS 7 · /certificate — справка CLI"
|
||
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
|
||
contentClassName="px-5 py-4"
|
||
>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||
{[
|
||
{
|
||
title: "Создать CA",
|
||
lines: [
|
||
"/certificate add \\",
|
||
" name=my-ca \\",
|
||
" common-name=MyCA \\",
|
||
" key-size=4096 \\",
|
||
" days-valid=3650 \\",
|
||
" key-usage=key-cert-sign,crl-sign",
|
||
"/certificate sign my-ca",
|
||
],
|
||
},
|
||
{
|
||
title: "Импорт LE",
|
||
lines: [
|
||
"/certificate import \\",
|
||
" file-name=router.crt \\",
|
||
" name=router-cert \\",
|
||
" trusted=yes \\",
|
||
" trust-store=www,api",
|
||
],
|
||
},
|
||
{
|
||
title: "Статус",
|
||
lines: ["/certificate print detail", "/certificate export-certificate router-cert"],
|
||
},
|
||
].map((b) => (
|
||
<div key={b.title}>
|
||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
|
||
{b.title}
|
||
</p>
|
||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||
{b.lines.join("\n")}
|
||
</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</OpsPanel>
|
||
)
|
||
}
|
||
|
||
function CertPartIssueForm({
|
||
serverList,
|
||
issueServerId,
|
||
setIssueServerId,
|
||
issueCertName,
|
||
setIssueCertName,
|
||
issueCommonName,
|
||
setIssueCommonName,
|
||
issueSans,
|
||
setIssueSans,
|
||
issueTrustWww,
|
||
setIssueTrustWww,
|
||
issueTrustApi,
|
||
setIssueTrustApi,
|
||
step,
|
||
}: {
|
||
serverList: Server[]
|
||
issueServerId: string
|
||
setIssueServerId: (v: string) => void
|
||
issueCertName: string
|
||
setIssueCertName: (v: string) => void
|
||
issueCommonName: string
|
||
setIssueCommonName: (v: string) => void
|
||
issueSans: string
|
||
setIssueSans: (v: string) => void
|
||
issueTrustWww: boolean
|
||
setIssueTrustWww: (v: boolean) => void
|
||
issueTrustApi: boolean
|
||
setIssueTrustApi: (v: boolean) => void
|
||
step?: 1 | 2 | 3 | 4
|
||
}) {
|
||
const showAll = step == null
|
||
return (
|
||
<div className="flex flex-col gap-5">
|
||
{(showAll || step === 1) && (
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Основные</SectionTitle>
|
||
<FormField label="Сервер" required hint="RouterOS 7.22+, куда импортируется сертификат">
|
||
<select
|
||
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"
|
||
>
|
||
<option value="" disabled>
|
||
Выбрать сервер…
|
||
</option>
|
||
{serverList.map((s) => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.name} ({s.site})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</FormField>
|
||
<FormField label="Имя сертификата на роутере" required hint="Имя объекта /certificate на устройстве">
|
||
<Input
|
||
className="font-mono"
|
||
value={issueCertName}
|
||
onChange={(e) => setIssueCertName(e.target.value)}
|
||
placeholder="router-le"
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
)}
|
||
|
||
{(showAll || step === 2) && (
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Домены</SectionTitle>
|
||
<FormField label="Common Name" required hint="Основное имя в сертификате">
|
||
<Input
|
||
className="font-mono"
|
||
value={issueCommonName}
|
||
onChange={(e) => setIssueCommonName(e.target.value)}
|
||
placeholder="vpn.example.com"
|
||
/>
|
||
</FormField>
|
||
<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"
|
||
value={issueSans}
|
||
onChange={(e) => setIssueSans(e.target.value)}
|
||
placeholder="www.example.com"
|
||
/>
|
||
</FormField>
|
||
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
|
||
<p className="font-medium text-foreground mb-1">Let's Encrypt · DNS-01 (Cloudflare)</p>
|
||
<p>TXT-запись создаётся в Cloudflare, сертификат импортируется на выбранный RouterOS.</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{(showAll || step === 3) && (
|
||
<div className="flex flex-col gap-4">
|
||
<SectionTitle>Импорт на RouterOS</SectionTitle>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">Trust store · www</p>
|
||
<p className="text-xs text-muted-foreground">Веб-интерфейс и HTTPS-сервисы</p>
|
||
</div>
|
||
<FormToggle checked={issueTrustWww} onChange={setIssueTrustWww} />
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium">Trust store · api</p>
|
||
<p className="text-xs text-muted-foreground">REST API и управление</p>
|
||
</div>
|
||
<FormToggle checked={issueTrustApi} onChange={setIssueTrustApi} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{(showAll || step === 4) && (
|
||
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm">
|
||
<p className="font-medium mb-2">Проверьте параметры</p>
|
||
<ul className="text-xs text-muted-foreground space-y-1">
|
||
<li>Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}</li>
|
||
<li>Имя: {issueCertName || "—"}</li>
|
||
<li>CN: {issueCommonName || "—"}</li>
|
||
<li>Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}</li>
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function CertificatesPage() {
|
||
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
|
||
const isLive = prefsHydrated && mode === "live"
|
||
const liveReady = isLive && backendStatus === true
|
||
|
||
const [search, setSearch] = useState("")
|
||
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
|
||
const [certificates, setCertificates] = useState<CertificateDto[]>([])
|
||
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("idle")
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
const [serverList, setServerList] = useState<Server[]>([])
|
||
|
||
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("")
|
||
const [issueSans, setIssueSans] = useState("")
|
||
const [issueTrustWww, setIssueTrustWww] = useState(true)
|
||
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
||
|
||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||
|
||
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
|
||
"https://acme-v02.api.letsencrypt.org/directory",
|
||
)
|
||
const [acmeZoneId, setAcmeZoneId] = useState("")
|
||
const [acmeTokenDraft, setAcmeTokenDraft] = useState("")
|
||
const [acmeTokenConfigured, setAcmeTokenConfigured] = useState(false)
|
||
const [acmeSaveBusy, setAcmeSaveBusy] = useState(false)
|
||
|
||
const displayCerts = useMemo(() => {
|
||
if (!prefsHydrated) return []
|
||
if (isLive) return certificates
|
||
return routerCertificates.map(mockToDto)
|
||
}, [prefsHydrated, isLive, certificates])
|
||
|
||
const displayServers = isLive ? serverList : mockServers
|
||
|
||
const scopedCerts = useMemo(() => {
|
||
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
|
||
return displayCerts.filter((c) => c.serverId === selectedServerId)
|
||
}, [displayCerts, selectedServerId])
|
||
|
||
const certRailItems = useMemo<ServerTileItem[]>(() => (
|
||
displayServers.map((s) => ({
|
||
id: s.id,
|
||
name: s.name,
|
||
host: s.host,
|
||
site: s.site,
|
||
country: s.country,
|
||
status: s.status,
|
||
type: s.type,
|
||
enabled: s.enabled,
|
||
meta: String(displayCerts.filter((c) => c.serverId === s.id).length),
|
||
}))
|
||
), [displayServers, displayCerts])
|
||
|
||
const serverById = useMemo(() => {
|
||
const map = new Map<string, Server>()
|
||
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
|
||
return map
|
||
}, [isLive, serverList])
|
||
|
||
const loadLive = useCallback(
|
||
async (silent = false) => {
|
||
if (!isLive) return
|
||
if (!silent) setLoadState("loading")
|
||
setLoadError(null)
|
||
try {
|
||
const [certRes, serversRes] = await Promise.all([
|
||
listCertificates(backendUrl),
|
||
listServers(backendUrl),
|
||
])
|
||
setCertificates(certRes.certificates)
|
||
setServerList(serversRes.map(toFrontendServer))
|
||
if (certRes.failures.length > 0 && certRes.certificates.length === 0) {
|
||
setLoadError(
|
||
certRes.failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; "),
|
||
)
|
||
} else if (certRes.failures.length > 0) {
|
||
toast.warning(`Часть серверов недоступна: ${certRes.failures.length}`)
|
||
}
|
||
setLoadState("idle")
|
||
} catch (e) {
|
||
setCertificates([])
|
||
setLoadError(e instanceof Error ? e.message : "Не удалось загрузить сертификаты")
|
||
setLoadState("error")
|
||
}
|
||
},
|
||
[backendUrl, isLive],
|
||
)
|
||
|
||
const loadAcmeSettings = useCallback(async () => {
|
||
if (!liveReady) return
|
||
try {
|
||
const s = await getAcmeSettings(backendUrl)
|
||
setAcmeDirectoryUrl(s.directoryUrl)
|
||
setAcmeZoneId(s.defaultZoneId ?? "")
|
||
setAcmeTokenConfigured(s.tokenConfigured)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}, [backendUrl, liveReady])
|
||
|
||
useEffect(() => {
|
||
if (!isLive) {
|
||
queueMicrotask(() => {
|
||
setCertificates([])
|
||
setServerList([])
|
||
setLoadState("idle")
|
||
setLoadError(null)
|
||
})
|
||
return
|
||
}
|
||
queueMicrotask(() => {
|
||
void loadLive()
|
||
void loadAcmeSettings()
|
||
})
|
||
}, [isLive, loadLive, loadAcmeSettings])
|
||
|
||
const expiring = useMemo(
|
||
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||
[scopedCerts],
|
||
)
|
||
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
||
|
||
const filtered = useMemo(() => {
|
||
return scopedCerts.filter((c) => {
|
||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||
if (!search) return true
|
||
const q = search.toLowerCase()
|
||
return (
|
||
c.name.toLowerCase().includes(q) ||
|
||
c.commonName.toLowerCase().includes(q) ||
|
||
c.issuedBy.toLowerCase().includes(q) ||
|
||
c.sans.some((s) => s.includes(q))
|
||
)
|
||
})
|
||
}, [scopedCerts, search, statusFilter])
|
||
|
||
async function handleRefresh() {
|
||
if (!liveReady) return
|
||
try {
|
||
const res = await refreshCertificates(backendUrl)
|
||
setCertificates(res.certificates)
|
||
toast.success("Список сертификатов обновлён")
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Ошибка обновления")
|
||
}
|
||
}
|
||
|
||
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(true)
|
||
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)
|
||
}
|
||
}
|
||
|
||
async function handleSaveAcmeSettings() {
|
||
if (!liveReady) return
|
||
setAcmeSaveBusy(true)
|
||
try {
|
||
const saved = await putAcmeSettings(backendUrl, {
|
||
directoryUrl: acmeDirectoryUrl.trim(),
|
||
defaultZoneId: acmeZoneId.trim() || null,
|
||
cloudflareApiToken: acmeTokenDraft.trim() ? acmeTokenDraft.trim() : undefined,
|
||
})
|
||
setAcmeTokenConfigured(saved.tokenConfigured)
|
||
setAcmeTokenDraft("")
|
||
toast.success("Настройки ACME сохранены")
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Не удалось сохранить настройки")
|
||
} finally {
|
||
setAcmeSaveBusy(false)
|
||
}
|
||
}
|
||
|
||
async function handleTestAcme() {
|
||
if (!liveReady) return
|
||
try {
|
||
const res = await testAcmeSettings(backendUrl, {
|
||
cloudflareApiToken: acmeTokenDraft.trim() || undefined,
|
||
})
|
||
if (res.ok) toast.success(res.message ?? "Cloudflare API доступен")
|
||
else toast.error(res.message ?? "Проверка не прошла")
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Проверка Cloudflare не удалась")
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<ServerRailLayout
|
||
items={certRailItems}
|
||
selectedId={selectedServerId}
|
||
onSelect={setSelectedServerId}
|
||
showAll
|
||
allCount={displayServers.length}
|
||
loading={isLive && loadState === "loading" && displayServers.length === 0}
|
||
header={
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||
actions={
|
||
<>
|
||
<ServerRailMobileButton />
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={!liveReady || loadState === "loading"}
|
||
onClick={() => {
|
||
void handleRefresh()
|
||
}}
|
||
>
|
||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||
<UploadIcon className="size-4" />
|
||
Импорт
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
disabled={!liveReady || issueBusy}
|
||
onClick={() => {
|
||
setIssueStep(1)
|
||
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||
setIssueOpen(true)
|
||
}}
|
||
>
|
||
<PlusIcon className="size-4" />
|
||
Выпустить сертификат
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-5">
|
||
{isLive && backendStatus === false && (
|
||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||
Backend недоступен — live-операции отключены.
|
||
</div>
|
||
)}
|
||
{loadError && isLive && (
|
||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||
{loadError}
|
||
</div>
|
||
)}
|
||
|
||
{(expiring.length > 0 || expired.length > 0) && (
|
||
<div className="flex flex-col gap-2">
|
||
{expired.length > 0 && <CertPartAlertExpired expired={expired} />}
|
||
{expiring.length > 0 && <CertPartAlertExpiring expiring={expiring} />}
|
||
</div>
|
||
)}
|
||
|
||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||
|
||
{liveReady && (
|
||
<CertPartAcmeSettings
|
||
acmeDirectoryUrl={acmeDirectoryUrl}
|
||
setAcmeDirectoryUrl={setAcmeDirectoryUrl}
|
||
acmeZoneId={acmeZoneId}
|
||
setAcmeZoneId={setAcmeZoneId}
|
||
acmeTokenDraft={acmeTokenDraft}
|
||
setAcmeTokenDraft={setAcmeTokenDraft}
|
||
acmeTokenConfigured={acmeTokenConfigured}
|
||
acmeSaveBusy={acmeSaveBusy}
|
||
onTest={() => {
|
||
void handleTestAcme()
|
||
}}
|
||
onSave={() => {
|
||
void handleSaveAcmeSettings()
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<DataPageCard>
|
||
<DataPageToolbar
|
||
search={search}
|
||
onSearchChange={setSearch}
|
||
searchPlaceholder="Поиск по имени, CN, эмитенту…"
|
||
segmented={{
|
||
value: statusFilter,
|
||
onChange: setStatusFilter,
|
||
options: [
|
||
{ value: "all", label: "Все" },
|
||
{ value: "valid", label: "Действующие" },
|
||
{ value: "expired", label: "Истёкшие" },
|
||
{ value: "revoked", label: "Отозванные" },
|
||
],
|
||
}}
|
||
countLabel={`${filtered.length} сертификатов`}
|
||
/>
|
||
<CertificatesDataGrid
|
||
certificates={filtered}
|
||
serverMap={serverById}
|
||
isLoading={prefsHydrated && isLive && loadState === "loading" && displayCerts.length === 0}
|
||
/>
|
||
</DataPageCard>
|
||
|
||
<CertPartReference />
|
||
</div>
|
||
</ServerRailLayout>
|
||
|
||
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||
<SheetTitle>Выпуск сертификата</SheetTitle>
|
||
<SheetDescription>
|
||
Let's Encrypt через DNS-01 (Cloudflare) и импорт на выбранный RouterOS.
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<Stepper value={issueStep} onValueChange={setIssueStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||
<StepperNav className="mb-5">
|
||
{[
|
||
{ step: 1, title: "Основные" },
|
||
{ step: 2, title: "Домены" },
|
||
{ step: 3, title: "Импорт" },
|
||
{ step: 4, title: "Проверка" },
|
||
].map(({ step, title }, i, arr) => (
|
||
<StepperItem key={step} step={step}>
|
||
<StepperTrigger>
|
||
<StepperIndicator>{step}</StepperIndicator>
|
||
<StepperTitle className="sr-only">{title}</StepperTitle>
|
||
</StepperTrigger>
|
||
{i < arr.length - 1 && <StepperSeparator />}
|
||
</StepperItem>
|
||
))}
|
||
</StepperNav>
|
||
<StepperPanel className="flex-1 overflow-y-auto">
|
||
{[1, 2, 3, 4].map((s) => (
|
||
<StepperContent key={s} value={s}>
|
||
<CertPartIssueForm
|
||
step={s as 1 | 2 | 3 | 4}
|
||
serverList={displayServers}
|
||
issueServerId={issueServerId}
|
||
setIssueServerId={setIssueServerId}
|
||
issueCertName={issueCertName}
|
||
setIssueCertName={setIssueCertName}
|
||
issueCommonName={issueCommonName}
|
||
setIssueCommonName={setIssueCommonName}
|
||
issueSans={issueSans}
|
||
setIssueSans={setIssueSans}
|
||
issueTrustWww={issueTrustWww}
|
||
setIssueTrustWww={setIssueTrustWww}
|
||
issueTrustApi={issueTrustApi}
|
||
setIssueTrustApi={setIssueTrustApi}
|
||
/>
|
||
</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} />}>
|
||
Отмена
|
||
</SheetClose>
|
||
{issueStep > 1 && (
|
||
<Button variant="outline" className="flex-1" disabled={issueBusy} onClick={() => setIssueStep((s) => s - 1)}>
|
||
Назад
|
||
</Button>
|
||
)}
|
||
{issueStep < 4 ? (
|
||
<Button
|
||
className="flex-1"
|
||
disabled={issueStep === 1 && (!issueServerId || !issueCertName)}
|
||
onClick={() => setIssueStep((s) => s + 1)}
|
||
>
|
||
Далее
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
className="flex-1"
|
||
disabled={!liveReady || issueBusy}
|
||
onClick={() => { void handleIssue() }}
|
||
>
|
||
{issueBusy ? "Выпуск…" : "Выпустить"}
|
||
</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} готов к импорту на роутер`)
|
||
}}
|
||
/>
|
||
</>
|
||
)
|
||
}
|