Rack: drawn fronts for the NVR and server cases
Lint / JS (eslint) (push) Successful in 15s
Security / Python Security (bandit) (push) Successful in 1m5s
Lint / Python (flake8) (push) Successful in 49s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Lint / Python (flake8) (pull_request) Successful in 53s
Test / Python Tests (pytest) (push) Successful in 1m0s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 53s
Test / Python Tests (pytest) (pull_request) Successful in 57s

Items Gandalf has no live faceplate for can pick a Front drawing: the
UniFi NVR Pro (drawn from the CC0 NetBox devicetype-library image), the
Sliger CX4712 4U and Rosewill RSV-L4500U 4U cases (from driveAtlas's
layouts), and generic 1U/2U/4U servers. The power light shows the linked
device's live status. They export with the rest of the rack.

New cutsheet_items column chassis, added on first use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 23:25:02 -05:00
co-authored by Claude Opus 5.5
parent b416239438
commit 0f4e129c20
12 changed files with 255 additions and 5 deletions
+6 -1
View File
@@ -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
+2
View File
@@ -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 ''
+12
View File
@@ -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']:
+3 -1
View File
@@ -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')
+4 -2
View File
@@ -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 => `<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>' +
Object.entries(_cs.chassis || {}).map(([k, v]) => `<option value="${esc(k)}">${esc(v)}</option>`).join('');
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',
'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');
+168
View File
@@ -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 => `<rect class="rf-slot" x="${x}" y="${y - 9}" width="9" height="18" rx="4"/>`).join('');
return `<rect class="${cls}" x="0" y="0" width="34" height="${h}" rx="3"/>${holes(12)}
<rect class="${cls}" x="${w - 34}" y="0" width="34" height="${h}" rx="3"/>${holes(w - 21)}`;
}
function vents(x0, x1, y, h = 8, gap = 6, seg = 120) {
const out = [];
for (let x = x0; x + seg <= x1; x += seg + gap) out.push(`<rect class="rf-vent" x="${x}" y="${y}" width="${seg}" height="${h}" rx="${h / 2}"/>`);
return out.join('');
}
function powerButton(cx, cy, r, status) {
return `<circle class="rf-power-ring" cx="${cx}" cy="${cy}" r="${r}" style="stroke:${frontLed(status)}"/>
<path class="rf-power-icon" d="M${cx - r * 0.35} ${cy - r * 0.3} a${r * 0.48} ${r * 0.48} 0 1 0 ${r * 0.7} 0 M${cx} ${cy - r * 0.55} v${r * 0.5}"/>`;
}
function usbA(x, y) {
return `<rect class="rf-port" x="${x}" y="${y}" width="22" height="9" rx="1"/><rect class="rf-port-tongue" x="${x + 3}" y="${y + 2}" width="16" height="3"/>`;
}
function usbC(x, y) {
return `<rect class="rf-port" x="${x}" y="${y}" width="16" height="6" rx="3"/>`;
}
function keyLock(cx, cy) {
return `<circle class="rf-lock" cx="${cx}" cy="${cy}" r="9"/><rect class="rf-lock-slot" x="${cx - 1.5}" y="${cy - 6}" width="3" height="12" rx="1"/>`;
}
function brand(x, y, text, anchor = 'start') {
return `<text class="rf-brand" x="${x}" y="${y}" text-anchor="${anchor}">${text}</text>`;
}
function driveBay(x, y, w, h, vertical) {
// A hot-swap bay: tray face, latch and activity light
const latch = vertical
? `<rect class="rf-latch" x="${x + w / 2 - 6}" y="${y + h - 26}" width="12" height="18" rx="2"/>`
: `<rect class="rf-latch" x="${x + w - 26}" y="${y + h / 2 - 6}" width="18" height="12" rx="2"/>`;
const led = vertical ? `<circle class="rf-bay-led" cx="${x + w / 2}" cy="${y + 10}" r="2.5"/>`
: `<circle class="rf-bay-led" cx="${x + 10}" cy="${y + h / 2}" r="2.5"/>`;
return `<rect class="rf-bay" x="${x}" y="${y}" width="${w}" height="${h}" rx="3"/>${latch}${led}`;
}
const RACK_FRONTS = {
// Ubiquiti UNVR Pro: drawn from the CC0 NetBox devicetype-library front image.
// 2U; touchscreen top left, bays 1-3 on top and 4-7 below, RJ45 + SFP+ top right.
'unvr-pro': {
label: 'UniFi NVR Pro (UNVR Pro)', units: 2, width: 1167, height: 230,
draw({ status }) {
const bay = (x, y, w, n) => `<rect class="rf-unvr-bay" x="${x}" y="${y}" width="${w}" height="76" rx="2"/>
<rect class="rf-unvr-rail" x="${x}" y="${y}" width="5" height="76"/>
<circle class="rf-unvr-dot" cx="${x + w - 30}" cy="${y + 38}" r="2.6"/>
<text class="rf-small" x="${x + w / 2}" y="${y + 90}" text-anchor="middle">${n}</text>`;
const top = [97, 384, 669].map((x, i) => bay(x, 27, 283, i + 1)).join('');
const bottom = [12, 299, 585, 871].map((x, i) => bay(x, 125, 283, i + 4)).join('');
const strip = (y) => [97, 302, 507, 712, 916].map(x => `<rect class="rf-unvr-strip" x="${x}" y="${y}" width="196" height="4" rx="2"/>`).join('');
return `<rect class="rf-unvr-body" width="1167" height="230" rx="3"/>
${strip(9)}${strip(216)}
<rect class="rf-screen" x="14" y="35" width="61" height="60" rx="4"/>
<circle class="rf-screen-ring" cx="44" cy="65" r="13" style="stroke:${frontLed(status)}"/>
<text class="rf-small rf-dark" x="44" y="113" text-anchor="middle">NVR Pro</text>
${top}${bottom}
<rect class="rf-port" x="1018" y="52" width="44" height="38" rx="2"/>
<rect class="rf-port-led" x="1050" y="55" width="9" height="5" style="fill:${frontLed(status)}"/>
<rect class="rf-port" x="1070" y="65" width="40" height="27" rx="2"/>
<text class="rf-small" x="1040" y="46" text-anchor="middle">1</text>
<text class="rf-small" x="1090" y="46" text-anchor="middle">2</text>
<circle class="rf-reset" cx="1128" cy="219" r="3"/><text class="rf-small" x="1134" y="222">RESET</text>`;
},
},
// Sliger CX4712 4U with its faceplate off (as driveAtlas draws it): two 5.25" blanks side by
// side, ten 3.5" hot-swap bays standing on edge in one row, a narrow I/O strip on the right.
'sliger-cx4712': {
label: 'Sliger CX4712 4U (10-bay)', units: 4, width: 1100, height: 420,
draw({ status }) {
const bays = Array.from({ length: 10 }, (_, i) => driveBay(78 + i * 86, 150, 78, 240, true)).join('');
return `<rect class="rf-body-dark" width="1100" height="420" rx="3"/>${ears(1100, 420, 'rf-ear-dark')}
<rect class="rf-blank" x="78" y="30" width="418" height="98" rx="3"/>
<rect class="rf-blank" x="510" y="30" width="418" height="98" rx="3"/>
${vents(98, 480, 74, 7, 8, 40)}${vents(530, 912, 74, 7, 8, 40)}
${bays}
<rect class="rf-io" x="950" y="30" width="104" height="360" rx="4"/>
${powerButton(1002, 78, 20, status)}
${usbA(991, 130)}${usbA(991, 150)}${usbC(995, 176)}
${keyLock(1002, 220)}
${brand(1002, 372, 'SLIGER', 'middle')}`;
},
},
// Rosewill RSV-L4500U 4U: the perforated, lockable drop-down door over the fan wall
// (15 bays sit behind it), D-handles on the ears, I/O strip with power and USB on the right.
'rosewill-rsv-l4500u': {
label: 'Rosewill RSV-L4500U 4U (15-bay)', units: 4, width: 1100, height: 420,
draw({ status }) {
const fan = cx => `<circle class="rf-fan" cx="${cx}" cy="210" r="112"/>
<circle class="rf-fan-hub" cx="${cx}" cy="210" r="30"/>
${[0, 72, 144, 216, 288].map(a => `<path class="rf-fan-blade" transform="rotate(${a} ${cx} 210)" d="M${cx} 180 q40 -40 70 -10 q-30 10 -70 40 z"/>`).join('')}`;
const handle = x => `<rect class="rf-handle" x="${x}" y="60" width="16" height="300" rx="8"/>`;
return `<rect class="rf-body-dark" width="1100" height="420" rx="3"/>${ears(1100, 420, 'rf-ear-dark')}
${handle(36)}${handle(1048)}
<rect class="rf-door" x="64" y="16" width="880" height="388" rx="6"/>
<rect class="rf-mesh" x="80" y="32" width="848" height="356" rx="4"/>
${fan(225)}${fan(504)}${fan(783)}
<rect class="rf-io" x="954" y="16" width="86" height="388" rx="4"/>
${powerButton(997, 62, 18, status)}
<circle class="rf-reset" cx="997" cy="104" r="5"/>
<circle class="rf-led" cx="985" cy="130" r="3.5" style="fill:${frontLed(status)}"/><circle class="rf-led" cx="1009" cy="130" r="3.5"/>
${usbA(986, 160)}${usbA(986, 180)}
${keyLock(997, 232)}
${brand(997, 388, 'ROSEWILL', 'middle')}`;
},
},
// Generic servers, for gear without a specific drawing
'server-1u': genericServer(1, 4),
'server-2u': genericServer(2, 12),
'server-4u': genericServer(4, 24),
};
function genericServer(units, bays) {
const H = 105 * units;
return {
label: `Generic ${units}U server`, units, width: 1100, height: H,
draw({ status }) {
const rows = Math.max(1, Math.round(units * 0.75));
const cols = Math.ceil(bays / rows);
const bw = Math.min(118, 720 / cols - 8);
const bh = (H - 30 - (rows - 1) * 8) / rows;
const out = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols && r * cols + c < bays; c++) out.push(driveBay(60 + c * (bw + 8), 15 + r * (bh + 8), bw, bh, false));
}
return `<rect class="rf-body-dark" width="1100" height="${H}" rx="3"/>${ears(1100, H, 'rf-ear-dark')}
${out.join('')}
${vents(810, 1040, 18, 7, 6, 36)}${units > 1 ? vents(810, 1040, H - 26, 7, 6, 36) : ''}
${powerButton(1020, H / 2, Math.min(18, H / 5), status)}`;
},
};
}
// The markup for one item's drawn front, or '' when the key is unknown
function rackFrontSvg(key, ctx) {
const spec = RACK_FRONTS[key];
if (!spec) return '';
return `<svg class="rack-svg rack-front" viewBox="0 0 ${spec.width} ${spec.height}" preserveAspectRatio="none"
role="img" aria-label="${lt.escHtml(`${ctx.name}: ${spec.label}${ctx.status ? `, ${ctx.status}` : ''}`)}">
<title>${lt.escHtml(`${ctx.name} · ${spec.label}${ctx.status ? ` · ${ctx.status}` : ''}`)}</title>
${spec.draw(ctx)}
</svg>`;
}
+2 -1
View File
@@ -1,5 +1,5 @@
'use strict';
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT */
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, rackFrontSvg */
// 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.
@@ -126,6 +126,7 @@ function itemHtml(item) {
let body;
if (item.through) body = backHtml(item);
else if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item);
else if (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item);
else if (item.kind === 'patch_panel') body = panelSvg(item);
else if (item.kind === 'shelf') body = shelfHtml(item);
else body = plateHtml(item);
+37
View File
@@ -1524,3 +1524,40 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
.rack-face-btn + .rack-face-btn { border-left: 1px solid var(--border-color); }
.rack-face-btn.active { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.rack-back { opacity: .75; background: repeating-linear-gradient(135deg, var(--rk-plate-b) 0 8px, var(--rk-frame) 8px 16px); }
/* ── Drawn rack fronts (static/fronts.js) ─────────────────────────── */
.rf-ear, .rf-ear-dark { fill: #2b3037; }
.rf-ear { fill: #c9cbcf; }
.rf-slot { fill: #0d1014; }
.rf-vent { fill: #14181d; }
.rf-body-dark { fill: #1e2329; stroke: #3a424c; }
.rf-blank { fill: #262c33; stroke: #3d4651; }
.rf-bay { fill: #2c333b; stroke: #4c5662; stroke-width: 1.5; }
.rf-latch { fill: #8a96a3; }
.rf-bay-led { fill: #3b8bff; opacity: .8; }
.rf-io { fill: #171b20; stroke: #333b45; }
.rf-power-ring { fill: none; stroke-width: 3; }
.rf-power-icon { fill: none; stroke: #c7ced6; stroke-width: 2.4; stroke-linecap: round; }
.rf-port { fill: #0b0d10; stroke: #6b737d; }
.rf-port-tongue { fill: #2f6fd8; }
.rf-port-led { fill: #2fd36b; }
.rf-lock { fill: #a7b0ba; }
.rf-lock-slot { fill: #2b3037; }
.rf-led { fill: #6b737d; }
.rf-reset { fill: #0b0d10; stroke: #6b737d; }
.rf-brand { fill: #9aa7b4; font-size: 13px; font-weight: 700; letter-spacing: .12em; font-family: Helvetica, Arial, sans-serif; }
.rf-small { fill: #4a4f57; font-size: 10px; font-family: Helvetica, Arial, sans-serif; }
.rf-dark { fill: #1b1e22; font-weight: 700; }
.rf-door { fill: #121519; stroke: #3a424c; stroke-width: 2; }
.rf-mesh { fill: #0b0d10; stroke: #262c33; }
.rf-fan { fill: #15191e; stroke: #2e353e; stroke-width: 6; }
.rf-fan-hub { fill: #262c33; }
.rf-fan-blade { fill: #1f252c; stroke: #2e353e; }
.rf-handle { fill: none; stroke: #8a96a3; stroke-width: 5; }
.rf-unvr-body { fill: #d9dadc; stroke: #a9abaf; }
.rf-unvr-strip { fill: #26282c; }
.rf-unvr-bay { fill: #e9eaec; stroke: #9fa2a7; stroke-width: 1.5; }
.rf-unvr-rail { fill: #55595f; }
.rf-unvr-dot { fill: #ffffff; stroke: #c4c6ca; }
.rf-screen { fill: #0f1726; }
.rf-screen-ring { fill: none; stroke-width: 3; }
+5
View File
@@ -263,6 +263,11 @@
<input class="lt-input" id="cs-item-h" name="rack_height" type="number" min="1" max="20">
</div>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-item-chassis">Front drawing</label>
<select class="lt-select" id="cs-item-chassis" name="chassis"><option value="">Automatic (faceplate or plain plate)</option></select>
<div class="lt-field-hint">For gear Gandalf has no live faceplate for: the NVR, or a server's case.</div>
</div>
<div class="form-row">
<div class="lt-form-group">
<label class="lt-label" for="cs-item-rack">Rack</label>
+1
View File
@@ -84,5 +84,6 @@
{% block scripts %}
<script>const RACK_CAN_EDIT = {{ 'true' if can_edit else 'false' }};</script>
<script src="{{ url_for('static', filename='faceplates.js') }}"></script>
<script src="{{ url_for('static', filename='fronts.js') }}"></script>
<script src="{{ url_for('static', filename='rack.js') }}"></script>
{% endblock %}
+4
View File
@@ -490,6 +490,10 @@ class TestRackRacksShelvesLinks:
item = client.get('/api/rack', headers=AUTH).get_json()['items'][0]
assert not item['linked'] and item['url'] == '/cutsheet#item-1' and 'switch' not in item
def test_drawn_front_is_passed_through(self, client):
self._items({'name': 'UNVR-Pro', 'kind': 'device', 'rack_u': 5, 'rack_height': 2, 'chassis': 'unvr-pro'})
assert client.get('/api/rack', headers=AUTH).get_json()['items'][0]['chassis'] == 'unvr-pro'
def test_shelf_lists_what_is_on_it(self, client):
self._items({'name': 'Shelf', 'kind': 'shelf', 'rack_u': 3, 'rack_height': 1},
{'name': 'large1', 'kind': 'device', 'shelf': 'Shelf'},
+11
View File
@@ -357,3 +357,14 @@ class TestRackFaces:
for bad in ({'name': '', 'units': 25}, {'name': 'x'}, {'name': 'x', 'units': 61}, {'name': 'x', 'units': 0}):
with pytest.raises(cs.ValidationError):
cs.validate_rack(bad)
class TestChassis:
def test_known_drawing_kept_unknown_refused(self):
assert cs.validate_item({'name': 'nvr', 'chassis': 'unvr-pro'})['chassis'] == 'unvr-pro'
assert cs.validate_item({'name': 'x'})['chassis'] == ''
with pytest.raises(cs.ValidationError):
cs.validate_item({'name': 'x', 'chassis': 'tardis'})
def test_every_drawing_has_a_label(self):
assert all(cs.CHASSIS.values()) and 'sliger-cx4712' in cs.CHASSIS and 'rosewill-rsv-l4500u' in cs.CHASSIS