diff --git a/README.md b/README.md index da8193b..4bcb7e0 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,8 @@ sees but `config.json` doesn't list still get a page, with their state shown as they aren't alerted on. ### Port inspector (on switch and router device pages) -A model-accurate front panel, drawn from the layout config in `static/port-inspector.js` -(`SWITCH_LAYOUTS`). `/inspector` now redirects to the dashboard. Link to a port with `/device/#port-`. +A realistic front panel, drawn as SVG from the model's real geometry in `static/faceplates.js` +(`FACEPLATES`). `/inspector` now redirects to the dashboard. Link to a port with `/device/#port-`. **Port block colours:** | Colour | State | @@ -168,19 +168,20 @@ neighbors that can't be matched show their MAC. Bonded NICs share a MAC, so only 2. The Pulse worker can SSH to the server 3. Optional: `lldpd` on the server lets diagnostics confirm the cabling from the server's side -**Supported switch models** (set `SWITCH_LAYOUTS` keys to your UniFi model codes): +**Supported models** (keys are UniFi model codes): -| Key | Model | Layout | +| Key | Model | Front panel | |---|---|---| -| `USF5P` | UniFi Switch Flex 5 PoE | 4×RJ45 + 1×SFP uplink | -| `USL8A` | UniFi Switch Lite 8 PoE | 8×SFP (2 rows of 4) | -| `US24PRO` | UniFi Switch Pro 24 | 24×RJ45 staggered + 2×SFP | -| `USPPDUP` | Custom/other | Single-port fallback | -| `UDMPRO` | Dream Machine Pro | 9×RJ45 + 2×SFP+ | -| `USMINI` | UniFi Switch Mini | 5-port row | +| `UDMPRO` | Dream Machine Pro | 8×RJ45 LAN, RJ45 WAN, SFP+ WAN, SFP+ LAN, screen, drive bay | +| `US24PRO` | USW Pro 24 PoE | 24×RJ45 in two blocks of 12, 2×SFP+ | +| `USL8A` | USW Aggregation | 8×SFP+ in four stacked pairs | +| `USF5P` | USW Flex | 5×RJ45, PoE in on port 1, PoE out on 2-5 | +| `USMINI` | USW Flex Mini | 5×RJ45, PoE in on port 1 | +| `USPPDUP` | USP PDU Pro | 20 outlets (4 USB-C, 16 AC), management port 21, surge pass-through ports 22-23 | +| `USPDA2B` | UPS 2U Pro | Front with touchscreen outlet tiles; rear with 8 outlets, EPO, surge and MGMT ports | -Add new layouts by adding a key to `SWITCH_LAYOUTS` matching the `model` field -returned by the UniFi API for that device. +Port LEDs use the colors from each model's quick start guide. Other models use plain blocks until they get an +entry in `FACEPLATES`. ### Suppressions (`/suppressions`) - Create timed (30 min / 1 hr / 4 hr / 8 hr) or manual suppressions @@ -480,9 +481,9 @@ after `ldap.cache_ttl` seconds or can be removed from `ldap.cache_dir`. - Hosts missing from `config.json` are named by IP, so their ports may not match what you expect ### Port inspector: front panel shows as a flat list (no layout) -- The switch's `model` field from UniFi doesn't match any key in `SWITCH_LAYOUTS` in `port-inspector.js` +- The switch's `model` field from UniFi doesn't match any key in `FACEPLATES` in `faceplates.js` - The model is shown on the device page -- Add the model key to `SWITCH_LAYOUTS` in `port-inspector.js` with the correct row/SFP layout +- Add the model to `FACEPLATES` in `faceplates.js` (see Adding a New Device Faceplate) ### Baseline re-initializing on every restart - `interface_baseline` is stored in the `monitor_state` DB table; survives restarts @@ -545,19 +546,24 @@ gandalf/ 4. Restart monitor: `systemctl restart gandalf-monitor` 5. For SFP DOM / ethtool: ensure the host is SSH-accessible from the Pulse worker -### Adding a New Switch Layout +### Adding a New Device Faceplate -Find the UniFi model code for the switch (shown on its device page), then add to `SWITCH_LAYOUTS` in -`static/port-inspector.js`: +Find the UniFi model code on the device page, then add an entry to `FACEPLATES` in `static/faceplates.js`. +Trace coordinates from a front-on image of the device, using the image's pixels as the `viewBox`: ```javascript 'MYNEWMODEL': { - rows: [[1,2,3,4,5,6,7,8], [9,10,11,12,13,14,15,16]], // port_idx by row - sfp_section: [17, 18], // separate SFP cage ports (rendered below rows) - sfp_ports: [], // port_idx values that are SFP-type within rows + width: 1100, height: 110, frameClass: 'fp-scroll', scrollX: 500, + label: 'My New Switch', + legend: [['off', 'no link'], ['amber', '10/100M'], ['green', '1G']], + roles: { 1: 'RJ45' }, + ports: rj45Pairs([520, 557, 594], { top: 38, bottom: 69, topLabel: 15.5, bottomLabel: 98 }), + decor() { return fpVents([[90, 500]], 3) + fpQuadScreen(15, 23, 60); }, }, ``` +Take LED colors from the model's quick start guide. See the comment at the top of the file for every option. + ### Database Schema Notes - `network_events`: one row per active event; `resolved_at` is set when recovered diff --git a/static/device.js b/static/device.js index ca11a74..b1f6bf0 100644 --- a/static/device.js +++ b/static/device.js @@ -1,5 +1,5 @@ 'use strict'; -/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort */ +/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort, FACEPLATES */ // Per-device page: overview, alerts, and a compact interface/port table. Host // rows expand in place; switch and router rows open the port inspector panel. @@ -317,10 +317,14 @@ function renderInterfaces() { highlightSelectedPort(); } -function updateInterfaceSection(rows, title) { +function updateInterfaceSection(rows, title, hasChassis) { const section = document.getElementById('device-ports'); - section.hidden = !rows.length; - if (!rows.length) return; + section.hidden = !rows.length && !hasChassis; + document.querySelector('.device-port-table').hidden = !rows.length; + if (!rows.length) { + document.getElementById('device-ports-title').textContent = 'Front panel'; + return; + } document.getElementById('device-ports-title').textContent = `${title} · ${rows.filter(r => r.up).length} of ${rows.length} up`; document.getElementById('device-table-search').setAttribute('aria-label', `Filter ${title.toLowerCase()}`); @@ -347,9 +351,11 @@ function render(d) { renderOutlets(d); const hasPorts = !!(d.ports && Object.keys(d.ports).length); - document.getElementById('device-inspector').classList.toggle('has-chassis', hasPorts); - updateInterfaceSection(currentRows(), d.ports ? 'Ports' : 'Interfaces'); - if (hasPorts) renderPortInspector(d); + // A device like a UPS has a faceplate worth showing but no ports to list + const hasChassis = hasPorts || !!FACEPLATES[d.model]; + document.getElementById('device-inspector').classList.toggle('has-chassis', hasChassis); + updateInterfaceSection(currentRows(), d.ports ? 'Ports' : 'Interfaces', hasChassis); + if (hasChassis) renderPortInspector(d); renderInterfaces(); if (d.kind === 'modem') refreshModem(); diff --git a/static/faceplates.js b/static/faceplates.js new file mode 100644 index 0000000..52d06b8 --- /dev/null +++ b/static/faceplates.js @@ -0,0 +1,495 @@ +'use strict'; +/* exported renderFaceplate, faceplateScrollLeft, FACEPLATES */ + +// Realistic front panels, drawn as SVG from each model's real geometry. +// Coordinates are traced from reference images. Keys match UniFi's model code. +// +// To add a model, add an entry to FACEPLATES: +// width, height viewBox size, in proportion to the real device +// label name used for screen readers +// frameClass how a narrow panel adapts: fp-crop-* (style.css), fp-scroll, fp-desk or fp-fit +// noBody decor() draws the whole body itself +// scrollX left edge of the port area, shown first in an fp-scroll panel +// legend [[LED class, text], ...] +// decor(ctx) SVG for the fixed parts, ctx: { online } +// ports { idx, type: 'rj45'|'sfp', x, y, ... } for each port +// outlets { idx, kind, x, y, w, h, label } for each power outlet +// Models without an entry use the plain block layout in port-inspector.js. + +// LED colors come from each model's quick start guide. +const FP_LED_RULES = { + rj45(mbps) { return mbps >= 1000 ? 'green' : 'amber'; }, + sfp(mbps) { return mbps >= 10000 ? 'white' : 'green'; }, + // USW Flex: green at every speed + linkOnly() { return 'green'; }, +}; + +function faceplateLed(port, rule) { + if (!port || !port.up) return 'off'; + if (!port.speed_mbps) return 'unknown'; + return FP_LED_RULES[rule](port.speed_mbps); +} + +function ledActivityClass(port) { + if (!port || !port.up) return ''; + return (port.tx_bytes_rate || port.rx_bytes_rate) ? ' fp-led-active' : ''; +} + +const hasPoe = port => !!(port && port.poe_power > 0); + +// An RJ45 jack seen head-on, centered on (0,0) with the clip notch up. +// A cable is drawn in the jack when the port has link. +function rj45Svg(port, p) { + const led = faceplateLed(port, p.rule || 'rj45'); + const plug = port && port.up + ? ` + ` + : `${[-9, -6, -3, 0, 3, 6, 9].map(x => ``).join('')}`; + const leftLed = p.poeLed ? `fp-led fp-led-${hasPoe(port) ? 'amber' : 'off'}` : 'fp-led-idle'; + return ` + + ${plug} + + `; +} + +// An SFP+ cage seen head-on. Fills with a module when the port has link. +function sfpSvg(port, w, h) { + const inner = port && port.up + ? ` + + ` + : ` + `; + return `${inner}`; +} + +// The cages' LEDs sit between them: left ▲ for the top port, right ▼ for the bottom. +function sfpLedSvg(p, port) { + const [x, y] = p.led; + const points = p.flip ? `${x - 2.4},${y - 1.8} ${x + 2.4},${y - 1.8} ${x},${y + 2}` + : `${x - 2.4},${y + 1.8} ${x + 2.4},${y + 1.8} ${x},${y - 2}`; + return ``; +} + +function portLabelSvg(p, port) { + if (!p.label) return ''; + const [x, y] = p.label; + const size = p.labelSize ? ` style="font-size:${p.labelSize}px"` : ''; + const bolt = hasPoe(port) && !p.poeLed + ? `` + : ''; + return `${p.text || p.idx}${bolt}`; +} + +function faceplatePortSvg(p, port, swName, title) { + // The drawn port number can differ from the index UniFi reports (see anyPort) + const idx = (p.anyPort && port) ? port.port_idx : p.idx; + const attrs = `class="fp-port${port && port.up ? ' fp-up' : ''}" data-switch="${lt.escHtml(swName)}" data-port-idx="${idx}" + data-action="select-port" role="button" tabindex="0" aria-label="${title}"`; + if (p.type === 'sfp') { + return ` + ${title} + ${sfpSvg(port, p.w, p.h)} + + + ${sfpLedSvg(p, port)}${portLabelSvg(p, port)}`; + } + const scale = p.scale || 1; + return ` + ${title} + ${rj45Svg(port, p)} + + ${portLabelSvg(p, port)}`; +} + +// ── Power outlets ──────────────────────────────────────────────────────── +function outletFaceSvg(kind, w, h, idx) { + const cx = w / 2, cy = h / 2; + if (kind === 'tile') { + return ` + ${idx}`; + } + if (kind === 'usbc') { + return ` + `; + } + const face = ``; + if (kind === 'horizontal') { + return `${face} + + + `; + } + const tee = kind === 'vertical-20' + ? `` : ''; + return `${face} + + + ${tee} + `; +} + +function outletTitle(o, data) { + const parts = [`Outlet ${o.idx}`]; + if (data) { + if (data.name && data.name !== parts[0]) parts.push(data.name); + parts.push(data.on === false ? 'off' : 'on'); + if (data.power_w != null) parts.push(`${data.power_w.toFixed(1)} W`); + if (data.auto_power_cycle) parts.push('auto power-cycle'); + } + return lt.escHtml(parts.join(' · ')); +} + +function faceplateOutletSvg(o, data) { + const title = outletTitle(o, data); + const off = data && data.on === false; + const face = outletFaceSvg(o.kind, o.w, o.h, o.idx); + const group = ` + ${title} + ${o.flip ? `${face}` : face} + + `; + if (!o.label) return group; + const [lx, ly] = o.label; + // An outlet can read 0 W while its device runs, so only the relay state means off + const state = ``; + const cycle = data && data.auto_power_cycle + ? `` : ''; + return `${group}${state}${o.idx}${cycle}`; +} + +// A jack Gandalf gets no data for. Hovering or focusing it says what it is. +function fpPassiveJack(x, y, flip, description) { + const title = lt.escHtml(description); + return ` + ${title} + ${rj45Svg(null, {})} + + `; +} + +// ── Shared drawing helpers ─────────────────────────────────────────────── +const fpGlobe = (x, y) => ` + `; + +const fpVents = (spans, y, h = 2.6) => spans + .map(([a, b]) => ``).join(''); + +const fpReset = (x, y) => ` + RESET`; + +// The status screen on UniFi switches: four quadrants around a center dot +function fpQuadScreen(x, y, s) { + const c = s / 2; + const icons = [[.24, .24], [.76, .24], [.24, .76], [.76, .76]] + .map(([a, b]) => ``).join(''); + return ` + + + ${icons} + + `; +} + +const fpBrand = (x, y, text) => `${text}`; + +// Two rows of jacks, odd numbers on top: how UniFi rack switches number ports +function rj45Pairs(columns, rows) { + return columns.flatMap((x, i) => [ + { idx: 2 * i + 1, type: 'rj45', x, y: rows.top, label: [x, rows.topLabel] }, + { idx: 2 * i + 2, type: 'rj45', x, y: rows.bottom, flip: true, label: [x, rows.bottomLabel] }, + ]); +} + +function sfpPairs(columns, g) { + return columns.flatMap((x, i) => [ + { idx: g.first + 2 * i, type: 'sfp', x, y: g.top, w: g.w, h: g.h, + led: [x + g.w / 2 - g.ledGap, g.ledY], label: g.topLabel && [x + g.w / 2, g.topLabel] }, + { idx: g.first + 2 * i + 1, type: 'sfp', x, y: g.bottom, w: g.w, h: g.h, flip: true, + led: [x + g.w / 2 + g.ledGap, g.ledY], label: g.bottomLabel && [x + g.w / 2, g.bottomLabel] }, + ]); +} + +const roleRange = (from, to, role) => Object.fromEntries( + Array.from({ length: to - from + 1 }, (_, i) => [from + i, role])); + +const LEGEND_RJ45 = [['off', 'no link'], ['amber', '10/100M'], ['green', '1G']]; + +const FACEPLATES = { + // https://dl.ui.com/qsg/UDM-Pro/UDM-Pro_EN.html + UDMPRO: { + width: 1065, height: 106, frameClass: 'fp-crop-udmpro', + label: 'Dream Machine Pro', + legend: [...LEGEND_RJ45, ['white', '10G']], + roles: { + ...roleRange(1, 8, 'RJ45 LAN'), + 9: 'RJ45 Internet', 10: 'SFP+ Internet', 11: 'SFP+ LAN', + }, + ports: [ + ...rj45Pairs([760.5, 798, 835.5, 873], { top: 40, bottom: 66, topLabel: 18.5, bottomLabel: 93.5 }), + { idx: 9, type: 'rj45', x: 948, y: 69 }, + { idx: 10, type: 'sfp', x: 975, y: 22, w: 35, h: 23.5, led: [985, 52.5] }, + { idx: 11, type: 'sfp', x: 975, y: 59.5, w: 35, h: 23.5, flip: true, led: [1000, 52.5] }, + ], + decor() { + return `${fpVents([[90, 268], [277, 452], [462, 638], [647, 822], [833, 1010]], 8)} + + + + ${fpBrand(16, 94, 'Dream Machine Pro')} + + + + + + + 9${fpGlobe(951, 90.5)} + 10${fpGlobe(999, 15.5)} + 11 + ${fpReset(1026, 91)}`; + }, + }, + + // Port layout and LED colors: https://dl.ui.com/qsg/USW-Pro-24/USW-Pro-24_EN.html + // (the non-PoE sibling). PoE draw has no documented LED, so it is a bolt by the number. + US24PRO: { + width: 1179, height: 113, frameClass: 'fp-scroll', scrollX: 550, + label: 'USW Pro 24 PoE', + legend: [...LEGEND_RJ45, ['white', '10G'], ['poe', 'PoE in use']], + roles: { ...roleRange(1, 24, 'RJ45'), 25: 'SFP+', 26: 'SFP+' }, + ports: [ + ...rj45Pairs([586.7, 624.2, 661.7, 699.2, 736.7, 774.2, 820, 857.5, 895, 932.5, 970, 1007.5], + { top: 38, bottom: 69, topLabel: 15.5, bottomLabel: 98 }), + ...sfpPairs([1083], { first: 25, top: 19, bottom: 61, w: 39, h: 26, ledY: 53.5, ledGap: 10.5, + topLabel: 15.5, bottomLabel: 98 }), + ], + decor() { + return `${fpVents([[98, 298], [304, 504], [512, 710], [717, 917], [923, 1122]], 3)} + ${fpQuadScreen(15, 23, 62)}${fpBrand(17, 99, 'USW Pro')} + + + + ${fpReset(1140, 97)}`; + }, + }, + + // No quick start guide was available: layout from a reference image. LED colors + // are taken from the SFP+ ports of the USW Pro 24 guide. + USL8A: { + width: 1115, height: 110, frameClass: 'fp-crop-agg', + label: 'USW Aggregation', + legend: [['off', 'no link'], ['green', '1G'], ['white', '10G']], + roles: roleRange(1, 8, 'SFP+'), + ports: sfpPairs([876.4, 925.6, 975.6, 1025], { + first: 1, top: 21.6, bottom: 61.6, w: 36, h: 25, ledY: 54.5, ledGap: 9.5, topLabel: 17, bottomLabel: 95, + }), + decor() { + const dividers = [876.4, 925.6, 975.6, 1025] + .map(x => ``).join(''); + return `${fpVents([[91, 280], [287, 476], [482, 671], [678, 867], [874, 1062]], 7)} + ${fpQuadScreen(12, 25, 58)}${fpBrand(28, 96, 'USW')} + ${dividers} + + ${fpReset(1078.6, 95)}`; + }, + }, + + // https://dl.ui.com/qsg/USW-Flex/USW-Flex_EN.html + USF5P: { + width: 553, height: 128, frameClass: 'fp-desk', noBody: true, + label: 'USW Flex', + legend: [['off', 'no link'], ['green', 'link'], ['amber', 'PoE out']], + roles: { 1: 'RJ45, PoE in', ...roleRange(2, 5, 'RJ45, PoE out') }, + ports: [170, 225, 280, 335, 391].map((x, i) => ({ + idx: i + 1, type: 'rj45', x, y: 60, scale: 1.6, rule: 'linkOnly', poeLed: i > 0, + label: [x, 106], labelSize: 12, + })), + decor() { + return ` + + + `; + }, + }, + + // https://dl.ui.com/qsg/USW-Flex-Mini/USW-Flex-Mini_EN.html + USMINI: { + width: 663, height: 160, frameClass: 'fp-desk', noBody: true, + label: 'USW Flex Mini', + legend: LEGEND_RJ45, + roles: { 1: 'RJ45, PoE in', ...roleRange(2, 5, 'RJ45') }, + ports: [132, 235, 332, 430, 527].map((x, i) => ({ + idx: i + 1, type: 'rj45', x, y: 73, scale: 2.4, + label: [x, 134], labelSize: 14, text: i === 0 ? 'PoE IN' : String(i + 1), + })), + decor() { + return ` + + + + LINK + DOWN + 10/100 + 1000`; + }, + }, + + // Layout from a reference image, no quick start guide was available. Outlets 1-4 are + // USB-C, 5-16 AC, 17-20 larger AC. Port 21 is management, the one port UniFi reports. + // Ports 22 and 23 are a surge pass-through pair (for a modem line) with no data. + USPPDUP: { + width: 1174, height: 215, frameClass: 'fp-scroll', noBody: true, + label: 'USP PDU Pro', + legend: [['outlet-on', 'outlet on'], ['outlet-off', 'outlet off'], ['cycle', 'auto power-cycle']], + roles: { 21: 'RJ45 management' }, + ports: [ + { idx: 21, anyPort: true, type: 'rj45', x: 996.5, y: 178, label: [996.5, 200] }, + ], + outlets: [ + ...[[1, 74, 22, [68, 36]], [2, 74, 62, [68, 76]], [3, 109, 22, [102, 36]], [4, 109, 62, [102, 76]]] + .map(([idx, x, y, label]) => ({ idx, kind: 'usbc', x, y, w: 9, h: 24, label })), + ...Array.from({ length: 12 }, (_, i) => { + const x = 175 + i * 66.75; + return { idx: 5 + i, kind: 'horizontal', x, y: 21, w: 66, h: 64, label: [x + 33, 96] }; + }), + ...[208, 431, 654, 877].map((x, i) => ({ + idx: 17 + i, kind: i === 3 ? 'vertical-20' : 'vertical', x, y: 126, w: 66, h: 67, label: [x + 33, 205], + })), + ], + decor() { + const ear = x => ` + + `; + return `${ear(0)}${ear(1126)} + + ${fpQuadScreen(61, 132, 56)}${fpBrand(68, 199, 'USP PDU Pro')} + + + + ${fpPassiveJack(1052.5, 147, false, 'Port 22 · surge pass-through · not monitored')} + ${fpPassiveJack(1052.5, 175.5, true, 'Port 23 · surge pass-through · not monitored')} + 22${fpGlobe(1059, 124)} + 23${fpGlobe(1059, 202)} + ${fpReset(1088, 201)}`; + }, + }, + + // Front and rear from the labeled diagrams on Ubiquiti's store page: + // https://store.ui.com/us/en/products/ups-2u-pro + // The outlets are on the rear, and the touchscreen shows a tile for each one. + USPDA2B: { + width: 1086, height: 460, frameClass: 'fp-scroll fp-stack', scrollX: 340, noBody: true, + label: 'UPS 2U Pro', + legend: [['outlet-on', 'outlet on'], ['outlet-off', 'outlet off'], ['blue', 'online']], + ports: [], + outlets: [ + ...[457, 478, 498, 535].flatMap((x, i) => [ + { idx: i + 1, kind: 'tile', x, y: 83, w: 18, h: 18 }, + { idx: i + 5, kind: 'tile', x, y: 104, w: 18, h: 18 }, + ]), + ...[173, 251, 330, 479].flatMap((x, i) => [ + { idx: i + 1, kind: 'vertical-20', flip: true, x, y: 270, w: 66, h: 66, label: [x + 33, 350] }, + { idx: i + 5, kind: 'vertical-20', x, y: 373, w: 66, h: 66, label: [x + 33, 453] }, + ]), + ], + decor({ online }) { + const spans = [[52, 238], [251, 437], [449, 636], [647, 834], [846, 1032]]; + const vents = y => spans.flatMap(([a, b]) => [ + ``, + ``, + ]).join(''); + return ` + ${vents(10)} + ${vents(188)} + + + + + + UPS Pro + + + + + + REAR + + + + BREAKER + + POWER INPUT + + + + EPO + + SURGE NETWORK + IN + OUT + ${fpPassiveJack(973, 110, false, 'Surge in · not monitored')} + ${fpPassiveJack(1033, 110, false, 'Surge out · not monitored')} + + ${fpPassiveJack(973, 165, false, 'MGMT · 100/10 MbE · not monitored per port')} + MGMT + `; + }, + }, +}; + +function faceplateLegendSwatch(cls) { + if (cls === 'poe') return ''; + if (cls === 'cycle') return ''; + return ``; +} + +function faceplateLegendHtml(spec) { + const items = spec.legend.map(([cls, text]) => + `
${faceplateLegendSwatch(cls)}${text}
`).join(''); + const hasDetail = spec.ports.length || spec.outlets; + return `
+ ${items} + ${hasDetail ? '
' : ''} +
`; +} + +// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role) } +function renderFaceplate(model, ctx) { + const spec = FACEPLATES[model]; + const anyPort = Object.values(ctx.portMap)[0]; + const ports = spec.ports.map(p => { + const port = ctx.portMap[p.idx] || (p.anyPort ? anyPort : undefined); + const title = ctx.titleFor(port, p.idx, spec.roles && spec.roles[p.idx]); + return faceplatePortSvg(p, port, ctx.swName, title); + }).join(''); + const outletData = Object.fromEntries((ctx.outlets || []).map(o => [o.index, o])); + const outlets = (spec.outlets || []).map(o => faceplateOutletSvg(o, outletData[o.idx])).join(''); + const body = spec.noBody ? '' + : ``; + return `
+
+ + + + + + + + + + + ${body} + ${spec.decor({ online: ctx.online })} + ${outlets} + ${ports} + +
+
${faceplateLegendHtml(spec)}`; +} + +// Where an fp-scroll panel should start: its port area +function faceplateScrollLeft(model, frame) { + const spec = FACEPLATES[model]; + return spec.scrollX ? (frame.scrollWidth / spec.width) * spec.scrollX : 0; +} diff --git a/static/port-inspector.js b/static/port-inspector.js index c5c44f3..359f362 100644 --- a/static/port-inspector.js +++ b/static/port-inspector.js @@ -1,5 +1,5 @@ 'use strict'; -/* global deviceUrl */ +/* global deviceUrl, FACEPLATES, renderFaceplate, faceplateScrollLeft */ /* exported renderPortInspector, fmtRate, fmtErrors */ // Port inspector for a switch or router's device page: front-panel diagram, @@ -7,26 +7,6 @@ const escHtml = s => lt.escHtml(s); -// ── Switch layout config ───────────────────────────────────────────────── -// keys match the model field returned by the UniFi API -// rows: array of rows, each row is an array of port_idx values -// sfp_ports: port_idx values that are SFP cages (in rows, rendered differently) -// sfp_section: port_idx values rendered as a separate SFP bank below the rows -const SWITCH_LAYOUTS = { - 'USF5P': { rows: [[1,2,3,4]], sfp_section: [5] }, - 'USL8A': { rows: [[1,2,3,4],[5,6,7,8]], sfp_ports: [1,2,3,4,5,6,7,8] }, - 'US24PRO': { - rows: [ - [1,3,5,7,9,11,13,15,17,19,21,23], - [2,4,6,8,10,12,14,16,18,20,22,24], - ], - sfp_section: [25,26], - }, - 'USPPDUP': { rows: [[1]] }, - 'USMINI': { rows: [[1,2,3,4,5]] }, - 'UDMPRO': { rows: [[1,2,3,4,5,6,7,8,9]], sfp_section: [10,11] }, -}; - // ── Formatting helpers ─────────────────────────────────────────────────── function fmtShortSpeed(mbps) { if (!mbps) return '–'; @@ -86,6 +66,30 @@ function portTitle(port, idx) { return escHtml(neighbor ? `${port.name} → ${neighbor}` : port.name); } +function faceplatePortTitle(port, idx, role) { + const parts = [role ? `Port ${idx} (${role})` : `Port ${idx}`]; + if (!port || !port.up) { + parts.push('no link'); + } else { + const speed = portSpeedLabel(port); + if (speed) parts.push(speed); + if (port.poe_power > 0) parts.push(`${port.poe_power.toFixed(1)} W PoE`); + } + const neighbor = port && port.lldp && (port.lldp.system_name || port.lldp.chassis_id); + if (neighbor) parts.push(`→ ${neighbor}`); + return escHtml(parts.join(' · ')); +} + +// Faceplate jacks are too small for text, so one line under the panel names +// the hovered or selected port. +function updateFaceplateReadout(el) { + const readout = document.getElementById('fp-readout'); + if (!readout) return; + const target = el || document.querySelector('.fp-port.selected'); + const title = target && target.getAttribute('aria-label'); + readout.textContent = title || (document.querySelector('.fp-outlet') ? 'Hover or select an outlet or port' : 'Hover or select a port'); +} + // ── Render a single port block element ────────────────────────────────── function portBlockHtml(idx, port, swName, sfpBlock) { const state = portBlockState(port); @@ -118,78 +122,36 @@ function chassisLegendHtml() { // ── Render one switch chassis ──────────────────────────────────────────── function renderChassis(swName, sw) { const model = sw.model || ''; - const layout = SWITCH_LAYOUTS[model] || null; const portMap = buildPortIdxMap(sw.ports || {}); + const ports = Object.entries(sw.ports || {}); - const upCount = Object.values(sw.ports || {}).filter(p => p.up).length; - const totCount = Object.keys(sw.ports || {}).length; - const downCount = totCount - upCount; - const meta = [model, `${upCount}/${totCount} up`, downCount ? `${downCount} down` : ''].filter(Boolean).join(' · '); - - // Is this a US24PRO? Used to add group-separator class - const isUs24Pro = (model === 'US24PRO'); - - let chassisHtml = ''; - - if (layout) { - const sfpPortSet = new Set(layout.sfp_ports || []); - const sfpSectionSet = new Set(layout.sfp_section || []); - - // Main port rows - chassisHtml += '
'; - for (const row of layout.rows) { - const rowCls = isUs24Pro ? ' us24pro-row' : ''; - chassisHtml += `
`; - for (const idx of row) { - const port = portMap[idx]; - const isSfp = sfpPortSet.has(idx); - const sfpCls = isSfp ? ' sfp-port' : ''; - const state = portBlockState(port); - const title = portTitle(port, idx); - const speedTxt = portSpeedLabel(port); - const lldpName = (port && port.lldp && (port.lldp.system_name || port.lldp.chassis_id)) - ? escHtml((port.lldp.system_name || port.lldp.chassis_id || '').slice(0, 6)) - : ''; - const speedHtml = speedTxt ? `${speedTxt}` : ''; - const lldpHtml = lldpName ? `${lldpName}` : ''; - chassisHtml += `
${idx}${speedHtml}${lldpHtml}
`; - } - chassisHtml += '
'; - } - chassisHtml += '
'; - - // Separate SFP section (if present) - if (sfpSectionSet.size) { - chassisHtml += '
'; - for (const idx of layout.sfp_section) { - chassisHtml += portBlockHtml(idx, portMap[idx], swName, true); - } - chassisHtml += '
'; - } - } else { - // Fallback: render all ports sorted by idx - const allPorts = Object.entries(sw.ports || {}) - .sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0)); - chassisHtml += '
'; - for (const [pname, d] of allPorts) { - chassisHtml += portBlockHtml(d.port_idx || 0, Object.assign({ name: pname }, d), swName, false); - } - chassisHtml += '
'; - } - - return ` -
-
+ const upCount = ports.filter(([, p]) => p.up).length; + const downCount = ports.length - upCount; + const counts = ports.length ? [`${upCount}/${ports.length} up`, downCount ? `${downCount} down` : ''] : []; + const meta = [model, ...counts].filter(Boolean).join(' · '); + const header = `
Front panel ${escHtml(meta)} -
+
`; + + if (FACEPLATES[model]) { + return `
${header}${renderFaceplate(model, { + swName, portMap, outlets: sw.outlets, online: sw.online, titleFor: faceplatePortTitle, + })}
`; + } + + // No faceplate for this model yet: plain blocks in port order + const blocks = ports + .sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0)) + .map(([pname, d]) => portBlockHtml(d.port_idx || 0, Object.assign({ name: pname }, d), swName, false)) + .join(''); + return ` +
+ ${header}
- ${chassisHtml} +
${blocks}
${chassisLegendHtml()}
`; @@ -428,11 +390,19 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) { // ── Page integration: device.js supplies this one device's data ────────── function renderPortInspector(detail) { _apiData = { - unifi_switches: { [detail.name]: { ip: detail.ip, model: detail.model, ports: detail.ports || {} } }, + unifi_switches: { [detail.name]: { + ip: detail.ip, model: detail.model, ports: detail.ports || {}, + outlets: detail.info && detail.info.outlets, online: detail.status === 'up', + } }, hosts: detail.neighbor_hosts || {}, }; const chassis = document.getElementById('device-chassis'); + // Auto-refresh redraws the panel, so keep a scrolled panel where the user left it + const oldFrame = chassis.querySelector('.fp-scroll'); + const scrollLeft = oldFrame ? oldFrame.scrollLeft : null; chassis.innerHTML = renderChassis(detail.name, _apiData.unifi_switches[detail.name]); + const frame = chassis.querySelector('.fp-scroll'); + if (frame) frame.scrollLeft = scrollLeft !== null ? scrollLeft : faceplateScrollLeft(detail.model, frame); if (_selectedSwitch === detail.name && _selectedIdx !== null) { renderPanel(_selectedSwitch, _selectedIdx); } else if (_selectedSwitch === null) { @@ -447,9 +417,10 @@ function openPortFromHash(swName) { } function highlightSelectedPort() { - document.querySelectorAll('.switch-port-block').forEach(el => { + document.querySelectorAll('[data-action="select-port"]').forEach(el => { el.classList.toggle('selected', parseInt(el.dataset.portIdx, 10) === _selectedIdx); }); + updateFaceplateReadout(); document.querySelectorAll('tr.device-row[data-port-idx]').forEach(row => { row.classList.toggle('is-selected', parseInt(row.dataset.portIdx, 10) === _selectedIdx); }); @@ -465,6 +436,18 @@ document.addEventListener('keydown', e => { if (block) { e.preventDefault(); selectPort(block); } }); +document.addEventListener('mouseover', e => { + const port = e.target.closest && e.target.closest('.fp-port, .fp-outlet'); + if (port) updateFaceplateReadout(port); +}); +document.addEventListener('mouseout', e => { + if (e.target.closest && e.target.closest('.fp-port, .fp-outlet')) updateFaceplateReadout(); +}); +document.addEventListener('focusin', e => { + const port = e.target.closest && e.target.closest('.fp-port, .fp-outlet'); + if (port) updateFaceplateReadout(port); +}); + document.addEventListener('click', e => { const portBlock = e.target.closest('[data-action="select-port"]'); if (portBlock) { selectPort(portBlock); return; } @@ -521,7 +504,7 @@ function pollDiagnostic(jobId, statusEl, resultsEl) { statusEl.innerHTML = 'Timed out waiting for results. ' + ''; document.getElementById('diag-retry-btn')?.addEventListener('click', () => { - const sel = document.querySelector('.switch-port-block.selected'); + const sel = document.querySelector('[data-action="select-port"].selected'); if (sel) runDiagnostic(sel.dataset.switch, parseInt(sel.dataset.portIdx)); }); return; @@ -541,7 +524,7 @@ function pollDiagnostic(jobId, statusEl, resultsEl) { statusEl.innerHTML = 'Error: lost connection while collecting diagnostics. ' + ''; document.getElementById('diag-retry-btn')?.addEventListener('click', () => { - const sel = document.querySelector('.switch-port-block.selected'); + const sel = document.querySelector('[data-action="select-port"].selected'); if (sel) runDiagnostic(sel.dataset.switch, parseInt(sel.dataset.portIdx)); }); }); diff --git a/static/style.css b/static/style.css index 6d6ab90..e0895fc 100644 --- a/static/style.css +++ b/static/style.css @@ -1172,3 +1172,141 @@ tr.device-row.is-selected { background: var(--bg-tertiary); box-shadow: inset 3p .device-inspector .inspector-panel-inner { width: 100%; max-height: none; } .device-chassis .chassis-ear-l, .device-chassis .chassis-ear-r { display: none; } } + +/* ── Realistic device faceplates (static/faceplates.js) ───────────── */ +/* Hardware colors are fixed: a silver chassis stays silver in every theme. */ +.device-chassis { container-type: inline-size; } +.fp-wrap { padding: 14px 16px; } +.fp-frame { overflow: hidden; border-radius: 3px; box-shadow: 0 2px 10px rgba(0,0,0,.55); } +.fp-svg { display: block; width: 100%; height: auto; } +/* Too narrow for the whole unit: zoom to the port area so jacks stay tappable. + Width = viewBox width / visible width; margin = cropX / visible width. */ +@container (max-width: 620px) { + .fp-crop-udmpro .fp-svg { width: 308.7%; max-width: none; margin-left: -208.7%; } +} + +.fp-body { fill: url(#fp-metal); } +.fp-vent { fill: #242326; } +.fp-screen { fill: #0c1020; stroke: #2a2c34; stroke-width: 1; } +.fp-screen-ring{ fill: none; stroke: #3f7bd9; stroke-width: 2.2; } +.fp-screen-dot { fill: #dfe8f8; } +.fp-brand { fill: #55565c; font: 600 7.5px Arial, Helvetica, sans-serif; } +.fp-bay { fill: url(#fp-bay-face); stroke: #54555a; stroke-width: 1; } +.fp-bay-edge { fill: #bdbdc1; } +.fp-bay-led { fill: #3a3a3e; } +.fp-block { fill: #e1e1e3; stroke: #5d5e63; stroke-width: 1; } +.fp-divider { fill: #dcdcde; } +.fp-label { fill: #55565c; font: 9px Arial, Helvetica, sans-serif; } +.fp-label-sm { font-size: 6.5px; letter-spacing: .03em; } +.fp-globe { fill: none; stroke: #3f7bd9; stroke-width: .8; } +.fp-reset { fill: #1b1b1d; } + +.fp-jack { fill: #18181b; stroke: #0c0c0d; stroke-width: .6; } +.fp-contacts { fill: #b89545; } +.fp-plug { fill: #9ea1a8; stroke: #5b5d63; stroke-width: .6; } +.fp-plug-clip { fill: #7e8087; stroke: #5b5d63; stroke-width: .5; } +.fp-cage { fill: #17171a; stroke: #6e6f74; stroke-width: 1; } +.fp-cage-spring{ fill: none; stroke: #5d5e63; stroke-width: 1.2; stroke-dasharray: 2.5 1.5; } +.fp-cage-rail { fill: #3b3c41; } +.fp-module { fill: #a5a7ad; stroke: #6a6b70; stroke-width: .6; } +.fp-module-lc { fill: #2a4a7f; stroke: #1a2a44; stroke-width: .5; } + +.fp-led-idle { fill: #6a6b70; } +.fp-led-off { fill: #4b4c50; } +.fp-led-amber { fill: #ffad1f; } +.fp-led-green { fill: #35f06f; } +.fp-led-white { fill: #ffffff; stroke: #8fa6c4; stroke-width: .5; } +.fp-led-unknown{ fill: #b9bcc2; } +.fp-led-amber, .fp-led-green, .fp-led-white { filter: drop-shadow(0 0 1.5px currentColor); } +.fp-led-amber { color: #ffad1f; } +.fp-led-green { color: #35f06f; } +.fp-led-white { color: #cfe2ff; } +.fp-led-active { animation: fp-blink 1.3s steps(2, jump-none) infinite; } +@keyframes fp-blink { 50% { opacity: .45; } } +@media (prefers-reduced-motion: reduce) { .fp-led-active { animation: none; } } + +.fp-port { cursor: pointer; outline: none; } +.fp-hit { fill: transparent; stroke: transparent; stroke-width: 2.5; pointer-events: all; } +.fp-port:hover .fp-hit { stroke: rgba(0,90,220,.55); } +.fp-port:focus-visible .fp-hit, +.fp-port.selected .fp-hit { stroke: #0058e0; fill: rgba(0,88,224,.14); } + +.fp-legend-led { width: 12px; height: 5px; border-radius: 1px; display: inline-block; background: #4b4c50; } +.fp-legend-led.fp-led-amber { background: #ffad1f; } +.fp-legend-led.fp-led-green { background: #35f06f; } +.fp-legend-led.fp-led-white { background: #fff; } +.fp-readout { margin-left: auto; font-size: .72em; color: var(--text-dim, var(--text-muted)); text-transform: none; letter-spacing: 0; } + +/* Narrow panels: crop to the ports, or scroll within the panel */ +@container (max-width: 620px) { + .fp-crop-agg .fp-svg { width: 437%; max-width: none; margin-left: -337%; } + .fp-scroll { overflow-x: auto; } + .fp-scroll .fp-svg { width: 260%; max-width: none; } +} +.fp-desk { box-shadow: none; } +.fp-desk .fp-svg { max-width: 560px; margin: 0 auto; filter: drop-shadow(0 3px 6px rgba(0,0,0,.5)); } + +.fp-body-white { fill: #f2f2f3; stroke: #c9c9cc; stroke-width: 1; } +.fp-frame-grey { fill: #c6c8cc; stroke: #8b8d92; stroke-width: 1; } +.fp-foot { fill: #b7b7ba; } +.fp-body-metal { fill: url(#fp-metal); } +.fp-ear { fill: #c4c4c7; stroke: #8f9095; stroke-width: 1; } +.fp-ear-hole { fill: #0b0d12; } +.fp-button { fill: #3a3a3e; stroke: #1b1b1d; stroke-width: 1; } +.fp-speed-bar { fill: #8d8e93; } +.fp-screen-grid{ fill: none; stroke: #2f6fd0; stroke-width: 1; } +.fp-screen-icon{ fill: #6b9be4; } +.fp-static { opacity: .85; } +.fp-bolt { fill: #e88a00; } +.fp-cycle { fill: none; stroke: #0058e0; stroke-width: 1.4; stroke-linecap: round; } + +.fp-outlet { cursor: default; outline: none; } +.fp-outlet-face { fill: #2a2a2d; stroke: #111113; stroke-width: 1; } +.fp-slot { fill: #050506; } +.fp-usbc { fill: #1c1c1f; stroke: #8b8d92; stroke-width: 1; } +.fp-usbc-tongue { fill: #b9bcc2; } +.fp-outlet-is-off .fp-outlet-face { fill: #5b5c60; } +.fp-outlet-is-off .fp-usbc { fill: #66676b; } +.fp-outlet:hover .fp-hit { stroke: rgba(0,90,220,.55); } +.fp-outlet:focus-visible .fp-hit { stroke: #0058e0; fill: rgba(0,88,224,.14); } +.fp-outlet-state { fill: #8d8e93; } +.fp-state-on { fill: #12a84a; } +.fp-state-off { fill: #d6332a; } + +.fp-ups-band { fill: #8c8d90; } +.fp-ups-vent { fill: #2b2b2e; } +.fp-ups-rib { fill: none; stroke: #77787c; stroke-width: 1; } +.fp-ups-batt { fill: none; stroke: #2b2b2e; stroke-width: 1.3; } +.fp-ups-batt-tip{ fill: #2b2b2e; } + +.fp-led-blue { fill: #2f7bff; color: #2f7bff; filter: drop-shadow(0 0 2px currentColor); } +.fp-legend-led.fp-led-blue { background: #2f7bff; } +.fp-legend-led.fp-led-outlet-on { background: #12a84a; width: 7px; height: 7px; border-radius: 50%; } +.fp-legend-led.fp-led-outlet-off { background: #d6332a; width: 7px; height: 7px; border-radius: 50%; } +.fp-legend-icon { width: 11px; height: 11px; flex-shrink: 0; } + +/* A white LED needs an edge to show against the light metal around it */ +polygon.fp-led-white { stroke: #6b7c95; stroke-width: .7; } +polygon.fp-led-off { fill: #6b6c71; } +.fp-key { fill: none; stroke: #55565c; stroke-width: .8; } +.fp-key-amber { fill: #e8762c; stroke: none; } +.fp-key-green { fill: #7ec23a; stroke: none; } +.fp-ups-bolt { fill: #2b2b2e; } + +/* UPS 2U Pro: front and rear panels stacked */ +.fp-stack { box-shadow: none; } +.fp-caption { fill: #8b96a6; font: 600 10px Arial, Helvetica, sans-serif; letter-spacing: .1em; } +.fp-ups-band-light { fill: #f4f4f5; } +.fp-power-ring { fill: none; stroke: #8d8e93; stroke-width: 3; } +.fp-power-on { stroke: #2f9bff; filter: drop-shadow(0 0 3px #2f9bff); } +.fp-power-face { fill: #dcdcde; stroke: #a9a9ad; stroke-width: 1; } +.fp-power-icon { fill: none; stroke: #55565c; stroke-width: 1.5; stroke-linecap: round; } +.fp-tile { fill: #1f6fff; } +.fp-tile-num { fill: #fff; font: 600 9px Arial, Helvetica, sans-serif; } +.fp-outlet-is-off .fp-tile { fill: #3a3d46; } +.fp-screen-tile-dim{ fill: #2a2d36; } +.fp-breaker { fill: #4a4a4e; } +.fp-fan { fill: none; stroke: #3a3a3e; stroke-width: 7; } +.fp-fan-strut { fill: none; stroke: #c9c9cc; stroke-width: 5; } +.fp-epo { fill: #1c1c1f; } +.fp-bracket { fill: none; stroke: #55565c; stroke-width: 1; } diff --git a/templates/device.html b/templates/device.html index 7f91795..0acde66 100644 --- a/templates/device.html +++ b/templates/device.html @@ -50,6 +50,7 @@ {% block scripts %} + {% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py index d2d37f6..81a7c2a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -70,6 +70,10 @@ class TestPagesAndNav: html = client.get('/device/large1', headers=AUTH).get_data(as_text=True) assert 'port-inspector.js' in html and 'id="inspector-panel"' in html + def test_faceplates_load_before_the_port_inspector(self, client): + html = client.get('/device/large1', headers=AUTH).get_data(as_text=True) + assert html.index('faceplates.js') < html.index('port-inspector.js') + def test_link_data_api_remains(self, client): assert client.get('/api/links', headers=AUTH).status_code == 200