Real-time's receiver strip gains an RX LED beside the station state, borrowed from the radio's own front panel. It lights for 140 ms on each live reception, and a burst keeps it lit instead of strobing. It is dark when nothing is arriving, which honestly answers "is anything coming in?" on a quiet station. It uses the connected lamp's green because it means receiving, sits behind aria-hidden next to "Last heard", which carries the same information in words, and stays dark under prefers-reduced-motion. Replayed history doesn't light it; it's one class toggle per packet, with no polling.
The dashboard's New nodes tile names the newest arrival from the last day under its count, e.g. "newest: N7JMV-BEACON-RP · 7 hops · 1h ago", so the number has a neighbor behind it. Long names truncate so the tile keeps its height. The snapshot computes it with one indexed query alongside the other contact counts, so nothing new is polled.
On a fresh install the dashboard was about fifteen identical "No data." boxes and empty donut rings, which read as broken at exactly the moment an operator wants to know the new bot is working. Real-time said "Nothing heard yet" forever, with no sense of whether the silence was normal.
Until the snapshot has seen any node or packet, the dashboard now shows one panel in place of the grid:
- What the station is doing, from the same verdict as the strip: listening, starting, not running (start the bot), or the radio problem in the way.
- What fills in first: nodes from adverts, then paths and hop counts, then one-hop neighbors.
- That timing depends on the mesh.
- For admins, buttons to watch Real-time and check the radio settings; both pages are admin-only, so anonymous visitors get the panel without them.
The grid comes back on its own once anything is heard.
Real-time's empty tape now says "Listening since 2:28 PM (12 min) · nothing heard yet", counting each second from the bot's start time, which /api/banner-status now reports while the bot runs. After ten minutes with nothing, it adds one line of diagnosis: that can be normal on a quiet mesh, otherwise check the frequency and preset in Radio settings, or the Logs. When the bot isn't running or the radio can't receive, it says that instead.
- base.html adds shared readout colors (blue, teal, violet, orange), a row-action component (per-action color with a light outline, as on Contacts) and tinted tag chips
- Radio and Feeds stat tiles read in their own colors; Feeds' Active Errors turns red only when there are errors
- Greeter's 311 solid red "Mark Ungreeted" buttons are red-outline row actions, Manual/Rollout are tinted chips instead of lamp green and amber, and names are plain text instead of pink code
- Feeds' four solid row buttons use the row-action style
- Radio's writes are primary blue; amber stays only on the frequency/bandwidth write, which changes the channel for every node on it
- the dashboard's ranked lists (commands, users, channels, repeaters, paths) show counts as tinted chips in each list's own color with a muted rank, instead of lamp-colored pills
Every page loaded Chart.js (~200KB), Moment and js-sha256 from a CDN whether it used them or not. Chart.js now loads only on the dashboard, Contacts and Multibyte, and js-sha256 only on Realtime, through a page_libs block in base.html. Moment is gone: its one call wrote to a #last-update element that no page renders, and that formatting is now done natively. The remaining base scripts stay at the end of <body>, so they don't block first paint.
The Mesh page loaded Leaflet 1.9.4, MapLibre GL 5.6.0, maplibre-gl-leaflet 0.1.4 and vis-network 9.1.9 from unpkg, and the login page loaded DM Sans and Outfit from Google Fonts, so neither worked on a LAN without internet. They are now vendored under static/vendor/ and static/fonts/ with their licenses and a README recording versions and sources (source-map comments stripped, since the maps aren't vendored). With nothing left on unpkg or Google Fonts, the CSP drops those hosts; the characterization golden is regenerated for that. package-data gains a fourth directory level for Leaflet's images.
Bootstrap, Font Awesome, Socket.IO, Chart.js and js-sha256 still come from CDNs; PRODUCT.md records that as the remaining offline gap.
On a phone most controls were under 32px: Bootstrap's btn-sm, form-select-sm and 16px checkboxes are used everywhere, with 103 of 214 controls on Contacts and 61 of 127 on Plugins under 24px. A shared (pointer: coarse) block in base.html grows standard controls to 44px and small ones to 36px, checkboxes, radios and switches to 24px, and dropdown items, nav links, close buttons, summaries and Leaflet's zoom buttons to match. The dashboard's bare icon buttons and the log level toggles get the same treatment in their own stylesheets. Desktop with a mouse is unchanged. What remains under 32px is the 24px checkboxes and inline text links, both within WCAG 2.5.8.
Bigger controls squeezed some card-header titles into a column, so card headers now wrap their controls onto a second row when both don't fit.
/config was 429px wide on a 390px phone because its single-column grid used 1fr, which can't shrink below its widest child; it now uses minmax(0, 1fr). The radio offline and zombie banners took about 200px of a phone screen; below 576px they drop the secondary sentence (the reconnect warning stays), put the time on its own line and tighten their padding, for 132px. Their small text also loses an opacity that put it at 3.96:1.
Three layout and cost changes.
Hops away and Roles now grow into their cards instead of leaving dead
space under a fixed-height canvas. Chart.js needs a positioned parent
with a real height, so the body becomes a flex column and the chart takes
the slack via flex-basis 0 — height: 100% would resolve against an
auto-height parent and collapse.
The hop axis now ends at the last hop that carries an observation rather
than at the last non-empty bucket of the padded union. On a quiet window
that collapses 64 buckets to 13; on a full one it changes nothing,
because the flood series really does have packets at every hop out to 63
— 14 of them at hop 63, and 14.1% of all flood traffic beyond hop 20.
That tail is real data, so it is drawn rather than truncated.
Dropped the Live Activity card. It opened three SocketIO subscriptions
and re-rendered on every packet to duplicate /realtime, which is a page
that already does it better. The dashboard now costs one snapshot read
per poll and holds no streaming subscriptions. A test asserts it stays
that way rather than merely that the markup is gone.
307 direct neighbours was not plausible, and it was not real.
complete_contact_tracking.hop_count claims 800 zero-hop contacts. Only
68 of them have any one-hop path in observed_paths to corroborate that.
Their stored SNR piles up in a 1.5 dB band — 655 of 800 between 11.25
and 12.75 dB — and their RSSI clusters at -39..-48 dBm. Hundreds of
radios at different distances and terrain cannot land in a 10 dB window.
That is the signature of one strong local link being recorded against
every node whose traffic happened to arrive through it. Their return
paths agree: these "direct" contacts have out_path_len of 3 to 11.
The writer's intent is sound — repeater_manager only stores RSSI/SNR
when signal_info reports hops == 0 — so the field being fed to it does
not mean what the surrounding code assumes. Left as is; this change
stops the dashboard depending on it.
Neighbour membership now comes from path evidence: an advert whose
path_length equals its bytes_per_hop travelled exactly one hop. That
yields 38 nodes in 24h and 124 in 7d, with a plausible spread. Signal is
shown only where the path evidence and the stored hop count agree, which
is 5 and 12 nodes respectively; the rest read "no signal reading" rather
than borrowing a measurement taken on somebody else's link. A 24h/7d
selector bounds the window, capped well under observed_paths' 90-day
retention because a month-old link says nothing about today.
Separately, this fixes a bug I introduced. path_length is a BYTE count,
and with 2- or 3-byte hop encoding a 3-hop path is 6 or 9 bytes long. The
path-length histogram plotted that raw value on an axis readers would
take as hops, overstating distance two- to threefold on a mesh that is
~95% multibyte. It is replaced by a single hops-away chart computed as
path_length / bytes_per_hop, which also retires the histogram built on
the untrustworthy stored hop count. The result is unimodal, peaking at 3
hops and decaying — the shape a mesh should have, and not the bimodal
one the old chart drew.
Three adjustments from review.
Role and device type are the same field twice. Measured on the live
database they disagree on 16 of 11,028 contacts (ten roomservers and a
handful of bots and gateways reporting device Companion); every other
row is repeater/Repeater, companion/Companion, type11/Type11 and so on.
Charting both filled half a card with a copy of the other half. Keep the
role mix, which also carries the type0..type15 bucketing, and move it
into the routing row where the old signal card was.
Drop the tracked-contacts tile. is_currently_tracked does not describe
anything a reader can act on, and node activity is already covered by
nodes-heard and gone-quiet. The known-contacts total moves onto the
coverage tile, which leaves five tiles splitting the row evenly.
Rebuild the signal panel around zero-hop neighbours. Percentiles over
every received message answered no question anyone has: SNR on a relayed
packet measures the last hop into this radio, not the link to the node
that sent it, so averaging across hop counts describes nothing in
particular.
The panel now shows the nodes heard with no repeater in between — how
many, their SNR distribution, and the weakest links named, worst first,
on a fixed -12..+14 dB scale so bars mean the same thing between
refreshes. On the live database that is 307 neighbours, median 12.0 dB,
with eight marginal links surfaced from -9.0 dB down.
This also corrects the source. The plan rejected
complete_contact_tracking.snr as a badly biased 7% sample; in fact it is
populated on exactly the 800 hop_count=0 contacts and NULL on all 10,228
others. It is not a sample of the network, it is a complete census of
the neighbours — which is precisely the population the metric applies
to. message_stats.hops=0 covers only 34 senders by comparison.
The landing page re-ran ~50 aggregate queries five times per load, then
repeated the whole sequence every 30 seconds forever — including in
backgrounded tabs. Against the live 1.44 GB database that was roughly 20
seconds of SQLite work per page load.
Move the work off the request path. A refresher thread in the viewer
process (which already runs migrations, so it works for a split-DB
install) writes two tables: daily_rollup, one row per local date, and
dashboard_snapshot, a single JSON row. A page load now reads one row.
Measured on the live database: first paint 6 requests -> 2,
/api/dashboard/summary p50 1.1 ms (304 in 0.8 ms), /api/stats 130 ms,
and a 0.32 s refresh once a minute in the background.
Make the numbers mean what they say:
- Window selectors are built from each source's retention. The page
offered "30d" and "All" against tables pruned at 7 days, so three of
four choices returned the same figure under a label that denied it.
- The incoming-packet chart reports its measured window instead of
claiming 7 days for a table pruned at 3 — it sat beside a genuine
7-day contacts chart inviting an invalid comparison.
- Days with no source data store NULL and render as gaps. Writing 0
would put a fake cliff at every retention boundary.
- Signal metrics are stored as sums and counts, never means, so any
window re-aggregates correctly.
- Delta chips compare the last two complete calendar days and say so;
the headline above them is a rolling 24 hours.
- Unmapped role ordinals (type0..type15) bucket into "Unknown".
- SNR comes from message_stats, where it is populated on every row, not
from complete_contact_tracking, where it is populated on 7%.
Kill the json_extract scans: packet_stream gains denormalized
route_type_name, payload_type_name, path_len and bytes_per_hop, written
at capture time. Aggregating those from JSON cost 3-6 s per query.
Existing rows convert a bounded batch per tick rather than in one
migration that would rewrite ~180 MB into the WAL and stall bot startup.
A partial index serves as the backfill worklist — without it the "any
rows left?" probe is a full scan costing 4.6 s per tick, and it costs
that after the backfill finishes, because finding nothing still means
reading everything.
Also: replace the per-contact hop-prefix scan with the existing bucketed
matcher and memoize the 7-day chunk set (264 ms -> 35 ms on a synthetic
100k-row database, regression-locked by a test); move the dashboard's JS
and CSS to static files, which removes the CSP nonce requirement for the
bulk of the page; and give cleanup_old_stats a future-timestamp guard,
without which rows dated 2103 are never older than the cutoff and so
live forever.
Deletes the orphaned /stats page, unreachable from the nav and rendering
stub charts that never populated. /api/stats stays as a shim with every
key name intact plus Deprecation and Sunset headers.
All schema changes are additive, so a downgraded codebase can read the
data; it would however need the new schema_version rows removed, since
MigrationRunner rejects versions it does not know.