Lint / JS (eslint) (push) Successful in 21s
Lint / Python (flake8) (push) Successful in 1m20s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m0s
Test / Python Tests (pytest) (push) Successful in 1m48s
Lint / Python (flake8) (pull_request) Successful in 1m44s
Lint / JS (eslint) (pull_request) Successful in 30s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m42s
Test / Python Tests (pytest) (pull_request) Successful in 1m16s
The dashboard's hand-drawn diagram was out of date. /topology replaces it with the network as the switches report it, every device drawn to scale from its faceplate or cutsheet front, and one cable per physical link leaving the port it is plugged into. - topology.py builds the graph: switch-to-switch cables seen from both ends are merged, servers get one cable per NIC, the gateway's internet port goes to the modem and on to the internet, unknown neighbours are stubs, and gear with no live cable is drawn where it was last seen. - Ports named after a different device than the one plugged in are flagged. - Cables show medium, speed, state and traffic; click one for its details, hover a device to trace its path to the internet; pan, zoom and filters. - The monitor now keeps host_macs (MAC to host NIC) in link_stats. - The old dashboard diagram and its JS and CSS are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
663 lines
35 KiB
JavaScript
663 lines
35 KiB
JavaScript
'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
|
|
? `<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}`;
|
|
}
|
|
|
|
// 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>`;
|
|
}
|
|
|
|
// 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)}`;
|
|
},
|
|
},
|
|
|
|
// 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 { svg, marks } = faceplateSvg(model, ctx);
|
|
return `<div class="fp-wrap">
|
|
<div class="fp-frame ${FACEPLATES[model].frameClass}">${svg}</div>
|
|
</div>${faceplateLegendHtml(FACEPLATES[model], marks)}`;
|
|
}
|
|
|
|
// The faceplate's SVG on its own, for drawing inside another picture (a shelf). `attrs` adds
|
|
// attributes to the <svg> (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 ? ''
|
|
: `<rect class="fp-body" width="${spec.width}" height="${spec.height}" rx="3"/>`;
|
|
const svg = `<svg class="fp-svg" ${attrs} 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>`;
|
|
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;
|