Persist the path-hash byte width (1/2/3) as a nullable Integer column on
RawPacket, computed at ingest by the collector and backfilled for historical
rows via a self-contained Python migration. Replace the per-request Python
decode loop in the grouped-list route with a SQL MAX() aggregate + HAVING
filter, and add a discrete <select> filter (Any/1B/2B/3B) to the /packets
SPA page.
- Model: add path_hash_bytes column to RawPacket (nullable Integer)
- Migration: batch_alter_table add_column + keyset-paginated Python backfill
reading decoded via Core select() on sa.JSON column (portable across
SQLite and Postgres)
- Collector: compute path_hash_bytes at ingest via two-tier path-hash
extraction (decoded.path -> payload.decoded.pathHashes)
- API: add func.max() aggregate to group query, HAVING filter on
?path_hash_bytes=1|2|3 param; delete Phase 3 decode loop and dead helper
- Frontend: add path-width select filter wired through query/apiParams/
pagination/headerParams; add i18n keys (en/nl)
- Tests: 1186 passed, 22 skipped; collector + API + model coverage
- Add reusable lit-html JSON tree component (json-tree.js) with
expand/collapse-all toolbar and type-coloured primitives; replace
flat <pre> decoded blocks on packet detail and packet group detail
- Render packet path as a complete flow with static sender (green dot)
and observer (satellite dish) terminators around hop badges
- Fill homepage chart areas and move legend to top-right to match
dashboard charts
- Add iconChevronRight helper and expand_all/collapse_all strings (en, nl)
Replace raw integer counts with Intl.NumberFormat()-grouped numbers
across all SPA pages — stat cards, dashboard stats, list-page total
badges, inline reception/observer counts, chart axis ticks and
tooltips, map counts, and packet-group-detail fields. Formatting uses
the visitor's browser locale (no explicit locale argument), decoupled
from the admin's datetime_locale.
Redesign the filter panel on all five filter-bearing pages (nodes,
packets, advertisements, messages, map): replace the heavy DaisyUI
collapse card with a compact right-aligned toggle slider plus bare
filter fields rendered below the control row. Filter open-state
survives auto-refresh and navigation via DOM-read of #filter-toggle.
Also fixes a pre-existing bug where pubkey_prefix was missing from
nodes.js hasActiveFilters, causing the filter to not default open
when only the public-key-prefix field was filled.
Recent adverts card now shows route-type badges, an observer column
with three-way fallback (observers -> observed_by -> dash), and renders
all 10 rows instead of 5. The Type column hides on mobile to prevent
overflow, and the misleading cursor-help on observer badges is removed.
Also adds a Packets chart card to the dashboard and a packets stat to
the homepage, backed by the new dashboard packet-activity endpoint.
Promotes routeTypeBadge to a shared export in components.js (previously
local to advertisements.js), and enriches RecentAdvertisement with
route_type, observers, and observed_by fields.
Backend resolves observers via fetch_observers_for_events and
observer_node_id -> public_key in batched queries.
The two-column mobile nav set display:block on #mobile-nav (an ID
selector) to override .menu's display:flex so column-count worked.
That ID specificity also beat DaisyUI's closed-state display:none
rule, so the menu stayed display:block while 'closed' (only opacity
dropped to 0). The invisible, two-column-wide overlay captured clicks
over underlying page content and fired SPA navigation.
Scope display:block (and the column layout) to the dropdown's open
state (.dropdown-open / :focus-within) so DaisyUI's display:none can
hide the menu and its links when closed.
- Replace panel-glow radial gradient with panel-accent flat tint + 5px
colored left border for section identity
- Add theme-aware --panel-tint-strength/--panel-tint-bg variables: dark
mode keeps 8% colored tint, light mode uses neutral grey fill (0%)
- Remove inline section color from stat-value numbers so they inherit
base-content (white/black) while icons and borders keep section colors
- Fix hero layout: center welcome text vertically, drop spacer div,
add flex-col to content wrapper
- Split mobile nav dropdown into two balanced columns (column-count: 2)
with responsive width to reduce vertical height on phones
- Soften flash banner in light mode with direct amber oklch values to
avoid hue drift from low-chroma color-mix
- Boost stat-title/stat-desc opacity from 60% to 80% in light mode for
better contrast on grey panels
- Dashboard chart cards now use section colors instead of neutral,
subtitles bumped from opacity-70 to opacity-80
Replace the OS system font stack with IBM Plex: the variable-weight
sans (100-700, one file per subset) for UI and headings, and Plex Mono
400 for the public keys, packet hashes, and hex that font-mono renders
across the app. Latin + latin-ext subsets only (shipped locales are
en/nl); no italics; mono is never rendered bold here.
Fonts are self-hosted from @fontsource packages via the existing
build.js vendor pipeline (no CDN), copied to static/vendor/fonts/ with
a hard build failure on wrong filenames. Wiring:
- input.css: @theme --font-sans/--font-mono + @font-face rules with
unicode-ranges taken verbatim from the package CSS; Tailwind v4
derives the document default from --font-sans, re-fonting daisyUI
components with no other changes.
- spa.html: preload the latin sans variable woff2 (crossorigin, URL
identical to the @font-face src) to minimize FOUT.
- charts.js: Chart.defaults.font.family to match (Chart.js otherwise
uses Helvetica/Arial).
- error.html: name-prepend only; the page stays dependency-free.
- middleware.py: long-term immutable cache for /static/vendor/fonts/
(stable names referenced from CSS, so ?v= versioning can't apply),
with a matching test.
- app.css: slight hero-title letter-spacing tightening for Plex at
display sizes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The channel cards, member node badges, and member URL text are
clickable divs/spans, so keyboard users could not reach or activate
them. Add role/tabindex, Enter/Space key handling, and a
focus-visible outline (invisible to mouse users). daisyUI btn/link/
input components already ship focus-visible outlines, so only these
custom elements needed it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Page <h1>s: text-3xl font-bold everywhere (packet-detail,
packet-group-detail, channels were text-2xl; channels icon bumped to
match).
- Page-header rows: mb-6 on all pages (list pages used mb-4).
- Empty states: text-center py-8 opacity-70 (members used py-12,
channels py-10 opacity-60).
- QR backings and adopted-node rows: rounded-box like other
panel-level surfaces; QR padding unified at p-2.
- Home hero welcome text: max-w-[90%] sm:max-w-[70%] so narrow screens
don't wrap into a skinny column (desktop unchanged).
- Document the styling conventions in the components.js header so
future pages don't drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Zero-visual-delta cleanups:
- Drop input-bordered/select-bordered (removed in daisyUI v5; emitted
no CSS) from all inputs and selects.
- Rename bare `shadow` to `shadow-sm` — in Tailwind v4 bare `shadow` is
a deprecated alias with the identical value.
- Hoist the hardcoded map marker hex colors into CSS variables in the
app.css palette block (same values; markers sit on map tiles and stay
theme-independent by design).
- Convert the five text-base-content/* outliers to the repo's dominant
opacity-* muted-text idiom (identical rendering on plain text).
- Remove a dead ternary in renderNodeDisplay and an unused iconLock
import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The channels modal still used daisyUI v4's label-text class, which was
removed in v5, so its form labels rendered unstyled. Replace with the
Tailwind equivalent of what v4 produced (text-sm, muted).
.prose a:hover used --color-primary-content (the on-primary foreground),
making hovered markdown links nearly invisible against the page
background. Blend primary toward base-content instead so the hover
shade works in both themes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FastAPI 0.137.0 refactored include_router to keep included routers as
nested objects rather than flattening their routes into app.routes, so
test_app_factory's `{route.path for route in app.routes}` no longer found
the /metrics route (the endpoint still serves; only this introspection
broke). FastAPI now treats router.routes as an internal implementation
detail.
Switch the metrics route checks to the public OpenAPI schema
(app.openapi()["paths"]), which is stable across versions and resolves
router prefixes correctly, and drop the <0.137.0 pin that was blocking
the upgrade.
Verified: app factory tests pass on both 0.136.3 and 0.137.2; full
tests/test_api suite (462 tests) passes on 0.137.2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restrict which remote observers may ingest events, keyed on the observer's
public key (the <public_key> segment of its LetsMesh upload topic). Anyone
with broker access can publish as an observer via JWT auth, so operators can
now gate ingestion.
- New ObserverFilter (case-insensitive prefix matching, allowlist overrides
denylist, accept-all when both empty)
- New OBSERVER_ALLOWLIST / OBSERVER_DENYLIST collector settings, wired through
the CLI, run_collector, create_subscriber, and Subscriber
- Filter applied at the top of _handle_mqtt_message: blocked observers' packets
are dropped before any decode, raw-packet capture, or DB write; zero added
work on the default accept-all path
- Tests: ObserverFilter unit tests, subscriber drop/allow integration tests,
config parsing tests
- Docs: configuration.md, observer.md, upgrading.md (v0.16.0), .env.example,
docker-compose.yml
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The spam-detection change wrapped the message table cell content in a
multi-line div/span so the spam badge could sit alongside the text, but left
white-space: pre-wrap on the td. The template literal's own indentation
(newlines + spaces around the div) then rendered as literal whitespace inside
the pre-wrap cell, padding out every row regardless of spam state.
Move pre-wrap onto the span that holds the message text so multi-line bodies
still wrap while the cell's layout whitespace collapses normally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make FEATURE_SPAM_DETECTION on by default with opt-out, mirroring
FEATURE_PACKETS: flip the web feature flag's Python default to true and the
Compose substitutions (collector/api/web) to :-true, so the shipped stack
scores and hides likely-spam without configuration. Opt out with
FEATURE_SPAM_DETECTION=false.
Update .env.example, docs/configuration.md and the v0.15 upgrade notes to
describe the feature as enabled-by-default with opt-out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Messages page badged a row as spam at a hardcoded score >= 0.6 while the
API hides rows at SPAM_SCORE_THRESHOLD (now 0.65), so messages scored in
[0.6, 0.65) appeared flagged but were never hidden by the "show potential
spam" toggle.
Expose spam_score_threshold in the SPA config and use it for the badge so a
row is badged exactly when the API would hide it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adjust the default spam-scoring knobs across Python settings, SpamConfig,
docker-compose, .env.example and docs to reduce false positives on chatty
legitimate users:
SPAM_MIN_PATH_HOPS 5 -> 3
SPAM_PATH_THRESHOLD 5 -> 6
SPAM_NAME_THRESHOLD 5 -> 10
SPAM_WEIGHT_PATH 0.7 -> 0.75
SPAM_WEIGHT_NAME 0.3 -> 0.25
SPAM_SCORE_THRESHOLD 0.6 -> 0.65
Also document the spam-detection feature and the pull_policy change in a
new v0.15.0 section of docs/upgrading.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guard the optional `_spam_rescore_thread` with an `is not None` assert before
calling `.is_alive()`, matching the sibling test. CI runs `pre-commit run
--all-files`, so mypy checks the test files too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>