'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. // What sits in a cage: 'empty', 'optic', 'dac', or 'module' when the module // doesn't say what it is. Without module data (older snapshots, or a gateway // that doesn't report it) a linked port is shown as an optic, as before. function sfpModuleKind(port) { const sfp = port && port.sfp; if (sfp) return !sfp.found ? 'empty' : (sfp.kind || 'module'); return port && port.up ? 'optic' : 'empty'; } function sfpSvg(port, w, h) { const kind = sfpModuleKind(port); const sfp = port && port.sfp; const fault = sfp && (sfp.rx_fault || sfp.tx_fault) ? ' fp-module-fault' : ''; const body = ``; let inner; if (kind === 'optic') { inner = `${body} `; } else if (kind === 'dac') { // A DAC's cable leaves through a boot in the middle of the module inner = `${body} `; } else if (kind === 'module') { // Present but unidentified: the module body with its pull tab, no connector detail inner = `${body} `; } else { inner = ` `; } 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; }