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`
${iconNodes('h-8 w-8')} @@ -133,8 +155,9 @@ export async function render(container, params, router) {
Total Nodes
${stats.total_nodes}
All discovered nodes
-
+
` : nothing} + ${showAdverts ? html`
${iconAdvertisements('h-8 w-8')} @@ -142,8 +165,9 @@ export async function render(container, params, router) {
Advertisements
${stats.advertisements_7d}
Last 7 days
-
+
` : nothing} + ${showMessages ? html`
${iconMessages('h-8 w-8')} @@ -151,10 +175,11 @@ export async function render(container, params, router) {
Messages
${stats.messages_7d}
Last 7 days
-
+
` : 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}
-

${networkName}

+

${networkName}

${cityCountry}
${welcomeText}
+ ${features.dashboard !== false ? html` ${iconDashboard('h-5 w-5 mr-2')} Dashboard - + ` : nothing} + ${features.nodes !== false ? html` ${iconNodes('h-5 w-5 mr-2')} Nodes - + ` : nothing} + ${features.advertisements !== false ? html` ${iconAdvertisements('h-5 w-5 mr-2')} Adverts - + ` : nothing} + ${features.messages !== false ? html` ${iconMessages('h-5 w-5 mr-2')} Messages - + ` : nothing} + ${features.map !== false ? html` ${iconMap('h-5 w-5 mr-2')} Map - + ` : nothing} ${customPageButtons}
+ ${showStats ? html`
+ ${features.nodes !== false ? html`
${iconNodes('h-8 w-8')} @@ -103,8 +118,9 @@ export async function render(container, params, router) {
Total Nodes
${stats.total_nodes}
All discovered nodes
-
+
` : nothing} + ${features.advertisements !== false ? html`
${iconAdvertisements('h-8 w-8')} @@ -112,8 +128,9 @@ export async function render(container, params, router) {
Advertisements
${stats.advertisements_7d}
Last 7 days
-
+
` : nothing} + ${features.messages !== false ? html`
${iconMessages('h-8 w-8')} @@ -121,11 +138,11 @@ export async function render(container, params, router) {
Messages
${stats.messages_7d}
Last 7 days
-
-
+
` : 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 @@
@@ -72,15 +86,29 @@