move stats to tanstack query, implement cancelation

This commit is contained in:
ajvpot
2025-09-10 02:47:44 +02:00
parent a5638fe924
commit 6fab8a112a
2 changed files with 195 additions and 46 deletions

View File

@@ -1,54 +1,44 @@
"use client";
import { useEffect, useState } from "react";
import { buildApiUrl } from "@/lib/api";
import { useConfig } from "@/components/ConfigContext";
import { getRegionConfig } from "@/lib/regions";
import {
useTotalNodes,
useNodesOverTime,
usePopularChannels,
useRepeaterPrefixes,
useUnusedPrefixes
} from "@/hooks/useStats";
export default function StatsPage() {
const { config } = useConfig();
const [totalNodes, setTotalNodes] = useState<number | null>(null);
const [nodesOverTime, setNodesOverTime] = useState<any[]>([]);
const [popularChannels, setPopularChannels] = useState<any[]>([]);
const [repeaterPrefixes, setRepeaterPrefixes] = useState<any[]>([]);
const [unusedPrefixes, setUnusedPrefixes] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchStats() {
setLoading(true);
// Build API URLs with region parameter if selected
const regionParam = config?.selectedRegion ? `?region=${encodeURIComponent(config.selectedRegion)}` : '';
const [totalNodesRes, nodesOverTimeRes, popularChannelsRes, repeaterPrefixesRes] = await Promise.all([
fetch(buildApiUrl(`/api/stats/total-nodes${regionParam}`)).then(r => r.json()),
fetch(buildApiUrl(`/api/stats/nodes-over-time${regionParam}`)).then(r => r.json()),
fetch(buildApiUrl(`/api/stats/popular-channels${regionParam}`)).then(r => r.json()),
fetch(buildApiUrl(`/api/stats/repeater-prefixes${regionParam}`)).then(r => r.json()),
]);
setTotalNodes(totalNodesRes.total_nodes ?? null);
setNodesOverTime(nodesOverTimeRes.data ?? []);
setPopularChannels(popularChannelsRes.data ?? []);
setRepeaterPrefixes(repeaterPrefixesRes.data ?? []);
// Generate all possible 2-character hex prefixes (01-FE, excluding 00 and FF)
const allPrefixes = [];
for (let i = 1; i < 255; i++) {
allPrefixes.push(i.toString(16).padStart(2, '0').toUpperCase());
}
// Get used prefixes from the API response
const usedPrefixes = new Set((repeaterPrefixesRes.data ?? []).map((row: any) => row.prefix));
// Find unused prefixes
const unused = allPrefixes.filter(prefix => !usedPrefixes.has(prefix));
setUnusedPrefixes(unused);
setLoading(false);
}
fetchStats();
}, [config?.selectedRegion]);
const region = config?.selectedRegion;
// 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 ||
repeaterPrefixesQuery.isLoading;
// Combine error states
const error = totalNodesQuery.error ||
nodesOverTimeQuery.error ||
popularChannelsQuery.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 ?? [];
// Get the friendly name for the selected region
const regionFriendlyName = config?.selectedRegion
@@ -65,8 +55,16 @@ export default function StatsPage() {
</div>
)}
</div>
{loading ? (
<div>Loading...</div>
{error ? (
<div className="text-red-600 dark:text-red-400">
<h2 className="text-lg font-semibold mb-2">Error Loading Stats</h2>
<p>{error.message || 'An error occurred while loading statistics.'}</p>
</div>
) : isLoading ? (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p className="mt-2 text-gray-600 dark:text-gray-400">Loading statistics...</p>
</div>
) : (
<>
<div className="mb-6">

151
src/hooks/useStats.ts Normal file
View File

@@ -0,0 +1,151 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { buildApiUrl } from "@/lib/api";
interface TotalNodesResponse {
total_nodes: number;
}
interface NodesOverTimeRow {
day: string;
cumulative_unique_nodes: number;
nodes_with_location: number;
nodes_without_location: number;
repeaters: number;
room_servers: number;
}
interface NodesOverTimeResponse {
data: NodesOverTimeRow[];
}
interface PopularChannelRow {
channel_hash: string;
message_count: number;
}
interface PopularChannelsResponse {
data: PopularChannelRow[];
}
interface RepeaterPrefixRow {
prefix: string;
node_names: string[];
}
interface RepeaterPrefixesResponse {
data: RepeaterPrefixRow[];
}
const STALE_TIME = 5 * 60 * 1000; // 5 minutes
const GC_TIME = 10 * 60 * 1000; // 10 minutes
export function useTotalNodes(region?: string) {
return useQuery<TotalNodesResponse>({
queryKey: ['stats', 'total-nodes', region],
queryFn: async ({ signal }) => {
const regionParam = region ? `?region=${encodeURIComponent(region)}` : '';
const response = await fetch(buildApiUrl(`/api/stats/total-nodes${regionParam}`), {
signal
});
if (!response.ok) {
throw new Error(`Failed to fetch total nodes: ${response.statusText}`);
}
return response.json();
},
staleTime: STALE_TIME,
gcTime: GC_TIME,
retry: 2,
});
}
export function useNodesOverTime(region?: string) {
return useQuery<NodesOverTimeResponse>({
queryKey: ['stats', 'nodes-over-time', region],
queryFn: async ({ signal }) => {
const regionParam = region ? `?region=${encodeURIComponent(region)}` : '';
const response = await fetch(buildApiUrl(`/api/stats/nodes-over-time${regionParam}`), {
signal
});
if (!response.ok) {
throw new Error(`Failed to fetch nodes over time: ${response.statusText}`);
}
return response.json();
},
staleTime: STALE_TIME,
gcTime: GC_TIME,
retry: 2,
});
}
export function usePopularChannels(region?: string) {
return useQuery<PopularChannelsResponse>({
queryKey: ['stats', 'popular-channels', region],
queryFn: async ({ signal }) => {
const regionParam = region ? `?region=${encodeURIComponent(region)}` : '';
const response = await fetch(buildApiUrl(`/api/stats/popular-channels${regionParam}`), {
signal
});
if (!response.ok) {
throw new Error(`Failed to fetch popular channels: ${response.statusText}`);
}
return response.json();
},
staleTime: STALE_TIME,
gcTime: GC_TIME,
retry: 2,
});
}
export function useRepeaterPrefixes(region?: string) {
return useQuery<RepeaterPrefixesResponse>({
queryKey: ['stats', 'repeater-prefixes', region],
queryFn: async ({ signal }) => {
const regionParam = region ? `?region=${encodeURIComponent(region)}` : '';
const response = await fetch(buildApiUrl(`/api/stats/repeater-prefixes${regionParam}`), {
signal
});
if (!response.ok) {
throw new Error(`Failed to fetch repeater prefixes: ${response.statusText}`);
}
return response.json();
},
staleTime: STALE_TIME,
gcTime: GC_TIME,
retry: 2,
});
}
export function useUnusedPrefixes(region?: string) {
const { data: repeaterPrefixesData, isLoading, error } = useRepeaterPrefixes(region);
const unusedPrefixes = React.useMemo(() => {
if (!repeaterPrefixesData?.data) return [];
// Generate all possible 2-character hex prefixes (01-FE, excluding 00 and FF)
const allPrefixes = [];
for (let i = 1; i < 255; i++) {
allPrefixes.push(i.toString(16).padStart(2, '0').toUpperCase());
}
// Get used prefixes from the API response
const usedPrefixes = new Set(repeaterPrefixesData.data.map(row => row.prefix));
// Find unused prefixes
return allPrefixes.filter(prefix => !usedPrefixes.has(prefix));
}, [repeaterPrefixesData?.data]);
return {
data: unusedPrefixes,
isLoading,
error,
};
}