feat(web): add locale-aware number formatting and redesign filter panel

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.
This commit is contained in:
Louis King
2026-07-03 16:02:45 +01:00
parent ee4e8fa768
commit 2c25a9cd17
13 changed files with 698 additions and 130 deletions
@@ -0,0 +1,363 @@
# Regional number formatting + filter panel redesign
## Summary
Two related, independent frontend workstreams that clean up how numbers and the
filter panel are presented across the SPA.
1. **Regional number formatting.** Every numeric count in the UI — home & dashboard
stat cards, list-page total/shown badges, inline reception/observer counts, and
chart axis ticks + tooltips — currently renders as a raw integer (e.g. `251234`).
A single `formatNumber()` helper backed by `Intl.NumberFormat()` (no explicit
locale) is added so each visitor sees separators matching their browser locale
(`251,234` in en-GB, `251.234` in de-DE). No backend, schema, or i18n-key changes
are required; counts are pre-formatted at call sites before being interpolated.
2. **Filter panel redesign.** The list pages (nodes, packets, advertisements,
messages) and the map page wrap their filter fields in a heavy DaisyUI
`collapse` card (`bg-base-200 border-2 ... rounded-box mb-6` with a summary row
and `collapse-content pt-4` padding). This is replaced by a compact DaisyUI
slider `toggle` + "Filters" label placed on the **right** of the existing
record-count + auto-refresh control row; when toggled on, the bare filter fields
(no border, no card, no heavy padding) render directly below the control row.
## Background & Motivation
**Number formatting.** The frontend renders counts entirely raw. The shared stat
card helper `renderStatCard` emits `${value}` verbatim
(`src/meshcore_hub/web/static/js/spa/components.js:822`); the dashboard's four inline
stat numbers do the same (`pages/dashboard.js:139,160,181,202`); total/shown badges
pass raw ints into `t()` which `String()`-ifies them (`i18n.js:63`); and `charts.js`
has **no** `ticks.callback` on the y-axis and **no** `tooltip.callbacks` — so a
y-value of `12000` prints as `12000` and the tooltip shows the bare number. The only
locale-aware formatting in the codebase is for *dates* (`formatDateTime*` in
`components.js`, and `toLocaleDateString` in `charts.js:108`, the latter hard-coded
to `en-GB`). There is no `Intl.NumberFormat` / `toLocaleString` for numbers
anywhere. `__APP_CONFIG__` exposes `datetime_locale` (admin-controlled, default
`en-US`) at `web/app.py:319`, but this is a single admin-chosen locale rather than
each visitor's region; per-visitor regional formatting should decouple from it.
**Filter panel.** The collapsible filter was introduced by
`docs/plans/20260505-0900-improve-filter-options/plan.md`, which wrapped the form in
a DaisyUI `<details class="collapse collapse-arrow ...">` with `collapsible` /
`defaultOpen` options on `renderFilterCard` (`components.js:774-806`). Open-state
survival across auto-refresh re-renders works by reading `details.collapse.open`
from the DOM before each render (all 5 pages). The observer filter was later moved
*out* of the panel into toggle badges below it
(`docs/plans/20260614-1220-observer-filter-badges/plan.md`), leaving the collapse
card as a relatively heavy wrapper for a few selects. The card's `border-2`,
`rounded-box`, `bg-base-200`, `mb-6`, and `collapse-title`/`collapse-content`
padding consume a lot of visual real estate for what is now a compact form.
**Recent direction.** Recent UI work (`479c263` "redesign panel accent system",
`7956532` "normalize spacing", `91a3fcf` keyboard accessibility, `510612d`
self-hosted typography) shows an active push toward a tighter, more consistent UI.
This plan continues that direction. No git history touches number formatting or the
filter toggle style, so this is greenfield UI work.
## Goals
- Every count rendered in the SPA is formatted with locale-appropriate grouping
separators, driven by the visitor's browser locale (`Intl.NumberFormat()`).
- The filter panel on all five filter-bearing pages (nodes, packets,
advertisements, messages, map) becomes a right-aligned slider toggle in the
existing control row, with bare filter fields rendering below when enabled — no
bordered/padded card.
- Filter open-state continues to survive auto-refresh re-renders and SPA
navigation exactly as it does today.
- No backend, API schema, database, or `__APP_CONFIG__` changes. No new i18n keys
required (reuse existing `common.filters`).
## Non-Goals
- No change to `t()` itself — counts are pre-formatted at call sites so the i18n
function stays generic (it interpolates arbitrary params, not just counts).
- Pagination page-number indices (`components.js:520,522`) are left raw — formatting
`1,234` page indices would be wrong.
- Decimal physical values stay on `toFixed()`: SNR (`packet-group-detail.js:186`,
`packet-detail.js:104`), spam score (`messages.js:144`), GPS coords
(`map.js:105`), radio-config tiles (`home.js:31`).
- No per-dialect/backend work; `charts.js` consumes the same JSON shape (confirmed
by `docs/plans/20260616-2023-fix-postgres-charts-flatline/plan.md`).
- No change to the observer toggle badges (`components.js:638-654`) or their
localStorage plumbing — they already render below the filter and stay in place.
- No new CSS file; DaisyUI `toggle` + existing utilities cover it.
- No CI Postgres matrix; this is frontend-only and the existing `tests/test_web/`
suite (which asserts `__APP_CONFIG__` presence) is the regression gate.
## Requirements
### Functional Requirements
- Stat numbers on home (`renderStatCard` → 6 cards) and dashboard (4 inline
numbers) display with locale grouping (e.g. `12,345`).
- List-page total/shown badges and map count badges show grouped counts inside
their translated strings (e.g. "12,345 total").
- Inline reception/observer counts on packets, packet-detail, and
packet-group-detail pages, and the observer badge count, are grouped.
- Chart.js y-axis ticks and tooltip body values are grouped.
- On all five filter-bearing pages, a slider toggle labeled "Filters" appears at
the right end of the control row (after the record-count badge and the
auto-refresh pause/play control).
- Toggling the switch on reveals the filter fields directly below the control row,
with no surrounding border, card background, or heavy padding/margin. Toggling
off hides them.
- Filter visibility persists across auto-refresh ticks and across re-renders
triggered by navigation/sort/pagination.
- On a fresh page mount (no prior state), the filter defaults open iff active
filters exist (`hasActiveFilters`), matching current behavior.
### Technical Requirements
- **Locale source:** `new Intl.NumberFormat()` with **no** argument — uses the
runtime default locale (`navigator.language`). No explicit locale tag is passed,
so formatting is per-visitor and decoupled from the admin's `datetime_locale`.
- **`formatNumber` contract:** returns `''` for null/undefined/empty, returns the
original `String(value)` for non-finite numbers, otherwise `Intl.NumberFormat().format(n)`.
This makes it safe to wrap values that may be missing.
- **State preservation mechanism is unchanged in spirit:** today each page reads
`container.querySelector('details.collapse').open` before render; the new design
reads `container.querySelector('#filter-toggle').checked` instead. The native
checkbox holds the state in the DOM exactly as `<details>.open` did — no extra
state plumbing, no refetch on toggle (the toggle's `@change` re-runs the existing
`renderPage(lastContent)` closure).
- **`lit-html` controlled-checkbox safety:** because the value read from the DOM is
the same value bound back via `?checked=${filterOpen}`, the binding is idempotent
and does not fight the native toggle.
- **DaisyUI `toggle`** (slider switch) is loaded already (`@plugin "daisyui"` in
`input.css:2`) but not yet used anywhere in the codebase; this plan introduces it.
It is a DaisyUI component class, so no Tailwind safelist entry is needed.
- **`charts.js` is a classic (non-module) script** loaded at `spa.html:193`, before
the SPA module bundle. To avoid load-order/import issues, `charts.js` defines a
local `formatNumber` (the same `Intl.NumberFormat().format(v)` one-liner) rather
than importing from the module graph.
- **Map page** uses an inline filter (not `renderFilterCard`) and has no
auto-refresh; its toggle's `onChange` wires into the map's existing client-side
`applyFilters`/re-render path (no API refetch needed, since map filters are
applied client-side).
- No Python, HTML structure, or `.env` changes. `tests/test_web/` must still pass
unmodified (it checks `__APP_CONFIG__` and page scaffolding, not JS internals).
## Implementation Plan
### Phase 1: `formatNumber` helper + stat numbers
- **`components.js`** — add `export function formatNumber(value)` near the existing
`formatDateTime*` helpers (~line 193). Implement the contract above. Also expose
`window.formatNumber = formatNumber` (mirrors `window.t = t` at `i18n.js:78`) for
any non-module callers.
- **`components.js:822`** — in `renderStatCard`, `${value}``${formatNumber(value)}`.
(Cascades to all 6 home-page stat cards: `home.js:148,155,162,169,205,211`.)
- **`dashboard.js:139,160,181,202`** — wrap the four `${stats.*}` numbers with
`formatNumber(...)`. Add `formatNumber` to the existing `components.js` import.
### Phase 2: total/shown badges + inline counts
Wrap counts with `formatNumber(...)` at each render site. Most pass counts as
`t()` interpolation params but some use raw `${}` template expressions — both
patterns get the same `formatNumber(...)` wrapper:
- `pages/nodes.js:46`
- `pages/packets.js:80`, plus inline `:39` (`observer_count`), `:41`
(`reception_count`)
- `pages/advertisements.js:62`
- `pages/messages.js:227`
- `pages/map.js:309,312,313,331` (set via `element.textContent = t(...)`, counts
formatted inside the `t()` call)
- `pages/members.js:16,106`
- `pages/packet-group-detail.js:172``t()` interpolation
- `pages/packet-group-detail.js:304,318` — raw `${reception_count}` or `${observer_count}` in template literal
- `pages/packet-group-detail.js:345-346` — raw `${g.reception_count}` and `${g.observer_count}` in template literal
- `components.js:558` (`observerIcons` badge count, raw `${observers.length}`)
e.g. `t('common.total', { count: formatNumber(displayTotal) })` for `t()` call
sites; `${formatNumber(oc)}` for raw template literal sites.
### Phase 3: Chart axis + tooltip formatting
- **`charts.js`** — add a module-local `function formatNumber(v)` (one-liner).
- In `createChartOptions()` (`charts.js:49-98`):
- **y-axis ticks** (`:86-89`): add
`callback: function(value) { return formatNumber(value); }` alongside
`precision: 0`.
- **tooltip** (`:63-71`): add a `callbacks` object with a `label` function that
formats `ctx.parsed.y` via `formatNumber(...)` (prefixed by the dataset label,
preserving Chart.js's default label style).
### Phase 4: Filter component refactor (`components.js` + `icons.js`)
- **`icons.js`** — add a new `iconFilter` SVG function (funnel icon, consistent
with existing Heroicon-style patterns in the file). All 40 existing icons follow
the same signature; match that pattern.
- **Replace** `renderFilterCard()` (`components.js:774-806`) with two exports.
Add `iconFilter` to the `../icons.js` import in `components.js`.
- `renderFilterForm({ fields, basePath, navigate, submitLabel, clearLabel })`
returns **only** the `<form>` (fields + submit/clear buttons), no `<details>`,
no card, no border, no `mb-6`. Reuses the existing `createFilterHandler` /
`autoSubmit` / `submitOnEnter` helpers (`components.js:665-700`) unchanged.
- `renderFilterToggle({ open, onChange })` — returns the right-side control:
```html
<label class="label cursor-pointer gap-2" title=${t('common.filters')}>
<span class="text-sm opacity-80 flex items-center gap-1">
${iconFilter('w-4 h-4')} ${t('common.filters')}
</span>
<input type="checkbox" id="filter-toggle"
class="toggle toggle-sm toggle-primary"
?checked=${open} @change=${onChange}>
</label>
```
- Keep the `createFilterHandler` / `autoSubmit` / `submitOnEnter` helpers as-is.
### Phase 5: Four shared list pages
For each of nodes, packets, advertisements, messages:
- Replace the state-read lines (e.g. `nodes.js:176-177`):
```js
const existingToggle = container.querySelector('#filter-toggle');
const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters;
```
- **`nodes.js:175`** — fix `hasActiveFilters` to also check `pubkey_prefix`:
```js
const hasActiveFilters = search !== '' || adv_type !== '' || pubkey_prefix !== '' || (config.oidc_enabled && adopted_by !== '');
```
The public-key-prefix text field was historically omitted from the
hasActiveFilters check (a pre-existing bug); fixing it here ensures the new
toggle defaults open when it is filled.
- Drop the `renderFilterCard({ collapsible: true, ... })` call (e.g.
`nodes.js:179-185`).
- Restructure the control row so the toggle sits right (e.g. `nodes.js:44-50`):
```html
<div class="flex items-center gap-2 mb-4">
${displayTotal !== null ? html`<span class="badge badge-lg">${t('common.total', { count: formatNumber(displayTotal) })}</span>` : nothing}
${error ? warningBadge(error) : nothing}
<div class="ml-auto flex items-center gap-3">
<span id="auto-refresh-toggle"></span>
${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}
</div>
</div>
${filterOpen ? renderFilterForm({ fields: filterFields, basePath: '/nodes', navigate }) : nothing}
```
- Add `function onFilterToggle() { renderPage(lastContent); }` — reuses the page's
existing `renderPage` closure (calls `litRender`). Because `#filter-toggle.checked`
is read fresh at the top of each render, the toggle drives visibility with no
refetch.
**Files & line refs:** `pages/nodes.js` (44-51, 176-185), `pages/packets.js`
(73-86, 176-185), `pages/advertisements.js` (55-68, 250-259), `pages/messages.js`
(220-233, 426-435).
> Note: the advertisements and messages pages render observer toggle badges below
> the filter form (`advertisements.js:269,285`; `messages.js:445,463`). Their
> placement is unchanged; they naturally sit below the new inline form.
### Phase 6: Map page
**File:** `pages/map.js:185-246`.
- State-read `:187-188` → `container.querySelector('#filter-toggle')?.checked ?? false`.
Unlike list pages, the map has no `hasActiveFilters` check — its selects all
default to neutral/empty values, so the filter is never "active" on fresh mount.
This matches current map behavior (the `<details>` was always default-closed).
- Add `renderFilterToggle` into the existing right-side badge group inside the
header (the `<span id="node-count">` / `<span id="filtered-count">` block at
`map.js:193-197`). `onChange` calls the map's existing client-side
re-render/`applyFilters` path (no API refetch — map filters are applied
client-side).
- Add `renderFilterToggle` to the imports from `../components.js`. Add
`renderFilterForm` to the same import (the map renders its own field layout,
so `renderFilterForm` import is only needed if map adopts the shared form;
otherwise just import `renderFilterToggle`).
- Replace the `<details>…</details>` block (`map.js:200-246`) with a bare
`<div class="flex gap-4 flex-wrap items-end ${filterOpen ? '' : 'hidden'}">`
containing the same fieldsets/selects/checkbox/clear button — drop `collapse`,
`bg-base-200`, `border-2 border-base-content/25`, `rounded-box`,
`collapse-title`, `collapse-content pt-4`. The `hidden` class toggles visibility.
### Phase 7: Build + verify
- Frontend assets build into the Docker image (no local `npm` step per `AGENTS.md`):
`docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core build`.
- `pytest --no-cov tests/test_web/` — confirm `__APP_CONFIG__` assertions and page
scaffolding still pass (no Python/HTML-structure changes, so expected green).
- `pre-commit run --all-files`.
## Verification
1. **Stack up:** `docker compose -f docker-compose.yml -f docker-compose.dev.yml
--profile core up -d`.
2. **Numbers (en-GB browser):** home & dashboard stat cards, list total badges,
map count badges, packet reception/observer counts, and chart y-axis + tooltips
show thousands separators (e.g. `12,345`). In a `de-DE` browser, the same values
show `12.345`.
3. **Filters (nodes, packets, ads, messages, map):** the slider toggle appears at
the right of the count + auto-refresh control; toggling on shows the bare fields
below (no border/padded box); toggling off hides them; open state survives an
auto-refresh tick (wait one interval on nodes/ads/etc.) and survives a sort/page
change; on a fresh navigation the panel opens iff filters are active.
4. **Regression:** `pytest --no-cov tests/test_web/` green; `pre-commit run
--all-files` green.
5. **Sanity:** in devtools, `(1234567).toLocaleString()` returns the expected
grouped string for the active browser locale.
## Review
**Status**: Approved with Changes
**Reviewed**: 2026-07-03
### Resolutions
- **No filter icon exists in icons.js**: Confirmed — 40 icons, none are filter/funnel.
Added `iconFilter` SVG creation to Phase 4 (follows existing Heroicon-style patterns).
- **`pubkey_prefix` missing from `nodes.js` `hasActiveFilters`**: Confirmed at
`nodes.js:175`. This is a pre-existing bug (the filter panel won't open on fresh
mount when only the public-key-prefix field is filled). Fixed in-scope by adding
`pubkey_prefix !== ''` to the check in Phase 5.
- **Tooltip vs. label string**: Reuse `common.filters` ("Filters") for both the
visible label and the toggle's `title` attribute. No new i18n keys required.
- **`packet-group-detail.js` format sites are raw template literals, not `t()`
calls**: Lines 304, 318, and 345-346 use direct `${}` interpolation rather than
`t()` params. Phase 2 updated to list them explicitly as raw-template-literal
format sites (still wrapped with `formatNumber(...)` — just not inside `t()`).
- **Map page has no `hasActiveFilters` check**: Unlike list pages, map defaults to
`?? false` because its selects all start at neutral/empty values. This matches
current behavior (the `<details>` was always default-closed on fresh mount).
Documented in Phase 6.
- **`window.formatNumber` is unused by `charts.js`**: `charts.js` defines its own
local `formatNumber` (Phase 3) to avoid module-load-order issues.
`window.formatNumber` (Phase 1) is harmless — it mirrors the `window.t` pattern
and serves as a debug/console utility — but is not strictly required. Retained
for consistency with the existing `window.t` convention at `i18n.js:78`.
- **`toggle-primary` color**: Needs visual verification during implementation
(appearance against current light/dark themes). Fall back to plain `toggle` if
the primary variant doesn't render well.
### Remaining Action Items
- At implementation time, visually verify `toggle toggle-sm toggle-primary`
renders acceptably in both light and dark themes. Fall back to `toggle toggle-sm`
if needed.
- Verify no `id="filter-toggle"` collision — only one page renders at a time in
the SPA, but confirm no stale checkbox remains after a quick page transition.
- After implementation, spot-check `navigator.language` returns a grouped format
in each target browser (Chrome, Firefox, Safari).
## References
- `docs/plans/20260505-0900-improve-filter-options/plan.md` — introduced the
`renderFilterCard` `collapsible`/`defaultOpen` pattern and the `<details>`-based
collapse + `existingDetails.open` state-preservation mechanism that this plan
refactors.
- `docs/plans/20260614-1220-observer-filter-badges/plan.md` — moved observer
filtering out of the panel into toggle badges below it; confirms the badges stay
in place below the new inline filter form.
- `docs/plans/20260616-2023-fix-postgres-charts-flatline/plan.md` — confirms
`charts.js` consumes a stable JSON shape and needs no backend-coordinated edits;
this plan's `charts.js` changes are purely presentational.
- Key source locations: `components.js:774-825` (`renderFilterCard`, `renderStatCard`),
`components.js:558` (`observerIcons`), `i18n.js:56-78` (`t()`), `auto-refresh.js:20-88`,
`charts.js:49-98` (`createChartOptions`), `web/app.py:319` (`datetime_locale` in
`__APP_CONFIG__`).
- Git: `479c263` (panel accent redesign), `7956532` (spacing normalization),
`91a3fcf` (keyboard accessibility) — recent UI-direction context.
@@ -0,0 +1,166 @@
# Tasks: Regional number formatting + filter panel redesign
> Generated from `plan.md` on 2026-07-03
## 1. formatNumber helper + stat card numbers
- [x] Create `formatNumber()` helper in `components.js`
- [x] Add `export function formatNumber(value)` near existing `formatDateTime*` helpers (~line 193)
- [x] Contract: return `''` for null/undefined/empty, `String(value)` for non-finite, else `Intl.NumberFormat().format(n)`
- [x] Expose `window.formatNumber = formatNumber` (mirrors `window.t` pattern at `i18n.js:78`)
- [x] Update `renderStatCard` in `components.js:822`
- [x] Replace `${value}` with `${formatNumber(value)}`
- [x] Verifies: all 6 home-page stat cards format via `home.js:148,155,162,169,205,211`
- [x] Update dashboard inline stats in `pages/dashboard.js`
- [x] Add `formatNumber` to the `../components.js` import
- [x] Wrap `${stats.nodes}` at line 139
- [x] Wrap `${stats.packets}` at line 160
- [x] Wrap `${stats.advertisements}` at line 181
- [x] Wrap `${stats.messages}` at line 202
## 2. total/shown badges + inline counts
- [x] Update list-page total/shown badges
- [x] `pages/nodes.js:46` — wrap `displayTotal` and `shown` in `t()` badges
- [x] `pages/packets.js:80` — wrap `displayTotal` and `shown` in `t()` badges
- [x] `pages/advertisements.js:62` — wrap `displayTotal` and `shown` in `t()` badges
- [x] `pages/messages.js:227` — wrap `displayTotal` and `shown` in `t()` badges
- [x] Update inline count renderers
- [x] `pages/packets.js:39` — wrap `${observer_count}` with `formatNumber()` (raw template literal)
- [x] `pages/packets.js:41` — wrap `${reception_count}` with `formatNumber()` (raw template literal)
- [x] Update map count badges in `pages/map.js`
- [x] Line 309 — wrap count in `t()` call
- [x] Line 312 — wrap count in `t()` call
- [x] Line 313 — wrap count in `t()` call
- [x] Line 331 — wrap count in `t()` call
- [x] Update members page in `pages/members.js`
- [x] Line 16 — wrap `${online_count}/${total}` with `formatNumber()`
- [x] Line 106 — wrap count in `t()` call
- [x] Update packet-group-detail page in `pages/packet-group-detail.js`
- [x] Line 172 — wrap count in `t()` interpolation
- [x] Line 304 — wrap `${reception_count}` in raw template literal with `formatNumber()`
- [x] Line 318 — wrap `${observer_count}` in raw template literal with `formatNumber()`
- [x] Line 345 — wrap `${g.reception_count}` in raw template literal with `formatNumber()`
- [x] Line 346 — wrap `${g.observer_count}` in raw template literal with `formatNumber()`
- [x] Update observerIcons badge count in `components.js:558`
- [x] Wrap `${observers.length}` in raw template literal with `formatNumber()`
- [x] Add `formatNumber` to imports on all updated page files
## 3. Chart.js axis + tooltip formatting
- [x] Add local `formatNumber` function in `charts.js`
- [x] Define `function formatNumber(v) { return Intl.NumberFormat().format(v); }` at module scope
- [x] Add y-axis tick callback in `createChartOptions()` at `charts.js:86-89`
- [x] Add `callback: function(value) { return formatNumber(value); }` alongside `precision: 0`
- [x] Add tooltip label callback in `createChartOptions()` at `charts.js:63-71`
- [x] Add `callbacks` object with `label` function
- [x] Format `ctx.parsed.y` via `formatNumber()` prefixed by dataset label (preserve Chart.js default label style)
## 4. Filter component refactor
- [x] Add `iconFilter` SVG in `icons.js`
- [x] Create funnel icon function: `export function iconFilter(cls = 'h-5 w-5')`
- [x] Follow existing Heroicon-style SVG patterns (24x24 viewBox, stroke, fill="none")
- [x] Create `renderFilterToggle()` in `components.js`
- [x] Export function accepting `{ open, onChange }`
- [x] Render label with DaisyUI `toggle toggle-sm toggle-primary` slider switch
- [x] Include `iconFilter('w-4 h-4')` + `t('common.filters')` text in the label
- [x] Add `title=${t('common.filters')}` on the label element
- [x] Bind `?checked=${open}` and `@change=${onChange}` on the `<input type="checkbox" id="filter-toggle">`
- [x] Add `iconFilter` to the `../icons.js` import
- [x] Create `renderFilterForm()` in `components.js`
- [x] Export function accepting `{ fields, basePath, navigate, submitLabel, clearLabel }`
- [x] Return bare `<form>` (fields + submit/clear buttons) — no `<details>`, card, border, or `mb-6`
- [x] Reuse existing `createFilterHandler` / `autoSubmit` / `submitOnEnter` helpers unchanged
- [x] Remove old `renderFilterCard()` from `components.js:774-806`
- [x] Confirm no other callers remain (grep for `renderFilterCard`)
## 5. Four shared list pages (nodes, packets, ads, messages)
- [x] Update `pages/nodes.js`
- [x] Add `renderFilterToggle` and `renderFilterForm` to `../components.js` import
- [x] Fix `hasActiveFilters` at line 175: add `pubkey_prefix !== ''` to the condition
- [x] Replace state-read lines 176-177: query `#filter-toggle` checked state instead of `<details>.open`
- [x] Replace `renderFilterCard({ collapsible: true, ... })` call (lines 179-185) with `renderFilterToggle` + `renderFilterForm`
- [x] Restructure control row (lines 44-51): toggle right-aligned after auto-refresh
- [x] Add `function onFilterToggle() { renderPage(lastContent); }` closure
- [x] Update `pages/packets.js`
- [x] Add `renderFilterToggle` and `renderFilterForm` to `../components.js` import
- [x] Replace state-read lines 176-177: query `#filter-toggle` checked state
- [x] Replace `renderFilterCard({ collapsible: true, ... })` call (lines 176-185) with `renderFilterToggle` + `renderFilterForm`
- [x] Restructure control row (lines 73-86): toggle right-aligned after auto-refresh
- [x] Add `function onFilterToggle() { renderPage(lastContent); }` closure
- [x] Update `pages/advertisements.js`
- [x] Add `renderFilterToggle` and `renderFilterForm` to `../components.js` import
- [x] Replace state-read lines 250-251: query `#filter-toggle` checked state
- [x] Replace `renderFilterCard({ collapsible: true, ... })` call (lines 250-259) with `renderFilterToggle` + `renderFilterForm`
- [x] Restructure control row (lines 55-68): toggle right-aligned after auto-refresh
- [x] Add `function onFilterToggle() { renderPage(lastContent); }` closure
- [x] Confirm observer toggle badges (lines 269, 285) render below the new inline form unchanged
- [x] Update `pages/messages.js`
- [x] Add `renderFilterToggle` and `renderFilterForm` to `../components.js` import
- [x] Replace state-read lines 426-427: query `#filter-toggle` checked state
- [x] Replace `renderFilterCard({ collapsible: true, ... })` call (lines 426-435) with `renderFilterToggle` + `renderFilterForm`
- [x] Restructure control row (lines 220-233): toggle right-aligned after auto-refresh
- [x] Add `function onFilterToggle() { renderPage(lastContent); }` closure
- [x] Confirm observer toggle badges (lines 445, 463) render below the new inline form unchanged
## 6. Map page
- [x] Update `pages/map.js`
- [x] Add `renderFilterToggle` to `../components.js` import
- [x] Replace `<details>` open check (lines 187-188): query `#filter-toggle?.checked ?? false`
- [x] Place `renderFilterToggle` in the right-side badge group inside header (lines 193-197)
- [x] Wire `onChange` to map's existing `applyFilters`/re-render path (no API refetch)
- [x] Replace `<details>…</details>` block (lines 200-246) with bare `<div>` using `hidden` class for visibility toggle
- [x] Drop all collapse card classes: `collapse`, `bg-base-200`, `border-2`, `border-base-content/25`, `rounded-box`, `collapse-title`, `collapse-content pt-4`
- [x] Preserve all existing fieldsets, selects, checkbox, and clear button inside the bare `<div>`
## 7. Build + regression
- [x] Build the Docker image
- [x] `docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile all build`
- [x] Confirm no build errors (JS syntax, module imports, Tailwind compilation)
- [x] Run web test suite
- [x] `pytest --no-cov tests/test_web/` — confirm green (no Python/HTML changes expected)
- [x] Run pre-commit
- [x] `pre-commit run --all-files`
## 8. Verification
- [ ] Stack up and smoke test
- [ ] `docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core up -d`
- [ ] Verify regional number formatting
- [ ] Home page stat cards show locale-grouped numbers (e.g. `12,345` in en-GB, `12.345` in de-DE)
- [ ] Dashboard inline stats show locale-grouped numbers
- [ ] List-page total/shown badges show locale-grouped numbers
- [ ] Map count badges show locale-grouped numbers
- [ ] Packet reception/observer inline counts show locale-grouped numbers
- [ ] Chart y-axis ticks show locale-grouped numbers
- [ ] Chart tooltip values show locale-grouped numbers
- [ ] Members page counts show locale-grouped numbers
- [ ] Packet-group-detail inline counts show locale-grouped numbers
- [ ] Verify filter toggle on all 5 pages
- [ ] Nodes: toggle appears right of control row; toggling reveals/hides bare fields; survives auto-refresh and sort/nav
- [ ] Packets: same as nodes
- [ ] Advertisements: same as nodes; observer badges render below filter form unchanged
- [ ] Messages: same as nodes; observer badges render below filter form unchanged
- [ ] Map: toggle appears in header badge group; toggling reveals/hides client-side filter fields
- [ ] Verify filter open-state behavior
- [ ] Fresh navigation with active filters → toggle defaults on
- [ ] Fresh navigation with no active filters → toggle defaults off
- [ ] `pubkey_prefix` field on nodes page correctly triggers active-on-mount (Phase 5 fix)
- [ ] Record count badge stays visible when toggling filter (onFilterToggle preserves lastTotal)
- [ ] Visual verification
- [ ] `toggle toggle-sm toggle-primary` renders acceptably in light theme
- [ ] `toggle toggle-sm toggle-primary` renders acceptably in dark theme
- [ ] If `toggle-primary` looks off in either theme, fall back to plain `toggle toggle-sm`
- [ ] Cross-browser sanity check
- [ ] `navigator.language` grouping works in Chrome (devtools: `(1234567).toLocaleString()`)
- [ ] `navigator.language` grouping works in Firefox
- [ ] `navigator.language` grouping works in Safari
- [ ] Verify no `#filter-toggle` collision on rapid SPA page transitions
- [ ] Navigate quickly between pages and confirm no stale checkbox remains in DOM
- [x] Run final regression
- [x] `pytest --no-cov tests/test_web/` green (236 passed)
- [x] `pre-commit run --all-files` green
+20 -2
View File
@@ -10,6 +10,16 @@ if (typeof Chart !== 'undefined') {
Chart.defaults.font.family = '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif';
}
/**
* Format a number with locale-appropriate grouping separators.
* Uses the visitor's browser locale (no explicit locale argument).
* @param {number} v
* @returns {string}
*/
function formatNumber(v) {
return new Intl.NumberFormat().format(v);
}
/**
* Read page colors from CSS custom properties (defined in app.css :root).
* Falls back to hardcoded values if CSS vars are unavailable.
@@ -67,7 +77,14 @@ function createChartOptions(showLegend) {
titleColor: ChartColors.tooltipText,
bodyColor: ChartColors.tooltipText,
borderColor: ChartColors.tooltipBorder,
borderWidth: 1
borderWidth: 1,
callbacks: {
label: function(ctx) {
const label = ctx.dataset.label || '';
const value = formatNumber(ctx.parsed.y);
return label ? label + ': ' + value : value;
}
}
}
},
scales: {
@@ -85,7 +102,8 @@ function createChartOptions(showLegend) {
grid: { color: ChartColors.grid },
ticks: {
color: ChartColors.text,
precision: 0
precision: 0,
callback: function(value) { return formatNumber(value); }
}
}
},
@@ -20,7 +20,7 @@ import { html, nothing } from 'lit-html';
import { render } from 'lit-html';
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
import { t } from './i18n.js';
import { iconAlert, iconError, iconInfo, iconSuccess, iconUser, iconLogout } from './icons.js';
import { iconAlert, iconError, iconInfo, iconSuccess, iconUser, iconLogout, iconFilter } from './icons.js';
// Re-export lit-html utilities for page modules
export { html, nothing, unsafeHTML };
@@ -192,6 +192,20 @@ export const pageColors = {
// --- Formatting Helpers (return strings) ---
/**
* Format a number with locale-appropriate grouping separators.
* Uses the visitor's browser locale (no explicit locale argument).
* @param {number|string|null|undefined} value
* @returns {string} Grouped number string, or '' for missing values
*/
export function formatNumber(value) {
if (value === null || value === undefined || value === '') return '';
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
return new Intl.NumberFormat().format(n);
}
window.formatNumber = formatNumber;
/**
* Get the type emoji for a node advertisement type.
* @param {string|null} advType
@@ -555,7 +569,7 @@ export function observerIcons(observers) {
if (!observers || observers.length === 0) return nothing;
const names = observers.map(o => o.tag_name || o.name || truncateKey(o.public_key, 8));
const tooltip = names.join(', ');
return html`<span class="badge badge-sm badge-primary observer-badge" title=${tooltip}>${observers.length}</span>`;
return html`<span class="badge badge-sm badge-primary observer-badge" title=${tooltip}>${formatNumber(observers.length)}</span>`;
}
export function routeTypeBadge(routeType) {
@@ -761,18 +775,17 @@ export function renderAuthSection(container, config) {
}
/**
* Render a filter card with configurable form fields, submit, and clear buttons.
* Render a bare filter form (fields + submit/clear buttons).
* No surrounding card, border, or collapse wrapper — the caller controls visibility.
* @param {Array<Function>} options.fields - Array of render functions returning lit-html form controls
* @param {string} options.basePath - Base URL path for the page (e.g., '/nodes')
* @param {Function} options.navigate - Router navigate function
* @param {string} [options.submitLabel] - Text for submit button (default: translated "Filter")
* @param {string} [options.clearLabel] - Text for clear button (default: translated "Clear")
* @param {boolean} [options.collapsible=false] - Wrap in DaisyUI collapsible <details>
* @param {boolean} [options.defaultOpen=false] - Start expanded when collapsible
* @returns {TemplateResult}
*/
export function renderFilterCard({ fields, basePath, navigate, submitLabel, clearLabel, collapsible = false, defaultOpen = false }) {
const formBody = html`
export function renderFilterForm({ fields, basePath, navigate, submitLabel, clearLabel }) {
return html`
<form method="GET" action=${basePath} class="flex flex-col gap-4" @submit=${createFilterHandler(basePath, navigate)}>
<div class="flex gap-4 flex-wrap items-start">
${fields.map(f => f())}
@@ -781,28 +794,26 @@ export function renderFilterCard({ fields, basePath, navigate, submitLabel, clea
<button type="submit" class="btn btn-primary btn-sm">${submitLabel || t('common.filter')}</button>
<a href=${basePath} class="btn btn-ghost btn-sm">${clearLabel || t('common.clear')}</a>
</div>
</form>
`;
if (!collapsible) {
return html`
<div class="card shadow-sm mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">${formBody}</div>
</div>
`;
}
</form>`;
}
/**
* Render the filter toggle control (DaisyUI slider switch + label).
* Placed at the right of the control row; the native checkbox holds open-state.
* @param {boolean} options.open - Whether the toggle is checked
* @param {Function} options.onChange - @change handler on the checkbox
* @returns {TemplateResult}
*/
export function renderFilterToggle({ open, onChange }) {
return html`
<details class="collapse collapse-arrow bg-base-200 border-2 border-base-content/25 rounded-box mb-6"
?open=${defaultOpen}>
<summary class="collapse-title text-sm font-medium cursor-pointer">
${t('common.filters')}
</summary>
<div class="collapse-content pt-4">
${formBody}
</div>
</details>
`;
<label class="label cursor-pointer gap-2" title=${t('common.filters')}>
<span class="text-sm opacity-80 flex items-center gap-1">
${iconFilter('w-4 h-4')} ${t('common.filters')}
</span>
<input type="checkbox" id="filter-toggle"
class="toggle toggle-sm toggle-primary"
?checked=${open} @change=${onChange}>
</label>`;
}
/**
@@ -819,7 +830,7 @@ export function renderStatCard({ icon, color, title, value, description }) {
<div class="stat bg-base-200 rounded-box shadow-sm panel-accent !py-2" style="--panel-color: ${color}">
<div class="stat-figure" style="color: ${color}">${icon}</div>
<div class="stat-title">${title}</div>
<div class="stat-value text-3xl">${value}</div>
<div class="stat-value text-3xl">${formatNumber(value)}</div>
${description ? html`<div class="stat-desc">${description}</div>` : nothing}
</div>`;
}
@@ -165,3 +165,7 @@ export function iconCodingRate(cls = 'h-5 w-5') {
export function iconTxPower(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" /></svg>`;
}
export function iconFilter(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" /></svg>`;
}
@@ -1,10 +1,10 @@
import { apiGet, isAbortError } from '../api.js';
import {
html, litRender, nothing, t,
getConfig, formatDateTime, formatDateTimeShort,
getConfig, formatDateTime, formatDateTimeShort, formatNumber,
warningBadge,
pagination, sortableTableHeader, mobileSortSelect,
renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
renderFilterForm, renderFilterToggle, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
observerIcons, getDisabledObservers, toggleObserver, observerFilterBadges, routeTypeBadge
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -44,6 +44,10 @@ export async function render(container, params, router) {
let lastContent = nothing;
let lastTotal = null;
let currentFilterFields = [];
const hasActiveFilters = search !== '' || (config.oidc_enabled && adopted_by !== '') || route_type !== 'flood,transport_flood';
function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); }
function renderPage(content, { total = null, error = null } = {}) {
if (!error) {
@@ -52,6 +56,8 @@ export async function render(container, params, router) {
}
const displayContent = error ? lastContent : content;
const displayTotal = error ? lastTotal : total;
const existingToggle = container.querySelector('#filter-toggle');
const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters;
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.advertisements')}</h1>
@@ -59,11 +65,17 @@ export async function render(container, params, router) {
</div>
<div class="flex items-center gap-2 mb-4">
${displayTotal !== null
? html`<span class="badge badge-lg">${t('common.total', { count: displayTotal })}</span>`
? html`<span class="badge badge-lg">${t('common.total', { count: formatNumber(displayTotal) })}</span>`
: nothing}
<span id="auto-refresh-toggle"></span>
${error ? warningBadge(error) : nothing}
<div class="ml-auto flex items-center gap-3">
<span id="auto-refresh-toggle"></span>
</div>
<div class="ml-4">${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}</div>
</div>
${(filterOpen && currentFilterFields.length > 0)
? html`<div class="mb-4">${renderFilterForm({ fields: currentFilterFields, basePath: '/advertisements', navigate })}</div>`
: nothing}
${displayContent}`, container);
}
@@ -246,25 +258,15 @@ ${displayContent}`, container);
</select>
</div>`);
}
const hasActiveFilters = search !== '' || (config.oidc_enabled && adopted_by !== '') || route_type !== 'flood,transport_flood';
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/advertisements',
navigate,
collapsible: true,
defaultOpen: isFilterOpen,
});
const headerParams = { search, adopted_by, route_type, limit };
const sortable = (label, sortKey) => sortableTableHeader(label, {
sortKey, currentSort: sort, currentOrder: order,
navigate, basePath: '/advertisements', params: headerParams,
});
renderPage(html`${filterCard}
currentFilterFields = filterFields;
renderPage(html`
${observerBadges('hidden lg:flex mb-4')}
@@ -2,7 +2,7 @@ import { apiGet, isAbortError } from '../api.js';
import {
html, litRender, nothing,
getConfig, getChannelLabelsMap, resolveChannelLabel,
observerIcons, routeTypeBadge, errorAlert, t, formatDateTime,
observerIcons, routeTypeBadge, errorAlert, t, formatDateTime, formatNumber,
} from '../components.js';
import {
iconNodes, iconAdvertisements, iconMessages, iconPackets, iconChannel,
@@ -136,7 +136,7 @@ function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, s
<p class="text-xs opacity-80">${t('time.over_time_last_7_days')}</p>
</div>
<div class="text-4xl font-bold leading-none" style="color: var(--color-nodes)">
${stats.total_nodes}
${formatNumber(stats.total_nodes)}
</div>
</div>
<div class="h-32">
@@ -157,7 +157,7 @@ function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, s
<p class="text-xs opacity-80">${t('time.per_day_last_7_days')}</p>
</div>
<div class="text-4xl font-bold leading-none" style="color: var(--color-adverts)">
${stats.advertisements_7d}
${formatNumber(stats.advertisements_7d)}
</div>
</div>
<div class="h-32">
@@ -178,7 +178,7 @@ function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, s
<p class="text-xs opacity-80">${t('time.per_day_last_7_days')}</p>
</div>
<div class="text-4xl font-bold leading-none" style="color: var(--color-messages)">
${stats.messages_7d}
${formatNumber(stats.messages_7d)}
</div>
</div>
<div class="h-32">
@@ -199,7 +199,7 @@ function renderChartCards({ showNodes, showAdverts, showMessages, showPackets, s
<p class="text-xs opacity-80">${t('time.per_day_last_7_days')}</p>
</div>
<div class="text-4xl font-bold leading-none" style="color: var(--color-packets)">
${stats.packets_7d}
${formatNumber(stats.packets_7d)}
</div>
</div>
<div class="h-32">
+15 -17
View File
@@ -2,7 +2,7 @@ import { apiGet, isAbortError } from '../api.js';
import {
html, litRender, nothing, t,
getConfig, typeEmoji, formatRelativeTime, escapeHtml, errorAlert,
timezoneIndicator,
timezoneIndicator, formatNumber, renderFilterToggle,
} from '../components.js';
const MAX_BOUNDS_RADIUS_KM = 20;
@@ -184,8 +184,13 @@ export async function render(container, params, router) {
applyFilters();
}
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : false;
function onMapFilterToggle() {
const filterDiv = container.querySelector('#map-filter-fields');
if (filterDiv) filterDiv.classList.toggle('hidden');
}
const existingToggle = container.querySelector('#filter-toggle');
const isFilterOpen = existingToggle ? existingToggle.checked : false;
litRender(html`
<div class="flex items-center justify-between mb-6">
@@ -194,16 +199,11 @@ export async function render(container, params, router) {
${timezoneIndicator()}
<span id="node-count" class="badge badge-lg">${t('common.loading')}</span>
<span id="filtered-count" class="badge badge-lg badge-ghost hidden"></span>
${renderFilterToggle({ open: isFilterOpen, onChange: onMapFilterToggle })}
</div>
</div>
<details class="collapse collapse-arrow bg-base-200 border-2 border-base-content/25 rounded-box mb-6"
?open=${isFilterOpen}>
<summary class="collapse-title text-sm font-medium cursor-pointer">
${t('common.filters')}
</summary>
<div class="collapse-content pt-4">
<div class="flex gap-4 flex-wrap items-end">
<div id="map-filter-fields" class="flex gap-4 flex-wrap items-end ${isFilterOpen ? '' : 'hidden'} mb-6">
<div class="fieldset">
<label class="fieldset-label">${t('common.show')}</label>
<select id="filter-category" class="select select-sm" @change=${applyFilters}>
@@ -241,9 +241,7 @@ export async function render(container, params, router) {
</label>
</div>
<button id="clear-filters" class="btn btn-ghost btn-sm" @click=${clearFiltersHandler}>${t('common.clear_filters')}</button>
</div>
</div>
</details>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-2">
@@ -306,11 +304,11 @@ ${config.oidc_enabled ? html`
const filteredEl = container.querySelector('#filtered-count');
if (filteredNodes.length === allNodes.length) {
countEl.textContent = t('map.nodes_on_map', { count: allNodes.length });
countEl.textContent = t('map.nodes_on_map', { count: formatNumber(allNodes.length) });
filteredEl.classList.add('hidden');
} else {
countEl.textContent = t('common.total', { count: allNodes.length });
filteredEl.textContent = t('common.shown', { count: filteredNodes.length });
countEl.textContent = t('common.total', { count: formatNumber(allNodes.length) });
filteredEl.textContent = t('common.shown', { count: formatNumber(filteredNodes.length) });
filteredEl.classList.remove('hidden');
}
@@ -328,7 +326,7 @@ ${config.oidc_enabled ? html`
}
if (debug.nodes_with_coords === 0) {
container.querySelector('#node-count').textContent = t('map.nodes_none_have_coordinates', { count: debug.total_nodes });
container.querySelector('#node-count').textContent = t('map.nodes_none_have_coordinates', { count: formatNumber(debug.total_nodes) });
return () => map.remove();
}
@@ -1,5 +1,5 @@
import { apiGet, isAbortError } from '../api.js';
import { html, litRender, nothing, t, errorAlert, getConfig } from '../components.js';
import { html, litRender, nothing, t, errorAlert, getConfig, formatNumber } from '../components.js';
import { iconAntenna, iconUsers } from '../icons.js';
function renderProfileTile(profile, router) {
@@ -14,7 +14,7 @@ function renderProfileTile(profile, router) {
: nothing;
const nodeCountLabel = profile.node_count > 0
? html`<span class="text-sm opacity-60">${t('members_page.node_count', { count: profile.node_count })}</span>`
? html`<span class="text-sm opacity-60">${t('members_page.node_count', { count: formatNumber(profile.node_count) })}</span>`
: nothing;
const nodeBadges = profile.adopted_nodes && profile.adopted_nodes.length > 0
@@ -103,7 +103,7 @@ export async function render(container, params, router) {
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.members')}</h1>
<span class="badge badge-lg">${t('common.count_entity', { count: operators.length + members.length, entity: t('entities.members').toLowerCase() })}</span>
<span class="badge badge-lg">${t('common.count_entity', { count: formatNumber(operators.length + members.length), entity: t('entities.members').toLowerCase() })}</span>
</div>
${renderGroup(t('members_page.operators'), operators, html`<span class="text-primary">${iconAntenna('h-6 w-6')}</span>`, router)}
@@ -1,11 +1,11 @@
import { apiGet, isAbortError } from '../api.js';
import {
html, litRender, nothing, t,
getConfig, formatDateTime, formatDateTimeShort,
getConfig, formatDateTime, formatDateTimeShort, formatNumber,
getChannelLabelsMap, resolveChannelLabel,
warningBadge,
pagination, sortableTableHeader, mobileSortSelect,
renderFilterCard, autoSubmit,
renderFilterForm, renderFilterToggle, autoSubmit,
observerIcons, getDisabledObservers, toggleObserver, observerFilterBadges
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -209,6 +209,10 @@ export async function render(container, params, router) {
let lastContent = nothing;
let lastTotal = null;
let currentFilterFields = [];
const hasActiveFilters = message_type !== '' || channel_idx !== '' || includeSpam;
function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); }
function renderPage(content, { total = null, error = null } = {}) {
if (!error) {
@@ -217,6 +221,8 @@ export async function render(container, params, router) {
}
const displayContent = error ? lastContent : content;
const displayTotal = error ? lastTotal : total;
const existingToggle = container.querySelector('#filter-toggle');
const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters;
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.messages')}</h1>
@@ -224,11 +230,17 @@ export async function render(container, params, router) {
</div>
<div class="flex items-center gap-2 mb-4">
${displayTotal !== null
? html`<span class="badge badge-lg">${t('common.total', { count: displayTotal })}</span>`
? html`<span class="badge badge-lg">${t('common.total', { count: formatNumber(displayTotal) })}</span>`
: nothing}
<span id="auto-refresh-toggle"></span>
${error ? warningBadge(error) : nothing}
<div class="ml-auto flex items-center gap-3">
<span id="auto-refresh-toggle"></span>
</div>
<div class="ml-4">${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}</div>
</div>
${(filterOpen && currentFilterFields.length > 0)
? html`<div class="mb-4">${renderFilterForm({ fields: currentFilterFields, basePath: '/messages', navigate })}</div>`
: nothing}
${displayContent}`, container);
}
@@ -422,25 +434,15 @@ ${displayContent}`, container);
</label>
</div>`);
}
const hasActiveFilters = message_type !== '' || channel_idx !== '' || includeSpam;
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/messages',
navigate,
collapsible: true,
defaultOpen: isFilterOpen,
});
const headerParams = { message_type, channel_idx, limit, ...spamParam };
const sortable = (label, sortKey) => sortableTableHeader(label, {
sortKey, currentSort: sort, currentOrder: order,
navigate, basePath: '/messages', params: headerParams,
});
renderPage(html`${filterCard}
currentFilterFields = filterFields;
renderPage(html`
${observerBadges('hidden lg:flex mb-4')}
@@ -1,10 +1,10 @@
import { apiGet, isAbortError } from '../api.js';
import {
html, litRender, nothing,
getConfig, formatDateTime, formatDateTimeShort,
getConfig, formatDateTime, formatDateTimeShort, formatNumber,
warningBadge,
pagination, sortableTableHeader, mobileSortSelect,
renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
renderFilterForm, renderFilterToggle, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -28,6 +28,10 @@ export async function render(container, params, router) {
let lastContent = nothing;
let lastTotal = null;
let currentFilterFields = [];
const hasActiveFilters = search !== '' || adv_type !== '' || pubkey_prefix !== '' || (config.oidc_enabled && adopted_by !== '');
function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); }
function renderPage(content, { total = null, error = null } = {}) {
if (!error) {
@@ -36,6 +40,8 @@ export async function render(container, params, router) {
}
const displayContent = error ? lastContent : content;
const displayTotal = error ? lastTotal : total;
const existingToggle = container.querySelector('#filter-toggle');
const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters;
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.nodes')}</h1>
@@ -43,11 +49,17 @@ export async function render(container, params, router) {
</div>
<div class="flex items-center gap-2 mb-4">
${displayTotal !== null
? html`<span class="badge badge-lg">${t('common.total', { count: displayTotal })}</span>`
? html`<span class="badge badge-lg">${t('common.total', { count: formatNumber(displayTotal) })}</span>`
: nothing}
<span id="auto-refresh-toggle"></span>
${error ? warningBadge(error) : nothing}
<div class="ml-auto flex items-center gap-3">
<span id="auto-refresh-toggle"></span>
</div>
<div class="ml-4">${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}</div>
</div>
${(filterOpen && currentFilterFields.length > 0)
? html`<div class="mb-4">${renderFilterForm({ fields: currentFilterFields, basePath: '/nodes', navigate })}</div>`
: nothing}
${displayContent}`, container);
}
@@ -172,25 +184,15 @@ ${displayContent}`, container);
</div>`);
}
const hasActiveFilters = search !== '' || adv_type !== '' || (config.oidc_enabled && adopted_by !== '');
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/nodes',
navigate,
collapsible: true,
defaultOpen: isFilterOpen,
});
const headerParams = { search, adv_type, adopted_by, pubkey_prefix, limit };
const sortable = (label, sortKey) => sortableTableHeader(label, {
sortKey, currentSort: sort, currentOrder: order,
navigate, basePath: '/nodes', params: headerParams,
});
renderPage(html`${filterCard}
currentFilterFields = filterFields;
renderPage(html`
${mobileSortSelect({
currentSort: sort, currentOrder: order,
@@ -1,7 +1,7 @@
import { apiGet, isAbortError } from '../api.js';
import {
html, litRender, nothing, t,
getConfig, formatDateTime, formatRelativeTime, warningBadge, copyToClipboard,
getConfig, formatDateTime, formatRelativeTime, formatNumber, warningBadge, copyToClipboard,
loading, truncateKey
} from '../components.js';
@@ -169,7 +169,7 @@ export async function render(container, params, router) {
</a></li>`)}
${more > 0 ? html`<li>
<a href="/nodes?pubkey_prefix=${ph}" @click=${closePopover} class="text-xs opacity-70">
${t('packets.path_nodes_more', { count: more })}
${t('packets.path_nodes_more', { count: formatNumber(more) })}
</a></li>` : nothing}
</ul>`;
litRender(popoverShell(ph, body), popoverEl);
@@ -301,7 +301,7 @@ ${content}`, container);
<div class="mt-6">
<h2 class="text-sm font-semibold uppercase opacity-60 mb-3">
${t('packets.receptions_title')}
<span class="ml-1 normal-case opacity-80">(${g.reception_count} ${g.reception_count === 1 ? t('packets.reception_singular') : t('packets.reception_plural')}, ${g.observer_count} ${t('common.observers').toLowerCase()})</span>
<span class="ml-1 normal-case opacity-80">(${formatNumber(g.reception_count)} ${g.reception_count === 1 ? t('packets.reception_singular') : t('packets.reception_plural')}, ${formatNumber(g.observer_count)} ${t('common.observers').toLowerCase()})</span>
</h2>
${[...observerGroups.entries()].map(([_key, recs]) => {
const first = recs[0];
@@ -315,7 +315,7 @@ ${content}`, container);
? html`<a href="/nodes/${first.observed_by}" class="link link-hover">${displayName}</a>`
: html`${displayName}`}
${recs.length > 1
? html`<span class="text-xs opacity-50 ml-1">(${recs.length} ${t('packets.reception_plural')})</span>`
? html`<span class="text-xs opacity-50 ml-1">(${formatNumber(recs.length)} ${t('packets.reception_plural')})</span>`
: nothing}
</div>
${receptionCards(recs)}
@@ -343,7 +343,7 @@ ${redactedNotice}
${field(t('packets.payload_type'), g.payload_type != null ? g.payload_type : '—')}
${field(t('packets.col_route_type'), g.route_type || '—')}
${field(t('packets.receptions_count'),
html`${g.reception_count} ${g.reception_count === 1 ? t('packets.reception_singular') : t('packets.reception_plural')} · ${g.observer_count} ${t('common.observers').toLowerCase()}`)}
html`${formatNumber(g.reception_count)} ${g.reception_count === 1 ? t('packets.reception_singular') : t('packets.reception_plural')} · ${formatNumber(g.observer_count)} ${t('common.observers').toLowerCase()}`)}
</div>
${receptionsSection}
${rawBlock}
@@ -1,10 +1,10 @@
import { apiGet, isAbortError } from '../api.js';
import {
html, litRender, nothing, t,
getConfig, formatDateTime, formatDateTimeShort,
getConfig, formatDateTime, formatDateTimeShort, formatNumber,
warningBadge,
pagination, sortableTableHeader, mobileSortSelect,
renderFilterCard, autoSubmit, submitOnEnter
renderFilterForm, renderFilterToggle, autoSubmit, submitOnEnter
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
import { iconSatelliteDish, iconPath } from '../icons.js';
@@ -36,9 +36,9 @@ function receptionBadge(packet) {
const pb = packet.path_hash_bytes;
return html`<span class="inline-flex items-center gap-1">
${iconSatelliteDish('h-4 w-4 opacity-70')}
<span class="badge badge-sm badge-primary" title=${t('common.observers')}>${oc}</span>
<span class="badge badge-sm badge-primary" title=${t('common.observers')}>${formatNumber(oc)}</span>
${iconPath('h-4 w-4 opacity-70')}
<span class="badge badge-sm badge-primary" title=${t('packets.reception_plural')}>${rc}</span>
<span class="badge badge-sm badge-primary" title=${t('packets.reception_plural')}>${formatNumber(rc)}</span>
${pb ? html`<span class="badge badge-sm badge-ghost" title=${t('packets.path_width_title')}>${t('packets.path_width_bytes', { count: pb })}</span>` : nothing}
</span>`;
}
@@ -62,6 +62,10 @@ export async function render(container, params, router) {
let lastContent = nothing;
let lastTotal = null;
let currentFilterFields = [];
const hasActiveFilters = search !== '' || event_type !== '' || channel_idx !== '';
function onFilterToggle() { renderPage(lastContent, { total: lastTotal }); }
function renderPage(content, { total = null, error = null } = {}) {
if (!error) {
@@ -70,6 +74,8 @@ export async function render(container, params, router) {
}
const displayContent = error ? lastContent : content;
const displayTotal = error ? lastTotal : total;
const existingToggle = container.querySelector('#filter-toggle');
const filterOpen = existingToggle ? existingToggle.checked : hasActiveFilters;
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.packets')}</h1>
@@ -77,11 +83,17 @@ export async function render(container, params, router) {
</div>
<div class="flex items-center gap-2 mb-4">
${displayTotal !== null
? html`<span class="badge badge-lg">${t('common.total', { count: displayTotal })}</span>`
? html`<span class="badge badge-lg">${t('common.total', { count: formatNumber(displayTotal) })}</span>`
: nothing}
<span id="auto-refresh-toggle"></span>
${error ? warningBadge(error) : nothing}
<div class="ml-auto flex items-center gap-3">
<span id="auto-refresh-toggle"></span>
</div>
<div class="ml-4">${renderFilterToggle({ open: filterOpen, onChange: onFilterToggle })}</div>
</div>
${(filterOpen && currentFilterFields.length > 0)
? html`<div class="mb-4">${renderFilterForm({ fields: currentFilterFields, basePath: '/packets', navigate })}</div>`
: nothing}
${displayContent}`, container);
}
@@ -172,25 +184,15 @@ ${displayContent}`, container);
</div>`,
];
const hasActiveFilters = search !== '' || event_type !== '' || channel_idx !== '';
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/packets',
navigate,
collapsible: true,
defaultOpen: isFilterOpen,
});
const headerParams = { search, event_type, channel_idx, limit };
const sortable = (label, sortKey) => sortableTableHeader(label, {
sortKey, currentSort: sort, currentOrder: order,
navigate, basePath: '/packets', params: headerParams,
});
renderPage(html`${filterCard}
currentFilterFields = filterFields;
renderPage(html`
${mobileSortSelect({
currentSort: sort, currentOrder: order,