"use client" import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" import { Alert as ContextAlert, AlertTitle as ContextTitle, AlertDescription as ContextDescription } from "@/components/reui/alert" import { PageHeader } from "@/components/page-header" import { vxlanTunnels as mockVxlanTunnels, servers as mockServers } from "@/lib/data" import type { Server, VxlanTunnel } from "@/lib/data" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar } from "@/components/data-page-toolbar" import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Alert, AlertDescription } from "@/components/ui/alert" import { NetworkIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon, } from "lucide-react" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { useDataSource } from "@/lib/data-source" import { generateVxlanRsc } from "@/lib/vxlan-export" import { vxlanResponseSchema, vxlanServersSchema } from "@/lib/vxlan-response" import { requestJson } from "@/shared/api/http-client" import { cn } from "@/lib/utils" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" interface BackendServer { id: number name: string host: string type?: Server["type"] site?: string country: string asn?: string enabled: boolean status?: Server["status"] | null latency?: number | null } interface VxlanApiResponse { failures?: Array<{ serverId: string; serverName?: string; error: string }> tunnels: VxlanTunnel[] } 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: s.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, } } function ExportSheet({ open, tunnel, onClose, serverById, receivedAt, stale, mock, }: { open: boolean tunnel: VxlanTunnel | null onClose: () => void serverById: Record receivedAt: string | null stale: boolean mock: boolean }) { const code = useMemo( () => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""), [tunnel, serverById], ) return ( ) } function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, onSelectServer }: { selectedServerId: string selectedTunnelId: string | null allTunnelsHref: string onSelectServer: (id: string) => void }) { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const [search, setSearch] = useState("") const [exportSnapshot, setExportSnapshot] = useState<{ tunnel: VxlanTunnel; servers: Record; at: string | null; stale: boolean; mock: boolean } | null>(null) const [readReceipt, setReadReceipt] = useState>({}) const [responseReceipt, setResponseReceipt] = useState(null) const [liveTunnels, setLiveTunnels] = useState([]) const [liveServers, setLiveServers] = useState([]) const [loading, setLoading] = useState(true) const [failures, setFailures] = useState>([]) const [hasLoaded, setHasLoaded] = useState(false) const loadEpoch = useRef(0) useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive]) const [liveError, setLiveError] = useState(null) const loadLive = useCallback(async () => { if (!isLive) return const epoch = ++loadEpoch.current setLoading(true) try { const [tunnelsRes, serversRes] = await Promise.all([ requestJson(backendUrl, "/api/vxlan"), requestJson(backendUrl, "/api/servers"), ]) if (epoch !== loadEpoch.current) return const parsedTunnels = vxlanResponseSchema.safeParse(tunnelsRes) const parsedServers = vxlanServersSchema.safeParse(serversRes) if (!parsedTunnels.success || !parsedServers.success) throw new Error("Некорректный ответ загрузки VXLAN") const nextTunnels = parsedTunnels.data.tunnels const nextServers = parsedServers.data const nextFailures = parsedTunnels.data.failures const failedIds = new Set(nextFailures.map(f => f.serverId)) const enabledIds = new Set(nextServers.filter(s => s.enabled).map(s => String(s.id))) setLiveTunnels(previous => [...nextTunnels.filter(t => !failedIds.has(t.serverId)), ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))]) setFailures(nextFailures) setLiveError(null) setHasLoaded(true) const at = new Date().toISOString() setResponseReceipt(at) setReadReceipt(previous => Object.fromEntries([...enabledIds].map(id => [id, failedIds.has(id) ? previous[id] : at]).filter((entry): entry is [string, string] => !!entry[1]))) setLiveServers(nextServers.filter((s) => s.enabled).map(mapBackendServer)) } catch (e) { if (epoch !== loadEpoch.current) return setLiveError(e instanceof Error ? e.message : "Ошибка загрузки") } finally { if (epoch === loadEpoch.current) setLoading(false) } }, [isLive, backendUrl]) useEffect(() => { if (!isLive) { queueMicrotask(() => { setLiveTunnels([]) setLiveServers([]) setLiveError(null) }) return } queueMicrotask(() => { void loadLive() }) }, [isLive, loadLive]) const displayTunnels = isLive ? liveTunnels : mockVxlanTunnels const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled) const effectiveServerId = selectedServerId const scopedTunnels = useMemo(() => { if (selectedTunnelId !== null) return displayTunnels.filter(t => t.serverId === effectiveServerId && t.id === selectedTunnelId) if (effectiveServerId === ALL_SERVERS_ID) return displayTunnels return displayTunnels.filter((t) => t.serverId === effectiveServerId) }, [displayTunnels, effectiveServerId, selectedTunnelId]) const selectedTunnel = selectedTunnelId === null ? undefined : scopedTunnels[0] const serverById = useMemo( () => Object.fromEntries(displayServers.map((s) => [s.id, s])), [displayServers], ) const railItems = useMemo(() => ( 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, enabled: s.enabled, meta: `${displayTunnels.filter((t) => t.serverId === s.id).length}${isLive && (s as InventoryServer).monitoringUnknown ? " · Статус неизвестен" : ""}`, })) ), [displayServers, displayTunnels, isLive]) const filtered = useMemo(() => { if (!search) return scopedTunnels const q = search.toLowerCase() return scopedTunnels.filter((t) => t.name.includes(q) || String(t.vni).includes(q) || t.vtepIp.includes(q) || (serverById[t.serverId]?.name.toLowerCase().includes(q) ?? false), ) }, [search, scopedTunnels, serverById]) const failedServerIds = useMemo(() => !isLive ? [] : liveError ? [...new Set([...liveTunnels.map(t => t.serverId), ...liveServers.map(s => s.id)])] : failures.map(f => f.serverId), [isLive, liveError, liveTunnels, liveServers, failures]) const scopedFailures = failures.filter(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId) const upCount = scopedTunnels.filter((t) => t.status === "up").length const vnis = new Set(scopedTunnels.map((t) => t.vni)).size return ( <> Только просмотр } /> } >
{isLive && scopedFailures.length > 0 && Не все устройства ответили

Не удалось опросить: {scopedFailures.map(f => f.serverName ?? f.serverId).join(", ")}. Для этих устройств сохранены последние данные; текущее состояние туннелей неизвестно.

} {selectedTunnelId !== null && ( {selectedTunnel ? "Выбран VXLAN: " + selectedTunnel.name : "VXLAN по ссылке"}

{isLive && loading ? "Загрузка туннеля…" : isLive && liveError ? "Не удалось обновить VXLAN. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · VNI " + selectedTunnel.vni + ". Список и сводка ограничены выбранным туннелем." : "Туннель не получен для указанного сервера. Проверьте ссылку и доступ к устройству: туннель может отсутствовать или устройство не ответило на опрос."}

Все туннели сервера К дашборду
)} {isLive && liveError && selectedTunnelId === null && ( Ошибка загрузки: {liveError} )} {isLive &&
{effectiveServerId === ALL_SERVERS_ID ? "Последний ответ получен в приложении: " : "Ответ устройства получен в приложении: "} {(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]) ? : "неизвестно"}
} {isLive && loading &&

{hasLoaded ? "Обновление VXLAN…" : "Загрузка VXLAN…"}

} {isLive && liveError && hasLoaded &&

Показаны последние полученные данные; они могут быть устаревшими.

} {selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && scopedFailures.length === 0 && (
На опрошенных серверах нет VXLAN-интерфейсов
)} {mode === "mock" && ( Моковые данные )} 0) ? "—" : scopedTunnels.length, icon: , iconClassName: "text-muted-foreground", }, { id: "up", label: "Активных", value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : upCount, icon: , iconClassName: "text-success", }, { id: "vni", label: "VNI", value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis, icon: , iconClassName: "text-info", }, { id: "servers", label: "Серверов", value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size, icon: , iconClassName: "text-primary", }, ]} />

