diff --git a/README.md b/README.md
index ab75903..41c91bb 100644
--- a/README.md
+++ b/README.md
@@ -133,6 +133,10 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
patch panels get a generic panel with each port's label; anything else is a labelled plate with its
live status. Cables may use either name: checks, suggestions, tickets and the device page treat an
item and its device as the same thing.
+- **Drawn fronts**: gear without a live faceplate can pick a **Front drawing** on its cutsheet item:
+ UniFi NVR Pro (from the CC0 NetBox device library image), Sliger CX4712 4U (10 bays), Rosewill
+ RSV-L4500U 4U (15 bays behind the fan door), or a generic 1U/2U/4U server. The power light shows the
+ linked device's live status. 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
@@ -674,6 +678,7 @@ gandalf/
│ ├── cutsheet.js # Cutsheet page
│ ├── events.js # Alert history page
│ ├── rack.js # Rack drawing
+│ ├── fronts.js # Drawn fronts for gear without a live faceplate (NVR, server cases)
│ └── port-inspector.js # Switch front panel, port panel, path check, diagnostics
└── templates/
├── base.html # Shared layout (header, nav, footer)
@@ -717,7 +722,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` and `mount` columns; they are added automatically on first use
+- `cutsheet_items` gained `rack`, `shelf`, `device`, `mount` and `chassis` 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 d03aa3b..5ea1d2e 100644
--- a/app.py
+++ b/app.py
@@ -942,6 +942,7 @@ def _cutsheet_view() -> dict:
data['devices'] = sorted(set(switches) | set(snapshot.get('hosts') or {})
| {u['name'] for u in snapshot.get('unifi') or [] if u.get('name')}, key=str.lower)
data['verdicts'] = cutsheet.VERDICTS
+ data['chassis'] = cutsheet.CHASSIS
data['options'] = {'kinds': cutsheet.ITEM_KINDS, 'passive_kinds': cutsheet.PASSIVE_KINDS,
'statuses': cutsheet.STATUSES, 'cable_types': cutsheet.CABLE_TYPES}
data['updated'] = link_stats.get('updated')
@@ -1174,6 +1175,7 @@ def api_rack():
entry['device'] = device
entry['linked'] = device in known
entry['mount'] = item.get('mount') or 'front'
+ entry['chassis'] = item.get('chassis') or ''
entry['through'] = item.get('through', False)
entry['url'] = url(item)
entry['status'] = _device_status(device, snapshot) if device in known else ''
diff --git a/cutsheet.py b/cutsheet.py
index b3614a2..fd04604 100644
--- a/cutsheet.py
+++ b/cutsheet.py
@@ -26,6 +26,15 @@ ITEM_KINDS = ('patch_panel', 'wall_jack', 'device', 'shelf', 'other')
# a unit; a full-depth one (a server) takes the unit on both sides.
MOUNTS = ('front', 'rear', 'full')
RACK_UNITS_MAX = 60
+# Drawn fronts for gear without a live faceplate (keys of RACK_FRONTS in static/fronts.js)
+CHASSIS = {
+ 'unvr-pro': 'UniFi NVR Pro (UNVR Pro)',
+ 'sliger-cx4712': 'Sliger CX4712 4U (10-bay)',
+ 'rosewill-rsv-l4500u': 'Rosewill RSV-L4500U 4U (15-bay)',
+ 'server-1u': 'Generic 1U server',
+ 'server-2u': 'Generic 2U server',
+ 'server-4u': 'Generic 4U server',
+}
PASSIVE_KINDS = ('patch_panel', 'wall_jack')
SIDES = ('front', 'rear')
STATUSES = ('planned', 'installed', 'dark', 'retired')
@@ -99,9 +108,12 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
'rack': _text(data, 'rack', RACK_NAME_MAX),
'device': _text(data, 'device'),
'mount': _text(data, 'mount', 10) or 'front',
+ 'chassis': _text(data, 'chassis', 40),
'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['mount'] not in MOUNTS:
raise ValidationError(f'mount must be one of {", ".join(MOUNTS)}')
if item['shelf']:
diff --git a/db.py b/db.py
index 666697c..90eed8c 100644
--- a/db.py
+++ b/db.py
@@ -678,6 +678,7 @@ _CUTSHEET_SCHEMA = (
shelf VARCHAR(100) NOT NULL DEFAULT '',
device VARCHAR(255) NOT NULL DEFAULT '',
mount VARCHAR(10) NOT NULL DEFAULT 'front',
+ chassis VARCHAR(40) NOT NULL DEFAULT '',
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -768,7 +769,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', 'notes')
+ 'mount', 'chassis', 'notes')
# Columns added to cutsheet_items after its first release: (name, definition)
_ITEM_COLUMNS_ADDED = (
('rack', "VARCHAR(100) NOT NULL DEFAULT ''"),
@@ -776,6 +777,7 @@ _ITEM_COLUMNS_ADDED = (
# The Gandalf device (UniFi or host name) an item is, when its own name differs
('device', "VARCHAR(255) NOT NULL DEFAULT ''"),
('mount', "VARCHAR(10) NOT NULL DEFAULT 'front'"),
+ ('chassis', "VARCHAR(40) NOT NULL DEFAULT ''"),
)
_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 dac4bac..330789f 100644
--- a/static/cutsheet.js
+++ b/static/cutsheet.js
@@ -392,11 +392,13 @@ function openItem(item) {
const data = Object.assign({ kind: 'patch_panel', mount: 'front' }, item || {});
document.getElementById('cs-rack-list').innerHTML = (_cs.racks || []).map(r => `' +
+ Object.entries(_cs.chassis || {}).map(([k, v]) => ``).join('');
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',
- 'location', 'notes']) {
+ 'chassis', 'location', 'notes']) {
form[key].value = data[key] == null ? '' : data[key];
}
if (!item) form.port_count.value = 24;
@@ -450,7 +452,7 @@ 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',
- 'location', 'notes']);
+ '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
new file mode 100644
index 0000000..6fda2db
--- /dev/null
+++ b/static/fronts.js
@@ -0,0 +1,168 @@
+'use strict';
+/* exported RACK_FRONTS, rackFrontSvg */
+// Drawn fronts for rack gear Gandalf can't read a faceplate from: an NVR UniFi doesn't
+// report, and 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.
+//
+// Spec: { label, units, width, height, draw(ctx) -> SVG markup }, ctx: { status, name }.
+// Width:height should be about 10.5:1 per rack unit so the drawing fills its slot.
+
+const FRONT_STATUS = { up: '#2fd36b', degraded: '#ffb300', down: '#e0312b' };
+
+function frontLed(status) {
+ return FRONT_STATUS[status] || '#6b737d';
+}
+
+function ears(w, h, cls = 'rf-ear') {
+ // Rack ears with their mounting slots, both sides
+ const slots = Array.from({ length: Math.round(h / 105) }, (_, i) => 105 * i + 52);
+ const holes = x => slots.map(y => `