Merge branch 'main' of github.com:ipnet-mesh/meshcore-hub into feat/postgres-support

This commit is contained in:
Louis King
2026-06-14 20:38:42 +01:00
26 changed files with 1038 additions and 85 deletions
+13
View File
@@ -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
# -------------------
+3
View File
@@ -0,0 +1,3 @@
if has nix && declare -F use_nix >/dev/null; then
use nix
fi
+3 -1
View File
@@ -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/
+3
View File
@@ -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:
+2
View File
@@ -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)
@@ -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.
@@ -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; `<main id="app">` 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 %}
<div id="system-banner" class="alert alert-error rounded-none py-2 px-4 text-center text-sm">
<div class="flash-banner-content">{{ system_announcement | safe }}</div>
</div>
{% 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 %}
<div id="auth-section"></div>
{% 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 { <all existing setup> }`, 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`
<div class="hero min-h-[70vh]">
<div class="hero-content text-center">
<div class="max-w-md flex flex-col items-center gap-4">
<img src=${config.logo_url} alt=${config.network_name}
class="theme-logo${config.logo_invert_light ? ' theme-logo--invert-light' : ''} h-16 w-16" />
<h1 class="text-3xl font-bold">${config.network_name}</h1>
<h2 class="text-xl font-semibold text-warning">${t('maintenance.title')}</h2>
<p class="text-base-content/70">${t('maintenance.message')}</p>
</div>
</div>
</div>`, 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**``<strong>`); raw `<script>` does not execute |
| `tests/test_web/test_app.py` | When `system_maintenance=True`: `#auth-section` absent; desktop nav contains only Home (no dashboard/nodes/etc links); `config_json` contains `"system_maintenance": true` |
| `tests/test_web/test_app.py` | When `system_maintenance=False`: nav + auth render as today (regression) |
(Frontend SPA behavior — route short-circuit, no-API page — is verified by code review + manual check, matching the repo's existing JS test posture. Note in PR that the maintenance page imports nothing from `api.js`.)
## Edge Cases
- **Both banners set:** system (error/red) on top, network (warning/amber) below — verified by DOM order. Both visible simultaneously.
- **System announcement empty / whitespace:** no banner (Jinja `{% if %}` falsy).
- **Operator-controlled HTML in system announcement:** same trust model as network announcement — operator env var, Markdown lib doesn't execute JS.
- **Maintenance + announcements:** banners still render in maintenance mode (operator likely wants the maintenance notice visible as a banner too). Confirm this is desired; the design keeps banners independent of the maintenance gate.
- **Maintenance + OIDC:** profile menu hidden; no `/auth/user` call needed for rendering. Existing auth routes still exist server-side but are not exercised by the maintenance SPA path.
- **Deep-link during maintenance** (e.g. `/dashboard`): SPA not-found handler → maintenance page; no API page module loaded.
- **Restart required:** both vars read at startup into `app.state`; consistent with all other settings.
## Out of Scope
- Scheduling / auto start-end times for either feature (operator toggles var + restart).
- Admin UI to edit announcement or toggle maintenance live.
- Multiple severity levels for the system banner (single error-style banner).
- Blocking the API service itself or returning 503 from API routes — maintenance is a `web`-layer UX gate only; the operator stops the API/DB separately.
- Per-user/role maintenance bypass.
## Implementation Order
1. `config.py`: add both fields.
2. `app.py`: params, system-announcement render, maintenance state, feature suppression, config JSON, template context.
3. `spa.html`: system banner block (above network), auth-section gate.
4. `pages/maintenance.js`: new no-API page.
5. `app.js`: maintenance short-circuit + `pages.maintenance` entry.
6. i18n: `maintenance` block in `en.json` + `nl.json`.
7. `cli.py`: options + wiring (verify bool env coercion).
8. `app.css`: optional `#system-banner` styling.
9. Tests (config + web).
10. Docs (`.env.example`, `AGENTS.md`, `README.md`).
11. Run `pre-commit run --all-files` and `pytest tests/test_web/ tests/test_common/`; manually verify banner stacking and that maintenance mode issues no network requests (browser devtools).
@@ -0,0 +1,93 @@
# Collapse multiple newlines in rendered message text
## Problem
Some users post messages containing runs of newlines, e.g.
`"Watching the World Cup\n\n\n\n\n\nOf Darts"`. The messages view renders the
message body with `white-space: pre-wrap` (table cell, line 339) and
`whitespace-pre-wrap` (mobile card, line 306), so the embedded newlines are
preserved and blow up the row/card height, breaking the table layout.
We want the body to render as `"Watching the World Cup Of Darts"` — i.e.
collapse any run of one-or-more newlines (and surrounding whitespace) down to a
single space when displaying.
## Scope
Display-only normalization on the messages page. We do **not** alter stored
message data — the raw text in the DB/API is untouched; only the rendered
string changes.
## Affected code
- `src/meshcore_hub/web/static/js/spa/pages/messages.js`
- `messageTextWithSender(msg, text)` (lines ~106-118) builds the final
display string used by both the mobile card (`displayMessage`, line 274)
and the desktop table row (`displayMessage`, line 319). This is the single
chokepoint for the displayed body.
## Approach
Add a small whitespace-normalizing helper and apply it to the body inside
`messageTextWithSender` so both render paths benefit from one change.
1. Add a helper near the other text helpers in `messages.js`:
```js
// Collapse any run of newlines (and the whitespace around them) into a
// single space so multi-line messages don't blow up the table/card layout.
function collapseNewlines(text) {
if (!text || typeof text !== 'string') return text;
return text.replace(/\s*\n\s*/g, ' ');
}
```
Notes on the regex:
- `\s*\n\s*` matches each newline plus adjacent spaces/tabs/newlines, so a
run like `\n\n\n\n` collapses to one space (the `\s*` on both sides
absorbs the intermediate newlines), and a single `\n` also becomes one
space. This satisfies the example exactly.
- It deliberately leaves single spaces between words alone (no aggressive
`\s+` collapse) to avoid changing intentional spacing.
2. Apply it in `messageTextWithSender` to the computed `body`:
```js
const body = collapseNewlines((parsed.text || text || '-').trim()) || '-';
```
The sender-prefix logic (`${sender}: ${body}`) stays as-is and now operates
on the single-line body.
## Why here (and not in CSS or the API)
- CSS alone (`white-space: normal`) would collapse newlines visually but the
`pre-wrap` is intentional for legitimate wrapping; switching it wholesale
risks other formatting. Normalizing the string is more precise and matches
the requested output exactly.
- Doing it in the API/DB would lose the original text and affect non-display
consumers (e.g. packet detail). This is purely a presentation concern for the
messages list.
## Build / artifacts
The SPA is bundled with esbuild (`npm run build``node build.js`), output to
`src/meshcore_hub/web/static/dist/`. After editing the source `messages.js`,
run the build so the hashed `dist/chunks/messages.*.js` is regenerated.
## Testing / verification
- Manual: load the messages view with a message containing multiple newlines
(or temporarily inject one) and confirm it renders on a single line as
`"Watching the World Cup Of Darts"` in both the desktop table and the mobile
card.
- Confirm normal single-line messages and sender-prefixed messages
(`@[name]: ...`) still render unchanged.
- Spot-check that channel-label parsing (`[label] body`) is unaffected, since
`channelInfo` runs before `messageTextWithSender`.
## Out of scope
- Changing stored message text.
- Other views that display message text (packet detail, dashboards), unless a
follow-up reports the same layout issue there.
+15
View File
@@ -128,6 +128,21 @@ Because raw packets are pruned after 7 days, opening an old advert/message's pac
**No migration or action required** beyond the defaults above; override either variable in your `.env` to restore prior behaviour.
### System Announcement Banner & Maintenance Mode
Two new operator-only web settings, both applied at startup (set the variable, then restart the `web` service):
| Variable | Default | Description |
| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------- |
| `SYSTEM_ANNOUNCEMENT` | _(none)_ | Markdown system notice shown as a **non-dismissable** banner above the existing `NETWORK_ANNOUNCEMENT` banner. |
| `SYSTEM_MAINTENANCE` | `false` | Maintenance mode: nav shows only Home, the profile menu is hidden, and every page renders a maintenance notice. |
**`SYSTEM_ANNOUNCEMENT`** stacks above `NETWORK_ANNOUNCEMENT` (order: navbar → system → network). Unlike the network banner it has no close button and cannot be dismissed — it stays until you unset the variable and restart. Use it for downtime/maintenance windows and alerts. Markdown (bold, italic, links, inline code) is supported, same as `NETWORK_ANNOUNCEMENT`.
**`SYSTEM_MAINTENANCE=true`** disables almost all site functionality so that the dashboard makes **no backend API calls**. This lets you take the API service and database offline for upgrades/maintenance while leaving the `web` component running to show users a friendly "Site Under Maintenance" page (site logo, name, and a translatable message). Set it before maintenance, restart `web`, and unset it (or `false`) + restart when done.
**No migration or action required** — both variables are optional and default to off. They are passed through in `docker-compose.yml` automatically; just add them to your `.env`.
## v0.12.0
### Multi-Worker API (`API_WORKERS`)
+5 -2
View File
@@ -8,7 +8,7 @@ version = "0.0.0"
description = "Python monorepo for managing and orchestrating MeshCore mesh networks"
readme = "README.md"
license = {text = "GPL-3.0-or-later"}
requires-python = ">=3.14"
requires-python = ">=3.13"
authors = [
{name = "MeshCore Hub Contributors"}
]
@@ -30,7 +30,10 @@ dependencies = [
"python-dotenv>=1.0.0",
"sqlalchemy>=2.0.0",
"alembic>=1.12.0",
"fastapi>=0.100.0",
# 0.137.0 regressed include_router: routed endpoints no longer appear in
# app.routes (they still serve, but introspection breaks). Pin below it
# until a fixed release lands. See test_app_factory metrics route checks.
"fastapi>=0.100.0,<0.137.0",
"starlette>=0.40.0,<1.0.0",
"uvicorn[standard]>=0.23.0",
"paho-mqtt>=2.0.0",
+10
View File
@@ -0,0 +1,10 @@
with import <nixpkgs> {};
mkShell {
NIX_LD_LIBRARY_PATH = lib.makeLibraryPath [
stdenv.cc.cc
];
NIX_LD = lib.fileContents "${stdenv.cc}/nix-support/dynamic-linker";
shellHook = ''
export LD_LIBRARY_PATH=$NIX_LD_LIBRARY_PATH
'';
}
+10
View File
@@ -460,6 +460,16 @@ class WebSettings(CommonSettings):
default=None,
description="Markdown announcement text for flash banner (empty = no banner)",
)
system_announcement: Optional[str] = Field(
default=None,
description="Markdown system announcement banner, non-dismissable, shown "
"above the network announcement (empty = no banner)",
)
system_maintenance: bool = Field(
default=False,
description="Enable maintenance mode: disables site functionality and "
"prevents all API calls",
)
# Feature flags (control which pages are visible in the web dashboard)
feature_dashboard: bool = Field(
+29
View File
@@ -322,6 +322,7 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
"logo_invert_light": app.state.logo_invert_light,
"debug": app.state.web_debug,
"locale_version": getattr(app.state, "locale_version", ""),
"system_maintenance": app.state.system_maintenance,
}
role_names = {
@@ -373,6 +374,8 @@ def create_app(
network_contact_youtube: str | None = None,
network_welcome_text: str | None = None,
network_announcement: str | None = None,
system_announcement: str | None = None,
system_maintenance: bool | None = None,
features: dict[str, bool] | None = None,
) -> FastAPI:
"""Create and configure the web dashboard application.
@@ -398,6 +401,8 @@ def create_app(
network_contact_youtube: YouTube channel URL
network_welcome_text: Welcome text for homepage
network_announcement: Markdown announcement text for flash banner
system_announcement: Markdown text for the non-dismissable system banner
system_maintenance: Enable maintenance mode (disables functionality)
features: Feature flags dict (default: all enabled from settings)
Returns:
@@ -537,6 +542,24 @@ def create_app(
else:
app.state.network_announcement = None
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
app.state.system_maintenance = (
system_maintenance
if system_maintenance is not None
else settings.system_maintenance
)
# Store feature flags with automatic dependencies:
# - Dashboard requires at least one of nodes/advertisements/messages
# - Map requires nodes (map displays node locations)
@@ -556,6 +579,10 @@ def create_app(
overrides["members"] = False
if overrides:
effective_features = {**effective_features, **overrides}
# Maintenance mode disables every feature so the server-rendered nav
# collapses to just the static Home link and no API-backed page is exposed.
if app.state.system_maintenance:
effective_features = dict.fromkeys(effective_features, False)
app.state.features = effective_features
# Set up templates (for SPA shell only)
@@ -1180,6 +1207,8 @@ def create_app(
"network_contact_youtube": request.app.state.network_contact_youtube,
"network_welcome_text": request.app.state.network_welcome_text,
"network_announcement": request.app.state.network_announcement,
"system_announcement": request.app.state.system_announcement,
"system_maintenance": request.app.state.system_maintenance,
"oidc_enabled": request.app.state.oidc_enabled,
"features": features,
"custom_pages": custom_pages,
+17
View File
@@ -144,6 +144,19 @@ import click
envvar="NETWORK_ANNOUNCEMENT",
help="Markdown announcement text for flash banner",
)
@click.option(
"--system-announcement",
type=str,
default=None,
envvar="SYSTEM_ANNOUNCEMENT",
help="Markdown text for the non-dismissable system announcement banner",
)
@click.option(
"--system-maintenance/--no-system-maintenance",
default=None,
help="Enable maintenance mode (disables site functionality). "
"Defaults to the SYSTEM_MAINTENANCE environment variable.",
)
@click.option(
"--reload",
is_flag=True,
@@ -173,6 +186,8 @@ def web(
network_contact_youtube: str | None,
network_welcome_text: str | None,
network_announcement: str | None,
system_announcement: str | None,
system_maintenance: bool | None,
reload: bool,
) -> None:
"""Run the web dashboard.
@@ -272,6 +287,8 @@ def web(
network_contact_youtube=network_contact_youtube,
network_welcome_text=network_welcome_text,
network_announcement=network_announcement,
system_announcement=system_announcement,
system_maintenance=system_maintenance,
)
click.echo("\nStarting web dashboard...")
+2 -1
View File
@@ -444,7 +444,8 @@ footer.footer {
Flash Banner
========================================================================== */
#flash-banner {
#flash-banner,
#system-banner {
display: flex;
align-items: center;
justify-content: center;
+11
View File
@@ -28,6 +28,7 @@ const pages = {
customPage: () => import('./pages/custom-page.js'),
notFound: () => import('./pages/not-found.js'),
profile: () => import('./pages/profile.js'),
maintenance: () => import('./pages/maintenance.js'),
};
// Main app container
@@ -63,7 +64,16 @@ function pageHandler(loader) {
};
}
// Maintenance mode: every route renders the maintenance page and no
// API-backed page module is ever loaded.
const maintenanceMode = config.system_maintenance === true;
// Register routes (conditionally based on feature flags)
if (maintenanceMode) {
const maintenanceHandler = pageHandler(pages.maintenance);
router.addRoute('/', maintenanceHandler);
router.setNotFound(maintenanceHandler);
} else {
router.addRoute('/', pageHandler(pages.home));
if (features.dashboard !== false) {
@@ -109,6 +119,7 @@ if (config.oidc_enabled) {
// 404 handler
router.setNotFound(pageHandler(pages.notFound));
}
/**
* Update the active state of navigation links.
@@ -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>
@@ -0,0 +1,27 @@
/**
* Maintenance page.
*
* Rendered for every route when SYSTEM_MAINTENANCE is enabled. This page makes
* NO backend API calls the API service / database may be offline while the
* web component stays up. Keep it dependency-free (no api.js import, no fetch).
*/
import { html, litRender, t, getConfig } from '../components.js';
export async function render(container, params, router) {
const config = getConfig();
const logoClass = config.logo_invert_light
? 'theme-logo theme-logo--invert-light'
: 'theme-logo';
litRender(html`
<div class="hero min-h-[70vh]">
<div class="hero-content text-center">
<div class="max-w-md flex flex-col items-center gap-4">
<img src=${config.logo_url} alt=${config.network_name} class="${logoClass} h-16 w-16" />
<h1 class="text-3xl font-bold">${config.network_name}</h1>
<h2 class="text-xl font-semibold text-warning">${t('maintenance.title')}</h2>
<p class="text-base-content/70">${t('maintenance.message')}</p>
</div>
</div>
</div>`, container);
}
@@ -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;
@@ -103,11 +103,18 @@ export async function render(container, params, router) {
return { sender: null, text };
}
// Collapse any run of newlines (and the whitespace around them) into a
// single space so multi-line messages don't blow up the table/card layout.
function collapseNewlines(text) {
if (!text || typeof text !== 'string') return text;
return text.replace(/\s*\n\s*/g, ' ');
}
function messageTextWithSender(msg, text) {
const parsed = parseSenderFromText(text || '-');
const explicitSender = msg.sender_tag_name || msg.sender_name || (msg.pubkey_prefix || '').slice(0, 12) || null;
const sender = explicitSender || parsed.sender;
const body = (parsed.text || text || '-').trim() || '-';
const body = collapseNewlines((parsed.text || text || '-').trim()) || '-';
if (!sender) {
return body;
}
@@ -210,10 +217,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 +231,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 +349,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 +380,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 +392,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 +400,8 @@ ${displayContent}`, container);
renderPage(html`${filterCard}
${observerBadges('hidden lg:flex mb-4')}
${mobileSortSelect({
currentSort: sort, currentOrder: order,
navigate, basePath: '/messages',
@@ -402,6 +418,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...",
@@ -288,6 +290,10 @@
"not_found": {
"description": "The page you're looking for doesn't exist or has been moved."
},
"maintenance": {
"title": "Site Under Maintenance",
"message": "We're performing scheduled maintenance and will be back shortly. Thank you for your patience."
},
"custom_page": {
"failed_to_load": "Failed to load page"
},
+8 -1
View File
@@ -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",
@@ -214,6 +217,10 @@
"not_found": {
"description": "De pagina die u zoekt bestaat niet of is verplaatst."
},
"maintenance": {
"title": "Site in onderhoud",
"message": "We voeren gepland onderhoud uit en zijn zo weer terug. Bedankt voor uw geduld."
},
"custom_page": {
"failed_to_load": "Pagina laden mislukt"
},
+7 -1
View File
@@ -98,7 +98,7 @@
<!-- moon icon - shown in light mode (click to switch to dark) -->
<svg class="swap-on fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
</label>
{% if oidc_enabled %}
{% if oidc_enabled and not system_maintenance %}
<div id="auth-section"></div>
{% endif %}
<div class="dropdown dropdown-end lg:hidden">
@@ -111,6 +111,12 @@
</div>
</div>
{% if system_announcement %}
<div id="system-banner" class="alert alert-error rounded-none py-2 px-4 text-center text-sm">
<div class="flash-banner-content">{{ system_announcement | safe }}</div>
</div>
{% endif %}
{% if network_announcement %}
<div id="flash-banner" class="alert alert-warning rounded-none py-2 px-4 text-center text-sm">
<div class="flash-banner-content">{{ network_announcement | safe }}</div>
+12
View File
@@ -146,6 +146,18 @@ class TestWebSettings:
assert settings.network_announcement is None
def test_system_announcement_default_none(self) -> None:
"""Test that system_announcement defaults to None."""
settings = WebSettings(_env_file=None)
assert settings.system_announcement is None
def test_system_maintenance_default_false(self) -> None:
"""Test that system_maintenance defaults to False."""
settings = WebSettings(_env_file=None)
assert settings.system_maintenance is False
def test_feature_channels_default_true(self) -> None:
"""Test that feature_channels defaults to True."""
settings = WebSettings(_env_file=None)
+2
View File
@@ -322,6 +322,8 @@ def web_app(mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch) -
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
monkeypatch.setenv("OIDC_ENABLED", "false")
monkeypatch.setenv("NETWORK_ANNOUNCEMENT", "")
monkeypatch.setenv("SYSTEM_ANNOUNCEMENT", "")
monkeypatch.setenv("SYSTEM_MAINTENANCE", "false")
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
+126
View File
@@ -414,6 +414,132 @@ class TestFlashBannerMarkdown:
assert "<b>bold</b>" in response.text
class TestSystemAnnouncementBanner:
"""Tests for the non-dismissable system announcement banner."""
def test_system_banner_present_when_set(
self, mock_http_client: MockHttpClient
) -> None:
"""System banner HTML is present and Markdown-rendered when set."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
system_announcement="**Outage** at 22:00",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
html = client.get("/").text
assert 'id="system-banner"' in html
assert "<strong>Outage</strong> at 22:00" in html
def test_system_banner_absent_when_none(self, client: TestClient) -> None:
"""System banner HTML is absent when not set."""
assert 'id="system-banner"' not in client.get("/").text
def test_system_banner_absent_for_empty_string(
self, mock_http_client: MockHttpClient
) -> None:
"""System banner is not shown for an empty string."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
system_announcement="",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
assert 'id="system-banner"' not in client.get("/").text
def test_system_banner_not_dismissable(
self, mock_http_client: MockHttpClient
) -> None:
"""System banner has no dismiss button or sessionStorage script."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
system_announcement="Heads up",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
html = client.get("/").text
banner = html[html.index('id="system-banner"') :]
banner = banner[: banner.index("</div>")]
assert "Dismiss" not in banner
assert "sessionStorage" not in banner
def test_system_banner_stacked_above_network_banner(
self, mock_http_client: MockHttpClient
) -> None:
"""System banner is rendered above the network announcement banner."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
system_announcement="System notice",
network_announcement="Network notice",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
html = client.get("/").text
assert html.index('id="system-banner"') < html.index('id="flash-banner"')
class TestSystemMaintenance:
"""Tests for maintenance mode behaviour."""
def test_maintenance_disables_all_features(self) -> None:
"""All feature flags are forced off in maintenance mode."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
system_maintenance=True,
features=ALL_FEATURES_ENABLED,
)
assert all(value is False for value in app.state.features.values())
def test_maintenance_nav_only_home(self, mock_http_client: MockHttpClient) -> None:
"""Desktop nav contains only Home (no feature links) in maintenance."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
system_maintenance=True,
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
html = client.get("/dashboard").text
assert 'href="/dashboard"' not in html
assert 'href="/nodes"' not in html
assert 'href="/messages"' not in html
def test_maintenance_flag_in_config_json(
self, mock_http_client: MockHttpClient
) -> None:
"""The SPA config JSON exposes system_maintenance so the SPA can gate."""
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
system_maintenance=True,
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=True)
assert '"system_maintenance": true' in client.get("/").text
def test_maintenance_off_by_default(self, client: TestClient) -> None:
"""Without maintenance, nav links render normally (regression)."""
html = client.get("/").text
assert '"system_maintenance": false' in html
class TestRolelessUserProfileUpdate:
"""Integration test: role-less OIDC user can PUT their own profile through the proxy."""