diff --git a/docs/plans/20260707-2157-observer-area-filters/plan.md b/docs/plans/20260707-2157-observer-area-filters/plan.md new file mode 100644 index 0000000..7c2e62a --- /dev/null +++ b/docs/plans/20260707-2157-observer-area-filters/plan.md @@ -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` 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). diff --git a/docs/plans/20260707-2157-observer-area-filters/tasks.md b/docs/plans/20260707-2157-observer-area-filters/tasks.md new file mode 100644 index 0000000..01235d6 --- /dev/null +++ b/docs/plans/20260707-2157-observer-area-filters/tasks.md @@ -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` 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 diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js index 15713d2..262fd0d 100644 --- a/src/meshcore_hub/web/static/js/spa/app.js +++ b/src/meshcore_hub/web/static/js/spa/app.js @@ -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); diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index b1e2a33..c2dcc31 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -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} */ -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} 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} */ -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} options.nodes - Observer nodes (with public_key and _displayName) - * @param {Set} 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} options.areas - Observer area codes (already sorted) + * @param {Set} 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`
${t('common.filter_observer_label')}: - ${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``; + @click=${() => onToggle(area)}>${area}`; })}
`; } diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index 8137501..c4f718a 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -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 diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index 4a5f866..45948b4 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -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