From 04374aad57227b0320b52192f6ac770806ba7216 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 30 Sep 2026 13:24:41 -0400 Subject: [PATCH] Bring back the Analytics observer tab, move page notes into info tips, tidy analytics and the phone header --- src/App.tsx | 8 +- src/components/AppShell.tsx | 161 ++++++++++++------ src/components/InfoTip.tsx | 23 +++ src/components/LanguagePicker.tsx | 45 +++-- src/components/Tooltip.tsx | 6 +- src/features/observers/ObserverComparison.tsx | 7 +- src/features/observers/ObserverPicker.tsx | 117 ++++++++++--- src/features/observers/ObserverSummary.tsx | 11 +- src/features/stats/CompareObserversTab.tsx | 67 +++----- src/features/stats/MeshTab.tsx | 2 +- src/features/stats/NeighbourGraphTab.tsx | 8 +- src/features/stats/ObserverAnalyticsTab.tsx | 128 ++++++++++++++ src/features/stats/ObserverTab.tsx | 5 +- src/features/stats/PathsTab.tsx | 13 +- src/features/stats/ScopesTab.tsx | 7 +- src/features/stats/SignalTab.tsx | 13 +- src/features/stats/StatsOverview.tsx | 20 +-- src/features/stats/StatsSubHeader.tsx | 10 ++ src/features/stats/TalkersTab.tsx | 2 +- src/features/stats/TrafficTab.tsx | 7 +- src/features/stats/cards.tsx | 2 +- src/features/stats/types.ts | 2 +- src/i18n/locales/en.json | 10 +- src/i18n/locales/fr.json | 10 +- tests/App.observerInvestigation.test.tsx | 21 ++- tests/components/AppShell.test.tsx | 20 +++ tests/components/InfoTip.test.tsx | 21 +++ .../observers/ObserverComparison.test.tsx | 2 +- .../observers/ObserverPicker.test.tsx | 16 +- .../stats/CompareObserversTab.test.tsx | 37 ++-- tests/features/stats/ScopesTab.test.tsx | 3 +- tests/features/stats/StatsSubHeader.test.tsx | 5 +- tests/features/stats/TrafficTab.test.tsx | 4 + .../stats/analytics-navigation.test.tsx | 28 ++- 34 files changed, 614 insertions(+), 227 deletions(-) create mode 100644 src/components/InfoTip.tsx create mode 100644 src/features/stats/ObserverAnalyticsTab.tsx create mode 100644 tests/components/InfoTip.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 02c5975d..deeac026 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -160,8 +160,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: // unknown ?tab value falls back to Packets instead of rendering a blank pane. // "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working. const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab"); - const legacyObserver = tabParam === "Analytics" && searchParams.get("statsTab") === "observer"; - const activeTab = legacyObserver ? "Observers" : ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS.includes("Packets") ? "Packets" : ENABLED_TABS[0] ?? "Packets"); + const activeTab = ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS.includes("Packets") ? "Packets" : ENABLED_TABS[0] ?? "Packets"); // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); @@ -170,9 +169,6 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: const analyzerHash = searchParams.get("analyze") === "1" ? searchParams.get("hash") : null; const [selectedObservationId, setSelectedObservationId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(() => searchParams.get("node")); - useEffect(() => { - if (legacyObserver) setSearchParams(observerDestination(searchParams, searchParams.get("observerId")), { replace: true }); - }, [legacyObserver, searchParams, setSearchParams]); const [panels, setPanels] = useState([]); const scene = JSON.stringify([activeTab, searchParams.get("route"), searchParams.get("hash"), searchParams.get("node"), searchParams.get("observer"), searchParams.get("analyze")]); const [panelScene, setPanelScene] = useState(scene); @@ -319,7 +315,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses Traces: { if (hash) viewPacket(hash); }} onViewNode={viewNode} />, Channels: , - Analytics: , + Analytics: , Map: , }; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 77450882..6e72c43d 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -10,7 +10,8 @@ import { useRateLimit } from "../hooks/useRateLimit"; import { useTheme } from "../hooks/useTheme"; import { Dropdown } from "./Dropdown"; import { BottomNav } from "./BottomNav"; -import { LanguagePicker } from "./LanguagePicker"; +import { LanguageOptions, LanguagePicker } from "./LanguagePicker"; +import { useIsMobile } from "../hooks/useMediaQuery"; import { BeaconWordmark } from "./BeaconWordmark"; import { getIatas } from "../api/client"; import { ENABLED_TABS, ENABLED_THEME_IDS, selectableThemes, APP_NAME, GITHUB_URL } from "../lib/constants"; @@ -18,7 +19,7 @@ import type { WsManager } from "../api/ws-manager"; // header widgets: WS status, region picker, theme picker -function LiveBadge({ wsManager }: { wsManager: WsManager }) { +function LiveBadge({ wsManager, compact = false }: { wsManager: WsManager; compact?: boolean }) { const { t } = useTranslation(); const { status } = useWsStatus(wsManager); const [staleStr, setStaleStr] = useState(""); @@ -35,6 +36,11 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) { }, [status, wsManager]); if (status === "connected") { + if (compact) return ( + + + + ); return (
@@ -306,9 +312,8 @@ function RegionSelectorPanel() { function ThemePicker() { const { t } = useTranslation(); - const { themeId, themes, setThemeId } = useTheme(); + const { themeId, themes } = useTheme(); const current = themes.find((t) => t.id === themeId); - const list = selectableThemes(themes, ENABLED_THEME_IDS); return ( ▾ )} + > + {(close) => } + + ); +} + +function ThemeOptions({ onPick }: { onPick?: () => void }) { + const { themeId, themes, setThemeId } = useTheme(); + return ( + <> + {selectableThemes(themes, ENABLED_THEME_IDS).map((theme) => ( + + ))} + + ); +} + +function GitHubLink() { + return ( + + + + ); +} + +const MENU_GROUP = "px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-wide text-text-dim"; + +// Phones fold theme, language and the repo link into one menu so the header stays a single row. +function SettingsMenu() { + const { t } = useTranslation(); + return ( + ( + + )} > {(close) => ( <> - {list.map((t) => ( - - ))} +
{t("language.label")}
+ +
{t("theme.label")}
+ +
+ + {APP_NAME} v{__APP_VERSION__} +
)}
@@ -367,31 +427,36 @@ interface AppShellProps { export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShellProps) { const { t } = useTranslation(); + const isMobile = useIsMobile(); return (
-
- -
- - - -
-
- - - - - -
-
+ {isMobile ? ( +
+ +
+ +
+
+ + + +
+
+ ) : ( +
+ +
+ + + +
+
+ + + +
+
+ )}
)} ); } + +export function LanguageOptions({ onPick }: { onPick?: () => void }) { + const { i18n } = useTranslation(); + const language = i18n.resolvedLanguage ?? "en"; + return ( + <> + {languages.map(({ code, name }) => ( + + ))} + + ); +} diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index 7d6c838e..267f38be 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -4,7 +4,7 @@ import { useHasHover } from "../hooks/useMediaQuery"; // Portals to with fixed positioning so overflow parents (the data tables) can't clip it. // Hover reveals with a mouse; on touch it toggles on tap and dismisses on an outside tap. -export function Tooltip({ label, children, className = "" }: { label: ReactNode; children: ReactNode; className?: string }) { +export function Tooltip({ label, children, className = "", wrap = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean }) { const hasHover = useHasHover(); const ref = useRef(null); const tipRef = useRef(null); @@ -60,6 +60,8 @@ export function Tooltip({ label, children, className = "" }: { label: ReactNode; onMouseEnter={hasHover ? show : undefined} onMouseLeave={hasHover ? hide : undefined} onClick={hasHover ? undefined : toggle} + onFocus={hasHover ? show : undefined} + onBlur={hasHover ? hide : undefined} className={`inline-flex ${className}`} > {children} @@ -69,7 +71,7 @@ export function Tooltip({ label, children, className = "" }: { label: ReactNode; ref={tipRef} role="tooltip" style={{ left: pos.left, top: pos.top }} - className="fixed z-50 pointer-events-none whitespace-nowrap rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg" + className={`fixed z-50 pointer-events-none ${wrap ? "max-w-xs whitespace-normal leading-relaxed" : "whitespace-nowrap"} rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg`} > {label} , diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx index bbfe6100..9fa407b2 100644 --- a/src/features/observers/ObserverComparison.tsx +++ b/src/features/observers/ObserverComparison.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import { InfoTip } from "../../components/InfoTip"; import { getObserverComparison } from "../../api/client"; import { ACTION_BUTTON_CLASS } from "../../components/action-button"; import { formatBattery, formatUptime } from "../../lib/formatters"; @@ -82,16 +83,14 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u

