diff --git a/.env.example b/.env.example index dfaab4a..f73caf4 100644 --- a/.env.example +++ b/.env.example @@ -519,6 +519,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/.envrc b/.envrc new file mode 100644 index 0000000..a195da8 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +if has nix && declare -F use_nix >/dev/null; then + use nix +fi diff --git a/.gitignore b/.gitignore index aa2a958..e042944 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ /backup/ /content/ +# Direnv +.direnv + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] @@ -148,7 +151,6 @@ celerybeat.pid # Environments .env .env.backup -.envrc .venv env/ venv/ diff --git a/README.md b/README.md index 4caa776..1430cfb 100644 --- a/README.md +++ b/README.md @@ -486,6 +486,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/docker-compose.yml b/docker-compose.yml index 6fa0a5d..c8495f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -378,6 +378,8 @@ services: - NETWORK_CONTACT_YOUTUBE=${NETWORK_CONTACT_YOUTUBE:-} - NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-} - NETWORK_ANNOUNCEMENT=${NETWORK_ANNOUNCEMENT:-} + - SYSTEM_ANNOUNCEMENT=${SYSTEM_ANNOUNCEMENT:-} + - SYSTEM_MAINTENANCE=${SYSTEM_MAINTENANCE:-false} - CONTENT_HOME=/content - TZ=${TZ:-UTC} # Feature flags (set to false to disable specific pages) 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/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 `