Compare commits

..
3 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
6 changed files with 93 additions and 135 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
+1 -31
View File
@@ -6,7 +6,6 @@ import {
Network, Network,
Clock3, Clock3,
FileText, FileText,
Route,
} 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'
@@ -19,7 +18,6 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { formatNanoDelay } from '@/lib/format' import { formatNanoDelay } from '@/lib/format'
export interface SheetNodeInfo { export interface SheetNodeInfo {
@@ -47,7 +45,6 @@ interface NodeDetailSheetProps {
/** 设备描述(来自 /api/node-info 查询) */ /** 设备描述(来自 /api/node-info 查询) */
description?: string | null description?: string | null
descriptionLoading?: boolean descriptionLoading?: boolean
extraRoutes?: string[]
} }
export function NodeDetailSheet({ export function NodeDetailSheet({
@@ -59,7 +56,6 @@ export function NodeDetailSheet({
onCopy, onCopy,
description, description,
descriptionLoading, descriptionLoading,
extraRoutes = [],
}: NodeDetailSheetProps) { }: NodeDetailSheetProps) {
if (!node) return null if (!node) return null
@@ -95,13 +91,7 @@ export function NodeDetailSheet({
</div> </div>
</SheetHeader> </SheetHeader>
<div className="flex flex-1 flex-col overflow-y-auto p-4"> <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
<Tabs defaultValue="overview" className="min-h-0">
<TabsList className="w-full">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="routes"><Route className="size-3.5" /></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="flex flex-col gap-4 pt-4">
{/* Device Description */} {/* Device Description */}
<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="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
@@ -207,26 +197,6 @@ export function NodeDetailSheet({
<Gauge data-icon="inline-start" /> <Gauge data-icon="inline-start" />
Kperf Kperf
</Button> </Button>
</TabsContent>
<TabsContent value="routes" className="pt-4">
{descriptionLoading ? (
<div className="flex flex-col gap-1.5 py-8" aria-live="polite">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-2/3" />
</div>
) : extraRoutes.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground"></p>
) : (
<ul className="flex flex-col gap-1.5" aria-label="额外路由列表">
{extraRoutes.map((route, index) => (
<li key={`${route}-${index}`}>
<code className="block select-text break-all rounded-md border bg-muted/30 px-2.5 py-2 font-mono text-xs text-foreground">{route}</code>
</li>
))}
</ul>
)}
</TabsContent>
</Tabs>
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
+2 -15
View File
@@ -14,8 +14,6 @@ export interface LayoutNode extends SimulationNodeDatum {
compressedAddress?: string compressedAddress?: string
isSelf: boolean isSelf: boolean
deviceName?: string deviceName?: string
nodeInfoFullRevision?: number
nodeInfoFullRevisionEpoch?: string
x: number x: number
y: number y: number
} }
@@ -119,11 +117,7 @@ export function useTopology(): UseTopologyResult {
const topoNodes: TopologyNode[] = Array.isArray(payload.nodes) const topoNodes: TopologyNode[] = Array.isArray(payload.nodes)
? payload.nodes.filter( ? payload.nodes.filter(
(n) => n && typeof n.id === 'string' && n.id !== '' (n) => n && typeof n.id === 'string' && n.id !== ''
).map((n) => ({ )
...n,
nodeInfoFullRevision: n.nodeInfoFullRevision ?? 0,
nodeInfoFullRevisionEpoch: n.nodeInfoFullRevisionEpoch ?? '',
}))
: [] : []
const topoEdgesRaw: TopologyEdge[] = Array.isArray(payload.edges) const topoEdgesRaw: TopologyEdge[] = Array.isArray(payload.edges)
? payload.edges ? payload.edges
@@ -176,14 +170,7 @@ export function useTopology(): UseTopologyResult {
prev.length === topoNodes.length prev.length === topoNodes.length
? prev.map((n) => { ? prev.map((n) => {
const fresh = topoNodes.find((t) => t.id === n.id) const fresh = topoNodes.find((t) => t.id === n.id)
return fresh return fresh ? { ...n, deviceName: fresh.deviceName } : n
? {
...n,
deviceName: fresh.deviceName,
nodeInfoFullRevision: fresh.nodeInfoFullRevision,
nodeInfoFullRevisionEpoch: fresh.nodeInfoFullRevisionEpoch,
}
: n
}) })
: prev : prev
) )
+30 -33
View File
@@ -20,42 +20,37 @@ import {
import { useRoutingTable } from "@/hooks/use-routing-table" import { useRoutingTable } from "@/hooks/use-routing-table"
import type { RoutingTableItem } from "@/types/api" import type { RoutingTableItem } from "@/types/api"
type ProtocolFilter = "all" | "direct" | "klalb" type ProtocolFilter = "all" | string
function protocolName(protocol: RoutingTableItem["protocol"]): string {
const value = String(protocol)
const normalized = value.toLowerCase()
if (normalized === "direct" || value === "0") return "Direct"
if (
normalized.includes("klalb") ||
normalized.includes("srv6") ||
value === "1"
)
return "KLALB SRv6"
return value
}
function protocolBadge(protocol: RoutingTableItem["protocol"]) { function protocolBadge(protocol: RoutingTableItem["protocol"]) {
const name = protocolName(protocol) if (protocol === "Direct")
if (name === "Direct")
return ( return (
<Badge <Badge
variant="secondary" variant="secondary"
className="bg-blue-500/10 text-blue-600 dark:text-blue-400" className="bg-blue-500/10 text-blue-600 dark:text-blue-400"
> >
{name} {protocol}
</Badge> </Badge>
) )
if (name === "KLALB SRv6") if (protocol === "SRv6 ENDXSID")
return ( return (
<Badge <Badge
variant="secondary" variant="secondary"
className="bg-purple-500/10 text-purple-600 dark:text-purple-400" className="bg-purple-500/10 text-purple-600 dark:text-purple-400"
> >
{name} {protocol}
</Badge> </Badge>
) )
return <Badge variant="outline">{name}</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() { export function RoutingTablePage() {
@@ -67,12 +62,9 @@ export function RoutingTablePage() {
const counts = useMemo( const counts = useMemo(
() => ({ () => ({
total: routingTable.length, total: routingTable.length,
direct: routingTable.filter( direct: routingTable.filter((route) => route.protocol === "Direct").length,
(route) => protocolName(route.protocol) === "Direct" endxsid: routingTable.filter((route) => route.protocol === "SRv6 ENDXSID").length,
).length, endsid: routingTable.filter((route) => route.protocol === "SRv6 ENDSID").length,
klalb: routingTable.filter(
(route) => protocolName(route.protocol) === "KLALB SRv6"
).length,
}), }),
[routingTable] [routingTable]
) )
@@ -86,13 +78,11 @@ export function RoutingTablePage() {
route.destination, route.destination,
route.nexthop, route.nexthop,
route.interface, route.interface,
protocolName(route.protocol), route.protocol,
].some((value) => String(value).toLowerCase().includes(query)) ].some((value) => String(value).toLowerCase().includes(query))
const name = protocolName(route.protocol)
const matchesProtocol = const matchesProtocol =
protocolFilter === "all" || protocolFilter === "all" ||
(protocolFilter === "direct" && name === "Direct") || route.protocol === protocolFilter
(protocolFilter === "klalb" && name === "KLALB SRv6")
return matchesSearch && matchesProtocol return matchesSearch && matchesProtocol
}) })
}, [routingTable, searchQuery, protocolFilter]) }, [routingTable, searchQuery, protocolFilter])
@@ -112,8 +102,9 @@ export function RoutingTablePage() {
const filterOptions: { value: ProtocolFilter; label: string }[] = [ const filterOptions: { value: ProtocolFilter; label: string }[] = [
{ value: "all", label: "全部" }, { value: "all", label: "全部" },
{ value: "direct", label: "直连" }, ...Array.from(new Set(routingTable.map((route) => route.protocol))).map(
{ value: "klalb", label: "KLALB SRv6" }, (protocol) => ({ value: protocol, label: protocol })
),
] ]
return ( return (
@@ -142,7 +133,13 @@ export function RoutingTablePage() {
variant="secondary" variant="secondary"
className="bg-purple-500/10 text-purple-600 dark:text-purple-400" className="bg-purple-500/10 text-purple-600 dark:text-purple-400"
> >
KLALB SRv6 {counts.klalb} 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> </Badge>
</div> </div>
</div> </div>
+19 -51
View File
@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react' import { useState, useMemo, useCallback, useRef } from 'react'
import { ReactFlowProvider } from '@xyflow/react' import { ReactFlowProvider } from '@xyflow/react'
import { import {
Share2, Share2,
@@ -17,22 +17,6 @@ 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' import type { NodeInfoDetail } from '@/types/api'
type NodeInfoFreshnessKey = readonly [epoch: string, revision: number]
function getNodeInfoFreshnessKey(node: {
nodeInfoFullRevision?: number
nodeInfoFullRevisionEpoch?: string
}): NodeInfoFreshnessKey {
return [node.nodeInfoFullRevisionEpoch ?? '', node.nodeInfoFullRevision ?? 0]
}
function freshnessKeysEqual(
left: NodeInfoFreshnessKey | null,
right: NodeInfoFreshnessKey
): boolean {
return left?.[0] === right[0] && left?.[1] === right[1]
}
export function TopologyPage() { export function TopologyPage() {
const { nodes, edges, isLoading, error, relayout } = useTopology() const { nodes, edges, isLoading, error, relayout } = useTopology()
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -44,23 +28,6 @@ export function TopologyPage() {
const [nodeInfo, setNodeInfo] = useState<NodeInfoDetail | null>(null) const [nodeInfo, setNodeInfo] = useState<NodeInfoDetail | null>(null)
const [nodeInfoLoading, setNodeInfoLoading] = useState(false) const [nodeInfoLoading, setNodeInfoLoading] = useState(false)
const nodeInfoSeqRef = useRef(0) const nodeInfoSeqRef = useRef(0)
const nodeInfoFreshnessRef = useRef<NodeInfoFreshnessKey | null>(null)
const nodeInfoRevisionInitializedRef = useRef(false)
const fetchNodeInfo = useCallback((nodeId: string) => {
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)
})
}, [])
const handleCopy = useCallback((text: string) => { const handleCopy = useCallback((text: string) => {
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
@@ -72,18 +39,30 @@ export function TopologyPage() {
setSelectedNodeId(nodeId) setSelectedNodeId(nodeId)
if (nodeId) { if (nodeId) {
setSheetOpen(true) setSheetOpen(true)
const selected = nodes.find((node) => node.id === nodeId) setNodeInfo(null)
nodeInfoFreshnessRef.current = getNodeInfoFreshnessKey(selected ?? {}) setNodeInfoLoading(true)
nodeInfoRevisionInitializedRef.current = true const seq = ++nodeInfoSeqRef.current
fetchNodeInfo(nodeId) 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 { } else {
setSheetOpen(false) setSheetOpen(false)
setNodeInfo(null) setNodeInfo(null)
setNodeInfoLoading(false) setNodeInfoLoading(false)
nodeInfoSeqRef.current++ nodeInfoSeqRef.current++
nodeInfoRevisionInitializedRef.current = false
} }
}, [fetchNodeInfo, nodes]) }, [])
const handleNodeDragEnd = useCallback( const handleNodeDragEnd = useCallback(
(_id: string, _x: number, _y: number) => { (_id: string, _x: number, _y: number) => {
@@ -99,16 +78,6 @@ export function TopologyPage() {
[nodes, selectedNodeId] [nodes, selectedNodeId]
) )
useEffect(() => {
if (!selectedNode) return
const freshnessKey = getNodeInfoFreshnessKey(selectedNode)
if (!nodeInfoRevisionInitializedRef.current) return
if (!freshnessKeysEqual(nodeInfoFreshnessRef.current, freshnessKey)) {
nodeInfoFreshnessRef.current = freshnessKey
fetchNodeInfo(selectedNode.id)
}
}, [selectedNode, fetchNodeInfo])
// 邻居信息(基于边集合推导) // 邻居信息(基于边集合推导)
const neighbors = useMemo(() => { const neighbors = useMemo(() => {
if (!selectedNode) return [] if (!selectedNode) return []
@@ -234,7 +203,6 @@ export function TopologyPage() {
onCopy={handleCopy} onCopy={handleCopy}
description={nodeInfo?.deviceDescription ?? null} description={nodeInfo?.deviceDescription ?? null}
descriptionLoading={nodeInfoLoading} descriptionLoading={nodeInfoLoading}
extraRoutes={nodeInfo?.extraRoutes}
/> />
</div> </div>
) )
+2 -5
View File
@@ -66,10 +66,10 @@ export interface SSEEventData {
export interface RoutingTableItem { 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
} }
@@ -80,8 +80,6 @@ export interface TopologyNode {
compressedAddress?: string compressedAddress?: string
isSelf: boolean isSelf: boolean
deviceName?: string deviceName?: string
nodeInfoFullRevision?: number
nodeInfoFullRevisionEpoch?: string
} }
export interface TopologyEdge { export interface TopologyEdge {
@@ -103,7 +101,6 @@ export interface NodeInfoDetail {
deviceDescription: string deviceDescription: string
reachable?: boolean reachable?: boolean
openLines?: string[] openLines?: string[]
extraRoutes?: string[]
} }
export interface KLALBControllerConfig { export interface KLALBControllerConfig {