diff --git a/README.md b/README.md index ab44251..b819927 100644 --- a/README.md +++ b/README.md @@ -212,15 +212,23 @@ servers along the bottom. Power gear (PDU, UPS, power strip) sits in a rail on t "Downlink to X" with X on the other end never flag. - **Alerts**: a device with active alerts (not suppressed) gets a ring and a count in its worst severity colour, and the NIC it is about is marked. +- **Power**: each PDU, UPS and power strip card lists its outlets that feed something, draw power or + are off, with live watts; each device it feeds shows a ⚡ badge with what it draws. Hover a power + card or a device to draw its feeds. An outlet that is off but feeds something is listed above the + diagram. What an outlet feeds comes from, in order: an assignment saved here, the outlet's name in + UniFi when it names a device ("storage-01", "Dream Machine Pro", "AT&T Modem" for the modem), or a + MAC in its name that belongs to a host NIC or UniFi device. Admins click an outlet to set what it + feeds, set it to nothing, or go back to automatic. Assignments are kept in Gandalf (table + `power_feeds`); Gandalf never renames outlets in UniFi. API: `PUT /api/topology/power//` + with `{"target": ""}`, `""` for nothing or `null` for automatic. - Click a device for its page, or a port on a faceplate for that port. Click a cable for its details: both ends, speed, cable type and module, traffic each way and how busy it is, errors and drops, and its cutsheet record. Hovering a device traces its path to the internet. - Drag to pan, scroll or pinch to zoom, **Fit** (or `0`) to see everything, `+` and `-` to zoom. Show unknown devices grouped, one by one or not at all. **Server 1G links** (off by default) adds each - server's slower second cable, its management link; **Power gear** (off by default) adds the PDU, UPS - and power strip. Traffic labels and dimming everything without an alert can be switched too. The + server's slower second cable, its management link; **Power gear** shows or hides the power rail. Traffic labels and dimming everything without an alert can be switched too. The filters are remembered per browser. Refreshes every 30 seconds without moving the view. -- API: `GET /api/topology` returns `{nodes, links, warnings, updated}` (see `topology.py`). +- API: `GET /api/topology` returns `{nodes, links, power, warnings, updated}` (see `topology.py`). ### Health check (`/health`) No login needed. Returns 200 when the database answers and the monitor has completed a check recently, diff --git a/app.py b/app.py index 87aa948..ef0dd89 100644 --- a/app.py +++ b/app.py @@ -1312,7 +1312,8 @@ def api_rack(): @app.route('/topology') @require_auth def topology_page(): - return render_template('topology.html', user=_get_user()) + user = _get_user() + return render_template('topology.html', user=user, can_edit='admin' in user['groups']) @app.route('/api/topology') @@ -1324,14 +1325,48 @@ def api_topology(): try: _ensure_cutsheet_schema() sheet = db.cutsheet_load() + feeds = db.get_power_feeds() except Exception as e: logger.error(f'Cutsheet unavailable for topology: {e}') - sheet = {'items': [], 'cables': []} - graph = topology.build_graph( + sheet, feeds = {'items': [], 'cables': []}, [] + return jsonify(_topology_graph(events, sheet, feeds)) + + +def _topology_graph(events: list, sheet: dict, feeds: list) -> dict: + return topology.build_graph( _state_json('network_snapshot'), _state_json('link_stats'), _state_json('modem_stats'), - events, sheet['items'], sheet['cables'], _state_json('known_uplinks'), + events, sheet['items'], sheet['cables'], _state_json('known_uplinks'), feeds, ) - return jsonify(graph) + + +@app.route('/api/topology/power//', methods=['PUT']) +@require_auth +@require_admin +@_cutsheet_write +def api_topology_power(source: str, outlet: int): + """Say what a power outlet feeds. Body: {"target": ""} or "" for nothing, or + null to go back to the guess from the outlet's name.""" + body = _json_body() + if 'target' not in body: + raise cutsheet.ValidationError('Send target: a device name, "" for nothing, or null for automatic') + target = body['target'] + if target is not None and not isinstance(target, str): + raise cutsheet.ValidationError('target must be a device name, "" or null') + graph = _topology_graph([], {'items': [], 'cables': []}, []) + src = next((p for p in graph['power'] if p['node'] == source), None) + if src is None: + return jsonify({'error': f'No power device named "{source}"'}), 404 + if not any(o['index'] == outlet for o in src['outlets']): + return jsonify({'error': f'{source} has no outlet {outlet}'}), 404 + names = {n['name'] for n in graph['nodes'] if n['kind'] not in ('stub', 'internet')} + if target and target.strip() not in names: + raise cutsheet.ValidationError(f'No device named "{target}"') + target = target.strip() if target else target + user = _get_user()['username'] + db.set_power_feed(source, outlet, target, user) + shown = '(automatic)' if target is None else (target or '(nothing)') + logger.info(f'{user} set {source} outlet {outlet} to feed {shown}') + return jsonify({'success': True, 'source': source, 'outlet': outlet, 'target': target}) @app.route('/api/cutsheet/racks', methods=['POST']) diff --git a/db.py b/db.py index 88839f3..4859485 100644 --- a/db.py +++ b/db.py @@ -826,9 +826,44 @@ _CUTSHEET_SCHEMA = ( updated_by VARCHAR(255) NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB""", + # What each PDU, UPS or power strip outlet feeds, set by hand on the topology page. An empty + # target means "nothing": it overrides a guess from the outlet's name. Gandalf never renames + # outlets in UniFi. + """CREATE TABLE IF NOT EXISTS power_feeds ( + source VARCHAR(255) NOT NULL, + outlet SMALLINT NOT NULL, + target VARCHAR(255) NOT NULL DEFAULT '', + updated_by VARCHAR(255) NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (source, outlet) + ) ENGINE=InnoDB""", ) +def get_power_feeds() -> list: + """Every outlet assignment: [{source, outlet, target, updated_by, updated_at}].""" + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute('SELECT source, outlet, target, updated_by, updated_at FROM power_feeds') + rows = cur.fetchall() + return [dict(r, updated_at=r['updated_at'].isoformat() + 'Z' if hasattr(r['updated_at'], 'isoformat') + else r['updated_at']) for r in rows] + + +def set_power_feed(source: str, outlet: int, target: Optional[str], user: str) -> None: + """Say what an outlet feeds: a device name, '' for nothing, or None to go back to the guess.""" + with get_conn() as conn: + with conn.cursor() as cur: + if target is None: + cur.execute('DELETE FROM power_feeds WHERE source=%s AND outlet=%s', (source, outlet)) + return + cur.execute( + """INSERT INTO power_feeds (source, outlet, target, updated_by) VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE target=VALUES(target), updated_by=VALUES(updated_by)""", + (source, outlet, target, user), + ) + + def get_device_link(name: str) -> str: """The device's inventory URL, or ''.""" with get_conn() as conn: diff --git a/static/style.css b/static/style.css index 04d3e4b..456ddd1 100644 --- a/static/style.css +++ b/static/style.css @@ -1636,3 +1636,30 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .tp-chip-bad { color: var(--red); border-color: var(--red); background: var(--red-dim); } .tp-chip-warn { color: var(--amber); border-color: var(--amber); background: var(--amber-dim); } .tp-problem-chip:hover, .tp-problem-chip:focus-visible { filter: brightness(1.25); outline: none; } + +/* Power: outlet lists on power cards, badges on what they feed, feed lines on hover */ +.tp-outlet-hit { fill: transparent; } +.tp-outlet-row:hover .tp-outlet-hit, .tp-outlet-row:focus-visible .tp-outlet-hit { fill: rgba(255,179,0,.12); } +.tp-outlet-row[role="button"] { cursor: pointer; outline: none; } +.tp-outlet-idx { font-family: var(--font); font-size: 24px; fill: var(--text-muted); } +.tp-outlet-target { font-family: var(--font); font-size: 26px; fill: var(--text); } +.tp-outlet-watts { font-family: var(--font); font-size: 24px; fill: #e0a400; } +.tp-outlet-free .tp-outlet-target { fill: var(--text-muted); font-style: italic; } +.tp-outlet-off .tp-outlet-watts, .tp-outlet-off .tp-outlet-target { fill: #ff6b5e; } +.tp-power-badge { font-family: var(--font); font-size: 26px; font-weight: 700; fill: #e0a400; } +.tp-power-off { fill: #ff6b5e; } +.tp-feeds { pointer-events: none; } +.tp-feed path { fill: none; stroke: #ffb300; stroke-width: 6; stroke-dasharray: 2 14; stroke-linecap: round; } +.tp-feed circle { fill: #ffb300; } +.tp-feed-off path { stroke: #e0312b; } +.tp-feed-off circle { fill: #e0312b; } +.tp-feed-tag rect { stroke: #ffb300; } +.tp-pop { position: absolute; z-index: 3; width: 280px; padding: 10px 12px; font-size: .8em; + background: var(--bg-primary); border: 1px solid var(--amber); box-shadow: 0 8px 24px rgba(0,0,0,.35); cursor: auto; } +.tp-pop[hidden] { display: none; } +.tp-pop-title { font-weight: 700; color: var(--amber); } +.tp-pop-sub { color: var(--text-muted); margin: 2px 0 8px; overflow-wrap: anywhere; } +.tp-pop-label { display: block; color: var(--text-dim); margin-bottom: 3px; } +.tp-pop select { width: 100%; } +.tp-pop-hint { color: var(--text-muted); font-size: .9em; margin: 6px 0 8px; } +.tp-pop-actions { display: flex; justify-content: flex-end; gap: 6px; } diff --git a/static/topology.js b/static/topology.js index 9764d8d..af3f530 100644 --- a/static/topology.js +++ b/static/topology.js @@ -1,5 +1,5 @@ 'use strict'; -/* global FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */ +/* global TOPO_CAN_EDIT, FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */ // Topology page: a card per device with its real faceplate or chassis, and each cable leaving the // exact port it is plugged into. Data comes from /api/topology (topology.build_graph). @@ -8,7 +8,7 @@ const PER_MM = 1100 / 482.6; const NODE_GAP = 90; const REFRESH_MS = 30000; const FILTER_KEY = 'gandalf.topology.filters'; -const DEFAULT_FILTERS = { stubs: 'grouped', power: false, traffic: true, mgmt: false, alerts: false }; +const DEFAULT_FILTERS = { stubs: 'grouped', power: true, traffic: true, mgmt: false, alerts: false }; const STUB_MODES = ['grouped', 'all', 'none']; const state = { @@ -100,6 +100,12 @@ function problemsOf(data) { out.push({ kind: 'node', id: n.id, severity: n.status === 'down' || n.severity === 'critical' ? 'bad' : 'warn', text: `${n.name}: ${bits.join(', ')}` }); }); + (data.power || []).forEach(src => src.outlets.forEach(o => { + if (o.on || !o.target) return; + const down = (byId[o.target] || {}).status === 'down'; + out.push({ kind: 'node', id: o.target, severity: down ? 'bad' : 'warn', + text: `${src.node} outlet ${o.index} is off, and it feeds ${o.target}` }); + })); data.links.forEach(l => { const p = linkProblem(l); if (!p) return; @@ -352,6 +358,22 @@ function artFor(node) { } } +// ── Power ─────────────────────────────────────────────────────────────── +function wattText(w) { + return w == null ? '' : `${w >= 100 ? Math.round(w) : (+w).toFixed(1)} W`; +} + +function powerOf(name) { + return ((state.data && state.data.power) || []).find(p => p.node === name); +} + +// The outlets worth listing on a power card: those feeding something, drawing power, or off +function listedOutlets(src) { + return src.outlets.filter(o => o.target || (o.power_w || 0) >= 0.5 || !o.on); +} + +const ROW_H = 46; + // ── Cards ─────────────────────────────────────────────────────────────── // Every device is a card of the same height: its name and state on top, and below that its real // faceplate (or, for a server, its front and a socket per NIC) shrunk to fit. Cables meet the card @@ -421,6 +443,37 @@ function cardFor(node, linksByIface) { return a ? { x: f.ox + a.x * f.k, y: f.oy + a.y * f.k } : null; }; } + // Power gear lists its outlets under the picture: what each feeds and what it draws + let h = CARD_H; + let rowsSvg = ''; + const rows = {}; + const src = node.power ? powerOf(node.name) : null; + if (src) { + const list = listedOutlets(src); + rowsSvg = list.map((o, i) => { + const y = CARD_H + i * ROW_H; + rows[o.index] = y + ROW_H / 2; + const what = o.target || (o.match === 'cleared' ? 'nothing' : 'unassigned'); + const cls = `tp-outlet-row${o.on ? '' : ' tp-outlet-off'}${o.target ? '' : ' tp-outlet-free'}`; + const tip = `${node.name} outlet ${o.index}${o.name ? ` "${o.name}"` : ''} · ${o.on ? 'on' : 'off'}${o.power_w != null ? ` · ${wattText(o.power_w)}` : ''} · feeds ${what}${o.match === 'saved' ? ' (set here)' : o.match === 'name' ? ' (from its name)' : o.match === 'mac' ? ' (from the MAC in its name)' : ''}${TOPO_CAN_EDIT ? ' · click to change' : ''}`; + return ` + ${esc(tip)} + + ${o.index} + ${esc(what.length > 22 ? `${what.slice(0, 21)}…` : what)} + ${o.on ? esc(wattText(o.power_w)) : 'off'} + `; + }).join(''); + h = CARD_H + list.length * ROW_H + (list.length ? 16 : 0); + } + // What feeds this device, as a badge by its name + let badge = ''; + if ((node.power_in || []).length) { + const off = node.power_in.some(f => !f.on); + const watts = node.power_in.reduce((t, f) => t + (f.power_w || 0), 0); + const tip = node.power_in.map(f => `${f.source} outlet ${f.outlet}${f.on ? '' : ' (off)'}${f.power_w != null ? ` · ${wattText(f.power_w)}` : ''}`).join('; '); + badge = `${esc(`Fed by ${tip}`)}⚡ ${off ? 'off' : esc(wattText(watts) || 'fed')}`; + } const maxChars = Math.floor((w - 130) / 27); const name = node.name.length > maxChars ? `${node.name.slice(0, maxChars - 1)}…` : node.name; let sub; @@ -428,15 +481,19 @@ function cardFor(node, linksByIface) { else if (node.kind === 'stub') sub = node.ip ? `${node.switch} port ${node.port_idx}` : (node.mac || `${node.switch} port ${node.port_idx}`); else if (node.kind === 'host') sub = node.status === 'up' ? 'server' : `server · ${node.status}`; else if (node.kind === 'internet') sub = node.status === 'up' ? 'connected' : node.status; - else sub = [node.model || node.kind, node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · '); - const svg = ` - + else sub = [node.model || node.kind, src && src.power_w != null ? `${wattText(src.power_w)} total` : '', + node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · '); + // The second line shares its space with the power badge + const subMax = Math.floor((w - (badge ? 250 : 70)) / 16.8); + if (sub.length > subMax) sub = `${sub.slice(0, subMax - 1)}…`; + const svg = ` + ${esc(name)} ${esc(sub)} - + ${badge} - ${band}`; - return { w, h: CARD_H, svg, anchorAt }; + ${band}${rowsSvg}`; + return { w, h, svg, anchorAt, rows }; } // ── Layout ────────────────────────────────────────────────────────────── @@ -829,7 +886,8 @@ function render() { const cables = lay.links.map(l => renderLink(l, lay)); world.innerHTML = `${cables.map(c => c.under).join('')} ${lay.nodes.map(n => renderNode(n, lay)).join('')} - ${cables.map(c => c.over).join('')}`; + ${cables.map(c => c.over).join('')} + `; document.getElementById('tp-loading').hidden = true; applyFilterClasses(); if (!state.fitted) { @@ -929,14 +987,50 @@ function upstream(id) { return { nodes, hot }; } +// Power feeds, drawn while a power unit or a device it feeds is under the pointer: dotted lines +// from the outlet's row on the power card to the device +function drawFeeds(id) { + const lay = state.layout; + const g = document.getElementById('tp-feeds'); + if (!g || !lay) return []; + const pairs = []; + ((state.data && state.data.power) || []).forEach(src => { + const sp = lay.pos[src.node]; + if (!sp) return; + src.outlets.forEach(o => { + if (!o.target || !lay.pos[o.target] || (id !== src.node && id !== o.target)) return; + const rowY = lay.drawings[src.node].rows[o.index]; + if (rowY == null) return; + pairs.push({ o, from: { x: sp.x, y: sp.y + rowY }, to: lay.pos[o.target], toH: lay.drawings[o.target].h, target: o.target }); + }); + }); + g.innerHTML = pairs.map(({ o, from, to, toH, target }) => { + const B = { x: to.x + to.w, y: to.y + toH / 2 }; + if (to.rail) { B.x = to.x; } + const c = Math.max(160, Math.abs(from.x - B.x) * 0.4); + const d = `M${from.x} ${from.y} C${from.x - c} ${from.y} ${B.x + c} ${B.y} ${B.x} ${B.y}`; + const mx = (from.x + B.x) / 2, my = (from.y + B.y) / 2; + return ` + + + ⚡ ${esc(target)} ${o.on ? esc(wattText(o.power_w)) : 'off'}`; + }).join(''); + // Everything at either end of a feed stays lit while the path is traced + return new Set(pairs.flatMap(p => [p.target, ...((state.data.power || []).filter(src => src.outlets.includes(p.o)).map(src => src.node))])); +} + function highlight(id) { const canvas = document.getElementById('tp-canvas'); document.querySelectorAll('.tp-hot').forEach(el => el.classList.remove('tp-hot')); + const feeds = document.getElementById('tp-feeds'); + if (feeds) feeds.innerHTML = ''; if (!id || !state.layout) { canvas.classList.remove('tp-tracing'); return; } + const fed = drawFeeds(id); const { nodes, hot } = upstream(id); + fed.forEach(n => nodes.add(n)); nodes.forEach(n => document.querySelector(`.tp-node[data-node="${CSS.escape(n)}"]`)?.classList.add('tp-hot')); hot.forEach(l => document.querySelectorAll(`.tp-link[data-link="${CSS.escape(l)}"]`).forEach(el => el.classList.add('tp-hot'))); canvas.classList.add('tp-tracing'); @@ -1068,6 +1162,13 @@ function initPanZoom() { function initEvents() { const svg = document.getElementById('tp-svg'); svg.addEventListener('click', e => { + const outletRow = e.target.closest('[data-outlet]'); + if (outletRow && TOPO_CAN_EDIT) { + e.preventDefault(); + openOutletEditor(outletRow, e); + return; + } + if (!e.target.closest('#tp-pop')) closeOutletEditor(); const port = e.target.closest('[data-action="select-port"]'); if (port) { e.preventDefault(); @@ -1090,6 +1191,12 @@ function initEvents() { }); svg.addEventListener('keydown', e => { if (e.key !== 'Enter' && e.key !== ' ') return; + const outletRow = e.target.closest('[data-outlet]'); + if (outletRow && TOPO_CAN_EDIT) { + e.preventDefault(); + openOutletEditor(outletRow, null); + return; + } const group = e.target.closest('[data-expand]'); if (group) { e.preventDefault(); @@ -1113,6 +1220,15 @@ function initEvents() { svg.addEventListener('mouseleave', () => highlight(null)); svg.addEventListener('focusout', e => { if (!svg.contains(e.relatedTarget)) highlight(null); }); + document.getElementById('tp-pop').addEventListener('click', e => { + const b = e.target.closest('[data-pop]'); + if (!b) return; + if (b.dataset.pop === 'save') saveOutlet(); + else closeOutletEditor(); + }); + document.getElementById('tp-pop').addEventListener('keydown', e => { + if (e.key === 'Escape') closeOutletEditor(); + }); document.getElementById('tp-summary').addEventListener('click', e => { const chip = e.target.closest('.tp-problem-chip'); if (!chip) return; @@ -1145,6 +1261,62 @@ function initEvents() { }); } +// ── Outlet editor (admins) ────────────────────────────────────────────── +function closeOutletEditor() { + const pop = document.getElementById('tp-pop'); + if (pop) pop.hidden = true; +} + +function openOutletEditor(row, evt) { + const pop = document.getElementById('tp-pop'); + const source = row.dataset.outletSource; + const index = Number(row.dataset.outlet); + const src = powerOf(source); + const o = src && src.outlets.find(x => x.index === index); + if (!o) return; + const names = state.data.nodes.filter(n => !['stub', 'internet'].includes(n.kind) && n.name !== source) + .map(n => n.name).sort((a, b) => a.localeCompare(b)); + const current = o.match === 'saved' ? o.target : (o.match === 'cleared' ? '__none' : '__auto'); + const opt = (v, t) => ``; + pop.innerHTML = `
${esc(source)} outlet ${index}
+
${esc(o.name || 'no name in UniFi')} · ${o.on ? esc(wattText(o.power_w) || 'on') : 'off'}
+ + +

