-
+
diff --git a/backend/package.json b/backend/package.json
index 67c70bf..914c849 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
- "version": "0.23.10-beta",
+ "version": "0.23.11-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
diff --git a/backend/src/routes/clients.ts b/backend/src/routes/clients.ts
index 70c0327..30b474b 100644
--- a/backend/src/routes/clients.ts
+++ b/backend/src/routes/clients.ts
@@ -5,6 +5,7 @@ import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
import { PollerService } from '../services/PollerService';
import { getQueryApi, bucket } from '../config/influxdb';
import { fingerprintClient, DEVICE_CATEGORIES, DeviceCategory } from '../utils/clientFingerprint';
+import { parseRoamLine, buildSessions, flappingSessions } from '../utils/roaming';
// Effective category: the user's override wins; otherwise the fingerprint.
function withCategory>(row: T): T & { device_category: string; auto_category: string } {
@@ -23,6 +24,57 @@ router.use(requireAuth);
let pollerService: PollerService | null = null;
export function setPollerService(p: PollerService): void { pollerService = p; }
+/**
+ * GET /api/clients/:mac/roaming — reconstruct this client's wireless sessions.
+ *
+ * Built from log lines already collected into `events`, so it needs no new
+ * collection and works retroactively over whatever history exists. Roaming happens
+ * between polls, so the log is the only place it is visible at all (issue #105).
+ */
+router.get('/:mac/roaming', async (req: Request, res: Response) => {
+ const mac = String(req.params.mac).toUpperCase();
+ if (!/^[0-9A-F:]{17}$/.test(mac)) return res.status(400).json({ error: 'Invalid MAC address' });
+
+ const range = String(req.query.range || '24h');
+ const hours = range === '7d' ? 168 : range === '30d' ? 720 : range === '1h' ? 1 : 24;
+
+ // Time-bounded first so the scan stays small — most of an events table is login
+ // noise. Both message shapes are matched: wireless lines start with the MAC,
+ // DHCP lines carry it mid-string.
+ const rows = await query<{ event_time: string; message: string; device_name: string | null }>(
+ `SELECT e.event_time, e.message, d.name AS device_name
+ FROM events e
+ LEFT JOIN devices d ON d.id = e.device_id
+ WHERE e.event_time > NOW() - ($2 || ' hours')::interval
+ AND (e.message ILIKE $1 || '@%' OR e.message ILIKE '%for ' || $1 || '%')
+ ORDER BY e.event_time ASC
+ LIMIT 5000`,
+ [mac, String(hours)]
+ );
+
+ const events = rows
+ .map((r) => parseRoamLine(r.message, new Date(r.event_time).toISOString(), r.device_name))
+ .filter((e): e is NonNullable => e !== null && e.mac === mac);
+
+ const sessions = buildSessions(events);
+ const flapping = flappingSessions(sessions);
+
+ res.json({
+ mac,
+ range,
+ sessions: [...sessions].reverse(), // newest first for display
+ totalRoams: sessions.reduce((n, s) => n + s.hops.length, 0),
+ flappingSessions: flapping.length,
+ /** Radios this client has been seen on, most-used first. */
+ radios: Object.entries(
+ sessions.flatMap((s) => s.path).reduce>((acc, i) => {
+ acc[i] = (acc[i] ?? 0) + 1;
+ return acc;
+ }, {})
+ ).sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, count })),
+ });
+});
+
// GET /api/clients
router.get('/', async (req: Request, res: Response) => {
const {
diff --git a/backend/src/utils/__tests__/roaming.test.ts b/backend/src/utils/__tests__/roaming.test.ts
new file mode 100644
index 0000000..1c1edf7
--- /dev/null
+++ b/backend/src/utils/__tests__/roaming.test.ts
@@ -0,0 +1,225 @@
+import { parseRoamLine, buildSessions, flappingSessions } from '../roaming';
+
+const T = (s: number) => new Date(Date.UTC(2026, 7, 22, 12, 0, s)).toISOString();
+
+describe('parseRoamLine', () => {
+ // Verbatim from a live wAP ax.
+ it('parses a connect', () => {
+ const e = parseRoamLine('0C:DC:7E:E4:14:80@wifi3(popoladuper) connected, signal strength -52', T(0))!;
+ expect(e.kind).toBe('connected');
+ expect(e.mac).toBe('0C:DC:7E:E4:14:80');
+ expect(e.interfaceName).toBe('wifi3');
+ expect(e.ssid).toBe('popoladuper');
+ expect(e.signal).toBe(-52);
+ });
+
+ it('parses a disconnect and keeps the stated reason', () => {
+ const e = parseRoamLine('B0:BE:76:BC:B3:D7@wifi3(popoladuper) disconnected, connection lost, signal strength -69', T(0))!;
+ expect(e.kind).toBe('disconnected');
+ expect(e.reason).toBe('connection lost');
+ expect(e.signal).toBe(-69);
+ });
+
+ // Verbatim from issue #105.
+ it('parses a roam, capturing both ends', () => {
+ const e = parseRoamLine(
+ 'D6:E0:17:43:FF:14@wifi-legacy-left-ap-001-main-5g(MySSIDName) roamed to D6:E0:17:43:FF:14@wifi-legacy-left-ap-001-main-2g(MySSIDName), signal strength -60',
+ T(0))!;
+ expect(e.kind).toBe('roamed');
+ expect(e.interfaceName).toBe('wifi-legacy-left-ap-001-main-5g');
+ expect(e.toInterface).toBe('wifi-legacy-left-ap-001-main-2g');
+ expect(e.signal).toBe(-60);
+ });
+
+ it('parses DHCP assignment and release', () => {
+ const a = parseRoamLine('public_wifi_dhcp assigned 10.120.205.125 for D6:E0:17:43:FF:14 Redmi-Note-11-Pro', T(0))!;
+ expect(a.kind).toBe('dhcp-assigned');
+ expect(a.ip).toBe('10.120.205.125');
+ expect(a.hostname).toBe('Redmi-Note-11-Pro');
+
+ const d = parseRoamLine('public_wifi_dhcp deassigned 10.120.205.125 for D6:E0:17:43:FF:14 Redmi-Note-11-Pro', T(0))!;
+ expect(d.kind).toBe('dhcp-released');
+ });
+
+ /**
+ * Observed in real logs: `signal strength 91`. dBm is negative, so a positive
+ * reading is not a plausible measurement — recording it would draw a client with
+ * an impossibly perfect signal.
+ */
+ it('rejects an implausible positive signal rather than trusting it', () => {
+ const e = parseRoamLine('D6:FC:E1:03:CD:F5@wifi4(popoladuper) disconnected, connection lost, signal strength 91', T(0))!;
+ expect(e.kind).toBe('disconnected');
+ expect(e.signal).toBeNull();
+ });
+
+ it('copes with an SSID containing brackets', () => {
+ const e = parseRoamLine('AA:BB:CC:DD:EE:FF@wifi1(Guest (2.4)) connected, signal strength -40', T(0))!;
+ expect(e.ssid).toBe('Guest (2.4)');
+ expect(e.signal).toBe(-40);
+ });
+
+ it('ignores unrelated log lines', () => {
+ expect(parseRoamLine('system,info,account user admin logged in', T(0))).toBeNull();
+ expect(parseRoamLine('', T(0))).toBeNull();
+ });
+});
+
+describe('buildSessions', () => {
+ const line = (m: string, s: number) => parseRoamLine(m, T(s))!;
+ const MAC = 'D6:E0:17:43:FF:14';
+
+ /** The full trace from issue #105 — a textbook ping-pong between bands. */
+ const reported = () => [
+ line(`${MAC}@left-5g(SSID) connected, signal strength -75`, 0),
+ line(`public_wifi_dhcp assigned 10.120.205.125 for ${MAC} Redmi-Note-11-Pro`, 5),
+ line(`${MAC}@left-5g(SSID) roamed to ${MAC}@left-2g(SSID), signal strength -60`, 60),
+ line(`${MAC}@left-2g(SSID) roamed to ${MAC}@left-5g(SSID), signal strength -72`, 120),
+ line(`${MAC}@left-5g(SSID) roamed to ${MAC}@left-2g(SSID), signal strength -59`, 180),
+ line(`${MAC}@left-2g(SSID) roamed to ${MAC}@left-5g(SSID), signal strength -73`, 240),
+ line(`${MAC}@left-5g(SSID) roamed to ${MAC}@left-2g(SSID), signal strength -64`, 300),
+ line(`${MAC}@left-2g(SSID) roamed to ${MAC}@left-5g(SSID), signal strength -70`, 360),
+ line(`${MAC}@left-5g(SSID) disconnected, connection lost, signal strength -70`, 420),
+ line(`public_wifi_dhcp deassigned 10.120.205.125 for ${MAC} Redmi-Note-11-Pro`, 425),
+ ];
+
+ it('reconstructs one session with the full path', () => {
+ const [s] = buildSessions(reported());
+ expect(s.path).toEqual([
+ 'left-5g', 'left-2g', 'left-5g', 'left-2g', 'left-5g', 'left-2g', 'left-5g',
+ ]);
+ expect(s.hops).toHaveLength(6);
+ expect(s.durationSec).toBe(420);
+ expect(s.disconnectReason).toBe('connection lost');
+ });
+
+ it('picks up the DHCP lease for the session', () => {
+ const [s] = buildSessions(reported());
+ expect(s.ip).toBe('10.120.205.125');
+ expect(s.hostname).toBe('Redmi-Note-11-Pro');
+ });
+
+ it('records the signal range seen across the session', () => {
+ const [s] = buildSessions(reported());
+ expect(s.signalMin).toBe(-75);
+ expect(s.signalMax).toBe(-59);
+ });
+
+ it('flags the reported trace as flapping', () => {
+ const sessions = buildSessions(reported());
+ // Six roams in seven minutes.
+ expect(sessions[0].roamsPerHour).toBeGreaterThan(6);
+ expect(flappingSessions(sessions)).toHaveLength(1);
+ });
+
+ it('does not flag a client that settles', () => {
+ const sessions = buildSessions([
+ line(`${MAC}@left-5g(SSID) connected, signal strength -55`, 0),
+ line(`${MAC}@left-5g(SSID) roamed to ${MAC}@hall-5g(SSID), signal strength -70`, 1800),
+ line(`${MAC}@hall-5g(SSID) disconnected, connection lost, signal strength -58`, 3600),
+ ]);
+ expect(flappingSessions(sessions)).toHaveLength(0);
+ });
+
+ it('splits consecutive connects into separate sessions', () => {
+ const sessions = buildSessions([
+ line(`${MAC}@a(S) connected, signal strength -50`, 0),
+ line(`${MAC}@a(S) disconnected, connection lost, signal strength -60`, 60),
+ line(`${MAC}@b(S) connected, signal strength -55`, 120),
+ line(`${MAC}@b(S) disconnected, connection lost, signal strength -65`, 180),
+ ]);
+ expect(sessions).toHaveLength(2);
+ });
+
+ /**
+ * Logs are lossy — rotation, a reboot, or simply querying a window that starts
+ * mid-session. Evidence of a roam matters more than a tidy session boundary.
+ */
+ it('opens a session retroactively when the connect was never seen', () => {
+ const sessions = buildSessions([
+ line(`${MAC}@a(S) roamed to ${MAC}@b(S), signal strength -70`, 0),
+ line(`${MAC}@b(S) disconnected, connection lost, signal strength -72`, 60),
+ ]);
+ expect(sessions).toHaveLength(1);
+ expect(sessions[0].hops).toHaveLength(1);
+ });
+
+ it('leaves an unterminated session open rather than inventing an end', () => {
+ const [s] = buildSessions([line(`${MAC}@a(S) connected, signal strength -50`, 0)]);
+ expect(s.endedAt).toBeNull();
+ expect(s.durationSec).toBeNull();
+ });
+
+ it('sorts out-of-order events before reconstructing', () => {
+ const [s] = buildSessions([
+ line(`${MAC}@a(S) disconnected, connection lost, signal strength -60`, 60),
+ line(`${MAC}@a(S) connected, signal strength -50`, 0),
+ ]);
+ expect(s.durationSec).toBe(60);
+ });
+
+ it('returns nothing for a client with no wireless history', () => {
+ expect(buildSessions([])).toEqual([]);
+ });
+});
+
+/**
+ * RouterOS log timestamps are second-granular, so a disconnect and the reconnect
+ * that follows land on the same instant and arrive in arbitrary order. Observed on
+ * a live AP: an IoT client reassociating every 15 minutes produced pairs recorded
+ * at an identical time. Without an ordering rule every reassociation reconstructed
+ * as a 0-second session and the real 15-minute one vanished.
+ */
+describe('buildSessions — same-instant disconnect and reconnect', () => {
+ const MAC = '28:37:2F:C8:31:DC';
+ const at = (m: number) => new Date(Date.UTC(2026, 7, 18, 19, m, 0)).toISOString();
+ const conn = (t: string) => parseRoamLine(`${MAC}@wifi5(IoT) connected, signal strength -60`, t)!;
+ const disc = (t: string) => parseRoamLine(`${MAC}@wifi5(IoT) disconnected, connection lost, signal strength -61`, t)!;
+
+ it('closes the old session rather than opening a zero-length one', () => {
+ const sessions = buildSessions([conn(at(0)), conn(at(15)), disc(at(15))]);
+ expect(sessions).toHaveLength(2);
+ expect(sessions[0].durationSec).toBe(900); // the real 15-minute session
+ expect(sessions[0].disconnectReason).toBe('connection lost');
+ expect(sessions[1].endedAt).toBeNull(); // the reconnect is still open
+ });
+
+ it('gives the same answer whichever order the pair arrives in', () => {
+ const a = buildSessions([conn(at(0)), disc(at(15)), conn(at(15))]);
+ const b = buildSessions([conn(at(0)), conn(at(15)), disc(at(15))]);
+ expect(a.map(s => s.durationSec)).toEqual(b.map(s => s.durationSec));
+ });
+});
+
+/**
+ * The same pair, one millisecond apart with the connect first — the shape actually
+ * seen in collected logs (`20:06:17.096 connected`, `20:06:17.097 disconnected`).
+ * Sub-second ordering is a collection artefact, so it must not decide which session
+ * the disconnect belongs to.
+ */
+describe('buildSessions — sub-second jitter within one logged second', () => {
+ const MAC = '28:37:2F:C8:31:DC';
+ const conn = (t: string) => parseRoamLine(`${MAC}@wifi5(IoT) connected, signal strength -60`, t)!;
+ const disc = (t: string) => parseRoamLine(`${MAC}@wifi5(IoT) disconnected, connection lost, signal strength -61`, t)!;
+
+ it('attributes the disconnect to the earlier session despite arriving later', () => {
+ const sessions = buildSessions([
+ conn('2026-08-18T19:51:16.849Z'),
+ conn('2026-08-18T20:06:17.096Z'), // 1 ms before its own disconnect
+ disc('2026-08-18T20:06:17.097Z'),
+ ]);
+ expect(sessions).toHaveLength(2);
+ expect(sessions[0].durationSec).toBe(900); // the real ~15-minute session
+ expect(sessions[1].durationSec).toBeNull(); // reconnect still open
+ });
+
+ it('produces no zero-length session from a reassociation', () => {
+ const sessions = buildSessions([
+ conn('2026-08-18T19:00:00.100Z'),
+ conn('2026-08-18T19:15:00.100Z'),
+ disc('2026-08-18T19:15:00.200Z'),
+ conn('2026-08-18T19:30:00.300Z'),
+ disc('2026-08-18T19:30:00.100Z'),
+ ]);
+ expect(sessions.filter(s => s.durationSec === 0)).toHaveLength(0);
+ });
+});
diff --git a/backend/src/utils/roaming.ts b/backend/src/utils/roaming.ts
new file mode 100644
index 0000000..f4530a2
--- /dev/null
+++ b/backend/src/utils/roaming.ts
@@ -0,0 +1,281 @@
+/**
+ * Reconstructing a wireless client's session from RouterOS log lines.
+ *
+ * RouterOS narrates the whole life of a association in its log — association,
+ * every band or AP change, and the disconnect — each line carrying the signal
+ * strength at that moment:
+ *
+ * D6:E0:...@wifi-left-5g(Guest) connected, signal strength -75
+ * D6:E0:...@wifi-left-5g(Guest) roamed to D6:E0:...@wifi-left-2g(Guest), signal strength -60
+ * D6:E0:...@wifi-left-5g(Guest) disconnected, connection lost, signal strength -70
+ *
+ * That is enough to answer the question people actually have about a flaky client:
+ * *where was it, how well was it hearing us, and why did it move?* Nothing else in
+ * the platform can answer that, because a poll only ever sees the current state and
+ * roaming happens between polls.
+ *
+ * Everything here is pure over already-collected `events` rows — no new collection,
+ * no new storage, and it works retroactively over whatever log history exists.
+ */
+
+export type RoamEventKind =
+ | 'connected'
+ | 'roamed'
+ | 'disconnected'
+ | 'dhcp-assigned'
+ | 'dhcp-released';
+
+export interface RoamEvent {
+ kind: RoamEventKind;
+ at: string;
+ mac: string;
+ /** Interface the client was on when the line was written. */
+ interfaceName: string | null;
+ ssid: string | null;
+ signal: number | null;
+ /** Where it moved to — `roamed` only. */
+ toInterface: string | null;
+ toSsid: string | null;
+ /** RouterOS's stated reason, e.g. "connection lost". */
+ reason: string | null;
+ /** DHCP lines only. */
+ ip: string | null;
+ hostname: string | null;
+ /** The managed device whose log carried the line. */
+ deviceName: string | null;
+}
+
+const MAC = '[0-9A-Fa-f:]{17}';
+
+// Lazy SSID capture with a required suffix: correct even for an SSID containing
+// a bracket, because the match must still end at the literal that follows.
+const RE_ROAMED = new RegExp(
+ `^(${MAC})@(\\S+?)\\((.*?)\\) roamed to (${MAC})@(\\S+?)\\((.*?)\\), signal strength (-?\\d+)`
+);
+const RE_CONNECTED = new RegExp(
+ `^(${MAC})@(\\S+?)\\((.*?)\\) connected, signal strength (-?\\d+)`
+);
+const RE_DISCONNECTED = new RegExp(
+ `^(${MAC})@(\\S+?)\\((.*?)\\) disconnected, (.+?), signal strength (-?\\d+)`
+);
+const RE_DHCP = new RegExp(
+ `^(\\S+) (assigned|deassigned) (\\S+) for (${MAC})(?:\\s+(.*))?$`
+);
+
+/**
+ * Signal strengths are dBm and therefore negative. RouterOS occasionally emits a
+ * positive value — `signal strength 91` appears in real logs — which is not a
+ * plausible reading. Treated as unknown rather than plotted as an excellent signal.
+ */
+function parseSignal(raw: string): number | null {
+ const n = parseInt(raw, 10);
+ if (isNaN(n)) return null;
+ return n <= 0 && n >= -120 ? n : null;
+}
+
+const base = (at: string, deviceName: string | null): Omit => ({
+ at, deviceName,
+ interfaceName: null, ssid: null, signal: null,
+ toInterface: null, toSsid: null, reason: null, ip: null, hostname: null,
+});
+
+/** Parse one log line, or null when it is not part of a wireless session. */
+export function parseRoamLine(
+ message: string,
+ at: string,
+ deviceName: string | null = null
+): RoamEvent | null {
+ const roamed = RE_ROAMED.exec(message);
+ if (roamed) {
+ return {
+ ...base(at, deviceName), kind: 'roamed',
+ mac: roamed[1].toUpperCase(),
+ interfaceName: roamed[2], ssid: roamed[3] || null,
+ toInterface: roamed[5], toSsid: roamed[6] || null,
+ signal: parseSignal(roamed[7]),
+ };
+ }
+
+ const connected = RE_CONNECTED.exec(message);
+ if (connected) {
+ return {
+ ...base(at, deviceName), kind: 'connected',
+ mac: connected[1].toUpperCase(),
+ interfaceName: connected[2], ssid: connected[3] || null,
+ signal: parseSignal(connected[4]),
+ };
+ }
+
+ const disconnected = RE_DISCONNECTED.exec(message);
+ if (disconnected) {
+ return {
+ ...base(at, deviceName), kind: 'disconnected',
+ mac: disconnected[1].toUpperCase(),
+ interfaceName: disconnected[2], ssid: disconnected[3] || null,
+ reason: disconnected[4],
+ signal: parseSignal(disconnected[5]),
+ };
+ }
+
+ const dhcp = RE_DHCP.exec(message);
+ if (dhcp) {
+ return {
+ ...base(at, deviceName),
+ kind: dhcp[2] === 'assigned' ? 'dhcp-assigned' : 'dhcp-released',
+ mac: dhcp[4].toUpperCase(),
+ ip: dhcp[3],
+ hostname: dhcp[5]?.trim() || null,
+ };
+ }
+
+ return null;
+}
+
+export interface RoamHop {
+ at: string;
+ from: string | null;
+ to: string;
+ signalBefore: number | null;
+ signalAfter: number | null;
+}
+
+export interface RoamSession {
+ startedAt: string;
+ endedAt: string | null;
+ /** Null while the session is still open. */
+ durationSec: number | null;
+ ssid: string | null;
+ ip: string | null;
+ hostname: string | null;
+ /** Interfaces held, in order. */
+ path: string[];
+ hops: RoamHop[];
+ disconnectReason: string | null;
+ signalMin: number | null;
+ signalMax: number | null;
+ /**
+ * Roams per hour. High values mean the client is ping-ponging between radios
+ * rather than settling — usually a roaming-threshold problem, not a client fault.
+ */
+ roamsPerHour: number | null;
+ events: RoamEvent[];
+}
+
+const secondsBetween = (a: string, b: string) =>
+ Math.max(0, Math.round((new Date(b).getTime() - new Date(a).getTime()) / 1000));
+
+/**
+ * Group a client's events into sessions.
+ *
+ * A session runs from a `connected` to the matching `disconnected`. Logs are
+ * lossy — a device reboots, log rotation drops the start, a disconnect never
+ * arrives — so a roam or disconnect seen with no open session opens one
+ * retroactively rather than being discarded. Losing the beginning of a session is
+ * normal; losing the evidence of a roam is not.
+ */
+export function buildSessions(events: RoamEvent[]): RoamSession[] {
+ // RouterOS log timestamps are second-granular. A reassociation therefore emits
+ // its disconnect and reconnect inside the same logged second, and collection
+ // assigns them sub-second values in whichever order they were read — observed on
+ // a live AP as both `.849/.849` and `.096/.097` for the same kind of pair.
+ //
+ // Those sub-second differences are an artefact of collection, not evidence of
+ // ordering, so events are ordered by whole second and ties broken by meaning: a
+ // disconnect closes what came before, a connect opens what comes after. Sorting
+ // on the raw timestamp instead attaches the disconnect to the session the connect
+ // just opened, and every reassociation reconstructs as a 0-second session while
+ // the real one disappears.
+ const RANK: Record = {
+ 'dhcp-released': 0, disconnected: 1, connected: 2, roamed: 3, 'dhcp-assigned': 4,
+ };
+ const second = (iso: string) => Math.floor(new Date(iso).getTime() / 1000);
+ const ordered = [...events].sort((a, b) => {
+ const t = second(a.at) - second(b.at);
+ if (t !== 0) return t;
+ const r = RANK[a.kind] - RANK[b.kind];
+ return r !== 0 ? r : new Date(a.at).getTime() - new Date(b.at).getTime();
+ });
+ const sessions: RoamSession[] = [];
+ let current: RoamSession | null = null;
+
+ const open = (e: RoamEvent, iface: string | null): RoamSession => ({
+ startedAt: e.at, endedAt: null, durationSec: null,
+ ssid: e.ssid, ip: null, hostname: null,
+ path: iface ? [iface] : [],
+ hops: [], disconnectReason: null,
+ signalMin: null, signalMax: null, roamsPerHour: null,
+ events: [],
+ });
+
+ const note = (s: RoamSession, e: RoamEvent) => {
+ s.events.push(e);
+ if (e.signal != null) {
+ s.signalMin = s.signalMin == null ? e.signal : Math.min(s.signalMin, e.signal);
+ s.signalMax = s.signalMax == null ? e.signal : Math.max(s.signalMax, e.signal);
+ }
+ };
+
+ for (const e of ordered) {
+ switch (e.kind) {
+ case 'connected':
+ if (current) { current.endedAt = e.at; sessions.push(current); }
+ current = open(e, e.interfaceName);
+ note(current, e);
+ break;
+
+ case 'roamed': {
+ if (!current) current = open(e, e.interfaceName);
+ note(current, e);
+ const prev = current.path[current.path.length - 1] ?? e.interfaceName;
+ if (e.toInterface) {
+ current.path.push(e.toInterface);
+ current.hops.push({
+ at: e.at, from: prev, to: e.toInterface,
+ signalBefore: e.signal, signalAfter: null,
+ });
+ }
+ if (e.ssid && !current.ssid) current.ssid = e.ssid;
+ break;
+ }
+
+ case 'disconnected':
+ if (!current) current = open(e, e.interfaceName);
+ note(current, e);
+ current.disconnectReason = e.reason;
+ current.endedAt = e.at;
+ sessions.push(current);
+ current = null;
+ break;
+
+ case 'dhcp-assigned':
+ if (current) { note(current, e); current.ip = e.ip; current.hostname = e.hostname; }
+ break;
+
+ case 'dhcp-released':
+ if (current) note(current, e);
+ break;
+ }
+ }
+ if (current) sessions.push(current);
+
+ for (const s of sessions) {
+ s.durationSec = s.endedAt ? secondsBetween(s.startedAt, s.endedAt) : null;
+ // Rate needs a meaningful window; over a few seconds it is noise, not a rate.
+ if (s.durationSec != null && s.durationSec >= 60) {
+ s.roamsPerHour = Math.round((s.hops.length / s.durationSec) * 3600 * 10) / 10;
+ }
+ // Fill in the signal recorded when the client landed on the new radio.
+ for (const hop of s.hops) {
+ const after = s.events.find(
+ (e) => e.at > hop.at && e.signal != null && (e.interfaceName === hop.to || e.kind === 'roamed')
+ );
+ hop.signalAfter = after?.signal ?? null;
+ }
+ }
+ return sessions;
+}
+
+/** Sessions where the client never settled on one radio. */
+export function flappingSessions(sessions: RoamSession[], threshold = 6): RoamSession[] {
+ return sessions.filter((s) => (s.roamsPerHour ?? 0) >= threshold);
+}
diff --git a/docs/capsman.md b/docs/capsman.md
index fee0598..fb2acfe 100644
--- a/docs/capsman.md
+++ b/docs/capsman.md
@@ -82,3 +82,21 @@ prediction-and-revert protection that per-device changes do.
Legacy `/caps-man` — the pre-RouterOS 7 controller — is a separate API tree and is not
covered. The RouterOS 7 `wifi` stack is supported.
+
+## Roaming history
+
+A client's association history is reconstructed from access point logs at
+`GET /api/clients/:mac/roaming?range=24h`, and shown on the client detail page.
+
+Roaming happens between polls, so a poll-based view can never see it — the log is
+the only record that a client moved, when, and at what signal. Sessions are rebuilt
+from RouterOS's own lines, including every band or AP change and the disconnect
+reason.
+
+Sessions with six or more roams per hour are flagged. A client bouncing between
+radios usually indicates roaming thresholds rather than a faulty client: overlapping
+cells at similar signal give it no reason to prefer one.
+
+Nothing new is collected — this reads the events already gathered, so it works
+retroactively over whatever log history exists. It does require association logging
+to be enabled on the access points.
diff --git a/frontend/package.json b/frontend/package.json
index a1ebcdb..c56ead0 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.23.10-beta",
+ "version": "0.23.11-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/components/clients/RoamingTimeline.tsx b/frontend/src/components/clients/RoamingTimeline.tsx
new file mode 100644
index 0000000..3e14702
--- /dev/null
+++ b/frontend/src/components/clients/RoamingTimeline.tsx
@@ -0,0 +1,191 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { Route, AlertTriangle, ArrowRight, Wifi } from 'lucide-react';
+import clsx from 'clsx';
+import { clientsApi, type RoamSession } from '../../services/api';
+import { rssiColor } from '../../utils/wifiChannels';
+
+const RANGES = ['1h', '24h', '7d', '30d'] as const;
+
+function duration(sec: number | null): string {
+ if (sec == null) return 'ongoing';
+ if (sec < 60) return `${sec}s`;
+ if (sec < 3600) return `${Math.round(sec / 60)}m`;
+ const h = Math.floor(sec / 3600);
+ return `${h}h ${Math.round((sec % 3600) / 60)}m`;
+}
+
+const time = (iso: string) =>
+ new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+const day = (iso: string) =>
+ new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric' });
+
+function Signal({ dbm }: { dbm: number | null }) {
+ if (dbm == null) return —;
+ return {dbm} dBm;
+}
+
+function Session({ s }: { s: RoamSession }) {
+ const [open, setOpen] = useState(false);
+ // Roams per hour is only computed for sessions long enough to have a rate.
+ const flapping = (s.roamsPerHour ?? 0) >= 6;
+
+ return (
+
+ );
+}
+
+/**
+ * A client's wireless history, reconstructed from access point logs.
+ *
+ * Roaming happens between polls, so a poll-based view can never show it — the log
+ * is the only record that a client moved, when, and at what signal. That makes this
+ * the one place "why does this device keep dropping?" can actually be answered.
+ */
+export default function RoamingTimeline({ mac }: { mac: string }) {
+ const [range, setRange] = useState<(typeof RANGES)[number]>('24h');
+ const { data, isLoading } = useQuery({
+ queryKey: ['roaming', mac, range],
+ queryFn: () => clientsApi.getRoaming(mac, range).then(r => r.data),
+ });
+
+ const sessions = data?.sessions ?? [];
+
+ return (
+
+
+
+
Roaming & sessions
+
+ {RANGES.map(r => (
+
+ ))}
+
+
+
+ {isLoading ? (
+
Reading access point logs…
+ ) : sessions.length === 0 ? (
+
+ No wireless sessions logged in this window. Association logging must be enabled
+ on the access point for this to populate.
+
+ {data!.flappingSessions} session{data!.flappingSessions !== 1 ? 's' : ''} where this
+ client kept moving between radios rather than settling. That usually points at
+ roaming thresholds rather than the client — overlapping cells at similar signal give
+ it no reason to prefer one.
+
+
+ )}
+
+
+ {sessions.slice(0, 60).map((s, i) => )}
+
+ {sessions.length > 60 && (
+
+ Showing the 60 most recent of {sessions.length} sessions.
+