Compare commits

...
5 Commits
Author SHA1 Message Date
SerinaNya beb826e7d6 💚 fix ci: add ACTIONS_RESULTS_URL for actions/upload-artifact@v4
Build Dashboard / build (push) Successful in 5m24s
2026-09-03 15:54:46 +08:00
SerinaNya f2dae09b6b 👷 ci: add build workflow
Build Dashboard / build (push) Failing after 6m49s
2026-09-01 15:02:38 +08:00
SerinaNya 885a0c2d89 fix(routing-table): preserve SRv6 protocol types and colors
- Type route protocol and flag fields as strings
- Distinguish Direct, SRv6 ENDXSID, and SRv6 ENDSID routes
- Add dynamic protocol filtering and protocol-specific counters
- Use distinct violet and emerald badges for ENDXSID and ENDSID
2026-08-31 14:40:33 +08:00
SerinaNya 89474b9e91 feat(dashboard)!: add routing table and split endpoint settings
- Add #/routing-table with one-second polling and route filters
- Separate auto-connect targets from local external endpoints
- Remove the obsolete interfaces navigation module

BREAKING CHANGE: #/interfaces is removed.
2026-08-27 23:19:39 +08:00
SerinaNya ad94d2c274 feat(topology): add device description support and polish UI layouts
- Topology: query /api/node-info on node select and display multi-line description
- Topology: widen device node cards and remove SID truncation for full display
- Topology: tune d3-force layout parameters (increased distance & repulsion)
- Topology: fix d3-force string ID matching error ("node not found: 0")
- Settings: switch device description field from Input to multi-line Textarea
- Overview: fix active lines count logic and broken badge layout
- Sidebar: remove redundant Live badge from overview navigation item
2026-08-26 21:27:44 +08:00
13 changed files with 589 additions and 189 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Build Dashboard
on:
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 11
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build dashboard
run: pnpm build
- name: Upload dashboard artifact
uses: actions/upload-artifact@v4
env:
ACTIONS_RESULTS_URL: https://git.code.cq.cn/
with:
name: dashboard-dist
path: dist/
if-no-files-found: error
retention-days: 14
+5 -2
View File
@@ -8,6 +8,7 @@ import { OverviewPage } from '@/pages/overview'
import { ConnectionsPage } from '@/pages/connections' import { ConnectionsPage } from '@/pages/connections'
import { SettingsPage } from '@/pages/settings' import { SettingsPage } from '@/pages/settings'
import { TopologyPage } from '@/pages/topology' import { TopologyPage } from '@/pages/topology'
import { RoutingTablePage } from '@/pages/routing-table'
import { useKlalbSSE } from '@/hooks/use-klalb-sse' import { useKlalbSSE } from '@/hooks/use-klalb-sse'
import { useHashRoute } from '@/hooks/use-hash-route' import { useHashRoute } from '@/hooks/use-hash-route'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -39,7 +40,6 @@ export function App() {
<AppSidebar <AppSidebar
currentTab={currentTab} currentTab={currentTab}
onSelectTab={navigate} onSelectTab={navigate}
onlineDevices={status?.onlineDevices}
linksCount={safeLinks.length} linksCount={safeLinks.length}
establishedLinksCount={establishedLinksCount} establishedLinksCount={establishedLinksCount}
isConnected={isConnected} isConnected={isConnected}
@@ -75,10 +75,13 @@ export function App() {
{currentTab === 'topology' && <TopologyPage />} {currentTab === 'topology' && <TopologyPage />}
{currentTab === 'routing-table' && <RoutingTablePage />}
{currentTab !== 'overview' && {currentTab !== 'overview' &&
currentTab !== 'connections' && currentTab !== 'connections' &&
currentTab !== 'settings' && currentTab !== 'settings' &&
currentTab !== 'topology' && ( currentTab !== 'topology' &&
currentTab !== 'routing-table' && (
<div className="flex flex-1 items-center justify-center p-8"> <div className="flex flex-1 items-center justify-center p-8">
<Card className="max-w-md text-center"> <Card className="max-w-md text-center">
<CardHeader> <CardHeader>
+2 -13
View File
@@ -3,7 +3,6 @@ import {
Network, Network,
GitFork, GitFork,
Share2, Share2,
Cpu,
Settings, Settings,
Activity, Activity,
Layers, Layers,
@@ -24,12 +23,11 @@ import {
} from '@/components/ui/sidebar' } from '@/components/ui/sidebar'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
export type NavTab = 'overview' | 'connections' | 'routes' | 'topology' | 'interfaces' | 'settings' export type NavTab = 'overview' | 'connections' | 'routing-table' | 'topology' | 'settings'
interface AppSidebarProps { interface AppSidebarProps {
currentTab: NavTab currentTab: NavTab
onSelectTab: (tab: NavTab) => void onSelectTab: (tab: NavTab) => void
onlineDevices?: number
linksCount?: number linksCount?: number
establishedLinksCount?: number establishedLinksCount?: number
isConnected?: boolean isConnected?: boolean
@@ -38,7 +36,6 @@ interface AppSidebarProps {
export function AppSidebar({ export function AppSidebar({
currentTab, currentTab,
onSelectTab, onSelectTab,
onlineDevices = 0,
linksCount = 0, linksCount = 0,
establishedLinksCount = 0, establishedLinksCount = 0,
isConnected = false, isConnected = false,
@@ -48,7 +45,6 @@ export function AppSidebar({
id: 'overview' as NavTab, id: 'overview' as NavTab,
title: '总览', title: '总览',
icon: LayoutDashboard, icon: LayoutDashboard,
badge: isConnected ? 'Live' : undefined,
}, },
{ {
id: 'connections' as NavTab, id: 'connections' as NavTab,
@@ -57,7 +53,7 @@ export function AppSidebar({
badge: linksCount > 0 ? `${establishedLinksCount}/${linksCount}` : undefined, badge: linksCount > 0 ? `${establishedLinksCount}/${linksCount}` : undefined,
}, },
{ {
id: 'routes' as NavTab, id: 'routing-table' as NavTab,
title: 'SRv6 路由表', title: 'SRv6 路由表',
icon: GitFork, icon: GitFork,
}, },
@@ -65,12 +61,6 @@ export function AppSidebar({
id: 'topology' as NavTab, id: 'topology' as NavTab,
title: '网络拓扑', title: '网络拓扑',
icon: Share2, icon: Share2,
badge: onlineDevices > 0 ? `${onlineDevices}` : undefined,
},
{
id: 'interfaces' as NavTab,
title: '网卡接口',
icon: Cpu,
}, },
{ {
id: 'settings' as NavTab, id: 'settings' as NavTab,
@@ -161,4 +151,3 @@ export function AppSidebar({
</Sidebar> </Sidebar>
) )
} }
+3 -3
View File
@@ -22,7 +22,7 @@ function DeviceNodeComponent({ data, selected }: NodeProps<Node<DeviceNodeData>>
return ( return (
<div <div
className={cn( className={cn(
'group relative flex min-w-44 cursor-pointer flex-col gap-1 rounded-xl border bg-card px-3 py-2.5 shadow-sm transition-all', 'group relative flex w-fit min-w-56 cursor-pointer flex-col gap-1 rounded-xl border bg-card px-3 py-2.5 shadow-sm transition-all',
'hover:border-primary/50 hover:shadow-md', 'hover:border-primary/50 hover:shadow-md',
selected && 'border-primary ring-2 ring-primary/30', selected && 'border-primary ring-2 ring-primary/30',
isSelf && isSelf &&
@@ -48,7 +48,7 @@ function DeviceNodeComponent({ data, selected }: NodeProps<Node<DeviceNodeData>>
<div className="flex min-w-0 flex-col leading-tight"> <div className="flex min-w-0 flex-col leading-tight">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="max-w-36 truncate text-xs font-semibold text-foreground"> <span className="max-w-60 truncate text-xs font-semibold text-foreground">
{deviceName || '未命名节点'} {deviceName || '未命名节点'}
</span> </span>
{isSelf && ( {isSelf && (
@@ -66,7 +66,7 @@ function DeviceNodeComponent({ data, selected }: NodeProps<Node<DeviceNodeData>>
className="flex items-center gap-0.5 font-mono text-[10px] text-muted-foreground hover:text-foreground" className="flex items-center gap-0.5 font-mono text-[10px] text-muted-foreground hover:text-foreground"
title="点击复制完整地址" title="点击复制完整地址"
> >
<span className="max-w-32 truncate">{compressedAddress || address}</span> <span className="whitespace-nowrap">{compressedAddress || address}</span>
{isCopied ? ( {isCopied ? (
<Check className="size-2.5 shrink-0 text-emerald-500" /> <Check className="size-2.5 shrink-0 text-emerald-500" />
) : ( ) : (
@@ -5,10 +5,12 @@ import {
Gauge, Gauge,
Network, Network,
Clock3, Clock3,
FileText,
} from 'lucide-react' } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -40,6 +42,9 @@ interface NodeDetailSheetProps {
neighbors: SheetNeighborInfo[] neighbors: SheetNeighborInfo[]
copiedId: string | null copiedId: string | null
onCopy: (text: string) => void onCopy: (text: string) => void
/** 设备描述(来自 /api/node-info 查询) */
description?: string | null
descriptionLoading?: boolean
} }
export function NodeDetailSheet({ export function NodeDetailSheet({
@@ -49,6 +54,8 @@ export function NodeDetailSheet({
neighbors, neighbors,
copiedId, copiedId,
onCopy, onCopy,
description,
descriptionLoading,
}: NodeDetailSheetProps) { }: NodeDetailSheetProps) {
if (!node) return null if (!node) return null
@@ -85,6 +92,24 @@ export function NodeDetailSheet({
</SheetHeader> </SheetHeader>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"> <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
{/* Device Description */}
<div className="flex flex-col gap-1.5 rounded-lg border bg-muted/30 p-3">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<FileText className="size-3.5" />
</span>
{descriptionLoading ? (
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-2/3" />
</div>
) : (
<span className="whitespace-pre-wrap break-words text-xs leading-relaxed text-foreground">
{description ? description : '无描述信息'}
</span>
)}
</div>
{/* Full Address */} {/* Full Address */}
<div className="flex flex-col gap-1.5 rounded-lg border bg-muted/30 p-3"> <div className="flex flex-col gap-1.5 rounded-lg border bg-muted/30 p-3">
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
+1 -2
View File
@@ -4,9 +4,8 @@ import type { NavTab } from '@/components/layout/app-sidebar'
const VALID_TABS: NavTab[] = [ const VALID_TABS: NavTab[] = [
'overview', 'overview',
'connections', 'connections',
'routes', 'routing-table',
'topology', 'topology',
'interfaces',
'settings', 'settings',
] ]
+47
View File
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { RoutingTableItem } from '@/types/api'
const POLL_INTERVAL_MS = 1000
interface UseRoutingTableResult {
routingTable: RoutingTableItem[]
isLoading: boolean
error: string | null
refresh: () => void
}
export function useRoutingTable(): UseRoutingTableResult {
const [routingTable, setRoutingTable] = useState<RoutingTableItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const mountedRef = useRef(true)
const fetchRoutingTable = useCallback(async () => {
try {
const response = await fetch('/api/routing-table')
if (!response.ok) throw new Error(`路由接口异常 (${response.status})`)
const payload: unknown = await response.json()
if (!Array.isArray(payload)) throw new Error('路由接口返回格式异常')
if (!mountedRef.current) return
setRoutingTable(payload as RoutingTableItem[])
setError(null)
} catch (err: unknown) {
if (mountedRef.current) setError(err instanceof Error ? err.message : String(err))
} finally {
if (mountedRef.current) setIsLoading(false)
}
}, [])
useEffect(() => {
mountedRef.current = true
void fetchRoutingTable()
const timer = window.setInterval(() => void fetchRoutingTable(), POLL_INTERVAL_MS)
return () => {
mountedRef.current = false
window.clearInterval(timer)
}
}, [fetchRoutingTable])
const refresh = useCallback(() => void fetchRoutingTable(), [fetchRoutingTable])
return { routingTable, isLoading, error, refresh }
}
+13 -10
View File
@@ -49,28 +49,31 @@ function computeForceLayout(
const positions = new Map<string, { x: number; y: number }>() const positions = new Map<string, { x: number; y: number }>()
if (topoNodes.length === 0) return positions if (topoNodes.length === 0) return positions
// 节点卡片较宽(~300px),初始环形半径与斥力需足够大以避免重叠
const simNodes: LayoutNode[] = topoNodes.map((n, i) => ({ const simNodes: LayoutNode[] = topoNodes.map((n, i) => ({
...n, ...n,
// 初始位置:环形分布避免重叠 x: Math.cos((2 * Math.PI * i) / Math.max(topoNodes.length, 1)) * 450,
x: Math.cos((2 * Math.PI * i) / Math.max(topoNodes.length, 1)) * 250, y: Math.sin((2 * Math.PI * i) / Math.max(topoNodes.length, 1)) * 450,
y: Math.sin((2 * Math.PI * i) / Math.max(topoNodes.length, 1)) * 250,
})) }))
const nodeIndex = new Map(simNodes.map((n, i) => [n.id, i])) const nodeIds = new Set(simNodes.map((n) => n.id))
const simLinks = topoEdges const simLinks = topoEdges
.filter((e) => nodeIndex.has(e.source) && nodeIndex.has(e.target)) .filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target))
.map((e) => { .map((e) => {
const cost = e.cost > 0 ? e.cost : e.delay const cost = e.cost > 0 ? e.cost : e.delay
// 延迟越高距离越远;基准距离 120px,按 ns 缩放 // 延迟越高距离越远;基准距离大于卡片宽度,保证相邻节点不挤压
const distance = Math.max(80, Math.min(400, 120 + Math.log10(Math.max(cost, 1)) * 20)) const distance = Math.max(280, Math.min(650, 300 + Math.log10(Math.max(cost, 1)) * 35))
return { source: nodeIndex.get(e.source)!, target: nodeIndex.get(e.target)!, distance } return { source: e.source, target: e.target, distance }
}) })
const simulation = forceSimulation(simNodes) const simulation = forceSimulation(simNodes)
.force('charge', forceManyBody().strength(-600)) .force('charge', forceManyBody().strength(-2200))
.force( .force(
'link', 'link',
forceLink(simLinks).id((d: SimulationNodeDatum) => (d as LayoutNode).id).distance((l: { distance: number }) => l.distance).strength(0.6) forceLink(simLinks)
.id((d: SimulationNodeDatum) => (d as LayoutNode).id)
.distance((l: any) => l.distance ?? 300)
.strength(0.5)
) )
.force('center', forceCenter(0, 0)) .force('center', forceCenter(0, 0))
.stop() .stop()
+5 -3
View File
@@ -52,9 +52,9 @@ export function OverviewPage({ status, links, isConnected }: OverviewPageProps)
const metrics = status?.metrics const metrics = status?.metrics
// Calculate link status counts // Calculate link status counts (LinkStatus: DOWN=0, UNSTABLE=1, UP=2; state string like "●up"/"○down")
const establishedLinks = links.filter( const establishedLinks = links.filter(
(l) => l.state === 'ESTABLISHED' || l.stateCode === 1 (l) => l.stateCode === 2 || (l.state && l.state.toLowerCase().includes('up')) || l.state === 'ESTABLISHED'
).length ).length
const totalLinks = links.length const totalLinks = links.length
@@ -142,7 +142,8 @@ export function OverviewPage({ status, links, isConnected }: OverviewPageProps)
</div> </div>
<div className="flex flex-col gap-0.5 rounded-xl border bg-background/60 px-3 py-1.5 shadow-2xs"> <div className="flex flex-col gap-0.5 rounded-xl border bg-background/60 px-3 py-1.5 shadow-2xs">
<Zap className="size-3.5 text-muted-foreground" /> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Zap className="size-3.5" />
<span>线</span> <span>线</span>
</div> </div>
<span className="font-mono text-sm font-bold text-foreground"> <span className="font-mono text-sm font-bold text-foreground">
@@ -151,6 +152,7 @@ export function OverviewPage({ status, links, isConnected }: OverviewPageProps)
</div> </div>
</div> </div>
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
+282
View File
@@ -0,0 +1,282 @@
import { useMemo, useState } from "react"
import { Check, Copy, GitFork, RefreshCw, Search } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useRoutingTable } from "@/hooks/use-routing-table"
import type { RoutingTableItem } from "@/types/api"
type ProtocolFilter = "all" | string
function protocolBadge(protocol: RoutingTableItem["protocol"]) {
if (protocol === "Direct")
return (
<Badge
variant="secondary"
className="bg-blue-500/10 text-blue-600 dark:text-blue-400"
>
{protocol}
</Badge>
)
if (protocol === "SRv6 ENDXSID")
return (
<Badge
variant="secondary"
className="bg-purple-500/10 text-purple-600 dark:text-purple-400"
>
{protocol}
</Badge>
)
if (protocol === "SRv6 ENDSID")
return (
<Badge
variant="secondary"
className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
>
{protocol}
</Badge>
)
return <Badge variant="outline">{protocol}</Badge>
}
export function RoutingTablePage() {
const { routingTable, isLoading, error, refresh } = useRoutingTable()
const [searchQuery, setSearchQuery] = useState("")
const [protocolFilter, setProtocolFilter] = useState<ProtocolFilter>("all")
const [copiedHop, setCopiedHop] = useState<string | null>(null)
const counts = useMemo(
() => ({
total: routingTable.length,
direct: routingTable.filter((route) => route.protocol === "Direct").length,
endxsid: routingTable.filter((route) => route.protocol === "SRv6 ENDXSID").length,
endsid: routingTable.filter((route) => route.protocol === "SRv6 ENDSID").length,
}),
[routingTable]
)
const filteredRoutingTable = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
return routingTable.filter((route) => {
const matchesSearch =
!query ||
[
route.destination,
route.nexthop,
route.interface,
route.protocol,
].some((value) => String(value).toLowerCase().includes(query))
const matchesProtocol =
protocolFilter === "all" ||
route.protocol === protocolFilter
return matchesSearch && matchesProtocol
})
}, [routingTable, searchQuery, protocolFilter])
const copyNextHop = async (nextHop: string) => {
try {
await navigator.clipboard.writeText(nextHop)
setCopiedHop(nextHop)
window.setTimeout(
() => setCopiedHop((current) => (current === nextHop ? null : current)),
1500
)
} catch {
setCopiedHop(null)
}
}
const filterOptions: { value: ProtocolFilter; label: string }[] = [
{ value: "all", label: "全部" },
...Array.from(new Set(routingTable.map((route) => route.protocol))).map(
(protocol) => ({ value: protocol, label: protocol })
),
]
return (
<div className="flex flex-1 flex-col gap-5 p-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
<GitFork className="size-5" />
</div>
<div>
<h1 className="text-xl font-bold tracking-tight">SRv6 </h1>
<p className="text-xs text-muted-foreground">
IPv6
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 sm:ml-13 lg:ml-0">
<Badge variant="outline"> {counts.total}</Badge>
<Badge
variant="secondary"
className="bg-blue-500/10 text-blue-600 dark:text-blue-400"
>
{counts.direct}
</Badge>
<Badge
variant="secondary"
className="bg-purple-500/10 text-purple-600 dark:text-purple-400"
>
SRv6 ENDXSID {counts.endxsid}
</Badge>
<Badge
variant="secondary"
className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
>
SRv6 ENDSID {counts.endsid}
</Badge>
</div>
</div>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="relative w-full max-w-sm">
<Search className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="搜索目的地址、下一跳、接口或协议"
className="pl-8"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center rounded-lg border bg-background p-0.5 shadow-2xs">
{filterOptions.map((option) => (
<Button
key={option.value}
variant={
protocolFilter === option.value ? "secondary" : "ghost"
}
size="sm"
onClick={() => setProtocolFilter(option.value)}
>
{option.label}
</Button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={isLoading}
title="刷新路由表"
>
<RefreshCw
data-icon="inline-start"
className={isLoading ? "animate-spin" : ""}
/>
</Button>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>
{error}
</AlertDescription>
</Alert>
)}
{isLoading && routingTable.length === 0 ? (
<div className="border-y py-12 text-center text-sm text-muted-foreground">
...
</div>
) : filteredRoutingTable.length === 0 ? (
<div className="border-y py-12 text-center">
<p className="text-sm font-medium"></p>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
) : (
<div className="overflow-hidden rounded-lg border bg-card shadow-2xs">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRoutingTable.map((route, index) => (
<TableRow
key={`${route.destination}-${route.nexthop}-${route.interface}-${index}`}
>
<TableCell className="font-mono text-xs font-medium">
{route.destination}
</TableCell>
<TableCell>{protocolBadge(route.protocol)}</TableCell>
<TableCell className="text-right font-mono text-xs">
{route.preference}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{(route.cost / 1_000_000).toFixed(2)} ms
</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-[10px]">
{route.flag}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<span className="font-mono text-xs">
{route.nexthop || "--"}
</span>
{route.nexthop && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
title="复制下一跳"
onClick={() => void copyNextHop(route.nexthop)}
/>
}
>
<>
{copiedHop === route.nexthop ? (
<Check
data-icon="inline-start"
className="text-emerald-500"
/>
) : (
<Copy data-icon="inline-start" />
)}
</>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{route.interface || "--"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)
}
+100 -128
View File
@@ -21,6 +21,7 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@@ -87,55 +88,6 @@ function toAddrString(item: unknown): string {
return String(item) return String(item)
} }
interface ConnectionItem {
id: string
address: string
autoConnect: boolean
}
function parseConnectionsFromConfig(config: KLALBControllerConfig | null): ConnectionItem[] {
if (!config) return []
const lineTable = (
Array.isArray(config.externalEndpoints)
? config.externalEndpoints
: Array.isArray(config.openConnections)
? config.openConnections
: Array.isArray(config.LineTable)
? config.LineTable
: []
).map(toAddrString)
const connectTable = (
Array.isArray(config.autoConnections)
? config.autoConnections
: Array.isArray(config.ConnectLineTable)
? config.ConnectLineTable
: []
).map(toAddrString)
const items: ConnectionItem[] = []
// openConnections (autoConnect: false)
lineTable.forEach((addr, index) => {
if (addr && addr.trim() !== '') {
items.push({
id: `open-${index}-${addr}`,
address: addr,
autoConnect: false,
})
}
})
// autoConnections (autoConnect: true)
connectTable.forEach((addr, index) => {
if (addr && addr.trim() !== '') {
items.push({
id: `auto-${index}-${addr}`,
address: addr,
autoConnect: true,
})
}
})
return items
}
function SettingsForm({ function SettingsForm({
initialConfig, initialConfig,
onSave, onSave,
@@ -162,8 +114,26 @@ function SettingsForm({
initialConfig.TUNName.trim() !== '') initialConfig.TUNName.trim() !== '')
) )
const [connections, setConnections] = useState<ConnectionItem[]>(() => const [autoConnections, setAutoConnections] = useState<string[]>(() =>
parseConnectionsFromConfig(initialConfig) (
Array.isArray(initialConfig.autoConnections)
? initialConfig.autoConnections
: Array.isArray(initialConfig.ConnectLineTable)
? initialConfig.ConnectLineTable
: []
).map(toAddrString)
)
const [externalEndpoints, setExternalEndpoints] = useState<string[]>(() =>
(
Array.isArray(initialConfig.externalEndpoints)
? initialConfig.externalEndpoints
: Array.isArray(initialConfig.openConnections)
? initialConfig.openConnections
: Array.isArray(initialConfig.LineTable)
? initialConfig.LineTable
: []
).map(toAddrString)
) )
const [ntpServers, setNtpServers] = useState<string[]>(() => const [ntpServers, setNtpServers] = useState<string[]>(() =>
@@ -195,14 +165,12 @@ function SettingsForm({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
const cleanConnections = connections.filter((c) => c.address.trim() !== '') const updatedAutoConnections = autoConnections
// 互斥分离:选中自动连接的进 autoConnections,未选中的进 externalEndpoints .filter((address) => address.trim() !== '')
const updatedExternalEndpoints = cleanConnections .map((address) => address.trim())
.filter((c) => !c.autoConnect) const updatedExternalEndpoints = externalEndpoints
.map((c) => c.address.trim()) .filter((address) => address.trim() !== '')
const updatedAutoConnections = cleanConnections .map((address) => address.trim())
.filter((c) => c.autoConnect)
.map((c) => c.address.trim())
// 保存时同时携带新键名与历史别名,兼容新旧后端 // 保存时同时携带新键名与历史别名,兼容新旧后端
const denyQuery = const denyQuery =
@@ -247,34 +215,6 @@ function SettingsForm({
await onSave(updatedConfig as KLALBControllerConfig) await onSave(updatedConfig as KLALBControllerConfig)
} }
// --- Dynamic Connection Items Handlers ---
const addConnection = () => {
setConnections((prev) => [
...prev,
{
id: `conn-${Date.now()}`,
address: '',
autoConnect: true,
},
])
}
const updateConnection = (
index: number,
field: 'address' | 'autoConnect',
value: string | boolean
) => {
setConnections((prev) => {
const next = [...prev]
next[index] = { ...next[index], [field]: value }
return next
})
}
const removeConnection = (index: number) => {
setConnections((prev) => prev.filter((_, i) => i !== index))
}
// --- Generic Dynamic List Handlers --- // --- Generic Dynamic List Handlers ---
const addListItem = (setter: React.Dispatch<React.SetStateAction<string[]>>) => { const addListItem = (setter: React.Dispatch<React.SetStateAction<string[]>>) => {
setter((prev) => [...prev, '']) setter((prev) => [...prev, ''])
@@ -411,15 +351,16 @@ function SettingsForm({
<Field className="md:col-span-2"> <Field className="md:col-span-2">
<FieldLabel htmlFor="deviceDescription"></FieldLabel> <FieldLabel htmlFor="deviceDescription"></FieldLabel>
<Input <Textarea
id="deviceDescription" id="deviceDescription"
placeholder="如: 机房 A 区多线聚合网关" placeholder={'如: 机房 A 区多线聚合网关\n支持多行输入'}
className="min-h-20"
value={form.DeviceDescription || ''} value={form.DeviceDescription || ''}
onChange={(e) => onChange={(e) =>
setForm((prev) => ({ ...prev, DeviceDescription: e.target.value })) setForm((prev) => ({ ...prev, DeviceDescription: e.target.value }))
} }
/> />
<FieldDescription></FieldDescription> <FieldDescription></FieldDescription>
</Field> </Field>
</FieldGroup> </FieldGroup>
</CardContent> </CardContent>
@@ -686,80 +627,111 @@ function SettingsForm({
{/* Tab 3: 连接与同步列表 (Lists) */} {/* Tab 3: 连接与同步列表 (Lists) */}
<TabsContent value="lists" className="m-0 flex flex-col gap-6"> <TabsContent value="lists" className="m-0 flex flex-col gap-6">
{/* 1. Connections List (with Auto Connect toggle) */} {/* 1. Automatic Connection Endpoints */}
<Card className="shadow-2xs"> <Card className="shadow-2xs">
<CardHeader className="flex flex-row items-center justify-between pb-3"> <CardHeader className="flex flex-row items-center justify-between pb-3">
<div> <div>
<CardTitle className="text-base"></CardTitle> <CardTitle className="text-base"></CardTitle>
<CardDescription> <CardDescription></CardDescription>
Socket
</CardDescription>
</div> </div>
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="outline" variant="outline"
onClick={addConnection} onClick={() => addListItem(setAutoConnections)}
> >
<Plus data-icon="inline-start" /> <Plus data-icon="inline-start" />
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<FieldGroup className="gap-3"> <FieldGroup className="gap-2">
{connections.length === 0 ? ( {autoConnections.length === 0 ? (
<div className="rounded-lg border border-dashed p-6 text-center text-xs text-muted-foreground"> <div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
</div> </div>
) : ( ) : (
connections.map((item, idx) => ( autoConnections.map((endpoint, idx) => (
<div <div key={idx} className="flex items-center gap-2">
key={item.id}
className="flex flex-col gap-3 rounded-lg border bg-card p-3 shadow-2xs sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-1 items-center gap-2">
<Input <Input
className="flex-1 font-mono text-xs" className="flex-1 font-mono text-xs"
placeholder="tcp://kne01.yoyo250.fun:4565 或 udp://..." placeholder="tcp://kne01.yoyo250.fun:4565 或 udp://..."
value={item.address} value={endpoint}
onChange={(e) => onChange={(e) =>
updateConnection(idx, 'address', e.target.value) updateListItem(idx, e.target.value, setAutoConnections)
} }
/> />
</div>
<div className="flex shrink-0 items-center justify-end gap-4">
<Field orientation="horizontal" className="flex items-center gap-2">
<FieldLabel htmlFor={`auto-conn-${item.id}`} className="cursor-pointer text-xs text-muted-foreground">
</FieldLabel>
<Switch
id={`auto-conn-${item.id}`}
checked={item.autoConnect}
onCheckedChange={(checked) =>
updateConnection(idx, 'autoConnect', checked)
}
/>
</Field>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon-xs" size="icon-xs"
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
onClick={() => removeConnection(idx)} onClick={() => removeListItem(idx, setAutoConnections)}
title="删除该连接" title="删除该端点"
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
</div> </div>
</div>
)) ))
)} )}
</FieldGroup> </FieldGroup>
</CardContent> </CardContent>
</Card> </Card>
{/* 2. NTP Server List */} {/* 2. Published External Endpoints */}
<Card className="shadow-2xs">
<CardHeader className="flex flex-row items-center justify-between pb-3">
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription>
访
</CardDescription>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => addListItem(setExternalEndpoints)}
>
<Plus data-icon="inline-start" />
</Button>
</CardHeader>
<CardContent>
<FieldGroup className="gap-2">
{externalEndpoints.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
</div>
) : (
externalEndpoints.map((endpoint, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
className="flex-1 font-mono text-xs"
placeholder="tcp://本机公网地址:4565 或 udp://..."
value={endpoint}
onChange={(e) =>
updateListItem(idx, e.target.value, setExternalEndpoints)
}
/>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-destructive hover:text-destructive"
onClick={() => removeListItem(idx, setExternalEndpoints)}
title="删除该端点"
>
<Trash2 className="size-3.5" />
</Button>
</div>
))
)}
</FieldGroup>
</CardContent>
</Card>
{/* 3. NTP Server List */}
<Card className="shadow-2xs"> <Card className="shadow-2xs">
<CardHeader className="flex flex-row items-center justify-between pb-3"> <CardHeader className="flex flex-row items-center justify-between pb-3">
<div> <div>
+39 -3
View File
@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback } from 'react' import { useState, useMemo, useCallback, useRef } from 'react'
import { ReactFlowProvider } from '@xyflow/react' import { ReactFlowProvider } from '@xyflow/react'
import { import {
Share2, Share2,
@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input'
import { useTopology } from '@/hooks/use-topology' import { useTopology } from '@/hooks/use-topology'
import { TopologyCanvas } from '@/components/topology/topology-canvas' import { TopologyCanvas } from '@/components/topology/topology-canvas'
import { NodeDetailSheet } from '@/components/topology/node-detail-sheet' import { NodeDetailSheet } from '@/components/topology/node-detail-sheet'
import type { NodeInfoDetail } from '@/types/api'
export function TopologyPage() { export function TopologyPage() {
const { nodes, edges, isLoading, error, relayout } = useTopology() const { nodes, edges, isLoading, error, relayout } = useTopology()
@@ -23,6 +24,11 @@ export function TopologyPage() {
const [copiedId, setCopiedId] = useState<string | null>(null) const [copiedId, setCopiedId] = useState<string | null>(null)
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
// 节点详情(含设备描述,来自 /api/node-info
const [nodeInfo, setNodeInfo] = useState<NodeInfoDetail | null>(null)
const [nodeInfoLoading, setNodeInfoLoading] = useState(false)
const nodeInfoSeqRef = useRef(0)
const handleCopy = useCallback((text: string) => { const handleCopy = useCallback((text: string) => {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
setCopiedId(text) setCopiedId(text)
@@ -31,7 +37,31 @@ export function TopologyPage() {
const handleNodeSelect = useCallback((nodeId: string | null) => { const handleNodeSelect = useCallback((nodeId: string | null) => {
setSelectedNodeId(nodeId) setSelectedNodeId(nodeId)
if (nodeId) setSheetOpen(true) if (nodeId) {
setSheetOpen(true)
setNodeInfo(null)
setNodeInfoLoading(true)
const seq = ++nodeInfoSeqRef.current
fetch(`/api/node-info?address=${encodeURIComponent(nodeId)}`)
.then((res) => (res.ok ? res.json() : null))
.then((data: NodeInfoDetail | null) => {
// 防止乱序:仅当仍是当前选中节点时才应用结果
if (seq === nodeInfoSeqRef.current && data) {
setNodeInfo(data)
}
})
.catch(() => {})
.finally(() => {
if (seq === nodeInfoSeqRef.current) {
setNodeInfoLoading(false)
}
})
} else {
setSheetOpen(false)
setNodeInfo(null)
setNodeInfoLoading(false)
nodeInfoSeqRef.current++
}
}, []) }, [])
const handleNodeDragEnd = useCallback( const handleNodeDragEnd = useCallback(
@@ -161,12 +191,18 @@ export function TopologyPage() {
open={sheetOpen} open={sheetOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
setSheetOpen(open) setSheetOpen(open)
if (!open) setSelectedNodeId(null) if (!open) {
setSelectedNodeId(null)
setNodeInfo(null)
nodeInfoSeqRef.current++
}
}} }}
node={sheetNode} node={sheetNode}
neighbors={neighbors} neighbors={neighbors}
copiedId={copiedId} copiedId={copiedId}
onCopy={handleCopy} onCopy={handleCopy}
description={nodeInfo?.deviceDescription ?? null}
descriptionLoading={nodeInfoLoading}
/> />
</div> </div>
) )
+10 -7
View File
@@ -64,12 +64,12 @@ export interface SSEEventData {
links: RemoteLinkItem[] links: RemoteLinkItem[]
} }
export interface RouteItemData { export interface RoutingTableItem {
destination: string destination: string
protocol: number protocol: string
preference: number preference: number
cost: number cost: number
flag: number flag: string
nexthop: string nexthop: string
interface: string interface: string
} }
@@ -94,10 +94,13 @@ export interface TopologyData {
edges: TopologyEdge[] edges: TopologyEdge[]
} }
export interface NetworkInterfaceInfo { export interface NodeInfoDetail {
name: string address: string
displayName: string isSelf: boolean
addresses: string[] deviceName: string
deviceDescription: string
reachable?: boolean
openLines?: string[]
} }
export interface KLALBControllerConfig { export interface KLALBControllerConfig {