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: `