Region-filtered scope lists (#113)

This commit is contained in:
MrAlders0n
2026-09-30 22:09:37 -04:00
committed by GitHub
parent 6de673b6a0
commit 35d117a636
10 changed files with 77 additions and 19 deletions
+4 -4
View File
@@ -159,10 +159,10 @@ export function getBrokers(): Promise<BrokerStatus[]> {
return request("/brokers");
}
// The authoritative list of configured transport scope names (e.g. "#bc", "#west"), used to populate
// the scope filter dropdowns. The no-param /scopes endpoint returns the names directly.
export function getScopes(): Promise<string[]> {
return request("/scopes");
// 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[]> {
return request("/scopes", { iatas: iatasParam(iatas) });
}
// Wrap a bare-array endpoint into a CursorPage so it can drive the cursor-paginated hooks. A page that
+2 -2
View File
@@ -43,7 +43,7 @@ function MessageRow({ msg, heardCount, onAnalyze }: { msg: ChannelMessage; heard
<Timestamp value={msg.sentAt} className="text-[11px] text-text-dim" />
{reach > 0 && <Badge variant="text">×{reach}</Badge>}
{/* Unscoped is the default, so only a named or unresolved scope earns a chip. */}
{msg.scope ? <ScopeTag className="max-w-full break-all">{msg.scope}</ScopeTag>
{msg.scope ? <ScopeTag boxed className="max-w-full break-all">{msg.scope}</ScopeTag>
: msg.scopeStatus === "unknown" && <span className="rounded-sm bg-text-normal/5 px-1.5 py-px font-mono text-[11px] text-text-dim">{t("channelMessages.unknownScope")}</span>}
{onAnalyze && <button type="button" aria-label={t("channelMessages.inspect", { sender: msg.senderName })}
className="ml-auto shrink-0 cursor-pointer font-mono text-[11px] text-text-dim transition-colors hover:text-primary group-hover:text-primary"
@@ -68,7 +68,7 @@ interface MessagePanelProps {
export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze, onBack, scope = "", onScopeChange }: MessagePanelProps) {
const { t } = useTranslation();
const scopeNames = useScopes();
const scopeNames = useScopes(scope);
const { data, isLoading, isError, isFetching, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["channel-messages", channel?.id, regionKey, scope],
queryFn: ({ pageParam }) => getChannelMessagesPage(channel!.id, { iatas, cursor: pageParam, limit: 50, scope }),
+1 -2
View File
@@ -186,8 +186,7 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable
keepPrevious: true,
});
// scope options are the configured scopes; the filter itself is applied client-side on defaultScope
const scopeOptions = useScopes();
const scopeOptions = useScopes(scopeFilter);
const displayNodes = useMemo(
() => (scopeFilter ? nodes.filter((n) => n.defaultScope === scopeFilter) : nodes),
+1 -2
View File
@@ -152,8 +152,7 @@ export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver,
return [...types].sort();
}, [observers]);
// scope options are the configured scopes; the filter itself is applied client-side on obs.scopes
const scopeOptions = useScopes();
const scopeOptions = useScopes(scope);
const displayObservers = useMemo(
() => (scope ? observers.filter((o) => o.scopes?.includes(scope)) : observers),
+1 -1
View File
@@ -47,7 +47,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
// single-value selections go to the server so scrolling pages through matching history
const serverFilter = useMemo(() => toServerFilter(filters), [filters]);
const pathSearch = useMemo(() => parsePathSearch(filters.search), [filters.search]);
const scopeNames = useScopes();
const scopeNames = useScopes(filters.scopes);
const scopeOptions = useMemo(() => scopeNames.map((s) => ({ value: s, label: s })), [scopeNames]);
const { regionKey } = useRegion();
+15 -8
View File
@@ -1,17 +1,24 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getScopes } from "../api/client";
import { useRegion } from "./useRegion";
// The configured transport scope names (e.g. "#bc", "#west"), from /scopes. This is the authoritative
// list — the scope filters use it for their options so they show every configured scope even before any
// packet/node/observer has been matched to one. Scopes are near-static, so cache long and share across
// tabs via React Query. The filtering itself stays client-side on each record's scope.
export function useScopes(): string[] {
// Scope names for the filter dropdowns, limited to the selected region ("All" lists every scope).
// `keep` is the current selection: it stays an option even when outside the region, so a shared link
// still filters. The filtering itself stays client-side on each record's scope.
export function useScopes(keep?: string | string[]): string[] {
const { iatas, regionKey, isResolved } = useRegion();
const { data } = useQuery({
queryKey: ["scopes"],
queryFn: getScopes,
queryKey: ["scopes", regionKey],
queryFn: () => getScopes(iatas),
enabled: isResolved !== false,
staleTime: 5 * 60_000,
});
return useMemo(() => [...(data ?? [])].sort(), [data]);
const kept = typeof keep === "string" ? keep : keep?.join("\n");
return useMemo(() => {
const names = new Set(data ?? []);
for (const k of kept ? kept.split("\n") : []) names.add(k);
return [...names].sort();
}, [data, kept]);
}
+8
View File
@@ -129,6 +129,14 @@ describe("getScopes", () => {
expect(url).not.toContain("?"); // no query params on the authoritative list
expect(scopes).toEqual(["#bc", "#west"]);
});
it("asks for the selected region's scopes by IATA", async () => {
const getUrl = mockFetchOnce(["#yow"]);
await getScopes(["YOW", "YYZ"]);
expect(new URL(getUrl()).searchParams.get("iatas")).toBe("YOW,YYZ");
});
});
describe("getKnownRoutesPage", () => {
@@ -65,6 +65,11 @@ describe("channel scope evidence", () => {
fireEvent.click(view);
expect(inspect).toHaveBeenCalledTimes(1);
});
it("sizes the scope chip like the heard-count badge", async () => {
show(<MessagePanel channel={channel} heardCounts={{}} regionKey="YOW" />);
const chip = await screen.findByText("#yow");
expect(chip).toHaveClass("border", "px-2", "py-0.5", "font-semibold");
});
it("switches French labels without refetching or changing protocol names", async () => {
show(<MessagePanel channel={channel} heardCounts={{}} regionKey="YOW" />);
await screen.findByText("#yow");
@@ -39,6 +39,7 @@ const multiLineMsg: ChannelMessage = {
sentAt: 4000,
};
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: undefined, regionKey: "*" }) }));
vi.mock("../../../src/api/client", () => ({
getScopes: vi.fn(async () => []),
getChannelMessagesPage: vi.fn(() =>
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useScopes } from "../../src/hooks/useScopes";
import { getScopes } from "../../src/api/client";
const region = { iatas: ["YOW"] as string[] | undefined, regionKey: "YOW", isResolved: true };
vi.mock("../../src/hooks/useRegion", () => ({ useRegion: () => region }));
vi.mock("../../src/api/client", () => ({ getScopes: vi.fn(async (iatas?: string[]) => (iatas ? ["#yow"] : ["#can", "#yow", "#yyz"])) }));
afterEach(() => { vi.clearAllMocks(); region.iatas = ["YOW"]; region.regionKey = "YOW"; region.isResolved = true; });
const setup = (keep?: string | string[]) => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const hook = renderHook(() => useScopes(keep), { wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
return { ...hook, client };
};
it("lists only the selected region's scopes, and every scope for All", async () => {
const { result, rerender, client } = setup();
await waitFor(() => expect(result.current).toEqual(["#yow"]));
expect(getScopes).toHaveBeenCalledWith(["YOW"]);
region.iatas = undefined; region.regionKey = "*"; rerender();
await waitFor(() => expect(result.current).toEqual(["#can", "#yow", "#yyz"]));
expect(getScopes).toHaveBeenLastCalledWith(undefined);
expect(client.getQueryCache().findAll({ queryKey: ["scopes"] })).toHaveLength(2);
});
it("keeps a selected scope from another region so a shared link still filters", async () => {
const { result } = setup(["#yyz"]);
await waitFor(() => expect(result.current).toEqual(["#yow", "#yyz"]));
});
it("does not fall back to every scope while the selected region is unresolved", () => {
region.isResolved = false;
const { result } = setup("#yow");
expect(getScopes).not.toHaveBeenCalled();
expect(result.current).toEqual(["#yow"]);
});