'use strict';
/* exported renderFaceplate, faceplateScrollLeft, faceplatePortAnchor, 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}`;
}
// Cutsheet status dot in a port's corner: ok (matches), bad (mismatch), doc (documented, not confirmed)
function cutsheetMarkSvg(mark, x, y) {
if (!mark) return '';
return ` `;
}
function faceplatePortSvg(p, port, swName, title, mark) {
// 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)}
${cutsheetMarkSvg(mark, p.w - 3, 3)}
${sfpLedSvg(p, port)}${portLabelSvg(p, port)}`;
}
const scale = p.scale || 1;
return `
${title}
${rj45Svg(port, p)}
${cutsheetMarkSvg(mark, 14, p.flip ? 13 : -13)}
${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}
`;
}
// The bar UniFi prints under a group of faster ports, with its speed ("2.5G", "10G")
function fpSpeedBar(x0, x1, y, text) {
return `
${text} `;
}
// The model name printed under a UniFi screen: centred on the screen, first word bold and
// second word light ("USW Pro", "NVR Pro"), as on the real units. `bolt` adds the PoE mark.
function fpModel(cx, y, bold, light = '', { size = 7.5, bolt = false } = {}) {
const chars = bold.length + (light ? light.length + 1 : 0);
const boltW = bolt ? size * 1.3 : 0;
const textW = chars * size * 0.6;
const x = cx - boltW / 2;
const mark = bolt
? `
`
: '';
return `${bold}${light
? `${light} ` : ''} ${mark}`;
}
// 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)}
${fpModel(43, 94, 'UDM', 'Pro')}
9 ${fpGlobe(951, 90.5)}
10 ${fpGlobe(999, 15.5)}
11
${fpReset(1026, 91)}`;
},
},
// UniFi NVR Pro, 2U. Layout from the CC0 NetBox devicetype-library front image
// (elevation-images/Ubiquiti/ubiquiti-unifi-protect-network-video-recorder-pro.front.png):
// touchscreen top left, bays 1-3 on top and 4-7 below, RJ45 (1) and SFP+ (2) top right.
// UniFi Network doesn't list a Protect NVR, so on the rack it is picked by Front drawing;
// if it is ever reported as UNVRPRO its ports go live like any other.
UNVRPRO: {
width: 1167, height: 230, frameClass: 'fp-scroll', scrollX: 700,
label: 'UniFi NVR Pro',
legend: [...LEGEND_RJ45, ['white', '10G']],
roles: { 1: 'RJ45 1G', 2: 'SFP+ 10G' },
ports: [
{ idx: 1, type: 'rj45', x: 1040, y: 74, scale: 1.3, label: [1040, 44] },
{ idx: 2, type: 'sfp', x: 1072, y: 61, w: 40, h: 27, led: [1092, 96], label: [1092, 44] },
],
decor() {
const spans = [[97, 293], [302, 498], [507, 703], [712, 908], [916, 1112]];
const bay = (x, y, n) => `
${n} `;
return `${fpVents(spans, 8, 4)}${fpVents(spans, 216, 4)}
${fpQuadScreen(14, 33, 62)}${fpModel(45, 111, 'NVR', 'Pro')}
${[97, 384, 669].map((x, i) => bay(x, 27, i + 1)).join('')}
${[12, 299, 585, 871].map((x, i) => bay(x, 125, i + 4)).join('')}
${fpReset(1124, 219)}`;
},
},
// 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)}${fpModel(46, 99, 'USW', 'Pro', { bolt: true })}
${fpReset(1140, 97)}`;
},
},
// USW Pro Max 24 PoE, laid out from the CC0 NetBox devicetype-library image
// (ubiquiti-unifi-switch-pro-max-24-poe.front.png; jack centres measured from it):
// one row of 24 RJ45 in three groups of 8 (17-24 are 2.5G), then 2 SFP+.
USPM24P: {
width: 1167, height: 117, frameClass: 'fp-scroll', scrollX: 560,
label: 'USW Pro Max 24 PoE',
legend: [...LEGEND_RJ45, ['white', '10G'], ['poe', 'PoE in use']],
roles: { ...roleRange(1, 16, 'RJ45 1G'), ...roleRange(17, 24, 'RJ45 2.5G'), 25: 'SFP+', 26: 'SFP+' },
ports: [
...[118.5, 427, 734.5].flatMap((x0, g) => Array.from({ length: 8 }, (_, i) => {
const x = x0 + i * 37.35;
return { idx: g * 8 + i + 1, type: 'rj45', x, y: 74, scale: 1.1, label: [x, 100] };
})),
...[1030, 1071].map((x, i) => ({ idx: 25 + i, type: 'sfp', x, y: 62, w: 38, h: 27,
led: [x + 19, 54], label: [x + 19, 100] })),
],
decor() {
return `${fpVents([[97, 293], [301, 498], [506, 703], [710, 906], [914, 1110]], 9, 3)}
${fpQuadScreen(14, 27, 60)}${fpModel(44, 103, 'USW', 'Pro Max', { bolt: true })}
${[98, 407, 714].map(x => ` `).join('')}
${fpSpeedBar(714, 1017, 107, '2.5G')}${fpSpeedBar(1030, 1109, 107, '10G')}
${fpReset(1128, 101)}`;
},
},
// USW Pro Max 48 PoE, from ubiquiti-unifi-switch-pro-max-48-poe.front.png (CC0, NetBox):
// two rows, odd ports on top, three groups of 16 (33-48 are 2.5G), then 4 SFP+ (49-52).
USPM48P: {
width: 1169, height: 114, frameClass: 'fp-scroll', scrollX: 560,
label: 'USW Pro Max 48 PoE',
legend: [...LEGEND_RJ45, ['white', '10G'], ['poe', 'PoE in use']],
roles: { ...roleRange(1, 32, 'RJ45 1G'), ...roleRange(33, 48, 'RJ45 2.5G'), ...roleRange(49, 52, 'SFP+') },
ports: [
...rj45Pairs([122, 425.5, 731].flatMap(x0 => Array.from({ length: 8 }, (_, i) => x0 + i * 36.8)),
{ top: 38, bottom: 68, topLabel: 15, bottomLabel: 97 }),
...sfpPairs([1033, 1075], { first: 49, top: 23, bottom: 60, w: 37, h: 27, ledY: 56, ledGap: 9,
topLabel: 15, bottomLabel: 97 }),
],
decor() {
return `${fpVents([[97, 293], [301, 498], [506, 703], [710, 906], [914, 1110]], 7, 3)}
${fpQuadScreen(14, 25, 60)}${fpModel(44, 101, 'USW', 'Pro Max', { bolt: true })}
${[101, 405, 711].map(x => ` `).join('')}
${fpSpeedBar(711, 1009, 104, '2.5G')}${fpSpeedBar(1033, 1112, 104, '10G')}
${fpReset(1130, 99)}`;
},
},
// 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)}${fpModel(41, 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)}${fpModel(89, 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,
rackView: [0, 0, 1086, 210], // the front only, for the rack drawing
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 ` `;
}
const CUTSHEET_MARK_LEGEND = [['ok', 'Matches cutsheet'], ['bad', 'Cutsheet mismatch'], ['doc', 'Documented, unconfirmed']];
function faceplateLegendHtml(spec, marks) {
let items = spec.legend.map(([cls, text]) =>
`
${faceplateLegendSwatch(cls)}${text}
`).join('');
if (marks && marks.size) {
items += CUTSHEET_MARK_LEGEND.filter(([m]) => marks.has(m)).map(([m, text]) =>
` ${text}
`).join('');
items += ` Cutsheet marks `;
}
const hasDetail = spec.ports.length || spec.outlets;
return `
${items}
${hasDetail ? '
' : ''}
`;
}
// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role), cutsheetFor?(idx) -> {mark, text},
// rack? (draw only the front, spec.rackView, as mounted in a rack) }
function renderFaceplate(model, ctx) {
const { svg, marks } = faceplateSvg(model, ctx);
return `${faceplateLegendHtml(FACEPLATES[model], marks)}`;
}
// The faceplate's SVG on its own, for drawing inside another picture (a shelf). `attrs` adds
// attributes to the (x, y, width, height to place it). Returns { svg, marks }.
function faceplateSvg(model, ctx, attrs = '') {
const spec = FACEPLATES[model];
const anyPort = Object.values(ctx.portMap)[0];
const marks = new Set();
const ports = spec.ports.map(p => {
const port = ctx.portMap[p.idx] || (p.anyPort ? anyPort : undefined);
let title = ctx.titleFor(port, p.idx, spec.roles && spec.roles[p.idx]);
const cs = ctx.cutsheetFor ? ctx.cutsheetFor((p.anyPort && port) ? port.port_idx : p.idx) : null;
if (cs) {
marks.add(cs.mark);
title += lt.escHtml(` · ${cs.text}`);
}
return faceplatePortSvg(p, port, ctx.swName, title, cs && cs.mark);
}).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 ? ''
: ` `;
const svg = `
${body}
${spec.decor({ online: ctx.online })}
${outlets}
${ports}
`;
return { svg, marks };
}
// Where a port's cable plugs in, in the faceplate's viewBox units: { x, y, face }, face 'top' for a
// port the right way up and 'bottom' for a flipped one (the lower row). null when there is no such
// port. A model whose one port takes any index (the PDU's management port) answers for every index.
function faceplatePortAnchor(spec, idx) {
const p = spec.ports.find(q => q.idx === idx) || spec.ports.find(q => q.anyPort);
if (!p) return null;
const face = p.flip ? 'bottom' : 'top';
if (p.type === 'sfp') return { x: p.x + p.w / 2, y: p.y + p.h / 2, face };
return { x: p.x, y: p.y, face };
}
// 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;
}
// Same fronts under the other model codes UniFi uses for these switches (non-PoE variants
// draw the same; PoE marks simply never show)
FACEPLATES.USPM24 = FACEPLATES.USPM24P;
FACEPLATES.USPM48 = FACEPLATES.USPM48P;