Files
gandalf/static/faceplates.js
T
jaredandClaude Opus 5.5 051e70b82b
Lint / JS (eslint) (push) Successful in 10s
Lint / Python (flake8) (push) Successful in 57s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m7s
Lint / Python (flake8) (pull_request) Successful in 59s
Test / Python Tests (pytest) (push) Successful in 1m22s
Lint / JS (eslint) (pull_request) Successful in 11s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m7s
Test / Python Tests (pytest) (pull_request) Successful in 1m28s
Draw realistic front panels for every device model
Replace the port blocks on device pages with SVG faceplates traced from
reference images: Dream Machine Pro, USW Pro 24 PoE, USW Aggregation,
USW Flex, USW Flex Mini, USP PDU Pro and UPS 2U Pro. Port LEDs use each
model's documented colors, and ports show a cable or module when up.

Remove SWITCH_LAYOUTS, which had wrong data: the aggregation switch was
numbered across the rows instead of odd over even, and the USW Flex had
an SFP cage it doesn't have. Models without a faceplate use plain blocks.

Show PDU and UPS outlets with on/off state and auto power-cycle, and let
the UPS page show its panel even though it reports no ports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:11:48 -05:00

496 lines
26 KiB
JavaScript

'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.
function sfpSvg(port, w, h) {
const inner = port && port.up
? `<rect class="fp-module" x="2.5" y="2.5" width="${w - 5}" height="${h - 5}" rx="1"/>
<rect class="fp-module-lc" x="6.5" y="6" width="9" height="${h - 12}" rx="1.2"/>
<rect class="fp-module-lc" x="${w - 15.5}" y="6" width="9" height="${h - 12}" rx="1.2"/>`
: `<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"/>`;
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}`;
}
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 `<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"/>
</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"/>
</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>`;
}
const fpBrand = (x, y, text) => `<text class="fp-brand" x="${x}" y="${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)}
<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"/>
${fpBrand(16, 94, 'Dream Machine 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)}`;
},
},
// 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')}
<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)}`;
},
},
// 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)}${fpBrand(28, 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)}${fpBrand(68, 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,
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>`;
}
function faceplateLegendHtml(spec) {
const items = spec.legend.map(([cls, text]) =>
`<div class="chassis-legend-item">${faceplateLegendSwatch(cls)}${text}</div>`).join('');
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) }
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 ? ''
: `<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="0 0 ${spec.width} ${spec.height}" 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)}`;
}
// 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;
}