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