Skip to content
Open
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
46 changes: 24 additions & 22 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import { PacketPathMapModal } from "./features/map/PacketPathMapModal";
import { NodeTable } from "./features/nodes/NodeTable";
import { NodeDetailPanel } from "./features/nodes/NodeDetailPanel";
import { NodeDetailOverlay } from "./features/nodes/NodeDetailOverlay";
import { ObserverTable } from "./features/observers/ObserverTable";
import { ObserverPage } from "./features/observers/ObserverPage";
import { ObserverDetailPanel } from "./features/observers/ObserverDetailPanel";
import { observerDestination } from "./features/observers/observer-navigation";
import { ModalOverlay } from "./components/ModalOverlay";
import { RouteTable } from "./features/routes/RouteTable";
import { TraceList } from "./features/traces/TraceList";
import { ChannelList } from "./features/channels/ChannelList";
Expand Down Expand Up @@ -149,13 +152,15 @@ function TabLoading({ tab }: { tab: string }) {
}

function AppInner() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const isMobile = useIsMobile();
// The URL is the single source of truth for the active tab — back/forward just work, and an
// 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 activeTab = ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets");
const legacyObserver = tabParam === "Analytics" && searchParams.get("statsTab") === "observer";
const activeTab = legacyObserver ? "Observers" : ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets");
// Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection).
const [initialSelection] = useState(() => computeInitialSelection(searchParams));

