Merge pull request #301 from ipnet-mesh/feat/observer-area-filters

feat(web): group observer filter badges by area tag
This commit is contained in:
JingleManSweep
2026-07-07 22:21:03 +01:00
committed by GitHub
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