diff --git a/README.md b/README.md index 4f164ab..b08ecc5 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 914c849..93bf77b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.11-beta", + "version": "0.23.12-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index e9f6c5e..aca2014 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -703,6 +703,95 @@ CREATE TABLE IF NOT EXISTS wireless_security_profiles ( updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(device_id, name) ); + +-- LTE / cellular support (discussion #85). +-- +-- has_lte is set during interface collection rather than inferred from +-- device_type: a cellular modem turns up on routers, CPE and travel gear alike, +-- and probing every device on every fast poll to find out costs more than +-- remembering the answer. +ALTER TABLE devices ADD COLUMN IF NOT EXISTS has_lte BOOLEAN NOT NULL DEFAULT FALSE; + +-- Latest state of each LTE interface. One row per interface, overwritten on each +-- poll; the time series lives in InfluxDB and the movement history below. +-- +-- Almost every column is nullable on purpose. What an LTE interface reports +-- depends on the modem chipset rather than on RouterOS — the Quectel EG18-EA +-- this was built against reports no registration-status, no pin-status and no +-- tracking area, while others do — so absence is a normal state, not an error. +CREATE TABLE IF NOT EXISTS lte_interfaces ( + id SERIAL PRIMARY KEY, + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + interface_name VARCHAR(64) NOT NULL, + status VARCHAR(32), + data_class VARCHAR(32), + modem_model VARCHAR(64), + modem_revision VARCHAR(64), + operator VARCHAR(64), + cell_id VARCHAR(32), + enb_id VARCHAR(32), + sector_id VARCHAR(32), + phy_cell_id VARCHAR(32), + session_uptime_s BIGINT, + primary_band JSONB, + ca_bands JSONB NOT NULL DEFAULT '[]'::jsonb, + rssi REAL, + rsrp REAL, + rsrq REAL, + sinr REAL, + cqi INTEGER, + rank_indicator INTEGER, + mcs INTEGER, + dl_modulation VARCHAR(32), + quality VARCHAR(16), + -- Configured allow-list from /interface/lte, e.g. "1,3,7". Empty means auto. + allowed_bands VARCHAR(128), + network_mode VARCHAR(64), + apn_profiles VARCHAR(128), + allow_roaming BOOLEAN, + config_json JSONB, + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(device_id, interface_name) +); + +-- Tower and band movement, reconstructed by comparing consecutive polls. +-- +-- Scanning costs service to run and finds nothing at a site served by a single +-- base station, and the modem may log nothing at all, so polling is the only +-- vantage point that always exists. session-uptime +-- running backwards means the modem re-registered; a changed cell id means it +-- handed over. Neither can be recovered after the fact, so it is recorded live. +CREATE TABLE IF NOT EXISTS lte_cell_history ( + id SERIAL PRIMARY KEY, + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + interface_name VARCHAR(64) NOT NULL, + at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + kind VARCHAR(24) NOT NULL, + detail TEXT, + cell_id VARCHAR(32), + enb_id VARCHAR(32), + bands VARCHAR(64), + rsrp REAL, + sinr REAL +); +CREATE INDEX IF NOT EXISTS idx_lte_cell_history_device + ON lte_cell_history(device_id, at DESC); + +-- Bands actually observed serving a device, which is what makes it possible to +-- warn before a band lock. Scanning cannot answer "is this band available here?" +-- dependably: it interrupts service, and comes back empty at exactly the remote +-- sites that most need the warning. A band the modem has already used at this +-- location demonstrably works there, and locking to one never once seen is how a +-- device on a mast is lost. +CREATE TABLE IF NOT EXISTS lte_observed_bands ( + device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + interface_name VARCHAR(64) NOT NULL, + band INTEGER NOT NULL, + first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + observations INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (device_id, interface_name, band) +); `; const DEFAULT_SETTINGS = [ diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 835257e..c64c5de 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -19,6 +19,7 @@ import { runConfigHealth } from '../services/changeGuard/configHealth'; import { isMultiVlanSpec } from '../utils/vlan'; import { redis } from '../config/redis'; import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAddWorker'; +import { parseBandList, uplinkAnchor, totalBandwidthMhz, type LteBandInfo } from '../utils/lte'; // Resolve a credential preset id into decrypted credentials. Returns null if // no id was provided; throws a readable error if the id is invalid. @@ -345,7 +346,7 @@ router.get('/:id', async (req: Request, res: Response) => { notes, location_address, location_lat::float8 AS location_lat, location_lng::float8 AS location_lng, - rack_name, rack_slot, wifi_role, created_at, updated_at + rack_name, rack_slot, wifi_role, has_lte, created_at, updated_at FROM devices WHERE id = $1`, [req.params.id] ); @@ -386,7 +387,7 @@ router.patch('/:id/location', requireWrite, async (req: Request, res: Response) notes, location_address, location_lat::float8 AS location_lat, location_lng::float8 AS location_lng, - rack_name, rack_slot, wifi_role, created_at, updated_at + rack_name, rack_slot, wifi_role, has_lte, created_at, updated_at FROM devices WHERE id = $1`, [req.params.id] ); @@ -2201,6 +2202,149 @@ router.get('/:id/wireless', async (req: Request, res: Response) => { return res.json(rows); }); +// ─── LTE / cellular (discussion #85) ───────────────────────────────────────── + +// GET /api/devices/:id/lte — current state of every LTE interface +router.get('/:id/lte', async (req: Request, res: Response) => { + try { + const interfaces = await query>( + `SELECT interface_name, status, data_class, modem_model, modem_revision, operator, + cell_id, enb_id, sector_id, phy_cell_id, session_uptime_s, + primary_band, ca_bands, rssi, rsrp, rsrq, sinr, cqi, rank_indicator, mcs, + dl_modulation, quality, allowed_bands, network_mode, apn_profiles, + allow_roaming, updated_at + FROM lte_interfaces WHERE device_id = $1 ORDER BY interface_name`, + [req.params.id], + ); + + // Bands seen serving this device. This is what a band lock gets checked + // against, since a scan interrupts service and finds nothing at a site with + // a single base station. + const observed = await query<{ interface_name: string; band: number; observations: number; last_seen: string }>( + `SELECT interface_name, band, observations, last_seen + FROM lte_observed_bands WHERE device_id = $1 ORDER BY band`, + [req.params.id], + ); + + res.json({ + interfaces: interfaces.map(i => { + const carriers = { + primaryBand: (i.primary_band as LteBandInfo | null) ?? null, + caBands: (i.ca_bands as LteBandInfo[] | null) ?? [], + }; + return { + ...i, + // Reconstruct the interpreted view so the client never re-derives it. + allowed_bands: parseBandList(i.allowed_bands as string | null), + total_bandwidth_mhz: totalBandwidthMhz(carriers), + // Uplink rides the primary carrier alone, so an anchor narrower than + // what is being aggregated caps upload however wide the downlink is. + uplink_anchor: uplinkAnchor(carriers), + observed_bands: observed + .filter(o => o.interface_name === i.interface_name) + .map(o => ({ band: o.band, observations: o.observations, last_seen: o.last_seen })), + }; + }), + }); + } catch (error) { + console.error('Error fetching LTE state:', error); + res.status(500).json({ error: 'Failed to fetch LTE state' }); + } +}); + +// GET /api/devices/:id/lte/history — handovers, re-registrations and band changes +router.get('/:id/lte/history', async (req: Request, res: Response) => { + const { range = '24h', limit = '200' } = req.query as { range?: string; limit?: string }; + const windows: Record = { + '1h': '1 hour', '24h': '24 hours', '7d': '7 days', '30d': '30 days', + }; + const window = windows[range] || '24 hours'; + const cap = Math.min(Math.max(parseInt(limit, 10) || 200, 1), 1000); + + try { + const events = await query( + `SELECT interface_name, at, kind, detail, cell_id, enb_id, bands, rsrp, sinr + FROM lte_cell_history + WHERE device_id = $1 AND at > NOW() - $2::interval + ORDER BY at DESC LIMIT $3`, + [req.params.id, window, cap], + ); + res.json({ events }); + } catch (error) { + console.error('Error fetching LTE history:', error); + res.status(500).json({ error: 'Failed to fetch LTE history' }); + } +}); + +// GET /api/devices/:id/lte/metrics — InfluxDB lte_signal time series +router.get('/:id/lte/metrics', async (req: Request, res: Response) => { + const { iface, range = '6h' } = req.query as { iface?: string; range?: string }; + + const ranges: Record = { + '1h': '1h', '6h': '6h', '24h': '24h', '7d': '7d', '30d': '30d', + }; + const fluxRange = ranges[range] || '6h'; + // Keep roughly 200 points regardless of window, so a month is as readable as an hour. + const windows: Record = { + '1h': '30s', '6h': '2m', '24h': '10m', '7d': '1h', '30d': '4h', + }; + + const { getQueryApi } = await import('../config/influxdb'); + const queryApi = getQueryApi(); + const bucket = process.env.INFLUXDB_BUCKET || 'mikrotik'; + + const ifaceFilter = iface + ? `|> filter(fn: (r) => r["interface"] == "${iface.replace(/["\\]/g, '')}")` + : ''; + + const flux = ` + from(bucket: "${bucket}") + |> range(start: -${fluxRange}) + |> filter(fn: (r) => r["_measurement"] == "lte_signal") + |> filter(fn: (r) => r["device_id"] == "${String(req.params.id).replace(/[^0-9]/g, '')}") + ${ifaceFilter} + |> filter(fn: (r) => r["_field"] == "rsrp" or r["_field"] == "rsrq" + or r["_field"] == "sinr" or r["_field"] == "rssi" + or r["_field"] == "cqi" or r["_field"] == "carriers") + |> aggregateWindow(every: ${windows[fluxRange] || '2m'}, fn: mean, createEmpty: false) + // Drop the tag columns before pivoting. Serving band is a tag, so leaving + // it in splits the result into one table per band — which streams back as + // a run of B1 rows followed by a run of B3 rows, and draws a chart that + // jumps backwards in time at the seam. + |> keep(columns: ["_time", "_field", "_value", "interface"]) + |> group(columns: ["interface"]) + |> pivot(rowKey:["_time","interface"], columnKey: ["_field"], valueColumn: "_value") + |> sort(columns: ["_time"]) + `; + + try { + const num = (v: unknown) => (v == null ? undefined : Math.round(Number(v) * 10) / 10); + const rows: Record[] = []; + await new Promise((resolve, reject) => { + queryApi.queryRows(flux, { + next(row, tableMeta) { + const obj = tableMeta.toObject(row) as Record; + rows.push({ + time: String(obj['_time'] || ''), + interface: String(obj['interface'] || ''), + rsrp: num(obj['rsrp']), rsrq: num(obj['rsrq']), + sinr: num(obj['sinr']), rssi: num(obj['rssi']), + cqi: num(obj['cqi']), carriers: num(obj['carriers']), + }); + }, + error: reject, + complete: resolve, + }); + }); + // One table per interface still streams sequentially, so order globally. + rows.sort((a, b) => String(a.time).localeCompare(String(b.time))); + res.json({ metrics: rows }); + } catch (error) { + console.error('Error fetching LTE metrics:', error); + res.status(500).json({ error: 'Failed to fetch LTE metrics' }); + } +}); + // GET /api/devices/:id/wireless/metrics — InfluxDB wireless_stats time series router.get('/:id/wireless/metrics', async (req: Request, res: Response) => { const { iface, range = '6h' } = req.query as { iface?: string; range?: string }; diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index f68980f..1da3a2b 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -13,6 +13,9 @@ import { clientsPerRadio, buildMacIndex, matchRadiosToDevices, type WifiRole, } from './capsman'; +import { + parseLteMonitor, overallQuality, detectChanges, type LteStatus, +} from '../../utils/lte'; import { BackupService } from '../BackupService'; import { alertService } from '../AlertService'; @@ -115,6 +118,9 @@ export class DeviceCollector { if (this.device.device_type === 'switch') { await this.collectPoePower(); } + if ((this.device as unknown as { has_lte?: boolean }).has_lte) { + await this.collectLte(); + } await this.updateDeviceStatus('online'); } @@ -142,6 +148,7 @@ export class DeviceCollector { await this.collectVlans(); await this.collectSystemInfo(); await this.collectStp(); + await this.collectLte(); if (this.shouldCollectWireless()) { await this.collectWirelessInterfaces(); await this.collectSecurityProfiles(); @@ -167,6 +174,7 @@ export class DeviceCollector { await this.updateClients(); await this.collectEvents(); await this.collectNeighbors(); + await this.collectLte(); // Wireless data belongs in a full resync too — without this, a manual sync // never refreshed radios/SSIDs (or pruned ones deleted on the device), so // the UI could only catch up on the 5-minute slow poll. @@ -2832,6 +2840,204 @@ export class DeviceCollector { } } + // ─── LTE / cellular → Postgres + InfluxDB ───────────────────────────────── + + /** + * Collect cellular state from every LTE interface on the device. + * + * Called on the fast poll (for the signal series) and on the slow poll, where + * it also refreshes `devices.has_lte`. The device is only contacted when the + * interface table already shows an LTE interface, so the overwhelming majority + * of a wired fleet costs one local query and no API call at all. + * + * Field handling is deliberately forgiving. What a modem reports depends on + * its chipset rather than on RouterOS, so a missing value is an ordinary state + * and never a reason to abandon the rest of the row. + */ + async collectLte(): Promise { + try { + const lteInterfaces = await query<{ name: string }>( + `SELECT name FROM interfaces WHERE device_id = $1 AND type = 'lte'`, + [this.device.id], + ); + + if (lteInterfaces.length === 0) { + // Clear the flag if a modem was removed, so the fast poll stops looking. + if ((this.device as unknown as { has_lte?: boolean }).has_lte) { + await query(`UPDATE devices SET has_lte = FALSE WHERE id = $1`, [this.device.id]); + (this.device as unknown as { has_lte?: boolean }).has_lte = false; + } + return; + } + + if (!(this.device as unknown as { has_lte?: boolean }).has_lte) { + await query(`UPDATE devices SET has_lte = TRUE WHERE id = $1`, [this.device.id]); + (this.device as unknown as { has_lte?: boolean }).has_lte = true; + } + + const configs = await this.client + .execute('/interface/lte/print', { detail: '' }) + .catch(() => [] as Record[]); + const configByName = new Map(configs.filter(c => c['name']).map(c => [c['name'], c])); + + const writeApi = getWriteApi(); + + for (const { name } of lteInterfaces) { + // `monitor` addresses the interface by name and must be told not to stream. + const rows = await this.client + .execute('/interface/lte/monitor', { numbers: name, once: '' }) + .catch(() => [] as Record[]); + if (rows.length === 0) continue; + + const status = parseLteMonitor(rows[0]); + const config = configByName.get(name) || {}; + const quality = overallQuality(status); + + const previous = await this.previousLteStatus(name); + await this.recordLteChanges(name, previous, status); + + await query( + `INSERT INTO lte_interfaces ( + device_id, interface_name, status, data_class, modem_model, modem_revision, + operator, cell_id, enb_id, sector_id, phy_cell_id, session_uptime_s, + primary_band, ca_bands, rssi, rsrp, rsrq, sinr, cqi, rank_indicator, mcs, + dl_modulation, quality, allowed_bands, network_mode, apn_profiles, + allow_roaming, config_json, updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, + $19,$20,$21,$22,$23,$24,$25,$26,$27,$28,NOW()) + ON CONFLICT (device_id, interface_name) DO UPDATE SET + status = EXCLUDED.status, data_class = EXCLUDED.data_class, + modem_model = EXCLUDED.modem_model, modem_revision = EXCLUDED.modem_revision, + operator = EXCLUDED.operator, cell_id = EXCLUDED.cell_id, + enb_id = EXCLUDED.enb_id, sector_id = EXCLUDED.sector_id, + phy_cell_id = EXCLUDED.phy_cell_id, session_uptime_s = EXCLUDED.session_uptime_s, + primary_band = EXCLUDED.primary_band, ca_bands = EXCLUDED.ca_bands, + rssi = EXCLUDED.rssi, rsrp = EXCLUDED.rsrp, rsrq = EXCLUDED.rsrq, + sinr = EXCLUDED.sinr, cqi = EXCLUDED.cqi, + rank_indicator = EXCLUDED.rank_indicator, mcs = EXCLUDED.mcs, + dl_modulation = EXCLUDED.dl_modulation, quality = EXCLUDED.quality, + allowed_bands = EXCLUDED.allowed_bands, network_mode = EXCLUDED.network_mode, + apn_profiles = EXCLUDED.apn_profiles, allow_roaming = EXCLUDED.allow_roaming, + config_json = EXCLUDED.config_json, updated_at = NOW()`, + [ + this.device.id, name, status.status, status.dataClass, status.modemModel, + status.modemRevision, status.operator, status.cellId, status.enbId, + status.sectorId, status.phyCellId, status.sessionUptimeSeconds, + status.primaryBand ? JSON.stringify(status.primaryBand) : null, + JSON.stringify(status.caBands), + status.rssi, status.rsrp, status.rsrq, status.sinr, status.cqi, + status.ri, status.mcs, status.dlModulation, quality, + config['band'] || null, config['network-mode'] || null, + config['apn-profiles'] || null, + config['allow-roaming'] ? config['allow-roaming'] === 'yes' : null, + JSON.stringify({ ...rows[0], ...config }), + ], + ); + + await this.recordObservedBands(name, status); + + // Signal series. Fields are written only when the modem reported them — + // a zero here would read as a signal no transmitter produces. + const point = new Point('lte_signal') + .tag('device_id', String(this.device.id)) + .tag('device_name', this.device.name) + .tag('interface', name) + .tag('operator', status.operator || 'unknown') + .tag('band', status.primaryBand ? `B${status.primaryBand.band}` : 'unknown') + .timestamp(new Date()); + + let hasField = false; + const addField = (key: string, value: number | null, integer = false) => { + if (value == null) return; + if (integer) point.intField(key, Math.round(value)); + else point.floatField(key, value); + hasField = true; + }; + addField('rsrp', status.rsrp); + addField('rsrq', status.rsrq); + addField('sinr', status.sinr); + addField('rssi', status.rssi); + addField('cqi', status.cqi, true); + addField('rank', status.ri, true); + addField('mcs', status.mcs, true); + // Aggregated carriers, primary included — the shape of the link over time. + point.intField('carriers', status.caBands.length + (status.primaryBand ? 1 : 0)); + + if (hasField) writeApi.writePoint(point); + } + } catch (error) { + console.error(`LTE collection failed for ${this.device.name}:`, error); + } + } + + /** Last stored state for an interface, for comparing consecutive polls. */ + private async previousLteStatus(name: string): Promise { + const row = await queryOne<{ + cell_id: string | null; session_uptime_s: string | null; + primary_band: { band: number } | null; ca_bands: { band: number }[] | null; + }>( + `SELECT cell_id, session_uptime_s, primary_band, ca_bands + FROM lte_interfaces WHERE device_id = $1 AND interface_name = $2`, + [this.device.id, name], + ); + if (!row) return null; + + // Only the fields detectChanges compares need to be faithful here. + return { + status: null, dataClass: null, modemModel: null, modemRevision: null, + operator: null, cellId: row.cell_id, enbId: null, sectorId: null, phyCellId: null, + sessionUptimeSeconds: row.session_uptime_s == null ? null : Number(row.session_uptime_s), + primaryBand: row.primary_band + ? { band: row.primary_band.band, bandwidthMhz: null, earfcn: null, phyCellId: null, raw: '' } + : null, + caBands: (row.ca_bands || []).map(b => ({ + band: b.band, bandwidthMhz: null, earfcn: null, phyCellId: null, raw: '', + })), + rssi: null, rsrp: null, rsrq: null, sinr: null, + cqi: null, ri: null, mcs: null, dlModulation: null, + }; + } + + /** Persist handovers, re-registrations and band changes seen between polls. */ + private async recordLteChanges( + name: string, previous: LteStatus | null, next: LteStatus, + ): Promise { + const bands = [next.primaryBand, ...next.caBands] + .filter(Boolean).map(b => b!.band).sort((a, b) => a - b).join(','); + + for (const change of detectChanges(previous, next)) { + await query( + `INSERT INTO lte_cell_history + (device_id, interface_name, kind, detail, cell_id, enb_id, bands, rsrp, sinr) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [this.device.id, name, change.kind, change.detail, + next.cellId, next.enbId, bands || null, next.rsrp, next.sinr], + ); + } + } + + /** + * Remember which bands have actually served this device. + * + * This is the evidence a band lock is checked against later. Scanning is not a + * dependable alternative — it interrupts service and finds nothing at a site + * served by one base station — but a band already used here demonstrably works. + */ + private async recordObservedBands(name: string, status: LteStatus): Promise { + const bands = [...new Set( + [status.primaryBand, ...status.caBands].filter(Boolean).map(b => b!.band), + )]; + for (const band of bands) { + await query( + `INSERT INTO lte_observed_bands (device_id, interface_name, band) + VALUES ($1,$2,$3) + ON CONFLICT (device_id, interface_name, band) DO UPDATE SET + last_seen = NOW(), observations = lte_observed_bands.observations + 1`, + [this.device.id, name, band], + ); + } + } + // ─── Port VLAN config ───────────────────────────────────────────────────── async setPortVlanConfig( diff --git a/backend/src/services/mikrotik/RouterOSClient.ts b/backend/src/services/mikrotik/RouterOSClient.ts index f1757dc..47e7501 100644 --- a/backend/src/services/mikrotik/RouterOSClient.ts +++ b/backend/src/services/mikrotik/RouterOSClient.ts @@ -3,6 +3,14 @@ import * as tls from 'tls'; import * as crypto from 'crypto'; import { EventEmitter } from 'events'; +/** + * Key under which repeated attributes from one sentence are preserved as JSON. + * Present only when a reply actually repeated a key, so ordinary rows are + * byte-for-byte unchanged. + */ +export const REPEATED_ATTRIBUTES_KEY = '.repeated'; + + export interface RouterOSSentence { type: string; words: Record; @@ -363,17 +371,33 @@ export class RouterOSClient extends EventEmitter { const type = words[0]; const parsed: Record = {}; let tag: string | undefined; + // A sentence may carry the same attribute more than once — an LTE modem + // reports one `ca-band` per aggregated carrier — and a flat object can + // only hold the last. Repeats are preserved alongside it so that + // multi-valued replies are not silently reduced to their final value. + let repeated: Record | undefined; for (let i = 1; i < words.length; i++) { const w = words[i]; if (w.startsWith('=')) { const eq = w.indexOf('=', 1); - if (eq > 0) parsed[w.slice(1, eq)] = w.slice(eq + 1); + if (eq > 0) { + const key = w.slice(1, eq); + const value = w.slice(eq + 1); + if (key in parsed) { + repeated ??= {}; + repeated[key] ??= [parsed[key]]; + repeated[key].push(value); + } + parsed[key] = value; + } } else if (w.startsWith('.tag=')) { tag = w.slice(5); } } + if (repeated) parsed[REPEATED_ATTRIBUTES_KEY] = JSON.stringify(repeated); + return { type, words: parsed, tag }; } diff --git a/backend/src/services/mikrotik/__tests__/RouterOSClient.test.ts b/backend/src/services/mikrotik/__tests__/RouterOSClient.test.ts new file mode 100644 index 0000000..de35cb7 --- /dev/null +++ b/backend/src/services/mikrotik/__tests__/RouterOSClient.test.ts @@ -0,0 +1,79 @@ +import { RouterOSClient, RouterOSSentence, REPEATED_ATTRIBUTES_KEY } from '../RouterOSClient'; +import { REPEATED_KEY, parseBandSpecs } from '../../../utils/lte'; + +/** Encode one word using RouterOS's variable-length prefix (short form only). */ +const word = (s: string): Buffer => { + const body = Buffer.from(s, 'utf8'); + if (body.length >= 0x80) throw new Error('test helper handles short words only'); + return Buffer.concat([Buffer.from([body.length]), body]); +}; + +/** Build a complete `!re` sentence from raw `key=value` words. */ +const sentence = (...kv: string[]): Buffer => + Buffer.concat([word('!re'), ...kv.map(p => word('=' + p)), Buffer.from([0x00])]); + +/** Feed bytes straight to the parser, bypassing the socket. */ +function parse(bytes: Buffer): RouterOSSentence | null { + const client = new RouterOSClient('127.0.0.1', 8728, 'x', 'x'); + const internals = client as unknown as { + buffer: Buffer; + tryParseSentence(): RouterOSSentence | null; + }; + internals.buffer = bytes; + return internals.tryParseSentence(); +} + +describe('sentence parsing', () => { + it('reads ordinary attributes into a flat object', () => { + const s = parse(sentence('name=lte1', 'status=running'))!; + expect(s.type).toBe('!re'); + expect(s.words).toEqual({ name: 'lte1', status: 'running' }); + }); + + it('adds no repeat side-channel when nothing repeats', () => { + // Every existing caller sees byte-for-byte what it saw before. + const s = parse(sentence('name=lte1'))!; + expect(Object.keys(s.words)).toEqual(['name']); + }); + + it('preserves every value when an attribute repeats', () => { + // A Cat-18 modem reports one `ca-band` per aggregated carrier. A flat object + // keeps only the last, which under-reports aggregation exactly when the link + // is performing best. + const s = parse(sentence( + 'primary-band=B1@20Mhz earfcn: 500', + 'ca-band=B3@20Mhz earfcn: 1800', + 'ca-band=B7@20Mhz earfcn: 3350', + 'ca-band=B20@10Mhz earfcn: 6300', + ))!; + + expect(s.words['ca-band']).toBe('B20@10Mhz earfcn: 6300'); // unchanged + expect(JSON.parse(s.words[REPEATED_ATTRIBUTES_KEY])).toEqual({ + 'ca-band': [ + 'B3@20Mhz earfcn: 1800', + 'B7@20Mhz earfcn: 3350', + 'B20@10Mhz earfcn: 6300', + ], + }); + }); + + it('hands the LTE parser all three carriers', () => { + const s = parse(sentence( + 'ca-band=B3@20Mhz earfcn: 1800', + 'ca-band=B7@20Mhz earfcn: 3350', + 'ca-band=B20@10Mhz earfcn: 6300', + ))!; + expect(parseBandSpecs(s.words, 'ca-band').map(b => b.band)).toEqual([3, 7, 20]); + }); + + it('tracks repeats of several different attributes independently', () => { + const s = parse(sentence('a=1', 'b=x', 'a=2', 'b=y', 'c=only'))!; + expect(JSON.parse(s.words[REPEATED_ATTRIBUTES_KEY])).toEqual({ a: ['1', '2'], b: ['x', 'y'] }); + expect(s.words.c).toBe('only'); + }); + + it('agrees with the key the LTE parser reads', () => { + // These constants live apart to keep the pure parser free of socket code. + expect(REPEATED_KEY).toBe(REPEATED_ATTRIBUTES_KEY); + }); +}); diff --git a/backend/src/utils/__tests__/fixtures/lte-atl18.md b/backend/src/utils/__tests__/fixtures/lte-atl18.md new file mode 100644 index 0000000..cd360c5 --- /dev/null +++ b/backend/src/utils/__tests__/fixtures/lte-atl18.md @@ -0,0 +1,97 @@ +# Field capture — MikroTik ATL 18 (Quectel EG18-EA, Cat-18) + +Contributed by @trackersoft (Bulgaria, VIVACOM) in discussion #85, 2026-08-23. +Two captures: a CLI session, and a WinBox view of the same device carrying four +aggregated carriers. +Sensitive identifiers were redacted by the contributor. RouterOS 7.24 stable, arm64. + +Kept verbatim because every LTE parsing decision in `utils/lte.ts` is calibrated +against it. Field *names* here differ from MikroTik's documentation in several +places (`data-class` not `access-technology`, `iccid` not `uicc`, `status: running` +not `connected`), and several documented fields are absent entirely — what a modem +reports depends on the chipset, not on RouterOS. + +``` +[user@MikroTik-LTE] > /interface/lte/monitor lte1 once + status: running + model: EG18-EA + revision: EG18EAPAR01A14M4G + current-operator: VIVACOM + current-cellid: xxxxxx + enb-id: xxxx + sector-id: xx + phy-cellid: xxx + data-class: LTE + session-uptime: 3d7h17m59s + imei: xxxxxxxxxxxxxxx + imsi: xxxxxxxxxxxxxxx + iccid: xxxxxxxxxxxxxxxxxxxx + primary-band: B1@20Mhz earfcn: 500 phy-cellid: 190 + ca-band: B3@20Mhz earfcn: 1800 phy-cellid: 190 + dl-modulation: 256qam + cqi: 15 + ri: 2 + mcs: 20 + rssi: -69dBm + rsrp: -97dBm + rsrq: -9dB + sinr: 17dB + +[user@MikroTik-LTE] > /interface/lte/print detail +Flags: R - RUNNING +0 R default-name="lte1" name="lte1" mtu=1500 advertised-mtu=1500 apn-profiles=default + allow-roaming=no sms-read=no sms-protocol=auto network-mode=lte band=1,3,7 + +[user@MikroTik-LTE] > /system/resource/print + board-name: ATLGM version: 7.24 (stable) cpu: ARM64 + free-memory: 157.8MiB total-memory: 256.0MiB +free-hdd-space: 1160.0KiB total-hdd-space: 16.0MiB + +[user@MikroTik-LTE] > /interface/lte/scan lte1 duration=10 +(empty) +``` + +## Second capture — four aggregated carriers (WinBox) + +The same interface at a moment of 4-carrier aggregation. Reproduced here because +it is the case that proves repeated attributes must survive parsing. + +``` + Current Operator: VIVACOM + Data Class: LTE + Primary Band: B3@20Mhz earfcn: 1800 phy-cellid: 36 + CA Band: B1@20Mhz earfcn: 500 phy-cellid: 36 + B7@20Mhz earfcn: 3150 phy-cellid: 36 + B20@10Mhz earfcn: 6300 phy-cellid: 36 + Session Uptime: 00:02:51 + RSSI: -40 dBm RSRP: -70 dBm + SINR: 13 dB RSRQ: -9.0 dB + CQI: 14 RI: 2 MCS: 0 +``` + +- **`ca-band` repeats once per carrier.** Keeping only the last value yields + `B3 + B20` — 30 MHz — on a device actually running 70 MHz, and the narrowest + carrier is the one that arrives last, so the error is severe rather than subtle. +- **`Session Uptime` renders as a clock here** but as `3d7h17m59s` over the CLI, + so both forms are parsed. +- **`MCS: 0` is a reading, not a blank**, on an otherwise healthy link. It must + not be treated as missing, and must not feed the quality grade. +- **Uplink rides the primary carrier alone.** Left on automatic this modem anchors + to whichever band is loudest — often a 10 MHz B20 or B28 — and aggregates the + wider bands for downlink only, which caps upload. Excluding the narrow bands to + move the anchor is the contributor's actual reason for locking bands, and the + reason `uplinkAnchor()` exists. + +## Notes that shaped the implementation + +- **Units live inside values** (`-69dBm`, `-9dB`, `17dB`), so every signal read strips them. +- **`primary-band` / `ca-band` are composite strings**, not structured fields. +- **The scan returned nothing because the site has one base station**, 10 km away — + not because the modem cannot scan. Scanning is still not a dependable source: it + interrupts service and finds nothing at exactly the remote sites that most need a + band-lock warning. Observed-band history replaces it. +- **`session-uptime` resets on re-registration**, which makes session drops and + handovers detectable by polling alone — this modem may log nothing. +- RSRP −97 dBm reads as mediocre, yet CQI 15 / RI 2 / 256QAM is the modem at its + ceiling. Raw RSRP alone misleads; the interpreted view exists because of this. +- 1160 KiB free of 16 MiB flash: a Change Guard restore point cannot be assumed to fit. diff --git a/backend/src/utils/__tests__/lte.test.ts b/backend/src/utils/__tests__/lte.test.ts new file mode 100644 index 0000000..104fd89 --- /dev/null +++ b/backend/src/utils/__tests__/lte.test.ts @@ -0,0 +1,451 @@ +import { + parseSignalValue, parseIntField, parseDurationSeconds, parseBandList, formatBandList, + parseBandSpec, parseBandSpecs, parseLteMonitor, isConnected, + classifyRsrp, classifyRsrq, classifySinr, overallQuality, describeQuality, + detectChanges, observedBands, unprovenBands, totalBandwidthMhz, uplinkAnchor, + REPEATED_KEY, LteStatus, +} from '../lte'; + +/** + * Verbatim from a MikroTik ATL 18 (Quectel EG18-EA, Cat-18) on VIVACOM, + * RouterOS 7.24 — see fixtures/lte-atl18.md. Identifiers redacted at source. + */ +const ATL18: Record = { + 'status': 'running', + 'model': 'EG18-EA', + 'revision': 'EG18EAPAR01A14M4G', + 'current-operator': 'VIVACOM', + 'current-cellid': '123456', + 'enb-id': '1234', + 'sector-id': '12', + 'phy-cellid': '190', + 'data-class': 'LTE', + 'session-uptime': '3d7h17m59s', + 'primary-band': 'B1@20Mhz earfcn: 500 phy-cellid: 190', + 'ca-band': 'B3@20Mhz earfcn: 1800 phy-cellid: 190', + 'dl-modulation': '256qam', + 'cqi': '15', + 'ri': '2', + 'mcs': '20', + 'rssi': '-69dBm', + 'rsrp': '-97dBm', + 'rsrq': '-9dB', + 'sinr': '17dB', +}; + +/** + * The same device carrying four aggregated carriers, from a WinBox capture in + * discussion #85. Primary B3 with three CA rows — the shape that proves repeated + * attributes must survive parsing, since the *last* of them is the narrowest. + */ +const FOUR_CARRIER: Record = { + 'status': 'running', + 'data-class': 'LTE', + 'current-operator': 'VIVACOM', + 'primary-band': 'B3@20Mhz earfcn: 1800 phy-cellid: 36', + 'ca-band': 'B20@10Mhz earfcn: 6300 phy-cellid: 36', + [REPEATED_KEY]: JSON.stringify({ + 'ca-band': [ + 'B1@20Mhz earfcn: 500 phy-cellid: 36', + 'B7@20Mhz earfcn: 3150 phy-cellid: 36', + 'B20@10Mhz earfcn: 6300 phy-cellid: 36', + ], + }), + 'rssi': '-40dBm', 'rsrp': '-70dBm', 'rsrq': '-9.0dB', 'sinr': '13dB', + 'cqi': '14', 'ri': '2', 'mcs': '0', + 'session-uptime': '00:02:51', +}; + +// ── scalars ─────────────────────────────────────────────────────────────────── + +describe('parseSignalValue', () => { + it('strips the unit glued to the value', () => { + expect(parseSignalValue('-97dBm')).toBe(-97); + expect(parseSignalValue('-9dB')).toBe(-9); + expect(parseSignalValue('17dB')).toBe(17); + }); + + it('accepts a bare number and decimals', () => { + expect(parseSignalValue('-69')).toBe(-69); + expect(parseSignalValue('17.5dB')).toBe(17.5); + }); + + it('returns null rather than 0 for what an idle modem reports', () => { + // Zero would draw a signal stronger than any transmitter produces. + for (const junk of ['', ' ', '-', 'unknown', 'n/a', undefined, null]) { + expect(parseSignalValue(junk as string)).toBeNull(); + } + }); +}); + +describe('parseIntField', () => { + it('reads plain integers', () => { + expect(parseIntField('15')).toBe(15); + expect(parseIntField('0')).toBe(0); + }); + it('rejects blanks and non-numerics', () => { + expect(parseIntField('')).toBeNull(); + expect(parseIntField('none')).toBeNull(); + expect(parseIntField(undefined)).toBeNull(); + }); +}); + +describe('parseDurationSeconds', () => { + it('parses the captured session uptime', () => { + // 3d7h17m59s + expect(parseDurationSeconds('3d7h17m59s')).toBe(3 * 86400 + 7 * 3600 + 17 * 60 + 59); + }); + it('handles weeks and partial forms', () => { + expect(parseDurationSeconds('1w2d')).toBe(604800 + 172800); + expect(parseDurationSeconds('45s')).toBe(45); + }); + it('accepts the clock form WinBox renders', () => { + expect(parseDurationSeconds('00:02:51')).toBe(171); + expect(parseDurationSeconds('13:45:00')).toBe(49500); + }); + + it('rejects junk', () => { + expect(parseDurationSeconds('soon')).toBeNull(); + expect(parseDurationSeconds('')).toBeNull(); + expect(parseDurationSeconds('99:99:99')).toBeNull(); + }); +}); + +describe('parseBandList / formatBandList', () => { + it('parses the configured allow-list', () => { + expect(parseBandList('1,3,7')).toEqual([1, 3, 7]); + }); + it('tolerates spacing and B prefixes', () => { + expect(parseBandList(' B1 , b3 ,7 ')).toEqual([1, 3, 7]); + }); + it('is empty when unset — meaning auto, not none', () => { + expect(parseBandList('')).toEqual([]); + expect(parseBandList(undefined)).toEqual([]); + }); + it('round-trips sorted and deduplicated', () => { + expect(formatBandList([7, 1, 3, 1])).toBe('1,3,7'); + }); +}); + +// ── bands ───────────────────────────────────────────────────────────────────── + +describe('parseBandSpec', () => { + it('parses the captured primary band', () => { + expect(parseBandSpec('B1@20Mhz earfcn: 500 phy-cellid: 190')).toEqual({ + band: 1, bandwidthMhz: 20, earfcn: 500, phyCellId: 190, + raw: 'B1@20Mhz earfcn: 500 phy-cellid: 190', + }); + }); + + it('parses a bare band number, since only that is universal', () => { + const b = parseBandSpec('B7')!; + expect(b.band).toBe(7); + expect(b.bandwidthMhz).toBeNull(); + expect(b.earfcn).toBeNull(); + }); + + it('returns null when there is no band at all', () => { + expect(parseBandSpec('')).toBeNull(); + expect(parseBandSpec('unknown')).toBeNull(); + expect(parseBandSpec(undefined)).toBeNull(); + }); +}); + +describe('parseBandSpecs — carrier aggregation', () => { + it('reads the single CA carrier in the capture', () => { + const ca = parseBandSpecs(ATL18, 'ca-band'); + expect(ca).toHaveLength(1); + expect(ca[0].band).toBe(3); + expect(ca[0].earfcn).toBe(1800); + }); + + it('recovers every carrier from repeated attributes', () => { + // A Cat-18 modem aggregating three carriers: the API client preserves the + // repeats, which a plain object would otherwise collapse to the last one. + const row: Record = { + 'ca-band': 'B7@20Mhz earfcn: 3350 phy-cellid: 191', + [REPEATED_KEY]: JSON.stringify({ + 'ca-band': [ + 'B3@20Mhz earfcn: 1800 phy-cellid: 190', + 'B7@20Mhz earfcn: 3350 phy-cellid: 191', + ], + }), + }; + expect(parseBandSpecs(row, 'ca-band').map(b => b.band)).toEqual([3, 7]); + }); + + it('recovers carriers concatenated into one value', () => { + const row = { 'ca-band': 'B3@20Mhz earfcn: 1800 B7@10Mhz earfcn: 3350' }; + const ca = parseBandSpecs(row, 'ca-band'); + expect(ca.map(b => b.band)).toEqual([3, 7]); + expect(ca[1].bandwidthMhz).toBe(10); + }); + + it('deduplicates a carrier reported through both channels', () => { + const row: Record = { + 'ca-band': 'B3@20Mhz earfcn: 1800 phy-cellid: 190', + [REPEATED_KEY]: JSON.stringify({ 'ca-band': ['B3@20Mhz earfcn: 1800 phy-cellid: 190'] }), + }; + expect(parseBandSpecs(row, 'ca-band')).toHaveLength(1); + }); + + it('falls back to the plain value when the side-channel is malformed', () => { + const row: Record = { 'ca-band': 'B3@20Mhz', [REPEATED_KEY]: '{not json' }; + expect(parseBandSpecs(row, 'ca-band').map(b => b.band)).toEqual([3]); + }); + + it('is empty when the modem reports no aggregation', () => { + expect(parseBandSpecs({ 'primary-band': 'B1@20Mhz' }, 'ca-band')).toEqual([]); + }); +}); + +// ── monitor ─────────────────────────────────────────────────────────────────── + +describe('parseLteMonitor', () => { + const s = parseLteMonitor(ATL18); + + it('reads the capture end to end', () => { + expect(s.status).toBe('running'); + expect(s.modemModel).toBe('EG18-EA'); + expect(s.operator).toBe('VIVACOM'); + expect(s.rsrp).toBe(-97); + expect(s.rsrq).toBe(-9); + expect(s.sinr).toBe(17); + expect(s.rssi).toBe(-69); + expect(s.cqi).toBe(15); + expect(s.ri).toBe(2); + expect(s.mcs).toBe(20); + expect(s.dlModulation).toBe('256qam'); + expect(s.primaryBand!.band).toBe(1); + expect(s.caBands.map(b => b.band)).toEqual([3]); + expect(s.sessionUptimeSeconds).toBe(285479); + }); + + it('takes the access technology from data-class, not the documented name', () => { + expect(s.dataClass).toBe('LTE'); + }); + + it('still accepts access-technology from modems that use it', () => { + expect(parseLteMonitor({ 'access-technology': 'Evolved 3G' }).dataClass).toBe('Evolved 3G'); + }); + + it('survives a reply with nothing in it', () => { + const empty = parseLteMonitor({}); + expect(empty.rsrp).toBeNull(); + expect(empty.primaryBand).toBeNull(); + expect(empty.caBands).toEqual([]); + expect(empty.status).toBeNull(); + }); + + it('survives a modem that is registering — keys present, values blank', () => { + const registering = parseLteMonitor({ + 'status': 'searching', 'rsrp': '', 'rsrq': '', 'sinr': '', + 'primary-band': '', 'session-uptime': '', + }); + expect(registering.status).toBe('searching'); + expect(registering.rsrp).toBeNull(); + expect(registering.primaryBand).toBeNull(); + expect(isConnected(registering)).toBe(false); + }); +}); + +describe('isConnected', () => { + it('accepts running, which is what this modem family reports', () => { + expect(isConnected(parseLteMonitor(ATL18))).toBe(true); + }); + it('accepts connected, which others report', () => { + expect(isConnected(parseLteMonitor({ status: 'connected' }))).toBe(true); + }); + it('rejects anything else', () => { + expect(isConnected(parseLteMonitor({ status: 'searching' }))).toBe(false); + expect(isConnected(parseLteMonitor({}))).toBe(false); + }); +}); + +// ── quality ─────────────────────────────────────────────────────────────────── + +describe('signal classification', () => { + it('places RSRP on the standard operating bands', () => { + expect(classifyRsrp(-75)).toBe('excellent'); + expect(classifyRsrp(-85)).toBe('good'); + expect(classifyRsrp(-97)).toBe('fair'); + expect(classifyRsrp(-110)).toBe('poor'); + expect(classifyRsrp(null)).toBe('unknown'); + }); + + it('places RSRQ and SINR likewise', () => { + expect(classifyRsrq(-9)).toBe('excellent'); + expect(classifyRsrq(-18)).toBe('fair'); + expect(classifySinr(25)).toBe('excellent'); + expect(classifySinr(17)).toBe('good'); + expect(classifySinr(5)).toBe('fair'); + expect(classifySinr(-3)).toBe('poor'); + }); +}); + +describe('overallQuality', () => { + it('does not let a quiet cell alone condemn a clean link', () => { + // The captured device: RSRP -97 ("fair") but SINR 17 with CQI 15, RI 2 and + // 256QAM — the modem at its ceiling. Reporting "fair" would misinform. + expect(overallQuality(parseLteMonitor(ATL18))).toBe('good'); + }); + + it('still reports poorly when the signal really is poor', () => { + expect(overallQuality(parseLteMonitor({ rsrp: '-115dBm', sinr: '-2dB' }))).toBe('poor'); + }); + + it('falls back to whichever measure exists', () => { + expect(overallQuality(parseLteMonitor({ rsrp: '-85dBm' }))).toBe('good'); + expect(overallQuality(parseLteMonitor({ sinr: '25dB' }))).toBe('excellent'); + expect(overallQuality(parseLteMonitor({}))).toBe('unknown'); + }); + + it('is dragged down by a genuinely weak cell', () => { + // A clean channel with almost no signal has no fading margin, so however + // good the SINR, this cannot be graded above one step over the RSRP. + expect(overallQuality(parseLteMonitor({ rsrp: '-112dBm', sinr: '22dB' }))).toBe('fair'); + }); +}); + +describe('describeQuality', () => { + it('counts the primary carrier in the aggregation total', () => { + expect(describeQuality(parseLteMonitor(ATL18))).toContain('Aggregating 2 carriers'); + }); + it('says so plainly when the modem is not attached', () => { + expect(describeQuality(parseLteMonitor({ status: 'searching' }))).toBe('Not connected'); + }); +}); + +// ── change detection ────────────────────────────────────────────────────────── + +const at = (over: Partial>): LteStatus => + parseLteMonitor({ ...ATL18, ...over } as Record); + +describe('detectChanges', () => { + it('reports nothing on the first poll', () => { + expect(detectChanges(null, at({}))).toEqual([]); + }); + + it('reports nothing when the link is steady', () => { + expect(detectChanges(at({}), at({ 'session-uptime': '3d7h18m29s' }))).toEqual([]); + }); + + it('sees a re-registration when uptime runs backwards', () => { + const changes = detectChanges(at({}), at({ 'session-uptime': '45s' })); + expect(changes.map(c => c.kind)).toContain('session-reset'); + }); + + it('sees a handover when the cell id changes', () => { + const changes = detectChanges(at({}), at({ 'current-cellid': '999999' })); + expect(changes.map(c => c.kind)).toContain('handover'); + }); + + it('sees a band change, including one that only drops a CA carrier', () => { + const dropped = parseLteMonitor( + Object.fromEntries(Object.entries(ATL18).filter(([k]) => k !== 'ca-band')), + ); + const changes = detectChanges(at({}), dropped); + expect(changes.map(c => c.kind)).toEqual(['band-change']); + expect(changes[0].detail).toContain('from 1,3 to 1'); + }); + + it('reports a reset and a handover together when the modem moves tower', () => { + const kinds = detectChanges(at({}), at({ 'session-uptime': '10s', 'current-cellid': '888888' })) + .map(c => c.kind); + expect(kinds).toEqual(['session-reset', 'handover']); + }); +}); + +// ── observed bands ──────────────────────────────────────────────────────────── + +describe('observedBands / unprovenBands', () => { + it('collects every band seen, primary and aggregated', () => { + const history = [at({}), at({ 'primary-band': 'B7@10Mhz earfcn: 3350' })]; + expect(observedBands(history)).toEqual([1, 3, 7]); + }); + + it('passes a lock that history supports', () => { + expect(unprovenBands([1, 3], [1, 3, 7])).toEqual([]); + }); + + it('flags a band never once seen serving this device', () => { + // Locking to B20 here strands a device that has only ever used 1, 3 and 7. + expect(unprovenBands([1, 20], [1, 3, 7])).toEqual([20]); + }); +}); + + +// ── four-carrier aggregation ────────────────────────────────────────────────── + +describe('four-carrier capture', () => { + const s = parseLteMonitor(FOUR_CARRIER); + + it('recovers every aggregated carrier, not just the last reported', () => { + expect(s.primaryBand!.band).toBe(3); + expect(s.caBands.map(b => b.band)).toEqual([1, 7, 20]); + }); + + it('reports the true aggregate bandwidth', () => { + // 20 + 20 + 20 + 10. Keeping only the final `ca-band` would show 30 MHz — + // and it is the narrowest carrier that arrives last, so the error is severe. + expect(totalBandwidthMhz(s)).toBe(70); + }); + + it('keeps MCS 0, which is a reading rather than a blank', () => { + expect(s.mcs).toBe(0); + expect(s.cqi).toBe(14); + }); + + it('parses the clock-form session uptime', () => { + expect(s.sessionUptimeSeconds).toBe(171); + }); + + it('grades a loud but noisy cell on its SINR', () => { + // RSRP -70 is excellent, SINR 13 only good; the weaker measure must lead. + expect(overallQuality(s)).toBe('good'); + }); + + it('counts all four carriers in the description', () => { + expect(describeQuality(s)).toContain('Aggregating 4 carriers'); + }); +}); + +describe('uplinkAnchor', () => { + it('says nothing when the anchor is already the widest carrier', () => { + // The primary here is B3@20 and no aggregated carrier beats it. + expect(uplinkAnchor(parseLteMonitor(FOUR_CARRIER))).toBeNull(); + expect(uplinkAnchor(parseLteMonitor(ATL18))).toBeNull(); + }); + + it('flags an anchor narrower than what is being aggregated', () => { + // Left on auto this modem anchors to the loudest band — often a 10 MHz one — + // and uplink rides the primary alone, so upload is capped at 10 MHz. + const anchored = parseLteMonitor({ + 'primary-band': 'B20@10Mhz earfcn: 6300', + 'ca-band': 'B3@20Mhz earfcn: 1800', + [REPEATED_KEY]: JSON.stringify({ + 'ca-band': ['B1@20Mhz earfcn: 500', 'B3@20Mhz earfcn: 1800'], + }), + }); + expect(uplinkAnchor(anchored)).toEqual({ + primaryMhz: 10, primaryBand: 20, widestMhz: 20, widestBand: 1, + }); + }); + + it('says nothing when bandwidth is unreported', () => { + expect(uplinkAnchor(parseLteMonitor({ 'primary-band': 'B3', 'ca-band': 'B1' }))).toBeNull(); + }); + + it('says nothing when there is no aggregation at all', () => { + expect(uplinkAnchor(parseLteMonitor({ 'primary-band': 'B3@20Mhz' }))).toBeNull(); + }); +}); + +describe('totalBandwidthMhz', () => { + it('sums primary and aggregated carriers', () => { + expect(totalBandwidthMhz(parseLteMonitor(ATL18))).toBe(40); + }); + it('is zero when no bandwidth is reported', () => { + expect(totalBandwidthMhz(parseLteMonitor({}))).toBe(0); + }); +}); diff --git a/backend/src/utils/lte.ts b/backend/src/utils/lte.ts new file mode 100644 index 0000000..eb5a0b1 --- /dev/null +++ b/backend/src/utils/lte.ts @@ -0,0 +1,459 @@ +/** + * LTE modem telemetry: parsing, quality interpretation and change detection. + * + * Everything here is calibrated against a real field capture from a MikroTik + * ATL 18 (Quectel EG18-EA, Cat-18) — see `__tests__/fixtures/lte-atl18.md`. + * That capture matters because **what an LTE interface reports depends on the + * modem chipset, not on RouterOS**: the documented `access-technology` arrives + * as `data-class`, the documented `uicc` as `iccid`, and `registration-status`, + * `pin-status`, `functionality` and `lac`/`tac` are simply absent. Any field + * read here is therefore optional, always. + * + * Two shapes need real parsing rather than a cast: + * + * **Units live inside values.** `rsrp: -97dBm`, `rsrq: -9dB`, `sinr: 17dB` — + * the suffix is part of the string, and differs per field. + * + * **Band information is a composite string**, not a structured record: + * `primary-band: B1@20Mhz earfcn: 500 phy-cellid: 190`. Band, bandwidth, EARFCN + * and physical cell id all have to be pulled out of one value. + * + * Carrier aggregation is the subtle one. A Cat-18 modem aggregates several + * carriers and reports one `ca-band` per carrier, so CA is inherently + * multi-valued while an API sentence parsed into a plain object is not. Both + * shapes are accepted here — repeated keys surfaced by the client, and several + * carriers concatenated into a single value — because under-reporting + * aggregation would misrepresent the link exactly when it is performing best. + */ + +/** One aggregated carrier, parsed out of a `primary-band` / `ca-band` value. */ +export interface LteBandInfo { + /** E-UTRA band number, e.g. 1 for `B1@20Mhz`. */ + band: number; + /** Channel bandwidth in MHz, when reported. */ + bandwidthMhz: number | null; + /** E-UTRA absolute radio frequency channel number. */ + earfcn: number | null; + /** Physical cell identity of this carrier. */ + phyCellId: number | null; + /** The value as the modem wrote it, for display and for debugging. */ + raw: string; +} + +export type SignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'unknown'; + +export interface LteStatus { + status: string | null; + /** Radio access technology — `data-class` on this modem family. */ + dataClass: string | null; + modemModel: string | null; + modemRevision: string | null; + operator: string | null; + cellId: string | null; + enbId: string | null; + sectorId: string | null; + phyCellId: string | null; + sessionUptimeSeconds: number | null; + primaryBand: LteBandInfo | null; + caBands: LteBandInfo[]; + rssi: number | null; + rsrp: number | null; + rsrq: number | null; + sinr: number | null; + cqi: number | null; + /** Rank indicator — spatial streams currently in use. */ + ri: number | null; + mcs: number | null; + dlModulation: string | null; +} + +/** Reserved key under which the API client surfaces repeated attributes. */ +export const REPEATED_KEY = '.repeated'; + +// ─── Scalars ────────────────────────────────────────────────────────────────── + +/** + * Read a signal figure whose unit is glued to the number (`-97dBm`, `17dB`). + * + * Deliberately strict about what counts as a reading. A modem that has not + * registered reports blanks, dashes or `unknown` rather than omitting the key, + * and treating those as 0 would draw a client sitting at 0 dBm — a signal + * stronger than any transmitter produces. + */ +export function parseSignalValue(raw: string | undefined | null): number | null { + if (raw == null) return null; + const m = String(raw).trim().match(/^(-?\d+(?:\.\d+)?)\s*(?:dBm|dB)?$/i); + if (!m) return null; + const n = parseFloat(m[1]); + return Number.isFinite(n) ? n : null; +} + +/** Read a plain integer field, rejecting the blanks an idle modem reports. */ +export function parseIntField(raw: string | undefined | null): number | null { + if (raw == null) return null; + const m = String(raw).trim().match(/^-?\d+$/); + if (!m) return null; + const n = parseInt(m[0], 10); + return Number.isFinite(n) ? n : null; +} + +/** + * RouterOS duration to seconds. + * + * Two forms are accepted. The API returns the compact `3d7h17m59s`, but the same + * value renders as a `00:02:51` clock in WinBox, and a field that reads one way + * in the GUI and another over the wire is not worth betting a blank display on. + */ +export function parseDurationSeconds(raw: string | undefined | null): number | null { + if (raw == null) return null; + const s = String(raw).trim(); + + const clock = s.match(/^(\d+):([0-5]\d):([0-5]\d)$/); + if (clock) { + return parseInt(clock[1], 10) * 3600 + parseInt(clock[2], 10) * 60 + parseInt(clock[3], 10); + } + + if (!/^(\d+[wdhms])+$/.test(s)) return null; + const units: Record = { w: 604800, d: 86400, h: 3600, m: 60, s: 1 }; + let total = 0; + for (const [, n, u] of s.matchAll(/(\d+)([wdhms])/g)) total += parseInt(n, 10) * units[u]; + return total; +} + +/** Allowed-band list as configured on the interface: `band=1,3,7`. */ +export function parseBandList(raw: string | undefined | null): number[] { + if (raw == null) return []; + return String(raw) + .split(',') + .map(p => parseInt(p.trim().replace(/^[bB]/, ''), 10)) + .filter(n => Number.isFinite(n) && n > 0); +} + +/** Render a band list back into the form RouterOS expects. */ +export function formatBandList(bands: number[]): string { + return [...new Set(bands)].sort((a, b) => a - b).join(','); +} + +// ─── Bands ──────────────────────────────────────────────────────────────────── + +/** + * Parse one carrier out of `B1@20Mhz earfcn: 500 phy-cellid: 190`. + * + * Bandwidth, EARFCN and cell id are all optional: the band number is the only + * part every modem reports, so a value of bare `B7` still yields a carrier. + */ +export function parseBandSpec(raw: string | undefined | null): LteBandInfo | null { + if (raw == null) return null; + const text = String(raw).trim(); + if (!text) return null; + + const band = text.match(/\bB(\d+)\b/i); + if (!band) return null; + + const bandwidth = text.match(/@\s*([\d.]+)\s*mhz/i); + const earfcn = text.match(/earfcn:?\s*(\d+)/i); + const phy = text.match(/phy-cellid:?\s*(\d+)/i); + + return { + band: parseInt(band[1], 10), + bandwidthMhz: bandwidth ? parseFloat(bandwidth[1]) : null, + earfcn: earfcn ? parseInt(earfcn[1], 10) : null, + phyCellId: phy ? parseInt(phy[1], 10) : null, + raw: text, + }; +} + +/** + * Collect every carrier reported under one key. + * + * A Cat-18 modem aggregating three carriers reports three `ca-band` values, but + * an API sentence flattened into an object holds one value per key. Two shapes + * therefore have to be handled: the repeated attributes the client preserves + * under {@link REPEATED_KEY}, and several carriers concatenated into a single + * value. Splitting before each `B@` recovers the second case without + * disturbing the first. + */ +export function parseBandSpecs( + row: Record, + key: string, +): LteBandInfo[] { + const values: string[] = []; + + const repeatedRaw = row[REPEATED_KEY]; + if (repeatedRaw) { + try { + const repeated = JSON.parse(repeatedRaw) as Record; + if (Array.isArray(repeated?.[key])) values.push(...repeated[key]); + } catch { + // A malformed side-channel must never cost us the primary value below. + } + } + if (values.length === 0 && row[key] != null) values.push(row[key]); + + const out: LteBandInfo[] = []; + const seen = new Set(); + for (const value of values) { + // `B1@20Mhz earfcn: 500 B3@20Mhz earfcn: 1800` → one entry per carrier. + for (const part of String(value).split(/\s*(?=B\d+@)/)) { + const parsed = parseBandSpec(part); + if (!parsed) continue; + const dedupe = `${parsed.band}:${parsed.earfcn ?? ''}:${parsed.phyCellId ?? ''}`; + if (seen.has(dedupe)) continue; + seen.add(dedupe); + out.push(parsed); + } + } + return out; +} + +// ─── Monitor ────────────────────────────────────────────────────────────────── + +/** Parse a `/interface/lte/monitor` reply. Every field is treated as optional. */ +export function parseLteMonitor(row: Record): LteStatus { + const caBands = parseBandSpecs(row, 'ca-band'); + const primary = parseBandSpecs(row, 'primary-band'); + + return { + status: row['status'] || null, + // `data-class` on Quectel-based modems; `access-technology` is the + // documented name and appears on others. Accept either. + dataClass: row['data-class'] || row['access-technology'] || null, + modemModel: row['model'] || null, + modemRevision: row['revision'] || null, + operator: row['current-operator'] || row['operator-name'] || null, + cellId: row['current-cellid'] || null, + enbId: row['enb-id'] || null, + sectorId: row['sector-id'] || null, + phyCellId: row['phy-cellid'] || null, + sessionUptimeSeconds: parseDurationSeconds(row['session-uptime']), + primaryBand: primary[0] ?? null, + caBands, + rssi: parseSignalValue(row['rssi']), + rsrp: parseSignalValue(row['rsrp']), + rsrq: parseSignalValue(row['rsrq']), + sinr: parseSignalValue(row['sinr']), + cqi: parseIntField(row['cqi']), + ri: parseIntField(row['ri']), + mcs: parseIntField(row['mcs']), + dlModulation: row['dl-modulation'] || null, + }; +} + +/** Is the modem attached and carrying traffic? */ +export function isConnected(status: LteStatus): boolean { + const s = (status.status || '').toLowerCase(); + // `running` on this modem family, `connected` on others. + return s === 'running' || s === 'connected'; +} + +// ─── Quality interpretation ─────────────────────────────────────────────────── + +/** + * Signal thresholds. These are the widely used 3GPP-derived operating bands, and + * the whole point of carrying them: `-97 dBm` tells a user nothing, while "fair" + * alongside it tells them where they stand. + */ +export function classifyRsrp(rsrp: number | null): SignalQuality { + if (rsrp == null) return 'unknown'; + if (rsrp >= -80) return 'excellent'; + if (rsrp >= -90) return 'good'; + if (rsrp >= -100) return 'fair'; + return 'poor'; +} + +export function classifyRsrq(rsrq: number | null): SignalQuality { + if (rsrq == null) return 'unknown'; + if (rsrq >= -10) return 'excellent'; + if (rsrq >= -15) return 'good'; + if (rsrq >= -20) return 'fair'; + return 'poor'; +} + +export function classifySinr(sinr: number | null): SignalQuality { + if (sinr == null) return 'unknown'; + if (sinr >= 20) return 'excellent'; + if (sinr >= 13) return 'good'; + if (sinr >= 0) return 'fair'; + return 'poor'; +} + +const QUALITY_RANK: Record, number> = { + poor: 0, fair: 1, good: 2, excellent: 3, +}; + +/** + * Overall link quality. + * + * Deliberately **not** RSRP alone. The field capture that shaped this module + * showed RSRP −97 dBm — "fair" — on a link running 256QAM at CQI 15 with two + * spatial streams, which is the modem at its ceiling. RSRP measures how loud the + * tower is; SINR measures whether the signal is usable, and it is the better + * predictor of throughput. So SINR leads, RSRP moderates, and a modem reporting + * neither stays honest about not knowing. + */ +export function overallQuality(status: LteStatus): SignalQuality { + const sinr = classifySinr(status.sinr); + const rsrp = classifyRsrp(status.rsrp); + if (sinr === 'unknown') return rsrp; + if (rsrp === 'unknown') return sinr; + // SINR sets the grade; a quiet cell pulls it down but only one step, so a + // clean link at −97 dBm reads "good" while a clean link at −112 reads "fair". + const rank = Math.min(QUALITY_RANK[sinr], QUALITY_RANK[rsrp] + 1); + return (Object.keys(QUALITY_RANK) as Exclude[]) + .find(k => QUALITY_RANK[k] === rank) ?? 'unknown'; +} + +/** Plain-language reading of the link, for users who don't speak dBm. */ +export function describeQuality(status: LteStatus): string { + if (!isConnected(status)) return 'Not connected'; + const q = overallQuality(status); + const carriers = status.caBands.length; + const agg = carriers > 0 + ? ` Aggregating ${carriers + 1} carriers.` + : ''; + switch (q) { + case 'excellent': + return `Excellent — the modem is operating near its ceiling.${agg}`; + case 'good': + return `Good — full throughput expected under normal load.${agg}`; + case 'fair': + return `Fair — usable, but expect degradation at peak hours.${agg}`; + case 'poor': + return `Poor — throughput and stability are likely affected.${agg}`; + default: + return `Connected. The modem does not report enough detail to judge quality.${agg}`; + } +} + +// ─── Change detection ───────────────────────────────────────────────────────── + +export type LteChangeKind = 'session-reset' | 'handover' | 'band-change'; + +export interface LteChange { + kind: LteChangeKind; + detail: string; +} + +/** + * Compare consecutive polls to recover what happened between them. + * + * Scanning is not a usable substitute: it costs service to run, and at the site + * that prompted this work it returns nothing because there is only one base + * station within reach. Polling is the vantage point that always exists. + * Two signals carry most of the story: `session-uptime` running *backwards* + * means the modem re-registered, and a changed cell id means it handed over. + */ +export function detectChanges(prev: LteStatus | null, next: LteStatus): LteChange[] { + if (!prev) return []; + const changes: LteChange[] = []; + + const before = prev.sessionUptimeSeconds; + const after = next.sessionUptimeSeconds; + if (before != null && after != null && after < before) { + changes.push({ + kind: 'session-reset', + detail: `Session restarted — uptime fell from ${before}s to ${after}s`, + }); + } + + if (prev.cellId && next.cellId && prev.cellId !== next.cellId) { + changes.push({ + kind: 'handover', + detail: `Cell changed from ${prev.cellId} to ${next.cellId}`, + }); + } + + const bandsOf = (s: LteStatus) => + [s.primaryBand, ...s.caBands].filter(Boolean).map(b => b!.band).sort((a, b) => a - b).join(','); + const bandsBefore = bandsOf(prev); + const bandsAfter = bandsOf(next); + if (bandsBefore && bandsAfter && bandsBefore !== bandsAfter) { + changes.push({ + kind: 'band-change', + detail: `Bands changed from ${bandsBefore} to ${bandsAfter}`, + }); + } + + return changes; +} + +/** + * Bands seen serving this device, for warning before a band lock. + * + * Scanning is not a dependable answer to "is band N available here?". It costs + * service to run, is not offered by every modem, and comes back empty in exactly + * the deployments that most need the warning — the field report behind this code + * is a fixed antenna 10 km from its only base station, where a scan finds nothing + * because there is nothing else to find. + * + * History answers it passively instead: a band the modem has actually used at + * this location demonstrably works there, and locking to one never once observed + * is the shape of a change that strands a device on a mast. + */ +export function observedBands(statuses: LteStatus[]): number[] { + const seen = new Set(); + for (const s of statuses) { + if (s.primaryBand) seen.add(s.primaryBand.band); + for (const c of s.caBands) seen.add(c.band); + } + return [...seen].sort((a, b) => a - b); +} + +/** The carriers alone, so callers reading stored band data need no full status. */ +export type CarrierSet = Pick; + +/** Aggregate spectrum in use: the primary carrier plus every aggregated one. */ +export function totalBandwidthMhz(status: CarrierSet): number { + return [status.primaryBand, ...status.caBands] + .filter(Boolean) + .reduce((mhz, c) => mhz + (c!.bandwidthMhz ?? 0), 0); +} + +export interface UplinkAnchor { + /** Bandwidth of the carrier the uplink actually rides on. */ + primaryMhz: number; + primaryBand: number; + /** The widest carrier being aggregated for downlink. */ + widestMhz: number; + widestBand: number; +} + +/** + * Is the uplink pinned to a narrower carrier than the downlink is using? + * + * Left to itself a modem anchors to whichever band it hears best, then aggregates + * the rest for downlink. That optimises the wrong thing when the loudest band is + * also the narrowest: uplink rides the primary carrier alone, so a 10 MHz anchor + * caps upload however much downlink is stacked on top of it. Operators hit this + * and fix it by excluding the narrow bands, which is the actual reason to lock + * bands — not coverage. + * + * Returns the comparison only when a wider carrier is genuinely being aggregated, + * so a link that is already anchored to its widest carrier says nothing. + */ +export function uplinkAnchor(status: CarrierSet): UplinkAnchor | null { + const primary = status.primaryBand; + if (!primary?.bandwidthMhz) return null; + + let widest = primary; + for (const c of status.caBands) { + if ((c.bandwidthMhz ?? 0) > (widest.bandwidthMhz ?? 0)) widest = c; + } + if (widest === primary) return null; + + return { + primaryMhz: primary.bandwidthMhz, + primaryBand: primary.band, + widestMhz: widest.bandwidthMhz!, + widestBand: widest.band, + }; +} + +/** + * Which of the bands about to be locked have never been seen serving this device? + * An empty result means the lock is supported by evidence. + */ +export function unprovenBands(requested: number[], observed: number[]): number[] { + const known = new Set(observed); + return [...new Set(requested)].filter(b => !known.has(b)).sort((a, b) => a - b); +} diff --git a/frontend/package.json b/frontend/package.json index c56ead0..f03d72e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.11-beta", + "version": "0.23.12-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/LteTab.tsx b/frontend/src/components/device-detail/LteTab.tsx new file mode 100644 index 0000000..84dd903 --- /dev/null +++ b/frontend/src/components/device-detail/LteTab.tsx @@ -0,0 +1,390 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, +} from 'recharts'; +import { Signal, RadioTower, ArrowLeftRight, RotateCcw, Radio, ArrowUpFromLine } from 'lucide-react'; +import { lteApi, type LteInterface, type LteHistoryEvent } from '../../services/api'; + +interface Props { + deviceId: number; +} + +const RANGES = ['1h', '6h', '24h', '7d', '30d'] as const; + +type Quality = NonNullable; + +const QUALITY_STYLE: Record = { + excellent: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', + good: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', + fair: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + poor: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', + unknown: 'bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-slate-300', +}; + +/** + * What the grade means, in words. + * + * A raw figure like −97 dBm tells most people nothing, and taken alone it + * actively misleads: the field capture this was built against showed −97 dBm on + * a link running 256QAM at maximum reported channel quality. SINR drives the + * grade for that reason, with RSRP able to pull it down but not define it. + */ +const QUALITY_TEXT: Record = { + excellent: 'The modem is operating near its ceiling.', + good: 'Full throughput expected under normal load.', + fair: 'Usable, but expect degradation at peak hours.', + poor: 'Throughput and stability are likely affected.', + unknown: 'The modem does not report enough detail to judge quality.', +}; + +function formatUptime(seconds: number | null): string { + if (seconds == null || !Number.isFinite(seconds)) return '—'; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +function formatTime(iso: string, compact: boolean): string { + const d = new Date(iso); + return compact + ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +function Metric({ label, value, unit, hint }: { + label: string; value: number | string | null; unit?: string; hint?: string; +}) { + return ( +

+
{label}
+
+ {value == null || value === '' ? '—' : value} + {value != null && value !== '' && unit + ? {unit} + : null} +
+ {hint &&
{hint}
} +
+ ); +} + +/** Primary carrier plus every aggregated one, which is the shape of the link. */ +function BandChips({ iface }: { iface: LteInterface }) { + const carriers = [ + ...(iface.primary_band ? [{ ...iface.primary_band, primary: true }] : []), + ...iface.ca_bands.map(b => ({ ...b, primary: false })), + ]; + + if (carriers.length === 0) { + return No band reported; + } + + return ( +
+ {carriers.map((c, i) => ( +
+
+ B{c.band} + {c.bandwidthMhz != null && ( + {c.bandwidthMhz} MHz + )} +
+
+ {c.primary ? 'Primary' : 'Aggregated'} + {c.earfcn != null && ` · EARFCN ${c.earfcn}`} +
+
+ ))} +
+ ); +} + +const EVENT_ICON = { + 'handover': ArrowLeftRight, + 'session-reset': RotateCcw, + 'band-change': Radio, +} as const; + +const EVENT_LABEL = { + 'handover': 'Handover', + 'session-reset': 'Session restarted', + 'band-change': 'Bands changed', +} as const; + +function HistoryList({ events }: { events: LteHistoryEvent[] }) { + if (events.length === 0) { + return ( +

+ No tower or band changes recorded in this window — a stable link looks exactly like this. +

+ ); + } + return ( + + ); +} + +export default function LteTab({ deviceId }: Props) { + const [range, setRange] = useState('6h'); + + const { data: state, isLoading } = useQuery({ + queryKey: ['lte', deviceId], + queryFn: () => lteApi.state(deviceId).then(r => r.data), + refetchInterval: 30_000, + }); + + const { data: metrics } = useQuery({ + queryKey: ['lte-metrics', deviceId, range], + queryFn: () => lteApi.metrics(deviceId, range).then(r => r.data), + refetchInterval: 60_000, + }); + + const { data: history } = useQuery({ + queryKey: ['lte-history', deviceId, range], + queryFn: () => lteApi.history(deviceId, range === '1h' || range === '6h' ? '24h' : range) + .then(r => r.data), + refetchInterval: 60_000, + }); + + if (isLoading) { + return
Loading cellular state…
; + } + + const interfaces = state?.interfaces ?? []; + if (interfaces.length === 0) { + return ( +
+ No cellular data yet. It appears after the next poll of an LTE interface. +
+ ); + } + + const compact = range === '1h' || range === '6h'; + const chartData = (metrics?.metrics ?? []).map(p => ({ + time: formatTime(p.time, compact), + rsrp: p.rsrp, sinr: p.sinr, rsrq: p.rsrq, rssi: p.rssi, + })); + + return ( +
+ {interfaces.map(iface => { + const quality = (iface.quality ?? 'unknown') as Quality; + const connected = iface.status === 'running' || iface.status === 'connected'; + const carriers = iface.ca_bands.length + (iface.primary_band ? 1 : 0); + const uptime = iface.session_uptime_s == null ? null : Number(iface.session_uptime_s); + + return ( +
+ {/* Header: what the link is doing, in words before numbers */} +
+
+ +
+
+

+ {iface.interface_name} +

+ + {connected ? quality : 'not connected'} + + {carriers > 1 && ( + + {carriers}× carrier aggregation + {iface.total_bandwidth_mhz > 0 && ` · ${iface.total_bandwidth_mhz} MHz`} + + )} +
+

+ {connected ? QUALITY_TEXT[quality] : 'The modem is not attached to a network.'} +

+

+ {[iface.operator, iface.data_class, iface.modem_model] + .filter(Boolean).join(' · ') || 'Modem details not reported'} +

+
+
+
+
Session up {formatUptime(uptime)}
+
Updated {formatTime(iface.updated_at, true)}
+
+
+ + {/* Signal figures. Each shows only what the modem actually reported. */} +
+ + + + + + +
+ + {/* Bands */} +
+
+ +

Carriers in use

+
+ + + {iface.uplink_anchor && ( +
+ +
+
+ Upload is limited by the primary carrier +
+

+ The uplink runs on B{iface.uplink_anchor.primaryBand} alone at{' '} + {iface.uplink_anchor.primaryMhz} MHz, while B{iface.uplink_anchor.widestBand} is + being aggregated at {iface.uplink_anchor.widestMhz} MHz for download. Left on + automatic, a modem anchors to whichever band it hears loudest rather than the + widest one — excluding the narrow bands moves the anchor and raises upload. +

+
+
+ )} + +
+
+ Allowed by configuration: + + {iface.allowed_bands.length > 0 + ? iface.allowed_bands.map(b => `B${b}`).join(', ') + : 'Any (modem chooses)'} + +
+
+ Seen serving this device: + + {iface.observed_bands.length > 0 + ? iface.observed_bands.map(o => `B${o.band}`).join(', ') + : 'Nothing recorded yet'} + +
+
+ {iface.observed_bands.length > 0 && ( +

+ Observed bands accumulate from polling. They are the evidence a future band + lock will be checked against — scanning costs service to run and comes back + empty where a site has only one base station, so history is the safer source. +

+ )} +
+ + {/* Cell */} +
+ + + + +
+
+ ); + })} + + {/* Signal over time */} +
+
+

Signal over time

+
+ {RANGES.map(r => ( + + ))} +
+
+ + {chartData.length === 0 ? ( +

+ No samples in this window yet. +

+ ) : ( + + + + + {/* dBm and dB share a chart but not a scale. */} + + + + + + + + + + + )} +
+ + {/* Movement */} +
+

Tower and band changes

+

+ Reconstructed by comparing consecutive polls — a session uptime that runs backwards + means the modem re-registered, and a changed cell means it handed over. +

+ +
+
+ ); +} diff --git a/frontend/src/components/wireless/RfHealth.tsx b/frontend/src/components/wireless/RfHealth.tsx index c936735..7da2c84 100644 --- a/frontend/src/components/wireless/RfHealth.tsx +++ b/frontend/src/components/wireless/RfHealth.tsx @@ -45,6 +45,11 @@ function SpectrumBandRow({ band, radios }: { band: RfBandRange; radios: RfChanne const ticks: number[] = []; for (let f = Math.ceil(band.startMhz / step) * step; f < band.endMhz; f += step) ticks.push(f); + // The 2.4 GHz axis is labelled by channel, so a tick outside the channel plan + // (2400 and 2480 both fall there) would print a bare frequency and break the + // sequence — reading "2400, 3, 7, 11, 2480" rather than as channels. + const labelledTicks = band.band === '2.4' ? ticks.filter((f) => channelOf(f) != null) : ticks; + return (
@@ -79,12 +84,18 @@ function SpectrumBandRow({ band, radios }: { band: RfBandRange; radios: RfChanne
- {ticks.map((f) => ( - - {band.band === '2.4' ? (channelOf(f) ?? f) : f} - - ))} + {labelledTicks.map((f) => { + const at = pct(f); + // A centred label on a tick at either extreme hangs half its width past + // the card. Anchor the outermost labels inward instead of centring them. + const anchor = at <= 3 ? 'translateX(0)' : at >= 97 ? 'translateX(-100%)' : 'translateX(-50%)'; + return ( + + {band.band === '2.4' ? (channelOf(f) ?? f) : f} + + ); + })}
); diff --git a/frontend/src/pages/DeviceDetailPage.tsx b/frontend/src/pages/DeviceDetailPage.tsx index 3145aed..4efb594 100644 --- a/frontend/src/pages/DeviceDetailPage.tsx +++ b/frontend/src/pages/DeviceDetailPage.tsx @@ -17,12 +17,13 @@ import ConfigHistoryTab from '../components/device-detail/ConfigHistoryTab'; import HardwareTab from '../components/device-detail/HardwareTab'; import ToolsTab from '../components/device-detail/ToolsTab'; import RadiosTab from '../components/device-detail/RadiosTab'; +import LteTab from '../components/device-detail/LteTab'; import ConnectionsTab from '../components/device-detail/ConnectionsTab'; import QueuesTab from '../components/device-detail/QueuesTab'; import SecurityTab from '../components/device-detail/SecurityTab'; import clsx from 'clsx'; -type TabKey = 'overview' | 'ports' | 'vlans' | 'routing' | 'firewall' | 'security' | 'queues' | 'connections' | 'config' | 'config-history' | 'hardware' | 'tools' | 'radios'; +type TabKey = 'overview' | 'ports' | 'vlans' | 'routing' | 'firewall' | 'security' | 'queues' | 'connections' | 'config' | 'config-history' | 'hardware' | 'tools' | 'radios' | 'lte'; function formatUptime(raw: string): string { if (!raw) return '—'; @@ -50,7 +51,7 @@ export default function DeviceDetailPage() { const queryClient = useQueryClient(); const canWrite = useCanWrite(); const [searchParams] = useSearchParams(); - const VALID_TABS: TabKey[] = ['overview', 'ports', 'vlans', 'routing', 'firewall', 'security', 'queues', 'connections', 'config', 'config-history', 'hardware', 'tools', 'radios']; + const VALID_TABS: TabKey[] = ['overview', 'ports', 'vlans', 'routing', 'firewall', 'security', 'queues', 'connections', 'config', 'config-history', 'hardware', 'tools', 'radios', 'lte']; const requestedTab = searchParams.get('tab') as TabKey | null; const [activeTab, setActiveTab] = useState(requestedTab && VALID_TABS.includes(requestedTab) ? requestedTab : 'overview'); const [autoOpenBridge, setAutoOpenBridge] = useState(null); @@ -125,6 +126,10 @@ export default function DeviceDetailPage() { { key: 'config-history', label: 'Config History' }, { key: 'hardware', label: 'Hardware' }, ...(isWirelessAP ? [{ key: 'radios' as TabKey, label: 'Radios' }] : []), + // Cellular is a property of the hardware, not the device type: a modem turns + // up on routers, CPE and travel gear alike, so the flag is set from the + // interface list rather than inferred (discussion #85). + ...(device.has_lte ? [{ key: 'lte' as TabKey, label: 'LTE' }] : []), { key: 'tools', label: 'Tools' }, ]; @@ -396,6 +401,7 @@ export default function DeviceDetailPage() { {activeTab === 'config-history' && } {activeTab === 'hardware' && } {activeTab === 'radios' && } + {activeTab === 'lte' && } {activeTab === 'tools' && } {showTerminal && ( diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ecb67af..c06bc7e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -212,6 +212,95 @@ export interface ConfigHealthResponse { checked_at: string | null; } + +// ─── LTE / cellular ─────────────────────────────────────────────────────────── + +/** One aggregated carrier as the modem reported it. */ +export interface LteBandInfo { + band: number; + bandwidthMhz: number | null; + earfcn: number | null; + phyCellId: number | null; + raw: string; +} + +export interface LteInterface { + interface_name: string; + status: string | null; + data_class: string | null; + modem_model: string | null; + modem_revision: string | null; + operator: string | null; + cell_id: string | null; + enb_id: string | null; + sector_id: string | null; + phy_cell_id: string | null; + session_uptime_s: string | number | null; + primary_band: LteBandInfo | null; + ca_bands: LteBandInfo[]; + rssi: number | null; + rsrp: number | null; + rsrq: number | null; + sinr: number | null; + cqi: number | null; + rank_indicator: number | null; + mcs: number | null; + dl_modulation: string | null; + quality: 'excellent' | 'good' | 'fair' | 'poor' | 'unknown' | null; + /** Configured allow-list; empty means the modem picks freely. */ + allowed_bands: number[]; + /** Primary plus every aggregated carrier. */ + total_bandwidth_mhz: number; + /** + * Set when the uplink is anchored to a narrower carrier than the downlink is + * aggregating — the reason operators lock bands, and invisible in RouterOS. + */ + uplink_anchor: { + primaryMhz: number; primaryBand: number; + widestMhz: number; widestBand: number; + } | null; + network_mode: string | null; + apn_profiles: string | null; + allow_roaming: boolean | null; + updated_at: string; + /** Bands actually seen serving this device — the evidence behind a band lock. */ + observed_bands: { band: number; observations: number; last_seen: string }[]; +} + +export interface LteHistoryEvent { + interface_name: string; + at: string; + kind: 'session-reset' | 'handover' | 'band-change'; + detail: string | null; + cell_id: string | null; + enb_id: string | null; + bands: string | null; + rsrp: number | null; + sinr: number | null; +} + +export interface LteMetricPoint { + time: string; + interface: string; + rsrp?: number; + rsrq?: number; + sinr?: number; + rssi?: number; + cqi?: number; + carriers?: number; +} + +export const lteApi = { + state: (deviceId: number) => + api.get<{ interfaces: LteInterface[] }>(`/devices/${deviceId}/lte`), + history: (deviceId: number, range = '24h') => + api.get<{ events: LteHistoryEvent[] }>(`/devices/${deviceId}/lte/history`, { params: { range } }), + metrics: (deviceId: number, range = '6h', iface?: string) => + api.get<{ metrics: LteMetricPoint[] }>(`/devices/${deviceId}/lte/metrics`, { + params: { range, ...(iface ? { iface } : {}) }, + }), +}; + export const devicesApi = { list: () => api.get('/devices'), discovered: () => api.get('/devices/discovered'), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2f11888..be4e6c3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -71,6 +71,7 @@ export interface Device { rack_slot?: string; /** Detected wireless role: none | standalone | cap | controller | controller_cap. */ wifi_role?: string | null; + has_lte?: boolean; created_at: string; updated_at?: string; tags?: { id: number; name: string; color: string }[]; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 61e22b8..93bc92a 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.11 Beta'; +export const APP_VERSION = 'v0.23.12 Beta'; diff --git a/scripts/seed-lte-fixture-clean.sql b/scripts/seed-lte-fixture-clean.sql new file mode 100644 index 0000000..79faf78 --- /dev/null +++ b/scripts/seed-lte-fixture-clean.sql @@ -0,0 +1,5 @@ +-- Remove everything seed-lte-fixture.sql created. The device cascades to its +-- interfaces, lte_interfaces, lte_cell_history and lte_observed_bands rows. +DELETE FROM devices WHERE name LIKE 'fixture-lte-%'; +SELECT 'cleaned' AS status, + (SELECT count(*) FROM devices WHERE name LIKE 'fixture-lte-%') AS remaining; diff --git a/scripts/seed-lte-fixture.sql b/scripts/seed-lte-fixture.sql new file mode 100644 index 0000000..1bc1783 --- /dev/null +++ b/scripts/seed-lte-fixture.sql @@ -0,0 +1,87 @@ +-- A cellular device for local development, reproducing a real field capture. +-- +-- The fleet this platform is developed against contains no LTE hardware at all +-- (49 ethernet interfaces, 7 wifi, zero cellular), which is exactly the position +-- CAPsMAN support was written from before two bugs shipped. This seeds the shape +-- of a real modem so the LTE paths can be exercised against actual endpoints. +-- +-- Values come from a MikroTik ATL 18 (Quectel EG18-EA, Cat-18) on VIVACOM, +-- contributed by @trackersoft in discussion #85. See +-- backend/src/utils/__tests__/fixtures/lte-atl18.md for the raw capture. +-- +-- What makes this shape worth reproducing: +-- +-- * RSRP −97 dBm — "fair" on its own — on a link running 256QAM at CQI 15 with +-- two spatial streams, which is the modem at its ceiling. Any UI that grades +-- on RSRP alone gets this device visibly wrong. +-- * a primary carrier plus an aggregated one, so carrier-aggregation rendering +-- is exercised rather than assumed +-- * a configured band allow-list (1,3,7) that is wider than the bands actually +-- observed, which is the state a band-lock warning has to reason about +-- * tower movement history, which cannot be recovered after the fact +-- +-- Everything is prefixed `fixture-` and removed by seed-lte-fixture-clean.sql. + +BEGIN; + +-- A cellular CPE. Deliberately device_type 'router': cellular is a property of +-- the hardware, and gating the LTE tab on device_type would hide it here. +INSERT INTO devices (name, ip_address, api_username, api_password_encrypted, + device_type, status, model, has_lte) +VALUES ('fixture-lte-cpe-001', '10.251.0.1', 'admin', 'x', + 'router', 'online', 'ATL18', TRUE) +ON CONFLICT DO NOTHING; + +-- The interface row is what collectLte keys off, so the modem is only contacted +-- on devices that actually have one. +INSERT INTO interfaces (device_id, name, type, mtu, running, disabled, updated_at) +SELECT id, 'lte1', 'lte', 1500, TRUE, FALSE, NOW() + FROM devices WHERE name = 'fixture-lte-cpe-001' +ON CONFLICT (device_id, name) DO UPDATE SET type = 'lte', running = TRUE; + +-- Current state, verbatim from the capture. +INSERT INTO lte_interfaces ( + device_id, interface_name, status, data_class, modem_model, modem_revision, + operator, cell_id, enb_id, sector_id, phy_cell_id, session_uptime_s, + primary_band, ca_bands, rssi, rsrp, rsrq, sinr, cqi, rank_indicator, mcs, + dl_modulation, quality, allowed_bands, network_mode, apn_profiles, + allow_roaming, updated_at) +SELECT id, 'lte1', 'running', 'LTE', 'EG18-EA', 'EG18EAPAR01A14M4G', + 'VIVACOM', '123456', '1234', '12', '190', 285479, + '{"band":1,"bandwidthMhz":20,"earfcn":500,"phyCellId":190, + "raw":"B1@20Mhz earfcn: 500 phy-cellid: 190"}'::jsonb, + '[{"band":3,"bandwidthMhz":20,"earfcn":1800,"phyCellId":190, + "raw":"B3@20Mhz earfcn: 1800 phy-cellid: 190"}]'::jsonb, + -69, -97, -9, 17, 15, 2, 20, + '256qam', 'good', '1,3,7', 'lte', 'default', FALSE, NOW() + FROM devices WHERE name = 'fixture-lte-cpe-001' +ON CONFLICT (device_id, interface_name) DO UPDATE SET updated_at = NOW(); + +-- Bands seen serving this device. Note B7 is *allowed* but has never been +-- observed — locking to it is the case a future pre-flight has to catch. +INSERT INTO lte_observed_bands (device_id, interface_name, band, observations, first_seen, last_seen) +SELECT d.id, 'lte1', b.band, b.n, NOW() - INTERVAL '6 days', NOW() + FROM devices d, (VALUES (1, 4120), (3, 3980)) AS b(band, n) + WHERE d.name = 'fixture-lte-cpe-001' +ON CONFLICT (device_id, interface_name, band) DO NOTHING; + +-- Movement over the last day: a handover, a re-registration, and an aggregated +-- carrier dropping away and returning. +INSERT INTO lte_cell_history (device_id, interface_name, at, kind, detail, cell_id, enb_id, bands, rsrp, sinr) +SELECT d.id, 'lte1', NOW() - h.ago, h.kind, h.detail, h.cell, '1234', h.bands, h.rsrp, h.sinr + FROM devices d, (VALUES + (INTERVAL '35 minutes', 'band-change', 'Bands changed from 1 to 1,3', '123456', '1,3', -97::real, 17::real), + (INTERVAL '2 hours', 'band-change', 'Bands changed from 1,3 to 1', '123456', '1', -101::real, 12::real), + (INTERVAL '7 hours', 'handover', 'Cell changed from 998877 to 123456', '123456', '1,3', -95::real, 18::real), + (INTERVAL '7 hours', 'session-reset', 'Session restarted — uptime fell from 210433s to 12s', + '998877', '1', -104::real, 9::real), + (INTERVAL '19 hours', 'handover', 'Cell changed from 123456 to 998877', '998877', '1', -103::real, 10::real) + ) AS h(ago, kind, detail, cell, bands, rsrp, sinr) + WHERE d.name = 'fixture-lte-cpe-001'; + +COMMIT; + +SELECT 'seeded' AS status, + (SELECT count(*) FROM lte_interfaces) AS lte_interfaces, + (SELECT count(*) FROM lte_cell_history) AS history_rows, + (SELECT count(*) FROM lte_observed_bands) AS observed_bands;