Merge upstream observer directory into dev1 preview 2.2.2

This commit is contained in:
n30nex committed 2026-10-07 05:47:43 -04:00
commit 9d08d98956
17 files changed
+727 -153

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "beacon-web",
"version": "2.2.1-n30nex.1",
"version": "2.2.2-n30nex.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beacon-web",
"version": "2.2.1-n30nex.1",
"version": "2.2.2-n30nex.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nazka/map-gl-js-spiderfy": "^2.0.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "beacon-web",
"private": true,
"version": "2.2.1-n30nex.1",
"version": "2.2.2-n30nex.1",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
+41 -1
View File
@@ -2,7 +2,7 @@ import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants";
import { noteRateLimited, noteRequestOk, parseRetryAfter } from "./rate-limit";
import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, RouteEvidence, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api";
import type { ChannelPage, ChannelMessage } from "../features/channels/types";
import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types";
import type { ObserverSummary, Observer, AdvertObservation, ObserverDirectoryPage, ObserverDirectorySort } from "../features/observers/types";
import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types";
import type {
StatsSeries,
@@ -321,6 +321,46 @@ export function getNodesPage(
}, signal);
}
export interface ObserverDirectoryRequest {
location?: StatsRegion;
sort?: ObserverDirectorySort;
since?: number;
until?: number;
cursor?: number;
limit?: number;
name?: string;
type?: string;
broker?: string;
status?: string;
scope?: string;
}
export async function getObserverDirectoryPage(params: ObserverDirectoryRequest, signal?: AbortSignal): Promise<ObserverDirectoryPage> {
const { location, ...filters } = params;
const page = await request<ObserverDirectoryPage>("/observers/directory", { ...statsRegionParams(location), ...filters }, signal);
// A legacy detail response must never be mistaken for a paginated directory.
const isCount = (value: unknown) => value === null || (typeof value === "number" && Number.isFinite(value) && value >= 0);
if (!page || !Array.isArray(page.items) || !Array.isArray(page.observerTypes) || typeof page.hasMore !== "boolean" ||
!Number.isFinite(page.windowStart) || !Number.isFinite(page.windowEnd) || page.windowStart >= page.windowEnd ||
!["traffic", "name"].includes(page.effectiveSort) ||
!["complete", "partial", "unavailable"].includes(page.coverage?.status) || !isCount(page.maxObservationCount) ||
page.items.some(row => !row || typeof row.id !== "string" || !isCount(row.observationCount))) {
throw new Error("Invalid observer directory response");
}
return page;
}
export async function supportsObserverDirectory(signal?: AbortSignal): Promise<boolean> {
try {
await getObserverDirectoryPage({ limit: 1 }, signal);
return true;
} catch (error) {
if (error instanceof ApiError && (error.status === 404 ||
(error.status === 400 && error.message === "failed to parse observer UUID"))) return false;
throw error;
}
}
// Paginated /observers, mirroring getNodesPage; used by the Observers table.
export function getObserversPage(
iatas: string[] | undefined,
+1 -1
View File
@@ -65,7 +65,7 @@ export function ObserverFilterBar({
const controls = (fullWidth: boolean) => (
<>
<SelectDropdown label={t("observerFilters.status")} options={statusOptions} value={statusFilter} onChange={onStatusChange} fullWidth={fullWidth} />
{typeOptions.length > 0 && (
{(typeOptions.length > 0 || typeFilter) && (
<SelectDropdown label={t("observerFilters.type")} options={typeOptions.map((o) => ({ value: o, label: o }))} value={typeFilter} onChange={onTypeChange} fullWidth={fullWidth} />
)}
{brokerOptions.length > 0 && (
+12 -8
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useState, useMemo } from "react";
import { lazy, Suspense, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
@@ -13,7 +13,7 @@ import { useTick } from "../../hooks/useTick";
import { ObserverFilterBar } from "./ObserverFilterBar";
import { ObserverSidebar } from "./ObserverSidebar";
import { ObserverAdverts } from "./ObserverAdverts";
import { filterObservers } from "./observer-filter";
import type { ObserverDirectorySort } from "./types";
import { useObserverDirectory } from "./useObserverDirectory";
import { observerDestination, observerRange } from "./observer-navigation";
import { Segmented } from "../stats/Segmented";
@@ -41,17 +41,17 @@ export function ObserverPage({ wsManager, onAnalyzePacket }: { wsManager: WsMana
const [broker, setBroker] = useState("");
const [scope, setScope] = useState("");
const [minimized, setMinimized] = useState(false);
const directory = useObserverDirectory(wsManager, broker);
const [sort, setSort] = useState<ObserverDirectorySort>("traffic");
const directory = useObserverDirectory(wsManager, { sort, search, status, type, broker, scope });
const { data: brokers } = useQuery({ queryKey: ["brokers"], queryFn: getBrokers, staleTime: 60_000 });
const scopeOptions = useScopes(scope);
const typeOptions = useMemo(() => [...new Set((directory.data ?? []).flatMap((o) => o.observerType ? [o.observerType] : []))].sort(), [directory.data]);
const observers = useMemo(() => filterObservers(directory.data ?? [], { search, status, type, scope }, now), [directory.data, search, status, type, scope, now]);
const typeOptions = directory.observerTypes;
const observers = directory.observers;
const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString();
if (id) share.searchParams.set("range", range);
if (comparing && until != null) share.searchParams.set("compareUntil", String(until));
const select = (observer: string | null) => { setMinimized(false); setParams(observerDestination(params, observer, range)); };
const compare = (observer: string) => {
// eslint-disable-next-line react-hooks/purity -- Capture time when the user invokes this event callback.
const clickedAt = Date.now();
setActionTime(clickedAt);
setParams(old => {
@@ -84,8 +84,12 @@ export function ObserverPage({ wsManager, onAnalyzePacket }: { wsManager: WsMana
/>
<div className="relative flex min-h-0 min-w-0 flex-1">
<div className="flex min-h-0 w-full shrink-0 flex-col md:w-[260px] md:p-4 md:pr-0">
<ObserverSidebar observers={observers} filtered={Boolean(search || status || type || broker || scope)} isPending={directory.isPending} isError={directory.isError}
onRetry={() => void directory.refetch()} range={range} selectedId={id} onSelect={select} />
<ObserverSidebar key={directory.resetKey} observers={observers} filtered={Boolean(search || status || type || broker || scope)} isPending={directory.isPending} isError={directory.isError}
onRetry={() => void directory.retry()} onRefresh={() => void directory.refresh()} selectedId={id} onSelect={select}
sort={sort} onSortChange={setSort} maxObservationCount={directory.maxObservationCount}
coverage={directory.coverage} effectiveSort={directory.effectiveSort} windowEnd={directory.windowEnd}
unsupported={directory.unsupported} hasNextPage={directory.hasNextPage}
isFetchingNextPage={directory.isFetchingNextPage} onLoadMore={directory.loadMore} />
</div>
{/* below md the dashboard overlays the list, like the node detail panel */}
{id ? <div className={`${minimized ? "absolute inset-x-0 bottom-0" : "absolute inset-0"} z-30 flex min-h-0 min-w-0 flex-col bg-bg-base md:static md:z-auto md:flex-1`}>
+61 -49
View File
@@ -1,77 +1,89 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { getTopObservers } from "../../api/client";
import { formatCount } from "../../lib/formatters";
import { formatCount, formatUtc } from "../../lib/formatters";
import { SCROLL_BOTTOM_THRESHOLD_PX } from "../../lib/constants";
import { Segmented } from "../stats/Segmented";
import { gatedOn, sinceFor, useStatsRegion } from "../stats/useStats";
import type { StatsRange } from "../stats/types";
import { observerName as nameOf } from "./observer-filter";
import { deriveObserverStatus } from "./observer-status";
import type { ObserverSummary } from "./types";
import type { ObserverDirectoryItem, ObserverDirectoryPage, ObserverDirectorySort } from "./types";
type Sort = "activity" | "name";
const TOP = 200; // the API's list cap
export function ObserverSidebar({ observers, filtered, isPending, isError, onRetry, range, selectedId, onSelect }: {
observers: ObserverSummary[]; filtered: boolean; isPending: boolean; isError: boolean; onRetry: () => void;
range: StatsRange; selectedId: string | null; onSelect: (id: string) => void;
export function ObserverSidebar({ observers, filtered, isPending, isError, unsupported, onRetry, onRefresh,
sort, onSortChange, maxObservationCount, coverage, effectiveSort, windowEnd,
hasNextPage, isFetchingNextPage, onLoadMore, selectedId, onSelect }: {
observers: ObserverDirectoryItem[]; filtered: boolean; isPending: boolean; isError: boolean;
unsupported: boolean; onRetry: () => void; onRefresh?: () => void;
sort: ObserverDirectorySort; onSortChange: (sort: ObserverDirectorySort) => void;
maxObservationCount: number | null; coverage?: ObserverDirectoryPage["coverage"];
effectiveSort?: ObserverDirectorySort; windowEnd?: number;
hasNextPage: boolean; isFetchingNextPage: boolean; onLoadMore: () => void;
selectedId: string | null; onSelect: (id: string) => void;
}) {
const { t } = useTranslation();
const { where, regionKey, isResolved } = useStatsRegion();
const [sort, setSort] = useState<Sort>("activity");
const activity = useQuery({
queryKey: ["observer-sidebar-activity", regionKey, range],
...gatedOn(isResolved, () => getTopObservers(where, sinceFor(range), TOP)),
staleTime: 30_000,
});
const counts = useMemo(() => new Map((activity.data ?? []).map((o) => [o.observerId, o.observationCount])), [activity.data]);
const max = Math.max(1, ...counts.values());
const rows = useMemo(() => {
const byName = (a: ObserverSummary, b: ObserverSummary) => nameOf(a).localeCompare(nameOf(b), undefined, { numeric: true });
return [...observers].sort(sort === "name" ? byName : (a, b) => (counts.get(b.id) ?? 0) - (counts.get(a.id) ?? 0) || byName(a, b));
}, [observers, sort, counts]);
const scroller = useRef<HTMLDivElement>(null);
const loadNearEnd = useCallback(() => {
const element = scroller.current;
if (element && element.clientHeight > 0 && hasNextPage && !isFetchingNextPage && !isError && !isPending &&
element.scrollHeight - element.scrollTop - element.clientHeight < SCROLL_BOTTOM_THRESHOLD_PX) onLoadMore();
}, [hasNextPage, isFetchingNextPage, isError, isPending, onLoadMore]);
useEffect(() => {
loadNearEnd();
const element = scroller.current;
if (!element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(loadNearEnd);
observer.observe(element);
return () => observer.disconnect();
}, [loadNearEnd, observers.length]);
const messageClass = "px-3 py-3 text-center font-mono text-[11px] text-text-dim";
const retry = <button type="button" onClick={onRetry} className={`${messageClass} w-full text-danger`}>{t("common.loadFailed")} · {t("observerPage.retry")}</button>;
return (
// a card at md+, an edge-to-edge list like the Nodes tab below it
<div className="flex min-h-0 w-full flex-col bg-bg-base md:rounded-lg md:border md:border-border md:bg-bg-surface md:p-3.5">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border-subtle px-4 py-2 md:mb-2.5 md:border-0 md:p-0">
<div className="font-mono text-[11px] font-semibold uppercase tracking-wider text-text-normal">{t("tabs.Observers")}</div>
<Segmented size="sm" ariaLabel={t("observerPage.sort")} value={sort} onChange={(v) => setSort(v as Sort)}
options={[{ value: "activity", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} />
<Segmented size="sm" ariaLabel={t("observerPage.sort")} value={sort} onChange={v => onSortChange(v as ObserverDirectorySort)}
options={[{ value: "traffic", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} />
</div>
{isPending ? <div className="py-6 text-center font-mono text-[11px] text-text-dim">{t("common.loading")}</div>
: isError ? <button type="button" onClick={onRetry} className="py-6 text-center font-mono text-[11px] text-danger">{t("common.loadFailed")} · {t("observerPage.retry")}</button>
: rows.length === 0 ? <div className="py-6 text-center font-mono text-[11px] text-text-dim">{t(filtered ? "observerPage.noMatches" : "observerPage.none")}</div>
: (
<div role="listbox" aria-label={t("tabs.Observers")} className="flex min-h-0 flex-col overflow-y-auto md:-mx-1 md:gap-0.5 md:px-1">
{rows.map((o) => {
{onRefresh && !unsupported && <button type="button" onClick={onRefresh} disabled={isPending || isFetchingNextPage}
className="mb-2 self-end px-3 font-mono text-[10px] text-primary disabled:opacity-50">{t("observerPage.refresh")}</button>}
{coverage && coverage.status !== "complete" && <p className={messageClass}>
{t(maxObservationCount != null ? "observerPage.countsPartial"
: effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")}
</p>}
{windowEnd != null && <div className="shrink-0 px-3 pb-2 font-mono text-[10px] text-text-dim">
<p>{t("observerPage.directoryPeriod")}</p>
<p>{t("observerPage.countsThrough", { time: formatUtc(windowEnd) })}</p>
</div>}
{unsupported ? <div className={messageClass}><p>{t("observerPage.upgradeRequired")}</p>
<button type="button" onClick={onRetry} className="mt-2 text-primary">{t("observerPage.retry")}</button></div>
: isPending ? <div className={messageClass}>{t("common.loading")}</div>
: isError && observers.length === 0 ? retry
: observers.length === 0 ? <div className={messageClass}>{t(filtered ? "observerPage.noMatches" : "observerPage.none")}</div>
: <div ref={scroller} onScroll={loadNearEnd} className="min-h-0 overflow-y-auto md:-mx-1 md:px-1">
<div role="listbox" aria-label={t("tabs.Observers")} className="flex flex-col md:gap-0.5">
{observers.map(o => {
const active = o.id === selectedId;
const count = counts.get(o.id);
const count = o.observationCount;
return (
<button
key={o.id}
type="button"
role="option"
aria-selected={active}
onClick={() => onSelect(o.id)}
<button key={o.id} type="button" role="option" aria-selected={active} onClick={() => onSelect(o.id)}
className={`relative shrink-0 overflow-hidden border-b border-l-2 border-b-border-subtle px-4 py-3 text-left md:rounded md:border-b-0 md:px-2.5 md:py-1.5 transition-colors ${
active ? "border-primary bg-primary/10" : "border-transparent hover:bg-text-normal/3"
}`}
>
{count != null && <div className="absolute inset-y-0 left-0 bg-secondary/8 md:bg-secondary/15" style={{ width: `${(count / max) * 100}%` }} aria-hidden />}
}`}>
{count != null && maxObservationCount != null && <div className="absolute inset-y-0 left-0 bg-secondary/8 md:bg-secondary/15"
style={{ width: `${maxObservationCount > 0 ? Math.min(1, Math.max(0, count / maxObservationCount)) * 100 : 0}%` }} aria-hidden />}
<div className="relative flex items-center gap-2">
<span aria-hidden className={`h-1.5 w-1.5 shrink-0 rounded-full ${deriveObserverStatus(o) === "online" ? "bg-green" : "bg-text-dim/30"}`} />
<span className={`min-w-0 flex-1 truncate font-mono text-[13px] md:text-[12px] ${active ? "text-text-bright" : "text-text-normal"}`}>{nameOf(o)}</span>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-text-muted">{count != null ? formatCount(count) : "—"}</span>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-text-muted">{count != null && maxObservationCount != null ? formatCount(count) : "-"}</span>
</div>
</button>
);
})}
</div>
)}
{isFetchingNextPage ? <div className={messageClass}>{t("common.loading")}</div>
: isError ? retry
: hasNextPage ? <button type="button" onClick={onLoadMore} className={`${messageClass} w-full text-primary`}>{t("observerPage.loadMore")}</button>
: <div className={messageClass}>{t("observerPage.end")}</div>}
</div>}
</div>
);
}
+26
View File
@@ -49,3 +49,29 @@ export interface AdvertObservation {
nodeName?: string;
nodePublicKey?: string;
}
export type ObserverDirectorySort = "traffic" | "name";
export interface ObserverDirectoryItem extends ObserverSummary {
observationCount: number | null;
}
export interface ObserverDirectoryPage {
items: ObserverDirectoryItem[];
nextCursor?: number;
hasMore: boolean;
generatedAt: number;
windowStart: number;
windowEnd: number;
sort: ObserverDirectorySort;
effectiveSort: ObserverDirectorySort;
coverage: {
status: "complete" | "partial" | "unavailable";
expectedHours: number;
completeHours: number;
partialHours: number;
missingHours: number;
};
maxObservationCount: number | null;
observerTypes: string[];
}
+111 -30
View File
@@ -1,39 +1,120 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getObserversPage } from "../../api/client";
import { useRegion } from "../../hooks/useRegion";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import { getObserverDirectoryPage, supportsObserverDirectory } from "../../api/client";
import { API_BASE } from "../../lib/constants";
import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers";
import { patchObserverSummary } from "./observer-updates";
import type { ObserverSummary } from "./types";
import { useStatsRegion } from "../stats/useStats";
import type { ObserverDirectoryItem, ObserverDirectoryPage, ObserverDirectorySort } from "./types";
import type { WsManager } from "../../api/ws-manager";
import type { WsObserverStatus } from "../../types/ws";
const PAGE = 200; // the API's list cap
const MAX_PAGES = 10;
// The whole region fits in a page or two, so sorting and the other filters stay client-side and instant.
async function allObservers(iatas: string[] | undefined, broker: string): Promise<ObserverSummary[]> {
const items: ObserverSummary[] = [];
let cursor: number | undefined;
for (let i = 0; i < MAX_PAGES; i++) {
const page = await getObserversPage(iatas, { cursor, limit: PAGE, broker: broker || undefined });
items.push(...page.items);
if (!page.hasMore || page.nextCursor == null) break;
cursor = page.nextCursor;
}
return items;
export interface ObserverDirectoryOptions {
sort: ObserverDirectorySort;
search: string;
status: string;
type: string;
broker: string;
scope: string;
}
// The region's observers, kept live by WS status events between the minute refetches.
export function useObserverDirectory(wsManager: WsManager, broker: string) {
const { iatas, regionKey, isResolved } = useRegion();
type Continuation = { cursor: number; since: number; until: number } | undefined;
const retryDelay = (attempt: number, error: Error) =>
(error as { retryAfterMs?: number }).retryAfterMs ?? Math.min(1000 * 2 ** attempt, 30_000);
export function useObserverDirectory(wsManager: WsManager, options: ObserverDirectoryOptions) {
const { where, regionKey, isResolved } = useStatsRegion();
const { sort, search, status, type, broker, scope } = options;
const queryClient = useQueryClient();
const queryKey = useMemo(() => ["observer-directory", regionKey, broker], [regionKey, broker]);
const list = useQuery({ queryKey, queryFn: () => allObservers(iatas, broker), enabled: isResolved !== false, staleTime: 30_000, refetchInterval: 60_000 });
const onStatus = useCallback(
(data: WsObserverStatus["data"]) => queryClient.setQueryData<ObserverSummary[]>(queryKey, (old) => patchObserverSummary(old, data)),
[queryClient, queryKey],
);
const capability = useQuery({
queryKey: ["observer-directory-support", API_BASE],
queryFn: ({ signal }) => supportsObserverDirectory(signal),
enabled: isResolved !== false,
staleTime: 60_000,
retryDelay,
});
const queryKey = useMemo(() => ["observer-directory", API_BASE, regionKey, sort, search, status, type, broker, scope],
[regionKey, sort, search, status, type, broker, scope]);
const list = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam, signal }) => getObserverDirectoryPage({
location: where, sort, name: search || undefined, status: status || undefined,
type: type || undefined, broker: broker || undefined, scope: scope || undefined, ...pageParam, limit: 200,
}, signal),
// Refetch starts unbounded; later pages take their bounds from the new first response.
initialPageParam: undefined as Continuation,
getNextPageParam: (last, pages, _lastParam, params): Continuation => {
const first = pages[0];
if (!first || last.effectiveSort !== first.effectiveSort || !last.hasMore || last.nextCursor == null || last.nextCursor === 0 ||
params.some(p => p?.cursor === last.nextCursor)) return undefined;
return { cursor: last.nextCursor, since: first.windowStart, until: first.windowEnd };
},
enabled: isResolved !== false && capability.data === true,
staleTime: Infinity,
refetchInterval: 60_000,
gcTime: 0,
retryDelay,
});
const first = list.data?.pages[0];
const sortChanged = list.data?.pages.some(page => page.effectiveSort !== first?.effectiveSort) ?? false;
const resetKey = JSON.stringify(queryKey);
const restartedKey = useRef<string | null>(null);
useEffect(() => {
// Coverage can oscillate during corrections; restart automatically only once per query.
if (sortChanged && restartedKey.current !== resetKey) {
restartedKey.current = resetKey;
void queryClient.resetQueries({ queryKey, exact: true });
}
}, [sortChanged, resetKey, queryClient, queryKey]);
const onStatus = useCallback((data: WsObserverStatus["data"]) => {
queryClient.setQueryData<InfiniteData<ObserverDirectoryPage, Continuation>>(queryKey, old => old && ({
...old,
pages: old.pages.map(page => ({ ...page, items: page.items.map(row => row.id !== data.observerId ? row : {
...row, status: data.online ? "online" : "offline", lastStatusAt: data.lastStatusAt || row.lastStatusAt,
}) })),
}));
}, [queryClient, queryKey]);
useWsObserverStatusHandler(wsManager, onStatus);
return list;
const observers = useMemo(() => {
const seen = new Set<string>();
const rows: ObserverDirectoryItem[] = [];
for (const page of list.data?.pages ?? []) {
if (page.effectiveSort !== list.data?.pages[0]?.effectiveSort) break;
for (const row of page.items) {
if (!seen.has(row.id)) { seen.add(row.id); rows.push(row); }
}
}
return rows;
}, [list.data]);
const { fetchNextPage, hasNextPage, isFetching } = list;
const loadMore = useCallback(async () => {
if (hasNextPage && !isFetching) await fetchNextPage({ cancelRefetch: false });
}, [fetchNextPage, hasNextPage, isFetching]);
const refresh = async () => {
restartedKey.current = null;
await queryClient.resetQueries({ queryKey, exact: true });
};
const retry = async () => {
if (capability.data !== true) await capability.refetch();
else if (list.isFetchNextPageError && !sortChanged) await loadMore();
else await refresh();
};
return {
observers,
observerTypes: first?.observerTypes ?? [],
maxObservationCount: first?.maxObservationCount ?? null,
coverage: first?.coverage,
effectiveSort: first?.effectiveSort,
windowEnd: first?.windowEnd,
resetKey,
hasNextPage: list.hasNextPage,
isFetchingNextPage: list.isFetchingNextPage,
isPending: !capability.isError && capability.data !== false && (capability.isPending || list.isPending),
isError: capability.isError || list.isError || sortChanged,
unsupported: capability.data === false,
loadMore,
retry,
refresh,
};
}
+9
View File
@@ -335,6 +335,15 @@
}
},
"observerPage": {
"countsPartial": "Counts use available history; coverage is incomplete.",
"directoryPeriod": "Available history, up to 7 days",
"refresh": "Refresh",
"loadMore": "Load more observers",
"end": "All observers loaded",
"upgradeRequired": "Observer pagination requires a server upgrade.",
"countsUnavailable": "Traffic counts unavailable for this window.",
"countsUnavailableName": "Traffic counts unavailable for this window; showing Name order.",
"countsThrough": "Counts through {{time}} UTC",
"back": "Back to observers",
"detail": "Observer detail",
"range": "Time range",
+9
View File
@@ -343,6 +343,15 @@
}
},
"observerPage": {
"countsPartial": "Les compteurs utilisent l’historique disponible ; la couverture est incomplète.",
"directoryPeriod": "Historique disponible, jusqu’à 7 jours",
"refresh": "Actualiser",
"loadMore": "Charger plus d’observateurs",
"end": "Tous les observateurs sont chargés",
"upgradeRequired": "La pagination des observateurs nécessite une mise à jour du serveur.",
"countsUnavailable": "Les compteurs de trafic sont indisponibles pour cette période.",
"countsUnavailableName": "Les compteurs de trafic sont indisponibles pour cette période ; tri par nom.",
"countsThrough": "Comptages jusqu’au {{time}} UTC",
"back": "Retour aux observateurs",
"detail": "Détail de l'observateur",
"range": "Période",
+9
View File
@@ -333,6 +333,15 @@
}
},
"observerPage": {
"countsPartial": "Antalen baseras på tillgänglig historik; täckningen är ofullständig.",
"directoryPeriod": "Tillgänglig historik, upp till 7 dagar",
"refresh": "Uppdatera",
"loadMore": "Ladda fler observatörer",
"end": "Alla observatörer har laddats",
"upgradeRequired": "Sidindelning av observatörer kräver en serveruppgradering.",
"countsUnavailable": "Trafikräkning är inte tillgänglig för denna period.",
"countsUnavailableName": "Trafikräkning är inte tillgänglig för denna period; sorterar efter namn.",
"countsThrough": "Antal fram till {{time}} UTC",
"back": "Tillbaka till observatörer",
"detail": "Observatörsdetaljer",
"range": "Tidsintervall",
+1 -1
View File
@@ -20,7 +20,7 @@ vi.mock("../src/features/routes/RouteTable", () => ({ RouteTable: ({ onViewObser
return <div data-testid="route-origin"><input aria-label="Route filter" value={filter} onChange={e => setFilter(e.target.value)} /><output data-testid="origin-url">{params.toString()}</output><div data-testid="route-scroll" style={{ height: 80, overflow: "auto" }}><div style={{ height: 1000 }}>Routes</div></div><button onClick={() => onViewObserver("o1")}>Route observer</button><button onClick={() => onAnalyzePacket("aa", 7)}>Route packet</button><button onClick={() => onViewNode("n1")}>Route node</button></div>;
} }));
vi.mock("../src/features/observers/ObserverSidebar", () => ({ ObserverSidebar: () => <p>Observer directory</p> }));
vi.mock("../src/features/observers/useObserverDirectory", () => ({ useObserverDirectory: () => ({ data: [], isPending: false, isError: false, refetch: () => {} }) }));
vi.mock("../src/features/observers/useObserverDirectory", () => ({ useObserverDirectory: () => ({ observers: [], observerTypes: [], isPending: false, isError: false, unsupported: false, hasNextPage: false, isFetchingNextPage: false, maxObservationCount: null, resetKey: "test", retry: () => {}, refresh: () => {}, loadMore: () => {} }) }));
vi.mock("../src/features/stats/MeshTab", () => ({ MeshTab: ({ onSelectObserver }: { onSelectObserver: (id: string) => void }) => {
const [value, setValue] = useState("");
return <><input aria-label="Analytics local state" value={value} onChange={e => setValue(e.target.value)} /><button onClick={() => onSelectObserver("o1")}>Leaderboard observer</button></>;
@@ -1,9 +1,9 @@
import { expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { ObserverFilterBar } from "../../../src/features/observers/ObserverFilterBar";
import i18n from "../../../src/i18n";
function renderBar() {
function renderBar(typeFilter = "", typeOptions = ["mqtt"], onTypeChange = vi.fn()) {
return render(
<ObserverFilterBar
search=""
@@ -12,9 +12,9 @@ function renderBar() {
onSearchFieldChange={vi.fn()}
statusFilter=""
onStatusChange={vi.fn()}
typeFilter=""
onTypeChange={vi.fn()}
typeOptions={["mqtt"]}
typeFilter={typeFilter}
onTypeChange={onTypeChange}
typeOptions={typeOptions}
brokerFilter=""
onBrokerChange={vi.fn()}
brokerOptions={["broker-a"]}
@@ -38,3 +38,11 @@ it("labels the observer filters in French", async () => {
for (const label of ["État", "Courtier", "Portée"]) expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/par nom/)).toBeInTheDocument();
});
it("keeps an active type filter clearable when the server facets become empty", () => {
const onTypeChange = vi.fn();
renderBar("mqtt", [], onTypeChange);
fireEvent.click(screen.getByRole("button", { name: /Type/ }));
fireEvent.click(screen.getByRole("option", { name: "All", exact: true }));
expect(onTypeChange).toHaveBeenCalledWith("");
});
+27 -2
View File
@@ -1,10 +1,11 @@
import { describe, it, expect, vi } from "vitest";
import "../../../src/i18n";
import { render, screen, fireEvent, within, waitFor } from "@testing-library/react";
import { act, render, screen, fireEvent, within, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, useNavigate, useLocation } from "react-router-dom";
import type { WsManager } from "../../../src/api/ws-manager";
import type { ObserverSummary } from "../../../src/features/observers/types";
import { useObserverDirectory } from "../../../src/features/observers/useObserverDirectory";
import { ObserverPage } from "../../../src/features/observers/ObserverPage";
const directory: ObserverSummary[] = [
@@ -12,7 +13,12 @@ const directory: ObserverSummary[] = [
{ id: "observer-b", displayName: "Basement B", iata: "YOW", status: "offline", observerType: "RemoteTerm" },
];
vi.mock("../../../src/features/observers/useObserverDirectory", () => ({
useObserverDirectory: () => ({ data: directory, isPending: false, isError: false, refetch: vi.fn() }),
useObserverDirectory: vi.fn((_ws: unknown, options: { search?: string } | string) => {
const search = typeof options === "string" ? "" : options.search ?? "";
const rows = directory.filter(o => search === "%" || o.displayName?.toLowerCase().includes(search));
return { data: directory, observers: rows, observerTypes: ["meshcore-ha", "RemoteTerm"], isPending: false,
isError: false, retry: vi.fn(), refetch: vi.fn(), loadMore: vi.fn(), resetKey: search };
}),
}));
vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] }));
vi.mock("../../../src/api/client", () => ({ getBrokers: () => Promise.resolve([]) }));
@@ -168,3 +174,22 @@ it("rejects duplicate, malformed and future comparison anchors", async () => {
view(`?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=${hour}&compareUntil=${hour}`);
expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null");
});
it("preserves server-filtered wildcard results instead of filtering the loaded page again", async () => {
view();
fireEvent.change(screen.getByPlaceholderText("Search by name..."), { target: { value: "%" } });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 350)); });
expect(list().getAllByRole("button")).toHaveLength(2);
});
it("changes the detail range without changing the directory request", async () => {
view("?tab=Observers&observer=observer-a&range=7d");
expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 7d");
const before = vi.mocked(useObserverDirectory).mock.calls.at(-1)![1];
expect(before).not.toHaveProperty("range");
fireEvent.click(within(screen.getByRole("group", { name: "Time range" })).getByRole("button", { name: "30d" }));
expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 30d");
expect(vi.mocked(useObserverDirectory).mock.calls.at(-1)![1]).toEqual(before);
expect(list().getAllByRole("button")).toHaveLength(2);
});
@@ -1,69 +1,98 @@
import { beforeEach, expect, it, vi } from "vitest";
import { expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "../../../src/i18n";
import { ObserverSidebar } from "../../../src/features/observers/ObserverSidebar";
import { getTopObservers } from "../../../src/api/client";
import type { ObserverSummary } from "../../../src/features/observers/types";
import { directoryRow } from "../../fixtures/observer-directory";
const region = { regionKey: "YOW", iatas: ["YOW"], isResolved: true };
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region }));
vi.mock("../../../src/api/client", () => ({ getTopObservers: vi.fn() }));
const props = {
observers: [directoryRow("Charlie", 900), directoryRow("Alpha", 100), directoryRow("Zero", 0), directoryRow("Unavailable", null)],
filtered: false, isPending: false, isError: false, unsupported: false, onRetry: vi.fn(),
sort: "traffic" as const, onSortChange: vi.fn(), maxObservationCount: 1000,
hasNextPage: true, isFetchingNextPage: false, onLoadMore: vi.fn(),
selectedId: "Alpha", onSelect: vi.fn(),
};
const observers: ObserverSummary[] = [
{ id: "a", displayName: "Alpha", iata: "YOW", status: "online" },
{ id: "b", displayName: "Bravo", iata: "YOW", status: "offline" },
{ id: "c", displayName: "Charlie", iata: "YOW", status: "online" },
];
beforeEach(() => {
vi.mocked(getTopObservers).mockReset().mockResolvedValue([
{ observerId: "c", displayName: "Charlie", iata: "YOW", observationCount: 900 },
{ observerId: "a", displayName: "Alpha", iata: "YOW", observationCount: 100 },
] as never);
it("preserves server order and distinguishes known zero from unavailable counts", () => {
render(<ObserverSidebar {...props} />);
const rows = screen.getAllByRole("option");
expect(rows.map(row => row.textContent)).toEqual(["Receiver Charlie900", "Receiver Alpha100", "Receiver Zero0", "Receiver Unavailable-"]);
expect(rows[1]).toHaveAttribute("aria-selected", "true");
expect(rows[1].querySelector('[style]')).toHaveStyle({ width: "10%" });
expect(rows[2].querySelector('[style]')).toHaveStyle({ width: "0%" });
expect(rows[3].querySelector('[style]')).toBeNull();
fireEvent.click(rows[0]);
expect(props.onSelect).toHaveBeenCalledWith("Charlie");
});
function view({ list = observers, filtered = false, onSelect = vi.fn() } = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<ObserverSidebar observers={list} filtered={filtered} isPending={false} isError={false} onRetry={() => {}} range="7d" selectedId="a" onSelect={onSelect} />
</QueryClientProvider>,
);
return onSelect;
}
const names = () => screen.getAllByRole("option").map((o) => o.textContent);
it("lists every observer busiest first, with idle ones last", async () => {
view();
await screen.findByText("900");
expect(names()).toEqual([expect.stringMatching(/^Charlie.*900/), expect.stringMatching(/^Alpha.*100/), expect.stringMatching(/^Bravo/)]);
expect(screen.getByRole("option", { name: /Alpha/ })).toHaveAttribute("aria-selected", "true");
expect(getTopObservers).toHaveBeenCalledWith(["YOW"], expect.any(Number), 200);
});
it("sorts by name on request", async () => {
view();
await screen.findByText("900");
it("requests server Name sorting and keeps normalization fixed when pages append", () => {
const { rerender } = render(<ObserverSidebar {...props} />);
fireEvent.click(screen.getByRole("button", { name: "Name" }));
expect(names().map((n) => n?.match(/^[A-Z][a-z]+/)?.[0])).toEqual(["Alpha", "Bravo", "Charlie"]);
expect(props.onSortChange).toHaveBeenCalledWith("name");
rerender(<ObserverSidebar {...props} observers={[...props.observers, directoryRow("Later", 2000)]} />);
expect(screen.getByRole("option", { name: /Receiver Alpha/ }).querySelector('[style]')).toHaveStyle({ width: "10%" });
expect(screen.getByRole("option", { name: /Receiver Later/ }).querySelector('[style]')).toHaveStyle({ width: "100%" });
});
it("selects an observer on click", async () => {
const onSelect = view();
fireEvent.click(await screen.findByRole("option", { name: /Charlie/ }));
expect(onSelect).toHaveBeenCalledWith("c");
it("loads near the scroll end but not at the top of a long list or while loading", () => {
const onLoadMore = vi.fn();
const { rerender } = render(<ObserverSidebar {...props} onLoadMore={onLoadMore} />);
const scroll = screen.getByRole("listbox").parentElement!;
Object.defineProperties(scroll, { clientHeight: { value: 400 }, scrollHeight: { value: 2000 }, scrollTop: { value: 0, writable: true } });
fireEvent.scroll(scroll);
expect(onLoadMore).not.toHaveBeenCalled();
scroll.scrollTop = 1500;
fireEvent.scroll(scroll);
expect(onLoadMore).toHaveBeenCalledTimes(1);
rerender(<ObserverSidebar {...props} onLoadMore={onLoadMore} isFetchingNextPage />);
fireEvent.scroll(scroll);
expect(onLoadMore).toHaveBeenCalledTimes(1);
});
it("says when the filters match nothing", () => {
view({ list: [], filtered: true });
it("fills a short viewport automatically, and stops on errors or the final page", () => {
const height = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(500);
const scrollHeight = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(100);
const onLoadMore = vi.fn();
try {
const { rerender } = render(<ObserverSidebar {...props} onLoadMore={onLoadMore} />);
expect(onLoadMore).toHaveBeenCalledTimes(1);
rerender(<ObserverSidebar {...props} onLoadMore={onLoadMore} isError />);
expect(onLoadMore).toHaveBeenCalledTimes(1);
expect(screen.getAllByRole("option")).toHaveLength(4);
fireEvent.click(screen.getByRole("button", { name: /Retry/ }));
expect(props.onRetry).toHaveBeenCalled();
rerender(<ObserverSidebar {...props} onLoadMore={onLoadMore} hasNextPage={false} />);
expect(onLoadMore).toHaveBeenCalledTimes(1);
expect(screen.getByText("All observers loaded")).toBeInTheDocument();
} finally { height.mockRestore(); scrollHeight.mockRestore(); }
});
it("explains incomplete analytics and the server's effective Name order", () => {
render(<ObserverSidebar {...props} maxObservationCount={null} effectiveSort="name" coverage={{ status: "partial", expectedHours: 168, completeHours: 160, missingHours: 8, partialHours: 0 }} />);
expect(screen.getByText(/Traffic counts unavailable/)).toHaveTextContent(/Name order/);
expect(screen.getAllByRole("option")[0]).toHaveTextContent("Receiver Charlie-");
expect(screen.getAllByRole("option")[0].querySelector('[style]')).toBeNull();
});
it("shows upgrade and retry, loading, and empty-filter states", () => {
const { rerender } = render(<ObserverSidebar {...props} observers={[]} unsupported />);
expect(screen.getByText(/server upgrade/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Retry/ })).toBeInTheDocument();
rerender(<ObserverSidebar {...props} observers={[]} isPending />);
expect(screen.getByText("Loading…")).toBeInTheDocument();
rerender(<ObserverSidebar {...props} observers={[]} filtered hasNextPage={false} />);
expect(screen.getByText("No matches")).toBeInTheDocument();
});
it("does not load activity counts for every IATA while the region is unresolved", async () => {
region.isResolved = false;
view();
await Promise.resolve();
expect(getTopObservers).not.toHaveBeenCalled();
region.isResolved = true;
it.each(["partial", "unavailable"] as const)("renders available traffic and zero counts without a Name fallback when coverage is %s", status => {
render(<ObserverSidebar {...props} observers={props.observers.slice(0, 3)} effectiveSort="traffic"
windowEnd={1791162000000} coverage={{ status, expectedHours: 168, completeHours: 0, missingHours: 168, partialHours: 0 }} />);
const rows = screen.getAllByRole("option");
expect(rows.map(row => row.textContent)).toEqual(["Receiver Charlie900", "Receiver Alpha100", "Receiver Zero0"]);
expect(rows[0].querySelector('[style]')).toHaveStyle({ width: "90%" });
expect(rows[2].querySelector('[style]')).toHaveStyle({ width: "0%" });
expect(screen.queryByText(/counts unavailable/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Name order/)).not.toBeInTheDocument();
expect(screen.getByText(/coverage is incomplete/)).toBeInTheDocument();
expect(screen.getByText(/Available history, up to 7 days/)).toBeInTheDocument();
});
@@ -0,0 +1,308 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { WsManager } from "../../../src/api/ws-manager";
import type { WsObserverStatus } from "../../../src/types/ws";
import { useObserverDirectory, type ObserverDirectoryOptions } from "../../../src/features/observers/useObserverDirectory";
import { directoryPage, directoryRow } from "../../fixtures/observer-directory";
const region = { regionKey: "YOW", iatas: ["YOW"], isResolved: true, emptyRegion: undefined as string | undefined };
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region }));
let statusHandler: (data: WsObserverStatus["data"]) => void;
const ws = { onObserverStatus: (handler: typeof statusHandler) => { statusHandler = handler; return () => {}; } } as WsManager;
const defaults: ObserverDirectoryOptions = { sort: "traffic", search: "", status: "", type: "", broker: "", scope: "" };
const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
const failure = (status: number, message = "failed") => reply({ error: { code: "error", message } }, status);
let requests: URL[];
let respond: (url: URL) => Response | Promise<Response>;
beforeEach(() => {
requests = [];
region.isResolved = true; region.iatas = ["YOW"]; region.regionKey = "YOW"; region.emptyRegion = undefined;
respond = () => reply(directoryPage([directoryRow("first")]));
vi.stubGlobal("fetch", vi.fn(async (input: string) => {
const url = new URL(input); requests.push(url);
return url.search === "?limit=1" ? reply(directoryPage([])) : respond(url);
}));
});
afterEach(() => vi.unstubAllGlobals());
function view(options = defaults) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
return renderHook(p => useObserverDirectory(ws, p), { wrapper, initialProps: options });
}
const listingRequests = () => requests.filter(url => url.search !== "?limit=1");
it("loads only the first page initially, then returns later counts beyond 200 without duplicates", async () => {
const first = Array.from({ length: 200 }, (_, i) => directoryRow(String(i), 1000 - i));
let finish: (response: Response) => void;
respond = url => url.searchParams.has("cursor")
? new Promise(resolve => { finish = resolve; })
: reply(directoryPage(first, { hasMore: true, nextCursor: 200 }));
const { result } = view();
await waitFor(() => expect(result.current.observers).toHaveLength(200));
expect(listingRequests()).toHaveLength(1);
expect(listingRequests()[0].pathname).toMatch(/\/observers\/directory$/);
expect(result.current.observerTypes).toEqual(["meshcoretomqtt", "RemoteTerm"]);
act(() => { void result.current.loadMore(); void result.current.loadMore(); });
await waitFor(() => expect(listingRequests()).toHaveLength(2));
expect(Object.fromEntries(listingRequests()[1].searchParams)).toEqual({
iatas: "YOW", sort: "traffic", since: "1790557200000", until: "1791162000000", cursor: "200", limit: "200",
});
await act(async () => finish!(reply(directoryPage([first[199], directoryRow("later", 25), directoryRow("zero", 0)], { maxObservationCount: 2000 }))));
await waitFor(() => expect(result.current.observers).toHaveLength(202));
expect(result.current.observers.at(-2)?.observationCount).toBe(25);
expect(result.current.observers.at(-1)?.observationCount).toBe(0);
expect(result.current.maxObservationCount).toBe(1000);
expect(result.current.hasNextPage).toBe(false);
});
it("sends every filter but lets the server choose the rolling seven-day window", async () => {
const { result } = view({ ...defaults, search: "roof", status: "online", type: "RemoteTerm", broker: "local", scope: "#test" });
await waitFor(() => expect(result.current.observers).toHaveLength(1));
const p = listingRequests()[0].searchParams;
expect(Object.fromEntries(p)).toMatchObject({ iatas: "YOW", name: "roof", status: "online", type: "RemoteTerm", broker: "local", scope: "#test", sort: "traffic" });
expect(p.has("since")).toBe(false);
expect(p.has("until")).toBe(false);
});
it("ignores a slow old-filter page and restarts for new filters and sort", async () => {
let finish: (response: Response) => void;
respond = url => url.searchParams.get("name") === "new"
? reply(directoryPage([directoryRow("new")], { effectiveSort: "name" }))
: url.searchParams.has("cursor") ? new Promise(resolve => { finish = resolve; })
: reply(directoryPage([directoryRow("old")], { hasMore: true, nextCursor: 1 }));
const { result, rerender } = view();
await waitFor(() => expect(result.current.observers[0]?.id).toBe("old"));
act(() => { void result.current.loadMore(); });
await waitFor(() => expect(listingRequests()).toHaveLength(2));
rerender({ ...defaults, search: "new", sort: "name" });
await waitFor(() => expect(result.current.observers.map(o => o.id)).toEqual(["new"]));
await act(async () => finish!(reply(directoryPage([directoryRow("stale")]))));
expect(result.current.observers.map(o => o.id)).toEqual(["new"]);
expect(listingRequests().at(-1)?.searchParams.get("sort")).toBe("name");
expect(listingRequests().at(-1)?.searchParams.has("cursor")).toBe(false);
});
it("restarts at page one when the effective sort changes between pages", async () => {
let firstPages = 0;
respond = url => url.searchParams.has("cursor")
? reply(directoryPage([directoryRow("wrong-order")], { effectiveSort: "name" }))
: reply(++firstPages === 1 ? directoryPage([directoryRow("old")], { hasMore: true, nextCursor: 1 })
: directoryPage([directoryRow("fresh")], { effectiveSort: "name" }));
const { result } = view();
await waitFor(() => expect(result.current.observers[0]?.id).toBe("old"));
act(() => { void result.current.loadMore(); });
await waitFor(() => expect(result.current.observers.map(o => o.id)).toEqual(["fresh"]));
expect(firstPages).toBe(2);
});
it("stops automatic restart loops if effective sort keeps changing, and allows a manual refresh", async () => {
let firstPages = 0;
respond = url => url.searchParams.has("cursor")
? reply(directoryPage([directoryRow("wrong-order")], { effectiveSort: "name" }))
: (++firstPages, reply(directoryPage([directoryRow("first")], { hasMore: true, nextCursor: 1 })));
const { result } = view();
await waitFor(() => expect(result.current.observers).toHaveLength(1));
await act(async () => { await result.current.loadMore(); });
await waitFor(() => expect(firstPages).toBe(2));
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
await act(async () => { await result.current.loadMore(); });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(firstPages).toBe(2);
expect(result.current.observers.map(o => o.id)).toEqual(["first"]);
await act(async () => { await result.current.retry(); });
await waitFor(() => expect(firstPages).toBe(3));
});
it("keeps rows on transient continuation errors and retries the same cursor", async () => {
let attempts = 0;
respond = url => !url.searchParams.has("cursor") ? reply(directoryPage([directoryRow("first")], { hasMore: true, nextCursor: 1 }))
: ++attempts === 1 ? failure(503) : reply(directoryPage([directoryRow("second")]));
const { result } = view();
await waitFor(() => expect(result.current.observers).toHaveLength(1));
await act(async () => { await result.current.loadMore(); });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.observers[0].id).toBe("first");
await act(async () => { await result.current.retry(); });
await waitFor(() => expect(result.current.observers).toHaveLength(2));
expect(listingRequests()[1].search).toBe(listingRequests()[2].search);
});
it("stops on a repeated cursor and leaves loaded membership/order/counts intact on live status", async () => {
respond = url => reply(directoryPage([directoryRow(url.searchParams.has("cursor") ? "second" : "first")], { hasMore: true, nextCursor: 1 }));
const { result } = view();
await waitFor(() => expect(result.current.observers).toHaveLength(1));
await act(async () => { await result.current.loadMore(); });
await waitFor(() => expect(result.current.hasNextPage).toBe(false));
act(() => statusHandler({ observerId: "first", online: false, displayName: "renamed", lastStatusAt: Date.now() - 600000 } as WsObserverStatus["data"]));
await waitFor(() => expect(result.current.observers[0].status).toBe("offline"));
expect(result.current.observers.map(o => [o.id, o.displayName, o.observationCount])).toEqual([["first", "Receiver first", 100], ["second", "Receiver second", 100]]);
});
it("does not request any directory while the region is unresolved", async () => {
region.isResolved = false;
view();
await act(async () => {});
expect(requests).toHaveLength(0);
});
it("passes an empty region by slug instead of loading all observers", async () => {
region.iatas = []; region.emptyRegion = "empty";
const { result } = view();
await waitFor(() => expect(result.current.observers).toHaveLength(1));
expect(listingRequests()[0].searchParams.get("region")).toBe("empty");
});
it.each([404, 400])("shows upgrade required only for the documented legacy probe response (%s), and can detect an upgrade", async status => {
const fetcher = vi.mocked(fetch);
fetcher.mockImplementation(async input => {
const url = new URL(String(input)); requests.push(url);
return failure(status, "failed to parse observer UUID");
});
const { result } = view();
await waitFor(() => expect(result.current.unsupported).toBe(true));
expect(requests.map(u => u.search)).toEqual(["?limit=1"]);
fetcher.mockImplementation(async input => {
const url = new URL(String(input)); requests.push(url);
return reply(directoryPage([directoryRow("upgraded")]));
});
await act(async () => { await result.current.retry(); });
await waitFor(() => expect(result.current.observers[0]?.id).toBe("upgraded"));
});
it.each([400, 500, 503])("does not mistake a probe failure (%s) for an older server", async status => {
vi.mocked(fetch).mockResolvedValue(failure(status, "different failure"));
const { result } = view();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.unsupported).toBe(false);
});
it("rejects a successful probe lacking the directory contract", async () => {
vi.mocked(fetch).mockResolvedValue(reply({ items: [], hasMore: false }));
const { result } = view();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.unsupported).toBe(false);
});
it("repeats every filter on continuation using the response window, then clears pages on refresh", async () => {
let version = 0;
respond = url => reply(directoryPage([directoryRow(url.searchParams.has("cursor") ? "later" : `first-${version}`)], {
hasMore: !url.searchParams.has("cursor"), nextCursor: url.searchParams.has("cursor") ? undefined : 200,
maxObservationCount: version === 0 ? 1000 : 2000,
}));
const { result } = view({ ...defaults, search: "roof", type: "RemoteTerm", status: "online", broker: "local", scope: "#test" });
await waitFor(() => expect(result.current.observers).toHaveLength(1));
await act(async () => { await result.current.loadMore(); });
await waitFor(() => expect(result.current.observers).toHaveLength(2));
expect(Object.fromEntries(listingRequests()[1].searchParams)).toEqual({
iatas: "YOW", sort: "traffic", name: "roof", type: "RemoteTerm", status: "online", broker: "local", scope: "#test",
since: "1790557200000", until: "1791162000000", cursor: "200", limit: "200",
});
version = 1;
await act(async () => { await result.current.refresh(); });
await waitFor(() => expect(result.current.observers.map(o => o.id)).toEqual(["first-1"]));
expect(result.current.maxObservationCount).toBe(2000);
expect(listingRequests().at(-1)?.searchParams.has("cursor")).toBe(false);
});
it("starts a new first page with a server-chosen window on region changes", async () => {
respond = url => reply(directoryPage([directoryRow(url.searchParams.get("iatas")!)]));
const { result, rerender } = view();
await waitFor(() => expect(result.current.observers[0]?.id).toBe("YOW"));
region.iatas = ["YVR"]; region.regionKey = "YVR";
rerender({ ...defaults });
await waitFor(() => expect(result.current.observers.map(o => o.id)).toEqual(["YVR"]));
const p = listingRequests().at(-1)!.searchParams;
expect(p.has("cursor")).toBe(false);
expect(p.has("since")).toBe(false);
expect(p.has("until")).toBe(false);
});
it.each([undefined, 0])("stops when a nonterminal response has no usable cursor (%s)", async nextCursor => {
respond = () => reply(directoryPage([directoryRow("first")], { hasMore: true, nextCursor }));
const { result } = view();
await waitFor(() => expect(result.current.observers).toHaveLength(1));
expect(result.current.hasNextPage).toBe(false);
});
it("refreshes loaded rows each minute so fetched online status does not stay green indefinitely", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-04T12:15:00Z"));
let online = true;
respond = () => reply(directoryPage([{ ...directoryRow("first"), status: online ? "online" : "offline" }]));
try {
const { result } = view();
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
expect(result.current.observers[0]?.status).toBe("online");
online = false;
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(result.current.observers[0]?.status).toBe("offline");
expect(listingRequests()).toHaveLength(2);
} finally { vi.useRealTimers(); }
});
it("uses a fresh rolling window on periodic refresh and replaces all loaded pages atomically", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-05T12:15:23.456Z"));
let generation = 0;
let finishSecondPage: (response: Response) => void;
const page = (part: string, end: number) => directoryPage([directoryRow(`${generation}-${part}`, generation ? 200 : 100)], {
windowStart: end - 7 * 86400000, windowEnd: end,
hasMore: part === "first", nextCursor: part === "first" ? 200 : undefined,
maxObservationCount: generation ? 200 : 100,
});
respond = url => {
const continuation = url.searchParams.has("cursor");
if (generation && continuation) return new Promise(resolve => { finishSecondPage = resolve; });
return reply(page(continuation ? "second" : "first", continuation ? Number(url.searchParams.get("until")) : Date.now()));
};
try {
const { result } = view();
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
await act(async () => { await result.current.loadMore(); await vi.advanceTimersByTimeAsync(10); });
expect(result.current.observers.map(o => o.id)).toEqual(["0-first", "0-second"]);
const oldEnd = result.current.windowEnd;
generation = 1;
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
const refreshFirst = listingRequests().at(-2)!.searchParams;
const refreshNext = listingRequests().at(-1)!.searchParams;
expect(refreshFirst.has("cursor")).toBe(false);
expect(refreshFirst.has("since")).toBe(false);
expect(refreshFirst.has("until")).toBe(false);
expect(Number(refreshNext.get("until"))).toBeGreaterThan(oldEnd!);
expect(Number(refreshNext.get("until")) - Number(refreshNext.get("since"))).toBe(7 * 86400000);
expect(result.current.windowEnd).toBe(oldEnd);
expect(result.current.observers.map(o => o.id)).toEqual(["0-first", "0-second"]);
expect(result.current.maxObservationCount).toBe(100);
await act(async () => {
finishSecondPage!(reply(page("second", Number(refreshNext.get("until")))));
await vi.advanceTimersByTimeAsync(10);
});
expect(result.current.observers.map(o => o.id)).toEqual(["1-first", "1-second"]);
expect(result.current.windowEnd).toBe(Number(refreshNext.get("until")));
expect(result.current.maxObservationCount).toBe(200);
} finally { vi.useRealTimers(); }
});
it("ignores a pending old-window continuation when manual refresh obtains a new window", async () => {
let finishOld: (response: Response) => void;
let fresh = false;
respond = url => url.searchParams.has("cursor") ? new Promise(resolve => { finishOld = resolve; })
: reply(directoryPage([directoryRow(fresh ? "fresh" : "old")], {
windowStart: fresh ? 1790557260123 : 1790557200000, windowEnd: fresh ? 1791162060123 : 1791162000000,
hasMore: !fresh, nextCursor: fresh ? undefined : 200,
}));
const { result } = view();
await waitFor(() => expect(result.current.observers[0]?.id).toBe("old"));
act(() => { void result.current.loadMore(); });
await waitFor(() => expect(listingRequests()).toHaveLength(2));
fresh = true;
await act(async () => { await result.current.refresh(); });
await waitFor(() => expect(result.current.observers.map(o => o.id)).toEqual(["fresh"]));
await act(async () => finishOld!(reply(directoryPage([directoryRow("stale")]))));
expect(result.current.observers.map(o => o.id)).toEqual(["fresh"]);
expect(result.current.windowEnd).toBe(1791162060123);
expect(Object.fromEntries(listingRequests().at(-1)!.searchParams)).toEqual({ iatas: "YOW", sort: "traffic", limit: "200" });
});
+14
View File
@@ -0,0 +1,14 @@
import type { ObserverSummary } from "../../src/features/observers/types";
export const directoryRow = (id: string, count: number | null = 100) => ({
id, displayName: `Receiver ${id}`, iata: "YOW", status: "online" as const, observationCount: count,
});
export function directoryPage(items: (ObserverSummary & { observationCount: number | null })[], extra = {}) {
return {
items, hasMore: false, generatedAt: 1791162000000,
windowStart: 1790557200000, windowEnd: 1791162000000,
sort: "traffic" as const, effectiveSort: "traffic" as const,
coverage: { status: "complete" as const, expectedHours: 168, completeHours: 168, partialHours: 0, missingHours: 0 },
maxObservationCount: 1000, observerTypes: ["meshcoretomqtt", "RemoteTerm"], ...extra,
};
}