Merge pull request 'Rack: device links, shelves, multiple racks, export; layout and light-mode fixes' (#24) from racks-shelves-export into main
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 54s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 55s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 1m5s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 54s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 55s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 1m5s
This commit was merged in pull request #24.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user