mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 11:02:24 +00:00
Drop the Observer section from Analytics (#111)
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 /> },
|
||||
];
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -78,7 +78,6 @@
|
||||
"scopes": "Scopes",
|
||||
"talkers": "Talkers",
|
||||
"clockdrift": "Clock Drift",
|
||||
"observer": "Observer",
|
||||
"compare": "Compare observers",
|
||||
"graph": "Neighbour Graph"
|
||||
},
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
"scopes": "Scopes",
|
||||
"talkers": "Émetteurs",
|
||||
"clockdrift": "Dérive d’horloge",
|
||||
"observer": "Observateur",
|
||||
"compare": "Comparer les observateurs",
|
||||
"graph": "Graphe des voisins"
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user