Skip to content

v3.1.0: the admin redesign, security hardening, and live-data fixes - #60

Merged
revtex merged 66 commits into
mainfrom
dev
Sep 29, 2026
Merged

revtex merged 66 commits into
mainfrom
dev

Conversation

@revtex

@revtex revtex commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Pull Request

Summary

v3.1.0: the admin redesign, security hardening from an internal review, and the fixes found running it against live data. Upgrading from v3.0.0 needs no manual step; migrations run on start. The full list is under [3.1.0] in CHANGELOG.md.

Admin, rebuilt to the redesign. New shell, sidebar and top bar, with a search (Ctrl K) that finds users, talkgroups, keys, settings, monitors and downstreams by name. Overview has a Needs attention card. Every section has been rebuilt:

  • Systems, Users, API keys, Settings, Logs & audit, Folder monitors, Forwarding, Shared links, Transcription, and Groups & tags.
  • Trunk Recorder has its own page.
  • Admin → Connections shows where listeners connect from, can block an address, and can disconnect or sign a session out.
  • Backup & import is one page: a guarded configuration restore with a review, Merge or Replace, and an automatic pre-restore copy; one import wizard covers talkgroups, units, groups and tags.

Scanner. It looks like the mobile app. There are seven dark themes, chosen per browser, and fonts are bundled. SELECT has an Avoids tab. Shared-call pages offer "Open in app" on a phone. Display brightness is removed.

Security (operators may notice):

  • System restrictions now apply on every path that serves or accepts calls.
  • New --trusted-proxies / SQUELCH_TRUSTED_PROXIES.
  • --admin-password resets the first admin as documented.
  • The database and log are owner-only.
  • Clients refresh their session once after a restart.

Fixed. The most visible fixes:

  • Webhooks are delivered again.
  • Password changes and account disabling from Admin → Users work.
  • RadioReference Enrich touches only existing talkgroups.
  • Trunk Recorder's messages stay live after you open the Live view.
  • Logs level counts are the real totals.
  • A dropped listener connection no longer logs as a warning.
  • AAC recordings are written with faststart instead of fragmented MP4, so each Call downloads in one request instead of four.

Checklist

  • Tests pass locally (go test ./..., pnpm test), and CI is green on dev
  • docs/ updated if user-facing behavior changed
  • CHANGELOG.md updated: [Unreleased] cut to [3.1.0] — 2026-09-29
  • No new secrets, tokens, or keys in logs or error messages

Verified on the test deployment with live data (dev-db394ee).

🤖 Generated with Claude Code

OpenScanner Dev and others added 30 commits September 19, 2026 23:25
The line still described the listener and admin channels as /ws and
/api/admin/ws. Those are the deprecated 3-letter-framing aliases; the
frontend connects to /api/v1/ws/listener (shared/services/ws/client.ts)
and /api/v1/ws/admin (adminClient.ts). Name the v1 paths, point at
messages_v1.go for the frame shape, and mark the old paths as legacy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SPA fallback in serveFrontend only excluded /api/ and /ws before
handing back index.html, so /.well-known/assetlinks.json and the legacy
root-level /apple-app-site-association answered HTTP 200 with an HTML
body. Those paths are read by machines: an App Links or Universal Links
verifier reports that as a malformed association file rather than an
absent one, and the same applies to any other well-known document an
operator's tooling probes for.

The absence is now honest — a bare 404 — which is also the correct answer
for "this server has no such association". No association file is served
yet; that waits on the app's signing identity.

Tests cover both directions: the well-known paths must not return the
shell, and /call/<token>, /admin and / still must.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #58.

/auth/refresh read the refresh token only from the httpOnly cookie, which
is right for a browser and unworkable for a phone: media players fetch
audio on their own network stack, so a native client authenticates with a
bearer header everywhere and has nowhere sensible to keep a cookie.

Login with X-Squelch-Client: native now returns refreshToken in the body
and sets neither cookie; refresh reads the body when no cookie is present
and returns the rotated token there. The cookie still wins when present,
so browsers are untouched.

The guard that matters is structural rather than a header check: the raw
token is echoed only to a caller that presented one in the body (viaBody),
so possession of the httpOnly cookie can never be escalated into a
JS-readable 30-day token. Login's guard is weaker — the header is
client-asserted, so it rests on knowing the password — and the asymmetry
is commented at the header check rather than left for a reader to notice.

Logout is part of the same fix, not a nicety: it revoked the refresh
family only from the cookie, and the only other paths that revoke one are
replay detection and the max-families eviction on login. A native client
logging out would have left its family live for the full 30 days. Logout
reads an optional body now, so both route registrations gained the 1 MB
cap login and refresh already carry.

Rotation, family revocation and the replay grace window are unchanged and
covered on both paths — including that a replayed body refresh converges
on the same successor rather than revoking the family, which a phone on a
flaky network will hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #59.

