1002 lines
47 KiB
TypeScript
1002 lines
47 KiB
TypeScript
"use client"
|
||
|
||
import { can } from "@/lib/auth"
|
||
|
||
import { z } from "zod"
|
||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||
import { useSearchParams } from "next/navigation"
|
||
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 { Checkbox } from "@/components/ui/checkbox"
|
||
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"] | null
|
||
latency?: number | null
|
||
asn?: string
|
||
}
|
||
|
||
type InventoryServer = Server & { monitoringUnknown?: boolean }
|
||
|
||
function mapBackendServer(s: BackendServer): InventoryServer {
|
||
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 ?? "offline",
|
||
monitoringUnknown: s.status == null,
|
||
latency: s.latency ?? null,
|
||
sessions: 0,
|
||
}
|
||
}
|
||
|
||
const ipsecInventorySchema = z.array(z.object({
|
||
id: z.number().int().positive(), name: z.string(), host: z.string(), enabled: z.boolean(),
|
||
country: z.string().optional().default("UN"),
|
||
type: z.enum(["jump-host", "exit-node", "home-router"]).optional(),
|
||
status: z.enum(["online", "offline", "degraded"]).nullable().optional(),
|
||
latency: z.number().nullable().optional(), asn: z.string().optional(),
|
||
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length)
|
||
|
||
function IpsecPage({ selectedServerId, setSelectedServerId, workspaceTab, setWorkspaceTab }: { selectedServerId: string; setSelectedServerId: (id: string) => void; workspaceTab: IpsecWorkspaceTab; setWorkspaceTab: (tab: IpsecWorkspaceTab) => void }) {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const isLive = mode === "live"
|
||
|
||
const [summaries, setSummaries] = useState<IpsecServerSummaryDto[]>([])
|
||
const [clients, setClients] = useState<IpsecClientDto[]>([])
|
||
const [liveServers, setLiveServers] = useState<InventoryServer[]>([])
|
||
const [failures, setFailures] = useState<IpsecFailure[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [dataError, setDataError] = useState<string | null>(null)
|
||
const [inventoryError, setInventoryError] = useState<string | null>(null)
|
||
const [hasLoaded, setHasLoaded] = useState(false)
|
||
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
|
||
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
|
||
const [writeReceipt, setWriteReceipt] = useState<{ serverId: string; at: string } | null>(null)
|
||
const mounted = useRef(true)
|
||
const operationLock = useRef(false)
|
||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||
const readEpoch = useRef(0)
|
||
useEffect(() => () => { readEpoch.current += 1 }, [])
|
||
const [busy, setBusy] = useState(false)
|
||
const [certBusy, setCertBusy] = useState(false)
|
||
const [certError, setCertError] = useState<string | null>(null)
|
||
const certEpoch = useRef(0)
|
||
const certPending = useRef(false)
|
||
useEffect(() => () => { certEpoch.current += 1 }, [])
|
||
|
||
const [search, setSearch] = useState("")
|
||
const [initOpen, setInitOpen] = useState(false)
|
||
const [initTargetId, setInitTargetId] = useState<string | undefined>()
|
||
const [userOpen, setUserOpen] = useState(false)
|
||
const [editing, setEditing] = useState<IpsecClientDto | null>(null)
|
||
const [certOpen, setCertOpen] = useState(false)
|
||
const [certBundle, setCertBundle] = useState<IpsecCertBundle | null>(null)
|
||
const [certClient, setCertClient] = useState<IpsecClientDto | null>(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<PendingDelete | null>(null)
|
||
const [removeClientCertificate, setRemoveClientCertificate] = useState(true)
|
||
const deleteLock = useRef(false)
|
||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||
const [forceAllowed, setForceAllowed] = useState(false)
|
||
const [historyOpen, setHistoryOpen] = useState(false)
|
||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||
const [historyLoading, setHistoryLoading] = useState(false)
|
||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||
const historyEpoch = useRef(0)
|
||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||
|
||
const loadLive = useCallback(async (afterOperation = false) => {
|
||
if (!isLive || !mounted.current || (operationLock.current && !afterOperation)) return
|
||
const epoch = ++readEpoch.current
|
||
setLoading(true)
|
||
const [ipsecResult, inventoryResult] = await Promise.allSettled([
|
||
listIpsec(backendUrl), requestJson<unknown>(backendUrl, "/api/servers"),
|
||
])
|
||
if (epoch !== readEpoch.current) return
|
||
if (inventoryResult.status === "fulfilled") {
|
||
const parsed = ipsecInventorySchema.safeParse(inventoryResult.value)
|
||
if (parsed.success) {
|
||
setLiveServers(parsed.data.filter(s => s.enabled).map(mapBackendServer))
|
||
setInventoryError(null)
|
||
} else setInventoryError("Некорректный список устройств")
|
||
} else setInventoryError(inventoryResult.reason instanceof Error ? inventoryResult.reason.message : "Не удалось загрузить устройства")
|
||
if (ipsecResult.status === "fulfilled") {
|
||
const ipsec = ipsecResult.value
|
||
const failedIds = new Set((ipsec.failures ?? []).map(f => f.serverId))
|
||
setSummaries(previous => [...ipsec.servers, ...previous.filter(s => failedIds.has(s.serverId))])
|
||
setClients(previous => [...ipsec.clients, ...previous.filter(c => failedIds.has(c.serverId))])
|
||
setFailures(ipsec.failures ?? [])
|
||
const at = new Date().toISOString()
|
||
setResponseReceipt(at)
|
||
const successfulIds = new Set([...ipsec.servers.map(s => s.serverId), ...ipsec.clients.map(c => c.serverId)])
|
||
setReadReceipt(previous => ({ ...previous, ...Object.fromEntries([...successfulIds].map(id => [id, at])) }))
|
||
setHasLoaded(true)
|
||
setDataError(null)
|
||
} else {
|
||
setDataError(ipsecResult.reason instanceof Error ? ipsecResult.reason.message : "Ошибка загрузки IPsec")
|
||
}
|
||
setLoading(false)
|
||
}, [isLive, backendUrl])
|
||
|
||
useEffect(() => {
|
||
if (isLive) 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
|
||
const missingSelectedServer = selectedServerId !== ALL_SERVERS_ID && hasLoaded && !displayServers.some(s => s.id === selectedServerId)
|
||
|
||
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
|
||
|
||
useEffect(() => () => { historyEpoch.current += 1 }, [historyServerId, backendUrl, isLive])
|
||
|
||
const loadRevisions = useCallback(async () => {
|
||
if (!isLive || !historyServerId || !mounted.current) return
|
||
const epoch = ++historyEpoch.current
|
||
setHistoryLoading(true)
|
||
setHistoryError(null)
|
||
try {
|
||
const res = await requestJson<unknown>(
|
||
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 = async (id: string) => {
|
||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение конфигурации")
|
||
if (!isLive || !historyServerId) return
|
||
beginOperation(historyServerId)
|
||
setHistoryRestoring(true)
|
||
try {
|
||
const res = await restoreIpsecRevision(backendUrl, id, historyServerId)
|
||
if (!mounted.current) return
|
||
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
|
||
confirmOperation(historyServerId)
|
||
toast.success("Версия применена на роутер")
|
||
await loadLive(true)
|
||
await loadRevisions()
|
||
} catch (err) {
|
||
throw err
|
||
} finally {
|
||
endOperation()
|
||
if (mounted.current) setHistoryRestoring(false)
|
||
}
|
||
}
|
||
|
||
const failedServerIds = new Set(failures.map(f => f.serverId))
|
||
const scopeUnknown = missingSelectedServer || isLive && (!hasLoaded || !!dataError || (effectiveServerId === ALL_SERVERS_ID ? failures.length > 0 : failedServerIds.has(effectiveServerId)))
|
||
|
||
const canManage = (serverId: string) => isLive && hasLoaded && !dataError && !inventoryError &&
|
||
!loading && !busy && !historyRestoring && !failedServerIds.has(serverId) && liveServers.some(s => s.id === serverId)
|
||
const assertDeviceReady = (serverId: string) => {
|
||
if (operationLock.current || !canManage(serverId)) throw new Error("Сначала подтвердите актуальное состояние устройства: повторите опрос IPsec")
|
||
}
|
||
const confirmOperation = (serverId: string) => setWriteReceipt({ serverId, at: new Date().toISOString() })
|
||
const formatReceipt = (at: string) => new Date(at).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
||
const scopeReceipt = effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]
|
||
const scopeWriteReceipt = writeReceipt && (effectiveServerId === ALL_SERVERS_ID || writeReceipt.serverId === effectiveServerId) ? writeReceipt : null
|
||
const beginOperation = (serverId: string) => {
|
||
assertDeviceReady(serverId)
|
||
operationLock.current = true
|
||
readEpoch.current += 1
|
||
setLoading(false)
|
||
setBusy(true)
|
||
}
|
||
const endOperation = () => {
|
||
operationLock.current = false
|
||
if (mounted.current) setBusy(false)
|
||
}
|
||
const hasReadyDevice = !missingSelectedServer && displayServers.some(s => (effectiveServerId === ALL_SERVERS_ID || s.id === effectiveServerId) && canManage(s.id))
|
||
|
||
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 clientsAmbiguous = scopedClients.filter(c => c.sessionAmbiguous).length
|
||
const caOk = scopedSummaries.some((s) => s.caCert)
|
||
const compactServer = effectiveServerId !== ALL_SERVERS_ID
|
||
const sheetServerId = compactServer ? effectiveServerId : undefined
|
||
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<ServerTileItem[]>(() => {
|
||
const counts = new Map<string, number>()
|
||
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: isLive && (s as InventoryServer).monitoringUnknown ? undefined : s.status,
|
||
type: s.type,
|
||
count: counts.get(s.id) ?? 0,
|
||
meta: isLive && (s as InventoryServer).monitoringUnknown ? `${counts.get(s.id) ?? 0} · Статус неизвестен` : undefined,
|
||
enabled: s.enabled,
|
||
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||
}))
|
||
}, [displayServers, displayClients, isLive])
|
||
|
||
const handleInit = async (form: IpsecInitFormState) => {
|
||
beginOperation(form.serverId)
|
||
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,
|
||
})
|
||
if (!mounted.current) return
|
||
confirmOperation(form.serverId)
|
||
toast.success("IKEv2-сервер инициализирован")
|
||
setInitOpen(false)
|
||
await loadLive(true)
|
||
} finally {
|
||
endOperation()
|
||
}
|
||
}
|
||
|
||
const handleCreateUser = async (form: IpsecUserFormState) => {
|
||
beginOperation(form.serverId)
|
||
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,
|
||
})
|
||
if (!mounted.current) return
|
||
confirmOperation(form.serverId)
|
||
toast.success(`Клиент «${form.name.trim()}» создан`)
|
||
setUserOpen(false)
|
||
setEditing(null)
|
||
await loadLive(true)
|
||
if (!mounted.current) return
|
||
if (res.bundle) {
|
||
certEpoch.current += 1
|
||
certPending.current = false
|
||
setCertBusy(false)
|
||
setCertError(null)
|
||
setCertBundle(res.bundle)
|
||
setCertClient(res.client ?? null)
|
||
setCertByName(null)
|
||
setCertOpen(true)
|
||
}
|
||
} finally {
|
||
endOperation()
|
||
}
|
||
}
|
||
|
||
const handleEditUser = async (form: IpsecUserFormState) => {
|
||
if (!editing) return
|
||
beginOperation(editing.serverId)
|
||
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,
|
||
})
|
||
if (!mounted.current) return
|
||
confirmOperation(editing.serverId)
|
||
toast.success("Клиент обновлён")
|
||
setUserOpen(false)
|
||
setEditing(null)
|
||
await loadLive(true)
|
||
} finally {
|
||
endOperation()
|
||
}
|
||
}
|
||
|
||
const loadCert = async (serverId: string, request: () => Promise<IpsecCertBundle>) => {
|
||
if (certPending.current) return
|
||
certPending.current = true
|
||
const epoch = ++certEpoch.current
|
||
setCertBusy(true)
|
||
setCertError(null)
|
||
try {
|
||
assertDeviceReady(serverId)
|
||
const bundle = await request()
|
||
if (epoch === certEpoch.current) setCertBundle(bundle)
|
||
} catch (error) {
|
||
if (epoch === certEpoch.current) setCertError(error instanceof Error ? error.message : "Ошибка экспорта сертификата")
|
||
} finally {
|
||
if (epoch === certEpoch.current) { certPending.current = false; setCertBusy(false) }
|
||
}
|
||
}
|
||
|
||
const openCert = async (client: IpsecClientDto) => {
|
||
const byName = client.kind === "cert" && Boolean(client.certName)
|
||
certEpoch.current += 1
|
||
certPending.current = false
|
||
setCertClient(byName ? null : client)
|
||
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
|
||
setCertBundle(null)
|
||
setCertOpen(true)
|
||
const passphrase = crypto.randomUUID()
|
||
await loadCert(client.serverId, () => byName
|
||
? exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
|
||
: exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase))
|
||
}
|
||
|
||
const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => {
|
||
certEpoch.current += 1
|
||
certPending.current = false
|
||
setCertByName({ serverId, name: cert.name })
|
||
setCertClient(null)
|
||
setCertBundle(null)
|
||
setCertOpen(true)
|
||
await loadCert(serverId, () => exportIpsecCertByName(backendUrl, serverId, cert.name, crypto.randomUUID()))
|
||
}
|
||
|
||
const reexportCert = async (passphrase: string) => {
|
||
if (!certClient && !certByName) return
|
||
await loadCert(certByName?.serverId ?? certClient!.serverId, () => certByName
|
||
? exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
|
||
: exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase))
|
||
}
|
||
|
||
const deletingCertificateIdentity = pendingDelete?.kind === "user" && pendingDelete.client.kind === "cert" && pendingDelete.client.rosId.startsWith("identity:")
|
||
const deletingSharedCertificate = deletingCertificateIdentity && pendingDelete?.kind === "user" && Boolean(pendingDelete.client.certName) && displayClients.some(c => c.serverId === pendingDelete.client.serverId && c.certName === pendingDelete.client.certName && c.rosId !== pendingDelete.client.rosId)
|
||
const shouldRemoveClientCertificate = !deletingCertificateIdentity || (removeClientCertificate && !deletingSharedCertificate)
|
||
|
||
const confirmDelete = async (force = false) => {
|
||
if (!pendingDelete || deleteLock.current || (force && !forceAllowed)) return
|
||
const target = pendingDelete
|
||
const serverId = target.kind === "user" ? target.client.serverId : target.kind === "server" ? target.summary.serverId : target.serverId
|
||
if (operationLock.current || !canManage(serverId)) { setDeleteError("Повторите опрос IPsec перед удалением"); return }
|
||
beginOperation(serverId)
|
||
deleteLock.current = true
|
||
setDeleteError(null)
|
||
try {
|
||
if (target.kind === "user") {
|
||
await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId, { removeCertificate: shouldRemoveClientCertificate })
|
||
} else if (target.kind === "server") {
|
||
await deleteIpsecServer(backendUrl, target.summary.serverId)
|
||
} else if (target.kind === "peer") {
|
||
await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force })
|
||
} else {
|
||
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force })
|
||
}
|
||
if (!mounted.current) return
|
||
confirmOperation(serverId)
|
||
toast.success("Удалено")
|
||
setPendingDelete(null)
|
||
setForceAllowed(false)
|
||
await loadLive(true)
|
||
} catch (error) {
|
||
if (!mounted.current) return
|
||
setDeleteError(error instanceof Error ? error.message : "Не удалось удалить")
|
||
setForceAllowed(error instanceof ApiClientError && error.status === 409 && (target.kind === "peer" || target.kind === "cert"))
|
||
} finally {
|
||
deleteLock.current = false
|
||
endOperation()
|
||
}
|
||
}
|
||
|
||
const openEditPeer = (serverId: string, peer: IpsecPeerDto) => {
|
||
setEditingPeer({ serverId, peer })
|
||
setPeerOpen(true)
|
||
}
|
||
|
||
const handleEditPeer = async (form: IpsecPeerFormState) => {
|
||
if (!editingPeer) return
|
||
beginOperation(editingPeer.serverId)
|
||
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,
|
||
profile: form.profile.trim() || undefined,
|
||
disabled: form.disabled,
|
||
})
|
||
if (!mounted.current) return
|
||
confirmOperation(editingPeer.serverId)
|
||
toast.success("Peer обновлён")
|
||
setPeerOpen(false)
|
||
setEditingPeer(null)
|
||
await loadLive(true)
|
||
} finally {
|
||
endOperation()
|
||
}
|
||
}
|
||
|
||
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":
|
||
if (pendingDelete.client.kind === "psk") return "Удалит выбранную PSK identity и неиспользуемый персональный mode-config приложения. Сертификаты останутся на роутере."
|
||
if (deletingCertificateIdentity && !shouldRemoveClientCertificate) return "Удалит выбранную identity и неиспользуемый персональный mode-config приложения. Сертификат останется на роутере; другие identity сохранятся."
|
||
return "Удалит identity, неиспользуемый персональный mode-config приложения и прямо привязанный клиентский сертификат на роутере."
|
||
case "peer":
|
||
return "Удалит peer на роутере. Если на него ссылаются identity — потребуется принудительное удаление."
|
||
case "cert":
|
||
return "Удалит сертификат с роутера. Если он используется peer или identity — потребуется принудительное удаление."
|
||
}
|
||
})()
|
||
|
||
return (
|
||
<>
|
||
<ServerRailLayout
|
||
items={railItems}
|
||
preserveSelection
|
||
selectedId={effectiveServerId}
|
||
onSelect={setSelectedServerId}
|
||
showAll
|
||
allCount={displayClients.length}
|
||
loading={isLive && loading && displayServers.length === 0}
|
||
header={
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "IPsec / IKEv2" }]}
|
||
actions={
|
||
<>
|
||
<ServerRailMobileButton />
|
||
{isLive && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={loading || busy || historyRestoring}
|
||
onClick={() => void loadLive()}
|
||
>
|
||
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||
Обновить
|
||
</Button>
|
||
)}
|
||
{isLive && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={loading || busy || historyRestoring || !historyServerId}
|
||
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат"}
|
||
onClick={() => {
|
||
setHistoryOpen(true)
|
||
void loadRevisions()
|
||
}}
|
||
>
|
||
<HistoryIcon className="size-4" />
|
||
История
|
||
</Button>
|
||
)}
|
||
{isLive ? (
|
||
<Button size="sm" disabled={!hasReadyDevice} onClick={() => setUserOpen(true)}>
|
||
<PlusIcon className="size-4" />
|
||
Новый клиент
|
||
</Button>
|
||
) : null}
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
{missingSelectedServer && <Alert variant="destructive" role="alert"><AlertTitle>Устройство из ссылки не найдено</AlertTitle><AlertDescription>Выберите доступный сервер. Данные других устройств не подставляются в этот срез.</AlertDescription></Alert>}
|
||
<KpiStatGrid
|
||
aria-label="Сводка IPsec"
|
||
items={[
|
||
{
|
||
id: "servers",
|
||
label: "Серверов IKEv2",
|
||
value: scopeUnknown ? "—" : serversWithIpsec,
|
||
icon: <ShieldCheckIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "clients",
|
||
label: "Клиентов",
|
||
value: scopeUnknown ? "—" : scopedClients.length,
|
||
icon: <UsersIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
{
|
||
id: "online",
|
||
label: "Онлайн",
|
||
value: scopeUnknown || clientsAmbiguous > 0 ? "—" : clientsOnline,
|
||
icon: <ActivityIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "ca",
|
||
label: "CA",
|
||
value: scopeUnknown ? "неизвестно" : caOk ? "ок" : "нет",
|
||
icon: <KeyRoundIcon className="size-4" />,
|
||
iconClassName: "text-primary",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{isLive && <p className="text-xs text-muted-foreground">Онлайн означает установленную IKE-сессию. Передачу данных проверяйте Ping.</p>}
|
||
|
||
{isLive && <div role="group" aria-label="Свежесть IPsec" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||
<span data-source-time="read">{effectiveServerId === ALL_SERVERS_ID ? "Последний ответ IPsec получен в приложении: " : "Устройство опрошено, ответ получен в приложении: "}
|
||
{scopeReceipt ? <time dateTime={scopeReceipt} title="Клиентское время получения ответа; не время измерения RouterOS">{formatReceipt(scopeReceipt)}</time> : "неизвестно"}
|
||
</span>
|
||
{scopeWriteReceipt && <span data-source-time="write">Изменение подтверждено API: <time dateTime={scopeWriteReceipt.at} title={`Устройство ${scopeWriteReceipt.serverId}; подтверждение API не заменяет опрос`}>{formatReceipt(scopeWriteReceipt.at)}</time></span>}
|
||
</div>}
|
||
|
||
{clientsAmbiguous > 0 && <Alert>
|
||
<AlertCircleIcon />
|
||
<AlertTitle>Статус части клиентов не определён</AlertTitle>
|
||
<AlertDescription>У {clientsAmbiguous} клиентов совпадает идентификатор сессии. RouterOS не сообщает, какому из них принадлежит подключение. Проверьте Remote ID.</AlertDescription>
|
||
</Alert>}
|
||
|
||
{isLive && (dataError || inventoryError) ? (
|
||
<Alert variant="destructive">
|
||
<AlertCircleIcon />
|
||
<AlertTitle>{dataError ? "Не удалось загрузить IPsec" : "Не удалось загрузить устройства"}</AlertTitle>
|
||
<AlertDescription>
|
||
{dataError || inventoryError}. {hasLoaded ? "Показаны последние полученные данные; их актуальность не подтверждена." : "Данные пока неизвестны."}
|
||
<Button size="sm" variant="outline" disabled={loading || busy || historyRestoring} onClick={() => void loadLive()}>Повторить загрузку IPsec</Button>
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
|
||
{failures.length > 0 ? (
|
||
<Alert variant="destructive">
|
||
<AlertCircleIcon />
|
||
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
|
||
<AlertDescription>
|
||
{failures.map((f) => `${f.serverName ?? f.serverId}: ${f.error}`).join("; ")}. Показаны последние данные недоступных устройств; актуальность не подтверждена.
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
|
||
{!isLive ? (
|
||
<Alert variant="info">
|
||
<InfoIcon />
|
||
<AlertTitle>Mock-режим</AlertTitle>
|
||
<AlertDescription>
|
||
Переключитесь в live в настройках, чтобы управлять IKEv2 на MikroTik.
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
|
||
<Tabs
|
||
value={workspaceTab}
|
||
onValueChange={(v) => setWorkspaceTab(v as IpsecWorkspaceTab)}
|
||
className="gap-3"
|
||
>
|
||
<TabsList variant="line">
|
||
<TabsTrigger value="clients" className="gap-1.5">
|
||
<UsersIcon className="size-3.5" />
|
||
Клиенты
|
||
</TabsTrigger>
|
||
<TabsTrigger value="server" className="gap-1.5">
|
||
<ShieldCheckIcon className="size-3.5" />
|
||
Сервер
|
||
</TabsTrigger>
|
||
<TabsTrigger value="cli" className="gap-1.5">
|
||
<CodeXmlIcon className="size-3.5" />
|
||
CLI
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="clients" className="mt-0 outline-none">
|
||
<DataPageCard>
|
||
<DataPageToolbar
|
||
search={search}
|
||
onSearchChange={setSearch}
|
||
searchPlaceholder="Поиск по имени / CN / IP…"
|
||
actions={
|
||
isLive ? (
|
||
<Button size="sm" disabled={!hasReadyDevice} onClick={() => { setEditing(null); setUserOpen(true) }}>
|
||
<PlusIcon className="size-4" />
|
||
Клиент
|
||
</Button>
|
||
) : undefined
|
||
}
|
||
/>
|
||
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">{loading ? "Загрузка клиентов IPsec…" : "Клиенты IPsec пока неизвестны"}</p> : <IpsecUsersGrid
|
||
clients={filteredClients}
|
||
canManage={canManage}
|
||
emptyTitle={scopeUnknown ? "Клиенты IPsec пока неизвестны" : undefined}
|
||
emptyDescription={scopeUnknown ? "Повторите опрос устройств, чтобы подтвердить состояние" : undefined}
|
||
compactServer={compactServer}
|
||
onDownloadCert={isLive ? (row) => void openCert(row) : undefined}
|
||
onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined}
|
||
onDelete={isLive ? (row) => { setRemoveClientCertificate(true); setPendingDelete({ kind: "user", client: row }) } : undefined}
|
||
/>}
|
||
</DataPageCard>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="server" className="mt-0 outline-none">
|
||
{isLive && !hasLoaded ? <p role="status" className="p-5 text-sm text-muted-foreground">Состояние серверов IPsec пока неизвестно</p> : <IpsecServerGrid
|
||
servers={scopedSummaries}
|
||
canManage={canManage}
|
||
onInit={isLive ? (s) => { setInitTargetId(s.serverId); 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}
|
||
/>}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="cli" className="mt-0 outline-none">
|
||
<OpsPanel title="RouterOS 7 · /ip ipsec — быстрые команды" contentClassName="px-5 py-4">
|
||
<div className="grid grid-cols-1 gap-4 font-mono text-xs sm:grid-cols-3">
|
||
{[
|
||
{
|
||
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 profile=ipsec-vpn",
|
||
"/ip ipsec identity add \\",
|
||
" peer=ipsec-vpn auth-method=digital-signature \\",
|
||
" certificate=ipsec-server \\",
|
||
" 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) => (
|
||
<div key={b.title}>
|
||
<p className="mb-1.5 font-sans text-[11px] font-semibold uppercase tracking-wide text-foreground/80">
|
||
{b.title}
|
||
</p>
|
||
<pre className="overflow-x-auto rounded-md bg-muted p-2.5 text-[11px] leading-relaxed text-muted-foreground">
|
||
{b.lines.join("\n")}
|
||
</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</OpsPanel>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
</ServerRailLayout>
|
||
|
||
<IpsecUserSheet
|
||
open={userOpen}
|
||
onOpenChange={(v) => { setUserOpen(v); if (!v) setEditing(null) }}
|
||
servers={serverOptions.filter(s => canManage(s.id) || busy)}
|
||
peers={peerOptions}
|
||
busy={busy}
|
||
defaultServerId={sheetServerId}
|
||
editing={editing}
|
||
freeIpHint={freeIpHint}
|
||
onSubmit={editing ? handleEditUser : handleCreateUser}
|
||
/>
|
||
|
||
<IpsecPeerSheet
|
||
open={peerOpen}
|
||
onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }}
|
||
busy={busy}
|
||
editing={editingPeer?.peer ?? null}
|
||
onSubmit={handleEditPeer}
|
||
/>
|
||
|
||
<IpsecInitSheet
|
||
open={initOpen}
|
||
onOpenChange={setInitOpen}
|
||
servers={serverOptions.filter(s => canManage(s.id) || busy)}
|
||
busy={busy}
|
||
defaultServerId={initTargetId ?? sheetServerId}
|
||
onSubmit={handleInit}
|
||
/>
|
||
|
||
<IpsecCertSheet
|
||
open={certOpen}
|
||
onOpenChange={(v) => {
|
||
if (!v) { certEpoch.current += 1; certPending.current = false; setCertBusy(false); setCertBundle(null); setCertClient(null); setCertByName(null) }
|
||
setCertOpen(v)
|
||
}}
|
||
error={certError}
|
||
bundle={certBundle}
|
||
busy={certBusy}
|
||
onReexport={reexportCert}
|
||
/>
|
||
|
||
<AlertDialog open={pendingDelete != null} onOpenChange={(v) => { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null); setForceAllowed(false) } }}>
|
||
<AlertDialogContent size="default">
|
||
<AlertDialogHeader>
|
||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||
<Trash2Icon />
|
||
</AlertDialogMedia>
|
||
<AlertDialogTitle>{deleteDialogTitle}</AlertDialogTitle>
|
||
<AlertDialogDescription>{deleteDialogDescription}</AlertDialogDescription>
|
||
{deletingCertificateIdentity && (
|
||
<div className="space-y-2 text-left text-sm">
|
||
<label htmlFor="ipsec-delete-certificate" className="flex items-center gap-2">
|
||
<Checkbox id="ipsec-delete-certificate" checked={shouldRemoveClientCertificate} disabled={busy || deletingSharedCertificate} onCheckedChange={setRemoveClientCertificate} />
|
||
Удалить также клиентский сертификат
|
||
</label>
|
||
{deletingSharedCertificate && <p className="text-muted-foreground">Сертификат используется другими identity и будет сохранён.</p>}
|
||
</div>
|
||
)}
|
||
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание результата удаления…</p>}
|
||
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>
|
||
{forceAllowed && <Button variant="destructive" disabled={busy} onClick={() => void confirmDelete(true)}>Удалить принудительно</Button>}
|
||
<AlertDialogAction
|
||
variant="destructive"
|
||
disabled={busy}
|
||
onClick={(e) => { e.preventDefault(); void confirmDelete() }}
|
||
>
|
||
<Trash2Icon className="size-4" />
|
||
Удалить
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
<ConfigHistorySheet
|
||
open={historyOpen}
|
||
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
||
loadError={historyError}
|
||
onRetry={() => void loadRevisions()}
|
||
revisions={revisions}
|
||
loading={historyLoading}
|
||
restoring={historyRestoring}
|
||
restoreDisabled={!can("mm:network:write") || !historyServerId || !canManage(historyServerId)}
|
||
onRestore={restoreRevision}
|
||
title="История конфигураций IPsec"
|
||
itemLabel="конфигурация"
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function IpsecRoute() {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const params = useSearchParams()
|
||
const selectedServerId = params.get("server") ?? ALL_SERVERS_ID
|
||
const rawTab = params.get("tab")
|
||
const workspaceTab: IpsecWorkspaceTab = rawTab === "server" || rawTab === "cli" ? rawTab : "clients"
|
||
const navigate = (key: string, value: string, defaultValue: string) => {
|
||
const next = new URLSearchParams(params.toString())
|
||
if (value === defaultValue) next.delete(key)
|
||
else next.set(key, value)
|
||
window.history.pushState(null, "", "/ipsec" + (next.size ? "?" + next.toString() : ""))
|
||
}
|
||
return <IpsecPage key={JSON.stringify([mode, backendUrl])} selectedServerId={selectedServerId} setSelectedServerId={id => navigate("server", id, ALL_SERVERS_ID)} workspaceTab={workspaceTab} setWorkspaceTab={tab => navigate("tab", tab, "clients")} />
|
||
}
|
||
|
||
export default function IpsecSource() {
|
||
return <Suspense fallback={<div role="status">Загрузка IPsec…</div>}><IpsecRoute /></Suspense>
|
||
}
|