mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 17:41:23 +00:00
12 files changed
+51
-23
No files matched your search
Generated
+2
-2
@@ -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
@@ -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
@@ -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[]> {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user