Universal Links and App Links cannot open a self-hosted server's share
link in a generic store build: both platforms bind the app-to-domain
association at signing time, and a squelch instance lives on a domain the
app has never heard of. iOS compounds it by fetching
apple-app-site-association through an Apple-operated CDN, which a LAN-only
or split-horizon instance is invisible to. So the page asks instead, with
a button that hands the token to a custom scheme.

Android gets the intent:// form, whose S.browser_fallback_url keeps a
visitor with no app installed on this page rather than reporting a failed
navigation. iOS gets the plain scheme and has no such safety net — Safari
reports an invalid address when nothing handles it, and the page cannot
detect whether the app is there. That branch is kept separate in the
builder so it can be switched off without touching the Android path.

The token is validated against the UUID shape the share endpoint issues
before it is interpolated, because `;` and `#` are structural in an
intent:// URL: a token carrying either could otherwise rewrite the intent's
package or extras. Encoding alone would cover it today; the check survives
the encoding being dropped by a later edit. Tests cover that hostile
tokens produce no link at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Links

The association-file approach is the one any reader will reach for first,
so the ADR records what actually rules it out for a self-hosted product:
both platforms bind the app to its domains at signing time, iOS fetches
the association file through an Apple CDN that a LAN-only instance cannot
reach, Android's user-granted path is manifest-gated as well, and iOS
forbids ports in applinks.

It also records the two things the implementation cannot fix — the iOS
error dialog when no app handles the scheme, and the fact that any app may
claim a custom scheme — and the wildcard-redirector alternative that does
work, with the reasons it was not taken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md told the next session to commit the regenerated backend/docs/
after editing Swaggo annotations. That directory is gitignored
(.gitignore:13) and rebuilt by `make build`, so the instruction sends
anyone who follows it looking for a diff that never appears. The
annotations in the handler source are the tracked copy; running swag is
how you confirm they parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestSPARoutesStillServeIndex assumed an embedded SPA, which only exists
after a frontend build. CI runs `go test` without one — the embed
directory holds just .gitkeep in a fresh checkout — so serveFrontend
returns early, registers no NoRoute handler, and /call/<token> 404s by
Gin default. The test failed there while passing locally.

Both tests now skip when dist/index.html is absent. That also makes the
well-known test honest: without a NoRoute handler every unknown path 404s
anyway, so it was passing vacuously in CI rather than exercising the
guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds cloudflare/security-audit-skill at c1c8a8c under
.claude/skills/security-audit, with its MIT LICENSE alongside as the
license requires.

Copied rather than installed with `npx skills add`, which would fetch and
run a separate npm package. Reviewed before adding, since it becomes
instructions Claude follows and scripts it runs in this repo:

- The two validators only open, fstat and read the file they are given —
  no writes, network, or subprocesses. The tests write fixtures to
  os.tmpdir(), spawn node against the validators, and clean up.
- The reference docs carry no URLs, HTML comments, hidden or bidi Unicode,
  or instruction-override phrasing.
- The skill is guidance-only by default, writes full-audit output outside
  the target repo, forbids probing live endpoints, and runs target code
  only inside an OS-enforced sandbox.

Its own suite passes from the installed location (65/65).

CLAUDE.md lists it with the two modes, the out-of-repo output path, its
distinction from the built-in /security-review, and the note that a
vendored copy never auto-updates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tighten several server-side checks found in an internal review:

- Apply listener, API-key and downstream system restrictions on every
  path that serves or accepts calls, in both stored grant formats.
- Honour X-Forwarded-For only from trusted proxies (new
  --trusted-proxies option), and reserve login attempts atomically.
- Refuse access tokens issued before the process started; keep revoked
  refresh tokens until they expire.
- Make --admin-password reset the first admin's password as documented.
- Create the database and log owner-only; confine directory-monitor reads
  to the watched directory; reserve conversion output names.
- Bound request bodies, rate-limiter state and MQTT system frames; route
  admin transcription calls through safehttp.
- Pin swag in CI, release and Docker builds and scope the release token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scanner page now follows squelch-mobile's layout: the display's
head (clock, counts, system and tag, talkgroup name) sits in a solid
ink block over a dithered edge, the name is set in Big Shoulders and
shrinks to fit, and the readout, badge row and call clock sit below.
The transport is a large play/pause between replay and skip, the mode
buttons are an even row that wraps 3 + 2 only when it has to, and
recent calls are one-line rows under the controls that replay on tap.
The live transcript follows playback in a three-line window under a
duration-weighted timeline.

Themes are generated from the app's design tokens: Midnight (default),
Graphite, Ember, Moss, Plum, Ash and Squelch classic, the original
pale LCD. The choice is per browser, applied before first render so
every route uses it, and picked from the new overflow menu, which also
holds display brightness, bookmarks, admin and account actions. The
light theme is retired; a stored retired value falls back to Midnight.

