diff --git a/README.md b/README.md index 0269e23..dea4e8c 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 9d7613d..b9f8272 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.32-beta", + "version": "0.23.33-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 1c12369..551c2be 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -20,6 +20,14 @@ import { isMultiVlanSpec } from '../utils/vlan'; import { redis } from '../config/redis'; import { enqueueBulkAddJob, getBulkAddJobState } from '../services/DeviceBulkAddWorker'; import { logSafe } from '../utils/logSafe'; +import { buildSegments, summarise, summariseBands } from '../utils/lteDwell'; + +/** Postgres interval text to milliseconds, for the few windows we offer. */ +function intervalToMs(interval: string): number { + const [n, unit] = interval.split(' '); + const per: Record = { hours: 3_600_000, days: 86_400_000 }; + return (Number(n) || 0) * (per[unit] ?? 0); +} import { parseBandList, uplinkAnchor, totalBandwidthMhz, type LteBandInfo } from '../utils/lte'; // Resolve a credential preset id into decrypted credentials. Returns null if @@ -2598,6 +2606,82 @@ router.get('/:id/lte/history', async (req: Request, res: Response) => { } }); +/** + * GET /api/devices/:id/lte/dwell — time spent on each band and each cell. + * + * The history panel lists every handover and grows without bound; this answers + * the question that list cannot, which is what the pattern actually is (#120). + */ +router.get('/:id/lte/dwell', async (req: Request, res: Response) => { + const { range = '7d', iface } = req.query as { range?: string; iface?: string }; + const windows: Record = { + '24h': '24 hours', '7d': '7 days', '30d': '30 days', + }; + const window = windows[range] || '7 days'; + + try { + const rows = await query<{ + interface_name: string; at: string; cell_id: string | null; + enb_id: string | null; bands: string | null; + }>( + `SELECT interface_name, at, cell_id, enb_id, bands + FROM lte_cell_history + WHERE device_id = $1 AND at > NOW() - $2::interval + AND ($3::text IS NULL OR interface_name = $3) + ORDER BY at ASC`, + [req.params.id, window, iface ?? null] + ); + + // The state in force when the window opened. Without it, a link that changed + // once yesterday would report a few minutes of dwell across seven days, + // having silently dropped everything before the first change in range. + const prior = await queryOne<{ + interface_name: string; at: string; cell_id: string | null; + enb_id: string | null; bands: string | null; + }>( + `SELECT interface_name, at, cell_id, enb_id, bands + FROM lte_cell_history + WHERE device_id = $1 AND at <= NOW() - $2::interval + AND ($3::text IS NULL OR interface_name = $3) + ORDER BY at DESC LIMIT 1`, + [req.params.id, window, iface ?? null] + ); + + const [bounds] = await query<{ earliest: string | null }>( + `SELECT MIN(at) AS earliest FROM lte_cell_history WHERE device_id = $1`, + [req.params.id] + ); + + const now = new Date(); + const windowStart = new Date(now.getTime() - intervalToMs(window)); + const toEvent = (r: { at: string; cell_id: string | null; enb_id: string | null; bands: string | null }) => + ({ at: new Date(r.at), cellId: r.cell_id, enbId: r.enb_id, bands: r.bands }); + + const segments = buildSegments( + rows.map(toEvent), now, windowStart, prior ? toEvent(prior) : null + ); + const measuredSeconds = segments.reduce((n, s) => n + s.seconds, 0); + + res.json({ + range, + // Stated so the UI can be honest about a window longer than our records. + measured_seconds: measuredSeconds, + earliest_record: bounds?.earliest ?? null, + truncated: Boolean(bounds?.earliest && new Date(bounds.earliest) > windowStart), + changes: rows.length, + bands: summariseBands(segments, measuredSeconds), + cells: summarise( + segments, + (s) => s.cellId, + (key, s) => (s.enbId ? `Cell ${key} · eNB ${s.enbId}` : `Cell ${key}`) + ), + }); + } catch (error) { + console.error('Error building LTE dwell stats:', error); + res.status(500).json({ error: 'Failed to build dwell statistics' }); + } +}); + // 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 }; diff --git a/backend/src/utils/__tests__/lteDwell.test.ts b/backend/src/utils/__tests__/lteDwell.test.ts new file mode 100644 index 0000000..3d749a6 --- /dev/null +++ b/backend/src/utils/__tests__/lteDwell.test.ts @@ -0,0 +1,153 @@ +import { + buildSegments, summarise, summariseBands, expandBands, formatDuration, + type CellEvent, +} from '../lteDwell'; + +const T = (iso: string) => new Date(iso); +const ev = (at: string, cellId: string | null, bands: string | null, enbId: string | null = 'E1'): CellEvent => + ({ at: T(at), cellId, enbId, bands }); + +const NOW = T('2026-09-06T12:00:00Z'); + +describe('buildSegments', () => { + it('runs each state until the next change', () => { + const segs = buildSegments([ + ev('2026-09-06T09:00:00Z', 'A', '1'), + ev('2026-09-06T10:00:00Z', 'B', '1,3'), + ], NOW); + expect(segs).toHaveLength(2); + expect(segs[0]).toMatchObject({ cellId: 'A', seconds: 3600, open: false }); + expect(segs[1]).toMatchObject({ cellId: 'B', seconds: 7200, open: true }); + }); + + it('leaves the final segment open, since it is still accruing', () => { + const segs = buildSegments([ev('2026-09-06T11:30:00Z', 'A', '1')], NOW); + expect(segs[0].open).toBe(true); + expect(segs[0].seconds).toBe(1800); + }); + + it('credits the window before the first change to the state then in force', () => { + // Without this, a link that changed once at 11:00 would report only one hour + // of dwell across a twelve-hour window — most of it silently dropped. + const segs = buildSegments( + [ev('2026-09-06T11:00:00Z', 'B', '3')], + NOW, + T('2026-09-06T00:00:00Z'), + ev('2026-09-05T22:00:00Z', 'A', '1'), + ); + expect(segs).toHaveLength(2); + expect(segs[0]).toMatchObject({ cellId: 'A', seconds: 39600 }); + expect(segs[1]).toMatchObject({ cellId: 'B', seconds: 3600 }); + }); + + it('sorts events that arrive out of order', () => { + const segs = buildSegments([ + ev('2026-09-06T10:00:00Z', 'B', '1'), + ev('2026-09-06T09:00:00Z', 'A', '1'), + ], NOW); + expect(segs.map((s) => s.cellId)).toEqual(['A', 'B']); + }); + + it('discards a state that never actually held', () => { + // Two changes in the same second describe a transient, not a stretch of + // time; counting it would inflate the visit count for that cell. + const segs = buildSegments([ + ev('2026-09-06T09:00:00Z', 'A', '1'), + ev('2026-09-06T09:00:00Z', 'B', '1'), + ev('2026-09-06T10:00:00Z', 'C', '1'), + ], NOW); + expect(segs.map((s) => s.cellId)).toEqual(['B', 'C']); + }); + + it('returns nothing when there is no history at all', () => { + expect(buildSegments([], NOW)).toEqual([]); + }); +}); + +describe('summarise', () => { + const segs = buildSegments([ + ev('2026-09-06T06:00:00Z', 'A', '1'), + ev('2026-09-06T08:00:00Z', 'B', '1'), + ev('2026-09-06T09:00:00Z', 'A', '1'), + ], NOW); + + it('adds up separate visits to the same cell', () => { + const totals = summarise(segs, (s) => s.cellId); + const a = totals.find((t) => t.key === 'A')!; + expect(a.seconds).toBe(7200 + 10800); + expect(a.visits).toBe(2); + }); + + it('orders by time spent, not by recency', () => { + expect(summarise(segs, (s) => s.cellId).map((t) => t.key)).toEqual(['A', 'B']); + }); + + it('reports each share of the measured time', () => { + const totals = summarise(segs, (s) => s.cellId); + expect(totals.reduce((n, t) => n + t.pct, 0)).toBeCloseTo(100, 0); + }); + + it('ignores segments where the attribute was never reported', () => { + // Attributing an unreported cell to "unknown" would invent a state the + // modem never described. + const withGap = buildSegments([ + ev('2026-09-06T09:00:00Z', null, '1'), + ev('2026-09-06T10:00:00Z', 'A', '1'), + ], NOW); + expect(summarise(withGap, (s) => s.cellId).map((t) => t.key)).toEqual(['A']); + }); + + it('tracks when each was last in use', () => { + const a = summarise(segs, (s) => s.cellId).find((t) => t.key === 'A')!; + expect(a.lastSeen).toEqual(NOW); + }); +}); + +describe('summariseBands', () => { + it('credits both bands of an aggregated pair for the whole stretch', () => { + // "1,3" means B1 and B3 were in use *simultaneously*, so each gets the full + // duration — percentages describe usage, not a division of the window. + const segs = buildSegments([ev('2026-09-06T11:00:00Z', 'A', '1,3')], NOW); + const bands = summariseBands(segs, 3600); + expect(bands.map((b) => b.label).sort()).toEqual(['B1', 'B3']); + expect(bands.every((b) => b.seconds === 3600)).toBe(true); + expect(bands.every((b) => b.pct === 100)).toBe(true); + }); + + it('adds a band up across the stretches it appeared in', () => { + const segs = buildSegments([ + ev('2026-09-06T10:00:00Z', 'A', '1,3'), + ev('2026-09-06T11:00:00Z', 'A', '1'), + ], NOW); + const b1 = summariseBands(segs, 7200).find((b) => b.label === 'B1')!; + const b3 = summariseBands(segs, 7200).find((b) => b.label === 'B3')!; + expect(b1.seconds).toBe(7200); + expect(b3.seconds).toBe(3600); + }); + + it('handles a segment with no band recorded', () => { + const segs = buildSegments([ev('2026-09-06T11:00:00Z', 'A', null)], NOW); + expect(summariseBands(segs, 3600)).toEqual([]); + }); +}); + +describe('expandBands', () => { + it('splits and trims', () => { + expect(expandBands('1, 3 ,7')).toEqual(['1', '3', '7']); + }); + it('copes with nothing', () => { + expect(expandBands(null)).toEqual([]); + expect(expandBands('')).toEqual([]); + }); +}); + +describe('formatDuration', () => { + it('reads the way a person would say it', () => { + expect(formatDuration(45)).toBe('45s'); + expect(formatDuration(600)).toBe('10m'); + expect(formatDuration(3600)).toBe('1h'); + expect(formatDuration(5400)).toBe('1h 30m'); + expect(formatDuration(86400)).toBe('1d'); + expect(formatDuration(187200)).toBe('2d 4h'); + }); +}); diff --git a/backend/src/utils/lteDwell.ts b/backend/src/utils/lteDwell.ts new file mode 100644 index 0000000..ea4a8c1 --- /dev/null +++ b/backend/src/utils/lteDwell.ts @@ -0,0 +1,177 @@ +/** + * How long a modem has spent on each band and each cell. + * + * The Tower and band changes panel lists every handover, newest first, and grows + * without bound — it answers "what happened" but not "what is the pattern", + * which is the question an operator actually has (#120). + * + * History is recorded on *change*, so each row marks the start of a state that + * runs until the next row, or until now for the last one. That makes dwell a + * matter of pairing consecutive events, the same reconstruction already used for + * WiFi roaming sessions. + */ + +export interface CellEvent { + at: Date; + cellId: string | null; + enbId: string | null; + /** Comma-separated band numbers as recorded, e.g. "1,3". */ + bands: string | null; +} + +export interface DwellSegment { + start: Date; + end: Date; + seconds: number; + cellId: string | null; + enbId: string | null; + bands: string | null; + /** True for the segment still in progress, whose length keeps growing. */ + open: boolean; +} + +export interface DwellTotal { + key: string; + label: string; + seconds: number; + /** Share of the measured window, 0–100. */ + pct: number; + /** How many separate stretches contributed — 6 short visits differ from 1 long one. */ + visits: number; + lastSeen: Date; +} + +/** + * Turn change events into the stretches between them. + * + * `priorState`, when supplied, is the last event *before* the window: without it + * the time from the window opening until the first change inside it would be + * dropped, which on a stable link is most of the window. + */ +export function buildSegments( + events: CellEvent[], + now: Date, + windowStart?: Date, + priorState?: CellEvent | null, +): DwellSegment[] { + const ordered = [...events].sort((a, b) => a.at.getTime() - b.at.getTime()); + + // The state in force when the window opened, if we know it. + if (priorState && windowStart) { + ordered.unshift({ ...priorState, at: windowStart }); + } + if (ordered.length === 0) return []; + + const segments: DwellSegment[] = []; + for (let i = 0; i < ordered.length; i++) { + const start = ordered[i].at; + const next = ordered[i + 1]; + const end = next ? next.at : now; + const seconds = Math.max(0, Math.round((end.getTime() - start.getTime()) / 1000)); + // A change recorded at the same second as the next one describes a state + // that never really held; counting it would inflate the visit count. + if (seconds === 0 && next) continue; + segments.push({ + start, end, seconds, + cellId: ordered[i].cellId, + enbId: ordered[i].enbId, + bands: ordered[i].bands, + open: !next, + }); + } + return segments; +} + +/** Sum dwell by a chosen attribute, largest first. */ +export function summarise( + segments: DwellSegment[], + pick: (s: DwellSegment) => string | null, + label?: (key: string, s: DwellSegment) => string, +): DwellTotal[] { + const totals = new Map(); + + for (const s of segments) { + const key = pick(s); + // A segment whose attribute was never reported tells us nothing about that + // attribute; attributing it to "unknown" would be inventing a state. + if (key == null || key === '') continue; + const existing = totals.get(key); + if (existing) { + existing.seconds += s.seconds; + existing.visits += 1; + if (s.end > existing.lastSeen) existing.lastSeen = s.end; + } else { + totals.set(key, { + seconds: s.seconds, visits: 1, lastSeen: s.end, + label: label ? label(key, s) : key, + }); + } + } + + const measured = [...totals.values()].reduce((n, t) => n + t.seconds, 0); + return [...totals.entries()] + .map(([key, t]) => ({ + key, + label: t.label, + seconds: t.seconds, + pct: measured > 0 ? Math.round((t.seconds / measured) * 1000) / 10 : 0, + visits: t.visits, + lastSeen: t.lastSeen, + })) + .sort((a, b) => b.seconds - a.seconds); +} + +/** Each individual band, so an aggregated pair counts toward both. */ +export function expandBands(bands: string | null): string[] { + if (!bands) return []; + return bands.split(',').map((b) => b.trim()).filter(Boolean); +} + +/** + * Dwell per individual band. + * + * A segment on "1,3" is time spent on B1 *and* on B3 simultaneously, so both are + * credited the full duration. Percentages therefore describe how much of the + * window each band was in use, not a division of it, and can exceed 100 in + * total — which is the honest description of carrier aggregation. + */ +export function summariseBands(segments: DwellSegment[], windowSeconds: number): DwellTotal[] { + const totals = new Map(); + + for (const s of segments) { + for (const band of expandBands(s.bands)) { + const existing = totals.get(band); + if (existing) { + existing.seconds += s.seconds; + existing.visits += 1; + if (s.end > existing.lastSeen) existing.lastSeen = s.end; + } else { + totals.set(band, { seconds: s.seconds, visits: 1, lastSeen: s.end }); + } + } + } + + return [...totals.entries()] + .map(([band, t]) => ({ + key: band, + label: `B${band}`, + seconds: t.seconds, + pct: windowSeconds > 0 ? Math.round((t.seconds / windowSeconds) * 1000) / 10 : 0, + visits: t.visits, + lastSeen: t.lastSeen, + })) + .sort((a, b) => b.seconds - a.seconds); +} + +/** Human duration, e.g. "2d 4h" or "18m". */ +export function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + const remM = m % 60; + if (h < 24) return remM ? `${h}h ${remM}m` : `${h}h`; + const d = Math.floor(h / 24); + const remH = h % 24; + return remH ? `${d}d ${remH}h` : `${d}d`; +} diff --git a/docs/cellular.md b/docs/cellular.md index 624218e..083e60b 100644 --- a/docs/cellular.md +++ b/docs/cellular.md @@ -77,6 +77,26 @@ cell id means it handed over. This is deliberately not log-based. The modem this was built for may log nothing at all, so polling is the vantage point that always exists. +## Where the link spends its time + +Handovers and band changes are also aggregated into dwell totals — how long the +modem has spent on each band and on each cell — over 24 hours, 7 days or 30 days. + +Two properties are worth understanding: + +**Aggregated carriers each count for the whole stretch.** A period spent on B1 +and B3 together credits both with the full duration, so band shares describe how +much of the window each band was *in use* rather than dividing the window between +them. The totals can therefore exceed 100%, which is the honest description of +carrier aggregation. + +**The window is bounded by when collection began.** History is written on change, +so the panel says plainly when records start later than the range you asked for. + +The individual change events remain available underneath, folded away. They +answer a narrower question — what happened, and when — and grow without bound, +which is why they are no longer the primary view. + ## Data-cap reset SMS Several European carriers sell "unlimited" data that is throttled past a daily allowance, diff --git a/frontend/package.json b/frontend/package.json index 327d60c..a6cf3d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.32-beta", + "version": "0.23.33-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/LteTab.tsx b/frontend/src/components/device-detail/LteTab.tsx index 43ef5d8..4a9b99e 100644 --- a/frontend/src/components/device-detail/LteTab.tsx +++ b/frontend/src/components/device-detail/LteTab.tsx @@ -3,8 +3,8 @@ 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'; +import { Signal, RadioTower, ArrowLeftRight, RotateCcw, Radio, ArrowUpFromLine, Clock, ChevronDown, ChevronRight } from 'lucide-react'; +import { lteApi, type LteInterface, type LteHistoryEvent, type LteDwellTotal } from '../../services/api'; import DataCapCard from './DataCapCard'; interface Props { @@ -111,6 +111,53 @@ function BandChips({ iface }: { iface: LteInterface }) { ); } +/** Human duration, matching how the backend phrases it. */ +function fmtDwell(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`; + const d = Math.floor(h / 24); + return h % 24 ? `${d}d ${h % 24}h` : `${d}d`; +} + +/** + * Dwell totals as proportion bars. + * + * The event list this sits above answers "what happened"; a reader wants "where + * does this link actually live". Ordered by time spent, so the answer is the + * first row rather than something to be worked out (#120). + */ +function DwellList({ rows, emptyText }: { rows: LteDwellTotal[]; emptyText: string }) { + if (rows.length === 0) { + return

