Topology: power map, what each outlet feeds
Lint / JS (eslint) (push) Successful in 21s
Lint / Python (flake8) (push) Successful in 2m36s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Test / Python Tests (pytest) (push) Successful in 2m46s
Lint / JS (eslint) (pull_request) Successful in 15s
Lint / Python (flake8) (pull_request) Successful in 2m14s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (push) Successful in 1m33s
Security / Python Security (bandit) (pull_request) Successful in 54s
Test / Python Tests (pytest) (pull_request) Successful in 1m3s

- Each PDU, UPS and power strip card lists its outlets that feed
  something, draw power or are off, with live watts. Each device fed
  gets a power badge with what it draws. Hovering a power card or a
  device draws its feeds as dotted lines and keeps both ends lit.
- What an outlet feeds: a saved assignment, else the outlet's UniFi name
  when it names a device (or the modem), else a MAC in its name that
  belongs to a host NIC (the monitor's host_macs) or UniFi device.
- Admins click an outlet to set what it feeds, nothing, or automatic.
  Saved in a new power_feeds table (created with the cutsheet tables);
  PUT /api/topology/power/<device>/<outlet>, admin and JSON only, with
  the device, outlet and target checked. UniFi is never changed.
- An outlet that is off but feeds something is listed under "to look at".
- Power gear is shown by default again, now that it has something to say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 20:28:13 -05:00
co-authored by Claude Opus 5.5
parent dcf5fdf4cc
commit 415d2222e7
10 changed files with 492 additions and 22 deletions
+11 -3
View File
@@ -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/<device>/<outlet>`
with `{"target": "<device>"}`, `""` 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,
+40 -5
View File
@@ -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/<path:source>/<int:outlet>', methods=['PUT'])
@require_auth
@require_admin
@_cutsheet_write
def api_topology_power(source: str, outlet: int):
"""Say what a power outlet feeds. Body: {"target": "<device>"} 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'])
+35
View File
@@ -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:
+27
View File
@@ -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; }
+181 -9
View File
@@ -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 `<g class="${cls}" data-outlet-source="${esc(node.name)}" data-outlet="${o.index}"${TOPO_CAN_EDIT ? ' role="button" tabindex="0"' : ''}>
<title>${esc(tip)}</title>
<rect class="tp-outlet-hit" x="16" y="${y + 4}" width="${w - 32}" height="${ROW_H - 8}" rx="8"/>
<text class="tp-outlet-idx" x="34" y="${y + 31}">${o.index}</text>
<text class="tp-outlet-target" x="96" y="${y + 31}">${esc(what.length > 22 ? `${what.slice(0, 21)}…` : what)}</text>
<text class="tp-outlet-watts" x="${w - 34}" y="${y + 31}" text-anchor="end">${o.on ? esc(wattText(o.power_w)) : 'off'}</text>
</g>`;
}).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 = `<text class="tp-power-badge${off ? ' tp-power-off' : ''}" x="${w - 34}" y="108" text-anchor="end"><title>${esc(`Fed by ${tip}`)}</title>⚡ ${off ? 'off' : esc(wattText(watts) || 'fed')}</text>`;
}
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 = `<rect class="tp-card" width="${w}" height="${CARD_H}" rx="18"/>
<rect class="tp-card-stripe tp-stripe-${esc(node.status)}" x="0" y="18" width="8" height="${CARD_H - 36}" rx="4"/>
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 = `<rect class="tp-card" width="${w}" height="${h}" rx="18"/>
<rect class="tp-card-stripe tp-stripe-${esc(node.status)}" x="0" y="18" width="8" height="${h - 36}" rx="4"/>
<text class="tp-card-name" x="34" y="66">${esc(name)}</text>
<text class="tp-card-sub" x="34" y="108">${esc(sub)}</text>
<circle class="tp-dot tp-dot-${esc(node.status)}" cx="${w - 42}" cy="52" r="13"/>
<circle class="tp-dot tp-dot-${esc(node.status)}" cx="${w - 42}" cy="52" r="13"/>${badge}
<rect class="tp-band" x="20" y="${BAND_Y - 8}" width="${w - 40}" height="${BAND_H + 16}" rx="10"/>
${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 = `<g class="tp-links">${cables.map(c => c.under).join('')}</g>
<g class="tp-nodes">${lay.nodes.map(n => renderNode(n, lay)).join('')}</g>
<g class="tp-links-over">${cables.map(c => c.over).join('')}</g>`;
<g class="tp-links-over">${cables.map(c => c.over).join('')}</g>
<g class="tp-feeds" id="tp-feeds"></g>`;
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 `<g class="tp-feed${o.on ? '' : ' tp-feed-off'}"><path d="${d}"/>
<circle cx="${B.x}" cy="${B.y}" r="10"/>
<g class="tp-tag tp-feed-tag" transform="translate(${mx} ${my})"><rect x="-150" y="-26" width="300" height="44" rx="22"/>
<text y="6" text-anchor="middle">⚡ ${esc(target)} ${o.on ? esc(wattText(o.power_w)) : 'off'}</text></g></g>`;
}).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) => `<option value="${esc(v)}"${v === current ? ' selected' : ''}>${esc(t)}</option>`;
pop.innerHTML = `<div class="tp-pop-title">${esc(source)} outlet ${index}</div>
<div class="tp-pop-sub">${esc(o.name || 'no name in UniFi')} · ${o.on ? esc(wattText(o.power_w) || 'on') : 'off'}</div>
<label class="tp-pop-label" for="tp-pop-select">Feeds</label>
<select class="lt-select" id="tp-pop-select">
${opt('__auto', `Automatic (${o.match === 'name' || o.match === 'mac' ? o.target : 'from its name: nothing'})`)}
${opt('__none', 'Nothing')}
${names.map(n => opt(n, n)).join('')}
</select>
<p class="tp-pop-hint">Saved in Gandalf only; the outlet keeps its name in UniFi.</p>
<div class="tp-pop-actions">
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-pop="cancel">Cancel</button>
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" data-pop="save">Save</button>
</div>`;
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');
+2
View File
@@ -39,6 +39,7 @@
</svg>
<div class="link-loading tp-loading" id="tp-loading">Loading topology</div>
<aside class="tp-panel" id="tp-panel" hidden aria-live="polite" aria-label="Cable details"></aside>
<div class="tp-pop" id="tp-pop" role="dialog" aria-label="What this outlet feeds" hidden></div>
</div>
<div class="tp-legend" aria-label="Legend">
@@ -55,6 +56,7 @@
{% endblock %}
{% block scripts %}
<script>const TOPO_CAN_EDIT = {{ 'true' if can_edit else 'false' }};</script>
<script src="{{ url_for('static', filename='faceplates.js') }}"></script>
<script src="{{ url_for('static', filename='fronts.js') }}"></script>
<script src="{{ url_for('static', filename='topology.js') }}"></script>
+48
View File
@@ -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
+22
View File
@@ -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'
+51
View File
@@ -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']
+75 -5
View File
@@ -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'),
}