feat(lte): cellular telemetry, band details and roaming history (v0.23.12-beta)

Adds LTE support, requested in discussion #85 by a contributor running a
MikroTik ATL 18 on a fixed antenna. Cellular was previously a blank page: the
only occurrence of "lte" in the tree excluded LTE interfaces from DHCP binding.

Built against two real field captures rather than documentation, which
disagreed with the hardware in several places — the modem reports `data-class`
not `access-technology`, `iccid` not `uicc`, `status: running` not `connected`,
embeds units inside values (`-97dBm`, `17dB`), and omits `registration-status`,
`pin-status` and `lac`/`tac` entirely. What an LTE interface reports depends on
the modem chipset, not on RouterOS, so every field is optional by construction.

Fixes a latent bug in the API client while it was in reach. A sentence carrying
the same attribute twice kept only the last value, and a Cat-18 modem reports
one `ca-band` per aggregated carrier. On the contributor's 4-carrier capture
that yielded B3 + B20 — 30 MHz — on a device actually running 70 MHz, because
the narrowest carrier happens to arrive last. Repeats are now preserved
alongside the flat value, so existing callers are byte-for-byte unaffected.

Signal quality is graded rather than merely displayed. RSRP alone misleads: the
capture shows −97 dBm, nominally "fair", on a link running 256QAM at CQI 15 with
two spatial streams. SINR leads the grade and RSRP moderates it.

Surfaces the uplink anchor, which no MikroTik UI shows. Left on automatic a
modem anchors to whichever band it hears loudest, often a narrow one, and
aggregates wider bands for downlink only — but uplink rides the primary carrier
alone, so upload stays capped. This is the contributor's actual reason for
locking bands, and it reframes band selection as anchor control rather than
coverage.

Band changes, tower handovers and re-registrations are reconstructed by
comparing consecutive polls, since scanning interrupts service and finds nothing
at a site served by a single base station. Observed bands accumulate as the
evidence a future band lock will be checked against.

Band selection itself is deliberately absent: it needs Change Guard plus a
free-space check, as the target hardware has 1.1 MB free of 16 MB flash.