Expand All @@ -165,7 +170,10 @@ function AppInner() {
const [selectedObservationId, setSelectedObservationId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(() => searchParams.get("node"));
// lifted (like selectedNodeId) so a node's "View observer" link can select it before the tab mounts
const [selectedObserverId, setSelectedObserverId] = useState<string | null>(() => searchParams.get("observer"));
const [quickObserverId, setQuickObserverId] = useState<string | null>(null);
useEffect(() => {
if (legacyObserver) setSearchParams(observerDestination(searchParams, searchParams.get("observerId")), { replace: true });
}, [legacyObserver, searchParams, setSearchParams]);
// node detail shown as a modal over the packet analyzer (e.g. clicking a resolved path hop)
const [overlayNodeId, setOverlayNodeId] = useState<string | null>(null);
// packet analyzer shown as a modal over the node panel (clicking a node's observation row)
Expand Down Expand Up @@ -210,15 +218,16 @@ function AppInner() {
if (isMobile) {
setSelectedObservationId(null);
setSelectedNodeId(null);
setSelectedObserverId(null);
setQuickObserverId(null);
}
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", tab);
// the analyzer is URL-backed, so its mobile close lives here rather than above
if (isMobile) next.delete("analyze");
if (tab !== "Observers") next.delete("observer");
// stats sub-state shouldn't haunt the URL on other tabs
if (tab !== "Analytics") {
if (tab !== "Analytics" && tab !== "Observers") {
next.delete("statsTab");
next.delete("observerId");
next.delete("range");
Expand All @@ -231,7 +240,7 @@ function AppInner() {
setSelectedNodeId(null);
setOverlayNodeId(null);
setOverlayPacketHash(null);
setSelectedObserverId(null);
setQuickObserverId(null);
setPathMapDetail(null);
}, []);

Expand All @@ -251,22 +260,15 @@ function AppInner() {
dropSelectionParam("node");
}, [dropSelectionParam]);

const handleSelectObserver = useCallback((id: string | null) => {
setSelectedObserverId(id);
if (id === null) dropSelectionParam("observer");
}, [dropSelectionParam]);

// Jump from an observer's detail panel to its telemetry on the Stats tab (Stats → Observer, preselected).
const handleViewObserverStats = useCallback(
(id: string) => {
setOverlayNodeId(null);
setOverlayPacketHash(null);
setQuickObserverId(null);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", "Analytics");
next.set("statsTab", "observer");
next.set("observerId", id);
return next;
return observerDestination(next, id);
});
},
[setSearchParams],
Expand All @@ -291,7 +293,7 @@ function AppInner() {
/>
),
Nodes: <NodeTable wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
Observers: <ObserverTable wsManager={wsManager} selectedObserverId={selectedObserverId} onSelectObserver={handleSelectObserver} onAnalyzePacket={setOverlayPacketHash} onViewStats={handleViewObserverStats} />,
Observers: <ObserverPage wsManager={wsManager} />,
Routes: <RouteTable />,
// analyze opens the packet overlay (modal) rather than the side drawer, which suits the
// master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses
Expand Down Expand Up @@ -335,8 +337,7 @@ function AppInner() {
nodeId={selectedNodeId}
onClose={handleCloseNode}
onViewObserver={(observerId) => {
handleTabChange("Observers");
setSelectedObserverId(observerId);
setQuickObserverId(observerId);
}}
onViewNode={setSelectedNodeId}
onAnalyzePacket={setOverlayPacketHash}
Expand All @@ -347,8 +348,7 @@ function AppInner() {
nodeId={overlayNodeId}
onClose={() => setOverlayNodeId(null)}
onViewObserver={(observerId) => {
handleTabChange("Observers");
setSelectedObserverId(observerId);
setQuickObserverId(observerId);
}}
onViewNode={setOverlayNodeId}
/>
Expand All @@ -359,13 +359,15 @@ function AppInner() {
loading={overlayPacketLoading}
onClose={() => setOverlayPacketHash(null)}
onViewObserver={(observerId) => {
handleTabChange("Observers");
setSelectedObserverId(observerId);
setQuickObserverId(observerId);
}}
onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }}
inactive={!!pathMapDetail}
/>
)}
{quickObserverId && <ModalOverlay label={t("tabs.Observers")} onClose={() => setQuickObserverId(null)}>
<ObserverDetailPanel observerId={quickObserverId} onClose={() => setQuickObserverId(null)} onViewStats={handleViewObserverStats} onAnalyzePacket={setOverlayPacketHash} />
</ModalOverlay>}
{pathMapDetail && (
<PacketPathMapModal
detail={pathMapDetail}
Expand Down
6 changes: 4 additions & 2 deletions src/features/observers/ObserverDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import type { Observer, AdvertObservation } from "./types";
import { useQuery } from "@tanstack/react-query";
import { getObserver, getObserverAdverts } from "../../api/client";
Expand Down Expand Up @@ -106,6 +107,7 @@ interface ObserverDetailPanelProps {
}

export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onViewStats }: ObserverDetailPanelProps) {
const { t } = useTranslation();
const { data: observer, isLoading } = useQuery({
queryKey: ["observer", observerId],
queryFn: () => getObserver(observerId),
Expand Down Expand Up @@ -155,7 +157,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi
<CopyButton value={observer.publicKey} ariaLabel="Copy public key" className="shrink-0" />
</div>
<div className="flex items-center gap-3 font-mono text-[13px]">
<Field label="Observations" value={observer.observationCount.toLocaleString()} />
<Field label={t("observerPage.legacy")} value={observer.observationCount.toLocaleString()} />
</div>
<div className="flex flex-wrap items-center gap-2 mt-1.5">
{observer.observerType && <Badge variant="default">{observer.observerType}</Badge>}
Expand All @@ -173,7 +175,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M4 20V4M4 20h16M8 16v-4M13 16V8M18 16v-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Statistics
{t("observerPage.open")}
</button>
)}
</Section>
Expand Down
41 changes: 41 additions & 0 deletions src/features/observers/ObserverPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { lazy, Suspense, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { CopyButton } from "../../components/CopyButton";
import { ObserverTable } from "./ObserverTable";
import { observerDestination, observerRange } from "./observer-navigation";
import type { WsManager } from "../../api/ws-manager";
const ObserverTab = lazy(() => import("../stats/ObserverTab").then(m => ({ default: m.ObserverTab })));

export function ObserverPage({ wsManager }: { wsManager: WsManager }) {
const [params, setParams] = useSearchParams();
const { t } = useTranslation();
const id = params.get("observer");
const [directoryVisited, setDirectoryVisited] = useState(!id);
if (!id && !directoryVisited) setDirectoryVisited(true);
const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString();
const range = observerRange(params.get("range"));
const select = (observer: string | null) => setParams(observerDestination(params, observer, range));
return <div className="flex min-h-0 min-w-0 flex-1 flex-col">
{/* Keep the directory mounted so Back restores filters, sorting and scroll. */}
{directoryVisited && <div className={id ? "hidden" : "flex min-h-0 flex-1"} aria-hidden={id ? true : undefined}>
<ObserverTable wsManager={wsManager} selectedObserverId={null} onSelectObserver={select} />
</div>}
{id && <div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-3 border-b border-border px-4 py-2">
<button type="button" onClick={() => select(null)} className="min-h-11 text-sm text-primary">← {t("observerPage.back")}</button>
<CopyButton value={share.toString()} label={t("observerPage.copyLink")} copiedLabel={t("observerPage.copied")} />
<label className="ml-auto flex items-center gap-2 text-sm text-text-muted">{t("observerPage.range")}
<select aria-label={t("observerPage.range")} value={range} onChange={e => setParams(observerDestination(params, id, observerRange(e.target.value)))} className="min-h-11 rounded border border-border bg-bg-raised px-3 text-text-normal">
<option value="24h">24h</option><option value="7d">7d</option><option value="30d">30d</option>
</select>
</label>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<Suspense fallback={<p role="status" className="p-4">{t("common.loading")}</p>}>
<ObserverTab range={range} selectedObserverId={id} onSelectObserver={select} wsManager={wsManager} />
</Suspense>
</div>
</div>}
</div>;
}
25 changes: 25 additions & 0 deletions src/features/observers/ObserverPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getObserversPage } from "../../api/client";
import { useRegion } from "../../hooks/useRegion";

export function ObserverPicker({ id, name, onSelect }: { id: string; name: string; onSelect: (id: string) => void }) {
const { t } = useTranslation();
const { iatas, regionKey } = useRegion();
const [text, setText] = useState("");
const [query, setQuery] = useState("");
useEffect(() => { const timer = setTimeout(() => setQuery(text.trim()), 250); return () => clearTimeout(timer); }, [text]);
const options = useQuery({ queryKey: ["observer-picker", regionKey, query], queryFn: () => getObserversPage(iatas, { name: query || undefined, limit: 50 }), staleTime: 30_000 });
const rows = options.data?.items ?? [];
return <div className="min-w-0 space-y-1 text-sm">
<div className="flex flex-wrap gap-2">
<input type="search" aria-label={t("observerPage.search")} placeholder={t("observerPage.search")} value={text} onChange={e => setText(e.target.value)} className="min-h-11 min-w-0 flex-1 rounded border border-border bg-bg-base px-3 text-text-normal" />
<select aria-label={t("observerPage.choose")} value={id} onChange={e => onSelect(e.target.value)} className="min-h-11 min-w-0 max-w-full flex-1 rounded border border-border bg-bg-base px-3 text-text-normal">
{!rows.some(o => o.id === id) && <option value={id}>{name}</option>}
{rows.map(o => <option key={o.id} value={o.id}>{o.displayName ?? o.id.slice(0, 8)} · {o.iata}</option>)}
</select>
</div>
{options.isError ? <button type="button" onClick={() => void options.refetch()} className="text-danger">{t("common.loadFailed")} · {t("observerPage.retry")}</button> : <p className="text-xs text-text-muted">{options.isFetching ? t("common.loading") : t("observerPage.searchHelp")}</p>}
</div>;
}
61 changes: 61 additions & 0 deletions src/features/observers/ObserverSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useTranslation } from "react-i18next";
import { Timestamp } from "../../components/Timestamp";
import { CopyButton } from "../../components/CopyButton";
import { formatBattery, formatRadioParts, formatUptime } from "../../lib/formatters";
import { useTick } from "../../hooks/useTick";
import type { Observer } from "./types";
import type { ObserverActivity, TelemetryPoint } from "../stats/types";

export function ObserverSummary({ observer, activity, points, pending = false }: { observer: Observer; activity?: ObserverActivity; points: TelemetryPoint[]; pending?: boolean }) {
const { t } = useTranslation(); const now = useTick();
const summary = activity?.summary;
const statusFresh = observer.lastStatusAt != null && now - observer.lastStatusAt < 300_000;
const lastArrival = Math.max(0, ...observer.brokers.map(b => b.lastPacketAt || 0));
const trafficKnown = !!summary && lastArrival > 0;
const trafficFresh = trafficKnown && now - lastArrival < 300_000;
const stats = observer.statusMetadata?.stats;
const reportedNoise = stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? stats.noise_floor : null;
const noise = reportedNoise ?? points.at(-1)?.noiseFloorDb;
const cards = [
["records", summary?.recordedPackets.toLocaleString() ?? "—"],
["lastHour", summary?.lastCompleteHour.toLocaleString() ?? "—"],
["lastPacket", summary?.latestRecordedAt != null ? <Timestamp value={summary.latestRecordedAt} /> : "—"],
["battery", observer.batteryLevel != null ? formatBattery(observer.batteryLevel) : "—"],
["uptime", observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—"],
["noise", noise != null && Number.isFinite(noise) ? `${noise} dBm` : "—"],
] as const;
return <>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0"><h1 className="break-words text-2xl font-semibold text-text-bright">{observer.displayName ?? observer.id.slice(0, 8)}</h1>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<span className={`rounded px-2 py-1 ${trafficFresh ? "bg-green/10 text-green" : "bg-bg-raised text-text-muted"}`}>{t(`observerPage.${!trafficKnown ? "trafficUnknown" : trafficFresh ? "trafficRecent" : "trafficQuiet"}`)}</span>
<span className={`rounded px-2 py-1 ${statusFresh ? "bg-green/10 text-green" : "bg-warn/10 text-warn"}`}>{t(`observerPage.${observer.lastStatusAt == null ? "statusMissing" : statusFresh ? "statusRecent" : "statusStale"}`)}</span>
<span className="rounded bg-primary/10 px-2 py-1 text-primary">{observer.iata}</span>
</div>
</div>
</div>
<ul role="list" aria-label={t("observerPage.metrics")} className="grid grid-cols-2 gap-3 xl:grid-cols-6">
{cards.map(([key, value]) => <li key={key} className="min-w-0 rounded-lg border border-border bg-bg-surface p-3.5">
<div className="text-xs font-medium text-text-muted">{t(`observerPage.${key}`)}</div>
<div className="mt-2 break-words font-mono text-xl font-semibold tabular-nums text-text-bright">{value}</div>
{key === "lastHour" && summary && <div className="mt-1 text-xs text-text-muted">{new Date(summary.lastCompleteHourStart).toISOString().slice(11, 16)}–{new Date(summary.lastCompleteHourEnd).toISOString().slice(11, 16)} UTC</div>}
{key === "noise" && noise != null && <div className="mt-1 text-xs text-text-muted">{t(reportedNoise != null ? "observerPage.latestStatus" : "observerPage.latestTelemetry")}</div>}
</li>)}
</ul>
{!summary && !pending && <p className="text-sm text-text-muted">{t("observerPage.summaryMissing")}</p>}
</>;
}

export function ObserverDeviceDetails({ observer }: { observer: Observer }) {
const { t } = useTranslation();
const radio = formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr });
return <details className="rounded-lg border border-border bg-bg-surface p-4">
<summary className="cursor-pointer text-sm font-semibold text-text-normal">{t("observerPage.details")}</summary>
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
{([ ["model", observer.hardwareModel], ["firmware", observer.firmwareVersion], ["client", observer.softwareVersion], ["radio", radio] ] as const).map(([key, value]) => <div key={key}><dt className="text-text-muted">{t(`observerPage.${key}`)}</dt><dd className="break-words text-text-normal">{value ?? "—"}</dd></div>)}
<div className="min-w-0 sm:col-span-2"><dt className="text-text-muted">{t("observerPage.publicKey")}</dt><dd className="flex items-center gap-2"><code className="min-w-0 break-all text-xs">{observer.publicKey}</code><CopyButton value={observer.publicKey} label={t("observerPage.copy")} copiedLabel={t("observerPage.copied")} /></dd></div>
<div><dt className="text-text-muted">{t("observerPage.firstSeen")}</dt><dd><Timestamp value={observer.firstSeen} /></dd></div>
</dl>
<ul className="mt-4 space-y-2 text-sm">{observer.brokers.map(b => <li key={b.name} className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border pt-2"><strong>{b.name}</strong><span>{t("observerPage.presence")}: <Timestamp value={b.lastSeenAt} /></span><span>{t("observerPage.packetArrival")}: {b.lastPacketAt > 0 ? <Timestamp value={b.lastPacketAt} /> : "—"}</span></li>)}</ul>
</details>;
}
12 changes: 12 additions & 0 deletions src/features/observers/observer-navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { StatsRange } from "../stats/types";

export const observerRange = (value: string | null): StatsRange => value === "24h" || value === "30d" ? value : "7d";

export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) {
const next = new URLSearchParams(params);
next.set("tab", "Observers");
for (const key of ["statsTab", "observerId", "node", "hash", "analyze", "path"]) next.delete(key);
if (id) { next.set("observer", id); next.set("range", range); }
else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); }
return next;
}
Loading
Loading