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) <noreply@anthropic.com>
This commit is contained in:
Alex Vanderpot
2026-06-20 19:59:55 -04:00
parent 53cc82c38f
commit e05e13c772
4 changed files with 36 additions and 78 deletions
+2
View File
@@ -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.
+1
View File
@@ -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:
+25 -78
View File
@@ -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() {
</div>
)}
</div>
{grafanaUrl && (
<a
href={grafanaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 mb-6 text-blue-600 dark:text-blue-400 hover:underline"
>
<ExternalLink size={16} />
View Grafana dashboard
</a>
)}
{error ? (
<div className="text-red-600 dark:text-red-400">
<h2 className="text-lg font-semibold mb-2">Error Loading Stats</h2>
@@ -107,65 +113,6 @@ export default function StatsPage() {
<div className="text-3xl font-mono">{totalNodes}</div>
</div>
<div className="mb-6">
<div className="group flex items-center">
<h2 id="nodes-over-time" className="text-lg font-semibold mb-2">Nodes Heard Over Time</h2>
<AnchorLink id="nodes-over-time" />
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Shows nodes heard within the last 7 days by date.
</p>
<div className="w-full overflow-x-auto">
<table className="w-full text-sm border rounded">
<thead>
<tr className="bg-gray-100 dark:bg-gray-800 font-semibold">
<th className="border px-3 py-2 text-center min-w-[120px]">Day</th>
<th className="border px-3 py-2 text-center">Total Nodes</th>
<th className="border px-3 py-2 text-center">With Location</th>
<th className="border px-3 py-2 text-center">Without Location</th>
<th className="border px-3 py-2 text-center">Repeaters</th>
<th className="border px-3 py-2 text-center">Room Servers</th>
</tr>
</thead>
<tbody>
{nodesOverTime.map((row, i) => (
<tr key={i} className="border-t">
<td className="border px-3 py-2 text-center min-w-[120px]">{row.day}</td>
<td className="border px-3 py-2 text-center">{row.cumulative_unique_nodes}</td>
<td className="border px-3 py-2 text-center">{row.nodes_with_location}</td>
<td className="border px-3 py-2 text-center">{row.nodes_without_location}</td>
<td className="border px-3 py-2 text-center">{row.repeaters}</td>
<td className="border px-3 py-2 text-center">{row.room_servers}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="mb-6">
<div className="group flex items-center">
<h2 id="popular-channels" className="text-lg font-semibold mb-2">Most Popular Channels</h2>
<AnchorLink id="popular-channels" />
</div>
<table className="w-full text-sm border">
<thead>
<tr>
<th className="border px-2 py-1">Channel Hash</th>
<th className="border px-2 py-1">Message Count</th>
</tr>
</thead>
<tbody>
{popularChannels.map((row, i) => (
<tr key={i}>
<td className="border px-2 py-1">{row.channel_hash}</td>
<td className="border px-2 py-1">{row.message_count}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mb-6">
<div className="group flex items-center">
<h2 id="used-prefixes" className="text-lg font-semibold mb-2">Used Repeater Prefixes</h2>
+8
View File
@@ -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')