Merge pull request 'Cutsheet trace mode and mismatch alerts' (#19) from cutsheet-trace-and-alerts into main
Lint / JS (eslint) (push) Successful in 15s
Lint / Python (flake8) (push) Successful in 1m32s
Lint / Notify on failure (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m12s
Lint / Deploy (push) Successful in 4s
Test / Python Tests (pytest) (push) Successful in 1m12s

This commit was merged in pull request #19.
This commit is contained in:
2026-11-10 17:22:55 -05:00
11 changed files with 446 additions and 1 deletions
+13
View File
@@ -213,6 +213,14 @@ can view it; only the `admin` group can edit, and every change is logged with wh
- **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.
- **Trace a cord** (admins): pick a panel port and press Start, then unplug that cord at the panel.
Gandalf reads every switch port's state from UniFi every 3 seconds and shows the one that drops,
with a button to record the cable, then confirms when it's plugged back in. The device on that
port loses its link for those seconds. A cord whose far end has nothing linked can't be traced
this way.
- **Mismatch alerts**: the monitor runs the same check every poll. A mismatched run opens a
`cable_mismatch` event on the switch straight away and resolves it once fixed; it becomes a P4
ticket only after `cutsheet.mismatch_ticket_hours`.
### Suppressions (`/suppressions`)
- Create timed (30 min / 1 hr / 4 hr / 8 hr) or manual suppressions
@@ -243,6 +251,7 @@ can view it; only the `admin` group can edit, and every change is logged with wh
| Modem pages unreadable, e.g. after a firmware update (≥`modem.failure_threshold`) | P3 |
| UniFi logs that Smart Power auto power-cycled the modem | P3 |
| UniFi logs that the WAN failed multiple times in 24 hours (at most one ticket a day) | P2 |
| An installed cable doesn't match the cutsheet (wrong neighbor, no link, missing port, port conflict) for `cutsheet.mismatch_ticket_hours` | P4 |
A failed Pulse ping check counts as "unknown": it neither raises nor resolves `host_unreachable`.
@@ -332,6 +341,9 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/
{ "name": "pbs", "ip": "10.10.10.3" }
]
},
"cutsheet": {
"mismatch_ticket_hours": 24
},
"modem": {
"url": "http://192.168.1.254",
"name": "att-modem"
@@ -361,6 +373,7 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/
| `monitor.unifi_failure_threshold` | Consecutive failures before ticketing a UniFi device as offline (default: same as `failure_threshold`). Set higher than `failure_threshold` — UniFi switches/APs can take several minutes to rejoin after a firmware-update reboot, so a low threshold cuts tickets for momentary maintenance reboots instead of real failures. |
| `monitor.cluster_threshold` | Hosts with failures to trigger cluster-wide P1 (default: 3) |
| `monitor.ping_hosts` | Hosts checked only by ping (no node_exporter) |
| `cutsheet.mismatch_ticket_hours` | Hours a cutsheet mismatch must last before it opens a P4 ticket (default: 24; `0` never tickets). The mismatch shows on the dashboard and the switch's page straight away, so recabling never pages anyone. Suppressing the switch or its port also stops the ticket. |
| `modem.url` | AT&T gateway base URL. Omit the `modem` block to disable modem monitoring. Must be reachable from the monitor host. |
| `modem.name` | Name used for the modem's alerts, tickets, and suppressions (default: `att-modem`) |
| `modem.timeout` | Seconds to wait per status page (default: 10) |
+32 -1
View File
@@ -24,7 +24,7 @@ import cutsheet
import db
import devices
import diagnose
from monitor import PulseClient
from monitor import PulseClient, UnifiClient
logging.basicConfig(
level=logging.INFO,
@@ -1014,6 +1014,37 @@ def api_cutsheet_port_label(item_id: int, port: str):
return jsonify({'success': True})
_live_ports_cache: dict = {'at': 0.0, 'data': None, 'client': None}
_live_ports_lock = threading.Lock()
_LIVE_PORTS_TTL = 2.0
@app.route('/api/cutsheet/live-ports')
@require_auth
@require_admin
def api_cutsheet_live_ports():
"""Current up/down state of every switch port, read from UniFi now.
Trace mode polls this while someone unplugs a cord, to see which port dropped.
The monitor's cached state is minutes old, so it can't be used for this.
"""
with _live_ports_lock:
now = time.time()
if _live_ports_cache['data'] is None or now - _live_ports_cache['at'] > _LIVE_PORTS_TTL:
if _live_ports_cache['client'] is None:
_live_ports_cache['client'] = UnifiClient(_config()['unifi'])
raw = _live_ports_cache['client'].get_switch_ports()
if raw is None:
return jsonify({'error': 'The UniFi controller did not answer'}), 502
_live_ports_cache['data'] = {
sw: {str(p['port_idx']): {'up': bool(p.get('up')), 'name': name}
for name, p in (d.get('ports') or {}).items() if p.get('port_idx') is not None}
for sw, d in raw.items()
}
_live_ports_cache['at'] = now
return jsonify({'switches': _live_ports_cache['data'], 'at': _live_ports_cache['at']})
@app.route('/api/cutsheet/cables.csv')
@require_auth
def api_cutsheet_export():
+42
View File
@@ -469,3 +469,45 @@ def parse_csv(text: str, items: Dict[str, dict], max_rows: int = 2000) -> Tuple[
labels.add(cable['label'].lower())
rows.append(cable)
return rows, errors
# ── Problems, for the monitor ───────────────────────────────────────────────
def problems(cables: List[dict], items: Dict[str, dict], switches: dict) -> List[dict]:
"""One entry per installed run whose check found a problem.
A run through a panel is several cables with the same verdict; it is reported
once, under the cable on the switch port (the one people look for).
Returns [{'key', 'cable_id', 'label', 'switch', 'port', 'verdict', 'detail', 'path'}].
"""
switches = switches or {}
results = evaluate(cables, items, switches)
by_id = {c['id']: c for c in cables}
seen_runs = set()
found = []
for cid, r in sorted(results.items()):
if r['verdict'] not in PROBLEM_VERDICTS or by_id[cid]['status'] != 'installed':
continue
run = tuple(sorted(r['path']['cable_ids']))
if run in seen_runs and r['verdict'] != 'conflict':
continue
seen_runs.add(run)
hops = r['path']['hops']
ends = [n for n in (hops[0], hops[-1]) if n and n[0] in switches]
switch_end = ends[0] if ends else _node(by_id[cid], 'a')
# Name the run after the cable plugged into the switch, when there is one
lead = next((by_id[i] for i in r['path']['cable_ids']
if switch_end in (_node(by_id[i], 'a'), _node(by_id[i], 'b'))), by_id[cid])
if r['verdict'] == 'conflict':
lead = by_id[cid]
found.append({
'key': f'cable {lead["id"]}',
'cable_id': lead['id'],
'label': lead.get('label') or f'#{lead["id"]}',
'switch': switch_end[0] if switch_end else lead['a_device'],
'port': switch_end[1] if switch_end else lead['a_port'],
'verdict': r['verdict'],
'detail': r['detail'],
'path': ' → '.join(describe_node(h) for h in hops if not isinstance(h, int)),
})
return found
+11
View File
@@ -178,6 +178,17 @@ def resolve_event(event_type: str, target_name: str, target_detail: str = '') ->
)
def get_open_event_targets(event_type: str) -> list:
"""(target_name, target_detail) of each unresolved event of one type."""
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
'SELECT target_name, target_detail FROM network_events WHERE event_type=%s AND resolved_at IS NULL',
(event_type,),
)
return [(r['target_name'], r['target_detail']) for r in cur.fetchall()]
def set_ticket_id(event_id: int, ticket_id: str) -> None:
with get_conn() as conn:
with conn.cursor() as cur:
+68
View File
@@ -21,6 +21,7 @@ import requests
from urllib3.exceptions import InsecureRequestWarning
import db
import cutsheet
import devices
import modem as modem_api
@@ -943,6 +944,13 @@ class NetworkMonitor:
except Exception as e:
logger.error(f'Could not create modem history table: {e}')
# Cutsheet mismatches open a P4 ticket once they've lasted this long (0 = never ticket)
self.cutsheet_ticket_hours = (self.cfg.get('cutsheet') or {}).get('mismatch_ticket_hours', 24)
try:
db.ensure_cutsheet_schema()
except Exception as e:
logger.error(f'Could not create cutsheet tables: {e}')
# Last good Prometheus result, shown (as unknown) during a Prometheus outage
self._last_iface_states: Dict[str, Dict[str, bool]] = {}
@@ -1165,6 +1173,58 @@ class NetworkMonitor:
if tid:
db.set_ticket_id(event_id, tid)
# ------------------------------------------------------------------
# Cutsheet: documented cabling vs. what discovery sees
# ------------------------------------------------------------------
def _process_cutsheet(self, link_data: dict, suppressions: list) -> None:
"""Keep one event per mismatched cable run, and ticket the ones that last.
Events appear on the dashboard and the switch's page straight away; a
ticket waits for cutsheet.mismatch_ticket_hours, so recabling never pages.
"""
data = db.cutsheet_load()
items = {i['name']: i for i in data['items']}
switches = link_data.get('unifi_switches') or {}
found = cutsheet.problems(data['cables'], items, switches)
hours = self.cutsheet_ticket_hours
threshold = max(1, -(-int(hours * 3600) // max(1, self.poll_interval))) if hours else 0
current = set()
for p in found:
current.add((p['switch'], p['key']))
description = f'{p["label"]}: {p["detail"]} (path: {p["path"]})'
event_id, _, consec, ticket_id = db.upsert_event(
'cable_mismatch', 'warning', 'cutsheet', p['switch'], p['key'], description,
)
sup = (db.check_suppressed(suppressions, 'unifi_device', p['switch']) or
db.check_suppressed(suppressions, 'interface', p['switch'], p['port']))
if threshold and not sup and consec >= threshold and not ticket_id:
self._ticket_cable_mismatch(event_id, p, hours)
for target_name, target_detail in db.get_open_event_targets('cable_mismatch'):
if (target_name, target_detail) not in current:
db.resolve_event('cable_mismatch', target_name, target_detail)
def _ticket_cable_mismatch(self, event_id: int, p: dict, hours: float) -> None:
title = (
f'[{p["switch"]}][auto][production][issue][network][single-node] '
f'Cable {p["label"]} does not match the cutsheet'
)
desc = (
f'Cutsheet Mismatch\n{"=" * 40}\n\n'
f'Cable: {p["label"]}\n'
f'Switch port: {p["switch"]} port {p["port"]}\n'
f'Problem: {p["detail"]}\n'
f'Documented path: {p["path"]}\n'
f'Detected: {_now_utc()}\n\n'
f'This has not matched for at least {hours:g} hours.\n\n'
f'Either the cable was moved (update the cutsheet in Gandalf: /cutsheet#cable-{p["cable_id"]}) '
f'or it is plugged into the wrong place.'
)
tid = self.tickets.create(title, desc, priority='4')
if tid:
db.set_ticket_id(event_id, tid)
# ------------------------------------------------------------------
# Ping-only hosts (no node_exporter)
# ------------------------------------------------------------------
@@ -1579,6 +1639,7 @@ class NetworkMonitor:
db.set_state('last_check', _now_utc())
# 4. Collect link stats (ethtool + traffic metrics)
link_data = None
try:
link_data = self.link_stats.collect(self._instance_map)
db.set_state('link_stats', link_data)
@@ -1596,6 +1657,13 @@ class NetworkMonitor:
self._process_ping_hosts(suppressions, ping_states)
# Cutsheet: compare documented cabling with what was just discovered
if link_data is not None:
try:
self._process_cutsheet(link_data, suppressions)
except Exception as e:
logger.error(f'Cutsheet check failed: {e}', exc_info=True)
# WAN modem: isolated so a failure here can't stop the rest of the cycle
if self.modem:
try:
+137
View File
@@ -329,6 +329,7 @@ function render() {
renderItems();
renderHistory();
renderDatalists();
renderTracePanels();
}
async function load() {
@@ -500,6 +501,136 @@ async function importCsv(e, dryRun) {
await load();
}
// ── Trace mode ──────────────────────────────────────────────────────────
// Snapshot which switch ports are up, then watch for the one that drops when a
// cord is unplugged at the panel. UniFi takes a few seconds to notice.
const TRACE_POLL_MS = 3000;
const TRACE_TIMEOUT_MS = 180000;
let _trace = null;
function renderTracePanels() {
const select = document.getElementById('cs-trace-panel');
if (!select) return;
const panels = _cs.items.filter(i => i.kind === 'patch_panel').sort((a, b) => natural(a.name, b.name));
const keep = select.value;
select.innerHTML = panels.map(p => `<option value="${esc(p.name)}">${esc(p.name)}</option>`).join('');
if (panels.some(p => p.name === keep)) select.value = keep;
document.querySelector('[data-action="trace-start"]').disabled = !panels.length || !!_trace;
if (!panels.length) traceStatus('Add a patch panel first.');
}
function traceStatus(html) {
const el = document.getElementById('cs-trace-status');
if (el) el.innerHTML = html;
}
function portKey(sw, idx) { return JSON.stringify([sw, String(idx)]); }
function upPorts(switches) {
const up = new Set();
for (const [sw, ports] of Object.entries(switches || {})) {
for (const [idx, p] of Object.entries(ports)) if (p.up) up.add(portKey(sw, idx));
}
return up;
}
function traceButtons(running) {
document.querySelector('[data-action="trace-start"]').hidden = running;
document.querySelector('[data-action="trace-stop"]').hidden = !running;
}
function stopTrace() {
if (_trace && _trace.timer) clearTimeout(_trace.timer);
_trace = null;
traceButtons(false);
renderTracePanels();
}
async function startTrace() {
const panel = document.getElementById('cs-trace-panel').value;
const port = String(parseInt(document.getElementById('cs-trace-port').value, 10) || '');
const item = itemByName(panel);
if (!item || !port || Number(port) < 1 || Number(port) > (item.port_count || 0)) {
traceStatus(`<span class="val-crit">Choose a port between 1 and ${esc(item ? item.port_count : '?')}.</span>`);
return;
}
traceButtons(true);
traceStatus('Reading switch ports…');
let live;
try {
live = (await lt.api.get('/api/cutsheet/live-ports')).switches;
} catch (err) {
traceStatus(`<span class="val-crit">${esc(err.message)}</span>`);
traceButtons(false);
return;
}
_trace = { panel, port, baseline: upPorts(live), started: Date.now(), found: null, timer: null };
traceStatus(`Ready. <b>Unplug the cord from ${esc(panel)} port ${esc(port)} now.</b>
Watching ${_trace.baseline.size} linked switch ports… UniFi can take up to about 30 seconds to notice.`);
_trace.timer = setTimeout(pollTrace, TRACE_POLL_MS);
}
async function pollTrace() {
const t = _trace;
if (!t) return;
let live;
try {
live = (await lt.api.get('/api/cutsheet/live-ports')).switches;
} catch (err) {
if (_trace === t) t.timer = setTimeout(pollTrace, TRACE_POLL_MS);
return;
}
if (_trace !== t) return;
const up = upPorts(live);
if (t.found) {
if (up.has(t.found.key)) {
showTraceResult(t, true);
stopTrace();
} else {
t.timer = setTimeout(pollTrace, TRACE_POLL_MS);
}
return;
}
const dropped = [...t.baseline].filter(k => !up.has(k)).map(k => JSON.parse(k));
if (dropped.length === 1) {
const [sw, idx] = dropped[0];
t.found = { key: portKey(sw, idx), sw, idx, name: ((live[sw] || {})[idx] || {}).name || '' };
showTraceResult(t, false);
t.timer = setTimeout(pollTrace, TRACE_POLL_MS);
} else if (dropped.length > 1) {
traceStatus(`<span class="val-warn">Several ports dropped at once, so the cord can't be told apart:</span>
${dropped.map(([sw, idx]) => `${esc(sw)} port ${esc(idx)}`).join(', ')}. Plug everything back in and try again.`);
stopTrace();
} else if (Date.now() - t.started > TRACE_TIMEOUT_MS) {
traceStatus(`No switch port dropped in 3 minutes. If nothing is plugged in at the far end of
${esc(t.panel)} port ${esc(t.port)}, its switch port was never linked; trace that one by hand.`);
stopTrace();
} else {
t.timer = setTimeout(pollTrace, TRACE_POLL_MS);
}
}
function showTraceResult(t, replugged) {
const f = t.found;
const existing = cableAt(f.sw, f.idx, '');
const panelCable = cableAt(t.panel, t.port, 'front');
let action;
if (existing && panelCable && existing.id === panelCable.id) {
action = `<span class="val-good">Matches ${esc(existing.label || 'the cutsheet')}.</span>`;
} else if (existing) {
action = `That switch port is documented as <a class="cs-id" href="#cable-${esc(existing.id)}">${esc(existing.label || 'an unlabeled cable')}</a>
to ${esc(existing.a_device === f.sw ? existing.b_text : existing.a_text)}.
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="edit-cable" data-id="${esc(existing.id)}">Edit it</button>`;
} else {
action = `<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" data-action="trace-record"
data-sw="${esc(f.sw)}" data-idx="${esc(f.idx)}" data-panel="${esc(t.panel)}" data-port="${esc(t.port)}">Record cable</button>`;
}
traceStatus(`
<div class="cs-trace-found">${esc(t.panel)} port ${esc(t.port)} → <b>${esc(f.sw)} port ${esc(f.idx)}</b>${f.name ? ` (${esc(f.name)})` : ''}</div>
<div>${action}</div>
<div class="cs-hint">${replugged ? '<span class="val-good">Plugged back in, link is up again.</span>' : 'Plug the cord back in now.'}</div>`);
}
// ── Wiring ──────────────────────────────────────────────────────────────
document.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
@@ -511,6 +642,12 @@ document.addEventListener('click', e => {
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 === 'trace-start') startTrace();
else if (action === 'trace-stop') { stopTrace(); traceStatus('Stopped.'); }
else if (action === 'trace-record') {
openCable(null, { a_device: btn.dataset.sw, a_port: btn.dataset.idx, b_device: btn.dataset.panel,
b_port: btn.dataset.port, b_side: 'front' });
}
else if (action === 'use-next-label') document.getElementById('cs-label').value = _cs.next_label;
else if (action === 'toggle-suggestions') { _showAllSuggestions = !_showAllSuggestions; renderSuggestions(); }
else if (action === 'import-check') importCsv(null, true);
+8
View File
@@ -1318,6 +1318,14 @@ polygon.fp-led-off { fill: #6b6c71; }
.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; }
/* Button styles set display, which would otherwise override the hidden attribute */
.lt-btn[hidden], .cs-link-btn[hidden] { display: none !important; }
.cs-trace { margin-bottom: 16px; }
.cs-trace-form { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.cs-trace-form .lt-form-group { margin: 0; min-width: 120px; }
.cs-trace-buttons { display: flex; gap: 8px; }
.cs-trace-status { margin-top: 10px; font-size: .88rem; line-height: 1.6; }
.cs-trace-found { font-size: 1rem; margin-bottom: 6px; }
.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;
+26
View File
@@ -51,6 +51,32 @@
<div class="g-section-header">
<h2 class="g-section-title">Patch panels</h2>
</div>
{% if can_edit %}
<div class="lt-frame cs-trace no-print" id="cs-trace">
<span class="lt-frame-bl">&#x255A;</span><span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Trace a cord</div>
<div class="lt-section-body">
<p class="cs-hint">Find which switch port a panel port's cord goes to: start, then unplug that cord at the panel.
Gandalf watches every switch port and shows the one that drops. Plug it back in afterwards. The device on it
loses its link for those few seconds.</p>
<div class="cs-trace-form">
<div class="lt-form-group">
<label class="lt-label" for="cs-trace-panel">Panel</label>
<select class="lt-select" id="cs-trace-panel"></select>
</div>
<div class="lt-form-group">
<label class="lt-label" for="cs-trace-port">Port</label>
<input class="lt-input" id="cs-trace-port" type="number" min="1" max="512" value="1">
</div>
<div class="lt-form-group cs-trace-buttons">
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" data-action="trace-start">Start</button>
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="trace-stop" hidden>Stop</button>
</div>
</div>
<div id="cs-trace-status" class="cs-trace-status" aria-live="polite"></div>
</div>
</div>
{% endif %}
<div id="cs-panels"></div>
</section>
+39
View File
@@ -277,3 +277,42 @@ class TestCutsheetApi:
db.cutsheet_load.side_effect = RuntimeError('db down')
d = client.get('/api/device/USW%20Flex', headers=AUTH).get_json()
assert d['cutsheet'] == []
class TestCutsheetLivePorts:
def _fake(self, result):
class Fake:
calls = 0
def __init__(self, cfg):
pass
def get_switch_ports(self):
Fake.calls += 1
return result
return Fake
def test_reads_unifi_and_caches_briefly(self, cs_client):
client, _ = cs_client
fake = self._fake({'sw': {'ports': {'Office TV': {'port_idx': 13, 'up': True},
'Port 14': {'port_idx': 14, 'up': False}}}})
gandalf._live_ports_cache.update(at=0.0, data=None, client=None)
with patch.object(gandalf, 'UnifiClient', fake), \
patch.dict(gandalf._cfg, {'unifi': {'controller': 'https://u', 'api_key': 'k'}}):
first = client.get('/api/cutsheet/live-ports', headers=AUTH).get_json()
client.get('/api/cutsheet/live-ports', headers=AUTH)
assert first['switches'] == {'sw': {'13': {'up': True, 'name': 'Office TV'},
'14': {'up': False, 'name': 'Port 14'}}}
assert fake.calls == 1
def test_controller_failure_is_502_and_not_cached(self, cs_client):
client, _ = cs_client
gandalf._live_ports_cache.update(at=0.0, data=None, client=None)
with patch.object(gandalf, 'UnifiClient', self._fake(None)), \
patch.dict(gandalf._cfg, {'unifi': {'controller': 'https://u', 'api_key': 'k'}}):
assert client.get('/api/cutsheet/live-ports', headers=AUTH).status_code == 502
assert gandalf._live_ports_cache['data'] is None
def test_admin_only(self, cs_client):
client, _ = cs_client
assert client.get('/api/cutsheet/live-ports', headers=USER).status_code == 403
+23
View File
@@ -225,3 +225,26 @@ class TestCsv:
def test_missing_required_column(self):
assert cs.parse_csv('label\nC1\n', ITEMS) == ([], ['Missing column: a_device'])
class TestProblems:
def test_one_problem_per_run_named_after_the_switch_cable(self):
cables = [cable(1, ('PP1', '5', 'rear'), ('tv', 'eth0')),
cable(2, ('sw', '13'), ('PP1', '5', 'front'), label='C07')]
found = cs.problems(cables, ITEMS, switch([port(13, seen='printer')]))
assert len(found) == 1
p = found[0]
assert (p['label'], p['switch'], p['port'], p['verdict']) == ('C07', 'sw', '13', 'wrong_neighbor')
assert p['key'] == 'cable 2' and 'PP1 port 5 (front)' in p['path']
def test_only_installed_problems(self):
cables = [cable(1, ('sw', '13'), ('tv', 'eth0'), status='planned'),
cable(2, ('sw', '14'), ('tv', 'eth0')),
cable(3, ('sw', '15'), ('Bedroom jack', '1', 'rear'))]
switches = switch([port(13, seen='x'), port(14, seen='tv'), port(15, up=False)])
assert cs.problems(cables, ITEMS, switches) == []
def test_each_conflicting_cable_is_reported(self):
cables = [cable(1, ('sw', '13'), ('tv', 'eth0')), cable(2, ('sw', '13'), ('pc', 'eth0'))]
found = cs.problems(cables, ITEMS, switch([port(13, seen='tv')]))
assert sorted(p['cable_id'] for p in found) == [1, 2]
+47
View File
@@ -513,3 +513,50 @@ class TestLldpCollection:
c = monitor.UnifiClient({'controller': 'https://u.test', 'api_key': 'k'})
c.session.get = MagicMock(side_effect=OSError('boom'))
assert c.get_wired_clients() == []
class TestCutsheetMismatch:
CABLE = {'id': 7, 'label': 'C07', 'a_device': 'sw', 'a_port': '13', 'a_side': '', 'b_device': 'tv',
'b_port': '', 'b_side': '', 'status': 'installed'}
def _run(self, mon, db, seen='printer', consec=1, ticket=None, suppressions=(), open_targets=()):
mon.poll_interval = 120
mon.cutsheet_ticket_hours = getattr(mon, 'cutsheet_ticket_hours', 24)
db.cutsheet_load.return_value = {'items': [], 'cables': [dict(self.CABLE)], 'port_labels': []}
db.upsert_event.return_value = (5, consec == 1, consec, ticket)
db.get_open_event_targets.return_value = list(open_targets)
db.check_suppressed.side_effect = lambda sups, kind, name, detail='': (kind, name) in sups
link = {'unifi_switches': {'sw': {'ports': {'Port 13': {
'port_idx': 13, 'up': True, 'lldp': {'system_name': seen}}}}}}
mon._process_cutsheet(link, list(suppressions))
def test_mismatch_opens_an_event_but_no_ticket_yet(self, mon, db):
self._run(mon, db)
args = db.upsert_event.call_args.args
assert args[:5] == ('cable_mismatch', 'warning', 'cutsheet', 'sw', 'cable 7')
assert 'printer' in args[5]
assert not mon.tickets.create.called
def test_ticket_after_a_day_of_polls(self, mon, db):
self._run(mon, db, consec=719)
assert not mon.tickets.create.called
self._run(mon, db, consec=720)
title = mon.tickets.create.call_args.args[0]
assert 'Cable C07 does not match the cutsheet' in title
assert mon.tickets.create.call_args.kwargs['priority'] == '4'
def test_no_second_ticket_and_suppression_respected(self, mon, db):
self._run(mon, db, consec=900, ticket='123')
self._run(mon, db, consec=900, suppressions=[('unifi_device', 'sw')])
assert not mon.tickets.create.called
def test_zero_hours_never_tickets(self, mon, db):
mon.cutsheet_ticket_hours = 0
self._run(mon, db, consec=10_000)
assert db.upsert_event.called and not mon.tickets.create.called
def test_fixed_cable_resolves_its_event(self, mon, db):
self._run(mon, db, seen='tv', open_targets=[('sw', 'cable 7'), ('other', 'cable 9')])
assert not db.upsert_event.called
resolved = {c.args for c in db.resolve_event.call_args_list}
assert ('cable_mismatch', 'sw', 'cable 7') in resolved and ('cable_mismatch', 'other', 'cable 9') in resolved