A: {observerA.displayName ?? observerA.id} {b.data && <>· B: {b.data.displayName ?? b.data.id}}

{until == null ?

{t("observerCompare.invalidTime")}

: !valid ?

{t(observerBId ? "observerCompare.invalidObserver" : "observerCompare.choose")}

: b.isError || activityB.isError ?

{t("observerPage.loadFailed")}

: !activityA || activityB.isPending || b.isPending ?

{t("common.loading")}

: !aligned ?

{t("observerCompare.unavailable")}

: <> -

{t("observerPage.window")}: {date(start)} – {date(end)}. {t("observerCompare.currentNote")}

+

{t("observerPage.window")}: {date(start)} – {date(end)}

{rows.map(([label, a, bValue]) => )}
{t("observerCompare.metric")}AB
{label}{a}{bValue}
- -

{t("observerCompare.retention")}

+ }> {overlap.isError ?

{t("common.loadFailed")}

: !overlap.data ?

{t("common.loading")}

: <>

{t("observerCompare.total", { count: overlap.data.totalPackets })}

{overlap.data.totalPackets === 0 ?

{t("observerCompare.empty")}

:
{groups.map(g =>
)}
}
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}
-

{t("observerCompare.definition")}

} } diff --git a/src/features/observers/ObserverPicker.tsx b/src/features/observers/ObserverPicker.tsx index c15d3e0d..c374d82b 100644 --- a/src/features/observers/ObserverPicker.tsx +++ b/src/features/observers/ObserverPicker.tsx @@ -1,33 +1,100 @@ -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getObserversPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; -import { SearchBar } from "../../components/SearchBar"; -import { SelectDropdown } from "../../components/SelectDropdown"; +import { Dropdown } from "../../components/Dropdown"; -export function ObserverPicker({ id, name, onSelect, excludeId, label }: { id: string; name: string; onSelect: (id: string) => void; excludeId?: string; label?: string }) { +function PickerPanel({ id, onPick, excludeId, label }: { id: string; onPick: (id: string) => void; excludeId?: string; label: string }) { const { t } = useTranslation(); const { iatas, regionKey } = useRegion(); const [query, setQuery] = useState(""); - const options = useQuery({ queryKey: ["observer-picker", regionKey, query.trim()], queryFn: () => getObserversPage(iatas, { name: query.trim() || undefined, limit: 50 }), staleTime: 30_000 }); - const rows = (options.data?.items ?? []).filter(o => o.id !== excludeId); - return
-
- {}} value={query} onChange={setQuery} /> - o.id === id) ? [{ value: id, label: name }] : []), - ...rows.map(o => ({ value: o.id, label: `${o.displayName ?? o.id.slice(0, 8)} · ${o.iata}` })), - ]} - /> -
- {options.isError ? :

{options.isFetching ? t("common.loading") : t("observerPage.searchHelp")}

} -
; + // debounce so the server-side lookup fires once per pause, not once per keystroke + const [q, setQ] = useState(""); + useEffect(() => { + const timer = setTimeout(() => setQ(query.trim()), 250); + return () => clearTimeout(timer); + }, [query]); + const options = useQuery({ + queryKey: ["observer-picker", regionKey, q], + queryFn: () => getObserversPage(iatas, { name: q || undefined, limit: 50 }), + staleTime: 30_000, + placeholderData: keepPreviousData, + }); + const rows = (options.data?.items ?? []).filter((o) => o.id !== excludeId); + const status = options.isPending ? t("common.loading") : options.isError ? null : rows.length === 0 ? t("observerPage.noMatches") : null; + + return ( + <> +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && rows[0]) { e.preventDefault(); onPick(rows[0].id); } + }} + aria-label={`${t("observerPage.sidebarSearch")} · ${label}`} + placeholder={`${t("observerPage.sidebarSearch")}…`} + className="w-full rounded border border-border bg-bg-base px-2 py-1 font-mono text-base text-text-normal placeholder:text-text-dim sm:text-[12px]" + /> +
+
+ {rows.map((o) => { + const selected = o.id === id; + return ( + + ); + })} +
+ {status &&
{status}
} + {options.isError && ( + + )} + + ); +} + +// One field that opens a searchable list; the search runs server-side across the whole region. +export function ObserverPicker({ id, name, onSelect, excludeId, label }: { id: string; name: string; onSelect: (id: string) => void; excludeId?: string; label?: string }) { + const { t } = useTranslation(); + const title = label ?? t("observerPage.choose"); + return ( + ( + + )} + > + {(close) => { onSelect(next); close(); }} />} + + ); } diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx index e7f5fdab..30862f78 100644 --- a/src/features/observers/ObserverSummary.tsx +++ b/src/features/observers/ObserverSummary.tsx @@ -6,6 +6,7 @@ import { useTick } from "../../hooks/useTick"; import { observerNoiseFloor } from "./observer-stats"; import { Card, StatCard } from "../stats/cards"; import { Field } from "../../components/DetailPanel"; +import { IataChip } from "../../components/IataChip"; import type { Observer } from "./types"; import type { ObserverActivity, TelemetryPoint } from "../stats/types"; @@ -26,22 +27,22 @@ export function ObserverSummary({ observer, activity, points, pending = false }: return <>

