Files
MIKROTIK-RMM/backend/src/utils/rolloutTopology.ts
T
2GT-RichandClaude Opus 5 df8f6dca44 feat(firmware): optional parallel upgrades within a wave (v0.24.3-beta)
Closes #135. The reporter's numbers: CRS switches take ~5 minutes each to
reboot, so ten devices is an hour of mostly waiting.

Devices in a wave can now upgrade concurrently, up to a per-rollout limit.
Waves themselves stay strictly sequential — that is the decision point, and
what makes wave 1 a canary. The default is 1, which is the behaviour every
existing rollout was built around, and existing rows backfill to it.

CommandRunner already runs a wave with Promise.all, but an upgrade is not a
bulk command: it holds an API connection for minutes across a download, a
reboot and a verification, and connection pressure is exactly where this
system has been shown to break. So concurrency is bounded rather than
unlimited, via mapWithConcurrency, capped at 20 and clamped server-side.

The trade is stated rather than hidden. Halting is now evaluated at the end
of a wave instead of the instant a device fails, so a wave of five can put a
bad build on five devices before stopping. At the default of 1 those are the
same moment. The UI says so plainly whenever the setting is above 1.

An upstream warning covers the risk Change Guard cannot: several devices
rebooting together is fine for independent routers and dangerous for a switch
the others are reached through, and a device unreachable because its uplink
rebooted has no config change to revert.

The first implementation of that warning was wrong in an instructive way. It
flagged any device another selected device linked to — and on the real
four-device fleet it flagged all four, because discovered links are
bidirectional and a trunk port often resolves to several neighbours at once.
A warning that fires on everything is worse than none: it teaches people to
dismiss it. It now requires the link to be the reporting device's STP root
port *and* that port to resolve to exactly one neighbour, so it stays quiet
when it cannot tell — which, on this fleet's ambiguous topology, it correctly
does.

Verified on hardware with devices already up to date, so the wave machinery
ran end to end without flashing anything:

  - sequential: 04:20:21→:26, then :26→:31. No overlap, 12s.
  - concurrency 2: both started within 10ms of each other, 7s.
  - concurrency 5 across two waves: wave 2 started 3ms *after* wave 1
    finished, never during it.

Clamping checked at the API: 999 stored as 20, 0 and "abc" as 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:23:08 -07:00

77 lines
3.1 KiB
TypeScript

/**
* Which selected devices carry other selected devices' traffic (issue #135).
*
* Upgrading devices concurrently means several reboot at the same time. That is
* fine for independent routers and dangerous for a switch the others are
* reached *through*: it takes them offline mid-upgrade, and the manager loses
* the path to devices it is in the middle of changing.
*
* Change Guard does not help here. It protects configuration — a device that
* stops answering after a config change reverts itself — but a device that is
* unreachable because its uplink rebooted has nothing to revert.
*
* ## Why this is deliberately conservative
*
* The obvious implementation — "flag any device another selected device links
* to" — reports the entire fleet. Discovered links are bidirectional (both ends
* run LLDP), and a single trunk interface frequently resolves to several
* neighbours at once, which the topology view already surfaces as ambiguity. On
* a real four-device fleet that approach flagged all four.
*
* A warning that fires on everything is worse than no warning: it trains people
* to dismiss it. So two conditions must both hold:
*
* 1. The link is the reporting device's **root port** — its own path toward
* the spanning-tree root, i.e. genuinely upstream rather than merely
* adjacent.
* 2. That port resolves to exactly **one** neighbour. An ambiguous port tells
* us a device is upstream but not which one, and naming the wrong device is
* worse than saying nothing.
*
* The result is a warning that stays quiet unless it is reasonably sure, and is
* advisory even then — the operator may know the topology better than the
* discovered links do.
*/
export interface TopologyEdge {
from_device_id: number;
to_device_id: number | null;
from_interface?: string | null;
/** RouterOS STP port role: 'root' faces the root bridge, 'designated' faces away. */
stp_role?: string | null;
}
export function findUpstreamWithinSelection(
selectedIds: number[],
links: TopologyEdge[]
): number[] {
const selected = new Set(selectedIds);
if (selected.size < 2) return [];
// Group each device's links by the interface they were seen on, so a port
// that resolved to several neighbours can be recognised and skipped.
const byPort = new Map<string, Set<number>>();
const rootPorts = new Set<string>();
for (const link of links) {
const { from_device_id: from, to_device_id: to } = link;
if (to === null || from === to) continue;
if (!selected.has(from) || !selected.has(to)) continue;
const port = `${from}${link.from_interface ?? ''}`;
if (!byPort.has(port)) byPort.set(port, new Set());
byPort.get(port)!.add(to);
if ((link.stp_role ?? '').replace(/-port$/, '') === 'root') rootPorts.add(port);
}
const upstream = new Set<number>();
for (const [port, neighbours] of byPort) {
if (!rootPorts.has(port)) continue; // not this device's way up
if (neighbours.size !== 1) continue; // ambiguous: we don't know which
upstream.add([...neighbours][0]);
}
// Stable order, so the same selection always reads the same way.
return [...upstream].sort((a, b) => a - b);
}