"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { PageHeader } from "@/components/page-header" import { servers as mockServers } from "@/lib/data" import type { Server } from "@/lib/data" import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar } from "@/components/data-page-toolbar" import { IpsecUsersGrid } from "@/components/ipsec/ipsec-users-grid" import { IpsecServerGrid } from "@/components/ipsec/ipsec-server-grid" import { IpsecUserSheet, type IpsecUserFormState } from "@/components/ipsec/ipsec-user-sheet" import { IpsecInitSheet, type IpsecInitFormState } from "@/components/ipsec/ipsec-init-sheet" import { IpsecCertSheet } from "@/components/ipsec/ipsec-cert-sheet" import { IpsecPeerSheet, type IpsecPeerFormState } from "@/components/ipsec/ipsec-peer-sheet" import { Button } from "@/components/ui/button" import { Alert, AlertDescription, AlertTitle, } from "@/components/reui/alert" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { useDataSource } from "@/lib/data-source" import { ApiClientError, requestJson } from "@/shared/api/http-client" import { createIpsecUser, deleteIpsecCert, deleteIpsecPeer, deleteIpsecServer, deleteIpsecUser, exportIpsecCertByName, exportIpsecUserCert, initIpsecServer, listIpsec, patchIpsecPeer, patchIpsecUser, restoreIpsecRevision, } from "@/shared/api/ipsec" import type { IpsecCertBundle, IpsecCertInfoDto, IpsecClientDto, IpsecPeerDto, IpsecServerSummaryDto, } from "@mmapp/contracts/ipsec" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ALL_SERVERS_ID, type ServerTileItem, } from "@/components/server-tile-rail" import { ConfigHistorySheet } from "@/components/config-history-sheet" import { parseConfigRevisions, type ConfigRevisionDto } from "@/lib/config-revisions" import { toast } from "sonner" import { findFreePoolIp } from "@/lib/ipsec-client" import { ShieldCheckIcon, PlusIcon, KeyRoundIcon, UsersIcon, ActivityIcon, RefreshCwIcon, InfoIcon, Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon, } from "lucide-react" type IpsecWorkspaceTab = "clients" | "server" | "cli" type IpsecFailure = { serverId: string; serverName?: string; error: string } type PendingDelete = | { kind: "user"; client: IpsecClientDto } | { kind: "server"; summary: IpsecServerSummaryDto } | { kind: "peer"; serverId: string; peer: IpsecPeerDto } | { kind: "cert"; serverId: string; cert: IpsecCertInfoDto } const MOCK_IPSEC_PEER: IpsecPeerDto = { id: "srv2:pp1", rosId: "*PP1", serverId: "srv2", serverName: "mt-spb-edge-01", name: "ipsec-vpn", address: "0.0.0.0/0", exchangeMode: "ike2", passive: true, certificate: "ipsec-server", profile: "ipsec-vpn", disabled: false, managed: true, } const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [ { serverId: "srv2", serverName: "mt-spb-edge-01", initialized: true, ike2Ready: true, serverEndpoint: "vpn.example.com", pool: { id: "srv2:p1", rosId: "*P1", serverId: "srv2", name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", managed: true }, sharedModeConfig: { id: "srv2:m1", rosId: "*M1", serverId: "srv2", name: "ipsec-vpn", addressPool: "ipsec-vpn", staticDns: "10.77.0.1", managed: true }, peer: MOCK_IPSEC_PEER, peers: [MOCK_IPSEC_PEER], caCert: { name: "ipsec-ca", commonName: "MikrotikManager IPsec CA", keySize: "4096", expiresAt: "2036-09-01", trusted: true, hasPrivateKey: true, role: "ca", managed: true }, serverCert: { name: "ipsec-server", commonName: "vpn.example.com", keySize: "2048", expiresAt: "2031-09-01", trusted: true, hasPrivateKey: true, role: "server", managed: true }, natRuleManaged: true, clientsTotal: 3, clientsOnline: 1, certs: [], }, ] const MOCK_IPSEC_CLIENTS: IpsecClientDto[] = [ { id: "srv2:*I1", rosId: "*I1", serverId: "srv2", serverName: "mt-spb-edge-01", name: "alice", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-alice", certName: "ipsec-user-alice", signedBy: "MyCA", commonName: "alice", staticIp: "10.77.0.10", modeConfigName: "mc-ipsec-alice", peerName: "ipsec-vpn", online: true, activeAddress: "10.100.1.7", activeSince: "2h", disabled: false, managed: true, }, { id: "srv2:*I2", rosId: "*I2", serverId: "srv2", serverName: "mt-spb-edge-01", name: "bob", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-bob", certName: "ipsec-user-bob", signedBy: "MyCA", commonName: "bob", peerName: "ipsec-vpn", online: false, disabled: false, managed: true, }, { id: "srv2:*I3", rosId: "*I3", serverId: "srv2", serverName: "mt-spb-edge-01", name: "tablet-psk", authMethod: "pre-shared-key", kind: "psk", remoteId: "tablet", peerName: "ipsec-vpn", online: false, disabled: false, managed: true, }, ] interface BackendServer { id: number name: string host: string country: string type?: Server["type"] enabled: boolean status?: Server["status"] latency?: number | null asn?: string } function mapBackendServer(s: BackendServer): Server { return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: "", country: s.country || "UN", asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled, status: s.status ?? "online", latency: s.latency ?? null, sessions: 0, } } export default function IpsecPage() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const [summaries, setSummaries] = useState([]) const [clients, setClients] = useState([]) const [liveServers, setLiveServers] = useState([]) const [failures, setFailures] = useState([]) const [loading, setLoading] = useState(false) const [busy, setBusy] = useState(false) const [certBusy, setCertBusy] = useState(false) const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [workspaceTab, setWorkspaceTab] = useState("clients") const [search, setSearch] = useState("") const [initOpen, setInitOpen] = useState(false) const [userOpen, setUserOpen] = useState(false) const [editing, setEditing] = useState(null) const [certOpen, setCertOpen] = useState(false) const [certBundle, setCertBundle] = useState(null) const [certClient, setCertClient] = useState(null) const [certByName, setCertByName] = useState<{ serverId: string; name: string } | null>(null) const [peerOpen, setPeerOpen] = useState(false) const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null) const [pendingDelete, setPendingDelete] = useState(null) const [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) const [historyLoading, setHistoryLoading] = useState(false) const [historyError, setHistoryError] = useState(null) const historyEpoch = useRef(0) const [historyRestoring, setHistoryRestoring] = useState(false) const loadLive = useCallback(async () => { if (!isLive) return setLoading(true) try { const [ipsec, servers] = await Promise.all([ listIpsec(backendUrl), requestJson(backendUrl, "/api/servers"), ]) setSummaries(ipsec.servers) setClients(ipsec.clients) setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer)) setFailures(ipsec.failures ?? []) if (ipsec.failures?.length) { toast.warning( `Не удалось опросить: ${ipsec.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`, ) } } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка загрузки IPsec") setSummaries([]) setClients([]) setFailures([]) } finally { setLoading(false) } }, [isLive, backendUrl]) useEffect(() => { if (!isLive) { queueMicrotask(() => { setSummaries([]) setClients([]) setLiveServers([]) setFailures([]) }) return } queueMicrotask(() => { void loadLive() }) }, [isLive, loadLive]) const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled) const displaySummaries = isLive ? summaries : MOCK_IPSEC_SERVERS const displayClients = isLive ? clients : MOCK_IPSEC_CLIENTS const effectiveServerId = selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId) ? selectedServerId : ALL_SERVERS_ID const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive]) const loadRevisions = useCallback(async () => { if (!isLive || !historyServerId) return const epoch = ++historyEpoch.current setHistoryLoading(true) setHistoryError(null) try { const res = await requestJson( backendUrl, `/api/ipsec/revisions?serverId=${encodeURIComponent(historyServerId)}`, ) if (epoch !== historyEpoch.current) return setRevisions(parseConfigRevisions(res, historyServerId, "ipsec")) } catch (err) { if (epoch !== historyEpoch.current) return setHistoryError(err instanceof Error ? err.message : "Не удалось загрузить историю") } finally { if (epoch === historyEpoch.current) setHistoryLoading(false) } }, [isLive, historyServerId, backendUrl]) const restoreRevision = useCallback(async (id: string) => { if (!isLive || !historyServerId) return setHistoryRestoring(true) try { const res = await restoreIpsecRevision(backendUrl, id, historyServerId) if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление") toast.success("Версия применена на роутер") await loadLive() await loadRevisions() } catch (err) { throw err } finally { setHistoryRestoring(false) } }, [isLive, historyServerId, backendUrl, loadLive, loadRevisions]) const scopedClients = useMemo(() => { if (effectiveServerId === ALL_SERVERS_ID) return displayClients return displayClients.filter((c) => c.serverId === effectiveServerId) }, [displayClients, effectiveServerId]) const scopedSummaries = useMemo(() => { if (effectiveServerId === ALL_SERVERS_ID) return displaySummaries return displaySummaries.filter((s) => s.serverId === effectiveServerId) }, [displaySummaries, effectiveServerId]) const filteredClients = useMemo(() => { if (!search) return scopedClients const q = search.toLowerCase() return scopedClients.filter( (c) => c.name.toLowerCase().includes(q) || c.serverName.toLowerCase().includes(q) || (c.commonName ?? "").toLowerCase().includes(q) || (c.staticIp ?? "").includes(q), ) }, [scopedClients, search]) const serversWithIpsec = scopedSummaries.filter((s) => s.peers.length > 0).length const clientsOnline = scopedClients.filter((c) => c.online).length const caOk = scopedSummaries.some((s) => s.caCert) const compactServer = effectiveServerId !== ALL_SERVERS_ID const sheetServerId = compactServer ? effectiveServerId : undefined const certNames = useMemo( () => Array.from(new Set(scopedSummaries.flatMap((s) => (s.certs ?? []).map((c) => c.name)))).sort(), [scopedSummaries], ) const peerOptions = useMemo( () => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))), [scopedSummaries], ) const serverOptions = displayServers.map((s) => ({ id: s.id, name: s.name, host: s.host, })) /** Подсказка свободного IP из пула выбранного сервера. */ const freeIpHint = useMemo(() => { const summary = scopedSummaries.find((s) => s.ike2Ready && s.pool) if (!summary?.pool) return undefined const taken = scopedClients.map((c) => c.staticIp).filter(Boolean) as string[] return findFreePoolIp(summary.pool.ranges, taken) ?? undefined }, [scopedSummaries, scopedClients]) const railItems = useMemo(() => { const counts = new Map() for (const c of displayClients) { counts.set(c.serverId, (counts.get(c.serverId) ?? 0) + 1) } return displayServers.map((s) => ({ id: s.id, name: s.name, host: s.host, site: s.site, country: s.country, status: s.status, type: s.type, count: counts.get(s.id) ?? 0, enabled: s.enabled, title: [s.name, s.host, s.asn].filter(Boolean).join(" · "), })) }, [displayServers, displayClients]) const handleInit = async (form: IpsecInitFormState) => { setBusy(true) try { await initIpsecServer(backendUrl, { serverId: form.serverId, serverEndpoint: form.serverEndpoint.trim(), poolCidr: form.poolCidr.trim(), dns: form.dns.trim() || undefined, caDaysValid: 3650, serverDaysValid: 3650, clientDaysValid: 1825, createNatRule: form.createNatRule, }) toast.success("IKEv2-сервер инициализирован") setInitOpen(false) await loadLive() } catch (e) { toast.error("Ошибка инициализации", { description: e instanceof Error ? e.message : String(e), }) } finally { setBusy(false) } } const handleCreateUser = async (form: IpsecUserFormState) => { setBusy(true) try { const res = await createIpsecUser(backendUrl, { serverId: form.serverId, name: form.name.trim(), peerName: form.peerName.trim() || undefined, authMethod: form.authMethod, psk: form.authMethod === "pre-shared-key" ? form.psk : undefined, remoteId: form.authMethod === "pre-shared-key" ? form.remoteId.trim() || undefined : undefined, staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : undefined, passphrase: form.passphrase.trim() || undefined, }) toast.success(`Клиент «${form.name.trim()}» создан`) setUserOpen(false) setEditing(null) await loadLive() if (res.bundle) { setCertBundle(res.bundle) setCertClient(res.client ?? null) setCertByName(null) setCertOpen(true) } } catch (e) { toast.error("Ошибка создания клиента", { description: e instanceof Error ? e.message : String(e), }) } finally { setBusy(false) } } const handleEditUser = async (form: IpsecUserFormState) => { if (!editing) return setBusy(true) try { await patchIpsecUser(backendUrl, editing.serverId, editing.rosId, { name: form.name.trim() !== editing.name ? form.name.trim() : undefined, staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : null, psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined, }) toast.success("Клиент обновлён") setUserOpen(false) setEditing(null) await loadLive() } catch (e) { toast.error("Ошибка обновления", { description: e instanceof Error ? e.message : String(e) }) } finally { setBusy(false) } } const openCert = async (client: IpsecClientDto) => { const byName = client.kind === "cert" && Boolean(client.certName) setCertClient(byName ? null : client) setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null) setCertBundle(null) setCertOpen(true) setCertBusy(true) try { const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}` const bundle = byName ? await exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase) : await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase) setCertBundle(bundle) } catch (e) { toast.error("Ошибка экспорта сертификата", { description: e instanceof Error ? e.message : String(e), }) setCertOpen(false) } finally { setCertBusy(false) } } /** Экспорт .p12 существующего клиентского серта по имени (client1/anakondra и т.п.). */ const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => { setCertByName({ serverId, name: cert.name }) setCertClient(null) setCertBundle(null) setCertOpen(true) setCertBusy(true) try { const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}` const bundle = await exportIpsecCertByName(backendUrl, serverId, cert.name, passphrase) setCertBundle(bundle) } catch (e) { toast.error("Ошибка экспорта сертификата", { description: e instanceof Error ? e.message : String(e), }) setCertOpen(false) } finally { setCertBusy(false) } } const reexportCert = async (passphrase: string) => { if (!certClient && !certByName) return setCertBusy(true) try { const bundle = certByName ? await exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase) : await exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase) setCertBundle(bundle) } catch (e) { toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) }) } finally { setCertBusy(false) } } const confirmDelete = async () => { if (!pendingDelete) return const target = pendingDelete setBusy(true) try { if (target.kind === "user") { await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId) toast.success(`Клиент «${target.client.name}» удалён`) } else if (target.kind === "server") { await deleteIpsecServer(backendUrl, target.summary.serverId) toast.success(`IKEv2-сервер на ${target.summary.serverName} удалён`) } else if (target.kind === "peer") { await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId) toast.success(`Peer «${target.peer.name}» удалён`) } else { await deleteIpsecCert(backendUrl, target.serverId, target.cert.name) toast.success(`Сертификат «${target.cert.name}» удалён`) } await loadLive() } catch (e) { const forceDelete = async (fn: () => Promise) => { try { await fn() toast.success("Удалено") await loadLive() } catch (err) { toast.error("Не удалось удалить", { description: err instanceof Error ? err.message : String(err) }) } } if (e instanceof ApiClientError && e.status === 409 && target.kind === "peer") { toast.error("На peer ссылаются identity", { description: e.message, action: { label: "Удалить принудительно", onClick: () => { void forceDelete(() => deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force: true })) }, }, }) } else if (e instanceof ApiClientError && e.status === 409 && target.kind === "cert") { toast.error("Сертификат используется", { description: e.message, action: { label: "Удалить принудительно", onClick: () => { void forceDelete(() => deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force: true })) }, }, }) } else { toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) }) } } finally { setBusy(false) setPendingDelete(null) } } const openEditPeer = (serverId: string, peer: IpsecPeerDto) => { setEditingPeer({ serverId, peer }) setPeerOpen(true) } const handleEditPeer = async (form: IpsecPeerFormState) => { if (!editingPeer) return setBusy(true) try { await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, { name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined, address: form.address.trim() || undefined, exchangeMode: form.exchangeMode.trim() || undefined, passive: form.passive, certificate: form.certificate.trim() || undefined, profile: form.profile.trim() || undefined, disabled: form.disabled, }) toast.success("Peer обновлён") setPeerOpen(false) setEditingPeer(null) await loadLive() } catch (e) { toast.error("Ошибка обновления peer", { description: e instanceof Error ? e.message : String(e) }) } finally { setBusy(false) } } const deleteDialogTitle = (() => { if (!pendingDelete) return "" switch (pendingDelete.kind) { case "server": return `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?` case "user": return `Удалить клиента «${pendingDelete.client.name}»?` case "peer": return `Удалить peer «${pendingDelete.peer.name}»?` case "cert": return `Удалить сертификат «${pendingDelete.cert.name}»?` } })() const deleteDialogDescription = (() => { if (!pendingDelete) return "" switch (pendingDelete.kind) { case "server": return "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)." case "user": return "Удалит identity, персональный mode-config и клиентский сертификат на роутере." case "peer": return "Удалит peer на роутере. Если на него ссылаются identity — потребуется принудительное удаление." case "cert": return "Удалит сертификат с роутера. Если он используется peer или identity — потребуется принудительное удаление." } })() return ( <> {isLive && ( )} {isLive && ( )} {isLive ? ( ) : null} } /> } >
, iconClassName: "text-muted-foreground", }, { id: "clients", label: "Клиентов", value: scopedClients.length, icon: , iconClassName: "text-info", }, { id: "online", label: "Онлайн", value: clientsOnline, icon: , iconClassName: "text-success", }, { id: "ca", label: "CA", value: caOk ? "ок" : "—", icon: , iconClassName: "text-primary", }, ]} /> {failures.length > 0 ? ( Не удалось опросить часть роутеров {failures.map((f) => f.serverName ?? f.serverId).join(", ")} ) : null} {!isLive ? ( Mock-режим Переключитесь в live в настройках, чтобы управлять IKEv2 на MikroTik. ) : null} setWorkspaceTab(v as IpsecWorkspaceTab)} className="gap-3" > Клиенты Сервер CLI { setEditing(null); setUserOpen(true) }}> Клиент ) : undefined } /> void openCert(row) : undefined} onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined} onDelete={isLive ? (row) => setPendingDelete({ kind: "user", client: row }) : undefined} /> { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined} onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined} onEditPeer={isLive ? openEditPeer : undefined} onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined} onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined} onExportCert={isLive ? (serverId, cert) => void openCertByName(serverId, cert) : undefined} />
{[ { title: "CA и серверный серт.", lines: [ "/certificate add name=ipsec-ca \\", " common-name=\"MM IPsec CA\" \\", " key-size=4096 \\", " key-usage=key-cert-sign,crl-sign", "/certificate sign ipsec-ca", "/certificate add name=ipsec-server \\", " common-name=vpn.example.com \\", " subject-alt-name=DNS:vpn.example.com", "/certificate sign ipsec-server ca=ipsec-ca", ], }, { title: "Peer + identity", lines: [ "/ip ipsec peer add \\", " name=ipsec-vpn address=0.0.0.0/0 \\", " exchange-mode=ike2 passive=yes \\", " certificate=ipsec-server send-cert=always", "/ip ipsec identity add \\", " peer=ipsec-vpn auth-method=rsa-key \\", " certificate=ipsec-server \\", " match-by=certificate \\", " generate-policy=port-strict \\", " mode-config=ipsec-vpn", ], }, { title: "Клиент: серт и .p12", lines: [ "/certificate add name=ipsec-user-alice \\", " common-name=alice key-size=2048 \\", " key-usage=digital-signature,\\", " key-encipherment,tls-client", "/certificate sign ipsec-user-alice \\", " ca=ipsec-ca", "/certificate export-certificate \\", " ipsec-user-alice type=pkcs12 \\", " export-passphrase=*****", "", "# Онлайн-клиенты:", "/ip ipsec active-peers print", ], }, ].map((b) => (

{b.title}

                          {b.lines.join("\n")}
                        
))}
{ setUserOpen(v); if (!v) setEditing(null) }} servers={serverOptions} peers={peerOptions} busy={busy} defaultServerId={sheetServerId} editing={editing} freeIpHint={freeIpHint} onSubmit={editing ? handleEditUser : handleCreateUser} /> { setPeerOpen(v); if (!v) setEditingPeer(null) }} busy={busy} editing={editingPeer?.peer ?? null} certificates={certNames} onSubmit={handleEditPeer} /> { setCertOpen(v); if (!v) setCertByName(null) }} bundle={certBundle} busy={certBusy} onReexport={reexportCert} /> { if (!v) setPendingDelete(null) }}> {deleteDialogTitle} {deleteDialogDescription} Отмена { e.preventDefault(); void confirmDelete() }} > Удалить { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }} loadError={historyError} onRetry={() => void loadRevisions()} revisions={revisions} loading={historyLoading} restoring={historyRestoring} onRestore={restoreRevision} title="История конфигураций IPsec" itemLabel="конфигурация" /> ) }