Also fixes an unrelated display bug in the RF Health channel map. The 2.4 GHz
axis is labelled by channel, but ticks at 2400 and 2480 fall outside the channel
plan and fell back to printing raw frequencies — and the 2400 label sat at
exactly 0% with a centring transform, hanging half its width outside the card.
2.4 GHz is the only band with a tick at an edge. Unlabelled ticks are now
dropped and edge labels anchor inward.

  - backend/src/utils/lte.ts — parsing, quality, change detection (59 tests)
  - RouterOSClient — repeated attributes preserved (6 tests)
  - lte_interfaces, lte_cell_history, lte_observed_bands
  - GET /devices/:id/lte, /lte/history, /lte/metrics
  - LteTab, gated on has_lte from the interface list
  - scripts/seed-lte-fixture.sql — a cellular device for local development

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-23 13:06:28 -07:00
co-authored by Claude Opus 5
parent 57d650c159
commit 2625fea8de
19 changed files with 2153 additions and 15 deletions
@@ -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<LteInterface['quality']>;
const QUALITY_STYLE: Record<Quality, string> = {
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<Quality, string> = {
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 (
<div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div className="text-xs text-gray-500 dark:text-slate-400">{label}</div>
<div className="text-lg font-semibold text-gray-900 dark:text-white tabular-nums">
{value == null || value === '' ? '—' : value}
{value != null && value !== '' && unit
? <span className="text-xs font-normal text-gray-500 dark:text-slate-400 ml-1">{unit}</span>
: null}
</div>
{hint && <div className="text-[11px] text-gray-400 dark:text-slate-500 mt-0.5">{hint}</div>}
</div>
);
}
/** 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 <span className="text-sm text-gray-500 dark:text-slate-400">No band reported</span>;
}
return (
<div className="flex flex-wrap gap-2">
{carriers.map((c, i) => (
<div
key={`${c.band}-${c.earfcn ?? i}`}
className={`rounded-lg border px-3 py-1.5 ${
c.primary
? 'border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30'
: 'border-gray-200 bg-gray-50 dark:border-slate-700 dark:bg-slate-800'
}`}
>
<div className="flex items-baseline gap-2">
<span className="font-semibold text-gray-900 dark:text-white">B{c.band}</span>
{c.bandwidthMhz != null && (
<span className="text-xs text-gray-600 dark:text-slate-300">{c.bandwidthMhz} MHz</span>
)}
</div>
<div className="text-[11px] text-gray-500 dark:text-slate-400">
{c.primary ? 'Primary' : 'Aggregated'}
{c.earfcn != null && ` · EARFCN ${c.earfcn}`}
</div>
</div>
))}
</div>
);
}
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 (
<p className="text-sm text-gray-500 dark:text-slate-400">
No tower or band changes recorded in this window — a stable link looks exactly like this.
</p>
);
}
return (
<ul className="space-y-2">
{events.map((e, i) => {
const Icon = EVENT_ICON[e.kind] ?? Radio;
return (
<li key={i} className="flex gap-3 text-sm">
<Icon className="w-4 h-4 mt-0.5 shrink-0 text-gray-400 dark:text-slate-500" />
<div className="min-w-0">
<span className="font-medium text-gray-900 dark:text-white">
{EVENT_LABEL[e.kind] ?? e.kind}
</span>
<span className="text-gray-500 dark:text-slate-400"> · {formatTime(e.at, false)}</span>
{e.detail && (
<div className="text-gray-600 dark:text-slate-300 break-words">{e.detail}</div>
)}
{(e.rsrp != null || e.sinr != null) && (
<div className="text-[11px] text-gray-400 dark:text-slate-500">
{e.rsrp != null && `RSRP ${e.rsrp} dBm`}
{e.rsrp != null && e.sinr != null && ' · '}
{e.sinr != null && `SINR ${e.sinr} dB`}
</div>
)}
</div>
</li>
);
})}
</ul>
);
}
export default function LteTab({ deviceId }: Props) {
const [range, setRange] = useState<string>('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 <div className="text-sm text-gray-500 dark:text-slate-400">Loading cellular state…</div>;
}
const interfaces = state?.interfaces ?? [];
if (interfaces.length === 0) {
return (
<div className="text-sm text-gray-500 dark:text-slate-400">
No cellular data yet. It appears after the next poll of an LTE interface.
</div>
);
}
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 (
<div className="space-y-6">
{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 (
<div
key={iface.interface_name}
className="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4 space-y-4"
>
{/* Header: what the link is doing, in words before numbers */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<Signal className="w-5 h-5 mt-1 text-gray-400 dark:text-slate-500" />
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900 dark:text-white">
{iface.interface_name}
</h3>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${QUALITY_STYLE[quality]}`}>
{connected ? quality : 'not connected'}
</span>
{carriers > 1 && (
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
{carriers}× carrier aggregation
{iface.total_bandwidth_mhz > 0 && ` · ${iface.total_bandwidth_mhz} MHz`}
</span>
)}
</div>
<p className="text-sm text-gray-600 dark:text-slate-300 mt-0.5">
{connected ? QUALITY_TEXT[quality] : 'The modem is not attached to a network.'}
</p>
<p className="text-xs text-gray-500 dark:text-slate-400 mt-1">
{[iface.operator, iface.data_class, iface.modem_model]
.filter(Boolean).join(' · ') || 'Modem details not reported'}
</p>
</div>
</div>
<div className="text-right text-xs text-gray-500 dark:text-slate-400">
<div>Session up {formatUptime(uptime)}</div>
<div>Updated {formatTime(iface.updated_at, true)}</div>
</div>
</div>
{/* Signal figures. Each shows only what the modem actually reported. */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<Metric label="RSRP" value={iface.rsrp} unit="dBm" hint="Signal power" />
<Metric label="SINR" value={iface.sinr} unit="dB" hint="Signal vs noise" />
<Metric label="RSRQ" value={iface.rsrq} unit="dB" hint="Signal quality" />
<Metric label="RSSI" value={iface.rssi} unit="dBm" hint="Wideband power" />
<Metric label="CQI" value={iface.cqi} hint="Reported 0–15" />
<Metric
label="Streams"
value={iface.rank_indicator}
hint={iface.dl_modulation ? `${iface.dl_modulation.toUpperCase()} downlink` : 'MIMO rank'}
/>
</div>
{/* Bands */}
<div>
<div className="flex items-center gap-2 mb-2">
<RadioTower className="w-4 h-4 text-gray-400 dark:text-slate-500" />
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Carriers in use</h4>
</div>
<BandChips iface={iface} />
{iface.uplink_anchor && (
<div className="mt-3 flex gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3 dark:border-amber-700/60 dark:bg-amber-900/20">
<ArrowUpFromLine className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="text-xs">
<div className="font-medium text-amber-900 dark:text-amber-200">
Upload is limited by the primary carrier
</div>
<p className="mt-0.5 text-amber-800 dark:text-amber-300">
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.
</p>
</div>
</div>
)}
<div className="mt-3 grid sm:grid-cols-2 gap-3 text-xs">
<div>
<span className="text-gray-500 dark:text-slate-400">Allowed by configuration: </span>
<span className="text-gray-900 dark:text-white font-medium">
{iface.allowed_bands.length > 0
? iface.allowed_bands.map(b => `B${b}`).join(', ')
: 'Any (modem chooses)'}
</span>
</div>
<div>
<span className="text-gray-500 dark:text-slate-400">Seen serving this device: </span>
<span className="text-gray-900 dark:text-white font-medium">
{iface.observed_bands.length > 0
? iface.observed_bands.map(o => `B${o.band}`).join(', ')
: 'Nothing recorded yet'}
</span>
</div>
</div>
{iface.observed_bands.length > 0 && (
<p className="text-[11px] text-gray-400 dark:text-slate-500 mt-1">
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.
</p>
)}
</div>
{/* Cell */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Metric label="Cell ID" value={iface.cell_id} />
<Metric label="eNB" value={iface.enb_id} />
<Metric label="Sector" value={iface.sector_id} />
<Metric label="PCI" value={iface.phy_cell_id} />
</div>
</div>
);
})}
{/* Signal over time */}
<div className="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4">
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
<h3 className="font-semibold text-gray-900 dark:text-white">Signal over time</h3>
<div className="flex gap-1">
{RANGES.map(r => (
<button
key={r}
onClick={() => setRange(r)}
className={`px-2 py-1 text-xs rounded font-medium transition-colors ${
range === 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>
{chartData.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-slate-400">
No samples in this window yet.
</p>
) : (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={24} />
{/* dBm and dB share a chart but not a scale. */}
<YAxis yAxisId="dbm" tick={{ fontSize: 11 }} width={44}
label={{ value: 'dBm', angle: -90, position: 'insideLeft', fontSize: 11 }} />
<YAxis yAxisId="db" orientation="right" tick={{ fontSize: 11 }} width={40}
label={{ value: 'dB', angle: 90, position: 'insideRight', fontSize: 11 }} />
<Tooltip />
<Legend />
<Line yAxisId="dbm" type="monotone" dataKey="rsrp" name="RSRP (dBm)"
stroke="#3b82f6" dot={false} strokeWidth={2} connectNulls />
<Line yAxisId="dbm" type="monotone" dataKey="rssi" name="RSSI (dBm)"
stroke="#94a3b8" dot={false} strokeWidth={1} connectNulls />
<Line yAxisId="db" type="monotone" dataKey="sinr" name="SINR (dB)"
stroke="#10b981" dot={false} strokeWidth={2} connectNulls />
<Line yAxisId="db" type="monotone" dataKey="rsrq" name="RSRQ (dB)"
stroke="#f59e0b" dot={false} strokeWidth={1} connectNulls />
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* Movement */}
<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>
</div>
);
}
+17 -6
View File
@@ -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 (
<div>
<div className="flex items-baseline justify-between mb-1">
@@ -79,12 +84,18 @@ function SpectrumBandRow({ band, radios }: { band: RfBandRange; radios: RfChanne
</div>
<div className="relative h-3 mt-0.5">
{ticks.map((f) => (
<span key={f} className="absolute text-[9px] leading-none text-gray-400 dark:text-slate-500"
style={{ left: `${pct(f)}%`, transform: 'translateX(-50%)' }}>
{band.band === '2.4' ? (channelOf(f) ?? f) : f}
</span>
))}
{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 (
<span key={f} className="absolute text-[9px] leading-none text-gray-400 dark:text-slate-500"
style={{ left: `${at}%`, transform: anchor }}>
{band.band === '2.4' ? (channelOf(f) ?? f) : f}
</span>
);
})}
</div>
</div>
);