diff --git a/Makefile b/Makefile index c3c5be4..c6abb36 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,8 @@ PROJECT_TARGETS += dev PROJECT_TARGETS += dev-cluster PROJECT_TARGETS += preview PROJECT_TARGETS += lint +PROJECT_TARGETS += ui-test +PROJECT_TARGETS += frontpanel-labels-check PROJECT_TARGETS += ts-contract PROJECT_TARGETS += ts-contract-check PROJECT_TARGETS += ui-build @@ -65,6 +67,16 @@ HELP_lint := Run frontend lint checks lint: $(FRONTEND) lint +.PHONY: ui-test +HELP_ui-test := Run frontend unit tests +ui-test: + $(FRONTEND) test + +.PHONY: frontpanel-labels-check +HELP_frontpanel-labels-check := Fail if generated front-panel silkscreen labels are stale +frontpanel-labels-check: + $(FRONTEND) check:frontpanel-labels + TYGO ?= github.com/gzuidhof/tygo@v0.2.21 .PHONY: ts-contract @@ -119,7 +131,7 @@ deploy-local: .PHONY: check HELP_check := Run lint, the contract-drift guard, Go tests, and a full embedded binary build -check: lint ts-contract-check test build +check: lint ui-test frontpanel-labels-check ts-contract-check test build .PHONY: clean HELP_clean := Remove generated frontend, embedded frontend, and operator build output diff --git a/cable_map/frontend/dev/fixtures/mixed-sros-topology.json b/cable_map/frontend/dev/fixtures/mixed-sros-topology.json index d633e50..ca15106 100644 --- a/cable_map/frontend/dev/fixtures/mixed-sros-topology.json +++ b/cable_map/frontend/dev/fixtures/mixed-sros-topology.json @@ -280,6 +280,15 @@ "type": "interSwitch", "lldpStatus": "ok", "lldpPeer": "mix-sros-sr2se-m36-mse24 1/1/c1/1" + }, + { + "label": "ethernet-1-25", + "desc": "dcgw fabric", + "remoteNode": "pod1", + "remotePort": "1/1/c1/1", + "type": "interSwitch", + "lldpStatus": "ok", + "lldpPeer": "pod1 1/1/c1/1" } ], "raw": { @@ -1082,6 +1091,160 @@ } } }, + { + "name": "pod1", + "platform": "7750 SR-1s", + "os": "sros", + "role": "dcgw", + "tier": 4, + "namespace": "dev-mixed-sros", + "version": "dev-mixed-sros", + "labels": { + "eda.nokia.com/role": "dcgw", + "eda.nokia.com/os": "sros", + "eda.nokia.com/dev-fixture": "mixed-sros", + "eda.nokia.com/pod": "pod1", + "eda.nokia.com/fabric-node-group": "dcgw" + }, + "table": [ + { + "label": "1/1/c1/1", + "desc": "dcgw fabric", + "remoteNode": "mix-spine-srl", + "remotePort": "ethernet-1-25", + "type": "interSwitch", + "lldpStatus": "ok", + "lldpPeer": "mix-spine-srl ethernet-1-25" + } + ], + "raw": { + "apiVersion": "core.eda.nokia.com/v1", + "kind": "TopoNode", + "metadata": { + "name": "pod1", + "namespace": "dev-mixed-sros", + "labels": { + "eda.nokia.com/role": "dcgw", + "eda.nokia.com/os": "sros", + "eda.nokia.com/dev-fixture": "mixed-sros", + "eda.nokia.com/pod": "pod1", + "eda.nokia.com/fabric-node-group": "dcgw" + } + }, + "spec": { + "platform": "7750 SR-1s", + "operatingSystem": "sros", + "component": [ + { + "kind": "lineCard", + "type": "xcm-1s", + "slot": "1" + }, + { + "kind": "mda", + "type": "s18-100gb-qsfp28", + "slot": "1-a" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-1" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-2" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-3" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-4" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-5" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-6" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-7" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-8" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-9" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-10" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-11" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-12" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-13" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-14" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-15" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-16" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-17" + }, + { + "kind": "connector", + "type": "c1-100g", + "slot": "1-a-18" + } + ] + }, + "status": { + "platform": "7750 SR-1s", + "operatingSystem": "sros", + "version": "24.7.r1", + "node-state": "connected" + } + } + }, { "name": "mix-sros-sr1-46s", "platform": "7750 SR-1", @@ -2349,6 +2512,14 @@ "sourcePort": "1/1/c2/1", "targetPort": "1/1/c22/1", "type": "interSwitch" + }, + { + "id": "mix-pod1-mix-spine-srl-44", + "source": "pod1", + "target": "mix-spine-srl", + "sourcePort": "1/1/c1/1", + "targetPort": "ethernet-1-25", + "type": "interSwitch" } ], "lags": { @@ -2438,6 +2609,9 @@ "eda.nokia.com/dev-fixture": [ "mixed-sros" ], + "eda.nokia.com/fabric-node-group": [ + "dcgw" + ], "eda.nokia.com/os": [ "srl", "sros" @@ -2449,6 +2623,7 @@ "eda.nokia.com/pod": [ "edge", "pod-00", + "pod1", "sros-variants" ], "eda.nokia.com/rack": [ @@ -2474,6 +2649,7 @@ ], "eda.nokia.com/role": [ "borderleaf", + "dcgw", "leaf", "spine", "superspine" diff --git a/cable_map/frontend/package.json b/cable_map/frontend/package.json index d988d21..2cf07ae 100644 --- a/cable_map/frontend/package.json +++ b/cable_map/frontend/package.json @@ -6,9 +6,12 @@ "type": "module", "scripts": { "dev": "vite", + "test": "vitest run", "build": "tsc -b && vite build", "lint": "oxlint .", "preview": "vite preview", + "gen:frontpanel-labels": "node scripts/gen-frontpanel-labels.mjs", + "check:frontpanel-labels": "node scripts/gen-frontpanel-labels.mjs --check", "gen:golden-topology": "node scripts/gen-golden-topology.mjs" }, "devDependencies": { @@ -30,6 +33,7 @@ "react-dom": "^19.2.6", "typescript": "~6.0.2", "vite": "^8.0.12", + "vitest": "^4.1.10", "write-excel-file": "^4.1.1" } } diff --git a/cable_map/frontend/pnpm-lock.yaml b/cable_map/frontend/pnpm-lock.yaml index a41aea3..a25b1a7 100644 --- a/cable_map/frontend/pnpm-lock.yaml +++ b/cable_map/frontend/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: vite: specifier: ^8.0.12 version: 8.0.16(@types/node@24.13.2) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)) write-excel-file: specifier: ^4.1.1 version: 4.1.1 @@ -522,9 +525,15 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} @@ -543,6 +552,12 @@ packages: '@types/d3-zoom@3.0.8': resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@24.13.2': resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} @@ -581,6 +596,35 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@xyflow/react@12.11.0': resolution: {integrity: sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==} peerDependencies: @@ -600,6 +644,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -608,6 +656,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + classcat@5.0.5: resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} @@ -618,6 +670,9 @@ packages: convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} @@ -689,10 +744,20 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -833,6 +898,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@14.0.0: resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} engines: {node: '>= 18'} @@ -853,6 +921,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + oxlint@1.70.0: resolution: {integrity: sha512-D6JgHtzkhRwvEC+A0Nw5AEc5bk8x5i1pHzvZIEf/a0C4hOzmAACNGtkDGPyFaxxX3ZVGxCPeig3P3rMM8XU3/g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -881,6 +953,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -933,6 +1008,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -941,9 +1019,15 @@ packages: resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -951,10 +1035,21 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1014,6 +1109,52 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + write-excel-file@4.1.1: resolution: {integrity: sha512-MUnCnNtQrcZek832ZcU24uU0rSphFmKPD1DvIjXOlygVb93CV7Tme6H3jUTkxsMmjB2W7HIzERzjqTi5kui71A==} engines: {node: '>=18'} @@ -1425,11 +1566,18 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-color@3.1.3': {} '@types/d3-drag@3.0.7': @@ -1451,6 +1599,10 @@ snapshots: '@types/d3-interpolate': 3.0.4 '@types/d3-selection': 3.0.11 + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/node@24.13.2': dependencies: undici-types: 7.18.2 @@ -1479,6 +1631,47 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.0.16(@types/node@24.13.2) + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.0.16(@types/node@24.13.2))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@24.13.2) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@xyflow/react@12.11.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@xyflow/system': 0.0.77 @@ -1506,6 +1699,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.29.7 @@ -1514,12 +1709,16 @@ snapshots: callsites@3.1.0: {} + chai@6.2.2: {} + classcat@5.0.5: {} clsx@2.1.1: {} convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} + cosmiconfig@7.1.0: dependencies: '@types/parse-json': 4.0.2 @@ -1587,8 +1786,16 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.0: {} + escape-string-regexp@4.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1688,6 +1895,10 @@ snapshots: dependencies: js-tokens: 4.0.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + marked@14.0.0: {} monaco-editor@0.55.1: @@ -1701,6 +1912,8 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.3: {} + oxlint@1.70.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.70.0 @@ -1738,6 +1951,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -1806,21 +2021,33 @@ snapshots: scheduler@0.27.0: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} source-map@0.5.7: {} + stackback@0.0.2: {} + state-local@1.0.7: {} + std-env@4.2.0: {} + stylis@4.2.0: {} supports-preserve-symlinks-flag@1.0.0: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tslib@2.8.1: optional: true @@ -1843,6 +2070,38 @@ snapshots: '@types/node': 24.13.2 fsevents: 2.3.3 + vitest@4.1.10(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@24.13.2)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@24.13.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.2 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + write-excel-file@4.1.1: dependencies: fflate: 0.8.3 diff --git a/cable_map/frontend/scripts/gen-frontpanel-labels.mjs b/cable_map/frontend/scripts/gen-frontpanel-labels.mjs new file mode 100644 index 0000000..d373d5a --- /dev/null +++ b/cable_map/frontend/scripts/gen-frontpanel-labels.mjs @@ -0,0 +1,92 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const panelsDir = path.resolve(here, '../../operators/cable-map/frontpanel'); +const output = path.resolve(here, '../src/generated/frontpanel-labels.json'); +const check = process.argv.includes('--check'); + +const attr = (tag, name) => { + const match = new RegExp(`\\b${name}=(?:"([^"]*)"|'([^']*)')`).exec(tag); + return match?.[1] ?? match?.[2] ?? null; +}; + +function labelStyles(svg, file) { + const scopes = []; + const svgStack = []; + let capture = null; + + for (const token of svg.match(/<[^>]+>|[^<]+/g) ?? []) { + if (!token.startsWith('<')) { + if (capture) capture.text += token; + continue; + } + if (/^ 1 ? parts.slice(0, -1).join('-') : ''; + const group = byPrefix.get(prefix) ?? { numbers: new Set(), labels: new Set(scope.labels) }; + group.numbers.add(number); + byPrefix.set(prefix, group); + } + for (const [prefix, group] of byPrefix) { + let c = 0, bare = 0; + for (const number of group.numbers) { + if (group.labels.has(`c${number}`)) c += 1; + if (group.labels.has(number)) bare += 1; + } + if (c === bare) continue; + const style = c > bare ? 'c' : ''; + if (Object.hasOwn(out, prefix) && out[prefix] !== style) { + throw new Error(`${file}: conflicting label styles for panel slot ${prefix || '(fixed)'}`); + } + out[prefix] = style; + } + } + return out; +} + +const result = {}; +for (const file of fs.readdirSync(panelsDir).filter((name) => name.endsWith('.svg')).toSorted()) { + const platform = file.slice(0, -4); + const styles = labelStyles(fs.readFileSync(path.join(panelsDir, file), 'utf8'), file); + if (Object.keys(styles).length) result[platform] = styles; +} + +const text = `${JSON.stringify(result)}\n`; +if (check) { + const current = fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : ''; + if (current !== text) { + console.error('frontpanel-labels.json is stale; run pnpm gen:frontpanel-labels'); + process.exit(1); + } +} else { + fs.writeFileSync(output, text); + console.log(`wrote ${output} (${Object.keys(result).length} platforms)`); +} diff --git a/cable_map/frontend/scripts/gen-golden-topology.mjs b/cable_map/frontend/scripts/gen-golden-topology.mjs index 1ba4b4f..7de9720 100644 --- a/cable_map/frontend/scripts/gen-golden-topology.mjs +++ b/cable_map/frontend/scripts/gen-golden-topology.mjs @@ -581,6 +581,20 @@ function buildMixedSrosTopology() { os: 'srl', labels: { 'eda.nokia.com/pod': 'pod-00', 'eda.nokia.com/rack': 'rack-06' }, }); + addNode({ + name: 'pod1', + role: 'dcgw', + platform: '7750 SR-1s', + os: 'sros', + labels: { 'eda.nokia.com/pod': 'pod1', 'eda.nokia.com/fabric-node-group': 'dcgw' }, + components: [ + { kind: 'lineCard', type: 'xcm-1s', slot: '1' }, + { kind: 'mda', type: 's18-100gb-qsfp28', slot: '1-a' }, + ...Array.from({ length: 18 }, (_, index) => ({ + kind: 'connector', type: 'c1-100g', slot: `1-a-${index + 1}`, + })), + ], + }); const srosVariants = []; function addSrosVariant(name, platform, components) { @@ -640,6 +654,9 @@ function buildMixedSrosTopology() { addLink(leaf, 'mix-spine-srl', 'leaf fabric A'); addLink(leaf, 'mix-spine-sros', 'leaf fabric B'); } + // Live SR OS regression: connector 1/channel 1 on the dcgw lands on SR Linux port 25. + nextPort.set('mix-spine-srl', 25); + addLink('pod1', 'mix-spine-srl', 'dcgw fabric'); addEdge('mix-leaf-d3l', 'single attached telemetry host', 'telemetry-01'); const localLag = 'lag-mix-border-tools-local'; diff --git a/cable_map/frontend/src/NodeView.tsx b/cable_map/frontend/src/NodeView.tsx index 4c1b010..afba3fb 100644 --- a/cable_map/frontend/src/NodeView.tsx +++ b/cable_map/frontend/src/NodeView.tsx @@ -10,7 +10,7 @@ import TableCell from '@mui/material/TableCell'; import Link from '@mui/material/Link'; import FrontPanel from './FrontPanel'; import RemoteCell from './RemoteCell'; -import { cageKey } from './domain/ports/interfaceNames'; +import { cageKey, detailedPortLabel } from './domain/ports/interfaceNames'; import { slottedOf } from './domain/ports/frontPanelMeta'; import type { Node, Row } from './domain/topology/contract'; import { stencilFor, componentsOf } from './stencil'; @@ -42,7 +42,7 @@ export default function NodeView({ node, rows, showHeader = true, interactive = // panel highlights match the stencil's data-port on both fixed and modular chassis. const stencil = stencilFor(node.platform, componentsOf(node.raw)); const slotted = slottedOf(stencil); - const occupied = rows.map((r) => cageKey(r.label, slotted)).filter((c): c is string => c != null); + const occupied = rows.map((r) => cageKey(r.label, slotted, stencil)).filter((c): c is string => c != null); // natural order: digit runs compare numerically (ethernet-1-2 before ethernet-1-10), slot-aware const sorted = rows.toSorted((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true })); // print leaves empty cells blank (room to write); the drawer shows a dash @@ -77,14 +77,14 @@ export default function NodeView({ node, rows, showHeader = true, interactive = {sorted.map((r) => { - const cage = cageKey(r.label, slotted); + const cage = cageKey(r.label, slotted, stencil); const isHot = interactive && hovered != null && cage === hovered; return ( interactive && cage && setHovered(cage)} onMouseLeave={() => interactive && setHovered(null)}> - {r.label} + {interactive ? detailedPortLabel(r.label) : r.label} {r.desc || blank} {r.type === 'interSwitch' ? 'ISL' : r.type === 'edge' ? 'edge' : r.type} @@ -112,7 +112,7 @@ export default function NodeView({ node, rows, showHeader = true, interactive = : blank} - {r.remotePort || blank} + {r.remotePort ? (interactive ? detailedPortLabel(r.remotePort) : r.remotePort) : blank} )} diff --git a/cable_map/frontend/src/SwitchNode.tsx b/cable_map/frontend/src/SwitchNode.tsx index 2d0b74e..71e7d7c 100644 --- a/cable_map/frontend/src/SwitchNode.tsx +++ b/cable_map/frontend/src/SwitchNode.tsx @@ -2,9 +2,11 @@ import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState, type CSS import { Handle, Position, type NodeProps } from '@xyflow/react'; import { CABLE_COLOR, LLDP_COLOR, COMPACT_SWITCH_H, COMPACT_SWITCH_W, - breakoutGrid, compactSwitchHandle, panelDims, portHandle, type PortInfo, type SwitchData, + breakoutCellBox, breakoutGrid, compactSwitchHandle, panelDims, portHandle, type PortInfo, type SwitchData, } from './graph'; import type { Breakout } from './domain/topology/contract'; +import type { ConnectorInfo } from './domain/ports/srosPorts'; +import { detailedPortLabel } from './domain/ports/interfaceNames'; import { trace, useTrace, modeFor } from './trace'; import { useLldp } from './lldp'; import spineIcon from './assets/icons/spine.svg?raw'; @@ -74,9 +76,11 @@ function PortCell({ info, hot, dim, lldp, showEdgeStub, box, label, title, visua const id = portHandle(info.port, info.channel); const stubVisible = showsEdgeStub(info, showEdgeStub) && !hot; // responsive label: fit the number to the cell in BOTH axes (digit-aware) so dense composite panels - // never overflow their tiny cages; it scales up with canvas zoom. grid mode stays at a flat 7px. + // never overflow their tiny cages. Breakout channel text is deliberately smaller than a cage label. const digits = Math.max(1, String(label ?? info.port).length); - const fontSize = box ? Math.max(4, Math.min(13, box.height * 0.9, box.width / (digits * 0.62))) : 7; + const minFont = channel ? 3 : 4; + const maxFont = channel ? 6.5 : 13; + const fontSize = box ? Math.max(minFont, Math.min(maxFont, box.height * 0.8, box.width / (digits * 0.62))) : channel ? 5 : 7; return (
{ e.stopPropagation(); onSelectNode?.(true); }} - title={title ?? (free ? `port ${info.port} · free` : `${info.port} → ${info.remote} ${info.remotePort}`)} + title={title ?? (free + ? `port ${info.port} · free` + : `${info.interfaceLabel ?? info.port}${info.physicalAlias ? ` (${info.physicalAlias})` : ''} → ${info.remote} ${detailedPortLabel(info.remotePort)}`)} style={{ ...(box ? { position: 'absolute', left: box.left, top: box.top, width: box.width, height: box.height } : { position: 'relative', width: CELL, height: CELL }), @@ -98,12 +104,12 @@ function PortCell({ info, hot, dim, lldp, showEdgeStub, box, label, title, visua display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: '"NokiaPureText", sans-serif', // match the SVG silkscreen labels fontSize, lineHeight: 1, fontWeight: 700, - overflow: 'visible', - color: free ? 'var(--cell-free-fg)' : 'var(--bg)', + overflow: channel ? 'hidden' : 'visible', + color: free ? 'var(--muted)' : 'var(--bg)', background: free ? 'var(--cell-free-bg)' : fill, // occupied ports ring in their own cable colour; the traced port rings white outline: channel ? 'none' : hot ? '1.5px solid white' : free ? '1px solid var(--cell-free-line)' : `1px solid ${fill}`, - outlineOffset: hot ? 0 : -1, + outlineOffset: -1, cursor: free ? 'default' : 'pointer', opacity: visualHidden ? 0 : dim ? (free ? 0.18 : 0.24) : 1, pointerEvents: visualHidden ? 'none' : undefined, @@ -129,7 +135,7 @@ function PortCell({ info, hot, dim, lldp, showEdgeStub, box, label, title, visua }} /> )} - {(channel || !free) && (label ?? info.port)} + {(label != null || channel || !free) && (label ?? info.port)} {!free && renderHandles !== false && ( <> void; renderHandles?: boolean }) { +function BreakoutCage({ cage, displayLabel, infos, bo, connector, hot, selected, dim, lldp, showEdgeStubs, box, onSelectNode, renderHandles }: + { cage: string; displayLabel: string; infos: PortInfo[]; bo: Breakout; connector?: ConnectorInfo; hot: boolean; selected: boolean; dim: boolean; lldp: boolean; showEdgeStubs: boolean; box?: Box; onSelectNode?: (expand?: boolean) => void; renderHandles?: boolean }) { const [hovered, setHovered] = useState(false); // Per-channel trace state: subscribe to a primitive derived from this cage's cables, so unrelated // trace updates don't re-render every breakout cage. @@ -165,15 +171,11 @@ function BreakoutCage({ cage, infos, bo, hot, selected, dim, lldp, showEdgeStubs const n = Math.max(1, bo.channels); const W = box?.width ?? CELL, H = box?.height ?? CELL; const { cols, rows } = breakoutGrid(n); - const colGap = cols > 2 ? 0.5 : 1; - const rowGap = rows > 1 ? 0.5 : 0; - const sw = (W - colGap * (cols - 1)) / cols; - const sh = (H - rowGap * (rows - 1)) / rows; const byCh = useMemo(() => new Map(infos.map((i) => [i.channel ?? 1, i])), [infos]); // any channel of this cage in the active trace -> the whole cage rings white (one ring, like a port) const anyHot = hot || activeCableStr.length > 0; const label = bo.speed ? `${n}×${bo.speed} breakout` : `${n}-channel breakout`; - const digits = Math.max(1, cage.length); + const digits = Math.max(1, displayLabel.length); const fontSize = box ? Math.max(4, Math.min(13, H * 0.9, W / (digits * 0.62))) : 7; const expanded = hovered || anyHot || selected; const primary = infos[0] ?? freeCell(cage); @@ -184,70 +186,91 @@ function BreakoutCage({ cage, infos, bo, hot, selected, dim, lldp, showEdgeStubs const collapsedFree = collapsed.kind === 'free'; return (
setHovered(true)} onMouseLeave={() => setHovered(false)} - style={{ - ...(box - ? { position: 'absolute', left: box.left, top: box.top, width: W, height: H } - : { position: 'relative', width: CELL, height: CELL }), - // expanded: one ring around the whole QSFP-sized port (white when traced), with the channel - // gaps reading as thin light dividers via the cage background showing through. + style={box + ? { position: 'absolute', left: box.left, top: box.top, width: W, height: H } + : { position: 'relative', width: CELL, height: CELL }} + > +
- {Array.from({ length: n }, (_, i) => { - const ch = i + 1; - const info = byCh.get(ch) ?? freeCell(cage); - const occ = info.kind !== 'free'; - const col = i % cols; - const row = Math.floor(i / cols); - // this channel is hot when its own cable is traced; dim when the cage is dimmed (cage not in - // the trace) or a trace is active and this channel's cable isn't part of it. - const chHot = !!info.cableId && activeCableIds.has(info.cableId); - const chDim = dim || (activeCableStr.length > 0 && !chHot); - return ( - - ); - })} -
- {!collapsedFree && cage} + }), + }}> + {Array.from({ length: n }, (_, i) => { + const ch = i + 1; + const info = byCh.get(ch) ?? freeCell(cage); + const occ = info.kind !== 'free'; + const chHot = !!info.cableId && activeCableIds.has(info.cableId); + const chDim = dim || (activeCableStr.length > 0 && !chHot); + const showChannelLabel = occ || hovered || selected || anyHot; + return ( + + ); + })} + {expanded && Array.from({ length: cols - 1 }, (_, i) => ( + + ))} + {expanded && Array.from({ length: rows - 1 }, (_, i) => ( + + ))} +
+ {displayLabel} +
+ {expanded && box && H >= 10 && ( +
+ {displayLabel} +
+ )}
); } @@ -255,9 +278,9 @@ function BreakoutCage({ cage, infos, bo, hot, selected, dim, lldp, showEdgeStubs const freeCell = (port: string): PortInfo => ({ port, kind: 'free', side: 'bottom', remote: '', remotePort: '', desc: '', cableId: '' }); // One cage: a breakout splits into channel slivers, otherwise it's a single cell (free if uncabled). -function renderCage(cage: string, infos: PortInfo[] | undefined, bo: Breakout | undefined, hot: boolean, selected: boolean, dim: boolean, lldp: boolean, showEdgeStubs: boolean, box?: Box, onSelectNode?: (expand?: boolean) => void, renderHandles?: boolean) { - if (bo) return ; - return ; +function renderCage(cage: string, displayLabel: string, infos: PortInfo[] | undefined, bo: Breakout | undefined, connector: ConnectorInfo | undefined, hot: boolean, selected: boolean, dim: boolean, lldp: boolean, showEdgeStubs: boolean, box?: Box, onSelectNode?: (expand?: boolean) => void, renderHandles?: boolean) { + if (bo && bo.channels > 1) return ; + return ; } function CompactSwitchNode({ id, data, selected }: NodeProps) { @@ -342,8 +365,8 @@ function CompactSwitchNode({ id, data, selected }: NodeProps) { function SummaryPanelCanvas({ data, lldp, width, height, showLabels }: { data: SwitchData; lldp: boolean; width: number; height: number; showLabels: boolean }) { const ref = useRef(null); const byPort = useMemo(() => { - const m = new Map(); - for (const p of data.ports) if (!m.has(p.port)) m.set(p.port, p); + const m = new Map(); + for (const p of data.ports) (m.get(p.port) ?? m.set(p.port, []).get(p.port)!).push(p); return m; }, [data.ports]); const layoutKey = useMemo( @@ -373,12 +396,14 @@ function SummaryPanelCanvas({ data, lldp, width, height, showLabels }: { data: S }; const freeBg = resolveColor('var(--cell-free-bg)'); const freeLine = resolveColor('var(--cell-free-line)'); - const freeText = resolveColor('var(--cell-free-fg)'); + const freeText = resolveColor('var(--muted)'); const occupiedText = resolveColor('var(--bg)'); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const drawCell = (port: string, x: number, y: number, w: number, h: number) => { - const info = byPort.get(port); + const infos = byPort.get(port) ?? []; + const info = infos[0]; + const connector = data.connectors?.[port]; const fill = info ? resolveColor(fillFor(info, lldp)) : freeBg; ctx.fillStyle = fill; ctx.fillRect(x, y, w, h); @@ -386,7 +411,7 @@ function SummaryPanelCanvas({ data, lldp, width, height, showLabels }: { data: S ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, Math.max(0, w - 1), Math.max(0, h - 1)); if (!showLabels || w < 5 || h < 5) return; - const label = String(port); + const label = data.portLabels?.[port] ?? connector?.label ?? String(port).split('-').at(-1)!; const fontSize = Math.max(4, Math.min(9, h * 0.72, w / (Math.max(1, label.length) * 0.62))); ctx.font = `700 ${fontSize}px sans-serif`; ctx.fillStyle = info ? occupiedText : freeText; @@ -408,7 +433,7 @@ function SummaryPanelCanvas({ data, lldp, width, height, showLabels }: { data: S const col = Math.floor(i / rows); drawCell(String(i + 1), col * (CELL + CELL_GAP), row * (CELL + CELL_GAP), CELL, CELL); } - }, [byPort, data.layout, data.portCount, data.rows, height, layoutKey, lldp, showLabels, width]); + }, [byPort, data.breakouts, data.connectors, data.layout, data.portCount, data.portLabels, data.rows, height, layoutKey, lldp, showLabels, width]); return ; } @@ -486,8 +511,13 @@ function DetailedSwitchNode({ id, data, selected }: NodeProps) { }, [d.activeEdgeStubPorts, id]); const hotPorts = useMemo(() => { const out = new Set(); - for (const p of activeStr ? activeStr.split('\n') : []) out.add(p); - for (const p of expandedStubStr ? expandedStubStr.split('\n') : []) out.add(p); + const add = (port: string) => { + out.add(port); + const channelSep = port.lastIndexOf('_'); + if (channelSep > 0) out.add(port.slice(0, channelSep)); + }; + for (const p of activeStr ? activeStr.split('\n') : []) add(p); + for (const p of expandedStubStr ? expandedStubStr.split('\n') : []) add(p); return out; }, [activeStr, expandedStubStr]); const dimInactivePorts = mode === 'on' && traceAny && hotPorts.size > 0; @@ -541,14 +571,16 @@ function DetailedSwitchNode({ id, data, selected }: NodeProps) { const cw = Math.max(3, p.w * panel.W), ch = Math.max(3, p.h * panel.H); const box = { left: p.x * panel.W - cw / 2, top: p.y * panel.H - ch / 2, width: cw, height: ch }; const hot = hotPorts.has(cage); - return renderCage(cage, byPort.get(cage), d.breakouts?.[cage], hot, isSelected, dimInactivePorts && !hot, lldp, !!d.showEdgeStubs, box, openThisNode, renderHandles); + const displayLabel = d.portLabels?.[cage] ?? d.connectors?.[cage]?.label ?? cage.split('-').at(-1)!; + return renderCage(cage, displayLabel, byPort.get(cage), d.breakouts?.[cage], d.connectors?.[cage], hot, isSelected, dimInactivePorts && !hot, lldp, !!d.showEdgeStubs, box, openThisNode, renderHandles); })}
) : (
{cages.map((cage) => { const hot = hotPorts.has(cage); - return renderCage(cage, byPort.get(cage), d.breakouts?.[cage], hot, isSelected, dimInactivePorts && !hot, lldp, !!d.showEdgeStubs, undefined, openThisNode, renderHandles); + const displayLabel = d.portLabels?.[cage] ?? d.connectors?.[cage]?.label ?? cage.split('-').at(-1)!; + return renderCage(cage, displayLabel, byPort.get(cage), d.breakouts?.[cage], d.connectors?.[cage], hot, isSelected, dimInactivePorts && !hot, lldp, !!d.showEdgeStubs, undefined, openThisNode, renderHandles); })}
)} diff --git a/cable_map/frontend/src/domain/ports/frontPanelMeta.test.ts b/cable_map/frontend/src/domain/ports/frontPanelMeta.test.ts new file mode 100644 index 0000000..75c5de2 --- /dev/null +++ b/cable_map/frontend/src/domain/ports/frontPanelMeta.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { portLabelsOf } from './frontPanelMeta'; + +describe('front-panel cage labels', () => { + it('uses the SR OS stencil silkscreen instead of internal slotted cage keys', () => { + expect(portLabelsOf('7750 SR-1s s18-100gb-qsfp28')['1-1']).toBe('c1'); + expect(portLabelsOf('7750 SR-1-48D')['1']).toBe('c1'); + }); + + it('supports different label conventions in separate modular bays', () => { + const labels = portLabelsOf('7750 SR-1 me12-100gb-qsfp28_me6-100gb-qsfp28'); + expect(labels['1-1']).toBe('c1'); + expect(labels['2-1']).toBe('1'); + }); + + it('keeps numeric SR Linux labels when no generated SR OS label style exists', () => { + expect(portLabelsOf('7220 IXR-H4')['1']).toBe('1'); + }); +}); diff --git a/cable_map/frontend/src/domain/ports/frontPanelMeta.ts b/cable_map/frontend/src/domain/ports/frontPanelMeta.ts index cf59fab..1e93222 100644 --- a/cable_map/frontend/src/domain/ports/frontPanelMeta.ts +++ b/cable_map/frontend/src/domain/ports/frontPanelMeta.ts @@ -1,4 +1,5 @@ import frontpanelMeta from '../../generated/frontpanel-meta.json'; +import frontpanelLabels from '../../generated/frontpanel-labels.json'; export type FrontPanelPortPos = { p: string; x: number; y: number; w: number; h: number }; export type FrontPanelMeta = { @@ -10,6 +11,8 @@ export type FrontPanelMeta = { }; const FP_META = frontpanelMeta as Record; +const FP_LABEL_STYLE = frontpanelLabels as Record>; +const PORT_LABEL_CACHE = new Map>(); export const frontPanelPlatforms = (): string[] => Object.keys(FP_META); export const frontPanelMetaOf = (platform: string): FrontPanelMeta | undefined => FP_META[platform]; @@ -26,3 +29,20 @@ export const layoutOf = (platform: string): FrontPanelPortPos[] | undefined => { export const aspectOf = (platform: string): number => FP_META[platform]?.aspect ?? 4; // modular chassis whose cages are addressed per slot ("-"); drives cageKey() parsing. export const slottedOf = (platform: string): boolean => FP_META[platform]?.slotted ?? false; + +// User-facing cage labels copied from the stencil's baked silkscreen. The layout key remains the +// unique internal address (e.g. "2-1"), while the visible label is "c1" or "1" for that card. +export const portLabelsOf = (platform: string): Record => { + const cached = PORT_LABEL_CACHE.get(platform); + if (cached) return cached; + const styles = FP_LABEL_STYLE[platform]; + const labels: Record = {}; + for (const port of FP_META[platform]?.layout ?? []) { + const parts = port.p.split('-'); + const number = parts.at(-1)!; + const slot = parts.length > 1 ? parts.slice(0, -1).join('-') : ''; + labels[port.p] = `${styles?.[slot] ?? ''}${number}`; + } + PORT_LABEL_CACHE.set(platform, labels); + return labels; +}; diff --git a/cable_map/frontend/src/domain/ports/interfaceNames.test.ts b/cable_map/frontend/src/domain/ports/interfaceNames.test.ts new file mode 100644 index 0000000..461d1e6 --- /dev/null +++ b/cable_map/frontend/src/domain/ports/interfaceNames.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + breakoutChannel, + cageKey, + detailedPortLabel, + edaPhysicalAlias, + parsePortAddress, +} from './interfaceNames'; + +describe('SR OS interface names', () => { + it('parses native and EDA physical forms without confusing SR Linux names', () => { + expect(parsePortAddress('1/2/c3/4')).toMatchObject({ + kind: 'sros-native', slot: 1, mda: 2, connector: 3, channel: 4, + }); + expect(parsePortAddress('ethernet-1-b-3-4')).toMatchObject({ + kind: 'eda-physical', slot: 1, mda: 2, connector: 3, channel: 4, + }); + expect(parsePortAddress('ethernet-1-25')).toMatchObject({ kind: 'ethernet', slot: 1, port: 25 }); + }); + + it('keeps native SR OS first while exposing the EDA physical alias', () => { + expect(edaPhysicalAlias('1/1/c1/1')).toBe('1-a-1-1'); + expect(detailedPortLabel('1/1/c1/1')).toBe('1/1/c1/1 (1-a-1-1)'); + expect(detailedPortLabel('ethernet-1-25')).toBe('ethernet-1-25'); + }); + + it('maps fixed, two-bay, and four-bay chassis to their stencil cages', () => { + expect(cageKey('1/1/c3/4', false, '7750 SR-1-48D')).toBe('3'); + expect(cageKey('1/2/c3/4', true, '7750 SR-1 me12-100gb-qsfp28_me6-100gb-qsfp28')).toBe('2-3'); + expect(cageKey('2/2/c3/4', true, '7750 SR-2s ms16_ms8')).toBe('4-3'); + expect(cageKey('ethernet-1-25', false, '7220 IXR-H4')).toBe('25'); + expect(cageKey('ethernet-1-25', true, 'modular')).toBe('1-25'); + }); + + it('extracts breakout channels from both vendors without treating the cage as a channel', () => { + expect(breakoutChannel('1/1/c3/4')).toBe(4); + expect(breakoutChannel('ethernet-1-8-4')).toBe(4); + expect(breakoutChannel('ethernet-1-25')).toBeNull(); + }); +}); diff --git a/cable_map/frontend/src/domain/ports/interfaceNames.ts b/cable_map/frontend/src/domain/ports/interfaceNames.ts index 80eed36..a22042f 100644 --- a/cable_map/frontend/src/domain/ports/interfaceNames.ts +++ b/cable_map/frontend/src/domain/ports/interfaceNames.ts @@ -1,5 +1,73 @@ const srosConnector = (label: string) => label.match(/(?:^|[/_-])c(\d+)(?:[/_-]|$)/i)?.[1] ?? null; +export type PortAddress = { + kind: 'sros-native' | 'eda-physical' | 'ethernet'; + slot?: number; + mda?: number; + connector?: number; + port: number; + channel?: number; +}; + +const nativeSrosRE = /^(\d+)\/(\d+)\/c(\d+)(?:\/(\d+))?$/i; +const edaPhysicalRE = /^(?:ethernet-)?(\d+)-([a-z])-(\d+)(?:-(\d+))?$/i; + +const mdaNumber = (letter: string): number => letter.toLowerCase().charCodeAt(0) - 96; +const mdaLetter = (number: number): string | null => + number >= 1 && number <= 26 ? String.fromCharCode(96 + number) : null; + +// Parse the three interface forms the UI needs to correlate: +// native SR OS (1/1/c1/1), EDA physical (ethernet-1-a-1-1), and SR Linux ethernet-1-1[-1]. +export function parsePortAddress(label: string): PortAddress | null { + const value = label.trim(); + const native = nativeSrosRE.exec(value); + if (native) { + return { + kind: 'sros-native', + slot: Number(native[1]), + mda: Number(native[2]), + connector: Number(native[3]), + port: Number(native[3]), + channel: native[4] == null ? undefined : Number(native[4]), + }; + } + + const physical = edaPhysicalRE.exec(value); + if (physical) { + return { + kind: 'eda-physical', + slot: Number(physical[1]), + mda: mdaNumber(physical[2]), + connector: Number(physical[3]), + port: Number(physical[3]), + channel: physical[4] == null ? undefined : Number(physical[4]), + }; + } + + const ns = value.match(/\d+/g)?.map(Number); + if (!ns?.length) return null; + return { + kind: 'ethernet', + slot: ns.length >= 2 ? ns[0] : undefined, + port: ns.length >= 2 ? ns[1] : ns[0], + channel: ns.length >= 3 ? ns[2] : undefined, + }; +} + +// Native SR OS -> EDA's physical connector/channel spelling, without the redundant ethernet prefix. +export function edaPhysicalAlias(label: string): string | null { + const address = parsePortAddress(label); + if (!address || address.kind !== 'sros-native' || address.slot == null || address.mda == null || address.connector == null) return null; + const letter = mdaLetter(address.mda); + if (!letter) return null; + return `${address.slot}-${letter}-${address.connector}${address.channel == null ? '' : `-${address.channel}`}`; +} + +export function detailedPortLabel(label: string): string { + const alias = edaPhysicalAlias(label); + return alias ? `${label} (${alias})` : label; +} + // EDA/SROS interface name -> front-panel cage index. For SRL names like ethernet-1-12, // the cage is the 2nd number. For native SROS connector names like 1/1/c12/1, the cage // is the c-number; the following number is a breakout channel and must not be captured. @@ -13,22 +81,29 @@ export function portIdx(label: string): number | null { // Front-panel cage id matching the stencil's data-port. On modular (slotted) chassis a cage is // addressed per slot, so the key is "-"; on fixed chassis it's just the port. -// Native SROS 1/1/c6/1 maps to "1-6" when slotted and "6" otherwise. -export function cageKey(label: string, slotted: boolean): string | null { - const ns = label.match(/\d+/g); - if (!ns) return null; - const connector = srosConnector(label); - const port = connector ?? (ns.length >= 2 ? ns[1] : ns[0]); - if (slotted) return ns.length >= 2 ? `${ns[0]}-${port}` : port; - return port; +// Native SROS 1/1/c6/1 maps to the stencil's panel-slot/cage key when modular and "6" otherwise. +export function cageKey(label: string, slotted: boolean, platform = ''): string | null { + const address = parsePortAddress(label); + if (!address) return null; + if (!slotted) return String(address.connector ?? address.port); + + if ((address.kind === 'sros-native' || address.kind === 'eda-physical') && address.mda != null) { + // SR-1/SR-1s composites number their two MDA bays 1/2. SR-2s/SR-2se flatten + // XCM 1 a/b and XCM 2 a/b into the stencil's four panel slots 1..4. + const panelSlot = /^7750 SR-2s(?:\s|$)|^7750 SR-2se(?:\s|$)/.test(platform) + ? 2 * Math.max(0, (address.slot ?? 1) - 1) + address.mda + : /^7750 SR-1(?:s)?(?:\s|$)/.test(platform) + ? address.mda + : address.slot ?? address.mda; + return `${panelSlot}-${address.connector ?? address.port}`; + } + + return address.slot == null ? String(address.port) : `${address.slot}-${address.port}`; } // Breakout channel (1-based) if this interface is a broken-out sub-port, else null. export function breakoutChannel(label: string): number | null { - const nativeSros = label.match(/(?:^|[/_-])c\d+[/_-](\d+)$/i); - if (nativeSros) return Number(nativeSros[1]); - const ns = label.match(/\d+/g); - return ns && ns.length >= 3 ? Number(ns[2]) : null; + return parsePortAddress(label)?.channel ?? null; } // ethernet-1-12 -> "1-12" (matches the on-panel cage labelling) diff --git a/cable_map/frontend/src/domain/ports/srosPorts.test.ts b/cable_map/frontend/src/domain/ports/srosPorts.test.ts new file mode 100644 index 0000000..8b3d3d2 --- /dev/null +++ b/cable_map/frontend/src/domain/ports/srosPorts.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import type { Node } from '../topology/contract'; +import { mergeBreakouts, srosPortModel } from './srosPorts'; + +const node = (components: unknown[]): Node => ({ + name: 'sr1', platform: '7750 SR-1', os: 'sros', role: 'leaf', table: [], + raw: { spec: { component: components } }, +}); + +describe('SR OS connector inventory', () => { + it('derives c1 and c10 capacity and ignores malformed connector types', () => { + const model = srosPortModel(node([ + { kind: 'connector', slot: '1-a-1', type: 'c10-10g' }, + { kind: 'connector', slot: '1-a-2', type: 'c1-100g' }, + { kind: 'connector', slot: '1-a-3', type: 'invalid' }, + ]), '7750 SR-1 me6-100gb-qsfp28_blank'); + + expect(model.connectors['1-1']).toMatchObject({ label: 'c1', channels: 10, speed: '10g', physical: '1-a-1' }); + expect(model.breakouts['1-1']).toEqual({ channels: 10, speed: '10g' }); + expect(model.connectors['1-2']).toMatchObject({ label: 'c2', channels: 1, speed: '100g' }); + expect(model.breakouts['1-2']).toBeUndefined(); + expect(model.connectors['1-3']).toBeUndefined(); + }); + + it('maps an SR-2 second-XCM second-bay connector to panel slot four', () => { + const model = srosPortModel({ ...node([ + { kind: 'connector', slot: '2-b-7', type: 'c4-100g' }, + ]), platform: '7750 SR-2s' }, '7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28'); + expect(model.connectors['4-7']).toMatchObject({ label: 'c7', channels: 4 }); + }); + + it('does not derive connector capacity for SR Linux nodes', () => { + const model = srosPortModel({ ...node([{ kind: 'connector', slot: '1-a-1', type: 'c10-10g' }]), os: 'srl' }, '7750 SR-1 me6-100gb-qsfp28_blank'); + expect(model).toEqual({ connectors: {}, breakouts: {} }); + }); + + it('lets SR OS connector capacity override explicit or inferred data for the same cage', () => { + expect(mergeBreakouts( + { '1-1': { channels: 4, speed: '25G' }, '1-2': { channels: 2, speed: '50G' } }, + { '1-1': { channels: 10, speed: '10g' } }, + { '1-1': { channels: 3, speed: '' } }, + )).toEqual({ + '1-1': { channels: 10, speed: '10g' }, + '1-2': { channels: 2, speed: '50G' }, + }); + }); +}); diff --git a/cable_map/frontend/src/domain/ports/srosPorts.ts b/cable_map/frontend/src/domain/ports/srosPorts.ts new file mode 100644 index 0000000..3f966a5 --- /dev/null +++ b/cable_map/frontend/src/domain/ports/srosPorts.ts @@ -0,0 +1,63 @@ +import type { Breakout, Node } from '../topology/contract'; +import { componentsOf } from '../../stencil'; +import { slottedOf } from './frontPanelMeta'; +import { cageKey, parsePortAddress } from './interfaceNames'; + +export type ConnectorInfo = { + cage: string; + label: string; + channels: number; + speed: string; + physical: string; +}; + +export type SrosPortModel = { + connectors: Record; + breakouts: Record; +}; + +const EMPTY_MODEL: SrosPortModel = { connectors: {}, breakouts: {} }; +const connectorTypeRE = /^c(\d+)-(.+)$/i; + +// Connector inventory is the SR OS source of truth for physical channel capacity. Building this +// once per node keeps component/address parsing out of the dense per-cage React render path. +export function srosPortModel(node: Node, stencil: string): SrosPortModel { + if (node.os.toLowerCase() !== 'sros') return EMPTY_MODEL; + + const connectors: Record = {}; + const breakouts: Record = {}; + const slotted = slottedOf(stencil); + + for (const component of componentsOf(node.raw) ?? []) { + if (component.kind !== 'connector' || !component.slot || !component.type) continue; + const address = parsePortAddress(component.slot); + const type = connectorTypeRE.exec(component.type.trim()); + if (!address || address.kind !== 'eda-physical' || !type) continue; + + const channels = Number(type[1]); + if (!Number.isInteger(channels) || channels < 1) continue; + const cage = cageKey(component.slot, slotted, stencil); + if (!cage) continue; + + const info: ConnectorInfo = { + cage, + label: `c${address.connector ?? address.port}`, + channels, + speed: type[2], + physical: component.slot, + }; + connectors[cage] = info; + if (channels > 1) breakouts[cage] = { channels, speed: type[2] }; + } + + return { connectors, breakouts }; +} + +export function mergeBreakouts( + explicit: Record | undefined, + connector: Record, + inferred: Record | undefined, +): Record | undefined { + const merged = { ...explicit, ...inferred, ...connector }; + return Object.keys(merged).length ? merged : undefined; +} diff --git a/cable_map/frontend/src/generated/frontpanel-labels.json b/cable_map/frontend/src/generated/frontpanel-labels.json new file mode 100644 index 0000000..481ad55 --- /dev/null +++ b/cable_map/frontend/src/generated/frontpanel-labels.json @@ -0,0 +1 @@ +{"7250 IXR-X1b":{"":"c"},"7250 IXR-X3b":{"":"c"},"7730 SXR-1X-44S":{"":"c"},"7750 SR-1 blank_me12-100gb-qsfp28":{"2":"c"},"7750 SR-1 blank_me16-25gb-sfp28-2-100gb-qsfp-b":{"2":"c"},"7750 SR-1 blank_me16-25gb-sfp28-2-100gb-qsfp28":{"2":"c"},"7750 SR-1 blank_me3-200gb-cfp2-dco":{"2":""},"7750 SR-1 blank_me3-400gb-qsfpdd":{"2":"c"},"7750 SR-1 blank_me6-100gb-qsfp28":{"2":""},"7750 SR-1 blank_me6-400gb-qsfpdd":{"2":"c"},"7750 SR-1 me12-100gb-qsfp28_blank":{"1":"c"},"7750 SR-1 me12-100gb-qsfp28_me12-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1 me12-100gb-qsfp28_me16-25gb-sfp28-2-100gb-qsfp-b":{"1":"c","2":"c"},"7750 SR-1 me12-100gb-qsfp28_me16-25gb-sfp28-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1 me12-100gb-qsfp28_me3-200gb-cfp2-dco":{"1":"c","2":""},"7750 SR-1 me12-100gb-qsfp28_me3-400gb-qsfpdd":{"1":"c","2":"c"},"7750 SR-1 me12-100gb-qsfp28_me6-100gb-qsfp28":{"1":"c","2":""},"7750 SR-1 me12-100gb-qsfp28_me6-400gb-qsfpdd":{"1":"c","2":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp-b_blank":{"1":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp-b_me12-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp-b_me16-25gb-sfp28-2-100gb-qsfp-b":{"1":"c","2":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp-b_me16-25gb-sfp28-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp-b_me3-200gb-cfp2-dco":{"1":"c","2":""},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp-b_me3-400gb-qsfpdd":{"1":"c","2":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp-b_me6-100gb-qsfp28":{"1":"c","2":""},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp-b_me6-400gb-qsfpdd":{"1":"c","2":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp28_blank":{"1":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp28_me12-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp28_me16-25gb-sfp28-2-100gb-qsfp-b":{"1":"c","2":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp28_me16-25gb-sfp28-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp28_me3-200gb-cfp2-dco":{"1":"c","2":""},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp28_me3-400gb-qsfpdd":{"1":"c","2":"c"},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp28_me6-100gb-qsfp28":{"1":"c","2":""},"7750 SR-1 me16-25gb-sfp28-2-100gb-qsfp28_me6-400gb-qsfpdd":{"1":"c","2":"c"},"7750 SR-1 me3-200gb-cfp2-dco_blank":{"1":""},"7750 SR-1 me3-200gb-cfp2-dco_me12-100gb-qsfp28":{"1":"","2":"c"},"7750 SR-1 me3-200gb-cfp2-dco_me16-25gb-sfp28-2-100gb-qsfp-b":{"1":"","2":"c"},"7750 SR-1 me3-200gb-cfp2-dco_me16-25gb-sfp28-2-100gb-qsfp28":{"1":"","2":"c"},"7750 SR-1 me3-200gb-cfp2-dco_me3-200gb-cfp2-dco":{"1":"","2":""},"7750 SR-1 me3-200gb-cfp2-dco_me3-400gb-qsfpdd":{"1":"","2":"c"},"7750 SR-1 me3-200gb-cfp2-dco_me6-100gb-qsfp28":{"1":"","2":""},"7750 SR-1 me3-200gb-cfp2-dco_me6-400gb-qsfpdd":{"1":"","2":"c"},"7750 SR-1 me3-400gb-qsfpdd_blank":{"1":"c"},"7750 SR-1 me3-400gb-qsfpdd_me12-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1 me3-400gb-qsfpdd_me16-25gb-sfp28-2-100gb-qsfp-b":{"1":"c","2":"c"},"7750 SR-1 me3-400gb-qsfpdd_me16-25gb-sfp28-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1 me3-400gb-qsfpdd_me3-200gb-cfp2-dco":{"1":"c","2":""},"7750 SR-1 me3-400gb-qsfpdd_me3-400gb-qsfpdd":{"1":"c","2":"c"},"7750 SR-1 me3-400gb-qsfpdd_me6-100gb-qsfp28":{"1":"c","2":""},"7750 SR-1 me3-400gb-qsfpdd_me6-400gb-qsfpdd":{"1":"c","2":"c"},"7750 SR-1 me6-100gb-qsfp28_blank":{"1":""},"7750 SR-1 me6-100gb-qsfp28_me12-100gb-qsfp28":{"1":"","2":"c"},"7750 SR-1 me6-100gb-qsfp28_me16-25gb-sfp28-2-100gb-qsfp-b":{"1":"","2":"c"},"7750 SR-1 me6-100gb-qsfp28_me16-25gb-sfp28-2-100gb-qsfp28":{"1":"","2":"c"},"7750 SR-1 me6-100gb-qsfp28_me3-200gb-cfp2-dco":{"1":"","2":""},"7750 SR-1 me6-100gb-qsfp28_me3-400gb-qsfpdd":{"1":"","2":"c"},"7750 SR-1 me6-100gb-qsfp28_me6-100gb-qsfp28":{"1":"","2":""},"7750 SR-1 me6-100gb-qsfp28_me6-400gb-qsfpdd":{"1":"","2":"c"},"7750 SR-1 me6-400gb-qsfpdd_blank":{"1":"c"},"7750 SR-1 me6-400gb-qsfpdd_me12-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1 me6-400gb-qsfpdd_me16-25gb-sfp28-2-100gb-qsfp-b":{"1":"c","2":"c"},"7750 SR-1 me6-400gb-qsfpdd_me16-25gb-sfp28-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1 me6-400gb-qsfpdd_me3-200gb-cfp2-dco":{"1":"c","2":""},"7750 SR-1 me6-400gb-qsfpdd_me3-400gb-qsfpdd":{"1":"c","2":"c"},"7750 SR-1 me6-400gb-qsfpdd_me6-100gb-qsfp28":{"1":"c","2":""},"7750 SR-1 me6-400gb-qsfpdd_me6-400gb-qsfpdd":{"1":"c","2":"c"},"7750 SR-1-24D":{"":"c"},"7750 SR-1-46S":{"":"c"},"7750 SR-1-48D":{"":"c"},"7750 SR-1-92S":{"":"c"},"7750 SR-1s blank_ms16-100gb-sfpdd-4-100gb-qsfp28":{"2":"c"},"7750 SR-1s blank_ms18-100gb-qsfp28":{"2":"c"},"7750 SR-1s blank_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"2":"c"},"7750 SR-1s blank_ms24-10-100gb-sfpdd":{"2":"c"},"7750 SR-1s blank_ms3-200gb-cfp2-dco":{"2":"c"},"7750 SR-1s blank_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"2":"c"},"7750 SR-1s blank_ms6-300gb-cfp2-dco":{"2":"c"},"7750 SR-1s blank_ms8-100gb-sfpdd-2-100gb-qsfp28":{"2":"c"},"7750 SR-1s ms16-100gb-sfpdd-4-100gb-qsfp28_blank":{"1":"c"},"7750 SR-1s ms16-100gb-sfpdd-4-100gb-qsfp28_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms16-100gb-sfpdd-4-100gb-qsfp28_ms18-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms16-100gb-sfpdd-4-100gb-qsfp28_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms16-100gb-sfpdd-4-100gb-qsfp28_ms24-10-100gb-sfpdd":{"1":"c","2":"c"},"7750 SR-1s ms16-100gb-sfpdd-4-100gb-qsfp28_ms3-200gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms16-100gb-sfpdd-4-100gb-qsfp28_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms16-100gb-sfpdd-4-100gb-qsfp28_ms6-300gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms16-100gb-sfpdd-4-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms18-100gb-qsfp28_blank":{"1":"c"},"7750 SR-1s ms18-100gb-qsfp28_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms18-100gb-qsfp28_ms18-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms18-100gb-qsfp28_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms18-100gb-qsfp28_ms24-10-100gb-sfpdd":{"1":"c","2":"c"},"7750 SR-1s ms18-100gb-qsfp28_ms3-200gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms18-100gb-qsfp28_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms18-100gb-qsfp28_ms6-300gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms18-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms2-400gb-qsfpdd-2-100gb-qsfp28_blank":{"1":"c"},"7750 SR-1s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms18-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms24-10-100gb-sfpdd":{"1":"c","2":"c"},"7750 SR-1s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms3-200gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms6-300gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms24-10-100gb-sfpdd_blank":{"1":"c"},"7750 SR-1s ms24-10-100gb-sfpdd_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms24-10-100gb-sfpdd_ms18-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms24-10-100gb-sfpdd_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms24-10-100gb-sfpdd_ms24-10-100gb-sfpdd":{"1":"c","2":"c"},"7750 SR-1s ms24-10-100gb-sfpdd_ms3-200gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms24-10-100gb-sfpdd_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms24-10-100gb-sfpdd_ms6-300gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms24-10-100gb-sfpdd_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms3-200gb-cfp2-dco_blank":{"1":"c"},"7750 SR-1s ms3-200gb-cfp2-dco_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms3-200gb-cfp2-dco_ms18-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms3-200gb-cfp2-dco_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms3-200gb-cfp2-dco_ms24-10-100gb-sfpdd":{"1":"c","2":"c"},"7750 SR-1s ms3-200gb-cfp2-dco_ms3-200gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms3-200gb-cfp2-dco_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms3-200gb-cfp2-dco_ms6-300gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms3-200gb-cfp2-dco_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms4-400gb-qsfpdd-4-100gb-qsfp28_blank":{"1":"c"},"7750 SR-1s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms18-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms24-10-100gb-sfpdd":{"1":"c","2":"c"},"7750 SR-1s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms3-200gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms6-300gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms6-300gb-cfp2-dco_blank":{"1":"c"},"7750 SR-1s ms6-300gb-cfp2-dco_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms6-300gb-cfp2-dco_ms18-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms6-300gb-cfp2-dco_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms6-300gb-cfp2-dco_ms24-10-100gb-sfpdd":{"1":"c","2":"c"},"7750 SR-1s ms6-300gb-cfp2-dco_ms3-200gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms6-300gb-cfp2-dco_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms6-300gb-cfp2-dco_ms6-300gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms6-300gb-cfp2-dco_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms8-100gb-sfpdd-2-100gb-qsfp28_blank":{"1":"c"},"7750 SR-1s ms8-100gb-sfpdd-2-100gb-qsfp28_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms8-100gb-sfpdd-2-100gb-qsfp28_ms18-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms8-100gb-sfpdd-2-100gb-qsfp28_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms8-100gb-sfpdd-2-100gb-qsfp28_ms24-10-100gb-sfpdd":{"1":"c","2":"c"},"7750 SR-1s ms8-100gb-sfpdd-2-100gb-qsfp28_ms3-200gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms8-100gb-sfpdd-2-100gb-qsfp28_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s ms8-100gb-sfpdd-2-100gb-qsfp28_ms6-300gb-cfp2-dco":{"1":"c","2":"c"},"7750 SR-1s ms8-100gb-sfpdd-2-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c"},"7750 SR-1s s18-100gb-qsfp28":{"1":"c"},"7750 SR-1s s36-400gb-qsfpdd":{"1":"c"},"7750 SR-1se":{"":"c"},"7750 SR-1x-48D":{"":"c"},"7750 SR-1x-92S":{"":"c"},"7750 SR-2s blank_ms16-100gb-sfpdd-4-100gb-qsfp28":{"3":"c","4":"c"},"7750 SR-2s blank_ms18-100gb-qsfp28":{"3":"c","4":"c"},"7750 SR-2s blank_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"3":"c","4":"c"},"7750 SR-2s blank_ms24-10-100gb-sfpdd":{"3":"c","4":"c"},"7750 SR-2s blank_ms3-200gb-cfp2-dco":{"3":"c","4":"c"},"7750 SR-2s blank_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"3":"c","4":"c"},"7750 SR-2s blank_ms6-300gb-cfp2-dco":{"3":"c","4":"c"},"7750 SR-2s blank_ms8-100gb-sfpdd-2-100gb-qsfp28":{"3":"c","4":"c"},"7750 SR-2s blank_s18-100gb-qsfp28":{"3":"c"},"7750 SR-2s blank_s36-400gb-qsfpdd":{"3":"c"},"7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_blank":{"1":"c","2":"c"},"7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_ms18-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_ms24-10-100gb-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_ms3-200gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_ms6-300gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_s18-100gb-qsfp28":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms16-100gb-sfpdd-4-100gb-qsfp28_s36-400gb-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms18-100gb-qsfp28_blank":{"1":"c","2":"c"},"7750 SR-2s ms18-100gb-qsfp28_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms18-100gb-qsfp28_ms18-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms18-100gb-qsfp28_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms18-100gb-qsfp28_ms24-10-100gb-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms18-100gb-qsfp28_ms3-200gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms18-100gb-qsfp28_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms18-100gb-qsfp28_ms6-300gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms18-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms18-100gb-qsfp28_s18-100gb-qsfp28":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms18-100gb-qsfp28_s36-400gb-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms2-400gb-qsfpdd-2-100gb-qsfp28_blank":{"1":"c","2":"c"},"7750 SR-2s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms18-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms24-10-100gb-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms3-200gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms6-300gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms2-400gb-qsfpdd-2-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms2-400gb-qsfpdd-2-100gb-qsfp28_s18-100gb-qsfp28":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms2-400gb-qsfpdd-2-100gb-qsfp28_s36-400gb-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms24-10-100gb-sfpdd_blank":{"1":"c","2":"c"},"7750 SR-2s ms24-10-100gb-sfpdd_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms24-10-100gb-sfpdd_ms18-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms24-10-100gb-sfpdd_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms24-10-100gb-sfpdd_ms24-10-100gb-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms24-10-100gb-sfpdd_ms3-200gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms24-10-100gb-sfpdd_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms24-10-100gb-sfpdd_ms6-300gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms24-10-100gb-sfpdd_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms24-10-100gb-sfpdd_s18-100gb-qsfp28":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms24-10-100gb-sfpdd_s36-400gb-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms3-200gb-cfp2-dco_blank":{"1":"c","2":"c"},"7750 SR-2s ms3-200gb-cfp2-dco_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms3-200gb-cfp2-dco_ms18-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms3-200gb-cfp2-dco_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms3-200gb-cfp2-dco_ms24-10-100gb-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms3-200gb-cfp2-dco_ms3-200gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms3-200gb-cfp2-dco_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms3-200gb-cfp2-dco_ms6-300gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms3-200gb-cfp2-dco_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms3-200gb-cfp2-dco_s18-100gb-qsfp28":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms3-200gb-cfp2-dco_s36-400gb-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms4-400gb-qsfpdd-4-100gb-qsfp28_blank":{"1":"c","2":"c"},"7750 SR-2s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms18-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms24-10-100gb-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms3-200gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms6-300gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms4-400gb-qsfpdd-4-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms4-400gb-qsfpdd-4-100gb-qsfp28_s18-100gb-qsfp28":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms4-400gb-qsfpdd-4-100gb-qsfp28_s36-400gb-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms6-300gb-cfp2-dco_blank":{"1":"c","2":"c"},"7750 SR-2s ms6-300gb-cfp2-dco_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms6-300gb-cfp2-dco_ms18-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms6-300gb-cfp2-dco_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms6-300gb-cfp2-dco_ms24-10-100gb-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms6-300gb-cfp2-dco_ms3-200gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms6-300gb-cfp2-dco_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms6-300gb-cfp2-dco_ms6-300gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms6-300gb-cfp2-dco_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms6-300gb-cfp2-dco_s18-100gb-qsfp28":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms6-300gb-cfp2-dco_s36-400gb-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms8-100gb-sfpdd-2-100gb-qsfp28_blank":{"1":"c","2":"c"},"7750 SR-2s ms8-100gb-sfpdd-2-100gb-qsfp28_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms8-100gb-sfpdd-2-100gb-qsfp28_ms18-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms8-100gb-sfpdd-2-100gb-qsfp28_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms8-100gb-sfpdd-2-100gb-qsfp28_ms24-10-100gb-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms8-100gb-sfpdd-2-100gb-qsfp28_ms3-200gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms8-100gb-sfpdd-2-100gb-qsfp28_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms8-100gb-sfpdd-2-100gb-qsfp28_ms6-300gb-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms8-100gb-sfpdd-2-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2s ms8-100gb-sfpdd-2-100gb-qsfp28_s18-100gb-qsfp28":{"1":"c","2":"c","3":"c"},"7750 SR-2s ms8-100gb-sfpdd-2-100gb-qsfp28_s36-400gb-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2s s18-100gb-qsfp28_blank":{"1":"c"},"7750 SR-2s s18-100gb-qsfp28_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","3":"c","4":"c"},"7750 SR-2s s18-100gb-qsfp28_ms18-100gb-qsfp28":{"1":"c","3":"c","4":"c"},"7750 SR-2s s18-100gb-qsfp28_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","3":"c","4":"c"},"7750 SR-2s s18-100gb-qsfp28_ms24-10-100gb-sfpdd":{"1":"c","3":"c","4":"c"},"7750 SR-2s s18-100gb-qsfp28_ms3-200gb-cfp2-dco":{"1":"c","3":"c","4":"c"},"7750 SR-2s s18-100gb-qsfp28_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","3":"c","4":"c"},"7750 SR-2s s18-100gb-qsfp28_ms6-300gb-cfp2-dco":{"1":"c","3":"c","4":"c"},"7750 SR-2s s18-100gb-qsfp28_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","3":"c","4":"c"},"7750 SR-2s s18-100gb-qsfp28_s18-100gb-qsfp28":{"1":"c","3":"c"},"7750 SR-2s s18-100gb-qsfp28_s36-400gb-qsfpdd":{"1":"c","3":"c"},"7750 SR-2s s36-400gb-qsfpdd_blank":{"1":"c"},"7750 SR-2s s36-400gb-qsfpdd_ms16-100gb-sfpdd-4-100gb-qsfp28":{"1":"c","3":"c","4":"c"},"7750 SR-2s s36-400gb-qsfpdd_ms18-100gb-qsfp28":{"1":"c","3":"c","4":"c"},"7750 SR-2s s36-400gb-qsfpdd_ms2-400gb-qsfpdd-2-100gb-qsfp28":{"1":"c","3":"c","4":"c"},"7750 SR-2s s36-400gb-qsfpdd_ms24-10-100gb-sfpdd":{"1":"c","3":"c","4":"c"},"7750 SR-2s s36-400gb-qsfpdd_ms3-200gb-cfp2-dco":{"1":"c","3":"c","4":"c"},"7750 SR-2s s36-400gb-qsfpdd_ms4-400gb-qsfpdd-4-100gb-qsfp28":{"1":"c","3":"c","4":"c"},"7750 SR-2s s36-400gb-qsfpdd_ms6-300gb-cfp2-dco":{"1":"c","3":"c","4":"c"},"7750 SR-2s s36-400gb-qsfpdd_ms8-100gb-sfpdd-2-100gb-qsfp28":{"1":"c","3":"c","4":"c"},"7750 SR-2s s36-400gb-qsfpdd_s18-100gb-qsfp28":{"1":"c","3":"c"},"7750 SR-2s s36-400gb-qsfpdd_s36-400gb-qsfpdd":{"1":"c","3":"c"},"7750 SR-2se blank_mse14-800g-4-400g":{"3":"c","4":"c"},"7750 SR-2se blank_mse24-200g-sfpdd":{"3":"c","4":"c"},"7750 SR-2se blank_mse6-800g-cfp2-dco":{"3":"c","4":"c"},"7750 SR-2se blank_mse6-800g-qsfpdd":{"3":"c","4":"c"},"7750 SR-2se blank_x2-s36-800g-qsfpdd":{"3":"c"},"7750 SR-2se mse14-800g-4-400g_blank":{"1":"c","2":"c"},"7750 SR-2se mse14-800g-4-400g_mse14-800g-4-400g":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse14-800g-4-400g_mse24-200g-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse14-800g-4-400g_mse6-800g-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse14-800g-4-400g_mse6-800g-qsfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse14-800g-4-400g_x2-s36-800g-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2se mse24-200g-sfpdd_blank":{"1":"c","2":"c"},"7750 SR-2se mse24-200g-sfpdd_mse14-800g-4-400g":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse24-200g-sfpdd_mse24-200g-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse24-200g-sfpdd_mse6-800g-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse24-200g-sfpdd_mse6-800g-qsfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse24-200g-sfpdd_x2-s36-800g-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2se mse6-800g-cfp2-dco_blank":{"1":"c","2":"c"},"7750 SR-2se mse6-800g-cfp2-dco_mse14-800g-4-400g":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse6-800g-cfp2-dco_mse24-200g-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse6-800g-cfp2-dco_mse6-800g-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse6-800g-cfp2-dco_mse6-800g-qsfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse6-800g-cfp2-dco_x2-s36-800g-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2se mse6-800g-qsfpdd_blank":{"1":"c","2":"c"},"7750 SR-2se mse6-800g-qsfpdd_mse14-800g-4-400g":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse6-800g-qsfpdd_mse24-200g-sfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse6-800g-qsfpdd_mse6-800g-cfp2-dco":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse6-800g-qsfpdd_mse6-800g-qsfpdd":{"1":"c","2":"c","3":"c","4":"c"},"7750 SR-2se mse6-800g-qsfpdd_x2-s36-800g-qsfpdd":{"1":"c","2":"c","3":"c"},"7750 SR-2se x2-s36-800g-qsfpdd_blank":{"1":"c"},"7750 SR-2se x2-s36-800g-qsfpdd_mse14-800g-4-400g":{"1":"c","3":"c","4":"c"},"7750 SR-2se x2-s36-800g-qsfpdd_mse24-200g-sfpdd":{"1":"c","3":"c","4":"c"},"7750 SR-2se x2-s36-800g-qsfpdd_mse6-800g-cfp2-dco":{"1":"c","3":"c","4":"c"},"7750 SR-2se x2-s36-800g-qsfpdd_mse6-800g-qsfpdd":{"1":"c","3":"c","4":"c"},"7750 SR-2se x2-s36-800g-qsfpdd_x2-s36-800g-qsfpdd":{"1":"c","3":"c"}} diff --git a/cable_map/frontend/src/graph/build.test.ts b/cable_map/frontend/src/graph/build.test.ts new file mode 100644 index 0000000..3ae8d13 --- /dev/null +++ b/cable_map/frontend/src/graph/build.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import fixture from '../../dev/fixtures/mixed-sros-topology.json'; +import type { Topology } from '../domain/topology/contract'; +import { buildGraph } from './build'; +import type { SwitchData } from './types'; + +const topology = fixture as unknown as Topology; + +describe('mixed SR OS graph', () => { + it('places dcgw below the spine and channelises the live pod1 link', () => { + const graph = buildGraph(topology, { compact: false, tiered: false }); + const pod = graph.nodes.find((node) => node.id === 'pod1')!; + const spine = graph.nodes.find((node) => node.id === 'mix-spine-srl')!; + expect(pod.position.y).toBeGreaterThan(spine.position.y); + + const edge = graph.edges.find((item) => item.source === 'pod1' && item.target === 'mix-spine-srl')!; + expect(edge.sourceHandle).toBe('p1-1_1'); + expect(edge.targetHandle).toBe('p25t'); + expect(edge.data).toMatchObject({ a: '1/1/c1/1', b: '1-25' }); + + const data = pod.data as SwitchData; + expect(data.portLabels?.['1-1']).toBe('c1'); + expect(data.connectors?.['1-1']).toMatchObject({ label: 'c1', channels: 1, physical: '1-a-1' }); + expect(data.ports.find((port) => port.port === '1-1')).toMatchObject({ + channel: 1, interfaceLabel: '1/1/c1/1', physicalAlias: '1-a-1-1', side: 'top', + }); + }); + + it('uses connector capacity for persistent c10 channel data and channel handles', () => { + const graph = buildGraph(topology, { compact: false, tiered: false }); + const node = graph.nodes.find((item) => item.id === 'mix-sros-sr1-iom-me6')!; + const data = node.data as SwitchData; + expect(data.breakouts?.['1-1']).toEqual({ channels: 10, speed: '10g' }); + expect(data.connectors?.['1-1']).toMatchObject({ label: 'c1', channels: 10 }); + + const edge = graph.edges.find((item) => item.source === node.id && item.sourceHandle === 'p1-1_1'); + expect(edge).toBeDefined(); + + const lag = graph.edges.find((item) => item.id.startsWith('isllag|'))!; + expect(String(lag.sourceHandle)).toMatch(/_1$/); + expect(String(lag.targetHandle)).toMatch(/_1t$/); + }); + + it('preserves fixed SR Linux cage mapping and explicit breakouts', () => { + const graph = buildGraph(topology, { compact: false, tiered: false }); + const srl = graph.nodes.find((item) => item.id === 'mix-spine-srl')!; + const srlData = srl.data as SwitchData; + expect(srlData.ports.some((port) => port.port === '25' && port.channel == null)).toBe(true); + + const sr1 = graph.nodes.find((item) => item.id === 'mix-leaf-sr1')!; + expect((sr1.data as SwitchData).breakouts?.['3']).toEqual({ channels: 4, speed: '100G' }); + + const mixedMda = graph.nodes.find((item) => item.id === 'mix-sros-sr1-mda-me12-me6')!; + expect((mixedMda.data as SwitchData).portLabels).toMatchObject({ '1-1': 'c1', '2-1': '1' }); + }); +}); diff --git a/cable_map/frontend/src/graph/build.ts b/cable_map/frontend/src/graph/build.ts index e107ab0..3555d64 100644 --- a/cable_map/frontend/src/graph/build.ts +++ b/cable_map/frontend/src/graph/build.ts @@ -1,7 +1,8 @@ // Builds the React Flow graph (positioned nodes + edges) and the trace adjacency straight from the // topology tables — which carry the full per-port truth, including the edge/server interfaces. import type { Edge as RFEdge } from '@xyflow/react'; -import { cageKey, shortPortLabel } from '../domain/ports/interfaceNames'; +import { breakoutChannel, cageKey, edaPhysicalAlias, shortPortLabel } from '../domain/ports/interfaceNames'; +import { srosPortModel } from '../domain/ports/srosPorts'; import type { LagInfo, Topology } from '../domain/topology/contract'; import { nodeLabelPairs } from '../domain/topology/labels'; import { stencilFor, componentsOf } from '../stencil'; @@ -13,7 +14,7 @@ import { realizeEndpointGroups, realizeIslBundles, type EndpointGroups, type IslBundles, } from './endpointGrouping'; import { - compactSwitchHandle, + compactSwitchHandle, portHandle, } from './geometry'; import { layoutGraph } from './layout'; import { @@ -36,6 +37,7 @@ export function buildGraph(topo: Topology, options: BuildGraphOptions = {}): Bui // sizing + FP_META lookups, while node.platform stays the displayed model name. const stencilByName = new Map(topo.nodes.map((n) => [n.name, stencilFor(n.platform, componentsOf(n.raw))])); const stencilOf = (name: string) => stencilByName.get(name) ?? ''; + const portModels = new Map(topo.nodes.map((n) => [n.name, srosPortModel(n, stencilOf(n.name))])); const lagOf = (name?: string): LagInfo | undefined => (name ? topo.lags[name] : undefined); const sideBetween = (node: string, remote: string): 'top' | 'bottom' => tier(remote) <= tier(node) ? 'top' : 'bottom'; @@ -66,39 +68,52 @@ export function buildGraph(topo: Topology, options: BuildGraphOptions = {}): Bui const endpointGroups: EndpointGroups = new Map(); for (const sw of topo.nodes) { - const slotted = slottedOf(stencilOf(sw.name)); + const stencil = stencilOf(sw.name); + const slotted = slottedOf(stencil); for (const r of sw.table ?? []) { - const p = cageKey(r.label, slotted); + const p = cageKey(r.label, slotted, stencil); if (p == null) continue; + const channel = breakoutChannel(r.label) ?? undefined; + const physicalAlias = edaPhysicalAlias(r.label) ?? undefined; if (r.type === 'interSwitch' && r.remoteNode) { - hay(sw.name).push(r.label); + hay(sw.name).push(r.label, ...(physicalAlias ? [physicalAlias] : [])); const remoteTier = tier(r.remoteNode); // uplinks leave the top; a same-tier peer link (leaf↔leaf MLAG ISL) also routes over // the top, into the open space above — routing it below would dive through the host band. const side: 'top' | 'bottom' = remoteTier <= tier(sw.name) ? 'top' : 'bottom'; - const rp = cageKey(r.remotePort, slottedOf(stencilOf(r.remoteNode))) ?? p; + const remoteStencil = stencilOf(r.remoteNode); + const rp = cageKey(r.remotePort, slottedOf(remoteStencil), remoteStencil) ?? p; + const remoteChannel = breakoutChannel(r.remotePort) ?? undefined; // canonical per-cable id, stable across the mirrored far-side row const cableId = canonicalIslCableId(sw.name, r); if (lagOf(r.lag)?.isl) { - const bid = addIslBundleMember({ bundles: islBundles, node: sw.name, row: r, side, localPort: p, remotePort: rp }); - portsOf(sw.name).push({ port: p, kind: 'isl', side, remote: r.remoteNode, remotePort: r.remotePort, desc: r.desc, cableId: bid, lldp: r.lldpStatus }); + const bid = addIslBundleMember({ bundles: islBundles, node: sw.name, row: r, side, localPort: p, remotePort: rp, localChannel: channel, remoteChannel }); + portsOf(sw.name).push({ port: p, channel, interfaceLabel: r.label, physicalAlias, kind: 'isl', side, remote: r.remoteNode, remotePort: r.remotePort, desc: r.desc, cableId: bid, lldp: r.lldpStatus }); } else { - portsOf(sw.name).push({ port: p, kind: 'isl', side, remote: r.remoteNode, remotePort: r.remotePort, desc: r.desc, cableId, lldp: r.lldpStatus }); + portsOf(sw.name).push({ port: p, channel, interfaceLabel: r.label, physicalAlias, kind: 'isl', side, remote: r.remoteNode, remotePort: r.remotePort, desc: r.desc, cableId, lldp: r.lldpStatus }); if (!islSeen.has(cableId)) { islSeen.add(cableId); const child = tier(sw.name) >= tier(r.remoteNode) ? sw.name : r.remoteNode; const parent = child === sw.name ? r.remoteNode : sw.name; const childPort = child === sw.name ? p : rp; const parentPort = child === sw.name ? rp : p; + const childChannel = child === sw.name ? channel : remoteChannel; + const parentChannel = child === sw.name ? remoteChannel : channel; rfEdges.push({ id: cableId, source: child, target: parent, type: 'cable', - sourceHandle: switchHandle(child, parent) ?? `p${childPort}`, - targetHandle: switchHandle(parent, child, true) ?? `p${parentPort}t`, - data: { kind: 'isl', a: shortPortLabel(r.label), b: shortPortLabel(r.remotePort), lldp: r.lldpStatus, fast: compact }, + sourceHandle: switchHandle(child, parent) ?? portHandle(childPort, childChannel), + targetHandle: switchHandle(parent, child, true) ?? `${portHandle(parentPort, parentChannel)}t`, + data: { + kind: 'isl', + a: shortPortLabel(child === sw.name ? r.label : r.remotePort), + b: shortPortLabel(child === sw.name ? r.remotePort : r.label), + lldp: r.lldpStatus, + fast: compact, + }, }); - link(cableId, child, parent, [`${child}:${childPort}`, `${parent}:${parentPort}`]); + link(cableId, child, parent, [`${child}:${childPort}${childChannel ? `_${childChannel}` : ''}`, `${parent}:${parentPort}${parentChannel ? `_${parentChannel}` : ''}`]); } } } else if (r.type === 'edge') { @@ -128,6 +143,6 @@ export function buildGraph(topo: Topology, options: BuildGraphOptions = {}): Bui search.set(n.name, [...(haystacks.get(n.name) ?? [n.name, n.platform, n.role]), ...pairs].join(' ').toLowerCase()); } - const rfNodes = layoutGraph({ topo, compact, tiered, endpoints, switchPorts, stencilOf }); + const rfNodes = layoutGraph({ topo, compact, tiered, endpoints, switchPorts, portModels, stencilOf }); return { nodes: rfNodes, edges: rfEdges, adjacency }; } diff --git a/cable_map/frontend/src/graph/endpointGrouping.ts b/cable_map/frontend/src/graph/endpointGrouping.ts index ebdf414..424ecd7 100644 --- a/cable_map/frontend/src/graph/endpointGrouping.ts +++ b/cable_map/frontend/src/graph/endpointGrouping.ts @@ -1,5 +1,5 @@ import type { Edge as RFEdge } from '@xyflow/react'; -import { breakoutChannel, shortPortLabel } from '../domain/ports/interfaceNames'; +import { breakoutChannel, edaPhysicalAlias, shortPortLabel } from '../domain/ports/interfaceNames'; import type { LagInfo, LldpStatus, Row } from '../domain/topology/contract'; import { worseLldp } from './constants'; import { compactSwitchHandle, portHandle } from './geometry'; @@ -28,7 +28,7 @@ type IslBundle = { parent: string; side: 'top' | 'bottom'; lldp: LldpStatus; - cables: Map; + cables: Map; }; export type EndpointGroups = Map; @@ -87,6 +87,8 @@ export function addCompactEndpointPort({ ports.push({ port, channel, + interfaceLabel: row.label, + physicalAlias: edaPhysicalAlias(row.label) ?? undefined, kind, side: 'bottom', remote: shortEndpoint(node, remoteName), @@ -109,6 +111,8 @@ export function addIslBundleMember({ side, localPort, remotePort, + localChannel, + remoteChannel, }: { bundles: IslBundles; node: string; @@ -116,12 +120,16 @@ export function addIslBundleMember({ side: 'top' | 'bottom'; localPort: string; remotePort: string; + localChannel?: number; + remoteChannel?: number; }): string { const cableId = canonicalIslCableId(node, row); const bid = `isllag|${row.lag!.replace(/^lag-/, '').replace(/-(local|remote)$/, '')}`; const [a, b] = [node, row.remoteNode].toSorted(); const aPort = a === node ? localPort : remotePort; const bPort = a === node ? remotePort : localPort; + const aChannel = a === node ? localChannel : remoteChannel; + const bChannel = a === node ? remoteChannel : localChannel; const bundle = bundles.get(bid) ?? bundles.set(bid, { id: bid, child: a, @@ -134,6 +142,8 @@ export function addIslBundleMember({ bundle.cables.set(cableId, { childPort: aPort, parentPort: bPort, + childChannel: aChannel, + parentChannel: bChannel, childLabel: shortPortLabel(a === node ? row.label : row.remotePort), parentLabel: shortPortLabel(a === node ? row.remotePort : row.label), }); @@ -161,8 +171,8 @@ export function realizeIslBundles({ source: b.child, target: b.parent, type: 'cable', - sourceHandle: switchHandle(b.child, b.parent) ?? `p${rep.childPort}`, - targetHandle: switchHandle(b.parent, b.child, true) ?? `p${rep.parentPort}t`, + sourceHandle: switchHandle(b.child, b.parent) ?? portHandle(rep.childPort, rep.childChannel), + targetHandle: switchHandle(b.parent, b.child, true) ?? `${portHandle(rep.parentPort, rep.parentChannel)}t`, data: { kind: 'isl', a: cables.map((c) => c.childLabel).join(', '), @@ -172,7 +182,10 @@ export function realizeIslBundles({ fast, }, }); - link(b.id, b.child, b.parent, cables.flatMap((c) => [`${b.child}:${c.childPort}`, `${b.parent}:${c.parentPort}`])); + link(b.id, b.child, b.parent, cables.flatMap((c) => [ + `${b.child}:${c.childPort}${c.childChannel ? `_${c.childChannel}` : ''}`, + `${b.parent}:${c.parentPort}${c.parentChannel ? `_${c.parentChannel}` : ''}`, + ])); } } @@ -219,7 +232,10 @@ export function realizeEndpointGroups({ lldp: epLldp, }, }); - const labels = allMembers.map((x) => x.label); + const labels = allMembers.flatMap((x) => { + const alias = edaPhysicalAlias(x.label); + return alias ? [x.label, alias] : [x.label]; + }); search.set(epId, [a.label, a.full, remotePort, ...labels].join(' ').toLowerCase()); for (const [node, members] of a.portsByNode) { @@ -228,6 +244,8 @@ export function realizeEndpointGroups({ portsOf(node).push({ port: m.port, channel: m.channel, + interfaceLabel: m.label, + physicalAlias: edaPhysicalAlias(m.label) ?? undefined, kind, side: 'bottom', remote: a.label, @@ -245,7 +263,7 @@ export function realizeEndpointGroups({ targetHandle: 'in', data: { kind, a: shortPortLabel(m.label), b: m.remotePort || a.label, lldp: m.lldp, fast: compact }, }); - link(sid, node, epId, [`${node}:${m.port}`]); + link(sid, node, epId, [`${node}:${m.port}${m.channel ? `_${m.channel}` : ''}`]); } } } diff --git a/cable_map/frontend/src/graph/geometry.test.ts b/cable_map/frontend/src/graph/geometry.test.ts new file mode 100644 index 0000000..31849d0 --- /dev/null +++ b/cable_map/frontend/src/graph/geometry.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { breakoutCellBox, breakoutGrid } from './geometry'; + +describe('breakout geometry', () => { + it('lays a ten-channel cage out as two contiguous rows of five', () => { + expect(breakoutGrid(10)).toEqual({ cols: 5, rows: 2 }); + expect(breakoutCellBox(10, 1, 100, 40)).toEqual({ left: 0, top: 0, width: 20, height: 20 }); + expect(breakoutCellBox(10, 5, 100, 40)).toEqual({ left: 80, top: 0, width: 20, height: 20 }); + expect(breakoutCellBox(10, 6, 100, 40)).toEqual({ left: 0, top: 20, width: 20, height: 20 }); + expect(breakoutCellBox(10, 10, 100, 40)).toEqual({ left: 80, top: 20, width: 20, height: 20 }); + }); +}); diff --git a/cable_map/frontend/src/graph/geometry.ts b/cable_map/frontend/src/graph/geometry.ts index d615bdf..34e8c29 100644 --- a/cable_map/frontend/src/graph/geometry.ts +++ b/cable_map/frontend/src/graph/geometry.ts @@ -36,6 +36,20 @@ export function breakoutGrid(channels: number): { cols: number; rows: number } { return { cols: Math.ceil(n / rows), rows }; } +export function breakoutCellBox(channels: number, channel: number, width: number, height: number) { + const n = Math.max(1, channels); + const ch = Math.max(1, Math.min(n, channel)); + const { cols, rows } = breakoutGrid(n); + const cellWidth = width / cols; + const cellHeight = height / rows; + return { + left: ((ch - 1) % cols) * cellWidth, + top: Math.floor((ch - 1) / cols) * cellHeight, + width: cellWidth, + height: cellHeight, + }; +} + function switchCages(d: SwitchData): string[] { return d.layout ? d.layout.map((p) => p.p) : Array.from({ length: d.portCount }, (_, i) => String(i + 1)); } @@ -48,7 +62,7 @@ export function findSwitchPort(d: SwitchData, text: string): string | null { const candidates = new Set(); for (const token of [text.toLowerCase(), ...tokens]) { - const key = cageKey(token, slotted); + const key = cageKey(token, slotted, d.platform); if (key) candidates.add(key); const normalized = token @@ -78,18 +92,10 @@ export function switchPortCenter(d: SwitchData, port: string, channel?: number): const channelOffset = (width: number, height: number) => { const bo = d.breakouts?.[port]; if (!bo || !channel) return { x: 0, y: 0 }; - const n = Math.max(1, bo.channels); - const ch = Math.max(1, Math.min(n, channel)); - const { cols, rows } = breakoutGrid(n); - const col = (ch - 1) % cols; - const row = Math.floor((ch - 1) / cols); - const colGap = cols > 2 ? 0.5 : 1; - const rowGap = rows > 1 ? 0.5 : 0; - const sw = (width - colGap * (cols - 1)) / cols; - const sh = (height - rowGap * (rows - 1)) / rows; + const cell = breakoutCellBox(bo.channels, channel, width, height); return { - x: -width / 2 + col * (sw + colGap) + sw / 2, - y: -height / 2 + row * (sh + rowGap) + sh / 2, + x: -width / 2 + cell.left + cell.width / 2, + y: -height / 2 + cell.top + cell.height / 2, }; }; diff --git a/cable_map/frontend/src/graph/layout.ts b/cable_map/frontend/src/graph/layout.ts index d5e6fa1..637ab10 100644 --- a/cable_map/frontend/src/graph/layout.ts +++ b/cable_map/frontend/src/graph/layout.ts @@ -1,6 +1,7 @@ import type { Node as RFNode } from '@xyflow/react'; -import { aspectOf, layoutOf, portCountOf, rowsOf } from '../domain/ports/frontPanelMeta'; +import { aspectOf, layoutOf, portCountOf, portLabelsOf, rowsOf } from '../domain/ports/frontPanelMeta'; import type { Breakout, Topology } from '../domain/topology/contract'; +import { mergeBreakouts, type SrosPortModel } from '../domain/ports/srosPorts'; import { labelOf } from '../domain/topology/labels'; import { EP_COLS, EP_H, EP_HGAP, EP_TOP_GAP, EP_VGAP, EP_W, LEAF_Y, SPINE_Y, tierOf, @@ -16,6 +17,7 @@ type LayoutGraphArgs = { tiered: boolean; endpoints: GraphEndpoint[]; switchPorts: Map; + portModels: Map; stencilOf: (name: string) => string; }; @@ -47,7 +49,7 @@ function breakoutsWithInferredChannels(explicit: Record | unde return Object.keys(out).length ? out : undefined; } -export function layoutGraph({ topo, compact, tiered, endpoints, switchPorts, stencilOf }: LayoutGraphArgs): RFNode[] { +export function layoutGraph({ topo, compact, tiered, endpoints, switchPorts, portModels, stencilOf }: LayoutGraphArgs): RFNode[] { // tiers present, top (smallest) first. The bottom tier is the leaf row (endpoint fans); // every tier above it gets its own row, so superspine and spine never share one. const tiers = [...new Set(topo.nodes.map((n) => tierOf(n)))].toSorted((a, b) => a - b); @@ -191,6 +193,7 @@ export function layoutGraph({ topo, compact, tiered, endpoints, switchPorts, ste const rfNodes: RFNode[] = topo.nodes.map((n) => { const nodePorts = switchPorts.get(n.name) ?? []; + const portModel = portModels.get(n.name); const nodeState = nodeStateOf(n); return { id: n.name, type: 'switch', position: pos.get(n.name) ?? { x: 0, y: 0 }, @@ -204,7 +207,15 @@ export function layoutGraph({ topo, compact, tiered, endpoints, switchPorts, ste 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, + aspect: aspectOf(stencilOf(n.name)), + breakouts: compact ? undefined : mergeBreakouts( + n.breakouts, + portModel?.breakouts ?? {}, + breakoutsWithInferredChannels(n.breakouts, nodePorts), + ), + connectors: compact ? undefined : portModel?.connectors, + portLabels: compact ? undefined : portLabelsOf(stencilOf(n.name)), + compact, } satisfies SwitchData, draggable: !compact, }; diff --git a/cable_map/frontend/src/graph/types.ts b/cable_map/frontend/src/graph/types.ts index 987ae14..e7fa6b9 100644 --- a/cable_map/frontend/src/graph/types.ts +++ b/cable_map/frontend/src/graph/types.ts @@ -1,5 +1,6 @@ import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react'; import type { Breakout, LldpStatus } from '../domain/topology/contract'; +import type { ConnectorInfo } from '../domain/ports/srosPorts'; import type { Adjacency } from './adjacency'; export type CableKind = 'isl' | 'edge' | 'lag' | 'mlag'; @@ -9,6 +10,8 @@ export type PortPos = { p: string; x: number; y: number; w: number; h: number }; export type PortInfo = { port: string; // cage id matching the stencil's data-port ("" or "-") channel?: number; // breakout channel (1-based) when this cable lands on a broken-out sub-port + interfaceLabel?: string; // native interface name shown first in hover details + physicalAlias?: string; // EDA physical SR OS alias (e.g. 1-a-1-1) kind: CableKind | 'free'; side: 'top' | 'bottom'; remote: string; // short remote label e.g. "spine1" / "srv03" / "lag-a" @@ -30,6 +33,8 @@ export type SwitchData = { layout?: PortPos[]; // real on-panel port positions (from the stencil); else grid aspect?: number; // cage-box w/h, to size the layout panel breakouts?: Record; // cage id -> breakout split (channels + speed) + connectors?: Record; // SR OS connector inventory keyed by panel cage + portLabels?: Record; // stencil silkscreen label keyed by internal cage id compact?: boolean; // large-topology mode: draw a cheap aggregate switch node renderHandles?: boolean; // false when WebGL computes cable endpoints from node data detailLevel?: SwitchDetailLevel; // adaptive detail: labeled/summary draw the panel into one canvas diff --git a/cable_map/manifest.yaml b/cable_map/manifest.yaml index 8feaa1f..5eb30c0 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.3 + image: ghcr.io/eda-labs/cable-map/operators/cable-map:v0.2.4 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.3 + image: ghcr.io/eda-labs/cable-map:v0.2.4 supportedCoreVersions: - v5.0.0-0 title: Cable Map diff --git a/cable_map/operators/cable-map/internal/eda/store.go b/cable_map/operators/cable-map/internal/eda/store.go index 8b34535..3acf78f 100644 --- a/cable_map/operators/cable-map/internal/eda/store.go +++ b/cable_map/operators/cable-map/internal/eda/store.go @@ -36,7 +36,7 @@ var kinds = []crKind{ {edav1.KTopoNodeGVK.Group, edav1.KTopoNodeGVK.Version, edav1.KTopoNodeGVK.Kind}, {edav1.KTopoLinkGVK.Group, edav1.KTopoLinkGVK.Version, edav1.KTopoLinkGVK.Kind}, {"interfaces.eda.nokia.com", "v1", "Interface"}, - {"interfaces.eda.nokia.com", "v1alpha1", "Breakout"}, + {"interfaces.eda.nokia.com", "v1", "Breakout"}, {"topologies.eda.nokia.com", "v1", "TopologyGrouping"}, } diff --git a/cable_map/operators/cable-map/internal/topology/topology.go b/cable_map/operators/cable-map/internal/topology/topology.go index 8330c6d..add73d2 100644 --- a/cable_map/operators/cable-map/internal/topology/topology.go +++ b/cable_map/operators/cable-map/internal/topology/topology.go @@ -82,7 +82,7 @@ type RawIface struct { StatusMembers []IfStatusMember // status.members (carry LLDP neighbours) } -// RawBreakout is an interfaces.eda.nokia.com/Breakout projection: which parent ports on which nodes +// RawBreakout is an interfaces.eda.nokia.com/v1 Breakout projection: which parent ports on which nodes // are split into how many channels. type RawBreakout struct { Name string diff --git a/cable_map/operators/cable-map/internal/topology/topology_test.go b/cable_map/operators/cable-map/internal/topology/topology_test.go index fcb1625..51c38aa 100644 --- a/cable_map/operators/cable-map/internal/topology/topology_test.go +++ b/cable_map/operators/cable-map/internal/topology/topology_test.go @@ -173,6 +173,7 @@ func TestTopologyGroupingTiers(t *testing.T) { {Name: "spine1", Labels: map[string]string{"eda.nokia.com/role": "spine"}}, {Name: "leaf1", Labels: map[string]string{"eda.nokia.com/role": "leaf"}}, {Name: "bl1", Labels: map[string]string{"eda.nokia.com/role": "borderleaf"}}, + {Name: "dcgw1", Labels: map[string]string{"eda.nokia.com/role": "dcgw"}}, {Name: "srv1", Labels: map[string]string{"eda.nokia.com/role": "server"}}, } role := RawGrouping{Name: "role", Selectors: []RawTierSelector{ @@ -180,12 +181,12 @@ func TestTopologyGroupingTiers(t *testing.T) { {Tier: 2, NodeSelectors: []string{"eda.nokia.com/role=superspine"}}, {Tier: 3, NodeSelectors: []string{"eda.nokia.com/role=spine"}}, {Tier: 4, NodeSelectors: []string{"eda.nokia.com/role=leaf", "eda.nokia.com/role=borderleaf"}}, - {Tier: 5}, // catch-all + {Tier: 4}, // live role grouping catch-all (e.g. dcgw) }} other := RawGrouping{Name: "alt", Selectors: []RawTierSelector{{Tier: 9}}} topo := BuildTopology(nodes, nil, nil, nil, []RawGrouping{other, role}) - want := map[string]int{"bb1": 1, "ss1": 2, "spine1": 3, "leaf1": 4, "bl1": 4, "srv1": 5} + want := map[string]int{"bb1": 1, "ss1": 2, "spine1": 3, "leaf1": 4, "bl1": 4, "dcgw1": 4, "srv1": 4} for name, tier := range want { if got := nodeByName(topo, name).Tier; got != tier { t.Errorf("%s tier: got %d want %d", name, got, tier) diff --git a/docs/stencils.md b/docs/stencils.md index 329c2f6..ab029de 100644 --- a/docs/stencils.md +++ b/docs/stencils.md @@ -168,7 +168,7 @@ a port number). It records `hasC` per label, which the bake step uses to emit `c --- -## 5. frontpanel-meta.json +## 5. Generated front-panel metadata The metadata generation step walks every `cable_map/operators/cable-map/frontpanel/*.svg`, reads the `data-port` cage geometry, and writes per-platform: @@ -185,6 +185,17 @@ The metadata generation step walks every `cable_map/operators/cable-map/frontpan - `rows` = distinct y-bands of cages. `aspect` = panel width/height. - Regenerate after **any** SVG change. It's a committed file (`cable_map/frontend/src/generated/frontpanel-meta.json`) + +The app also derives `cable_map/frontend/src/generated/frontpanel-labels.json` from each stencil's +baked `data-added="1"` port text. It records whether each fixed panel or modular bay uses `c1`, +`c2`, … or bare `1`, `2`, … silkscreen labels. The topology canvas uses that label while keeping +addresses such as `1-1` solely as internal cage keys. Regenerate it after changing stencils with: + +```sh +pnpm -C cable_map/frontend gen:frontpanel-labels +``` + +`make check` verifies that the committed label metadata is current. and the FE reads it through `src/domain/ports/frontPanelMeta.ts`. (Note: label-only edits don't change meta — it's derived from cage geometry, not text.) @@ -316,6 +327,7 @@ live with the operator that serves them; the inspector + generation scripts live | `stencils/` | gitignored | raw vendor SVG library (source material) | | `cable_map/operators/cable-map/frontpanel/.svg` | committed | **the stencils** (data-port + baked labels); served by the operator at `/frontpanel/*` | | `cable_map/frontend/src/generated/frontpanel-meta.json` | committed | derived per-platform `{ports,rows,aspect,slotted,layout}` | +| `cable_map/frontend/src/generated/frontpanel-labels.json` | committed | derived per-platform/per-bay visible cage-label convention (`c1` or `1`) | | `cable_map/frontend/src/stencil.ts` | committed | `stencilFor()` resolver (platform+components → name) | | `cable_map/frontend/src/FrontPanel.tsx` | committed | drawer render (inline SVG, fill cages) | | `cable_map/frontend/src/SwitchNode.tsx` | committed | canvas render (cells from meta layout) |