Saved in Gandalf only; the outlet keeps its name in UniFi.

+
+ + +
`; + const canvas = document.getElementById('tp-canvas').getBoundingClientRect(); + const r = evt ? { x: evt.clientX, y: evt.clientY } : (() => { const b = row.getBoundingClientRect(); return { x: b.left, y: b.bottom }; })(); + pop.hidden = false; + const left = Math.min(Math.max(8, r.x - canvas.left - pop.offsetWidth - 12), canvas.width - pop.offsetWidth - 8); + const top = Math.min(Math.max(8, r.y - canvas.top - 20), canvas.height - pop.offsetHeight - 8); + pop.style.left = `${left}px`; + pop.style.top = `${top}px`; + pop.dataset.source = source; + pop.dataset.outlet = String(index); + document.getElementById('tp-pop-select').focus(); +} + +async function saveOutlet() { + const pop = document.getElementById('tp-pop'); + const v = document.getElementById('tp-pop-select').value; + const target = v === '__auto' ? null : (v === '__none' ? '' : v); + try { + await lt.api.put(`/api/topology/power/${encodeURIComponent(pop.dataset.source)}/${encodeURIComponent(pop.dataset.outlet)}`, { target }); + closeOutletEditor(); + lt.toast.success('Outlet saved'); + refresh(); + } catch (e) { + lt.toast.error(`Could not save: ${e.message || e}`); + } +} + // ── Problem summary ───────────────────────────────────────────────────── function renderSummary() { const box = document.getElementById('tp-summary'); diff --git a/templates/topology.html b/templates/topology.html index 04eef79..2e5758f 100644 --- a/templates/topology.html +++ b/templates/topology.html @@ -39,6 +39,7 @@ +
@@ -55,6 +56,7 @@ {% endblock %} {% block scripts %} + diff --git a/tests/test_app.py b/tests/test_app.py index 6e06352..ca56e1a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -743,3 +743,51 @@ class TestTopology: patch.object(gandalf.db, 'check_suppressed', return_value=True): data = client.get('/api/topology', headers=AUTH).get_json() assert next(n for n in data['nodes'] if n['name'] == 'large1')['alert_count'] == 0 + + +class TestPowerFeedApi: + LINK_STATS = json.dumps({'hosts': {}, 'unifi_switches': {}, 'unifi_devices': { + 'RPDU': {'outlets': [{'index': 7, 'name': 'Outlet 7', 'on': True, 'power_w': 48.0}]}}}) + SNAPSHOT = json.dumps({ + 'hosts': {'large1': {'status': 'up', 'interfaces': {}}}, + 'unifi': [{'name': 'RPDU', 'type': 'usw', 'model': 'USPPDUP', 'connected': True, 'mac': '', 'ip': ''}], + }) + + def _put(self, client, path, body, headers=AUTH): + state = dict(STATE, link_stats=self.LINK_STATS, network_snapshot=self.SNAPSHOT) + gandalf._cutsheet_schema_ready = True + with patch.object(gandalf.db, 'get_state', side_effect=lambda k, d=None: state.get(k, d)): + return client.put(path, json=body, headers=headers) + + def test_admin_assigns_an_outlet(self, client): + resp = self._put(client, '/api/topology/power/RPDU/7', {'target': 'large1'}) + assert resp.status_code == 200 + gandalf.db.set_power_feed.assert_called_once_with('RPDU', 7, 'large1', 'jared') + + def test_empty_means_nothing_and_null_means_automatic(self, client): + assert self._put(client, '/api/topology/power/RPDU/7', {'target': ''}).status_code == 200 + assert self._put(client, '/api/topology/power/RPDU/7', {'target': None}).status_code == 200 + calls = [c.args for c in gandalf.db.set_power_feed.call_args_list] + assert calls == [('RPDU', 7, '', 'jared'), ('RPDU', 7, None, 'jared')] + + def test_needs_admin(self, client): + resp = self._put(client, '/api/topology/power/RPDU/7', {'target': 'large1'}, + headers=dict(AUTH, **{'Remote-Groups': 'users'})) + assert resp.status_code in (401, 403) + gandalf.db.set_power_feed.assert_not_called() + + def test_unknown_target_source_or_outlet(self, client): + assert self._put(client, '/api/topology/power/RPDU/7', {'target': 'nope'}).status_code == 400 + assert self._put(client, '/api/topology/power/NOPE/7', {'target': 'large1'}).status_code == 404 + assert self._put(client, '/api/topology/power/RPDU/99', {'target': 'large1'}).status_code == 404 + gandalf.db.set_power_feed.assert_not_called() + + def test_body_must_say_target(self, client): + assert self._put(client, '/api/topology/power/RPDU/7', {}).status_code == 400 + assert self._put(client, '/api/topology/power/RPDU/7', {'target': 5}).status_code == 400 + + def test_must_be_json(self, client): + gandalf._cutsheet_schema_ready = True + resp = client.put('/api/topology/power/RPDU/7', data='target=large1', headers=AUTH, + content_type='application/x-www-form-urlencoded') + assert resp.status_code == 400 diff --git a/tests/test_db.py b/tests/test_db.py index 2410fa7..1863cc2 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -62,3 +62,25 @@ class TestGetClearedTickets: out = db.get_cleared_tickets(['1', '2']) assert [r['ticket_id'] for r in out] == ['2', '1'] assert out[0]['target_name'] == 'micro1' + + +class TestPowerFeeds: + def test_none_goes_back_to_automatic(self): + patcher, cur = _fake_conn([]) + with patcher: + db.set_power_feed('RPDU', 7, None, 'jared') + sql, args = cur.execute.call_args.args + assert sql.startswith('DELETE FROM power_feeds') and args == ('RPDU', 7) + + def test_empty_target_is_saved_as_nothing(self): + patcher, cur = _fake_conn([]) + with patcher: + db.set_power_feed('RPDU', 7, '', 'jared') + sql, args = cur.execute.call_args.args + assert 'INSERT INTO power_feeds' in sql and args == ('RPDU', 7, '', 'jared') + + def test_rows_come_back_with_iso_times(self): + patcher, _ = _fake_conn([{'source': 'RPDU', 'outlet': 7, 'target': 'large1', 'updated_by': 'jared', + 'updated_at': NOW}]) + with patcher: + assert db.get_power_feeds()[0]['updated_at'] == '2026-11-14T12:00:00Z' diff --git a/tests/test_topology.py b/tests/test_topology.py index 2eade1a..49fc963 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -336,3 +336,54 @@ class TestEmpty: g = topology.build_graph({}, {}, None, [], [], [], None) assert [n['id'] for n in g['nodes']] == [topology.INTERNET] assert g['links'] == [] and g['warnings'] + + +class TestPower: + OUTLETS = [ + {'index': 1, 'name': 'AT&T Modem', 'on': True, 'power_w': 9.0}, + {'index': 6, 'name': 'Dream Machine Pro', 'on': True, 'power_w': 20.6}, + {'index': 7, 'name': 'Outlet 7', 'on': True, 'power_w': 48.0}, + {'index': 14, 'name': 'host-a', 'on': False, 'power_w': 0.0}, + {'index': 16, 'name': '02:00:5e:00:0a:01', 'on': True, 'power_w': 131.7}, + {'index': 17, 'name': 'USB Outlet 1', 'on': True, 'power_w': None}, + ] + + def _graph(self, feeds=(), outlets=None): + snapshot, link_stats, modem = network() + link_stats['unifi_devices']['RPDU']['outlets'] = outlets or self.OUTLETS + link_stats['host_macs'] = {'02:00:5e:00:0a:01': ['host-c', 'nic0']} + return topology.build_graph(snapshot, link_stats, modem, [], [], [], None, list(feeds)) + + def _outlets(self, g, source='RPDU'): + return {o['index']: o for o in next(s for s in g['power'] if s['node'] == source)['outlets']} + + def test_matched_by_name_alias_and_mac(self): + out = self._outlets(self._graph()) + assert (out[1]['target'], out[1]['match']) == ('att-modem', 'name') + assert (out[6]['target'], out[6]['match']) == ('Dream Machine Pro', 'name') + assert (out[14]['target'], out[14]['on']) == ('host-a', False) + assert (out[16]['target'], out[16]['match']) == ('host-c', 'mac') + + def test_generic_names_feed_nothing(self): + out = self._outlets(self._graph()) + assert (out[7]['target'], out[7]['match']) == (None, 'none') + assert out[17]['target'] is None + + def test_saved_assignment_wins_and_empty_means_nothing(self): + feeds = [{'source': 'RPDU', 'outlet': 7, 'target': 'host-b'}, + {'source': 'RPDU', 'outlet': 6, 'target': ''}] + out = self._outlets(self._graph(feeds)) + assert (out[7]['target'], out[7]['match']) == ('host-b', 'saved') + assert (out[6]['target'], out[6]['match']) == (None, 'cleared') + + def test_devices_know_what_feeds_them(self): + g = self._graph() + assert node(g, 'host-c')['power_in'] == [{'source': 'RPDU', 'outlet': 16, 'on': True, 'power_w': 131.7}] + assert node(g, 'host-b')['power_in'] == [] + + def test_a_power_device_does_not_feed_itself(self): + out = self._outlets(self._graph(outlets=[{'index': 1, 'name': 'RPDU spare', 'on': True}])) + assert out[1]['target'] is None + + def test_every_power_device_is_listed(self): + assert [s['node'] for s in self._graph()['power']] == ['RPDU', 'UPS 2U Pro', 'USP Strip'] diff --git a/topology.py b/topology.py index 90fcbf9..b61db41 100644 --- a/topology.py +++ b/topology.py @@ -119,16 +119,74 @@ def name_mismatch(port_name: str, neighbour: str, own: str, known: Dict[str, str """ if not port_name or _is_generic_port_name(port_name): return None - words = _TOKEN_RE.findall(port_name.lower()) - skip = {_norm(neighbour), _norm(own)} + named = _named_device(port_name, known, {_norm(neighbour), _norm(own)}) + return {'named': named, 'actual': neighbour} if named else None + + +def _named_device(text: str, known: Dict[str, str], skip=()) -> Optional[str]: + """The device a free-text name refers to: the longest run of up to four of its words that + is a device name (ignoring case, spaces, dashes and underscores), or None.""" + words = _TOKEN_RE.findall((text or '').lower()) for size in range(min(4, len(words)), 0, -1): for i in range(len(words) - size + 1): run = ''.join(words[i:i + size]) if run in known and run not in skip: - return {'named': known[run], 'actual': neighbour} + return known[run] return None +_MAC_IN_TEXT = re.compile(r'\b([0-9a-f]{2}(?:[:-][0-9a-f]{2}){5})\b', re.IGNORECASE) + + +def build_power(nodes: Dict[str, dict], link_stats: dict, feeds: List[dict]) -> List[dict]: + """What every PDU, UPS and power strip outlet feeds. + + In order: an assignment saved on the topology page (target '' means nothing), then the + outlet's name if it names a device ("storage-01", "Dream Machine Pro", or "AT&T Modem" for + the modem), then a MAC in its name that belongs to a host NIC or a UniFi device. + Returns [{node, power_w, outlets: [{index, name, on, power_w, auto_power_cycle, target, + match: saved|cleared|name|mac|none}]}]. + """ + known = {_norm(n['name']): n['name'] for n in nodes.values() if n['kind'] not in ('stub', 'internet')} + modem = next((n['name'] for n in nodes.values() if n['kind'] == 'modem'), None) + macs = {str(mac).lower(): owner[0] for mac, owner in (link_stats.get('host_macs') or {}).items() if owner} + for name, info in (link_stats.get('unifi_devices') or {}).items(): + if info.get('mac'): + macs.setdefault(info['mac'].lower(), name) + saved = {} + for f in feeds or []: + try: + saved[(f['source'], int(f['outlet']))] = f.get('target') or '' + except (KeyError, TypeError, ValueError): + continue + + sources = [] + for node in sorted((n for n in nodes.values() if n.get('power')), key=lambda n: n['name'].lower()): + rows = [] + for o in node.get('outlets') or []: + idx = o.get('index') + text = str(o.get('name') or '') + target, how = None, 'none' + if (node['name'], idx) in saved: + target = saved[(node['name'], idx)] or None + how = 'saved' if target else 'cleared' + else: + target = _named_device(text, known, {_norm(node['name'])}) + if not target and modem and 'modem' in _TOKEN_RE.findall(text.lower()): + target = modem + if target: + how = 'name' + else: + m = _MAC_IN_TEXT.search(text) + target = macs.get(m.group(1).lower().replace('-', ':')) if m else None + how = 'mac' if target else 'none' + rows.append({'index': idx, 'name': text, 'on': o.get('on') is not False, + 'power_w': o.get('power_w'), 'auto_power_cycle': bool(o.get('auto_power_cycle')), + 'target': target, 'match': how}) + sources.append({'node': node['name'], 'power_w': node.get('power_w'), 'outlets': rows}) + return sources + + def _cable_labels(items: List[dict], cables: List[dict]) -> Dict[Tuple[str, str], dict]: """{(device, port): cable} for cutsheet cables still in place. A cable can name the device itself or a cutsheet item linked to it.""" @@ -167,13 +225,14 @@ def _worst(alerts: List[dict]) -> str: def build_graph(snapshot: dict, link_stats: dict, modem: Optional[dict], events: List[dict], cutsheet_items: List[dict], cutsheet_cables: List[dict], - known_uplinks: Optional[dict] = None) -> dict: - """Every device and cable, as {nodes, links, warnings, updated}. + known_uplinks: Optional[dict] = None, power_feeds: Optional[List[dict]] = None) -> dict: + """Every device and cable, as {nodes, links, power, warnings, updated}. snapshot, link_stats, modem: the monitor's saved state. events: active events, with is_suppressed set (suppressed ones are left out). known_uplinks: the monitor's record of where each device was last plugged in, drawn as "last seen" links for a device with no live link. + power_feeds: outlet assignments saved on the topology page (see build_power). """ snapshot = snapshot or {} link_stats = link_stats or {} @@ -401,11 +460,22 @@ def build_graph(snapshot: dict, link_stats: dict, modem: Optional[dict], events: if nic['name'] == end['iface']: nic.setdefault('links', []).append(link['id']) + # Power: what each outlet feeds, and on each device, the outlets feeding it + power = build_power({k: v for k, v in nodes.items() if v['kind'] != 'stub'}, link_stats, power_feeds or []) + for node in nodes.values(): + node['power_in'] = [] + for src in power: + for o in src['outlets']: + if o['target'] in nodes: + nodes[o['target']]['power_in'].append( + {'source': src['node'], 'outlet': o['index'], 'on': o['on'], 'power_w': o['power_w']}) + if not switches: warnings.append('No UniFi switch data yet: the monitor has not reported any ports') return { 'nodes': sorted(nodes.values(), key=lambda n: (99 if n['depth'] is None else n['depth'], n['name'].lower())), 'links': links, + 'power': power, 'warnings': warnings, 'updated': link_stats.get('updated'), }