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

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:
2026-11-11 00:12:07 -05:00
co-authored by Claude Opus 5.5
parent 6530433d9c
commit a9de175d90
12 changed files with 281 additions and 56 deletions
+13 -5
View File
@@ -141,10 +141,18 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
drawings live in `static/fronts.js`.
- **Shelves**: an item can sit on a shelf item instead of having a rack unit. The shelf draws what is on
it side by side (mini PCs, desk switches), each with its status and a link to its page.
- **Front and rear**: each item is mounted from the front, the rear, or full depth (both sides). On a
4-post rack a front and a rear item can share a unit; a full-depth item (a server) takes it on both
sides, and the rear view shows its back. Items on a shelf take the shelf's side. The Front / Rear switch
flips the drawing (`?face=rear`).
- **Posts, facing and views**: each item hangs on the front posts, the rear posts, or both (full
depth), and its ports face the front or the rear. On a 4-post rack a front-post and a rear-post item
can share a unit; a full-depth item takes it on both. The page has three views: **Front** (the front
posts), **Rear posts** (gear on the rear posts seen from the front, i.e. what sits behind the front
gear, such as a PDU or a shelf mounted from the rear but facing forward) and **Rear** (from behind).
An item seen from the side it doesn't face shows its back. Items on a shelf take the shelf's posts
and facing.
- **Shelves to scale**: a shelf draws its contents left to right by **Position on shelf**, at true scale
(a 19" panel is 482.6 mm). Gear with a drawing, such as the IceWhale ZimaBoard 832 (traced from
IceWhale's labelled photo of its port end), shows its real ports: each RJ45's link light follows the
host's physical NICs in name order (first NIC on the left jack), and the status glow follows the
host. Anything else is a labelled box.
- **Racks**: admins name a rack and set its height with **Rack settings** on the page, and add more with
**+ Rack** (a tab per rack). Renaming a rack moves its items; an empty rack can be deleted. Each item
has a rack (empty means the first). Until a rack is saved, racks come from config:
@@ -724,7 +732,7 @@ Take LED colors from the model's quick start guide. See the comment at the top o
### Database Schema Notes
- `cutsheet_items` gained `rack`, `shelf`, `device`, `mount` and `chassis` columns; they are added automatically on first use
- `cutsheet_items` gained `rack`, `shelf`, `device`, `mount`, `chassis`, `facing` and `slot` columns; they are added automatically on first use
- `cutsheet_racks`: racks saved on the rack page (name, height, notes)
- `device_links`: each device's inventory URL, by device name; created with the cutsheet tables
- `network_events`: one row per active event; `resolved_at` is set when recovered, and
+21 -4
View File
@@ -1105,6 +1105,19 @@ def _racks(items: list) -> list:
return racks
_VIRTUAL_IFACE = ('lo', 'vmbr', 'bond', 'docker', 'veth', 'tap', 'fwbr', 'fwpr', 'fwln', 'br-', 'virbr', 'wg', 'tun')
def _physical_interfaces(name: str, snapshot: dict) -> list:
"""A host's physical NICs in name order, with their state: [{'name', 'up'}].
Drawn fronts light one RJ45 per NIC in this order (first NIC on the leftmost jack).
"""
ifaces = ((snapshot.get('hosts') or {}).get(name) or {}).get('interfaces') or {}
return [{'name': n, 'up': str(state).lower() == 'up'} for n, state in sorted(ifaces.items())
if not n.startswith(_VIRTUAL_IFACE)]
def _device_status(name: str, snapshot: dict) -> str:
host = (snapshot.get('hosts') or {}).get(name)
if host:
@@ -1123,8 +1136,8 @@ def api_rack():
Query: rack (name; default the first rack), face (front|rear).
"""
face = request.args.get('face', 'front')
if face not in ('front', 'rear'):
return jsonify({'error': 'face must be front or rear'}), 400
if face not in cutsheet.RACK_VIEWS:
return jsonify({'error': f'face must be one of {", ".join(cutsheet.RACK_VIEWS)}'}), 400
view = _cutsheet_view()
link_stats = _state_json('link_stats')
switches = link_stats.get('unifi_switches') or {}
@@ -1145,7 +1158,9 @@ def api_rack():
for r in racks:
racked = [i for i in view['items'] if not i.get('shelf') and i.get('rack_u') and rack_of(i) == r['name']]
r['count'] = len(racked)
# How many items each view shows: rear-post gear is what "behind" and "rear" add
r['rear'] = sum(1 for i in racked if i.get('mount') in ('rear', 'full'))
r['behind'] = sum(1 for i in racked if i.get('mount') == 'rear')
for cable in view['cables']:
cable['run'] = [h['cable_id'] for h in cable['path'] if 'cable_id' in h]
marks = cutsheet.port_marks(view['cables'])
@@ -1189,12 +1204,14 @@ def api_rack():
'outlets': (unifi_info.get(device) or {}).get('outlets') or [],
'online': online.get(device, True)}
if item['kind'] == 'shelf':
# Left to right: by position on the shelf, then by name
entry['contents'] = [
{'id': c['id'], 'name': c['name'], 'kind': c['kind'], 'url': url(c),
'status': _device_status(dev(c), snapshot) if dev(c) in known else '',
'model': next((u.get('model') for u in snapshot.get('unifi') or [] if u.get('name') == dev(c)), '')
or '', 'notes': c.get('notes') or ''}
for c in sorted(on_shelf.get(name, []), key=lambda c: c['name'].lower())
or '', 'notes': c.get('notes') or '', 'chassis': c.get('chassis') or '',
'slot': c.get('slot'), 'interfaces': _physical_interfaces(dev(c), snapshot)}
for c in sorted(on_shelf.get(name, []), key=lambda c: (c.get('slot') or 999, c['name'].lower()))
]
out.append(entry)
return jsonify({
+26 -8
View File
@@ -25,6 +25,11 @@ ITEM_KINDS = ('patch_panel', 'wall_jack', 'device', 'shelf', 'other')
# Which side of a 4-post rack an item is mounted from. A front and a rear item can share
# a unit; a full-depth one (a server) takes the unit on both sides.
MOUNTS = ('front', 'rear', 'full')
# Which way an item's ports face. Gear on the rear posts usually still faces the front.
FACINGS = ('front', 'rear')
# Rack views: the front posts seen from the front, the rear posts seen from the front
# (what is behind the front gear), and the rack seen from behind
RACK_VIEWS = ('front', 'behind', 'rear')
RACK_UNITS_MAX = 60
# Drawn fronts for gear without a live faceplate (keys of RACK_FRONTS in static/fronts.js)
CHASSIS = {
@@ -32,6 +37,7 @@ CHASSIS = {
'uacc-patch-24': 'UniFi Patch Panel Blank 24 (UACC-Rack-Panel-Patch-Blank-24)',
'sliger-cx4712': 'Sliger CX4712 4U (10-bay)',
'rosewill-rsv-l4500u': 'Rosewill RSV-L4500U 4U (15-bay)',
'zimaboard-832': 'IceWhale ZimaBoard 832 (shelf gear)',
'server-1u': 'Generic 1U server',
'server-2u': 'Generic 2U server',
'server-4u': 'Generic 4U server',
@@ -110,11 +116,17 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
'device': _text(data, 'device'),
'mount': _text(data, 'mount', 10) or 'front',
'chassis': _text(data, 'chassis', 40),
'facing': _text(data, 'facing', 10) or 'front',
'slot': _int(data, 'slot', 1, 99, 'Position on the shelf'),
'shelf': _text(data, 'shelf', NAME_MAX),
'notes': _text(data, 'notes', NOTES_MAX),
}
if item['chassis'] and item['chassis'] not in CHASSIS:
raise ValidationError(f'Unknown front drawing {item["chassis"]}')
if item['facing'] not in FACINGS:
raise ValidationError(f'facing must be one of {", ".join(FACINGS)}')
if item['slot'] and not item['shelf']:
raise ValidationError('A position applies only to an item on a shelf')
if item['mount'] not in MOUNTS:
raise ValidationError(f'mount must be one of {", ".join(MOUNTS)}')
if item['shelf']:
@@ -127,6 +139,7 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
raise ValidationError('An item on a shelf takes its rack position from the shelf; clear the rack unit')
item['rack'] = '' # the shelf's rack and side
item['mount'] = 'front'
item['facing'] = 'front'
if item['rack_height'] and not item['rack_u']:
raise ValidationError('Set a rack unit to go with the height')
if item['rack_u'] and not item['rack_height']:
@@ -602,15 +615,20 @@ def _sides(item: dict) -> Tuple[str, ...]:
def rack_layout(items: List[dict], units: int, face: str = 'front') -> dict:
"""Place items that have a rack unit, top of the rack first, as seen from one face.
"""Place items that have a rack unit, top of the rack first, for one view.
Front and rear items may share a unit; full-depth items take it on both sides.
An item that would share a unit and side with one already placed, or runs past the
top of the rack, is reported (whichever face is asked for) rather than drawn over another.
Front-post and rear-post items may share a unit; full-depth items take it on both.
Views (RACK_VIEWS): 'front' shows the front posts from the front; 'behind' shows the
rear posts from the front (what sits behind the front gear); 'rear' shows the rear posts
from behind. Full-depth items appear in all three.
An item that would share a unit and post with one already placed, or runs past the
top of the rack, is reported (whatever the view) rather than drawn over another.
Returns {'placed': [item + 'rack_top' + 'through'], 'overlaps': [{'name', 'units', 'with'}],
'outside': [name]}, where 'through' marks a full-depth item seen from the side it isn't faced to
(its back, for the rear view).
'outside': [name]}, where 'through' marks an item seen from the side it doesn't face
(its back, rather than its ports).
"""
side = 'front' if face == 'front' else 'rear'
looking = 'rear' if face == 'rear' else 'front'
taken: Dict[tuple, str] = {}
placed, overlaps, outside = [], [], []
racked = [i for i in items if i.get('rack_u')]
@@ -630,8 +648,8 @@ def rack_layout(items: List[dict], units: int, face: str = 'front') -> dict:
continue
for c in cells:
taken[c] = item['name']
if face in sides:
placed.append(dict(item, rack_top=high, through=item.get('mount') == 'full' and face == 'rear'))
if side in sides:
placed.append(dict(item, rack_top=high, through=(item.get('facing') or 'front') != looking))
return {'placed': placed, 'overlaps': overlaps, 'outside': outside}
+7 -1
View File
@@ -679,6 +679,8 @@ _CUTSHEET_SCHEMA = (
device VARCHAR(255) NOT NULL DEFAULT '',
mount VARCHAR(10) NOT NULL DEFAULT 'front',
chassis VARCHAR(40) NOT NULL DEFAULT '',
facing VARCHAR(10) NOT NULL DEFAULT 'front',
slot SMALLINT NULL,
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -769,7 +771,7 @@ def set_device_link(name: str, inventory_url: str, user: str) -> None:
)
_ITEM_FIELDS = ('name', 'kind', 'port_count', 'location', 'rack_u', 'rack_height', 'rack', 'shelf', 'device',
'mount', 'chassis', 'notes')
'mount', 'chassis', 'facing', 'slot', 'notes')
# Columns added to cutsheet_items after its first release: (name, definition)
_ITEM_COLUMNS_ADDED = (
('rack', "VARCHAR(100) NOT NULL DEFAULT ''"),
@@ -778,6 +780,10 @@ _ITEM_COLUMNS_ADDED = (
('device', "VARCHAR(255) NOT NULL DEFAULT ''"),
('mount', "VARCHAR(10) NOT NULL DEFAULT 'front'"),
('chassis', "VARCHAR(40) NOT NULL DEFAULT ''"),
# Which way the ports face, independent of which posts it hangs on
('facing', "VARCHAR(10) NOT NULL DEFAULT 'front'"),
# Left-to-right position on a shelf
('slot', 'SMALLINT NULL'),
)
_CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes')
+7 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+33
View File
@@ -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; }
+13 -2
View File
@@ -275,11 +275,18 @@
<datalist id="cs-rack-list"></datalist>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-item-mount">Mounted</label>
<label class="lt-label" for="cs-item-mount">Mounted on</label>
<select class="lt-select" id="cs-item-mount" name="mount">
<option value="front">Front posts</option>
<option value="rear">Rear posts</option>
<option value="full">Both (full depth)</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-item-facing">Ports face</label>
<select class="lt-select" id="cs-item-facing" name="facing">
<option value="front">Front</option>
<option value="rear">Rear</option>
<option value="full">Full depth (both sides)</option>
</select>
</div>
</div>
@@ -288,6 +295,10 @@
<label class="lt-label" for="cs-item-shelf">On shelf</label>
<select class="lt-select" id="cs-item-shelf" name="shelf"><option value="">Not on a shelf</option></select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-item-slot">Position on shelf</label>
<input class="lt-input" id="cs-item-slot" name="slot" type="number" min="1" max="99" placeholder="1 = leftmost">
</div>
</div>
<div class="lt-field-hint cs-shelf-hint">Gear on a shelf (mini PCs, desk switches) takes its place and side from the shelf: leave its rack unit empty. On a 4-post rack a front and a rear item can share a unit; a full-depth one (a server) takes it on both sides.</div>
<div class="lt-form-group">
+6 -2
View File
@@ -21,8 +21,12 @@
<div class="rack-bar">
<div class="rack-tabs" id="rack-tabs" role="tablist" aria-label="Racks" hidden></div>
<div class="rack-face" role="group" aria-label="Rack side">
<button type="button" class="rack-face-btn active" data-face="front" aria-pressed="true">Front</button>
<button type="button" class="rack-face-btn" data-face="rear" aria-pressed="false">Rear</button>
<button type="button" class="rack-face-btn active" data-face="front" aria-pressed="true"
title="The front posts, seen from the front">Front</button>
<button type="button" class="rack-face-btn" data-face="behind" aria-pressed="false"
title="Gear on the rear posts, seen from the front (what is behind the front gear)">Rear posts</button>
<button type="button" class="rack-face-btn" data-face="rear" aria-pressed="false"
title="The rack seen from behind">Rear</button>
</div>
</div>
+24 -6
View File
@@ -524,17 +524,35 @@ class TestRackFacesAndSettings:
gandalf.db.cutsheet_load.return_value = {
'items': [dict(base, id=n, **i) for n, i in enumerate(items, 1)], 'cables': [], 'port_labels': []}
def test_rear_face(self, client):
def test_three_views(self, client):
# A server through both posts, a PDU on the rear posts facing the front (as in the StarTech),
# a fan tray on the rear posts facing the rear, and a switch on the front posts
self._items({'name': 'Server', 'kind': 'device', 'rack_u': 5, 'rack_height': 4, 'mount': 'full'},
{'name': 'PDU', 'kind': 'device', 'rack_u': 12, 'rack_height': 1, 'mount': 'rear'},
{'name': 'Fans', 'kind': 'device', 'rack_u': 14, 'rack_height': 1, 'mount': 'rear',
'facing': 'rear'},
{'name': 'Switch', 'kind': 'device', 'rack_u': 12, 'rack_height': 1})
front = client.get('/api/rack', headers=AUTH).get_json()
rear = client.get('/api/rack?face=rear', headers=AUTH).get_json()
assert [(i['name'], i['through']) for i in front['items']] == [('Switch', False), ('Server', False)]
assert [(i['name'], i['through']) for i in rear['items']] == [('PDU', False), ('Server', True)]
assert rear['face'] == 'rear' and rear['racks'][0]['rear'] == 2 and rear['racks'][0]['count'] == 3
def view(face):
return [(i['name'], i['through']) for i in
client.get(f'/api/rack?face={face}', headers=AUTH).get_json()['items']]
assert view('front') == [('Switch', False), ('Server', False)]
assert view('behind') == [('Fans', True), ('PDU', False), ('Server', False)]
assert view('rear') == [('Fans', False), ('PDU', True), ('Server', True)]
racks = client.get('/api/rack', headers=AUTH).get_json()['racks'][0]
assert (racks['count'], racks['rear'], racks['behind']) == (4, 3, 2)
assert client.get('/api/rack?face=side', headers=AUTH).status_code == 400
def test_shelf_contents_ordered_with_nics(self, client):
self._items({'name': 'Shelf', 'kind': 'shelf', 'rack_u': 3, 'rack_height': 2, 'mount': 'rear'},
{'name': 'b-box', 'kind': 'device', 'shelf': 'Shelf', 'slot': 1, 'chassis': 'zimaboard-832'},
{'name': 'large1', 'kind': 'device', 'shelf': 'Shelf', 'slot': 2})
data = client.get('/api/rack?face=behind', headers=AUTH).get_json()
things = data['items'][0]['contents']
assert [(c['name'], c['slot'], c['chassis']) for c in things] == [('b-box', 1, 'zimaboard-832'),
('large1', 2, '')]
assert things[1]['interfaces'] == [{'name': 'eth0', 'up': True}]
def test_saved_racks_replace_config(self, client):
self._items()
gandalf.db.cutsheet_racks.return_value = [{'id': 4, 'name': 'StarTech 25U', 'units': 25, 'notes': 'open frame'}]
+20
View File
@@ -369,3 +369,23 @@ class TestChassis:
def test_every_drawing_has_a_label(self):
assert all(cs.CHASSIS.values())
assert {'sliger-cx4712', 'rosewill-rsv-l4500u', 'unvr-pro', 'uacc-patch-24'} <= set(cs.CHASSIS)
class TestFacingAndSlots:
def test_facing_and_slot_validation(self):
shelf = {'S': {'name': 'S', 'kind': 'shelf'}}
assert cs.validate_item({'name': 'p', 'facing': 'rear'})['facing'] == 'rear'
assert cs.validate_item({'name': 'p'})['facing'] == 'front'
assert cs.validate_item({'name': 'z', 'shelf': 'S', 'slot': '2'}, shelf)['slot'] == 2
with pytest.raises(cs.ValidationError):
cs.validate_item({'name': 'p', 'facing': 'up'})
with pytest.raises(cs.ValidationError):
cs.validate_item({'name': 'p', 'slot': 1}) # a position needs a shelf
def test_rear_posts_facing_front_are_seen_from_the_front(self):
items = [{'id': 1, 'name': 'pdu', 'kind': 'device', 'rack_u': 24, 'rack_height': 2, 'mount': 'rear'},
{'id': 2, 'name': 'udm', 'kind': 'device', 'rack_u': 24, 'rack_height': 1}]
assert [i['name'] for i in cs.rack_layout(items, 25, 'front')['placed']] == ['udm']
behind = cs.rack_layout(items, 25, 'behind')['placed']
assert [(i['name'], i['through']) for i in behind] == [('pdu', False)]
assert [(i['name'], i['through']) for i in cs.rack_layout(items, 25, 'rear')['placed']] == [('pdu', True)]