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
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:
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">╚</span><span class="lt-frame-br">╝</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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user