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
17 changes: 15 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
}, { replace: true });
}, [setSearchParams]);

const handleTabChange = (tab: string) => {
const handleTabChange = (tab: string, mapFocus?: { lat: number; lng: number }) => {
setPanels([]);
dashboardFocus.current = null;
onExitVisit();
Expand All @@ -240,6 +240,13 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
// 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");
// lat/lng/zoom only seed the map on mount, so they'd re-frame it on every later visit
if (tab !== "Map" || mapFocus) for (const key of ["lat", "lng", "zoom"]) next.delete(key);
if (mapFocus) {
next.set("lat", mapFocus.lat.toFixed(5));
next.set("lng", mapFocus.lng.toFixed(5));
next.set("zoom", "14");
}
// stats sub-state shouldn't haunt the URL on other tabs
if (tab !== "Analytics" && tab !== "Observers") {
next.delete("statsTab");
Expand All @@ -250,6 +257,11 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
});
};

const handleViewOnMap = (nodeId: string, lat: number, lng: number) => {
handleTabChange("Map", { lat, lng });
if (!isMobile) setSelectedNodeId(nodeId);
};

const clearSelection = useCallback(() => {
setSelectedNodeId(null);
setPanels([]);
Expand Down Expand Up @@ -346,9 +358,10 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }:
onViewObserver={viewObserver}
onViewNode={setSelectedNodeId}
onAnalyzePacket={viewPacket}
onViewOnMap={activeTab === "Map" ? undefined : (lat, lng) => handleViewOnMap(selectedNodeId, lat, lng)}
/>
)}
{panels.map((panel, index) => <InvestigationPanel key={panel.key} target={panel.target} inactive={index !== panels.length - 1} onClose={() => closePanel(index)} onOpen={openPanel} onObserverDashboard={handleViewObserverStats} />)}
{panels.map((panel, index) => <InvestigationPanel key={panel.key} target={panel.target} inactive={index !== panels.length - 1} onClose={() => closePanel(index)} onOpen={openPanel} onObserverDashboard={handleViewObserverStats} onViewOnMap={activeTab === "Map" ? undefined : handleViewOnMap} />)}
</div>
{observerVisit && <Routes location={observerVisit}><Route path="*" element={<ObserverPage wsManager={wsManager} onReturn={onReturn} returnLabel={t(`tabs.${activeTab}`)} />} /></Routes>}
</div>
Expand Down
5 changes: 3 additions & 2 deletions src/features/InvestigationPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ export interface Investigation {
}

// Each panel stays mounted underneath the next one so its selection, scroll and trigger survive.
export function InvestigationPanel({ target, inactive, onClose, onOpen, onObserverDashboard }: {
export function InvestigationPanel({ target, inactive, onClose, onOpen, onObserverDashboard, onViewOnMap }: {
target: InvestigationTarget; inactive: boolean; onClose: () => void;
onOpen: (target: InvestigationTarget) => void; onObserverDashboard: (id: string) => void;
onViewOnMap?: (nodeId: string, lat: number, lng: number) => void;
}) {
const { t } = useTranslation();
const { data: detail, isLoading } = usePacketDetail(target.kind === "packet" ? target.hash : null);
Expand All @@ -35,7 +36,7 @@ export function InvestigationPanel({ target, inactive, onClose, onOpen, onObserv
{target.kind === "observer" ? (
<ObserverDetailPanel observerId={target.id} onClose={onClose} onViewStats={onObserverDashboard} onAnalyzePacket={packet} />
) : target.kind === "node" ? (
<NodeDetailPanel nodeId={target.id} onClose={onClose} onViewObserver={observer} onViewNode={node} onAnalyzePacket={packet} />
<NodeDetailPanel nodeId={target.id} onClose={onClose} onViewObserver={observer} onViewNode={node} onAnalyzePacket={packet} onViewOnMap={onViewOnMap && ((lat, lng) => onViewOnMap(target.id, lat, lng))} />
) : (
<PacketAnalyzerDrawer
detail={detail} loading={isLoading} selectedObservationId={selected} onSelectObservation={setSelected}
Expand Down
12 changes: 11 additions & 1 deletion src/features/nodes/NodeDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ interface NodeDetailPanelProps {
onViewObserver: (observerId: string) => void;
onViewNode?: (nodeId: string) => void;
onAnalyzePacket?: (hash: string) => void;
onViewOnMap?: (lat: number, lng: number) => void;
}

export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, onAnalyzePacket }: NodeDetailPanelProps) {
export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, onAnalyzePacket, onViewOnMap }: NodeDetailPanelProps) {
const { data: node, isLoading } = useQuery({
queryKey: ["node", nodeId],
queryFn: () => getNode(nodeId),
Expand Down Expand Up @@ -145,6 +146,15 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
{node.lng != null && <Field label="Lng" value={node.lng.toFixed(5)} />}
{node.locationSource && <Field label="Source" value={node.locationSource} />}
</div>
{hasLocation && onViewOnMap && (
<button
type="button"
onClick={() => onViewOnMap(node.lat!, node.lng!)}
className="mt-2 block font-mono text-[11px] text-primary hover:underline"
>
View on map
</button>
)}
</Section>
)}

Expand Down
4 changes: 2 additions & 2 deletions src/features/observers/ObserverPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,12 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager:
{(id || onReturn) && <div className="flex flex-wrap items-center gap-3 border-b border-border px-4 py-2">
{onReturn && <button ref={returnButton} type="button" aria-label={t("observerPage.returnTo", { page: returnLabel })} onClick={onReturn} className="min-h-11 text-sm text-primary">← {t("observerPage.returnTo", { page: returnLabel })}</button>}
{id && <>
<button type="button" onClick={() => select(null)} className="min-h-11 text-sm text-primary md:hidden">{onReturn ? t("observerPage.directory") : `← ${t("observerPage.back")}`}</button>
<button type="button" onClick={() => select(null)} className="min-h-11 text-sm text-primary">{onReturn ? t("observerPage.directory") : `← ${t("observerPage.back")}`}</button>
<CopyButton value={share.toString()} label={t("observerPage.copyLink")} copiedLabel={t("observerPage.copied")} />
<button type="button" onClick={() => comparing ? closeCompare() : compare("")} className="min-h-11 text-sm text-primary">{t(comparing ? "observerCompare.close" : "observerCompare.open")}</button>
<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)), visitOptions)} className="min-h-11 rounded border border-border bg-bg-raised px-3 text-text-normal">
<option value="24h">{t("stats.ranges.24h")}</option><option value="3d">{t("stats.ranges.3d")}</option>
<option value="24h">{t("stats.ranges.24h")}</option><option value="7d">{t("stats.ranges.7d")}</option><option value="30d">{t("stats.ranges.30d")}</option>
</select>
</label>
</>}
Expand Down
22 changes: 14 additions & 8 deletions src/features/observers/ObserverTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId,
const [typeFilter, setTypeFilter] = useState("");
const [brokerFilter, setBrokerFilter] = useState("");
const [scopeFilter, setScopeFilter] = useState(""); // "" = Any; applied client-side over the loaded set
// the compact sidebar only has a name search, so the directory's other filters sit out until it returns
const status = compact ? "" : statusFilter;
const type = compact ? "" : typeFilter;
const broker = compact ? "" : brokerFilter;
const scope = compact ? "" : scopeFilter;
const field = compact ? "name" : searchField;

useTick(); // keep recency-derived status badges fresh

Expand All @@ -130,8 +136,8 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId,
);

const queryKey = useMemo(
() => ["observers", regionKey, statusFilter, typeFilter, brokerFilter, search, searchField],
[regionKey, statusFilter, typeFilter, brokerFilter, search, searchField],
() => ["observers", regionKey, status, type, broker, search, field],
[regionKey, status, type, broker, search, field],
);

// page the region's observers 50 at a time (filters stay server-side, in the query key); rows
Expand All @@ -141,10 +147,10 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId,
queryFn: (cursor) =>
getObserversPage(iatas, {
cursor,
status: statusFilter || undefined,
type: typeFilter || undefined,
broker: brokerFilter || undefined,
name: searchField === "name" ? search || undefined : undefined,
status: status || undefined,
type: type || undefined,
broker: broker || undefined,
name: field === "name" ? search || undefined : undefined,
}),
getId: observerId,
keepPrevious: true,
Expand All @@ -162,8 +168,8 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId,
const scopeOptions = useScopes();

const displayObservers = useMemo(
() => (scopeFilter ? observers.filter((o) => o.scopes?.includes(scopeFilter)) : observers),
[observers, scopeFilter],
() => (scope ? observers.filter((o) => o.scopes?.includes(scope)) : observers),
[observers, scope],
);

// patch the live status into the paged cache (mirrors NodeTable). A brand-new observer not on any
Expand Down
2 changes: 1 addition & 1 deletion src/features/observers/observer-navigation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { StatsRange } from "../stats/types";

export const observerRange = (value: string | null): StatsRange => value === "24h" ? "24h" : "3d";
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);
Expand Down
11 changes: 8 additions & 3 deletions src/features/packets/PacketEndpoints.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PacketSummary, ResolvedHop } from "../../types/api";
import type { PacketSummary, ResolvedHop, ResolvedNode } from "../../types/api";
import { PayloadType, type PathConfidence } from "../../types/enums";
import { HopPopover } from "./PathData";

Expand All @@ -9,12 +9,17 @@ const CONFIDENCE_CLASSES: Record<PathConfidence, string> = {
none: "bg-text-muted/8 text-text-dim",
};

const nodeLabel = (node: ResolvedNode) => node.name ?? node.publicKey.slice(0, 8);

function Chip({ hop }: { hop: ResolvedHop }) {
const node = hop.nodes[0];
const label = node ? node.name ?? node.publicKey.slice(0, 8) : "?";
const label = node ? nodeLabel(node) : "?";
return (
<HopPopover hop={hop} showSnr={false}>
<span className={`font-mono text-[10px] px-1.5 py-px rounded-sm truncate ${CONFIDENCE_CLASSES[hop.confidence]}`}>
<span
title={hop.nodes.map(nodeLabel).join(", ") || "No Path Resolutions Available"}
className={`font-mono text-[10px] px-1.5 py-px rounded-sm truncate ${CONFIDENCE_CLASSES[hop.confidence]}`}
>
{label}{hop.nodes.length > 1 ? ` +${hop.nodes.length - 1}` : ""}
</span>
</HopPopover>
Expand Down
1 change: 0 additions & 1 deletion src/features/packets/PathData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ export function HopPopover({ hop, onViewNode, showSnr = true, children }: {
onMouseEnter={hasHover ? open : undefined}
onMouseLeave={hasHover ? scheduleClose : undefined}
onClick={hasHover ? undefined : toggle}
title={nodes.map(nodeLabel).join(", ") || "No Path Resolutions Available"}
className="inline-flex min-w-0 max-w-full"
>
{children}
Expand Down
8 changes: 4 additions & 4 deletions src/features/routes/RouteEvidencePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on
const { t } = useTranslation();
const [params, setParams] = useSearchParams();
const [openedAt] = useState(Date.now);
const range = ["3d", "7d", "30d"].includes(params.get("routeRange") ?? "") ? "3d" : "24h";
const range = params.get("routeRange") === "7d" ? "7d" : params.get("routeRange") === "30d" ? "30d" : "24h";
const rawSince = params.get("routeSince"), rawUntil = params.get("routeUntil");
const fixed = rawSince !== null || rawUntil !== null;
const since = Number(rawSince), until = Number(rawUntil);
const invalid = fixed && (!rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until > openedAt || until - since > 3 * 86400000);
const invalid = fixed && (!rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until > openedAt || until - since > 30 * 86400000);
const query = useInfiniteQuery({
queryKey: ["route-evidence", iata, pathKey, range, rawSince, rawUntil],
queryFn: ({ pageParam, signal }) => getRouteEvidence(iata, pathKey, pageParam ? { pageCursor: pageParam, limit: 50 } : fixed ? { since, until, limit: 50 } : { range: range === "3d" ? "72h" : "24h", limit: 50 }, signal),
queryFn: ({ pageParam, signal }) => getRouteEvidence(iata, pathKey, pageParam ? { pageCursor: pageParam, limit: 50 } : fixed ? { since, until, limit: 50 } : { range: range === "7d" ? "168h" : range === "30d" ? "720h" : "24h", limit: 50 }, signal),
initialPageParam: undefined as string | undefined,
getNextPageParam: (last, pages) => pages.length < 10 && last.hasMore ? last.nextPageCursor : undefined,
enabled: !invalid,
Expand All @@ -41,7 +41,7 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on
const capped = (query.data?.pages.length ?? 0) >= 10 && query.data?.pages.at(-1)?.hasMore;
return <DetailPanel title={t("routeEvidence.title")} onClose={onClose} closeLabel={t("routeEvidence.close")} headerAction={first && <CopyLinkButton label={t("investigation.copy")} copiedLabel={t("observerPage.copied")} params={() => ({ tab: "Routes", route: pathKey, routeIata: iata, routeRange: range, routeSince: String(first.windowStart), routeUntil: String(first.windowEnd), hash: null, analyze: null, observation: null, path: null, observer: null, node: null })} />}>
<Section title={t("routeEvidence.window")} first>
<Segmented ariaLabel={t("routeEvidence.window")} value={fixed ? "" : range} options={[{ value: "24h", label: t("stats.ranges.24h") }, { value: "3d", label: t("stats.ranges.3d") }]} onChange={value => setParams(previous => { const next = new URLSearchParams(previous); next.set("routeRange", value); next.delete("routeSince"); next.delete("routeUntil"); return next; })} />
<Segmented ariaLabel={t("routeEvidence.window")} value={fixed ? "" : range} options={[{ value: "24h", label: t("stats.ranges.24h") }, { value: "7d", label: t("stats.ranges.7d") }, { value: "30d", label: t("stats.ranges.30d") }]} onChange={value => setParams(previous => { const next = new URLSearchParams(previous); next.set("routeRange", value); next.delete("routeSince"); next.delete("routeUntil"); return next; })} />
<p className="mt-2 text-xs text-text-muted">{t("observerCompare.retainedWindow")}</p>
{fixed && <p className="mt-2 text-xs text-text-muted">{t("routeEvidence.shared")}</p>}
{first && <p className="mt-2 text-xs leading-relaxed text-text-normal">{new Date(first.windowStart).toISOString().slice(0, 19).replace("T", " ")} → {new Date(first.windowEnd).toISOString().slice(0, 19).replace("T", " ")} UTC</p>}
Expand Down
1 change: 0 additions & 1 deletion src/features/stats/CompareObserversTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ function validation(value: Selection): string | null {
if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) {
return "Choose a valid start and a later end time.";
}
if (value.until - value.since > 3 * 86_400_000) return "observerCompare.windowLimit";
return null;
}

Expand Down
9 changes: 4 additions & 5 deletions src/features/stats/StatsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,18 @@ import { NeighbourGraphTab } from "./NeighbourGraphTab";
import type { StatsRange, StatsTab } from "./types";

const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "compare", "graph"];
const RANGES: StatsRange[] = ["24h", "3d", "30d"];
const RANGES: StatsRange[] = ["24h", "7d", "30d"];

const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh");
const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "3d");
const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "7d");

interface StatsOverviewProps {
wsManager: WsManager;
onObserverDashboard?: (id: string) => void;
}

// Stats page shell: an analytics sub-header and range over the active
// sub-tab. Sub-tab, range, and selected observer live in the URL (?statsTab/?range/?observerId) so the
// view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant.
// 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; replace:true keeps it out of history.
export function StatsOverview({ wsManager, onObserverDashboard }: StatsOverviewProps) {
const [params, setParams] = useSearchParams();
const tab = asTab(params.get("statsTab"));
Expand Down
2 changes: 1 addition & 1 deletion src/features/stats/StatsSubHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const TAB_OPTIONS = [
{ value: "graph", icon: <GraphIcon /> },
];

const RANGES: StatsRange[] = ["24h", "3d", "30d"];
const RANGES: StatsRange[] = ["24h", "7d", "30d"];

interface Props {
tab: StatsTab;
Expand Down
5 changes: 2 additions & 3 deletions src/features/stats/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,11 @@ 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 StatsRange = "24h" | "3d" | "7d" | "30d";
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> = {
"24h": 24 * 60 * 60 * 1000,
"3d": 3 * 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
Expand Down
3 changes: 0 additions & 3 deletions src/features/stats/useTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type { StatsRange } from "./types";
// Go time.ParseDuration strings the telemetry endpoint expects, per selected range.
const RANGE_PARAM: Record<StatsRange, string> = {
"24h": "24h",
"3d": "72h",
"7d": "168h",
"30d": "720h",
};
Expand All @@ -14,7 +13,6 @@ const RANGE_PARAM: Record<StatsRange, string> = {
// charts don't drown in points.
const INTERVAL_PARAM: Record<StatsRange, string> = {
"24h": "1h",
"3d": "1h",
"7d": "6h",
"30d": "24h",
};
Expand All @@ -33,7 +31,6 @@ export function useObserver(observerId: string | null, poll = false) {
// Bucket per range: a quiet hour stays visible at 24h, the longer windows stay under ~200 points.
const ACTIVITY_INTERVAL: Record<StatsRange, string> = {
"24h": "15m",
"3d": "1h",
"7d": "1h",
"30d": "6h",
};
Expand Down
Loading
Loading