Selawik, JetBrains Mono and Big Shoulders Display are bundled as woff2
with their OFL licences. Call duration is read as seconds, which is
what the server sends; the type comment said milliseconds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Put the bookmark and share buttons back where they used to sit: at the
start of the row under the frequency, ahead of the site/decoder text,
so the badge row keeps its flags and call clock unmoved.

Revert reading call duration as seconds. The v1 upload recorders use
stores durationMs, and the mobile app divides by 1000 too; reading it
as seconds showed a 5.8 s call as 96:00.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The original pale LCD is the default again; Midnight and the other
block themes stay one pick away in the theme menu. A browser with no
stored choice, or with the retired squelch-dark/squelch-light value,
gets classic. The pre-load page colour (theme-color and the manifest)
follows it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The display kept changing height as calls arrived: every row of the
readout now has a fixed height, including the talkgroup name, which is
sized down to fit rather than wrapping.

Replay only worked while a call was still playing, which is the opposite
of when it is reached for — the player now keeps the last item and
replays it, and the button is greyed out only when nothing has played.

SELECT gains an Avoids tab listing timed avoids with the time left and a
Resume button. A permanent avoid has no clock to show, so it stays out of
the tab and is turned back on from the talkgroup itself.

Also: Squelch classic draws the transcript timeline, the badges and the
tag chip in its own ink instead of the blue/orange accents; the display's
bookmark and share buttons dim and brighten with no background behind
them and carry tooltips, as do HOLD and AVOID; the transcript scrolls
without a scrollbar; and HOLD/AVOID now follow the error and spike counts
rather than pushing them along the row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The display's bookmark and share buttons took daisyUI's hover colour,
which repaints them in base-content — near-white on the classic theme's
pale panel, where they all but disappeared. Buttons drawn on the readout
now keep the panel's ink in every state.

Their tooltips open above them rather than to the side. The readout clips
to its bezel and the call rows clip to the list, so a tip centred on a
control at either edge lost its first or last words; the two flush
variants anchor it to that edge instead, with the arrow still on the
control.

The remaining browser-native tooltips — share in the search and bookmark
lists, Copy and Open in the share popup — are now the same styled tip as
everything else, in the interface face rather than the readout's mono,
and every tip carries a hairline so it still reads as a tip where it
lands on a panel of its own tone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HOLD and AVOID are dropdown triggers, so their tip is anchored on the
button itself and came out in the button's own bold, wide-tracked,
uppercased label face. The tip now sets its type outright rather than
inheriting it, which covers the readout's mono for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
daisyUI ships its button styles in the utilities layer, so the earlier
override in the components layer lost on hover: the pointer repainted the
bookmark and share marks in base-content — near-white on the classic
theme's pale panel — and raised a button plate under them, an inset
highlight plus drop shadows that read as a circle.

The override is unlayered now, which outranks every layer, and covers
hover, focus and active. The pointer only brings the mark from dim to
full.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The button-press sound sat in the admin options panel, which is the right
place for it on a shared screen in a dispatch office and the wrong place
on someone's own browser: the person listening is the one in the quiet
room, and they are not always the admin. It moves to the scanner's menu,
as it already works on the phone — Off, Uniden or Whistler, kept per
browser, and picking one plays it, since a sound is not something anyone
can choose off a list of names.

Nothing chosen is not the same as off: until the listener picks, the
instance's keypadBeeps is followed, so an operator who set the beeps up
does not have to set them up twice, and the listener's own choice wins
from then on. The setting stays server-side as that starting point and is
set with `squelch config-set keypadBeeps <style>`; the admin panel's
Display section, which held nothing else, is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The choice was kept in the browser that made it, so the same person got
beeps on their laptop after silencing them on their desktop. It belongs
to the listener, so it now lives with the listener: a new preferences_json
column on users, read and written through GET/PUT
/api/v1/listener/preferences.

Precedence is account, then this browser, then the instance's own
keypadBeeps. The browser stays in the picture because an instance may
allow anonymous listening, which has no account to write to, and because
it holds a choice made before signing in. Absent is not off: until
somebody picks, the instance default is followed, and an explicit
"disabled" then outranks it for good.

The endpoint keeps what the body omits, so a client that knows one
preference cannot wipe one it has never heard of, refuses a style no
client can play rather than storing it, and treats a malformed row as
unset rather than failing a listener's sign-in.

The mobile app is unaffected and still keeps its own choice on the
device; making it follow the account too is a separate change there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four documents the project was missing, plus a pass over the existing ones
to bring them back in line with the code.

New:

- CONTRIBUTING.md — setup, the make targets, running one test, the branch
  and commit conventions, the PR checklist and the changelog gate. It points
  at the convention files rather than restating them.
- SECURITY.md — the supported release line, how to report privately, what
  is in scope, and the protections that actually ship.
- docs/listener-guide.md — the listening screen, for the person listening.
- docs/troubleshooting.md — operator failure modes arranged by symptom.

Corrections to existing docs, each verified against the source:

