From e05e13c77276eb99529d2504c4d0d79709768beb Mon Sep 17 00:00:00 2001 From: Alex Vanderpot Date: Sat, 20 Jun 2026 19:59:55 -0400 Subject: [PATCH] Add Grafana dashboard link to Stats page Surface the MeshCore Grafana dashboard from the Stats page via a new NEXT_PUBLIC_GRAFANA_URL env var; the link is hidden when unset and renders immediately, before the stats APIs finish loading. Also remove the noisy "Nodes Heard Over Time" and "Most Popular Channels" sections. Closes #16 Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 2 + docker-compose.yml | 1 + meshexplorer/src/app/(app)/stats/page.tsx | 103 ++++++---------------- meshexplorer/src/lib/api.ts | 8 ++ 4 files changed, 36 insertions(+), 78 deletions(-) diff --git a/.env.example b/.env.example index e1b3125..62519a7 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,8 @@ MQTT_STALE_AFTER_SECONDS=300 # ─── Web app ───────────────────────────────────────────────────────────────── # Base URL for client-side API calls. Leave empty to use relative URLs. NEXT_PUBLIC_API_URL= +# URL of the Grafana dashboard linked from the Stats page. Leave empty to hide the link. +NEXT_PUBLIC_GRAFANA_URL= # ─── Discord relay bot (optional, --profile bot) ───────────────────────────── # Required when running the bot. Create a webhook in your Discord server. diff --git a/docker-compose.yml b/docker-compose.yml index c58dada..fc21595 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,6 +92,7 @@ services: - CLICKHOUSE_USER=${CLICKHOUSE_READONLY_USER:-readonly} - CLICKHOUSE_PASSWORD=${CLICKHOUSE_READONLY_PASSWORD:-readonly} - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-} + - NEXT_PUBLIC_GRAFANA_URL=${NEXT_PUBLIC_GRAFANA_URL:-} ports: - "3001:3000" depends_on: diff --git a/meshexplorer/src/app/(app)/stats/page.tsx b/meshexplorer/src/app/(app)/stats/page.tsx index 2ed22c2..420c7e5 100644 --- a/meshexplorer/src/app/(app)/stats/page.tsx +++ b/meshexplorer/src/app/(app)/stats/page.tsx @@ -4,13 +4,13 @@ import { useEffect } from "react"; import { useConfig } from "@/components/ConfigContext"; import { selectorLabel } from "@/lib/regions"; import { useRegionGroups } from "@/hooks/useRegions"; -import { - useTotalNodes, - useNodesOverTime, - usePopularChannels, - useRepeaterPrefixes, - useUnusedPrefixes +import { + useTotalNodes, + useRepeaterPrefixes, + useUnusedPrefixes } from "@/hooks/useStats"; +import { getGrafanaUrl } from "@/lib/api"; +import { ExternalLink } from "lucide-react"; import Link from "next/link"; // Component for anchor links next to section headings @@ -31,30 +31,25 @@ export default function StatsPage() { // Use TanStack Query hooks for data fetching const totalNodesQuery = useTotalNodes(region); - const nodesOverTimeQuery = useNodesOverTime(region); - const popularChannelsQuery = usePopularChannels(region); const repeaterPrefixesQuery = useRepeaterPrefixes(region); const unusedPrefixesQuery = useUnusedPrefixes(region); - + // Combine loading states - show loading if any query is loading - const isLoading = totalNodesQuery.isLoading || - nodesOverTimeQuery.isLoading || - popularChannelsQuery.isLoading || + const isLoading = totalNodesQuery.isLoading || repeaterPrefixesQuery.isLoading; - + // Combine error states - const error = totalNodesQuery.error || - nodesOverTimeQuery.error || - popularChannelsQuery.error || + const error = totalNodesQuery.error || repeaterPrefixesQuery.error; - + // Extract data with fallbacks const totalNodes = totalNodesQuery.data?.total_nodes ?? null; - const nodesOverTime = nodesOverTimeQuery.data?.data ?? []; - const popularChannels = popularChannelsQuery.data?.data ?? []; const repeaterPrefixes = repeaterPrefixesQuery.data?.data ?? []; const unusedPrefixes = unusedPrefixesQuery.data ?? []; + // Grafana dashboard link (only shown when configured) + const grafanaUrl = getGrafanaUrl(); + // Get the display label for the selected region/group const { groups } = useRegionGroups(); const regionFriendlyName = selectorLabel(config?.selectedRegion, groups); @@ -87,6 +82,17 @@ export default function StatsPage() { )} + {grafanaUrl && ( + + + View Grafana dashboard + + )} {error ? (

Error Loading Stats

@@ -107,65 +113,6 @@ export default function StatsPage() {
{totalNodes}
-
-
-

Nodes Heard Over Time

- -
-

- Shows nodes heard within the last 7 days by date. -

-
- - - - - - - - - - - - - {nodesOverTime.map((row, i) => ( - - - - - - - - - ))} - -
DayTotal NodesWith LocationWithout LocationRepeatersRoom Servers
{row.day}{row.cumulative_unique_nodes}{row.nodes_with_location}{row.nodes_without_location}{row.repeaters}{row.room_servers}
-
-
- -
-
- - -
- - - - - - - - - {popularChannels.map((row, i) => ( - - - - - ))} - -
Channel HashMessage Count
{row.channel_hash}{row.message_count}
-
-

Used Repeater Prefixes

diff --git a/meshexplorer/src/lib/api.ts b/meshexplorer/src/lib/api.ts index 7b6335a..6ffade4 100644 --- a/meshexplorer/src/lib/api.ts +++ b/meshexplorer/src/lib/api.ts @@ -16,6 +16,14 @@ export function getAppName(): string { return process.env.NEXT_PUBLIC_APP_NAME || 'MeshExplorer'; } +/** + * Get the Grafana dashboard URL. Returns undefined when NEXT_PUBLIC_GRAFANA_URL + * is not set, in which case the Stats page hides the Grafana link. + */ +export function getGrafanaUrl(): string | undefined { + return process.env.NEXT_PUBLIC_GRAFANA_URL || undefined; +} + /** * Build a full API URL by combining the base URL with the endpoint path. * @param endpoint - The API endpoint path (e.g., '/api/stats/total-nodes')