Draw realistic front panels for every device model
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

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>
This commit is contained in:
2026-11-05 11:11:48 -05:00
co-authored by Claude Opus 5.5
parent d6263049ef
commit 051e70b82b
7 changed files with 750 additions and 117 deletions
+26 -20
View File
@@ -136,8 +136,8 @@ sees but `config.json` doesn't list still get a page, with their state shown as
they aren't alerted on.
### Port inspector (on switch and router device pages)
A model-accurate front panel, drawn from the layout config in `static/port-inspector.js`
(`SWITCH_LAYOUTS`). `/inspector` now redirects to the dashboard. Link to a port with `/device/<name>#port-<n>`.
A realistic front panel, drawn as SVG from the model's real geometry in `static/faceplates.js`
(`FACEPLATES`). `/inspector` now redirects to the dashboard. Link to a port with `/device/<name>#port-<n>`.
**Port block colours:**
| Colour | State |
@@ -168,19 +168,20 @@ neighbors that can't be matched show their MAC. Bonded NICs share a MAC, so only
2. The Pulse worker can SSH to the server
3. Optional: `lldpd` on the server lets diagnostics confirm the cabling from the server's side
**Supported switch models** (set `SWITCH_LAYOUTS` keys to your UniFi model codes):
**Supported models** (keys are UniFi model codes):
| Key | Model | Layout |
| Key | Model | Front panel |
|---|---|---|
| `USF5P` | UniFi Switch Flex 5 PoE | 4×RJ45 + 1×SFP uplink |
| `USL8A` | UniFi Switch Lite 8 PoE | 8×SFP (2 rows of 4) |
| `US24PRO` | UniFi Switch Pro 24 | 24×RJ45 staggered + 2×SFP |
| `USPPDUP` | Custom/other | Single-port fallback |
| `UDMPRO` | Dream Machine Pro | 9×RJ45 + 2×SFP+ |
| `USMINI` | UniFi Switch Mini | 5-port row |
| `UDMPRO` | Dream Machine Pro | 8×RJ45 LAN, RJ45 WAN, SFP+ WAN, SFP+ LAN, screen, drive bay |
| `US24PRO` | USW Pro 24 PoE | 24×RJ45 in two blocks of 12, 2×SFP+ |
| `USL8A` | USW Aggregation | 8×SFP+ in four stacked pairs |
| `USF5P` | USW Flex | 5×RJ45, PoE in on port 1, PoE out on 2-5 |
| `USMINI` | USW Flex Mini | 5×RJ45, PoE in on port 1 |
| `USPPDUP` | USP PDU Pro | 20 outlets (4 USB-C, 16 AC), management port 21, surge pass-through ports 22-23 |
| `USPDA2B` | UPS 2U Pro | Front with touchscreen outlet tiles; rear with 8 outlets, EPO, surge and MGMT ports |
Add new layouts by adding a key to `SWITCH_LAYOUTS` matching the `model` field
returned by the UniFi API for that device.
Port LEDs use the colors from each model's quick start guide. Other models use plain blocks until they get an
entry in `FACEPLATES`.
### Suppressions (`/suppressions`)
- Create timed (30 min / 1 hr / 4 hr / 8 hr) or manual suppressions
@@ -480,9 +481,9 @@ after `ldap.cache_ttl` seconds or can be removed from `ldap.cache_dir`.
- Hosts missing from `config.json` are named by IP, so their ports may not match what you expect
### Port inspector: front panel shows as a flat list (no layout)
- The switch's `model` field from UniFi doesn't match any key in `SWITCH_LAYOUTS` in `port-inspector.js`
- The switch's `model` field from UniFi doesn't match any key in `FACEPLATES` in `faceplates.js`
- The model is shown on the device page
- Add the model key to `SWITCH_LAYOUTS` in `port-inspector.js` with the correct row/SFP layout
- Add the model to `FACEPLATES` in `faceplates.js` (see Adding a New Device Faceplate)
### Baseline re-initializing on every restart
- `interface_baseline` is stored in the `monitor_state` DB table; survives restarts
@@ -545,19 +546,24 @@ gandalf/
4. Restart monitor: `systemctl restart gandalf-monitor`
5. For SFP DOM / ethtool: ensure the host is SSH-accessible from the Pulse worker
### Adding a New Switch Layout
### Adding a New Device Faceplate
Find the UniFi model code for the switch (shown on its device page), then add to `SWITCH_LAYOUTS` in
`static/port-inspector.js`:
Find the UniFi model code on the device page, then add an entry to `FACEPLATES` in `static/faceplates.js`.
Trace coordinates from a front-on image of the device, using the image's pixels as the `viewBox`:
```javascript
'MYNEWMODEL': {
rows: [[1,2,3,4,5,6,7,8], [9,10,11,12,13,14,15,16]], // port_idx by row
sfp_section: [17, 18], // separate SFP cage ports (rendered below rows)
sfp_ports: [], // port_idx values that are SFP-type within rows
width: 1100, height: 110, frameClass: 'fp-scroll', scrollX: 500,
label: 'My New Switch',
legend: [['off', 'no link'], ['amber', '10/100M'], ['green', '1G']],
roles: { 1: 'RJ45' },
ports: rj45Pairs([520, 557, 594], { top: 38, bottom: 69, topLabel: 15.5, bottomLabel: 98 }),
decor() { return fpVents([[90, 500]], 3) + fpQuadScreen(15, 23, 60); },
},
```
Take LED colors from the model's quick start guide. See the comment at the top of the file for every option.
### Database Schema Notes
- `network_events`: one row per active event; `resolved_at` is set when recovered
+13 -7
View File
@@ -1,5 +1,5 @@
'use strict';
/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort */
/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort, FACEPLATES */
// Per-device page: overview, alerts, and a compact interface/port table. Host
// rows expand in place; switch and router rows open the port inspector panel.
@@ -317,10 +317,14 @@ function renderInterfaces() {
highlightSelectedPort();
}
function updateInterfaceSection(rows, title) {
function updateInterfaceSection(rows, title, hasChassis) {
const section = document.getElementById('device-ports');
section.hidden = !rows.length;
if (!rows.length) return;
section.hidden = !rows.length && !hasChassis;
document.querySelector('.device-port-table').hidden = !rows.length;
if (!rows.length) {
document.getElementById('device-ports-title').textContent = 'Front panel';
return;
}
document.getElementById('device-ports-title').textContent =
`${title} · ${rows.filter(r => r.up).length} of ${rows.length} up`;
document.getElementById('device-table-search').setAttribute('aria-label', `Filter ${title.toLowerCase()}`);
@@ -347,9 +351,11 @@ function render(d) {
renderOutlets(d);
const hasPorts = !!(d.ports && Object.keys(d.ports).length);
document.getElementById('device-inspector').classList.toggle('has-chassis', hasPorts);
updateInterfaceSection(currentRows(), d.ports ? 'Ports' : 'Interfaces');
if (hasPorts) renderPortInspector(d);
// A device like a UPS has a faceplate worth showing but no ports to list
const hasChassis = hasPorts || !!FACEPLATES[d.model];
document.getElementById('device-inspector').classList.toggle('has-chassis', hasChassis);
updateInterfaceSection(currentRows(), d.ports ? 'Ports' : 'Interfaces', hasChassis);
if (hasChassis) renderPortInspector(d);
renderInterfaces();
if (d.kind === 'modem') refreshModem();
+495
View File
@@ -0,0 +1,495 @@
'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;
}
+73 -90
View File
@@ -1,5 +1,5 @@
'use strict';
/* global deviceUrl */
/* global deviceUrl, FACEPLATES, renderFaceplate, faceplateScrollLeft */
/* exported renderPortInspector, fmtRate, fmtErrors */
// Port inspector for a switch or router's device page: front-panel diagram,
@@ -7,26 +7,6 @@
const escHtml = s => lt.escHtml(s);
// ── Switch layout config ─────────────────────────────────────────────────
// keys match the model field returned by the UniFi API
// rows: array of rows, each row is an array of port_idx values
// sfp_ports: port_idx values that are SFP cages (in rows, rendered differently)
// sfp_section: port_idx values rendered as a separate SFP bank below the rows
const SWITCH_LAYOUTS = {
'USF5P': { rows: [[1,2,3,4]], sfp_section: [5] },
'USL8A': { rows: [[1,2,3,4],[5,6,7,8]], sfp_ports: [1,2,3,4,5,6,7,8] },
'US24PRO': {
rows: [
[1,3,5,7,9,11,13,15,17,19,21,23],
[2,4,6,8,10,12,14,16,18,20,22,24],
],
sfp_section: [25,26],
},
'USPPDUP': { rows: [[1]] },
'USMINI': { rows: [[1,2,3,4,5]] },
'UDMPRO': { rows: [[1,2,3,4,5,6,7,8,9]], sfp_section: [10,11] },
};
// ── Formatting helpers ───────────────────────────────────────────────────
function fmtShortSpeed(mbps) {
if (!mbps) return '–';
@@ -86,6 +66,30 @@ function portTitle(port, idx) {
return escHtml(neighbor ? `${port.name} → ${neighbor}` : port.name);
}
function faceplatePortTitle(port, idx, role) {
const parts = [role ? `Port ${idx} (${role})` : `Port ${idx}`];
if (!port || !port.up) {
parts.push('no link');
} else {
const speed = portSpeedLabel(port);
if (speed) parts.push(speed);
if (port.poe_power > 0) parts.push(`${port.poe_power.toFixed(1)} W PoE`);
}
const neighbor = port && port.lldp && (port.lldp.system_name || port.lldp.chassis_id);
if (neighbor) parts.push(`→ ${neighbor}`);
return escHtml(parts.join(' · '));
}
// Faceplate jacks are too small for text, so one line under the panel names
// the hovered or selected port.
function updateFaceplateReadout(el) {
const readout = document.getElementById('fp-readout');
if (!readout) return;
const target = el || document.querySelector('.fp-port.selected');
const title = target && target.getAttribute('aria-label');
readout.textContent = title || (document.querySelector('.fp-outlet') ? 'Hover or select an outlet or port' : 'Hover or select a port');
}
// ── Render a single port block element ──────────────────────────────────
function portBlockHtml(idx, port, swName, sfpBlock) {
const state = portBlockState(port);
@@ -118,78 +122,36 @@ function chassisLegendHtml() {
// ── Render one switch chassis ────────────────────────────────────────────
function renderChassis(swName, sw) {
const model = sw.model || '';
const layout = SWITCH_LAYOUTS[model] || null;
const portMap = buildPortIdxMap(sw.ports || {});
const ports = Object.entries(sw.ports || {});
const upCount = Object.values(sw.ports || {}).filter(p => p.up).length;
const totCount = Object.keys(sw.ports || {}).length;
const downCount = totCount - upCount;
const meta = [model, `${upCount}/${totCount} up`, downCount ? `${downCount} down` : ''].filter(Boolean).join(' · ');
// Is this a US24PRO? Used to add group-separator class
const isUs24Pro = (model === 'US24PRO');
let chassisHtml = '';
if (layout) {
const sfpPortSet = new Set(layout.sfp_ports || []);
const sfpSectionSet = new Set(layout.sfp_section || []);
// Main port rows
chassisHtml += '<div class="chassis-rows">';
for (const row of layout.rows) {
const rowCls = isUs24Pro ? ' us24pro-row' : '';
chassisHtml += `<div class="chassis-row${rowCls}">`;
for (const idx of row) {
const port = portMap[idx];
const isSfp = sfpPortSet.has(idx);
const sfpCls = isSfp ? ' sfp-port' : '';
const state = portBlockState(port);
const title = portTitle(port, idx);
const speedTxt = portSpeedLabel(port);
const lldpName = (port && port.lldp && (port.lldp.system_name || port.lldp.chassis_id))
? escHtml((port.lldp.system_name || port.lldp.chassis_id || '').slice(0, 6))
: '';
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
chassisHtml += `<div class="switch-port-block ${state}${sfpCls}"
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
title="${title}" aria-label="${title}"
data-action="select-port" role="button" tabindex="0"><span class="port-num">${idx}</span>${speedHtml}${lldpHtml}</div>`;
}
chassisHtml += '</div>';
}
chassisHtml += '</div>';
// Separate SFP section (if present)
if (sfpSectionSet.size) {
chassisHtml += '<div class="chassis-sfp-section">';
for (const idx of layout.sfp_section) {
chassisHtml += portBlockHtml(idx, portMap[idx], swName, true);
}
chassisHtml += '</div>';
}
} else {
// Fallback: render all ports sorted by idx
const allPorts = Object.entries(sw.ports || {})
.sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0));
chassisHtml += '<div class="chassis-rows"><div class="chassis-row">';
for (const [pname, d] of allPorts) {
chassisHtml += portBlockHtml(d.port_idx || 0, Object.assign({ name: pname }, d), swName, false);
}
chassisHtml += '</div></div>';
}
return `
<div class="inspector-chassis">
<div class="chassis-header">
const upCount = ports.filter(([, p]) => p.up).length;
const downCount = ports.length - upCount;
const counts = ports.length ? [`${upCount}/${ports.length} up`, downCount ? `${downCount} down` : ''] : [];
const meta = [model, ...counts].filter(Boolean).join(' · ');
const header = `<div class="chassis-header">
<span class="chassis-name">Front panel</span>
<span class="chassis-meta">${escHtml(meta)}</span>
</div>
</div>`;
if (FACEPLATES[model]) {
return `<div class="inspector-chassis">${header}${renderFaceplate(model, {
swName, portMap, outlets: sw.outlets, online: sw.online, titleFor: faceplatePortTitle,
})}</div>`;
}
// No faceplate for this model yet: plain blocks in port order
const blocks = ports
.sort(([, a], [, b]) => (a.port_idx || 0) - (b.port_idx || 0))
.map(([pname, d]) => portBlockHtml(d.port_idx || 0, Object.assign({ name: pname }, d), swName, false))
.join('');
return `
<div class="inspector-chassis">
${header}
<div class="chassis-body">
<div class="chassis-ear-l"></div>
<div class="chassis-ear-r"></div>
${chassisHtml}
<div class="chassis-rows"><div class="chassis-row">${blocks}</div></div>
</div>
${chassisLegendHtml()}
</div>`;
@@ -428,11 +390,19 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
// ── Page integration: device.js supplies this one device's data ──────────
function renderPortInspector(detail) {
_apiData = {
unifi_switches: { [detail.name]: { ip: detail.ip, model: detail.model, ports: detail.ports || {} } },
unifi_switches: { [detail.name]: {
ip: detail.ip, model: detail.model, ports: detail.ports || {},
outlets: detail.info && detail.info.outlets, online: detail.status === 'up',
} },
hosts: detail.neighbor_hosts || {},
};
const chassis = document.getElementById('device-chassis');
// Auto-refresh redraws the panel, so keep a scrolled panel where the user left it
const oldFrame = chassis.querySelector('.fp-scroll');
const scrollLeft = oldFrame ? oldFrame.scrollLeft : null;
chassis.innerHTML = renderChassis(detail.name, _apiData.unifi_switches[detail.name]);
const frame = chassis.querySelector('.fp-scroll');
if (frame) frame.scrollLeft = scrollLeft !== null ? scrollLeft : faceplateScrollLeft(detail.model, frame);
if (_selectedSwitch === detail.name && _selectedIdx !== null) {
renderPanel(_selectedSwitch, _selectedIdx);
} else if (_selectedSwitch === null) {
@@ -447,9 +417,10 @@ function openPortFromHash(swName) {
}
function highlightSelectedPort() {
document.querySelectorAll('.switch-port-block').forEach(el => {
document.querySelectorAll('[data-action="select-port"]').forEach(el => {
el.classList.toggle('selected', parseInt(el.dataset.portIdx, 10) === _selectedIdx);
});
updateFaceplateReadout();
document.querySelectorAll('tr.device-row[data-port-idx]').forEach(row => {
row.classList.toggle('is-selected', parseInt(row.dataset.portIdx, 10) === _selectedIdx);
});
@@ -465,6 +436,18 @@ document.addEventListener('keydown', e => {
if (block) { e.preventDefault(); selectPort(block); }
});
document.addEventListener('mouseover', e => {
const port = e.target.closest && e.target.closest('.fp-port, .fp-outlet');
if (port) updateFaceplateReadout(port);
});
document.addEventListener('mouseout', e => {
if (e.target.closest && e.target.closest('.fp-port, .fp-outlet')) updateFaceplateReadout();
});
document.addEventListener('focusin', e => {
const port = e.target.closest && e.target.closest('.fp-port, .fp-outlet');
if (port) updateFaceplateReadout(port);
});
document.addEventListener('click', e => {
const portBlock = e.target.closest('[data-action="select-port"]');
if (portBlock) { selectPort(portBlock); return; }
@@ -521,7 +504,7 @@ function pollDiagnostic(jobId, statusEl, resultsEl) {
statusEl.innerHTML = 'Timed out waiting for results. '
+ '<button class="lt-btn lt-btn-ghost lt-btn-sm" id="diag-retry-btn">Retry</button>';
document.getElementById('diag-retry-btn')?.addEventListener('click', () => {
const sel = document.querySelector('.switch-port-block.selected');
const sel = document.querySelector('[data-action="select-port"].selected');
if (sel) runDiagnostic(sel.dataset.switch, parseInt(sel.dataset.portIdx));
});
return;
@@ -541,7 +524,7 @@ function pollDiagnostic(jobId, statusEl, resultsEl) {
statusEl.innerHTML = 'Error: lost connection while collecting diagnostics. '
+ '<button class="lt-btn lt-btn-ghost lt-btn-sm" id="diag-retry-btn">Retry</button>';
document.getElementById('diag-retry-btn')?.addEventListener('click', () => {
const sel = document.querySelector('.switch-port-block.selected');
const sel = document.querySelector('[data-action="select-port"].selected');
if (sel) runDiagnostic(sel.dataset.switch, parseInt(sel.dataset.portIdx));
});
});
+138
View File
@@ -1172,3 +1172,141 @@ tr.device-row.is-selected { background: var(--bg-tertiary); box-shadow: inset 3p
.device-inspector .inspector-panel-inner { width: 100%; max-height: none; }
.device-chassis .chassis-ear-l, .device-chassis .chassis-ear-r { display: none; }
}
/* ── Realistic device faceplates (static/faceplates.js) ───────────── */
/* Hardware colors are fixed: a silver chassis stays silver in every theme. */
.device-chassis { container-type: inline-size; }
.fp-wrap { padding: 14px 16px; }
.fp-frame { overflow: hidden; border-radius: 3px; box-shadow: 0 2px 10px rgba(0,0,0,.55); }
.fp-svg { display: block; width: 100%; height: auto; }
/* Too narrow for the whole unit: zoom to the port area so jacks stay tappable.
Width = viewBox width / visible width; margin = cropX / visible width. */
@container (max-width: 620px) {
.fp-crop-udmpro .fp-svg { width: 308.7%; max-width: none; margin-left: -208.7%; }
}
.fp-body { fill: url(#fp-metal); }
.fp-vent { fill: #242326; }
.fp-screen { fill: #0c1020; stroke: #2a2c34; stroke-width: 1; }
.fp-screen-ring{ fill: none; stroke: #3f7bd9; stroke-width: 2.2; }
.fp-screen-dot { fill: #dfe8f8; }
.fp-brand { fill: #55565c; font: 600 7.5px Arial, Helvetica, sans-serif; }
.fp-bay { fill: url(#fp-bay-face); stroke: #54555a; stroke-width: 1; }
.fp-bay-edge { fill: #bdbdc1; }
.fp-bay-led { fill: #3a3a3e; }
.fp-block { fill: #e1e1e3; stroke: #5d5e63; stroke-width: 1; }
.fp-divider { fill: #dcdcde; }
.fp-label { fill: #55565c; font: 9px Arial, Helvetica, sans-serif; }
.fp-label-sm { font-size: 6.5px; letter-spacing: .03em; }
.fp-globe { fill: none; stroke: #3f7bd9; stroke-width: .8; }
.fp-reset { fill: #1b1b1d; }
.fp-jack { fill: #18181b; stroke: #0c0c0d; stroke-width: .6; }
.fp-contacts { fill: #b89545; }
.fp-plug { fill: #9ea1a8; stroke: #5b5d63; stroke-width: .6; }
.fp-plug-clip { fill: #7e8087; stroke: #5b5d63; stroke-width: .5; }
.fp-cage { fill: #17171a; stroke: #6e6f74; stroke-width: 1; }
.fp-cage-spring{ fill: none; stroke: #5d5e63; stroke-width: 1.2; stroke-dasharray: 2.5 1.5; }
.fp-cage-rail { fill: #3b3c41; }
.fp-module { fill: #a5a7ad; stroke: #6a6b70; stroke-width: .6; }
.fp-module-lc { fill: #2a4a7f; stroke: #1a2a44; stroke-width: .5; }
.fp-led-idle { fill: #6a6b70; }
.fp-led-off { fill: #4b4c50; }
.fp-led-amber { fill: #ffad1f; }
.fp-led-green { fill: #35f06f; }
.fp-led-white { fill: #ffffff; stroke: #8fa6c4; stroke-width: .5; }
.fp-led-unknown{ fill: #b9bcc2; }
.fp-led-amber, .fp-led-green, .fp-led-white { filter: drop-shadow(0 0 1.5px currentColor); }
.fp-led-amber { color: #ffad1f; }
.fp-led-green { color: #35f06f; }
.fp-led-white { color: #cfe2ff; }
.fp-led-active { animation: fp-blink 1.3s steps(2, jump-none) infinite; }
@keyframes fp-blink { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { .fp-led-active { animation: none; } }
.fp-port { cursor: pointer; outline: none; }
.fp-hit { fill: transparent; stroke: transparent; stroke-width: 2.5; pointer-events: all; }
.fp-port:hover .fp-hit { stroke: rgba(0,90,220,.55); }
.fp-port:focus-visible .fp-hit,
.fp-port.selected .fp-hit { stroke: #0058e0; fill: rgba(0,88,224,.14); }
.fp-legend-led { width: 12px; height: 5px; border-radius: 1px; display: inline-block; background: #4b4c50; }
.fp-legend-led.fp-led-amber { background: #ffad1f; }
.fp-legend-led.fp-led-green { background: #35f06f; }
.fp-legend-led.fp-led-white { background: #fff; }
.fp-readout { margin-left: auto; font-size: .72em; color: var(--text-dim, var(--text-muted)); text-transform: none; letter-spacing: 0; }
/* Narrow panels: crop to the ports, or scroll within the panel */
@container (max-width: 620px) {
.fp-crop-agg .fp-svg { width: 437%; max-width: none; margin-left: -337%; }
.fp-scroll { overflow-x: auto; }
.fp-scroll .fp-svg { width: 260%; max-width: none; }
}
.fp-desk { box-shadow: none; }
.fp-desk .fp-svg { max-width: 560px; margin: 0 auto; filter: drop-shadow(0 3px 6px rgba(0,0,0,.5)); }
.fp-body-white { fill: #f2f2f3; stroke: #c9c9cc; stroke-width: 1; }
.fp-frame-grey { fill: #c6c8cc; stroke: #8b8d92; stroke-width: 1; }
.fp-foot { fill: #b7b7ba; }
.fp-body-metal { fill: url(#fp-metal); }
.fp-ear { fill: #c4c4c7; stroke: #8f9095; stroke-width: 1; }
.fp-ear-hole { fill: #0b0d12; }
.fp-button { fill: #3a3a3e; stroke: #1b1b1d; stroke-width: 1; }
.fp-speed-bar { fill: #8d8e93; }
.fp-screen-grid{ fill: none; stroke: #2f6fd0; stroke-width: 1; }
.fp-screen-icon{ fill: #6b9be4; }
.fp-static { opacity: .85; }
.fp-bolt { fill: #e88a00; }
.fp-cycle { fill: none; stroke: #0058e0; stroke-width: 1.4; stroke-linecap: round; }
.fp-outlet { cursor: default; outline: none; }
.fp-outlet-face { fill: #2a2a2d; stroke: #111113; stroke-width: 1; }
.fp-slot { fill: #050506; }
.fp-usbc { fill: #1c1c1f; stroke: #8b8d92; stroke-width: 1; }
.fp-usbc-tongue { fill: #b9bcc2; }
.fp-outlet-is-off .fp-outlet-face { fill: #5b5c60; }
.fp-outlet-is-off .fp-usbc { fill: #66676b; }
.fp-outlet:hover .fp-hit { stroke: rgba(0,90,220,.55); }
.fp-outlet:focus-visible .fp-hit { stroke: #0058e0; fill: rgba(0,88,224,.14); }
.fp-outlet-state { fill: #8d8e93; }
.fp-state-on { fill: #12a84a; }
.fp-state-off { fill: #d6332a; }
.fp-ups-band { fill: #8c8d90; }
.fp-ups-vent { fill: #2b2b2e; }
.fp-ups-rib { fill: none; stroke: #77787c; stroke-width: 1; }
.fp-ups-batt { fill: none; stroke: #2b2b2e; stroke-width: 1.3; }
.fp-ups-batt-tip{ fill: #2b2b2e; }
.fp-led-blue { fill: #2f7bff; color: #2f7bff; filter: drop-shadow(0 0 2px currentColor); }
.fp-legend-led.fp-led-blue { background: #2f7bff; }
.fp-legend-led.fp-led-outlet-on { background: #12a84a; width: 7px; height: 7px; border-radius: 50%; }
.fp-legend-led.fp-led-outlet-off { background: #d6332a; width: 7px; height: 7px; border-radius: 50%; }
.fp-legend-icon { width: 11px; height: 11px; flex-shrink: 0; }
/* A white LED needs an edge to show against the light metal around it */
polygon.fp-led-white { stroke: #6b7c95; stroke-width: .7; }
polygon.fp-led-off { fill: #6b6c71; }
.fp-key { fill: none; stroke: #55565c; stroke-width: .8; }
.fp-key-amber { fill: #e8762c; stroke: none; }
.fp-key-green { fill: #7ec23a; stroke: none; }
.fp-ups-bolt { fill: #2b2b2e; }
/* UPS 2U Pro: front and rear panels stacked */
.fp-stack { box-shadow: none; }
.fp-caption { fill: #8b96a6; font: 600 10px Arial, Helvetica, sans-serif; letter-spacing: .1em; }
.fp-ups-band-light { fill: #f4f4f5; }
.fp-power-ring { fill: none; stroke: #8d8e93; stroke-width: 3; }
.fp-power-on { stroke: #2f9bff; filter: drop-shadow(0 0 3px #2f9bff); }
.fp-power-face { fill: #dcdcde; stroke: #a9a9ad; stroke-width: 1; }
.fp-power-icon { fill: none; stroke: #55565c; stroke-width: 1.5; stroke-linecap: round; }
.fp-tile { fill: #1f6fff; }
.fp-tile-num { fill: #fff; font: 600 9px Arial, Helvetica, sans-serif; }
.fp-outlet-is-off .fp-tile { fill: #3a3d46; }
.fp-screen-tile-dim{ fill: #2a2d36; }
.fp-breaker { fill: #4a4a4e; }
.fp-fan { fill: none; stroke: #3a3a3e; stroke-width: 7; }
.fp-fan-strut { fill: none; stroke: #c9c9cc; stroke-width: 5; }
.fp-epo { fill: #1c1c1f; }
.fp-bracket { fill: none; stroke: #55565c; stroke-width: 1; }
+1
View File
@@ -50,6 +50,7 @@
{% block scripts %}
<script>const DEVICE_NAME = {{ device_name | tojson }};</script>
<script src="{{ url_for('static', filename='faceplates.js') }}"></script>
<script src="{{ url_for('static', filename='port-inspector.js') }}"></script>
<script src="{{ url_for('static', filename='device.js') }}"></script>
{% endblock %}
+4
View File
@@ -70,6 +70,10 @@ class TestPagesAndNav:
html = client.get('/device/large1', headers=AUTH).get_data(as_text=True)
assert 'port-inspector.js' in html and 'id="inspector-panel"' in html
def test_faceplates_load_before_the_port_inspector(self, client):
html = client.get('/device/large1', headers=AUTH).get_data(as_text=True)
assert html.index('faceplates.js') < html.index('port-inspector.js')
def test_link_data_api_remains(self, client):
assert client.get('/api/links', headers=AUTH).status_code == 200