diff --git a/README.md b/README.md
index 514149a..9e9f488 100644
--- a/README.md
+++ b/README.md
@@ -126,17 +126,28 @@ saw them: when each started, how long it lasted, failed checks, and its ticket.
(100 per page).
### Rack (`/rack`)
-The rack front, drawn from the rack units set on cutsheet items (`rack.units`, default 42U, top first).
-- An item named after a UniFi switch or gateway with a faceplate is drawn with its real faceplate and
- live port state; patch panels get a generic panel with each port's label; anything else is a labelled
- blank plate (desk switches and devices without a faceplate included).
+The rack front, drawn from the rack units set on cutsheet items, top first.
+- **Linking items to devices**: a cutsheet item can be named after the product ("UDM-Pro") and linked to
+ the live device with its **Gandalf device** field ("Dream Machine Pro"). Linked UniFi gear with a
+ faceplate is drawn with its real faceplate and live port state (the UPS 2U Pro shows its front only);
+ 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.
+- **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.
- 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.
+- Runs of 3 or more empty units fold into one band; "Show empty units" draws them in full (remembered
+ per browser). Every device is drawn at the full 19" width.
+- **Download SVG / PNG** saves the current rack on a white page for printing or planning.
- Items that would overlap another or run past the top of the rack are listed, not drawn over. Where two
claim the same top unit, the one recorded first is drawn. Items without a rack unit are listed below.
-- Adding the new switch: give it a cutsheet item with its rack unit and it appears; it is drawn as a
- plate until its model has a faceplate in `static/faceplates.js`.
+- The rack keeps its own dark "hardware" palette in both themes.
### Health check (`/health`)
No login needed. Returns 200 when the database answers and the monitor has completed a check recently,
@@ -436,7 +447,8 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/
| `ticket_api.api_key` | Tinker Tickets bearer token. Needs `read_write` scope to add recovery notes |
| `ticket_api.comment_url` | Tinker comment endpoint (default: `api/ticket_comment_api.php` beside `ticket_api.url`) |
| `gandalf.public_url` | Gandalf's own address, for links in tickets (default: `https://gandalf.lotusguild.org`) |
-| `rack.units` | Height of the rack drawn on `/rack` (default: 42) |
+| `rack.units`, `rack.name` | The single rack's height and name on `/rack` (defaults: 42, "Rack 1") |
+| `racks` | Several racks: `[{"name": "...", "units": 42}, ...]`, in tab order; overrides `rack` |
| `inventory.base_url` | Asset inventory (HomeBox) address; device inventory links must be on this host (default: `https://inventory.lotusguild.org`) |
| `inventory.search_url` | Search link used before a device has a record linked, with `{base}` and `{q}` placeholders (default: `{base}/items?q={q}`) |
| `pulse.url` | Pulse worker API base URL (for SSH relay) |
@@ -700,6 +712,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` and `device` columns; they are added automatically on first use
- `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)
diff --git a/app.py b/app.py
index 12cfacf..8f88c74 100644
--- a/app.py
+++ b/app.py
@@ -933,10 +933,14 @@ def _cutsheet_view() -> dict:
]
cable['a_text'] = cutsheet.describe_node(cutsheet._node(cable, 'a'))
cable['b_text'] = cutsheet.describe_node(cutsheet._node(cable, 'b'))
- data['suggestions'] = cutsheet.undocumented(data['cables'], switches)
+ data['suggestions'] = cutsheet.undocumented(data['cables'], switches, items)
data['labels'] = cutsheet.labels_to_make(data['cables'], items, data['port_labels'])
data['next_label'] = cutsheet.next_label(data['cables'])
data['catalog'] = _cutsheet_catalog(link_stats, data['items'])
+ data['racks'] = [r['name'] for r in _racks(data['items'])]
+ snapshot = _state_json('network_snapshot')
+ 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['options'] = {'kinds': cutsheet.ITEM_KINDS, 'passive_kinds': cutsheet.PASSIVE_KINDS,
'statuses': cutsheet.STATUSES, 'cable_types': cutsheet.CABLE_TYPES}
@@ -947,10 +951,12 @@ def _cutsheet_view() -> dict:
def _cutsheet_for_device(name: str) -> list:
"""The cables that end on one device, for its port panel."""
view = _cutsheet_view()
+ # Cables may name the device or a cutsheet item linked to it
+ names = {name} | {i['name'] for i in view['items'] if i.get('device') == name}
out = []
for cable in view['cables']:
for end in ('a', 'b'):
- if cable[f'{end}_device'] == name and cable['status'] != 'retired':
+ if cable[f'{end}_device'] in names and cable['status'] != 'retired':
out.append({
'port': cable[f'{end}_port'], 'side': cable[f'{end}_side'], 'id': cable['id'],
'label': cable['label'], 'status': cable['status'], 'verdict': cable['verdict'],
@@ -1066,17 +1072,51 @@ def rack_page():
return render_template('rack.html', user=user, can_edit='admin' in user['groups'])
-def _rack_units() -> int:
- try:
- return max(1, min(int((_config().get('rack') or {}).get('units', 42)), 60))
- except (TypeError, ValueError):
- return 42
+def _racks(items: list) -> list:
+ """Every rack, in order: the configured ones, 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.
+ """
+ def units(value, default=42):
+ try:
+ return max(1, min(int(value), 60))
+ except (TypeError, ValueError):
+ return default
+
+ 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()]
+ if not racks:
+ racks = [{'name': str(single.get('name') or 'Rack 1'), 'units': default_units}]
+ 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})
+ return racks
+
+
+def _device_status(name: str, snapshot: dict) -> str:
+ host = (snapshot.get('hosts') or {}).get(name)
+ if host:
+ return host.get('status') or 'unknown'
+ for d in snapshot.get('unifi') or []:
+ if d.get('name') == name:
+ return 'up' if d.get('connected') else 'down'
+ return 'unknown'
@app.route('/api/rack')
@require_auth
def api_rack():
- """The rack front: placed cutsheet items, live switch ports, and each port's cutsheet state."""
+ """One rack's front: placed cutsheet items, live switch ports, and each port's cutsheet state.
+
+ Query: rack (name; default the first rack).
+ """
view = _cutsheet_view()
link_stats = _state_json('link_stats')
switches = link_stats.get('unifi_switches') or {}
@@ -1085,6 +1125,17 @@ def api_rack():
online = {d['name']: d.get('connected', True) for d in snapshot.get('unifi') or []}
known = set(switches) | set(online) | set(snapshot.get('hosts') or {})
+ racks = _racks(view['items'])
+ default = racks[0]['name']
+ current = next((r for r in racks if r['name'] == request.args.get('rack', default)), None)
+ if current is None:
+ return jsonify({'error': 'No such rack'}), 404
+
+ def rack_of(item):
+ 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'])
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'])
@@ -1092,25 +1143,51 @@ def api_rack():
for pl in view['port_labels']:
labels.setdefault(pl['item_name'], {})[str(pl['port'])] = pl['label']
- units = _rack_units()
- layout = cutsheet.rack_layout(view['items'], units)
+ def dev(item):
+ # An item can be named after the product ("UDM-Pro") and linked to the live device
+ return item.get('device') or item['name']
+
+ def url(item):
+ return f'/device/{quote(dev(item), safe="")}' if dev(item) in known else f'/cutsheet#item-{item["id"]}'
+
+ on_shelf: dict = {}
+ for item in view['items']:
+ if item.get('shelf'):
+ 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'])
out = []
for item in layout['placed']:
- name = item['name']
+ name, device = item['name'], dev(item)
entry = {k: item.get(k) for k in ('id', 'name', 'kind', 'port_count', 'rack_u', 'rack_height',
'rack_top', 'location', 'notes')}
- entry['url'] = f'/device/{quote(name, safe="")}' if name in known else f'/cutsheet#item-{item["id"]}'
- entry['ports'] = {port: m for (dev, port), m in marks.items() if dev == name}
+ entry['device'] = device
+ entry['linked'] = device in known
+ 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
+ entry['ports'] = {port: m for (d, port), m in marks.items() if d in (name, device)}
entry['labels'] = labels.get(name, {})
- if name in switches:
- sw = switches[name]
- entry['switch'] = {'model': sw.get('model') or '', 'ports': sw.get('ports') or {},
- 'outlets': (unifi_info.get(name) or {}).get('outlets') or [],
- 'online': online.get(name, True)}
+ model = next((u.get('model') for u in snapshot.get('unifi') or [] if u.get('name') == device), '')
+ if device in switches or model:
+ sw = switches.get(device) or {}
+ entry['switch'] = {'model': sw.get('model') or model or '', 'ports': sw.get('ports') or {},
+ 'outlets': (unifi_info.get(device) or {}).get('outlets') or [],
+ 'online': online.get(device, True)}
+ if item['kind'] == 'shelf':
+ entry['contents'] = [
+ {'id': c['id'], 'name': c['name'], 'kind': c['kind'], 'url': url(c),
+ 'status': _device_status(dev(c), snapshot) if dev(c) in known else '',
+ 'model': next((u.get('model') for u in snapshot.get('unifi') or [] if u.get('name') == dev(c)), '')
+ or '', 'notes': c.get('notes') or ''}
+ for c in sorted(on_shelf.get(name, []), key=lambda c: c['name'].lower())
+ ]
out.append(entry)
return jsonify({
- 'units': units, 'items': out, 'overlaps': layout['overlaps'], 'outside': layout['outside'],
- 'unplaced': sorted(i['name'] for i in view['items'] if not i.get('rack_u')),
+ 'racks': racks, 'rack': current['name'], 'units': current['units'], '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'),
})
@@ -1151,7 +1228,7 @@ def api_cutsheet_delete_cable(cable_id: int):
@require_admin
@_cutsheet_write
def api_cutsheet_create_item():
- item = cutsheet.validate_item(_json_body())
+ item = cutsheet.validate_item(_json_body(), _cutsheet_items())
item_id = db.cutsheet_create_item(item, _get_user()['username'])
return jsonify({'success': True, 'id': item_id})
@@ -1161,7 +1238,7 @@ def api_cutsheet_create_item():
@require_admin
@_cutsheet_write
def api_cutsheet_update_item(item_id: int):
- item = cutsheet.validate_item(_json_body())
+ item = cutsheet.validate_item(_json_body(), _cutsheet_items())
if not db.cutsheet_update_item(item_id, item, _get_user()['username']):
return jsonify({'error': 'No such item'}), 404
return jsonify({'success': True})
diff --git a/cutsheet.py b/cutsheet.py
index e4df2fe..cd82cd8 100644
--- a/cutsheet.py
+++ b/cutsheet.py
@@ -32,6 +32,7 @@ NAME_MAX = 100
PORT_MAX = 64
TEXT_MAX = 255
NOTES_MAX = 2000
+RACK_NAME_MAX = 100
# Comparison outcomes, and whether each counts as a problem
VERDICTS = {
@@ -79,7 +80,8 @@ def _int(data: dict, key: str, low: int, high: int, label: str) -> Optional[int]
return number
-def validate_item(data: dict) -> dict:
+def validate_item(data: dict, items: Optional[Dict[str, dict]] = None) -> dict:
+ """An item from a form. `items` (by name) is needed to check a shelf placement."""
kind = _text(data, 'kind', 20) or 'other'
if kind not in ITEM_KINDS:
raise ValidationError(f'kind must be one of {", ".join(ITEM_KINDS)}')
@@ -90,8 +92,20 @@ def validate_item(data: dict) -> dict:
'location': _text(data, 'location'),
'rack_u': _int(data, 'rack_u', 1, 60, 'Rack unit'),
'rack_height': _int(data, 'rack_height', 1, 20, 'Height'),
+ 'rack': _text(data, 'rack', RACK_NAME_MAX),
+ 'device': _text(data, 'device'),
+ 'shelf': _text(data, 'shelf', NAME_MAX),
'notes': _text(data, 'notes', NOTES_MAX),
}
+ if item['shelf']:
+ shelf = (items or {}).get(item['shelf'])
+ if not shelf or shelf['kind'] != 'shelf':
+ raise ValidationError(f'{item["shelf"]} is not a shelf on the cutsheet')
+ if kind == 'shelf' or shelf['name'] == item['name']:
+ 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
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']:
@@ -259,6 +273,21 @@ def _same(a: str, b: str) -> bool:
return bool(a) and bool(b) and a.strip().lower() == b.strip().lower()
+def device_of(name: str, items: Dict[str, dict]) -> str:
+ """The live device an item name stands for: its linked device, or the name itself."""
+ return (items.get(name) or {}).get('device') or name
+
+
+def with_aliases(switches: dict, items: Dict[str, dict]) -> dict:
+ """Switches also reachable by the name of an item linked to them (item "UDM-Pro" -> "Dream Machine Pro")."""
+ out = dict(switches or {})
+ for name, item in (items or {}).items():
+ device = item.get('device')
+ if device and device in out and name not in out:
+ out[name] = out[device]
+ return out
+
+
def _check_end(node: tuple, other: Optional[tuple], switches: dict, items: Dict[str, dict]) -> Optional[dict]:
port = _switch_port(node, switches)
if port is None:
@@ -266,13 +295,13 @@ def _check_end(node: tuple, other: Optional[tuple], switches: dict, items: Dict[
if port == {}:
return {'verdict': 'missing_port', 'detail': f'{node[0]} reports no port {node[1]}'}
other_passive = other is None or (items.get(other[0], {}).get('kind') in PASSIVE_KINDS)
- expected = None if other_passive else other[0]
+ expected = None if other_passive else device_of(other[0], items)
lldp = port.get('lldp') or {}
seen = lldp.get('system_name') or ''
sfp_peer = (port.get('sfp') or {}).get('cable_peer')
if sfp_peer and other is not None and not other_passive:
- if _same(sfp_peer.get('switch', ''), other[0]) and str(sfp_peer.get('port_idx')) == other[1]:
+ if _same(sfp_peer.get('switch', ''), device_of(other[0], items)) and str(sfp_peer.get('port_idx')) == other[1]:
return {'verdict': 'match', 'detail': 'Both ends report the same DAC serial'}
if not port.get('up'):
if expected:
@@ -309,7 +338,7 @@ def compare_path(path: dict, cables_by_id: Dict[int, dict], switches: dict, item
def evaluate(cables: List[dict], items: Dict[str, dict], switches: dict) -> Dict[int, dict]:
"""Trace and compare every cable. Returns {cable_id: {'path', 'verdict', 'detail'}}."""
- switches = switches or {}
+ switches = with_aliases(switches, items)
index = node_index(cables)
by_id = {c['id']: c for c in cables}
result: Dict[int, dict] = {}
@@ -345,7 +374,7 @@ def describe_node(node: Optional[tuple]) -> str:
# ── Undocumented links ──────────────────────────────────────────────────────
-def undocumented(cables: List[dict], switches: dict) -> List[dict]:
+def undocumented(cables: List[dict], switches: dict, items: Optional[Dict[str, dict]] = None) -> List[dict]:
"""Linked switch ports that no live cable mentions, as suggestions to add."""
documented = set()
for c in cables:
@@ -354,7 +383,7 @@ def undocumented(cables: List[dict], switches: dict) -> List[dict]:
for end in ('a', 'b'):
node = _node(c, end)
if node:
- documented.add((node[0].lower(), node[1]))
+ documented.add((device_of(node[0], items or {}).lower(), node[1]))
suggestions = []
for sw_name, sw in sorted((switches or {}).items()):
for port_name, port in (sw.get('ports') or {}).items():
@@ -489,7 +518,7 @@ def runs_for(device: str, cables: List[dict], items: Dict[str, dict], switches:
if not _live(c):
continue
for end in ('a', 'b'):
- if not _same(c.get(f'{end}_device') or '', device):
+ if not _same(device_of(c.get(f'{end}_device') or '', items), device):
continue
if port is not None and str(c.get(f'{end}_port') or '') != str(port):
continue
diff --git a/db.py b/db.py
index ff06e20..375b5a2 100644
--- a/db.py
+++ b/db.py
@@ -674,6 +674,9 @@ _CUTSHEET_SCHEMA = (
location VARCHAR(255) NOT NULL DEFAULT '',
rack_u SMALLINT NULL,
rack_height SMALLINT NULL,
+ rack VARCHAR(100) NOT NULL DEFAULT '',
+ shelf VARCHAR(100) NOT NULL DEFAULT '',
+ device VARCHAR(255) NOT NULL DEFAULT '',
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -754,7 +757,15 @@ def set_device_link(name: str, inventory_url: str, user: str) -> None:
(name, inventory_url, user),
)
-_ITEM_FIELDS = ('name', 'kind', 'port_count', 'location', 'rack_u', 'rack_height', 'notes')
+_ITEM_FIELDS = ('name', 'kind', 'port_count', 'location', 'rack_u', 'rack_height', 'rack', 'shelf', 'device',
+ '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 ''"),
+)
_CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes')
@@ -768,6 +779,12 @@ def ensure_cutsheet_schema() -> None:
with conn.cursor() as cur:
for statement in _CUTSHEET_SCHEMA:
cur.execute(statement)
+ cur.execute('SHOW COLUMNS FROM cutsheet_items')
+ have = {r['Field'] for r in cur.fetchall()}
+ for column, definition in _ITEM_COLUMNS_ADDED:
+ if column not in have:
+ # Fixed names from _ITEM_COLUMNS_ADDED, never user input
+ cur.execute(f'ALTER TABLE cutsheet_items ADD COLUMN {column} {definition}') # nosec B608
def _cs_jsonable(row: Optional[dict]) -> Optional[dict]:
@@ -876,17 +893,27 @@ def cutsheet_update_item(item_id: int, item: dict, user: str) -> bool:
tuple(item[f] for f in _ITEM_FIELDS) + (item_id,),
)
summary = f'Edited {item["name"]}'
+ if before['kind'] == 'shelf' and item['kind'] != 'shelf':
+ on_it = _cs_on_shelf(cur, before['name'])
+ if on_it:
+ raise CutsheetConflict(f'{before["name"]} still holds {on_it} item(s); move them off the shelf first')
if before['name'] != item['name']:
for end in ('a', 'b'):
cur.execute(
f'UPDATE cutsheet_cables SET {end}_device=%s, updated_by=%s WHERE {end}_device=%s', # nosec B608
(item['name'], user, before['name']),
)
+ cur.execute('UPDATE cutsheet_items SET shelf=%s WHERE shelf=%s', (item['name'], before['name']))
summary = f'Renamed {before["name"]} to {item["name"]}'
_cs_log(cur, user, 'item', item_id, 'update', summary, before, item)
return True
+def _cs_on_shelf(cur, shelf: str) -> int:
+ cur.execute('SELECT COUNT(*) AS n FROM cutsheet_items WHERE shelf=%s', (shelf,))
+ return cur.fetchone()['n']
+
+
def cutsheet_delete_item(item_id: int, user: str) -> bool:
with _cs_transaction() as cur:
before = _cs_get(cur, 'cutsheet_items', item_id)
@@ -899,6 +926,9 @@ def cutsheet_delete_item(item_id: int, user: str) -> bool:
used = cur.fetchone()['n']
if used:
raise CutsheetConflict(f'{before["name"]} still has {used} cable(s); move or delete them first')
+ on_it = _cs_on_shelf(cur, before['name'])
+ if on_it:
+ raise CutsheetConflict(f'{before["name"]} still holds {on_it} item(s); move them off the shelf first')
cur.execute('DELETE FROM cutsheet_port_labels WHERE item_id=%s', (item_id,))
cur.execute('DELETE FROM cutsheet_items WHERE id=%s', (item_id,))
_cs_log(cur, user, 'item', item_id, 'delete', f'Removed {before["name"]}', before, None)
diff --git a/static/cutsheet.js b/static/cutsheet.js
index a16daf8..5d6a032 100644
--- a/static/cutsheet.js
+++ b/static/cutsheet.js
@@ -257,6 +257,13 @@ 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)` : ''}`;
+ return (_cs.racks || []).length > 1 ? `${where} · ${esc(i.rack || _cs.racks[0])}` : where;
+}
+
function renderItems() {
const items = [..._cs.items].sort((a, b) => natural(a.kind, b.kind) || natural(a.name, b.name));
document.getElementById('cs-item-count').textContent = items.length;
@@ -275,7 +282,7 @@ function renderItems() {