{emptyText}

; + } + const widest = Math.max(...rows.map(r => r.seconds), 1); + return ( + + ); +} + const EVENT_ICON = { 'handover': ArrowLeftRight, 'session-reset': RotateCcw, @@ -163,6 +210,10 @@ function HistoryList({ events }: { events: LteHistoryEvent[] }) { export default function LteTab({ deviceId }: Props) { const [range, setRange] = useState('6h'); + const [dwellRange, setDwellRange] = useState('7d'); + // The raw event list is kept, but folded away: it answers a different and + // narrower question than the totals above it (#120). + const [showEvents, setShowEvents] = useState(false); const { data: state, isLoading } = useQuery({ queryKey: ['lte', deviceId], @@ -176,6 +227,12 @@ export default function LteTab({ deviceId }: Props) { refetchInterval: 60_000, }); + const { data: dwell } = useQuery({ + queryKey: ['lte-dwell', deviceId, dwellRange], + queryFn: () => lteApi.dwell(deviceId, dwellRange).then(r => r.data), + refetchInterval: 120_000, + }); + const { data: history } = useQuery({ queryKey: ['lte-history', deviceId, range], queryFn: () => lteApi.history(deviceId, range === '1h' || range === '6h' ? '24h' : range) @@ -382,14 +439,70 @@ export default function LteTab({ deviceId }: Props) { )} - {/* Movement */} + {/* Where the link actually spends its time */}
-

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

- +
+
+ +
+

Where this link spends its time

+

+ Reconstructed from handovers and band changes. Aggregated carriers each count + for the whole stretch, so band shares describe usage rather than dividing the window. +

+
+
+
+ {(['24h', '7d', '30d'] as const).map(r => ( + + ))} +
+
+ + {dwell && dwell.truncated && ( +

+ Records only begin {new Date(dwell.earliest_record!).toLocaleDateString()}, so this + covers less than the full {dwellRange}. +

+ )} + +
+
+

By band

+ +
+
+

By cell

+ +
+
+ + {/* The event stream, folded away. It grows without bound, which is what + made it hard to read as the primary view. */} + + {showEvents && ( +
+

+ A session uptime that runs backwards means the modem re-registered; a changed cell + means it handed over. +

+ +
+ )}
); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ab4c419..18a0bd1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -290,9 +290,27 @@ export interface LteMetricPoint { carriers?: number; } + +export interface LteDwellTotal { + key: string; label: string; seconds: number; pct: number; visits: number; lastSeen: string; +} + +export interface LteDwell { + range: string; + measured_seconds: number; + earliest_record: string | null; + /** True when our records start later than the requested window. */ + truncated: boolean; + changes: number; + bands: LteDwellTotal[]; + cells: LteDwellTotal[]; +} + export const lteApi = { state: (deviceId: number) => api.get<{ interfaces: LteInterface[] }>(`/devices/${deviceId}/lte`), + dwell: (deviceId: number, range = '7d') => + api.get(`/devices/${deviceId}/lte/dwell`, { params: { range } }), history: (deviceId: number, range = '24h') => api.get<{ events: LteHistoryEvent[] }>(`/devices/${deviceId}/lte/history`, { params: { range } }), metrics: (deviceId: number, range = '6h', iface?: string) => diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 70c8fe2..a78ff10 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.32 Beta'; +export const APP_VERSION = 'v0.23.33 Beta';