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
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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']:
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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'},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user