diff --git a/cable_map/frontend/src/App.tsx b/cable_map/frontend/src/App.tsx
index b73b4c2..a51c601 100644
--- a/cable_map/frontend/src/App.tsx
+++ b/cable_map/frontend/src/App.tsx
@@ -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}
diff --git a/cable_map/frontend/src/components/AppToolbar.tsx b/cable_map/frontend/src/components/AppToolbar.tsx
index 8e14fc3..985b2d1 100644
--- a/cable_map/frontend/src/components/AppToolbar.tsx
+++ b/cable_map/frontend/src/components/AppToolbar.tsx
@@ -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';
@@ -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';
@@ -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;
@@ -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)`
@@ -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
- ? Label filter
- : 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 Label filter;
+ }
+ 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) => (
+ e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ >
+ onLabelFilterTextChange(e.target.value)}
+ onKeyDown={(e) => e.stopPropagation()}
+ placeholder="Search labels"
+ slotProps={{ input: { startAdornment: (
+
+
+
+ ) } }}
+ sx={{
+ '& .MuiOutlinedInput-root': { height: 31 },
+ '& .MuiInputBase-input': { py: '3px' },
+ }}
+ />
+
+ {visibleLabelOptions.map((value) => (
))}
+ {visibleLabelOptions.length === 0 && (
+
+ )}
- {selectedLabels.length > 0 && (
-
+ {labelFilterActive && (
+
{ e.preventDefault(); e.stopPropagation(); }}
- onClick={(e) => { e.stopPropagation(); onSelectedLabelsChange([]); }}
+ onClick={(e) => {
+ e.stopPropagation();
+ onSelectedLabelsChange([]);
+ onLabelFilterTextChange('');
+ }}
sx={{
position: 'absolute',
right: 28,
diff --git a/cable_map/frontend/src/components/NodeDrawer.tsx b/cable_map/frontend/src/components/NodeDrawer.tsx
index e1105fa..29fcd03 100644
--- a/cable_map/frontend/src/components/NodeDrawer.tsx
+++ b/cable_map/frontend/src/components/NodeDrawer.tsx
@@ -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';
@@ -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(() => {
@@ -154,6 +157,14 @@ function NodeDrawer({ node, expanded, onExpandedChange, resetSizeSignal, onOpenN
)}
+ {installGuide && (
+
+
+
+
+
+ )}
onExpandedChange(false)} size="small">
diff --git a/cable_map/frontend/src/domain/platform/installGuides.ts b/cable_map/frontend/src/domain/platform/installGuides.ts
new file mode 100644
index 0000000..6e3406d
--- /dev/null
+++ b/cable_map/frontend/src/domain/platform/installGuides.ts
@@ -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} `);
+ });
+}
diff --git a/cable_map/frontend/src/hooks/useLabelFilter.ts b/cable_map/frontend/src/hooks/useLabelFilter.ts
index e29c04e..7c3f36d 100644
--- a/cable_map/frontend/src/hooks/useLabelFilter.ts
+++ b/cable_map/frontend/src/hooks/useLabelFilter.ts
@@ -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';
@@ -10,6 +10,26 @@ type LabelFilterDeps = {
largeTopology: boolean;
};
+function labelSetHasSelected(labels: Set, selected: Set): boolean {
+ if (selected.size === 0) return true;
+ for (const label of selected) if (labels.has(label)) return true;
+ return false;
+}
+
+function labelSetMatchesTerms(labels: Set, 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.
@@ -20,6 +40,13 @@ export function useLabelFilter(
{ nodes, ready, fitNodes, largeTopology }: LabelFilterDeps,
) {
const [selectedLabels, setSelectedLabels] = useState([]); // [] = 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(
() => {
@@ -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)
@@ -44,15 +72,14 @@ export function useLabelFilter(
});
}, [nodeLabelOptions]);
- const filterActive = selectedLabels.length > 0;
+ const filterActive = selectedLabels.length > 0 || labelFilterTerms.length > 0;
const shown = useMemo | null>(() => {
if (!filterActive) return null;
const sel = new Set(selectedLabels);
const out = new Set();
- 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.
@@ -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.
@@ -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 };
}
diff --git a/cable_map/manifest.yaml b/cable_map/manifest.yaml
index 8868e9f..d5b1f0f 100644
--- a/cable_map/manifest.yaml
+++ b/cable_map/manifest.yaml
@@ -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