"use client"
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
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"
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 { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
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 { 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,
getAcmeSettings,
getCertificateIssueJob,
putAcmeSettings,
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: CertificateView[] }) {
return (
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
{expired.map((c) => c.name).join(", ")} — требуют обновления
)
}
function CertPartAlertExpiring({ expiring }: { expiring: CertificateView[] }) {
return (
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
{expiring.map((c) => `${c.name} (${c.daysLeft}д)`).join(", ")}
)
}
function CertPartKpi({
displayCerts,
expiring,
expired,
complete,
}: {
displayCerts: CertificateView[]
expiring: CertificateView[]
expired: CertificateView[]
complete: boolean
}) {
return (
,
iconClassName: "text-muted-foreground",
},
{
id: "valid",
label: "Действующих",
value: complete ? displayCerts.filter((c) => c.status === "valid").length : "—",
icon: ,
iconClassName: "text-success",
},
{
id: "expiring",
label: "Истекают",
value: complete ? expiring.length : "—",
icon: ,
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
variant: expiring.length > 0 ? "warning" : "default",
},
{
id: "expired",
label: "Истёкших",
value: complete ? expired.length : "—",
icon: ,
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 (
Проверить Cloudflare
Сохранить настройки
)
}
function CertPartReference() {
return (
{[
{
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) => (
{b.title}
{b.lines.join("\n")}
))}
)
}
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 (
{(showAll || step === 1) && (
Основные
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"
>
Выбрать сервер…
{serverList.map((s) => (
{s.name} ({s.site})
))}
setIssueCertName(e.target.value)}
placeholder="router-le"
/>
)}
{(showAll || step === 2) && (
)}
{(showAll || step === 3) && (
Импорт на RouterOS
Trust store · www
Веб-интерфейс и HTTPS-сервисы
Trust store · api
REST API и управление
)}
{(showAll || step === 4) && (
Проверьте параметры
Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}
Имя: {issueCertName || "—"}
CN: {issueCommonName || "—"}
Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}
)}
)
}
export default function CertificatesPage() {
return Загрузка контекста сертификатов…}>
}
function CertificatesSource() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return Загрузка настроек источника…
return
}
function CertificatesContent() {
const { mode, backendUrl, prefsHydrated, backendStatus } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const liveReady = isLive && backendStatus === true
const params = useSearchParams()
const context = readCertificatesUrl(params)
const search = context.search
const statusFilter = context.status
const selectedServerId = context.server
const urlError = context.error
function writeContext(changes: Record, replace = false) {
const href = patchCertificatesUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
}
const setSearch = (value: string) => writeContext({ q: value }, true)
const setStatusFilter = (value: CertificateStatusFilter) => writeContext({ status: value === "all" ? null : value })
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
const canRead = can("mm:certificates:read")
const canWrite = can("mm:certificates:write")
const canReadServers = can("mm:servers:read")
const catalog = useCertificateCatalog(backendUrl, isLive, canRead, canReadServers)
const { snapshots, loading, error: loadError, serverError } = catalog
const serverList = useMemo(() => catalog.servers.map(toFrontendServer), [catalog.servers])
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 [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 Object.values(snapshots).flatMap(snapshot => snapshot.confirmed?.certificates ?? [])
return routerCertificates.map(mockToDto)
}, [prefsHydrated, isLive, snapshots])
const displayServers = isLive ? serverList : mockServers
const scopedCerts = useMemo(() => {
if (urlError) return []
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
return displayCerts.filter((c) => c.serverId === selectedServerId)
}, [displayCerts, selectedServerId, urlError])
const certRailItems = useMemo(() => (
displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
site: s.site,
country: s.country,
status: isLive ? catalog.servers.find(server => String(server.id) === s.id)?.status ?? undefined : s.status,
type: s.type,
enabled: s.enabled,
meta: isLive && !snapshots[s.id]?.confirmed ? "—" : String(displayCerts.filter((c) => c.serverId === s.id).length),
}))
), [displayServers, displayCerts, isLive, catalog.servers, snapshots])
const serverById = useMemo(() => {
const map = new Map()
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
return map
}, [isLive, serverList])
const loadLive = catalog.reload
const loadAcmeSettings = useCallback(async () => {
if (!liveReady || !canRead) return
try {
const s = await getAcmeSettings(backendUrl)
setAcmeDirectoryUrl(s.directoryUrl)
setAcmeZoneId(s.defaultZoneId ?? "")
setAcmeTokenConfigured(s.tokenConfigured)
} catch {
/* ignore */
}
}, [backendUrl, liveReady, canRead])
useEffect(() => {
queueMicrotask(() => { void loadAcmeSettings() })
}, [loadAcmeSettings])
const expiring = useMemo(
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft !== null && c.daysLeft >= 0 && c.daysLeft <= 30),
[scopedCerts],
)
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
const scopeSnapshots = Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId)
const scopeKnown = !urlError && (!isLive && (selectedServerId === ALL_SERVERS_ID || mockServers.some(server => server.id === selectedServerId)) || canRead && catalog.hasState && !loadError && (selectedServerId === ALL_SERVERS_ID || !!snapshots[selectedServerId]) && scopeSnapshots.every(snapshot => snapshot.latest.error === null))
const classificationsKnown = scopeKnown && scopedCerts.every(cert => cert.status !== "unknown" && cert.daysLeft !== null)
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() {
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)
}
}
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 (
<>
{
void handleRefresh()
}}
>
Обновить
setImportOpen(true)}>
Импорт
{
setIssueStep(1)
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
setIssueOpen(true)
}}
>
Выпустить сертификат
>
}
/>
}
>
{!isLive &&
Моковые данные
}
{urlError &&
{urlError} writeContext({ server: null, status: null, q: null }, true)}>Сбросить контекст
}
{!urlError && selectedServerId !== ALL_SERVERS_ID && (isLive ? catalog.hasState && !snapshots[selectedServerId] : !mockServers.some(server => server.id === selectedServerId)) &&
Выбранный сервер недоступен в каталоге сертификатов. Выберите другой сервер.
}
{isLive && backendStatus === false && (
Backend недоступен — live-операции отключены.
)}
{loadError && isLive && (
{catalog.hasState ? "Последние данные сохранены. " : "Сертификаты пока неизвестны. "}{loadError}
)}
{isLive && serverError &&
{catalog.hasServers ? "Последние серверы сохранены. " : "Каталог серверов пока неизвестен. "}{serverError}
}
{isLive && !canRead &&
Нет права mm:certificates:read.
}
{isLive && !canReadServers &&
Нет права mm:servers:read. Выбор устройства для выпуска недоступен.
}
{isLive && catalog.collectedAt &&
Последнее чтение каталога: {new Date(catalog.collectedAt).toLocaleString("ru-RU")}
}
{isLive && Object.values(snapshots).filter(snapshot => selectedServerId === ALL_SERVERS_ID || snapshot.latest.serverId === selectedServerId).map(snapshot =>
{snapshot.latest.error &&
{snapshot.latest.serverName}: {snapshot.latest.error}. {snapshot.confirmed ? "Последние сертификаты сохранены." : "Сертификаты пока неизвестны."}
}
{snapshot.latest.serverName}: {snapshot.confirmed ? "Подтверждённые данные от " + new Date(snapshot.confirmed.collectedAt).toLocaleString("ru-RU") : "Успешного чтения нет"}
)}
{(expiring.length > 0 || expired.length > 0) && (
{expired.length > 0 && }
{expiring.length > 0 && }
)}
{!classificationsKnown &&
Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.
}
{liveReady && canWrite && (
{
void handleTestAcme()
}}
onSave={() => {
void handleSaveAcmeSettings()
}}
/>
)}
{urlError ? Исправьте контекст ссылки
: isLive && scopedCerts.length === 0 && !scopeKnown ? {loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}
: }
{ setIssueOpen(v); if (!v) setIssueStep(1) }}>
Выпуск сертификата
Let's Encrypt через DNS-01 (Cloudflare) и импорт на выбранный RouterOS.
{[
{ step: 1, title: "Основные" },
{ step: 2, title: "Домены" },
{ step: 3, title: "Импорт" },
{ step: 4, title: "Проверка" },
].map(({ step, title }, i, arr) => (
{step}
{title}
{i < arr.length - 1 && }
))}
{[1, 2, 3, 4].map((s) => (
))}
}>
Отмена
{issueStep > 1 && (
setIssueStep((s) => s - 1)}>
Назад
)}
{issueStep < 4 ? (
setIssueStep((s) => s + 1)}
>
Далее
) : (
{ void handleIssue() }}
>
{issueBusy ? "Выпуск…" : "Выпустить"}
)}
{
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
}}
/>
>
)
}