feat(wireless): reconstruct per-client roaming history from AP logs (v0.23.11-beta)

Closes #105.

RouterOS narrates the whole life of an association in its log — association, every
band or AP change, and the disconnect — each line carrying the signal at that
moment. Nothing else in the platform can see any of it: roaming happens between
polls, so a poll only ever observes where a client ended up, never that it moved.

Sessions are now rebuilt from those lines and shown on the client detail page: the
path across radios, signal at each step, duration, and RouterOS's own disconnect
reason. Sessions averaging six or more roams an hour are flagged, since a client
bouncing between radios is usually a roaming-threshold problem rather than a faulty
client.

No new collection and no new storage. It reads the events already gathered, so it
works retroactively over whatever log history exists.

Two things came out of testing against real logs rather than the samples in the
issue, and both were wrong in ways the samples could not have shown:

  Ordering. RouterOS log timestamps are second-granular, so a reassociation emits
  its disconnect and reconnect inside the same logged second, and collection
  assigns 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. Sorting on the
  raw timestamp attached the disconnect to the session the connect had just opened,
  so every reassociation reconstructed as a 0-second session while the real one
  disappeared. On one IoT client that was 255 phantom sessions out of 422. Events
  are now ordered by whole second and ties broken by meaning: a disconnect closes
  what came before, a connect opens what comes after. The same client now shows its
  real behaviour — 900s, 840s and 930s sessions, reassociating every 15 minutes.

  Signal sanity. Real logs contain `signal strength 91`. dBm is negative, so a
  positive reading is not a measurement; recording it would draw a client with an
  impossibly perfect signal. Treated as unknown.

