Skip to content

Commit 565e0ff

Browse files
Merge pull request #90 from ipnet-mesh/feat/feature-flags
Add feature flags to control web dashboard page visibility
2 parents bafc16d + bdc3b86 commit 565e0ff

File tree

17 files changed

+830
-181
lines changed

17 files changed

+830
-181
lines changed

.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,20 @@ NETWORK_RADIO_CONFIG=
213213
# If not set, a default welcome message is shown
214214
NETWORK_WELCOME_TEXT=
215215

216+
# -------------------
217+
# Feature Flags
218+
# -------------------
219+
# Control which pages are visible in the web dashboard
220+
# Set to false to completely hide a page (nav, routes, sitemap, robots.txt)
221+
222+
# FEATURE_DASHBOARD=true
223+
# FEATURE_NODES=true
224+
# FEATURE_ADVERTISEMENTS=true
225+
# FEATURE_MESSAGES=true
226+
# FEATURE_MAP=true
227+
# FEATURE_MEMBERS=true
228+
# FEATURE_PAGES=true
229+
216230
# -------------------
217231
# Contact Information
218232
# -------------------

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ Key variables:
493493
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
494494
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
495495
- `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`)
496+
- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
496497
- `LOG_LEVEL` - Logging verbosity
497498

498499
The database defaults to `sqlite:///{DATA_HOME}/collector/meshcore.db` and does not typically need to be configured.

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,22 @@ The collector automatically cleans up old event data and inactive nodes:
341341
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
342342
| `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) |
343343

344+
#### Feature Flags
345+
346+
Control which pages are visible in the web dashboard. Disabled features are fully hidden: removed from navigation, return 404 on their routes, and excluded from sitemap/robots.txt.
347+
348+
| Variable | Default | Description |
349+
|----------|---------|-------------|
350+
| `FEATURE_DASHBOARD` | `true` | Enable the `/dashboard` page |
351+
| `FEATURE_NODES` | `true` | Enable the `/nodes` pages (list, detail, short links) |
352+
| `FEATURE_ADVERTISEMENTS` | `true` | Enable the `/advertisements` page |
353+
| `FEATURE_MESSAGES` | `true` | Enable the `/messages` page |
354+
| `FEATURE_MAP` | `true` | Enable the `/map` page and `/map/data` endpoint |
355+
| `FEATURE_MEMBERS` | `true` | Enable the `/members` page |
356+
| `FEATURE_PAGES` | `true` | Enable custom markdown pages |
357+
358+
**Dependencies:** Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
359+
344360
### Custom Content
345361

346362
The web dashboard supports custom content including markdown pages and media files. Content is organized in subdirectories:

docker-compose.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,14 @@ services:
263263
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
264264
- CONTENT_HOME=/content
265265
- TZ=${TZ:-UTC}
266+
# Feature flags (set to false to disable specific pages)
267+
- FEATURE_DASHBOARD=${FEATURE_DASHBOARD:-true}
268+
- FEATURE_NODES=${FEATURE_NODES:-true}
269+
- FEATURE_ADVERTISEMENTS=${FEATURE_ADVERTISEMENTS:-true}
270+
- FEATURE_MESSAGES=${FEATURE_MESSAGES:-true}
271+
- FEATURE_MAP=${FEATURE_MAP:-true}
272+
- FEATURE_MEMBERS=${FEATURE_MEMBERS:-true}
273+
- FEATURE_PAGES=${FEATURE_PAGES:-true}
266274
command: ["web"]
267275
healthcheck:
268276
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]

src/meshcore_hub/common/config.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,12 +301,52 @@ class WebSettings(CommonSettings):
301301
default=None, description="Welcome text for homepage"
302302
)
303303

304+
# Feature flags (control which pages are visible in the web dashboard)
305+
feature_dashboard: bool = Field(
306+
default=True, description="Enable the /dashboard page"
307+
)
308+
feature_nodes: bool = Field(default=True, description="Enable the /nodes pages")
309+
feature_advertisements: bool = Field(
310+
default=True, description="Enable the /advertisements page"
311+
)
312+
feature_messages: bool = Field(
313+
default=True, description="Enable the /messages page"
314+
)
315+
feature_map: bool = Field(
316+
default=True, description="Enable the /map page and /map/data endpoint"
317+
)
318+
feature_members: bool = Field(default=True, description="Enable the /members page")
319+
feature_pages: bool = Field(
320+
default=True, description="Enable custom markdown pages"
321+
)
322+
304323
# Content directory (contains pages/ and media/ subdirectories)
305324
content_home: Optional[str] = Field(
306325
default=None,
307326
description="Directory containing custom content (pages/, media/) (default: ./content)",
308327
)
309328

329+
@property
330+
def features(self) -> dict[str, bool]:
331+
"""Get feature flags as a dictionary.
332+
333+
Automatic dependencies:
334+
- Dashboard requires at least one of nodes/advertisements/messages.
335+
- Map requires nodes (map displays node locations).
336+
"""
337+
has_dashboard_content = (
338+
self.feature_nodes or self.feature_advertisements or self.feature_messages
339+
)
340+
return {
341+
"dashboard": self.feature_dashboard and has_dashboard_content,
342+
"nodes": self.feature_nodes,
343+
"advertisements": self.feature_advertisements,
344+
"messages": self.feature_messages,
345+
"map": self.feature_map and self.feature_nodes,
346+
"members": self.feature_members,
347+
"pages": self.feature_pages,
348+
}
349+
310350
@property
311351
def effective_content_home(self) -> str:
312352
"""Get the effective content home directory."""

src/meshcore_hub/web/app.py

Lines changed: 106 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -68,23 +68,32 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
6868
radio_config_dict = None
6969
if radio_config:
7070
radio_config_dict = {
71+
"profile": radio_config.profile,
7172
"frequency": radio_config.frequency,
7273
"bandwidth": radio_config.bandwidth,
7374
"spreading_factor": radio_config.spreading_factor,
7475
"coding_rate": radio_config.coding_rate,
76+
"tx_power": radio_config.tx_power,
7577
}
7678

77-
# Get custom pages for navigation
79+
# Get feature flags
80+
features = app.state.features
81+
82+
# Get custom pages for navigation (empty when pages feature is disabled)
7883
page_loader = app.state.page_loader
79-
custom_pages = [
80-
{
81-
"slug": p.slug,
82-
"title": p.title,
83-
"url": p.url,
84-
"menu_order": p.menu_order,
85-
}
86-
for p in page_loader.get_menu_pages()
87-
]
84+
custom_pages = (
85+
[
86+
{
87+
"slug": p.slug,
88+
"title": p.title,
89+
"url": p.url,
90+
"menu_order": p.menu_order,
91+
}
92+
for p in page_loader.get_menu_pages()
93+
]
94+
if features.get("pages", True)
95+
else []
96+
)
8897

8998
config = {
9099
"network_name": app.state.network_name,
@@ -97,6 +106,7 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
97106
"network_contact_youtube": app.state.network_contact_youtube,
98107
"network_welcome_text": app.state.network_welcome_text,
99108
"admin_enabled": app.state.admin_enabled,
109+
"features": features,
100110
"custom_pages": custom_pages,
101111
"logo_url": app.state.logo_url,
102112
"version": __version__,
@@ -121,6 +131,7 @@ def create_app(
121131
network_contact_github: str | None = None,
122132
network_contact_youtube: str | None = None,
123133
network_welcome_text: str | None = None,
134+
features: dict[str, bool] | None = None,
124135
) -> FastAPI:
125136
"""Create and configure the web dashboard application.
126137
@@ -140,6 +151,7 @@ def create_app(
140151
network_contact_github: GitHub repository URL
141152
network_contact_youtube: YouTube channel URL
142153
network_welcome_text: Welcome text for homepage
154+
features: Feature flags dict (default: all enabled from settings)
143155
144156
Returns:
145157
Configured FastAPI application
@@ -189,6 +201,24 @@ def create_app(
189201
network_welcome_text or settings.network_welcome_text
190202
)
191203

204+
# Store feature flags with automatic dependencies:
205+
# - Dashboard requires at least one of nodes/advertisements/messages
206+
# - Map requires nodes (map displays node locations)
207+
effective_features = features if features is not None else settings.features
208+
overrides: dict[str, bool] = {}
209+
has_dashboard_content = (
210+
effective_features.get("nodes", True)
211+
or effective_features.get("advertisements", True)
212+
or effective_features.get("messages", True)
213+
)
214+
if not has_dashboard_content:
215+
overrides["dashboard"] = False
216+
if not effective_features.get("nodes", True):
217+
overrides["map"] = False
218+
if overrides:
219+
effective_features = {**effective_features, **overrides}
220+
app.state.features = effective_features
221+
192222
# Set up templates (for SPA shell only)
193223
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
194224
templates.env.trim_blocks = True
@@ -309,6 +339,8 @@ async def api_proxy(request: Request, path: str) -> Response:
309339
@app.get("/map/data", tags=["Map"])
310340
async def map_data(request: Request) -> JSONResponse:
311341
"""Return node location data as JSON for the map."""
342+
if not request.app.state.features.get("map", True):
343+
return JSONResponse({"detail": "Map feature is disabled"}, status_code=404)
312344
nodes_with_location: list[dict[str, Any]] = []
313345
members_list: list[dict[str, Any]] = []
314346
members_by_id: dict[str, dict[str, Any]] = {}
@@ -448,6 +480,10 @@ async def map_data(request: Request) -> JSONResponse:
448480
@app.get("/spa/pages/{slug}", tags=["SPA"])
449481
async def get_custom_page(request: Request, slug: str) -> JSONResponse:
450482
"""Get a custom page by slug."""
483+
if not request.app.state.features.get("pages", True):
484+
return JSONResponse(
485+
{"detail": "Pages feature is disabled"}, status_code=404
486+
)
451487
page_loader = request.app.state.page_loader
452488
page = page_loader.get_page(slug)
453489
if not page:
@@ -489,21 +525,57 @@ def _get_https_base_url(request: Request) -> str:
489525
async def robots_txt(request: Request) -> str:
490526
"""Serve robots.txt."""
491527
base_url = _get_https_base_url(request)
492-
return f"User-agent: *\nDisallow:\n\nSitemap: {base_url}/sitemap.xml\n"
528+
features = request.app.state.features
529+
530+
# Always disallow message and node detail pages
531+
disallow_lines = [
532+
"Disallow: /messages",
533+
"Disallow: /nodes/",
534+
]
535+
536+
# Add disallow for disabled features
537+
feature_paths = {
538+
"dashboard": "/dashboard",
539+
"nodes": "/nodes",
540+
"advertisements": "/advertisements",
541+
"map": "/map",
542+
"members": "/members",
543+
"pages": "/pages",
544+
}
545+
for feature, path in feature_paths.items():
546+
if not features.get(feature, True):
547+
line = f"Disallow: {path}"
548+
if line not in disallow_lines:
549+
disallow_lines.append(line)
550+
551+
disallow_block = "\n".join(disallow_lines)
552+
return (
553+
f"User-agent: *\n"
554+
f"{disallow_block}\n"
555+
f"\n"
556+
f"Sitemap: {base_url}/sitemap.xml\n"
557+
)
493558

494559
@app.get("/sitemap.xml")
495560
async def sitemap_xml(request: Request) -> Response:
496-
"""Generate dynamic sitemap including all node pages."""
561+
"""Generate dynamic sitemap."""
497562
base_url = _get_https_base_url(request)
563+
features = request.app.state.features
564+
565+
# Home is always included; other pages depend on feature flags
566+
all_static_pages = [
567+
("", "daily", "1.0", None),
568+
("/dashboard", "hourly", "0.9", "dashboard"),
569+
("/nodes", "hourly", "0.9", "nodes"),
570+
("/advertisements", "hourly", "0.8", "advertisements"),
571+
("/map", "daily", "0.7", "map"),
572+
("/members", "weekly", "0.6", "members"),
573+
]
498574

499575
static_pages = [
500-
("", "daily", "1.0"),
501-
("/dashboard", "hourly", "0.9"),
502-
("/nodes", "hourly", "0.9"),
503-
("/advertisements", "hourly", "0.8"),
504-
("/messages", "hourly", "0.8"),
505-
("/map", "daily", "0.7"),
506-
("/members", "weekly", "0.6"),
576+
(path, freq, prio)
577+
for path, freq, prio, feature in all_static_pages
578+
if feature is None or features.get(feature, True)
507579
]
508580

509581
urls = []
@@ -516,34 +588,16 @@ async def sitemap_xml(request: Request) -> Response:
516588
f" </url>"
517589
)
518590

519-
try:
520-
response = await request.app.state.http_client.get(
521-
"/api/v1/nodes", params={"limit": 500, "role": "infra"}
522-
)
523-
if response.status_code == 200:
524-
nodes = response.json().get("items", [])
525-
for node in nodes:
526-
public_key = node.get("public_key")
527-
if public_key:
528-
urls.append(
529-
f" <url>\n"
530-
f" <loc>{base_url}/nodes/{public_key[:8]}</loc>\n"
531-
f" <changefreq>daily</changefreq>\n"
532-
f" <priority>0.5</priority>\n"
533-
f" </url>"
534-
)
535-
except Exception as e:
536-
logger.warning(f"Failed to fetch nodes for sitemap: {e}")
537-
538-
page_loader = request.app.state.page_loader
539-
for page in page_loader.get_menu_pages():
540-
urls.append(
541-
f" <url>\n"
542-
f" <loc>{base_url}{page.url}</loc>\n"
543-
f" <changefreq>weekly</changefreq>\n"
544-
f" <priority>0.6</priority>\n"
545-
f" </url>"
546-
)
591+
if features.get("pages", True):
592+
page_loader = request.app.state.page_loader
593+
for page in page_loader.get_menu_pages():
594+
urls.append(
595+
f" <url>\n"
596+
f" <loc>{base_url}{page.url}</loc>\n"
597+
f" <changefreq>weekly</changefreq>\n"
598+
f" <priority>0.6</priority>\n"
599+
f" </url>"
600+
)
547601

548602
xml = (
549603
'<?xml version="1.0" encoding="UTF-8"?>\n'
@@ -559,8 +613,11 @@ async def sitemap_xml(request: Request) -> Response:
559613
async def spa_catchall(request: Request, path: str = "") -> HTMLResponse:
560614
"""Serve the SPA shell for all non-API routes."""
561615
templates_inst: Jinja2Templates = request.app.state.templates
616+
features = request.app.state.features
562617
page_loader = request.app.state.page_loader
563-
custom_pages = page_loader.get_menu_pages()
618+
custom_pages = (
619+
page_loader.get_menu_pages() if features.get("pages", True) else []
620+
)
564621

565622
config_json = _build_config_json(request.app, request)
566623

@@ -577,6 +634,7 @@ async def spa_catchall(request: Request, path: str = "") -> HTMLResponse:
577634
"network_contact_youtube": request.app.state.network_contact_youtube,
578635
"network_welcome_text": request.app.state.network_welcome_text,
579636
"admin_enabled": request.app.state.admin_enabled,
637+
"features": features,
580638
"custom_pages": custom_pages,
581639
"logo_url": request.app.state.logo_url,
582640
"version": __version__,

src/meshcore_hub/web/cli.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ def web(
183183
if effective_city and effective_country:
184184
click.echo(f"Location: {effective_city}, {effective_country}")
185185
click.echo(f"Reload mode: {reload}")
186+
disabled_features = [
187+
name for name, enabled in settings.features.items() if not enabled
188+
]
189+
if disabled_features:
190+
click.echo(f"Disabled features: {', '.join(disabled_features)}")
186191
click.echo("=" * 50)
187192

188193
if reload:

0 commit comments

Comments
 (0)