feat(bgp, vxlan, ospf): enhance server data handling and introduce new routes
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
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]>
This commit is contained in:
+190
-45
@@ -1,30 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { vxlanTunnels, servers } from "@/lib/data"
|
||||
import type { VxlanTunnel } from "@/lib/data"
|
||||
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, CodeXmlIcon, LayersIcon,
|
||||
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"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function serverFor(id: string) {
|
||||
return servers.find((s) => s.id === id)
|
||||
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
|
||||
}
|
||||
|
||||
// ─── RSC generator ───────────────────────────────────────────────────────────
|
||||
interface VxlanApiResponse {
|
||||
tunnels: VxlanTunnel[]
|
||||
}
|
||||
|
||||
function generateVxlanRsc(t: VxlanTunnel): string {
|
||||
const srv = serverFor(t.serverId)
|
||||
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})`)
|
||||
@@ -42,7 +75,6 @@ function generateVxlanRsc(t: VxlanTunnel): string {
|
||||
if (!t.enabled) lines.push(` disabled=yes \\`)
|
||||
lines.push(``)
|
||||
|
||||
// FDB entries for remote VTEPs
|
||||
for (const vtep of t.remoteVteps) {
|
||||
lines.push(`/interface/vxlan/vteps/add \\`)
|
||||
lines.push(` interface=${t.name} \\`)
|
||||
@@ -50,7 +82,6 @@ function generateVxlanRsc(t: VxlanTunnel): string {
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
// Bridge
|
||||
lines.push(`# Добавить в bridge:`)
|
||||
lines.push(`/interface/bridge/port/add \\`)
|
||||
lines.push(` bridge=bridge-overlay \\`)
|
||||
@@ -59,12 +90,18 @@ function generateVxlanRsc(t: VxlanTunnel): string {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ExportSheet({ open, tunnel, onClose }: {
|
||||
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
||||
function ExportSheet({
|
||||
open, tunnel, onClose, serverById,
|
||||
}: {
|
||||
open: boolean
|
||||
tunnel: VxlanTunnel | null
|
||||
onClose: () => void
|
||||
serverById: Record<string, Server>
|
||||
}) {
|
||||
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
||||
const code = useMemo(
|
||||
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
|
||||
[tunnel, serverById],
|
||||
)
|
||||
|
||||
return (
|
||||
<CodeExportSheet
|
||||
@@ -84,46 +121,156 @@ function ExportSheet({ open, tunnel, onClose }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||||
export default function VxlanPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
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 vxlanTunnels
|
||||
if (!search) return scopedTunnels
|
||||
const q = search.toLowerCase()
|
||||
return vxlanTunnels.filter((t) =>
|
||||
return scopedTunnels.filter((t) =>
|
||||
t.name.includes(q) ||
|
||||
String(t.vni).includes(q) ||
|
||||
t.vtepIp.includes(q) ||
|
||||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false)
|
||||
(serverById[t.serverId]?.name.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
}, [search])
|
||||
}, [search, scopedTunnels, serverById])
|
||||
|
||||
const upCount = vxlanTunnels.filter((t) => t.status === "up").length
|
||||
const vnis = new Set(vxlanTunnels.map((t) => t.vni)).size
|
||||
const upCount = scopedTunnels.filter((t) => t.status === "up").length
|
||||
const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" />Новый VXLAN
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<>
|
||||
<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: vxlanTunnels.length,
|
||||
value: scopedTunnels.length,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
@@ -144,14 +291,13 @@ export default function VxlanPage() {
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверов",
|
||||
value: new Set(vxlanTunnels.map((t) => t.serverId)).size,
|
||||
value: new Set(scopedTunnels.map((t) => t.serverId)).size,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Info banner */}
|
||||
<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>
|
||||
@@ -163,7 +309,6 @@ export default function VxlanPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
search={search}
|
||||
@@ -173,12 +318,11 @@ export default function VxlanPage() {
|
||||
/>
|
||||
<VxlanDataGrid
|
||||
tunnels={filtered}
|
||||
servers={servers}
|
||||
servers={displayServers}
|
||||
onExport={setExportTunnel}
|
||||
/>
|
||||
</DataPageCard>
|
||||
|
||||
{/* Reference */}
|
||||
<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">
|
||||
{[
|
||||
@@ -232,13 +376,14 @@ export default function VxlanPage() {
|
||||
</OpsPanel>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<ExportSheet
|
||||
open={!!exportTunnel}
|
||||
tunnel={exportTunnel}
|
||||
onClose={() => setExportTunnel(null)}
|
||||
serverById={serverById}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user