Drop the Observer section from Analytics (#111)

This commit is contained in:
MrAlders0n authored and GitHub committed 2026-09-30 16:09:13 -04:00
1 parent fb0994bd05
commit e2d272e08e
9 files changed
+25 -109

No files matched your search

@@ -1,44 +0,0 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ObserverTab } from "./ObserverTab";
import { ObserverPicker } from "../observers/ObserverPicker";
import { ObserverSidebar } from "../observers/ObserverSidebar";
import { useObserver } from "./useTelemetry";
import { useTopObservers } from "./useStats";
import type { WsManager } from "../../api/ws-manager";
import type { StatsRange } from "./types";
interface Props {
range: StatsRange;
selectedObserverId: string | null;
onSelectObserver: (id: string) => void;
wsManager: WsManager;
}
export function ObserverAnalyticsTab({ range, selectedObserverId, onSelectObserver, wsManager }: Props) {
const { t } = useTranslation();
const top = useTopObservers(range, 15);
const selected = useObserver(selectedObserverId, true);
const selectedName = selected.data?.displayName ?? selectedObserverId?.slice(0, 8) ?? t("observerPage.choose");
// default to the busiest observer once the list loads and nothing is selected
useEffect(() => {
if (selectedObserverId) return;
const first = top.data?.[0];
if (first) onSelectObserver(first.observerId);
}, [selectedObserverId, top.data, onSelectObserver]);
return (
<div className="mx-auto flex h-full w-full max-w-[1600px] flex-col lg:flex-row">
<div className="px-4 pt-4 lg:hidden">
<ObserverPicker id={selectedObserverId ?? ""} name={selectedName} label={t("tabs.Observers")} onSelect={onSelectObserver} />
</div>
<div className="hidden min-h-0 w-[260px] shrink-0 flex-col py-4 pl-4 lg:flex">
<ObserverSidebar range={range} selectedId={selectedObserverId} onSelect={onSelectObserver} />
</div>
<div className="min-w-0 flex-1 lg:overflow-y-auto">
<ObserverTab range={range} selectedObserverId={selectedObserverId} onSelectObserver={onSelectObserver} wsManager={wsManager} />
</div>
</div>
);
}
+14 -11
View File
@@ -1,5 +1,5 @@
import { useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import { useCallback, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import type { WsManager } from "../../api/ws-manager";
import { StatsSubHeader } from "./StatsSubHeader";
import { MeshTab } from "./MeshTab";
@@ -9,12 +9,12 @@ import { PathsTab } from "./PathsTab";
import { ScopesTab } from "./ScopesTab";
import { TalkersTab } from "./TalkersTab";
import { ClockDriftTab } from "./ClockDriftTab";
import { ObserverAnalyticsTab } from "./ObserverAnalyticsTab";
import { CompareObserversTab } from "./CompareObserversTab";
import { NeighbourGraphTab } from "./NeighbourGraphTab";
import { observerDestination } from "../observers/observer-navigation";
import type { StatsRange, StatsTab } from "./types";
const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"];
const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "compare", "graph"];
const RANGES: StatsRange[] = ["24h", "7d", "30d"];
const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh");
@@ -24,13 +24,14 @@ interface StatsOverviewProps {
wsManager: WsManager;
}
// Stats page shell: an analytics sub-header and range over the active sub-tab. Sub-tab, range and the
// selected observer live in the URL (?statsTab/?range/?observerId) so the view is shareable.
// Stats page shell: an analytics sub-header and range over the active sub-tab. Sub-tab and range live
// in the URL (?statsTab/?range) so the view is shareable.
export function StatsOverview({ wsManager }: StatsOverviewProps) {
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const tab = asTab(params.get("statsTab"));
const range = asRange(params.get("range"));
const observerId = params.get("observerId");
const legacyObserver = params.get("statsTab") === "observer";
const patch = useCallback(
(updates: Record<string, string | null>) => {
@@ -51,7 +52,12 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
const handleTab = useCallback((t: StatsTab) => patch({ statsTab: t }), [patch]);
const handleRange = useCallback((r: StatsRange) => patch({ range: r }), [patch]);
const handleSelectObserver = useCallback((id: string) => patch({ statsTab: "observer", observerId: id }), [patch]);
const handleSelectObserver = useCallback((id: string) => navigate({ search: "?" + observerDestination(params, id, range).toString() }), [navigate, params, range]);
// The observer dashboard moved to the Observers tab; old ?statsTab=observer links land there.
useEffect(() => {
if (legacyObserver) navigate({ search: "?" + observerDestination(params, params.get("observerId"), range).toString() }, { replace: true });
}, [legacyObserver, navigate, params, range]);
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
@@ -64,9 +70,6 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
{tab === "scopes" && <ScopesTab />}
{tab === "talkers" && <TalkersTab range={range} />}
{tab === "clockdrift" && <ClockDriftTab />}
{tab === "observer" && (
<ObserverAnalyticsTab range={range} selectedObserverId={observerId} onSelectObserver={handleSelectObserver} wsManager={wsManager} />
)}
{tab === "graph" && <NeighbourGraphTab />}
{tab === "compare" && <CompareObserversTab />}
</div>
-10
View File
@@ -34,15 +34,6 @@ function MeshIcon() {
);
}
function ObserverIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden>
<circle cx="7" cy="9.5" r="1.4" />
<path d="M7 8V4M4.5 6.5a3.5 3.5 0 0 1 5 0M2.7 4.7a6 6 0 0 1 8.6 0" strokeOpacity="0.85" />
</svg>
);
}
function SignalIcon() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden>
<path d="M1 7h2l2-4 3.5 8L11 5l1 2h1" strokeLinecap="round" strokeLinejoin="round" />
@@ -95,7 +86,6 @@ const TAB_OPTIONS = [
{ value: "scopes", icon: <ScopesIcon /> },
{ value: "talkers", icon: <TalkersIcon /> },
{ value: "clockdrift", icon: <ClockDriftIcon /> },
{ value: "observer", icon: <ObserverIcon /> },
{ value: "compare", icon: <CompareIcon /> },
{ value: "graph", icon: <GraphIcon /> },
];
+1 -1
View File
@@ -165,7 +165,7 @@ export interface ObserverActivity {
}
// Sub-tab + time-range identifiers shared across the Stats page.
export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph";
export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "compare" | "graph";
export type StatsRange = "24h" | "7d" | "30d";
export const RANGE_MS: Record<StatsRange, number> = {
-1
View File
@@ -78,7 +78,6 @@
"scopes": "Scopes",
"talkers": "Talkers",
"clockdrift": "Clock Drift",
"observer": "Observer",
"compare": "Compare observers",
"graph": "Neighbour Graph"
},
-1
View File
@@ -79,7 +79,6 @@
"scopes": "Scopes",
"talkers": "Émetteurs",
"clockdrift": "Dérive d’horloge",
"observer": "Observateur",
"compare": "Comparer les observateurs",
"graph": "Graphe des voisins"
},
+7 -8
View File
@@ -103,24 +103,23 @@ describe("observer investigation return", () => {
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1");
await browserBack(); expect(screen.getByLabelText("Route filter")).toHaveValue("keep");
});
it("opens the Analytics observer tab from the leaderboard", async () => {
it("opens the Observers dashboard from the leaderboard", async () => {
await import("../src/features/stats/StatsOverview");
window.history.replaceState({}, "", "/?tab=Analytics&statsTab=mesh&range=24h");
render(<App />);
await screen.findByRole("button", { name: "Leaderboard observer" });
click("Leaderboard observer");
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 24h");
expect(window.location.search).toContain("tab=Analytics");
expect(window.location.search).toContain("statsTab=observer");
expect(window.location.search).toContain("observerId=o1");
expect(screen.queryByRole("button", { name: "Back to Analytics" })).not.toBeInTheDocument();
expect(window.location.search).toContain("tab=Observers");
expect(window.location.search).toContain("observer=o1");
expect(window.location.search).not.toContain("statsTab");
});
it("keeps shared Analytics observer links in Analytics", async () => {
it("redirects legacy Analytics observer links to the Observers dashboard", async () => {
window.history.replaceState({}, "", "/?tab=Analytics&statsTab=observer&observerId=o2&range=7d");
render(<App />);
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o2 7d");
expect(window.location.search).toContain("statsTab=observer");
expect(window.location.search).not.toContain("tab=Observers");
expect(window.location.search).toContain("tab=Observers");
expect(window.location.search).not.toContain("statsTab");
});
it("does not invent a return destination for a reloaded/shared dashboard", async () => {
window.history.replaceState({ usr: { beaconObserverReturnKey: "old" } }, "", "/?tab=Observers&observer=o2&range=24h");
+2 -3
View File
@@ -6,14 +6,13 @@ import i18n from "../../../src/i18n";
afterEach(() => vi.restoreAllMocks());
describe("translated analytics controls", () => {
it("keeps archived analytics ranges and the Observer destination", async () => {
it("keeps archived analytics ranges and has no Observer section", async () => {
const props = { onTabChange: vi.fn(), onRangeChange: vi.fn() };
const { rerender } = render(<StatsSubHeader {...props} tab="mesh" range="7d" />);
expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByRole("button", { name: "3d" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Observer", exact: true }));
expect(props.onTabChange).toHaveBeenCalledWith("observer");
expect(screen.queryByRole("button", { name: "Observer", exact: true })).not.toBeInTheDocument();
await act(() => i18n.changeLanguage("fr"));
fireEvent.click(screen.getByRole("button", { name: "7 j" }));
expect(props.onRangeChange).toHaveBeenCalledWith("7d");
@@ -1,7 +1,6 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { expect, it, vi } from "vitest";
import { StatsOverview } from "../../../src/features/stats/StatsOverview";
import type { WsManager } from "../../../src/api/ws-manager";
@@ -61,31 +60,3 @@ it("opens Scopes without a misleading range control and preserves the chosen ran
expect(screen.getByText("Traffic range 30d")).toBeInTheDocument();
expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW");
});
describe("Observer analytics tab", () => {
const top = [
{ observerId: "o1", displayName: "Alpha", observationCount: 90 },
{ observerId: "o2", displayName: "Beta", observationCount: 30 },
];
beforeEach(() => {
vi.doMock("../../../src/features/stats/useStats", async (orig) => ({ ...(await orig<object>()), useTopObservers: () => ({ data: top, isLoading: false }) }));
vi.doMock("../../../src/hooks/useRegion", async (orig) => ({ ...(await orig<object>()), useRegion: () => ({ iatas: null, regionKey: "*" }) }));
vi.doMock("../../../src/features/observers/ObserverSidebar", () => ({ ObserverSidebar: ({ onSelect }: { onSelect: (id: string) => void }) => (
<><input type="search" aria-label="Search observers" />{top.map((o) => <button key={o.observerId} onClick={() => onSelect(o.observerId)}>{o.displayName}</button>)}</>
) }));
vi.doMock("../../../src/features/stats/ObserverTab", () => ({ ObserverTab: ({ selectedObserverId, range }: { selectedObserverId: string | null; range: string }) => <h1>Dashboard {selectedObserverId} {range}</h1> }));
});
afterEach(() => { vi.doUnmock("../../../src/features/observers/ObserverSidebar"); vi.doUnmock("../../../src/features/stats/useStats"); vi.doUnmock("../../../src/hooks/useRegion"); vi.doUnmock("../../../src/features/stats/ObserverTab"); vi.resetModules(); });
it("lists observers in a sidebar, defaults to the busiest and switches on click", async () => {
vi.resetModules();
const { StatsOverview: Overview } = await import("../../../src/features/stats/StatsOverview");
const client = new QueryClient();
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={["/?tab=Analytics&statsTab=observer&range=24h"]}><Overview wsManager={{} as WsManager} /><Location /></MemoryRouter></QueryClientProvider>);
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 24h");
expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("observerId=o1");
fireEvent.click(screen.getByRole("button", { name: /Beta/ }));
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o2 24h");
expect(screen.getByRole("searchbox", { name: "Search observers" })).toBeInTheDocument();
});
});