Files

646 lines
34 KiB
JavaScript
Raw Permalink Normal View History

'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
? `<rect class="fp-plug" x="-12" y="-6.5" width="24" height="14" rx="1"/>
<rect class="fp-plug-clip" x="-5" y="-14" width="10" height="8"/>`
: `<g class="fp-contacts">${[-9, -6, -3, 0, 3, 6, 9].map(x => `<rect x="${x - .6}" y="-8" width="1.2" height="4"/>`).join('')}</g>`;
const leftLed = p.poeLed ? `fp-led fp-led-${hasPoe(port) ? 'amber' : 'off'}` : 'fp-led-idle';
return `
<path class="fp-jack" d="M-15 -8.5 H-7.5 V-16 H7.5 V-8.5 H15 V-3 H16 V1 H15 V9 H-15 V1 H-16 V-3 H-15 Z"/>
${plug}
<rect class="${leftLed}" x="-16" y="-14" width="7.5" height="3.2" rx=".8"/>
<rect class="fp-led fp-led-${led}${ledActivityClass(port)}" x="8.5" y="-14" width="7.5" height="3.2" rx=".8"/>`;
}
// An SFP+ cage seen head-on. Fills with a module when the port has link.
2026-11-10 14:37:08 -05:00
// 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) {
2026-11-10 14:37:08 -05:00
const kind = sfpModuleKind(port);
const sfp = port && port.sfp;
const fault = sfp && (sfp.rx_fault || sfp.tx_fault) ? ' fp-module-fault' : '';
const body = `<rect class="fp-module${fault}" x="2.5" y="2.5" width="${w - 5}" height="${h - 5}" rx="1"/>`;
let inner;
if (kind === 'optic') {
inner = `${body}
<rect class="fp-module-lc" x="6.5" y="6" width="9" height="${h - 12}" rx="1.2"/>
2026-11-10 14:37:08 -05:00
<rect class="fp-module-lc" x="${w - 15.5}" y="6" width="9" height="${h - 12}" rx="1.2"/>`;
} else if (kind === 'dac') {
// A DAC's cable leaves through a boot in the middle of the module
inner = `${body}
<rect class="fp-dac-boot" x="${w / 2 - 7}" y="4.5" width="14" height="${h - 9}" rx="2.5"/>
<circle class="fp-dac-cable" cx="${w / 2}" cy="${h / 2}" r="3.2"/>`;
} else if (kind === 'module') {
// Present but unidentified: the module body with its pull tab, no connector detail
inner = `${body}
<rect class="fp-cage-rail" x="${w / 2 - 6}" y="${h / 2 - 1.5}" width="12" height="3" rx="1"/>`;
} else {
inner = `<path class="fp-cage-spring" d="M4 5 H${w - 4} M4 ${h - 5} H${w - 4}"/>
<rect class="fp-cage-rail" x="${w / 2 - 8}" y="${h / 2 - 2}" width="16" height="4" rx="1"/>`;
2026-11-10 14:37:08 -05:00
}
return `<rect class="fp-cage" width="${w}" height="${h}" rx="1"/>${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 `<polygon class="fp-led fp-led-${faceplateLed(port, 'sfp')}${ledActivityClass(port)}" points="${points}"/>`;
}
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
? `<path class="fp-bolt" transform="translate(${x + 8} ${y - 8})" d="M3 0 L0 5 H2.2 L1.4 9 L5 3.6 H2.8 L4 0 Z"/>`
: '';
return `<text class="fp-label" x="${x}" y="${y}" text-anchor="middle"${size}>${p.text || p.idx}</text>${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 `<circle class="fp-mark fp-mark-${mark}" cx="${x}" cy="${y}" r="3.2"/>`;
}
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 `<g ${attrs} transform="translate(${p.x} ${p.y})">
<title>${title}</title>
${sfpSvg(port, p.w, p.h)}
<rect class="fp-hit" x="-1" y="-1" width="${p.w + 2}" height="${p.h + 2}" rx="2"/>
${cutsheetMarkSvg(mark, p.w - 3, 3)}
</g>
${sfpLedSvg(p, port)}${portLabelSvg(p, port)}`;
}
const scale = p.scale || 1;
return `<g ${attrs} transform="translate(${p.x} ${p.y}) scale(${scale})">
<title>${title}</title>
<g transform="scale(1 ${p.flip ? -1 : 1})">${rj45Svg(port, p)}</g>
<rect class="fp-hit" x="-18" y="-${p.flip ? 11 : 17}" width="36" height="28" rx="2"/>
${cutsheetMarkSvg(mark, 14, p.flip ? 13 : -13)}
</g>${portLabelSvg(p, port)}`;
}
// ── Power outlets ────────────────────────────────────────────────────────
function outletFaceSvg(kind, w, h, idx) {
const cx = w / 2, cy = h / 2;
if (kind === 'tile') {
return `<rect class="fp-tile" width="${w}" height="${h}" rx="2"/>
<text class="fp-tile-num" x="${cx}" y="${cy + 3.5}" text-anchor="middle">${idx}</text>`;
}
if (kind === 'usbc') {
return `<rect class="fp-usbc" width="${w}" height="${h}" rx="${w / 2}"/>
<rect class="fp-usbc-tongue" x="${cx - 1}" y="4" width="2" height="${h - 8}" rx="1"/>`;
}
const face = `<rect class="fp-outlet-face" width="${w}" height="${h}" rx="3"/>`;
if (kind === 'horizontal') {
return `${face}
<rect class="fp-slot" x="${cx - 24}" y="${cy - 15}" width="19" height="4.5" rx=".6"/>
<rect class="fp-slot" x="${cx - 24}" y="${cy + 10.5}" width="19" height="4.5" rx=".6"/>
<path class="fp-slot" d="M${cx + 12} ${cy - 8} h13 v16 h-13 a8 8 0 0 1 0 -16 Z"/>`;
}
const tee = kind === 'vertical-20'
? `<rect class="fp-slot" x="${cx + 12}" y="${cy - 8}" width="10" height="4"/>` : '';
return `${face}
<rect class="fp-slot" x="${cx - 19}" y="${cy - 23}" width="5" height="19" rx=".6"/>
<rect class="fp-slot" x="${cx + 14}" y="${cy - 23}" width="5" height="19" rx=".6"/>
${tee}
<path class="fp-slot" d="M${cx - 7.5} ${cy + 22} v-9 a7.5 7.5 0 0 1 15 0 v9 Z"/>`;
}
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 = `<g class="fp-outlet${off ? ' fp-outlet-is-off' : ''}" tabindex="0" role="img" aria-label="${title}" transform="translate(${o.x} ${o.y})">
<title>${title}</title>
${o.flip ? `<g transform="rotate(180 ${o.w / 2} ${o.h / 2})">${face}</g>` : face}
<rect class="fp-hit" x="-1.5" y="-1.5" width="${o.w + 3}" height="${o.h + 3}" rx="3"/>
</g>`;
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 = `<circle class="fp-outlet-state ${off ? 'fp-state-off' : data ? 'fp-state-on' : ''}" cx="${lx - 9}" cy="${ly - 3}" r="2.2"/>`;
const cycle = data && data.auto_power_cycle
? `<path class="fp-cycle" transform="translate(${lx + 8} ${ly - 7})" d="M7 1.5 A3.5 3.5 0 1 0 8 5 M7 0 V2.5 H4.5"/>` : '';
return `${group}${state}<text class="fp-label" x="${lx}" y="${ly}" text-anchor="middle">${o.idx}</text>${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 `<g class="fp-outlet fp-static" tabindex="0" role="img" aria-label="${title}" transform="translate(${x} ${y})">
<title>${title}</title>
<g transform="scale(1 ${flip ? -1 : 1})">${rj45Svg(null, {})}</g>
<rect class="fp-hit" x="-18" y="-${flip ? 11 : 17}" width="36" height="28" rx="2"/>
</g>`;
}
// ── Shared drawing helpers ───────────────────────────────────────────────
const fpGlobe = (x, y) => `<g class="fp-globe" transform="translate(${x} ${y})">
<circle r="3.4"/><ellipse rx="1.5" ry="3.4"/><path d="M-3.4 0 H3.4"/></g>`;
const fpVents = (spans, y, h = 2.6) => spans
.map(([a, b]) => `<rect class="fp-vent" x="${a}" y="${y}" width="${b - a}" height="${h}" rx="${h / 2}"/>`).join('');
const fpReset = (x, y) => `<circle class="fp-reset" cx="${x}" cy="${y}" r="2.2"/>
<text class="fp-label fp-label-sm" x="${x + 5}" y="${y + 2.5}">RESET</text>`;
// 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]) => `<circle class="fp-screen-icon" cx="${s * a}" cy="${s * b}" r="${s * .07}"/>`).join('');
return `<g transform="translate(${x} ${y})">
<rect class="fp-screen" width="${s}" height="${s}" rx="3"/>
<path class="fp-screen-grid" d="M${c} 3 V${s - 3} M3 ${c} H${s - 3}"/>
${icons}
<circle class="fp-screen-dot" cx="${c}" cy="${c}" r="${s * .11}"/>
</g>`;
}
2026-11-11 00:01:12 -05:00
// The bar UniFi prints under a group of faster ports, with its speed ("2.5G", "10G")
function fpSpeedBar(x0, x1, y, text) {
return `<rect class="fp-speedbar" x="${x0}" y="${y - 3}" width="${x1 - x0}" height="3" rx="1.5"/>
<text class="fp-label fp-label-sm" x="${(x0 + x1) / 2}" y="${y + 6}" text-anchor="middle">${text}</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
? `<g transform="translate(${x + textW / 2 + size * 0.75} ${y - size * 0.4})">
<circle class="fp-brand-bolt-ring" r="${size * 0.62}"/>
<path class="fp-brand-bolt" transform="scale(${size / 11})" d="M1 -5 L-3 1 H0 L-1 5 L3 -1 H0 Z"/></g>`
: '';
return `<text class="fp-brand" x="${x}" y="${y}" text-anchor="middle" style="font-size:${size}px">${bold}${light
? `<tspan class="fp-brand-light" dx="${size * 0.25}">${light}</tspan>` : ''}</text>${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)}
<rect class="fp-screen" x="15" y="25" width="56" height="56" rx="3"/>
<circle class="fp-screen-ring" cx="43" cy="51" r="13"/>
<circle class="fp-screen-dot" cx="43" cy="51" r="4.5"/>
${fpModel(43, 94, 'UDM', 'Pro')}
<rect class="fp-bay" x="447" y="19" width="259" height="66" rx="1.5"/>
<rect class="fp-bay-edge" x="447.8" y="19.8" width="3.5" height="64.4"/>
<circle class="fp-bay-led" cx="681" cy="52" r="1.6"/>
<rect class="fp-block" x="740" y="22" width="153" height="61.5" rx="1"/>
<rect class="fp-block" x="928" y="50" width="40" height="33.5" rx="1"/>
<rect class="fp-divider" x="975" y="45.5" width="35" height="14"/>
<text class="fp-label" x="941" y="93.5" text-anchor="middle">9</text>${fpGlobe(951, 90.5)}
<text class="fp-label" x="987" y="18.5" text-anchor="middle">10</text>${fpGlobe(999, 15.5)}
<text class="fp-label" x="992.5" y="93.5" text-anchor="middle">11</text>
${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) => `<rect class="fp-bay" x="${x}" y="${y}" width="283" height="76" rx="1.5"/>
<rect class="fp-bay-edge" x="${x + 1}" y="${y + 1}" width="4.5" height="74"/>
<circle class="fp-bay-led" cx="${x + 255}" cy="${y + 38}" r="2.2"/>
<text class="fp-label" x="${x + 141}" y="${y + 89}" text-anchor="middle">${n}</text>`;
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('')}
<rect class="fp-block" x="1014" y="52" width="104" height="44" rx="1"/>
${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 })}
<rect class="fp-block" x="563" y="19" width="234" height="69" rx="1"/>
<rect class="fp-block" x="797.5" y="19" width="233" height="69" rx="1"/>
<rect class="fp-divider" x="1083" y="45" width="39" height="16"/>
${fpReset(1140, 97)}`;
},
},
2026-11-11 00:01:12 -05:00
// 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 => `<rect class="fp-block" x="${x}" y="53" width="303" height="36" rx="1"/>`).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 => `<rect class="fp-block" x="${x}" y="19" width="298" height="70" rx="1"/>`).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 => `<rect class="fp-divider" x="${x}" y="46.6" width="36" height="15"/>`).join('');
return `${fpVents([[91, 280], [287, 476], [482, 671], [678, 867], [874, 1062]], 7)}
${fpQuadScreen(12, 25, 58)}${fpModel(41, 96, 'USW')}
${dividers}
<rect class="fp-speed-bar" x="874" y="99" width="188" height="4" rx="1"/>
${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 `<rect class="fp-body-white" x="66" y="6" width="422" height="110" rx="18"/>
<rect class="fp-frame-grey" x="130" y="26" width="291" height="66" rx="3"/>
<rect class="fp-foot" x="115" y="116" width="44" height="6" rx="2"/>
<rect class="fp-foot" x="395" y="116" width="44" height="6" rx="2"/>`;
},
},
// 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 `<rect class="fp-body-white" x="2" y="14" width="659" height="134" rx="26"/>
<rect class="fp-frame-grey" x="82" y="28" width="98" height="84" rx="2"/>
<rect class="fp-frame-grey" x="184" y="28" width="392" height="84" rx="2"/>
<circle class="fp-reset" cx="62" cy="68" r="3"/>
<text class="fp-label fp-label-sm" x="590" y="52">LINK</text>
<rect class="fp-key" x="590" y="59" width="6" height="6"/><text class="fp-label fp-label-sm" x="600" y="65">DOWN</text>
<rect class="fp-key fp-key-amber" x="590" y="71" width="6" height="6"/><text class="fp-label fp-label-sm" x="600" y="77">10/100</text>
<rect class="fp-key fp-key-green" x="590" y="83" width="6" height="6"/><text class="fp-label fp-label-sm" x="600" y="89">1000</text>`;
},
},
// 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 => `<rect class="fp-ear" x="${x}" y="2" width="48" height="211" rx="5"/>
<rect class="fp-ear-hole" x="${x + 13}" y="7" width="27" height="17" rx="8.5"/>
<rect class="fp-ear-hole" x="${x + 13}" y="189" width="27" height="17" rx="8.5"/>`;
return `${ear(0)}${ear(1126)}
<rect class="fp-body-metal" x="48" y="1" width="1078" height="213" rx="1"/>
${fpQuadScreen(61, 132, 56)}${fpModel(89, 199, 'USP', 'PDU Pro')}
<circle class="fp-button" cx="1053" cy="53" r="4.5"/>
<rect class="fp-block" x="977" y="160" width="39" height="32.5" rx="1"/>
<rect class="fp-block" x="1032" y="130" width="41" height="62.5" rx="1"/>
${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')}
<text class="fp-label" x="1049" y="127" text-anchor="middle">22</text>${fpGlobe(1059, 124)}
<text class="fp-label" x="1049" y="205" text-anchor="middle">23</text>${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]) => [
`<rect class="fp-ups-vent" x="${a}" y="${y}" width="${b - a}" height="12" rx="5"/>`,
`<path class="fp-ups-rib" d="M${a + (b - a) / 3} ${y} v12 M${a + 2 * (b - a) / 3} ${y} v12"/>`,
]).join('');
return `<rect class="fp-body-metal" width="1086" height="210" rx="3"/>
<rect class="fp-ups-band-light" x="28" y="4" width="1030" height="24" rx="10"/>${vents(10)}
<rect class="fp-ups-band" x="28" y="183" width="1030" height="24" rx="10"/>${vents(188)}
<circle class="fp-power-ring ${online ? 'fp-power-on' : ''}" cx="51" cy="105" r="19"/>
<circle class="fp-power-face" cx="51" cy="105" r="15"/>
<path class="fp-power-icon" d="M46.5 100.5 a6.5 6.5 0 1 0 9 0 M51 97 v8"/>
<rect class="fp-ups-batt" x="80" y="100" width="15" height="10" rx="2.5"/>
<rect class="fp-ups-batt-tip" x="95.5" y="103" width="1.5" height="4"/>
<text class="fp-label" x="100" y="109">UPS Pro</text>
<rect class="fp-screen" x="430" y="77" width="224" height="55" rx="3"/>
<rect class="fp-screen-tile-dim" x="586" y="83" width="16" height="14" rx="2"/>
<rect class="fp-screen-tile-dim" x="606" y="83" width="16" height="14" rx="2"/>
<rect class="fp-screen-tile-dim" x="596" y="104" width="16" height="14" rx="2"/>
<circle class="fp-screen-icon" cx="643" cy="105" r="3.5"/>
<text class="fp-caption" x="0" y="238">REAR</text>
<g transform="translate(0 248)">
<rect class="fp-body-metal" width="1086" height="210" rx="3"/>
<circle class="fp-button" cx="68" cy="53" r="22"/><circle class="fp-breaker" cx="68" cy="53" r="11"/>
<text class="fp-label fp-label-sm" x="68" y="87" text-anchor="middle">BREAKER</text>
<circle class="fp-button" cx="68" cy="155" r="25"/><circle class="fp-breaker" cx="68" cy="155" r="13"/>
<text class="fp-label fp-label-sm" x="68" y="192" text-anchor="middle">POWER INPUT</text>
<path class="fp-fan" d="M721 20 a83 83 0 1 0 0.01 0 Z M721 52 a51 51 0 1 0 0.01 0 Z"/>
<path class="fp-fan-strut" d="M721 20 v32 M638 103 h32 M804 103 h-32 M721 186 v-32"/>
<rect class="fp-epo" x="893" y="80" width="15" height="45" rx="1"/>
<text class="fp-label fp-label-sm" x="900.5" y="74" text-anchor="middle">EPO</text>
<path class="fp-bracket" d="M955 88 v-8 h96 v8"/>
<text class="fp-label fp-label-sm" x="1003" y="74" text-anchor="middle">SURGE NETWORK</text>
<text class="fp-label fp-label-sm" x="973" y="134" text-anchor="middle">IN</text>
<text class="fp-label fp-label-sm" x="1033" y="134" text-anchor="middle">OUT</text>
${fpPassiveJack(973, 110, false, 'Surge in · not monitored')}
${fpPassiveJack(1033, 110, false, 'Surge out · not monitored')}
<rect class="fp-block" x="953" y="145" width="40" height="34" rx="1"/>
${fpPassiveJack(973, 165, false, 'MGMT · 100/10 MbE · not monitored per port')}
<text class="fp-label fp-label-sm" x="973" y="192" text-anchor="middle">MGMT</text>
</g>`;
},
},
};
function faceplateLegendSwatch(cls) {
if (cls === 'poe') return '<svg class="fp-legend-icon" viewBox="0 0 6 10"><path class="fp-bolt" d="M3 0 L0 5 H2.2 L1.4 9 L5 3.6 H2.8 L4 0 Z"/></svg>';
if (cls === 'cycle') return '<svg class="fp-legend-icon" viewBox="0 0 10 10"><path class="fp-cycle" d="M7 1.5 A3.5 3.5 0 1 0 8 5 M7 0 V2.5 H4.5"/></svg>';
return `<span class="fp-legend-led fp-led-${cls}"></span>`;
}
const CUTSHEET_MARK_LEGEND = [['ok', 'Matches cutsheet'], ['bad', 'Cutsheet mismatch'], ['doc', 'Documented, unconfirmed']];
function faceplateLegendHtml(spec, marks) {
let items = spec.legend.map(([cls, text]) =>
`<div class="chassis-legend-item">${faceplateLegendSwatch(cls)}${text}</div>`).join('');
if (marks && marks.size) {
items += CUTSHEET_MARK_LEGEND.filter(([m]) => marks.has(m)).map(([m, text]) =>
`<div class="chassis-legend-item fp-mark-key"><svg class="fp-legend-icon" viewBox="0 0 8 8"><circle class="fp-mark fp-mark-${m}" cx="4" cy="4" r="3.2"/></svg>${text}</div>`).join('');
items += `<label class="chassis-legend-item fp-mark-toggle"><input type="checkbox" data-action="toggle-cutsheet-marks"
${document.body.classList.contains('fp-marks-off') ? '' : 'checked'}> Cutsheet marks</label>`;
}
const hasDetail = spec.ports.length || spec.outlets;
return `<div class="chassis-legend">
${items}
${hasDetail ? '<div class="chassis-legend-item fp-readout" id="fp-readout" aria-live="polite"></div>' : ''}
</div>`;
}
// 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 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 ? ''
: `<rect class="fp-body" width="${spec.width}" height="${spec.height}" rx="3"/>`;
return `<div class="fp-wrap">
<div class="fp-frame ${spec.frameClass}">
<svg class="fp-svg" viewBox="${((ctx.rack && spec.rackView) || [0, 0, spec.width, spec.height]).join(' ')}" role="group" aria-label="${lt.escHtml(spec.label)} front panel">
<defs>
<linearGradient id="fp-metal" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e4e4e6"/><stop offset=".08" stop-color="#d6d6d8"/>
<stop offset=".92" stop-color="#cfcfd2"/><stop offset="1" stop-color="#a9a9ad"/>
</linearGradient>
<linearGradient id="fp-bay-face" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d9d9db"/><stop offset="1" stop-color="#cdcdd0"/>
</linearGradient>
</defs>
${body}
${spec.decor({ online: ctx.online })}
${outlets}
${ports}
</svg>
</div>
</div>${faceplateLegendHtml(spec, marks)}`;
}
// 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;
}
2026-11-11 00:01:12 -05:00
// 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;