- The deployment guide showed an encryption_key field in the saved JSON and
  said the resolved key was written there. The server refuses to start when
  that field holds a value, so the guide was steering people into a server
  that would not boot. The example now matches what --config-save really
  writes, and the guide covers the refusal and how to clear the field.
- PROJECT_LAYOUT pointed at a make swag target that does not exist and told
  contributors to commit backend/docs/, which is generated and gitignored.
  Its v1 error-code list was missing five codes and had two wrong.
- The testing conventions listed test files at their pre-refactor paths and
  claimed 23 + 13 files against an actual 82 + 36. The enumerated inventory
  is replaced with where tests live; the coverage-gap list is re-derived
  from what is genuinely untested today.
- Nothing parses INI. The config file is JSON, and internal/cli holds the
  remote commands — corrected in five places.
- README: avoid durations are 30/60/120 minutes, and the themes are seven
  dark ones with a per-listener beep and brightness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pausing a call and pressing resume left the audio stopped. The player
still considered the call "playing" while it was paused — pause() sets
_paused but never clears _playing — and resume() gated the re-play on
!_playing, so the branch that calls play() never ran. currentItem was
still set, so the queue branch did not run either. Resume updated the
button and the LED and touched nothing else.

Backgrounding the browser and returning to it was the only way to get
audio back, which is how the bug was reported and what confirmed the
cause: handleVisibilityChange bails on _paused, but by then resume() has
already cleared it, so the foreground stall recovery saw a paused element
and re-issued play(). The stall recovery was doing resume()'s job.

Gate on having a current item and an element instead. play() does not
seek, so the call picks up where it stopped rather than restarting.

Fixed the guard rather than clearing _playing in pause(): isPlaying()
returns that flag and both usePlaybackPosition and useActiveUnit read it,
and getPlaybackTime() returns 0 when not playing — so clearing it would
have snapped the elapsed readout to 0:00 for the duration of the pause.

The regression test also asserts the resume position, so a fix that
restarts the call from zero fails it too. Verified in headless Chrome
against a local build with a 5-minute call: play t=0, pause t=12.581466,
play t=12.581486, then currentTime advancing in real time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pause is about the call playing right now, but it was being written to
sessionStorage and read back into the slice's initial state, so a reload
came up paused. Nothing cleared it on a LIVE toggle either. Either way a
listener could land on a silent page whose only explanation was a
transport button sitting on Resume.

Its two neighbours in the same initial state, backgroundAudio and
streamState, already deliberately do not restore, and say why in comments
right above it. isPaused was the odd one out; it now matches them.

Touching LIVE clears the pause, in both directions. Resetting only on the
off-then-on edge would satisfy the report too, but then the button reads
Resume while LIVE is off — a control describing a state you cannot act
on.

useScanner resumes the player alongside the reducer. Redux alone is not
enough: enqueue() checks the player's own paused flag and would queue
arriving calls instead of playing them, so the store would say playing
over silence. That is the same desync that made resume() look broken in
c6020f0.

Scanner.tsx already called setPaused(false) by hand when leaving the
route, which is the same intent; the reducer now covers it.

Verified in headless Chrome. LIVE cycle: pause at 12.21, LIVE off, LIVE
on, then a newly ingested call played from t=0 and advanced in real time
with an empty queue. Reload: with a stale squelch-paused=true planted in
sessionStorage, the page came up playing and ignored the key, so tabs
still holding the old value are fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for an admin connection list. Nothing user-visible yet.

- internal/connections: a registry of live listener sockets, admin
  sockets and background audio streams, with who, from where (the
  proxy-resolved address and the raw socket peer), user-agent, and the
  refresh-token family behind it. Entries can be closed from the
  registry through the owning transport's normal teardown.
- WebSocket routes now carry the client address gin resolved through
  --trusted-proxies instead of losing it to gin.WrapF.
- Access tokens carry the refresh family they were minted from (fam
  claim), at login and on every refresh, so a connection can be traced
  to the device session that opened it.
- DisconnectByJTI ends every socket opened with the token, not just the
  first, and an empty JTI no longer matches an anonymous listener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deployment guide now walks through narrowing --trusted-proxies to
the proxy itself: where to find its address (including the Docker
gateway case), how to confirm it in the request log, and why the
default private ranges let a LAN device fake its address. Adds setups
for Nginx Proxy Manager, Traefik, Apache and Cloudflare, strips a
visitor-supplied X-Real-IP in the Caddy example, and corrects the
WebSocket paths.

Fixes the "bind to localhost" tip, which set SQUELCH_LISTEN to
127.0.0.1 inside the container and left the published port with
nothing to reach.

Troubleshooting gains "everyone is locked out at once", the symptom of
an unconfigured proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Admin -> Connections now covers the whole connection lifecycle:

- History of every connection, kept after it ends, filterable by user or
  address, with the reason it closed.
