mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-28 12:32:28 +02:00
feat(web): group observer filter badges by area tag
Switch the Adverts and Messages observer filter from one badge per observer node to one badge per unique area tag value (e.g. IP2, IP3, IP4). Toggling an area badge enables/disables all observers in that area together. Observers without an area tag are hidden from the filter row. Uses a new localStorage key (meshcore-observer-areas-disabled) to avoid stale public keys being misread as area codes. The old key is cleaned up on SPA boot. Frontend-only: no backend, API, schema, or migration changes.
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
# Plan: Area-tag-based observer filter badges
|
||||
|
||||
## Summary
|
||||
|
||||
The Adverts and Messages pages currently render one filter badge per observer
|
||||
node, labelled with the node's `name` tag, advertised `name`, or a truncated
|
||||
public key. Observer operators in practice choose unhelpful names, so the badge
|
||||
row is hard to scan. This plan switches the badge row to be **grouped by the
|
||||
node's `area` tag** (a short area code such as `IP2` or `SE1`): one badge per
|
||||
unique area code, toggling all observers in that area together. Observers
|
||||
without an `area` tag are hidden from the filter row entirely.
|
||||
|
||||
This is a **frontend-only** change. The `area` tag is already seeded into
|
||||
`node_tags` (`seed/node_tags.yaml`) and returned by `GET /api/v1/nodes`, but no
|
||||
code currently reads it. No backend, API, schema, or migration changes are
|
||||
required.
|
||||
|
||||
## Background & Motivation
|
||||
|
||||
The observer filter badge UI was introduced in
|
||||
`docs/plans/20260614-1220-observer-filter-badges/plan.md` as a replacement for a
|
||||
multi-select dropdown. The current data flow (unchanged by this plan):
|
||||
|
||||
1. Frontend calls `GET /api/v1/nodes?observer=true` (returns every node with
|
||||
`is_observer=true`, including its `tags` array).
|
||||
2. `advertisements.js:104-107` and `messages.js:268-271` derive `_displayName`
|
||||
per node as `tags[name]` -> `node.name` -> `public_key.slice(0,12)+'...'`.
|
||||
3. `components.js:652-668` `observerFilterBadges` renders one button per node
|
||||
using `_displayName`; `disabledObservers` (a `Set` of public keys) persists
|
||||
in `localStorage['meshcore-observers-disabled']`.
|
||||
4. The page expands the enabled set to public keys and sends them as repeated
|
||||
`?observed_by=` params on the data fetch.
|
||||
|
||||
The IPNet seed data assigns an `area` tag (e.g. `IP2`, `IP3`, `IP4`, `IP8`) to
|
||||
every node, but the value is stored and never displayed. Multiple observers
|
||||
share each area code (e.g. `IP2` has 3 repeaters -- `seed/node_tags.yaml:5,21,37`),
|
||||
so a naive per-node relabel would produce three identical `IP2` buttons.
|
||||
|
||||
Operator-chosen node names being unhelpful is the trigger; the `area` tag is the
|
||||
natural, already-present signal for a coarser, meaningful grouping.
|
||||
|
||||
## Goals
|
||||
|
||||
- Badge row shows **one badge per unique area code**, not one per observer node.
|
||||
- Badge label is the `area` tag value (e.g. `IP2`, `SE1`).
|
||||
- Toggling an area badge enables/disables **all observers** whose `area` tag
|
||||
matches that code, on both the Adverts and Messages pages.
|
||||
- Observers (`is_observer=true`) **without** a non-empty `area` tag are not
|
||||
shown as filter options (their data still appears when no area filter is
|
||||
active).
|
||||
- Selection persists across reloads and is shared between the two pages.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No change to the per-row observer-count badge tooltip (`observerIcons`,
|
||||
`components.js:568-573`) -- it keeps showing observer names.
|
||||
- No change to the filter row label (stays "Observer"; i18n key
|
||||
`common.filter_observer_label`).
|
||||
- No change to the `?observed_by=` API contract -- it still receives public keys.
|
||||
- No backend, model, schema, or migration changes.
|
||||
- No server-side filtering by tag (e.g. `?has_tag=area`) -- the observer node
|
||||
payload is small enough to filter client-side.
|
||||
- No new JS unit tests for the grouping logic (the JS layer currently has no
|
||||
unit-test harness; adding one is out of scope).
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-1** -- Filter the observer node list to those whose `area` tag exists and
|
||||
has a non-empty (non-whitespace) value.
|
||||
- **FR-2** -- Derive the set of unique area codes from that filtered list; sort
|
||||
them case-insensitively ascending.
|
||||
- **FR-3** -- Render one badge per area code; badge text = the area code.
|
||||
- **FR-4** -- Badge enabled state is driven by a persisted `Set<string>` of
|
||||
**area codes** (not public keys).
|
||||
- **FR-5** -- Clicking a badge toggles its area code in the persisted set; the
|
||||
data fetch expands the enabled area set to the underlying public keys and
|
||||
sends them as `?observed_by=`.
|
||||
- **FR-6** -- Enforce "keep at least one area enabled" -- refuse to disable the
|
||||
last enabled area (mirrors the existing per-observer guard).
|
||||
- **FR-7** -- When the user toggles a badge on a page > 1, reset to page 1
|
||||
(existing behaviour; preserved).
|
||||
- **FR-8** -- Observers without an `area` tag never appear as badges but their
|
||||
previously-recorded events are still returned when no area filter is active.
|
||||
- **FR-9** -- When no observers have an `area` tag, render no badge row and apply
|
||||
no `observed_by` filter (show all data).
|
||||
- **FR-10** -- Stale entries in the persisted set (area codes that no longer
|
||||
match any current observer) must not cause the data to be over-filtered.
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
- **TR-1** -- Frontend-only: changes confined to three files under
|
||||
`src/meshcore_hub/web/static/js/spa/`.
|
||||
- **TR-2** -- Use a **new** localStorage key (`meshcore-observer-areas-disabled`)
|
||||
rather than reusing `meshcore-observers-disabled`, so the legacy list of
|
||||
public keys cannot be misinterpreted as area codes.
|
||||
- **TR-3** -- No change to `apiGet`'s array-param encoding
|
||||
(`api.js:25-41` -- repeated `?observed_by=` keys).
|
||||
- **TR-4** -- No change to the two-phase fetch (observer list first, then
|
||||
filtered data); only the mapping from "disabled set" -> "enabled public keys"
|
||||
changes.
|
||||
- **TR-5** -- Existing i18n strings reused; no new keys.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Shared helpers and badge component (`components.js`)
|
||||
|
||||
File: `src/meshcore_hub/web/static/js/spa/components.js` (lines 588-668).
|
||||
|
||||
- **Rename the localStorage key** (line 592):
|
||||
`meshcore-observers-disabled` -> `meshcore-observer-areas-disabled`.
|
||||
- **Rename + re-scope the helpers**:
|
||||
- `getDisabledObservers()` -> `getDisabledObserverAreas()` (lines 598-607) --
|
||||
same JSON parse + safe-empty-on-error logic, new key.
|
||||
- `setDisabledObservers(set)` -> `setDisabledObserverAreas(set)` (lines
|
||||
613-619).
|
||||
- `toggleObserver(pubkey, totalObserverCount)` ->
|
||||
`toggleObserverArea(area, totalAreaCount)` (lines 628-641) -- same
|
||||
"block last disable" guard (`totalAreaCount - disabled.size <= 1`),
|
||||
operates on area-code strings.
|
||||
- **Reshape `observerFilterBadges` signature** (lines 643-668) from
|
||||
`{ nodes, disabled, onToggle, extraClass }` to
|
||||
`{ areas, disabled, onToggle, extraClass }`:
|
||||
- `areas`: `string[]` of area codes (already sorted by the caller).
|
||||
- Return `nothing` if `areas.length === 0`.
|
||||
- Per-badge: label = area code; enabled state = `!disabled.has(area)`;
|
||||
`@click=${() => onToggle(area)}`.
|
||||
- Existing styling (`badge badge-primary` / `badge badge-ghost opacity-50`),
|
||||
tooltip i18n keys (`filter_observer_enable` / `filter_observer_disable`),
|
||||
row label (`filter_observer_label`), and `extraClass` responsive-visibility
|
||||
contract all unchanged.
|
||||
- **Leave `observerIcons` (lines 568-573) untouched** -- per-row tooltip keeps
|
||||
using `o.tag_name || o.name`.
|
||||
|
||||
### Phase 2: Adverts page wiring (`advertisements.js`)
|
||||
|
||||
File: `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`.
|
||||
|
||||
- **Imports (line 8)**: replace `getDisabledObservers, toggleObserver` with
|
||||
`getDisabledObserverAreas, toggleObserverArea`. Keep `observerFilterBadges`,
|
||||
`observerIcons`, `routeTypeBadge`.
|
||||
- **Closure init (line 25)**:
|
||||
`let disabledObserverAreas = getDisabledObserverAreas();`
|
||||
- **Replace the per-node map/sort/enabled-keys block (lines 104-114)** with
|
||||
area grouping:
|
||||
```js
|
||||
const areaMap = new Map(); // area -> public_key[]
|
||||
for (const n of allNodes) {
|
||||
const area = n.tags?.find(tg => tg.key === 'area')?.value;
|
||||
if (!area || !area.trim()) continue;
|
||||
const key = area.trim();
|
||||
if (!areaMap.has(key)) areaMap.set(key, []);
|
||||
areaMap.get(key).push(n.public_key);
|
||||
}
|
||||
const sortedAreas = [...areaMap.keys()]
|
||||
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
const enabledObserverKeys = sortedAreas
|
||||
.filter(a => !disabledObserverAreas.has(a))
|
||||
.flatMap(a => areaMap.get(a));
|
||||
// Only constrain when some current area is actually hidden.
|
||||
const observerFilterActive = sortedAreas.some(a => disabledObserverAreas.has(a));
|
||||
```
|
||||
- **Toggle handler (lines 116-127)**: rename param `pubkey` -> `area`; call
|
||||
`disabledObserverAreas = toggleObserverArea(area, sortedAreas.length);`.
|
||||
Navigation/page-reset logic unchanged.
|
||||
- **Data fetch (lines 130-133)**: `apiParams.observed_by = enabledObserverKeys`
|
||||
when `observerFilterActive` -- unchanged mechanically, just sourced from the
|
||||
new area expansion.
|
||||
- **Badge factory (lines 139-141)**: pass `areas: sortedAreas` instead of
|
||||
`nodes: sortedNodes`.
|
||||
|
||||
### Phase 3: Messages page wiring (`messages.js`)
|
||||
|
||||
File: `src/meshcore_hub/web/static/js/spa/pages/messages.js`.
|
||||
|
||||
Mirror Phase 2 at the corresponding locations:
|
||||
- Imports (line 9).
|
||||
- Closure init (line 26).
|
||||
- Per-node block (lines 268-278).
|
||||
- Toggle handler (lines 280-291).
|
||||
- Badge factory (lines 302-304).
|
||||
|
||||
The data fetch (lines 293-297) and `channelLabels` plumbing are unaffected.
|
||||
|
||||
### Phase 4: Legacy cleanup and build
|
||||
|
||||
- **Legacy localStorage cleanup in `app.js`**: at the top of the boot sequence
|
||||
(after locale load, before router start), add an idempotent one-time cleanup:
|
||||
```js
|
||||
try { localStorage.removeItem('meshcore-observers-disabled'); } catch {}
|
||||
```
|
||||
This removes the orphaned key so users don't accumulate stale keys in dev
|
||||
tools. Runs unconditionally on every page load — harmless and idempotent.
|
||||
- Rebuild the SPA bundle (`make build`, which runs the Docker image build that
|
||||
bundles `dist/`).
|
||||
- `make up`, then manually exercise the verification checklist below.
|
||||
- `pre-commit run --all-files` — no Python changes, but the hook runs on the
|
||||
whole tree and will catch any JS lint config if present.
|
||||
|
||||
## Edge cases & invariants
|
||||
|
||||
- **Observer with `is_observer=true` but no `area` tag** -> filtered out by the
|
||||
`if (!area || !area.trim()) continue` guard; never rendered as a badge.
|
||||
- **Empty / whitespace-only `area` value** -> treated as missing (trimmed check).
|
||||
- **Multiple observers sharing an area** -> collapse into a single badge; toggle
|
||||
affects all of them via the `areaMap` expansion.
|
||||
- **Zero observers have area tags** -> `sortedAreas=[]`; `observerFilterBadges`
|
||||
returns `nothing`; `observerFilterActive=false`; no `observed_by` sent; all
|
||||
data shown.
|
||||
- **Stale area codes in localStorage** (an area that no longer exists) -> never
|
||||
matched in `sortedAreas.some(...)`, so `observerFilterActive` stays false and
|
||||
no over-filtering occurs. Harmless.
|
||||
- **"Block last disable"** -> uses `sortedAreas.length` so the rule is now "keep
|
||||
>= 1 area enabled", which is the correct invariant for grouped badges.
|
||||
- **Non-area observers excluded when filter active** -> when at least one area
|
||||
badge is toggled off, `observerFilterActive=true` and `enabledObserverKeys`
|
||||
contains only keys from enabled areas. Observers without an `area` tag are not
|
||||
in that set, so their events are excluded from results alongside the disabled
|
||||
areas. This is intentional: area filtering means "show me only events from
|
||||
these areas." Non-area observers' events reappear when all areas are re-enabled
|
||||
(i.e., no filter active).
|
||||
- **Legacy `meshcore-observers-disabled` key** -> one-time cleanup on SPA boot
|
||||
is included in Phase 4.
|
||||
|
||||
## Verification
|
||||
|
||||
Manual checklist after `make build && make up`:
|
||||
|
||||
- `/advertisements` and `/messages` each show one badge per unique area code
|
||||
present among observer nodes (e.g. `IP2`, `IP3`, `IP4`, `IP8`).
|
||||
- Observers with `is_observer=true` but no `area` tag do **not** appear as
|
||||
badges.
|
||||
- Clicking an area badge greys it out and re-scopes the list to events observed
|
||||
by nodes in the still-enabled areas; page resets to 1 if currently > 1.
|
||||
- Reload the page -> selection restored from `localStorage` before the first
|
||||
data fetch (no flash of unfiltered data).
|
||||
- Switch between Adverts and Messages -> same area selection applies (shared
|
||||
key).
|
||||
- Attempt to disable the last enabled area -> blocked.
|
||||
- Per-row observer-count badge tooltip still shows observer **names**
|
||||
(unchanged).
|
||||
- Filter row label still reads "Observer".
|
||||
- DevTools -> Application -> Local Storage: the new
|
||||
`meshcore-observer-areas-disabled` key holds an array of area-code strings;
|
||||
the old `meshcore-observers-disabled` key is removed by the one-time cleanup in `app.js`.
|
||||
|
||||
## Open Questions
|
||||
|
||||
All resolved during review.
|
||||
|
||||
- **Legacy localStorage cleanup** -> **RESOLVED**: Yes, add `localStorage.removeItem('meshcore-observers-disabled')` in `app.js` boot (Phase 4).
|
||||
- **Empty-area fallback display** -> **RESOLVED**: Observers without `area` are hidden from badges; their events appear when no area filter is active. The non-area exclusion when filter IS active is documented as an explicit edge case.
|
||||
- **JS unit tests** -> **RESOLVED (deferred)**: No existing JS test harness; adding one is a worthwhile follow-up but out of scope for this plan.
|
||||
|
||||
## Review
|
||||
|
||||
**Status**: Approved with Changes
|
||||
|
||||
**Reviewed**: 2026-07-07
|
||||
|
||||
### Resolutions
|
||||
|
||||
- **Legacy localStorage cleanup**: Add `localStorage.removeItem('meshcore-observers-disabled')` in `app.js` at SPA boot (idempotent, harmless).
|
||||
- **Non-area observer exclusion when filter active**: Documented as intentional edge case — area filtering means "show only events from selected areas," so un-categorized observers are excluded alongside disabled areas.
|
||||
- **Line number correction**: `advertisements.js` data fetch lines corrected from 129-133 to 130-133.
|
||||
- **JS unit tests**: Deferred — no harness exists; logic is simple and manually verifiable.
|
||||
|
||||
### Remaining Action Items
|
||||
|
||||
- Confirm the build produces a correct SPA bundle after changes (Phase 4 verification).
|
||||
|
||||
## References
|
||||
|
||||
- `docs/plans/20260614-1220-observer-filter-badges/plan.md` -- original design
|
||||
spec for the badge UI being modified here (data flow, two-phase fetch,
|
||||
localStorage-disabled-set model, "block last disable" guard).
|
||||
- `docs/plans/2026-06-14-observer-filter-cache-key-collision/plan.md` -- earlier
|
||||
fix to the API cache key for repeated `observed_by` values; unaffected by this
|
||||
change but referenced for completeness.
|
||||
- `docs/plans/20260625-2005-observer-ingestion-filters/plan.md` -- unrelated
|
||||
collector-side observer allow/deny filter; cited to disambiguate the two
|
||||
meanings of "observer filter" in this codebase.
|
||||
- `seed/node_tags.yaml` -- source of `area` tag values already loaded into
|
||||
`node_tags` (e.g. `IP2`, `IP3`, `IP4`, `IP8`).
|
||||
- `src/meshcore_hub/api/routes/nodes.py:62-64,154-157` -- `?observer=true` filter
|
||||
on `Node.is_observer` (unchanged).
|
||||
- `src/meshcore_hub/api/observer_utils.py:13-37` -- `observed_by_filter_clause`
|
||||
joins `event_observers` on `Node.public_key` (unchanged; still receives
|
||||
public keys expanded from the enabled area set).
|
||||
@@ -0,0 +1,105 @@
|
||||
# Tasks: Area-tag-based observer filter badges
|
||||
|
||||
> Generated from `plan.md` on 2026-07-07
|
||||
|
||||
## Phase 1: Shared helpers and badge component (`components.js`)
|
||||
|
||||
File: `src/meshcore_hub/web/static/js/spa/components.js` (lines 588-668).
|
||||
|
||||
- [x] Rename the localStorage key constant
|
||||
- [x] Change `OBSERVER_FILTER_KEY` value from `meshcore-observers-disabled` to `meshcore-observer-areas-disabled` (line ~592)
|
||||
- [x] Rename `getDisabledObservers` -> `getDisabledObserverAreas` (lines 598-607)
|
||||
- [x] Update function name and keep same JSON-parse + safe-empty-on-error logic, reading the new key
|
||||
- [x] Rename `setDisabledObservers` -> `setDisabledObserverAreas` (lines 613-619)
|
||||
- [x] Update function name, persist to new key
|
||||
- [x] Rename `toggleObserver` -> `toggleObserverArea` (lines 628-641)
|
||||
- [x] Change param from `pubkey` to `area` (area-code string)
|
||||
- [x] Change param from `totalObserverCount` to `totalAreaCount`
|
||||
- [x] Keep the "block last disable" guard: `totalAreaCount - disabled.size <= 1`
|
||||
- [x] Call `setDisabledObserverAreas` instead of `setDisabledObservers`
|
||||
- [x] Reshape `observerFilterBadges` signature (lines 643-668) from `{ nodes, disabled, onToggle, extraClass }` to `{ areas, disabled, onToggle, extraClass }`
|
||||
- [x] Accept `areas: string[]` (already sorted by caller) instead of `nodes`
|
||||
- [x] Return `nothing` (lit-html) if `areas.length === 0`
|
||||
- [x] Per-badge: label = area code string; enabled state = `!disabled.has(area)`
|
||||
- [x] Per-badge click handler: `@click=${() => onToggle(area)}`
|
||||
- [x] Preserve existing styling (`badge badge-primary` / `badge badge-ghost opacity-50`)
|
||||
- [x] Preserve tooltip i18n keys (`filter_observer_enable` / `filter_observer_disable`)
|
||||
- [x] Preserve row label (`filter_observer_label`) and `extraClass` responsive-visibility contract
|
||||
- [x] Confirm `observerIcons` (lines 568-573) is left untouched (per-row tooltip keeps `o.tag_name || o.name`)
|
||||
|
||||
## Phase 2: Adverts page wiring (`advertisements.js`)
|
||||
|
||||
File: `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`.
|
||||
|
||||
- [x] Update imports (line 8)
|
||||
- [x] Replace `getDisabledObservers, toggleObserver` with `getDisabledObserverAreas, toggleObserverArea`
|
||||
- [x] Keep `observerFilterBadges`, `observerIcons`, `routeTypeBadge` imports
|
||||
- [x] Update closure init (line 25)
|
||||
- [x] Change `disabledObservers` variable to `disabledObserverAreas = getDisabledObserverAreas()`
|
||||
- [x] Replace per-node map/sort/enabled-keys block (lines 104-114) with area grouping
|
||||
- [x] Build `areaMap: Map<string, string[]>` from `allNodes` where key = trimmed `area` tag value, value = array of `public_key`s
|
||||
- [x] Skip nodes where `area` tag is missing or empty/whitespace (`if (!area || !area.trim()) continue`)
|
||||
- [x] Derive `sortedAreas` = `[...areaMap.keys()].sort(...)` case-insensitive ascending
|
||||
- [x] Derive `enabledObserverKeys` = `sortedAreas.filter(a => !disabledObserverAreas.has(a)).flatMap(a => areaMap.get(a))`
|
||||
- [x] Derive `observerFilterActive` = `sortedAreas.some(a => disabledObserverAreas.has(a))`
|
||||
- [x] Update toggle handler (lines 116-127)
|
||||
- [x] Rename param `pubkey` -> `area`
|
||||
- [x] Call `disabledObserverAreas = toggleObserverArea(area, sortedAreas.length)`
|
||||
- [x] Keep navigation/page-reset logic unchanged (reset to page 1 if currently > 1)
|
||||
- [x] Verify data fetch (lines 130-133) still works
|
||||
- [x] `apiParams.observed_by = enabledObserverKeys` when `observerFilterActive` (mechanically unchanged, sourced from new area expansion)
|
||||
- [x] Update badge factory call (lines 139-141)
|
||||
- [x] Pass `areas: sortedAreas` instead of `nodes: sortedNodes`
|
||||
|
||||
## Phase 3: Messages page wiring (`messages.js`)
|
||||
|
||||
File: `src/meshcore_hub/web/static/js/spa/pages/messages.js`.
|
||||
|
||||
Mirror Phase 2 changes at corresponding locations.
|
||||
|
||||
- [x] Update imports (line 9)
|
||||
- [x] Replace `getDisabledObservers, toggleObserver` with `getDisabledObserverAreas, toggleObserverArea`
|
||||
- [x] Keep `observerFilterBadges`, `observerIcons` imports
|
||||
- [x] Update closure init (line 26)
|
||||
- [x] Change `disabledObservers` variable to `disabledObserverAreas = getDisabledObserverAreas()`
|
||||
- [x] Replace per-node block (lines 268-278) with area grouping
|
||||
- [x] Build `areaMap`, `sortedAreas`, `enabledObserverKeys`, `observerFilterActive` (same logic as Phase 2)
|
||||
- [x] Update toggle handler (lines 280-291)
|
||||
- [x] Rename param `pubkey` -> `area`
|
||||
- [x] Call `disabledObserverAreas = toggleObserverArea(area, sortedAreas.length)`
|
||||
- [x] Keep page-reset logic unchanged
|
||||
- [x] Verify data fetch (lines 293-297) and `channelLabels` plumbing are unaffected
|
||||
- [x] Update badge factory call (lines 302-304)
|
||||
- [x] Pass `areas: sortedAreas` instead of `nodes: sortedNodes`
|
||||
|
||||
## Phase 4: Legacy cleanup (`app.js`)
|
||||
|
||||
File: `src/meshcore_hub/web/static/js/spa/app.js`.
|
||||
|
||||
- [x] Add idempotent legacy localStorage cleanup at boot (after locale load ~line 256, before router start ~line 265)
|
||||
- [x] Add `try { localStorage.removeItem('meshcore-observers-disabled'); } catch {}`
|
||||
- [x] Confirm it runs unconditionally on every page load
|
||||
|
||||
## Verification
|
||||
|
||||
- [x] Rebuild the SPA bundle
|
||||
- [x] Run `make build` (Docker image build that bundles `dist/`) — passed
|
||||
- [ ] Start the stack
|
||||
- [ ] Run `make up` — deferred to user (requires running stack for manual checks)
|
||||
- [x] Run pre-commit
|
||||
- [x] Run `pre-commit run --all-files` (no Python changes; hook runs on whole tree) — passed
|
||||
- [x] Run relevant web tests
|
||||
- [x] `pytest --no-cov tests/test_web/test_advertisements.py tests/test_web/test_messages.py` — 24 passed
|
||||
- [ ] Manual: `/advertisements` and `/messages` show one badge per unique area code (e.g. `IP2`, `IP3`, `IP4`, `IP8`)
|
||||
- [ ] Manual: observers with `is_observer=true` but no `area` tag do not appear as badges
|
||||
- [ ] Manual: clicking an area badge greys it out and re-scopes the list to events observed by nodes in still-enabled areas
|
||||
- [ ] Manual: page resets to 1 when toggling a badge on a page > 1
|
||||
- [ ] Manual: reload restores selection from `localStorage` before first data fetch (no flash of unfiltered data)
|
||||
- [ ] Manual: switch between Adverts and Messages -> same area selection applies (shared key)
|
||||
- [ ] Manual: attempt to disable the last enabled area -> blocked
|
||||
- [ ] Manual: per-row observer-count badge tooltip still shows observer **names** (unchanged)
|
||||
- [ ] Manual: filter row label still reads "Observer"
|
||||
- [ ] Manual: DevTools -> Application -> Local Storage confirms `meshcore-observer-areas-disabled` holds array of area-code strings
|
||||
- [ ] Manual: DevTools -> Application -> Local Storage confirms old `meshcore-observers-disabled` key is removed by cleanup
|
||||
- [ ] Edge case: zero observers have `area` tags -> no badge row, no filter, all data shown
|
||||
- [ ] Edge case: stale area codes in localStorage (area no longer exists) -> no over-filtering
|
||||
@@ -255,6 +255,10 @@ function renderMobileNav(config) {
|
||||
const locale = localStorage.getItem('meshcore-locale') || config.locale || 'en';
|
||||
await loadLocale(locale);
|
||||
|
||||
// Legacy cleanup: remove the old per-observer localStorage key so stale public
|
||||
// keys are never misread as area codes by the new area-based filter.
|
||||
try { localStorage.removeItem('meshcore-observers-disabled'); } catch {}
|
||||
|
||||
// Render auth section in navbar (after translations are loaded)
|
||||
const authSection = document.getElementById('auth-section');
|
||||
renderAuthSection(authSection, config);
|
||||
|
||||
@@ -588,14 +588,14 @@ export function routeTypeBadge(routeType) {
|
||||
// --- Observer filter (localStorage-backed toggle badges) ---
|
||||
|
||||
// Shared across the Adverts and Messages pages. We persist the *disabled* set
|
||||
// so any newly-discovered observer node defaults to enabled automatically.
|
||||
const OBSERVER_FILTER_KEY = 'meshcore-observers-disabled';
|
||||
// of area codes so any newly-discovered area defaults to enabled automatically.
|
||||
const OBSERVER_FILTER_KEY = 'meshcore-observer-areas-disabled';
|
||||
|
||||
/**
|
||||
* Read the set of disabled (deselected) observer public keys from localStorage.
|
||||
* Read the set of disabled (deselected) observer area codes from localStorage.
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function getDisabledObservers() {
|
||||
export function getDisabledObserverAreas() {
|
||||
try {
|
||||
const raw = localStorage.getItem(OBSERVER_FILTER_KEY);
|
||||
if (!raw) return new Set();
|
||||
@@ -607,10 +607,10 @@ export function getDisabledObservers() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the set of disabled observer public keys to localStorage.
|
||||
* Persist the set of disabled observer area codes to localStorage.
|
||||
* @param {Set<string>} disabled
|
||||
*/
|
||||
export function setDisabledObservers(disabled) {
|
||||
export function setDisabledObserverAreas(disabled) {
|
||||
try {
|
||||
localStorage.setItem(OBSERVER_FILTER_KEY, JSON.stringify([...disabled]));
|
||||
} catch {
|
||||
@@ -619,42 +619,42 @@ export function setDisabledObservers(disabled) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle an observer's enabled state, enforcing that at least one observer
|
||||
* Toggle an observer area's enabled state, enforcing that at least one area
|
||||
* stays enabled. Returns the updated disabled set (persisted).
|
||||
* @param {string} pubkey - Observer public key to toggle
|
||||
* @param {number} totalObserverCount - Total number of observer nodes
|
||||
* @param {string} area - Observer area code to toggle
|
||||
* @param {number} totalAreaCount - Total number of observer areas
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function toggleObserver(pubkey, totalObserverCount) {
|
||||
const disabled = getDisabledObservers();
|
||||
if (disabled.has(pubkey)) {
|
||||
disabled.delete(pubkey);
|
||||
export function toggleObserverArea(area, totalAreaCount) {
|
||||
const disabled = getDisabledObserverAreas();
|
||||
if (disabled.has(area)) {
|
||||
disabled.delete(area);
|
||||
} else {
|
||||
// Block disabling the last enabled observer.
|
||||
if (totalObserverCount - disabled.size <= 1) {
|
||||
// Block disabling the last enabled area.
|
||||
if (totalAreaCount - disabled.size <= 1) {
|
||||
return disabled;
|
||||
}
|
||||
disabled.add(pubkey);
|
||||
disabled.add(area);
|
||||
}
|
||||
setDisabledObservers(disabled);
|
||||
setDisabledObserverAreas(disabled);
|
||||
return disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a row of clickable observer filter badges.
|
||||
* @param {Array<Object>} options.nodes - Observer nodes (with public_key and _displayName)
|
||||
* @param {Set<string>} options.disabled - Currently disabled observer public keys
|
||||
* @param {Function} options.onToggle - Called with a public_key when a badge is clicked
|
||||
* Render a row of clickable observer filter badges, one per area code.
|
||||
* @param {Array<string>} options.areas - Observer area codes (already sorted)
|
||||
* @param {Set<string>} options.disabled - Currently disabled area codes
|
||||
* @param {Function} options.onToggle - Called with an area code when a badge is clicked
|
||||
* @param {string} [options.extraClass] - Wrapper classes; must set the display
|
||||
* (e.g. 'hidden lg:flex' or 'flex lg:hidden') since the base omits it to avoid conflicts
|
||||
* @returns {TemplateResult|nothing}
|
||||
*/
|
||||
export function observerFilterBadges({ nodes, disabled, onToggle, extraClass = 'flex' }) {
|
||||
if (!nodes || nodes.length === 0) return nothing;
|
||||
export function observerFilterBadges({ areas, disabled, onToggle, extraClass = 'flex' }) {
|
||||
if (!areas || areas.length === 0) return nothing;
|
||||
return html`<div class="flex-wrap items-center gap-2 ${extraClass}">
|
||||
<span class="opacity-80 text-sm">${t('common.filter_observer_label')}:</span>
|
||||
${nodes.map(n => {
|
||||
const enabled = !disabled.has(n.public_key);
|
||||
${areas.map(area => {
|
||||
const enabled = !disabled.has(area);
|
||||
const cls = enabled ? 'badge badge-primary' : 'badge badge-ghost opacity-50';
|
||||
const title = enabled
|
||||
? t('common.filter_observer_disable')
|
||||
@@ -662,7 +662,7 @@ export function observerFilterBadges({ nodes, disabled, onToggle, extraClass = '
|
||||
return html`<button type="button"
|
||||
class="${cls} cursor-pointer"
|
||||
title=${title}
|
||||
@click=${() => onToggle(n.public_key)}>${n._displayName}</button>`;
|
||||
@click=${() => onToggle(area)}>${area}</button>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
warningBadge,
|
||||
pagination, sortableTableHeader, mobileSortSelect,
|
||||
renderFilterForm, renderFilterToggle, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
|
||||
observerIcons, getDisabledObservers, toggleObserver, observerFilterBadges, routeTypeBadge
|
||||
observerIcons, getDisabledObserverAreas, toggleObserverArea, observerFilterBadges, routeTypeBadge
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.js';
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function render(container, params, router) {
|
||||
const order = query.order || 'desc';
|
||||
|
||||
// Observer filter is sourced from localStorage (shared toggle badges), not the URL.
|
||||
let disabledObservers = getDisabledObservers();
|
||||
let disabledObserverAreas = getDisabledObserverAreas();
|
||||
|
||||
const config = getConfig();
|
||||
const features = config.features || {};
|
||||
@@ -101,20 +101,24 @@ ${displayContent}`, container);
|
||||
: [];
|
||||
const allNodes = nodesData.items || [];
|
||||
|
||||
const sortedNodes = allNodes.map(n => {
|
||||
const tagName = n.tags?.find(t => t.key === 'name')?.value;
|
||||
return { ...n, _sortName: (tagName || n.name || '').toLowerCase(), _displayName: tagName || n.name || n.public_key.slice(0, 12) + '...' };
|
||||
}).sort((a, b) => a._sortName.localeCompare(b._sortName));
|
||||
const areaMap = new Map(); // area -> public_key[]
|
||||
for (const n of allNodes) {
|
||||
const area = n.tags?.find(tg => tg.key === 'area')?.value;
|
||||
if (!area || !area.trim()) continue;
|
||||
const key = area.trim();
|
||||
if (!areaMap.has(key)) areaMap.set(key, []);
|
||||
areaMap.get(key).push(n.public_key);
|
||||
}
|
||||
const sortedAreas = [...areaMap.keys()]
|
||||
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
const enabledObserverKeys = sortedAreas
|
||||
.filter(a => !disabledObserverAreas.has(a))
|
||||
.flatMap(a => areaMap.get(a));
|
||||
// Only constrain when some current area is actually hidden.
|
||||
const observerFilterActive = sortedAreas.some(a => disabledObserverAreas.has(a));
|
||||
|
||||
const enabledObserverKeys = sortedNodes
|
||||
.filter(n => !disabledObservers.has(n.public_key))
|
||||
.map(n => n.public_key);
|
||||
// Only constrain when some current observer is actually hidden (a stale
|
||||
// disabled key that no longer matches a node should not filter anything).
|
||||
const observerFilterActive = enabledObserverKeys.length < sortedNodes.length;
|
||||
|
||||
const onObserverToggle = (pubkey) => {
|
||||
disabledObservers = toggleObserver(pubkey, sortedNodes.length);
|
||||
const onObserverToggle = (area) => {
|
||||
disabledObserverAreas = toggleObserverArea(area, sortedAreas.length);
|
||||
if (page > 1) {
|
||||
// Re-scoping the data invalidates the current page; reset to page 1.
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
@@ -137,7 +141,7 @@ ${displayContent}`, container);
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const observerBadges = (extraClass) => observerFilterBadges({
|
||||
nodes: sortedNodes, disabled: disabledObservers, onToggle: onObserverToggle, extraClass,
|
||||
areas: sortedAreas, disabled: disabledObserverAreas, onToggle: onObserverToggle, extraClass,
|
||||
});
|
||||
|
||||
const mobileCards = advertisements.length === 0
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
warningBadge,
|
||||
pagination, sortableTableHeader, mobileSortSelect,
|
||||
renderFilterForm, renderFilterToggle, autoSubmit,
|
||||
observerIcons, getDisabledObservers, toggleObserver, observerFilterBadges
|
||||
observerIcons, getDisabledObserverAreas, toggleObserverArea, observerFilterBadges
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.js';
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function render(container, params, router) {
|
||||
const order = query.order || 'desc';
|
||||
|
||||
// Observer filter is sourced from localStorage (shared toggle badges), not the URL.
|
||||
let disabledObservers = getDisabledObservers();
|
||||
let disabledObserverAreas = getDisabledObserverAreas();
|
||||
|
||||
const config = getConfig();
|
||||
const features = config.features || {};
|
||||
@@ -265,20 +265,24 @@ ${displayContent}`, container);
|
||||
channelLabels = new Map([...builtinLabels, ...customLabels]);
|
||||
const allNodes = nodesData.items || [];
|
||||
|
||||
const sortedNodes = allNodes.map(n => {
|
||||
const tagName = n.tags?.find(t => t.key === 'name')?.value;
|
||||
return { ...n, _sortName: (tagName || n.name || '').toLowerCase(), _displayName: tagName || n.name || n.public_key.slice(0, 12) + '...' };
|
||||
}).sort((a, b) => a._sortName.localeCompare(b._sortName));
|
||||
const areaMap = new Map(); // area -> public_key[]
|
||||
for (const n of allNodes) {
|
||||
const area = n.tags?.find(tg => tg.key === 'area')?.value;
|
||||
if (!area || !area.trim()) continue;
|
||||
const key = area.trim();
|
||||
if (!areaMap.has(key)) areaMap.set(key, []);
|
||||
areaMap.get(key).push(n.public_key);
|
||||
}
|
||||
const sortedAreas = [...areaMap.keys()]
|
||||
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
const enabledObserverKeys = sortedAreas
|
||||
.filter(a => !disabledObserverAreas.has(a))
|
||||
.flatMap(a => areaMap.get(a));
|
||||
// Only constrain when some current area is actually hidden.
|
||||
const observerFilterActive = sortedAreas.some(a => disabledObserverAreas.has(a));
|
||||
|
||||
const enabledObserverKeys = sortedNodes
|
||||
.filter(n => !disabledObservers.has(n.public_key))
|
||||
.map(n => n.public_key);
|
||||
// Only constrain when some current observer is actually hidden (a stale
|
||||
// disabled key that no longer matches a node should not filter anything).
|
||||
const observerFilterActive = enabledObserverKeys.length < sortedNodes.length;
|
||||
|
||||
const onObserverToggle = (pubkey) => {
|
||||
disabledObservers = toggleObserver(pubkey, sortedNodes.length);
|
||||
const onObserverToggle = (area) => {
|
||||
disabledObserverAreas = toggleObserverArea(area, sortedAreas.length);
|
||||
if (page > 1) {
|
||||
// Re-scoping the data invalidates the current page; reset to page 1.
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
@@ -300,7 +304,7 @@ ${displayContent}`, container);
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const observerBadges = (extraClass) => observerFilterBadges({
|
||||
nodes: sortedNodes, disabled: disabledObservers, onToggle: onObserverToggle, extraClass,
|
||||
areas: sortedAreas, disabled: disabledObserverAreas, onToggle: onObserverToggle, extraClass,
|
||||
});
|
||||
|
||||
const mobileCards = messages.length === 0
|
||||
|
||||
Reference in New Issue
Block a user