feat(lte): time-on-band and time-on-cell statistics (v0.23.33-beta)

Closes #120.

The Tower and band changes panel listed every handover, newest first, growing
without bound. On a link that hands over several times a day it reads as a wall
of near-identical rows — it answers what happened, but not what the pattern is,
which is the question an operator actually has.

Dwell totals now sit above it: how long the modem has spent on each band and on
each cell, over 24 hours, 7 days or 30 days, ordered by time spent so the answer
is the first row rather than something to be worked out. Visit counts sit
alongside, because six brief stays and one long one are different situations
that share a total.

No new collection. History is already written on every change, so dwell is the
interval between consecutive events — the same reconstruction used for WiFi
roaming sessions.

Three decisions worth recording.

**The state in force when the window opened is included.** 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. The
last event *before* the window is fetched specifically to anchor that first
stretch.

**Aggregated carriers each count for the whole stretch.** A period 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 it between them. Totals can
exceed 100%, which is the honest description of carrier aggregation rather than a
rounding artefact.

**Absence is reported as absence.** A segment whose cell was never reported is
excluded rather than attributed to "unknown", a device with no history reads as
"no changes recorded" rather than an empty panel, and a window longer than our
records says so instead of implying the quiet period was real.

Logic lives in utils/lteDwell.ts with 17 tests. Verified end to end against
seeded history matching the reporter's own pattern: a 24-hour window covered
exactly 24 hours, B3 correctly showed 19 of them against a five-hour stretch
without it, cell totals summed to the window, and a 30-day request over 9 days of
records reported itself truncated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-09-06 09:30:07 -07:00
co-authored by Claude Opus 5
parent 33ca7cc651
commit 6c5b8ac21a
10 changed files with 578 additions and 13 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-0.23.32_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/version-0.23.33_Beta-blue" alt="Version" />
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.23.32-beta",
"version": "0.23.33-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
+84
View File
@@ -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<string, number> = { 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<string, string> = {
'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 };
@@ -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');
});
});
+177
View File
@@ -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<string, { seconds: number; visits: number; lastSeen: Date; label: string }>();
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<string, { seconds: number; visits: number; lastSeen: Date }>();
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`;
}
+20
View File
@@ -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,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.23.32-beta",
"version": "0.23.33-beta",
"private": true,
"type": "module",
"scripts": {
@@ -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 <p className="text-xs text-gray-500 dark:text-slate-400">{emptyText}</p>;
}
const widest = Math.max(...rows.map(r => r.seconds), 1);
return (
<ul className="space-y-2">
{rows.map(r => (
<li key={r.key}>
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className="font-medium text-gray-900 dark:text-white truncate">{r.label}</span>
<span className="tabular-nums text-gray-600 dark:text-slate-300 shrink-0">
{fmtDwell(r.seconds)} · {r.pct}%
</span>
</div>
<div className="mt-0.5 h-1.5 rounded-full bg-gray-100 dark:bg-slate-700 overflow-hidden">
<div className="h-full rounded-full bg-blue-500"
style={{ width: `${Math.max(2, (r.seconds / widest) * 100)}%` }} />
</div>
{/* Six brief visits and one long stay are different situations. */}
<div className="mt-0.5 text-[11px] text-gray-400 dark:text-slate-500">
{r.visits} {r.visits === 1 ? 'stretch' : 'stretches'}
</div>
</li>
))}
</ul>
);
}
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<string>('6h');
const [dwellRange, setDwellRange] = useState<string>('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) {
)}
</div>
{/* Movement */}
{/* Where the link actually spends its time */}
<div className="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">Tower and band changes</h3>
<p className="text-xs text-gray-500 dark:text-slate-400 mb-3">
Reconstructed by comparing consecutive polls — a session uptime that runs backwards
means the modem re-registered, and a changed cell means it handed over.
</p>
<HistoryList events={history?.events ?? []} />
<div className="flex items-start justify-between gap-3 flex-wrap mb-3">
<div className="flex items-start gap-3">
<Clock className="w-5 h-5 mt-0.5 text-gray-400 dark:text-slate-500" />
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">Where this link spends its time</h3>
<p className="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
Reconstructed from handovers and band changes. Aggregated carriers each count
for the whole stretch, so band shares describe usage rather than dividing the window.
</p>
</div>
</div>
<div className="flex gap-1">
{(['24h', '7d', '30d'] as const).map(r => (
<button key={r} onClick={() => setDwellRange(r)}
className={`px-2 py-1 text-xs rounded font-medium transition-colors ${
dwellRange === r
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-600'
}`}>{r}</button>
))}
</div>
</div>
{dwell && dwell.truncated && (
<p className="mb-3 text-[11px] text-amber-700 dark:text-amber-400">
Records only begin {new Date(dwell.earliest_record!).toLocaleDateString()}, so this
covers less than the full {dwellRange}.
</p>
)}
<div className="grid sm:grid-cols-2 gap-6">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">By band</h4>
<DwellList rows={dwell?.bands ?? []}
emptyText="No band changes recorded yet — the modem has stayed on one set of carriers." />
</div>
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">By cell</h4>
<DwellList rows={dwell?.cells ?? []}
emptyText="No handovers recorded yet — the modem has stayed on one cell." />
</div>
</div>
{/* The event stream, folded away. It grows without bound, which is what
made it hard to read as the primary view. */}
<button
onClick={() => setShowEvents(v => !v)}
className="mt-4 flex items-center gap-1.5 text-xs text-gray-600 dark:text-slate-300 hover:text-gray-900 dark:hover:text-white"
>
{showEvents ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
{showEvents ? 'Hide' : 'Show'} individual changes
{dwell ? ` (${dwell.changes} in ${dwellRange})` : ''}
</button>
{showEvents && (
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-slate-700">
<p className="text-xs text-gray-500 dark:text-slate-400 mb-2">
A session uptime that runs backwards means the modem re-registered; a changed cell
means it handed over.
</p>
<HistoryList events={history?.events ?? []} />
</div>
)}
</div>
</div>
);
+18
View File
@@ -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<LteDwell>(`/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) =>
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.23.32 Beta';
export const APP_VERSION = 'v0.23.33 Beta';