- Disconnect a connection, sign out one device, or sign an account out
  everywhere. Disabling an account signs it out too. Each action is
  written to Admin -> Logs.
- Block an address or range, optionally with an expiry. A block applies
  to every route, closes the connections it covers, and is refused for
  loopback, ranges wider than /16 or /48, and anything overlapping the
  server's --trusted-addresses list, which only the host can change.
- Show each connection's country from an operator-supplied MMDB file
  (--geoip-db). No address leaves the server; the page shows the credit
  the loaded database asks for.

ADRs 0008 and 0009 record why blocks are global with a host-only trusted
list, and why country lookup is local only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the per-row ⋯ menu and window.confirm prompts with a details panel
that slides in from the right (a bottom sheet on phones). It shows every
fact known about the connection or device, links to its history, and each
action with an inline confirmation that says what it will do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…m bar

Regroup the admin into Overview, People & access, Radio data, Ingest &
delivery and Server; list Webhooks and give Trunk Recorder its own page.
Add a Ctrl K palette, a live socket status in the top bar, a bottom bar
with a More sheet on phones, and one toast for the whole admin.

Add the primitives every page will be rebuilt on: DetailsPanel (side
sheet, bottom sheet on phones), InlineConfirm, ActionButton, DataTable
(sort, paging, selection, card layout on phones), FilterChips, SearchBox,
PageHeader and form fields. Connections is the first page on them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Users page now shows who can sign in, what they can hear and where
they are signed in: a sortable table with status, systems, live
connections, devices and last seen, a search box, status chips and a
details panel with every action and an inline confirmation. Bulk sign
out, disable and delete skip the primary admin and yourself.

Admins can reset a user's password from the panel, ask for a new one at
the next sign-in and sign every device out at once. Changing a password
from the admin was silently ignored before; users.update now stores it.
New users default to "require a new password at first sign-in".

Sign-in lockouts from the login limiter are listed on the page and can
be cleared (lockouts.list / lockouts.clear).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Groups & tags is two lists edited where they are: an add row at the top,
rename with the pencil (Enter saves, Esc cancels), and a usage count per
label that links to Systems filtered to those talkgroups. Deleting an
unused label offers a ten-second undo; deleting one that talkgroups still
use used to fail on the foreign key, and now asks where they should go and
moves them first (groups.delete / tags.delete take reassign + moveTo).
groups.list and tags.list return the usage count; create, rename and
delete are written to the audit log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ecret

