Merge pull request #156 from MeshCore-Beacon/dev

Release v2.0.4
This commit is contained in:
MrAlders0n authored and GitHub committed 2026-10-10 09:57:55 -04:00
commit 08fa3f1f0e
12 files changed
+51 -23

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "beacon-web",
"version": "2.0.3",
"version": "2.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beacon-web",
"version": "2.0.3",
"version": "2.0.4",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nazka/map-gl-js-spiderfy": "^2.0.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "beacon-web",
"private": true,
"version": "2.0.3",
"version": "2.0.4",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
+3 -2
View File
@@ -13,6 +13,7 @@ import type {
PayloadBreakdownItem,
TopNode,
TopObserver,
AdvertiserSort,
TopAdvertiser,
TopTalker,
RadioPreset,
@@ -420,8 +421,8 @@ export function getTopObservers(iatas?: StatsRegion, since?: number, limit = 10)
return request("/stats/top-observers", { ...statsRegionParams(iatas), since, limit });
}
export function getTopAdvertisers(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopAdvertiser[]> {
return request("/stats/top-advertisers", { ...statsRegionParams(iatas), since, limit });
export function getTopAdvertisers(iatas: StatsRegion | undefined, since: number | undefined, sort: AdvertiserSort, limit = 10): Promise<TopAdvertiser[]> {
return request("/stats/top-advertisers", { ...statsRegionParams(iatas), since, sort, limit });
}
export function getTopTalkers(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopTalker[]> {
+14 -4
View File
@@ -1,15 +1,16 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useChartColors } from "./chartTheme";
import { rolledWindow, useTopAdvertisers, useTopTalkers } from "./useStats";
import { leaderboardOption } from "./chartOptions";
import { Card, ChartCard } from "./cards";
import { Segmented } from "./Segmented";
import { DataTable, type Column } from "../../components/DataTable";
import { Badge } from "../../components/Badge";
import { IataChip } from "../../components/IataChip";
import { formatCount, formatRatePerDay } from "../../lib/formatters";
import { RANGE_MS } from "./types";
import type { TopAdvertiser, StatsRange } from "./types";
import type { AdvertiserSort, TopAdvertiser, StatsRange } from "./types";
interface TalkersTabProps {
range: StatsRange;
@@ -32,7 +33,8 @@ function coveredMs(range: StatsRange, fetchedAt: number) {
export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
const { t } = useTranslation();
const colors = useChartColors();
const topAdvertisers = useTopAdvertisers(range, 20);
const [advertSort, setAdvertSort] = useState<AdvertiserSort>("flood");
const topAdvertisers = useTopAdvertisers(range, advertSort, 20);
const topTalkers = useTopTalkers(range, 20);
const advertisersLoading = topAdvertisers.isPending || topAdvertisers.isPlaceholderData;
@@ -76,7 +78,15 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
const rangeLabel = t(`stats.ranges.${range}`);
return (
<div className="mx-auto grid w-full min-w-0 max-w-[1200px] grid-cols-1 items-start gap-3.5 p-4 lg:grid-cols-2">
<Card title={t("talkers.topAdvertisers", { range: rangeLabel })} right={<span className="font-mono text-[10px] text-text-muted">{t("talkers.floodDirect")}</span>}>
<Card title={t("talkers.topAdvertisers", { range: rangeLabel })} right={
<Segmented
size="xs"
ariaLabel={t("talkers.sortBy")}
value={advertSort}
onChange={(v) => setAdvertSort(v as AdvertiserSort)}
options={[{ value: "flood", label: t("talkers.flood") }, { value: "direct", label: t("talkers.direct") }]}
/>
}>
<div className="flex flex-col" style={{ height: leaderboardHeight(advertisers.length) }}>
<DataTable
columns={advertiserColumns}
+2
View File
@@ -77,6 +77,8 @@ export interface TopObserver {
observationCount: number;
}
export type AdvertiserSort = "flood" | "direct";
export interface TopAdvertiser {
nodeId: string | null; // null once the node row is deleted; key rows by publicKey
publicKey: string;
+4 -4
View File
@@ -14,7 +14,7 @@ import {
getClockDrift,
type StatsRegion,
} from "../../api/client";
import { RANGE_MS, type StatsRange } from "./types";
import { RANGE_MS, type AdvertiserSort, type StatsRange } from "./types";
// Shared query options: cache for 30s; previous data stays as placeholder so tabs can decide whether to show it during a switch.
const common = {
@@ -102,11 +102,11 @@ export function useTopObservers(range: StatsRange, limit = 10) {
});
}
export function useTopAdvertisers(range: StatsRange, limit = 10) {
export function useTopAdvertisers(range: StatsRange, sort: AdvertiserSort, limit = 10) {
const { where, regionKey, isResolved } = useStatsRegion();
return useQuery({
queryKey: ["stats-top-advertisers", regionKey, range, limit],
...gatedOn(isResolved, () => getTopAdvertisers(where, sinceFor(range), limit)),
queryKey: ["stats-top-advertisers", regionKey, range, sort, limit],
...gatedOn(isResolved, () => getTopAdvertisers(where, sinceFor(range), sort, limit)),
...common,
});
}
+1 -1
View File
@@ -815,7 +815,7 @@
"flood": "Flood",
"direct": "Direct",
"topAdvertisers": "Top advertisers · {{range}}",
"floodDirect": "flood · direct",
"sortBy": "Sort advertisers by",
"noAdvertisers": "No advertisers",
"topTalkers": "Top talkers · {{range}}",
"byName": "by name"
+1 -1
View File
@@ -847,7 +847,7 @@
"flood": "Flood",
"direct": "Direct",
"topAdvertisers": "Principaux annonceurs · {{range}}",
"floodDirect": "flood · direct",
"sortBy": "Trier les annonceurs par",
"noAdvertisers": "Aucun annonceur",
"topTalkers": "Principaux émetteurs · {{range}}",
"byName": "par nom"
+1 -1
View File
@@ -815,7 +815,7 @@
"flood": "Flood",
"direct": "Direkt",
"topAdvertisers": "Mest aktiva annonsörer · {{range}}",
"floodDirect": "flood · direkt",
"sortBy": "Sortera annonsörer efter",
"noAdvertisers": "Inga annonsörer",
"topTalkers": "Mest aktiva sändare · {{range}}",
"byName": "efter namn"
+3 -2
View File
@@ -431,15 +431,16 @@ describe("stats endpoints", () => {
expect(url.searchParams.get("limit")).toBe("15");
});
it("hits /stats/top-advertisers with iatas/since/limit", async () => {
it("hits /stats/top-advertisers with iatas/since/sort/limit", async () => {
const getUrl = mockFetchOnce([]);
await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, 10);
await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, "direct", 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("sort")).toBe("direct");
expect(url.searchParams.get("limit")).toBe("10");
});
+18 -4
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TalkersTab } from "../../../src/features/stats/TalkersTab";
import { getTopAdvertisers, getTopTalkers } from "../../../src/api/client";
@@ -59,7 +59,8 @@ it.each(["region", "range"] as const)("hides previous results while the new %s i
await screen.findByText("New sender");
const request = vi.mocked(getTopAdvertisers).mock.calls[1]!;
expect(request[0]).toEqual(filter === "region" ? ["YOW"] : ["YVR"]);
expect(request[2]).toBe(20);
expect(request[2]).toBe("flood");
expect(request[3]).toBe(20);
const windowMs = (filter === "range" ? 7 : 1) * 86_400_000;
expect(request[1]).toBeGreaterThanOrEqual(requestedAt - windowMs);
expect(request[1]).toBeLessThanOrEqual(Date.now() - windowMs);
@@ -73,7 +74,7 @@ it("shows an advertiser refetch error instead of cached rows, keeps the sender p
await screen.findByText("Failed to load");
expect(screen.queryByText("Old advertiser")).not.toBeInTheDocument();
expect(screen.getByText("Old sender")).toBeInTheDocument();
expect(client.getQueryData(["stats-top-advertisers", "YVR", "24h", 20])).toEqual(advertisers);
expect(client.getQueryData(["stats-top-advertisers", "YVR", "24h", "flood", 20])).toEqual(advertisers);
vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, nodeName: "Recovered advertiser" }]);
await act(() => client.refetchQueries({ queryKey: ["stats-top-advertisers"] }));
await screen.findByText("Recovered advertiser");
@@ -103,7 +104,7 @@ it("retains valid rows and rates during an ordinary background refresh", async (
vi.mocked(getTopAdvertisers).mockReturnValue(next.promise);
let refresh!: Promise<void>;
await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-top-advertisers"] }); });
expect(client.getQueryState(["stats-top-advertisers", "YVR", "24h", 20])?.fetchStatus).toBe("fetching");
expect(client.getQueryState(["stats-top-advertisers", "YVR", "24h", "flood", 20])?.fetchStatus).toBe("fetching");
expect(screen.getByText("Old advertiser")).toBeInTheDocument();
expect(screen.getByText("10/d")).toBeInTheDocument();
expect(screen.getByText("4/d")).toBeInTheDocument();
@@ -158,3 +159,16 @@ it("divides advert rates by the hours the rollup actually covers", async () => {
expect(await screen.findByText("21/d")).toBeInTheDocument();
expect(screen.getByText("4.2/d")).toBeInTheDocument();
});
it("asks the server for flood-ranked advertisers by default and refetches direct-ranked from the toggle", async () => {
mount();
await screen.findByText("Old advertiser");
expect(vi.mocked(getTopAdvertisers).mock.calls[0]!.slice(2)).toEqual(["flood", 20]);
const sort = screen.getByRole("group", { name: "Sort advertisers by" });
expect(within(sort).getByRole("button", { name: "Flood" })).toHaveAttribute("aria-pressed", "true");
vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, nodeName: "Direct advertiser" }]);
fireEvent.click(within(sort).getByRole("button", { name: "Direct" }));
await screen.findByText("Direct advertiser");
expect(vi.mocked(getTopAdvertisers).mock.calls[1]!.slice(2)).toEqual(["direct", 20]);
expect(within(sort).getByRole("button", { name: "Direct" })).toHaveAttribute("aria-pressed", "true");
});
+1 -1
View File
@@ -22,7 +22,7 @@ const hooks = [
["usePayloadBreakdown", () => usePayloadBreakdown("24h"), client.getPayloadBreakdown],
["useTopNodes", () => useTopNodes("24h"), client.getTopNodes],
["useTopObservers", () => useTopObservers("24h"), client.getTopObservers],
["useTopAdvertisers", () => useTopAdvertisers("24h"), client.getTopAdvertisers],
["useTopAdvertisers", () => useTopAdvertisers("24h", "flood"), client.getTopAdvertisers],
["useTopTalkers", () => useTopTalkers("24h"), client.getTopTalkers],
["useRadioPresets", () => useRadioPresets(), client.getRadioPresets],
["useNodeTypes", () => useNodeTypes(), client.getStatsNodeTypes],