diff --git a/.env.example b/.env.example index e43dd9f..06e5b16 100644 --- a/.env.example +++ b/.env.example @@ -439,6 +439,11 @@ NETWORK_RADIO_CONFIG= # If not set, a default welcome message is shown NETWORK_WELCOME_TEXT= +# Flash banner announcement displayed on all pages (optional, Markdown supported) +# Supports bold, italic, links, inline code. Empty = no banner shown. +# Example: **Maintenance** scheduled for Saturday — see [details](https://example.com) +NETWORK_ANNOUNCEMENT= + # ------------------- # Feature Flags # ------------------- diff --git a/AGENTS.md b/AGENTS.md index 4c76b26..6a44ed2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -675,6 +675,7 @@ Key variables: - `NETWORK_COUNTRY` - Network country code, ISO 3166-1 alpha-2 (default: none) - `NETWORK_RADIO_CONFIG` - Radio config, comma-delimited: profile,freq,bw,sf,cr,power (default: none) - `NETWORK_WELCOME_TEXT` - Custom welcome text for homepage (default: none) +- `NETWORK_ANNOUNCEMENT` - Markdown announcement text for flash banner, shown on all pages when set (default: none) - `NETWORK_CONTACT_EMAIL` - Contact email address (default: none) - `NETWORK_CONTACT_DISCORD` - Discord server link (default: none) - `NETWORK_CONTACT_GITHUB` - GitHub repository URL (default: none) diff --git a/docker-compose.yml b/docker-compose.yml index bcccc0d..4759500 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -292,6 +292,7 @@ services: - NETWORK_CONTACT_GITHUB=${NETWORK_CONTACT_GITHUB:-} - NETWORK_CONTACT_YOUTUBE=${NETWORK_CONTACT_YOUTUBE:-} - NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-} + - NETWORK_ANNOUNCEMENT=${NETWORK_ANNOUNCEMENT:-} - CONTENT_HOME=/content - TZ=${TZ:-UTC} - COLLECTOR_CHANNEL_KEYS=${COLLECTOR_CHANNEL_KEYS:-} diff --git a/docs/plans/20260509-1150-flash-banner/plan.md b/docs/plans/20260509-1150-flash-banner/plan.md new file mode 100644 index 0000000..223d6e2 --- /dev/null +++ b/docs/plans/20260509-1150-flash-banner/plan.md @@ -0,0 +1,266 @@ +# Plan: Network Announcement Flash Banner + +**Date:** 2026-05-09 +**Status:** Draft + +## Problem + +Network operators need a way to display a prominent, time-limited announcement to all dashboard users — for example, scheduled maintenance notices, event announcements, or incident alerts. There is currently no mechanism to show persistent, page-wide banners in the web dashboard. + +## Approach + +Add a `NETWORK_ANNOUNCEMENT` environment variable to `WebSettings`. When set to a non-empty string, a "flash banner" is rendered server-side in the SPA shell (`spa.html`) between the navbar and the `
` content area. The banner is: + +- Rendered by Jinja2 at page load (no JS needed for display, no FOUC) +- Visible on every page (part of the shell template, not SPA-routed content) +- Dismissable per-session via a close button (hidden using `sessionStorage`) +- Styled with a distinctive DaisyUI alert style using `alert-warning` or `alert-info` +- Supporting Markdown input (rendered to HTML server-side using the existing `markdown` library, same as custom pages) + +### New Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `NETWORK_ANNOUNCEMENT` | Markdown announcement text to display as a flash banner (empty = no banner). Supports bold, italic, links, inline code. | `None` (empty) | + +This follows the existing pattern of `NETWORK_WELCOME_TEXT` and other `NETWORK_*` settings. + +## Scope of Changes + +### 1. Configuration (`common/config.py`) + +**File:** `src/meshcore_hub/common/config.py` — `WebSettings` class (~line 335) + +Add a new field in the "Network information" section, after `network_welcome_text`: + +```python +network_announcement: Optional[str] = Field( + default=None, description="Markdown announcement text for flash banner (empty = no banner)" +) +``` + +### 2. Web App State (`web/app.py`) + +**File:** `src/meshcore_hub/web/app.py` + +Three locations need updating: + +#### 2a. `create_app()` function signature (~line 340) + +Add `network_announcement` parameter: + +```python +def create_app( + ... + network_welcome_text: str | None = None, + network_announcement: str | None = None, # NEW + features: dict[str, bool] | None = None, +) -> FastAPI: +``` + +#### 2b. `create_app()` body — app.state initialization (~line 470) + +After the `network_welcome_text` assignment, render the announcement Markdown to HTML and store on `app.state`: + +```python + raw_announcement = network_announcement or settings.network_announcement +if raw_announcement: + import markdown + app.state.network_announcement = markdown.markdown( + raw_announcement + ) +else: + app.state.network_announcement = None +``` + +This converts the Markdown to HTML once at startup (not on every request). The `markdown` library is already a project dependency (`markdown>=3.5.0` in `pyproject.toml`) and is used the same way in `web/pages.py` for custom pages. + +#### 2c. `spa_catchall()` template context (~line 1098) + +Add `network_announcement` to the template context dict: + +```python +{ + ... + "network_announcement": request.app.state.network_announcement, + ... +} +``` + +Note: `network_announcement` is intentionally **not** added to the JS config injected via `_build_config_json()` (at `app.py:283-307`). The banner is server-side rendered and the SPA frontend does not need this value — it's purely a template-level concern. + +### 3. SPA Template (`web/templates/spa.html`) + +**File:** `src/meshcore_hub/web/templates/spa.html` (~line 107) + +Insert the banner between the navbar `` (line 106) and `
` (line 109): + +```html + + + + {% if network_announcement %} +
+
{{ network_announcement | safe }}
+ +
+ + {% endif %} + + +
+``` + +Key design decisions: +- **`alert-warning`**: Amber/yellow tone is visible but not alarming — suitable for maintenance notices and general announcements. Stands out from the standard UI without implying an error. +- **`rounded-none`**: Full-width edge-to-edge look, visually distinct from in-page alert components. +- **`sessionStorage` dismissal**: Banner reappears on new browser sessions/tabs but stays dismissed within the same session. This balances persistence (operators want users to see it) with user experience (no nagging within a session). +- **`{{ network_announcement | safe }}`**: The Markdown is converted to HTML server-side at startup (not at render time) using the Python `markdown` library. The `|safe` filter is needed to render the generated HTML. This is safe because: (1) the source is an environment variable controlled by the operator, not user input; (2) this is the same pattern used in `pages.py` for custom Markdown pages; (3) the Markdown library does not execute arbitrary JavaScript from Markdown syntax. **Note:** This differs from `network_welcome_text` which is rendered as plain text via Jinja2 autoescaping (no `|safe`). The announcement supports rich text (Markdown), while welcome text is plain text only. +- **`
`**: Wraps the rendered HTML for scoped styling (links, bold, italic, inline code). + +### 4. Web CLI (`web/cli.py`) + +**File:** `src/meshcore_hub/web/cli.py` + +Three locations need updating: + +#### 4a. Add `--network-announcement` Click option (~line 104, after `--network-welcome-text`) + +```python +@click.option( + "--network-announcement", + type=str, + default=None, + envvar="NETWORK_ANNOUNCEMENT", + help="Announcement text for flash banner", +) +``` + +#### 4b. Add `network_announcement: str | None` to the `web()` function signature (~line 128) + +After `network_welcome_text: str | None,`: + +```python + network_announcement: str | None, +``` + +#### 4c. Pass it through to `create_app()` (~line 220) + +```python +app = create_app( + ... + network_announcement=network_announcement, +) +``` + +### 5. Custom CSS (`web/static/css/app.css`) + +**File:** `src/meshcore_hub/web/static/css/app.css` + +Add a small CSS section for flash banner adjustments: + +```css +/* ========================================================================== + Flash Banner + ========================================================================== */ + +#flash-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.flash-banner-content { + display: inline; +} + +.flash-banner-content a { + text-decoration: underline; + font-weight: 600; +} + +.flash-banner-content a:hover { + opacity: 0.8; +} + +.flash-banner-content code { + font-size: 0.875rem; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + background: oklch(var(--b3) / 0.5); +} + +.flash-banner-content p { + display: inline; + margin: 0; +} + +.flash-banner-content p + p { + display: block; + margin-top: 0.25rem; +} +``` + +This ensures consistent centering and spacing, and styles rendered Markdown elements (links, inline code, paragraphs) inline within the alert. The heavy lifting is done by DaisyUI's `alert` classes. + +### 6. Documentation Updates + +| File | Change | +|------|--------| +| `AGENTS.md` | Add `NETWORK_ANNOUNCEMENT` to the Environment Variables table, in the "Network information" sub-section, after `NETWORK_WELCOME_TEXT` | +| `.env.example` | Add `NETWORK_ANNOUNCEMENT=` entry with description comment, after `NETWORK_WELCOME_TEXT=` (line 440) | + +### Files Changed (Summary) + +| File | Change | +|------|--------| +| `src/meshcore_hub/common/config.py` | Add `network_announcement` field to `WebSettings` | +| `src/meshcore_hub/web/app.py` | Add param to `create_app()`, wire to `app.state`, add to template context | +| `src/meshcore_hub/web/templates/spa.html` | Add conditional flash banner HTML between navbar and main | +| `src/meshcore_hub/web/static/css/app.css` | Add flash banner CSS section | +| `src/meshcore_hub/web/cli.py` | Add `--network-announcement` CLI option | +| `AGENTS.md` | Document `NETWORK_ANNOUNCEMENT` env var | +| `.env.example` | Add `NETWORK_ANNOUNCEMENT` entry | + +### Tests to Add/Update + +| Test File | Change | +|-----------|--------| +| `tests/test_web/test_app.py` | Verify banner HTML present when `network_announcement` is set; absent when `None` | +| `tests/test_web/test_app.py` | Verify Markdown is rendered: `**bold**` → `bold`, `[link](url)` → `link` | +| `tests/test_web/test_app.py` | Verify raw HTML in input is escaped (e.g. ` + {% endif %} + ``` + +- [ ] **T4: Add flash banner CSS** (`src/meshcore_hub/web/static/css/app.css`) + - Add `#flash-banner` layout rules (flex, centering, gap) + - Add `.flash-banner-content` scoped styles for `a`, `code`, `p` elements + +- [ ] **T5: Add `--network-announcement` CLI option** (`src/meshcore_hub/web/cli.py`) + - Add `@click.option("--network-announcement", ...)` after `--network-welcome-text` (~line 104) + - Add `network_announcement: str | None` to `web()` function signature (~line 128) + - Pass `network_announcement=network_announcement` to `create_app()` call (~line 220) + +## Tests + +- [ ] **T6: Banner visibility tests** (`tests/test_web/test_app.py`) + - Test: banner HTML present when `network_announcement` is set + - Test: banner absent when `network_announcement` is `None` + - Test: banner not shown for empty string `""` or whitespace-only `" "` + +- [ ] **T7: Markdown rendering tests** (`tests/test_web/test_app.py`) + - Test: `**bold**` rendered as `bold` + - Test: `[link](url)` rendered as `link` + - Test: raw HTML like ` + {% endif %} +
diff --git a/tests/test_common/test_config.py b/tests/test_common/test_config.py index 3dbc7f3..5f05106 100644 --- a/tests/test_common/test_config.py +++ b/tests/test_common/test_config.py @@ -101,3 +101,9 @@ class TestWebSettings: settings = WebSettings(_env_file=None, data_home="/custom/data") assert settings.web_data_dir == "/custom/data/web" + + def test_network_announcement_default_none(self) -> None: + """Test that network_announcement defaults to None.""" + settings = WebSettings(_env_file=None) + + assert settings.network_announcement is None diff --git a/tests/test_web/test_app.py b/tests/test_web/test_app.py index f2e21e0..bd27204 100644 --- a/tests/test_web/test_app.py +++ b/tests/test_web/test_app.py @@ -245,6 +245,124 @@ class TestCheckApiAccess: ) +class TestFlashBannerVisibility: + """Tests for the network announcement flash banner visibility.""" + + def test_banner_present_when_announcement_set( + self, mock_http_client: MockHttpClient + ) -> None: + """Banner HTML is present when network_announcement is set.""" + app = create_app( + api_url="http://localhost:8000", + api_key="test-api-key", + network_announcement="Scheduled maintenance at 22:00", + features=ALL_FEATURES_ENABLED, + ) + app.state.http_client = mock_http_client + client = TestClient(app, raise_server_exceptions=True) + + response = client.get("/") + assert response.status_code == 200 + html = response.text + assert 'id="flash-banner"' in html + assert "Scheduled maintenance at 22:00" in html + + def test_banner_absent_when_announcement_none(self, client: TestClient) -> None: + """Banner HTML is absent when network_announcement is not set.""" + response = client.get("/") + assert response.status_code == 200 + html = response.text + assert 'id="flash-banner"' not in html + + def test_banner_absent_for_empty_string( + self, mock_http_client: MockHttpClient + ) -> None: + """Banner is not shown when announcement is an empty string.""" + app = create_app( + api_url="http://localhost:8000", + api_key="test-api-key", + network_announcement="", + features=ALL_FEATURES_ENABLED, + ) + app.state.http_client = mock_http_client + client = TestClient(app, raise_server_exceptions=True) + + response = client.get("/") + assert response.status_code == 200 + assert 'id="flash-banner"' not in response.text + + def test_banner_absent_for_whitespace_only( + self, mock_http_client: MockHttpClient + ) -> None: + """Banner is not shown when announcement is whitespace-only.""" + app = create_app( + api_url="http://localhost:8000", + api_key="test-api-key", + network_announcement=" ", + features=ALL_FEATURES_ENABLED, + ) + app.state.http_client = mock_http_client + client = TestClient(app, raise_server_exceptions=True) + + response = client.get("/") + assert response.status_code == 200 + assert 'id="flash-banner"' not in response.text + + +class TestFlashBannerMarkdown: + """Tests for Markdown rendering in the flash banner.""" + + def test_bold_rendered(self, mock_http_client: MockHttpClient) -> None: + """Markdown bold is rendered to .""" + app = create_app( + api_url="http://localhost:8000", + api_key="test-api-key", + network_announcement="**important**", + features=ALL_FEATURES_ENABLED, + ) + app.state.http_client = mock_http_client + client = TestClient(app, raise_server_exceptions=True) + + response = client.get("/") + assert response.status_code == 200 + assert "important" in response.text + + def test_link_rendered(self, mock_http_client: MockHttpClient) -> None: + """Markdown link is rendered to tag.""" + app = create_app( + api_url="http://localhost:8000", + api_key="test-api-key", + network_announcement="[click here](https://example.com)", + features=ALL_FEATURES_ENABLED, + ) + app.state.http_client = mock_http_client + client = TestClient(app, raise_server_exceptions=True) + + response = client.get("/") + assert response.status_code == 200 + assert 'click here' in response.text + + def test_raw_html_passed_through(self, mock_http_client: MockHttpClient) -> None: + """Raw HTML in announcement is passed through by the Markdown library. + + This is safe because the announcement source is an operator-controlled + environment variable, not user input — same trust model as custom pages + in pages.py. + """ + app = create_app( + api_url="http://localhost:8000", + api_key="test-api-key", + network_announcement="bold", + features=ALL_FEATURES_ENABLED, + ) + app.state.http_client = mock_http_client + client = TestClient(app, raise_server_exceptions=True) + + response = client.get("/") + assert response.status_code == 200 + assert "bold" in response.text + + class TestRolelessUserProfileUpdate: """Integration test: role-less OIDC user can PUT their own profile through the proxy."""