mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 21:34:00 +00:00
Merge upstream observer directory into dev1 preview 2.2.2
This commit is contained in:
commit
9d08d98956
17 files changed
+727
-153
No files matched your search
Generated
+2
-2
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
Vendored
+14
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user