Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions cable_map/frontend/src/SwitchNode.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useState, type ReactNode } from 'react';
import { memo, useCallback, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import {
CABLE_COLOR, LLDP_COLOR, COMPACT_SWITCH_H, COMPACT_SWITCH_W,
Expand All @@ -16,6 +16,32 @@ const RoleIcons: Record<string, string> = {
spine: spineIcon, superspine: superspineIcon, leaf: leafIcon, borderleaf: leafIcon,
};

type RoleIconStyle = CSSProperties & {
'--color-icon-bg': string;
'--color-icon-fg': string;
};

const roleIconStyle = (size: number, synced?: boolean): RoleIconStyle => ({
lineHeight: 0,
width: size,
height: size,
flexShrink: 0,
'--color-icon-bg': synced ? 'var(--eda-green-600)' : 'var(--cell-free-bg)',
'--color-icon-fg': synced ? '#fff' : 'var(--muted)',
});

function RoleIcon({ icon, size, role, nodeState, synced }: { icon?: string; size: number; role: string; nodeState?: string; synced?: boolean }) {
if (!icon) return null;
const state = nodeState ? ` · node-state: ${nodeState}` : '';
return (
<span
title={`${role}${state}`}
style={roleIconStyle(size, synced)}
dangerouslySetInnerHTML={{ __html: icon.replace(/width="\d+"/, `width="${size}"`).replace(/height="\d+"/, `height="${size}"`) }}
/>
);
}

const CELL = 13, CELL_GAP = 3;
// occupied cells fill with their cable-kind colour, or their LLDP status while the overlay is on
const fillFor = (info: PortInfo, lldp: boolean) =>
Expand Down Expand Up @@ -285,7 +311,7 @@ function CompactSwitchNode({ id, data, selected }: NodeProps) {
<Handle type="target" id={compactSwitchHandle('bottom', true)} position={Position.Bottom} isConnectable={false}
style={{ ...handleStyle, bottom: -5, top: 'auto' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
{icon && <span style={{ lineHeight: 0, width: 15, height: 15, flexShrink: 0 }} dangerouslySetInnerHTML={{ __html: icon.replace(/width="\d+"/, 'width="15"').replace(/height="\d+"/, 'height="15"') }} />}
<RoleIcon icon={icon} size={15} role={d.role} nodeState={d.nodeState} synced={d.synced} />
<span style={{ fontSize: 11.5, fontWeight: 700, color: 'var(--fg)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginTop: 4 }}>
Expand Down Expand Up @@ -370,7 +396,7 @@ function DetailedSwitchNode({ id, data, selected }: NodeProps) {
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, height: 20 }}>
{icon && <span style={{ lineHeight: 0, width: 18, height: 18, flexShrink: 0 }} dangerouslySetInnerHTML={{ __html: icon.replace(/width="\d+"/, 'width="18"').replace(/height="\d+"/, 'height="18"') }} />}
<RoleIcon icon={icon} size={18} role={d.role} nodeState={d.nodeState} synced={d.synced} />
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--fg)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 0, maxWidth: '60%' }}>{d.name}</span>
<span style={{ fontSize: 9, color: 'var(--muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>{d.platform}</span>
<span style={{ flex: 1, minWidth: 0 }} />
Expand Down
69 changes: 50 additions & 19 deletions cable_map/frontend/src/components/AppToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import MenuItem from '@mui/material/MenuItem';
import Checkbox from '@mui/material/Checkbox';
import ListItemText from '@mui/material/ListItemText';
import SearchIcon from '@mui/icons-material/SearchOutlined';
import CloseIcon from '@mui/icons-material/CloseOutlined';
import LightModeIcon from '@mui/icons-material/LightModeOutlined';
import DarkModeIcon from '@mui/icons-material/DarkModeOutlined';
import SensorsIcon from '@mui/icons-material/SensorsOutlined';
Expand Down Expand Up @@ -98,25 +99,55 @@ function AppToolbar({
)}
{ready && nodeLabelOptions.length > 0 && (
<FormControl size="small" sx={{ width: 260 }}>
<Select
multiple
displayEmpty
value={selectedLabels}
onChange={(e) => onSelectedLabelsChange(typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value)}
renderValue={(sel) => sel.length === 0
? <Box component="span" sx={{ color: 'text.secondary' }}>Label filter</Box>
: sel.length === 1 ? sel[0] : `${sel.length} labels`}
inputProps={{ 'aria-label': 'Label filter' }}
MenuProps={{ slotProps: { paper: { sx: { maxHeight: 360 } } } }}
sx={{ height: 31 }}
>
{nodeLabelOptions.map((value) => (
<MenuItem key={value} value={value} dense>
<Checkbox size="small" checked={selectedLabels.includes(value)} sx={{ py: 0, mr: 0.5 }} />
<ListItemText primary={value} slotProps={{ primary: { noWrap: true } }} />
</MenuItem>
))}
</Select>
<Box sx={{ position: 'relative' }}>
<Select
multiple
displayEmpty
value={selectedLabels}
onChange={(e) => onSelectedLabelsChange(typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value)}
renderValue={(sel) => sel.length === 0
? <Box component="span" sx={{ color: 'text.secondary' }}>Label filter</Box>
: sel.length === 1 ? sel[0] : `${sel.length} labels`}
inputProps={{ 'aria-label': 'Label filter' }}
MenuProps={{ slotProps: { paper: { sx: { maxHeight: 360 } } } }}
sx={{
height: 31,
width: '100%',
'& .MuiSelect-select': selectedLabels.length > 0 ? { pr: '64px !important' } : undefined,
}}
>
{nodeLabelOptions.map((value) => (
<MenuItem key={value} value={value} dense>
<Checkbox size="small" checked={selectedLabels.includes(value)} sx={{ py: 0, mr: 0.5 }} />
<ListItemText primary={value} slotProps={{ primary: { noWrap: true } }} />
</MenuItem>
))}
</Select>
{selectedLabels.length > 0 && (
<Tooltip title="Clear label filters">
<IconButton
aria-label="Clear label filters"
size="small"
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); onSelectedLabelsChange([]); }}
sx={{
position: 'absolute',
right: 28,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 1,
width: 24,
height: 24,
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'text.primary' },
}}
>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
</Box>
</FormControl>
)}
{namespaces.length > 1 && namespace && (
Expand Down
23 changes: 16 additions & 7 deletions cable_map/frontend/src/components/CanvasLegend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { CABLE_COLOR, LLDP_COLOR } from '../graph';
import { useLldp } from '../lldp';

const LEGEND: Array<[string, string]> = [
['Fabric (ISL)', CABLE_COLOR.isl], ['Edge', CABLE_COLOR.edge], ['Local LAG', CABLE_COLOR.lag], ['ESI / MH-LAG', CABLE_COLOR.mlag],
['InterSwitch', CABLE_COLOR.isl], ['Edge', CABLE_COLOR.edge], ['Local LAG', CABLE_COLOR.lag], ['Multihome LAG', CABLE_COLOR.mlag],
];
const LLDP_LEGEND: Array<[string, string]> = [
['LLDP confirmed', LLDP_COLOR.ok], ['Mismatch', LLDP_COLOR.mismatch], ['No neighbor', LLDP_COLOR.none],
Expand All @@ -32,19 +32,28 @@ export default function CanvasLegend() {
<CloseIcon sx={{ fontSize: 14, color: 'text.secondary' }} />
</IconButton>
</Tooltip>
<Box sx={{ display: 'flex', gap: 2, mb: 0.75 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', columnGap: 2, rowGap: 0.5, mb: 0.75 }}>
{(lldpOn ? LLDP_LEGEND : LEGEND).map(([label, swatch]) => (
<Box key={label} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Box sx={{ width: 14, height: 6, borderRadius: 2, bgcolor: swatch }} />
<Typography variant="caption" sx={{ color: 'text.primary', fontWeight: 500 }}>{label}</Typography>
</Box>
))}
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{lldpOn
? 'LLDP overlay — cables coloured by neighbour match vs the configured remote'
: 'Hover to trace · click a cable or port to pin · click a switch for its panel'}
</Typography>
{lldpOn ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
LLDP overlay — cables coloured by neighbour match vs the configured remote
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Select a node to highlight its connections
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Double click on a node to see the front-panel layout
</Typography>
</Box>
)}
</Box>
</Grow>
<Grow in={!legendOpen} style={{ transformOrigin: 'bottom left' }} unmountOnExit>
Expand Down
8 changes: 8 additions & 0 deletions cable_map/frontend/src/graph/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ function podKey(n: Topology['nodes'][number]): string {
return labelOf(n, 'eda.nokia.com/pod') ?? 'pod';
}

function nodeStateOf(n: Topology['nodes'][number]): string | undefined {
const value = n.raw?.status?.['node-state'];
return value == null ? undefined : String(value);
}

function breakoutsWithInferredChannels(explicit: Record<string, Breakout> | undefined, ports: PortInfo[]): Record<string, Breakout> | undefined {
const inferred = new Map<string, number>();
for (const port of ports) {
Expand Down Expand Up @@ -154,6 +159,7 @@ export function layoutGraph({ topo, compact, tiered, endpoints, switchPorts, ste

const rfNodes: RFNode[] = topo.nodes.map((n) => {
const nodePorts = switchPorts.get(n.name) ?? [];
const nodeState = nodeStateOf(n);
return {
id: n.name, type: 'switch', position: pos.get(n.name) ?? { x: 0, y: 0 },
width: swW(n.name),
Expand All @@ -162,6 +168,8 @@ export function layoutGraph({ topo, compact, tiered, endpoints, switchPorts, ste
initialHeight: swH(n.name),
data: {
name: n.name, role: n.role, platform: n.platform,
nodeState,
synced: nodeState?.trim().toLowerCase() === 'synced',
portCount: portCountOf(stencilOf(n.name)), rows: rowsOf(stencilOf(n.name)),
ports: nodePorts, layout: compact ? undefined : layoutOf(stencilOf(n.name)),
aspect: aspectOf(stencilOf(n.name)), breakouts: compact ? undefined : breakoutsWithInferredChannels(n.breakouts, nodePorts), compact,
Expand Down
2 changes: 2 additions & 0 deletions cable_map/frontend/src/graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export type PortInfo = {

export type SwitchData = {
name: string; role: string; platform: string;
nodeState?: string;
synced?: boolean;
portCount: number;
rows: number; // physical cage rows, mirrored on the canvas
ports: PortInfo[]; // occupied ports only (free cells inferred)
Expand Down
4 changes: 2 additions & 2 deletions cable_map/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ spec:
# The Go operator runtime image (serves the SPA + topology API, reads EDA state via EDK).
# edabuilder bakes this container into the single OCI app image at publish/deploy time.
- container:
image: ghcr.io/eda-labs/cable-map/operators/cable-map:v0.1.9
image: ghcr.io/eda-labs/cable-map/operators/cable-map:v0.2.0
name: cable-map
description: Cable-map topology and front-panel view for EDA fabrics.
group: cable-map.eda.labs
image: ghcr.io/eda-labs/cable-map:v0.1.9
image: ghcr.io/eda-labs/cable-map:v0.2.0
supportedCoreVersions:
- v5.0.0-0
title: Cable Map
Expand Down
Loading