fix(topology): read the spanning-tree root instead of guessing it (v0.23.35-beta)

Closes #131.

Reported as the wrong device being marked root: an RB4011 at priority 4096 says
"root bridge: yes", two APs at 8192 say they are not and name the RB4011's MAC,
and the map crowned an AP anyway — consistently, always the same one.

The detection had never worked at all. It looked for a device with no port whose
STP role was 'root', but RouterOS reports the role as 'root-port'. Nothing ever
matched, so the set of devices-with-a-root-port was permanently empty and the
lookup returned the first device in the list. It was not choosing badly; it was
not choosing.

The same mismatch broke link state. Blocking was derived from a role of
'alternate' or 'backup' against values that arrive as 'alternate-port' and
'backup-port', so blocked links were drawn as forwarding — which is the "traffic
connection path" half of the report, since a blocked link shown as live yields a
path that does not exist.

Roles are now normalised on write so both ends of the system share one
vocabulary, and the root is no longer inferred. RouterOS answers it directly:
/interface/bridge/monitor reports whether a bridge is the root and names the root
it sees, which is exactly what the reporter's own screenshots show the device
knowing. That is collected into device_bridges and resolved in
utils/stpRoot.ts with 13 tests.

Two properties of real networks shaped the resolution, both observed on the
development fleet rather than reasoned about:

  * **A root belongs to a spanning tree, not to a fleet.** Two of four devices
    here legitimately report root at once, being separate bridge domains, so
    roots are resolved per connected component.
  * **The root may not be ours.** One switch points at priority 8192 on a MAC
    belonging to no device in the inventory. Naming a local device in that case
    is precisely the original bug, so the crown is withheld and the foreign
    bridge id reported instead.

Verified against that fleet: the managed root resolves to the device that claims
it, a self-rooted bridge keeps its own crown, and the switch with an external
root now reports "not ours" where the old logic confidently crowned it — the
reported symptom, reproduced and fixed.

