diff --git a/README.md b/README.md
index 4d9ee87..c5e714a 100644
--- a/README.md
+++ b/README.md
@@ -141,10 +141,20 @@ 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: the onboard RJ45 link lights follow
+ enp2s0 (left) and enp3s0 (right), and the status glow follows the host. The "ZimaBoard 832 +
+ ConnectX-3" drawing adds the single-port SFP+ card lying flat in the side PCIe slot, as in the rack,
+ with a DAC attached; its round dot is Gandalf's link mark for enp1s0 (the slot is PCI bus 1), not an
+ LED on the card. 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:
@@ -734,7 +744,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
diff --git a/app.py b/app.py
index e110718..b18fba1 100644
--- a/app.py
+++ b/app.py
@@ -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({
diff --git a/cutsheet.py b/cutsheet.py
index e657633..3f13b1d 100644
--- a/cutsheet.py
+++ b/cutsheet.py
@@ -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 = {
@@ -34,6 +39,8 @@ CHASSIS = {
'usw-pro-max-48-poe': 'UniFi Switch Pro Max 48 PoE (until UniFi reports it)',
'sliger-cx4712': 'Sliger CX4712 4U (10-bay)',
'rosewill-rsv-l4500u': 'Rosewill RSV-L4500U 4U (15-bay)',
+ 'zimaboard-832': 'IceWhale ZimaBoard 832 (shelf gear)',
+ 'zimaboard-832-cx3': 'IceWhale ZimaBoard 832 + ConnectX-3 SFP+ in PCIe (shelf gear)',
'server-1u': 'Generic 1U server',
'server-2u': 'Generic 2U server',
'server-4u': 'Generic 4U server',
@@ -112,11 +119,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']:
@@ -129,6 +142,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']:
@@ -604,15 +618,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')]
@@ -632,8 +651,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}
diff --git a/db.py b/db.py
index 90eed8c..e55da2d 100644
--- a/db.py
+++ b/db.py
@@ -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')
diff --git a/static/cutsheet.js b/static/cutsheet.js
index db73a57..df9ee84 100644
--- a/static/cutsheet.js
+++ b/static/cutsheet.js
@@ -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 => `' +
@@ -397,8 +397,8 @@ function openItem(item) {
const shelves = _cs.items.filter(i => i.kind === 'shelf' && (!item || i.id !== item.id));
form.shelf.innerHTML = '' +
shelves.map(i => ``).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');
diff --git a/static/fronts.js b/static/fronts.js
index b4aaa4d..c068120 100644
--- a/static/fronts.js
+++ b/static/fronts.js
@@ -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.
//
@@ -151,3 +151,147 @@ function rackFrontSvg(key, ctx) {
${spec.draw(ctx)}
`;
}
+
+// ── 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) => `