Files
gandalf/static/faceplates.js
T
jaredandClaude Opus 5.5 7e588e5b02
Security / Python Security (bandit) (push) Successful in 1m50s
Test / Python Tests (pytest) (push) Successful in 1m52s
Lint / Python (flake8) (pull_request) Successful in 1m20s
Lint / Python (flake8) (push) Successful in 1m16s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / JS (eslint) (push) Successful in 15s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m5s
Test / Python Tests (pytest) (pull_request) Successful in 1m7s
Keep every switch port and show SFP modules
Ports were keyed by their UniFi name, so when two ports shared a name the
later one replaced the earlier. The 24-port switch lost port 13 this way
(ports 13 and 14 are both "Office TV"). A repeated name is now kept as
"<name> (Port N)".

Traffic rates are now matched to the previous poll by port number rather
than name. Names are re-read from UniFi every poll, so a port renamed
there keeps its rates instead of losing them for a cycle.

Collect what UniFi reports about each SFP cage: whether a module is
present, its type (DAC or optic), part, serial, fault flags, and DOM
readings when the module has them. The faceplate draws empty cages,
optics, DACs and unidentified modules differently and outlines a faulted
module in red. The port panel gets an SFP Module section. Both ends of a
DAC report the same cable serial, so each end links to the other.

Closes #7
Closes #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:37:08 -05:00

521 lines
27 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.
// 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 = `<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"/>
<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"/>`;
}
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;
}