VXLAN — L2-over-L3 оверлей для RouterOS 7.x

VXLAN переносит L2 через IP-сеть. VNI определяет сегмент; шифрование зависит от настроенного транспорта.

{(!isLive || hasLoaded) && setExportSnapshot({ tunnel, servers: serverById, at: isLive ? readReceipt[tunnel.serverId] ?? null : null, stale: isLive && (!!liveError || failedServerIds.includes(tunnel.serverId)), mock: !isLive })} />}

Экспорт содержит интерфейс, известный MTU и статические VTEP выбранного туннеля. IP-адреса, bridge и FDB в него не входят. Параметры VXLAN в документации MikroTik

setExportSnapshot(null)} serverById={exportSnapshot?.servers ?? {}} receivedAt={exportSnapshot?.at ?? null} stale={exportSnapshot?.stale ?? false} mock={exportSnapshot?.mock ?? !isLive} /> ) } function VxlanRoute() { const { mode, backendUrl } = useDataSource() const params = useSearchParams() const router = useRouter() const serverId = params.get("server") ?? ALL_SERVERS_ID const tunnelId = params.get("tunnel") const rest = new URLSearchParams(params.toString()) rest.delete("tunnel") const allTunnelsHref = "/vxlan" + (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("/vxlan" + (next.size ? "?" + next.toString() : "")) }} /> } export default function VxlanPage() { return Загрузка VXLAN…}> }