1064 lines
45 KiB
TypeScript
1064 lines
45 KiB
TypeScript
"use client"
|
||
|
||
import { can } from "@/lib/auth"
|
||
|
||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||
import Link from "next/link"
|
||
import { useRouter, useSearchParams } from "next/navigation"
|
||
import { wireGuardServersSchema } from "@/lib/wireguard-response"
|
||
import { useWireGuardUrlState } from "@/hooks/use-wireguard-url-state"
|
||
import type { WireGuardTab as WgWorkspaceTab } from "@/lib/wireguard-url-state"
|
||
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 {
|
||
WireguardDataGrid,
|
||
type WgIfaceWithServer,
|
||
} from "@/components/data-grids/wireguard-data-grid"
|
||
import {
|
||
WireguardPeersGrid,
|
||
type WgPeerRow,
|
||
} from "@/components/data-grids/wireguard-peers-grid"
|
||
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 { requestJson } from "@/shared/api/http-client"
|
||
import {
|
||
createWireGuardInterface,
|
||
createWireGuardPeer,
|
||
deleteWireGuardInterface,
|
||
deleteWireGuardPeer,
|
||
exportWireGuard,
|
||
importWireGuard,
|
||
listWireGuard,
|
||
listWireGuardRevisions,
|
||
patchWireGuardInterface,
|
||
} from "@/shared/api/wireguard"
|
||
import type { WgIfaceDto } from "@mmapp/contracts/wireguard"
|
||
import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg-create-sheet"
|
||
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
|
||
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
|
||
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
|
||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||
import {
|
||
ALL_SERVERS_ID,
|
||
type ServerTileItem,
|
||
} from "@/components/server-tile-rail"
|
||
import { toast } from "sonner"
|
||
import { selectWgExportPeer } from "@/lib/wg-export-peer"
|
||
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||
import {
|
||
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
||
Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
|
||
} from "lucide-react"
|
||
|
||
type WgFailure = { serverId: string; serverName?: string; error: string }
|
||
type PendingDelete =
|
||
| { kind: "iface"; iface: WgIfaceWithServer }
|
||
| { kind: "peer"; iface: WgIfaceWithServer; peerId: string }
|
||
|
||
function collectMockInterfaces(): WgIfaceWithServer[] {
|
||
const result: WgIfaceWithServer[] = []
|
||
for (const srv of mockServers) {
|
||
for (const wg of srv.wireGuardIfaces ?? []) {
|
||
result.push({
|
||
...wg,
|
||
serverId: srv.id,
|
||
serverName: srv.name,
|
||
serverCountry: srv.country,
|
||
})
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
function dtoToRow(d: WgIfaceDto): WgIfaceWithServer {
|
||
return {
|
||
id: d.id,
|
||
rosId: d.rosId,
|
||
name: d.name,
|
||
listenPort: d.listenPort,
|
||
mtu: d.mtu,
|
||
publicKey: d.publicKey,
|
||
privateKey: d.privateKey,
|
||
address: d.address,
|
||
peers: d.peers.map((p) => ({
|
||
id: p.id,
|
||
rosId: p.rosId,
|
||
publicKey: p.publicKey,
|
||
allowedIps: p.allowedIps,
|
||
endpoint: p.endpoint,
|
||
latestHandshake: p.latestHandshake,
|
||
transferRx: p.transferRx,
|
||
transferTx: p.transferTx,
|
||
persistentKeepalive: p.persistentKeepalive,
|
||
persistent: p.persistent,
|
||
comment: p.comment,
|
||
disabled: p.disabled,
|
||
name: p.name,
|
||
clientAddress: p.clientAddress,
|
||
clientDns: p.clientDns,
|
||
clientEndpoint: p.clientEndpoint,
|
||
})),
|
||
comment: d.comment,
|
||
enabled: d.enabled,
|
||
status: d.status,
|
||
serverId: d.serverId,
|
||
serverName: d.serverName,
|
||
serverCountry: d.serverCountry ?? "UN",
|
||
}
|
||
}
|
||
|
||
interface BackendServer {
|
||
id: number
|
||
name: string
|
||
host: string
|
||
country: string
|
||
type?: Server["type"]
|
||
enabled: boolean
|
||
status?: Server["status"] | null
|
||
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 ?? "unknown",
|
||
latency: s.latency ?? null,
|
||
sessions: 0,
|
||
}
|
||
}
|
||
|
||
function parseEndpoint(endpoint: string): { address?: string; port?: number } {
|
||
const t = endpoint.trim()
|
||
if (!t) return {}
|
||
const idx = t.lastIndexOf(":")
|
||
if (idx <= 0) return { address: t }
|
||
return {
|
||
address: t.slice(0, idx),
|
||
port: Number.parseInt(t.slice(idx + 1), 10) || undefined,
|
||
}
|
||
}
|
||
|
||
function peerRowId(iface: WgIfaceWithServer, peer: WgIfaceWithServer["peers"][number], index: number): string {
|
||
return peer.id ?? peer.rosId ?? `${iface.id}-${peer.publicKey}-${index}`
|
||
}
|
||
|
||
function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterfacesHref, onSelectServer }: {
|
||
selectedServerId: string
|
||
selectedInterfaceId: string | null
|
||
allInterfacesHref: string
|
||
onSelectServer: (id: string) => void
|
||
}) {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const isLive = mode === "live"
|
||
const canWrite = !isLive || can("mm:network:write")
|
||
|
||
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
|
||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||
const [failures, setFailures] = useState<WgFailure[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [loadError, setLoadError] = useState(false)
|
||
const [readError, setReadError] = useState<string | null>(null)
|
||
const [receivedAt, setReceivedAt] = useState<number | null>(null)
|
||
const catalogKnown = !isLive || receivedAt !== null
|
||
const active = useRef(true)
|
||
const readRevision = useRef(0)
|
||
useEffect(() => { active.current = true; return () => { active.current = false } }, [])
|
||
const [busy, setBusy] = useState(false)
|
||
const toggleLock = useRef(false)
|
||
const [togglePending, setTogglePending] = useState<string | null>(null)
|
||
const [toggleError, setToggleError] = useState<string | null>(null)
|
||
|
||
const { tab: workspaceTab, setTab: setWorkspaceTab, status: statusFilter, setStatus: setStatusFilter, search, setSearch, error: urlError, resetFilters } = useWireGuardUrlState()
|
||
const [createOpen, setCreateOpen] = useState(false)
|
||
const [importOpen, setImportOpen] = useState(false)
|
||
const formLock = useRef(false)
|
||
const [createError, setCreateError] = useState<string | null>(null)
|
||
const [peerError, setPeerError] = useState<string | null>(null)
|
||
const [importError, setImportError] = useState<string | null>(null)
|
||
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
|
||
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
|
||
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
|
||
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
|
||
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
||
const pendingPeer = pendingDelete?.kind === "peer" ? pendingDelete.iface.peers.find((peer, index) => peerRowId(pendingDelete.iface, peer, index) === pendingDelete.peerId) : undefined
|
||
const deleteLock = useRef(false)
|
||
const [deleteBusy, setDeleteBusy] = useState(false)
|
||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||
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)
|
||
useEffect(() => () => { historyEpoch.current += 1 }, [])
|
||
const restoreLock = useRef(false)
|
||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||
const [liveExport, setLiveExport] = useState<{
|
||
rsc?: string
|
||
conf?: string
|
||
peerConf?: string
|
||
} | null>(null)
|
||
const [exportBusy, setExportBusy] = useState(false)
|
||
const exportEpoch = useRef(0)
|
||
const exportPending = useRef<number | null>(null)
|
||
useEffect(() => () => { exportEpoch.current += 1 }, [])
|
||
|
||
const loadLive = useCallback(async () => {
|
||
if (!isLive || !active.current) return
|
||
const revision = ++readRevision.current
|
||
setLoading(true)
|
||
try {
|
||
const [wg, servers] = await Promise.all([
|
||
listWireGuard(backendUrl),
|
||
requestJson<unknown>(backendUrl, "/api/servers"),
|
||
])
|
||
if (!active.current || revision !== readRevision.current) return
|
||
const parsedServers = wireGuardServersSchema.safeParse(servers)
|
||
if (!parsedServers.success) throw new Error("Ответ сервера содержит некорректный каталог устройств WireGuard")
|
||
const nextIfaces = wg.interfaces.map(dtoToRow), nextServers = parsedServers.data.filter(s => s.enabled).map(mapBackendServer)
|
||
setLiveIfaces(nextIfaces)
|
||
setLiveServers(nextServers)
|
||
setReceivedAt(Date.now())
|
||
setReadError(null)
|
||
setFailures(wg.failures ?? [])
|
||
setLoadError(false)
|
||
if (wg.failures?.length) {
|
||
if (active.current) toast.warning(
|
||
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
|
||
)
|
||
}
|
||
} catch (e) {
|
||
if (!active.current || revision !== readRevision.current) return
|
||
setReadError(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
|
||
setLoadError(true)
|
||
} finally {
|
||
if (active.current && revision === readRevision.current) setLoading(false)
|
||
}
|
||
}, [isLive, backendUrl])
|
||
|
||
useEffect(() => {
|
||
if (!isLive) {
|
||
queueMicrotask(() => {
|
||
setLiveIfaces([])
|
||
setLiveServers([])
|
||
setFailures([])
|
||
})
|
||
return
|
||
}
|
||
queueMicrotask(() => {
|
||
void loadLive()
|
||
})
|
||
}, [isLive, loadLive])
|
||
|
||
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
|
||
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||
|
||
const effectiveServerId = selectedServerId
|
||
const selectedSourceError = !!urlError || isLive && (!catalogKnown || loadError || failures.some(f => f.serverId === selectedServerId))
|
||
|
||
const completeScope = catalogKnown && !urlError && (!isLive || !failures.some(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId))
|
||
|
||
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
|
||
|
||
const loadRevisions = useCallback(async () => {
|
||
if (!isLive || !historyServerId || !active.current) return
|
||
const epoch = ++historyEpoch.current
|
||
setHistoryLoading(true)
|
||
setHistoryError(null)
|
||
try {
|
||
const revisions = await listWireGuardRevisions(backendUrl, historyServerId)
|
||
if (epoch !== historyEpoch.current) return
|
||
setRevisions(revisions)
|
||
} catch (error) {
|
||
if (epoch !== historyEpoch.current) return
|
||
setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю")
|
||
} finally {
|
||
if (epoch === historyEpoch.current) setHistoryLoading(false)
|
||
}
|
||
}, [isLive, historyServerId, backendUrl])
|
||
|
||
const restoreRevision = useCallback(async (id: string) => {
|
||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение конфигурации")
|
||
if (!isLive || !historyServerId || restoreLock.current) return
|
||
restoreLock.current = true
|
||
setHistoryRestoring(true)
|
||
try {
|
||
const result = await requestJson<{ ok: boolean }>(
|
||
backendUrl,
|
||
`/api/wireguard/revisions/${encodeURIComponent(id)}/restore`,
|
||
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
|
||
)
|
||
if (result?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
|
||
if (active.current) toast.success("Версия применена на роутер")
|
||
await loadLive()
|
||
await loadRevisions()
|
||
} catch (err) {
|
||
throw err
|
||
} finally {
|
||
restoreLock.current = false
|
||
setHistoryRestoring(false)
|
||
}
|
||
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
|
||
|
||
const scopedIfaces = useMemo(() => {
|
||
if (urlError) return []
|
||
if (selectedInterfaceId !== null) return displayIfaces.filter(i => i.serverId === effectiveServerId && i.id === selectedInterfaceId)
|
||
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
|
||
return displayIfaces.filter((i) => i.serverId === effectiveServerId)
|
||
}, [displayIfaces, effectiveServerId, selectedInterfaceId, urlError])
|
||
|
||
const selectedInterface = selectedInterfaceId === null ? undefined : scopedIfaces[0]
|
||
|
||
const statusCounts = useMemo(() => {
|
||
let up = 0
|
||
let down = 0
|
||
for (const iface of scopedIfaces) {
|
||
if (iface.status === "up") up += 1
|
||
else down += 1
|
||
}
|
||
return { all: scopedIfaces.length, up, down }
|
||
}, [scopedIfaces])
|
||
|
||
const statusFiltered = useMemo(() => {
|
||
if (statusFilter === "all") return scopedIfaces
|
||
return scopedIfaces.filter((i) => i.status === statusFilter)
|
||
}, [scopedIfaces, statusFilter])
|
||
|
||
const filteredIfaces = useMemo(() => {
|
||
if (!search) return statusFiltered
|
||
const q = search.toLowerCase()
|
||
return statusFiltered.filter(
|
||
(i) =>
|
||
i.name.toLowerCase().includes(q) ||
|
||
i.serverName.toLowerCase().includes(q) ||
|
||
i.peers.some(
|
||
(p) =>
|
||
p.allowedIps.some((a) => a.includes(q)) ||
|
||
(p.endpoint ?? "").includes(q),
|
||
),
|
||
)
|
||
}, [statusFiltered, search])
|
||
|
||
const peerRows = useMemo<WgPeerRow[]>(() => {
|
||
const q = search.toLowerCase()
|
||
const rows: WgPeerRow[] = []
|
||
for (const iface of scopedIfaces) {
|
||
iface.peers.forEach((peer, index) => {
|
||
const id = peerRowId(iface, peer, index)
|
||
if (q) {
|
||
const hay = [
|
||
peer.publicKey,
|
||
peer.name ?? "",
|
||
peer.endpoint ?? "",
|
||
peer.allowedIps.join(" "),
|
||
iface.name,
|
||
iface.serverName,
|
||
].join(" ").toLowerCase()
|
||
if (!hay.includes(q)) return
|
||
}
|
||
rows.push({
|
||
...peer,
|
||
id,
|
||
ifaceId: iface.id,
|
||
ifaceName: iface.name,
|
||
serverId: iface.serverId,
|
||
serverName: iface.serverName,
|
||
serverCountry: iface.serverCountry,
|
||
})
|
||
})
|
||
}
|
||
return rows
|
||
}, [scopedIfaces, search])
|
||
|
||
const totalPeers = scopedIfaces.reduce((s, i) => s + i.peers.length, 0)
|
||
const peersWithHandshakeData = scopedIfaces.reduce(
|
||
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake?.trim()).length,
|
||
0,
|
||
)
|
||
const upIfaces = scopedIfaces.filter((i) => i.status === "up").length
|
||
const compactServer = effectiveServerId !== ALL_SERVERS_ID
|
||
const sheetServerId = compactServer ? effectiveServerId : undefined
|
||
|
||
const serverOptions = displayServers.map((s) => ({
|
||
id: s.id,
|
||
name: s.name,
|
||
host: s.host,
|
||
}))
|
||
|
||
const railItems = useMemo<ServerTileItem[]>(() => {
|
||
const counts = new Map<string, number>()
|
||
for (const iface of displayIfaces) {
|
||
counts.set(iface.serverId, (counts.get(iface.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: !catalogKnown || failures.some(f => f.serverId === s.id) ? undefined : counts.get(s.id) ?? 0,
|
||
enabled: s.enabled,
|
||
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||
}))
|
||
}, [displayServers, displayIfaces, catalogKnown, failures])
|
||
|
||
async function handleCreate(form: WgCreateFormState) {
|
||
if (urlError || !catalogKnown || loadError || formLock.current) return
|
||
if (!isLive) {
|
||
if (active.current) toast.info("Создание на роутер доступно только в live-режиме")
|
||
return
|
||
}
|
||
formLock.current = true
|
||
setCreateError(null)
|
||
setBusy(true)
|
||
try {
|
||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||
const ep = parseEndpoint(form.peerEndpoint)
|
||
await createWireGuardInterface(backendUrl, {
|
||
serverId: form.serverId,
|
||
name: form.name.trim(),
|
||
listenPort: Number.parseInt(form.listenPort, 10) || 13231,
|
||
mtu: Number.parseInt(form.mtu, 10) || 1420,
|
||
comment: form.comment || undefined,
|
||
address: form.address.trim() || undefined,
|
||
disabled: !form.enabled,
|
||
peer: form.peerEnabled && form.peerPublicKey.trim()
|
||
? {
|
||
publicKey: form.peerPublicKey.trim(),
|
||
allowedAddresses: form.peerAllowedIps
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean),
|
||
endpointAddress: ep.address,
|
||
endpointPort: ep.port,
|
||
persistentKeepalive: Number.parseInt(form.peerKeepalive, 10) || undefined,
|
||
comment: form.peerComment || undefined,
|
||
}
|
||
: undefined,
|
||
})
|
||
if (active.current) toast.success(`Интерфейс ${form.name} создан`)
|
||
setCreateOpen(false)
|
||
await loadLive()
|
||
} catch (e) {
|
||
setCreateError(e instanceof Error ? e.message : "Ошибка создания")
|
||
} finally {
|
||
formLock.current = false
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
async function handleImport(args: {
|
||
serverId: string
|
||
content: string
|
||
format: "auto" | "rsc" | "conf"
|
||
dryRun: boolean
|
||
}) {
|
||
if (formLock.current) return
|
||
if (!isLive) {
|
||
if (active.current) toast.info("Импорт на роутер доступен только в live-режиме")
|
||
return
|
||
}
|
||
formLock.current = true
|
||
setImportError(null)
|
||
setBusy(true)
|
||
try {
|
||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||
const res = await importWireGuard(backendUrl, {
|
||
serverId: args.serverId,
|
||
content: args.content,
|
||
format: args.format,
|
||
dryRun: args.dryRun,
|
||
})
|
||
if (res.dryRun) {
|
||
if (active.current) toast.info("Конфигурация проверена без применения на роутер")
|
||
return
|
||
}
|
||
if (active.current) toast.success(
|
||
res.applied
|
||
? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)`
|
||
: "Импорт выполнен",
|
||
)
|
||
setImportOpen(false)
|
||
await loadLive()
|
||
} catch (e) {
|
||
setImportError(e instanceof Error ? e.message : "Ошибка импорта")
|
||
} finally {
|
||
formLock.current = false
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
function sourceUnavailable(serverId: string | undefined) {
|
||
const unavailable = !!urlError || isLive && (loadError || failures.some(f => f.serverId === serverId))
|
||
if (unavailable && active.current) toast.error("Обновите данные сервера перед изменением объекта")
|
||
return unavailable
|
||
}
|
||
|
||
async function handleToggle(iface: WgIfaceWithServer) {
|
||
if (toggleLock.current) return
|
||
if (sourceUnavailable(iface.serverId)) return
|
||
if (!isLive || !iface.rosId) {
|
||
if (active.current) toast.info("Доступно только в live-режиме")
|
||
return
|
||
}
|
||
toggleLock.current = true
|
||
const context = `${iface.name} · ${iface.serverName}`
|
||
setTogglePending(context)
|
||
setToggleError(null)
|
||
try {
|
||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||
const result = await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, {
|
||
disabled: iface.enabled,
|
||
})
|
||
if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение интерфейса")
|
||
if (active.current) toast.success(iface.enabled ? "Отключено" : "Включено")
|
||
await loadLive()
|
||
} catch (e) {
|
||
setToggleError(`${context}: ${e instanceof Error ? e.message : "Ошибка переключения"}`)
|
||
} finally {
|
||
toggleLock.current = false
|
||
setTogglePending(null)
|
||
}
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
if (!pendingDelete || deleteLock.current) return
|
||
if (sourceUnavailable(pendingDelete.iface.serverId)) return
|
||
if (!isLive) {
|
||
if (active.current) toast.info("Доступно только в live-режиме")
|
||
setPendingDelete(null)
|
||
return
|
||
}
|
||
deleteLock.current = true
|
||
setDeleteBusy(true)
|
||
setDeleteError(null)
|
||
try {
|
||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||
if (pendingDelete.kind === "iface") {
|
||
if (!pendingDelete.iface.rosId) {
|
||
if (active.current) toast.info("Доступно только в live-режиме")
|
||
return
|
||
}
|
||
const result = await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId)
|
||
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
|
||
if (active.current) toast.success("Удалено")
|
||
} else {
|
||
const peer = pendingDelete.iface.peers.find(p => p.id === pendingDelete.peerId || p.rosId === pendingDelete.peerId)
|
||
const result = await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, peer?.rosId ?? pendingDelete.peerId)
|
||
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
|
||
if (active.current) toast.success("Пир удалён")
|
||
}
|
||
setPendingDelete(null)
|
||
await loadLive()
|
||
} catch (e) {
|
||
setDeleteError(e instanceof Error ? e.message : "Ошибка удаления")
|
||
} finally {
|
||
deleteLock.current = false
|
||
setDeleteBusy(false)
|
||
}
|
||
}
|
||
|
||
async function handleAddPeer(form: WgPeerFormState) {
|
||
if (formLock.current) return
|
||
if (sourceUnavailable(peerIface?.serverId)) return
|
||
if (!isLive || !peerIface) {
|
||
if (active.current) toast.info("Доступно только в live-режиме")
|
||
return
|
||
}
|
||
formLock.current = true
|
||
setPeerError(null)
|
||
setBusy(true)
|
||
try {
|
||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||
const ep = parseEndpoint(form.endpoint)
|
||
await createWireGuardPeer(backendUrl, {
|
||
serverId: peerIface.serverId,
|
||
interfaceName: peerIface.name,
|
||
publicKey: form.publicKey.trim(),
|
||
allowedAddresses: form.allowedIps
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean),
|
||
endpointAddress: ep.address,
|
||
endpointPort: ep.port,
|
||
persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined,
|
||
comment: form.comment || undefined,
|
||
})
|
||
if (active.current) toast.success("Пир добавлен")
|
||
setPeerIface(null)
|
||
await loadLive()
|
||
} catch (e) {
|
||
setPeerError(e instanceof Error ? e.message : "Ошибка")
|
||
} finally {
|
||
formLock.current = false
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
|
||
if (!exportIface || !isLive || exportPending.current !== null) return
|
||
const epoch = exportEpoch.current
|
||
exportPending.current = epoch
|
||
setExportBusy(true)
|
||
try {
|
||
if (!can("mm:network:write")) throw new Error("Нет прав на изменение сети или экспорт ключей")
|
||
const peer = format === "peer-conf" ? selectWgExportPeer(exportIface.peers, exportPeerId) : undefined
|
||
if (format === "peer-conf" && !peer) throw new Error("Выбранный пир не найден. Обновите список и выберите пира заново.")
|
||
const res = await exportWireGuard(backendUrl, {
|
||
serverId: exportIface.serverId,
|
||
interfaceName: exportIface.name,
|
||
format,
|
||
includePrivateKey: format !== "peer-conf",
|
||
peerId: peer?.rosId ?? peer?.id,
|
||
})
|
||
if (epoch !== exportEpoch.current) return
|
||
setLiveExport((prev) => ({
|
||
...prev,
|
||
...(format === "rsc"
|
||
? { rsc: res.content }
|
||
: format === "conf"
|
||
? { conf: res.content }
|
||
: { peerConf: res.content }),
|
||
}))
|
||
if (active.current) toast.success("Конфиг загружен с роутера")
|
||
} catch (e) {
|
||
if (epoch !== exportEpoch.current) return
|
||
if (active.current) toast.error(e instanceof Error ? e.message : "Ошибка экспорта")
|
||
} finally {
|
||
if (epoch === exportEpoch.current) {
|
||
exportPending.current = null
|
||
setExportBusy(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) {
|
||
exportEpoch.current += 1
|
||
exportPending.current = null
|
||
setExportBusy(false)
|
||
setLiveExport(null)
|
||
setExportInitialTab(tab)
|
||
setExportPeerId(peerId ?? null)
|
||
setExportIface(iface)
|
||
}
|
||
|
||
const emptyCreateAction = (
|
||
<Button size="sm" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
|
||
<PlusIcon className="size-4" />
|
||
Новый интерфейс
|
||
</Button>
|
||
)
|
||
|
||
return (
|
||
<>
|
||
<ServerRailLayout
|
||
items={railItems}
|
||
selectedId={effectiveServerId}
|
||
preserveSelection
|
||
onSelect={onSelectServer}
|
||
showAll
|
||
allCount={completeScope ? displayIfaces.length : undefined}
|
||
loading={isLive && loading && displayServers.length === 0}
|
||
banner={!canWrite ? <p role="status" className="border-b px-4 py-2 text-sm text-muted-foreground">Режим просмотра. Изменение конфигурации и экспорт ключей недоступны.</p> : undefined}
|
||
header={
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
|
||
actions={
|
||
<>
|
||
<ServerRailMobileButton />
|
||
{isLive && (
|
||
<>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={loading}
|
||
onClick={() => void loadLive()}
|
||
>
|
||
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||
Обновить
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={loading || !historyServerId}
|
||
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
|
||
onClick={() => {
|
||
setHistoryOpen(true)
|
||
void loadRevisions()
|
||
}}
|
||
>
|
||
<HistoryIcon className="size-4" />
|
||
История
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button size="sm" variant="outline" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setImportError(null); setImportOpen(true) }}>
|
||
<UploadIcon className="size-4" />
|
||
Импорт
|
||
</Button>
|
||
<Button size="sm" disabled={!canWrite || !!urlError || !catalogKnown || loadError} onClick={() => { setCreateError(null); setCreateOpen(true) }}>
|
||
<PlusIcon className="size-4" />
|
||
Новый интерфейс
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
{urlError && <Alert variant="warning" role="alert" aria-label="Фильтры WireGuard"><AlertTitle>Проверьте ссылку</AlertTitle><AlertDescription><p>{urlError}</p><Button type="button" variant="outline" onClick={resetFilters}>Сбросить фильтры</Button></AlertDescription></Alert>}
|
||
{isLive && <Alert variant={loadError ? "warning" : "info"} role={loadError ? "alert" : "status"} aria-label="Чтение WireGuard"><AlertTitle>{loadError ? "Не удалось обновить WireGuard" : loading ? "Обновление WireGuard" : "Данные WireGuard"}</AlertTitle><AlertDescription>{loadError && readError && <p className="wrap-anywhere">{readError}</p>}{receivedAt !== null ? <p>Получены {new Date(receivedAt).toLocaleTimeString("ru-RU")}.{loadError ? " Показаны последние полученные данные. Повторите обновление перед изменением объектов." : loading ? " Выполняется обновление." : ""}</p> : <p>{loadError ? "Каталог пока неизвестен. Повторите обновление." : "Ожидание ответа сервера."}</p>}</AlertDescription></Alert>}
|
||
{selectedInterfaceId !== null && (
|
||
<Alert variant={selectedSourceError || ((!isLive || !loading) && !selectedInterface) ? "warning" : "info"} role="status" aria-label="WireGuard по ссылке">
|
||
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedInterface ? "Выбран интерфейс: " + selectedInterface.name : "WireGuard по ссылке"}</AlertTitle>
|
||
<AlertDescription>
|
||
<p className="wrap-anywhere">{isLive && loading ? "Загрузка интерфейса…" : selectedSourceError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedInterface ? selectedInterface.serverName + " · " + (selectedInterface.address ?? selectedInterface.id) + ". Список, пиры и сводка ограничены выбранным интерфейсом." : "Интерфейс не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}</p>
|
||
<div className="flex flex-wrap gap-4">
|
||
<Link href={allInterfacesHref} className="underline underline-offset-2">Все интерфейсы сервера</Link>
|
||
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
||
</div>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{togglePending && <p role="status" className="text-sm text-muted-foreground">Изменение интерфейса {togglePending}…</p>}
|
||
{toggleError && <p role="alert" className="text-sm text-destructive">{toggleError}</p>}
|
||
<KpiStatGrid
|
||
aria-label="Сводка WireGuard"
|
||
items={[
|
||
{
|
||
id: "ifaces",
|
||
label: "Интерфейсов",
|
||
value: completeScope ? scopedIfaces.length : "—",
|
||
icon: <ShieldCheckIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "up",
|
||
label: "Активных (UP)",
|
||
value: completeScope ? upIfaces : "—",
|
||
icon: <ActivityIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "peers",
|
||
label: "Всего пиров",
|
||
value: completeScope ? totalPeers : "—",
|
||
icon: <UsersIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
{
|
||
id: "handshake-data",
|
||
label: "Данные рукопожатия",
|
||
value: completeScope ? `${peersWithHandshakeData}/${totalPeers}` : "—",
|
||
hint: <span className="block text-xs text-muted-foreground">Доступность пиров не проверена</span>,
|
||
icon: <KeyRoundIcon className="size-4" />,
|
||
iconClassName: "text-primary",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
{failures.length > 0 ? (
|
||
<Alert variant="destructive">
|
||
<AlertCircleIcon />
|
||
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
|
||
<AlertDescription>
|
||
{failures.map((f) => f.serverName ?? f.serverId).join(", ")}
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
|
||
{!isLive ? (
|
||
<Alert variant="info">
|
||
<InfoIcon />
|
||
<AlertTitle>Mock-режим</AlertTitle>
|
||
<AlertDescription>
|
||
Переключитесь в live в настройках, чтобы применять изменения на MikroTik.
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
|
||
<Tabs
|
||
value={workspaceTab}
|
||
onValueChange={(v) => setWorkspaceTab(v as WgWorkspaceTab)}
|
||
className="gap-3"
|
||
>
|
||
<TabsList variant="line">
|
||
<TabsTrigger value="interfaces" className="gap-1.5">
|
||
<ShieldCheckIcon className="size-3.5" />
|
||
Интерфейсы
|
||
</TabsTrigger>
|
||
<TabsTrigger value="peers" className="gap-1.5">
|
||
<UsersIcon className="size-3.5" />
|
||
Пиры
|
||
</TabsTrigger>
|
||
<TabsTrigger value="cli" className="gap-1.5">
|
||
<CodeXmlIcon className="size-3.5" />
|
||
CLI
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="interfaces" className="mt-0 outline-none">
|
||
<DataPageCard>
|
||
<DataPageToolbar
|
||
search={search}
|
||
onSearchChange={setSearch}
|
||
searchPlaceholder="Поиск по имени, серверу, IP…"
|
||
countLabel={catalogKnown && !urlError ? `${filteredIfaces.length} интерфейсов` : "Количество неизвестно"}
|
||
segmented={{
|
||
value: statusFilter,
|
||
onChange: setStatusFilter,
|
||
options: [
|
||
{ value: "all", label: "Все", count: statusCounts.all },
|
||
{ value: "up", label: "UP", count: statusCounts.up },
|
||
{ value: "down", label: "DOWN", count: statusCounts.down },
|
||
],
|
||
}}
|
||
/>
|
||
{catalogKnown && !urlError && (completeScope || filteredIfaces.length > 0) ? <WireguardDataGrid
|
||
interfaces={filteredIfaces}
|
||
canWrite={canWrite}
|
||
compactServer={compactServer}
|
||
emptyAction={emptyCreateAction}
|
||
onExport={(iface) => openExport(iface, "rsc")}
|
||
onAddPeer={!canWrite || selectedSourceError ? undefined : iface => { setPeerError(null); setPeerIface(iface) }}
|
||
onToggle={!canWrite || selectedSourceError ? undefined : handleToggle}
|
||
toggleBusy={togglePending !== null}
|
||
onDelete={!canWrite || selectedSourceError ? undefined : (iface) => setPendingDelete({ kind: "iface", iface })}
|
||
onDeletePeer={!canWrite || selectedSourceError ? undefined : (iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })}
|
||
onExportPeer={canWrite ? (iface, peerId) => openExport(iface, "peer", peerId) : undefined}
|
||
/> : <p role="status" className="px-5 py-8 text-sm text-muted-foreground">Каталог интерфейсов пока неизвестен</p>}
|
||
</DataPageCard>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="peers" className="mt-0 outline-none">
|
||
<DataPageCard>
|
||
<DataPageToolbar
|
||
search={search}
|
||
onSearchChange={setSearch}
|
||
searchPlaceholder="Поиск по ключу, IP, endpoint…"
|
||
countLabel={catalogKnown && !urlError ? `${peerRows.length} пиров` : "Количество неизвестно"}
|
||
/>
|
||
{catalogKnown && !urlError && (completeScope || peerRows.length > 0) ? <WireguardPeersGrid
|
||
peers={peerRows}
|
||
compactServer={compactServer}
|
||
emptyAction={
|
||
scopedIfaces.length === 1 ? (
|
||
<Button size="sm" disabled={!canWrite || selectedSourceError} onClick={() => { setPeerError(null); setPeerIface(scopedIfaces[0]) }}>
|
||
<PlusIcon className="size-4" />
|
||
Добавить пира
|
||
</Button>
|
||
) : emptyCreateAction
|
||
}
|
||
onDeletePeer={!canWrite || selectedSourceError ? undefined : (row) => {
|
||
const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId)
|
||
if (!iface) return
|
||
setPendingDelete({ kind: "peer", iface, peerId: row.id })
|
||
}}
|
||
onExportPeer={canWrite ? (row) => {
|
||
const iface = scopedIfaces.find((i) => i.id === row.ifaceId && i.serverId === row.serverId)
|
||
if (!iface) return
|
||
openExport(iface, "peer", row.id)
|
||
} : undefined}
|
||
/> : <p role="status" className="px-5 py-8 text-sm text-muted-foreground">Каталог пиров пока неизвестен</p>}
|
||
</DataPageCard>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="cli" className="mt-0 outline-none">
|
||
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
|
||
<div className="grid grid-cols-1 gap-4 font-mono text-xs sm:grid-cols-3">
|
||
{[
|
||
{
|
||
title: "Создать интерфейс",
|
||
lines: [
|
||
"/interface wireguard add \\",
|
||
" name=wg0 \\",
|
||
" listen-port=13231 \\",
|
||
" mtu=1420",
|
||
],
|
||
},
|
||
{
|
||
title: "Добавить пира",
|
||
lines: [
|
||
"/interface wireguard peers add \\",
|
||
" interface=wg0 \\",
|
||
' public-key="<ключ>" \\',
|
||
" allowed-address=10.0.0.2/32 \\",
|
||
" endpoint-address=1.2.3.4 \\",
|
||
" persistent-keepalive=25",
|
||
],
|
||
},
|
||
{
|
||
title: "Назначить IP",
|
||
lines: [
|
||
"/ip address add \\",
|
||
" address=10.210.0.1/30 \\",
|
||
" interface=wg0",
|
||
"",
|
||
"# Статус:",
|
||
"/interface wireguard 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>
|
||
|
||
<WgCreateSheet
|
||
open={createOpen}
|
||
onOpenChange={v => { if (!formLock.current) setCreateOpen(v) }}
|
||
error={createError}
|
||
servers={serverOptions}
|
||
defaultServerId={sheetServerId}
|
||
busy={busy}
|
||
onSubmit={handleCreate}
|
||
/>
|
||
<WgImportSheet
|
||
open={importOpen}
|
||
onOpenChange={v => { if (!formLock.current) setImportOpen(v) }}
|
||
error={importError}
|
||
servers={serverOptions}
|
||
defaultServerId={sheetServerId}
|
||
busy={busy}
|
||
onImport={handleImport}
|
||
/>
|
||
<WgPeerSheet
|
||
open={!!peerIface}
|
||
iface={peerIface}
|
||
busy={busy}
|
||
onOpenChange={(v) => { if (!v && !formLock.current) setPeerIface(null) }}
|
||
error={peerError}
|
||
onSubmit={handleAddPeer}
|
||
/>
|
||
<WgExportSheet
|
||
open={!!exportIface}
|
||
iface={exportIface}
|
||
serverHost={displayServers.find(server => server.id === exportIface?.serverId)?.host}
|
||
initialTab={exportInitialTab}
|
||
peerId={exportPeerId}
|
||
onClose={() => {
|
||
exportEpoch.current += 1
|
||
exportPending.current = null
|
||
setExportBusy(false)
|
||
setExportIface(null)
|
||
setExportPeerId(null)
|
||
setExportInitialTab("rsc")
|
||
setLiveExport(null)
|
||
}}
|
||
liveContent={liveExport}
|
||
liveBusy={exportBusy}
|
||
onRequestLiveExport={isLive ? handleLiveExport : undefined}
|
||
/>
|
||
|
||
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) } }}>
|
||
<AlertDialogContent size="default">
|
||
<AlertDialogHeader>
|
||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||
<Trash2Icon />
|
||
</AlertDialogMedia>
|
||
<AlertDialogTitle>
|
||
{pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"}
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription className="break-all">
|
||
{pendingDelete?.kind === "iface"
|
||
? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры и IP-адреса на роутере.`
|
||
: pendingDelete
|
||
? `Пир «${pendingPeer?.name || pendingPeer?.publicKey || pendingDelete.peerId}» (ID ${pendingDelete.peerId}) на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).`
|
||
: null}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
{deleteBusy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения удаления…</p>}
|
||
{deleteError && <p role="alert" className="text-sm text-destructive">{deleteError}</p>}
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel disabled={deleteBusy}>Отмена</AlertDialogCancel>
|
||
<AlertDialogAction type="button" variant="destructive" disabled={deleteBusy} onClick={() => void confirmDelete()}>
|
||
{deleteBusy ? "Удаление…" : "Удалить"}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
<ConfigHistorySheet
|
||
open={historyOpen}
|
||
onOpenChange={v => { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }}
|
||
loadError={historyError}
|
||
onRetry={() => void loadRevisions()}
|
||
title="История WireGuard"
|
||
itemLabel="интерфейсов"
|
||
revisions={revisions}
|
||
loading={historyLoading}
|
||
restoring={historyRestoring}
|
||
restoreDisabled={!can("mm:network:write") || !isLive || !historyServerId || !displayServers.some(s => s.id === historyServerId && s.enabled) || loading || busy || deleteBusy || !!togglePending}
|
||
onRestore={restoreRevision}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function WireGuardRoute() {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const params = useSearchParams()
|
||
const router = useRouter()
|
||
const serverId = params.get("server") ?? ALL_SERVERS_ID
|
||
const interfaceId = params.get("interface")
|
||
const rest = new URLSearchParams(params.toString())
|
||
rest.delete("interface")
|
||
const allInterfacesHref = "/wireguard" + (rest.size ? "?" + rest.toString() : "")
|
||
return <WireGuardPageContent key={JSON.stringify([serverId, interfaceId, mode, backendUrl])} selectedServerId={serverId} selectedInterfaceId={interfaceId} allInterfacesHref={allInterfacesHref} onSelectServer={id => {
|
||
const next = new URLSearchParams(rest)
|
||
if (id === ALL_SERVERS_ID) next.delete("server")
|
||
else next.set("server", id)
|
||
router.push("/wireguard" + (next.size ? "?" + next.toString() : ""))
|
||
}} />
|
||
}
|
||
|
||
export default function WireGuardPage() {
|
||
return <Suspense fallback={<div role="status">Загрузка WireGuard…</div>}><WireGuardRoute /></Suspense>
|
||
}
|