995 Commits
Author SHA1 Message Date
agessaman 8082aa7b47 fix(web_viewer): narrow template preview types so mypy passes 2026-10-24 19:08:38 -07:00
agessaman 332a61eb35 feat(web_viewer): fetch a message row's packet by hash when it's opened on Real-time
The replay on load is the newest 50 messages and the newest 50 packets, and packets reach back far less, so older message and command rows had a hash and no packet. Opening such a row now asks GET /api/packets/by-hash/<hash>?near=<epoch> for that packet's stored receptions and fills in route, path, receptions and Inspect bytes. The near time bounds the scan to the timestamp index, since the hash is only inside the row's JSON (about 6 ms against a full scan's 250 ms on 90k rows).

The fetched receptions fill the row only: they stay out of the spark and the replay's coverage, since they aren't new hearings. The detail says Loading while it waits, that the packet isn't in the bot's log when nothing comes back, or that it couldn't load and reopening retries.
2026-10-24 16:11:16 -07:00
agessaman b057ad9622 fix(web_viewer): explain a message row's missing packet in its detail instead of on the row
"packet not on the tape" didn't say why. On load the page replays the newest 50 messages and the newest 50 packets, and packets reach back far less, so older messages have no packet to join. The row no longer carries the fact; the expanded detail says the packet is older than the ones loaded, wasn't seen on the page (live rows), or was never recorded by the bot.
2026-10-24 16:01:21 -07:00
agessaman bcebeadcab style(web_viewer): polish the dashboard and Real-time after the critique
- Real-time rows put SNR before the hop count. On a phone the end of the line is cut off, and SNR is the reading a quick check wants; it used to show as "SNR …".
- A repeat heard again only lights its "heard ×N" count instead of re-running the whole row's 1.6 s flash, so a busy tape no longer shimmers. In 20 s of live traffic: 26 count bumps, 8 flashes for genuinely new rows. Reduced motion turns both off.
- The health strip says "1 client", not "1 clients".
- American spelling throughout the user-visible text: "One-hop neighbors" and its tooltip and empty message.
- "Multibyte share of packets" in sentence case like the other card titles.
- The hops chart's y-axis reads "% of nodes or packets" instead of chart-library "% of series".
- The route bar's label rounds to one decimal like its legend, so it no longer shows 98% beside 98.4%.
- The note that bot statistics need collect_stats is configuration guidance, not a degraded state, so it drops the warning color.

The full suite passes (5878), and axe-core still finds nothing on any page in either theme.
2026-10-24 14:59:35 -07:00
agessaman 3657bea8ec style(web_viewer): one color per packet type and role, and 20 channel colors that aren't status lamps
The dashboard's payload mix colored its bars by rank, so a packet type's color changed whenever the traffic order did, and disagreed with the multibyte chart further down, which colors the same types by a fixed slot. The sixth rank was also #198754, the status green. Payload types now take their fixed slot from the chart's SERIES_PALETTE, per theme, so each type has one color everywhere on the page. The role mix gives each role a fixed color matching the Mesh page's node fills: repeaters Signal Blue, room servers Route Teal, then companion orange, sensor violet, bot pink, gateway cyan. Unknown and Other stay neutral, any other role takes a spare, and none of these colors is a lamp.

Real-time hashed channel names onto the whole hue wheel, so a channel could come out status green or alarm red; Public was green. Channel dots now draw from 20 colors per theme, ten hues kept clear of the lamp red, amber and green at two lightness steps, each holding 3:1 on the card (4.2:1 light, 5.2:1 dark). A channel starts at its hashed slot, so its color stays the same between visits. If another channel on the page already holds that slot it steps 7 slots on, a clearly different hue, so up to 20 channels never share a color; with a plain hash, two of three channels on the test mesh collided.

DESIGN.md's data-palette section describes both.
2026-10-24 14:52:30 -07:00
agessaman 12672f432f feat(web_viewer): an RX lamp on Real-time and the newest node by name
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.
2026-10-24 14:48:49 -07:00
agessaman 732ac72b76 feat(web_viewer): a first-run panel and a listening clock for a station that hasn't heard anything
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.
2026-10-24 14:45:16 -07:00
agessaman b42f3c39b2 fix(web_viewer): say when the radio comes back and where the tape has gaps
Recoveries passed in silence. When the banner poll saw the radio come back, the offline or zombie banner simply vanished; clearing the flag by hand replaced the banner's markup with "Offline flag cleared … Refresh to confirm", so a refresh was the only confirmation. Worse, that replacement was permanent: if the radio dropped again on the same page, the banner came back showing the stale success message (and its Since time was gone). The zombie banner had the same bug.

The banner poll now notices when the bot starts running again, the radio answers again, reconnects, or comes back online. It shows one notice for the most basic of these, e.g. "Radio back online at 2:23 PM · offline for 42 min, since 1:41 PM". The notice dismisses itself after 20 seconds or on close. The server keeps no start time for a dropped radio link, so for that case the page notes when it first saw the link down. The clear and restart buttons no longer touch their banner: they wait with a spinner, poll again sooner, and reset once the flag clears, so the banner is intact if the radio drops again.

Real-time records both kinds of event on the tape as small dividers in time order:
- A recovery gets the same line with a green dot.
- A dropped socket gets a line when it reconnects. The reconnect replays the newest 50 rows; if the oldest of them reaches back past the drop, the line says "Link lost 2:28–2:31 PM · nothing missed" (and drops under 15 seconds aren't mentioned). Otherwise the line says which span isn't shown, with an amber dot, and the 10-minute spark draws that span as unknown instead of quiet.
Clear tape removes the dividers, and at most 20 are kept.
2026-10-24 14:33:08 -07:00
agessaman 87aaac3006 fix(web_viewer): stop claiming the station is up when it isn't
The dashboard's health strip and Real-time's receiver strip both said all was well whenever the viewer itself answered. On a database the bot had never run against, the strip showed a green "Online · radio ok" beside "bot down?", and Real-time a green "Listening": /api/health reported "healthy" with no bot at all, and "radio ok" only meant the zombie flag was unset.

/api/banner-status, which every page already polls every 10 seconds, now also says whether the bot is running and whether its radio is connected. The stored start time survives the bot stopping, so "running" comes from the system_health heartbeat the bot rewrites every 30 seconds: older than two minutes, or missing, and the bot isn't there. radio_connected is only reported while it is, since it's the bot's last word on the link. The poll also runs once on load instead of waiting 10 seconds.

base.html turns that into one verdict for both pages: Bot starting, Bot not running (with when it was last seen), Radio not responding, Radio disconnected, Radio can't transmit, or Listening. The lamp colors follow DESIGN.md: red for not working, amber for degraded (sends failing while receiving may still work), green only when the radio is listening. Real-time says "Checking the bot…" until the first answer instead of assuming. Uptime shows a dash while the bot isn't running, in place of "bot down?".

The change chips compared the last two complete days even when one of them had recorded nothing, so a day the bot was down showed as a 100% collapse on every tile. When either day has no packets, adverts or messages, the chips stay neutral and the note under the tiles names the day: "No change shown: nothing was recorded on Oct 23." A fresh install, where both days are empty, gets the neutral chips without the note.
2026-10-24 14:14:43 -07:00
agessaman da2bb91226 fix(web_viewer): announce connection changes and let the log stream take focus
The navbar's connection text changed silently, so a screen-reader user wasn't told when the viewer lost or regained its link to the bot. It becomes a status region after the first update, which keeps the routine connect on every page load quiet and announces later changes; it's only rewritten when the text actually changes.

The Logs stream scrolls but couldn't take focus, so keyboard users couldn't scroll it. It is now a focusable region named "Bot log stream". It is deliberately not a live region: announcing every log line would drown everything else.
2026-10-24 13:38:15 -07:00
agessaman 02868841dd fix(web_viewer): give pages a real heading outline and dialogs their names
Screen-reader users skim a page by its headings, and several outlines were broken:
- The dashboard had no h1; it now has a visually hidden "Dashboard" heading ahead of the health strip.
- Feeds and Radio stat cards made the number the heading ("heading level 2, 4") under an h5 label. The label is now the h2 and the number a styled paragraph, and their card sections are h2s instead of h5s straight after the h1. Radio's uppercase group labels become h3s. Feeds' error count toggles its color classes instead of replacing them, which had dropped the readout size.
- Logs went from h1 to h6 for its stream card; it is an h2.
- Config's panel titles are h2s, their group labels h3s, and the database table names (built in JS) h4s.
Visual sizes are unchanged: each heading keeps its old look through Bootstrap's .h5/.h6 classes.

11 of 17 modals had no aria-labelledby, so they announced as an unnamed "dialog". Each now points at its modal title.

axe-core reports no violations on any of 15 pages in either theme.
2026-10-24 13:38:15 -07:00
agessaman e2f5149927 fix(web_viewer): hide decorative icons from screen readers
Font Awesome draws its glyphs as CSS content, and none of the viewer's 235 icons (plus the ones pages build in JS) were hidden from assistive tech, so private-use glyphs ended up in the accessible names of headings and buttons: the dashboard's Mesh heading was " MESH". Font Awesome's JS normally marks icons aria-hidden; the viewer loads only its CSS.

A small script at the top of base.html does the same: every i.fas/far/fab/fa gets aria-hidden="true" unless it has an aria-label, in which case it gets role="img" so the label is valid. A MutationObserver covers icons that pages add later, and a label added after the fact un-hides an icon the script had hidden. That also fixes Radio's slot-limit info icon, whose aria-label was prohibited on an <i> with no role.
2026-10-24 13:38:15 -07:00
agessaman 3b491e1537 fix(web_viewer): tag chips and Multibyte's filter buttons pass contrast
Tag chips draw their readout color on a 14% tint of itself, and on that tint several fell short of 4.5:1: orange 4.2:1 on the dashboard in light, blue 4.47:1 on dark cards and 3.76:1 across Greeter's dark table (207 chips). The readout colors move a step darker in light and lighter in dark so each holds 4.5:1 on its own tint over paper, paper-sunk, night-raised and night-sunk; violet in light already did.

Multibyte's "MB Relay" and "Direct MB" filters used Bootstrap's outline info and success, whose text is the fill tone (1.95:1 and 3.03:1). They now resolve through --text-info and --text-success like the other outline buttons.

axe-core finds no contrast failures on any page in either theme.
2026-10-24 13:28:34 -07:00
agessaman 319a69b401 fix(web_viewer): keep the Mesh map's shapes out of the tab order
Chrome makes Leaflet's interactive SVG shapes Tab stops even without a tabindex, so the Mesh page put about 120 unnamed stops with no focus ring between its toolbar and the rest of the page. After each render the overlay and marker shapes get tabindex="-1" and their SVGs aria-hidden; the map container stays focusable for arrow-key panning and +/- zoom. The Tab walk on /mesh drops from 147 stops to 22.

The map and graph had no accessible name. They are now labeled "Mesh map" and "Mesh graph" (the graph as an image), described by a visually hidden note: both are visual only, the map pans with the arrow keys, and the counts above, Search Node and the Contacts page carry the same information as text.
2026-10-24 13:28:34 -07:00
agessaman 75cd6efd13 fix(web_viewer): give every page a main landmark and a skip link
Screen readers navigate by landmark, but only /config had a <main>; everywhere else the page content sat outside any landmark, and keyboard users tabbed through about 20 navbar links before reaching it. base.html now wraps the content block in <main id="main-content">, labels the navbar "Main", and opens every page with a "Skip to content" link that appears on focus and moves focus into the main region. The login card is the login page's main.

/config had its own <main>, which would have nested inside the new one, so its panel column is a plain div. Its sidebar group names (Core, Database, Admin Tools) were h6 headings ahead of the page's h5 sections, which broke the heading order and left three unlabeled navigation landmarks; they are now paragraphs that label their nav.
2026-10-24 13:28:34 -07:00
agessaman f98caf7e56 style(web_viewer): give the Mesh page one palette that follows its map theme
Every color on the Mesh page now comes from `--mesh-*` tokens declared at the top of mesh.html. The map and graph have their own light/dark toggle, separate from the site theme, so the tokens switch on `.mesh-dark` and the JS reads them back through `meshColor()`. The viridis heat colors are unchanged.

Edges colored by recency used the status lamps (green, amber, orange, red) with no legend, so the default 72-hour view was mostly amber lines that read as warnings. They now fade one magenta-rose hue with age, under a new "Last heard" legend that hides while a selection recolors edges by direction. Unknown ages are solid gray, since dashes already mean single-byte evidence. Opacity steps more gently because the color carries the fade.

Room servers move from lamp green to Route Teal. A selected node or traced path is ringed or drawn in ink instead of amber, and keeps its own fill. Direction from a selected node uses the dashboard's series blue (incoming), orange (outgoing) and violet (both ways). The stat chips color each value like what it counts on the map; Avg km is plain.

Legends, popups, tooltips, zoom controls and the fullscreen button use the tokens, and popup labels move from #888 (3.5:1) to the muted panel color. Switching the map theme in graph view recolors the graph in place instead of rerunning its layout.

DESIGN.md documents the palette. Checked at 1400px and 390px in both themes, with a selection and in heat mode, with no console errors; 366 web viewer tests pass.
2026-10-24 12:41:05 -07:00
agessaman 8d1c5ea9c0 build(web_viewer): vendor the last CDN libraries and keep them updated with Dependabot
Bootstrap 5.1.3, Font Awesome 6.0.0, Socket.IO 4.7.2 and Chart.js 4.4.0 are now served from static/vendor/ at the versions the CDNs served, so every page works on a LAN with no internet. Only map tiles still need it. The Content-Security-Policy drops jsDelivr and cdnjs.

Every vendored package, the map stack and login fonts included, is pinned in scripts/web-vendor/package.json and its lockfile. sync.mjs copies the listed files into static/, strips the source-map comment, empties each directory it owns first, and regenerates the version table in static/vendor/README.md; it reproduces the previously vendored files byte for byte. Font Awesome ships only its woff2 fonts, which every supported browser takes before the .ttf.

Dependabot watches that directory: security fixes as advisories land, grouped minor and patch releases monthly, majors one per PR. Dependabot can't regenerate the vendored files, so vendor-refresh.yml adds them to its PRs as a second commit and dispatches the tests, since a GITHUB_TOKEN push doesn't trigger them. It runs on pull_request_target, so it uses the base branch's sync.mjs, spells out its commands instead of running the PR's npm scripts, and installs with install scripts disabled. A new CI job re-runs the sync and fails if anything under static/ changes.

Tests now check that no template loads a script, stylesheet or font from another host, that every vendored file a template names exists, and that the CSP allows no CDN host. In Chrome, 12 pages load with no external request, failed load or console error.
2026-10-24 12:02:40 -07:00
agessaman 49b369025b fix(web_viewer): rebuild the browser packet decoder from meshcore-decoder 0.4.0
My fork of meshcore-decoder is now at 0.4.0, which brings it in line with current MeshCore firmware: encrypted PATH payloads, GRP_DATA and MULTIPART, region transport codes, the new request and text types, and the firmware's packet hash. 0.4.0 isn't published to npm, so build.sh now fetches the fork at a pinned commit (60733c0) and installs its runtime dependencies from the lockfile, still with install scripts disabled. Two builds produce byte-identical bundles.

The browser entry exports the same additions as the fork's lite entry (AnonRequestType, TextType and their name functions, PathPayloadDecoder) plus the region transport-code helpers.

The byte breakdown's hash fallback now uses the decoder's packetHash, which is the firmware hash the server sends. The old fallback hashed the hex text and never matched.

All 72,828 packets in the bot's database decode to the same payload type and path as the bot's own decoder, and packetHash matches the stored packet_hash on every one. Advert names and Public decryption are unchanged from 0.3.0. In the browser, Real-time loads without console errors, and 2-byte-hop packets, including a decrypted Public message, show the right header, path and payload in the byte breakdown.
2026-10-17 22:31:03 -07:00
agessaman c841f91ca2 style(web_viewer): color stat tiles, quiet loud row buttons, tint dashboard list counts
- 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
2026-10-17 20:55:36 -07:00
agessaman 2fa8641645 style(web_viewer): put color back in Real-time's controls, the byte breakdown and Contacts' tiles
The polish passes had turned these gray in the name of keeping lamp colors for state. They now carry color that means something:
- Real-time's filter lenses take the colors of what they show (Everything in the link blue, Messages in the message blue, Commands in the bot's violet), filling when chosen; Pause fills while the tape is paused, and the 10-minute activity bars are blue
- the byte breakdown's segments are fuller tints (34% instead of 20%) with darker or lighter ink, 5.4:1 and up in light and 11.9:1 in dark, and each segment card's swatch is solid
- Contacts' Active Contacts counts read in the link blue and its Advertisements counts in teal, with the card icons matching
2026-10-17 20:43:25 -07:00
agessaman 163033535d fix(web_viewer): float toasts in one stack, give Contacts' actions their color back
Contacts and Cache inserted their alerts at the top of the content, so every save, star or error pushed the whole page down, and Radio, Feeds and the connection manager each pinned their own fixed alert at the same spot, so two messages covered each other. base.html now has one toast stack (window.viewerToast) that floats over the page below the navbar, or along the bottom on a phone, keeps at most four, announces them politely (errors as alerts) and takes either a string or a node the caller filled with text. Every page routes through it.

The Contacts polish had turned the row actions into muted bare icons, which made them hard to find and to tell apart. Each action now has its own color and a light outline: a gold star (filled when starred), blue info, teal location and red delete, with icon contrast from 3.8:1 in light to 8.7:1 in dark. The new-device counts take their role colors, matching the chips and the activity chart.
2026-10-17 20:37:52 -07:00
agessaman 931afd778b style(web_viewer): polish the Contacts page
Fixes from a rendered pass at desktop and phone width in both themes:
- stat tiles: counts are grouped and set as tabular readouts in the text color, so 257,052 and "Companions" no longer break mid-number or mid-word, and the blue, teal and green number colors are gone
- device roles are tinted chips in the dashboard's categorical data colors (repeater blue, companion orange, room server indigo, sensor teal) instead of lamp amber and green badges, and the activity chart reads the same --role-* tokens; the chips measure 6.6:1 or better
- SNR, distance, bytes/hop and advert counts are plain tabular readings, with a dash for unknown values instead of N/A and green or amber SNR badges
- the hop count that opens a contact's paths is a button (it was a span with a click handler, so unreachable by keyboard), labeled with the hop and path counts
- row actions are bare icons that take the link color on hover, red only for delete; a starred contact shows a filled star with aria-pressed
- sort icons stay attached to their header labels, and the table still fits at 1440px with all four actions
- paths modal: "N paths this radio has heard to <name>" instead of "from observed_paths table", correct plurals ("1 hop", "heard 2 times"), path bytes in the text color in both themes instead of Bootstrap's pink code color, a neutral Primary chip instead of a green badge and border, scrollable and full-screen on phones
- advert modal: the JSON wraps instead of running off the window, and a missing contact shows the page's error toast instead of a blocking alert()
- phone cards label distance and SNR and drop the bare "N/A · N/A" line; the sort select wraps under "Select all on page" with its full label
- "Just now" is no longer green
2026-10-17 20:27:30 -07:00
agessaman c5ea5b8aa1 fix(web_viewer): rebuild the browser packet decoder from meshcore-decoder 0.3.0
The web viewer's static/js/meshcore-browser.mjs was an esbuild bundle of @michaelhart/meshcore-decoder at 0.2.7 or older, added without a record of its version or how it was built. Those versions read the path-length byte as a plain byte count, so every packet with 2- or 3-byte hops was misparsed from the path onward: wrong header and type, a path split into single bytes, garbage payload fields, garbled advert names and group text that never decrypted.

The bundle is rebuilt from 0.3.0, which takes the hop count from bits 0-5 and the hash size from bits 6-7. Twelve real packets from the bot's database (adverts, group text, direct messages and returned paths at 1, 2 and 3 bytes per hop) decode to the same hop count, path and payload as the bot's own decoder; multibyte adverts get their real names and a 2-byte-hop Public message decrypts, on the tape and in the byte breakdown.

scripts/meshcore-decoder-browser/ holds the browser entry the old bundle was built from (recovered from the bundle: it re-exports the library and routes Ed25519's SHA-512 through crypto-js so signatures verify on a plain-HTTP page) and build.sh, which fetches the published package, installs its runtime dependencies with install scripts disabled and bundles it with a pinned esbuild in a temporary directory. It reproduces the committed bundle byte for byte. The unused meshcore-browser.min.mjs is removed, and the multibyte workarounds from the byte breakdown polish (skipping the browser decode and saying so) are taken out again.
2026-10-17 20:13:15 -07:00
agessaman 84f06f8ebf fix(web_viewer): polish the packet byte breakdown and stop trusting the old decoder on multibyte packets
The bundled browser decoder reads the path-length byte as a plain byte count, so on a packet with 2- or 3-byte hops it misplaces everything after it. The modal took its header fields, path and payload from that decode first: a flood group-text packet (0x15) showed as 0x3D Custom, the path came out as dozens of single bytes, and the advert and decrypted sections read garbage. The header now comes from the byte on the wire, path and payload from the bot's own decode, and multibyte packets skip the browser decode with a note saying why. The tape skips browser decoding for them too ("multibyte path, not decoded here") rather than risk a garbled advert name or a false "no key here".

The rest is the polish pass:
- the modal shows the 16-character packet hash the tape, search and Copy hash use, not the decoder's 8-character one
- byte ranges account for transport codes ("Byte 4", not a fixed "Bytes 0-0"), paths are grouped by hop and payloads by byte so the hex wraps between bytes, and the path-length byte's bits are explained (hop count in bits 0-5, hash size in bits 6-7)
- colors come from the base.html theme tokens, light first with dark overrides, replacing a separate bluish dark palette and its light-mode patches; byte segments use the dashboard's categorical data colors as tints (5.3:1 and up in light, 8.9:1 in dark) instead of lamp green, amber and red, the hash is no longer amber and the binary column no longer red
- one level of cards: the raw bytes are a single well instead of a card holding a box with a 4px amber stripe, the segment cards carry a swatch that ties them to the bytes, and labels follow the viewer's small-caps style
- Copy raw hex is the primary button, and copying falls back to execCommand when the Clipboard API is missing (plain-HTTP LAN viewers), saying so if the browser still refuses
2026-10-17 19:57:53 -07:00
agessaman 79e6dfe96d fix(web_viewer): keep Real-time's phone strip from overlapping
On a phone the strip was forced onto one line with items allowed to shrink below their text, so once the tape was held and the "N new · show" button joined Pause, "heard 10 s ago" ran into the rate and the button's label spilled out of it. The readings now keep their width, the controls drop to their own line at the right when there isn't room, and the button reads just "N new" on a phone (its accessible name keeps the full wording).
2026-10-17 19:47:28 -07:00
agessaman 2a1c2f86d8 feat(web_viewer): rebuild Real-time as one tape of what the radio heard
Real-time was three equal panels (commands, packets, messages) that each scrolled on their own, couldn't be held still, and showed the same transmission up to three times. It's now one tape with one row per transmission: a packet, the channel message decrypted from it and a command it triggered share a row, joined by packet hash, and the bot's reply hangs under the command with how many times a repeater was heard echoing it. Those echoes fold into the reply instead of getting rows of their own, and a packet heard several times is one row with a count and every reception (SNR, RSSI, hops, path) when opened.

A receiver strip at the top says whether the bot is listening or the radio is offline or not responding (from the banner poll base.html already runs, now published as a banner-status event), when it last heard a packet, the packet rate and a 10-minute activity bar. The tape holds still while you read it: Pause (P), scrolling down, a moving mouse pointer or keyboard focus queue new rows behind an "N new" button. Rows can be kept (safe from the 300-row limit and from Clear), filtered to messages or commands, by packet type or channel, and searched (/). Replayed history sits under a "Heard before you opened this page" divider, and adverts and keyed group text are decoded in the browser and labeled as such. Each row's glyph chip carries its packet type's icon in the dashboard chart's color for that type; decoded messages get a stronger tint, and the bot's commands and replies are violet.

Server side, command rows carry trigger_packet_hash (from the message's routing info) and, once the transmission tracker matches a repeat, reply_packet_hash. Replayed rows carry stream_ts and replayed, and live rows stream_ts. Commands stored before this pair with their message only on an exact sender, channel and text match within 20 seconds.

Real-time is admin-only now: its socket already was, so anonymous visitors got three "Admin login required" badges over spinners that never filled. They're redirected to login and the nav link is hidden from them.

The byte breakdown sliced multibyte paths by hop count, so 2- and 3-byte hops spilled into the payload; it uses the path's byte count and shows the raw path-length byte. Route types 0 and 2 were swapped, and the channel key store was often built before the decoder had loaded, so it never decrypted anything. js-sha256 is vendored under static/vendor instead of loading from cdnjs.
2026-10-17 19:40:23 -07:00
agessaman 73e7b95140 fix(core): replace a stale radio-offline flag at startup
bot.radio_offline is written only when the breaker changes state. A run that went offline and was then restarted left 'true' in bot_metadata, and the new process, which starts with no send failures, never transitioned, so nothing overwrote it: the web viewer showed Radio Offline indefinitely while the bot sent normally. The viewer-clear check couldn't help either, since it only runs while the bot itself thinks it's offline.

start() now publishes the fresh offline state right after setting bot.initializing, before the early web viewer starts, so the stored flag always belongs to the current run. The zombie flag didn't need this; a successful connect already writes it false.
2026-10-17 18:37:46 -07:00
agessaman e0b7eca4fd style(web_viewer): retire legacy blue and green, quiet the packet stream
The pre-Bootstrap-5 #007bff and #28a745 (and their rgba forms) still sat on the navbar brand, status lamps, dark form focus, the conversation rail, the Contacts focus ring, the login page's brand tokens, and Realtime's hex path and newest-row glow. They now use Signal Blue (#0d6efd) and Lamp Green (#198754), which also brings the hex path's white text from 3.1:1 to 4.5:1. The brand stays above 3:1 on the rail as large bold text. The unused .log-entry, .command-entry and .packet-entry rules in base.html are removed.

Realtime packet rows carried an 11-color stripe down the left of each rounded card, repeating what the colored type badge in the row already says. The stripes are gone, so the stream reads like the dashboard's cards.

DESIGN.md adds the 2px (hair) and 5px (meter) radii that legend swatches, focus rings and meters already use, drops the drift notes these changes resolve, and records the touch-target sizing from the adapt pass.
2026-10-17 18:14:04 -07:00
agessaman b7aa0ac0c5 fix(web_viewer): show banner times in local time with their age
The radio offline and zombie banners printed the raw UTC ISO string (2026-10-15T14:42:58.701279+00:00). They now show the time in the viewer's own time zone with a relative age, such as "Oct 15, 7:42 AM · 2 days ago" (just the time when it's today), in a <time> element that keeps the exact value in datetime= and a full date in its tooltip. The 10-second banner poll reformats it, so the age stays current.
2026-10-17 18:11:44 -07:00
agessaman 4d9f758eff perf(web_viewer): load page libraries where used and vendor the map stack
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.
2026-10-17 18:10:02 -07:00
agessaman 270680e6f1 fix(web_viewer): touch-sized controls, /config fits a phone, compact banners
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.
2026-10-17 18:00:58 -07:00
agessaman c514dd5361 fix(web_viewer): hold text at the 0.7rem floor and use one monospace stack
Realtime message times were 9.3px (0.58rem), Realtime meta chips and phone Mesh stat labels 9.9px, Mesh, Plugins and Contacts badges about 10.5px, and the Contacts path count compounded to 10.5px as a <small> inside a badge. All now sit at the 0.7rem floor DESIGN.md sets, and the path count inherits the badge size in a lighter weight. Phone layouts still fit at 390px.

Monospace was five different stacks, three of them ending in 'Courier New'. base.html now defines --font-mono (the ui-monospace stack from DESIGN.md) and points Bootstrap's --bs-font-monospace at it, and logs, realtime, mesh, contacts, greeter, config and the legacy .log-entry all read it.
2026-10-17 17:51:23 -07:00
agessaman ebdc44cd90 fix(web_viewer): status and accent text passes contrast in both themes
Bootstrap's text-success, text-warning, text-info and text-danger are fill tones, so used as text they failed contrast (amber on white was 1.6:1, info 2.0:1), and nothing adjusted them for dark mode. base.html now defines per-theme status text tokens that pass 4.5:1 on every surface tier, and the text utilities, text-muted, form-text, code, links and the outline buttons resolve through them. Info and warning badges carry dark text. Ink Muted moves a step darker so it holds on paper-sunk.

The dark message bubbles on Realtime were solid navy, forest green and purple, which overpowered the page and dropped the sender names to 1.7:1. They are now low-alpha tints of the signal and lamp hues over the card, like the dashboard delta chips, and the error bubble is red in both themes instead of purple in dark.

Also: the light log levels and level toggles move to tones that pass on the log well, the dark ERROR toggle uses dark text, Realtime drops its local orange text-warning and its opacity on message times, and the four copies of the dark .table td * rule no longer force body text color onto badges and buttons. DESIGN.md records the tokens and the rule that a fill hue never goes on text directly.
2026-10-17 17:46:51 -07:00
agessaman ae4235dcf0 fix(web_viewer): link plugin settings, contacts and radio fields to their labels
A scan of the rendered pages found about 300 Plugins settings fields whose visible label wasn't tied to the control, so screen readers announced them without names. renderField now links each label (and its help text, via aria-describedby) to the control it renders. Contacts gets names for its search box, export range, purge threshold and the select-all and per-row checkboxes ("Select <name>"); the Radio create-channel labels gain for=; and the Multibyte time range and repeater search get aria-labels.
2026-10-17 17:08:04 -07:00
agessaman 15a6096430 fix(web_viewer): name every icon-only button
Icon-only buttons were named only by title, or not at all: the Realtime scroll buttons, the Mesh toolbar, contact row actions, the Plugins remove and reveal buttons, the Feeds and Radio row actions, every Bootstrap close button and the navbar toggler. Each now has an aria-label (scoped where it helps, such as "Scroll packets to newest" or "Remove Webhook 2"), with its icon hidden from assistive tech. Toggles that hold state expose it: the Mesh hot-connections button and the password reveal buttons set aria-pressed. The Plugins filter box and the dynamic key/value fields also get names, and repeating blocks name their enable switch after the block.
2026-10-17 17:04:39 -07:00
agessaman 80ffe163ef fix(web_viewer): give every Feeds and Greeter field an accessible name
The Feeds form labels had no for=, so screen readers announced the fields without names, and the new-channel box, the three JSON config boxes and the Greeter search relied on placeholder text alone. Labels are now linked to their fields, the unlabeled boxes get aria-label, and the Output Format Help button sits beside its label instead of inside it. The empty spacer label above Preview Format is now a hidden div, and the accordion toggles declare aria-expanded and aria-controls.
2026-10-17 16:59:41 -07:00
agessaman 1371d6d537 fix(web_viewer): open realtime packet details from the keyboard
Packet rows with raw hex only opened their detail modal on a mouse click. They now act as buttons (role, tabindex, Enter and Space), show the hover treatment plus a focus ring when focused, and closing the modal sends focus back to the row that opened it, since Bootstrap doesn't do that for a modal opened from script.
2026-10-17 16:58:38 -07:00
agessaman 34f59bddf5 fix(web_viewer): open plugin cards from the keyboard
The header that opens a plugin's settings was a <div> with a click handler, so Expand all was the only keyboard route into a card. The title area is now a <button> with aria-expanded and aria-controls, kept separate from the enable switch, and Expand/Collapse all update it through the same path. The enable switch also gets an accessible name, since it had no label.
2026-10-17 16:57:27 -07:00
agessaman 52822feeda fix(web_viewer): make contact column sorting work from the keyboard
The sort headers were plain <th> cells with a click handler, so keyboard and screen reader users couldn't sort. Each header label is now a <button> inside the <th>, the active column carries aria-sort, and the focus ring follows the header text color. The Hops info tip sits outside the sort button, so opening it no longer re-sorts the table.
2026-10-17 16:56:24 -07:00
agessaman f5f3b6e52a fix(web_viewer): review fixes for plugin settings coverage and template previews
Fixes from a Codex review of the previous commit, plus the shared settings the rebase onto dev showed were still missing.

Settings that misstated or changed what runs:
- sports never read [Sports_Command] api_timeout (documented in the example config); the new field did nothing. It now sets the ESPN timeout.
- The path-byte requirement help on test, multitest and path said "bytes per hop"; the gate checks the path's total size (at least 2, or exactly 3).
- feed's allow_private_urls showed false while it inherited true from [Feed_Manager]. Fields can now name an inherit_section whose value they show when unset.
- notify_on_prefix_bytes was an int field for a value the service reads as a list (2,3), so the form refused a valid setting.
- The greeter's form defaults differed from the fallbacks it uses when the keys are missing. Both now come from shared constants.
- The MQTT weather limits accepted values the service clamps (payload 256 to 1 MiB, staleness 5 s to 7 days, length 64 to 4000).

Shared settings: the wx card now carries the [Weather] temperature, wind and precipitation units and the three temperature_*_format templates (with previews through format_temperature_high_low), next to wx's own [Wx_Command] unit overrides, which default to "Same as [Weather]". Two fields with one key in different sections are told apart as key@Section in the save payload; a bare key still resolves when only one field has it. An enum's "" option, like a blank number, removes the key. Packet capture brokers gain username, password, topic prefix, JWT timings and include_decoded; blank inheriting values are left out of block saves, since the service reads them with getint/getboolean when present.

Previews:
- They use the bot's saved config: a blank test format falls back to [Keywords] test, multitest lays out paths per condense_paths, and MQTT applies passthrough_max_length.
- A quoted empty template ("") counts as blank, as the commands strip the quotes first.
- A str.format width or precision over 500 isn't rendered, so `{sender:100000000}` can't make the endpoint build a huge string.
- The page invalidates an in-flight preview as soon as the field changes, and shows the server's note about fallbacks instead of guessing it.

The endpoint inventory golden gains the preview route (401 for anonymous requests when a password is set).
2026-10-04 15:55:17 -07:00
agessaman 7758403148 feat(web_viewer): cover every plugin setting, with a template reference and preview
The Plugins page now has a field for every setting the plugins read. I compared each plugin's settings_schema against config.ini.example and the keys the code reads; fourteen plugins had gaps (greeter, trace, stats, path, wx/gwx, rain, test, multitest, sports, worldcup, cmd, feed, the webhook, weather, packet capture, earthquake and prefix-collision services) and the World Cup service had no form at all. Webhook URLs, bot tokens and channel keys use the password field. A guard test needed four [Rain_Command] keys documented in config.ini.example.

Template fields get a { } button that opens a reference of the placeholders they take, with click-to-insert. Fields rendered by the piped engine (test's response_format, path's reply_prefix) also list the filters and conditionals. Every template field previews as you type: piped fields against a 2-byte, a 1-byte and a direct sample message, so a conditional's effect shows before saving, and the str.format fields (greeter, mesh info, multitest, MQTT weather) as their plugin renders them, including what it sends when the template has a mistake. The specs and renderers live in modules/template_reference.py; the preview endpoint only renders and never saves. The greeter's channel_greetings parser and multi-part split moved into module functions so the command and the preview share them.

Fixes the new fields would otherwise have exposed:
- Clearing a number field saved `key =`, which makes getint/getfloat raise and the plugin fail to load. A cleared number now removes the key.
- The weather service raised in __init__ when only some blitz_area_* corners were set. It now needs all four and logs a warning otherwise.
- A blank MQTT weather json_template saved an empty template, so every reply said no message had arrived. Blank now saves the default.
2026-10-04 15:41:45 -07:00
agessaman a902f1c221 Merge remote-tracking branch 'origin/dev' into fix/geocode-language 2026-10-04 11:35:45 -07:00
agessaman d131f30fed fix(geocode): name places in the bot's language, with an opt-out
Nominatim was never given a language, so place names in wx, gwx and other location replies came back in each place's local language ("千代田区, JP" for Tokyo). Forward and reverse lookups now pass [Localization] language, with English as the fallback where OpenStreetMap has no name in it. [Localization] place_name_language overrides it: "local" keeps the previous behavior, a language tag picks another language. Reverse-lookup cache keys include the language, so addresses cached in local languages are looked up again; "local" keeps the old keys.
2026-10-04 11:35:44 -07:00
agessaman 6468ae03d4 Merge dev (#344) into feat/wx-openmeteo-fallback
Conflict in wx_command.py: the refactor split _get_weather_for_location_sync into _locate_*, _noaa_forecast_body and _with_noaa_alerts. The fallback now checks coverage after _locate_* and when _noaa_forecast_body returns its fetch error. _openmeteo_weather takes no address_info: a city's label comes from city_to_lat_lon again, answered from the geocoding cache the first lookup filled.
2026-10-04 11:20:00 -07:00
agessaman 6cbe4e1ca8 Merge dev (#345) into refactor/maintainability
Conflict in modules/core.py: #345 changed _validate_config_snapshot, which this branch moved to config_reload.py. The method there now matches dev's version; reload_config's warning merged cleanly.
2026-10-04 11:06:43 -07:00
agessaman 388540cbc7 fix(config): a bare '%' in a value no longer blocks config reload
_validate_config_snapshot expanded '%' interpolation on every value, so a value read raw with a literal '%' (an [MqttWeather] json_template with "% RH", which config.ini.example documents as fine) failed every reload, although startup accepts the same file. Such values are now returned and named in a warning, and the reload goes ahead. Typed schema keys are still checked as before.
2026-10-04 09:04:43 -07:00
agessaman 0e7b9fef0f feat(wx): optional Open-Meteo fallback for places outside NWS coverage
[Weather] openmeteo_fallback (default false, only with weather_provider = noaa) answers wx from Open-Meteo when NWS reports a point as outside its coverage (HTTP 400/404 from /points), in gwx's format and labels and wx's units. Timeouts and server errors keep today's error. The point goes into the existing NWS no-coverage cache, so later requests skip NWS for 24 hours. wx <place> alerts there replies that alerts are not available.

gwx's per-point body moves into _weather_for_point so wx can reuse it without geocoding twice; gwx's output is unchanged.
2026-10-04 08:28:02 -07:00
agessaman fd1e8d1926 refactor(commands): read the help method's own signature, not the one functools.wraps copies
Codex on f0649558: inspect.signature follows __wrapped__, so an operator-installed command under modules/commands that wraps PingCommand.get_help_text with a get_help_text(self) override looked like it accepted the message, was called with it, and raised where dev retried and returned its help. _accepts_message now uses follow_wrapped=False. New test covers that override. No bundled get_help_text is decorated.
2026-10-03 23:51:32 -07:00
agessaman f0649558e8 refactor(commands): direct help call only when the method also accepts the message
Space bunny on d60e7cf1: operators may install their own command files inside modules/commands or modules/commands/alternatives (documented, and preserved across upgrades), so a class under modules. is not necessarily bundled. Such a command written as get_help_text(self) was treated as bundled, called with the message and raised, so help returned nothing where dev returned its text. help_text_for now calls once with the message only when the method comes from a class under modules. and its signature binds the message; otherwise, including when the signature can't be read, it uses dev's call-then-retry. New test: an operator command in the modules.commands namespace with get_help_text(self) gets its help.
2026-10-03 23:47:56 -07:00
agessaman dabb59b823 refactor(commands): help retry re-reads get_help_text; a reused bundled function stays bundled
Codex on d60e7cf1 found two more edges where dispatch differed from dev: the retry called the captured method instead of reading command.get_help_text again, so a local plugin that replaced its help callable before raising got the original back; and a local class that reuses a bundled function (get_help_text = PingCommand.get_help_text) was classified as local, so a TypeError inside the bundled body ran it twice. The retry reads the attribute again, and a function counts as bundled when any bundled class in the MRO holds it in its __dict__. Both reproductions now match dev.

Accepted: a local override that forwards *args to a bundled get_help_text now passes the message through (the bundled method accepts it), and adapters that pass a message to a formerly no-message bundled method now succeed instead of raising.
2026-10-03 23:26:00 -07:00