"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([])
const [liveServers, setLiveServers] = useState([])
const [failures, setFailures] = useState([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState(false)
const [readError, setReadError] = useState(null)
const [receivedAt, setReceivedAt] = useState(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(null)
const [toggleError, setToggleError] = useState(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(null)
const [peerError, setPeerError] = useState(null)
const [importError, setImportError] = useState(null)
const [exportIface, setExportIface] = useState(null)
const [exportInitialTab, setExportInitialTab] = useState<"rsc" | "conf" | "peer">("rsc")
const [exportPeerId, setExportPeerId] = useState(null)
const [peerIface, setPeerIface] = useState(null)
const [pendingDelete, setPendingDelete] = useState(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(null)
const [historyOpen, setHistoryOpen] = useState(false)
const [revisions, setRevisions] = useState([])
const [historyLoading, setHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState(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(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(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(() => {
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(() => {
const counts = new Map()
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 = (
{ setCreateError(null); setCreateOpen(true) }}>
Новый интерфейс
)
return (
<>
Режим просмотра. Изменение конфигурации и экспорт ключей недоступны.
: undefined}
header={
{isLive && (
<>
void loadLive()}
>
Обновить
{
setHistoryOpen(true)
void loadRevisions()
}}
>
История
>
)}
{ setImportError(null); setImportOpen(true) }}>
Импорт
{ setCreateError(null); setCreateOpen(true) }}>
Новый интерфейс
>
}
/>
}
>
{urlError &&
Проверьте ссылку {urlError}
Сбросить фильтры }
{isLive &&
{loadError ? "Не удалось обновить WireGuard" : loading ? "Обновление WireGuard" : "Данные WireGuard"} {loadError && readError && {readError}
}{receivedAt !== null ? Получены {new Date(receivedAt).toLocaleTimeString("ru-RU")}.{loadError ? " Показаны последние полученные данные. Повторите обновление перед изменением объектов." : loading ? " Выполняется обновление." : ""}
: {loadError ? "Каталог пока неизвестен. Повторите обновление." : "Ожидание ответа сервера."}
} }
{selectedInterfaceId !== null && (
{selectedInterface ? "Выбран интерфейс: " + selectedInterface.name : "WireGuard по ссылке"}
{isLive && loading ? "Загрузка интерфейса…" : selectedSourceError ? "Не удалось получить данные выбранного сервера. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedInterface ? selectedInterface.serverName + " · " + (selectedInterface.address ?? selectedInterface.id) + ". Список, пиры и сводка ограничены выбранным интерфейсом." : "Интерфейс не найден или недоступен на указанном сервере. Проверьте ссылку и доступ к устройству."}
Все интерфейсы сервера
К дашборду
)}
{togglePending &&
Изменение интерфейса {togglePending}…
}
{toggleError &&
{toggleError}
}
,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных (UP)",
value: completeScope ? upIfaces : "—",
icon:
,
iconClassName: "text-success",
},
{
id: "peers",
label: "Всего пиров",
value: completeScope ? totalPeers : "—",
icon:
,
iconClassName: "text-info",
},
{
id: "handshake-data",
label: "Данные рукопожатия",
value: completeScope ? `${peersWithHandshakeData}/${totalPeers}` : "—",
hint:
Доступность пиров не проверена ,
icon:
,
iconClassName: "text-primary",
},
]}
/>
{failures.length > 0 ? (
Не удалось опросить часть роутеров
{failures.map((f) => f.serverName ?? f.serverId).join(", ")}
) : null}
{!isLive ? (
Mock-режим
Переключитесь в live в настройках, чтобы применять изменения на MikroTik.
) : null}
setWorkspaceTab(v as WgWorkspaceTab)}
className="gap-3"
>
Интерфейсы
Пиры
CLI
{catalogKnown && !urlError && (completeScope || filteredIfaces.length > 0) ? 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}
/> : Каталог интерфейсов пока неизвестен
}
{catalogKnown && !urlError && (completeScope || peerRows.length > 0) ? { setPeerError(null); setPeerIface(scopedIfaces[0]) }}>
Добавить пира
) : 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}
/> : Каталог пиров пока неизвестен
}
{[
{
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) => (
{b.title}
{b.lines.join("\n")}
))}
{ if (!formLock.current) setCreateOpen(v) }}
error={createError}
servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy}
onSubmit={handleCreate}
/>
{ if (!formLock.current) setImportOpen(v) }}
error={importError}
servers={serverOptions}
defaultServerId={sheetServerId}
busy={busy}
onImport={handleImport}
/>
{ if (!v && !formLock.current) setPeerIface(null) }}
error={peerError}
onSubmit={handleAddPeer}
/>
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}
/>
{ if (!v && !deleteLock.current) { setPendingDelete(null); setDeleteError(null) } }}>
{pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"}
{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}
{deleteBusy && Ожидание подтверждения удаления…
}
{deleteError && {deleteError}
}
Отмена
void confirmDelete()}>
{deleteBusy ? "Удаление…" : "Удалить"}
{ 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 {
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 Загрузка WireGuard…}>
}