Rack: ports-facing separate from posts, three views, ZimaBoard 832 shelves to scale
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 1m0s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m9s
Lint / Python (flake8) (pull_request) Successful in 57s
Test / Python Tests (pytest) (push) Successful in 1m14s
Lint / JS (eslint) (pull_request) Successful in 8s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 56s
Test / Python Tests (pytest) (pull_request) Successful in 1m5s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 1m0s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m9s
Lint / Python (flake8) (pull_request) Successful in 57s
Test / Python Tests (pytest) (push) Successful in 1m14s
Lint / JS (eslint) (pull_request) Successful in 8s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 56s
Test / Python Tests (pytest) (pull_request) Successful in 1m5s
Gear on the rear posts usually still faces the front (the StarTech's RPDU and ZimaBoard shelf), so "which posts" and "which way the ports face" are now separate fields (mount, facing). The page has three views: Front (front posts), Rear posts (rear-post gear seen from the front, i.e. what is behind the front gear) and Rear (from behind). An item seen from the side it doesn't face shows its back. Facing defaults to front, so existing items keep their meaning. Shelves are drawn at true scale with their contents ordered by a new "Position on shelf". The IceWhale ZimaBoard 832 is traced from IceWhale's labelled photo of its port end (ZimaDocs): Mini-DP, two RJ45-over-USB 3.0 stacks with link/activity LEDs, DC jack, gunmetal case, gold PCB edge and black base. Its RJ45 link lights follow the host's physical NICs and the status glow follows the host. New cutsheet_items columns facing and slot, added on first use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+7
-7
@@ -258,9 +258,9 @@ function renderRack() {
|
||||
}
|
||||
|
||||
function rackText(i) {
|
||||
if (i.shelf) return `on ${esc(i.shelf)}`;
|
||||
if (i.shelf) return `on ${esc(i.shelf)}${i.slot ? `, position ${esc(i.slot)}` : ''}`;
|
||||
if (!i.rack_u) return '-';
|
||||
const side = { rear: ' rear', full: ' full depth' }[i.mount] || '';
|
||||
const side = ({ rear: ' rear posts', full: ' full depth' }[i.mount] || '') + (i.facing === 'rear' ? ', faces rear' : '');
|
||||
const where = `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}${side}`;
|
||||
return (_cs.racks || []).length > 1 ? `${where} · ${esc(i.rack || _cs.racks[0])}` : where;
|
||||
}
|
||||
@@ -389,7 +389,7 @@ function openCable(cable, prefill) {
|
||||
function openItem(item) {
|
||||
const form = document.getElementById('cs-item-form');
|
||||
form.reset();
|
||||
const data = Object.assign({ kind: 'patch_panel', mount: 'front' }, item || {});
|
||||
const data = Object.assign({ kind: 'patch_panel', mount: 'front', facing: 'front' }, item || {});
|
||||
document.getElementById('cs-rack-list').innerHTML = (_cs.racks || []).map(r => `<option value="${esc(r)}">`).join('');
|
||||
document.getElementById('cs-known-devices').innerHTML = (_cs.devices || []).map(d => `<option value="${esc(d)}">`).join('');
|
||||
form.chassis.innerHTML = '<option value="">Automatic (faceplate or plain plate)</option>' +
|
||||
@@ -397,8 +397,8 @@ function openItem(item) {
|
||||
const shelves = _cs.items.filter(i => i.kind === 'shelf' && (!item || i.id !== item.id));
|
||||
form.shelf.innerHTML = '<option value="">Not on a shelf</option>' +
|
||||
shelves.map(i => `<option value="${esc(i.name)}">${esc(i.name)}</option>`).join('');
|
||||
for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
|
||||
'chassis', 'location', 'notes']) {
|
||||
for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'facing',
|
||||
'shelf', 'slot', 'chassis', 'location', 'notes']) {
|
||||
form[key].value = data[key] == null ? '' : data[key];
|
||||
}
|
||||
if (!item) form.port_count.value = 24;
|
||||
@@ -451,8 +451,8 @@ async function saveCable(e) {
|
||||
async function saveItem(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
|
||||
'chassis', 'location', 'notes']);
|
||||
const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'facing',
|
||||
'shelf', 'slot', 'chassis', 'location', 'notes']);
|
||||
const id = form.id.value;
|
||||
await save(() => id ? lt.api.put(`/api/cutsheet/items/${encodeURIComponent(id)}`, body)
|
||||
: lt.api.post('/api/cutsheet/items', body), 'Saved', 'cs-item-modal');
|
||||
|
||||
+100
-1
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg */
|
||||
/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg, SHELF_GEAR */
|
||||
// Drawn fronts for rack gear Gandalf can't read a faceplate from: custom-built servers. Chosen per cutsheet item ("Front drawing"). Each one
|
||||
// shows the device's live status on its power light when the item is linked to a device.
|
||||
//
|
||||
@@ -146,3 +146,102 @@ function rackFrontSvg(key, ctx) {
|
||||
${spec.draw(ctx)}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ── IceWhale ZimaBoard 832 (shelf gear) ─────────────────────────────────
|
||||
// The port end, traced from IceWhale's labelled photo in ZimaDocs
|
||||
// (source/images/Hardware-Interface-Introduction-/hardware-interface-introduction.webp),
|
||||
// in that photo's pixel coordinates (the port end spans 862 x 415 px; 81.4 mm wide).
|
||||
// Left to right: Mini-DisplayPort 1.2, two stacks of GbE RJ45 over USB 3.0, DC 12V 5.5x2.5mm,
|
||||
// on a gunmetal case over a thin gold PCB edge and the black polycarbonate base.
|
||||
const ZIMA = { width: 862, height: 415, mm: 81.4 };
|
||||
|
||||
function zimaRj45(x, y, link) {
|
||||
const contacts = Array.from({ length: 8 }, (_, i) => `<rect class="zb-contact" x="${x + 52 + i * 14}" y="${y + 34}" width="5" height="22"/>`).join('');
|
||||
return `<rect class="zb-shield" x="${x}" y="${y}" width="207" height="143" rx="5"/>
|
||||
<rect class="zb-hole" x="${x + 22}" y="${y + 30}" width="163" height="102" rx="2"/>
|
||||
<rect class="zb-hole-key" x="${x + 70}" y="${y + 120}" width="67" height="16" rx="1"/>
|
||||
${contacts}
|
||||
<rect class="zb-led" x="${x + 16}" y="${y + 9}" width="30" height="15" rx="2" style="fill:${link ? '#3ad16b' : '#8f8f8a'}"/>
|
||||
<rect class="zb-led" x="${x + 161}" y="${y + 9}" width="30" height="15" rx="2" style="fill:${link ? '#f2d13a' : '#77714a'}"/>`;
|
||||
}
|
||||
|
||||
function zimaUsb(x, y) {
|
||||
return `<rect class="zb-shield" x="${x}" y="${y}" width="204" height="100" rx="5"/>
|
||||
<rect class="zb-hole" x="${x + 26}" y="${y + 20}" width="152" height="62" rx="2"/>
|
||||
<rect class="zb-usb-tongue" x="${x + 38}" y="${y + 28}" width="128" height="22" rx="2"/>
|
||||
${Array.from({ length: 5 }, (_, i) => `<rect class="zb-usb-pin" x="${x + 50 + i * 24}" y="${y + 46}" width="10" height="4"/>`).join('')}`;
|
||||
}
|
||||
|
||||
function zimaFront(ctx) {
|
||||
// ctx: { status, interfaces: [{name, up}] } - one RJ45 per NIC, left to right
|
||||
const nics = ctx.interfaces || [];
|
||||
const link = i => (nics[i] ? nics[i].up : ctx.status === 'up');
|
||||
const on = ctx.status === 'up' || ctx.status === 'degraded';
|
||||
const pads = [60, 105, 420, 470, 640, 700, 790, 835]
|
||||
.map(x => `<rect class="zb-pad" x="${x}" y="309" width="12" height="7"/>`).join('');
|
||||
return `
|
||||
<rect class="zb-base" x="0" y="300" width="862" height="115" rx="10"/>
|
||||
<rect class="zb-base-sheen" x="6" y="330" width="850" height="6" rx="3"/>
|
||||
${on ? '<ellipse class="zb-glow" cx="34" cy="404" rx="34" ry="9"/>' : ''}
|
||||
<rect class="zb-pcb" x="2" y="306" width="858" height="12"/>${pads}
|
||||
<path class="zb-case" d="M0 14 Q0 0 14 0 H848 Q862 0 862 14 V308 H0 Z"/>
|
||||
<rect class="zb-case-edge" x="0" y="6" width="18" height="300"/>
|
||||
<rect class="zb-case-top" x="14" y="0" width="834" height="5"/>
|
||||
<rect class="zb-shield" x="63" y="225" width="127" height="80" rx="6"/>
|
||||
<path class="zb-hole" d="M75 243 H178 V280 L163 293 H90 L75 280 Z"/>
|
||||
<rect class="zb-dp-pins" x="92" y="262" width="69" height="9" rx="1"/>
|
||||
${zimaRj45(213, 47, link(0))}${zimaRj45(443, 47, link(1))}
|
||||
${zimaUsb(216, 205)}${zimaUsb(443, 205)}
|
||||
<rect class="zb-dc" x="678" y="177" width="132" height="130" rx="8"/>
|
||||
<rect class="zb-dc-face" x="690" y="189" width="108" height="106" rx="6"/>
|
||||
<circle class="zb-dc-ring" cx="744" cy="242" r="36"/>
|
||||
<circle class="zb-hole" cx="744" cy="242" r="27"/>
|
||||
<circle class="zb-dc-pin" cx="744" cy="242" r="8"/>`;
|
||||
}
|
||||
|
||||
const SHELF_GEAR = {
|
||||
'zimaboard-832': {
|
||||
label: 'IceWhale ZimaBoard 832', mm: ZIMA.mm, width: ZIMA.width, height: ZIMA.height, draw: zimaFront,
|
||||
specs: 'Intel Celeron N3450, 8GB LPDDR4, 32GB eMMC, 2x GbE, 2x USB 3.0, 2x SATA, PCIe 2.0 x4, 6W',
|
||||
},
|
||||
};
|
||||
|
||||
// A shelf at true scale (1100 units = 482.6 mm, a 19" panel) with what sits on it, left to right.
|
||||
// Gear with a SHELF_GEAR drawing is drawn to its real width; anything else is a labelled box.
|
||||
function shelfSvg(item, esc) {
|
||||
const units = item.rack_height || 1;
|
||||
const W = 1100, H = 105 * units, perMm = W / 482.6;
|
||||
const floor = H - 20;
|
||||
let x = 64;
|
||||
const things = (item.contents || []).map(c => {
|
||||
const gear = SHELF_GEAR[c.chassis];
|
||||
const w = gear ? gear.mm * perMm : 150;
|
||||
const k = gear ? w / gear.width : 1;
|
||||
const h = gear ? gear.height * k : 58;
|
||||
const top = floor - h;
|
||||
const nics = (c.interfaces || []).map(n => `${n.name} ${n.up ? 'up' : 'down'}`).join(', ');
|
||||
const tip = [c.name, gear ? gear.label : c.kind, c.status, nics, gear && gear.specs].filter(Boolean).join(' · ');
|
||||
const body = gear
|
||||
? `<g transform="translate(${x} ${top}) scale(${k})">${gear.draw(c)}</g>`
|
||||
: `<rect class="sh-box" x="${x}" y="${top}" width="${w}" height="${h}" rx="4"/>
|
||||
<text class="sh-box-name" x="${x + w / 2}" y="${top + h / 2 + 5}" text-anchor="middle">${esc(c.name)}</text>`;
|
||||
const out = `<a class="sh-thing" href="${esc(c.url)}"><title>${esc(tip)}</title>
|
||||
${body}
|
||||
<rect class="sh-hit" x="${x - 4}" y="${top - 4}" width="${w + 8}" height="${h + 8}" rx="4"/>
|
||||
<rect class="sh-tag" x="${x + w / 2 - 46}" y="${floor + 5}" width="92" height="12" rx="2"/>
|
||||
<text class="sh-tag-text" x="${x + w / 2}" y="${floor + 14}" text-anchor="middle">${esc(c.name)}</text>
|
||||
</a>`;
|
||||
x += w + 26;
|
||||
return out;
|
||||
}).join('');
|
||||
return `<svg class="rack-svg rack-shelf-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="group"
|
||||
aria-label="${esc(item.name)}">
|
||||
<title>${esc(item.name)}</title>
|
||||
${ears(W, H, 'rf-ear-dark')}
|
||||
<rect class="sh-back" x="36" y="6" width="${W - 72}" height="${floor - 6}"/>
|
||||
<rect class="sh-floor" x="30" y="${floor}" width="${W - 60}" height="${H - floor - 2}" rx="2"/>
|
||||
<rect class="sh-lip" x="30" y="${floor}" width="${W - 60}" height="3"/>
|
||||
${things || `<text class="sh-empty" x="${W / 2}" y="${floor - 12}" text-anchor="middle">empty shelf</text>`}
|
||||
<text class="sh-name" x="46" y="18">${esc(item.name)}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
+11
-20
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg */
|
||||
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg */
|
||||
// Rack page: draws the rack front from /api/rack. Switches use their real faceplates,
|
||||
// patch panels a generic panel; hovering a port lights up every port on the same cable run.
|
||||
|
||||
@@ -93,18 +93,6 @@ function statusDot(status) {
|
||||
return status ? `<span class="${STATUS_CLASS[status] || 'dot-unknown'}" title="${esc(status)}"></span>` : '';
|
||||
}
|
||||
|
||||
function shelfHtml(item) {
|
||||
const contents = item.contents || [];
|
||||
const things = contents.map(c => `
|
||||
<a class="rack-shelf-thing" href="${esc(c.url)}" title="${esc([c.name, c.model, c.status].filter(Boolean).join(' · '))}">
|
||||
${statusDot(c.status)}<span class="rack-shelf-name">${esc(c.name)}</span>
|
||||
</a>`).join('');
|
||||
return `<div class="rack-plate rack-shelf">
|
||||
<span class="rack-shelf-label">${esc(item.name)}</span>
|
||||
<div class="rack-shelf-things">${things || '<span class="rack-plate-sub">empty shelf</span>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Ubiquiti UACC-Rack-Panel-Patch-Blank-24, laid out from the CC0 NetBox devicetype-library image
|
||||
// (elevation-images/Ubiquiti/ubiquiti-uacc-rack-panel-patch-blank-24.front.png): a grey steel face,
|
||||
// darker ears with oval holes, 24 square keystone openings in one row with the cable-support bar
|
||||
@@ -173,7 +161,7 @@ function itemHtml(item) {
|
||||
else if (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item);
|
||||
else if (item.kind === 'patch_panel' && item.chassis === 'uacc-patch-24') body = uaccPanelSvg(item);
|
||||
else if (item.kind === 'patch_panel') body = panelSvg(item);
|
||||
else if (item.kind === 'shelf') body = shelfHtml(item);
|
||||
else if (item.kind === 'shelf') body = shelfSvg(item, esc);
|
||||
else body = plateHtml(item);
|
||||
return `<div class="rack-item rack-kind-${esc(item.kind)}" data-item="${esc(item.id)}">${body}</div>`;
|
||||
}
|
||||
@@ -216,14 +204,16 @@ function renderTabs(data) {
|
||||
return `<button type="button" class="rack-tab${on ? ' active' : ''}" role="tab" aria-selected="${on}"
|
||||
data-rack="${esc(r.name)}">${esc(r.name)} <span class="rack-tab-count">${r.count}</span></button>`;
|
||||
}).join('');
|
||||
document.getElementById('rack-title').textContent = `${data.rack} · ${data.face}`;
|
||||
const VIEW_TITLE = { front: 'front', behind: 'rear posts, from the front', rear: 'rear' };
|
||||
document.getElementById('rack-title').textContent = `${data.rack} · ${VIEW_TITLE[data.face] || data.face}`;
|
||||
document.querySelectorAll('.rack-face-btn').forEach(b => {
|
||||
const on = b.dataset.face === data.face;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-pressed', on);
|
||||
});
|
||||
const rear = (data.racks.find(r => r.name === data.rack) || {}).rear || 0;
|
||||
document.querySelector('.rack-face-btn[data-face="rear"]').textContent = rear ? `Rear (${rear})` : 'Rear';
|
||||
const r = data.racks.find(x => x.name === data.rack) || {};
|
||||
document.querySelector('.rack-face-btn[data-face="behind"]').textContent = r.behind ? `Rear posts (${r.behind})` : 'Rear posts';
|
||||
document.querySelector('.rack-face-btn[data-face="rear"]').textContent = r.rear ? `Rear (${r.rear})` : 'Rear';
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
@@ -362,7 +352,8 @@ function currentRack() {
|
||||
}
|
||||
|
||||
function currentFace() {
|
||||
return new URLSearchParams(location.search).get('face') === 'rear' ? 'rear' : 'front';
|
||||
const face = new URLSearchParams(location.search).get('face');
|
||||
return ['behind', 'rear'].includes(face) ? face : 'front';
|
||||
}
|
||||
|
||||
function setView(changes) {
|
||||
@@ -383,14 +374,14 @@ document.getElementById('rack-tabs').addEventListener('click', e => {
|
||||
|
||||
document.querySelector('.rack-face').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-face]');
|
||||
if (btn) setView({ face: btn.dataset.face === 'rear' ? 'rear' : '' });
|
||||
if (btn) setView({ face: btn.dataset.face === 'front' ? '' : btn.dataset.face });
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const q = new URLSearchParams();
|
||||
if (currentRack()) q.set('rack', currentRack());
|
||||
if (currentFace() === 'rear') q.set('face', 'rear');
|
||||
if (currentFace() !== 'front') q.set('face', currentFace());
|
||||
render(await lt.api.get(`/api/rack${q.toString() ? `?${q}` : ''}`));
|
||||
} catch (err) {
|
||||
if (_rack) return; // keep the last drawing on a failed refresh
|
||||
|
||||
@@ -1584,3 +1584,36 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
|
||||
.rack-pp-bad .ua-mark { stroke: #e0312b; stroke-width: 3; }
|
||||
.rack-pp-doc .ua-mark { stroke: #4c5662; stroke-width: 2; }
|
||||
.rack-pp-port.rack-hl .ua-mark { fill: rgba(0,212,255,.25); stroke: #00b8e6; stroke-width: 3.5; }
|
||||
|
||||
/* ── Shelves drawn to scale, and the ZimaBoard 832 (static/fronts.js) ─ */
|
||||
.sh-back { fill: #0f1215; }
|
||||
.sh-floor { fill: #3a414b; }
|
||||
.sh-lip { fill: #59636f; }
|
||||
.sh-name { fill: var(--rk-muted, #8b97a5); font-size: 11px; font-family: Helvetica, Arial, sans-serif; }
|
||||
.sh-empty { fill: var(--rk-muted, #8b97a5); font-size: 13px; }
|
||||
.sh-box { fill: #d4d7dc; stroke: #8a929c; }
|
||||
.sh-box-name { fill: #15191e; font-size: 13px; font-weight: 700; font-family: Helvetica, Arial, sans-serif; }
|
||||
.sh-tag { fill: #111; stroke: #3a3f45; }
|
||||
.sh-tag-text { fill: #e9edf1; font-size: 9px; font-weight: 700; font-family: Helvetica, Arial, sans-serif; }
|
||||
.sh-hit { fill: transparent; stroke: transparent; stroke-width: 2.5; }
|
||||
.sh-thing:hover .sh-hit, .sh-thing:focus-visible .sh-hit { stroke: #00b8e6; }
|
||||
.zb-case { fill: #66625b; }
|
||||
.zb-case-edge { fill: #807c74; }
|
||||
.zb-case-top { fill: #8b877f; }
|
||||
.zb-pcb { fill: #a48f55; }
|
||||
.zb-pad { fill: #d3bd73; }
|
||||
.zb-base { fill: #121212; }
|
||||
.zb-base-sheen { fill: #2b2b2b; }
|
||||
.zb-glow { fill: #ff9a2e; opacity: .85; filter: blur(4px); }
|
||||
.zb-shield { fill: #cfcfca; stroke: #8f8f8a; stroke-width: 3; }
|
||||
.zb-hole { fill: #0b0b0b; }
|
||||
.zb-hole-key { fill: #0b0b0b; }
|
||||
.zb-contact { fill: #b89545; }
|
||||
.zb-led { stroke: #5c5c58; stroke-width: 1.5; }
|
||||
.zb-usb-tongue { fill: #2f7fe0; }
|
||||
.zb-usb-pin { fill: #c7a64b; }
|
||||
.zb-dp-pins { fill: #b89545; }
|
||||
.zb-dc { fill: #3a3a3a; stroke: #222; stroke-width: 2; }
|
||||
.zb-dc-face { fill: #2b2b2b; }
|
||||
.zb-dc-ring { fill: #4b4b4b; }
|
||||
.zb-dc-pin { fill: #c9a43a; }
|
||||
|
||||
Reference in New Issue
Block a user