Add feature flags to control web dashboard page visibility

Operators can now disable specific pages (Dashboard, Nodes, Advertisements,
Messages, Map, Members, Pages) via FEATURE_* environment variables. Disabled
features are fully hidden: removed from navigation, return 404 on routes,
and excluded from sitemap/robots.txt. Dashboard auto-disables when all of
Nodes/Advertisements/Messages are off. Map auto-disables when Nodes is off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Louis King
2026-02-10 15:43:23 +00:00
parent bafc16d746
commit 706c32ae01
15 changed files with 824 additions and 177 deletions

View File

@@ -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
# -------------------

View File

@@ -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.

View File

@@ -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:

View File

@@ -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')"]

View File

@@ -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."""

View File

@@ -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" </url>"
)
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" <url>\n"
f" <loc>{base_url}/nodes/{public_key[:8]}</loc>\n"
f" <changefreq>daily</changefreq>\n"
f" <priority>0.5</priority>\n"
f" </url>"
)
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" <url>\n"
f" <loc>{base_url}{page.url}</loc>\n"
f" <changefreq>weekly</changefreq>\n"
f" <priority>0.6</priority>\n"
f" </url>"
)
if features.get("pages", True):
page_loader = request.app.state.page_loader
for page in page_loader.get_menu_pages():
urls.append(
f" <url>\n"
f" <loc>{base_url}{page.url}</loc>\n"
f" <changefreq>weekly</changefreq>\n"
f" <priority>0.6</priority>\n"
f" </url>"
)
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\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__,

View File

@@ -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:

View File

@@ -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
);
}
}

View File

@@ -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/')) {

View File

@@ -111,8 +111,20 @@ function renderChannelMessages(channelMessages) {
</div>`;
}
/** 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`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Dashboard</h1>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
${topCount > 0 ? html`
<div class="grid grid-cols-1 ${topGrid} gap-6 mb-6">
${showNodes ? html`
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure" style="color: ${pageColors.nodes}">
${iconNodes('h-8 w-8')}
@@ -133,8 +155,9 @@ export async function render(container, params, router) {
<div class="stat-title">Total Nodes</div>
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
<div class="stat-desc">All discovered nodes</div>
</div>
</div>` : nothing}
${showAdverts ? html`
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure" style="color: ${pageColors.adverts}">
${iconAdvertisements('h-8 w-8')}
@@ -142,8 +165,9 @@ export async function render(container, params, router) {
<div class="stat-title">Advertisements</div>
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>` : nothing}
${showMessages ? html`
<div class="stat bg-base-100 rounded-box shadow">
<div class="stat-figure" style="color: ${pageColors.messages}">
${iconMessages('h-8 w-8')}
@@ -151,10 +175,11 @@ export async function render(container, params, router) {
<div class="stat-title">Messages</div>
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>` : nothing}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="grid grid-cols-1 ${topGrid} gap-6 mb-8">
${showNodes ? html`
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
@@ -166,8 +191,9 @@ export async function render(container, params, router) {
<canvas id="nodeChart"></canvas>
</div>
</div>
</div>
</div>` : nothing}
${showAdverts ? html`
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
@@ -179,8 +205,9 @@ export async function render(container, params, router) {
<canvas id="advertChart"></canvas>
</div>
</div>
</div>
</div>` : nothing}
${showMessages ? html`
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-base">
@@ -192,10 +219,12 @@ export async function render(container, params, router) {
<canvas id="messageChart"></canvas>
</div>
</div>
</div>
</div>
</div>` : nothing}
</div>` : nothing}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
${bottomCount > 0 ? html`
<div class="grid grid-cols-1 ${bottomGrid} gap-6">
${showAdverts ? html`
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
@@ -204,12 +233,16 @@ export async function render(container, params, router) {
</h2>
${renderRecentAds(stats.recent_advertisements)}
</div>
</div>
</div>` : nothing}
${renderChannelMessages(stats.channel_messages)}
</div>`, container);
${showMessages ? renderChannelMessages(stats.channel_messages) : nothing}
</div>` : nothing}`, container);
window.initDashboardCharts(nodeCount, advertActivity, messageActivity);
window.initDashboardCharts(
showNodes ? nodeCount : null,
showAdverts ? advertActivity : null,
showMessages ? messageActivity : null,
);
const chartIds = ['nodeChart', 'advertChart', 'messageChart'];
return () => {

View File

@@ -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`<p class="text-2xl opacity-70 mt-2">${config.network_city}, ${config.network_country}</p>`
? html`<p class="text-lg sm:text-2xl opacity-70 mt-2">${config.network_city}, ${config.network_country}</p>`
: 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.
</p>`;
const customPageButtons = customPages.slice(0, 3).map(page => html`
<a href="${page.url}" class="btn btn-outline btn-neutral">
${iconPage('h-5 w-5 mr-2')}
${page.title}
</a>`);
const customPageButtons = features.pages !== false
? customPages.slice(0, 3).map(page => html`
<a href="${page.url}" class="btn btn-outline btn-neutral">
${iconPage('h-5 w-5 mr-2')}
${page.title}
</a>`)
: [];
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`
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 bg-base-100 rounded-box p-6">
<div class="lg:col-span-2 flex flex-col items-center text-center">
<div class="flex items-center gap-8 mb-4">
<img src="${logoUrl}" alt="${networkName}" class="h-36 w-36" />
<div class="${showStats ? 'grid grid-cols-1 lg:grid-cols-3 gap-6' : ''} bg-base-100 rounded-box p-6">
<div class="${showStats ? 'lg:col-span-2' : ''} flex flex-col items-center text-center">
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 mb-4">
<img src="${logoUrl}" alt="${networkName}" class="h-24 w-24 sm:h-36 sm:w-36" />
<div class="flex flex-col justify-center">
<h1 class="text-6xl font-black tracking-tight">${networkName}</h1>
<h1 class="text-3xl sm:text-5xl lg:text-6xl font-black tracking-tight">${networkName}</h1>
${cityCountry}
</div>
</div>
${welcomeText}
<div class="flex-1"></div>
<div class="flex flex-wrap justify-center gap-3 mt-auto">
${features.dashboard !== false ? html`
<a href="/dashboard" class="btn btn-outline btn-info">
${iconDashboard('h-5 w-5 mr-2')}
Dashboard
</a>
</a>` : nothing}
${features.nodes !== false ? html`
<a href="/nodes" class="btn btn-outline btn-primary">
${iconNodes('h-5 w-5 mr-2')}
Nodes
</a>
</a>` : nothing}
${features.advertisements !== false ? html`
<a href="/advertisements" class="btn btn-outline btn-secondary">
${iconAdvertisements('h-5 w-5 mr-2')}
Adverts
</a>
</a>` : nothing}
${features.messages !== false ? html`
<a href="/messages" class="btn btn-outline btn-accent">
${iconMessages('h-5 w-5 mr-2')}
Messages
</a>
</a>` : nothing}
${features.map !== false ? html`
<a href="/map" class="btn btn-outline btn-warning">
${iconMap('h-5 w-5 mr-2')}
Map
</a>
</a>` : nothing}
${customPageButtons}
</div>
</div>
${showStats ? html`
<div class="flex flex-col gap-4">
${features.nodes !== false ? html`
<div class="stat bg-base-200 rounded-box">
<div class="stat-figure" style="color: ${pageColors.nodes}">
${iconNodes('h-8 w-8')}
@@ -103,8 +118,9 @@ export async function render(container, params, router) {
<div class="stat-title">Total Nodes</div>
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
<div class="stat-desc">All discovered nodes</div>
</div>
</div>` : nothing}
${features.advertisements !== false ? html`
<div class="stat bg-base-200 rounded-box">
<div class="stat-figure" style="color: ${pageColors.adverts}">
${iconAdvertisements('h-8 w-8')}
@@ -112,8 +128,9 @@ export async function render(container, params, router) {
<div class="stat-title">Advertisements</div>
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>` : nothing}
${features.messages !== false ? html`
<div class="stat bg-base-200 rounded-box">
<div class="stat-figure" style="color: ${pageColors.messages}">
${iconMessages('h-8 w-8')}
@@ -121,11 +138,11 @@ export async function render(container, params, router) {
<div class="stat-title">Messages</div>
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
<div class="stat-desc">Last 7 days</div>
</div>
</div>
</div>` : nothing}
</div>` : nothing}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
<div class="grid grid-cols-1 md:grid-cols-2 ${showActivityChart ? 'lg:grid-cols-3' : ''} gap-6 mt-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
@@ -158,6 +175,7 @@ export async function render(container, params, router) {
</div>
</div>
${showActivityChart ? html`
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
@@ -169,10 +187,17 @@ export async function render(container, params, router) {
<canvas id="activityChart"></canvas>
</div>
</div>
</div>
</div>` : nothing}
</div>`, 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();

View File

@@ -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());

View File

@@ -53,15 +53,29 @@
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> Home</a></li>
{% if features.dashboard %}
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> Dashboard</a></li>
{% endif %}
{% if features.nodes %}
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> Nodes</a></li>
{% endif %}
{% if features.advertisements %}
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> Advertisements</a></li>
{% endif %}
{% if features.messages %}
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> Messages</a></li>
{% endif %}
{% if features.map %}
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> Map</a></li>
{% endif %}
{% if features.members %}
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> Members</a></li>
{% endif %}
{% if features.pages %}
{% for page in custom_pages %}
<li><a href="{{ page.url }}" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> {{ page.title }}</a></li>
{% endfor %}
{% endif %}
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
@@ -72,15 +86,29 @@
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> Home</a></li>
{% if features.dashboard %}
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> Dashboard</a></li>
{% endif %}
{% if features.nodes %}
<li><a href="/nodes" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-nodes" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> Nodes</a></li>
{% endif %}
{% if features.advertisements %}
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> Advertisements</a></li>
{% endif %}
{% if features.messages %}
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> Messages</a></li>
{% endif %}
{% if features.map %}
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> Map</a></li>
{% endif %}
{% if features.members %}
<li><a href="/members" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-members" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg> Members</a></li>
{% endif %}
{% if features.pages %}
{% for page in custom_pages %}
<li><a href="{{ page.url }}" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> {{ page.title }}</a></li>
{% endfor %}
{% endif %}
</ul>
</div>
<div class="navbar-end">

View File

@@ -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

View File

@@ -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 "<loc>" 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