{observer.displayName ?? observer.id.slice(0, 8)}

-
+
- + - {observer.iata} + {observer.iata}
-
    +
      {cards.map(([key, value]) => { const note = key === "lastHour" && summary ? `${formatUtc(summary.lastCompleteHourStart, { timeOnly: true })}–${formatUtc(summary.lastCompleteHourEnd, { timeOnly: true })} UTC` : key === "noise" && noise != null ? t(reportedNoise != null ? "observerPage.latestStatus" : "observerPage.latestTelemetry") : undefined; return
    • - {note &&

      {note}

      } +

      {note}

    • ; })}
    diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx index 2956a376..f2bd7967 100644 --- a/src/features/stats/CompareObserversTab.tsx +++ b/src/features/stats/CompareObserversTab.tsx @@ -1,14 +1,17 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { useState, type FormEvent } from "react"; import { useQuery } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { getObserver, getObserverComparison, getObserversPage } from "../../api/client"; +import { InfoTip } from "../../components/InfoTip"; +import { getObserver, getObserverComparison } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; import { OBSERVER_UUID } from "../observers/observer-id"; +import { ObserverPicker } from "../observers/ObserverPicker"; import { Card } from "./cards"; type Selection = { observerA: string; observerB: string; since: number; until: number }; -const fieldClass = "min-w-0 w-full rounded border border-border bg-bg-base px-2.5 py-2 text-base text-text-normal"; +const fieldClass = "min-w-0 w-full max-w-full appearance-none rounded border border-border bg-bg-base px-2.5 py-1.5 font-mono text-base text-text-normal sm:text-[12px]"; +const labelClass = "font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted"; function validation(value: Selection): string | null { if (!OBSERVER_UUID.test(value.observerA) || !OBSERVER_UUID.test(value.observerB) || value.observerA.toLowerCase() === value.observerB.toLowerCase()) { @@ -25,39 +28,16 @@ function localTime(ms: number) { return new Date(ms - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 23); } -function ObserverSelect({ label, value, onChange }: { label: string; value: string; onChange: (id: string) => void }) { - const { iatas, regionKey } = useRegion(); - const [search, setSearch] = useState(""); - const [name, setName] = useState(""); - useEffect(() => { - const timer = setTimeout(() => setName(search.trim()), 250); - return () => clearTimeout(timer); - }, [search]); - const options = useQuery({ - queryKey: ["comparison-observers", regionKey, name], - queryFn: () => getObserversPage(iatas, { name: name || undefined, limit: 50 }), - staleTime: 30_000, - }); +function ObserverSelect({ label, value, onChange, excludeId }: { label: string; value: string; onChange: (id: string) => void; excludeId?: string }) { const selected = useQuery({ queryKey: ["observer", value], queryFn: () => getObserver(value), enabled: OBSERVER_UUID.test(value), staleTime: 30_000, retry: false, }); - const rows = options.data?.items ?? []; return ( -
    - {label} - setSearch(e.target.value)} className={fieldClass} /> - -

    - {options.isPending ? "Loading observers…" : options.isError ? "Observer search failed. Try again." : options.data?.hasMore ? "Showing 50 observers. Search to find others." : rows.length === 0 ? "No observers match this search." : "Search includes all matching observers in the selected region."} -

    - {options.isError && } -
    +
    + {label} + +
    ); } @@ -76,19 +56,19 @@ function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onC if (!message) onCompare(value); } return ( -
    -
    - - -