diff --git a/README.md b/README.md index ae32188..0b34056 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,17 @@ The port panel's **SFP Module** section lists type, part, serial and faults, plu optical readings (TX/RX power, temperature, voltage, bias) when it reports them. Copper DACs don't. Both ends of a DAC report the same cable serial, so a DAC seen on two ports links to its other end. +**Cutsheet marks**: a port with a cable documented in the cutsheet gets a dot in its corner: green when +it matches what Gandalf sees, red for a mismatch (wrong neighbor, no link, missing port, port conflict), +grey when it is documented but can't be confirmed (planned, dark, through a panel, or the far end isn't +visible). Hovering the port names the cable. "Cutsheet marks" in the legend hides them; the choice is +remembered in the browser. + +**Inventory link**: the device page's ๐Ÿ“ฆ button opens the device's record in the inventory +(`inventory.base_url`). Admins set it with โœŽ by pasting the record's URL; only URLs on the inventory +host are accepted. Until one is set, the button searches the inventory for the model. Tickets include +the link too. + **Port block colours:** | Colour | State | |---|---| @@ -412,6 +423,8 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/ | `ticket_api.api_key` | Tinker Tickets bearer token. Needs `read_write` scope to add recovery notes | | `ticket_api.comment_url` | Tinker comment endpoint (default: `api/ticket_comment_api.php` beside `ticket_api.url`) | | `gandalf.public_url` | Gandalf's own address, for links in tickets (default: `https://gandalf.lotusguild.org`) | +| `inventory.base_url` | Asset inventory (HomeBox) address; device inventory links must be on this host (default: `https://inventory.lotusguild.org`) | +| `inventory.search_url` | Search link used before a device has a record linked, with `{base}` and `{q}` placeholders (default: `{base}/items?q={q}`) | | `pulse.url` | Pulse worker API base URL (for SSH relay) | | `pulse.worker_id` | Which Pulse worker runs ethtool collection | | `pulse.timeout` | Max seconds to wait for SSH collection per host | @@ -671,6 +684,7 @@ Take LED colors from the model's quick start guide. See the comment at the top o ### Database Schema Notes +- `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) - `suppression_rules`: `active=FALSE` when removed; `expires_at` checked at query time diff --git a/app.py b/app.py index 56ab426..fcb8e6d 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,7 @@ import uuid from datetime import datetime, timezone from functools import wraps from typing import Optional +from urllib.parse import quote, urlparse from flask import Flask, jsonify, make_response, redirect, render_template, request, send_file, url_for @@ -318,7 +319,8 @@ def links_page(): @app.route('/device/') @require_auth def device_page(name: str): - return render_template('device.html', user=_get_user(), device_name=name) + user = _get_user() + return render_template('device.html', user=user, device_name=name, can_edit='admin' in user['groups']) @app.route('/inspector') @@ -525,6 +527,7 @@ def api_device(name: str): except Exception as e: logger.error(f'Cutsheet unavailable for {name}: {e}') detail['cutsheet'] = [] + detail['inventory'] = _inventory_for(name, detail.get('model') or '') return jsonify(detail) @@ -1000,6 +1003,58 @@ def api_cutsheet(): return jsonify(view) +# --------------------------------------------------------------------------- +# Inventory (HomeBox) links +# --------------------------------------------------------------------------- + +_INVENTORY_DEFAULT = 'https://inventory.lotusguild.org' + + +def _inventory_base() -> str: + return ((_config().get('inventory') or {}).get('base_url') or _INVENTORY_DEFAULT).rstrip('/') + + +def _inventory_for(name: str, model: str) -> dict: + """The device's inventory record link, or a search to find it.""" + base = _inventory_base() + template = (_config().get('inventory') or {}).get('search_url') or '{base}/items?q={q}' + try: + _ensure_cutsheet_schema() + url = db.get_device_link(name) + except Exception as e: + logger.error(f'Inventory link unavailable for {name}: {e}') + url = '' + return {'url': url, 'search_url': template.format(base=base, q=quote(model or name, safe=''))} + + +def _validate_inventory_url(value) -> str: + """Only links into the configured inventory site: this URL is shown as a link to every viewer.""" + url = str(value or '').strip() + if not url: + return '' + if len(url) > 512: + raise cutsheet.ValidationError('Inventory link is too long') + base = urlparse(_inventory_base()) + got = urlparse(url) + if got.scheme != 'https' or got.netloc.lower() != base.netloc.lower() or got.username or got.password: + raise cutsheet.ValidationError(f'Inventory link must start with {base.scheme}://{base.netloc}/') + return url + + +@app.route('/api/device//inventory', methods=['PUT']) +@require_auth +@require_admin +@_cutsheet_write +def api_device_inventory(name: str): + """Set or clear (empty url) the device's inventory link.""" + if len(name) > 255: + raise cutsheet.ValidationError('Device name is too long') + url = _validate_inventory_url(_json_body().get('url')) + db.set_device_link(name, url, _get_user()['username']) + logger.info(f'{_get_user()["username"]} set inventory link for {name}: {url or "(removed)"}') + return jsonify({'success': True, 'url': url}) + + @app.route('/api/cutsheet/cables', methods=['POST']) @require_auth @require_admin diff --git a/db.py b/db.py index ff64359..ff06e20 100644 --- a/db.py +++ b/db.py @@ -722,8 +722,38 @@ _CUTSHEET_SCHEMA = ( INDEX idx_hist_time (changed_at), INDEX idx_hist_entity (entity, entity_id) ) 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, + inventory_url VARCHAR(512) NOT NULL, + updated_by VARCHAR(255) NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB""", ) + +def get_device_link(name: str) -> str: + """The device's inventory URL, or ''.""" + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute('SELECT inventory_url FROM device_links WHERE name=%s', (name,)) + row = cur.fetchone() + return row['inventory_url'] if row else '' + + +def set_device_link(name: str, inventory_url: str, user: str) -> None: + """Save a device's inventory URL; an empty URL removes it.""" + with get_conn() as conn: + with conn.cursor() as cur: + if not inventory_url: + cur.execute('DELETE FROM device_links WHERE name=%s', (name,)) + return + cur.execute( + """INSERT INTO device_links (name, inventory_url, updated_by) VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE inventory_url=VALUES(inventory_url), updated_by=VALUES(updated_by)""", + (name, inventory_url, user), + ) + _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') diff --git a/monitor.py b/monitor.py index a069b40..cf46eb5 100644 --- a/monitor.py +++ b/monitor.py @@ -1160,6 +1160,9 @@ class NetworkMonitor: if device: lines.append(f'- {ticket_text.link(f"{device} in Gandalf", ticket_text.device_url(base, device))}') lines.append(f'- {ticket_text.link(f"Alert history for {device}", ticket_text.history_url(base, device))}') + inventory = db.get_device_link(device) + if inventory: + lines.append(f'- {ticket_text.link(f"{device} in the inventory", inventory)}') lines.append(f'- {ticket_text.link("Gandalf dashboard", base + "/")}') return lines diff --git a/static/device.js b/static/device.js index 93d2d8b..4e95d42 100644 --- a/static/device.js +++ b/static/device.js @@ -1,5 +1,5 @@ 'use strict'; -/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort, FACEPLATES */ +/* global DEVICE_NAME, CAN_EDIT, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort, FACEPLATES */ // Per-device page: overview, alerts, and a compact interface/port table. Host // rows expand in place; switch and router rows open the port inspector panel. @@ -332,6 +332,47 @@ function updateInterfaceSection(rows, title, hasChassis) { document.getElementById('device-error-count').textContent = rows.filter(rowHasProblem).length; } +// โ”€โ”€ Inventory (HomeBox) link โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +function inventoryActions(d) { + const inv = d.inventory || {}; + const edit = CAN_EDIT + ? `` : ''; + if (inv.url) { + return `๐Ÿ“ฆ Inventory โ†—${edit}`; + } + if (!inv.search_url) return edit; + return `๐Ÿ“ฆ Find in inventory โ†—${edit}`; +} + +function openInventoryEditor() { + const inv = (_device && _device.inventory) || {}; + const form = document.getElementById('inventory-form'); + form.url.value = inv.url || ''; + document.getElementById('inventory-search').href = inv.search_url || '#'; + lt.modal.open('inventory-modal'); + form.url.focus(); +} + +async function saveInventory(e) { + e.preventDefault(); + try { + await lt.api.put(`/api/device/${encodeURIComponent(DEVICE_NAME)}/inventory`, { url: e.target.url.value.trim() }); + lt.modal.close('inventory-modal'); + lt.toast.success(e.target.url.value.trim() ? 'Inventory link saved' : 'Inventory link removed'); + await loadDevice(); + } catch (err) { + lt.toast.error(err.message || 'Save failed'); + } +} + +document.addEventListener('click', e => { + if (e.target.closest('[data-action="edit-inventory"]')) openInventoryEditor(); +}); +const _inventoryForm = document.getElementById('inventory-form'); +if (_inventoryForm) _inventoryForm.addEventListener('submit', saveInventory); + function render(d) { _device = d; document.getElementById('device-dot').className = `dot-${d.status || 'unknown'}`; @@ -340,7 +381,7 @@ function render(d) { (d.updated ? ` ยท updated ${new Date(_toIso(d.updated)).toLocaleTimeString()}` : ''); const unifi = d.source === 'unifi'; - document.getElementById('device-actions').innerHTML = ` + document.getElementById('device-actions').innerHTML = inventoryActions(d) + ` `; diff --git a/static/faceplates.js b/static/faceplates.js index 8ea5ae4..93f28e3 100644 --- a/static/faceplates.js +++ b/static/faceplates.js @@ -107,7 +107,13 @@ function portLabelSvg(p, port) { return `${p.text || p.idx}${bolt}`; } -function faceplatePortSvg(p, port, swName, title) { +// Cutsheet status dot in a port's corner: ok (matches), bad (mismatch), doc (documented, not confirmed) +function cutsheetMarkSvg(mark, x, y) { + if (!mark) return ''; + return ``; +} + +function faceplatePortSvg(p, port, swName, title, mark) { // The drawn port number can differ from the index UniFi reports (see anyPort) const idx = (p.anyPort && port) ? port.port_idx : p.idx; const attrs = `class="fp-port${port && port.up ? ' fp-up' : ''}" data-switch="${lt.escHtml(swName)}" data-port-idx="${idx}" @@ -117,6 +123,7 @@ function faceplatePortSvg(p, port, swName, title) { ${title} ${sfpSvg(port, p.w, p.h)} + ${cutsheetMarkSvg(mark, p.w - 3, 3)} ${sfpLedSvg(p, port)}${portLabelSvg(p, port)}`; } @@ -125,6 +132,7 @@ function faceplatePortSvg(p, port, swName, title) { ${title} ${rj45Svg(port, p)} + ${cutsheetMarkSvg(mark, 14, p.flip ? 13 : -13)} ${portLabelSvg(p, port)}`; } @@ -469,9 +477,17 @@ function faceplateLegendSwatch(cls) { return ``; } -function faceplateLegendHtml(spec) { - const items = spec.legend.map(([cls, text]) => +const CUTSHEET_MARK_LEGEND = [['ok', 'Matches cutsheet'], ['bad', 'Cutsheet mismatch'], ['doc', 'Documented, unconfirmed']]; + +function faceplateLegendHtml(spec, marks) { + let items = spec.legend.map(([cls, text]) => `
${faceplateLegendSwatch(cls)}${text}
`).join(''); + if (marks && marks.size) { + items += CUTSHEET_MARK_LEGEND.filter(([m]) => marks.has(m)).map(([m, text]) => + `
${text}
`).join(''); + items += ``; + } const hasDetail = spec.ports.length || spec.outlets; return `
${items} @@ -479,14 +495,20 @@ function faceplateLegendHtml(spec) {
`; } -// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role) } +// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role), cutsheetFor?(idx) -> {mark, text} } function renderFaceplate(model, ctx) { const spec = FACEPLATES[model]; const anyPort = Object.values(ctx.portMap)[0]; + const marks = new Set(); const ports = spec.ports.map(p => { const port = ctx.portMap[p.idx] || (p.anyPort ? anyPort : undefined); - const title = ctx.titleFor(port, p.idx, spec.roles && spec.roles[p.idx]); - return faceplatePortSvg(p, port, ctx.swName, title); + let title = ctx.titleFor(port, p.idx, spec.roles && spec.roles[p.idx]); + const cs = ctx.cutsheetFor ? ctx.cutsheetFor((p.anyPort && port) ? port.port_idx : p.idx) : null; + if (cs) { + marks.add(cs.mark); + title += lt.escHtml(` ยท ${cs.text}`); + } + return faceplatePortSvg(p, port, ctx.swName, title, cs && cs.mark); }).join(''); const outletData = Object.fromEntries((ctx.outlets || []).map(o => [o.index, o])); const outlets = (spec.outlets || []).map(o => faceplateOutletSvg(o, outletData[o.idx])).join(''); @@ -510,7 +532,7 @@ function renderFaceplate(model, ctx) { ${ports} - ${faceplateLegendHtml(spec)}`; + ${faceplateLegendHtml(spec, marks)}`; } // Where an fp-scroll panel should start: its port area diff --git a/static/port-inspector.js b/static/port-inspector.js index 2d3c37e..1af6443 100644 --- a/static/port-inspector.js +++ b/static/port-inspector.js @@ -112,6 +112,32 @@ function cutsheetPanelHtml(swName, idx) { }).join(''); } +// The faceplate dot for one port: the worst state of the cables documented on it +const MARK_RANK = { bad: 3, doc: 2, ok: 1 }; + +function cutsheetMarkFor(idx) { + const entries = ((_apiData && _apiData.cutsheet) || []).filter(e => e.port === String(idx) && e.status !== 'retired'); + if (!entries.length) return null; + let best = null; + entries.forEach(e => { + const mark = CUTSHEET_PROBLEM.has(e.verdict) ? 'bad' : (e.verdict === 'match' ? 'ok' : 'doc'); + if (!best || MARK_RANK[mark] > MARK_RANK[best.mark]) { + best = { mark, text: `cable ${e.label || 'unlabeled'}: ${e.detail}` }; + } + }); + return best; +} + +document.addEventListener('change', e => { + if (!e.target.matches('[data-action="toggle-cutsheet-marks"]')) return; + const off = !e.target.checked; + document.body.classList.toggle('fp-marks-off', off); + try { localStorage.setItem('gandalf.cutsheetMarks', off ? 'off' : 'on'); } catch (_) { /* private mode */ } +}); +try { + if (localStorage.getItem('gandalf.cutsheetMarks') === 'off') document.body.classList.add('fp-marks-off'); +} catch (_) { /* private mode */ } + // โ”€โ”€ SFP module section of the port panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function sfpPanelHtml(sfp) { if (!sfp) return ''; @@ -206,6 +232,7 @@ function renderChassis(swName, sw) { if (FACEPLATES[model]) { return `
${header}${renderFaceplate(model, { swName, portMap, outlets: sw.outlets, online: sw.online, titleFor: faceplatePortTitle, + cutsheetFor: cutsheetMarkFor, })}
`; } diff --git a/static/style.css b/static/style.css index dfc4cd7..ad0364f 100644 --- a/static/style.css +++ b/static/style.css @@ -1422,3 +1422,12 @@ polygon.fp-led-off { fill: #6b6c71; } .ev-cards .ev-c-desc { flex: 1 1 100%; color: var(--text-muted); white-space: normal; overflow: visible; } .ev-cards .ev-c-action { flex: 1 1 100%; } } + +/* โ”€โ”€ Cutsheet marks on faceplates โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.fp-mark { stroke: #101418; stroke-width: 1.2; pointer-events: none; } +.fp-mark-ok { fill: var(--accent-green, #00ff88); } +.fp-mark-bad { fill: var(--status-critical, #e0312b); } +.fp-mark-doc { fill: #9aa7b4; } +body.fp-marks-off .fp-svg .fp-mark { display: none; } +.fp-mark-toggle { cursor: pointer; gap: 6px; } +.fp-mark-toggle input { margin: 0; } diff --git a/templates/base.html b/templates/base.html index e2c69f3..7977400 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,7 +6,7 @@ - {% block title %}GANDALF{% endblock %} โ€” GANDALF + {% block title %}Network Monitor{% endblock %} โ€” GANDALF diff --git a/templates/cutsheet.html b/templates/cutsheet.html index 6c3e177..2a4e6f7 100644 --- a/templates/cutsheet.html +++ b/templates/cutsheet.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Cutsheet โ€“ GANDALF{% endblock %} +{% block title %}Cutsheet{% endblock %} {% block content %}
diff --git a/templates/device.html b/templates/device.html index 0acde66..08688b4 100644 --- a/templates/device.html +++ b/templates/device.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}{{ device_name }} โ€“ GANDALF{% endblock %} +{% block title %}{{ device_name }}{% endblock %} {% block content %}
@@ -46,10 +46,37 @@ {% include "_modem_panel.html" %} + +{% if can_edit %} + +{% endif %} {% endblock %} {% block scripts %} - + diff --git a/templates/events.html b/templates/events.html index 27a2dba..78cdd7e 100644 --- a/templates/events.html +++ b/templates/events.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Alert History โ€“ GANDALF{% endblock %} +{% block title %}Alert History{% endblock %} {% block content %}
diff --git a/templates/index.html b/templates/index.html index d00aeb8..de1310c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Dashboard โ€“ GANDALF{% endblock %} +{% block title %}Dashboard{% endblock %} {% block content %} diff --git a/templates/suppressions.html b/templates/suppressions.html index 55633ca..34f4968 100644 --- a/templates/suppressions.html +++ b/templates/suppressions.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Suppressions โ€“ GANDALF{% endblock %} +{% block title %}Suppressions{% endblock %} {% block content %} diff --git a/tests/test_app.py b/tests/test_app.py index 3ae3687..2c9980f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -35,6 +35,7 @@ def client(): db.count_active_events.return_value = 0 db.get_status_summary.return_value = {'critical': 0, 'warning': 0, 'info': 0, 'resolved_24h': 0} db.check_suppressed.return_value = False + db.get_device_link.return_value = '' with patch.object(gandalf, '_cfg', CONFIG): yield gandalf.app.test_client() @@ -376,3 +377,53 @@ class TestHealth: def test_limit_follows_a_long_poll_interval(self, client): assert self._health(client, 1000, poll=300).status_code == 200 assert self._health(client, 1600, poll=300).status_code == 503 + + +class TestPageTitles: + @pytest.mark.parametrize('path,title', [('/', 'Dashboard โ€” GANDALF'), ('/events', 'Alert History โ€” GANDALF'), + ('/cutsheet', 'Cutsheet โ€” GANDALF')]) + def test_name_appears_once(self, client, path, title): + gandalf._cutsheet_schema_ready = True + html = client.get(path, headers=AUTH).get_data(as_text=True) + assert f'{title}' in html + + +class TestInventoryLinks: + def _put(self, client, url, headers=AUTH, name='USW Flex'): + gandalf._cutsheet_schema_ready = True + return client.put(f'/api/device/{name}/inventory', json={'url': url}, headers=headers) + + def test_device_api_has_link_and_search(self, client): + gandalf._cutsheet_schema_ready = True + gandalf.db.get_device_link.return_value = 'https://inventory.lotusguild.org/item/abc' + inv = client.get('/api/device/USW Flex', headers=AUTH).get_json()['inventory'] + assert inv == {'url': 'https://inventory.lotusguild.org/item/abc', + 'search_url': 'https://inventory.lotusguild.org/items?q=USF5P'} + + def test_admin_sets_and_clears(self, client): + assert self._put(client, ' https://inventory.lotusguild.org/item/abc ').status_code == 200 + gandalf.db.set_device_link.assert_called_with('USW Flex', 'https://inventory.lotusguild.org/item/abc', 'jared') + assert self._put(client, '').status_code == 200 + gandalf.db.set_device_link.assert_called_with('USW Flex', '', 'jared') + + @pytest.mark.parametrize('url', ['http://inventory.lotusguild.org/item/a', 'https://evil.example/item/a', + 'https://inventory.lotusguild.org.evil.example/x', 'javascript:alert(1)', + 'https://user:pw@inventory.lotusguild.org/x', + 'https://inventory.lotusguild.org/' + 'a' * 600]) + def test_only_inventory_links_are_accepted(self, client, url): + assert self._put(client, url).status_code == 400 + assert not gandalf.db.set_device_link.called + + def test_configured_host(self, client): + cfg = dict(CONFIG, inventory={'base_url': 'https://homebox.example'}) + with patch.object(gandalf, '_cfg', cfg): + assert self._put(client, 'https://homebox.example/item/1').status_code == 200 + assert self._put(client, 'https://inventory.lotusguild.org/item/1').status_code == 400 + + def test_needs_admin_and_json(self, client): + user = dict(AUTH, **{'Remote-Groups': 'staff'}) + with patch.object(gandalf, '_cfg', dict(CONFIG, auth=dict(CONFIG['auth'], allowed_groups=['admin', 'staff']))): + assert self._put(client, 'https://inventory.lotusguild.org/x', headers=user).status_code == 403 + gandalf._cutsheet_schema_ready = True + resp = client.put('/api/device/USW Flex/inventory', data='url=x', headers=AUTH) + assert resp.status_code == 400 diff --git a/tests/test_monitor.py b/tests/test_monitor.py index ca462f6..ad418c4 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -30,6 +30,7 @@ def db(): mock_db.get_events_for_target.return_value = [] mock_db.cutsheet_load.return_value = {'items': [], 'cables': [], 'port_labels': []} mock_db.get_pending_recovery_notes.return_value = [] + mock_db.get_device_link.return_value = '' yield mock_db @@ -593,6 +594,13 @@ class TestTicketContext: assert '(https://gandalf.test/device/agg#port-7)' in desc assert 'https://gandalf.test/device/large1' in desc assert 'Opened automatically by Gandalf' in desc + assert 'inventory' not in desc + + def test_inventory_link_when_set(self, mon, db): + db.get_device_link.return_value = 'https://inv.test/item/abc' + self._mon(mon)._ticket_interface(1, 'large1', 'enp1s0f0', 2) + assert '- [large1 in the inventory](https://inv.test/item/abc)' in self._desc(mon) + db.get_device_link.assert_called_with('large1') def test_dead_host_uses_the_last_known_port(self, mon, db): mon._known_uplinks = {'large1': {'seen': '2026-11-10 10:00:00 UTC', 'uplinks': [