"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { PageHeader } from "@/components/page-header" import { servers as mockServers } from "@/lib/data" import type { Server } from "@/lib/data" import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar } from "@/components/data-page-toolbar" import { 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, 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 { 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 WgWorkspaceTab = "interfaces" | "peers" | "cli" type WgStatusFilter = "all" | "up" | "down" 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"] latency?: number | null asn?: string } function mapBackendServer(s: BackendServer): Server { return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: "", country: s.country || "UN", asn: s.asn ?? "", type: s.type ?? "exit-node", enabled: s.enabled, status: s.status ?? "online", latency: s.latency ?? null, sessions: 0, } } 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}` } export default function WireGuardPage() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const [liveIfaces, setLiveIfaces] = useState([]) const [liveServers, setLiveServers] = useState([]) const [failures, setFailures] = useState([]) const [loading, setLoading] = useState(false) const [busy, setBusy] = useState(false) const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [workspaceTab, setWorkspaceTab] = useState("interfaces") const [statusFilter, setStatusFilter] = useState("all") const [search, setSearch] = useState("") const [createOpen, setCreateOpen] = useState(false) const [importOpen, setImportOpen] = useState(false) 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 [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) const [historyLoading, setHistoryLoading] = useState(false) const [historyRestoring, setHistoryRestoring] = useState(false) const [liveExport, setLiveExport] = useState<{ rsc?: string conf?: string peerConf?: string } | null>(null) const [exportBusy, setExportBusy] = useState(false) const loadLive = useCallback(async () => { if (!isLive) return setLoading(true) try { const [wg, servers] = await Promise.all([ listWireGuard(backendUrl), requestJson(backendUrl, "/api/servers"), ]) setLiveIfaces(wg.interfaces.map(dtoToRow)) setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer)) setFailures(wg.failures ?? []) if (wg.failures?.length) { toast.warning( `Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`, ) } } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard") setLiveIfaces([]) setFailures([]) } finally { 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 === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId) ? selectedServerId : ALL_SERVERS_ID const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId const loadRevisions = useCallback(async () => { if (!isLive || !historyServerId) return setHistoryLoading(true) try { const res = await requestJson<{ revisions: ConfigRevisionDto[] }>( backendUrl, `/api/wireguard/revisions?serverId=${encodeURIComponent(historyServerId)}`, ) setRevisions(res.revisions) } catch (err) { toast.error("Не удалось загрузить историю", { description: String(err) }) setRevisions([]) } finally { setHistoryLoading(false) } }, [isLive, historyServerId, backendUrl]) const restoreRevision = useCallback(async (id: string) => { if (!isLive || !historyServerId) return setHistoryRestoring(true) try { await requestJson( backendUrl, `/api/wireguard/revisions/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({ serverId: historyServerId }) }, ) toast.success("Версия применена на роутер") await loadLive() await loadRevisions() } catch (err) { toast.error("Не удалось откатить", { description: String(err) }) } finally { setHistoryRestoring(false) } }, [isLive, historyServerId, backendUrl, loadLive, loadRevisions]) const scopedIfaces = useMemo(() => { if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces return displayIfaces.filter((i) => i.serverId === effectiveServerId) }, [displayIfaces, effectiveServerId]) 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 onlinePeers = scopedIfaces.reduce( (s, i) => s + i.peers.filter((p) => !!p.latestHandshake).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: counts.get(s.id) ?? 0, enabled: s.enabled, title: [s.name, s.host, s.asn].filter(Boolean).join(" · "), })) }, [displayServers, displayIfaces]) async function handleCreate(form: WgCreateFormState) { if (!isLive) { toast.info("Создание на роутер доступно только в live-режиме") return } setBusy(true) try { 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, }) toast.success(`Интерфейс ${form.name} создан`) setCreateOpen(false) await loadLive() } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка создания") } finally { setBusy(false) } } async function handleImport(args: { serverId: string content: string format: "auto" | "rsc" | "conf" dryRun: boolean }) { if (!isLive) { toast.info("Импорт на роутер доступен только в live-режиме") return } setBusy(true) try { const res = await importWireGuard(backendUrl, { serverId: args.serverId, content: args.content, format: args.format, dryRun: args.dryRun, }) toast.success( res.applied ? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)` : "Импорт выполнен", ) setImportOpen(false) await loadLive() } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка импорта") } finally { setBusy(false) } } async function handleToggle(iface: WgIfaceWithServer) { if (!isLive || !iface.rosId) { toast.info("Доступно только в live-режиме") return } try { await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, { disabled: iface.enabled, }) toast.success(iface.enabled ? "Отключено" : "Включено") await loadLive() } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка") } } async function confirmDelete() { if (!pendingDelete) return if (!isLive) { toast.info("Доступно только в live-режиме") setPendingDelete(null) return } try { if (pendingDelete.kind === "iface") { if (!pendingDelete.iface.rosId) { toast.info("Доступно только в live-режиме") return } await deleteWireGuardInterface(backendUrl, pendingDelete.iface.serverId, pendingDelete.iface.rosId) toast.success("Удалено") } else { await deleteWireGuardPeer(backendUrl, pendingDelete.iface.serverId, pendingDelete.peerId) toast.success("Пир удалён") } setPendingDelete(null) await loadLive() } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка удаления") } } async function handleAddPeer(form: WgPeerFormState) { if (!isLive || !peerIface) { toast.info("Доступно только в live-режиме") return } setBusy(true) try { 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, }) toast.success("Пир добавлен") setPeerIface(null) await loadLive() } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка") } finally { setBusy(false) } } async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") { if (!exportIface || !isLive) return setExportBusy(true) try { const res = await exportWireGuard(backendUrl, { serverId: exportIface.serverId, interfaceName: exportIface.name, format, includePrivateKey: format !== "peer-conf", peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined, }) setLiveExport((prev) => ({ ...prev, ...(format === "rsc" ? { rsc: res.content } : format === "conf" ? { conf: res.content } : { peerConf: res.content }), })) toast.success("Конфиг загружен с роутера") } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка экспорта") } finally { setExportBusy(false) } } function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) { setLiveExport(null) setExportInitialTab(tab) setExportPeerId(peerId ?? null) setExportIface(iface) } const emptyCreateAction = ( ) return ( <> {isLive && ( <> )} } /> } >
, iconClassName: "text-muted-foreground", }, { id: "up", label: "Активных (UP)", value: upIfaces, icon: , iconClassName: "text-success", }, { id: "peers", label: "Всего пиров", value: totalPeers, icon: , iconClassName: "text-info", }, { id: "online", label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, 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 openExport(iface, "rsc")} onAddPeer={setPeerIface} onToggle={handleToggle} onDelete={(iface) => setPendingDelete({ kind: "iface", iface })} onDeletePeer={(iface, peerId) => setPendingDelete({ kind: "peer", iface, peerId })} onExportPeer={(iface, peerId) => openExport(iface, "peer", peerId)} /> setPeerIface(scopedIfaces[0])}> Добавить пира ) : emptyCreateAction } onDeletePeer={(row) => { const iface = scopedIfaces.find((i) => i.id === row.ifaceId) if (!iface) return setPendingDelete({ kind: "peer", iface, peerId: row.id }) }} onExportPeer={(row) => { const iface = scopedIfaces.find((i) => i.id === row.ifaceId) if (!iface) return openExport(iface, "peer", row.id) }} />
{[ { 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 (!v) setPeerIface(null) }} onSubmit={handleAddPeer} /> { setExportIface(null) setExportPeerId(null) setExportInitialTab("rsc") setLiveExport(null) }} liveContent={liveExport} liveBusy={exportBusy} onRequestLiveExport={isLive ? handleLiveExport : undefined} /> { if (!v) setPendingDelete(null) }}> {pendingDelete?.kind === "peer" ? "Удалить пира?" : "Удалить интерфейс?"} {pendingDelete?.kind === "iface" ? `${pendingDelete.iface.name} на ${pendingDelete.iface.serverName}. Вместе с интерфейсом будут удалены связанные пиры на роутере.` : pendingDelete ? `Пир на ${pendingDelete.iface.name} (${pendingDelete.iface.serverName}).` : null} setPendingDelete(null)}>Отмена void confirmDelete()}> Удалить ) }