mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-06 08:52:50 +02:00
Merge branch 'main' of github.com:ipnet-mesh/meshcore-hub into feat/postgres-support
This commit is contained in:
@@ -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
-1
@@ -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/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
'';
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -444,7 +444,8 @@ footer.footer {
|
||||
Flash Banner
|
||||
========================================================================== */
|
||||
|
||||
#flash-banner {
|
||||
#flash-banner,
|
||||
#system-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user