Also adds collectStp to the full sync. It previously ran only on the five-minute
slow poll, so pressing Sync after rewiring did not refresh the map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-09-07 08:58:29 -07:00
co-authored by Claude Opus 5
parent 273a0da737
commit e47819e232
11 changed files with 380 additions and 14 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.23.34-beta",
"version": "0.23.35-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
+25
View File
@@ -886,6 +886,31 @@ CREATE INDEX IF NOT EXISTS idx_data_cap_sends ON lte_data_cap_sends(device_id, a
-- The password is retained alongside. Key auth falling back to a password is a
-- degraded state worth knowing about; key auth failing with no fallback is an
-- outage.
-- What each bridge says about the spanning tree (#131).
--
-- The root bridge used to be *inferred* from port roles, which never worked: the
-- code compared against 'root' while RouterOS reports 'root-port', so no device
-- ever matched and the topology simply crowned whichever device sorted first.
--
-- RouterOS answers the question directly, so we ask instead of deducing.
-- root_bridge is the device's own verdict; root_bridge_id names the actual root
-- as priority.MAC, which is how a root outside the managed fleet stays
-- identifiable rather than being misattributed to something we do manage.
CREATE TABLE IF NOT EXISTS device_bridges (
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
bridge_name VARCHAR(64) NOT NULL,
-- This bridge's own identifier, e.g. "0x8000.F4:1E:57:51:74:1E".
bridge_id VARCHAR(64),
root_bridge BOOLEAN,
root_bridge_id VARCHAR(64),
root_port VARCHAR(64),
root_path_cost INTEGER,
protocol_mode VARCHAR(16),
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (device_id, bridge_name)
);
CREATE INDEX IF NOT EXISTS idx_device_bridges_rootid ON device_bridges(root_bridge_id);
-- Bulk command execution (#118).
--
-- Waves are not a nicety. Running one command across sixty devices in parallel
+21 -1
View File
@@ -18,9 +18,21 @@ export function setPollerService(p: PollerService): void {
}
interface BridgeRow {
device_id: number;
bridge_name: string;
/** This bridge's own id, "priority.MAC" — how a root is matched to a device. */
bridge_id: string | null;
root_bridge: boolean | null;
root_bridge_id: string | null;
root_port: string | null;
root_path_cost: number | null;
protocol_mode: string | null;
}
// GET /api/topology
router.get('/', async (_req: Request, res: Response) => {
const [devices, allLinks, manualLinks, deviceMacs] = await Promise.all([
const [devices, allLinks, manualLinks, deviceMacs, bridges] = await Promise.all([
query<TopoDevice>(
`SELECT id, name, ip_address, model, device_type, status, ros_version, ip_addresses_jsonb
FROM devices ORDER BY name ASC`
@@ -47,12 +59,20 @@ router.get('/', async (_req: Request, res: Response) => {
UNION
SELECT device_id, mac_address FROM wireless_interfaces WHERE mac_address IS NOT NULL`
),
// What each bridge reports about the spanning tree. Authoritative, and the
// reason the root is no longer guessed from port roles (#131).
query<BridgeRow>(
`SELECT device_id, bridge_name, bridge_id, root_bridge, root_bridge_id,
root_port, root_path_cost, protocol_mode
FROM device_bridges`
),
]);
const graph = buildTopology(devices, allLinks, manualLinks, deviceMacs);
res.json({
devices,
bridges,
links: graph.links,
externalNodes: graph.externalNodes,
segConns: graph.segConns,
@@ -176,6 +176,10 @@ export class DeviceCollector {
await this.updateClients();
await this.collectEvents();
await this.collectNeighbors();
// Topology and spanning tree belong in a full resync: someone pressing Sync
// after rewiring expects the map to catch up, and previously STP only
// refreshed on the five-minute slow poll (#131).
await this.collectStp();
await this.collectLte();
// Wireless data belongs in a full resync too — without this, a manual sync
// never refreshed radios/SSIDs (or pruned ones deleted on the device), so
@@ -1054,15 +1058,72 @@ export class DeviceCollector {
async collectStp(): Promise<void> {
try {
// Ask each bridge what it thinks the spanning tree looks like.
//
// This used to be inferred from port roles and got it wrong every time:
// the comparison was against 'root' while RouterOS reports 'root-port', so
// nothing matched and the topology crowned whichever device sorted first.
// The device already knows — `/interface/bridge/monitor` reports whether it
// is the root and names the root it sees (#131).
const bridges = await this.client
.execute('/interface/bridge/print', { detail: '' })
.catch(() => [] as Record<string, string>[]);
const seen: string[] = [];
for (const bridge of bridges) {
const name = bridge['name'];
if (!name) continue;
const mon = await this.client
.execute('/interface/bridge/monitor', { numbers: name, once: '' })
.catch(() => [] as Record<string, string>[]);
const m = mon[0];
if (!m) continue;
seen.push(name);
const cost = parseInt(m['root-path-cost'] || '', 10);
await query(
`INSERT INTO device_bridges
(device_id, bridge_name, bridge_id, root_bridge, root_bridge_id,
root_port, root_path_cost, protocol_mode, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW())
ON CONFLICT (device_id, bridge_name) DO UPDATE SET
bridge_id = EXCLUDED.bridge_id, root_bridge = EXCLUDED.root_bridge,
root_bridge_id = EXCLUDED.root_bridge_id, root_port = EXCLUDED.root_port,
root_path_cost = EXCLUDED.root_path_cost,
protocol_mode = EXCLUDED.protocol_mode, updated_at = NOW()`,
[
this.device.id, name, m['bridge-id'] || null,
m['root-bridge'] === 'true',
m['root-bridge-id'] || null,
m['root-port'] || null,
Number.isFinite(cost) ? cost : null,
bridge['protocol-mode'] || null,
]
);
}
// A bridge that has been deleted should stop being reported as root.
if (seen.length > 0) {
await query(
`DELETE FROM device_bridges WHERE device_id = $1 AND NOT (bridge_name = ANY($2::text[]))`,
[this.device.id, seen]
);
} else {
await query(`DELETE FROM device_bridges WHERE device_id = $1`, [this.device.id]);
}
const bridgePorts = await this.client
.execute('/interface/bridge/port/print', { detail: '' })
.catch(() => []);
for (const port of bridgePorts) {
const iface = (port['interface'] as string) || '';
const role = (port['role'] as string) || '';
const bridgeName = (port['bridge'] as string) || '';
// Derive state from role — alternate/backup are blocking, root/designated are forwarding
// RouterOS suffixes every role with "-port" — "root-port",
// "alternate-port". Comparing against the bare word matched nothing, so
// blocked links were drawn as forwarding. Normalised on write so both
// ends of the system agree on one vocabulary.
const role = ((port['role'] as string) || '').replace(/-port$/, '');
const state = (role === 'alternate' || role === 'backup') ? 'blocking' : 'forwarding';
if (!iface) continue;