diff --git a/docs/plans/20260614-1220-observer-filter-badges/plan.md b/docs/plans/20260614-1220-observer-filter-badges/plan.md new file mode 100644 index 0000000..d30334f --- /dev/null +++ b/docs/plans/20260614-1220-observer-filter-badges/plan.md @@ -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 `` 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 `
`. + +### 3. `src/meshcore_hub/web/static/js/spa/pages/messages.js` +- Identical changes: remove the `observerFilter` `` + + 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. diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index 1448ab4..ce342b9 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -545,6 +545,88 @@ export function observerIcons(observers) { return html`${observers.length}`; } +// --- 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} + */ +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} 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} + */ +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} options.nodes - Observer nodes (with public_key and _displayName) + * @param {Set} options.disabled - Currently disabled observer public keys + * @param {Function} options.onToggle - Called with a public_key when a badge is clicked + * @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`
+ ${t('common.filter_observer_label')}: + ${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``; + })} +
`; +} + // --- Form Helpers --- /** diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index 2776f98..5ab45be 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -5,7 +5,7 @@ import { warningBadge, pagination, sortableTableHeader, mobileSortSelect, 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` -
- - -
` - : 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`
${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}
` @@ -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); `); } - 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')} +
${mobileCards}
diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index b1cd807..2acee49 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -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`
${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}
` : 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` -
- - -
` - : nothing; - const filterFields = [ () => html`
@@ -362,11 +373,7 @@ ${displayContent}`, container);
`, ]; - 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')} +
${mobileCards}
diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 0ad7766..645b3bb 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -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...", diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json index dd87b3b..7c76229 100644 --- a/src/meshcore_hub/web/static/locales/nl.json +++ b/src/meshcore_hub/web/static/locales/nl.json @@ -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",