-
+
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
+ );
+}
+
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.
+