Merge pull request 'Rack: front and rear mounting, rack settings on the page' (#25) from rack-rear-and-settings into main
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 54s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 52s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 1m44s

This commit was merged in pull request #25.
This commit is contained in:
2026-11-10 22:52:15 -05:00
11 changed files with 453 additions and 52 deletions
+11 -5
View File
@@ -135,10 +135,15 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
item and its device as the same thing.
- **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.
- **More than one rack**: each item has a rack (empty means the first). The page shows a tab per rack.
Configure them with `racks: [{"name": "Main", "units": 42}, ...]`; without it there is one rack,
`rack.name` (default "Rack 1") with `rack.units` (default 42) units. A rack name used by an item but not
configured gets the default height.
- **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`).
- **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:
`racks: [{"name": "Main", "units": 42}, ...]`, or one rack `rack.name` (default "Rack 1") with
`rack.units` (default 42); saving the first one carries its items over.
- Every documented port shows its cutsheet state (green matches, red mismatch, grey unconfirmed).
Hovering a port lights up every port on the same cable run, through patch panels, and names the
cables; clicking opens the switch port or the cable.
@@ -712,7 +717,8 @@ 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` and `device` columns; they are added automatically on first use
- `cutsheet_items` gained `rack`, `shelf`, `device` and `mount` 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
`recovery_notified` once the recovery note is on its ticket (column added automatically at monitor startup)
+58 -13
View File
@@ -1073,11 +1073,11 @@ def rack_page():
def _racks(items: list) -> list:
"""Every rack, in order: the configured ones, then any other name an item uses.
"""Every rack, in order: those saved on the rack page, then any other name an item uses.
Config: "racks": [{"name": "Main", "units": 42}, ...]. Without it there is one rack,
named rack.name (default "Rack 1") with rack.units (default 42). An item with no rack
belongs to the first.
With none saved, racks come from config: "racks": [{"name": "Main", "units": 42}, ...], or
one rack named rack.name (default "Rack 1") with rack.units (default 42). Those have no id
until saved. An item with no rack belongs to the first.
"""
def units(value, default=42):
try:
@@ -1088,15 +1088,19 @@ def _racks(items: list) -> list:
cfg = _config()
single = cfg.get('rack') or {}
default_units = units(single.get('units', 42))
racks = [{'name': str(r.get('name') or '').strip(), 'units': units(r.get('units', default_units), default_units)}
for r in cfg.get('racks') or [] if isinstance(r, dict) and str(r.get('name') or '').strip()]
racks = [{'id': r['id'], 'name': r['name'], 'units': r['units'], 'notes': r.get('notes') or ''}
for r in db.cutsheet_racks()]
if not racks:
racks = [{'name': str(single.get('name') or 'Rack 1'), 'units': default_units}]
racks = [{'id': None, 'name': str(r.get('name') or '').strip(), 'notes': '',
'units': units(r.get('units', default_units), default_units)}
for r in cfg.get('racks') or [] if isinstance(r, dict) and str(r.get('name') or '').strip()]
if not racks:
racks = [{'id': None, 'name': str(single.get('name') or 'Rack 1'), 'units': default_units, 'notes': ''}]
named = {r['name'] for r in racks}
for item in items:
if item.get('rack') and item['rack'] not in named:
named.add(item['rack'])
racks.append({'name': item['rack'], 'units': default_units})
racks.append({'id': None, 'name': item['rack'], 'units': default_units, 'notes': ''})
return racks
@@ -1113,10 +1117,13 @@ def _device_status(name: str, snapshot: dict) -> str:
@app.route('/api/rack')
@require_auth
def api_rack():
"""One rack's front: placed cutsheet items, live switch ports, and each port's cutsheet state.
"""One rack face: placed cutsheet items, live switch ports, and each port's cutsheet state.
Query: rack (name; default the first 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
view = _cutsheet_view()
link_stats = _state_json('link_stats')
switches = link_stats.get('unifi_switches') or {}
@@ -1135,7 +1142,9 @@ def api_rack():
return item.get('rack') or default
for r in racks:
r['count'] = sum(1 for i in view['items'] if not i.get('shelf') and i.get('rack_u') and rack_of(i) == r['name'])
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)
r['rear'] = sum(1 for i in racked if i.get('mount') in ('rear', 'full'))
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'])
@@ -1156,7 +1165,7 @@ def api_rack():
on_shelf.setdefault(item['shelf'], []).append(item)
here = [i for i in view['items'] if not i.get('shelf') and rack_of(i) == current['name']]
layout = cutsheet.rack_layout(here, current['units'])
layout = cutsheet.rack_layout(here, current['units'], face)
out = []
for item in layout['placed']:
name, device = item['name'], dev(item)
@@ -1164,6 +1173,8 @@ def api_rack():
'rack_top', 'location', 'notes')}
entry['device'] = device
entry['linked'] = device in known
entry['mount'] = item.get('mount') or 'front'
entry['through'] = item.get('through', False)
entry['url'] = url(item)
entry['status'] = _device_status(device, snapshot) if device in known else ''
# Cables may name either the item or the device it is linked to
@@ -1185,13 +1196,47 @@ def api_rack():
]
out.append(entry)
return jsonify({
'racks': racks, 'rack': current['name'], 'units': current['units'], 'items': out,
'racks': racks, 'rack': current['name'], 'rack_id': current['id'], 'units': current['units'],
'notes': current['notes'], 'face': face, 'items': out,
'overlaps': layout['overlaps'], 'outside': layout['outside'],
'unplaced': sorted(i['name'] for i in here if not i.get('rack_u')),
'updated': link_stats.get('updated'),
})
@app.route('/api/cutsheet/racks', methods=['POST'])
@require_auth
@require_admin
@_cutsheet_write
def api_cutsheet_create_rack():
"""Add a rack. `previous` is the name of the default rack this replaces, when the first is saved."""
body = _json_body()
rack = cutsheet.validate_rack(body)
previous = str(body.get('previous') or '')[:100] if not db.cutsheet_racks() else ''
rack_id = db.cutsheet_save_rack(None, rack, _get_user()['username'], previous=previous)
return jsonify({'success': True, 'id': rack_id})
@app.route('/api/cutsheet/racks/<int:rack_id>', methods=['PUT'])
@require_auth
@require_admin
@_cutsheet_write
def api_cutsheet_update_rack(rack_id: int):
rack = cutsheet.validate_rack(_json_body())
db.cutsheet_save_rack(rack_id, rack, _get_user()['username'])
return jsonify({'success': True})
@app.route('/api/cutsheet/racks/<int:rack_id>', methods=['DELETE'])
@require_auth
@require_admin
@_cutsheet_write
def api_cutsheet_delete_rack(rack_id: int):
if not db.cutsheet_delete_rack(rack_id, _get_user()['username']):
return jsonify({'error': 'No such rack'}), 404
return jsonify({'success': True})
@app.route('/api/cutsheet/cables', methods=['POST'])
@require_auth
@require_admin
+42 -11
View File
@@ -22,6 +22,10 @@ import re
from typing import Dict, List, Optional, Tuple
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')
RACK_UNITS_MAX = 60
PASSIVE_KINDS = ('patch_panel', 'wall_jack')
SIDES = ('front', 'rear')
STATUSES = ('planned', 'installed', 'dark', 'retired')
@@ -94,9 +98,12 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
'rack_height': _int(data, 'rack_height', 1, 20, 'Height'),
'rack': _text(data, 'rack', RACK_NAME_MAX),
'device': _text(data, 'device'),
'mount': _text(data, 'mount', 10) or 'front',
'shelf': _text(data, 'shelf', NAME_MAX),
'notes': _text(data, 'notes', NOTES_MAX),
}
if item['mount'] not in MOUNTS:
raise ValidationError(f'mount must be one of {", ".join(MOUNTS)}')
if item['shelf']:
shelf = (items or {}).get(item['shelf'])
if not shelf or shelf['kind'] != 'shelf':
@@ -105,7 +112,8 @@ def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
raise ValidationError('A shelf cannot sit on a shelf')
if item['rack_u'] or item['rack_height']:
raise ValidationError('An item on a shelf takes its rack position from the shelf; clear the rack unit')
item['rack'] = '' # the shelf's rack
item['rack'] = '' # the shelf's rack and side
item['mount'] = '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']:
@@ -575,14 +583,22 @@ def problems(cables: List[dict], items: Dict[str, dict], switches: dict) -> List
# ── Rack elevation ──────────────────────────────────────────────────────────
def rack_layout(items: List[dict], units: int) -> dict:
"""Place items that have a rack unit, top of the rack first.
def _sides(item: dict) -> Tuple[str, ...]:
mount = item.get('mount') or 'front'
return ('front', 'rear') if mount == 'full' else (mount,)
An item that would share a unit with one already placed, or runs past the
top of the rack, is reported rather than drawn over another.
Returns {'placed': [item + 'rack_top'], 'overlaps': [{'name', 'units', 'with'}], 'outside': [name]}.
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.
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.
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).
"""
taken: Dict[int, str] = {}
taken: Dict[tuple, str] = {}
placed, overlaps, outside = [], [], []
racked = [i for i in items if i.get('rack_u')]
# Top first; where two claim the same top unit, the one recorded first is drawn
@@ -593,13 +609,16 @@ def rack_layout(items: List[dict], units: int) -> dict:
if high > units:
outside.append({'name': item['name'], 'units': span})
continue
clash = sorted({taken[u] for u in range(low, high + 1) if u in taken})
sides = _sides(item)
cells = [(u, s) for u in range(low, high + 1) for s in sides]
clash = sorted({taken[c] for c in cells if c in taken})
if clash:
overlaps.append({'name': item['name'], 'units': span, 'with': clash})
continue
for u in range(low, high + 1):
taken[u] = item['name']
placed.append(dict(item, rack_top=high))
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'))
return {'placed': placed, 'overlaps': overlaps, 'outside': outside}
@@ -633,3 +652,15 @@ def port_marks(cables: List[dict]) -> Dict[tuple, dict]:
entry['text'] = '; '.join(f'cable{"s" if len(labels) > 1 else ""} {", ".join(labels)}: {detail}'
for detail, labels in entry.pop('by_detail').items())
return out
def validate_rack(data: dict) -> dict:
"""A rack from the rack settings form."""
rack = {
'name': _text(data, 'name', RACK_NAME_MAX, required=True, label='Rack name'),
'units': _int(data, 'units', 1, RACK_UNITS_MAX, 'Height'),
'notes': _text(data, 'notes', NOTES_MAX),
}
if not rack['units']:
raise ValidationError('Set the rack height in units')
return rack
+73 -1
View File
@@ -677,6 +677,7 @@ _CUTSHEET_SCHEMA = (
rack VARCHAR(100) NOT NULL DEFAULT '',
shelf VARCHAR(100) NOT NULL DEFAULT '',
device VARCHAR(255) NOT NULL DEFAULT '',
mount VARCHAR(10) NOT NULL DEFAULT 'front',
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -725,6 +726,15 @@ _CUTSHEET_SCHEMA = (
INDEX idx_hist_time (changed_at),
INDEX idx_hist_entity (entity, entity_id)
) ENGINE=InnoDB""",
"""CREATE TABLE IF NOT EXISTS cutsheet_racks (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
units SMALLINT NOT NULL,
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_rack_name (name)
) ENGINE=InnoDB""",
# Where a device lives in the asset inventory (HomeBox), set by hand on its Gandalf page
"""CREATE TABLE IF NOT EXISTS device_links (
name VARCHAR(255) NOT NULL PRIMARY KEY,
@@ -758,13 +768,14 @@ 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',
'notes')
'mount', 'notes')
# Columns added to cutsheet_items after its first release: (name, definition)
_ITEM_COLUMNS_ADDED = (
('rack', "VARCHAR(100) NOT NULL DEFAULT ''"),
('shelf', "VARCHAR(100) NOT NULL DEFAULT ''"),
# 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'"),
)
_CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes')
@@ -1027,3 +1038,64 @@ def cutsheet_import_cables(cables: list, user: str) -> dict:
None, cable)
added += 1
return {'added': added, 'updated': updated}
# ---------------------------------------------------------------------------
# Racks (cutsheet_racks): name and height, edited on the rack page
# ---------------------------------------------------------------------------
def cutsheet_racks() -> list:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute('SELECT id, name, units, notes FROM cutsheet_racks ORDER BY id')
return list(cur.fetchall())
def cutsheet_save_rack(rack_id: Optional[int], rack: dict, user: str, previous: str = '') -> int:
"""Create (rack_id None) or update a rack. A rename carries over to the items in it.
`previous` names the rack being replaced when the first rack is saved for the first
time (it existed only as the default until then), so its items follow the new name.
"""
with _cs_transaction() as cur:
before = None
if rack_id is not None:
cur.execute('SELECT * FROM cutsheet_racks WHERE id=%s', (rack_id,))
before = cur.fetchone()
if before is None:
raise CutsheetConflict('No such rack')
cur.execute('SELECT name FROM cutsheet_racks WHERE LOWER(name)=LOWER(%s) AND id<>%s',
(rack['name'], rack_id or 0))
clash = cur.fetchone()
if clash:
raise CutsheetConflict(f'A rack named {clash["name"]} already exists')
if before is None:
cur.execute('INSERT INTO cutsheet_racks (name, units, notes) VALUES (%s, %s, %s)',
(rack['name'], rack['units'], rack['notes']))
rack_id = cur.lastrowid
old_name = previous
else:
cur.execute('UPDATE cutsheet_racks SET name=%s, units=%s, notes=%s WHERE id=%s',
(rack['name'], rack['units'], rack['notes'], rack_id))
old_name = before['name']
if old_name and old_name != rack['name']:
cur.execute('UPDATE cutsheet_items SET rack=%s WHERE rack=%s', (rack['name'], old_name))
renamed = f' (was {old_name})' if old_name and old_name != rack['name'] else ''
_cs_log(cur, user, 'rack', rack_id, 'update' if before else 'create',
f'Rack {rack["name"]}: {rack["units"]}U{renamed}', _cs_jsonable(before), rack)
return rack_id
def cutsheet_delete_rack(rack_id: int, user: str) -> bool:
with _cs_transaction() as cur:
cur.execute('SELECT * FROM cutsheet_racks WHERE id=%s', (rack_id,))
before = cur.fetchone()
if before is None:
return False
cur.execute('SELECT COUNT(*) AS n FROM cutsheet_items WHERE rack=%s', (before['name'],))
n = cur.fetchone()['n']
if n:
raise CutsheetConflict(f'{before["name"]} still holds {n} item(s); move them to another rack first')
cur.execute('DELETE FROM cutsheet_racks WHERE id=%s', (rack_id,))
_cs_log(cur, user, 'rack', rack_id, 'delete', f'Removed rack {before["name"]}', _cs_jsonable(before), None)
return True
+7 -5
View File
@@ -260,7 +260,8 @@ function renderRack() {
function rackText(i) {
if (i.shelf) return `on ${esc(i.shelf)}`;
if (!i.rack_u) return '–';
const where = `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}`;
const side = { rear: ' rear', full: ' full depth' }[i.mount] || '';
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;
}
@@ -388,13 +389,14 @@ function openCable(cable, prefill) {
function openItem(item) {
const form = document.getElementById('cs-item-form');
form.reset();
const data = Object.assign({ kind: 'patch_panel' }, 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('');
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', 'shelf', 'location', 'notes']) {
for (const key of ['id', 'name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
'location', 'notes']) {
form[key].value = data[key] == null ? '' : data[key];
}
if (!item) form.port_count.value = 24;
@@ -447,8 +449,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', 'shelf', 'location',
'notes']);
const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', 'mount', 'shelf',
'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');
+108 -13
View File
@@ -1,5 +1,5 @@
'use strict';
/* global FACEPLATES, renderFaceplate */
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT */
// 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.
@@ -114,9 +114,18 @@ function plateHtml(item) {
</div>`;
}
function backHtml(item) {
// A full-depth item seen from the rear: its back, not its faceplate
return `<div class="rack-plate rack-back">
${statusDot(item.status)}<a class="device-link rack-plate-name" href="${esc(item.url)}">${esc(item.name)}</a>
<span class="rack-plate-sub">back of a full-depth ${esc(KIND_TEXT[item.kind] || item.kind).toLowerCase()}</span>
</div>`;
}
function itemHtml(item) {
let body;
if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item);
if (item.through) body = backHtml(item);
else if (item.switch && isRackFaceplate(item.switch.model)) body = switchHtml(item);
else if (item.kind === 'patch_panel') body = panelSvg(item);
else if (item.kind === 'shelf') body = shelfHtml(item);
else body = plateHtml(item);
@@ -161,7 +170,14 @@ 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.racks.length > 1 ? `${data.rack} · front` : 'Front';
document.getElementById('rack-title').textContent = `${data.rack} · ${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';
}
function render(data) {
@@ -183,8 +199,17 @@ function render(data) {
u = low - 1;
}
document.getElementById('rack').innerHTML = rows.join('');
// Every rack device is 19" wide: fill the slot rather than keep each drawing's own proportions
document.querySelectorAll('#rack .fp-svg').forEach(s => s.setAttribute('preserveAspectRatio', 'none'));
// Every rack device is 19" wide: stretch a faceplate to fill its slot when its drawing is close to
// the slot's proportions. A drawing far from them (the PDU's is twice as tall as a real 1U) keeps its
// shape rather than being squashed.
document.querySelectorAll('#rack .rack-slot').forEach(slot => {
const svg = slot.querySelector('.fp-svg');
if (!svg) return;
const [, , w, h] = svg.getAttribute('viewBox').split(' ').map(Number);
const n = parseInt(slot.closest('.rack-row').style.getPropertyValue('--n'), 10);
const off = (w / h) / (RATIO / n);
svg.setAttribute('preserveAspectRatio', off > 0.85 && off < 1.15 ? 'none' : 'xMidYMid meet');
});
document.getElementById('rack-show-empty').checked = showEmpty();
document.getElementById('rack-count').textContent = data.items.length;
@@ -283,19 +308,37 @@ function currentRack() {
return new URLSearchParams(location.search).get('rack') || '';
}
function currentFace() {
return new URLSearchParams(location.search).get('face') === 'rear' ? 'rear' : 'front';
}
function setView(changes) {
const q = new URLSearchParams(location.search);
Object.entries(changes).forEach(([k, v]) => (v ? q.set(k, v) : q.delete(k)));
const qs = q.toString();
history.replaceState(null, '', qs ? `?${qs}` : location.pathname);
_rack = null;
load();
}
document.getElementById('rack-tabs').addEventListener('click', e => {
const tab = e.target.closest('[data-rack]');
if (!tab) return;
const first = _rack && _rack.racks[0] && _rack.racks[0].name;
history.replaceState(null, '', tab.dataset.rack === first ? location.pathname : `?rack=${encodeURIComponent(tab.dataset.rack)}`);
_rack = null;
load();
setView({ rack: tab.dataset.rack === first ? '' : tab.dataset.rack });
});
document.querySelector('.rack-face').addEventListener('click', e => {
const btn = e.target.closest('[data-face]');
if (btn) setView({ face: btn.dataset.face === 'rear' ? 'rear' : '' });
});
async function load() {
try {
const rack = currentRack();
render(await lt.api.get(`/api/rack${rack ? `?rack=${encodeURIComponent(rack)}` : ''}`));
const q = new URLSearchParams();
if (currentRack()) q.set('rack', currentRack());
if (currentFace() === 'rear') q.set('face', 'rear');
render(await lt.api.get(`/api/rack${q.toString() ? `?${q}` : ''}`));
} catch (err) {
if (_rack) return; // keep the last drawing on a failed refresh
document.getElementById('rack').innerHTML = `<div class="lt-empty-state lt-empty-state--sm">
@@ -342,7 +385,7 @@ function buildExport() {
root.appendChild(svgEl('rect', { width: '100%', height: '100%', fill: '#ffffff' }));
const stamp = new Date().toLocaleString();
root.appendChild(svgEl('text', { x: pad, y: pad + 14, 'font-size': 18, 'font-weight': 700, fill: '#111' },
`${_rack.rack} · front`));
`${_rack.rack} · ${_rack.face}`));
root.appendChild(svgEl('text', { x: W + gutter + pad, y: pad + 14, 'font-size': 11, fill: '#666', 'text-anchor': 'end' },
`Gandalf · ${stamp}`));
@@ -370,7 +413,7 @@ function buildExport() {
const clone = art.cloneNode(true);
inlineStyles(art, clone);
['x', 'y', 'width', 'height'].forEach((k, i) => clone.setAttribute(k, [x, y, W, h][i]));
clone.setAttribute('preserveAspectRatio', art.classList.contains('fp-svg') ? 'none' : 'xMidYMid meet');
clone.setAttribute('preserveAspectRatio', art.getAttribute('preserveAspectRatio') || 'xMidYMid meet');
clone.removeAttribute('style');
root.appendChild(clone);
} else if (plate) {
@@ -406,7 +449,7 @@ async function exportRack(kind) {
return;
}
const svg = buildExport();
const base = `${_rack.rack.replace(/[^\w.-]+/g, '-')}-rack`;
const base = `${_rack.rack.replace(/[^\w.-]+/g, '-')}-${_rack.face}`;
if (kind === 'svg') {
download(new Blob([svg], { type: 'image/svg+xml' }), `${base}.svg`);
return;
@@ -436,3 +479,55 @@ document.getElementById('rack-show-empty').addEventListener('change', e => {
});
document.querySelectorAll('[data-export]').forEach(btn => btn.addEventListener('click', () => exportRack(btn.dataset.export)));
// ── Rack settings (admins) ──────────────────────────────────────────────
function openRackForm(adding) {
const form = document.getElementById('rack-form');
form.reset();
const r = adding ? null : (_rack.racks.find(x => x.name === _rack.rack) || {});
form.id.value = r && r.id ? r.id : '';
// The default rack exists only in config until saved; its items follow the new name
form.previous.value = r && !r.id ? r.name : '';
form.name.value = r ? r.name : '';
form.units.value = r ? r.units : 42;
form.notes.value = r ? (r.notes || '') : '';
form.querySelector('[data-action="delete-rack"]').hidden = !(r && r.id);
document.getElementById('rack-modal-title').textContent = adding ? 'Add a rack' : 'Rack settings';
lt.modal.open('rack-modal');
form.name.focus();
}
async function saveRack(e) {
e.preventDefault();
const form = e.target;
const body = { name: form.name.value.trim(), units: form.units.value, notes: form.notes.value, previous: form.previous.value };
try {
if (form.id.value) await lt.api.put(`/api/cutsheet/racks/${encodeURIComponent(form.id.value)}`, body);
else await lt.api.post('/api/cutsheet/racks', body);
lt.modal.close('rack-modal');
lt.toast.success('Rack saved');
setView({ rack: body.name });
} catch (err) {
lt.toast.error(err.message || 'Save failed');
}
}
async function deleteRack() {
const form = document.getElementById('rack-form');
if (!form.id.value || !window.confirm(`Delete the rack ${form.name.value}?`)) return;
try {
await lt.api.delete(`/api/cutsheet/racks/${encodeURIComponent(form.id.value)}`);
lt.modal.close('rack-modal');
lt.toast.success('Rack deleted');
setView({ rack: '' });
} catch (err) {
lt.toast.error(err.message || 'Delete failed');
}
}
if (RACK_CAN_EDIT) {
document.querySelector('[data-action="rack-settings"]').addEventListener('click', () => _rack && openRackForm(false));
document.querySelector('[data-action="add-rack"]').addEventListener('click', () => _rack && openRackForm(true));
document.getElementById('rack-form').addEventListener('submit', saveRack);
document.querySelector('[data-action="delete-rack"]').addEventListener('click', deleteRack);
}
+8
View File
@@ -1515,3 +1515,11 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
.rack-shelf-thing:hover, .rack-shelf-thing:focus-visible { outline: 2px solid var(--accent-cyan); }
.rack-shelf-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@container (max-height: 50px) { .rack-shelf-label { display: none; } .rack-shelf-thing { font-size: .6rem; gap: 3px; } }
.rack-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between;
max-width: 1000px; margin: 0 auto 14px; }
.rack-bar .rack-tabs { margin: 0 !important; }
.rack-face { display: inline-flex; border: 1px solid var(--border-color); border-radius: 3px; overflow: hidden; margin-left: auto; }
.rack-face-btn { background: none; border: 0; color: inherit; font: inherit; font-size: .82rem; padding: 6px 14px; cursor: pointer; }
.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); }
+11 -1
View File
@@ -269,12 +269,22 @@
<input class="lt-input" id="cs-item-rack" name="rack" maxlength="100" list="cs-rack-list" placeholder="first rack">
<datalist id="cs-rack-list"></datalist>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-item-mount">Mounted</label>
<select class="lt-select" id="cs-item-mount" name="mount">
<option value="front">Front</option>
<option value="rear">Rear</option>
<option value="full">Full depth (both sides)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="lt-form-group">
<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>
<div class="lt-field-hint cs-shelf-hint">Gear on a shelf (mini PCs, desk switches) takes its place from the shelf: leave its rack unit empty.</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">
<label class="lt-label" for="cs-item-location">Location</label>
<input class="lt-input" id="cs-item-location" name="location" maxlength="255" placeholder="e.g. rack, office wall">
+46 -1
View File
@@ -11,12 +11,20 @@
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-export="svg">Download SVG</button>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-export="png">Download PNG</button>
{% if can_edit %}
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="rack-settings">Rack settings</button>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="add-rack">+ Rack</button>
<a class="lt-btn lt-btn-secondary lt-btn-sm" href="/cutsheet#cs-items-section">Edit positions</a>
{% endif %}
</div>
</div>
<div class="rack-tabs" id="rack-tabs" role="tablist" aria-label="Racks" hidden></div>
<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>
</div>
</div>
<div class="rack-warnings" id="rack-warnings" role="alert" hidden></div>
@@ -35,9 +43,46 @@
<div class="rack" id="rack"><div class="link-loading">Loading rack</div></div>
<p class="cs-hint rack-unplaced" id="rack-unplaced"></p>
</section>
{% if can_edit %}
<div id="rack-modal" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="rack-modal-title" aria-hidden="true">
<div class="lt-modal">
<div class="lt-modal-header">
<h3 class="lt-modal-title" id="rack-modal-title">Rack settings</h3>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="rack-form" autocomplete="off">
<div class="lt-modal-body">
<input type="hidden" name="id">
<input type="hidden" name="previous">
<div class="form-row">
<div class="lt-form-group">
<label class="lt-label" for="rack-name">Name <span class="required">*</span></label>
<input class="lt-input" id="rack-name" name="name" required maxlength="100" placeholder="e.g. StarTech 25U">
</div>
<div class="lt-form-group">
<label class="lt-label" for="rack-units">Height (U) <span class="required">*</span></label>
<input class="lt-input" id="rack-units" name="units" type="number" min="1" max="60" required>
</div>
</div>
<div class="lt-form-group">
<label class="lt-label" for="rack-notes">Notes</label>
<textarea class="lt-input" id="rack-notes" name="notes" rows="2" maxlength="2000" placeholder="model, location, depth"></textarea>
</div>
<p class="cs-hint">Renaming a rack moves its items with it. A rack can be deleted once it is empty.</p>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-danger" data-action="delete-rack">Delete</button>
<button type="button" class="lt-btn lt-btn-secondary" data-modal-close>Cancel</button>
<button type="submit" class="lt-btn lt-btn-primary">Save</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}
{% 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='rack.js') }}"></script>
{% endblock %}
+53 -2
View File
@@ -36,6 +36,7 @@ def client():
db.get_status_summary.return_value = {'critical': 0, 'warning': 0, 'info': 0, 'resolved_24h': 0}
db.check_suppressed.return_value = False
db.get_device_link.return_value = ''
db.cutsheet_racks.return_value = []
with patch.object(gandalf, '_cfg', CONFIG):
yield gandalf.app.test_client()
@@ -503,9 +504,59 @@ class TestRackRacksShelvesLinks:
cfg = dict(CONFIG, racks=[{'name': 'Main', 'units': 24}])
with patch.object(gandalf, '_cfg', cfg):
data = client.get('/api/rack', headers=AUTH).get_json()
assert data['racks'] == [{'name': 'Main', 'units': 24, 'count': 1},
{'name': 'Garage', 'units': 42, 'count': 1}]
assert [(r['id'], r['name'], r['units'], r['count']) for r in data['racks']] == [
(None, 'Main', 24, 1), (None, 'Garage', 42, 1)]
assert data['rack'] == 'Main' and [i['name'] for i in data['items']] == ['A']
garage = client.get('/api/rack?rack=Garage', headers=AUTH).get_json()
assert [i['name'] for i in garage['items']] == ['B'] and garage['units'] == 42
assert client.get('/api/rack?rack=Nope', headers=AUTH).status_code == 404
class TestRackFacesAndSettings:
def _items(self, *items):
base = {'port_count': None, 'rack_u': None, 'rack_height': None, 'rack': '', 'shelf': '', 'device': '',
'mount': 'front', 'location': '', 'notes': ''}
gandalf._cutsheet_schema_ready = True
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):
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': '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
assert client.get('/api/rack?face=side', headers=AUTH).status_code == 400
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'}]
data = client.get('/api/rack', headers=AUTH).get_json()
assert (data['rack'], data['rack_id'], data['units'], data['notes']) == ('StarTech 25U', 4, 25, 'open frame')
def test_first_save_carries_the_default_racks_items(self, client):
gandalf._cutsheet_schema_ready = True
gandalf.db.cutsheet_save_rack.return_value = 7
resp = client.post('/api/cutsheet/racks', json={'name': 'StarTech 25U', 'units': 25, 'previous': 'Rack 1'},
headers=AUTH)
assert resp.get_json() == {'success': True, 'id': 7}
args, kwargs = gandalf.db.cutsheet_save_rack.call_args
assert args[0] is None and args[1]['units'] == 25 and kwargs == {'previous': 'Rack 1'}
def test_later_racks_ignore_previous(self, client):
gandalf._cutsheet_schema_ready = True
gandalf.db.cutsheet_racks.return_value = [{'id': 4, 'name': 'A', 'units': 25, 'notes': ''}]
client.post('/api/cutsheet/racks', json={'name': 'B', 'units': 42, 'previous': 'A'}, headers=AUTH)
assert gandalf.db.cutsheet_save_rack.call_args.kwargs == {'previous': ''}
def test_rack_writes_need_admin_and_valid_data(self, client):
gandalf._cutsheet_schema_ready = True
assert client.post('/api/cutsheet/racks', json={'name': 'x', 'units': 99}, headers=AUTH).status_code == 400
user = dict(AUTH, **{'Remote-Groups': 'staff'})
with patch.object(gandalf, '_cfg', dict(CONFIG, auth=dict(CONFIG['auth'], allowed_groups=['admin', 'staff']))):
assert client.put('/api/cutsheet/racks/1', json={'name': 'x', 'units': 2}, headers=user).status_code == 403
gandalf.db.cutsheet_delete_rack.return_value = False
assert client.delete('/api/cutsheet/racks/9', headers=AUTH).status_code == 404
+36
View File
@@ -321,3 +321,39 @@ class TestDeviceAliases:
def test_runs_for_the_device_include_aliased_cables(self):
c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0'), label='C1')
assert [r['label'] for r in cs.runs_for('udm', [c], self.ITEMS, {})] == ['C1']
class TestRackFaces:
def _item(self, name, u, h=1, mount='front', id_=1):
return {'id': id_, 'name': name, 'kind': 'device', 'rack_u': u, 'rack_height': h, 'mount': mount}
def test_front_and_rear_share_a_unit(self):
items = [self._item('switch', 10), self._item('pdu', 10, mount='rear', id_=2)]
front = cs.rack_layout(items, 25, 'front')
rear = cs.rack_layout(items, 25, 'rear')
assert [i['name'] for i in front['placed']] == ['switch'] and front['overlaps'] == []
assert [i['name'] for i in rear['placed']] == ['pdu'] and rear['overlaps'] == []
def test_full_depth_takes_both_sides(self):
items = [self._item('server', 5, 4, mount='full'), self._item('pdu', 6, mount='rear', id_=2)]
rear = cs.rack_layout(items, 25, 'rear')
assert [(i['name'], i['through']) for i in rear['placed']] == [('server', True)]
assert rear['overlaps'] == [{'name': 'pdu', 'units': 'U6', 'with': ['server']}]
front = cs.rack_layout(items, 25, 'front')
assert [(i['name'], i['through']) for i in front['placed']] == [('server', False)]
assert front['overlaps'] == rear['overlaps'] # reported on both faces
def test_mount_is_validated_and_shelf_items_take_the_shelf_side(self):
assert cs.validate_item({'name': 'p', 'mount': 'rear', 'rack_u': 3})['mount'] == 'rear'
assert cs.validate_item({'name': 'p'})['mount'] == 'front'
with pytest.raises(cs.ValidationError):
cs.validate_item({'name': 'p', 'mount': 'side'})
shelf = {'Shelf': {'name': 'Shelf', 'kind': 'shelf'}}
assert cs.validate_item({'name': 'm', 'shelf': 'Shelf', 'mount': 'rear'}, shelf)['mount'] == 'front'
def test_validate_rack(self):
rack = cs.validate_rack({'name': ' StarTech 25U ', 'units': '25'})
assert rack == {'name': 'StarTech 25U', 'units': 25, 'notes': ''}
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)