Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m50s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Added support for fetching and displaying server data in BGP, VXLAN, and OSPF pages, improving the overall user experience. - Introduced new backend routes for OSPF and VXLAN, allowing for better data management and retrieval. - Implemented mapping functions for backend server data to frontend types, ensuring consistency across components. - Enhanced the sidebar to display counts for BGP sessions, VXLAN tunnels, and containers, providing users with quick insights into their network status. - Updated tests to cover new functionalities and ensure reliability. Co-authored-by: Cursor <[email protected]>
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||
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 { Button } from "@/components/ui/button"
|
||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||
import {
|
||
NetworkIcon, PlusIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
|
||
} from "lucide-react"
|
||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
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 {
|
||
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,
|
||
}: {
|
||
open: boolean
|
||
tunnel: VxlanTunnel | null
|
||
onClose: () => void
|
||
serverById: Record<string, Server>
|
||
}) {
|
||
const code = useMemo(
|
||
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
|
||
[tunnel, serverById],
|
||
)
|
||
|
||
return (
|
||
<CodeExportSheet
|
||
open={open}
|
||
onClose={onClose}
|
||
title="Экспорт VXLAN"
|
||
description="RouterOS 7.x · /interface/vxlan + vteps"
|
||
formats={[
|
||
{
|
||
id: "rsc",
|
||
label: "MikroTik .rsc",
|
||
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
|
||
code,
|
||
},
|
||
]}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export default function VxlanPage() {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const isLive = mode === "live"
|
||
|
||
const [search, setSearch] = useState("")
|
||
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||
|
||
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
|
||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [liveError, setLiveError] = useState<string | null>(null)
|
||
|
||
const loadLive = useCallback(async () => {
|
||
if (!isLive) return
|
||
setLoading(true)
|
||
setLiveError(null)
|
||
try {
|
||
const [tunnelsRes, serversRes] = await Promise.all([
|
||
requestJson<VxlanApiResponse>(backendUrl, "/api/vxlan"),
|
||
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||
])
|
||
setLiveTunnels(tunnelsRes.tunnels ?? [])
|
||
setLiveServers(serversRes.filter((s) => s.enabled).map(mapBackendServer))
|
||
} catch (e) {
|
||
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||
setLiveTunnels([])
|
||
} finally {
|
||
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 === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||
? selectedServerId
|
||
: ALL_SERVERS_ID
|
||
|
||
const scopedTunnels = useMemo(() => {
|
||
if (effectiveServerId === ALL_SERVERS_ID) return displayTunnels
|
||
return displayTunnels.filter((t) => t.serverId === effectiveServerId)
|
||
}, [displayTunnels, effectiveServerId])
|
||
|
||
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 upCount = scopedTunnels.filter((t) => t.status === "up").length
|
||
const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
|
||
|
||
return (
|
||
<>
|
||
<ServerRailLayout
|
||
items={railItems}
|
||
selectedId={effectiveServerId}
|
||
onSelect={setSelectedServerId}
|
||
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>
|
||
<Button size="sm">
|
||
<PlusIcon className="size-4" />Новый VXLAN
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-5">
|
||
|
||
{isLive && liveError && (
|
||
<Alert variant="warning" className="py-2">
|
||
<AlertCircleIcon />
|
||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{isLive && !loading && displayTunnels.length === 0 && !liveError && (
|
||
<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: scopedTunnels.length,
|
||
icon: <NetworkIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "up",
|
||
label: "Активных",
|
||
value: upCount,
|
||
icon: <LayersIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "vni",
|
||
label: "Уникальных VNI",
|
||
value: vnis,
|
||
icon: <LayersIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
{
|
||
id: "servers",
|
||
label: "Серверов",
|
||
value: 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={`${filtered.length} туннелей`}
|
||
/>
|
||
<VxlanDataGrid
|
||
tunnels={filtered}
|
||
servers={displayServers}
|
||
onExport={setExportTunnel}
|
||
/>
|
||
</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={!!exportTunnel}
|
||
tunnel={exportTunnel}
|
||
onClose={() => setExportTunnel(null)}
|
||
serverById={serverById}
|
||
/>
|
||
</>
|
||
)
|
||
}
|