The map endpoint fetched a single limit=500 page (the API's hard max,
sorted last_seen desc), so networks with more than 500 nodes silently
hid stale/offline nodes from the unfiltered map — including adopted
infrastructure, which still appeared under the adopted_by filter
because that filter narrows the query before the limit applies.
Follow offset/total pagination via a new _fetch_all_pages helper (40
pages / 20k nodes safety cap, partial results on later-page failure)
for both /api/v1/nodes and /api/v1/user/profiles, and report the
API-reported total in debug.total_nodes.
The meshcore-mqtt-broker destroys subscriber connections (keep-alive
timeout) without sending FIN/RST, leaving a half-open socket that paho
never sees as disconnected: the collector stays "healthy" but silently
stops ingesting forever.
- Enable TCP keepalive (idle 60s / intvl 30s / count 3) on every
connect, unwrapping paho's _WebsocketWrapper to reach the raw socket;
kernel now fails zombie sockets so paho reconnects and resubscribes
- Verify SUBACKs via on_subscribe; log broker refusals (0x80) as ERROR
- Resubscribe with the registered QoS instead of paho's QoS 0 default
- Drop the optimistic _mqtt_connected flag; health now reports
mqtt.is_connected, driven by real CONNACK/disconnect callbacks
PR #348 changed several defaults but left a handful of docs, the
docker-compose fallback, example seed files, and code docstrings stale:
- docker-compose.yml: RAW_PACKET_RETENTION_DAYS fallback 7->2 (the
:-7 override was the most consequential — operators without the env
var set got 7 days, contradicting the code default and .env.example)
- docs/configuration.md: route-history backfill now references the
300 s evaluator cadence (was 60 s)
- docs/seeding.md + example/seed/routes.yaml: window_hours 48/24 -> 6
(matches the new default; both exceeded the 12 h max)
- routes.py docstring: evaluator tick 60 s -> 300 s
- subscriber.py: raw_packet_retention_days signature defaults 7 -> 2
(overridden at runtime by effective_raw_packet_retention_days, but
kept consistent with the authoritative config default)
PR #348 lowered the RAW_PACKET_RETENTION_DAYS default 7->2, but the
dashboard packet count, activity chart, and breakdown chart still
advertised "Last 7 days" — a label that lied once only 2 days of data
remained. Make every raw-packet-derived window track the effective
retention so labels stay honest:
- get_dashboard_stats: packets_7d window = min(7, retention); expose
packets_window_days on DashboardStats so the UI renders the real window
- packet-activity / packet-breakdown: clamp days = min(days, 90, retention)
so responses never advertise days whose data has been purged
- Home StatCard + Dashboard chart subtitles: dynamic "Last N days" /
"Per day (last N days)" via new time.last_n_days / time.per_day_last_n_days
i18n keys ({{n}} interpolation, matching routes_over_last_n_days)
- ActivitySeries.days made optional (type shared across activity charts);
PacketBreakdown.days added (backend always serializes it)
Messages/ads 7-day displays are unchanged — those draw on event data
which retains the full 30-day DATA_RETENTION_DAYS window.
Tests: autouse retention fixture (->90) on the three packet test classes
so existing days-handling tests stay valid; added retention-clamp tests
proving the window tracks retention=2 and relaxes to 7 at retention>=7.
The route evaluator's fetch_candidate_paths triggered a 10-30s full-table
scan of packet_path_hops per route per sweep, flooding the slow-query
log and blocking route saves on the same scan. EXPLAIN analysis (Merge
Join vs forced Nested Loop, across query rewrites, ANALYZE, CTE
MATERIALIZED) confirmed both candidate plans cost ~10-15s at 6.4M rows;
no SQL rewrite or stats change could avoid the scan. The fix applies
four orthogonal data-volume pressures:
- ROUTE_EVALUATOR_INTERVAL_SECONDS default 60->300 (5x less frequent)
- Remove synchronous _reevaluate_route on POST/PUT; route_result and
recent_matches now refresh on the next background sweep instead of
inline (kills save latency)
- Route.window_hours default 48->6, max 12 (bounds the candidate set);
migration clamps pre-existing routes >12 to 12
- Covering index ix_packet_path_hops_raw_packet_id_position INCLUDE
(node_hash, packet_hash, event_hash, received_at, observer_node_id)
so the Merge Join outer scan becomes index-only
- RAW_PACKET_RETENTION_DAYS default 7->2 (hops cascade-delete with raw
packets; smaller table cuts the dominant outer scan)
Migration a59611449e2a clamps window_hours and rebuilds the index
CONCURRENTLY on PostgreSQL (SQLite is a no-op; no INCLUDE support).
DESTRUCTIVE on upgrade: the retention default change purges ~5 days of
raw packets + cascaded hops on the next cleanup run. See
docs/upgrading.md for the full change notes and the new save/sweep
contract.
Playwright browser binaries on this NixOS system are managed by
nixpkgs playwright.browsers (currently 1.61.1, browser revision
1228). Renovate bumped @playwright/test to 1.62.0 (revision 1234)
which has no matching browser binary in the Nix store.
Pin to exact 1.61.1 (no caret) and disable Renovate updates for
this package via renovate.json packageRules.
Vite 8 replaces Rollup with Rolldown as the bundler engine. This
requires migrating build.rollupOptions to build.rolldownOptions and
the removed object-form manualChunks to the new codeSplitting.groups
API.
Migrate vite.config.ts:
- rollupOptions -> rolldownOptions
- manualChunks object -> codeSplitting.groups with RegExp test patterns
(vendor: react/react-dom/react-router, i18n: i18next packages)
@vitejs/plugin-react@6 requires vite@^8 as peer dependency, so both
upgrades must land together.
Verified: npm run build succeeds (chunks: vendor 230KB, i18n 55KB),
tsc --noEmit clean, 339/339 vitest tests passing.
TypeScript 7 removes the 'baseUrl' compiler option and requires
'paths' entries to be relative (leading './'). Update tsconfig.json
accordingly. The '@/alias is also resolved by Vite's resolve.alias
in vite.config.ts, so only tsc --noEmit was affected.
Verified: tsc --noEmit clean, e2e typecheck clean, 339/339 vitest
tests passing.
Replace arrow with dash in the matched/threshold/degraded stat
(0/2→6 becomes 0/2-6) to save horizontal space and prevent the
stats row from wrapping on mobile.
Change the last history chart label from 'Last XXh' to 'Now' for
clarity and brevity.
Add 'now' key to en.json ('Now') and nl.json ('Nu'); remove the
now-dead 'last_n_hours' key from both locale files.
Add tests for the dash separator format and the 'Now' label.
Previous min-w-0 on the card grid item prevented the card from growing
but did not clip content that overflowed the card boundary (Chart.js
canvas, flex items with min-width:auto inside card-body). The overflow
was still visible beyond the card edge, causing page-level scroll.
Add overflow-hidden to card-body — this both resolves card-body's own
min-width:auto to 0 (per CSS spec, overflow != visible on a flex/grid
item zeroes its automatic minimum size) and clips any residual content
overflow from children. Add overflow-x-hidden to the page root as a
safety net for any non-card overflow.
Dashboard Recent Adverts widget overflowed because its card (a CSS
grid item with default min-width: auto) grew to the table's intrinsic
min-content width, preventing the overflow-x-auto wrapper from
engaging. Add min-w-0 to both bottom-grid cards and constrain the
Node name cell with max-w + truncate to reduce the table's min-width.
Routes had page-level horizontal scroll with no visible scrollbar due
to three triggers and no overflow containment in the ancestor chain:
(1) the history day-label flex row whose flex-1 spans had no min-w-0,
(2) -mx-1 on every clickable MatchRow, and (3) a Chart.js responsive
canvas in an unconstrained parent. Add min-w-0 to the card grid item,
chart wrapper, and label spans (with truncate); add overflow-hidden
to the MatchRow container to clip the negative-margin hover-bg.
Add a typed icon prop to PageHeader that renders the section icon at
h-8 w-8 inside the heading, replacing the ad-hoc span wrapper used by
Channels and Routes. Migrate all 10 list pages to pass their section
icon, and add section icons to the 3 detail page headers (PacketDetail,
PacketGroupDetail, NodeDetail).
Normalize Channels/Routes title keys to the entities.* namespace
(used everywhere else) and add the missing entities.channels key to
nl.json ('Kanalen'). Visible text is unchanged.
Update PageHeader tests for the icon contract and add a focused icon
assertion to each affected page test.
The update_profile endpoint used RequireUserOwner which returns only the
caller's user_id — no role information. The ownership check blocked ALL
non-owner edits with 403, including admins. This regressed in d37b30a
when the old Member model (RequireAdmin) was replaced with UserProfile.
Backend: read X-User-Roles header directly in update_profile (same
pattern as node_tags.py / routes.py) and bypass the ownership check when
the admin role is present. Regular members editing their own profiles
are unaffected — RequireUserOwner stays as the dependency.
Frontend: extract ProfileEditForm component (with data-testids) from
OwnProfileView. PublicProfileView now shows an inline edit form when an
admin views another user's profile. Owner still gets the existing edit
link; non-admins see nothing.
Tests:
- Backend: test_update_profile_admin_can_edit_other (admin edits other
user's profile, asserts 200 + all fields updated)
- Vitest: 4 new tests — admin button visibility, non-admin hidden,
owner link vs admin button, form submission to correct endpoint
- E2E: admin-profile-edit.spec.ts (admin edits Mem South's profile,
verifies persistence; admin on own profile sees no admin button);
members.spec.ts negative assertion (member sees no admin button)
Adds a checkbox filter to the Routes page that narrows the list to
routes owned by the current user. The filter is URL-driven (?mine=true),
cached server-side automatically via the existing key builder, and only
shown to operators/admins (members can't own routes).
Backend: mine query param on GET /api/v1/routes filters by created_by
matching the caller's X-User-Id. Legacy NULL routes are excluded.
Tests:
- Backend: 6 new TestRouteMineFilter tests (own/other/null/admin/default)
- Vitest: 6 new tests (param passing, role gating, checkbox state)
- E2E: new seed route owned by pw-operator + mine filter spec
Admins editing an operator-created route no longer steal ownership.
Ownership transfer now happens only for legacy (NULL created_by) routes.
This ensures operators retain edit access to their routes after an admin
makes a small tweak.
Replace visibility-tier-based write permissions with ownership-based:
operators can only edit/delete routes they created; admins can modify
any route and take ownership on edit. Each route stores the creator's
OIDC user_id (created_by column). Legacy routes with NULL created_by
are admin-only.
The creator's friendly name is resolved from UserProfile and displayed
on the route card with a profile link. Edit/delete buttons are hidden
per-route based on ownership rather than a flat role check.
- Migration: add nullable routes.created_by (batch mode, SQLite-safe)
- _assert_route_modifiable: dual-check visibility (404) + ownership (403)
- create_route: stamps created_by, ensures profile exists
- update_route: admin edits transfer ownership (logged)
- RouteOwner schema mirrors AdoptedByUser pattern
- Batch owner resolution in list endpoint (avoids N+1)
- Frontend: per-route canEdit gate, owner badge with profile link
- Tests: 20+ backend ownership tests, 5 frontend gating tests, e2e assertions
- Docs: routes.md + auth.md updated for ownership model
Operators can now create, edit, and delete routes (previously admin-only).
A user may never scope a route above their own role tier: an operator
creating/editing an admin-visibility route is rejected (403 on the
visibility value, 404 on touching an existing higher-visibility route),
preventing them from creating routes they could then never see or modify.
- routes.py: RequireAdmin -> RequireOperatorOrAdmin on create/update/delete;
add visibility-cap enforcement helpers reusing the existing
resolve_user_role / VISIBILITY_LEVELS ladder
- web/app.py: proxy access map admits operator for routes POST/PUT/DELETE
- Routes.tsx: canManage gate (admin||operator) on Add/Edit/Delete; visibility
<select> filters options by caller tier so operators never see 'admin'
- tests: operator-tier coverage (create/update/delete at/below/above level),
proxy access-map assertion, vitest role-gating + filtered select
- e2e: mint operator session + routes-operator spec
- docs: routes.md + auth.md operator/visibility-cap notes
Both Dashboard.tsx and Channels.tsx used the same React Query key
qk.channels.list({}) but returned different data shapes: Dashboard
returned the raw {items, total} object while Channels returned just
the items array. When Dashboard loaded first, React Query cached the
raw object, and Channels' for...of on the cached object threw
'g is not iterable'.
Fix: Channels queryFn now returns the raw API response (matching
Dashboard), and channels extraction uses data?.items ?? []. Added
regression test that pre-seeds the cache with the Dashboard shape.
A flake8 B007 fix at commit time renamed the whole observer-specs tuple
to (_area, _lat, _lon), but the Node() call below still referenced lat/lon.
Those names then resolved to the leaked values from the preceding
content_specs loop (last iteration = Delta's None/None), so all observer
nodes were seeded without coordinates and dropped from the map (7 -> 3
markers). Underscore only `area` (genuinely unused in that loop); lat/lon
are used in Node(), so they stay as loop vars.
Replaces the httpx-based smoke tests in tests/e2e/ with a browser E2E suite
under e2e/, running against a self-contained throwaway stack that never
touches the local development database.
Stack & data isolation (e2e/docker-compose.test.yml):
- Own ephemeral Postgres 17 (schema via the `migrate` service / Alembic),
distinct project name + named volumes, no host DB port, no \${VAR}
interpolation. `make e2e-down` destroys everything.
- OIDC enabled with a known session secret; WEB_AUTO_REFRESH_SECONDS=2 so
polling is assertable; CONTENT_HOME mounts a test markdown page.
- Deterministic seeder (e2e/seed_data.py) run via global setup inside the
collector container: nodes/observers with `area` tags, adverts, messages
on the public (17) + a custom channel, raw packets + path hops keyed to
node prefixes, a route + health/history, profiles + adoptions, and
event_observers rows so the observer filter/badges resolve.
Auth & tests:
- Forged signed `meshcore-session` cookies (e2e/mint_session.py,
itsdangerous) for admin/member identities -> storageState; no real IdP
needed. 31 specs across 12 files cover global nav/theme, profile
menu+edit, home hero, dashboard widgets, list filters/auto-refresh/row
actions, observer toggles, the path-node overlay, map filters +
show-labels, members, markdown pages, and routes add/edit/delete
(persistence, validation, confirm dialog). workers:1 / fullyParallel:false
against the single shared backend; targeted data-testids added to React
components for stable selectors.
Fixes surfaced while running the suite on fresh Postgres:
- Migration 5e3b712ccf10 aborted on Postgres: its route-health backfill
queries the live Route model (which now has max_path_length) before that
column exists. The swallowed error left the transaction aborted, killing
the subsequent alembic_version stamp. Wrapped the backfill in a SAVEPOINT
so a failure rolls back cleanly without blocking the migration.
- Restored the full ABUSE_* env set required by the MQTT broker, and set
the web service's API_KEY to the admin key (admin writes go through the
proxy as a Bearer token).
31/31 passing; tsc (frontend+e2e), pytest (1460), and pre-commit green.
Migrate the React SPA off the bespoke useApiFetch hook and raw useEffect fetches to TanStack Query: useQuery/useQueries for reads, useMutation + invalidateQueries for writes; a central query-key factory and invalidation helpers mirroring the backend cache_invalidation prefixes (channels/routes/nodes/messages/profiles/dashboard/adoptions); polling via refetchInterval (useAutoRefresh now returns pause state only); RouteCard self-fetches its detail/history, dropping the hand-rolled caches. Adds QueryClientProvider in App, a renderWithProviders test helper, and deletes useApiFetch.
Also consolidates recurring UI into reusable components (Breadcrumbs, ListToolbar, Modal/ConfirmDialog, NotFoundState, TimeAgo, Definition, CopyableValue, MeshQrCode, Badges, SectionGroup, PageHeader) and fixes the FilterForm clear button, merged toggle wrappers, and role-aware channels/messages cache keys so client invalidation matches the server.
Verified: tsc --noEmit clean, vitest 113 passed, pytest test_web 251 + test_cache 119 passed, pre-commit --all-files green.
- PacketGroupDetail: render the path-node popover via createPortal(document.body)
with position:absolute + document coordinates (rect + scrollX/scrollY) instead of
position:fixed with one-shot viewport coords, so it scrolls with the page rather
than staying pinned to the viewport. Outside-click/Escape close unchanged (DOM-based).
- Makefile: 'make test' now runs pytest then the frontend vitest suite via a new
'test-frontend' target (npm run test:frontend).
- pre-commit: add a local 'frontend-typecheck' hook (language: system) running
'npm run typecheck' (tsc --noEmit) on TS/TSX + tsconfig/package(-lock).json changes.
- AGENTS.md: document the pre-commit TS gate and that 'make test' includes the frontend.
Verified: tsc clean, vitest 49 passed, pre-commit --all-files green, make test
(1459 backend passed + 49 frontend passed).
The React frontend is complete (Phases 1-3); the lit-html fallback is dead
(its vendor globals were removed in Phase 3). Delete it and the scaffolding:
- Delete the entire src/meshcore_hub/web/static/js/spa/ lit-html tree,
LitBridge.tsx, and legacy.d.ts.
- Remove the @legacy alias from vite.config.ts and tsconfig.json.
- Remove lit-html and qrcodejs from package.json (both unused now).
- Remove the lit-html fallback {% else %} branch from spa.html — the Vite
build is now required to serve the UI (no fallback bundle).
Tests (fallback no longer exists):
- test_home/advertisements/nodes/messages.py: assert the React mount point
(id="app") instead of a bundled-or-fallback script tag.
- test_caching.py: JS-cache tests are header-only (static JS is bundled into
dist/, absent in test env; the middleware sets headers on 404 too); the
dist-bundle HTML test drops its fallback branch.
Docs:
- AGENTS.md: new Frontend (React) section (host-run npm/vite/tsc toolchain,
react-chartjs-2/react-leaflet/react-qr-code, CSS load order); clarified the
compose-stack rule to exempt frontend tooling.
- REACT_MIGRATION.md: Phase 4 complete, final file structure, decisions.
Verified: tsc --noEmit clean, npm run build, full pytest (1463 passed,
22 skipped), pre-commit (passed).
Replace all window.Chart / window.L / window.QRCode globals and the
charts.js helper script with bundled React components:
- react-chartjs-2: typed config builders in utils/charts.ts
(buildLineChart, buildActivityChart, buildStackedBar, buildRoutesTrend,
buildRouteDetailStrip + ChartColors, averageRouteTier, routeQualityToTier)
and wrappers in components/charts/Charts.tsx (ActivityChart,
TrendLineChart, StackedBarChart, RoutesTrendChart, RouteDetailStrip).
utils/charts.ts imports chart.js/auto. Wired into Home, Dashboard, Routes.
- react-leaflet: MapPage rewritten with MapContainer/TileLayer/Marker/Popup
+ a useMap MapController for fit-bounds and memoized markers; NodeDetail
static hero map with divIcon marker + OffsetCenter. Both import
leaflet/dist/leaflet.css.
- react-qr-code: replaces window.QRCode in Channels and NodeDetail.
Bundling & shell:
- Chart.js, Leaflet (+CSS), react-qr-code now bundled by Vite; removed the
leaflet/chart.js/qrcodejs vendor <script>/<link> tags from spa.html and
their copy steps from build.js (fonts stay vendored). Deleted charts.js.
- Moved the Vite CSS bundle (asset_app_css) into <head> before app.css so
app.css dark-mode Leaflet overrides win over the bundled leaflet.css.
- Dropped the chart globals from the Window type declaration.
Tests/docs:
- test_caching.py: removed charts.js-specific tests; generic JS-cache tests
now target spa/app.js.
- Updated charts.js cross-references in collector/routes.py + test_routes.py
to point at spa-react/utils/charts.ts.
Verified: tsc --noEmit clean, npm run build (153 modules),
pytest tests/test_web (255 passed), pre-commit (passed).
Convert every remaining lit-html page to React 19 + TypeScript and wire
them directly into the router, removing all LitBridge usage from App.tsx:
- Home, CustomPage, Profile, Members, Channels
- Advertisements, Messages, Routes, Nodes, NodeDetail
- Packets, PacketDetail, PacketGroupDetail, Dashboard, MapPage
Pages use the shared React infrastructure (apiGet<T>, useAutoRefresh,
usePageTitle, useFormatDateTime, Pagination, FilterForm, SortableTable,
NodeDisplay, ObserverBadges, RouteTypeBadge, JsonTree, StatCard, icons).
Charts/maps still call window.Chart / window.L / window.QRCode / charts.js
globals — these move to react-chartjs-2 / react-leaflet in Phase 3.
The old lit-html code in spa/ is intentionally kept as the spa.html
fallback (rendered only when the Vite bundle is absent) and is still
referenced by 5 web tests; it will be removed in Phase 4.
Added IconSatelliteDish, IconRuler, IconHopSpan, IconPathLength icons.
Verified: tsc --noEmit clean, npm run build (94 modules),
pytest tests/test_web/ (256 passed), pre-commit (passed).
Adds a new nullable per-route knob that caps the total number of hops
in a candidate packet's path. Packets whose path exceeds the cap are
dropped from matching consideration entirely (before the subsequence
matcher runs), so over-long paths never count toward
packet_count_threshold. Complements the existing max_hop_span, which
only constrains the gap between the first and last matched configured
node.
- Route model + migration (additive, nullable, default null = unlimited)
- Threaded through matcher chain (_subsequence_indices early-return)
- All 5 evaluate/preview/recent_matches call sites updated
- API serializer/create/update/preview passthrough
- CLI seed YAML import (update + create paths)
- Frontend: distinct icons for span (<-o->) vs path-length (|<->|),
always-rendered badges with infinity fallback, hover tooltips on
every stats row item, i18n keys (en + nl)
- Tests: matcher unit tests (within/exceeds cap), API round-trip,
CLI seed import
The route edit modal builds its PUT body with a ternary that collapses
an empty observer list to null:
observer_public_keys: observerPublicKeys.length > 0 ? observerPublicKeys : null,
Pydantic parses null as None, and the PUT handler's guard
if body.observer_public_keys is not None:
_sync_observers(session, route, observer_nodes)
skips the sync entirely when the field is None. So removing all
observers in the modal sent null -> no DB change. Adding observers
worked because a non-empty array passed the guard and _sync_observers
deleted + recreated.
The None-means-skip semantic is correct for true partial updates, so
the fix is on the frontend: the edit modal is a full-form PUT, so it
must always send the array. When empty, it sends [], which Pydantic
parses as [] (not None), the guard passes, and _sync_observers deletes
every existing RouteObserver row.
Tests: add test_update_clear_observers_with_empty_list next to the
existing test_update_observers as a regression guard. It seeds one
observer via PUT, then PUTs observer_public_keys: [] and asserts both
the response and a fresh GET come back with an empty list.
The Route Health and Routes Trend dashboard widgets pull from
GET /api/v1/dashboard/routes-overview, which previously filtered
routes by the caller's role tier (anonymous saw community, admin saw
all four tiers). Operators and admins therefore saw member/operator/
admin-tier routes mixed into the dashboard, even though the dedicated
/routes page is the working surface for managing those private tiers.
This change makes the dashboard widget surface ONLY community-tier
routes, regardless of the caller's role. Operators and admins still
see all four tiers on the /routes management page (unchanged).
Implementation:
- get_routes_overview: drop resolve_user_role/get_max_visibility_level;
push the filter into the SQL query
(where Route.visibility == RouteVisibility.COMMUNITY.value) so
higher-tier rows are no longer loaded just to be discarded.
- _dashboard_routes_overview_key_builder: drop the role dimension from
the cache key. The response is now identical across roles, so the
per-role cache slots were storing four identical copies. The prefix
is preserved so invalidate_routes' pattern invalidation still hits.
- Drop unused VISIBILITY_LEVELS import; add RouteVisibility to the
models import.
Tests:
- test_visibility_filter_hides_admin_routes renamed to
test_dashboard_only_shows_community_routes_regardless_of_role;
now seeds one route per visibility tier and asserts every role
(anonymous/member/operator/admin) sees only ['Public'].
- test_cache_key_is_role_scoped renamed to
test_cache_key_is_role_agnostic; asserts all four role-variants
produce the SAME cache key (no 'role=' dimension).
Change the new-route defaults so the create modal, REST API, YAML
import, and preview helper pre-fill packet_count_threshold=5 instead
of 3, and bump the effective-clear auto-multiplier from 2x to 3x so
the default comfort bar tracks to 15 for a default threshold of 5.
Schema/model/cli/preview:
- schemas/routes.py: RouteCreate + RoutePreviewRequest
packet_count_threshold default 3 -> 5 (clear_threshold stays None)
- models/route.py: Route INSERT default 3 -> 5 (clear_threshold stays
nullable, no default)
- collector/cli.py: YAML import fallbacks 3 -> 5
- collector/routes.py: preview helper fallback 3 -> 5; module constant
CLEAR_DEFAULT_MULTIPLIER 2 -> 3 (drives effective_clear_threshold)
- api/metrics.py: route_clear gauge help text 2x -> 3x
UI:
- web/spa/pages/routes.js: new-route modal pre-fills
packet_count_threshold=5; placeholder shows 3x threshold; parseInt
fallback 3 -> 5. clear_threshold field unchanged (clearing still
sends null for the auto-tracking behaviour).
Docs:
- docs/routes.md defaults table: window_hours 24->48, threshold 3->5,
clear (2x)->(3x), max_hop_span (unlimited)->8. Previous four
entries now match the shipped defaults (some were stale from #316).
- docs/seeding.md YAML example: window_hours 24->48, threshold 3->5,
commented clear_threshold example 10->15 with 3x note.
No migration: packet_count_threshold uses Python-side default= (no
server_default), so existing rows keep their stored values. Existing
routes with clear_threshold=NULL continue to track via the new 3x
multiplier at evaluation time.
Change the new-route defaults so the create modal and API/CLI paths
pre-fill a 48h evaluation window and a max-hop-span of 8 instead of
the previous 24h / unlimited (∞).
- schemas/routes.py: RouteCreate + RoutePreviewRequest defaults
- models/route.py: SQLAlchemy INSERT-time defaults
- collector/cli.py: YAML import fallbacks
- web/static/js/spa/pages/routes.js: new-route modal pre-fills 48/8
No migration: window_hours/max_hop_span use Python-side default=
(no server_default), so existing rows are untouched. Clearing the
max-hop-span field in the UI still sends null (no cap).
Routes list was sorted only by from_label; routes sharing the same
From appeared in backend-returned order, which looked random. Add
to_label as a secondary tiebreaker in the localeCompare comparator.
Case-sensitivity preserved (no behavior change for the primary key).
Adds a small DaisyUI loading-spinner to the primary action button and
disables both action buttons (Save/Delete + Cancel) while the request
is in flight. Prevents double-submit on slow operations like route
create/update, and prevents closing a modal mid-request which leaves
modalState in a confusing state.
Two patterns in the SPA, kept idiomatic to each:
- channels.js + routes.js (lit-html state-driven): thread a 'saving'
boolean from modalState into the modal renderers, which add
?disabled + a leading <span class="loading loading-spinner
loading-sm"></span> when set. Handlers set the flag and re-render
before await; clear and re-render in the catch.
- node-detail.js (native <dialog> + imperative listeners): add an
id to the Save button so the handler can toggle .disabled and swap
.innerHTML on the button element directly, restored in a finally.
Covers all six modal action buttons: channel add/edit + delete,
route add/edit + delete, node-tag edit + delete.