Topology: power map, what each outlet feeds #40
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user