feat: add network announcement flash banner with Markdown support

Add NETWORK_ANNOUNCEMENT env var that displays a dismissible flash banner
on every page when set. Announcement text supports Markdown (bold, italic,
links, inline code) rendered to HTML server-side at startup.
This commit is contained in:
Louis King
2026-05-09 12:27:20 +01:00
parent 46b9c37eb9
commit dd36a240ba
12 changed files with 543 additions and 0 deletions
+5
View File
@@ -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
# -------------------
+1
View File
@@ -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)
+1
View File
@@ -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:-}
@@ -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 `<main>` 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 `</div>` (line 106) and `<main>` (line 109):
```html
</div>
<!-- Flash Banner (shown when NETWORK_ANNOUNCEMENT is set) -->
{% 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>
<button aria-label="Dismiss" onclick="document.getElementById('flash-banner').style.display='none'; sessionStorage.setItem('flash-banner-dismissed','1')" class="btn btn-ghost btn-xs">&times;</button>
</div>
<script>
if (sessionStorage.getItem('flash-banner-dismissed') === '1') {
document.getElementById('flash-banner').style.display = 'none';
}
</script>
{% endif %}
<!-- Main Content -->
<main ...>
```
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.
- **`<div class="flash-banner-content">`**: 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**``<strong>bold</strong>`, `[link](url)``<a href="url">link</a>` |
| `tests/test_web/test_app.py` | Verify raw HTML in input is escaped (e.g. `<script>` does not execute) |
| `tests/test_web/test_app.py` | Verify banner is not shown for empty string `""` or whitespace-only `" "` |
| `tests/test_common/test_config.py` | Verify `network_announcement` field defaults to `None` |
### Edge Cases
- **`NETWORK_ANNOUNCEMENT` is empty string**: Banner should not appear (Jinja2 `{% if %}` is falsy for `""`).
- **`NETWORK_ANNOUNCEMENT` contains raw HTML**: The Python `markdown` library passes raw HTML through by default (e.g., `<b>bold</b>` renders as-is). This is safe because the source is an operator-controlled environment variable — same trust model as custom pages in `pages.py`.
- **Markdown with links**: Links render as `<a>` tags with proper styling. External links open in the same tab (no `target="_blank"` added) to keep the banner simple.
- **Very long announcement text**: DaisyUI `alert` will wrap naturally. Consider adding `max-height` + `overflow` in CSS if needed, but likely unnecessary for typical one-line notices.
- **Dismissed banner persists on SPA navigation**: Since the banner is in the shell (not SPA content), it stays visible/hidden across SPA route changes. `sessionStorage` survives SPA navigations.
- **Multiple browser tabs**: Each tab has its own `sessionStorage`, so dismissing in one tab does not affect another. This is desirable — each tab should independently show the banner until dismissed.
- **Reload/restart**: Since the banner text comes from `app.state` (loaded at startup), changing `NETWORK_ANNOUNCEMENT` requires a server restart. This matches the behavior of all other `NETWORK_*` settings.
### Out of Scope
- Multiple banner levels (info/warning/error) — a single warning-style banner is sufficient for the initial implementation
- Start/end datetime for automatic show/hide — operators can set/unset the env var and restart
- Admin UI for editing the announcement — env var only
- Animation or slide-in effects — static display is sufficient
## Implementation Order
1. Add `network_announcement` field to `WebSettings` in `config.py`
2. Wire `network_announcement` into `create_app()`, `app.state`, and `spa_catchall()` in `web/app.py`
3. Add conditional banner HTML to `spa.html`
4. Add CSS for flash banner in `app.css`
5. Add `--network-announcement` CLI option to `web/cli.py`
6. Add tests
7. Update documentation (`AGENTS.md`, `.env.example`)
8. Run `pre-commit run --all-files` and `pytest tests/test_web/`
@@ -0,0 +1,70 @@
# Tasks: Network Announcement Flash Banner
## Implementation
- [ ] **T1: Add `network_announcement` to `WebSettings`** (`src/meshcore_hub/common/config.py`)
- Add `network_announcement: Optional[str] = Field(default=None, description="Markdown announcement text for flash banner (empty = no banner)")` after `network_welcome_text` (~line 365)
- [ ] **T2: Wire `network_announcement` into `create_app()`** (`src/meshcore_hub/web/app.py`)
- Add `network_announcement: str | None = None` param to `create_app()` signature (~line 351)
- Render Markdown to HTML at startup and store on `app.state.network_announcement` (~line 470):
```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
```
- Add `"network_announcement": request.app.state.network_announcement` to `spa_catchall()` template context (~line 1098)
- Do NOT add to `_build_config_json()` — server-side only, SPA doesn't need it
- [ ] **T3: Add conditional banner HTML to `spa.html`** (`src/meshcore_hub/web/templates/spa.html`)
- Insert between navbar `</div>` (line 106) and `<main>` (line 109):
```html
{% 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>
<button aria-label="Dismiss" onclick="..." class="btn btn-ghost btn-xs">&times;</button>
</div>
<script>
if (sessionStorage.getItem('flash-banner-dismissed') === '1') {
document.getElementById('flash-banner').style.display = 'none';
}
</script>
{% 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 `<strong>bold</strong>`
- Test: `[link](url)` rendered as `<a href="url">link</a>`
- Test: raw HTML like `<script>` is escaped, not executed
- [ ] **T8: Config default test** (`tests/test_common/test_config.py`)
- Test: `network_announcement` field defaults to `None`
## Documentation & Quality
- [ ] **T9: Update documentation** (`AGENTS.md`, `.env.example`)
- Add `NETWORK_ANNOUNCEMENT` to Environment Variables → "Network information" sub-section in `AGENTS.md`, after `NETWORK_WELCOME_TEXT`
- Add `NETWORK_ANNOUNCEMENT=` entry to `.env.example` after `NETWORK_WELCOME_TEXT=` (line 440)
- [ ] **T10: Run quality checks**
- `pre-commit run --all-files`
- `pytest tests/test_web/ tests/test_common/test_config.py`
+4
View File
@@ -363,6 +363,10 @@ class WebSettings(CommonSettings):
network_welcome_text: Optional[str] = Field(
default=None, description="Welcome text for homepage"
)
network_announcement: Optional[str] = Field(
default=None,
description="Markdown announcement text for flash banner (empty = no banner)",
)
# Feature flags (control which pages are visible in the web dashboard)
feature_dashboard: bool = Field(
+10
View File
@@ -349,6 +349,7 @@ def create_app(
network_contact_github: str | None = None,
network_contact_youtube: str | None = None,
network_welcome_text: str | None = None,
network_announcement: str | None = None,
features: dict[str, bool] | None = None,
) -> FastAPI:
"""Create and configure the web dashboard application.
@@ -368,6 +369,7 @@ def create_app(
network_contact_github: GitHub repository URL
network_contact_youtube: YouTube channel URL
network_welcome_text: Welcome text for homepage
network_announcement: Markdown announcement text for flash banner
features: Feature flags dict (default: all enabled from settings)
Returns:
@@ -470,6 +472,13 @@ def create_app(
app.state.network_welcome_text = (
network_welcome_text or settings.network_welcome_text
)
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
# Store feature flags with automatic dependencies:
# - Dashboard requires at least one of nodes/advertisements/messages
@@ -1104,6 +1113,7 @@ def create_app(
"network_contact_github": request.app.state.network_contact_github,
"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,
"oidc_enabled": request.app.state.oidc_enabled,
"features": features,
"custom_pages": custom_pages,
+9
View File
@@ -102,6 +102,13 @@ import click
envvar="NETWORK_WELCOME_TEXT",
help="Welcome text for homepage",
)
@click.option(
"--network-announcement",
type=str,
default=None,
envvar="NETWORK_ANNOUNCEMENT",
help="Markdown announcement text for flash banner",
)
@click.option(
"--reload",
is_flag=True,
@@ -125,6 +132,7 @@ def web(
network_contact_github: str | None,
network_contact_youtube: str | None,
network_welcome_text: str | None,
network_announcement: str | None,
reload: bool,
) -> None:
"""Run the web dashboard.
@@ -218,6 +226,7 @@ def web(
network_contact_github=network_contact_github,
network_contact_youtube=network_contact_youtube,
network_welcome_text=network_welcome_text,
network_announcement=network_announcement,
)
click.echo("\nStarting web dashboard...")
+41
View File
@@ -433,3 +433,44 @@ footer.footer {
gap: 3rem;
}
}
/* ==========================================================================
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;
}
+12
View File
@@ -105,6 +105,18 @@
</div>
</div>
{% 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>
<button aria-label="Dismiss" onclick="document.getElementById('flash-banner').style.display='none'; sessionStorage.setItem('flash-banner-dismissed','1')" class="btn btn-ghost btn-xs">&times;</button>
</div>
<script>
if (sessionStorage.getItem('flash-banner-dismissed') === '1') {
document.getElementById('flash-banner').style.display = 'none';
}
</script>
{% endif %}
<!-- Main Content -->
<main class="container mx-auto px-4 py-6 flex-1" id="app">
</main>
+6
View File
@@ -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
+118
View File
@@ -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 <strong>."""
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 "<strong>important</strong>" in response.text
def test_link_rendered(self, mock_http_client: MockHttpClient) -> None:
"""Markdown link is rendered to <a> 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 '<a href="https://example.com">click here</a>' 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="<b>bold</b>",
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 "<b>bold</b>" in response.text
class TestRolelessUserProfileUpdate:
"""Integration test: role-less OIDC user can PUT their own profile through the proxy."""