Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/features/stats/CompareObserversTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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} />
Expand Down
6 changes: 2 additions & 4 deletions src/features/stats/PathsTab.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}`)} />
Expand Down
3 changes: 2 additions & 1 deletion src/features/stats/ScopesTab.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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>
Expand Down
10 changes: 10 additions & 0 deletions src/features/stats/SectionInfo.tsx
Original file line number Diff line number Diff line change
@@ -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} />;
}
6 changes: 2 additions & 4 deletions src/features/stats/SignalTab.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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}`)} />
Expand Down
8 changes: 6 additions & 2 deletions src/features/stats/StatsOverview.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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>) => {
Expand Down Expand Up @@ -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} />}
Expand All @@ -74,6 +77,7 @@ export function StatsOverview({ wsManager, onViewNode }: StatsOverviewProps) {
{tab === "graph" && <NeighbourGraphTab />}
{tab === "compare" && <CompareObserversTab />}
</div>
</SectionInfoSlot.Provider>
</div>
);
}
6 changes: 5 additions & 1 deletion src/features/stats/StatsSubHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 2 additions & 4 deletions src/features/stats/TrafficTab.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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}`)} />
Expand Down
3 changes: 3 additions & 0 deletions src/features/stats/section-info-slot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createContext } from "react";

export const SectionInfoSlot = createContext<HTMLElement | null>(null);
5 changes: 0 additions & 5 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
5 changes: 0 additions & 5 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion tests/features/stats/CompareObserversTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 2 additions & 5 deletions tests/features/stats/PathsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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" />);
Expand Down
2 changes: 1 addition & 1 deletion tests/features/stats/ScopesTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
23 changes: 23 additions & 0 deletions tests/features/stats/SectionInfo.test.tsx
Original file line number Diff line number Diff line change
@@ -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/ }));
});
Loading
Loading