mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-03 08:21:09 +02:00
Merge pull request #245 from ipnet-mesh/feat/system-announcement-maintenance
feat(web): system announcement banner and maintenance mode
This commit is contained in:
@@ -496,6 +496,19 @@ NETWORK_WELCOME_TEXT=
|
||||
# Example: **Maintenance** scheduled for Saturday — see [details](https://example.com)
|
||||
NETWORK_ANNOUNCEMENT=
|
||||
|
||||
# System announcement banner (optional, Markdown supported)
|
||||
# Non-dismissable banner shown above the network announcement on every page,
|
||||
# for important system notices (downtime, maintenance windows, alerts).
|
||||
# Stays visible until unset and the web service is restarted. Empty = no banner.
|
||||
SYSTEM_ANNOUNCEMENT=
|
||||
|
||||
# Maintenance mode (default: false)
|
||||
# When true, disables almost all site functionality: the nav shows only Home,
|
||||
# the user/profile menu is hidden, and every page renders a "Site Under
|
||||
# Maintenance" notice. No backend API calls are made, so the API/database can
|
||||
# be offline while the web component keeps running. Requires a web restart.
|
||||
SYSTEM_MAINTENANCE=false
|
||||
|
||||
# -------------------
|
||||
# Feature Flags
|
||||
# -------------------
|
||||
|
||||
@@ -460,6 +460,9 @@ docker compose --profile core up # Start without Redis
|
||||
| `NETWORK_CONTACT_DISCORD` | _(none)_ | Discord server link |
|
||||
| `NETWORK_CONTACT_GITHUB` | _(none)_ | GitHub repository URL |
|
||||
| `NETWORK_CONTACT_YOUTUBE` | _(none)_ | YouTube channel URL |
|
||||
| `NETWORK_ANNOUNCEMENT` | _(none)_ | Markdown announcement shown as a dismissable flash banner on every page |
|
||||
| `SYSTEM_ANNOUNCEMENT` | _(none)_ | Markdown system notice shown as a non-dismissable banner above the network announcement |
|
||||
| `SYSTEM_MAINTENANCE` | `false` | Maintenance mode: nav shows only Home, profile menu hidden, every page renders a maintenance notice, and no API calls are made |
|
||||
| `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) |
|
||||
|
||||
Timezone handling note:
|
||||
|
||||
@@ -332,6 +332,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,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).
|
||||
@@ -57,6 +57,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`)
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
@@ -413,6 +413,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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -290,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"
|
||||
},
|
||||
|
||||
@@ -217,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>
|
||||
|
||||
@@ -144,6 +144,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