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 && (
+
+
- Shows nodes heard within the last 7 days by date. -
-| Day | -Total Nodes | -With Location | -Without Location | -Repeaters | -Room Servers | -
|---|---|---|---|---|---|
| {row.day} | -{row.cumulative_unique_nodes} | -{row.nodes_with_location} | -{row.nodes_without_location} | -{row.repeaters} | -{row.room_servers} | -
| Channel Hash | -Message Count | -
|---|---|
| {row.channel_hash} | -{row.message_count} | -