mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-06-26 13:01:55 +02:00
feat(web): observer filter as toggle badges on adverts/messages
Replace the multi-select Observer dropdown buried in the filter panel with a row of clickable observer badges rendered between the filter panel and the data list (and below the Sorting dropdown on mobile). - Selection is stored in localStorage (shared across Adverts and Messages) as the disabled set, so new observers default to enabled. - Badge style reflects enabled (filled) vs disabled (muted) state; the last enabled observer cannot be toggled off. - Observer filter is sourced from localStorage instead of the URL query; a two-phase fetch resolves the enabled include-list (the API filters by inclusion only) before fetching data, with no flash of unfiltered results. - Toggling re-scopes data and resets to page 1. - Add enable/disable tooltip strings (en/nl) and the previously-missing Dutch observer label. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
# Plan: Observer filter as toggle badges (Adverts & Messages)
|
||||
|
||||
## Goal
|
||||
Replace the multi-select Observer dropdown (currently buried in the Filter panel) with a
|
||||
row of clickable observer **badges** rendered between the filter panel and the data list.
|
||||
Selection persists in `localStorage` (shared across both pages), defaults to all-enabled,
|
||||
and is applied to the first API call on load.
|
||||
|
||||
## Motivation
|
||||
The Observer filter on the Advert and Message pages is hard to reach (inside the collapsed
|
||||
filter panel, as a multi-select `<select>`). Many users only care about a single observer or
|
||||
a specific set, and re-opening the filter panel each visit is a chore. Badges give one-click
|
||||
toggling, visible state, and remembered preferences across sessions.
|
||||
|
||||
## Confirmed decisions
|
||||
- **Shared selection** across Adverts + Messages (single localStorage key).
|
||||
- **Block the last toggle-off** — always keep >= 1 observer enabled.
|
||||
- **Reset to page 1 on toggle** — toggling re-scopes the data, so navigate to the base path
|
||||
(dropping `page`) and re-fetch.
|
||||
|
||||
## Core model
|
||||
Persist the **disabled** set, not the enabled set. Storing deselected pubkeys means any
|
||||
newly-discovered observer node defaults to enabled automatically (matches "by default all
|
||||
observers enabled").
|
||||
|
||||
- localStorage key: `meshcore-observers-disabled` -> JSON array of pubkeys.
|
||||
- Effective filter = all observer nodes minus the disabled set.
|
||||
- If disabled set is empty -> send **no** `observed_by` param (show all).
|
||||
- If some disabled -> send `observed_by` = enabled pubkeys.
|
||||
- **Implementation note (deviation):** the adverts/messages API filters observers by
|
||||
*inclusion only* (`observed_by` is an include-list; there is no exclude param). So the data
|
||||
fetch genuinely depends on the full observer node list to translate the stored disabled set
|
||||
into an include-list. Implemented as a **two-phase fetch**: phase 1 fetches the observer
|
||||
nodes (plus channels/profiles), phase 2 fetches the data with the resolved `observed_by`.
|
||||
This is fully correct (always uses the fresh node list) and produces no flash of unfiltered
|
||||
data, at the cost of the main data call waiting on the (small) nodes call. The original
|
||||
"derive synchronously from localStorage, fetch in parallel" idea is not achievable without a
|
||||
client-side cache of observer pubkeys; two-phase was chosen as the simpler, always-correct
|
||||
option.
|
||||
- `observerFilterActive` is gated on `enabledKeys.length < sortedNodes.length`, so a stale
|
||||
disabled key that no longer matches any current node does not accidentally filter everything.
|
||||
|
||||
## Source-of-truth change
|
||||
Today `observed_by` lives in the **URL query string** and is threaded through pagination/sort
|
||||
links and the filter form. Moving to localStorage means:
|
||||
- Remove `observed_by` from the filter panel, from `headerParams`, and from `pagination(...)`
|
||||
params on both pages.
|
||||
- Toggling a badge updates localStorage and re-scopes the data (reset to page 1). Page/sort/
|
||||
search stay in the URL; observer selection does not.
|
||||
|
||||
### Why pagination is not broken
|
||||
- Pagination is driven by the `page` param, which stays in the URL. `observed_by` was only
|
||||
carried along so the filter survived a page click.
|
||||
- Every navigation re-invokes the page's `render()`, which re-reads `getDisabledObservers()`
|
||||
at the top, so the same filter is applied on every page. localStorage is stable across
|
||||
navigations, so total count / page count stay consistent while paging.
|
||||
- Toggling a badge can make the current page number out of range, so the toggle handler resets
|
||||
to page 1 (navigates to the base path without `page`, then re-fetches).
|
||||
|
||||
## Files to change
|
||||
|
||||
### 1. `src/meshcore_hub/web/static/js/spa/components.js` — add helpers + component
|
||||
- localStorage helpers (mirroring the theme pattern in `spa.html`):
|
||||
- `getDisabledObservers()` -> `Set<string>` (safe JSON parse, returns empty set on error).
|
||||
- `setDisabledObservers(set)` -> persists JSON array.
|
||||
- `toggleObserver(pubkey, totalObserverCount)` -> updates the set, enforcing the
|
||||
"keep >= 1 enabled" guard (refuse to disable the last enabled observer); returns the new set.
|
||||
- `observerFilterBadges({ nodes, disabled, onToggle, extraClass })` component:
|
||||
- Returns `nothing` if `nodes.length === 0`.
|
||||
- Small label (`common.filter_observer_label`) + one badge per observer (using `n._displayName`).
|
||||
- **Enabled** badge: `badge badge-primary` (filled). **Disabled** badge: `badge badge-ghost`
|
||||
+ `opacity-50` (muted/outlined). Each `cursor-pointer`, `@click=${() => onToggle(n.public_key)}`,
|
||||
with a `title` tooltip (enable/disable).
|
||||
- Optional "All" / "None" quick-toggle chips at the start of the row (nice-to-have; "None"
|
||||
still respects the keep->=1 guard).
|
||||
- `extraClass` lets the caller apply responsive visibility (`hidden lg:flex` vs `lg:hidden`).
|
||||
|
||||
### 2. `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
|
||||
- Remove `observed_by` read from `query`; instead keep a closure variable
|
||||
`disabledObservers = getDisabledObservers()`.
|
||||
- In `fetchAndRenderData`: compute `enabled = sortedNodes.filter(n => !disabledObservers.has(n.public_key))`;
|
||||
set `apiParams.observed_by = enabled.map(n => n.public_key)` only when `disabledObservers.size > 0`.
|
||||
- Remove the `nodesFilter` `<select>` from `filterFields`; drop `observed_by` from
|
||||
`headerParams`, `pagination`, and `hasActiveFilters`.
|
||||
- Add `onToggle(pubkey)` handler:
|
||||
1. Apply `toggleObserver` guard + persist.
|
||||
2. Update closure `disabledObservers`.
|
||||
3. Reset to page 1: `navigate('/advertisements?...')` rebuilt from current search/sort/order/limit
|
||||
**without** `page` (or navigate to base path when no other params). This re-runs `render()`,
|
||||
which re-reads localStorage and re-fetches.
|
||||
- Render two badge blocks:
|
||||
- **Desktop**: `observerFilterBadges({ ..., extraClass: 'hidden lg:flex mb-4' })` immediately
|
||||
after `filterCard`.
|
||||
- **Mobile**: `observerFilterBadges({ ..., extraClass: 'lg:hidden mb-4' })` between
|
||||
`mobileSortSelect(...)` and the mobile cards `<div>`.
|
||||
|
||||
### 3. `src/meshcore_hub/web/static/js/spa/pages/messages.js`
|
||||
- Identical changes: remove the `observerFilter` `<select>` + URL threading, add closure
|
||||
`disabledObservers`, add `onToggle` (reset to page 1 via `/messages?...`), add the two badge
|
||||
blocks (mobile block after `mobileSortSelect`, before mobile cards).
|
||||
|
||||
### 4. Locales `locales/en.json` + `locales/nl.json`
|
||||
- Add under `common`: badge tooltip key (e.g. `filter_observer_toggle`) and, if quick-toggles
|
||||
are added, `filter_observer_all` / `filter_observer_none`. Reuse existing
|
||||
`filter_observer_label` for the row label.
|
||||
|
||||
### 5. Build
|
||||
- Run `npm run build` (esbuild bundles `dist/`; `spa.html` loads the hashed bundle). Required
|
||||
for the change to appear.
|
||||
|
||||
## Cross-link audit (confirmed safe to remove from URL)
|
||||
- `?observer_id` is **not** a frontend navigation param at all — it only exists in the Python
|
||||
API as a SQL column label / internal variable (`raw_packets.py`, `messages.py`,
|
||||
`advertisements.py`, `packet_groups.py`).
|
||||
- `observed_by` in the frontend is only ever: (a) a **data field** on records used to build
|
||||
`/nodes/<pubkey>` links (`packet-detail.js`, `packet-group-detail.js`, `node-detail.js`) —
|
||||
not a query string; or (b) **internal to the Adverts/Messages pages** (the filter `<select>`
|
||||
+ their own pagination/sort link threading).
|
||||
- **No other page** links to `/advertisements?observed_by=` or `/messages?observed_by=`. The
|
||||
only cross-link into these routes carrying a query is `channels.js -> /messages?channel_idx=`,
|
||||
which uses `channel_idx` (untouched; the messages page keeps reading it from the URL).
|
||||
- Therefore removing `observed_by` from URL threading breaks nothing in site navigation. Only a
|
||||
hand-crafted/bookmarked external link would be affected -> covered by optional add-on (b).
|
||||
|
||||
## Notes / trade-offs
|
||||
- **No URL backward-compat**: existing `?observed_by=` links stop filtering. Acceptable given
|
||||
the redesign and the cross-link audit above. Optional add-on: a one-time URL->localStorage
|
||||
migration on load so old links keep working.
|
||||
- **Empty-selection guard**: keep-at-least-one-enabled avoids a confusing empty list and an
|
||||
ambiguous "all disabled == all enabled" API call.
|
||||
- Styling uses existing DaisyUI badge classes — no `app.css` changes expected.
|
||||
- Auto-refresh keeps working unchanged (it calls `fetchAndRenderData`, which reads current
|
||||
localStorage state).
|
||||
|
||||
## Optional add-ons (opt-in)
|
||||
- (a) "All" / "None" quick-toggle chips on the badge row.
|
||||
- (b) URL->localStorage migration for old `?observed_by=` links.
|
||||
|
||||
## Verification
|
||||
- Toggle an observer off on Adverts -> list re-scopes, page resets to 1, badge greys out.
|
||||
- Reload page -> selection restored from localStorage before first API call (filtered results
|
||||
appear immediately, no flash of unfiltered data).
|
||||
- Switch to Messages -> same selection applies (shared key).
|
||||
- Page through results -> filter persists, total/page count consistent.
|
||||
- Disable all but one, attempt to disable the last -> blocked, stays enabled.
|
||||
- Mobile viewport -> badges appear below the Sorting dropdown, above the cards.
|
||||
@@ -545,6 +545,88 @@ export function observerIcons(observers) {
|
||||
return html`<span class="badge badge-sm badge-primary cursor-help observer-badge" title=${tooltip}>${observers.length}</span>`;
|
||||
}
|
||||
|
||||
// --- 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';
|
||||
|
||||
/**
|
||||
* Read the set of disabled (deselected) observer public keys from localStorage.
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function getDisabledObservers() {
|
||||
try {
|
||||
const raw = localStorage.getItem(OBSERVER_FILTER_KEY);
|
||||
if (!raw) return new Set();
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? new Set(arr) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the set of disabled observer public keys to localStorage.
|
||||
* @param {Set<string>} disabled
|
||||
*/
|
||||
export function setDisabledObservers(disabled) {
|
||||
try {
|
||||
localStorage.setItem(OBSERVER_FILTER_KEY, JSON.stringify([...disabled]));
|
||||
} catch {
|
||||
// Ignore quota/availability errors — filtering still works in-memory.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle an observer's enabled state, enforcing that at least one observer
|
||||
* stays enabled. Returns the updated disabled set (persisted).
|
||||
* @param {string} pubkey - Observer public key to toggle
|
||||
* @param {number} totalObserverCount - Total number of observer nodes
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function toggleObserver(pubkey, totalObserverCount) {
|
||||
const disabled = getDisabledObservers();
|
||||
if (disabled.has(pubkey)) {
|
||||
disabled.delete(pubkey);
|
||||
} else {
|
||||
// Block disabling the last enabled observer.
|
||||
if (totalObserverCount - disabled.size <= 1) {
|
||||
return disabled;
|
||||
}
|
||||
disabled.add(pubkey);
|
||||
}
|
||||
setDisabledObservers(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
|
||||
* @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;
|
||||
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);
|
||||
const cls = enabled ? 'badge badge-primary' : 'badge badge-ghost opacity-50';
|
||||
const title = enabled
|
||||
? t('common.filter_observer_disable')
|
||||
: t('common.filter_observer_enable');
|
||||
return html`<button type="button"
|
||||
class="${cls} cursor-pointer"
|
||||
title=${title}
|
||||
@click=${() => onToggle(n.public_key)}>${n._displayName}</button>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// --- Form Helpers ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
warningBadge,
|
||||
pagination, sortableTableHeader, mobileSortSelect,
|
||||
renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
|
||||
observerIcons
|
||||
observerIcons, getDisabledObservers, toggleObserver, observerFilterBadges
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.js';
|
||||
|
||||
@@ -26,9 +26,6 @@ export async function render(container, params, router) {
|
||||
const { signal } = params || {};
|
||||
const query = params.query || {};
|
||||
const search = query.search || '';
|
||||
const observed_by = query.observed_by
|
||||
? (Array.isArray(query.observed_by) ? query.observed_by : [query.observed_by])
|
||||
: [];
|
||||
const adopted_by = query.adopted_by || '';
|
||||
const route_type = query.route_type || 'flood,transport_flood';
|
||||
const page = parseInt(query.page, 10) || 1;
|
||||
@@ -37,6 +34,9 @@ export async function render(container, params, router) {
|
||||
const sort = query.sort || 'time';
|
||||
const order = query.order || 'desc';
|
||||
|
||||
// Observer filter is sourced from localStorage (shared toggle badges), not the URL.
|
||||
let disabledObservers = getDisabledObservers();
|
||||
|
||||
const config = getConfig();
|
||||
const features = config.features || {};
|
||||
const packetsEnabled = features.packets === true;
|
||||
@@ -84,27 +84,22 @@ ${displayContent}`, container);
|
||||
|
||||
async function fetchAndRenderData() {
|
||||
try {
|
||||
const apiParams = { limit, offset, search, sort, order, route_type };
|
||||
if (observed_by.length > 0) apiParams.observed_by = observed_by;
|
||||
if (adopted_by) apiParams.adopted_by = adopted_by;
|
||||
const fetches = [
|
||||
apiGet('/api/v1/advertisements', apiParams, { signal }),
|
||||
// Phase 1: fetch the observer node list (and operator profiles) first.
|
||||
// The advertisements API filters observers by inclusion only, so we need
|
||||
// the full observer list to translate the stored "disabled" set into an
|
||||
// explicit include-list before fetching the data.
|
||||
const metaFetches = [
|
||||
apiGet('/api/v1/nodes', { limit: 500, observer: true }, { signal }),
|
||||
];
|
||||
if (config.oidc_enabled) {
|
||||
fetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }, { signal }));
|
||||
metaFetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }, { signal }));
|
||||
}
|
||||
const results = await Promise.all(fetches);
|
||||
const data = results[0];
|
||||
const nodesData = results[1];
|
||||
const metaResults = await Promise.all(metaFetches);
|
||||
const nodesData = metaResults[0];
|
||||
const operatorRole = config.role_names?.operator || 'operator';
|
||||
const profiles = config.oidc_enabled
|
||||
? (results[2]?.items || []).filter(p => p.roles && p.roles.includes(operatorRole))
|
||||
? (metaResults[1]?.items || []).filter(p => p.roles && p.roles.includes(operatorRole))
|
||||
: [];
|
||||
|
||||
const advertisements = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const allNodes = nodesData.items || [];
|
||||
|
||||
const sortedNodes = allNodes.map(n => {
|
||||
@@ -112,23 +107,39 @@ ${displayContent}`, container);
|
||||
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 nodesFilter = sortedNodes.length > 0
|
||||
? html`
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="flex items-center py-1">
|
||||
<span class="opacity-80 text-sm">${t('common.filter_observer_label')}</span>
|
||||
</label>
|
||||
<select name="observed_by" multiple size="2"
|
||||
class="select select-bordered select-sm w-full max-w-xs">
|
||||
${sortedNodes.map(n => html`
|
||||
<option value=${n.public_key}
|
||||
?selected=${observed_by.includes(n.public_key)}>
|
||||
${n._displayName}
|
||||
</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>`
|
||||
: nothing;
|
||||
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);
|
||||
if (page > 1) {
|
||||
// Re-scoping the data invalidates the current page; reset to page 1.
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
sp.delete('page');
|
||||
const qs = sp.toString();
|
||||
navigate(qs ? `/advertisements?${qs}` : '/advertisements');
|
||||
} else {
|
||||
fetchAndRenderData();
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 2: fetch the advertisements with the resolved observer filter.
|
||||
const apiParams = { limit, offset, search, sort, order, route_type };
|
||||
if (observerFilterActive) apiParams.observed_by = enabledObserverKeys;
|
||||
if (adopted_by) apiParams.adopted_by = adopted_by;
|
||||
const data = await apiGet('/api/v1/advertisements', apiParams, { signal });
|
||||
|
||||
const advertisements = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const observerBadges = (extraClass) => observerFilterBadges({
|
||||
nodes: sortedNodes, disabled: disabledObservers, onToggle: onObserverToggle, extraClass,
|
||||
});
|
||||
|
||||
const mobileCards = advertisements.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</div>`
|
||||
@@ -206,7 +217,7 @@ ${displayContent}`, container);
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/advertisements', {
|
||||
search, observed_by, adopted_by, route_type, limit, sort, order,
|
||||
search, adopted_by, route_type, limit, sort, order,
|
||||
});
|
||||
|
||||
const filterFields = [
|
||||
@@ -248,11 +259,7 @@ ${displayContent}`, container);
|
||||
</select>
|
||||
</div>`);
|
||||
}
|
||||
if (sortedNodes.length > 0) {
|
||||
filterFields.push(() => nodesFilter);
|
||||
}
|
||||
|
||||
const hasActiveFilters = search !== '' || observed_by.length > 0 || (config.oidc_enabled && adopted_by !== '') || route_type !== 'flood,transport_flood';
|
||||
const hasActiveFilters = search !== '' || (config.oidc_enabled && adopted_by !== '') || route_type !== 'flood,transport_flood';
|
||||
const existingDetails = container.querySelector('details.collapse');
|
||||
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
|
||||
|
||||
@@ -264,7 +271,7 @@ ${displayContent}`, container);
|
||||
defaultOpen: isFilterOpen,
|
||||
});
|
||||
|
||||
const headerParams = { search, observed_by, adopted_by, route_type, limit };
|
||||
const headerParams = { search, adopted_by, route_type, limit };
|
||||
const sortable = (label, sortKey) => sortableTableHeader(label, {
|
||||
sortKey, currentSort: sort, currentOrder: order,
|
||||
navigate, basePath: '/advertisements', params: headerParams,
|
||||
@@ -272,6 +279,8 @@ ${displayContent}`, container);
|
||||
|
||||
renderPage(html`${filterCard}
|
||||
|
||||
${observerBadges('hidden lg:flex mb-4')}
|
||||
|
||||
${mobileSortSelect({
|
||||
currentSort: sort, currentOrder: order,
|
||||
navigate, basePath: '/advertisements',
|
||||
@@ -286,6 +295,8 @@ ${mobileSortSelect({
|
||||
],
|
||||
})}
|
||||
|
||||
${observerBadges('flex lg:hidden mb-4')}
|
||||
|
||||
<div class="lg:hidden space-y-3">
|
||||
${mobileCards}
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
getConfig, formatDateTime, formatDateTimeShort,
|
||||
getChannelLabelsMap, resolveChannelLabel,
|
||||
warningBadge,
|
||||
pagination, sortableTableHeader, mobileSortSelect, timezoneIndicator,
|
||||
renderFilterCard, autoSubmit, submitOnEnter,
|
||||
observerIcons
|
||||
pagination, sortableTableHeader, mobileSortSelect,
|
||||
renderFilterCard, autoSubmit,
|
||||
observerIcons, getDisabledObservers, toggleObserver, observerFilterBadges
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.js';
|
||||
|
||||
@@ -15,15 +15,15 @@ export async function render(container, params, router) {
|
||||
const query = params.query || {};
|
||||
const message_type = query.message_type || '';
|
||||
const channel_idx = query.channel_idx || '';
|
||||
const observed_by = query.observed_by
|
||||
? (Array.isArray(query.observed_by) ? query.observed_by : [query.observed_by])
|
||||
: [];
|
||||
const page = parseInt(query.page, 10) || 1;
|
||||
const limit = parseInt(query.limit, 10) || 50;
|
||||
const offset = (page - 1) * limit;
|
||||
const sort = query.sort || 'time';
|
||||
const order = query.order || 'desc';
|
||||
|
||||
// Observer filter is sourced from localStorage (shared toggle badges), not the URL.
|
||||
let disabledObservers = getDisabledObservers();
|
||||
|
||||
const config = getConfig();
|
||||
const features = config.features || {};
|
||||
const packetsEnabled = features.packets === true;
|
||||
@@ -210,10 +210,10 @@ ${displayContent}`, container);
|
||||
|
||||
async function fetchAndRenderData() {
|
||||
try {
|
||||
const apiParams = { limit, offset, message_type, channel_idx, sort, order };
|
||||
if (observed_by.length > 0) apiParams.observed_by = observed_by;
|
||||
const [data, nodesData, channelsData] = await Promise.all([
|
||||
apiGet('/api/v1/messages', apiParams, { signal }),
|
||||
// Phase 1: fetch the observer node list (and channels) first. The messages
|
||||
// API filters observers by inclusion only, so we need the full observer list
|
||||
// to translate the stored "disabled" set into an explicit include-list.
|
||||
const [nodesData, channelsData] = await Promise.all([
|
||||
apiGet('/api/v1/nodes', { limit: 500, observer: true }, { signal }),
|
||||
apiGet('/api/v1/channels', {}, { signal }),
|
||||
]);
|
||||
@@ -224,16 +224,45 @@ ${displayContent}`, container);
|
||||
.filter(([idx]) => Number.isInteger(idx)),
|
||||
);
|
||||
channelLabels = new Map([...builtinLabels, ...customLabels]);
|
||||
const messages = dedupeBySignature(data.items || []);
|
||||
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 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);
|
||||
if (page > 1) {
|
||||
// Re-scoping the data invalidates the current page; reset to page 1.
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
sp.delete('page');
|
||||
const qs = sp.toString();
|
||||
navigate(qs ? `/messages?${qs}` : '/messages');
|
||||
} else {
|
||||
fetchAndRenderData();
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 2: fetch the messages with the resolved observer filter.
|
||||
const apiParams = { limit, offset, message_type, channel_idx, sort, order };
|
||||
if (observerFilterActive) apiParams.observed_by = enabledObserverKeys;
|
||||
const data = await apiGet('/api/v1/messages', apiParams, { signal });
|
||||
const messages = dedupeBySignature(data.items || []);
|
||||
const total = data.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const observerBadges = (extraClass) => observerFilterBadges({
|
||||
nodes: sortedNodes, disabled: disabledObservers, onToggle: onObserverToggle, extraClass,
|
||||
});
|
||||
|
||||
const mobileCards = messages.length === 0
|
||||
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</div>`
|
||||
: messages.map(msg => {
|
||||
@@ -313,27 +342,9 @@ ${displayContent}`, container);
|
||||
});
|
||||
|
||||
const paginationBlock = pagination(page, totalPages, '/messages', {
|
||||
message_type, channel_idx, observed_by, limit, sort, order,
|
||||
message_type, channel_idx, limit, sort, order,
|
||||
});
|
||||
|
||||
const observerFilter = sortedNodes.length > 0
|
||||
? html`
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="flex items-center py-1">
|
||||
<span class="opacity-80 text-sm">${t('common.filter_observer_label')}</span>
|
||||
</label>
|
||||
<select name="observed_by" multiple size="3"
|
||||
class="select select-bordered select-sm w-full max-w-xs">
|
||||
${sortedNodes.map(n => html`
|
||||
<option value=${n.public_key}
|
||||
?selected=${observed_by.includes(n.public_key)}>
|
||||
${n._displayName}
|
||||
</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const filterFields = [
|
||||
() => html`
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -362,11 +373,7 @@ ${displayContent}`, container);
|
||||
</select>
|
||||
</div>`,
|
||||
];
|
||||
if (sortedNodes.length > 0) {
|
||||
filterFields.push(() => observerFilter);
|
||||
}
|
||||
|
||||
const hasActiveFilters = message_type !== '' || channel_idx !== '' || observed_by.length > 0;
|
||||
const hasActiveFilters = message_type !== '' || channel_idx !== '';
|
||||
const existingDetails = container.querySelector('details.collapse');
|
||||
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
|
||||
|
||||
@@ -378,7 +385,7 @@ ${displayContent}`, container);
|
||||
defaultOpen: isFilterOpen,
|
||||
});
|
||||
|
||||
const headerParams = { message_type, channel_idx, observed_by, limit };
|
||||
const headerParams = { message_type, channel_idx, limit };
|
||||
const sortable = (label, sortKey) => sortableTableHeader(label, {
|
||||
sortKey, currentSort: sort, currentOrder: order,
|
||||
navigate, basePath: '/messages', params: headerParams,
|
||||
@@ -386,6 +393,8 @@ ${displayContent}`, container);
|
||||
|
||||
renderPage(html`${filterCard}
|
||||
|
||||
${observerBadges('hidden lg:flex mb-4')}
|
||||
|
||||
${mobileSortSelect({
|
||||
currentSort: sort, currentOrder: order,
|
||||
navigate, basePath: '/messages',
|
||||
@@ -402,6 +411,8 @@ ${mobileSortSelect({
|
||||
],
|
||||
})}
|
||||
|
||||
${observerBadges('flex lg:hidden mb-4')}
|
||||
|
||||
<div class="lg:hidden space-y-3">
|
||||
${mobileCards}
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
"filter_member_label": "Member",
|
||||
"filter_operator_label": "Operator",
|
||||
"filter_observer_label": "Observer",
|
||||
"filter_observer_enable": "Click to show this observer",
|
||||
"filter_observer_disable": "Click to hide this observer",
|
||||
"node_type": "Node Type",
|
||||
"show": "Show",
|
||||
"search_placeholder": "Search by name, ID, or public key...",
|
||||
|
||||
@@ -100,7 +100,10 @@
|
||||
"unnamed": "Naamloos",
|
||||
"unnamed_node": "Naamloos knooppunt",
|
||||
"all_operators": "Alle Operators",
|
||||
"filter_operator_label": "Operator"
|
||||
"filter_operator_label": "Operator",
|
||||
"filter_observer_label": "Waarnemer",
|
||||
"filter_observer_enable": "Klik om deze waarnemer te tonen",
|
||||
"filter_observer_disable": "Klik om deze waarnemer te verbergen"
|
||||
},
|
||||
"links": {
|
||||
"website": "Website",
|
||||
|
||||
Reference in New Issue
Block a user