Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b705f9ba0
|
@@ -1,39 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -6,6 +6,7 @@ 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'
|
||||||
@@ -18,6 +19,7 @@ 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 {
|
||||||
@@ -45,6 +47,7 @@ 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({
|
||||||
@@ -56,6 +59,7 @@ export function NodeDetailSheet({
|
|||||||
onCopy,
|
onCopy,
|
||||||
description,
|
description,
|
||||||
descriptionLoading,
|
descriptionLoading,
|
||||||
|
extraRoutes = [],
|
||||||
}: NodeDetailSheetProps) {
|
}: NodeDetailSheetProps) {
|
||||||
if (!node) return null
|
if (!node) return null
|
||||||
|
|
||||||
@@ -91,7 +95,13 @@ export function NodeDetailSheet({
|
|||||||
</div>
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
<div className="flex flex-1 flex-col 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">
|
||||||
@@ -197,6 +207,26 @@ 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>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ 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
|
||||||
}
|
}
|
||||||
@@ -117,7 +119,11 @@ 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
|
||||||
@@ -170,7 +176,14 @@ 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 ? { ...n, deviceName: fresh.deviceName } : n
|
return fresh
|
||||||
|
? {
|
||||||
|
...n,
|
||||||
|
deviceName: fresh.deviceName,
|
||||||
|
nodeInfoFullRevision: fresh.nodeInfoFullRevision,
|
||||||
|
nodeInfoFullRevisionEpoch: fresh.nodeInfoFullRevisionEpoch,
|
||||||
|
}
|
||||||
|
: n
|
||||||
})
|
})
|
||||||
: prev
|
: prev
|
||||||
)
|
)
|
||||||
|
|||||||
+33
-30
@@ -20,37 +20,42 @@ 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" | string
|
type ProtocolFilter = "all" | "direct" | "klalb"
|
||||||
|
|
||||||
|
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"]) {
|
||||||
if (protocol === "Direct")
|
const name = protocolName(protocol)
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
{protocol}
|
{name}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
if (protocol === "SRv6 ENDXSID")
|
if (name === "KLALB SRv6")
|
||||||
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"
|
||||||
>
|
>
|
||||||
{protocol}
|
{name}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
if (protocol === "SRv6 ENDSID")
|
return <Badge variant="outline">{name}</Badge>
|
||||||
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() {
|
||||||
@@ -62,9 +67,12 @@ export function RoutingTablePage() {
|
|||||||
const counts = useMemo(
|
const counts = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
total: routingTable.length,
|
total: routingTable.length,
|
||||||
direct: routingTable.filter((route) => route.protocol === "Direct").length,
|
direct: routingTable.filter(
|
||||||
endxsid: routingTable.filter((route) => route.protocol === "SRv6 ENDXSID").length,
|
(route) => protocolName(route.protocol) === "Direct"
|
||||||
endsid: routingTable.filter((route) => route.protocol === "SRv6 ENDSID").length,
|
).length,
|
||||||
|
klalb: routingTable.filter(
|
||||||
|
(route) => protocolName(route.protocol) === "KLALB SRv6"
|
||||||
|
).length,
|
||||||
}),
|
}),
|
||||||
[routingTable]
|
[routingTable]
|
||||||
)
|
)
|
||||||
@@ -78,11 +86,13 @@ export function RoutingTablePage() {
|
|||||||
route.destination,
|
route.destination,
|
||||||
route.nexthop,
|
route.nexthop,
|
||||||
route.interface,
|
route.interface,
|
||||||
route.protocol,
|
protocolName(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" ||
|
||||||
route.protocol === protocolFilter
|
(protocolFilter === "direct" && name === "Direct") ||
|
||||||
|
(protocolFilter === "klalb" && name === "KLALB SRv6")
|
||||||
return matchesSearch && matchesProtocol
|
return matchesSearch && matchesProtocol
|
||||||
})
|
})
|
||||||
}, [routingTable, searchQuery, protocolFilter])
|
}, [routingTable, searchQuery, protocolFilter])
|
||||||
@@ -102,9 +112,8 @@ export function RoutingTablePage() {
|
|||||||
|
|
||||||
const filterOptions: { value: ProtocolFilter; label: string }[] = [
|
const filterOptions: { value: ProtocolFilter; label: string }[] = [
|
||||||
{ value: "all", label: "全部" },
|
{ value: "all", label: "全部" },
|
||||||
...Array.from(new Set(routingTable.map((route) => route.protocol))).map(
|
{ value: "direct", label: "直连" },
|
||||||
(protocol) => ({ value: protocol, label: protocol })
|
{ value: "klalb", label: "KLALB SRv6" },
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,13 +142,7 @@ 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"
|
||||||
>
|
>
|
||||||
SRv6 ENDXSID {counts.endxsid}
|
KLALB SRv6 {counts.klalb}
|
||||||
</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>
|
||||||
|
|||||||
+51
-19
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useCallback, useRef } from 'react'
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||||
import { ReactFlowProvider } from '@xyflow/react'
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Share2,
|
Share2,
|
||||||
@@ -17,6 +17,22 @@ 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('')
|
||||||
@@ -28,6 +44,23 @@ 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)
|
||||||
@@ -39,30 +72,18 @@ export function TopologyPage() {
|
|||||||
setSelectedNodeId(nodeId)
|
setSelectedNodeId(nodeId)
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
setNodeInfo(null)
|
const selected = nodes.find((node) => node.id === nodeId)
|
||||||
setNodeInfoLoading(true)
|
nodeInfoFreshnessRef.current = getNodeInfoFreshnessKey(selected ?? {})
|
||||||
const seq = ++nodeInfoSeqRef.current
|
nodeInfoRevisionInitializedRef.current = true
|
||||||
fetch(`/api/node-info?address=${encodeURIComponent(nodeId)}`)
|
fetchNodeInfo(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) => {
|
||||||
@@ -78,6 +99,16 @@ 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 []
|
||||||
@@ -203,6 +234,7 @@ export function TopologyPage() {
|
|||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
description={nodeInfo?.deviceDescription ?? null}
|
description={nodeInfo?.deviceDescription ?? null}
|
||||||
descriptionLoading={nodeInfoLoading}
|
descriptionLoading={nodeInfoLoading}
|
||||||
|
extraRoutes={nodeInfo?.extraRoutes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-2
@@ -66,10 +66,10 @@ export interface SSEEventData {
|
|||||||
|
|
||||||
export interface RoutingTableItem {
|
export interface RoutingTableItem {
|
||||||
destination: string
|
destination: string
|
||||||
protocol: string
|
protocol: number
|
||||||
preference: number
|
preference: number
|
||||||
cost: number
|
cost: number
|
||||||
flag: string
|
flag: number
|
||||||
nexthop: string
|
nexthop: string
|
||||||
interface: string
|
interface: string
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,8 @@ 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 {
|
||||||
@@ -101,6 +103,7 @@ export interface NodeInfoDetail {
|
|||||||
deviceDescription: string
|
deviceDescription: string
|
||||||
reachable?: boolean
|
reachable?: boolean
|
||||||
openLines?: string[]
|
openLines?: string[]
|
||||||
|
extraRoutes?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KLALBControllerConfig {
|
export interface KLALBControllerConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user