diff --git a/README.md b/README.md index 3e74a07..4f164ab 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License Docker TypeScript 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 ( +

+ + + {open && s.events.length > 0 && ( +
+ {s.events.map((e, i) => ( +
+ {time(e.at)} + + {e.kind} + + + {e.kind === 'roamed' + ? `${e.interfaceName} → ${e.toInterface}` + : e.kind.startsWith('dhcp') + ? `${e.ip ?? ''}${e.hostname ? ` (${e.hostname})` : ''}` + : e.interfaceName ?? ''} + {e.reason ? ` — ${e.reason}` : ''} + + +
+ ))} +
+ )} +
+ ); +} + +/** + * 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. +
+ ) : ( + <> +
+ {sessions.length} session{sessions.length !== 1 ? 's' : ''} + {data!.totalRoams} roam{data!.totalRoams !== 1 ? 's' : ''} + {data!.radios.length > 0 && ( + + + {data!.radios.slice(0, 4).map(r => r.name).join(', ')} + + )} +
+ + {data!.flappingSessions > 0 && ( +
+ +

+ {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. +

+ )} + + )} +
+ ); +} diff --git a/frontend/src/pages/ClientDetailPage.tsx b/frontend/src/pages/ClientDetailPage.tsx index d547245..4b6d1ae 100644 --- a/frontend/src/pages/ClientDetailPage.tsx +++ b/frontend/src/pages/ClientDetailPage.tsx @@ -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() { <> + )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f9025d9..ecb67af 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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(`/clients/${mac}`), + getRoaming: (mac: string, range = '24h') => + api.get(`/clients/${mac}/roaming`, { params: { range } }), getPresence: (mac: string, range = '24h') => api.get(`/clients/${mac}/presence`, { params: { range } }), getTraffic: (mac: string, range = '24h') => diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 3136006..61e22b8 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.10 Beta'; +export const APP_VERSION = 'v0.23.11 Beta';