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
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ function AppInner() {
// master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses
Traces: <TraceList onAnalyze={hash => { if (hash) viewPacket(hash); }} onViewNode={viewNode} />,
Channels: <ChannelList wsManager={wsManager} onAnalyze={handleAnalyze} />,
Analytics: <StatsOverview wsManager={wsManager} onViewNode={viewNode} />,
Analytics: <StatsOverview onViewNode={viewNode} />,
Map: <MapView wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
};

Expand Down
76 changes: 47 additions & 29 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ChannelPage, ChannelMessage } from "../features/channels/types";
import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types";
import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types";
import type {
StatsOverview,
StatsSeries,
SignalStats,
PathStats,
ObserverComparison,
Expand Down Expand Up @@ -71,22 +71,34 @@ async function request<T>(path: string, params?: Record<string, string | number
// endpoint functions

export function getObserverComparison(
iatas: string[] | undefined,
iatas: StatsRegion,
params: { observerA: string; observerB: string; since: number; until: number },
signal?: AbortSignal,
): Promise<ObserverComparison> {
return request("/stats/observer-comparison", { ...params, iatas: iatasParam(iatas) }, signal);
return request("/stats/observer-comparison", { ...params, ...statsRegionParams(iatas) }, signal);
}

// The region filter travels as the comma-separated `iatas` param; undefined/empty means all regions.
function iatasParam(iatas?: string[]): string | undefined {
return iatas && iatas.length > 0 ? iatas.join(",") : undefined;
}

// An empty list is a region with no IATAs. These endpoints would answer it with every IATA, so skip them.
const noIatas = (iatas?: string[]) => iatas?.length === 0;
const emptyPage = <T>(): CursorPage<T> => ({ items: [], nextCursor: null, hasMore: false });

// Stats endpoints also take a region slug, which the server answers with zeros when it has no IATAs.
export type StatsRegion = string[] | { region: string } | undefined;

function statsRegionParams(r: StatsRegion): { iatas?: string; region?: string } {
return Array.isArray(r) || r === undefined ? { iatas: iatasParam(r) } : { region: r.region };
}

export function getPackets(
iatas: string[] | undefined,
params?: { cursor?: number; limit?: number; payloadTypes?: number[]; routeTypes?: number[]; scopes?: string[] },
): Promise<CursorPage<PacketSummary>> {
if (noIatas(iatas)) return Promise.resolve(emptyPage());
return request("/packets", {
iatas: iatasParam(iatas),
cursor: params?.cursor,
Expand Down Expand Up @@ -127,6 +139,7 @@ export function getRegion(regionId: number): Promise<Region> {

// Preserve the server cursor rather than deriving it from the displayed channel order.
export function getChannels(params?: { iatas?: string[]; limit?: number; cursor?: number | string; keyKnown?: boolean }): Promise<ChannelPage> {
if (noIatas(params?.iatas)) return Promise.resolve(emptyPage());
const iatas = params?.iatas ?? [];
return request("/channels", {
iata: iatas.length === 1 ? iatas[0] : undefined,
Expand All @@ -145,6 +158,7 @@ export async function getChannelMessagesPage(
channelId: number,
params?: { iatas?: string[]; cursor?: number; limit?: number; scope?: string },
): Promise<CursorPage<ChannelMessage>> {
if (noIatas(params?.iatas)) return emptyPage();
const limit = params?.limit ?? DEFAULT_PAGE_SIZE;
const page = await request<{ items: ChannelMessage[]; nextCursor?: number | null; hasMore?: boolean }>(`/channels/${channelId}/messages`, {
iatas: iatasParam(params?.iatas),
Expand All @@ -163,6 +177,7 @@ export function getBrokers(): Promise<BrokerStatus[]> {
// Transport scope names (e.g. "#bc", "#west") for the scope filter dropdowns. With IATAs the server
// returns only that region's scopes (manual config + its MeshMapper catalogue); bare, every stored name.
export function getScopes(iatas?: string[]): Promise<string[]> {
if (noIatas(iatas)) return Promise.resolve([]);
return request("/scopes", { iatas: iatasParam(iatas) });
}

Expand Down Expand Up @@ -217,6 +232,7 @@ export function getTraces(
iatas: string[] | undefined,
params?: { scope?: string; type?: TraceType; since?: number; until?: number; cursor?: number; limit?: number },
): Promise<TraceTagSummary[]> {
if (noIatas(iatas)) return Promise.resolve([]);
return request("/traces", {
iatas: iatasParam(iatas),
scope: params?.scope,
Expand Down Expand Up @@ -268,6 +284,7 @@ export function getNodesPage(
neighbors?: boolean; // include each node's neighborIds (?neighbors=true)
},
): Promise<CursorPage<NodeSummary>> {
if (noIatas(iatas)) return Promise.resolve(emptyPage());
return request("/nodes", {
iatas: iatasParam(iatas),
cursor: params?.cursor,
Expand All @@ -286,6 +303,7 @@ export function getObserversPage(
iatas: string[] | undefined,
params?: { cursor?: number; limit?: number; type?: string; broker?: string; status?: string; name?: string },
): Promise<CursorPage<ObserverSummary>> {
if (noIatas(iatas)) return Promise.resolve(emptyPage());
return request("/observers", {
iatas: iatasParam(iatas),
cursor: params?.cursor,
Expand Down Expand Up @@ -317,60 +335,60 @@ export function getNodeNeighbors(nodeId: string): Promise<NodeNeighbor[]> {

// stats endpoints

export function getStatsOverview(iatas?: string[]): Promise<StatsOverview> {
return request("/stats/overview", { iatas: iatasParam(iatas) });
export function getStatsSeries(since: number, until: number, iatas?: StatsRegion, signal?: AbortSignal): Promise<StatsSeries> {
return request("/stats/series", { since, until, ...statsRegionParams(iatas) }, signal);
}

export function getStatsObservations(iatas?: string[], since?: number, signal?: AbortSignal): Promise<ObservationPoint[]> {
return request("/stats/observations", { iatas: iatasParam(iatas), since }, signal);
export function getStatsObservations(iatas?: StatsRegion, since?: number, signal?: AbortSignal): Promise<ObservationPoint[]> {
return request("/stats/observations", { ...statsRegionParams(iatas), since }, signal);
}

export function getSignalStats(since: number, until: number, iatas?: string[], signal?: AbortSignal): Promise<SignalStats> {
return request("/stats/signal", { since, until, iatas: iatasParam(iatas) }, signal);
export function getSignalStats(since: number, until: number, iatas?: StatsRegion, signal?: AbortSignal): Promise<SignalStats> {
return request("/stats/signal", { since, until, ...statsRegionParams(iatas) }, signal);
}

export function getPathStats(since: number, until: number, iatas?: string[], signal?: AbortSignal): Promise<PathStats> {
return request("/stats/paths", { since, until, iatas: iatasParam(iatas) }, signal);
export function getPathStats(since: number, until: number, iatas?: StatsRegion, signal?: AbortSignal): Promise<PathStats> {
return request("/stats/paths", { since, until, ...statsRegionParams(iatas) }, signal);
}

export function getPayloadBreakdown(iatas?: string[], since?: number): Promise<PayloadBreakdownItem[]> {
return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since });
export function getPayloadBreakdown(iatas?: StatsRegion, since?: number): Promise<PayloadBreakdownItem[]> {
return request("/stats/payload-breakdown", { ...statsRegionParams(iatas), since });
}

export function getTopNodes(iatas?: string[], limit = 10): Promise<TopNode[]> {
return request("/stats/top-nodes", { iatas: iatasParam(iatas), limit });
export function getTopNodes(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopNode[]> {
return request("/stats/top-nodes", { ...statsRegionParams(iatas), since, limit });
}

export function getTopObservers(iatas?: string[], since?: number, limit = 10): Promise<TopObserver[]> {
return request("/stats/top-observers", { iatas: iatasParam(iatas), since, limit });
export function getTopObservers(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopObserver[]> {
return request("/stats/top-observers", { ...statsRegionParams(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 getTopAdvertisers(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopAdvertiser[]> {
return request("/stats/top-advertisers", { ...statsRegionParams(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 getTopTalkers(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopTalker[]> {
return request("/stats/top-talkers", { ...statsRegionParams(iatas), since, limit });
}

export function getRadioPresets(iatas?: string[]): Promise<RadioPreset[]> {
return request("/stats/radio-presets", { iatas: iatasParam(iatas) });
export function getRadioPresets(iatas?: StatsRegion): Promise<RadioPreset[]> {
return request("/stats/radio-presets", { ...statsRegionParams(iatas) });
}

export function getStatsNodeTypes(iatas?: string[]): Promise<NodeTypeCount[]> {
return request("/stats/node-types", { iatas: iatasParam(iatas) });
export function getStatsNodeTypes(iatas?: StatsRegion): Promise<NodeTypeCount[]> {
return request("/stats/node-types", { ...statsRegionParams(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 });
export function getClockDrift(iatas?: StatsRegion, limit = 100): Promise<ClockDriftEntry[]> {
return request("/stats/clock-drift", { ...statsRegionParams(iatas), limit });
}

// renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes
// aggregate (packet/observer/node counts), filtered by the selected IATAs.
export function getStatsScopes(iatas?: string[], signal?: AbortSignal): Promise<ScopeStats[]> {
return request("/stats/scopes", { iatas: iatasParam(iatas) }, signal);
export function getStatsScopes(iatas?: StatsRegion, since?: number, signal?: AbortSignal): Promise<ScopeStats[]> {
return request("/stats/scopes", { ...statsRegionParams(iatas), since }, signal);
}

export function getObserverTelemetry(
Expand Down
3 changes: 2 additions & 1 deletion src/api/ws-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,8 @@ export class WsManager {
}

private sendSubscribe(): void {
if (!this.filter) return;
// the server reads an empty IATA list as "all", so a region with no IATAs subscribes to nothing
if (!this.filter || this.filter.iatas?.length === 0) return;
const id = `sub-${this.nextId()}`;
this.lastSubscribeId = id;
this.send({
Expand Down
4 changes: 2 additions & 2 deletions src/features/map/MapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
const fitPoints = useMemo<[number, number][] | null>(() => {
const withCoords = (iatas ?? []).filter((i) => i.lat != null && i.lon != null);
if (withCoords.length === 0) return null;
const scope = selectedIatas && selectedIatas.length > 0 ? new Set(selectedIatas) : null;
const scope = selectedIatas ? new Set(selectedIatas) : null;
const chosen = scope ? withCoords.filter((i) => scope.has(i.iata)) : withCoords;
return chosen.length > 0 ? chosen.map((i) => [i.lon!, i.lat!]) : null;
}, [iatas, selectedIatas]);
Expand All @@ -182,7 +182,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
// which resolves to a 204 and is dropped). Only fetched while the layer is toggled on.
const borderIatas = useMemo(() => {
const all = (iatas ?? []).map((i) => i.iata);
return selectedIatas && selectedIatas.length > 0 ? all.filter((c) => selectedIatas.includes(c)) : all;
return selectedIatas ? all.filter((c) => selectedIatas.includes(c)) : all;
}, [iatas, selectedIatas]);
const borderData = useMapBordersData(borderIatas, borders);

Expand Down
6 changes: 3 additions & 3 deletions src/features/observers/ObserverSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getTopObservers } from "../../api/client";
import { useRegion } from "../../hooks/useRegion";
import { formatCount } from "../../lib/formatters";
import { Segmented } from "../stats/Segmented";
import { useStatsRegion } from "../stats/useStats";
import { RANGE_MS, type StatsRange } from "../stats/types";
import { observerName as nameOf } from "./observer-filter";
import { deriveObserverStatus } from "./observer-status";
Expand All @@ -18,12 +18,12 @@ export function ObserverSidebar({ observers, filtered, isPending, isError, onRet
range: StatsRange; selectedId: string | null; onSelect: (id: string) => void;
}) {
const { t } = useTranslation();
const { iatas, regionKey } = useRegion();
const { where, regionKey } = useStatsRegion();
const [sort, setSort] = useState<Sort>("activity");

const activity = useQuery({
queryKey: ["observer-sidebar-activity", regionKey, range],
queryFn: () => getTopObservers(iatas, Date.now() - RANGE_MS[range], TOP),
queryFn: () => getTopObservers(where, Date.now() - RANGE_MS[range], TOP),
staleTime: 30_000,
});

Expand Down
2 changes: 1 addition & 1 deletion src/features/routes/RouteTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export function RouteTable(actions: RouteActions) {
// region = all). Filtering by IATA stays client-side, consistent with the other tabs.
const rows = useMemo(() => {
if (search) return isCross ? [] : searchRoutes;
if (!iatas || iatas.length === 0) return listRoutes;
if (!iatas) return listRoutes;
const set = new Set(iatas);
return listRoutes.filter((r) => set.has(r.iata));
}, [search, isCross, searchRoutes, listRoutes, iatas]);
Expand Down
8 changes: 5 additions & 3 deletions src/features/stats/CompareObserversTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { InfoTip } from "../../components/InfoTip";
import { SectionInfo } from "./SectionInfo";
import { getObserver, getObserverComparison } from "../../api/client";
import { useRegion } from "../../hooks/useRegion";
import { useStatsRegion } from "./useStats";
import { OBSERVER_UUID } from "../observers/observer-id";
import { ObserverPicker } from "../observers/ObserverPicker";
import { Card } from "./cards";
Expand Down Expand Up @@ -82,7 +83,8 @@ function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onC

export function CompareObserversTab() {
const { t } = useTranslation();
const { iatas, regionKey } = useRegion();
const { iatas, emptyRegion } = useRegion();
const { where, regionKey } = useStatsRegion();
const [params, setParams] = useSearchParams();
const keys = ["compareA", "compareB", "compareSince", "compareUntil"];
const supplied = keys.some((key) => params.has(key));
Expand All @@ -94,7 +96,7 @@ export function CompareObserversTab() {
const selection = valid ? parsed : null;
const result = useQuery({
queryKey: ["observer-comparison", regionKey, selection],
queryFn: ({ signal }) => getObserverComparison(iatas, selection!, signal),
queryFn: ({ signal }) => getObserverComparison(where, selection!, signal),
enabled: selection !== null,
retry: false, staleTime: 30_000, refetchOnWindowFocus: false,
});
Expand Down Expand Up @@ -133,7 +135,7 @@ export function CompareObserversTab() {
</Card>
{selection && <Card title={t("observerCompare.floodHeard")}>
<p className="mb-2 break-words text-sm text-text-normal">{t("observerCompare.pair", { a: observerA.data?.displayName ?? selection.observerA, b: observerB.data?.displayName ?? selection.observerB })}</p>
<p className="mb-3 flex flex-wrap items-center gap-x-2 break-words text-sm text-text-muted">{new Date(selection.since).toLocaleString()} – {new Date(selection.until).toLocaleString()} · {iatas?.join(", ") || t("observerCompare.allRegions")}<InfoTip text={t("observerCompare.localTimeNote")} /></p>
<p className="mb-3 flex flex-wrap items-center gap-x-2 break-words text-sm text-text-muted">{new Date(selection.since).toLocaleString()} – {new Date(selection.until).toLocaleString()} · {iatas ? iatas.join(", ") || emptyRegion : t("observerCompare.allRegions")}<InfoTip text={t("observerCompare.localTimeNote")} /></p>
{result.isFetching && <p role="status" className="text-sm text-text-muted">{t("observerCompare.comparing")}</p>}
{result.isError && <div role="alert" className="text-sm text-danger"><p>{result.error.message}</p><button type="button" onClick={() => void result.refetch()} className="mt-2 text-primary">{t("observerCompare.retryComparison")}</button></div>}
{data && !result.isError && <>
Expand Down
Loading
Loading