diff --git a/.env.example b/.env.example
index 5370d3a..3a47a5f 100644
--- a/.env.example
+++ b/.env.example
@@ -213,6 +213,20 @@ NETWORK_RADIO_CONFIG=
# If not set, a default welcome message is shown
NETWORK_WELCOME_TEXT=
+# -------------------
+# Feature Flags
+# -------------------
+# Control which pages are visible in the web dashboard
+# Set to false to completely hide a page (nav, routes, sitemap, robots.txt)
+
+# FEATURE_DASHBOARD=true
+# FEATURE_NODES=true
+# FEATURE_ADVERTISEMENTS=true
+# FEATURE_MESSAGES=true
+# FEATURE_MAP=true
+# FEATURE_MEMBERS=true
+# FEATURE_PAGES=true
+
# -------------------
# Contact Information
# -------------------
diff --git a/AGENTS.md b/AGENTS.md
index d1e7adc..97abf9e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -493,6 +493,7 @@ Key variables:
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
- `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`)
+- `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.
- `LOG_LEVEL` - Logging verbosity
The database defaults to `sqlite:///{DATA_HOME}/collector/meshcore.db` and does not typically need to be configured.
diff --git a/README.md b/README.md
index 93b8ea8..7c57bf1 100644
--- a/README.md
+++ b/README.md
@@ -341,6 +341,22 @@ The collector automatically cleans up old event data and inactive nodes:
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
| `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) |
+#### Feature Flags
+
+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.
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `FEATURE_DASHBOARD` | `true` | Enable the `/dashboard` page |
+| `FEATURE_NODES` | `true` | Enable the `/nodes` pages (list, detail, short links) |
+| `FEATURE_ADVERTISEMENTS` | `true` | Enable the `/advertisements` page |
+| `FEATURE_MESSAGES` | `true` | Enable the `/messages` page |
+| `FEATURE_MAP` | `true` | Enable the `/map` page and `/map/data` endpoint |
+| `FEATURE_MEMBERS` | `true` | Enable the `/members` page |
+| `FEATURE_PAGES` | `true` | Enable custom markdown pages |
+
+**Dependencies:** Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
+
### Custom Content
The web dashboard supports custom content including markdown pages and media files. Content is organized in subdirectories:
diff --git a/docker-compose.yml b/docker-compose.yml
index ef0ad3d..c633a57 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -263,6 +263,14 @@ services:
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
- CONTENT_HOME=/content
- TZ=${TZ:-UTC}
+ # Feature flags (set to false to disable specific pages)
+ - FEATURE_DASHBOARD=${FEATURE_DASHBOARD:-true}
+ - FEATURE_NODES=${FEATURE_NODES:-true}
+ - FEATURE_ADVERTISEMENTS=${FEATURE_ADVERTISEMENTS:-true}
+ - FEATURE_MESSAGES=${FEATURE_MESSAGES:-true}
+ - FEATURE_MAP=${FEATURE_MAP:-true}
+ - FEATURE_MEMBERS=${FEATURE_MEMBERS:-true}
+ - FEATURE_PAGES=${FEATURE_PAGES:-true}
command: ["web"]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py
index 2babf38..ae5f1dd 100644
--- a/src/meshcore_hub/common/config.py
+++ b/src/meshcore_hub/common/config.py
@@ -301,12 +301,52 @@ class WebSettings(CommonSettings):
default=None, description="Welcome text for homepage"
)
+ # Feature flags (control which pages are visible in the web dashboard)
+ feature_dashboard: bool = Field(
+ default=True, description="Enable the /dashboard page"
+ )
+ feature_nodes: bool = Field(default=True, description="Enable the /nodes pages")
+ feature_advertisements: bool = Field(
+ default=True, description="Enable the /advertisements page"
+ )
+ feature_messages: bool = Field(
+ default=True, description="Enable the /messages page"
+ )
+ feature_map: bool = Field(
+ default=True, description="Enable the /map page and /map/data endpoint"
+ )
+ feature_members: bool = Field(default=True, description="Enable the /members page")
+ feature_pages: bool = Field(
+ default=True, description="Enable custom markdown pages"
+ )
+
# Content directory (contains pages/ and media/ subdirectories)
content_home: Optional[str] = Field(
default=None,
description="Directory containing custom content (pages/, media/) (default: ./content)",
)
+ @property
+ def features(self) -> dict[str, bool]:
+ """Get feature flags as a dictionary.
+
+ Automatic dependencies:
+ - Dashboard requires at least one of nodes/advertisements/messages.
+ - Map requires nodes (map displays node locations).
+ """
+ has_dashboard_content = (
+ self.feature_nodes or self.feature_advertisements or self.feature_messages
+ )
+ return {
+ "dashboard": self.feature_dashboard and has_dashboard_content,
+ "nodes": self.feature_nodes,
+ "advertisements": self.feature_advertisements,
+ "messages": self.feature_messages,
+ "map": self.feature_map and self.feature_nodes,
+ "members": self.feature_members,
+ "pages": self.feature_pages,
+ }
+
@property
def effective_content_home(self) -> str:
"""Get the effective content home directory."""
diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py
index fa7d58e..630f93f 100644
--- a/src/meshcore_hub/web/app.py
+++ b/src/meshcore_hub/web/app.py
@@ -74,17 +74,24 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
"coding_rate": radio_config.coding_rate,
}
- # Get custom pages for navigation
+ # Get feature flags
+ features = app.state.features
+
+ # Get custom pages for navigation (empty when pages feature is disabled)
page_loader = app.state.page_loader
- custom_pages = [
- {
- "slug": p.slug,
- "title": p.title,
- "url": p.url,
- "menu_order": p.menu_order,
- }
- for p in page_loader.get_menu_pages()
- ]
+ custom_pages = (
+ [
+ {
+ "slug": p.slug,
+ "title": p.title,
+ "url": p.url,
+ "menu_order": p.menu_order,
+ }
+ for p in page_loader.get_menu_pages()
+ ]
+ if features.get("pages", True)
+ else []
+ )
config = {
"network_name": app.state.network_name,
@@ -97,6 +104,7 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
"network_contact_youtube": app.state.network_contact_youtube,
"network_welcome_text": app.state.network_welcome_text,
"admin_enabled": app.state.admin_enabled,
+ "features": features,
"custom_pages": custom_pages,
"logo_url": app.state.logo_url,
"version": __version__,
@@ -121,6 +129,7 @@ def create_app(
network_contact_github: str | None = None,
network_contact_youtube: str | None = None,
network_welcome_text: str | None = None,
+ features: dict[str, bool] | None = None,
) -> FastAPI:
"""Create and configure the web dashboard application.
@@ -140,6 +149,7 @@ def create_app(
network_contact_github: GitHub repository URL
network_contact_youtube: YouTube channel URL
network_welcome_text: Welcome text for homepage
+ features: Feature flags dict (default: all enabled from settings)
Returns:
Configured FastAPI application
@@ -189,6 +199,24 @@ def create_app(
network_welcome_text or settings.network_welcome_text
)
+ # Store feature flags with automatic dependencies:
+ # - Dashboard requires at least one of nodes/advertisements/messages
+ # - Map requires nodes (map displays node locations)
+ effective_features = features if features is not None else settings.features
+ overrides: dict[str, bool] = {}
+ has_dashboard_content = (
+ effective_features.get("nodes", True)
+ or effective_features.get("advertisements", True)
+ or effective_features.get("messages", True)
+ )
+ if not has_dashboard_content:
+ overrides["dashboard"] = False
+ if not effective_features.get("nodes", True):
+ overrides["map"] = False
+ if overrides:
+ effective_features = {**effective_features, **overrides}
+ app.state.features = effective_features
+
# Set up templates (for SPA shell only)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.trim_blocks = True
@@ -309,6 +337,8 @@ def create_app(
@app.get("/map/data", tags=["Map"])
async def map_data(request: Request) -> JSONResponse:
"""Return node location data as JSON for the map."""
+ if not request.app.state.features.get("map", True):
+ return JSONResponse({"detail": "Map feature is disabled"}, status_code=404)
nodes_with_location: list[dict[str, Any]] = []
members_list: list[dict[str, Any]] = []
members_by_id: dict[str, dict[str, Any]] = {}
@@ -448,6 +478,10 @@ def create_app(
@app.get("/spa/pages/{slug}", tags=["SPA"])
async def get_custom_page(request: Request, slug: str) -> JSONResponse:
"""Get a custom page by slug."""
+ if not request.app.state.features.get("pages", True):
+ return JSONResponse(
+ {"detail": "Pages feature is disabled"}, status_code=404
+ )
page_loader = request.app.state.page_loader
page = page_loader.get_page(slug)
if not page:
@@ -489,21 +523,57 @@ def create_app(
async def robots_txt(request: Request) -> str:
"""Serve robots.txt."""
base_url = _get_https_base_url(request)
- return f"User-agent: *\nDisallow:\n\nSitemap: {base_url}/sitemap.xml\n"
+ features = request.app.state.features
+
+ # Always disallow message and node detail pages
+ disallow_lines = [
+ "Disallow: /messages",
+ "Disallow: /nodes/",
+ ]
+
+ # Add disallow for disabled features
+ feature_paths = {
+ "dashboard": "/dashboard",
+ "nodes": "/nodes",
+ "advertisements": "/advertisements",
+ "map": "/map",
+ "members": "/members",
+ "pages": "/pages",
+ }
+ for feature, path in feature_paths.items():
+ if not features.get(feature, True):
+ line = f"Disallow: {path}"
+ if line not in disallow_lines:
+ disallow_lines.append(line)
+
+ disallow_block = "\n".join(disallow_lines)
+ return (
+ f"User-agent: *\n"
+ f"{disallow_block}\n"
+ f"\n"
+ f"Sitemap: {base_url}/sitemap.xml\n"
+ )
@app.get("/sitemap.xml")
async def sitemap_xml(request: Request) -> Response:
- """Generate dynamic sitemap including all node pages."""
+ """Generate dynamic sitemap."""
base_url = _get_https_base_url(request)
+ features = request.app.state.features
+
+ # Home is always included; other pages depend on feature flags
+ all_static_pages = [
+ ("", "daily", "1.0", None),
+ ("/dashboard", "hourly", "0.9", "dashboard"),
+ ("/nodes", "hourly", "0.9", "nodes"),
+ ("/advertisements", "hourly", "0.8", "advertisements"),
+ ("/map", "daily", "0.7", "map"),
+ ("/members", "weekly", "0.6", "members"),
+ ]
static_pages = [
- ("", "daily", "1.0"),
- ("/dashboard", "hourly", "0.9"),
- ("/nodes", "hourly", "0.9"),
- ("/advertisements", "hourly", "0.8"),
- ("/messages", "hourly", "0.8"),
- ("/map", "daily", "0.7"),
- ("/members", "weekly", "0.6"),
+ (path, freq, prio)
+ for path, freq, prio, feature in all_static_pages
+ if feature is None or features.get(feature, True)
]
urls = []
@@ -516,34 +586,16 @@ def create_app(
f" "
)
- try:
- response = await request.app.state.http_client.get(
- "/api/v1/nodes", params={"limit": 500, "role": "infra"}
- )
- if response.status_code == 200:
- nodes = response.json().get("items", [])
- for node in nodes:
- public_key = node.get("public_key")
- if public_key:
- urls.append(
- f" \n"
- f" {base_url}/nodes/{public_key[:8]}\n"
- f" daily\n"
- f" 0.5\n"
- f" "
- )
- except Exception as e:
- logger.warning(f"Failed to fetch nodes for sitemap: {e}")
-
- page_loader = request.app.state.page_loader
- for page in page_loader.get_menu_pages():
- urls.append(
- f" \n"
- f" {base_url}{page.url}\n"
- f" weekly\n"
- f" 0.6\n"
- f" "
- )
+ if features.get("pages", True):
+ page_loader = request.app.state.page_loader
+ for page in page_loader.get_menu_pages():
+ urls.append(
+ f" \n"
+ f" {base_url}{page.url}\n"
+ f" weekly\n"
+ f" 0.6\n"
+ f" "
+ )
xml = (
'\n'
@@ -559,8 +611,11 @@ def create_app(
async def spa_catchall(request: Request, path: str = "") -> HTMLResponse:
"""Serve the SPA shell for all non-API routes."""
templates_inst: Jinja2Templates = request.app.state.templates
+ features = request.app.state.features
page_loader = request.app.state.page_loader
- custom_pages = page_loader.get_menu_pages()
+ custom_pages = (
+ page_loader.get_menu_pages() if features.get("pages", True) else []
+ )
config_json = _build_config_json(request.app, request)
@@ -577,6 +632,7 @@ def create_app(
"network_contact_youtube": request.app.state.network_contact_youtube,
"network_welcome_text": request.app.state.network_welcome_text,
"admin_enabled": request.app.state.admin_enabled,
+ "features": features,
"custom_pages": custom_pages,
"logo_url": request.app.state.logo_url,
"version": __version__,
diff --git a/src/meshcore_hub/web/cli.py b/src/meshcore_hub/web/cli.py
index e8e5e7e..73df613 100644
--- a/src/meshcore_hub/web/cli.py
+++ b/src/meshcore_hub/web/cli.py
@@ -183,6 +183,11 @@ def web(
if effective_city and effective_country:
click.echo(f"Location: {effective_city}, {effective_country}")
click.echo(f"Reload mode: {reload}")
+ disabled_features = [
+ name for name, enabled in settings.features.items() if not enabled
+ ]
+ if disabled_features:
+ click.echo(f"Disabled features: {', '.join(disabled_features)}")
click.echo("=" * 50)
if reload:
diff --git a/src/meshcore_hub/web/static/js/charts.js b/src/meshcore_hub/web/static/js/charts.js
index add073c..a1d3136 100644
--- a/src/meshcore_hub/web/static/js/charts.js
+++ b/src/meshcore_hub/web/static/js/charts.js
@@ -137,82 +137,95 @@ function createLineChart(canvasId, data, label, borderColor, backgroundColor, fi
}
/**
- * Create a multi-dataset activity chart (for home page)
+ * Create a multi-dataset activity chart (for home page).
+ * Pass null for advertData or messageData to omit that series.
* @param {string} canvasId - ID of the canvas element
- * @param {Object} advertData - Advertisement data with 'data' array
- * @param {Object} messageData - Message data with 'data' array
+ * @param {Object|null} advertData - Advertisement data with 'data' array, or null to omit
+ * @param {Object|null} messageData - Message data with 'data' array, or null to omit
*/
function createActivityChart(canvasId, advertData, messageData) {
var ctx = document.getElementById(canvasId);
- if (!ctx || !advertData || !advertData.data || advertData.data.length === 0) {
- return null;
+ if (!ctx) return null;
+
+ // Build datasets from whichever series are provided
+ var datasets = [];
+ var labels = null;
+
+ if (advertData && advertData.data && advertData.data.length > 0) {
+ if (!labels) labels = formatDateLabels(advertData.data);
+ datasets.push({
+ label: 'Advertisements',
+ data: advertData.data.map(function(d) { return d.count; }),
+ borderColor: ChartColors.adverts,
+ backgroundColor: ChartColors.advertsFill,
+ fill: false,
+ tension: 0.3,
+ pointRadius: 2,
+ pointHoverRadius: 5
+ });
}
- var labels = formatDateLabels(advertData.data);
- var advertCounts = advertData.data.map(function(d) { return d.count; });
- var messageCounts = messageData && messageData.data
- ? messageData.data.map(function(d) { return d.count; })
- : [];
+ if (messageData && messageData.data && messageData.data.length > 0) {
+ if (!labels) labels = formatDateLabels(messageData.data);
+ datasets.push({
+ label: 'Messages',
+ data: messageData.data.map(function(d) { return d.count; }),
+ borderColor: ChartColors.messages,
+ backgroundColor: ChartColors.messagesFill,
+ fill: false,
+ tension: 0.3,
+ pointRadius: 2,
+ pointHoverRadius: 5
+ });
+ }
+
+ if (datasets.length === 0 || !labels) return null;
return new Chart(ctx, {
type: 'line',
- data: {
- labels: labels,
- datasets: [{
- label: 'Advertisements',
- data: advertCounts,
- borderColor: ChartColors.adverts,
- backgroundColor: ChartColors.advertsFill,
- fill: false,
- tension: 0.3,
- pointRadius: 2,
- pointHoverRadius: 5
- }, {
- label: 'Messages',
- data: messageCounts,
- borderColor: ChartColors.messages,
- backgroundColor: ChartColors.messagesFill,
- fill: false,
- tension: 0.3,
- pointRadius: 2,
- pointHoverRadius: 5
- }]
- },
+ data: { labels: labels, datasets: datasets },
options: createChartOptions(true)
});
}
/**
- * Initialize dashboard charts (nodes, advertisements, messages)
- * @param {Object} nodeData - Node count data
- * @param {Object} advertData - Advertisement data
- * @param {Object} messageData - Message data
+ * Initialize dashboard charts (nodes, advertisements, messages).
+ * Pass null for any data parameter to skip that chart.
+ * @param {Object|null} nodeData - Node count data, or null to skip
+ * @param {Object|null} advertData - Advertisement data, or null to skip
+ * @param {Object|null} messageData - Message data, or null to skip
*/
function initDashboardCharts(nodeData, advertData, messageData) {
- createLineChart(
- 'nodeChart',
- nodeData,
- 'Total Nodes',
- ChartColors.nodes,
- ChartColors.nodesFill,
- true
- );
+ if (nodeData) {
+ createLineChart(
+ 'nodeChart',
+ nodeData,
+ 'Total Nodes',
+ ChartColors.nodes,
+ ChartColors.nodesFill,
+ true
+ );
+ }
- createLineChart(
- 'advertChart',
- advertData,
- 'Advertisements',
- ChartColors.adverts,
- ChartColors.advertsFill,
- true
- );
+ if (advertData) {
+ createLineChart(
+ 'advertChart',
+ advertData,
+ 'Advertisements',
+ ChartColors.adverts,
+ ChartColors.advertsFill,
+ true
+ );
+ }
- createLineChart(
- 'messageChart',
- messageData,
- 'Messages',
- ChartColors.messages,
- ChartColors.messagesFill,
- true
- );
+ if (messageData) {
+ createLineChart(
+ 'messageChart',
+ messageData,
+ 'Messages',
+ ChartColors.messages,
+ ChartColors.messagesFill,
+ true
+ );
+ }
}
diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js
index 2157741..c9cdcad 100644
--- a/src/meshcore_hub/web/static/js/spa/app.js
+++ b/src/meshcore_hub/web/static/js/spa/app.js
@@ -28,6 +28,10 @@ const pages = {
const appContainer = document.getElementById('app');
const router = new Router();
+// Read feature flags from config
+const config = getConfig();
+const features = config.features || {};
+
/**
* Create a route handler that lazy-loads a page module and calls its render function.
* @param {Function} loader - Module loader function
@@ -51,20 +55,35 @@ function pageHandler(loader) {
};
}
-// Register routes
+// Register routes (conditionally based on feature flags)
router.addRoute('/', pageHandler(pages.home));
-router.addRoute('/dashboard', pageHandler(pages.dashboard));
-router.addRoute('/nodes', pageHandler(pages.nodes));
-router.addRoute('/nodes/:publicKey', pageHandler(pages.nodeDetail));
-router.addRoute('/n/:prefix', async (params) => {
- // Short link redirect
- router.navigate(`/nodes/${params.prefix}`, true);
-});
-router.addRoute('/messages', pageHandler(pages.messages));
-router.addRoute('/advertisements', pageHandler(pages.advertisements));
-router.addRoute('/map', pageHandler(pages.map));
-router.addRoute('/members', pageHandler(pages.members));
-router.addRoute('/pages/:slug', pageHandler(pages.customPage));
+
+if (features.dashboard !== false) {
+ router.addRoute('/dashboard', pageHandler(pages.dashboard));
+}
+if (features.nodes !== false) {
+ router.addRoute('/nodes', pageHandler(pages.nodes));
+ router.addRoute('/nodes/:publicKey', pageHandler(pages.nodeDetail));
+ router.addRoute('/n/:prefix', async (params) => {
+ // Short link redirect
+ router.navigate(`/nodes/${params.prefix}`, true);
+ });
+}
+if (features.messages !== false) {
+ router.addRoute('/messages', pageHandler(pages.messages));
+}
+if (features.advertisements !== false) {
+ router.addRoute('/advertisements', pageHandler(pages.advertisements));
+}
+if (features.map !== false) {
+ router.addRoute('/map', pageHandler(pages.map));
+}
+if (features.members !== false) {
+ router.addRoute('/members', pageHandler(pages.members));
+}
+if (features.pages !== false) {
+ router.addRoute('/pages/:slug', pageHandler(pages.customPage));
+}
// Admin routes
router.addRoute('/a', pageHandler(pages.adminIndex));
@@ -114,18 +133,20 @@ function updatePageTitle(pathname) {
const networkName = config.network_name || 'MeshCore Network';
const titles = {
'/': networkName,
- '/dashboard': `Dashboard - ${networkName}`,
- '/nodes': `Nodes - ${networkName}`,
- '/messages': `Messages - ${networkName}`,
- '/advertisements': `Advertisements - ${networkName}`,
- '/map': `Map - ${networkName}`,
- '/members': `Members - ${networkName}`,
'/a': `Admin - ${networkName}`,
'/a/': `Admin - ${networkName}`,
'/a/node-tags': `Node Tags - Admin - ${networkName}`,
'/a/members': `Members - Admin - ${networkName}`,
};
+ // Add feature-dependent titles
+ if (features.dashboard !== false) titles['/dashboard'] = `Dashboard - ${networkName}`;
+ if (features.nodes !== false) titles['/nodes'] = `Nodes - ${networkName}`;
+ if (features.messages !== false) titles['/messages'] = `Messages - ${networkName}`;
+ if (features.advertisements !== false) titles['/advertisements'] = `Advertisements - ${networkName}`;
+ if (features.map !== false) titles['/map'] = `Map - ${networkName}`;
+ if (features.members !== false) titles['/members'] = `Members - ${networkName}`;
+
if (titles[pathname]) {
document.title = titles[pathname];
} else if (pathname.startsWith('/nodes/')) {
diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js
index 7bcb63c..5c7173b 100644
--- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js
+++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js
@@ -111,8 +111,20 @@ function renderChannelMessages(channelMessages) {
`;
}
+/** Return a Tailwind grid-cols class for the given visible column count. */
+function gridCols(count) {
+ if (count <= 1) return '';
+ return `md:grid-cols-${count}`;
+}
+
export async function render(container, params, router) {
try {
+ const config = getConfig();
+ const features = config.features || {};
+ const showNodes = features.nodes !== false;
+ const showAdverts = features.advertisements !== false;
+ const showMessages = features.messages !== false;
+
const [stats, advertActivity, messageActivity, nodeCount] = await Promise.all([
apiGet('/api/v1/dashboard/stats'),
apiGet('/api/v1/dashboard/activity', { days: 7 }),
@@ -120,12 +132,22 @@ export async function render(container, params, router) {
apiGet('/api/v1/dashboard/node-count', { days: 7 }),
]);
+ // Top section: stats + charts
+ const topCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
+ const topGrid = gridCols(topCount);
+
+ // Bottom section: recent adverts + recent channel messages
+ const bottomCount = (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
+ const bottomGrid = gridCols(bottomCount);
+
litRender(html`
Dashboard
-
+${topCount > 0 ? html`
+
+ ${showNodes ? html`
` : nothing}
+ ${showAdverts ? html`
` : nothing}
+ ${showMessages ? html`
` : nothing}
-
+
+ ${showNodes ? html`
@@ -166,8 +191,9 @@ export async function render(container, params, router) {
-
+
` : nothing}
+ ${showAdverts ? html`
@@ -179,8 +205,9 @@ export async function render(container, params, router) {
-
+ ` : nothing}
+ ${showMessages ? html`
@@ -192,10 +219,12 @@ export async function render(container, params, router) {
-
-
+ ` : nothing}
+` : nothing}
-
+${bottomCount > 0 ? html`
+
+ ${showAdverts ? html`
@@ -204,12 +233,16 @@ export async function render(container, params, router) {
${renderRecentAds(stats.recent_advertisements)}
-
+
` : nothing}
- ${renderChannelMessages(stats.channel_messages)}
-
`, container);
+ ${showMessages ? renderChannelMessages(stats.channel_messages) : nothing}
+` : nothing}`, container);
- window.initDashboardCharts(nodeCount, advertActivity, messageActivity);
+ window.initDashboardCharts(
+ showNodes ? nodeCount : null,
+ showAdverts ? advertActivity : null,
+ showMessages ? messageActivity : null,
+ );
const chartIds = ['nodeChart', 'advertChart', 'messageChart'];
return () => {
diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js
index ddc9455..b29d7f6 100644
--- a/src/meshcore_hub/web/static/js/spa/pages/home.js
+++ b/src/meshcore_hub/web/static/js/spa/pages/home.js
@@ -30,6 +30,7 @@ function renderRadioConfig(rc) {
export async function render(container, params, router) {
try {
const config = getConfig();
+ const features = config.features || {};
const networkName = config.network_name || 'MeshCore Network';
const logoUrl = config.logo_url || '/static/img/logo.svg';
const customPages = config.custom_pages || [];
@@ -42,7 +43,7 @@ export async function render(container, params, router) {
]);
const cityCountry = (config.network_city && config.network_country)
- ? html`${config.network_city}, ${config.network_country}
`
+ ? html`${config.network_city}, ${config.network_country}
`
: nothing;
const welcomeText = config.network_welcome_text
@@ -52,50 +53,64 @@ export async function render(container, params, router) {
Monitor network activity, view connected nodes, and explore message history.
`;
- const customPageButtons = customPages.slice(0, 3).map(page => html`
-
- ${iconPage('h-5 w-5 mr-2')}
- ${page.title}
- `);
+ const customPageButtons = features.pages !== false
+ ? customPages.slice(0, 3).map(page => html`
+
+ ${iconPage('h-5 w-5 mr-2')}
+ ${page.title}
+ `)
+ : [];
+
+ const showStats = features.nodes !== false || features.advertisements !== false || features.messages !== false;
+ const showAdvertSeries = features.advertisements !== false;
+ const showMessageSeries = features.messages !== false;
+ const showActivityChart = showAdvertSeries || showMessageSeries;
litRender(html`
-
-
-
-

+
+
+
+
-
${networkName}
+ ${networkName}
${cityCountry}
${welcomeText}
+ ${showStats ? html`
+ ${features.nodes !== false ? html`
` : nothing}
+ ${features.advertisements !== false ? html`
` : nothing}
+ ${features.messages !== false ? html`
+
` : nothing}
+
` : nothing}
-
+
@@ -158,6 +175,7 @@ export async function render(container, params, router) {
+ ${showActivityChart ? html`
@@ -169,10 +187,17 @@ export async function render(container, params, router) {
-
+
` : nothing}
`, container);
- const chart = window.createActivityChart('activityChart', advertActivity, messageActivity);
+ let chart = null;
+ if (showActivityChart) {
+ chart = window.createActivityChart(
+ 'activityChart',
+ showAdvertSeries ? advertActivity : null,
+ showMessageSeries ? messageActivity : null,
+ );
+ }
return () => {
if (chart) chart.destroy();
diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js
index 7c91748..18b79d1 100644
--- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js
+++ b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js
@@ -199,20 +199,27 @@ ${heroHtml}
cleanupFns.push(() => map.remove());
}
- // Initialize QR code (defer to next frame so layout settles after map init)
- requestAnimationFrame(() => {
+ // Initialize QR code - wait for both DOM element and QRCode library
+ const initQr = () => {
const qrEl = document.getElementById('qr-code');
- if (qrEl && typeof QRCode !== 'undefined') {
- const typeMap = { chat: 1, repeater: 2, room: 3, sensor: 4 };
- const typeNum = typeMap[(node.adv_type || '').toLowerCase()] || 1;
- const url = 'meshcore://contact/add?name=' + encodeURIComponent(displayName) + '&public_key=' + node.public_key + '&type=' + typeNum;
- new QRCode(qrEl, {
- text: url, width: 140, height: 140,
- colorDark: '#000000', colorLight: '#ffffff',
- correctLevel: QRCode.CorrectLevel.L,
- });
- }
- });
+ if (!qrEl || typeof QRCode === 'undefined') return false;
+ const typeMap = { chat: 1, repeater: 2, room: 3, sensor: 4 };
+ const typeNum = typeMap[(node.adv_type || '').toLowerCase()] || 1;
+ const url = 'meshcore://contact/add?name=' + encodeURIComponent(displayName) + '&public_key=' + node.public_key + '&type=' + typeNum;
+ new QRCode(qrEl, {
+ text: url, width: 140, height: 140,
+ colorDark: '#000000', colorLight: '#ffffff',
+ correctLevel: QRCode.CorrectLevel.L,
+ });
+ return true;
+ };
+ if (!initQr()) {
+ let attempts = 0;
+ const qrInterval = setInterval(() => {
+ if (initQr() || ++attempts >= 20) clearInterval(qrInterval);
+ }, 100);
+ cleanupFns.push(() => clearInterval(qrInterval));
+ }
return () => {
cleanupFns.forEach(fn => fn());
diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html
index 54f8225..682fd3e 100644
--- a/src/meshcore_hub/web/templates/spa.html
+++ b/src/meshcore_hub/web/templates/spa.html
@@ -53,15 +53,29 @@
- Home
+ {% if features.dashboard %}
- Dashboard
+ {% endif %}
+ {% if features.nodes %}
- Nodes
+ {% endif %}
+ {% if features.advertisements %}
- Advertisements
+ {% endif %}
+ {% if features.messages %}
- Messages
+ {% endif %}
+ {% if features.map %}
- Map
+ {% endif %}
+ {% if features.members %}
- Members
+ {% endif %}
+ {% if features.pages %}
{% for page in custom_pages %}
- {{ page.title }}
{% endfor %}
+ {% endif %}
@@ -72,15 +86,29 @@
diff --git a/tests/test_web/conftest.py b/tests/test_web/conftest.py
index 99fb92a..427ed11 100644
--- a/tests/test_web/conftest.py
+++ b/tests/test_web/conftest.py
@@ -9,6 +9,18 @@ from httpx import Response
from meshcore_hub.web.app import create_app
+# Explicit all-enabled features dict so tests are not affected by the user's
+# local .env file (pydantic-settings loads .env by default).
+ALL_FEATURES_ENABLED = {
+ "dashboard": True,
+ "nodes": True,
+ "advertisements": True,
+ "messages": True,
+ "map": True,
+ "members": True,
+ "pages": True,
+}
+
class MockHttpClient:
"""Mock HTTP client for testing web routes."""
@@ -315,6 +327,7 @@ def web_app(mock_http_client: MockHttpClient) -> Any:
network_radio_config="Test Radio Config",
network_contact_email="test@example.com",
network_contact_discord="https://discord.gg/test",
+ features=ALL_FEATURES_ENABLED,
)
# Override the lifespan to use our mock client
@@ -335,6 +348,38 @@ def client(web_app: Any, mock_http_client: MockHttpClient) -> TestClient:
return TestClient(web_app, raise_server_exceptions=True)
+@pytest.fixture
+def web_app_no_features(mock_http_client: MockHttpClient) -> Any:
+ """Create a web app with all features disabled."""
+ app = create_app(
+ api_url="http://localhost:8000",
+ api_key="test-api-key",
+ network_name="Test Network",
+ network_city="Test City",
+ network_country="Test Country",
+ features={
+ "dashboard": False,
+ "nodes": False,
+ "advertisements": False,
+ "messages": False,
+ "map": False,
+ "members": False,
+ "pages": False,
+ },
+ )
+ app.state.http_client = mock_http_client
+ return app
+
+
+@pytest.fixture
+def client_no_features(
+ web_app_no_features: Any, mock_http_client: MockHttpClient
+) -> TestClient:
+ """Create a test client with all features disabled."""
+ web_app_no_features.state.http_client = mock_http_client
+ return TestClient(web_app_no_features, raise_server_exceptions=True)
+
+
@pytest.fixture
def mock_http_client_with_members() -> MockHttpClient:
"""Create a mock HTTP client with members data."""
@@ -429,6 +474,7 @@ def web_app_with_members(mock_http_client_with_members: MockHttpClient) -> Any:
network_radio_config="Test Radio Config",
network_contact_email="test@example.com",
network_contact_discord="https://discord.gg/test",
+ features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client_with_members
diff --git a/tests/test_web/test_features.py b/tests/test_web/test_features.py
new file mode 100644
index 0000000..bab378f
--- /dev/null
+++ b/tests/test_web/test_features.py
@@ -0,0 +1,334 @@
+"""Tests for feature flags functionality."""
+
+import json
+
+import pytest
+from fastapi.testclient import TestClient
+
+from meshcore_hub.web.app import create_app
+from tests.test_web.conftest import MockHttpClient
+
+
+class TestFeatureFlagsConfig:
+ """Test feature flags in config."""
+
+ def test_all_features_enabled_by_default(self, client: TestClient) -> None:
+ """All features should be enabled by default in config JSON."""
+ response = client.get("/")
+ assert response.status_code == 200
+ html = response.text
+ # Extract config JSON from script tag
+ start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
+ end = html.index(";", start)
+ config = json.loads(html[start:end])
+ features = config["features"]
+ assert all(features.values()), "All features should be enabled by default"
+
+ def test_features_dict_has_all_keys(self, client: TestClient) -> None:
+ """Features dict should have all 7 expected keys."""
+ response = client.get("/")
+ html = response.text
+ start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
+ end = html.index(";", start)
+ config = json.loads(html[start:end])
+ features = config["features"]
+ expected_keys = {
+ "dashboard",
+ "nodes",
+ "advertisements",
+ "messages",
+ "map",
+ "members",
+ "pages",
+ }
+ assert set(features.keys()) == expected_keys
+
+ def test_disabled_features_in_config(self, client_no_features: TestClient) -> None:
+ """Disabled features should be false in config JSON."""
+ response = client_no_features.get("/")
+ html = response.text
+ start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
+ end = html.index(";", start)
+ config = json.loads(html[start:end])
+ features = config["features"]
+ assert all(not v for v in features.values()), "All features should be disabled"
+
+
+class TestFeatureFlagsNav:
+ """Test feature flags affect navigation."""
+
+ def test_enabled_features_show_nav_links(self, client: TestClient) -> None:
+ """Enabled features should show nav links."""
+ response = client.get("/")
+ html = response.text
+ assert 'href="/dashboard"' in html
+ assert 'href="/nodes"' in html
+ assert 'href="/advertisements"' in html
+ assert 'href="/messages"' in html
+ assert 'href="/map"' in html
+ assert 'href="/members"' in html
+
+ def test_disabled_features_hide_nav_links(
+ self, client_no_features: TestClient
+ ) -> None:
+ """Disabled features should not show nav links."""
+ response = client_no_features.get("/")
+ html = response.text
+ assert 'href="/dashboard"' not in html
+ assert 'href="/nodes"' not in html
+ assert 'href="/advertisements"' not in html
+ assert 'href="/messages"' not in html
+ assert 'href="/map"' not in html
+ assert 'href="/members"' not in html
+
+ def test_home_link_always_present(self, client_no_features: TestClient) -> None:
+ """Home link should always be present."""
+ response = client_no_features.get("/")
+ html = response.text
+ assert 'href="/"' in html
+
+
+class TestFeatureFlagsEndpoints:
+ """Test feature flags affect endpoints."""
+
+ def test_map_data_returns_404_when_disabled(
+ self, client_no_features: TestClient
+ ) -> None:
+ """/map/data should return 404 when map feature is disabled."""
+ response = client_no_features.get("/map/data")
+ assert response.status_code == 404
+ assert response.json()["detail"] == "Map feature is disabled"
+
+ def test_map_data_returns_200_when_enabled(self, client: TestClient) -> None:
+ """/map/data should return 200 when map feature is enabled."""
+ response = client.get("/map/data")
+ assert response.status_code == 200
+
+ def test_custom_page_returns_404_when_disabled(
+ self, client_no_features: TestClient
+ ) -> None:
+ """/spa/pages/{slug} should return 404 when pages feature is disabled."""
+ response = client_no_features.get("/spa/pages/about")
+ assert response.status_code == 404
+ assert response.json()["detail"] == "Pages feature is disabled"
+
+ def test_custom_pages_empty_when_disabled(
+ self, client_no_features: TestClient
+ ) -> None:
+ """Custom pages should be empty in config when pages feature is disabled."""
+ response = client_no_features.get("/")
+ html = response.text
+ start = html.index("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
+ end = html.index(";", start)
+ config = json.loads(html[start:end])
+ assert config["custom_pages"] == []
+
+
+class TestFeatureFlagsSEO:
+ """Test feature flags affect SEO endpoints."""
+
+ def test_sitemap_includes_all_when_enabled(self, client: TestClient) -> None:
+ """Sitemap should include all pages when all features are enabled."""
+ response = client.get("/sitemap.xml")
+ assert response.status_code == 200
+ content = response.text
+ assert "/dashboard" in content
+ assert "/nodes" in content
+ assert "/advertisements" in content
+ assert "/map" in content
+ assert "/members" in content
+
+ def test_sitemap_excludes_disabled_features(
+ self, client_no_features: TestClient
+ ) -> None:
+ """Sitemap should exclude disabled features."""
+ response = client_no_features.get("/sitemap.xml")
+ assert response.status_code == 200
+ content = response.text
+ assert "/dashboard" not in content
+ assert "/nodes" not in content
+ assert "/advertisements" not in content
+ assert "/map" not in content
+ assert "/members" not in content
+
+ def test_sitemap_always_includes_home(self, client_no_features: TestClient) -> None:
+ """Sitemap should always include the home page."""
+ response = client_no_features.get("/sitemap.xml")
+ assert response.status_code == 200
+ content = response.text
+ # Home page has an empty path, so check for base URL loc
+ assert "" in content
+
+ def test_robots_txt_adds_disallow_for_disabled(
+ self, client_no_features: TestClient
+ ) -> None:
+ """Robots.txt should add Disallow for disabled features."""
+ response = client_no_features.get("/robots.txt")
+ assert response.status_code == 200
+ content = response.text
+ assert "Disallow: /dashboard" in content
+ assert "Disallow: /nodes" in content
+ assert "Disallow: /advertisements" in content
+ assert "Disallow: /map" in content
+ assert "Disallow: /members" in content
+ assert "Disallow: /pages" in content
+
+ def test_robots_txt_default_disallows_when_enabled(
+ self, client: TestClient
+ ) -> None:
+ """Robots.txt should only disallow messages and nodes/ when all enabled."""
+ response = client.get("/robots.txt")
+ assert response.status_code == 200
+ content = response.text
+ assert "Disallow: /messages" in content
+ assert "Disallow: /nodes/" in content
+ # Should not disallow the full /nodes path (only /nodes/ for detail pages)
+ lines = content.strip().split("\n")
+ disallow_lines = [
+ line.strip() for line in lines if line.startswith("Disallow:")
+ ]
+ assert "Disallow: /nodes" not in disallow_lines or any(
+ line == "Disallow: /nodes/" for line in disallow_lines
+ )
+
+
+class TestFeatureFlagsIndividual:
+ """Test individual feature flags."""
+
+ @pytest.fixture
+ def _make_client(self, mock_http_client: MockHttpClient):
+ """Factory to create a client with specific features disabled."""
+
+ def _create(disabled_feature: str) -> TestClient:
+ features = {
+ "dashboard": True,
+ "nodes": True,
+ "advertisements": True,
+ "messages": True,
+ "map": True,
+ "members": True,
+ "pages": True,
+ }
+ features[disabled_feature] = False
+ app = create_app(
+ api_url="http://localhost:8000",
+ api_key="test-api-key",
+ network_name="Test Network",
+ features=features,
+ )
+ app.state.http_client = mock_http_client
+ return TestClient(app, raise_server_exceptions=True)
+
+ return _create
+
+ def test_disable_map_only(self, _make_client) -> None:
+ """Disabling only map should hide map but show others."""
+ client = _make_client("map")
+ response = client.get("/")
+ html = response.text
+ assert 'href="/map"' not in html
+ assert 'href="/dashboard"' in html
+ assert 'href="/nodes"' in html
+
+ # Map data endpoint should 404
+ response = client.get("/map/data")
+ assert response.status_code == 404
+
+ def test_disable_dashboard_only(self, _make_client) -> None:
+ """Disabling only dashboard should hide dashboard but show others."""
+ client = _make_client("dashboard")
+ response = client.get("/")
+ html = response.text
+ assert 'href="/dashboard"' not in html
+ assert 'href="/nodes"' in html
+ assert 'href="/map"' in html
+
+
+class TestDashboardAutoDisable:
+ """Test that dashboard is automatically disabled when it has no content."""
+
+ def test_dashboard_auto_disabled_when_all_stats_off(
+ self, mock_http_client: MockHttpClient
+ ) -> None:
+ """Dashboard should auto-disable when nodes, adverts, messages all off."""
+ app = create_app(
+ api_url="http://localhost:8000",
+ api_key="test-api-key",
+ network_name="Test Network",
+ features={
+ "dashboard": True,
+ "nodes": False,
+ "advertisements": False,
+ "messages": False,
+ "map": True,
+ "members": True,
+ "pages": True,
+ },
+ )
+ app.state.http_client = mock_http_client
+ client = TestClient(app, raise_server_exceptions=True)
+
+ response = client.get("/")
+ html = response.text
+ assert 'href="/dashboard"' not in html
+
+ # Check config JSON also reflects it
+ config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
+ assert config["features"]["dashboard"] is False
+
+ def test_map_auto_disabled_when_nodes_off(
+ self, mock_http_client: MockHttpClient
+ ) -> None:
+ """Map should auto-disable when nodes is off (map depends on nodes)."""
+ app = create_app(
+ api_url="http://localhost:8000",
+ api_key="test-api-key",
+ network_name="Test Network",
+ features={
+ "dashboard": True,
+ "nodes": False,
+ "advertisements": True,
+ "messages": True,
+ "map": True,
+ "members": True,
+ "pages": True,
+ },
+ )
+ app.state.http_client = mock_http_client
+ client = TestClient(app, raise_server_exceptions=True)
+
+ response = client.get("/")
+ html = response.text
+ assert 'href="/map"' not in html
+
+ # Check config JSON also reflects it
+ config = json.loads(html.split("window.__APP_CONFIG__ = ")[1].split(";")[0])
+ assert config["features"]["map"] is False
+
+ # Map data endpoint should 404
+ response = client.get("/map/data")
+ assert response.status_code == 404
+
+ def test_dashboard_stays_enabled_with_one_stat(
+ self, mock_http_client: MockHttpClient
+ ) -> None:
+ """Dashboard should stay enabled when at least one stat feature is on."""
+ app = create_app(
+ api_url="http://localhost:8000",
+ api_key="test-api-key",
+ network_name="Test Network",
+ features={
+ "dashboard": True,
+ "nodes": True,
+ "advertisements": False,
+ "messages": False,
+ "map": True,
+ "members": True,
+ "pages": True,
+ },
+ )
+ app.state.http_client = mock_http_client
+ client = TestClient(app, raise_server_exceptions=True)
+
+ response = client.get("/")
+ assert 'href="/dashboard"' in response.text