Add cutsheets: planned cabling checked against discovery
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 1m36s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m40s
Lint / Python (flake8) (pull_request) Successful in 52s
Test / Python Tests (pytest) (push) Successful in 1m4s
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 1m13s
Test / Python Tests (pytest) (pull_request) Successful in 1m15s

A new Cutsheet page records where every cable is meant to go and compares
it with what Gandalf discovers.

- Inventory for what discovery can't see: patch panels, wall jacks,
  shelves and rack positions. Panels and jacks pass through front to rear.
- Cables with two endpoints, ID, type, length, color, status (planned,
  installed, dark, retired) and notes; the far end may be unterminated.
- Each cable's full run is traced through panels and jacks, and runs
  ending on a switch port are checked against its discovered neighbor
  (LLDP, client table, or a DAC serial seen at both ends): matches,
  wrong neighbor, no link, missing port, port conflict, unverified, idle.
- Undocumented links: live switch ports no cable mentions, one click to
  add with what Gandalf already knows. Port panels show the planned cable.
- Labels to make: cable IDs and panel-port labels not yet applied, with a
  print view, for a handheld label maker.
- CSV export, and all-or-nothing import that upserts by cable ID.
- Cables, panels and jacks appear in the dashboard search.

Editing needs the admin group and a JSON request body. Every change is
written to cutsheet_history in the same transaction. Tables are created
on first use.

Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 16:05:08 -05:00
co-authored by Claude Opus 5.5
parent afc7aae580
commit b9412ff6ba
13 changed files with 2468 additions and 5 deletions
+31
View File
@@ -189,6 +189,31 @@ neighbors that can't be matched show their MAC. Bonded NICs share a MAC, so only
Port LEDs use the colors from each model's quick start guide. Other models use plain blocks until they get an
entry in `FACEPLATES`.
### Cutsheet (`/cutsheet`)
Where every cable is meant to go, checked against what Gandalf actually sees. Anyone who can log in
can view it; only the `admin` group can edit, and every change is logged with who made it and when.
- **Inventory**: things discovery can't see: patch panels, wall jacks, shelves, and rack positions
for devices (use a device's exact Gandalf name). Panels and jacks are passive: a cable on a
port's front continues through the same port's rear.
- **Cables**: two endpoints each (device or item, port, and front/rear for panels and jacks), plus
ID, type, length, color, status (`planned`, `installed`, `dark`, `retired`) and notes. Leave the
far end blank for a run that ends nowhere yet, such as an unterminated fibre.
- **Paths**: each cable shows its full run through any panels and jacks, e.g. switch port 13 →
panel port 5 → bedroom jack.
- **Plan vs. actual**: each installed run ending on a switch or router port is compared with that
port's discovered neighbor (LLDP, the UniFi client table, or a DAC's serial reported at both
ends): *matches*, *wrong neighbor*, *no link*, *missing port*, or *port conflict* (two installed
cables in one port). Runs that end at a jack are *unverified* while linked and *idle* when not.
Planned, dark and retired cables aren't checked.
- **Undocumented links**: live switch ports no cable mentions, with an Add button that pre-fills
what Gandalf knows. Each switch port panel also shows its planned cable, or links to add one.
- **Labels to make**: cable IDs (one per cord end) and panel-port labels not yet stuck on, sized for
typing on a handheld label maker. Tick each off once it's applied; **Print list** prints just this.
- **CSV**: export, and import that adds or updates cables by ID. A file with any invalid row imports
nothing.
- Cables, panels and jacks are included in the dashboard search.
### Suppressions (`/suppressions`)
- Create timed (30 min / 1 hr / 4 hr / 8 hr) or manual suppressions
- Target types: host, interface, UniFi device, or global
@@ -528,6 +553,7 @@ gandalf/
├── monitor.py # Background daemon (Prometheus, UniFi, Pulse, alert logic)
├── modem.py # Read-only AT&T gateway status page client and parser
├── devices.py # Device inventory for search and device pages
├── cutsheet.py # Cutsheet validation, path tracing, plan-vs-actual checks, CSV
├── db.py # Database operations (MariaDB via pymysql, thread-local conn reuse)
├── schema.sql # Database schema (network_events, suppression_rules, monitor_state)
├── config.json # Runtime configuration (not committed with secrets)
@@ -536,11 +562,13 @@ gandalf/
│ ├── style.css # Terminal aesthetic CSS (CRT scanlines, green-on-black)
│ ├── app.js # Dashboard JS (auto-refresh, events, suppress modal, search, modem panel)
│ ├── device.js # Device page rendering
│ ├── cutsheet.js # Cutsheet page
│ └── port-inspector.js # Switch front panel, port panel, path check, diagnostics
└── templates/
├── base.html # Shared layout (header, nav, footer)
├── index.html # Dashboard page
├── device.html # Per-device page
├── cutsheet.html # Cutsheet page and its editors
├── _modem_panel.html # WAN / Fiber panel, shared by the dashboard and modem page
└── suppressions.html # Suppression management page
```
@@ -582,6 +610,9 @@ Take LED colors from the model's quick start guide. See the comment at the top o
- `monitor_state` also holds `wan_history` (WAN outages, modem restarts) and `unifi_log_cursor` (how far the UniFi log has been read)
- Database sessions run in UTC (`db.py`), whatever the server's time zone
- `modem_samples`: optical readings per poll, kept 90 days; created automatically at monitor startup
- `cutsheet_items`, `cutsheet_cables`, `cutsheet_port_labels`, `cutsheet_history`: cutsheet records and
their change log, created automatically on first use. Cables refer to devices and items by name; renaming
an item updates its cables, but a device renamed in UniFi needs its cables edited to match
### Security Notes
+278 -3
View File
@@ -20,6 +20,7 @@ from functools import wraps
from flask import Flask, jsonify, make_response, redirect, render_template, request, send_file, url_for
import cutsheet
import db
import devices
import diagnose
@@ -409,10 +410,38 @@ def api_modem():
@app.route('/api/devices')
@require_auth
def api_devices():
"""Device list for the home page search."""
return jsonify({'devices': devices.build_index(
"""Device list for the home page search, plus cutsheet panels, jacks and cables."""
found = devices.build_index(
_state_json('network_snapshot'), _state_json('link_stats'), _state_json('modem_stats'),
)})
)
known = {d['name'] for d in found}
try:
_ensure_cutsheet_schema()
data = db.cutsheet_load()
except Exception as e:
logger.error(f'Cutsheet unavailable for search: {e}')
data = {'items': [], 'cables': []}
for item in data['items']:
if item['name'] in known:
continue
kind = item['kind'].replace('_', ' ')
found.append({
'name': item['name'], 'kind': kind, 'status': 'unknown', 'ip': '', 'mac': '', 'model': '',
'url': f'/cutsheet#item-{item["id"]}', 'interface_count': item.get('port_count') or 0,
'search': ' '.join(filter(None, [item['name'], kind, item.get('location'), 'cutsheet'])).lower(),
})
for cable in data['cables']:
if not cable.get('label'):
continue
ends = ' '.join(filter(None, [cable['a_device'], cable['b_device']]))
found.append({
'name': cable['label'], 'kind': 'cable', 'status': 'unknown', 'ip': '', 'mac': '',
'model': f'{cable["a_device"]} ↔ {cable["b_device"] or "unterminated"}',
'url': f'/cutsheet#cable-{cable["id"]}', 'interface_count': 0,
'search': ' '.join(filter(None, [cable['label'], ends, cable.get('cable_type'),
cable.get('color'), 'cable cutsheet'])).lower(),
})
return jsonify({'devices': found})
@app.route('/api/device/<path:name>')
@@ -427,6 +456,11 @@ def api_device(name: str):
events = db.get_events_for_target(name)
_annotate_suppressions(events, db.get_active_suppressions())
detail['events'] = events
try:
detail['cutsheet'] = _cutsheet_for_device(name)
except Exception as e:
logger.error(f'Cutsheet unavailable for {name}: {e}')
detail['cutsheet'] = []
return jsonify(detail)
@@ -769,6 +803,247 @@ def api_avatar():
return resp
# ---------------------------------------------------------------------------
# Cutsheets
# ---------------------------------------------------------------------------
_cutsheet_schema_ready = False
_cutsheet_schema_lock = threading.Lock()
def _ensure_cutsheet_schema() -> None:
"""Create the cutsheet tables on first use, so deploys need no migration step."""
global _cutsheet_schema_ready
if _cutsheet_schema_ready:
return
with _cutsheet_schema_lock:
if not _cutsheet_schema_ready:
db.ensure_cutsheet_schema()
_cutsheet_schema_ready = True
def _node_json(node) -> dict:
if node is None:
return {'device': '', 'port': '', 'side': '', 'text': 'unterminated'}
return {'device': node[0], 'port': node[1], 'side': node[2], 'text': cutsheet.describe_node(node)}
def _cutsheet_catalog(link_stats: dict, items: list) -> list:
"""Devices and their ports, for the cable form's suggestions."""
catalog = []
for name, sw in sorted((link_stats.get('unifi_switches') or {}).items()):
ports = sorted((p for p in (sw.get('ports') or {}).items() if p[1].get('port_idx') is not None),
key=lambda kv: kv[1]['port_idx'])
catalog.append({'name': name, 'kind': 'switch', 'ports': [
{'value': str(p['port_idx']), 'text': f'{p["port_idx"]} · {pname}'} for pname, p in ports]})
for name, ifaces in sorted((link_stats.get('hosts') or {}).items()):
catalog.append({'name': name, 'kind': 'host',
'ports': [{'value': i, 'text': i} for i in sorted(ifaces)]})
known = {c['name'] for c in catalog}
for item in items:
if item['name'] in known:
continue
ports = [{'value': str(n), 'text': str(n)} for n in range(1, (item.get('port_count') or 0) + 1)]
catalog.append({'name': item['name'], 'kind': item['kind'], 'ports': ports})
return catalog
def _cutsheet_view() -> dict:
"""Every cutsheet record with its traced path and plan-vs-actual verdict."""
_ensure_cutsheet_schema()
data = db.cutsheet_load()
link_stats = _state_json('link_stats')
switches = link_stats.get('unifi_switches') or {}
items = {i['name']: i for i in data['items']}
results = cutsheet.evaluate(data['cables'], items, switches)
for cable in data['cables']:
r = results[cable['id']]
cable['verdict'] = r['verdict']
cable['detail'] = r['detail']
cable['problem'] = r['verdict'] in cutsheet.PROBLEM_VERDICTS
cable['path'] = [
{'cable_id': h} if isinstance(h, int) else _node_json(h) for h in r['path']['hops']
]
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['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['verdicts'] = cutsheet.VERDICTS
data['options'] = {'kinds': cutsheet.ITEM_KINDS, 'passive_kinds': cutsheet.PASSIVE_KINDS,
'statuses': cutsheet.STATUSES, 'cable_types': cutsheet.CABLE_TYPES}
data['updated'] = link_stats.get('updated')
return data
def _cutsheet_for_device(name: str) -> list:
"""The cables that end on one device, for its port panel."""
view = _cutsheet_view()
out = []
for cable in view['cables']:
for end in ('a', 'b'):
if cable[f'{end}_device'] == name 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'],
'detail': cable['detail'], 'problem': cable['problem'], 'path': cable['path'],
})
return out
def _json_body() -> dict:
"""The request's JSON object. Requiring a JSON content type also keeps another
site's plain form post from reaching these write endpoints (no CORS preflight)."""
if not request.is_json:
raise cutsheet.ValidationError('Send the request as JSON')
data = request.get_json(silent=True)
if not isinstance(data, dict):
raise cutsheet.ValidationError('Request body must be a JSON object')
return data
def _cutsheet_write(fn):
"""Turn validation and conflict errors from a write into 400 and 409 responses."""
@wraps(fn)
def wrapper(*args, **kwargs):
try:
_ensure_cutsheet_schema()
return fn(*args, **kwargs)
except cutsheet.ValidationError as e:
return jsonify({'error': str(e)}), 400
except db.CutsheetConflict as e:
return jsonify({'error': str(e)}), 409
return wrapper
def _cutsheet_items() -> dict:
return {i['name']: i for i in db.cutsheet_load()['items']}
@app.route('/cutsheet')
@require_auth
def cutsheet_page():
user = _get_user()
return render_template('cutsheet.html', user=user, can_edit='admin' in user['groups'])
@app.route('/api/cutsheet')
@require_auth
def api_cutsheet():
view = _cutsheet_view()
view['history'] = db.cutsheet_history(limit=100)
return jsonify(view)
@app.route('/api/cutsheet/cables', methods=['POST'])
@require_auth
@require_admin
@_cutsheet_write
def api_cutsheet_create_cable():
cable = cutsheet.validate_cable(_json_body(), _cutsheet_items())
cable_id = db.cutsheet_create_cable(cable, _get_user()['username'])
return jsonify({'success': True, 'id': cable_id})
@app.route('/api/cutsheet/cables/<int:cable_id>', methods=['PUT'])
@require_auth
@require_admin
@_cutsheet_write
def api_cutsheet_update_cable(cable_id: int):
cable = cutsheet.validate_cable(_json_body(), _cutsheet_items())
if not db.cutsheet_update_cable(cable_id, cable, _get_user()['username']):
return jsonify({'error': 'No such cable'}), 404
return jsonify({'success': True})
@app.route('/api/cutsheet/cables/<int:cable_id>', methods=['DELETE'])
@require_auth
@require_admin
@_cutsheet_write
def api_cutsheet_delete_cable(cable_id: int):
if not db.cutsheet_delete_cable(cable_id, _get_user()['username']):
return jsonify({'error': 'No such cable'}), 404
return jsonify({'success': True})
@app.route('/api/cutsheet/items', methods=['POST'])
@require_auth
@require_admin
@_cutsheet_write
def api_cutsheet_create_item():
item = cutsheet.validate_item(_json_body())
item_id = db.cutsheet_create_item(item, _get_user()['username'])
return jsonify({'success': True, 'id': item_id})
@app.route('/api/cutsheet/items/<int:item_id>', methods=['PUT'])
@require_auth
@require_admin
@_cutsheet_write
def api_cutsheet_update_item(item_id: int):
item = cutsheet.validate_item(_json_body())
if not db.cutsheet_update_item(item_id, item, _get_user()['username']):
return jsonify({'error': 'No such item'}), 404
return jsonify({'success': True})
@app.route('/api/cutsheet/items/<int:item_id>', methods=['DELETE'])
@require_auth
@require_admin
@_cutsheet_write
def api_cutsheet_delete_item(item_id: int):
if not db.cutsheet_delete_item(item_id, _get_user()['username']):
return jsonify({'error': 'No such item'}), 404
return jsonify({'success': True})
@app.route('/api/cutsheet/items/<int:item_id>/ports/<port>', methods=['PUT'])
@require_auth
@require_admin
@_cutsheet_write
def api_cutsheet_port_label(item_id: int, port: str):
data = _json_body()
label = str(data.get('label') or '').strip()
if len(label) > 64:
raise cutsheet.ValidationError('Port label must be 64 characters or fewer')
if len(port) > cutsheet.PORT_MAX:
raise cutsheet.ValidationError('Port is too long')
if not db.cutsheet_set_port_label(item_id, port, label, bool(data.get('applied')), _get_user()['username']):
return jsonify({'error': 'No such item'}), 404
return jsonify({'success': True})
@app.route('/api/cutsheet/cables.csv')
@require_auth
def api_cutsheet_export():
_ensure_cutsheet_schema()
resp = make_response(cutsheet.cables_to_csv(db.cutsheet_load()['cables']))
resp.headers['Content-Type'] = 'text/csv; charset=utf-8'
resp.headers['Content-Disposition'] = 'attachment; filename="gandalf-cutsheet.csv"'
return resp
@app.route('/api/cutsheet/import', methods=['POST'])
@require_auth
@require_admin
@_cutsheet_write
def api_cutsheet_import():
data = _json_body()
text = data.get('csv')
if not isinstance(text, str) or not text.strip():
raise cutsheet.ValidationError('Paste or choose a CSV file')
if len(text) > 1_000_000:
raise cutsheet.ValidationError('CSV is larger than 1 MB')
cables, errors = cutsheet.parse_csv(text, _cutsheet_items())
if errors:
return jsonify({'error': 'Nothing was imported', 'errors': errors[:50]}), 400
if data.get('dry_run'):
return jsonify({'success': True, 'dry_run': True, 'rows': len(cables)})
result = db.cutsheet_import_cables(cables, _get_user()['username'])
return jsonify(dict(result, success=True))
@app.route('/health')
def health():
"""Health check endpoint (no auth). Checks DB and monitor freshness."""
+471
View File
@@ -0,0 +1,471 @@
"""Cutsheets: where every cable is meant to go, compared with what Gandalf sees.
Pure functions with no I/O, like devices.py. app.py loads rows from the
database and the monitor's cached link stats and passes them in.
Model
-----
* An **item** is something Gandalf can't discover: a patch panel, a wall jack,
a shelf, or a rack position for a device. Items of a passive kind (panels and
jacks) pass a signal straight through, from a port's front to the same port's
rear.
* A **cable** joins two endpoints. An endpoint is a device or item name, a port
(a switch's port number, a host's interface name, or a panel port), and for
passive items a side. The far end may be left blank for a cable that ends
nowhere yet, such as an unterminated fibre.
* A **path** is the chain of cables joined through passive items, e.g. switch
port 13 → panel port 5 (front) … panel port 5 (rear) → bedroom jack.
"""
import csv
import io
import re
from typing import Dict, List, Optional, Tuple
ITEM_KINDS = ('patch_panel', 'wall_jack', 'device', 'shelf', 'other')
PASSIVE_KINDS = ('patch_panel', 'wall_jack')
SIDES = ('front', 'rear')
STATUSES = ('planned', 'installed', 'dark', 'retired')
CABLE_TYPES = ('Cat5e', 'Cat6', 'Cat6a', 'DAC', 'AOC', 'OM3', 'OM4', 'OS2', 'Power', 'Other')
LABEL_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$')
NAME_MAX = 100
PORT_MAX = 64
TEXT_MAX = 255
NOTES_MAX = 2000
# Comparison outcomes, and whether each counts as a problem
VERDICTS = {
'match': 'Matches what Gandalf sees',
'wrong_neighbor': 'A different device is connected',
'no_link': 'Planned device is not linked',
'missing_port': 'That port is not reported by the device',
'unverified': "Gandalf can't confirm the far end",
'idle': 'Ends at a jack or panel with nothing linked',
'not_checked': 'No switch port to check against',
'planned': 'Planned, not installed yet',
'dark': 'Unused (dark)',
'retired': 'Retired',
'conflict': 'Another cable uses the same port',
}
PROBLEM_VERDICTS = ('wrong_neighbor', 'no_link', 'missing_port', 'conflict')
class ValidationError(ValueError):
"""A request the cutsheet can't accept, with a message for the user."""
# ── Validation ──────────────────────────────────────────────────────────────
def _text(data: dict, key: str, limit: int = TEXT_MAX, required: bool = False, label: str = '') -> str:
value = data.get(key)
value = '' if value is None else str(value).strip()
if required and not value:
raise ValidationError(f'{label or key} is required')
if len(value) > limit:
raise ValidationError(f'{label or key} must be {limit} characters or fewer')
return value
def _int(data: dict, key: str, low: int, high: int, label: str) -> Optional[int]:
value = data.get(key)
if value in (None, ''):
return None
try:
number = int(value)
except (TypeError, ValueError):
raise ValidationError(f'{label} must be a whole number')
if not low <= number <= high:
raise ValidationError(f'{label} must be between {low} and {high}')
return number
def validate_item(data: dict) -> dict:
kind = _text(data, 'kind', 20) or 'other'
if kind not in ITEM_KINDS:
raise ValidationError(f'kind must be one of {", ".join(ITEM_KINDS)}')
item = {
'name': _text(data, 'name', NAME_MAX, required=True, label='Name'),
'kind': kind,
'port_count': _int(data, 'port_count', 1, 512, 'Port count'),
'location': _text(data, 'location'),
'rack_u': _int(data, 'rack_u', 1, 60, 'Rack unit'),
'rack_height': _int(data, 'rack_height', 1, 20, 'Height'),
'notes': _text(data, 'notes', NOTES_MAX),
}
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']:
item['rack_height'] = 1
if kind == 'patch_panel' and not item['port_count']:
raise ValidationError('A patch panel needs a port count')
return item
def _endpoint(data: dict, prefix: str, items: Dict[str, dict], required: bool) -> Tuple[str, str, str]:
device = _text(data, f'{prefix}_device', NAME_MAX, required=required,
label='From device' if prefix == 'a' else 'To device')
port = _text(data, f'{prefix}_port', PORT_MAX)
side = _text(data, f'{prefix}_side', 10)
if not device:
if port or side:
raise ValidationError('Choose a device before a port')
return '', '', ''
item = items.get(device)
passive = bool(item and item['kind'] in PASSIVE_KINDS)
if passive:
if side not in SIDES:
raise ValidationError(f'Choose the front or rear of {device}')
if not port:
raise ValidationError(f'Choose a port on {device}')
count = item.get('port_count')
if count and (not port.isdigit() or not 1 <= int(port) <= count):
raise ValidationError(f'{device} has ports 1 to {count}')
else:
side = ''
return device, port, side
def validate_cable(data: dict, items: Dict[str, dict]) -> dict:
label = _text(data, 'label', 32)
if label and not LABEL_RE.match(label):
raise ValidationError('Label may use letters, digits, dot, dash and underscore (up to 32)')
status = _text(data, 'status', 20) or 'installed'
if status not in STATUSES:
raise ValidationError(f'status must be one of {", ".join(STATUSES)}')
cable_type = _text(data, 'cable_type', 32)
if cable_type and cable_type not in CABLE_TYPES:
raise ValidationError(f'Type must be one of {", ".join(CABLE_TYPES)}')
length = data.get('length_m')
if length in (None, ''):
length = None
else:
try:
length = round(float(length), 2)
except (TypeError, ValueError):
raise ValidationError('Length must be a number of metres')
if not 0 < length <= 999:
raise ValidationError('Length must be between 0 and 999 metres')
a = _endpoint(data, 'a', items, required=True)
b = _endpoint(data, 'b', items, required=False)
if a == b:
raise ValidationError('Both ends are the same port')
return {
'label': label or None,
'a_device': a[0], 'a_port': a[1], 'a_side': a[2],
'b_device': b[0], 'b_port': b[1], 'b_side': b[2],
'cable_type': cable_type, 'length_m': length,
'color': _text(data, 'color', 32), 'status': status,
'notes': _text(data, 'notes', NOTES_MAX),
'label_applied': bool(data.get('label_applied')),
}
def next_label(cables: List[dict], prefix: str = 'C') -> str:
"""The next free cable ID: C01, C02, ... (wider once past 99)."""
used = set()
for c in cables:
m = re.match(rf'^{re.escape(prefix)}(\d+)$', c.get('label') or '', re.IGNORECASE)
if m:
used.add(int(m.group(1)))
n = 1
while n in used:
n += 1
return f'{prefix}{n:02d}'
# ── Paths ───────────────────────────────────────────────────────────────────
def _node(cable: dict, end: str) -> Optional[tuple]:
device = cable.get(f'{end}_device') or ''
if not device:
return None
return (device, cable.get(f'{end}_port') or '', cable.get(f'{end}_side') or '')
def _other_side(node: tuple, items: Dict[str, dict]) -> Optional[tuple]:
"""Through a passive item, the same port's other side; otherwise the path ends."""
item = items.get(node[0])
if not item or item['kind'] not in PASSIVE_KINDS or node[2] not in SIDES:
return None
return (node[0], node[1], 'rear' if node[2] == 'front' else 'front')
def _live(cable: dict) -> bool:
return cable.get('status') != 'retired'
def node_index(cables: List[dict]) -> Dict[tuple, List[dict]]:
"""Map each endpoint to the live cables plugged into it."""
index: Dict[tuple, List[dict]] = {}
for c in cables:
if not _live(c):
continue
for end in ('a', 'b'):
node = _node(c, end)
if node:
index.setdefault(node, []).append(c)
return index
def trace(cable: dict, cables: List[dict], items: Dict[str, dict],
index: Optional[Dict[tuple, List[dict]]] = None) -> dict:
"""Follow a cable through passive items in both directions.
Returns {'hops': [node, cable_id, node, ...], 'cable_ids': [...]} with the
hops ordered from one end of the run to the other. A node is
(device, port, side), or None for an unterminated end.
"""
index = index if index is not None else node_index(cables)
def walk(node: Optional[tuple], came_from: dict, seen: set) -> list:
hops = []
while node is not None:
through = _other_side(node, items)
if through is None:
break
nxt = next((c for c in index.get(through, []) if c['id'] != came_from['id'] and c['id'] not in seen),
None)
hops.append(through)
if nxt is None:
break
seen.add(nxt['id'])
hops.append(nxt['id'])
far = 'b' if _node(nxt, 'a') == through else 'a'
node = _node(nxt, far)
hops.append(node)
came_from = nxt
return hops
seen = {cable['id']}
toward_a = walk(_node(cable, 'a'), cable, seen)
toward_b = walk(_node(cable, 'b'), cable, seen)
hops = list(reversed(toward_a)) + [_node(cable, 'a'), cable['id'], _node(cable, 'b')] + toward_b
return {'hops': hops, 'cable_ids': [h for h in hops if isinstance(h, int)]}
# ── Plan vs. actual ─────────────────────────────────────────────────────────
def _switch_port(node: Optional[tuple], switches: dict) -> Optional[dict]:
"""The discovered port behind an endpoint, if it is a switch or router port."""
if not node or node[0] not in switches or not node[1].isdigit():
return None
for port in (switches[node[0]].get('ports') or {}).values():
if port.get('port_idx') == int(node[1]):
return port
return {} # the device is known but doesn't report this port
def _same(a: str, b: str) -> bool:
return bool(a) and bool(b) and a.strip().lower() == b.strip().lower()
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:
return None
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]
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]:
return {'verdict': 'match', 'detail': 'Both ends report the same DAC serial'}
if not port.get('up'):
if expected:
return {'verdict': 'no_link', 'detail': f'{node[0]} port {node[1]} is down'}
return {'verdict': 'idle', 'detail': f'{node[0]} port {node[1]} is down'}
if expected:
if not seen:
return {'verdict': 'unverified', 'detail': f'Link up on port {node[1]}, but no neighbor is identified'}
if _same(seen, expected):
return {'verdict': 'match', 'detail': f'{node[0]} port {node[1]} sees {seen}'}
return {'verdict': 'wrong_neighbor', 'detail': f'{node[0]} port {node[1]} sees {seen}, not {expected}'}
detail = f'Link up on port {node[1]}' + (f', sees {seen}' if seen else '')
return {'verdict': 'unverified', 'detail': detail}
_VERDICT_RANK = ['wrong_neighbor', 'no_link', 'missing_port', 'unverified', 'idle', 'match']
def compare_path(path: dict, cables_by_id: Dict[int, dict], switches: dict, items: Dict[str, dict]) -> dict:
"""Check a traced run against discovery. Returns {'verdict', 'detail'}."""
run = [cables_by_id[i] for i in path['cable_ids']]
statuses = {c['status'] for c in run}
for status in ('retired', 'dark', 'planned'):
if status in statuses:
return {'verdict': status, 'detail': VERDICTS[status]}
ends = (path['hops'][0], path['hops'][-1])
checks = [c for c in (_check_end(ends[0], ends[1], switches, items),
_check_end(ends[1], ends[0], switches, items)) if c]
if not checks:
return {'verdict': 'not_checked', 'detail': VERDICTS['not_checked']}
# A switch-to-switch run is checked from both ends: report the worst
return min(checks, key=lambda c: _VERDICT_RANK.index(c['verdict']))
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 {}
index = node_index(cables)
by_id = {c['id']: c for c in cables}
result: Dict[int, dict] = {}
for c in cables:
if not _live(c):
result[c['id']] = {'path': {'hops': [_node(c, 'a'), c['id'], _node(c, 'b')], 'cable_ids': [c['id']]},
'verdict': 'retired', 'detail': VERDICTS['retired']}
continue
conflict = next((n for n in (_node(c, 'a'), _node(c, 'b'))
if n and len([x for x in index.get(n, []) if x['status'] in ('installed', 'dark')]) > 1
and c['status'] in ('installed', 'dark')), None)
path = trace(c, cables, items, index)
if conflict:
others = [x.get('label') or f'#{x["id"]}' for x in index[conflict] if x['id'] != c['id']]
result[c['id']] = {'path': path, 'verdict': 'conflict',
'detail': f'{describe_node(conflict)} is also used by {", ".join(others)}'}
continue
result[c['id']] = dict(compare_path(path, by_id, switches, items), path=path)
return result
def describe_node(node: Optional[tuple]) -> str:
if node is None:
return 'unterminated'
device, port, side = node
text = device
if port:
text += f' port {port}' if port.isdigit() else f' {port}'
if side:
text += f' ({side})'
return text
# ── Undocumented links ──────────────────────────────────────────────────────
def undocumented(cables: List[dict], switches: dict) -> List[dict]:
"""Linked switch ports that no live cable mentions, as suggestions to add."""
documented = set()
for c in cables:
if not _live(c):
continue
for end in ('a', 'b'):
node = _node(c, end)
if node:
documented.add((node[0].lower(), node[1]))
suggestions = []
for sw_name, sw in sorted((switches or {}).items()):
for port_name, port in (sw.get('ports') or {}).items():
idx = port.get('port_idx')
if not port.get('up') or idx is None or (sw_name.lower(), str(idx)) in documented:
continue
lldp = port.get('lldp') or {}
sfp = port.get('sfp') or {}
peer = sfp.get('cable_peer')
suggestion = {
'switch': sw_name, 'port_idx': idx, 'port_name': port_name,
'neighbor': lldp.get('system_name') or '', 'neighbor_port': lldp.get('port_id') or '',
'speed_mbps': port.get('speed_mbps') or 0,
'cable_type': 'DAC' if sfp.get('kind') == 'dac' else '',
'via': 'lldp' if lldp.get('system_name') else '',
}
if lldp.get('source') == 'unifi-clients':
suggestion['via'] = 'client table'
if peer:
suggestion.update(neighbor=peer['switch'], neighbor_port=str(peer['port_idx']), via='DAC serial')
# Offer each DAC once, from the end that sorts first
if (peer['switch'].lower(), str(peer['port_idx'])) in documented:
continue
if (peer['switch'], peer['port_idx']) < (sw_name, idx):
continue
suggestions.append(suggestion)
return suggestions
# ── Labels to make ──────────────────────────────────────────────────────────
def labels_to_make(cables: List[dict], items: Dict[str, dict], port_labels: List[dict]) -> dict:
"""Cable IDs and panel-port labels that haven't been stuck on yet."""
cords = [
{'id': c['id'], 'text': c['label'], 'copies': 2 if c.get('b_device') else 1,
'where': f'{describe_node(_node(c, "a"))} ↔ {describe_node(_node(c, "b"))}'}
for c in sorted(cables, key=lambda c: _natural(c.get('label') or ''))
if c.get('label') and not c.get('label_applied') and c['status'] in ('installed', 'dark')
]
unlabeled = [c['id'] for c in cables if not c.get('label') and c['status'] in ('installed', 'dark')]
ports = [
{'item': p['item_name'], 'port': p['port'], 'text': p['label']}
for p in sorted(port_labels, key=lambda p: (p['item_name'].lower(), _natural(p['port'])))
if p.get('label') and not p.get('applied')
]
return {'cables': cords, 'ports': ports, 'unlabeled_cable_ids': unlabeled}
def _natural(text: str) -> list:
return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', text or '')]
# ── CSV ─────────────────────────────────────────────────────────────────────
CSV_FIELDS = ['label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes']
def cables_to_csv(cables: List[dict]) -> str:
out = io.StringIO()
writer = csv.DictWriter(out, fieldnames=CSV_FIELDS, extrasaction='ignore', lineterminator='\n')
writer.writeheader()
for c in sorted(cables, key=lambda c: _natural(c.get('label') or '')):
row = {k: c.get(k) for k in CSV_FIELDS}
row['label_applied'] = 'yes' if c.get('label_applied') else ''
row['length_m'] = '' if c.get('length_m') is None else f'{float(c["length_m"]):g}'
# Keep spreadsheet apps from running a cell as a formula
writer.writerow({k: _csv_safe(v) for k, v in row.items()})
return out.getvalue()
def _csv_safe(value) -> str:
text = '' if value is None else str(value)
return "'" + text if text[:1] in ('=', '+', '-', '@', '\t', '\r') and not _is_number(text) else text
def _is_number(text: str) -> bool:
try:
float(text)
return True
except ValueError:
return False
def parse_csv(text: str, items: Dict[str, dict], max_rows: int = 2000) -> Tuple[List[dict], List[str]]:
"""Validate a cable CSV. Returns (cables, errors); nothing is applied on any error."""
reader = csv.DictReader(io.StringIO(text))
missing = [f for f in ('a_device',) if f not in (reader.fieldnames or [])]
if missing:
return [], [f'Missing column: {", ".join(missing)}']
rows, errors, labels = [], [], set()
for line, raw in enumerate(reader, start=2):
if line - 1 > max_rows:
errors.append(f'More than {max_rows} rows')
break
data = {k: (v or '').strip() for k, v in raw.items() if k}
for k in list(data):
if data[k].startswith("'") and data[k][1:2] in ('=', '+', '-', '@'):
data[k] = data[k][1:]
if not any(data.values()):
continue
data['label_applied'] = data.get('label_applied', '').lower() in ('yes', 'true', '1', 'y')
try:
cable = validate_cable(data, items)
except ValidationError as e:
errors.append(f'Line {line}: {e}')
continue
if cable['label']:
if cable['label'].lower() in labels:
errors.append(f'Line {line}: label {cable["label"]} appears twice')
continue
labels.add(cable['label'].lower())
rows.append(cable)
return rows, errors
+309
View File
@@ -478,3 +478,312 @@ def is_suppressed(target_type: str, target_name: str, target_detail: str = '') -
return True
return False
# ---------------------------------------------------------------------------
# Cutsheets (see cutsheet.py). Every change writes a cutsheet_history row in
# the same transaction, so the log can't miss an edit.
# ---------------------------------------------------------------------------
_CUTSHEET_SCHEMA = (
"""CREATE TABLE IF NOT EXISTS cutsheet_items (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
kind VARCHAR(20) NOT NULL,
port_count SMALLINT NULL,
location VARCHAR(255) NOT NULL DEFAULT '',
rack_u SMALLINT NULL,
rack_height SMALLINT NULL,
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_item_name (name)
) ENGINE=InnoDB""",
"""CREATE TABLE IF NOT EXISTS cutsheet_cables (
id INT AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(32) NULL,
a_device VARCHAR(100) NOT NULL,
a_port VARCHAR(64) NOT NULL DEFAULT '',
a_side VARCHAR(5) NOT NULL DEFAULT '',
b_device VARCHAR(100) NOT NULL DEFAULT '',
b_port VARCHAR(64) NOT NULL DEFAULT '',
b_side VARCHAR(5) NOT NULL DEFAULT '',
cable_type VARCHAR(32) NOT NULL DEFAULT '',
length_m DECIMAL(5,2) NULL,
color VARCHAR(32) NOT NULL DEFAULT '',
status VARCHAR(10) NOT NULL DEFAULT 'installed',
label_applied BOOLEAN NOT NULL DEFAULT FALSE,
notes TEXT NULL,
created_by VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(255) NOT NULL DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_cable_label (label),
INDEX idx_cable_a (a_device, a_port),
INDEX idx_cable_b (b_device, b_port)
) ENGINE=InnoDB""",
"""CREATE TABLE IF NOT EXISTS cutsheet_port_labels (
item_id INT NOT NULL,
port VARCHAR(64) NOT NULL,
label VARCHAR(64) NOT NULL DEFAULT '',
applied BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (item_id, port)
) ENGINE=InnoDB""",
"""CREATE TABLE IF NOT EXISTS cutsheet_history (
id INT AUTO_INCREMENT PRIMARY KEY,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
changed_by VARCHAR(255) NOT NULL,
entity VARCHAR(12) NOT NULL,
entity_id INT NOT NULL,
action VARCHAR(10) NOT NULL,
summary VARCHAR(500) NOT NULL,
before_json TEXT NULL,
after_json TEXT NULL,
INDEX idx_hist_time (changed_at),
INDEX idx_hist_entity (entity, entity_id)
) ENGINE=InnoDB""",
)
_ITEM_FIELDS = ('name', 'kind', 'port_count', 'location', 'rack_u', 'rack_height', 'notes')
_CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes')
class CutsheetConflict(Exception):
"""A change that would clash with existing records (duplicate name or label, item in use)."""
def ensure_cutsheet_schema() -> None:
with get_conn() as conn:
with conn.cursor() as cur:
for statement in _CUTSHEET_SCHEMA:
cur.execute(statement)
def _cs_jsonable(row: Optional[dict]) -> Optional[dict]:
if row is None:
return None
out = {}
for k, v in row.items():
if hasattr(v, 'isoformat'):
v = v.isoformat() + 'Z'
elif v is not None and k == 'length_m':
v = float(v)
elif k in ('label_applied', 'applied'):
v = bool(v)
out[k] = v
return out
def _cs_log(cur, user: str, entity: str, entity_id: int, action: str, summary: str,
before: Optional[dict], after: Optional[dict]) -> None:
cur.execute(
"""INSERT INTO cutsheet_history (changed_by, entity, entity_id, action, summary, before_json, after_json)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(user, entity, entity_id, action, summary[:500],
json.dumps(_cs_jsonable(before)) if before else None,
json.dumps(_cs_jsonable(after)) if after else None),
)
@contextmanager
def _cs_transaction():
with get_conn() as conn:
conn.begin()
try:
with conn.cursor() as cur:
yield cur
conn.commit()
except pymysql.err.IntegrityError as e:
conn.rollback()
raise CutsheetConflict(_cs_integrity_message(e)) from e
except Exception:
conn.rollback()
raise
def _cs_integrity_message(e: Exception) -> str:
text = str(e)
if 'uq_cable_label' in text:
return 'Another cable already has that label'
if 'uq_item_name' in text:
return 'Another item already has that name'
return 'That change conflicts with an existing record'
def cutsheet_load() -> dict:
"""Every cutsheet record, for one page render or API response."""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute('SELECT * FROM cutsheet_items ORDER BY name')
items = [_cs_jsonable(r) for r in cur.fetchall()]
cur.execute('SELECT * FROM cutsheet_cables ORDER BY id')
cables = [_cs_jsonable(r) for r in cur.fetchall()]
cur.execute(
"""SELECT l.item_id, i.name AS item_name, l.port, l.label, l.applied
FROM cutsheet_port_labels l JOIN cutsheet_items i ON i.id = l.item_id"""
)
port_labels = [_cs_jsonable(r) for r in cur.fetchall()]
return {'items': items, 'cables': cables, 'port_labels': port_labels}
def cutsheet_history(limit: int = 100) -> list:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""SELECT id, changed_at, changed_by, entity, entity_id, action, summary
FROM cutsheet_history ORDER BY id DESC LIMIT %s""",
(limit,),
)
return [_cs_jsonable(r) for r in cur.fetchall()]
def _cs_get(cur, table: str, row_id: int) -> Optional[dict]:
cur.execute(f'SELECT * FROM {table} WHERE id=%s FOR UPDATE', (row_id,)) # nosec B608 (fixed names)
return cur.fetchone()
def cutsheet_create_item(item: dict, user: str) -> int:
with _cs_transaction() as cur:
cur.execute(
f'INSERT INTO cutsheet_items ({", ".join(_ITEM_FIELDS)}) ' # nosec B608 (fixed names)
f'VALUES ({", ".join(["%s"] * len(_ITEM_FIELDS))})',
tuple(item[f] for f in _ITEM_FIELDS),
)
item_id = cur.lastrowid
_cs_log(cur, user, 'item', item_id, 'create', f'Added {item["kind"]} {item["name"]}', None, item)
return item_id
def cutsheet_update_item(item_id: int, item: dict, user: str) -> bool:
"""Update an item. A rename carries over to the cables that reference it."""
with _cs_transaction() as cur:
before = _cs_get(cur, 'cutsheet_items', item_id)
if before is None:
return False
cur.execute(
f'UPDATE cutsheet_items SET {", ".join(f"{f}=%s" for f in _ITEM_FIELDS)} WHERE id=%s', # nosec B608
tuple(item[f] for f in _ITEM_FIELDS) + (item_id,),
)
summary = f'Edited {item["name"]}'
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']),
)
summary = f'Renamed {before["name"]} to {item["name"]}'
_cs_log(cur, user, 'item', item_id, 'update', summary, before, item)
return True
def cutsheet_delete_item(item_id: int, user: str) -> bool:
with _cs_transaction() as cur:
before = _cs_get(cur, 'cutsheet_items', item_id)
if before is None:
return False
cur.execute(
'SELECT COUNT(*) AS n FROM cutsheet_cables WHERE a_device=%s OR b_device=%s',
(before['name'], before['name']),
)
used = cur.fetchone()['n']
if used:
raise CutsheetConflict(f'{before["name"]} still has {used} cable(s); move or delete them 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)
return True
def _cable_summary(c: dict) -> str:
def end(device: str, port: str) -> str:
return ' '.join(filter(None, [device or 'unterminated', port]))
ends = f'{end(c["a_device"], c["a_port"])} ↔ {end(c["b_device"], c["b_port"])}'
return f'{c.get("label") or "unlabeled cable"}: {ends}'
def cutsheet_create_cable(cable: dict, user: str) -> int:
with _cs_transaction() as cur:
cur.execute(
f'INSERT INTO cutsheet_cables ({", ".join(_CABLE_FIELDS)}, created_by, updated_by) ' # nosec B608
f'VALUES ({", ".join(["%s"] * len(_CABLE_FIELDS))}, %s, %s)',
tuple(cable[f] for f in _CABLE_FIELDS) + (user, user),
)
cable_id = cur.lastrowid
_cs_log(cur, user, 'cable', cable_id, 'create', f'Added {_cable_summary(cable)}', None, cable)
return cable_id
def cutsheet_update_cable(cable_id: int, cable: dict, user: str) -> bool:
with _cs_transaction() as cur:
before = _cs_get(cur, 'cutsheet_cables', cable_id)
if before is None:
return False
cur.execute(
f'UPDATE cutsheet_cables SET {", ".join(f"{f}=%s" for f in _CABLE_FIELDS)}, updated_by=%s ' # nosec B608
'WHERE id=%s',
tuple(cable[f] for f in _CABLE_FIELDS) + (user, cable_id),
)
_cs_log(cur, user, 'cable', cable_id, 'update', f'Edited {_cable_summary(cable)}', before, cable)
return True
def cutsheet_delete_cable(cable_id: int, user: str) -> bool:
with _cs_transaction() as cur:
before = _cs_get(cur, 'cutsheet_cables', cable_id)
if before is None:
return False
cur.execute('DELETE FROM cutsheet_cables WHERE id=%s', (cable_id,))
_cs_log(cur, user, 'cable', cable_id, 'delete', f'Deleted {_cable_summary(before)}', before, None)
return True
def cutsheet_set_port_label(item_id: int, port: str, label: str, applied: bool, user: str) -> bool:
with _cs_transaction() as cur:
item = _cs_get(cur, 'cutsheet_items', item_id)
if item is None:
return False
cur.execute('SELECT * FROM cutsheet_port_labels WHERE item_id=%s AND port=%s', (item_id, port))
before = cur.fetchone()
if label:
cur.execute(
"""INSERT INTO cutsheet_port_labels (item_id, port, label, applied) VALUES (%s, %s, %s, %s)
ON DUPLICATE KEY UPDATE label=VALUES(label), applied=VALUES(applied)""",
(item_id, port, label, applied),
)
else:
cur.execute('DELETE FROM cutsheet_port_labels WHERE item_id=%s AND port=%s', (item_id, port))
after = {'item_id': item_id, 'port': port, 'label': label, 'applied': applied} if label else None
_cs_log(cur, user, 'port_label', item_id, 'update',
f'{item["name"]} port {port} label: {label or "(cleared)"}', before, after)
return True
def cutsheet_import_cables(cables: list, user: str) -> dict:
"""Add or update cables from a CSV import, matching on label. All or nothing."""
added = updated = 0
with _cs_transaction() as cur:
for cable in cables:
before = None
if cable['label']:
cur.execute('SELECT * FROM cutsheet_cables WHERE label=%s FOR UPDATE', (cable['label'],))
before = cur.fetchone()
if before:
cur.execute(
f'UPDATE cutsheet_cables SET {", ".join(f"{f}=%s" for f in _CABLE_FIELDS)}, ' # nosec B608
'updated_by=%s WHERE id=%s',
tuple(cable[f] for f in _CABLE_FIELDS) + (user, before['id']),
)
_cs_log(cur, user, 'cable', before['id'], 'update', f'Imported {_cable_summary(cable)}',
before, cable)
updated += 1
else:
cur.execute(
f'INSERT INTO cutsheet_cables ({", ".join(_CABLE_FIELDS)}, created_by, updated_by) ' # nosec
f'VALUES ({", ".join(["%s"] * len(_CABLE_FIELDS))}, %s, %s)',
tuple(cable[f] for f in _CABLE_FIELDS) + (user, user),
)
_cs_log(cur, user, 'cable', cur.lastrowid, 'create', f'Imported {_cable_summary(cable)}',
None, cable)
added += 1
return {'added': added, 'updated': updated}
+62
View File
@@ -61,3 +61,65 @@ CREATE TABLE IF NOT EXISTS monitor_state (
value MEDIUMTEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- ── Cutsheets (planned cabling) ────────────────────────────────────────
-- Also created automatically on first use (db.ensure_cutsheet_schema).
CREATE TABLE IF NOT EXISTS cutsheet_items ( -- panels, jacks, shelves, rack positions
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
kind VARCHAR(20) NOT NULL, -- patch_panel, wall_jack, device, shelf, other
port_count SMALLINT NULL,
location VARCHAR(255) NOT NULL DEFAULT '',
rack_u SMALLINT NULL, -- bottom rack unit
rack_height SMALLINT NULL,
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_item_name (name)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS cutsheet_cables ( -- one row per physical cable
id INT AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(32) NULL, -- the ID on the cable, e.g. C07
a_device VARCHAR(100) NOT NULL, -- device or item name
a_port VARCHAR(64) NOT NULL DEFAULT '', -- switch port number, interface, or panel port
a_side VARCHAR(5) NOT NULL DEFAULT '', -- front/rear, for panels and jacks
b_device VARCHAR(100) NOT NULL DEFAULT '', -- blank = unterminated
b_port VARCHAR(64) NOT NULL DEFAULT '',
b_side VARCHAR(5) NOT NULL DEFAULT '',
cable_type VARCHAR(32) NOT NULL DEFAULT '',
length_m DECIMAL(5,2) NULL,
color VARCHAR(32) NOT NULL DEFAULT '',
status VARCHAR(10) NOT NULL DEFAULT 'installed', -- planned, installed, dark, retired
label_applied BOOLEAN NOT NULL DEFAULT FALSE,
notes TEXT NULL,
created_by VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(255) NOT NULL DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_cable_label (label),
INDEX idx_cable_a (a_device, a_port),
INDEX idx_cable_b (b_device, b_port)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS cutsheet_port_labels ( -- e.g. the room name on a panel port
item_id INT NOT NULL,
port VARCHAR(64) NOT NULL,
label VARCHAR(64) NOT NULL DEFAULT '',
applied BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (item_id, port)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS cutsheet_history ( -- who changed what, and when
id INT AUTO_INCREMENT PRIMARY KEY,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
changed_by VARCHAR(255) NOT NULL,
entity VARCHAR(12) NOT NULL, -- cable, item, port_label
entity_id INT NOT NULL,
action VARCHAR(10) NOT NULL, -- create, update, delete
summary VARCHAR(500) NOT NULL,
before_json TEXT NULL,
after_json TEXT NULL,
INDEX idx_hist_time (changed_at),
INDEX idx_hist_entity (entity, entity_id)
) ENGINE=InnoDB;
+2 -2
View File
@@ -233,7 +233,7 @@ function initDeviceSearch() {
}
results.innerHTML = matches.map((d, i) => `
<a class="device-search-row${i === active ? ' is-active' : ''}" id="device-opt-${i}" role="option"
aria-selected="${i === active}" href="${lt.escHtml(deviceUrl(d.name))}">
aria-selected="${i === active}" href="${lt.escHtml(d.url || deviceUrl(d.name))}">
<span class="dot-${lt.escHtml(d.status)}"></span>
<span class="device-search-name">${lt.escHtml(d.name)}</span>
<span class="device-search-kind">${lt.escHtml(d.kind)}</span>
@@ -250,7 +250,7 @@ function initDeviceSearch() {
if (!matches.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % matches.length; render(); }
if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + matches.length) % matches.length; render(); }
if (e.key === 'Enter') { e.preventDefault(); window.location.href = deviceUrl(matches[active].name); }
if (e.key === 'Enter') { e.preventDefault(); window.location.href = matches[active].url || deviceUrl(matches[active].name); }
});
document.addEventListener('click', e => {
if (!e.target.closest('#device-search')) close();
+566
View File
@@ -0,0 +1,566 @@
'use strict';
/* global CS_CAN_EDIT, deviceUrl */
// Cutsheet page: renders /api/cutsheet and edits cables, items and panel-port labels.
const esc = s => lt.escHtml(s == null ? '' : String(s));
const VERDICT_CLASS = {
match: 'badge-ok', wrong_neighbor: 'badge-critical', no_link: 'badge-critical', missing_port: 'badge-critical',
conflict: 'badge-critical', unverified: 'badge-info', idle: 'badge-neutral', not_checked: 'badge-neutral',
planned: 'badge-purple', dark: 'badge-neutral', retired: 'badge-neutral',
};
const VERDICT_SHORT = {
match: 'Matches', wrong_neighbor: 'Wrong neighbor', no_link: 'No link', missing_port: 'Missing port',
conflict: 'Port conflict', unverified: 'Unverified', idle: 'Idle', not_checked: 'Not checked',
planned: 'Planned', dark: 'Dark', retired: 'Retired',
};
const KIND_TEXT = { patch_panel: 'Patch panel', wall_jack: 'Wall jack', device: 'Device', shelf: 'Shelf', other: 'Other' };
let _cs = null;
let _showAllSuggestions = false;
const SUGGESTIONS_SHOWN = 10;
function natural(a, b) {
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
function itemByName(name) {
return (_cs.items || []).find(i => i.name === name);
}
function isPassive(name) {
const item = itemByName(name);
return !!(item && _cs.options.passive_kinds.includes(item.kind));
}
function verdictBadge(c) {
return `<span class="lt-badge ${VERDICT_CLASS[c.verdict] || 'badge-neutral'}" title="${esc(c.detail)}">${esc(VERDICT_SHORT[c.verdict] || c.verdict)}</span>`;
}
function deviceLink(name) {
if (!name) return '<span class="val-neutral">unterminated</span>';
const item = itemByName(name);
const known = (_cs.catalog || []).some(d => d.name === name && (d.kind === 'switch' || d.kind === 'host'));
if (item && !known) return `<a class="device-link" href="#item-${esc(item.id)}">${esc(name)}</a>`;
return `<a class="device-link" href="${esc(deviceUrl(name))}">${esc(name)}</a>`;
}
function endText(c, end) {
const device = c[`${end}_device`];
if (!device) return deviceLink('');
const port = c[`${end}_port`];
const side = c[`${end}_side`];
let text = deviceLink(device);
if (port) text += ` <span class="cs-port">${/^\d+$/.test(port) ? 'port ' : ''}${esc(port)}</span>`;
if (side) text += ` <span class="cs-side-tag">${esc(side)}</span>`;
return text;
}
function pathText(c) {
// The run beyond this cable, through any panels or jacks
const nodes = (c.path || []).filter(h => !('cable_id' in h)).map(h => h.text);
return nodes.length > 2 ? nodes.map(esc).join(' → ') : '';
}
// ── Rendering ───────────────────────────────────────────────────────────
function renderSummary() {
const cables = _cs.cables.filter(c => c.status !== 'retired');
const problems = cables.filter(c => c.problem).length;
const matches = cables.filter(c => c.verdict === 'match').length;
const labels = _cs.labels.cables.length + _cs.labels.ports.length;
const chip = (n, text, cls, target) =>
`<a class="cs-chip ${cls}" href="#${target}"><b>${n}</b> ${esc(text)}</a>`;
document.getElementById('cs-summary').innerHTML = [
chip(cables.length, 'cables', '', 'cs-cables-section'),
chip(matches, 'match what Gandalf sees', 'cs-chip-ok', 'cs-cables-section'),
chip(problems, problems === 1 ? 'problem' : 'problems', problems ? 'cs-chip-bad' : '', 'cs-cables-section'),
chip(_cs.suggestions.length, 'undocumented links', _cs.suggestions.length ? 'cs-chip-warn' : '', 'cs-suggest-section'),
chip(labels, 'labels to make', labels ? 'cs-chip-warn' : '', 'cs-labels-section'),
].join('');
}
function cableMatches(c, words, status, problemsOnly) {
if (status && c.status !== status) return false;
if (problemsOnly && !c.problem) return false;
if (!words.length) return true;
const hay = [c.label, c.a_text, c.b_text, c.cable_type, c.color, c.notes, c.detail, c.status]
.join(' ').toLowerCase();
return words.every(w => hay.includes(w));
}
function renderCables() {
const words = document.getElementById('cs-filter').value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const status = document.getElementById('cs-status-filter').value;
const problemsOnly = document.getElementById('cs-problems-only').checked;
const rows = _cs.cables
.filter(c => cableMatches(c, words, status, problemsOnly))
.sort((a, b) => (b.problem - a.problem) || natural(a.label || '~', b.label || '~') || a.id - b.id);
document.getElementById('cs-cable-count').textContent = _cs.cables.length;
const wrap = document.getElementById('cs-cables');
if (!_cs.cables.length) {
wrap.innerHTML = emptyState('🔌', 'No cables yet',
CS_CAN_EDIT ? 'Add one with + Cable, or start from the undocumented links below.' : 'An admin can add cables.');
return;
}
if (!rows.length) {
wrap.innerHTML = emptyState('🔍', 'No cables match the filter', '');
return;
}
wrap.innerHTML = `
<div class="lt-table-wrap">
<table class="lt-table cs-table">
<caption class="lt-sr-only">Cables</caption>
<thead><tr><th>ID</th><th>From</th><th>To</th><th>Type</th><th>Status</th><th>Check</th>${CS_CAN_EDIT ? '<th class="no-print"></th>' : ''}</tr></thead>
<tbody>${rows.map(c => `
<tr id="cable-${esc(c.id)}" class="${c.problem ? 'cs-row-problem' : ''}${c.status === 'retired' ? ' row-resolved' : ''}">
<td class="cs-id">${c.label ? esc(c.label) : '<span class="val-neutral">–</span>'}${c.label && !c.label_applied && c.status !== 'retired' ? ' <span class="cs-tag" title="The label isn\'t on the cable yet">not stuck on</span>' : ''}</td>
<td data-label="From">${endText(c, 'a')}</td>
<td data-label="To">${endText(c, 'b')}${pathText(c) ? `<div class="cs-path">${pathText(c)}</div>` : ''}</td>
<td data-label="Type">${esc([c.cable_type, c.length_m ? `${c.length_m} m` : '', c.color].filter(Boolean).join(' · ')) || '–'}</td>
<td data-label="Status">${esc(c.status)}</td>
<td data-label="Check">${verdictBadge(c)}<div class="cs-detail">${esc(c.detail)}</div></td>
${CS_CAN_EDIT ? `<td class="no-print"><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-cable" data-id="${esc(c.id)}" aria-label="Edit cable ${esc(c.label || c.id)}">Edit</button></td>` : ''}
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
function renderSuggestions() {
const list = _cs.suggestions;
document.getElementById('cs-suggest-count').textContent = list.length;
const wrap = document.getElementById('cs-suggestions');
if (!list.length) {
wrap.innerHTML = emptyState('✅', 'Every live switch port is documented', '');
return;
}
wrap.innerHTML = `
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Undocumented links</caption>
<thead><tr><th>Switch port</th><th>UniFi name</th><th>Sees</th><th>Found by</th>${CS_CAN_EDIT ? '<th></th>' : ''}</tr></thead>
<tbody>${list.slice(0, _showAllSuggestions ? list.length : SUGGESTIONS_SHOWN).map((s, i) => `
<tr>
<td>${deviceLink(s.switch)} <span class="cs-port">port ${esc(s.port_idx)}</span></td>
<td>${esc(s.port_name)}</td>
<td>${s.neighbor ? `${deviceLink(s.neighbor)}${s.neighbor_port ? ` <span class="cs-port">${esc(s.neighbor_port)}</span>` : ''}` : '<span class="val-neutral">unknown</span>'}</td>
<td>${esc(s.via || 'link only')}</td>
${CS_CAN_EDIT ? `<td><button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="add-suggestion" data-i="${i}">Add</button></td>` : ''}
</tr>`).join('')}
</tbody>
</table>
</div>
${list.length > SUGGESTIONS_SHOWN ? `<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm cs-more" data-action="toggle-suggestions">
${_showAllSuggestions ? 'Show fewer' : `Show all ${list.length}`}</button>` : ''}`;
}
function cableAt(name, port, side) {
return _cs.cables.find(c => c.status !== 'retired' &&
['a', 'b'].some(e => c[`${e}_device`] === name && c[`${e}_port`] === String(port) && c[`${e}_side`] === side));
}
function farEnd(c, name, port, side) {
const end = (c.a_device === name && c.a_port === String(port) && c.a_side === side) ? 'b' : 'a';
return endText(c, end);
}
function renderPanels() {
const panels = _cs.items.filter(i => i.kind === 'patch_panel').sort((a, b) => natural(a.name, b.name));
const wrap = document.getElementById('cs-panels');
if (!panels.length) {
wrap.innerHTML = emptyState('🗄️', 'No patch panels yet', CS_CAN_EDIT ? 'Add one with + Panel / item.' : '');
return;
}
const labels = {};
for (const l of _cs.port_labels) labels[`${l.item_id}|${l.port}`] = l;
wrap.innerHTML = panels.map(p => {
const rows = [];
for (let n = 1; n <= (p.port_count || 0); n++) {
const front = cableAt(p.name, n, 'front');
const rear = cableAt(p.name, n, 'rear');
const lab = labels[`${p.id}|${n}`];
const cell = (c, side) => c
? `<a class="cs-id" href="#cable-${esc(c.id)}">${esc(c.label || 'unlabeled')}</a> → ${farEnd(c, p.name, n, side)}`
: '<span class="val-neutral">–</span>';
rows.push(`
<tr>
<td class="cs-id">${n}</td>
<td>${lab ? esc(lab.label) + (lab.applied ? '' : ' <span class="cs-tag">not stuck on</span>') : '<span class="val-neutral">–</span>'}
${CS_CAN_EDIT ? `<button type="button" class="cs-link-btn no-print" data-action="edit-port-label" data-item="${esc(p.id)}" data-port="${n}" aria-label="Edit label for ${esc(p.name)} port ${n}">edit</button>` : ''}</td>
<td>${cell(front, 'front')}</td>
<td>${cell(rear, 'rear')}</td>
</tr>`);
}
return `
<div class="lt-frame cs-panel" id="item-${esc(p.id)}">
<span class="lt-frame-bl">&#x255A;</span><span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">${esc(p.name)}${p.rack_u ? ` · U${esc(p.rack_u)}` : ''}${p.location ? ` · ${esc(p.location)}` : ''}</div>
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Ports on ${esc(p.name)}</caption>
<thead><tr><th>Port</th><th>Label</th><th>Front (patch cord)</th><th>Rear (run)</th></tr></thead>
<tbody>${rows.join('')}</tbody>
</table>
</div>
</div>`;
}).join('');
}
function renderLabels() {
const { cables, ports, unlabeled_cable_ids: unlabeled } = _cs.labels;
document.getElementById('cs-label-count').textContent = cables.length + ports.length;
const wrap = document.getElementById('cs-labels');
if (!cables.length && !ports.length) {
wrap.innerHTML = emptyState('🏷️', 'Nothing to label', unlabeled.length
? `${unlabeled.length} cable(s) have no ID yet: give them one to add them here.` : '');
return;
}
const tick = (attrs, label) => CS_CAN_EDIT
? `<input type="checkbox" class="no-print" ${attrs} aria-label="${esc(label)}">` : '';
wrap.innerHTML = `
<div class="cs-label-grid">
${cables.map(c => `
<div class="cs-label-card">
${tick(`data-action="applied-cable" data-id="${esc(c.id)}"`, `${c.text} is stuck on`)}
<span class="cs-label-text">${esc(c.text)}</span>
<span class="cs-label-copies">×${esc(c.copies)}</span>
<span class="cs-label-where">${esc(c.where)}</span>
</div>`).join('')}
${ports.map(p => `
<div class="cs-label-card">
${tick(`data-action="applied-port" data-item="${esc(p.item)}" data-port="${esc(p.port)}"`, `${p.text} is stuck on`)}
<span class="cs-label-text">${esc(p.text)}</span>
<span class="cs-label-copies">×1</span>
<span class="cs-label-where">${esc(p.item)} port ${esc(p.port)}</span>
</div>`).join('')}
</div>
${unlabeled.length ? `<p class="cs-hint">${unlabeled.length} more cable(s) have no ID yet.</p>` : ''}`;
}
function renderRack() {
const placed = _cs.items.filter(i => i.rack_u).sort((a, b) => (b.rack_u + (b.rack_height || 1)) - (a.rack_u + (a.rack_height || 1)));
const wrap = document.getElementById('cs-rack');
if (!placed.length) {
wrap.innerHTML = emptyState('🗄️', 'No rack positions recorded', CS_CAN_EDIT ? 'Set a rack unit on a panel, shelf or device in the inventory.' : '');
return;
}
wrap.innerHTML = `
<div class="cs-rack">
${placed.map(i => {
const top = i.rack_u + (i.rack_height || 1) - 1;
return `
<div class="cs-rack-unit" style="--u:${Math.min(i.rack_height || 1, 8)}">
<span class="cs-rack-u">${i.rack_height > 1 ? `U${esc(top)}–${esc(i.rack_u)}` : `U${esc(i.rack_u)}`}</span>
<span class="cs-rack-name">${deviceLink(i.name)}</span>
<span class="cs-rack-kind">${esc(KIND_TEXT[i.kind] || i.kind)}</span>
</div>`;
}).join('')}
</div>`;
}
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;
const wrap = document.getElementById('cs-items');
if (!items.length) {
wrap.innerHTML = emptyState('📦', 'No items yet', CS_CAN_EDIT ? 'Add your patch panel first: + Panel / item.' : '');
return;
}
wrap.innerHTML = `
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Inventory</caption>
<thead><tr><th>Name</th><th>Kind</th><th>Ports</th><th>Rack</th><th>Location</th><th>Notes</th>${CS_CAN_EDIT ? '<th class="no-print"></th>' : ''}</tr></thead>
<tbody>${items.map(i => `
<tr id="${i.kind === 'patch_panel' ? '' : `item-${esc(i.id)}`}">
<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>${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>` : ''}
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
function renderHistory() {
const rows = _cs.history || [];
const wrap = document.getElementById('cs-history');
if (!rows.length) {
wrap.innerHTML = emptyState('📋', 'No changes yet', '');
return;
}
wrap.innerHTML = `
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Change history</caption>
<thead><tr><th>When</th><th>Who</th><th>Change</th></tr></thead>
<tbody>${rows.map(h => `
<tr><td class="ts-cell">${esc(fmtWhen(h.changed_at))}</td><td>${esc(h.changed_by)}</td><td>${esc(h.summary)}</td></tr>`).join('')}
</tbody>
</table>
</div>`;
}
function fmtWhen(iso) {
const d = new Date(iso);
return isNaN(d) ? iso : d.toLocaleString();
}
function emptyState(icon, title, body) {
return `<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">${icon}</div>
<div class="lt-empty-state-title">${esc(title)}</div>${body ? `<div class="lt-empty-state-body">${esc(body)}</div>` : ''}</div>`;
}
function renderDatalists() {
document.getElementById('cs-device-list').innerHTML =
(_cs.catalog || []).map(d => `<option value="${esc(d.name)}">${esc(d.kind)}</option>`).join('');
}
function render() {
renderSummary();
renderCables();
renderSuggestions();
renderPanels();
renderLabels();
renderRack();
renderItems();
renderHistory();
renderDatalists();
}
async function load() {
try {
_cs = await lt.api.get('/api/cutsheet');
} catch (err) {
document.getElementById('cs-summary').innerHTML =
`<div class="lt-badge badge-critical">Couldn't load the cutsheet: ${esc(err.message)}</div>`;
return;
}
render();
const hash = window.location.hash;
if (hash && /^#(cable|item)-\d+$/.test(hash)) {
const el = document.getElementById(hash.slice(1));
if (el) { el.classList.add('cs-flash'); el.scrollIntoView({ block: 'center' }); }
}
}
// ── Editing ─────────────────────────────────────────────────────────────
function fillPorts(end) {
const device = document.querySelector(`#cs-cable-form [name="${end}_device"]`).value;
const entry = (_cs.catalog || []).find(d => d.name === device);
document.getElementById(`cs-${end}-port-list`).innerHTML = entry
? entry.ports.map(p => `<option value="${esc(p.value)}">${esc(p.text)}</option>`).join('') : '';
const side = document.querySelector(`.cs-side[data-end="${end}"]`);
side.hidden = !isPassive(device);
}
function openCable(cable, prefill) {
const form = document.getElementById('cs-cable-form');
form.reset();
const types = ['', ..._cs.options.cable_types];
form.cable_type.innerHTML = types.map(t => `<option value="${esc(t)}">${esc(t || '–')}</option>`).join('');
const data = Object.assign({ status: 'installed', label: cable ? '' : _cs.next_label }, cable || {}, prefill || {});
for (const key of ['id', 'label', 'a_device', 'a_port', 'b_device', 'b_port', 'cable_type', 'length_m', 'color', 'status', 'notes']) {
if (form[key]) form[key].value = data[key] == null ? '' : data[key];
}
form.a_side.value = data.a_side || 'front';
form.b_side.value = data.b_side || 'front';
form.label_applied.checked = !!data.label_applied;
form.querySelector('.cs-delete').hidden = !cable;
document.getElementById('cs-cable-title').textContent = cable ? `Edit ${cable.label || 'cable'}` : 'New cable';
fillPorts('a');
fillPorts('b');
lt.modal.open('cs-cable-modal');
}
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']) {
form[key].value = data[key] == null ? '' : data[key];
}
if (!item) form.port_count.value = 24;
form.querySelector('.cs-delete').hidden = !item;
document.getElementById('cs-item-title').textContent = item ? `Edit ${item.name}` : 'New panel or item';
lt.modal.open('cs-item-modal');
}
function openPortLabel(itemId, port) {
const form = document.getElementById('cs-port-form');
form.reset();
const item = _cs.items.find(i => String(i.id) === String(itemId));
const lab = _cs.port_labels.find(l => String(l.item_id) === String(itemId) && String(l.port) === String(port));
form.item_id.value = itemId;
form.port.value = port;
form.label.value = lab ? lab.label : '';
form.applied.checked = !!(lab && lab.applied);
document.getElementById('cs-port-title').textContent = `${item ? item.name : 'Panel'} port ${port}`;
lt.modal.open('cs-port-modal');
}
function formData(form, fields) {
const out = {};
for (const f of fields) out[f] = form[f].value;
return out;
}
async function save(fn, okText, modalId) {
try {
await fn();
if (modalId) lt.modal.close(modalId);
lt.toast.success(okText);
await load();
} catch (err) {
lt.toast.error(err.message || 'Save failed');
}
}
async function saveCable(e) {
e.preventDefault();
const form = e.target;
const body = formData(form, ['label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
'cable_type', 'length_m', 'color', 'status', 'notes']);
body.label_applied = form.label_applied.checked;
const id = form.id.value;
await save(() => id ? lt.api.put(`/api/cutsheet/cables/${encodeURIComponent(id)}`, body)
: lt.api.post('/api/cutsheet/cables', body), 'Cable saved', 'cs-cable-modal');
}
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 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');
}
async function savePortLabel(e) {
e.preventDefault();
const form = e.target;
const url = `/api/cutsheet/items/${encodeURIComponent(form.item_id.value)}/ports/${encodeURIComponent(form.port.value)}`;
await save(() => lt.api.put(url, { label: form.label.value, applied: form.applied.checked }), 'Label saved', 'cs-port-modal');
}
async function markCableApplied(id, applied) {
const c = _cs.cables.find(x => String(x.id) === String(id));
if (!c) return;
const body = {};
for (const k of ['label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side', 'cable_type', 'length_m', 'color', 'status', 'notes']) {
body[k] = c[k] == null ? '' : c[k];
}
body.label_applied = applied;
await save(() => lt.api.put(`/api/cutsheet/cables/${encodeURIComponent(id)}`, body), `${c.label} marked as labeled`);
}
async function markPortApplied(itemName, port, applied) {
const item = itemByName(itemName);
const lab = item && _cs.port_labels.find(l => l.item_id === item.id && String(l.port) === String(port));
if (!lab) return;
await save(() => lt.api.put(`/api/cutsheet/items/${encodeURIComponent(item.id)}/ports/${encodeURIComponent(port)}`,
{ label: lab.label, applied }), `${lab.label} marked as labeled`);
}
async function importCsv(e, dryRun) {
if (e) e.preventDefault();
const errorsEl = document.getElementById('cs-import-errors');
errorsEl.innerHTML = '';
const fileInput = document.getElementById('cs-import-file');
let text = document.getElementById('cs-import-text').value;
if (fileInput.files.length) text = await fileInput.files[0].text();
let resp, data;
try {
// fetch directly: lt.api keeps only the message, and the row errors matter here
resp = await fetch('/api/cutsheet/import', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, lt.csrf.headers()),
body: JSON.stringify({ csv: text, dry_run: dryRun }),
});
data = await resp.json();
} catch (err) {
errorsEl.innerHTML = `<div class="val-crit">Import failed: ${esc(err.message)}</div>`;
return;
}
if (!resp.ok) {
errorsEl.innerHTML = `<div class="val-crit">${esc(data.error || `HTTP ${resp.status}`)}</div>` +
(data.errors ? `<ul>${data.errors.map(x => `<li>${esc(x)}</li>`).join('')}</ul>` : '');
return;
}
if (dryRun) {
errorsEl.innerHTML = `<div class="val-good">${esc(data.rows)} row(s) look valid. Nothing imported yet.</div>`;
return;
}
lt.modal.close('cs-import-modal');
lt.toast.success(`Imported: ${data.added} added, ${data.updated} updated`);
await load();
}
// ── Wiring ──────────────────────────────────────────────────────────────
document.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'new-cable') openCable(null);
else if (action === 'new-item') openItem(null);
else if (action === 'edit-cable') openCable(_cs.cables.find(c => String(c.id) === btn.dataset.id));
else if (action === 'edit-item') openItem(_cs.items.find(i => String(i.id) === btn.dataset.id));
else if (action === 'edit-port-label') openPortLabel(btn.dataset.item, btn.dataset.port);
else if (action === 'print-labels') printLabels();
else if (action === 'toggle-suggestions') { _showAllSuggestions = !_showAllSuggestions; renderSuggestions(); }
else if (action === 'import-check') importCsv(null, true);
else if (action === 'add-suggestion') {
const s = _cs.suggestions[Number(btn.dataset.i)];
openCable(null, {
a_device: s.switch, a_port: String(s.port_idx), b_device: s.neighbor, b_port: s.neighbor_port,
cable_type: s.cable_type,
});
} else if (action === 'delete-cable') {
const form = document.getElementById('cs-cable-form');
if (!confirm('Delete this cable record? Retiring it keeps the history visible; deleting removes it.')) return;
save(() => lt.api.delete(`/api/cutsheet/cables/${encodeURIComponent(form.id.value)}`), 'Cable deleted', 'cs-cable-modal');
} else if (action === 'delete-item') {
const form = document.getElementById('cs-item-form');
if (!confirm(`Delete ${form.name.value}?`)) return;
save(() => lt.api.delete(`/api/cutsheet/items/${encodeURIComponent(form.id.value)}`), 'Deleted', 'cs-item-modal');
}
});
document.addEventListener('change', e => {
const t = e.target;
if (t.dataset.action === 'applied-cable') markCableApplied(t.dataset.id, t.checked);
else if (t.dataset.action === 'applied-port') markPortApplied(t.dataset.item, t.dataset.port, t.checked);
});
function printLabels() {
document.body.classList.add('cs-print-labels');
window.print();
document.body.classList.remove('cs-print-labels');
}
function openFromQuery() {
const q = new URLSearchParams(window.location.search);
if (!CS_CAN_EDIT || !q.get('a_device')) return;
openCable(null, { a_device: q.get('a_device'), a_port: q.get('a_port') || '' });
history.replaceState(null, '', window.location.pathname);
}
document.getElementById('cs-filter').addEventListener('input', () => _cs && renderCables());
document.getElementById('cs-status-filter').addEventListener('change', () => _cs && renderCables());
document.getElementById('cs-problems-only').addEventListener('change', () => _cs && renderCables());
if (CS_CAN_EDIT) {
document.getElementById('cs-cable-form').addEventListener('submit', saveCable);
document.getElementById('cs-item-form').addEventListener('submit', saveItem);
document.getElementById('cs-port-form').addEventListener('submit', savePortLabel);
document.getElementById('cs-import-form').addEventListener('submit', e => importCsv(e, false));
for (const end of ['a', 'b']) {
const input = document.querySelector(`#cs-cable-form [name="${end}_device"]`);
input.addEventListener('input', () => fillPorts(end));
input.addEventListener('change', () => fillPorts(end));
}
}
load().then(openFromQuery);
+25
View File
@@ -89,6 +89,29 @@ function faceplatePortTitle(port, idx, role) {
return escHtml(parts.join(' · '));
}
// ── Cutsheet section of the port panel ──────────────────────────────────
const CUTSHEET_PROBLEM = new Set(['wrong_neighbor', 'no_link', 'missing_port', 'conflict']);
function cutsheetPanelHtml(swName, idx) {
const entries = ((_apiData && _apiData.cutsheet) || []).filter(e => e.port === String(idx));
const head = '<div class="lt-divider"><span class="lt-divider-label">Cutsheet</span></div>';
if (!entries.length) {
const add = `/cutsheet?a_device=${encodeURIComponent(swName)}&a_port=${encodeURIComponent(idx)}`;
return `${head}<div class="panel-row"><span class="panel-label">Planned</span><span class="panel-val">
<span class="val-neutral">Not documented</span> · <a class="device-link" href="${escHtml(add)}">Add cable</a></span></div>`;
}
return head + entries.map(e => {
const cls = CUTSHEET_PROBLEM.has(e.verdict) ? 'val-crit' : (e.verdict === 'match' ? 'val-good' : 'val-neutral');
const path = (e.path || []).filter(h => !('cable_id' in h)).map(h => escHtml(h.text)).join(' → ');
const name = e.label ? escHtml(e.label) : 'unlabeled';
return `
<div class="panel-row"><span class="panel-label">Cable</span><span class="panel-val">
<a class="device-link" href="/cutsheet#cable-${encodeURIComponent(e.id)}">${name}</a> · ${escHtml(e.status)}</span></div>
<div class="cutsheet-check ${cls}">${escHtml(e.detail)}</div>
${path ? `<div class="cutsheet-path">${path}</div>` : ''}`;
}).join('');
}
// ── SFP module section of the port panel ────────────────────────────────
function sfpPanelHtml(sfp) {
if (!sfp) return '';
@@ -351,6 +374,7 @@ function renderPanel(swName, idx) {
<div class="panel-row"><span class="panel-label" data-tooltip="Autonegotiation: NIC and switch automatically agree speed and duplex.">Auto-neg</span><span class="panel-val val-neutral">${autoneg}</span></div>
<div class="panel-row"><span class="panel-label">Media</span><span class="panel-val">${escHtml(mediaStr)}</span></div>
${cutsheetPanelHtml(swName, idx)}
${sfpPanelHtml(d.sfp)}
${poeHtml}
${trafficHtml}
@@ -442,6 +466,7 @@ function renderPortInspector(detail) {
outlets: detail.info && detail.info.outlets, online: detail.status === 'up',
} },
hosts: detail.neighbor_hosts || {},
cutsheet: detail.cutsheet || [],
};
const chassis = document.getElementById('device-chassis');
// Auto-refresh redraws the panel, so keep a scrolled panel where the user left it
+72
View File
@@ -1313,3 +1313,75 @@ polygon.fp-led-off { fill: #6b6c71; }
.fp-fan-strut { fill: none; stroke: #c9c9cc; stroke-width: 5; }
.fp-epo { fill: #1c1c1f; }
.fp-bracket { fill: none; stroke: #55565c; stroke-width: 1; }
/* ── Cutsheet ─────────────────────────────────────────────────────────── */
.cutsheet-check { font-size: 12px; padding: 2px 0; overflow-wrap: anywhere; }
.cutsheet-path { font-size: 11px; color: var(--text-muted, #8aa); padding: 2px 0 8px; overflow-wrap: anywhere; }
.cs-more { margin-top: 8px; }
.cs-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.cs-summary { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 18px; }
.cs-chip { padding: 5px 10px; border: 1px solid var(--border-color, #234); border-radius: 3px; font-size: .82rem;
color: inherit; text-decoration: none; }
.cs-chip b { font-size: .95rem; }
.cs-chip-ok b { color: var(--status-ok, #2ecc71); }
.cs-chip-bad { border-color: var(--status-critical, #e0312b); }
.cs-chip-bad b { color: var(--status-critical, #e0312b); }
.cs-chip-warn b { color: var(--accent-amber, #f0a020); }
.cs-filters { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 10px; }
.cs-filters .lt-input { flex: 1 1 240px; min-width: 0; }
.cs-filters .lt-select { flex: 0 1 180px; }
.cs-check { display: inline-flex; gap: 6px; align-items: center; font-size: .85rem; cursor: pointer; }
.cs-hint { font-size: .82rem; color: var(--text-muted, #8aa); margin: 0 0 10px; max-width: 75ch; }
.cs-table td { vertical-align: top; }
.cs-id { font-weight: 700; white-space: nowrap; }
.cs-port, .cs-side-tag { font-size: .8rem; color: var(--text-muted, #8aa); }
.cs-side-tag { text-transform: uppercase; letter-spacing: .04em; }
.cs-path, .cs-detail { font-size: .76rem; color: var(--text-muted, #8aa); margin-top: 3px; }
.cs-tag { font-size: .68rem; padding: 1px 5px; border: 1px solid var(--accent-amber, #f0a020); color: var(--accent-amber, #f0a020);
border-radius: 2px; white-space: nowrap; }
.cs-row-problem td:first-child { box-shadow: inset 3px 0 0 var(--status-critical, #e0312b); }
.cs-flash { animation: cs-flash 2.4s ease-out 1; }
@keyframes cs-flash { from { background: rgba(240,160,32,.28); } to { background: transparent; } }
@media (prefers-reduced-motion: reduce) { .cs-flash { animation: none; outline: 2px solid var(--accent-amber, #f0a020); } }
.cs-panel { margin-bottom: 16px; }
.cs-link-btn { background: none; border: 0; padding: 0 0 0 6px; color: var(--accent-cyan, #3cc); cursor: pointer;
font: inherit; font-size: .78rem; text-decoration: underline; }
.cs-label-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
.cs-label-card { display: grid; grid-template-columns: auto 1fr auto; gap: 2px 8px; align-items: center;
padding: 8px 10px; border: 1px dashed var(--border-color, #234); border-radius: 3px; }
.cs-label-text { font-family: var(--font-mono, monospace); font-size: 1.05rem; font-weight: 700; letter-spacing: .05em; }
.cs-label-copies { font-size: .8rem; color: var(--text-muted, #8aa); }
.cs-label-where { grid-column: 1 / -1; font-size: .74rem; color: var(--text-muted, #8aa); }
.cs-rack { display: flex; flex-direction: column; gap: 3px; max-width: 560px; }
.cs-rack-unit { display: grid; grid-template-columns: 70px 1fr auto; gap: 10px; align-items: center;
min-height: calc(var(--u, 1) * 26px); padding: 4px 10px; border: 1px solid var(--border-color, #234);
border-radius: 2px; background: rgba(255,255,255,.02); }
.cs-rack-u { font-family: var(--font-mono, monospace); font-size: .78rem; color: var(--text-muted, #8aa); }
.cs-rack-kind { font-size: .74rem; color: var(--text-muted, #8aa); }
.cs-end { border: 1px solid var(--border-color, #234); border-radius: 3px; padding: 4px 12px 0; margin: 0 0 12px; }
.cs-end legend { font-size: .78rem; padding: 0 6px; color: var(--text-muted, #8aa); text-transform: uppercase; }
.cs-side[hidden] { display: none; }
.cs-modal-wide { max-width: 720px; width: calc(100vw - 32px); }
.cs-mono { font-family: var(--font-mono, monospace); font-size: .8rem; }
.cs-errors ul { margin: 6px 0 0 18px; font-size: .8rem; }
@media (max-width: 640px) {
/* Cables become cards: one labeled field per line */
.cs-table thead { display: none; }
.cs-table, .cs-table tbody, .cs-table tr, .cs-table td { display: block; width: 100%; }
.cs-table tr { border: 1px solid var(--border-color, #234); border-radius: 3px; margin-bottom: 10px; padding: 6px 10px; }
.cs-table td { border: 0 !important; padding: 3px 0 !important; overflow-wrap: anywhere; }
.cs-table td[data-label]::before { content: attr(data-label); display: block; font-size: .68rem;
text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted, #8aa); }
.cs-table td.cs-id { font-size: 1rem; }
.cs-row-problem { box-shadow: inset 3px 0 0 var(--status-critical, #e0312b); }
.cs-row-problem td:first-child { box-shadow: none; }
.cs-rack-unit { grid-template-columns: 56px 1fr; }
.cs-rack-kind { display: none; }
}
@media print {
.no-print, .lt-header, .lt-nav-drawer, .lt-nav-drawer-overlay { display: none !important; }
body.cs-print-labels .g-section:not(#cs-labels-section),
body.cs-print-labels .lt-page-header, body.cs-print-labels .cs-summary { display: none !important; }
.cs-label-card { break-inside: avoid; border-color: #000; color: #000; }
.lt-table, .lt-table td, .lt-table th { color: #000 !important; }
}
+9
View File
@@ -37,6 +37,9 @@
<a href="{{ url_for('index') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'index' %} active{% endif %}"
{% if request.endpoint == 'index' %}aria-current="page"{% endif %}>Dashboard</a>
<a href="{{ url_for('cutsheet_page') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'cutsheet_page' %} active{% endif %}"
{% if request.endpoint == 'cutsheet_page' %}aria-current="page"{% endif %}>Cutsheet</a>
{% if user.groups and 'admin' in user.groups %}
<a href="{{ url_for('suppressions_page') }}"
class="lt-nav-drawer-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
@@ -79,6 +82,11 @@
{% if request.endpoint == 'index' %}aria-current="page"{% endif %}>
Dashboard
</a>
<a href="{{ url_for('cutsheet_page') }}"
class="lt-nav-link{% if request.endpoint == 'cutsheet_page' %} active{% endif %}"
{% if request.endpoint == 'cutsheet_page' %}aria-current="page"{% endif %}>
Cutsheet
</a>
{% if user.groups and 'admin' in user.groups %}
<a href="{{ url_for('suppressions_page') }}"
class="lt-nav-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
@@ -316,6 +324,7 @@
// Command palette
lt.cmdPalette.init([
{ id: 'nav-dashboard', group: 'Navigate', icon: '~', label: 'Dashboard', action: function() { window.location.href = '/'; } },
{ id: 'nav-cutsheet', group: 'Navigate', icon: '🔌', label: 'Cutsheet', action: function() { window.location.href = '/cutsheet'; } },
{ id: 'nav-suppressions', group: 'Navigate', icon: '🔕', label: 'Suppressions', action: function() { window.location.href = '/suppressions'; } },
{ id: 'action-refresh', group: 'Actions', icon: '↻', label: 'Refresh Data', kbd: 'R', action: function() { lt.autoRefresh.now(); } },
{ id: 'action-suppress', group: 'Actions', icon: '🔕', label: 'New Suppression', kbd: 'S', action: function() { if (typeof openSuppressModal === 'function') openSuppressModal('host','',''); else window.location.href='/suppressions'; } },
+310
View File
@@ -0,0 +1,310 @@
{% extends "base.html" %}
{% block title %}Cutsheet – GANDALF{% endblock %}
{% block content %}
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Cutsheet</h1>
<p class="g-page-sub">Where every cable is meant to go, checked against what Gandalf sees on the network.</p>
</div>
<div class="cs-actions no-print">
{% if can_edit %}
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" data-action="new-cable">+ Cable</button>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="new-item">+ Panel / item</button>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-modal-open="cs-import-modal">Import CSV</button>
{% endif %}
<a class="lt-btn lt-btn-secondary lt-btn-sm" href="/api/cutsheet/cables.csv">Export CSV</a>
</div>
</div>
<div id="cs-summary" class="cs-summary" aria-live="polite"></div>
<section class="g-section" id="cs-cables-section">
<div class="g-section-header">
<h2 class="g-section-title">Cables</h2>
<span class="g-section-badge" id="cs-cable-count">0</span>
</div>
<div class="cs-filters no-print">
<input type="search" class="lt-input" id="cs-filter" placeholder="Filter by label, device, port or note" aria-label="Filter cables">
<select class="lt-select" id="cs-status-filter" aria-label="Filter by status">
<option value="">All statuses</option>
<option value="installed">Installed</option>
<option value="planned">Planned</option>
<option value="dark">Dark</option>
<option value="retired">Retired</option>
</select>
<label class="cs-check"><input type="checkbox" id="cs-problems-only"> Problems only</label>
</div>
<div id="cs-cables"></div>
</section>
<section class="g-section no-print" id="cs-suggest-section">
<div class="g-section-header">
<h2 class="g-section-title">Undocumented links</h2>
<span class="g-section-badge" id="cs-suggest-count">0</span>
</div>
<p class="cs-hint">Switch ports with a live link that no cable record mentions. Adding one pre-fills what Gandalf already knows; insert the patch panel hop if the cable runs through one.</p>
<div id="cs-suggestions"></div>
</section>
<section class="g-section" id="cs-panels-section">
<div class="g-section-header">
<h2 class="g-section-title">Patch panels</h2>
</div>
<div id="cs-panels"></div>
</section>
<section class="g-section" id="cs-labels-section">
<div class="g-section-header">
<h2 class="g-section-title">Labels to make</h2>
<span class="g-section-badge" id="cs-label-count">0</span>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm no-print" data-action="print-labels">Print list</button>
</div>
<p class="cs-hint">Type these on the label maker. Cable IDs go on both ends of the cord; panel labels go on the port. Tick each one once it's stuck on.</p>
<div id="cs-labels"></div>
</section>
<section class="g-section" id="cs-rack-section">
<div class="g-section-header">
<h2 class="g-section-title">Rack</h2>
</div>
<div id="cs-rack"></div>
</section>
<section class="g-section" id="cs-items-section">
<div class="g-section-header">
<h2 class="g-section-title">Inventory</h2>
<span class="g-section-badge" id="cs-item-count">0</span>
</div>
<p class="cs-hint">Things Gandalf can't discover: patch panels, wall jacks, shelves, and rack positions for devices.</p>
<div id="cs-items"></div>
</section>
<section class="g-section no-print" id="cs-history-section">
<div class="g-section-header">
<h2 class="g-section-title">History</h2>
</div>
<div id="cs-history"></div>
</section>
<datalist id="cs-device-list"></datalist>
<datalist id="cs-a-port-list"></datalist>
<datalist id="cs-b-port-list"></datalist>
{% if can_edit %}
<!-- Cable editor -->
<div id="cs-cable-modal" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="cs-cable-title" aria-hidden="true">
<div class="lt-modal cs-modal-wide">
<div class="lt-modal-header">
<h3 class="lt-modal-title" id="cs-cable-title">Cable</h3>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="cs-cable-form" autocomplete="off">
<div class="lt-modal-body">
<input type="hidden" name="id">
<div class="form-row">
<div class="lt-form-group">
<label class="lt-label" for="cs-label">Cable ID</label>
<input class="lt-input" id="cs-label" name="label" maxlength="32" placeholder="C01">
<div class="lt-field-hint">Leave blank for a cable you haven't labeled.</div>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-status">Status</label>
<select class="lt-select" id="cs-status" name="status">
<option value="installed">Installed</option>
<option value="planned">Planned</option>
<option value="dark">Dark (run, not in use)</option>
<option value="retired">Retired</option>
</select>
</div>
</div>
<fieldset class="cs-end">
<legend>From</legend>
<div class="form-row">
<div class="lt-form-group">
<label class="lt-label" for="cs-a-device">Device or item <span class="required">*</span></label>
<input class="lt-input" id="cs-a-device" name="a_device" list="cs-device-list" required maxlength="100">
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-a-port">Port</label>
<input class="lt-input" id="cs-a-port" name="a_port" list="cs-a-port-list" maxlength="64">
</div>
<div class="lt-form-group cs-side" data-end="a">
<label class="lt-label" for="cs-a-side">Side</label>
<select class="lt-select" id="cs-a-side" name="a_side">
<option value="front">Front</option>
<option value="rear">Rear</option>
</select>
</div>
</div>
</fieldset>
<fieldset class="cs-end">
<legend>To</legend>
<div class="form-row">
<div class="lt-form-group">
<label class="lt-label" for="cs-b-device">Device or item</label>
<input class="lt-input" id="cs-b-device" name="b_device" list="cs-device-list" maxlength="100"
placeholder="blank = unterminated">
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-b-port">Port</label>
<input class="lt-input" id="cs-b-port" name="b_port" list="cs-b-port-list" maxlength="64">
</div>
<div class="lt-form-group cs-side" data-end="b">
<label class="lt-label" for="cs-b-side">Side</label>
<select class="lt-select" id="cs-b-side" name="b_side">
<option value="front">Front</option>
<option value="rear">Rear</option>
</select>
</div>
</div>
</fieldset>
<div class="form-row">
<div class="lt-form-group">
<label class="lt-label" for="cs-type">Type</label>
<select class="lt-select" id="cs-type" name="cable_type"></select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-length">Length (m)</label>
<input class="lt-input" id="cs-length" name="length_m" type="number" min="0.01" max="999" step="0.01">
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-color">Color</label>
<input class="lt-input" id="cs-color" name="color" maxlength="32">
</div>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-notes">Notes</label>
<textarea class="lt-input" id="cs-notes" name="notes" rows="2" maxlength="2000"></textarea>
</div>
<label class="cs-check"><input type="checkbox" name="label_applied"> Label is on the cable</label>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-danger cs-delete" data-action="delete-cable">Delete</button>
<button type="button" class="lt-btn lt-btn-secondary" data-modal-close>Cancel</button>
<button type="submit" class="lt-btn lt-btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- Item editor -->
<div id="cs-item-modal" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="cs-item-title" aria-hidden="true">
<div class="lt-modal">
<div class="lt-modal-header">
<h3 class="lt-modal-title" id="cs-item-title">Item</h3>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="cs-item-form" autocomplete="off">
<div class="lt-modal-body">
<input type="hidden" name="id">
<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="form-row">
<div class="lt-form-group">
<label class="lt-label" for="cs-item-kind">Kind</label>
<select class="lt-select" id="cs-item-kind" name="kind">
<option value="patch_panel">Patch panel</option>
<option value="wall_jack">Wall jack</option>
<option value="device">Device</option>
<option value="shelf">Shelf</option>
<option value="other">Other</option>
</select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-item-ports">Ports</label>
<input class="lt-input" id="cs-item-ports" name="port_count" type="number" min="1" max="512">
</div>
</div>
<div class="form-row">
<div class="lt-form-group">
<label class="lt-label" for="cs-item-u">Rack unit (bottom)</label>
<input class="lt-input" id="cs-item-u" name="rack_u" type="number" min="1" max="60">
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-item-h">Height (U)</label>
<input class="lt-input" id="cs-item-h" name="rack_height" type="number" min="1" max="20">
</div>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-item-location">Location</label>
<input class="lt-input" id="cs-item-location" name="location" maxlength="255" placeholder="e.g. rack, office wall">
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-item-notes">Notes</label>
<textarea class="lt-input" id="cs-item-notes" name="notes" rows="2" maxlength="2000"></textarea>
</div>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-danger cs-delete" data-action="delete-item">Delete</button>
<button type="button" class="lt-btn lt-btn-secondary" data-modal-close>Cancel</button>
<button type="submit" class="lt-btn lt-btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- Panel port label -->
<div id="cs-port-modal" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="cs-port-title" aria-hidden="true">
<div class="lt-modal">
<div class="lt-modal-header">
<h3 class="lt-modal-title" id="cs-port-title">Port label</h3>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="cs-port-form" autocomplete="off">
<div class="lt-modal-body">
<input type="hidden" name="item_id">
<input type="hidden" name="port">
<div class="lt-form-group">
<label class="lt-label" for="cs-port-label">Label</label>
<input class="lt-input" id="cs-port-label" name="label" maxlength="64" placeholder="e.g. GUEST BR">
<div class="lt-field-hint">Usually where the port's rear run goes. Blank removes it.</div>
</div>
<label class="cs-check"><input type="checkbox" name="applied"> Label is on the panel</label>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-secondary" data-modal-close>Cancel</button>
<button type="submit" class="lt-btn lt-btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- CSV import -->
<div id="cs-import-modal" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="cs-import-title" aria-hidden="true">
<div class="lt-modal cs-modal-wide">
<div class="lt-modal-header">
<h3 class="lt-modal-title" id="cs-import-title">Import cables from CSV</h3>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<form id="cs-import-form">
<div class="lt-modal-body">
<p class="cs-hint">Same columns as Export CSV. Rows with a cable ID that already exists update that cable; other rows are added. If any row is invalid, nothing is imported.</p>
<div class="lt-form-group">
<label class="lt-label" for="cs-import-file">CSV file</label>
<input class="lt-input" id="cs-import-file" type="file" accept=".csv,text/csv">
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-import-text">Or paste CSV</label>
<textarea class="lt-input cs-mono" id="cs-import-text" name="csv" rows="8"></textarea>
</div>
<div id="cs-import-errors" class="cs-errors" role="alert"></div>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn lt-btn-secondary" data-modal-close>Cancel</button>
<button type="button" class="lt-btn lt-btn-secondary" data-action="import-check">Check</button>
<button type="submit" class="lt-btn lt-btn-primary">Import</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>const CS_CAN_EDIT = {{ 'true' if can_edit else 'false' }};</script>
<script src="{{ url_for('static', filename='cutsheet.js') }}"></script>
{% endblock %}
+106
View File
@@ -171,3 +171,109 @@ class TestDiagnoseWithResolvedNeighbor:
finally:
STATE['link_stats'] = saved
assert resp.status_code == 200 and resp.get_json()['job_id']
CUTSHEET = {
'items': [{'id': 1, 'name': 'PP1', 'kind': 'patch_panel', 'port_count': 24, 'location': 'rack',
'rack_u': 25, 'rack_height': 1, 'notes': ''}],
'cables': [{'id': 7, 'label': 'C07', 'a_device': 'USW Flex', 'a_port': '2', 'a_side': '',
'b_device': 'PP1', 'b_port': '5', 'b_side': 'front', 'status': 'installed',
'cable_type': 'Cat6', 'length_m': None, 'color': '', 'notes': '', 'label_applied': False}],
'port_labels': [{'item_id': 1, 'item_name': 'PP1', 'port': '5', 'label': 'OFFICE', 'applied': False}],
}
USER = dict(AUTH, **{'Remote-Groups': 'staff'})
@pytest.fixture
def cs_client(client):
gandalf._cutsheet_schema_ready = True
db = gandalf.db
db.cutsheet_load.side_effect = lambda: json.loads(json.dumps(CUTSHEET))
db.cutsheet_history.return_value = []
db.cutsheet_create_cable.return_value = 8
db.CutsheetConflict = type('CutsheetConflict', (Exception,), {})
with patch.object(gandalf, '_cfg', dict(CONFIG, auth={'allowed_groups': ['admin', 'staff'],
'trusted_proxies': [PROXY]})):
yield client, db
class TestCutsheetApi:
def test_page_and_view(self, cs_client):
client, _ = cs_client
assert client.get('/cutsheet', headers=AUTH).status_code == 200
view = client.get('/api/cutsheet', headers=AUTH).get_json()
c = view['cables'][0]
assert c['b_text'] == 'PP1 port 5 (front)' and c['verdict'] in view['verdicts']
assert view['next_label'] == 'C01'
assert view['labels']['cables'][0]['text'] == 'C07'
assert any(d['name'] == 'PP1' for d in view['catalog'])
def test_create_cable(self, cs_client):
client, db = cs_client
resp = client.post('/api/cutsheet/cables', headers=AUTH,
json={'a_device': 'USW Flex', 'a_port': '3', 'b_device': 'PP1', 'b_port': '6',
'b_side': 'front', 'label': 'C08'})
assert resp.status_code == 200 and resp.get_json()['id'] == 8
saved, user = db.cutsheet_create_cable.call_args.args
assert saved['b_side'] == 'front' and user == 'jared'
def test_viewers_cannot_edit(self, cs_client):
client, db = cs_client
assert client.get('/api/cutsheet', headers=USER).status_code == 200
resp = client.post('/api/cutsheet/cables', headers=USER, json={'a_device': 'x'})
assert resp.status_code == 403 and not db.cutsheet_create_cable.called
def test_writes_require_json(self, cs_client):
client, db = cs_client
resp = client.post('/api/cutsheet/cables', headers=AUTH, data='a_device=x',
content_type='application/x-www-form-urlencoded')
assert resp.status_code == 400 and not db.cutsheet_create_cable.called
def test_validation_error_is_400(self, cs_client):
client, _ = cs_client
resp = client.post('/api/cutsheet/cables', headers=AUTH, json={'a_device': 'PP1', 'a_port': '3'})
assert resp.status_code == 400 and 'front or rear' in resp.get_json()['error']
def test_conflict_is_409(self, cs_client):
client, db = cs_client
db.cutsheet_create_item.side_effect = db.CutsheetConflict('Another item already has that name')
resp = client.post('/api/cutsheet/items', headers=AUTH, json={'name': 'PP1', 'kind': 'shelf'})
assert resp.status_code == 409 and 'already' in resp.get_json()['error']
def test_missing_records_are_404(self, cs_client):
client, db = cs_client
db.cutsheet_delete_cable.return_value = False
assert client.delete('/api/cutsheet/cables/99', headers=AUTH).status_code == 404
def test_port_label(self, cs_client):
client, db = cs_client
resp = client.put('/api/cutsheet/items/1/ports/5', headers=AUTH, json={'label': 'GUEST BR', 'applied': True})
assert resp.status_code == 200
assert db.cutsheet_set_port_label.call_args.args == (1, '5', 'GUEST BR', True, 'jared')
def test_csv_export_and_all_or_nothing_import(self, cs_client):
client, db = cs_client
text = client.get('/api/cutsheet/cables.csv', headers=AUTH).get_data(as_text=True)
assert text.startswith('label,a_device') and 'C07' in text
bad = client.post('/api/cutsheet/import', headers=AUTH, json={'csv': text + 'C09,PP1,99,front\n'})
assert bad.status_code == 400 and bad.get_json()['errors'] and not db.cutsheet_import_cables.called
dry = client.post('/api/cutsheet/import', headers=AUTH, json={'csv': text, 'dry_run': True})
assert dry.get_json()['rows'] == 1 and not db.cutsheet_import_cables.called
db.cutsheet_import_cables.return_value = {'added': 0, 'updated': 1}
done = client.post('/api/cutsheet/import', headers=AUTH, json={'csv': text})
assert done.get_json()['updated'] == 1
def test_device_page_and_search_include_the_cutsheet(self, cs_client):
client, _ = cs_client
d = client.get('/api/device/USW%20Flex', headers=AUTH).get_json()
assert d['cutsheet'][0]['label'] == 'C07' and d['cutsheet'][0]['port'] == '2'
found = client.get('/api/devices', headers=AUTH).get_json()['devices']
by_name = {x['name']: x for x in found}
assert by_name['C07']['url'] == '/cutsheet#cable-7'
assert by_name['PP1']['url'] == '/cutsheet#item-1'
def test_device_page_survives_a_cutsheet_failure(self, cs_client):
client, db = cs_client
db.cutsheet_load.side_effect = RuntimeError('db down')
d = client.get('/api/device/USW%20Flex', headers=AUTH).get_json()
assert d['cutsheet'] == []
+227
View File
@@ -0,0 +1,227 @@
"""Tests for cutsheet.py: validation, path tracing and plan-vs-actual checks."""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
import cutsheet as cs # noqa: E402
PANEL = {'id': 1, 'name': 'PP1', 'kind': 'patch_panel', 'port_count': 24}
JACK = {'id': 2, 'name': 'Bedroom jack', 'kind': 'wall_jack', 'port_count': 2}
SHELF = {'id': 3, 'name': 'Shelf', 'kind': 'shelf', 'port_count': None}
ITEMS = {i['name']: i for i in (PANEL, JACK, SHELF)}
def cable(cid, a, b=None, status='installed', label=None, **kw):
a_dev, a_port, a_side = (a + ('',) * 3)[:3]
b_dev, b_port, b_side = ((b or ('', '', '')) + ('',) * 3)[:3]
c = {'id': cid, 'label': label, 'a_device': a_dev, 'a_port': a_port, 'a_side': a_side,
'b_device': b_dev, 'b_port': b_port, 'b_side': b_side, 'status': status,
'cable_type': '', 'length_m': None, 'color': '', 'notes': '', 'label_applied': False}
c.update(kw)
return c
def switch(ports):
return {'sw': {'ports': {f'Port {p["port_idx"]}': p for p in ports}}}
def port(idx, up=True, seen='', **kw):
p = {'port_idx': idx, 'up': up}
if seen:
p['lldp'] = {'system_name': seen}
p.update(kw)
return p
class TestValidation:
def test_minimal_cable(self):
c = cs.validate_cable({'a_device': 'sw', 'a_port': '3'}, ITEMS)
assert c['status'] == 'installed' and c['label'] is None and c['b_device'] == ''
def test_panel_needs_side_and_valid_port(self):
with pytest.raises(cs.ValidationError, match='front or rear'):
cs.validate_cable({'a_device': 'PP1', 'a_port': '3'}, ITEMS)
with pytest.raises(cs.ValidationError, match='ports 1 to 24'):
cs.validate_cable({'a_device': 'PP1', 'a_port': '25', 'a_side': 'front'}, ITEMS)
c = cs.validate_cable({'a_device': 'PP1', 'a_port': '3', 'a_side': 'rear'}, ITEMS)
assert c['a_side'] == 'rear'
def test_side_is_dropped_for_active_devices(self):
c = cs.validate_cable({'a_device': 'sw', 'a_port': '3', 'a_side': 'front'}, ITEMS)
assert c['a_side'] == ''
@pytest.mark.parametrize('data, message', [
({}, 'From device is required'),
({'a_device': 'sw', 'label': 'bad label!'}, 'Label may use'),
({'a_device': 'sw', 'status': 'gone'}, 'status must be'),
({'a_device': 'sw', 'cable_type': 'Cat9'}, 'Type must be'),
({'a_device': 'sw', 'length_m': 'long'}, 'Length must be a number'),
({'a_device': 'sw', 'length_m': '0'}, 'between 0 and 999'),
({'a_device': 'sw', 'b_port': '3'}, 'Choose a device before a port'),
({'a_device': 'sw', 'a_port': '1', 'b_device': 'sw', 'b_port': '1'}, 'same port'),
({'a_device': 'x' * 101}, '100 characters'),
])
def test_rejects(self, data, message):
with pytest.raises(cs.ValidationError, match=message):
cs.validate_cable(data, ITEMS)
def test_item_rules(self):
assert cs.validate_item({'name': 'S', 'kind': 'shelf', 'rack_u': '5'})['rack_height'] == 1
with pytest.raises(cs.ValidationError, match='port count'):
cs.validate_item({'name': 'P', 'kind': 'patch_panel'})
with pytest.raises(cs.ValidationError, match='rack unit to go with'):
cs.validate_item({'name': 'S', 'rack_height': '2'})
with pytest.raises(cs.ValidationError, match='kind must be'):
cs.validate_item({'name': 'S', 'kind': 'rack'})
def test_next_label_fills_gaps(self):
assert cs.next_label([]) == 'C01'
assert cs.next_label([{'label': 'C01'}, {'label': 'c03'}, {'label': 'X9'}]) == 'C02'
class TestTrace:
def test_switch_through_panel_to_jack(self):
cables = [
cable(1, ('sw', '13'), ('PP1', '5', 'front')),
cable(2, ('PP1', '5', 'rear'), ('Bedroom jack', '1', 'rear')),
cable(3, ('Bedroom jack', '1', 'front'), ('tv', 'eth0')),
]
for start in cables:
hops = cs.trace(start, cables, ITEMS)['hops']
ends = {hops[0][0], hops[-1][0]}
assert ends == {'sw', 'tv'}
assert cs.trace(start, cables, ITEMS)['cable_ids'] in ([1, 2, 3], [3, 2, 1])
def test_retired_cables_are_not_followed(self):
cables = [cable(1, ('sw', '1'), ('PP1', '1', 'front')),
cable(2, ('PP1', '1', 'rear'), ('Bedroom jack', '1', 'rear'), status='retired')]
assert cs.trace(cables[0], cables, ITEMS)['cable_ids'] == [1]
def test_unterminated_end(self):
hops = cs.trace(cable(1, ('PP1', '9', 'rear')), [cable(1, ('PP1', '9', 'rear'))], ITEMS)['hops']
assert hops[-1] is None
class TestEvaluate:
def verdict(self, cables, switches, cid=1):
return cs.evaluate(cables, ITEMS, switches)[cid]['verdict']
def test_match_through_a_panel(self):
cables = [cable(1, ('sw', '13'), ('PP1', '5', 'front')),
cable(2, ('PP1', '5', 'rear'), ('tv', 'eth0'))]
assert self.verdict(cables, switch([port(13, seen='tv')])) == 'match'
assert self.verdict(cables, switch([port(13, seen='TV ')]), cid=2) == 'match'
def test_wrong_neighbor(self):
cables = [cable(1, ('sw', '13'), ('tv', 'eth0'))]
result = cs.evaluate(cables, ITEMS, switch([port(13, seen='printer')]))[1]
assert result['verdict'] == 'wrong_neighbor' and 'printer' in result['detail']
def test_no_link_vs_idle(self):
assert self.verdict([cable(1, ('sw', '13'), ('tv', 'eth0'))], switch([port(13, up=False)])) == 'no_link'
# A run that ends at a jack with nothing plugged in is normal
jack = [cable(1, ('sw', '13'), ('Bedroom jack', '1', 'rear'))]
assert self.verdict(jack, switch([port(13, up=False)])) == 'idle'
def test_unverified_when_nothing_identifies_the_far_end(self):
assert self.verdict([cable(1, ('sw', '13'), ('tv', 'eth0'))], switch([port(13)])) == 'unverified'
jack = [cable(1, ('sw', '13'), ('Bedroom jack', '1', 'rear'))]
result = cs.evaluate(jack, ITEMS, switch([port(13, seen='camera')]))[1]
assert result['verdict'] == 'unverified' and 'camera' in result['detail']
def test_missing_port_and_unknown_devices(self):
assert self.verdict([cable(1, ('sw', '99'), ('tv', 'eth0'))], switch([port(13)])) == 'missing_port'
assert self.verdict([cable(1, ('host-a', 'eth0'), ('host-b', 'eth0'))], switch([])) == 'not_checked'
def test_statuses_skip_the_check(self):
for status in ('planned', 'dark', 'retired'):
cables = [cable(1, ('sw', '13'), ('tv', 'eth0'), status=status)]
assert self.verdict(cables, switch([port(13, seen='printer')])) == status
def test_a_planned_hop_makes_the_run_planned(self):
cables = [cable(1, ('sw', '13'), ('PP1', '5', 'front')),
cable(2, ('PP1', '5', 'rear'), ('tv', 'eth0'), status='planned')]
assert self.verdict(cables, switch([port(13, seen='printer')])) == 'planned'
def test_dac_serial_confirms_both_ends(self):
switches = {
'gw': {'ports': {'p': port(11, sfp={'kind': 'dac', 'cable_peer': {'switch': 'agg', 'port_idx': 7}})}},
'agg': {'ports': {'p': port(7, sfp={'kind': 'dac', 'cable_peer': {'switch': 'gw', 'port_idx': 11}})}},
}
result = cs.evaluate([cable(1, ('gw', '11'), ('agg', '7'))], ITEMS, switches)[1]
assert result['verdict'] == 'match' and 'DAC' in result['detail']
def test_switch_to_switch_reports_the_worse_end(self):
switches = {'a': {'ports': {'p': port(1, seen='b')}}, 'b': {'ports': {'p': port(2, seen='c')}}}
assert self.verdict([cable(1, ('a', '1'), ('b', '2'))], switches) == 'wrong_neighbor'
def test_two_installed_cables_on_one_port_conflict(self):
cables = [cable(1, ('sw', '13'), ('tv', 'eth0'), label='C01'),
cable(2, ('sw', '13'), ('printer', 'eth0'), label='C02'),
cable(3, ('sw', '13'), ('tv', 'eth0'), status='planned')]
result = cs.evaluate(cables, ITEMS, switch([port(13, seen='tv')]))
assert result[1]['verdict'] == 'conflict' and 'C02' in result[1]['detail']
assert result[3]['verdict'] == 'planned'
class TestUndocumented:
def test_lists_live_ports_without_a_cable(self):
switches = switch([port(1, seen='tv'), port(2), port(3, up=False)])
found = cs.undocumented([cable(1, ('sw', '2'), ('x', ''))], switches)
assert [(s['port_idx'], s['neighbor']) for s in found] == [(1, 'tv')]
def test_retired_cables_dont_count(self):
found = cs.undocumented([cable(1, ('sw', '1'), ('x', ''), status='retired')], switch([port(1)]))
assert len(found) == 1
def test_dac_offered_once_from_one_end(self):
switches = {
'agg': {'ports': {'p': port(7, sfp={'kind': 'dac', 'cable_peer': {'switch': 'gw', 'port_idx': 11}})}},
'gw': {'ports': {'p': port(11, sfp={'kind': 'dac', 'cable_peer': {'switch': 'agg', 'port_idx': 7}})}},
}
found = cs.undocumented([], switches)
assert len(found) == 1 and found[0]['cable_type'] == 'DAC' and found[0]['via'] == 'DAC serial'
class TestLabels:
def test_only_unapplied_labels_of_live_cables(self):
cables = [cable(1, ('sw', '1'), ('tv', ''), label='C10'), cable(2, ('sw', '2'), ('tv', ''), label='C2'),
cable(3, ('sw', '3'), ('tv', ''), label='C3', label_applied=True),
cable(4, ('sw', '4'), label='C4', status='dark'),
cable(5, ('sw', '5'), ('tv', ''), label='C5', status='planned'),
cable(6, ('sw', '6'), ('tv', ''))]
ports = [{'item_name': 'PP1', 'port': '10', 'label': 'OFFICE', 'applied': False},
{'item_name': 'PP1', 'port': '2', 'label': 'GARAGE', 'applied': False},
{'item_name': 'PP1', 'port': '3', 'label': 'DONE', 'applied': True}]
out = cs.labels_to_make(cables, ITEMS, ports)
assert [c['text'] for c in out['cables']] == ['C2', 'C4', 'C10']
assert [c['copies'] for c in out['cables']] == [2, 1, 2]
assert [p['text'] for p in out['ports']] == ['GARAGE', 'OFFICE']
assert out['unlabeled_cable_ids'] == [6]
class TestCsv:
def test_round_trip(self):
original = [cable(1, ('sw', '13'), ('PP1', '5', 'front'), label='C01', cable_type='Cat6',
length_m=1.5, color='white', label_applied=True, notes='to office, via panel')]
rows, errors = cs.parse_csv(cs.cables_to_csv(original), ITEMS)
assert errors == []
assert rows[0]['label'] == 'C01' and rows[0]['b_side'] == 'front' and rows[0]['length_m'] == 1.5
assert rows[0]['label_applied'] is True and rows[0]['notes'] == 'to office, via panel'
def test_formulas_are_neutralised_and_restored(self):
text = cs.cables_to_csv([cable(1, ('sw', '1'), label='C01', notes='=HYPERLINK("x")')])
assert "'=HYPERLINK" in text
rows, _ = cs.parse_csv(text, ITEMS)
assert rows[0]['notes'] == '=HYPERLINK("x")'
def test_any_bad_row_is_reported(self):
text = 'label,a_device,a_port,a_side\nC01,sw,1,\nC01,sw,2,\n,PP1,40,front\n'
rows, errors = cs.parse_csv(text, ITEMS)
assert any('appears twice' in e for e in errors) and any('Line 4' in e for e in errors)
def test_missing_required_column(self):
assert cs.parse_csv('label\nC1\n', ITEMS) == ([], ['Missing column: a_device'])