From 3834546f384f6eef82191c6e167a3994dd9eb36b Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 11 Nov 2026 17:19:45 -0500 Subject: [PATCH] Mark host interfaces as unused Ports that are never meant to be up (compute-storage-01 nic1-3, large1's spare NIC and USB adapter) kept their hosts "degraded" and were the source of tonight's false alerts. An admin can now mark an interface unused from the device page, with an optional reason. Unused interfaces raise no link-down alerts, don't count toward the cluster-wide alert or the host's status, and show greyed as "unused" on the dashboard, device page and rack. Marking one closes any open link-down alert on it without a recovery note. New table unused_interfaces, created with the cutsheet tables. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +++++++++++ app.py | 40 ++++++++++++++++++++++++++++++-- db.py | 53 +++++++++++++++++++++++++++++++++++++++++++ monitor.py | 23 ++++++++++++++++--- static/device.js | 52 ++++++++++++++++++++++++++++++++++++------ static/fronts.js | 2 +- static/style.css | 8 +++++++ tests/test_app.py | 29 ++++++++++++++++++++++- tests/test_monitor.py | 13 +++++++++++ 9 files changed, 219 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b3dcceb..31a518b 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,18 @@ whatever Gandalf knows around the problem: Titles keep the `[target][auto][production]...` tag format, which Tinker uses to spot duplicates. +### Unused interfaces + +An admin can mark a host interface as unused on its device page (**Mark unused** on a down interface, +with an optional reason; **In use again** to undo). An unused interface: + +- never raises a link-down alert, and can't count toward the cluster-wide alert; +- doesn't count toward the host's status, so a host whose only down ports are unused shows as up; +- shows as "unused" (greyed, dashed dot) on the dashboard, the device page and the rack drawings; +- has any open link-down alert closed quietly when it is marked (no recovery note: nothing recovered). + +Marks are stored in `unused_interfaces` (host, interface, reason, who, when). + ### Recovery notes When an alert that opened a ticket clears, the monitor adds a comment to that ticket: when it cleared, how @@ -753,6 +765,7 @@ Take LED colors from the model's quick start guide. See the comment at the top o - `cutsheet_items` gained `rack`, `shelf`, `device`, `mount`, `chassis`, `facing` and `slot` columns; they are added automatically on first use - `cutsheet_racks`: racks saved on the rack page (name, height, notes) +- `unused_interfaces`: host interfaces marked unused (no alerts, not counted in host status) - `device_links`: each device's inventory URL, by device name; created with the cutsheet tables - `network_events`: one row per active event; `resolved_at` is set when recovered, and `recovery_notified` once the recovery note is on its ticket (column added automatically at monitor startup) diff --git a/app.py b/app.py index 4b49211..683a760 100644 --- a/app.py +++ b/app.py @@ -528,6 +528,12 @@ def api_device(name: str): logger.error(f'Cutsheet unavailable for {name}: {e}') detail['cutsheet'] = [] detail['inventory'] = _inventory_for(name, detail.get('model') or '') + try: + _ensure_cutsheet_schema() + detail['unused_interfaces'] = db.get_unused_interfaces().get(name, {}) + except Exception as e: + logger.error(f'Unused interfaces unavailable for {name}: {e}') + detail['unused_interfaces'] = {} return jsonify(detail) @@ -1114,8 +1120,8 @@ def _physical_interfaces(name: str, snapshot: dict) -> list: Drawn fronts light one RJ45 per NIC in this order (first NIC on the leftmost jack). """ ifaces = ((snapshot.get('hosts') or {}).get(name) or {}).get('interfaces') or {} - return [{'name': n, 'up': str(state).lower() == 'up'} for n, state in sorted(ifaces.items()) - if not n.startswith(_VIRTUAL_IFACE)] + return [{'name': n, 'up': str(state).lower() == 'up', 'unused': str(state).lower() == 'unused'} + for n, state in sorted(ifaces.items()) if not n.startswith(_VIRTUAL_IFACE)] def _device_status(name: str, snapshot: dict) -> str: @@ -1257,6 +1263,36 @@ def api_cutsheet_delete_rack(rack_id: int): return jsonify({'success': True}) +# --------------------------------------------------------------------------- +# Unused interfaces +# --------------------------------------------------------------------------- + +_IFACE_RE = re.compile(r'^[A-Za-z0-9_.:@-]{1,64}$') + + +@app.route('/api/device//interfaces//unused', methods=['PUT', 'DELETE']) +@require_auth +@require_admin +@_cutsheet_write +def api_interface_unused(name: str, iface: str): + """Mark a host interface unused (PUT {reason}) or in use again (DELETE). + + Unused interfaces raise no link-down alerts and don't count toward the host's status; + marking one quietly closes any link-down alert it has open. + """ + if len(name) > 255 or not _IFACE_RE.match(iface): + raise cutsheet.ValidationError('Not a valid host or interface name') + user = _get_user()['username'] + if request.method == 'DELETE': + found = db.clear_unused_interface(name, iface) + logger.info(f'{user} marked {name} {iface} in use again') + return jsonify({'success': True, 'changed': found}) + reason = str(_json_body().get('reason') or '').strip()[:255] + db.set_unused_interface(name, iface, reason, user) + logger.info(f'{user} marked {name} {iface} unused: {reason or "(no reason)"}') + return jsonify({'success': True}) + + @app.route('/api/cutsheet/cables', methods=['POST']) @require_auth @require_admin diff --git a/db.py b/db.py index e55da2d..8f80984 100644 --- a/db.py +++ b/db.py @@ -738,6 +738,15 @@ _CUTSHEET_SCHEMA = ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_rack_name (name) ) ENGINE=InnoDB""", + # Host interfaces an admin has marked as not in use: no alerts, not counted in host status + """CREATE TABLE IF NOT EXISTS unused_interfaces ( + host VARCHAR(255) NOT NULL, + iface VARCHAR(64) NOT NULL, + reason VARCHAR(255) NOT NULL DEFAULT '', + set_by VARCHAR(255) NOT NULL, + set_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (host, iface) + ) ENGINE=InnoDB""", # Where a device lives in the asset inventory (HomeBox), set by hand on its Gandalf page """CREATE TABLE IF NOT EXISTS device_links ( name VARCHAR(255) NOT NULL PRIMARY KEY, @@ -1107,3 +1116,47 @@ def cutsheet_delete_rack(rack_id: int, user: str) -> bool: cur.execute('DELETE FROM cutsheet_racks WHERE id=%s', (rack_id,)) _cs_log(cur, user, 'rack', rack_id, 'delete', f'Removed rack {before["name"]}', _cs_jsonable(before), None) return True + + +# --------------------------------------------------------------------------- +# Unused interfaces: ports an admin says aren't meant to be up +# --------------------------------------------------------------------------- + +def get_unused_interfaces() -> dict: + """{host: {iface: {'reason', 'set_by', 'set_at'}}}""" + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute('SELECT host, iface, reason, set_by, set_at FROM unused_interfaces') + out: dict = {} + for r in cur.fetchall(): + at = r['set_at'].isoformat() + 'Z' if hasattr(r['set_at'], 'isoformat') else r['set_at'] + out.setdefault(r['host'], {})[r['iface']] = {'reason': r['reason'], 'set_by': r['set_by'], + 'set_at': at} + return out + + +def set_unused_interface(host: str, iface: str, reason: str, user: str) -> None: + """Mark an interface unused and quietly close any link-down alert it has open. + + The alert is resolved without a recovery note: nothing recovered, the port just isn't used. + """ + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """INSERT INTO unused_interfaces (host, iface, reason, set_by) VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE reason=VALUES(reason), set_by=VALUES(set_by)""", + (host, iface, reason, user), + ) + cur.execute( + """UPDATE network_events SET resolved_at=NOW(), recovery_notified=1, last_seen=last_seen + WHERE event_type='interface_down' AND target_name=%s AND target_detail=%s + AND resolved_at IS NULL""", + (host, iface), + ) + + +def clear_unused_interface(host: str, iface: str) -> bool: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute('DELETE FROM unused_interfaces WHERE host=%s AND iface=%s', (host, iface)) + return cur.rowcount > 0 diff --git a/monitor.py b/monitor.py index 4d75dec..c19d7b5 100644 --- a/monitor.py +++ b/monitor.py @@ -1045,6 +1045,16 @@ class NetworkMonitor: def _hostname(self, instance: str) -> str: return self._instance_map.get(instance, instance.split(':')[0]) + def _unused_for(self, host: str) -> dict: + return (getattr(self, '_unused', None) or {}).get(host) or {} + + def _load_unused(self) -> None: + """Reload the interfaces marked unused (once a cycle; keeps the last list on error).""" + try: + self._unused = db.get_unused_interfaces() + except Exception as e: + logger.error(f'Could not load unused interfaces: {e}') + # ------------------------------------------------------------------ # Tickets: context, and notes when an alert clears # ------------------------------------------------------------------ @@ -1229,7 +1239,10 @@ class NetworkMonitor: new_baseline.setdefault(host, {}) host_has_regression = False + unused = self._unused_for(host) for iface, is_up in ifaces.items(): + if iface in unused: + continue # marked unused: no alerts, and it can't count as a regression prev = baseline.get(host, {}).get(iface) # 'up', 'initial_down', or None if is_up: @@ -1799,8 +1812,11 @@ class NetworkMonitor: continue # skip Prometheus instances not in config (e.g. LXC app servers) host = self._hostname(instance) phys = {k: v for k, v in ifaces.items()} - up_count = sum(1 for v in phys.values() if v) - total = len(phys) + # Interfaces marked unused don't count toward the host's status + unused = self._unused_for(host) + counted = {k: v for k, v in phys.items() if k not in unused} + up_count = sum(1 for v in counted.values() if v) + total = len(counted) if not prom_ok: status = 'unknown' elif total == 0 or up_count == total: @@ -1812,7 +1828,7 @@ class NetworkMonitor: hosts[host] = { 'ip': instance.split(':')[0], - 'interfaces': {k: ('up' if v else 'down') for k, v in phys.items()}, + 'interfaces': {k: ('unused' if k in unused else ('up' if v else 'down')) for k, v in phys.items()}, 'status': status, 'source': 'prometheus', } @@ -1855,6 +1871,7 @@ class NetworkMonitor: while True: try: logger.info('Starting network check cycle') + self._load_unused() # 1. Fetch interface states once: shared by snapshot and alert processing iface_states = self.prom.get_interface_states() diff --git a/static/device.js b/static/device.js index 78dd75f..c9afcb1 100644 --- a/static/device.js +++ b/static/device.js @@ -122,9 +122,11 @@ function hostRows(interfaces) { return Object.entries(interfaces) .sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })) .map(([name, d]) => { - const up = d.state ? d.state === 'up' : d.link_detected !== false; + const mark = (_device.unused_interfaces || {})[name]; + const unused = !!mark || d.state === 'unused'; + const up = !unused && (d.state ? d.state === 'up' : d.link_detected !== false); return { - id: name, label: name, up, data: d, sub: d.port_type || '', + id: name, label: name, up, unused, unusedReason: mark ? mark.reason : '', data: d, sub: d.port_type || '', detail: `