Drop analytics refresh buttons and repeated titles (#131)

This commit is contained in:
MrAlders0n
2026-10-01 18:58:26 -04:00
committed by GitHub
parent bdf253cbe6
commit f9ffb564c7
17 changed files with 69 additions and 50 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { InfoTip } from "../../components/InfoTip";
import { SectionInfo } from "./SectionInfo";
import { getObserver, getObserverComparison } from "../../api/client";
import { useRegion } from "../../hooks/useRegion";
import { OBSERVER_UUID } from "../observers/observer-id";
@@ -125,7 +126,7 @@ export function CompareObserversTab() {
] : [];
return (
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="flex items-center gap-2"><h2 className="text-lg font-semibold text-text-bright">{t("observerCompare.title")}</h2><InfoTip text={t("observerCompare.retainedWindow")} /></div>
<SectionInfo text={t("observerCompare.retainedWindow")} />
<Card title={t("observerCompare.selection")}>
{supplied && !valid && <p role="alert" className="mb-3 text-sm text-danger">{t("observerCompare.invalidLink")}</p>}
<ComparisonForm key={keys.map((key) => params.get(key)).join("|")} initial={selection} onCompare={compare} />
+2 -4
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { InfoTip } from "../../components/InfoTip";
import { SectionInfo } from "./SectionInfo";
import { formatCount, formatUtc } from "../../lib/formatters";
import { Card, ChartCard, StatCard } from "./cards";
import { donutOption } from "./chartOptions";
@@ -35,10 +36,7 @@ export function PathsTab({ range }: { range: StatsRange }) {
const largest = data?.pathLengths.at(-1)?.entries;
return <div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2"><h2 className="text-lg font-semibold text-text-bright">{t("stats.tabs.paths")}</h2><InfoTip text={[t("paths.subtitle"), t("paths.measurement"), data && t("paths.window", { since: formatUtc(data.since), until: formatUtc(data.until) })]} /></div>
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching || query.isPending} className="rounded border border-border px-3 py-1.5 text-xs text-text-normal hover:bg-bg-raised disabled:opacity-50">{t("paths.refresh")}</button>
</div>
<SectionInfo text={[t("paths.subtitle"), t("paths.measurement"), data && t("paths.window", { since: formatUtc(data.since), until: formatUtc(data.until) })]} />
{query.isError && <p role="alert" className="text-sm text-danger">{t("paths.error")}</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label={t("paths.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={t(`stats.ranges.${range}`)} />
+2 -1
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { InfoTip } from "../../components/InfoTip";
import { SectionInfo } from "./SectionInfo";
import { useScopes } from "./useStats";
import { useChartColors } from "./chartTheme";
import { Card, ChartCard, StatCard } from "./cards";
@@ -25,7 +26,7 @@ export function ScopesTab() {
return (
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="flex items-center gap-2"><h2 className="text-lg font-semibold text-text-bright">{t("scopes.title")}</h2><InfoTip text={[t("scopes.subtitle"), t("scopes.measurement")]} /></div>
<SectionInfo text={[t("scopes.subtitle"), t("scopes.measurement")]} />
<label className="flex max-w-sm flex-col gap-1 text-xs text-text-muted">{t("scopes.search")}
<input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="#bc, #east…" className="rounded border border-border bg-bg-raised px-3 py-2 text-base text-text-bright outline-none focus:border-primary sm:text-sm" />
</label>
+10
View File
@@ -0,0 +1,10 @@
import { useContext, type ComponentProps } from "react";
import { createPortal } from "react-dom";
import { InfoTip } from "../../components/InfoTip";
import { SectionInfoSlot } from "./section-info-slot";
// Sits beside the section chooser in the analytics bar; a tab rendered outside it keeps the tip in place.
export function SectionInfo({ text }: ComponentProps<typeof InfoTip>) {
const slot = useContext(SectionInfoSlot);
return slot ? createPortal(<InfoTip text={text} />, slot) : <InfoTip text={text} />;
}
+2 -4
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { InfoTip } from "../../components/InfoTip";
import { SectionInfo } from "./SectionInfo";
import { formatCount, formatUtc } from "../../lib/formatters";
import { Card, ChartCard, StatCard } from "./cards";
import { useChartColors } from "./chartTheme";
@@ -25,10 +26,7 @@ export function SignalTab({ range }: { range: StatsRange }) {
return (
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2"><h2 className="text-lg font-semibold text-text-bright">{t("signal.title")}</h2><InfoTip text={[t("signal.subtitle"), t("signal.measurement"), data && t("signal.window", { since: formatUtc(data.since), until: formatUtc(data.until) })]} /></div>
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching || query.isPending} className="rounded border border-border px-3 py-1.5 text-xs text-text-normal hover:bg-bg-raised disabled:opacity-50">{t("signal.refresh")}</button>
</div>
<SectionInfo text={[t("signal.subtitle"), t("signal.measurement"), data && t("signal.window", { since: formatUtc(data.since), until: formatUtc(data.until) })]} />
{query.isError && <p role="alert" className="text-sm text-danger">{t("signal.error")}</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label={t("signal.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={t(`stats.ranges.${range}`)} />
+6 -2
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import type { WsManager } from "../../api/ws-manager";
import { StatsSubHeader } from "./StatsSubHeader";
import { SectionInfoSlot } from "./section-info-slot";
import { MeshTab } from "./MeshTab";
import { TrafficTab } from "./TrafficTab";
import { SignalTab } from "./SignalTab";
@@ -33,6 +34,7 @@ export function StatsOverview({ wsManager, onViewNode }: StatsOverviewProps) {
const tab = asTab(params.get("statsTab"));
const range = asRange(params.get("range"));
const legacyObserver = params.get("statsTab") === "observer";
const [infoSlot, setInfoSlot] = useState<HTMLElement | null>(null);
const patch = useCallback(
(updates: Record<string, string | null>) => {
@@ -62,7 +64,8 @@ export function StatsOverview({ wsManager, onViewNode }: StatsOverviewProps) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<StatsSubHeader tab={tab} onTabChange={handleTab} range={range} onRangeChange={handleRange} />
<StatsSubHeader tab={tab} onTabChange={handleTab} range={range} onRangeChange={handleRange} infoSlot={setInfoSlot} />
<SectionInfoSlot.Provider value={infoSlot}>
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "mesh" && <MeshTab range={range} onSelectObserver={handleSelectObserver} wsManager={wsManager} />}
{tab === "traffic" && <TrafficTab range={range} />}
@@ -74,6 +77,7 @@ export function StatsOverview({ wsManager, onViewNode }: StatsOverviewProps) {
{tab === "graph" && <NeighbourGraphTab />}
{tab === "compare" && <CompareObserversTab />}
</div>
</SectionInfoSlot.Provider>
</div>
);
}
+5 -1
View File
@@ -97,15 +97,17 @@ interface Props {
onTabChange: (tab: StatsTab) => void;
range: StatsRange;
onRangeChange: (range: StatsRange) => void;
infoSlot?: (element: HTMLElement | null) => void;
}
export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props) {
export function StatsSubHeader({ tab, onTabChange, range, onRangeChange, infoSlot }: Props) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const tabOptions = TAB_OPTIONS.map((option) => ({ ...option, label: t(`stats.tabs.${option.value}`) }));
const rangeOptions = RANGES.map((value) => ({ value, label: t(`stats.ranges.${value}`) }));
return (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border bg-bg-surface px-4 py-2.5">
<div className="flex min-w-0 items-center gap-2">
{/* pills don't scale on a phone as sections grow — swap to a compact dropdown there */}
{isMobile ? (
<SelectDropdown
@@ -127,6 +129,8 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props
/>
</div>
)}
<span ref={infoSlot} data-section-info className="flex shrink-0 empty:hidden" />
</div>
{/* Comparison has explicit dates; graph and clock drift have no rolling window. */}
{tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && tab !== "scopes" && (
<Segmented
+2 -4
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { InfoTip } from "../../components/InfoTip";
import { SectionInfo } from "./SectionInfo";
import { formatCount } from "../../lib/formatters";
import { useStatsObservations } from "./useStats";
import { tooltipStyle, useChartColors } from "./chartTheme";
@@ -26,10 +27,7 @@ export function TrafficTab({ range }: { range: StatsRange }) {
return (
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2"><h2 className="text-lg font-semibold text-text-bright">{t("stats.tabs.traffic")}</h2><InfoTip text={[t("traffic.subtitle"), t("traffic.measurement")]} /></div>
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching || query.isPending} className="rounded border border-border px-3 py-1.5 text-xs text-text-normal hover:bg-bg-raised disabled:opacity-50">{t("traffic.refresh")}</button>
</div>
<SectionInfo text={[t("traffic.subtitle"), t("traffic.measurement")]} />
{query.isError && <p role="alert" className="text-sm text-danger">{t("traffic.error")}</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label={t("traffic.receptions")} value={value(model.total)} accent={colors.primary} sublabel={t(`stats.ranges.${range}`)} />
+3
View File
@@ -0,0 +1,3 @@
import { createContext } from "react";
export const SectionInfoSlot = createContext<HTMLElement | null>(null);
-5
View File
@@ -110,9 +110,7 @@
}
},
"signal": {
"title": "RF / Signal",
"subtitle": "Signal strength of packets heard by observers.",
"refresh": "Refresh signal",
"error": "Could not load signal data. Try refreshing or choosing a shorter time period.",
"receptions": "Observations",
"meanSnr": "Mean SNR",
@@ -150,7 +148,6 @@
},
"paths": {
"subtitle": "How received packets carry route hashes.",
"refresh": "Refresh paths",
"error": "Could not load path data. Try refreshing or choosing a shorter time period.",
"receptions": "Observations",
"withHashPaths": "With hash paths",
@@ -196,7 +193,6 @@
},
"traffic": {
"subtitle": "When and where observers hear packets.",
"refresh": "Refresh traffic",
"error": "Could not load traffic. Try refreshing.",
"receptions": "Observations",
"reportingIatas": "Active areas",
@@ -226,7 +222,6 @@
"heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} observations"
},
"scopes": {
"title": "Transport scopes",
"subtitle": "Scope activity for the selected region.",
"search": "Find a scope",
"error": "Could not load scopes. Try refreshing.",
-5
View File
@@ -112,9 +112,7 @@
}
},
"signal": {
"title": "RF / Signal",
"subtitle": "Force du signal des paquets reçus par les observateurs.",
"refresh": "Actualiser le signal",
"error": "Impossible de charger les données du signal. Réessayez ou choisissez une période plus courte.",
"receptions": "Observations",
"meanSnr": "SNR moyen",
@@ -152,7 +150,6 @@
},
"paths": {
"subtitle": "Les hachages de route portés par les paquets reçus.",
"refresh": "Actualiser les chemins",
"error": "Impossible de charger les données des chemins. Réessayez ou choisissez une période plus courte.",
"receptions": "Observations",
"withHashPaths": "Avec chemins hachés",
@@ -199,7 +196,6 @@
},
"traffic": {
"subtitle": "Quand et où les observateurs reçoivent des paquets.",
"refresh": "Actualiser le trafic",
"error": "Impossible de charger le trafic. Réessayez.",
"receptions": "Observations",
"reportingIatas": "Zones actives",
@@ -230,7 +226,6 @@
"heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} observations"
},
"scopes": {
"title": "Scopes de transport",
"subtitle": "Activité des scopes pour la région sélectionnée.",
"search": "Rechercher un scope",
"error": "Impossible de charger les scopes. Réessayez.",
@@ -143,7 +143,7 @@ describe("observer comparison", () => {
mount(query);
const table = await screen.findByRole("table", { name: "Comparaison des paquets flood" });
expect(within(table).getByRole("row", { name: /A seulement.*1.*25.0%/ })).toBeInTheDocument();
expect(screen.getByText("Comparer les observateurs")).toBeInTheDocument();
expect(screen.queryByText("Comparer les observateurs")).not.toBeInTheDocument();
expect(screen.getByText("A : Rooftop · B : Hilltop")).toBeInTheDocument();
expect(screen.getByText(/4 paquets flood/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Comparer" })).toBeInTheDocument();
+2 -5
View File
@@ -16,7 +16,7 @@ beforeEach(() => { query.data = originalData; query.isPending = false; query.isP
it("translates classification and exact tables while preserving counts and the requested window", async () => {
await act(() => i18n.changeLanguage("fr"));
render(<PathsTab range="7d" />);
expect(screen.getByRole("heading", { name: "Chemins et hachages" })).toBeInTheDocument();
expect(screen.queryByRole("heading")).not.toBeInTheDocument();
expect(usePathStats).toHaveBeenCalledWith("7d");
expect(screen.getByText("7 j")).toBeInTheDocument();
expect(screen.getByText("66.7%")).toBeInTheDocument();
@@ -29,8 +29,6 @@ it("translates classification and exact tables while preserving counts and the r
expect(screen.getByRole("table", { name: "Effectifs par largeur de hachage" })).toHaveTextContent("Octets par hachage");
expect(within(screen.getByRole("table", { name: "Effectifs par nombre d’entrées" })).getByRole("row", { name: "0 20" })).toBeInTheDocument();
expect(within(screen.getByRole("table", { name: "Effectifs horaires des chemins" })).getByRole("row", { name: "1970-01-01 00:00 100 20 30 10 20 10 10" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Actualiser les chemins" }));
expect(query.refetch).toHaveBeenCalledOnce();
});
it("redraws all chart labels on language change and keeps exact details open", async () => {
@@ -58,8 +56,7 @@ it("uses only nonempty hash paths for multi-byte share, exposes categories and e
const table = screen.getByRole("table", { name: "Path classification counts" });
expect(within(table).getByRole("row", { name: /Hash paths.*60.*60.0%/ })).toBeInTheDocument();
expect(within(table).getByRole("row", { name: /Empty.*20.*20.0%/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh paths" }));
expect(query.refetch).toHaveBeenCalledOnce();
expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument();
});
it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides old values when %s", (state) => {
query[state] = true; render(<PathsTab range="7d" />);
+1 -1
View File
@@ -15,7 +15,7 @@ beforeEach(() => { vi.clearAllMocks(); query.data = originalData; query.isError
it("translates scope counts and membership guidance without changing scope names or values", async () => {
await act(() => i18n.changeLanguage("fr"));
render(<ScopesTab />);
expect(screen.getByRole("heading", { name: "Scopes de transport" })).toBeInTheDocument();
expect(screen.queryByRole("heading")).not.toBeInTheDocument();
const table = screen.getByRole("table", { name: "Effectifs des scopes" });
expect(within(table).getByRole("row", { name: /#west.*3.*2.*2/ })).toBeInTheDocument();
expect(within(table).getByRole("row", { name: /#east.*2.*2.*3/ })).toBeInTheDocument();
+23
View File
@@ -0,0 +1,23 @@
import { expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { StatsOverview } from "../../../src/features/stats/StatsOverview";
import { SectionInfo } from "../../../src/features/stats/SectionInfo";
import type { WsManager } from "../../../src/api/ws-manager";
vi.mock("../../../src/features/stats/ScopesTab", async () => {
const { SectionInfo } = await import("../../../src/features/stats/SectionInfo");
return { ScopesTab: () => <main><SectionInfo text="About scopes" /></main> };
});
it("shows a section's info beside the section chooser", () => {
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=scopes"]}><StatsOverview wsManager={{} as WsManager} /></MemoryRouter>);
const tip = screen.getByRole("button", { name: /About scopes/ });
expect(tip.closest("[data-section-info]")).toBeInTheDocument();
expect(screen.getByRole("main")).not.toContainElement(tip);
});
it("keeps the info in place outside the analytics bar", () => {
render(<main><SectionInfo text="Standalone" /></main>);
expect(screen.getByRole("main")).toContainElement(screen.getByRole("button", { name: /Standalone/ }));
});
+3 -6
View File
@@ -15,17 +15,15 @@ beforeEach(() => { query.data = originalData; query.isPending = false; query.isP
it("translates the signal view while retaining the requested window, true zero and exact counts", async () => {
await act(() => i18n.changeLanguage("fr"));
render(<SignalTab range="7d" />);
expect(screen.getByRole("button", { name: "Actualiser le signal" })).toBeInTheDocument();
expect(screen.queryByRole("heading")).not.toBeInTheDocument();
expect(useSignalStats).toHaveBeenCalledWith("7d");
expect(screen.getByText("0.0 dB")).toBeInTheDocument();
const table = screen.getByRole("table", { name: "Disponibilité des échantillons du signal" });
expect(within(table).getByRole("row", { name: /SNR.*80.*20.*80.0%/ })).toBeInTheDocument();
expect(screen.getByText(/1970-01-01 00:00.*1970-01-01 01:00.*UTC/)).toHaveTextContent("heure en cours exclue");
fireEvent.click(screen.getByRole("button", { name: "Actualiser le signal" }));
expect(query.refetch).toHaveBeenCalledOnce();
});
it("shows measured units, exact sample coverage and refresh without treating zero SNR as missing", () => {
it("shows measured units and exact sample coverage without treating zero SNR as missing", () => {
render(<SignalTab range="24h" />);
expect(useSignalStats).toHaveBeenCalledWith("24h");
expect(screen.getByText("0.0 dB")).toBeInTheDocument();
@@ -34,8 +32,7 @@ it("shows measured units, exact sample coverage and refresh without treating zer
const table = screen.getByRole("table", { name: "Signal sample availability" });
expect(within(table).getByRole("row", { name: /SNR.*80.*20.*80.0%/ })).toBeInTheDocument();
expect(within(table).getByRole("row", { name: /RSSI.*90.*10.*90.0%/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh signal" }));
expect(query.refetch).toHaveBeenCalledOnce();
expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument();
});
it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides previous filter values when %s", (state) => {
query[state] = true;
+5 -10
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { act, render, screen, within } from "@testing-library/react";
import { TrafficTab } from "../../../src/features/stats/TrafficTab";
import { useStatsObservations } from "../../../src/features/stats/useStats";
import { EChart } from "../../../src/features/stats/EChart";
@@ -19,7 +19,7 @@ describe("Traffic page", () => {
it("translates the page and exact table without changing reception counts, IATA codes or ranges", async () => {
await act(() => i18n.changeLanguage("fr"));
render(<TrafficTab range="7d" />);
expect(screen.getByRole("heading", { name: "Trafic" })).toBeInTheDocument();
expect(screen.queryByRole("heading")).not.toBeInTheDocument();
expect(useStatsObservations).toHaveBeenCalledWith("7d");
expect(screen.getByText("Activité horaire · 7 j")).toBeInTheDocument();
const table = screen.getByRole("table", { name: "Observations par zone" });
@@ -29,8 +29,6 @@ describe("Traffic page", () => {
expect(screen.getByText(/pas une panne/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /pas une panne/ })).toBeInTheDocument();
expect(screen.queryByText(/pas une panne/, { selector: "p" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Actualiser le trafic" }));
expect(query.refetch).toHaveBeenCalledOnce();
});
it("redraws grouped and unassigned chart labels on language change without altering data", async () => {
@@ -79,15 +77,14 @@ describe("Traffic page", () => {
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
it("shows exact reception counts, UTC/missing-history guidance and a refresh action", () => {
it("shows exact reception counts and UTC/missing-history guidance without a refresh action", () => {
render(<TrafficTab range="24h" />);
expect(useStatsObservations).toHaveBeenCalledWith("24h");
const table = screen.getByRole("table", { name: "Observations by area" });
expect(within(table).getByRole("row", { name: /YOW.*1,200.*100.0%/ })).toBeInTheDocument();
expect(screen.queryByText("9,999")).not.toBeInTheDocument();
expect(screen.getByText(/not unique packets/)).toHaveTextContent(/UTC.*outage/);
fireEvent.click(screen.getByRole("button", { name: "Refresh traffic" }));
expect(query.refetch).toHaveBeenCalledOnce();
expect(screen.queryByRole("button", { name: /Refresh/ })).not.toBeInTheDocument();
});
it("does not display previous-region data while a new query is loading", () => {
@@ -98,12 +95,10 @@ describe("Traffic page", () => {
expect(screen.queryAllByTestId("chart")).toHaveLength(0);
});
it("provides a visible error state and retry without stale chart values", () => {
it("provides a visible error state without stale chart values", () => {
query.isError = true;
render(<TrafficTab range="30d" />);
expect(screen.getByRole("alert")).toHaveTextContent("Could not load traffic");
expect(screen.queryByText("YOW")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh traffic" }));
expect(query.refetch).toHaveBeenCalledOnce();
});
});