diff --git a/src/api/client.ts b/src/api/client.ts index 116ca58..6cac33a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -9,6 +9,8 @@ import type { PayloadBreakdownItem, TopNode, TopObserver, + TopAdvertiser, + TopTalker, RadioPreset, ScopeStats, ObserverTelemetry, @@ -282,6 +284,14 @@ export function getTopObservers(iatas?: string[], since?: number, limit = 10): P return request("/stats/top-observers", { iatas: iatasParam(iatas), since, limit }); } +export function getTopAdvertisers(iatas?: string[], since?: number, limit = 10): Promise { + return request("/stats/top-advertisers", { iatas: iatasParam(iatas), since, limit }); +} + +export function getTopTalkers(iatas?: string[], since?: number, limit = 10): Promise { + return request("/stats/top-talkers", { iatas: iatasParam(iatas), since, limit }); +} + export function getRadioPresets(iatas?: string[]): Promise { return request("/stats/radio-presets", { iatas: iatasParam(iatas) }); } diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 47a5585..3a1bbf9 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { formatCount } from "../../lib/formatters"; import { useChartColors, nodeTypeColor } from "./chartTheme"; -import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; +import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useTopAdvertisers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions"; import { Card, ChartCard, StatCard } from "./cards"; import { useLiveOverview } from "./useLiveStats"; @@ -37,6 +37,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const payload = usePayloadBreakdown(range); const topNodes = useTopNodes(10); const topObservers = useTopObservers(range, 8); + const topAdvertisers = useTopAdvertisers(range, 10); const radioPresets = useRadioPresets(); const scopes = useScopes(); const nodeTypes = useNodeTypes(); @@ -55,6 +56,17 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { ); const nodesOption = useMemo(() => leaderboardOption(nodeRows, colors), [nodeRows, colors]); + const advertiserRows = useMemo( + () => + (topAdvertisers.data ?? []).map((a) => ({ + name: a.nodeName ?? a.nodeId.slice(0, 8), + value: a.advertCount, + color: nodeTypeColor(a.nodeTypeName, colors), + })), + [topAdvertisers.data, colors], + ); + const advertisersOption = useMemo(() => leaderboardOption(advertiserRows, colors), [advertiserRows, colors]); + const payloadItems = useMemo( () => (payload.data ?? []) @@ -140,6 +152,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { isError={payload.isError} isEmpty={payloadItems.length === 0} /> + Top advertisers · {range}} height={208} option={advertisersOption} isLoading={topAdvertisers.isLoading} isError={topAdvertisers.isError} isEmpty={advertiserRows.length === 0} /> {/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 9397ada..b9c689b 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -3,11 +3,12 @@ import { useSearchParams } from "react-router-dom"; import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; +import { TalkersTab } from "./TalkersTab"; import { ObserverTab } from "./ObserverTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "observer", "graph"]; +const TABS: StatsTab[] = ["mesh", "talkers", "observer", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -52,6 +53,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
{tab === "mesh" && } + {tab === "talkers" && } {tab === "observer" && ( )} diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 7853f9a..9917d31 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -23,6 +23,15 @@ function ObserverIcon() { ); } +function TalkersIcon() { + return ( + + + + + ); +} + function GraphIcon() { return ( @@ -38,6 +47,7 @@ function GraphIcon() { const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, + { value: "talkers", label: "Talkers", icon: }, { value: "observer", label: "Observer", icon: }, { value: "graph", label: "Neighbour Graph", icon: }, ]; diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx new file mode 100644 index 0000000..e427ecb --- /dev/null +++ b/src/features/stats/TalkersTab.tsx @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import { useChartColors } from "./chartTheme"; +import { useTopTalkers } from "./useStats"; +import { leaderboardOption } from "./chartOptions"; +import { ChartCard } from "./cards"; +import type { StatsRange } from "./types"; + +interface TalkersTabProps { + range: StatsRange; +} + +// Top talkers by decrypted channel-message count. Grouped by sender display-name (see TopTalker), +// hence the "by name" caption — its own tab so the leaderboard can breathe and later grow. +export function TalkersTab({ range }: TalkersTabProps) { + const colors = useChartColors(); + const topTalkers = useTopTalkers(range, 20); + + const rows = useMemo( + () => (topTalkers.data ?? []).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })), + [topTalkers.data, colors], + ); + const option = useMemo(() => leaderboardOption(rows, colors), [rows, colors]); + // grow with the roster so bars stay readable; a floor keeps the loading/empty state from collapsing + const height = Math.max(260, rows.length * 34 + 24); + + return ( +
+ Top talkers · {range}} + right={by name} + height={height} + option={option} + isLoading={topTalkers.isLoading} + isError={topTalkers.isError} + isEmpty={rows.length === 0} + /> +
+ ); +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index a517233..6bf24fc 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -40,6 +40,23 @@ export interface TopObserver { observationCount: number; } +export interface TopAdvertiser { + nodeId: string; + nodeName: string | null; + nodeType: number; + nodeTypeName: string; + iata: string; + advertCount: number; + lastHeard: number; // epoch ms +} + +// grouped by decrypted sender display-name, not node identity: same-named pubkeys merge, a rename splits +export interface TopTalker { + senderName: string; + messageCount: number; + lastSent: number; // epoch ms +} + export interface RadioPreset { preset: string; // "freqMhz,bwKhz,sf" e.g. "910.525,62.5,7" iata: string; @@ -78,7 +95,7 @@ export interface ObserverTelemetry { } // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "observer" | "graph"; +export type StatsTab = "mesh" | "talkers" | "observer" | "graph"; export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index 836f3db..9f61c0b 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -6,6 +6,8 @@ import { getPayloadBreakdown, getTopNodes, getTopObservers, + getTopAdvertisers, + getTopTalkers, getRadioPresets, getStatsScopes, getStatsNodeTypes, @@ -71,6 +73,24 @@ export function useTopObservers(range: StatsRange, limit = 10) { }); } +export function useTopAdvertisers(range: StatsRange, limit = 10) { + const { iatas, regionKey } = useRegion(); + return useQuery({ + queryKey: ["stats-top-advertisers", regionKey, range, limit], + queryFn: () => getTopAdvertisers(iatas, sinceFor(range), limit), + ...common, + }); +} + +export function useTopTalkers(range: StatsRange, limit = 10) { + const { iatas, regionKey } = useRegion(); + return useQuery({ + queryKey: ["stats-top-talkers", regionKey, range, limit], + queryFn: () => getTopTalkers(iatas, sinceFor(range), limit), + ...common, + }); +} + export function useRadioPresets() { const { iatas, regionKey } = useRegion(); return useQuery({ diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index 0cdb141..0a9e127 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getStatsNodeTypes } from "../../src/api/client"; +import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes } from "../../src/api/client"; import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types"; @@ -381,6 +381,30 @@ describe("stats endpoints", () => { expect(url.searchParams.get("limit")).toBe("15"); }); + it("hits /stats/top-advertisers with iatas/since/limit", async () => { + const getUrl = mockFetchOnce([]); + + await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, 10); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/top-advertisers"); + expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); + expect(url.searchParams.get("since")).toBe("1700000000000"); + expect(url.searchParams.get("limit")).toBe("10"); + }); + + it("hits /stats/top-talkers with iatas/since/limit", async () => { + const getUrl = mockFetchOnce([]); + + await getTopTalkers(["YOW"], 1700000000000, 8); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/top-talkers"); + expect(url.searchParams.get("iatas")).toBe("YOW"); + expect(url.searchParams.get("since")).toBe("1700000000000"); + expect(url.searchParams.get("limit")).toBe("8"); + }); + it("hits /stats/node-types with the region's IATAs", async () => { const getUrl = mockFetchOnce([{ nodeType: 2, nodeTypeName: "repeater", count: 12 }]);