21 tests, including the full ping-pong trace quoted in the issue — which the
reconstruction correctly identifies as flapping — plus both timestamp-collision
shapes seen in production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-22 21:27:14 -07:00
co-authored by Claude Opus 5
parent 37887d38c2
commit 57d650c159
11 changed files with 820 additions and 4 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-0.23.10_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/version-0.23.11_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
+1 -1
View File
@@ -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": {
+52
View File
@@ -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<T extends Record<string, unknown>>(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<typeof e> => 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<Record<string, number>>((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 {
+225
View File
@@ -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);
});
});
+281
View File
@@ -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<RoamEvent, 'kind' | 'mac'> => ({
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<RoamEventKind, number> = {
'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);
}
+18
View File
@@ -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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.23.10-beta",
"version": "0.23.11-beta",
"private": true,
"type": "module",
"scripts": {
@@ -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 <span className="text-gray-400">—</span>;
return <span className="mono num-tab" style={{ color: rssiColor(dbm) }}>{dbm} dBm</span>;
}
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 (
<div className={clsx(
'rounded-lg border p-3',
flapping ? 'border-amber-300 dark:border-amber-800 bg-amber-50/60 dark:bg-amber-900/10'
: 'border-gray-200 dark:border-slate-700'
)}>
<button onClick={() => setOpen(o => !o)} className="w-full text-left">
<div className="flex items-center gap-2 flex-wrap text-xs">
<span className="text-gray-400">{day(s.startedAt)}</span>
<span className="mono text-gray-700 dark:text-slate-200">{time(s.startedAt)}</span>
<span className="text-gray-400">→</span>
<span className="mono text-gray-700 dark:text-slate-200">
{s.endedAt ? time(s.endedAt) : 'now'}
</span>
<span className="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300">
{duration(s.durationSec)}
</span>
{s.ssid && <span className="text-gray-500 dark:text-slate-400">{s.ssid}</span>}
{s.hops.length > 0 && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
{s.hops.length} roam{s.hops.length !== 1 ? 's' : ''}
</span>
)}
{flapping && (
<span className="flex items-center gap-1 text-amber-700 dark:text-amber-400 font-medium">
<AlertTriangle className="w-3 h-3" />
{s.roamsPerHour}/hr
</span>
)}
<span className="ml-auto text-gray-400">
{s.disconnectReason ?? (s.endedAt ? '' : 'connected')}
</span>
</div>
{/* The path is the answer to "where was this client?" */}
<div className="flex items-center gap-1 flex-wrap mt-2 text-[11px]">
{s.path.map((iface, i) => (
<span key={`${iface}-${i}`} className="flex items-center gap-1">
{i > 0 && <ArrowRight className="w-3 h-3 text-gray-300 dark:text-slate-600" />}
<span className="mono px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-800 text-gray-700 dark:text-slate-300">
{iface}
</span>
</span>
))}
{s.signalMin != null && (
<span className="ml-2 text-gray-400">
signal <Signal dbm={s.signalMax} /> … <Signal dbm={s.signalMin} />
</span>
)}
</div>
</button>
{open && s.events.length > 0 && (
<div className="mt-3 pt-2 border-t border-gray-100 dark:border-slate-700 space-y-1">
{s.events.map((e, i) => (
<div key={i} className="flex items-center gap-2 text-[11px]">
<span className="mono text-gray-400 w-16 flex-shrink-0">{time(e.at)}</span>
<span className={clsx('w-20 flex-shrink-0 font-medium',
e.kind === 'disconnected' ? 'text-red-600 dark:text-red-400'
: e.kind === 'roamed' ? 'text-blue-600 dark:text-blue-400'
: e.kind === 'connected' ? 'text-green-600 dark:text-green-400'
: 'text-gray-500')}>
{e.kind}
</span>
<span className="text-gray-600 dark:text-slate-300 flex-1 min-w-0 truncate">
{e.kind === 'roamed'
? `${e.interfaceName} → ${e.toInterface}`
: e.kind.startsWith('dhcp')
? `${e.ip ?? ''}${e.hostname ? ` (${e.hostname})` : ''}`
: e.interfaceName ?? ''}
{e.reason ? ` — ${e.reason}` : ''}
</span>
<Signal dbm={e.signal} />
</div>
))}
</div>
)}
</div>
);
}
/**
* 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 (
<div className="card p-4 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<Route className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Roaming &amp; sessions</h3>
<div className="ml-auto flex rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
{RANGES.map(r => (
<button key={r} onClick={() => setRange(r)}
className={clsx('px-2 py-1 text-[11px] font-medium transition-colors',
range === r ? 'bg-blue-600 text-white'
: 'text-gray-500 dark:text-slate-400 hover:bg-gray-50 dark:hover:bg-slate-700')}>
{r}
</button>
))}
</div>
</div>
{isLoading ? (
<div className="py-6 text-center text-xs text-gray-400">Reading access point logs…</div>
) : sessions.length === 0 ? (
<div className="py-6 text-center text-xs text-gray-400">
No wireless sessions logged in this window. Association logging must be enabled
on the access point for this to populate.
</div>
) : (
<>
<div className="flex items-center gap-4 flex-wrap text-[11px] text-gray-500 dark:text-slate-400">
<span>{sessions.length} session{sessions.length !== 1 ? 's' : ''}</span>
<span>{data!.totalRoams} roam{data!.totalRoams !== 1 ? 's' : ''}</span>
{data!.radios.length > 0 && (
<span className="flex items-center gap-1">
<Wifi className="w-3 h-3" />
{data!.radios.slice(0, 4).map(r => r.name).join(', ')}
</span>
)}
</div>
{data!.flappingSessions > 0 && (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-amber-500" />
<p className="text-xs text-amber-800 dark:text-amber-300">
{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.
</p>
</div>
)}
<div className="space-y-2 max-h-[28rem] overflow-y-auto">
{sessions.slice(0, 60).map((s, i) => <Session key={`${s.startedAt}-${i}`} s={s} />)}
</div>
{sessions.length > 60 && (
<p className="text-[11px] text-gray-400">
Showing the 60 most recent of {sessions.length} sessions.
</p>
)}
</>
)}
</div>
);
}
+2
View File
@@ -11,6 +11,7 @@ import {
BarChart, Bar, Cell, ReferenceLine,
} from 'recharts';
import { format, formatDistanceToNow, parseISO } from 'date-fns';
import RoamingTimeline from '../components/clients/RoamingTimeline';
import { clientsApi, trafficApi, devicesApi } from '../services/api';
import { CATEGORY_META, SELECTABLE_CATEGORIES } from '../utils/clientCategories';
import type { ClientDetail } from '../services/api';
@@ -1040,6 +1041,7 @@ export default function ClientDetailPage() {
<>
<TrafficCard mac={mac!} />
<SignalCard mac={mac!} />
<RoamingTimeline mac={client.mac_address} />
</>
)}
</div>
+47
View File
@@ -433,6 +433,51 @@ export interface PresencePoint { time: string; online: number; }
export interface TrafficPoint2 { time: string; tx_bytes: number; rx_bytes: number; }
export interface SignalPoint { time: string; signal_strength: number; }
export interface RoamEvent {
kind: 'connected' | 'roamed' | 'disconnected' | 'dhcp-assigned' | 'dhcp-released';
at: string;
mac: string;
interfaceName: string | null;
ssid: string | null;
signal: number | null;
toInterface: string | null;
toSsid: string | null;
reason: string | null;
ip: string | null;
hostname: string | null;
deviceName: string | 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;
durationSec: number | null;
ssid: string | null;
ip: string | null;
hostname: string | null;
path: string[];
hops: RoamHop[];
disconnectReason: string | null;
signalMin: number | null;
signalMax: number | null;
roamsPerHour: number | null;
events: RoamEvent[];
}
export interface RoamingHistory {
mac: string;
range: string;
sessions: RoamSession[];
totalRoams: number;
flappingSessions: number;
radios: { name: string; count: number }[];
}
export const clientsApi = {
list: (params?: {
deviceId?: number;
@@ -450,6 +495,8 @@ export const clientsApi = {
}) =>
api.get<{ clients: Client[]; total: number }>('/clients', { params }),
get: (mac: string) => api.get<ClientDetail>(`/clients/${mac}`),
getRoaming: (mac: string, range = '24h') =>
api.get<RoamingHistory>(`/clients/${mac}/roaming`, { params: { range } }),
getPresence: (mac: string, range = '24h') =>
api.get<PresencePoint[]>(`/clients/${mac}/presence`, { params: { range } }),
getTraffic: (mac: string, range = '24h') =>
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.23.10 Beta';
export const APP_VERSION = 'v0.23.11 Beta';