Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell. Co-authored-by: Cursor <cursoragent@cursor.com>
273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { vxlanTunnels, servers } from "@/lib/data"
|
||
import type { VxlanTunnel } from "@/lib/data"
|
||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||
import { IconTile } from "@/components/reui/icon-tile"
|
||
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 { cn } from "@/lib/utils"
|
||
import {
|
||
NetworkIcon, PlusIcon, CopyIcon, CheckIcon,
|
||
CodeXmlIcon, LayersIcon,
|
||
} from "lucide-react"
|
||
import {
|
||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||
SheetDescription, SheetFooter, SheetClose,
|
||
} from "@/components/ui/sheet"
|
||
|
||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function serverFor(id: string) {
|
||
return servers.find((s) => s.id === id)
|
||
}
|
||
|
||
// ─── RSC generator ───────────────────────────────────────────────────────────
|
||
|
||
function generateVxlanRsc(t: VxlanTunnel): string {
|
||
const srv = serverFor(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(``)
|
||
|
||
// FDB entries for remote VTEPs
|
||
for (const vtep of t.remoteVteps) {
|
||
lines.push(`/interface/vxlan/vteps/add \\`)
|
||
lines.push(` interface=${t.name} \\`)
|
||
lines.push(` remote-ip=${vtep}`)
|
||
lines.push(``)
|
||
}
|
||
|
||
// Bridge
|
||
lines.push(`# Добавить в bridge:`)
|
||
lines.push(`/interface/bridge/port/add \\`)
|
||
lines.push(` bridge=bridge-overlay \\`)
|
||
lines.push(` interface=${t.name}`)
|
||
|
||
return lines.join("\n")
|
||
}
|
||
|
||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||
|
||
function ExportSheet({ open, tunnel, onClose }: {
|
||
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
||
}) {
|
||
const [copied, setCopied] = useState(false)
|
||
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
||
|
||
function handleCopy() {
|
||
navigator.clipboard.writeText(code).then(() => {
|
||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||
})
|
||
}
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<SheetTitle>Экспорт VXLAN</SheetTitle>
|
||
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
|
||
</div>
|
||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
||
</Button>
|
||
</div>
|
||
</SheetHeader>
|
||
<div className="flex-1 overflow-y-auto">
|
||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
||
{code.split("\n").map((line, i) => {
|
||
const isComment = line.startsWith("#")
|
||
const isCmd = /^\//.test(line.trimStart())
|
||
const isParam = /^\s+[a-z]/.test(line)
|
||
return (
|
||
<span key={i} className={
|
||
isComment ? "text-muted-foreground"
|
||
: isCmd ? "text-sky-400"
|
||
: isParam ? "text-violet-300"
|
||
: "text-foreground"
|
||
}>
|
||
{line}{"\n"}
|
||
</span>
|
||
)
|
||
})}
|
||
</pre>
|
||
</div>
|
||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
||
<Button className="flex-1" onClick={handleCopy}>
|
||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
||
{copied ? "Скопировано" : "Копировать .rsc"}
|
||
</Button>
|
||
</SheetFooter>
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|
||
|
||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
||
export default function VxlanPage() {
|
||
const [search, setSearch] = useState("")
|
||
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!search) return vxlanTunnels
|
||
const q = search.toLowerCase()
|
||
return vxlanTunnels.filter((t) =>
|
||
t.name.includes(q) ||
|
||
String(t.vni).includes(q) ||
|
||
t.vtepIp.includes(q) ||
|
||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false)
|
||
)
|
||
}, [search])
|
||
|
||
const upCount = vxlanTunnels.filter((t) => t.status === "up").length
|
||
const vnis = new Set(vxlanTunnels.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">
|
||
<div className="flex flex-col gap-5">
|
||
|
||
{/* KPI */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{[
|
||
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
|
||
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
|
||
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
|
||
].map((s) => (
|
||
<Frame key={s.label} className="h-full">
|
||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||
{s.icon}
|
||
</IconTile>
|
||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
||
</div>
|
||
</FramePanel>
|
||
</Frame>
|
||
))}
|
||
</div>
|
||
|
||
{/* 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>
|
||
<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>
|
||
|
||
{/* Table */}
|
||
<DataPageCard>
|
||
<DataPageToolbar
|
||
search={search}
|
||
onSearchChange={setSearch}
|
||
searchPlaceholder="Поиск по имени, VNI, серверу…"
|
||
countLabel={`${filtered.length} туннелей`}
|
||
/>
|
||
<VxlanDataGrid
|
||
tunnels={filtered}
|
||
servers={servers}
|
||
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">
|
||
{[
|
||
{
|
||
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>
|
||
</div>
|
||
|
||
<ExportSheet
|
||
open={!!exportTunnel}
|
||
tunnel={exportTunnel}
|
||
onClose={() => setExportTunnel(null)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|