Rack: link items to devices, shelves, several racks, export; fix layout and light mode
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 48s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 49s
Lint / Python (flake8) (pull_request) Successful in 51s
Test / Python Tests (pytest) (push) Successful in 1m2s
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 50s
Test / Python Tests (pytest) (pull_request) Successful in 53s

Cutsheet items gain a "Gandalf device" link, so an item named after the
product (UDM-Pro) shows the live device's faceplate, ports and status.
Cable checks, suggestions, tickets and the device page treat an item and
its linked device as one. Without this, product-named items drew as bare
name plates.

Items can sit on a shelf instead of a rack unit; the shelf draws what is
on it with live status. Items have a rack, the page shows a tab per rack
(config "racks"), and a rack can be downloaded as SVG or PNG.

The rack is centred, long runs of empty units fold into one band (with a
toggle), every device fills the 19" width, the UPS shows its front only,
and the rack keeps its own palette so it reads in light mode.

New cutsheet_items columns rack, shelf and device are added on first use.
Deleting a shelf, or making it something else, is refused while it holds
items; renaming it carries over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 21:44:59 -05:00
co-authored by Claude Opus 5.5
parent 4f353888f8
commit bdd68a397d
12 changed files with 540 additions and 64 deletions
+20 -7
View File
@@ -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)
+99 -22
View File
@@ -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})
+36 -7
View File
@@ -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
+31 -1
View File
@@ -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)
+16 -3
View File
@@ -257,6 +257,13 @@ function renderRack() {
</div>`;
}
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() {
<td>${deviceLink(i.name)}</td>
<td>${esc(KIND_TEXT[i.kind] || i.kind)}</td>
<td>${esc(i.port_count || '–')}</td>
<td>${i.rack_u ? `U${esc(i.rack_u)}${i.rack_height > 1 ? ` (${esc(i.rack_height)}U)` : ''}` : '–'}</td>
<td>${rackText(i)}</td>
<td>${esc(i.location || '–')}</td>
<td>${esc(i.notes || '')}</td>
${CS_CAN_EDIT ? `<td class="no-print"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-item" data-id="${esc(i.id)}" aria-label="Edit ${esc(i.name)}">Edit</button></td>` : ''}
@@ -382,7 +389,12 @@ function openItem(item) {
const form = document.getElementById('cs-item-form');
form.reset();
const data = Object.assign({ kind: 'patch_panel' }, item || {});
for (const key of ['id', 'name', 'kind', 'port_count', 'rack_u', 'rack_height', 'location', 'notes']) {
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']) {
form[key].value = data[key] == null ? '' : data[key];
}
if (!item) form.port_count.value = 24;
@@ -435,7 +447,8 @@ async function saveCable(e) {
async function saveItem(e) {
e.preventDefault();
const form = e.target;
const body = formData(form, ['name', 'kind', 'port_count', 'rack_u', 'rack_height', 'location', 'notes']);
const body = formData(form, ['name', 'device', 'kind', 'port_count', 'rack_u', 'rack_height', 'rack', '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');
+4 -2
View File
@@ -413,6 +413,7 @@ const FACEPLATES = {
// The outlets are on the rear, and the touchscreen shows a tile for each one.
USPDA2B: {
width: 1086, height: 460, frameClass: 'fp-scroll fp-stack', scrollX: 340, noBody: true,
rackView: [0, 0, 1086, 210], // the front only, for the rack drawing
label: 'UPS 2U Pro',
legend: [['outlet-on', 'outlet on'], ['outlet-off', 'outlet off'], ['blue', 'online']],
ports: [],
@@ -495,7 +496,8 @@ function faceplateLegendHtml(spec, marks) {
</div>`;
}
// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role), cutsheetFor?(idx) -> {mark, text} }
// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role), cutsheetFor?(idx) -> {mark, text},
// rack? (draw only the front, spec.rackView, as mounted in a rack) }
function renderFaceplate(model, ctx) {
const spec = FACEPLATES[model];
const anyPort = Object.values(ctx.portMap)[0];
@@ -516,7 +518,7 @@ function renderFaceplate(model, ctx) {
: `<rect class="fp-body" width="${spec.width}" height="${spec.height}" rx="3"/>`;
return `<div class="fp-wrap">
<div class="fp-frame ${spec.frameClass}">
<svg class="fp-svg" viewBox="0 0 ${spec.width} ${spec.height}" role="group" aria-label="${lt.escHtml(spec.label)} front panel">
<svg class="fp-svg" viewBox="${((ctx.rack && spec.rackView) || [0, 0, spec.width, spec.height]).join(' ')}" role="group" aria-label="${lt.escHtml(spec.label)} front panel">
<defs>
<linearGradient id="fp-metal" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e4e4e6"/><stop offset=".08" stop-color="#d6d6d8"/>
+200 -6
View File
@@ -41,7 +41,7 @@ function isRackFaceplate(model) {
function switchHtml(item) {
const sw = item.switch;
return renderFaceplate(sw.model, {
swName: item.name, portMap: portMapOf(sw.ports), outlets: sw.outlets, online: sw.online,
swName: item.device, portMap: portMapOf(sw.ports), outlets: sw.outlets, online: sw.online, rack: true,
titleFor: (port, idx) => portTitle(port, idx),
cutsheetFor: idx => {
const m = item.ports[String(idx)];
@@ -86,10 +86,30 @@ function panelSvg(item) {
</svg>`;
}
const STATUS_CLASS = { up: 'dot-up', down: 'dot-down', degraded: 'dot-degraded' };
function statusDot(status) {
return status ? `<span class="${STATUS_CLASS[status] || 'dot-unknown'}" title="${esc(status)}"></span>` : '';
}
function shelfHtml(item) {
const contents = item.contents || [];
const things = contents.map(c => `
<a class="rack-shelf-thing" href="${esc(c.url)}" title="${esc([c.name, c.model, c.status].filter(Boolean).join(' · '))}">
${statusDot(c.status)}<span class="rack-shelf-name">${esc(c.name)}</span>
</a>`).join('');
return `<div class="rack-plate rack-shelf">
<span class="rack-shelf-label">${esc(item.name)}</span>
<div class="rack-shelf-things">${things || '<span class="rack-plate-sub">empty shelf</span>'}</div>
</div>`;
}
function plateHtml(item) {
const sub = [KIND_TEXT[item.kind] || item.kind, item.location].filter(Boolean).join(' · ');
const unlinked = item.kind === 'device' && !item.linked;
const sub = [item.device !== item.name ? item.device : '', KIND_TEXT[item.kind] || item.kind, item.location,
unlinked ? 'not linked to a Gandalf device' : ''].filter(Boolean).join(' · ');
return `<div class="rack-plate">
<a class="device-link rack-plate-name" href="${esc(item.url)}">${esc(item.name)}</a>
${statusDot(item.status)}<a class="device-link rack-plate-name" href="${esc(item.url)}">${esc(item.name)}</a>
<span class="rack-plate-sub">${esc(sub)}</span>
</div>`;
}
@@ -98,6 +118,7 @@ function itemHtml(item) {
let body;
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);
return `<div class="rack-item rack-kind-${esc(item.kind)}" data-item="${esc(item.id)}">${body}</div>`;
}
@@ -108,6 +129,21 @@ function uLabels(top, bottom) {
return `<div class="rack-u" aria-hidden="true">${spans.join('')}</div>`;
}
// Long runs of empty units are folded into one thin band unless the viewer asks for them
const GAP_MIN = 3;
function showEmpty() {
try { return localStorage.getItem('gandalf.rackShowEmpty') === '1'; } catch (_) { return false; }
}
function gapRow(top, bottom) {
const n = top - bottom + 1;
return `<div class="rack-row rack-row-gap" data-gap="${n}" style="--n:${n}">
<div class="rack-u" aria-hidden="true"></div>
<div class="rack-gap">U${bottom}–${top} · ${n}U free</div>
</div>`;
}
function row(top, bottom, inner, cls) {
const n = top - bottom + 1;
return `<div class="rack-row ${cls}" style="--n:${n}">
@@ -117,8 +153,20 @@ function row(top, bottom, inner, cls) {
}
// ── Page ────────────────────────────────────────────────────────────────
function renderTabs(data) {
const tabs = document.getElementById('rack-tabs');
tabs.hidden = data.racks.length < 2;
tabs.innerHTML = data.racks.map(r => {
const on = r.name === data.rack;
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';
}
function render(data) {
_rack = data;
renderTabs(data);
const byTop = Object.fromEntries(data.items.map(i => [i.rack_top, i]));
const rows = [];
let u = data.units;
@@ -131,15 +179,18 @@ function render(data) {
}
let low = u;
while (low - 1 >= 1 && !byTop[low - 1]) low--;
rows.push(row(u, low, '', 'rack-row-empty'));
rows.push(!showEmpty() && u - low + 1 >= GAP_MIN ? gapRow(u, low) : row(u, low, '', 'rack-row-empty'));
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'));
document.getElementById('rack-show-empty').checked = showEmpty();
document.getElementById('rack-count').textContent = data.items.length;
// Faceplate ports carry their cable run too, for hover and click
document.querySelectorAll('.rack-item .fp-port').forEach(g => {
const item = data.items.find(i => i.name === g.dataset.switch);
const item = data.items.find(i => i.device === g.dataset.switch);
const m = item && item.ports[g.dataset.portIdx];
if (m) {
g.dataset.run = m.run.join(' ');
@@ -228,9 +279,23 @@ rackEl.addEventListener('keydown', e => {
}
});
function currentRack() {
return new URLSearchParams(location.search).get('rack') || '';
}
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();
});
async function load() {
try {
render(await lt.api.get('/api/rack'));
const rack = currentRack();
render(await lt.api.get(`/api/rack${rack ? `?rack=${encodeURIComponent(rack)}` : ''}`));
} 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">
@@ -242,3 +307,132 @@ async function load() {
readout(null);
load();
setInterval(() => { if (!document.hidden && !document.querySelector('#rack .rack-hl')) load(); }, 60000);
// ── Export (SVG / PNG) ──────────────────────────────────────────────────
// Rebuilds the drawing as one standalone SVG on a white page, for printing or planning.
// Faceplates and panels are copied from the page with their computed styles inlined,
// since the stylesheet doesn't travel with the file.
const SVG_NS = 'http://www.w3.org/2000/svg';
const STYLE_PROPS = ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray',
'stroke-linecap', 'stroke-linejoin', 'opacity', 'font-size', 'font-family', 'font-weight', 'letter-spacing',
'text-anchor', 'dominant-baseline', 'display', 'visibility'];
function inlineStyles(src, dst) {
const cs = getComputedStyle(src);
dst.setAttribute('style', STYLE_PROPS.map(p => `${p}:${cs.getPropertyValue(p)}`).join(';'));
dst.removeAttribute('class');
for (let i = 0; i < src.children.length; i++) inlineStyles(src.children[i], dst.children[i]);
}
function svgEl(tag, attrs, text) {
const el = document.createElementNS(SVG_NS, tag);
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
if (text != null) el.textContent = text;
return el;
}
function buildExport() {
document.querySelectorAll('#rack .rack-hl').forEach(el => el.classList.remove('rack-hl'));
const W = 1100, gutter = 46, pad = 20, head = 44, unit = W / RATIO;
const rows = [...document.querySelectorAll('#rack .rack-row')];
const height = rows.reduce((h, r) => h + (r.dataset.gap ? 26 : parseInt(r.style.getPropertyValue('--n'), 10) * unit), 0);
const H = head + height + pad * 2;
const root = svgEl('svg', { xmlns: SVG_NS, width: W + gutter + pad * 2, height: H,
viewBox: `0 0 ${W + gutter + pad * 2} ${H}`, 'font-family': 'Helvetica, Arial, sans-serif' });
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`));
root.appendChild(svgEl('text', { x: W + gutter + pad, y: pad + 14, 'font-size': 11, fill: '#666', 'text-anchor': 'end' },
`Gandalf · ${stamp}`));
let y = head + pad;
const x = pad + gutter;
rows.forEach(row => {
const n = parseInt(row.style.getPropertyValue('--n'), 10);
if (row.dataset.gap) {
const band = 26;
root.appendChild(svgEl('rect', { x, y: y + 2, width: W, height: band - 4, fill: '#f4f5f7', stroke: '#d5d9de',
'stroke-dasharray': '4 4' }));
root.appendChild(svgEl('text', { x: x + W / 2, y: y + band / 2 + 4, 'font-size': 11, fill: '#777',
'text-anchor': 'middle' }, row.textContent.trim()));
y += band;
return;
}
const h = n * unit;
[...row.querySelectorAll('.rack-u span')].forEach((s, i) => {
root.appendChild(svgEl('text', { x: x - 8, y: y + unit * i + unit / 2 + 4, 'font-size': 11, fill: '#777',
'text-anchor': 'end' }, s.textContent));
});
const art = row.querySelector('svg.fp-svg, svg.rack-svg');
const plate = row.querySelector('.rack-plate');
if (art) {
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.removeAttribute('style');
root.appendChild(clone);
} else if (plate) {
root.appendChild(svgEl('rect', { x, y: y + 1, width: W, height: h - 2, rx: 3, fill: '#e9ecef', stroke: '#9aa3ad' }));
const name = plate.querySelector('.rack-plate-name, .rack-shelf-label');
const things = [...plate.querySelectorAll('.rack-shelf-name')].map(t => t.textContent);
const sub = things.length ? things.join(' · ') : (plate.querySelector('.rack-plate-sub') || {}).textContent || '';
root.appendChild(svgEl('text', { x: x + 16, y: y + h / 2 + (sub ? -2 : 5), 'font-size': 15, 'font-weight': 700,
fill: '#111' }, name ? name.textContent : ''));
if (sub) root.appendChild(svgEl('text', { x: x + 16, y: y + h / 2 + 15, 'font-size': 11, fill: '#444' }, sub));
} else {
root.appendChild(svgEl('rect', { x, y: y + 1, width: W, height: h - 2, fill: 'none', stroke: '#d5d9de',
'stroke-dasharray': '4 4' }));
}
y += h;
});
return new XMLSerializer().serializeToString(root);
}
function download(blob, name) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
}
async function exportRack(kind) {
if (!_rack || !_rack.items.length) {
lt.toast.error('Nothing in this rack to export yet');
return;
}
const svg = buildExport();
const base = `${_rack.rack.replace(/[^\w.-]+/g, '-')}-rack`;
if (kind === 'svg') {
download(new Blob([svg], { type: 'image/svg+xml' }), `${base}.svg`);
return;
}
const img = new Image();
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
try {
await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; });
const scale = 2;
const canvas = document.createElement('canvas');
canvas.width = img.width * scale;
canvas.height = img.height * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
canvas.toBlob(blob => download(blob, `${base}.png`), 'image/png');
} catch (err) {
lt.toast.error('Could not render the PNG; try the SVG');
} finally {
URL.revokeObjectURL(url);
}
}
document.getElementById('rack-show-empty').addEventListener('change', e => {
try { localStorage.setItem('gandalf.rackShowEmpty', e.target.checked ? '1' : '0'); } catch (_) { /* private mode */ }
if (_rack) render(_rack);
});
document.querySelectorAll('[data-export]').forEach(btn => btn.addEventListener('click', () => exportRack(btn.dataset.export)));
+40 -11
View File
@@ -1433,15 +1433,23 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
.fp-mark-toggle input { margin: 0; }
/* ── Rack (/rack) ──────────────────────────────────────────────────── */
.rack { max-width: 1000px; display: flex; flex-direction: column; gap: 2px; padding: 10px 12px 10px 0;
background: #0b0f14; border: 1px solid var(--border-color); border-radius: 4px; }
/* The rack is hardware: it keeps its own palette in both themes so labels inside it stay readable */
.rack {
--rk-frame: #0b0f14; --rk-edge: #2a3440; --rk-text: #e6edf3; --rk-muted: #8b97a5; --rk-line: rgba(255,255,255,.07);
--rk-plate-a: #232a33; --rk-plate-b: #1a2028; --rk-plate-edge: #2d3743; --rk-link: #6fd3ff;
max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; gap: 2px; padding: 10px 12px 10px 0;
background: var(--rk-frame); border: 1px solid var(--rk-edge); border-radius: 4px; color: var(--rk-text); }
[data-theme="light"] .rack {
--rk-frame: #2b3139; --rk-edge: #1d2228; --rk-muted: #a9b4c0; --rk-line: rgba(255,255,255,.10);
--rk-plate-a: #3a424d; --rk-plate-b: #313841; --rk-plate-edge: #4a5461; --rk-link: #8fdcff;
box-shadow: 0 6px 18px rgba(20,30,45,.25); }
.rack-row { display: grid; grid-template-columns: 34px 1fr; }
.rack-u { display: flex; flex-direction: column; font-size: .62rem; color: var(--text-muted); text-align: right;
.rack-u { display: flex; flex-direction: column; font-size: .62rem; color: var(--rk-muted); text-align: right;
padding-right: 6px; font-family: var(--font-mono, monospace); }
.rack-u span { flex: 1; display: flex; align-items: center; justify-content: flex-end; }
.rack-slot { position: relative; min-width: 0; }
.rack-row-empty .rack-slot { background: repeating-linear-gradient(to bottom, transparent 0 calc(100% / var(--n) - 1px),
rgba(255,255,255,.05) calc(100% / var(--n) - 1px) calc(100% / var(--n))); border: 1px dashed rgba(255,255,255,.06); }
var(--rk-line) calc(100% / var(--n) - 1px) calc(100% / var(--n))); border: 1px dashed var(--rk-line); }
.rack-item, .rack-item .fp-wrap, .rack-item .fp-frame { height: 100%; }
.rack-item .fp-wrap { padding: 0; }
.rack-item .fp-frame { box-shadow: none; overflow: hidden; }
@@ -1449,26 +1457,29 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
.rack-item .chassis-legend { display: none; }
.rack-svg { display: block; width: 100%; height: 100%; }
.rack-plate { height: 100%; display: flex; align-items: center; gap: 14px; padding: 0 18px; border-radius: 3px;
background: linear-gradient(#232a33, #1a2028); border: 1px solid #2d3743; }
.rack-plate-name { font-weight: 700; }
.rack-plate-sub { font-size: .78rem; color: var(--text-muted); }
.rack-kind-shelf .rack-plate { background: repeating-linear-gradient(90deg, #1b2129 0 10px, #161b22 10px 20px); }
background: linear-gradient(var(--rk-plate-a), var(--rk-plate-b)); border: 1px solid var(--rk-plate-edge); }
.rack .rack-plate-name { font-weight: 700; color: var(--rk-text); text-decoration-color: var(--rk-muted); }
.rack .rack-plate-name:hover, .rack .rack-plate-name:focus-visible { color: var(--rk-link); }
.rack-plate-sub { font-size: .78rem; color: var(--rk-muted); }
.rack-kind-shelf .rack-plate { background: repeating-linear-gradient(90deg, var(--rk-plate-b) 0 10px, var(--rk-frame) 10px 20px); }
.rack-pp-body { fill: #1d2229; stroke: #39424e; }
.rack-pp-name { fill: #9aa7b4; font-size: 15px; font-family: var(--font-mono, monospace); }
.rack-pp-num { fill: #7d8a97; font-size: 10px; }
.rack-pp-label { fill: #c9d3dc; font-size: 9px; }
.rack-pp-jack { fill: #0c0f13; stroke: #4a5560; stroke-width: 1.5; }
.rack-pp-ok .rack-pp-jack { stroke: var(--accent-green); }
.rack-pp-ok .rack-pp-jack { stroke: #00e07a; }
.rack-pp-bad .rack-pp-jack { stroke: var(--status-critical, #e0312b); stroke-width: 2.5; }
.rack-pp-doc .rack-pp-jack { stroke: #9aa7b4; }
.rack-pp-hit { fill: transparent; }
.rack-pp-port[data-cable] { cursor: pointer; outline: none; }
/* A hovered run: every port on it */
.rack-pp-port.rack-hl .rack-pp-jack { fill: rgba(0,212,255,.35); stroke: var(--accent-cyan); stroke-width: 3; }
.rack .fp-port.rack-hl .fp-hit { stroke: var(--accent-cyan); stroke-width: 4; fill: rgba(0,212,255,.3); }
.rack-pp-port.rack-hl .rack-pp-jack { fill: rgba(0,212,255,.35); stroke: #00d4ff; stroke-width: 3; }
.rack .fp-port.rack-hl .fp-hit { stroke: #00b8e6; stroke-width: 4; fill: rgba(0,212,255,.3); }
.rack-readout, .rack-legend, .rack-unplaced, .rack-tabs, .rack-warnings { max-width: 1000px;
margin-left: auto !important; margin-right: auto !important; }
.rack-readout { min-height: 1.4em; margin: 0 0 8px; font-size: .85rem; color: var(--accent-cyan); }
.rack-legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: .75rem; color: var(--text-muted); margin-bottom: 8px; }
.rack-key { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; vertical-align: -1px; }
@@ -1480,9 +1491,27 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
.rack-warnings p { margin: 0; }
.rack-unplaced { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.rack-empty { margin: 12px; }
.rack-gap { height: 26px; display: flex; align-items: center; justify-content: center; font-size: .72rem;
color: var(--rk-muted); border: 1px dashed var(--rk-line); letter-spacing: .04em; }
.rack-toggle { margin-left: auto; display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
.rack-legend { align-items: center; }
@media (max-width: 600px) {
.rack-row { grid-template-columns: 22px 1fr; }
.rack-u { font-size: .5rem; padding-right: 3px; }
.rack-plate { padding: 0 8px; gap: 8px; }
.rack-plate-sub { display: none; }
}
.rack-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.rack-tab { background: none; border: 1px solid var(--border-color); color: inherit; font: inherit; font-size: .85rem;
padding: 6px 12px; cursor: pointer; border-radius: 3px; }
.rack-tab.active { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); }
.rack-tab-count { color: var(--text-muted); font-size: .75rem; margin-left: 4px; }
.rack-shelf { gap: 10px; padding: 0 10px; container-type: size; }
.rack-shelf-label { font-size: .72rem; color: var(--rk-muted); white-space: nowrap; writing-mode: horizontal-tb; }
.rack-shelf-things { display: flex; gap: 6px; flex: 1; min-width: 0; height: 78%; }
.rack-shelf-thing { flex: 1 1 0; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 0 6px; border-radius: 3px; background: linear-gradient(#e6e8eb, #c9cdd2); color: #15191e;
text-decoration: none; font-size: .78rem; font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.5); }
.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; } }
+19 -1
View File
@@ -229,7 +229,13 @@
<div class="lt-form-group">
<label class="lt-label" for="cs-item-name">Name <span class="required">*</span></label>
<input class="lt-input" id="cs-item-name" name="name" required maxlength="100" list="cs-device-list">
<div class="lt-field-hint">For a device's rack position, use its exact Gandalf name.</div>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-item-device">Gandalf device</label>
<input class="lt-input" id="cs-item-device" name="device" maxlength="255" list="cs-known-devices"
placeholder="same as the name">
<datalist id="cs-known-devices"></datalist>
<div class="lt-field-hint">Which live device this is, when the name above is different (e.g. name "UDM-Pro", device "Dream Machine Pro"). Its faceplate, ports and status then show on the rack.</div>
</div>
<div class="form-row">
<div class="lt-form-group">
@@ -257,6 +263,18 @@
<input class="lt-input" id="cs-item-h" name="rack_height" type="number" min="1" max="20">
</div>
</div>
<div class="form-row">
<div class="lt-form-group">
<label class="lt-label" for="cs-item-rack">Rack</label>
<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-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-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">
+8 -3
View File
@@ -7,18 +7,22 @@
<h1 class="lt-page-title">Rack</h1>
<p class="g-page-sub">The rack front, drawn from the rack units on the cutsheet with live port state. Hover a port to light up every port on the same cable run; click it to open the port or the cable.</p>
</div>
{% if can_edit %}
<div class="cs-actions no-print">
<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 %}
<a class="lt-btn lt-btn-secondary lt-btn-sm" href="/cutsheet#cs-items-section">Edit positions</a>
{% endif %}
</div>
{% endif %}
</div>
<div class="rack-tabs" id="rack-tabs" role="tablist" aria-label="Racks" hidden></div>
<div class="rack-warnings" id="rack-warnings" role="alert" hidden></div>
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Front</h2>
<h2 class="g-section-title" id="rack-title">Front</h2>
<span class="g-section-badge" id="rack-count">0</span>
</div>
<p class="rack-readout" id="rack-readout" aria-live="polite"></p>
@@ -26,6 +30,7 @@
<span><i class="rack-key rack-key-ok"></i>matches cutsheet</span>
<span><i class="rack-key rack-key-bad"></i>mismatch</span>
<span><i class="rack-key rack-key-doc"></i>documented, unconfirmed</span>
<label class="rack-toggle"><input type="checkbox" id="rack-show-empty"> Show empty units</label>
</div>
<div class="rack" id="rack"><div class="link-loading">Loading rack</div></div>
<p class="cs-hint rack-unplaced" id="rack-unplaced"></p>
+42 -1
View File
@@ -456,7 +456,7 @@ class TestRackApi:
pp, sw = data['items']
assert pp['labels'] == {'5': 'OFFICE'} and pp['url'] == '/cutsheet#item-2'
assert pp['ports']['5']['run'] == [7] and pp['ports']['5']['cable_id'] == 7
assert sw['url'] == '/device/USW%20Flex' and 'switch' not in sw # no port data in this snapshot
assert sw['url'] == '/device/USW%20Flex' and sw['switch']['model'] == 'USF5P' # model from UniFi
assert sw['ports']['2']['mark'] in ('ok', 'bad', 'doc')
def test_rack_page_and_auth(self, client):
@@ -468,3 +468,44 @@ class TestRackApi:
gandalf.db.cutsheet_load.return_value = {'items': [], 'cables': [], 'port_labels': []}
with patch.object(gandalf, '_cfg', dict(CONFIG, rack={'units': 24})):
assert client.get('/api/rack', headers=AUTH).get_json()['units'] == 24
class TestRackRacksShelvesLinks:
def _items(self, *items):
base = {'port_count': None, 'rack_u': None, 'rack_height': None, 'rack': '', 'shelf': '', 'device': '',
'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_product_named_item_links_to_its_device(self, client):
self._items({'name': 'Flex (desk)', 'kind': 'device', 'rack_u': 5, 'rack_height': 1, 'device': 'USW Flex'})
item = client.get('/api/rack', headers=AUTH).get_json()['items'][0]
assert item['device'] == 'USW Flex' and item['linked'] and item['url'] == '/device/USW%20Flex'
assert item['status'] == 'up' and item['switch']['model'] == 'USF5P'
def test_unlinked_item(self, client):
self._items({'name': 'UNVR-Pro', 'kind': 'device', 'rack_u': 5, 'rack_height': 2})
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_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'},
{'name': 'Mini', 'kind': 'device', 'shelf': 'Shelf', 'device': 'USW Flex'})
data = client.get('/api/rack', headers=AUTH).get_json()
assert [i['name'] for i in data['items']] == ['Shelf'] and data['unplaced'] == []
assert [(c['name'], c['status']) for c in data['items'][0]['contents']] == [('large1', 'up'), ('Mini', 'up')]
def test_racks_tabs_and_selection(self, client):
self._items({'name': 'A', 'kind': 'other', 'rack_u': 1, 'rack_height': 1},
{'name': 'B', 'kind': 'other', 'rack_u': 1, 'rack_height': 1, 'rack': 'Garage'})
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 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
+25
View File
@@ -296,3 +296,28 @@ class TestPortMarks:
dict(cable(2, ('PP1', '1', 'rear'), ('jack', '1')), verdict='idle', detail='Idle', run=[1, 2]),
]
assert cs.port_marks(cables)[('PP1', '1')]['text'] == 'cables C1, #2: Idle'
class TestDeviceAliases:
ITEMS = {'UDM-Pro': {'name': 'UDM-Pro', 'kind': 'device', 'device': 'udm'},
'Big box': {'name': 'Big box', 'kind': 'device', 'device': 'large1'}}
def test_cable_on_an_item_is_checked_against_its_device(self):
switches = {'udm': switch([port(3, seen='large1')])['sw']}
c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0'))
assert cs.evaluate([c], self.ITEMS, switches)[1]['verdict'] == 'match'
def test_wrong_neighbor_still_caught_through_an_alias(self):
switches = {'udm': switch([port(3, seen='printer')])['sw']}
c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0'))
assert cs.evaluate([c], self.ITEMS, switches)[1]['verdict'] == 'wrong_neighbor'
def test_aliased_cable_counts_as_documented(self):
switches = {'udm': switch([port(3, seen='large1')])['sw']}
c = cable(1, ('UDM-Pro', '3'), ('Big box', 'eth0'))
assert cs.undocumented([c], switches, self.ITEMS) == []
assert len(cs.undocumented([c], switches)) == 1
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']