API keys are listed by label with status, systems by name, calls in the
last 24 hours and when and from where the key last authenticated. A key's
details offer rotate, disable, enable and delete with in-place
confirmation; creating or rotating shows the secret once with a curl test
command and a Trunk-Recorder plugin entry. Keys still hitting the
deprecated /api/* path carry a badge and an explanation.

Backend: api_keys gains created_at, last_used_at/ip and a previous_key
with an expiry (migration 026); calls gain api_key_id so uploads can be
counted per key. APIKeyAuth accepts the previous secret until its grace
ends and records last use at most once a minute. New apikeys.rotate op;
create requires a label; every change is written to the audit log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Shared links now show how many times each public page was opened and when
the link expires (its own date or the server-wide lifetime), with expired
rows dimmed, filterable and revocable in one go. A link's details offer
copy, open and revoke with a ten-second undo that restores the same
token, and revoke errors are surfaced instead of swallowed.

Backend: shared_links gains opens and last_opened_at (migration 027),
counted when the public page fetches the call; shared-links.list adds
userId, opens, lastOpenedAt, effectiveExpiresAt and expired; new
shared-links.restore and shared-links.revoke-expired ops; revoke and
restore are written to the audit log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
OpenScanner Dev and others added 28 commits September 25, 2026 10:59
One style layer, frontend/src/admin.css, gives the DaisyUI classes the
mockup's look inside the admin (.admin-ui). It sits in a utilities.admin
sublayer after DaisyUI's, so it beats DaisyUI and loses to a utility class.

- IBM Plex Sans and Mono, vendored with their OFL licences, 14 px base.
- Bordered 40 px buttons with a gold primary, 32 px small buttons, red
  danger; page actions and panel footers are always full size.
- 4 px tinted badges, gold tab underline, green switches, native square
  checkboxes with a gold accent, 40 px fields, uppercase table headers,
  tinted notices for alerts.
- Colours are roles mixed from the picked theme; Midnight pins the
  mockup's exact values. The roles are also Tailwind colours
  (border-admin-line, bg-admin-ok-bg, ...).
- DataTable: every header drawn the same (sortable ones were small bold
  DaisyUI buttons), a card frame, "Showing x–y of n" with Previous/Next,
  and on a phone each row is a two-column card of labelled values.
- Chips, search box, open button, page header, details panel, toast,
  palette and inline confirm follow the mockup's shapes.
- Secondary text and rules across the admin use the theme's dim colour and
  the admin line colour instead of opacity utilities.

ADR 0010 records the decision.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Sections regrouped to the mockup: Overview holds Trunk Recorder and Logs
  & audit; People & access gains API keys and Shared links; Radio data
  gains Transcription. Systems is "Systems & talkgroups" (page title too).
  Icons follow the mockup.
- Brand block (SQ mark, "Squelch", "Admin · <host>"), 11 px uppercase
  group headings, the current page in navy with a gold bar, a 76 px rail
  between md and lg, and Open scanner / Sign out with the username in the
  footer.
- Top bar: a search field that opens the palette (Ctrl K) and the socket
  status as a dot and text; the account menu is gone. On a phone a menu
  button opens every section in a bottom sheet.
- Phone bottom bar in the mockup's style with short labels (Systems, Logs)
  and gold for the current section.
- Palette restyled: "Search", one row per page with its kind.
- Table cells wrap at word breaks, so a narrow window scrolls the table
  instead of splitting names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… and downstreams

The top bar's search (Ctrl K) now lists matching users, talkgroups, API
keys, settings, folder monitors and downstream servers after pages, each
with its kind on the right, as the mockup draws it. Two letters are needed
before entities show, and each kind is capped at six rows.

A result opens the item's details through its page: a new _shell hook,
useOpenParam, opens the row named by ?open=<id> once the page's rows have
loaded and then drops the parameter. Users, API keys, Folder monitors,
Forwarding and Systems (with ?system=) use it. A setting links to
Settings with ?q=, which the page now follows even when it is already open.
The settings catalogue reaches the palette through the settings barrel, so
_shell still imports no sibling feature.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…redesign

Users: role, sessions and last sign-in in the table, an Admins chip, and a
details panel with who is live now, See devices, and the mockup's actions.
Sign-in lockouts move into the shared Card (lockouts are per address, so
the mockup's per-user "locked" badge cannot exist).

Connections: the page header, tabs, search and all four tabs use the shared
primitives, so tables become cards on a phone. Rows name the browser and
system from the user agent. History filters by range and type with chips.
Removing a block confirms inline instead of window.confirm. A link can open
a tab with a filter (?tab=&q=).

API keys: mockup columns, lowercase status badges, and editing inside the
details panel (label, systems, rate limit) with Copy test command, Disable,
Rotate and Delete. The page text says keys go as Authorization: Bearer,
which is what /api/v1 accepts.

Shared links: Copy link, Listen and Revoke (with Undo) on each row, Revoke
expired in the header, and a footnote with the expiry setting. Durations
are milliseconds; the page had read them as seconds.

Shared: Card, Notice, SystemPicker ("All systems" chip), useHour12,
useNow (moved from connections), formatDay/formatWhen, a "wide" phone
cell in DataTable, and PanelSection as a named region.
Durations and relative times are written as the redesign writes them
("2 h 41 m", "3 min ago", "in 28 d"); the connections copy of the
duration formatter is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The catch-all route redirected to the relative path "overview", which
resolves under the unmatched path, so /admin/groups-tags became
/admin/groups-tags/overview, then .../overview/overview, until the tab
hung. Redirect to /admin/overview.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The redesign drew the brand as "Squelch" in IBM Plex beside a boxed "SQ"
mark. The wordmark is the product's identity and stays the original sign
face in capitals: SQUELCH on the full sidebar, SQ on the narrow rail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Systems & talkgroups: the system header gives its number, counts and last
call; the talkgroup filters, the unlabeled chip and Add talkgroup share
one row; unlabeled talkgroups read "TG n" with a badge; a talkgroup's
LED colour follows its name. The talkgroup panel opens with three stat
tiles and picks the LED from colour chips. Blocked talkgroups are chips
with the number field below.

Groups & tags: each list is a card with a table (Rename, Show, Delete per
row, add field at the foot) that stacks as cards on a phone, with a note
on how deleting an in-use label works.

Transcription: the connection line is a tinted notice with mono values,
the figures are stat tiles, settings put the control on the right, the
save bar is sticky in the page instead of fixed over the sidebar, model
downloads show their percentage, and jobs show the call's time.

New shared parts: StatTile/StatGrid and SettingRow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ark ADR

- Transcriptions are counted by when they finished and calls by when they
  were made, so a catch-up or a retry can pass 100%. The tile says it
  includes older calls instead of showing, say, 230%.
- Server settings' fixed save bar still used the old sidebar widths.
- ADR 0011 records that the SQUELCH wordmark keeps its sign lettering in
  every design.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Folder monitors: rows read Folder (with file type and options under it),
Recorder, Sends to ("From filename"), Status, Last file and Calls 24 h.
The details panel says the recorder and state under the path and leads
with a red notice when the monitor is stopped. The add form picks the
recorder from chips.

Forwarding: the first tab is Downstream servers; each tab opens with a
line on what it does and its Add button. Rows read Server, Systems,
Status (delivering, sending or failing), Last delivery with its time or
response code, and Failed 24 h with the last error. A failing target's
panel leads with a red notice, and the address sits under its name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sign

Settings lists its groups in a sticky rail that marks the section in
view. Logs shows a clock time and a coloured level dot, with the same
dots on the level chips; Refresh is gone since Following reloads.
Trunk Recorder's banner, tiles and Add instance follow the shell, and
its dashboard is two cards: the decode-rate chart and a Per system
table with a Health column. Backup & import sets the configuration
backup beside the Radio data table, whose header picker chooses the
system for talkgroup and unit exports.

Shell: formatClock, a dot on filter chip options, and DataTable's
bare (inside a Card), headerHidden and phone "plain" options.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s scroll

Health read "ok" from the last decode rate even after the broker
dropped or the frames stopped. It now reads "no feed" while the broker
is down and "no recent rate" once rate frames are 30 s old (or three
plugin intervals). The banner's "last frame" age was computed from a
clock in seconds against frame times in milliseconds; it is in ms
again, and a test pins "last frame 5 s ago".

The settings rail's observer attached while the page was still loading
and never saw the sections. It is now a scroll listener that marks the
last group whose top passed the line under the top bar, marks the last
group at the bottom of the page, and lets a click hold for a second.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… time

activity.chart and activity.top-talkgroups take {"range": "24h" | "7d"
| "30d"} (24h when absent; anything else is refused). activity.stats
adds callsYesterday, counted up to this time of day so a morning is not
compared with a whole day, lastCallAt, startedAt and the server version.
Busiest talkgroups carry their system id and talkgroup number, so the
Overview can open the talkgroup's panel.

On 600k calls the 30-day chart and busiest-talkgroups queries take about
0.12 s and 0.25 s; the Overview refreshes the longer ranges on a timer
rather than on every call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The legacy usage report keeps six characters of a key's label, and the
API keys list matched that against the whole label, so a key named
longer than six characters always showed no legacy use; "TR-Lake-North"
and "TR-Lake-South" would also have shared one count. The report now
records the id of the key that made each request (entries carry
apiKeyId) and the list counts by id.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The report handler copied each entry into its own struct and dropped the
key id added in the previous commit, so keys whose idents share a prefix
still merged into one row. Also drop the package comment's link into
local planning notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Overview becomes the admin's landing page from the mockup: a Needs
attention card built from the monitors, storage, forwarding, Trunk
Recorder, transcription, legacy usage and users the shell already loads;
service health pills; five linked tiles (calls today against the same
time yesterday, the week, listeners now, transcribed 24 h, uptime); a
calls chart and Busiest talkgroups over 24 h, 7 d or 30 d kept in the
link; and the last four audit lines. The shell loads the sources once and
shares them, so the sidebar count badges and the top bar's "ingest N min
ago" read the same data. The legacy-usage banner and the old activity
dashboard are removed; their rows live in Needs attention.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The identifiers line wrapped mid-list and left a stray separator, and the
rate wrapped onto two lines, because the table was squeezed into half a
page it could not fit. Identifiers are now one line that never wraps,
written as RadioReference does (sysid 2EE), the rate and control channel
stay on one line, and the table uses the mockup's 10 px cell padding.
The chart and table sit side by side only when the table fits its half
(a container query) and stack otherwise. The decode-rate chart is drawn
at its card's measured width, so stacking stretches the line instead of
scaling its labels up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Classic, the default theme, kept Material's saturated green, sky blue and
signal red while the other six themes sit far calmer. Primary and success
become #6a9a6c, accent and info #6fa3c4, error #d9675e. The greys, the
pale display panel and the LED colours are unchanged. Every accent reads
at 4.8:1 or more as text on the page and card backgrounds, and dark ink on
each fill at 5.3:1 or more.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The plugin entry shown with a new secret was written only for
Trunk-Recorder's built-in rdio-scanner uploader, which posts to the
deprecated /api/call-upload that Overview then flags. It now defaults to
the Squelch uploader (libsquelch_uploader.so, one apiKey for the plugin,
posting to /api/v1/calls), with a chip to switch to the built-in
uploader, which is marked as using the deprecated path.

The test command printed nothing on success because /calls/test answers
204 with no body, and the hint said to expect 200. It now prints the
status code; the hint names 204 and 401. The recorder guide covers the
Squelch uploader first, then the built-in one, then folder monitoring.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Found on the live server with real data:

- go-whisper's deployed build answers GET /api/whisper/model with a bare
  array, not the documented {"object":"list","models":[...]} the stub
  used, so the connection banner said "Not connected" and Models failed
  while transcription ran fine. parseModelList reads both; the Models op
  returns a UserError so the tab shows the reason.
- A 50-character system name held the Busiest talkgroups System column
  at full width (whitespace-nowrap), wrapping talkgroup names by letter
  and pushing Calls off the card. The column shows from a 560 px card
  (container query), capped and truncated with a title; narrower, the
  system is a line under the talkgroup.
- Uptime's restart date keeps to one line; banner text in Transcription
  and Trunk Recorder may break a long URL instead of running under the
  button on phones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A read-only pass over the live server found pages that broke with real
data:

- Systems: a long talkgroup name set the label column's width and pushed
  Calls 24 h and Last heard out of the pane. The label is capped (wider
  in a wide container) and the table's cells sit closer.
- Users, API keys, Shared links: "3 live · 20 devices", sign-in times,
  "created <date>" and the row buttons broke mid-phrase; they now wrap
  only at the separator.
- Phone cards: the right column took a long value's full width and
  squeezed the left to one letter; it now stops at half the card.
- Backup & import: the RadioReference picker ran off a phone screen.
- Overview chart: edge tick labels anchor inward instead of clipping.
- Settings: the oldest call's date appeared twice past 90 days.
- Trunk Recorder: the decode-rate buffer held 300 samples, 15 minutes at
  the 3 s publish interval, under a "last 5 min" title. It is trimmed by
  time now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On live data the talkgroup table still needed 840px beside the system
list's 785px pane: group and tag names set their columns' minimum width
by their longest word. Under an 860px container the Tag column gives way
and each tag shows under its group, as Busiest talkgroups does with its
System column.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the group

Enrich from RadioReference opened the ordinary talkgroup import, so a
statewide export (6,649 talkgroups for Ohio MARCS) offered to create every
talkgroup the system didn't have. With `enrich`, the wizard keeps only
rows the system already has, says how many it left out, and points to
Import for adding them.

The review's counts now follow the mode: in "Fill in blanks only" a
talkgroup whose fields are already set counts as unchanged instead of as
a change the apply would never make.

The direct import endpoint (v1 and the deprecated /api path) mapped both
Tag and Category to the tag, so a RadioReference file lost its Tag column
and got no group. Category is the group, as the wizard's parser and
rdio-scanner read it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Overwrite sets order

- The configuration backup carries the Trunk Recorder brokers
  (tr_instances), passwords as stored. Restore matches them by label,
  refuses passwords this server cannot decrypt, removes absent ones on
  Replace, and reconnects or disconnects the live MQTT clients after
  commit. The restore review has a Trunk Recorder brokers row.
- Replace counts the talkgroups and units a removed system takes with
  it, as the review lists them.
- The import wizard's message counts rows the review showed unchanged
  or that were unticked, instead of "0 unchanged".
- Overwrite updates order for talkgroups and units; the review lists it
  and Fill leaves it alone. The option reads "Overwrite with the file's
  values".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… one line; drop display brightness

- Trunk Recorder › Messages: By opcode counts from running tallies in the
  slice instead of the 500-message live list, which a busy control
  channel fills in seconds. Live shows 20 per page.
- Logs & audit: the level chips read `logs.counts`, a new admin op that
  counts the in-memory log by level over the range and search, instead of
  tallying the loaded lines (at most 500, one level).
- Shared links: the Shared by column no longer wraps.
- Scanner: Display brightness and useLcdBrightness are removed; the
  readout always draws at full strength.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A busy control channel sends several messages in one millisecond with
the same opcode and system, and the Live table keyed rows on exactly
those. React cannot remove rows with duplicate keys, so they piled up
(655 rows in a 20-row page on live) and then sat above the opcode rows
after switching back, which looked frozen. Every message, unit event
and recent-call entry now gets a sequence number from the slice, and
Live, Units and Calls › Recent key on it. The two tables in Messages
and in Calls are keyed apart so switching views starts a fresh table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A listener that closes the tab or locks the phone leaves without a close
frame, so the read loop saw EOF or a closed connection and logged it as
`ws: read error` at warn. Those are ordinary; they now log at debug as
`ws: connection dropped`. Other read failures still warn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Move this cycle's entries from [Unreleased] into [3.1.0] and add the
comparison links.

Minor rather than major: upgrading from v3.0.0 needs no manual step.
The migrations run on start, and the security tightening changes what
restricted listeners and keys can reach without asking anything of the
operator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Fragmented MP4 (frag_keyframe+empty_moov) leaves the moov empty and puts
the index in an mfra box at the end. Media3's DefaultExtractorsFactory
always sets FLAG_READ_MFRA_FOR_SEEK_MAP, so the Android player fetched
every Call as a 200 and three 206s: the start, the mfro in the last 16
bytes, the mfra, and back to the audio.

+faststart moves a complete moov in front of mdat, so a player reads the
file in one request and knows the duration from the header. The reason
fragmentation was chosen, blob-URL playback on Mobile Edge, went away
when the web player moved to <audio src=...> in #27.

Only the container changes: the codec, bitrate and MP3 presets are as
they were, and existing recordings are left alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@revtex
revtex merged commit b7d5744 into main Sep 29, 2026
11 of 12 checks passed
@revtex
revtex deleted the dev branch September 29, 2026 04:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant