Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
df9f4a0
stats: node-types donut, multi-IATA filtering, preset split
MrAlders0n Jun 11, 2026
ad7b485
feat: traces vs pings
MrAlders0n Jun 14, 2026
a618a28
fix: github workflow, post images publically
MrAlders0n Jun 20, 2026
55fda1b
build(deps): bump undici from 7.27.2 to 7.28.0 (#9)
dependabot[bot] Jun 20, 2026
e505018
feat: discover renderers, neighbor map lines, live packet flow
MrAlders0n Jul 6, 2026
b5e2e5d
map: live mode dims nodes, flashes each packet's route
MrAlders0n Jul 6, 2026
a073654
map: shoot a comet along each packet's route in live mode
MrAlders0n Jul 6, 2026
105e68e
map: rework live mode β€” packet dot, dashed trail, per-hop flash
MrAlders0n Jul 8, 2026
c41fe35
map: fade live-mode clusters more so dense ones stay see-through
MrAlders0n Jul 8, 2026
03d7a37
map: persist clustering + node-type, default neighbor lines to selected
MrAlders0n Jul 8, 2026
dc64382
map: fade live-mode node markers more so overlaps stay see-through
MrAlders0n Jul 8, 2026
8294312
map: fade the live-mode node flash in sync with its packet trail
MrAlders0n Jul 8, 2026
26e83e0
nodes: let the mobile detail panel minimize without deselecting
MrAlders0n Jul 8, 2026
5462c08
map: dim other nodes to spotlight a selected node and its neighbours
MrAlders0n Jul 8, 2026
92806fa
map: colour a selected node's neighbour lines by obs count and freshness
MrAlders0n Jul 8, 2026
25bd3ce
Add a neighbour graph under the renamed Analytics tab
MrAlders0n Jul 11, 2026
45669d7
Focus the neighbour graph into an ego view on click
MrAlders0n Jul 11, 2026
bbe404f
Wrap the analytics sub-header so the range stays on-screen on mobile
MrAlders0n Jul 12, 2026
991f501
Use a dropdown for the analytics sections on mobile
MrAlders0n Jul 12, 2026
6b0486f
Gate the neighbour graph to a region and size labels by busyness
MrAlders0n Jul 12, 2026
c397932
Add a name search that spotlights matches and dims the rest of the mesh
MrAlders0n Jul 12, 2026
dbb4dca
Add a copy button beside node and observer public keys
MrAlders0n Jul 12, 2026
bda7147
Add a copy link to node and observer detail panels
MrAlders0n Jul 12, 2026
8786ef3
Add .env options for hiding tabs, MeshMapper themes, app name and a G…
MrAlders0n Jul 13, 2026
8344f3c
Make VITE_ENABLED_THEMES an exclusive theme allowlist
MrAlders0n Jul 13, 2026
04f1757
Add a .env option to skip the load splash
MrAlders0n Jul 13, 2026
58016bc
Keep the packets list static while scrolled down instead of jumping o…
MrAlders0n Jul 13, 2026
82a83fd
Add Map deep-links for camera and settings with a copy-link button
MrAlders0n Jul 14, 2026
2e2b845
Filter packet history on the server when a single type, route or scop…
MrAlders0n Jul 15, 2026
43ebcd9
Show loading feedback while packet history loads
MrAlders0n Jul 15, 2026
20c0e21
build(deps): bump echarts from 5.5.1 to 6.1.0 (#14)
dependabot[bot] Jul 15, 2026
493aedb
Bump version to 1.2.0
MrAlders0n Jul 15, 2026
cfe6a8f
Merge branch 'main' into dev
MrAlders0n Jul 15, 2026
1b42e02
Restore files clobbered by the main merge
MrAlders0n Jul 15, 2026
bc62d28
Bump version to 1.2.1
MrAlders0n Jul 15, 2026
65965ac
Merge main into dev
MrAlders0n Jul 15, 2026
fb37690
Drop empty observer telemetry frames and show airtime as duration
MrAlders0n Jul 16, 2026
11b0600
Back out frontend zero-frame filter; fixing at the source instead
MrAlders0n Jul 16, 2026
01b722b
Add 'view path on map' popup to the packet analyzer
MrAlders0n Jul 18, 2026
2132e78
Collapse the path map attribution to a bare (i) by default
MrAlders0n Jul 18, 2026
02ec63c
Add path-map deep links, per-observer propagation, and tappable nodes
MrAlders0n Jul 18, 2026
6af9d65
Keep path map at a fixed height on narrow screens
MrAlders0n Jul 19, 2026
88ae52b
Show packet source and destination on the path map and analyzer
MrAlders0n Jul 24, 2026
7bf30da
Add top advertisers to Mesh stats and a Talkers leaderboard tab
MrAlders0n Jul 24, 2026
23bacde
Add public key prefix search to the nodes table
MrAlders0n Jul 24, 2026
913151c
Show device clock drift on the node detail panel
MrAlders0n Jul 24, 2026
badd919
Only plot unambiguously-resolved path endpoints on the map
MrAlders0n Jul 24, 2026
948cfa2
Fix public-key node search field switching and non-hex input
MrAlders0n Jul 24, 2026
1303e95
Add tests for path endpoint, trace, and clock-drift edge cases
MrAlders0n Jul 24, 2026
98ca904
Refresh stale radio-freq and telemetry comments for current backend
MrAlders0n Jul 24, 2026
e526c08
Send multi-select packet filters to the server
MrAlders0n Jul 24, 2026
4584294
Group top advertisers and talkers on the Talkers tab
MrAlders0n Jul 24, 2026
34bb171
Lay out Talkers side by side and label advertiser node types
MrAlders0n Jul 24, 2026
f1a69fa
Mark each top advertiser with its IATA
MrAlders0n Jul 24, 2026
7a2d74b
Pin overview sparklines to a fixed 24h window
MrAlders0n Jul 24, 2026
e6a714a
Split top advertisers into flood and direct adverts
MrAlders0n Jul 25, 2026
8baa562
Add a Clock Drift analytics tab
MrAlders0n Jul 25, 2026
464e701
Add a toggleable IATA border layer to the map
MrAlders0n Jul 25, 2026
161f481
Carry path and endpoint fields from WS observations into packet summa…
MrAlders0n Jul 27, 2026
1dbbce1
Extract usePacketDetail and share it across the analyzer and overlay
MrAlders0n Jul 27, 2026
89a8980
Split ?hash into row expansion and ?analyze for the analyzer drawer
MrAlders0n Jul 27, 2026
c9eb929
Fix ?path deep link when ?analyze is absent
MrAlders0n Jul 27, 2026
7512425
Add a pure path-summary model for the packet table's path line
MrAlders0n Jul 27, 2026
48587a0
Cover pathBytes-absent and unresolved-endpoint cases in path summary …
MrAlders0n Jul 27, 2026
bdf7ac4
Render the packet path line with truncation and confidence tints
MrAlders0n Jul 27, 2026
dbed149
Cover endpoint pairs, high tint, and unresolved-run chips in PacketPa…
MrAlders0n Jul 27, 2026
8795c36
Add the two-line packet table row
MrAlders0n Jul 27, 2026
8bd0f92
Restore n/a fallback in observer and IATA cells, scope the covering test
MrAlders0n Jul 27, 2026
9300eef
Add the sticky packet table header
MrAlders0n Jul 27, 2026
7dfb363
Add the nested observation table for expanded packet rows
MrAlders0n Jul 27, 2026
16ee220
Fix PathData sizing and add null-SNR test to ObservationTable
MrAlders0n Jul 27, 2026
f400dc5
Add the expanded packet region with skeleton, error and empty states
MrAlders0n Jul 27, 2026
64b822f
Reorder PacketExpansion to show errors before empty state
MrAlders0n Jul 27, 2026
9582aaa
Wire the packet table and expandable rows into the virtual list
MrAlders0n Jul 28, 2026
f44f1ac
Carry the picked observation into the analyzer and finish two other s…
MrAlders0n Jul 28, 2026
663eeef
Stop stripping ?hash on mount, it breaks deep links to older packets
MrAlders0n Jul 28, 2026
907b42e
Keep the packet card layout below md
MrAlders0n Jul 28, 2026
cdda2a0
Keep the widest heard window when a live packet is re-observed
MrAlders0n Jul 28, 2026
9c037bc
Close the analyzer on a mobile tab change and add an expanded-row cop…
MrAlders0n Jul 28, 2026
d1ba42e
Drop a stale doc pointer from two map comments
MrAlders0n Jul 28, 2026
1f3fb5b
Collapse the packet row to a single line with hops and endpoints
MrAlders0n Jul 28, 2026
b2adc52
Open the analyzer from an observation and add a hash size column
MrAlders0n Jul 28, 2026
4f0ad44
Filter the region picker by IATA or airport name
MrAlders0n Jul 28, 2026
b74dcdb
Animate the live map path from source through to destination
MrAlders0n Jul 29, 2026
bc3bed7
Preserve linebreaks in message bodies
MrAlders0n Jul 29, 2026
6daf342
Merge origin/main into dev
MrAlders0n Jul 29, 2026
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
119 changes: 92 additions & 27 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react";
import { BrowserRouter, useSearchParams } from "react-router-dom";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RegionProvider, useRegion, useRegionSelection } from "./hooks/useRegion";
import {
ALL_REGIONS,
Expand All @@ -18,6 +18,7 @@ import { SplashScreen } from "./components/SplashScreen";
import { PacketList } from "./features/packets/PacketList";
import { PacketAnalyzerDrawer } from "./features/packets/PacketAnalyzerDrawer";
import { PacketAnalyzerOverlay } from "./features/packets/PacketAnalyzerOverlay";
import { PacketPathMapModal } from "./features/map/PacketPathMapModal";
import { NodeTable } from "./features/nodes/NodeTable";
import { NodeDetailPanel } from "./features/nodes/NodeDetailPanel";
import { NodeDetailOverlay } from "./features/nodes/NodeDetailOverlay";
Expand All @@ -26,9 +27,10 @@ import { RouteTable } from "./features/routes/RouteTable";
import { TraceList } from "./features/traces/TraceList";
import { ChannelList } from "./features/channels/ChannelList";
import { EmptyState } from "./components/EmptyState";
import { getPacketDetail } from "./api/client";
import { usePacketDetail } from "./features/packets/usePacketDetail";
import { WsManager } from "./api/ws-manager";
import { WS_URL, ENABLED_TABS } from "./lib/constants";
import type { PacketDetail } from "./types/api";

// Map is the only heavy tab (maplibre-gl is ~1MB), so lazy-load it β€” its chunk is fetched the
// first time someone opens the Map tab instead of bloating the initial bundle.
Expand Down Expand Up @@ -96,6 +98,29 @@ function RegionUrlSync() {
return null;
}

// Restores a shared "?path" link once its detail arrives. A copied path link carries ?hash without
// ?analyze (PacketPathMapModal's Copy Link strips it), so this can't reuse the analyzer drawer's fetch
// and needs its own β€” sharing usePacketDetail's query cache means that costs nothing extra when both
// params are present.
export function PathLinkRestore({ initialPath, hash, analyzerDetail, onRestore }: {
initialPath: string | null;
hash: string | null;
analyzerDetail: PacketDetail | undefined;
onRestore: (detail: PacketDetail, key: string) => void;
}) {
const { data: pathLinkDetail } = usePacketDetail(initialPath ? hash : null);
const handledRef = useRef(false);

useEffect(() => {
const detail = analyzerDetail ?? pathLinkDetail;
if (!initialPath || !detail || handledRef.current) return;
handledRef.current = true;
onRestore(detail, initialPath);
}, [initialPath, analyzerDetail, pathLinkDetail, onRestore]);

return null;
}

// Drop the shared node/observer selection when the user changes region, so a detail panel doesn't keep
// showing an entity that's no longer in the re-queried map/table. Watches the raw selection rather than
// the resolved regionKey: the async slug→IATA expansion on load bumps regionKey without any user action,
Expand Down Expand Up @@ -127,8 +152,9 @@ function AppInner() {
// Resolve the starting selection once from URL β†’ storage β†’ legacy key (see computeInitialSelection).
const [initialSelection] = useState(() => computeInitialSelection(searchParams));

// ?hash / ?node / ?observer restore a shared deep link on load (see each panel's Copy Link button)
const [analyzerHash, setAnalyzerHash] = useState<string | null>(() => searchParams.get("hash"));
// ?node / ?observer restore a shared deep link on load (see each panel's Copy Link button)
// ?analyze=1 is a boolean flag; the hash always lives in ?hash, so ?analyze alone opens nothing
const analyzerHash = searchParams.get("analyze") === "1" ? searchParams.get("hash") : null;
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
Expand All @@ -137,42 +163,53 @@ function AppInner() {
const [overlayNodeId, setOverlayNodeId] = useState<string | null>(null);
// packet analyzer shown as a modal over the node panel (clicking a node's observation row)
const [overlayPacketHash, setOverlayPacketHash] = useState<string | null>(null);
// packet path popup shown as a modal over the analyzer drawer/overlay ("View path on map")
const [pathMapDetail, setPathMapDetail] = useState<PacketDetail | null>(null);
// Frozen together: the restore must fetch the hash the link asked for, even if the user clicks a
// different row before it resolves.
const [pathLink] = useState(() => ({ path: searchParams.get("path"), hash: searchParams.get("hash") }));
const [pathMapInitialKey, setPathMapInitialKey] = useState<string | null>(null);

const { data: analyzerDetail, isLoading: analyzerLoading } = usePacketDetail(analyzerHash);
const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = usePacketDetail(overlayPacketHash);

const handlePathLinkRestore = useCallback((detail: PacketDetail, key: string) => {
setPathMapDetail(detail);
setPathMapInitialKey(key);
}, []);

// short staleTime: observations keep accruing, so reopening the analyzer should show them
// instead of a snapshot frozen at first open
const { data: analyzerDetail, isLoading: analyzerLoading } = useQuery({
queryKey: ["packet-detail", analyzerHash],
queryFn: () => getPacketDetail(analyzerHash!),
enabled: !!analyzerHash,
staleTime: 30_000,
});

const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = useQuery({
queryKey: ["packet-detail", overlayPacketHash],
queryFn: () => getPacketDetail(overlayPacketHash!),
enabled: !!overlayPacketHash,
staleTime: 30_000,
});
// "View path on map" from anywhere that already holds a detail β€” no key, so the modal picks its own
const handleViewPath = useCallback((detail: PacketDetail) => {
setPathMapDetail(detail);
setPathMapInitialKey(null);
}, []);

const handleAnalyze = useCallback((hash: string | null) => {
setAnalyzerHash(hash);
setSelectedObservationId(null);
}, []);
// No reset: observation ids are globally unique, so a pick inside an expanded row survives into the drawer.
setSearchParams((p) => {
const n = new URLSearchParams(p);
if (hash) { n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); }
else n.delete("analyze");
return n;
}, { replace: true });
}, [setSearchParams]);

const handleTabChange = (tab: string) => {
setOverlayNodeId(null);
setOverlayPacketHash(null);
// On mobile a detail panel fills the screen, so leaving its tab must close it; desktop side
// panels persist across tabs. Cross-nav (onViewObserver) re-sets its selection after this.
setPathMapDetail(null);
// On mobile a detail panel (and the analyzer) fills the screen, so leaving its tab must close it;
// desktop side panels persist across tabs. Cross-nav (onViewObserver) re-sets its selection after this.
if (isMobile) {
setAnalyzerHash(null);
setSelectedObservationId(null);
setSelectedNodeId(null);
setSelectedObserverId(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");
// stats sub-state shouldn't haunt the URL on other tabs
if (tab !== "Analytics") {
next.delete("statsTab");
Expand All @@ -188,10 +225,11 @@ function AppInner() {
setOverlayNodeId(null);
setOverlayPacketHash(null);
setSelectedObserverId(null);
setPathMapDetail(null);
}, []);

// Closing a detail panel drops its deep-link param so a reload can't reopen it (mirrors the packet
// analyzer's ?hash cleanup). Selecting a different node/observer doesn't touch the URL β€” the panel's
// analyzer's ?analyze cleanup). Selecting a different node/observer doesn't touch the URL β€” the panel's
// Copy Link button rebuilds a fresh link on demand.
const dropSelectionParam = useCallback((key: "node" | "observer") => {
setSearchParams((prev) => {
Expand Down Expand Up @@ -236,7 +274,15 @@ function AppInner() {
}, []);

const tabContent: Record<string, React.ReactNode> = {
Packets: <PacketList wsManager={wsManager} onAnalyze={handleAnalyze} />,
Packets: (
<PacketList
wsManager={wsManager}
onAnalyze={handleAnalyze}
onViewPath={handleViewPath}
selectedObservationId={selectedObservationId}
onSelectObservation={setSelectedObservationId}
/>
),
Nodes: <NodeTable wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
Observers: <ObserverTable wsManager={wsManager} selectedObserverId={selectedObserverId} onSelectObserver={handleSelectObserver} onAnalyzePacket={setOverlayPacketHash} onViewStats={handleViewObserverStats} />,
Routes: <RouteTable />,
Expand All @@ -253,6 +299,12 @@ function AppInner() {
<RegionWatcher wsManager={wsManager} />
<RegionUrlSync />
<SelectionResetOnRegion onRegionChange={clearSelection} />
<PathLinkRestore
initialPath={pathLink.path}
hash={pathLink.hash}
analyzerDetail={analyzerDetail}
onRestore={handlePathLinkRestore}
/>
<AppShell activeTab={activeTab} onTabChange={handleTabChange} wsManager={wsManager}>
<div className="relative flex flex-1 min-h-0">
<div key={activeTab} className="flex flex-1 min-h-0 fade-in">
Expand All @@ -268,6 +320,7 @@ function AppInner() {
onSelectObservation={setSelectedObservationId}
onClose={() => handleAnalyze(null)}
onViewNode={setOverlayNodeId}
onViewPath={() => { if (analyzerDetail) handleViewPath(analyzerDetail); }}
/>
)}
{(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && (
Expand Down Expand Up @@ -302,6 +355,18 @@ function AppInner() {
handleTabChange("Observers");
setSelectedObserverId(observerId);
}}
onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }}
inactive={!!pathMapDetail}
/>
)}
{pathMapDetail && (
<PacketPathMapModal
detail={pathMapDetail}
initialSelectedKey={pathMapInitialKey}
onClose={() => {
setPathMapDetail(null);
setSearchParams((prev) => { const n = new URLSearchParams(prev); n.delete("path"); return n; }, { replace: true });
}}
/>
)}
</div>
Expand Down
41 changes: 37 additions & 4 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ import type {
PayloadBreakdownItem,
TopNode,
TopObserver,
TopAdvertiser,
TopTalker,
RadioPreset,
ScopeStats,
ObserverTelemetry,
NodeTypeCount,
ClockDriftEntry,
} from "../features/stats/types";
import type { Feature, Polygon, MultiPolygon } from "geojson";

export type IataBorder = Feature<Polygon | MultiPolygon>;

// typed fetch wrapper with query params

Expand Down Expand Up @@ -58,15 +64,15 @@ function iatasParam(iatas?: string[]): string | undefined {

export function getPackets(
iatas: string[] | undefined,
params?: { cursor?: number; limit?: number; payloadType?: number; routeType?: number; scope?: string },
params?: { cursor?: number; limit?: number; payloadTypes?: number[]; routeTypes?: number[]; scopes?: string[] },
): Promise<CursorPage<PacketSummary>> {
return request("/packets", {
iatas: iatasParam(iatas),
cursor: params?.cursor,
limit: params?.limit ?? DEFAULT_PAGE_SIZE,
payloadType: params?.payloadType,
routeType: params?.routeType,
scope: params?.scope,
payloadTypes: params?.payloadTypes?.length ? params.payloadTypes.join(",") : undefined,
routeTypes: params?.routeTypes?.length ? params.routeTypes.join(",") : undefined,
scopes: params?.scopes?.length ? params.scopes.join(",") : undefined,
});
}

Expand All @@ -78,6 +84,17 @@ export function getIatas(): Promise<IataCode[]> {
return request("/iatas");
}

// An IATA's GeoJSON border, or null when none is configured. Can't use request(): the endpoint
// answers 204 (empty body) or a literal `null` for "no border", and request() always parses JSON.
export async function getIataBorder(iata: string): Promise<IataBorder | null> {
const url = new URL(`${API_BASE}/iatas/${iata}/border`, window.location.origin);
const res = await fetch(url.toString());
if (res.status === 204) return null;
if (!res.ok) throw new ApiError(res.status, "unknown", res.statusText);
const body = await res.json();
return (body ?? null) as IataBorder | null;
}

export function getRegions(): Promise<RegionSummary[]> {
return request("/regions");
}
Expand Down Expand Up @@ -209,6 +226,7 @@ export function getNodesPage(
limit?: number;
type?: string;
name?: string;
pubkeyPrefix?: string; // case-insensitive hex prefix; server matches and validates
supportsMultibytePaths?: "true" | "false";
supportsMultibyteTraces?: "true" | "false";
neighbors?: boolean; // include each node's neighborIds (?neighbors=true)
Expand All @@ -220,6 +238,7 @@ export function getNodesPage(
limit: params?.limit ?? DEFAULT_PAGE_SIZE,
typeName: params?.type,
name: params?.name,
pubkeyPrefix: params?.pubkeyPrefix,
supportsMultibytePaths: params?.supportsMultibytePaths,
supportsMultibyteTraces: params?.supportsMultibyteTraces,
neighbors: params?.neighbors ? "true" : undefined,
Expand Down Expand Up @@ -282,6 +301,14 @@ export function getTopObservers(iatas?: string[], since?: number, limit = 10): P
return request("/stats/top-observers", { iatas: iatasParam(iatas), since, limit });
}

export function getTopAdvertisers(iatas?: string[], since?: number, limit = 10): Promise<TopAdvertiser[]> {
return request("/stats/top-advertisers", { iatas: iatasParam(iatas), since, limit });
}

export function getTopTalkers(iatas?: string[], since?: number, limit = 10): Promise<TopTalker[]> {
return request("/stats/top-talkers", { iatas: iatasParam(iatas), since, limit });
}

export function getRadioPresets(iatas?: string[]): Promise<RadioPreset[]> {
return request("/stats/radio-presets", { iatas: iatasParam(iatas) });
}
Expand All @@ -290,6 +317,12 @@ export function getStatsNodeTypes(iatas?: string[]): Promise<NodeTypeCount[]> {
return request("/stats/node-types", { iatas: iatasParam(iatas) });
}

// Repeaters/room servers whose clock has drifted past the server threshold, worst-first. Not
// time-windowed and top-N only (no cursor), so callers pass a generous limit and page client-side.
export function getClockDrift(iatas?: string[], limit = 100): Promise<ClockDriftEntry[]> {
return request("/stats/clock-drift", { iatas: iatasParam(iatas), limit });
}

// renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes
// aggregate (packet/observer/node counts), reported globally regardless of the active region.
export function getStatsScopes(): Promise<ScopeStats[]> {
Expand Down
Loading
Loading