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
1 change: 1 addition & 0 deletions cable_map/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export default function App({ mode, onToggleMode }: { mode: Mode; onToggleMode:
query={search.query} onQueryChange={search.setQuery} searchError={search.searchError}
nodeLabelOptions={filter.nodeLabelOptions}
selectedLabels={filter.selectedLabels} onSelectedLabelsChange={filter.setSelectedLabels}
labelFilterText={filter.labelFilterText} onLabelFilterTextChange={filter.setLabelFilterText}
namespaces={namespaces} namespace={namespace} onNamespaceChange={setNamespace}
exportNodeIds={exportNodeIds} exportScope={exportScope}
pdfLoading={pdfLoading} onExportPdf={pdf.openPdf}
Expand Down
80 changes: 68 additions & 12 deletions cable_map/frontend/src/components/AppToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
Expand All @@ -15,6 +15,7 @@ import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Checkbox from '@mui/material/Checkbox';
import ListItemText from '@mui/material/ListItemText';
import ListSubheader from '@mui/material/ListSubheader';
import SearchIcon from '@mui/icons-material/SearchOutlined';
import CloseIcon from '@mui/icons-material/CloseOutlined';
import LightModeIcon from '@mui/icons-material/LightModeOutlined';
Expand All @@ -41,6 +42,8 @@ interface AppToolbarProps {
nodeLabelOptions: string[];
selectedLabels: string[];
onSelectedLabelsChange: (labels: string[]) => void;
labelFilterText: string;
onLabelFilterTextChange: (value: string) => void;
namespaces: string[];
namespace: string;
onNamespaceChange: (ns: string) => void;
Expand All @@ -55,10 +58,25 @@ interface AppToolbarProps {
// re-renders on each keystroke, as intended.
function AppToolbar({
mode, onToggleMode, ready, topo, query, onQueryChange, searchError,
nodeLabelOptions, selectedLabels, onSelectedLabelsChange,
nodeLabelOptions, selectedLabels, onSelectedLabelsChange, labelFilterText, onLabelFilterTextChange,
namespaces, namespace, onNamespaceChange, exportNodeIds, exportScope, pdfLoading, onExportPdf,
}: AppToolbarProps) {
const lldpOn = useLldp();
const labelFilterTerms = useMemo(
() => labelFilterText.trim().toLowerCase().split(/\s+/).filter(Boolean),
[labelFilterText],
);
const selectedLabelSet = useMemo(() => new Set(selectedLabels), [selectedLabels]);
const visibleLabelOptions = useMemo(() => {
if (labelFilterTerms.length === 0) return nodeLabelOptions;
return nodeLabelOptions.filter((value) => {
if (selectedLabelSet.has(value)) return true;
const hay = value.toLowerCase();
return labelFilterTerms.every((term) => hay.includes(term));
});
}, [labelFilterTerms, nodeLabelOptions, selectedLabelSet]);
const labelFilterTextTrimmed = labelFilterText.trim();
const labelFilterActive = selectedLabels.length > 0 || labelFilterTextTrimmed.length > 0;
const exportCount = exportScope === 'all' ? 0 : exportNodeIds.length;
const pdfTitle = exportScope === 'selected'
? `Download PDF (${exportNodeIds.length} selected)`
Expand Down Expand Up @@ -105,31 +123,69 @@ function AppToolbar({
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`}
renderValue={(sel) => {
const selectedText = sel.length === 0 ? '' : sel.length === 1 ? sel[0] : `${sel.length} labels`;
if (!selectedText && !labelFilterTextTrimmed) {
return <Box component="span" sx={{ color: 'text.secondary' }}>Label filter</Box>;
}
return selectedText && labelFilterTextTrimmed
? `${selectedText} · ${labelFilterTextTrimmed}`
: selectedText || labelFilterTextTrimmed;
}}
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,
'& .MuiSelect-select': labelFilterActive ? { pr: '64px !important' } : undefined,
}}
>
{nodeLabelOptions.map((value) => (
<ListSubheader
sx={{ bgcolor: 'background.paper', lineHeight: 'normal', px: 1, py: 1 }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<TextField
size="small"
fullWidth
value={labelFilterText}
onChange={(e) => onLabelFilterTextChange(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="Search labels"
slotProps={{ input: { startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
</InputAdornment>
) } }}
sx={{
'& .MuiOutlinedInput-root': { height: 31 },
'& .MuiInputBase-input': { py: '3px' },
}}
/>
</ListSubheader>
{visibleLabelOptions.map((value) => (
<MenuItem key={value} value={value} dense>
<Checkbox size="small" checked={selectedLabels.includes(value)} sx={{ py: 0, mr: 0.5 }} />
<Checkbox size="small" checked={selectedLabelSet.has(value)} sx={{ py: 0, mr: 0.5 }} />
<ListItemText primary={value} slotProps={{ primary: { noWrap: true } }} />
</MenuItem>
))}
{visibleLabelOptions.length === 0 && (
<MenuItem disabled dense>
<ListItemText primary="No matching labels" slotProps={{ primary: { color: 'text.secondary' } }} />
</MenuItem>
)}
</Select>
{selectedLabels.length > 0 && (
<Tooltip title="Clear label filters">
{labelFilterActive && (
<Tooltip title="Clear label filter">
<IconButton
aria-label="Clear label filters"
aria-label="Clear label filter"
size="small"
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); onSelectedLabelsChange([]); }}
onClick={(e) => {
e.stopPropagation();
onSelectedLabelsChange([]);
onLabelFilterTextChange('');
}}
sx={{
position: 'absolute',
right: 28,
Expand Down
11 changes: 11 additions & 0 deletions cable_map/frontend/src/components/NodeDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUpOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernetOutlined';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined';
import SvgGlyph from './SvgGlyph';
import pdfIcon from '../assets/icons/pdf.svg?raw';
import excelIcon from '../assets/icons/excel.svg?raw';
import type { Node, Topology } from '../domain/topology/contract';
import { installGuideForPlatform } from '../domain/platform/installGuides';
import { exportXlsx } from '../xlsx';
import NodeView from '../NodeView';
import NodeDetails from '../NodeDetails';
Expand Down Expand Up @@ -47,6 +49,7 @@ function NodeDrawer({ node, expanded, onExpandedChange, resetSizeSignal, onOpenN
const [drawerH, setDrawerH] = useState(defaultDrawerHeight);
const [dragging, setDragging] = useState(false); // suppresses the slide transition mid-drag
const nodeName = node?.name;
const installGuide = node ? installGuideForPlatform(node.platform) : undefined;
const openLinkedNode = useCallback((name: string) => onOpenNode(name, { pan: true, pin: true, select: true }), [onOpenNode]);

useEffect(() => {
Expand Down Expand Up @@ -154,6 +157,14 @@ function NodeDrawer({ node, expanded, onExpandedChange, resetSizeSignal, onOpenN
</IconButton>
</Tooltip>
)}
{installGuide && (
<Tooltip title={`Open ${installGuide.family} installation guide`}>
<IconButton component="a" href={installGuide.url} target="_blank" rel="noopener noreferrer"
aria-label={`Open ${installGuide.family} installation guide`} size="small" sx={{ color: 'text.primary' }}>
<ArticleOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Collapse">
<IconButton onClick={() => onExpandedChange(false)} size="small"><KeyboardArrowDownIcon /></IconButton>
</Tooltip>
Expand Down
40 changes: 40 additions & 0 deletions cable_map/frontend/src/domain/platform/installGuides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export interface InstallGuide {
family: string;
url: string;
}

const INSTALL_GUIDES: readonly (InstallGuide & { prefix: string })[] = [
{
prefix: '7215 IXS',
family: '7215 IXS',
url: 'https://documentation.nokia.com/pybin/doc_ctr.py?product_id=833-066294&model=754&model_name=Hardware&category=Installation&sortby=Issue%20Date',
},
{
prefix: '7220 IXR',
family: '7220 IXR',
url: 'https://documentation.nokia.com/pybin/doc_ctr.py?product_id=833-064480&model=753&model_name=Hardware&category=Installation&sortby=Issue%20Date',
},
{
prefix: '7250 IXR',
family: '7250 IXR',
url: 'https://documentation.nokia.com/pybin/doc_ctr.py?product_id=833-011541&model=660&model_name=Hardware&category=Installation&sortby=Issue%20Date',
},
{
prefix: '7730 SXR',
family: '7730 SXR',
url: 'https://documentation.nokia.com/pybin/doc_ctr.py?product_id=833-081666&category=Installation&sortby=Issue%20Date',
},
{
prefix: '7750 SR',
family: '7750 SR',
url: 'https://documentation.nokia.com/pybin/doc_ctr.py?product_id=833-006358&model=562&model_name=Hardware&category=Installation&sortby=Issue%20Date',
},
];

export function installGuideForPlatform(platform: string): InstallGuide | undefined {
const value = platform.trim().replace(/\s+/g, ' ').toUpperCase();
return INSTALL_GUIDES.find((guide) => {
const prefix = guide.prefix.toUpperCase();
return value === prefix || value.startsWith(`${prefix}-`) || value.startsWith(`${prefix} `);
});
}
48 changes: 38 additions & 10 deletions cable_map/frontend/src/hooks/useLabelFilter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { type Node as RFNode } from '@xyflow/react';
import type { Adjacency } from '../graph/adjacency';
import { type FitNodesOptions } from '../lib/viewport';
Expand All @@ -10,6 +10,26 @@ type LabelFilterDeps = {
largeTopology: boolean;
};

function labelSetHasSelected(labels: Set<string>, selected: Set<string>): boolean {
if (selected.size === 0) return true;
for (const label of selected) if (labels.has(label)) return true;
return false;
}

function labelSetMatchesTerms(labels: Set<string>, terms: string[]): boolean {
for (const term of terms) {
let found = false;
for (const label of labels) {
if (label.toLowerCase().includes(term)) {
found = true;
break;
}
}
if (!found) return false;
}
return true;
}

// Owns the label filter. `shown` is the set of node ids to display (null = no filter): nodes
// carrying any selected label, plus each matched node's directly-attached context (a switch
// pulls in its endpoint chips, an endpoint pulls in its switches). Auto-fits to the selection.
Expand All @@ -20,6 +40,13 @@ export function useLabelFilter(
{ nodes, ready, fitNodes, largeTopology }: LabelFilterDeps,
) {
const [selectedLabels, setSelectedLabels] = useState<string[]>([]); // [] = no filter
const [labelFilterText, setLabelFilterText] = useState('');
const deferredLabelFilterText = useDeferredValue(labelFilterText);
const labelFilterTerms = useMemo(
() => deferredLabelFilterText.trim().toLowerCase().split(/\s+/).filter(Boolean),
[deferredLabelFilterText],
);
const labelFilterTermKey = labelFilterTerms.join('\n');

const nodeLabelOptions = useMemo(
() => {
Expand All @@ -34,6 +61,7 @@ export function useLabelFilter(

useEffect(() => {
setSelectedLabels([]);
setLabelFilterText('');
}, [namespace]);

// drop any selected labels that no longer exist (e.g. after a namespace/topology change)
Expand All @@ -44,15 +72,14 @@ export function useLabelFilter(
});
}, [nodeLabelOptions]);

const filterActive = selectedLabels.length > 0;
const filterActive = selectedLabels.length > 0 || labelFilterTerms.length > 0;
const shown = useMemo<Set<string> | null>(() => {
if (!filterActive) return null;
const sel = new Set(selectedLabels);
const out = new Set<string>();
for (const [id, set] of adjacency.nodeLabels) {
let match = false;
for (const s of sel) if (set.has(s)) { match = true; break; }
if (!match) continue;
for (const [id, labels] of adjacency.nodeLabels) {
if (!labelSetHasSelected(labels, sel)) continue;
if (!labelSetMatchesTerms(labels, labelFilterTerms)) continue;
out.add(id);
}
// keep a matched node's directly-attached context across a switch↔endpoint edge only.
Expand All @@ -67,7 +94,7 @@ export function useLabelFilter(
}
}
return out;
}, [filterActive, selectedLabels, adjacency, switchIds]);
}, [filterActive, selectedLabels, labelFilterTerms, adjacency, switchIds]);

// read the latest `shown`/`nodes` via refs so the auto-fit effect fires on the user's label
// selection, not on every data refresh that recomputes them.
Expand All @@ -80,15 +107,16 @@ export function useLabelFilter(
if (!ready) return;
const target = shownRef.current;
const handle = window.setTimeout(() => {
if (!target || target.size === 0) {
if (!target) {
if (largeTopology) return;
fitNodes(nodesRef.current, { padding: 0.12, duration: 400, maxZoom: largeTopology ? 0.5 : undefined });
return;
}
if (target.size === 0) return;
fitNodes(nodesRef.current.filter((node) => target.has(node.id)), { padding: 0.2, duration: 400, maxZoom: 1.4 });
}, 60);
return () => window.clearTimeout(handle);
}, [selectedLabels, ready, fitNodes, largeTopology]);
}, [selectedLabels, labelFilterTermKey, ready, fitNodes, largeTopology]);

return { nodeLabelOptions, selectedLabels, setSelectedLabels, shown };
return { nodeLabelOptions, selectedLabels, setSelectedLabels, labelFilterText, setLabelFilterText, shown };
}
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.2.1
image: ghcr.io/eda-labs/cable-map/operators/cable-map:v0.2.2
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.2.1
image: ghcr.io/eda-labs/cable-map:v0.2.2
supportedCoreVersions:
- v5.0.0-0
title: Cable Map
Expand Down
Loading