Merge pull request 'Draw realistic front panels for every device model' (#13) from device-faceplates into main
Lint / JS (eslint) (push) Successful in 11s
Security / Python Security (bandit) (push) Successful in 1m20s
Lint / Python (flake8) (push) Successful in 1m31s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 7s
Test / Python Tests (pytest) (push) Successful in 1m15s
Lint / JS (eslint) (push) Successful in 11s
Security / Python Security (bandit) (push) Successful in 1m20s
Lint / Python (flake8) (push) Successful in 1m31s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Successful in 7s
Test / Python Tests (pytest) (push) Successful in 1m15s
This commit was merged in pull request #13.
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user