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:
Louis King
2026-07-07 22:13:15 +01:00
parent 62d5b637f0
commit d1a3605a54
6 changed files with 465 additions and 58 deletions
@@ -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