"use client"
import { Suspense, 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, SegmentedControl } from "@/components/form-kit"
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 { CertificateAcmeSettings } from "@/components/certificates/certificate-acme-settings"
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 { useCertificateIssue } from "@/lib/use-certificate-issue"
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-kit/accessible-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,
totalKnown,
}: {
displayCerts: CertificateView[]
expiring: CertificateView[]
expired: CertificateView[]
complete: boolean
totalKnown: 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 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 и управление
После импорта сертификат будет назначен сервисам www-ssl и api-ssl; оба сервиса будут включены. Переключатели выше задают trust store сертификата.
)}
{(showAll || step === 4) && (
Проверьте параметры
Сервер: {serverList.find((s) => s.id === issueServerId)?.name ?? "—"}
Имя: {issueCertName || "—"}
CN: {issueCommonName || "—"}
Trust www: {issueTrustWww ? "да" : "нет"} · api: {issueTrustApi ? "да" : "нет"}
Сервисы: www-ssl и api-ssl будут включены и получат этот сертификат.
DNS: приложение попробует синхронизировать A-записи доменов с IPv4 сервера; CNAME сохраняются.
)}
)
}
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 [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 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 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),
[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 handleIssue() {
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 (
<>
{
void handleRefresh()
}}
>
Обновить
Импорт
{
setIssueStep(issue.checkpoint ? 4 : 1)
if (!issue.checkpoint && selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
setIssueOpen(true)
}}
>
{issue.checkpoint ? "Задача выпуска" : "Выпустить сертификат"}
>
}
/>
}
>
{!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 &&
Сводка неполная: часть устройств, статусов или сроков неизвестна. Таблица показывает доступные снимки.
}
}
countLabel={isLive && !catalog.hasState ? "Количество неизвестно" : `${filtered.length} строк в снимках`}
/>
{urlError ? Исправьте контекст ссылки
: isLive && scopedCerts.length === 0 && !scopeKnown ? {loading ? "Загрузка сертификатов…" : "Список сертификатов пока неизвестен"}
: }
{ setIssueOpen(v); if (!v) setIssueStep(1) }}>
Выпуск сертификата
Let's Encrypt через DNS-01 (Cloudflare) и импорт на выбранный RouterOS.
{(issue.error || issue.storageError) &&
{issue.error || issue.storageError}
}
{issue.checkpoint &&
Закрытие формы не отменяет серверную задачу. Её можно проверить повторно.
}
{issue.checkpoint &&
Задача {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}.
}
{[
{ 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) => (
))}
{issueTerminal && canWrite && Новый выпуск }
}>
{issue.checkpoint ? "Закрыть" : "Отмена"}
{issueStep > 1 && (
setIssueStep((s) => s - 1)}>
Назад
)}
{issueStep < 4 ? (
setIssueStep((s) => s + 1)}
>
Далее
) : (
{ void handleIssue() }}
>
{issueBusy ? "Проверяем…" : issueTerminal ? "Задача завершена" : issue.checkpoint ? "Продолжить проверку" : "Выпустить"}
)}
>
)
}