From 56696bdcd6ecb004d17b97ebe25534c1fa97cfbe Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 14 Jun 2026 12:47:57 +0100 Subject: [PATCH 01/10] 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 --- .../plan.md | 146 ++++++++++++++++++ .../web/static/js/spa/components.js | 82 ++++++++++ .../web/static/js/spa/pages/advertisements.js | 95 +++++++----- .../web/static/js/spa/pages/messages.js | 83 +++++----- src/meshcore_hub/web/static/locales/en.json | 2 + src/meshcore_hub/web/static/locales/nl.json | 5 +- 6 files changed, 334 insertions(+), 79 deletions(-) create mode 100644 docs/plans/20260614-1220-observer-filter-badges/plan.md 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", From 17e6b65f8cd33926cc5f07d3e836cb246b4719f7 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 14 Jun 2026 17:42:42 +0100 Subject: [PATCH 02/10] feat(web): add system announcement banner and maintenance mode Add two operator-controlled, startup-time settings: - SYSTEM_ANNOUNCEMENT: non-dismissable Markdown banner rendered above the network announcement on every page (navbar -> system -> network). - SYSTEM_MAINTENANCE: when enabled, forces all feature flags off so the nav collapses to Home, hides the profile menu, and the SPA renders a maintenance page for every route. The maintenance page makes no API calls, so the API and database can be offline while the web component keeps running. CLI exposes --system-announcement and tri-state --system-maintenance; the bool falls back to pydantic settings to parse SYSTEM_MAINTENANCE reliably from env. Adds i18n strings (en/nl), tests, and docs. Co-Authored-By: Claude Opus 4.8 --- .env.example | 13 + README.md | 3 + .../plan.md | 298 ++++++++++++++++++ src/meshcore_hub/common/config.py | 10 + src/meshcore_hub/web/app.py | 29 ++ src/meshcore_hub/web/cli.py | 17 + src/meshcore_hub/web/static/js/spa/app.js | 11 + .../web/static/js/spa/pages/maintenance.js | 27 ++ src/meshcore_hub/web/static/locales/en.json | 4 + src/meshcore_hub/web/static/locales/nl.json | 4 + src/meshcore_hub/web/templates/spa.html | 8 +- tests/test_common/test_config.py | 12 + tests/test_web/test_app.py | 126 ++++++++ 13 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 docs/plans/20260614-1732-system-announcement-maintenance/plan.md create mode 100644 src/meshcore_hub/web/static/js/spa/pages/maintenance.js diff --git a/.env.example b/.env.example index 194e6f7..22d858b 100644 --- a/.env.example +++ b/.env.example @@ -496,6 +496,19 @@ NETWORK_WELCOME_TEXT= # Example: **Maintenance** scheduled for Saturday — see [details](https://example.com) NETWORK_ANNOUNCEMENT= +# System announcement banner (optional, Markdown supported) +# Non-dismissable banner shown above the network announcement on every page, +# for important system notices (downtime, maintenance windows, alerts). +# Stays visible until unset and the web service is restarted. Empty = no banner. +SYSTEM_ANNOUNCEMENT= + +# Maintenance mode (default: false) +# When true, disables almost all site functionality: the nav shows only Home, +# the user/profile menu is hidden, and every page renders a "Site Under +# Maintenance" notice. No backend API calls are made, so the API/database can +# be offline while the web component keeps running. Requires a web restart. +SYSTEM_MAINTENANCE=false + # ------------------- # Feature Flags # ------------------- diff --git a/README.md b/README.md index cc4b137..5b676c5 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,9 @@ docker compose --profile core up # Start without Redis | `NETWORK_CONTACT_DISCORD` | _(none)_ | Discord server link | | `NETWORK_CONTACT_GITHUB` | _(none)_ | GitHub repository URL | | `NETWORK_CONTACT_YOUTUBE` | _(none)_ | YouTube channel URL | +| `NETWORK_ANNOUNCEMENT` | _(none)_ | Markdown announcement shown as a dismissable flash banner on every page | +| `SYSTEM_ANNOUNCEMENT` | _(none)_ | Markdown system notice shown as a non-dismissable banner above the network announcement | +| `SYSTEM_MAINTENANCE` | `false` | Maintenance mode: nav shows only Home, profile menu hidden, every page renders a maintenance notice, and no API calls are made | | `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) | Timezone handling note: diff --git a/docs/plans/20260614-1732-system-announcement-maintenance/plan.md b/docs/plans/20260614-1732-system-announcement-maintenance/plan.md new file mode 100644 index 0000000..e0f7e5e --- /dev/null +++ b/docs/plans/20260614-1732-system-announcement-maintenance/plan.md @@ -0,0 +1,298 @@ +# Plan: System Announcement Banner + System Maintenance Mode + +**Date:** 2026-06-14 +**Status:** Draft + +## Problem + +Two new operator-only controls are needed, both driven by environment variables and applied at web-service startup (set var → restart `web` component): + +1. **`SYSTEM_ANNOUNCEMENT`** — a second, higher-priority banner for important system-level notices (downtime, maintenance windows, alerts). It must: + - Render across all pages, stacked **above** the existing network announcement banner and **below** the site navbar (order: navbar → system announcement → network announcement). + - **Not** be dismissable (no close button, no `sessionStorage`/`localStorage`). It stays until the operator unsets the var and restarts. + +2. **`SYSTEM_MAINTENANCE`** (boolean, default `false`) — a hard maintenance gate. When enabled, almost all site functionality is disabled so that **no API calls are made** (the API service / database may be offline while `web` stays up): + - Navbar menu shows only **Home**; the OIDC user/profile menu is hidden. + - The main content renders a friendly, translatable "Site Under Maintenance" page showing the site logo, site name, and the maintenance message — no dashboard widgets, counts, charts, or nav links. + - The maintenance page **may** be an SPA-rendered page, but it must make **zero** backend API calls. + +Both follow the existing `NETWORK_ANNOUNCEMENT` pattern (see `docs/plans/20260509-1150-flash-banner/plan.md`): config field → `app.state` → template context, wired through `web/cli.py`. + +## Background / Current State + +- The dashboard is a **server-rendered shell** (`web/templates/spa.html`) hosting a client-side SPA. The navbar and both banner slots live in the Jinja shell; `
` is filled by the SPA. +- The existing network announcement: config field `network_announcement` (`common/config.py:412`), Markdown-rendered to HTML once at startup in `create_app()` (`web/app.py:528-538`), passed to the template via `spa_catchall()` context (`web/app.py:1182`), and rendered in `spa.html:114-124` with a dismiss button backed by `sessionStorage`. +- Navbar menu items are gated by `{% if features.x %}` (`spa.html:58-90`); mobile nav is built client-side in `app.js:renderMobileNav()` from `config.features`; the OIDC auth/profile menu renders into `#auth-section` (`spa.html:101-103`, `app.js:248-249`, `components.js:renderAuthSection`). +- Feature flags are assembled in two parallel places: `WebSettings.features` (`config.py:451-474`) and the dependency-override block in `create_app()` (`web/app.py:540-559`). The SPA reads `config.features` to register routes (`app.js:66-108`). +- Home page (`pages/home.js`) **does** call the API (`/api/v1/dashboard/*`), so maintenance mode cannot simply fall back to Home — every route, including `/`, must short-circuit to the maintenance page. +- `pages/not-found.js` is a clean model for a no-API SPA page (pure `litRender` + `t()`). + +## Approach + +### Part A — `SYSTEM_ANNOUNCEMENT` (non-dismissable banner) + +Mirror the `NETWORK_ANNOUNCEMENT` mechanism exactly, minus the dismiss affordance, and render it **above** the network banner. + +- New `WebSettings.system_announcement: Optional[str]` field (Markdown supported, same as network announcement). +- Render Markdown → HTML once at startup into `app.state.system_announcement`. +- Pass into the `spa_catchall()` template context. +- In `spa.html`, insert a new banner block immediately **before** the existing `network_announcement` block (so DOM order is navbar → system → network). Use a distinct, more urgent style (`alert-error`) to differentiate it from the amber `alert-warning` network banner. **No** close button and **no** `sessionStorage` script. + +This is purely a template concern — like the network banner, it is **not** added to `_build_config_json()`. + +### Part B — `SYSTEM_MAINTENANCE` (functionality gate) + +A boolean that, when true, suppresses nav + auth UI server-side and forces the SPA to render a no-API maintenance page for every route. + +**Server side (`spa.html` + `app.py`):** +- New `WebSettings.system_maintenance: bool = False` field. +- Store `app.state.system_maintenance`. +- When maintenance is on, force `effective_features` to all-`False` in `create_app()` so the server-rendered desktop nav (`{% if features.x %}`) collapses to just the static Home link automatically. (Home is hard-coded at `spa.html:60`, not feature-gated, so it remains.) +- Hide the OIDC auth/profile menu: gate `#auth-section` with `{% if oidc_enabled and not system_maintenance %}`. +- Add `system_maintenance` to **both** the template context (for the auth gate) and `_build_config_json()` (so the SPA knows to short-circuit). + +**Client side (`app.js` + new `pages/maintenance.js`):** +- Early in `app.js`, if `config.system_maintenance` is truthy: register the maintenance page as the handler for `'/'`, set it as the not-found handler, and **skip** registering all other feature routes. This guarantees every navigation renders the maintenance page and no page module that calls the API is ever loaded. +- Skip `renderAuthSection()` and `renderMobileNav()` (or render an empty/Home-only mobile nav) when in maintenance mode, so no profile menu appears and the mobile menu has nothing API-dependent. +- New `pages/maintenance.js`: a pure `litRender` page (modeled on `not-found.js`) showing the logo (`config.logo_url`), site name (`config.network_name`), and the translatable maintenance message. **No imports from `api.js`, no `fetch`.** + +The two layers are belt-and-suspenders: server forces nav/auth empty; client refuses to load any API-touching page module. + +## New Configuration + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `SYSTEM_ANNOUNCEMENT` | string (Markdown) | `None` (empty) | Non-dismissable system banner shown above the network announcement on every page. Empty = no banner. | +| `SYSTEM_MAINTENANCE` | bool | `false` | When true, disables site functionality: nav shows only Home, profile menu hidden, all pages render a maintenance notice, and no API calls are made. | + +Both require a `web` service restart to take effect, consistent with all other `NETWORK_*`/`SYSTEM_*` settings. + +## Scope of Changes + +### 1. Configuration — `src/meshcore_hub/common/config.py` + +Add fields to `WebSettings`. Place `system_announcement` near `network_announcement` (~line 415) and `system_maintenance` near the feature-flag section (~line 417): + +```python +system_announcement: Optional[str] = Field( + default=None, + description="Markdown system announcement banner (non-dismissable, empty = none)", +) +system_maintenance: bool = Field( + default=False, + description="Enable maintenance mode: disables site functionality and API calls", +) +``` + +### 2. Web App — `src/meshcore_hub/web/app.py` + +#### 2a. `create_app()` signature (~line 375) +Add `system_announcement: str | None = None` and `system_maintenance: bool | None = None` parameters (after `network_announcement`). + +#### 2b. `create_app()` body — render system announcement (~after line 538) +Mirror the network-announcement block: + +```python +raw_system_announcement = ( + system_announcement + if system_announcement is not None + else settings.system_announcement +) +if raw_system_announcement: + import markdown + app.state.system_announcement = markdown.markdown(raw_system_announcement) +else: + app.state.system_announcement = None +``` + +#### 2c. `create_app()` body — maintenance state + feature suppression (~line 540-559) +```python +app.state.system_maintenance = ( + system_maintenance + if system_maintenance is not None + else settings.system_maintenance +) +``` +Then, after `effective_features` is computed, if maintenance is on, force everything off so the server-rendered nav collapses: + +```python +if app.state.system_maintenance: + effective_features = {k: False for k in effective_features} +app.state.features = effective_features +``` + +#### 2d. `_build_config_json()` (~line 301-325) +Add `"system_maintenance": app.state.system_maintenance,` to the `config` dict so the SPA can short-circuit. (System announcement is **not** added — template-only.) + +#### 2e. `spa_catchall()` template context (~line 1173-1193) +Add: +```python +"system_announcement": request.app.state.system_announcement, +"system_maintenance": request.app.state.system_maintenance, +``` + +### 3. SPA Template — `src/meshcore_hub/web/templates/spa.html` + +#### 3a. System banner — insert **before** the network banner block (before current line 114) +```html +{% if system_announcement %} +
+
{{ system_announcement | safe }}
+
+{% endif %} +``` +No close button, no script — non-dismissable. The existing `network_announcement` block stays directly below, preserving order: navbar → system → network. + +#### 3b. Hide auth/profile menu in maintenance (line 101) +```html +{% if oidc_enabled and not system_maintenance %} +
+{% endif %} +``` + +Desktop nav menu items need no change — they are already `{% if features.x %}` gated and collapse to Home once features are forced off in 2c. + +### 4. SPA App — `src/meshcore_hub/web/static/js/spa/app.js` + +After `const features = ...` (~line 39), branch on maintenance before route registration: + +```js +if (config.system_maintenance) { + const maintenanceHandler = pageHandler(pages.maintenance); + router.addRoute('/', maintenanceHandler); + router.setNotFound(maintenanceHandler); + await loadLocale(localStorage.getItem('meshcore-locale') || config.locale || 'en'); + // No auth section, no mobile nav (nothing API-dependent) + router.start(); +} else { + // ... existing route registration, auth/mobile nav render, router.start() +} +``` + +Add `maintenance: () => import('./pages/maintenance.js'),` to the `pages` map (~line 15-31). Keep the existing non-maintenance path intact (the simplest structure is an early `if (config.system_maintenance) { ...; } else { }`, or an early return-style guard wrapped appropriately for the top-level `await`). + +### 5. New Page — `src/meshcore_hub/web/static/js/spa/pages/maintenance.js` + +Modeled on `not-found.js`. **No `api.js` import, no fetch.** + +```js +import { html, litRender, t, getConfig } from '../components.js'; + +export async function render(container, params, router) { + const config = getConfig(); + litRender(html` +
+
+
+ ${config.network_name} +

${config.network_name}

+

${t('maintenance.title')}

+

${t('maintenance.message')}

+
+
+
`, container); +} +``` + +(Confirm `getConfig` is exported from `components.js` — it is imported in `app.js:10`.) + +### 6. i18n — `src/meshcore_hub/web/static/locales/en.json` and `nl.json` + +Add a `maintenance` top-level section to both locale files: + +```json +"maintenance": { + "title": "Site Under Maintenance", + "message": "We're performing scheduled maintenance and will be back shortly. Thank you for your patience." +} +``` + +(Provide a Dutch translation for `nl.json`.) If the server-rendered shell needs a maintenance string (it does not in this design — the message is SPA-rendered), the Python-side `t()` helper / locale loader would also need the key; not required here. + +### 7. Web CLI — `src/meshcore_hub/web/cli.py` + +Mirror `--network-announcement` (~line 140-146): + +```python +@click.option("--system-announcement", type=str, default=None, + envvar="SYSTEM_ANNOUNCEMENT", + help="Markdown system announcement banner (non-dismissable)") +@click.option("--system-maintenance", is_flag=True, default=False, + envvar="SYSTEM_MAINTENANCE", + help="Enable maintenance mode (disables site functionality)") +``` + +Add `system_announcement: str | None,` and `system_maintenance: bool,` to the `web()` signature (~line 175) and pass both through to `create_app()` (~line 274). + +Note: `is_flag` env parsing — Click coerces `SYSTEM_MAINTENANCE` truthy strings via `envvar`. Verify boolean env coercion ("true"/"1") behaves as expected; if not, read it via the settings object instead (settings already parses the bool through pydantic), i.e. pass `system_maintenance=None` default and let `create_app()` fall back to `settings.system_maintenance`. + +### 8. CSS — `src/meshcore_hub/web/static/css/app.css` + +The system banner reuses `.flash-banner-content` styling. Optionally add `#system-banner` to the existing flash-banner fl/centering rule so links/code render consistently. Minimal/no new CSS expected. + +### 9. Documentation + +| File | Change | +|------|--------| +| `.env.example` | Add `SYSTEM_ANNOUNCEMENT=` (after `NETWORK_ANNOUNCEMENT`, with comment) and `SYSTEM_MAINTENANCE=false` (near feature flags, with comment) | +| `AGENTS.md` | Add both vars to the Environment Variables table | +| `README.md` | If it documents `NETWORK_ANNOUNCEMENT`, add the two new vars alongside | + +## Files Changed (Summary) + +| File | Change | +|------|--------| +| `src/meshcore_hub/common/config.py` | Add `system_announcement`, `system_maintenance` fields | +| `src/meshcore_hub/web/app.py` | New params, render system announcement, maintenance state, force features off, config JSON + template context | +| `src/meshcore_hub/web/templates/spa.html` | System banner above network banner; gate auth section on maintenance | +| `src/meshcore_hub/web/static/js/spa/app.js` | Maintenance short-circuit: single route + not-found = maintenance page, skip auth/mobile nav | +| `src/meshcore_hub/web/static/js/spa/pages/maintenance.js` | **New** no-API maintenance page | +| `src/meshcore_hub/web/static/locales/en.json`, `nl.json` | New `maintenance` translation block | +| `src/meshcore_hub/web/cli.py` | `--system-announcement`, `--system-maintenance` options + wiring | +| `src/meshcore_hub/web/static/css/app.css` | Optional `#system-banner` styling | +| `.env.example`, `AGENTS.md`, `README.md` | Document new vars | + +## Tests to Add/Update + +| Test File | Change | +|-----------|--------| +| `tests/test_common/test_config.py` | `system_announcement` defaults to `None`; `system_maintenance` defaults to `False`; bool parses from env | +| `tests/test_web/test_app.py` | System banner HTML present when `system_announcement` set, absent when `None`; rendered **above** network banner (assert ordering in HTML); **no** dismiss button / `sessionStorage` script in the system block | +| `tests/test_web/test_app.py` | Markdown rendered (`**bold**` → ``); raw `