476 lines
21 KiB
TypeScript
476 lines
21 KiB
TypeScript
"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 { 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"]
|
||
latency?: number | null
|
||
}
|
||
|
||
interface VxlanApiResponse {
|
||
failures?: Array<{ serverId: string; serverName?: string; error: string }>
|
||
tunnels: VxlanTunnel[]
|
||
}
|
||
|
||
function mapBackendServer(s: BackendServer): Server {
|
||
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 ?? "online",
|
||
latency: s.latency ?? null,
|
||
sessions: 0,
|
||
}
|
||
}
|
||
|
||
function generateVxlanRsc(t: VxlanTunnel, serverById: Record<string, Server>): string {
|
||
const srv = serverById[t.serverId]
|
||
const lines: string[] = []
|
||
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
|
||
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
||
lines.push(`# RouterOS 7.x · /interface/vxlan`)
|
||
lines.push(``)
|
||
lines.push(`/interface/vxlan/add \\`)
|
||
lines.push(` name=${t.name} \\`)
|
||
lines.push(` vni=${t.vni} \\`)
|
||
lines.push(` port=${t.dstPort} \\`)
|
||
lines.push(` vtep-mac-address=auto \\`)
|
||
lines.push(` arp-proxy=${t.arpProxy ? "yes" : "no"} \\`)
|
||
lines.push(` mac-learning=${t.macLearning ? "yes" : "no"} \\`)
|
||
lines.push(` l2mtu=${t.l2mtu} \\`)
|
||
if (t.comment) lines.push(` comment="${t.comment}" \\`)
|
||
if (!t.enabled) lines.push(` disabled=yes \\`)
|
||
lines.push(``)
|
||
|
||
for (const vtep of t.remoteVteps) {
|
||
lines.push(`/interface/vxlan/vteps/add \\`)
|
||
lines.push(` interface=${t.name} \\`)
|
||
lines.push(` remote-ip=${vtep}`)
|
||
lines.push(``)
|
||
}
|
||
|
||
lines.push(`# Добавить в bridge:`)
|
||
lines.push(`/interface/bridge/port/add \\`)
|
||
lines.push(` bridge=bridge-overlay \\`)
|
||
lines.push(` interface=${t.name}`)
|
||
|
||
return lines.join("\n")
|
||
}
|
||
|
||
function ExportSheet({
|
||
open, tunnel, onClose, serverById, receivedAt, stale, mock,
|
||
}: {
|
||
open: boolean
|
||
tunnel: VxlanTunnel | null
|
||
onClose: () => void
|
||
serverById: Record<string, Server>
|
||
receivedAt: string | null
|
||
stale: boolean
|
||
mock: boolean
|
||
}) {
|
||
const code = useMemo(
|
||
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
|
||
[tunnel, serverById],
|
||
)
|
||
|
||
return (
|
||
<CodeExportSheet
|
||
open={open}
|
||
onClose={onClose}
|
||
title="Экспорт VXLAN"
|
||
description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
|
||
formats={[
|
||
{
|
||
id: "rsc",
|
||
label: "MikroTik .rsc",
|
||
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
|
||
code,
|
||
},
|
||
]}
|
||
/>
|
||
)
|
||
}
|
||
|
||
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<string, Server>; at: string | null; stale: boolean; mock: boolean } | null>(null)
|
||
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
|
||
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
|
||
|
||
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
|
||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [failures, setFailures] = useState<NonNullable<VxlanApiResponse["failures"]>>([])
|
||
const [hasLoaded, setHasLoaded] = useState(false)
|
||
const loadEpoch = useRef(0)
|
||
useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive])
|
||
const [liveError, setLiveError] = useState<string | null>(null)
|
||
|
||
const loadLive = useCallback(async () => {
|
||
if (!isLive) return
|
||
const epoch = ++loadEpoch.current
|
||
setLoading(true)
|
||
try {
|
||
const [tunnelsRes, serversRes] = await Promise.all([
|
||
requestJson<unknown>(backendUrl, "/api/vxlan"),
|
||
requestJson<unknown>(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<ServerTileItem[]>(() => (
|
||
displayServers.map((s) => ({
|
||
id: s.id,
|
||
name: s.name,
|
||
host: s.host,
|
||
site: s.site,
|
||
country: s.country,
|
||
status: s.status,
|
||
type: s.type,
|
||
enabled: s.enabled,
|
||
meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
|
||
}))
|
||
), [displayServers, displayTunnels])
|
||
|
||
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 (
|
||
<>
|
||
<ServerRailLayout
|
||
items={railItems}
|
||
selectedId={effectiveServerId}
|
||
preserveSelection
|
||
onSelect={onSelectServer}
|
||
showAll
|
||
allCount={displayServers.length}
|
||
loading={isLive && loading && displayServers.length === 0}
|
||
header={
|
||
<PageHeader
|
||
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
|
||
actions={
|
||
<>
|
||
<ServerRailMobileButton />
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => { void loadLive() }}
|
||
disabled={!isLive || loading}
|
||
>
|
||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
<Badge variant="outline">Только просмотр</Badge>
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-5">
|
||
{isLive && scopedFailures.length > 0 && <ContextAlert variant="warning" role="alert">
|
||
<ContextTitle>Не все устройства ответили</ContextTitle>
|
||
<ContextDescription>
|
||
<p>Не удалось опросить: {scopedFailures.map(f => f.serverName ?? f.serverId).join(", ")}. Для этих устройств сохранены последние данные; текущее состояние туннелей неизвестно.</p>
|
||
<Button variant="outline" size="sm" disabled={loading} onClick={() => void loadLive()}>Повторить опрос</Button>
|
||
</ContextDescription>
|
||
</ContextAlert>}
|
||
{selectedTunnelId !== null && (
|
||
<ContextAlert variant={liveError || ((!isLive || !loading) && !selectedTunnel) ? "warning" : "info"} role="status" aria-label="VXLAN по ссылке">
|
||
<ContextTitle className="line-clamp-none wrap-anywhere">{selectedTunnel ? "Выбран VXLAN: " + selectedTunnel.name : "VXLAN по ссылке"}</ContextTitle>
|
||
<ContextDescription>
|
||
<p className="wrap-anywhere">{isLive && loading ? "Загрузка туннеля…" : isLive && liveError ? "Не удалось обновить VXLAN. Повторите обновление; сохранённые данные могут быть устаревшими." : selectedTunnel ? (serverById[selectedServerId]?.name ?? selectedServerId) + " · VNI " + selectedTunnel.vni + ". Список и сводка ограничены выбранным туннелем." : "Туннель не получен для указанного сервера. Проверьте ссылку и доступ к устройству: туннель может отсутствовать или устройство не ответило на опрос."}</p>
|
||
<div className="flex flex-wrap gap-4">
|
||
<Link href={allTunnelsHref} className="underline underline-offset-2">Все туннели сервера</Link>
|
||
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
||
</div>
|
||
</ContextDescription>
|
||
</ContextAlert>
|
||
)}
|
||
|
||
|
||
{isLive && liveError && selectedTunnelId === null && (
|
||
<Alert variant="warning" className="py-2">
|
||
<AlertCircleIcon />
|
||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{isLive && <div role="group" aria-label="Свежесть VXLAN" className="text-xs text-muted-foreground">
|
||
{effectiveServerId === ALL_SERVERS_ID ? "Последний ответ получен в приложении: " : "Ответ устройства получен в приложении: "}
|
||
{(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]) ? <time dateTime={(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!} title="Клиентское время ответа, не время измерения RouterOS">{new Date((effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId])!).toLocaleString("ru-RU")}</time> : "неизвестно"}
|
||
</div>}
|
||
{isLive && loading && <p role="status" className="text-sm text-muted-foreground">{hasLoaded ? "Обновление VXLAN…" : "Загрузка VXLAN…"}</p>}
|
||
{isLive && liveError && hasLoaded && <p className="text-sm text-muted-foreground">Показаны последние полученные данные; они могут быть устаревшими.</p>}
|
||
{selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && scopedFailures.length === 0 && (
|
||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||
На опрошенных серверах нет VXLAN-интерфейсов
|
||
</div>
|
||
)}
|
||
{mode === "mock" && (
|
||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||
Моковые данные
|
||
</span>
|
||
)}
|
||
|
||
<KpiStatGrid
|
||
aria-label="Сводка VXLAN"
|
||
items={[
|
||
{
|
||
id: "tunnels",
|
||
label: "Туннелей",
|
||
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
|
||
icon: <NetworkIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "up",
|
||
label: "Активных",
|
||
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : upCount,
|
||
icon: <LayersIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "vni",
|
||
label: "Уникальных VNI",
|
||
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis,
|
||
icon: <LayersIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
{
|
||
id: "servers",
|
||
label: "Серверов",
|
||
value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size,
|
||
icon: <NetworkIcon className="size-4" />,
|
||
iconClassName: "text-primary",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="font-medium text-sky-600 dark:text-sky-400">VXLAN — L2-over-L3 оверлей для RouterOS 7.x</p>
|
||
<p className="text-muted-foreground text-xs mt-0.5">
|
||
Доступен с RouterOS 7.1+. VNI (Virtual Network Identifier) — уникальный идентификатор сегмента (0–16777215).
|
||
Рекомендуется использовать совместно с WireGuard или GRE туннелями для шифрования.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<DataPageCard>
|
||
<DataPageToolbar
|
||
search={search}
|
||
onSearchChange={setSearch}
|
||
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
||
countLabel={isLive && !hasLoaded ? "Туннели пока неизвестны" : `${filtered.length} туннелей`}
|
||
/>
|
||
{(!isLive || hasLoaded) && <VxlanDataGrid
|
||
tunnels={filtered}
|
||
failedServerIds={failedServerIds}
|
||
servers={displayServers}
|
||
onExport={tunnel => setExportSnapshot({ tunnel, servers: serverById, at: isLive ? readReceipt[tunnel.serverId] ?? null : null, stale: isLive && (!!liveError || failedServerIds.includes(tunnel.serverId)), mock: !isLive })}
|
||
/>}
|
||
</DataPageCard>
|
||
|
||
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||
{[
|
||
{
|
||
title: "Создать VXLAN",
|
||
lines: [
|
||
"/interface/vxlan/add \\",
|
||
" name=vxlan-10 \\",
|
||
" vni=10010 \\",
|
||
" port=8472 \\",
|
||
" arp-proxy=yes \\",
|
||
" mac-learning=yes",
|
||
],
|
||
},
|
||
{
|
||
title: "Добавить VTEP",
|
||
lines: [
|
||
"/interface/vxlan/vteps/add \\",
|
||
" interface=vxlan-10 \\",
|
||
" remote-ip=10.0.1.1",
|
||
"",
|
||
"/interface/vxlan/vteps/add \\",
|
||
" interface=vxlan-10 \\",
|
||
" remote-ip=10.0.2.1",
|
||
],
|
||
},
|
||
{
|
||
title: "Bridge + IP",
|
||
lines: [
|
||
"/interface/bridge/add \\",
|
||
" name=br-overlay",
|
||
"",
|
||
"/interface/bridge/port/add \\",
|
||
" bridge=br-overlay \\",
|
||
" interface=vxlan-10",
|
||
"",
|
||
"/ip/address/add \\",
|
||
" address=10.100.0.1/24 \\",
|
||
" interface=br-overlay",
|
||
],
|
||
},
|
||
].map((b) => (
|
||
<div key={b.title}>
|
||
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
|
||
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
|
||
{b.lines.join("\n")}
|
||
</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</OpsPanel>
|
||
|
||
</div>
|
||
</ServerRailLayout>
|
||
|
||
<ExportSheet
|
||
open={!!exportSnapshot}
|
||
tunnel={exportSnapshot?.tunnel ?? null}
|
||
onClose={() => 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 <VxlanPageContent key={JSON.stringify([serverId, tunnelId, mode, backendUrl])} selectedServerId={serverId} selectedTunnelId={tunnelId} allTunnelsHref={allTunnelsHref} onSelectServer={id => {
|
||
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 <Suspense fallback={<div role="status">Загрузка VXLAN…</div>}><VxlanRoute /></Suspense>
|
||
}
|