Release v2.0.2 (#149)

* stats: node-types donut, multi-IATA filtering, preset split

 - node-types: new /stats/node-types endpoint + useNodeTypes; donutOption
   replaces the "Coming soon" card with a live donut
 - stats endpoints take iatas[] instead of a single iata, so multi-IATA
   regions filter instead of falling back to all (drops useStatsIata)
 - radio presets keep the node/observer split via stacked presetBarsOption
 - drop client-side telemetry ms-normalization (backend now emits epoch ms
   on both paths); charts pick delta-vs-raw counters off the response interval
 - MeshTab: range-driven charts lead, all-time charts follow; KPI window
   from overview.windowHours instead of a hardcoded 24h
 - ci: docker-publish builds a :dev image on dev-branch pushes

* feat: traces vs pings
feat: SNR for trace path in list
feat: known niehgbour count

* fix: github workflow, post images publically

* build(deps): bump undici from 7.27.2 to 7.28.0 (#9)

Bumps [undici](https://github.com/nodejs/undici) from 7.27.2 to 7.28.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.27.2...v7.28.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.28.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: discover renderers, neighbor map lines, live packet flow

- Discover packets decoded: DISCOVER_REQ/DISCOVER_RESP render real fields
  (type-filter roles, tag, node-to-node request SNR) instead of a raw dump.
- Neighbor lines on the map: On/Selected/Off control draws links between nodes
  and their known neighbors.
- Live packet flow: a play button animates packets hop-to-hop between repeaters
  in real time (off by default).

* map: live mode dims nodes, flashes each packet's route

* map: shoot a comet along each packet's route in live mode

* map: rework live mode — packet dot, dashed trail, per-hop flash

* map: fade live-mode clusters more so dense ones stay see-through

* map: persist clustering + node-type, default neighbor lines to selected

* map: fade live-mode node markers more so overlaps stay see-through

* map: fade the live-mode node flash in sync with its packet trail

* nodes: let the mobile detail panel minimize without deselecting

* map: dim other nodes to spotlight a selected node and its neighbours

* map: colour a selected node's neighbour lines by obs count and freshness

* Add a neighbour graph under the renamed Analytics tab

* Focus the neighbour graph into an ego view on click

* Wrap the analytics sub-header so the range stays on-screen on mobile

* Use a dropdown for the analytics sections on mobile

* Gate the neighbour graph to a region and size labels by busyness

* Add a name search that spotlights matches and dims the rest of the mesh

* Add a copy button beside node and observer public keys

* Add a copy link to node and observer detail panels

Deep-linked ?node/?observer selections now survive load: the region
reset watches the raw selection instead of the async-resolved regionKey,
so slug expansion no longer wipes a restored panel.

* Add .env options for hiding tabs, MeshMapper themes, app name and a GitHub link

* Make VITE_ENABLED_THEMES an exclusive theme allowlist

* Add a .env option to skip the load splash

* Keep the packets list static while scrolled down instead of jumping on new packets

* Add Map deep-links for camera and settings with a copy-link button

* Filter packet history on the server when a single type, route or scope is selected

* Show loading feedback while packet history loads

* build(deps): bump echarts from 5.5.1 to 6.1.0 (#14)

* Fix: github workflow + stats tab + add traces vs pings (#10)

* stats: node-types donut, multi-IATA filtering, preset split

 - node-types: new /stats/node-types endpoint + useNodeTypes; donutOption
   replaces the "Coming soon" card with a live donut
 - stats endpoints take iatas[] instead of a single iata, so multi-IATA
   regions filter instead of falling back to all (drops useStatsIata)
 - radio presets keep the node/observer split via stacked presetBarsOption
 - drop client-side telemetry ms-normalization (backend now emits epoch ms
   on both paths); charts pick delta-vs-raw counters off the response interval
 - MeshTab: range-driven charts lead, all-time charts follow; KPI window
   from overview.windowHours instead of a hardcoded 24h
 - ci: docker-publish builds a :dev image on dev-branch pushes

* feat: traces vs pings
feat: SNR for trace path in list
feat: known niehgbour count

* fix: github workflow, post images publically

* build(deps): bump echarts from 5.5.1 to 6.1.0

Bumps [echarts](https://github.com/apache/echarts) from 5.5.1 to 6.1.0.
- [Release notes](https://github.com/apache/echarts/releases)
- [Commits](https://github.com/apache/echarts/compare/5.5.1...6.1.0)

---
updated-dependencies:
- dependency-name: echarts
  dependency-version: 6.1.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com>
Co-authored-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump version to 1.2.0

* Restore files clobbered by the main merge

* Bump version to 1.2.1

* Drop empty observer telemetry frames and show airtime as duration

* Back out frontend zero-frame filter; fixing at the source instead

* Add 'view path on map' popup to the packet analyzer

* Collapse the path map attribution to a bare (i) by default

* Add path-map deep links, per-observer propagation, and tappable nodes

* Keep path map at a fixed height on narrow screens

* Show packet source and destination on the path map and analyzer

* Add top advertisers to Mesh stats and a Talkers leaderboard tab

* Add public key prefix search to the nodes table

* Show device clock drift on the node detail panel

* Only plot unambiguously-resolved path endpoints on the map

* Fix public-key node search field switching and non-hex input

* Add tests for path endpoint, trace, and clock-drift edge cases

* Refresh stale radio-freq and telemetry comments for current backend

* Send multi-select packet filters to the server

* Group top advertisers and talkers on the Talkers tab

* Lay out Talkers side by side and label advertiser node types

* Mark each top advertiser with its IATA

* Pin overview sparklines to a fixed 24h window

The Observations and Active observers KPI cards show a fixed 24h snapshot
(from /stats/overview), but their sparklines were fed by the range-driven
observations series. Switching to 7d/30d reshaped the sparklines even
though the numbers beside them stayed at 24h, since aggregateByHour drops
empty hours and slice(-24) then spans a different set of hours.

Give the sparklines their own dedicated 24h observations series so the
whole top row is a consistent 24h snapshot; the range selector now only
drives the charts below. React Query dedupes the extra call when the
selector is already on 24h.

* Split top advertisers into flood and direct adverts

Surface each advertiser's flood vs direct advert counts with a per-day
rate for the selected window, as a sortable table on the Talkers tab.

* Add a Clock Drift analytics tab

List repeaters and room servers whose advert-derived clock has drifted
past the server threshold, worst first, in a sortable table. It's each
node's latest reading, so there's no time range to pick.

* Add a toggleable IATA border layer to the map

Draw each active IATA's GeoJSON region border as an outline over a faint
fill beneath the node markers, behind a Map Settings toggle that's off by
default and shareable via a URL param. IATAs with no border answer 204
and are skipped.

* Carry path and endpoint fields from WS observations into packet summaries

* Extract usePacketDetail and share it across the analyzer and overlay

* Split ?hash into row expansion and ?analyze for the analyzer drawer

* Fix ?path deep link when ?analyze is absent

* Add a pure path-summary model for the packet table's path line

* Cover pathBytes-absent and unresolved-endpoint cases in path summary tests

* Render the packet path line with truncation and confidence tints

* Cover endpoint pairs, high tint, and unresolved-run chips in PacketPathLine

* Add the two-line packet table row

* Restore n/a fallback in observer and IATA cells, scope the covering test

* Add the sticky packet table header

* Add the nested observation table for expanded packet rows

* Fix PathData sizing and add null-SNR test to ObservationTable

* Add the expanded packet region with skeleton, error and empty states

* Reorder PacketExpansion to show errors before empty state

* Wire the packet table and expandable rows into the virtual list

* Carry the picked observation into the analyzer and finish two other spec gaps

- Stop resetting selectedObservationId when opening the analyzer, so picking an
  observation inside an expanded row lands the drawer on it instead of observations[0]
- Disable View path on map in the expanded row when the detail has no resolvable path,
  matching the drawer's existing button
- Strip a ?hash deep link once the first page has loaded and it matches no packet

* Stop stripping ?hash on mount, it breaks deep links to older packets

The analyzer drawer and path-map restore both read the live ?hash and
fetch by hash directly, independent of whether the packet is in the
loaded list. Deleting the param once the first page settles without a
match unmounts the drawer and can drop path-map restores, for any
shared link more than a couple of minutes old.

* Keep the packet card layout below md

* Keep the widest heard window when a live packet is re-observed

* Close the analyzer on a mobile tab change and add an expanded-row copy link

* Drop a stale doc pointer from two map comments

* Collapse the packet row to a single line with hops and endpoints

The header and the rows are separate grids, so the shared track list can't use ch or auto — each resolved them differently and the columns drifted apart. Observer moves into the expansion to free the column.

* Open the analyzer from an observation and add a hash size column

Drops the Open analyzer and Copy link buttons from the expansion; the analyzer popup keeps its own copy link. Table rows butt up against each other now, so the whole strip is a click target.

* Filter the region picker by IATA or airport name

Typing narrows both groups: airports match on code or display name,
regions match on their name or on any member code — a code-only match
tags the row with the code that hit, so it doesn't look like a stray
result. Empty groups drop their headers so the divider never dangles.

The panel body moves into its own component so the query dies with the
dropdown, and focus returns to the trigger on close.

* Animate the live map path from source through to destination

Share the endpoint-gating chain builder with the path map so both plot
source and destination when the backend resolved them unambiguously.

* Preserve linebreaks in message bodies

* Rename map live toggle to Live Map and raise its contrast

* Bump version to 1.3.1

* Show observation cards in the expanded row below md

* Handle API rate limiting in the client and back off WS reconnects

* Color observation card edges by SNR, align card stats, join the expanded row

* Add packet deep link investigation notes

* Observer tab: airtime as percent, add heard-activity charts

Airtime telemetry is on-air seconds, so chart it as a percent of elapsed time.
Add channel busy, packets heard, SNR heard and payload type charts from the
new per-observer activity endpoint; hidden until the server has it.

* stats: read renamed telemetry airtime fields (airtimeTxSecs/RxSecs)

* stats tests: follow the airtime field rename

* Apply dependabot bumps; migrate to maplibre-gl 6

Named imports, Vite worker URL via setWorkerUrl, and the single
missing-image resolver replacing the styleimagemissing listeners.

* fix(packets): keep filtered history pageable without scrolling (#34)

* fix(channels): load older directory pages without losing selection (#37)

* fix(channels): page the directory without losing the open channel

* fix(channels): prefer precise server page cursors

* feat(packets): search latest paths by complete hop hashes (#39)

* feat(packets): surface selections outside loaded results (#41)

A ?hash selection outside the loaded or filtered results now shows an Open analyzer notice with loading, error/retry and dismiss states, using the existing detail query.

* fix(region): avoid focus zoom on small filter text (#45)

* feat(packets): display payload summaries in packet rows (#46)

* fix(layout): keep header controls inside narrow viewports (#44)

* fix(layout): keep region picker right-anchored on desktop (#47)

* feat(stats): compare flood packets heard by two observers (#48)

* feat(nodes): display the foreign-repeater indication (#49)

* feat(nodes): display the server foreign-repeater indication

* style(ws): align node update event declaration

* feat(stats): add hourly traffic and region-aware scope analytics (#53)

Adds the Traffic and Scopes analytics tabs. Includes the hourly traffic work from #52, which this PR contains in full.

Closes #50. Closes #51.

* fix(stats): distinguish analytics navigation icons (#59)

* feat(stats): add RF and signal analytics (#55)

* feat(stats): add Paths and Hashes analytics (#57)

* fix(map): omit reset and invalid node locations (#61)

* feat(i18n): add English and French navigation foundation (#62)

* feat(i18n): translate stats analytics, clock drift and timestamps (#70)

* fix(stats): hide unavailable Talkers results (#68)

* fix(stats): hide unavailable Mesh overview data (#72)

* fix(packets): fold summaries into the endpoints cell (#73)

* chore(types): mirror ws opt-in fields from the server (#91)

* feat: observer monitoring, retained evidence and channel scopes (#75-#99)

Lands the stacked web PRs #75, #79, #80, #81, #83, #85, #87, #89, #92, #95 and #99 in one squash. Server counterparts #166-#176 are merged. Remaining review items follow in a fix PR.

* build(deps-dev): bump brace-expansion from 5.0.9 to 5.0.12 (#100)

Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.9 to 5.0.12.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.9...v5.0.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix: follow up on the #75-#99 stack review (#101)

* fix: follow up on last week's merges (#55-#101) (#102)

Bugs: clear compare params on tab change, recover channel live messages after a 429,
observer copy link keeps only region params, route table no longer navigates on region
change with nothing selected, map borders memo on data identity, live overview can't
resurrect a failed query, French relative times, no language write on first visit,
0/0 node locations hidden outside the map, path/signal stats use the shared retry rule.

i18n: node/packet/mesh/observer strings that were still hardcoded after #62.

Cleanup: formatUtc, ACTION_BUTTON_CLASS, observer-stats, useWindowedStats, observer-id;
observer pages use StatCard/Card/Field/Segmented/SearchBar/SelectDropdown; one details
chrome on the stats tabs; comment and translations-guide trims; localStorage stub for
Node 26 in the test setup.

* fix(nodes): clear a node's position when nodeUpdate sends null (#103)

The server now nulls a location on an explicit 0/0 advert and the WS nodeUpdate
event mirrors that: omitted lat/lng keeps the current position, null clears it.
patchNodeSummary used ?? so a clear was ignored.

* Bring back the Analytics observer tab and tidy analytics (#104)

* Polish observer cards and clock drift on phones (#106)

* Observer header details, channel row cleanup, tab order (#107)

* Shared observer sidebar and header actions (#108)

* Labelled observer details and stacked header actions (#109)

* One observations list in the packet analyzer (#110)

* Drop the Observer section from Analytics (#111)

* Analyzer: one observations list, transport scope breakdown (#112)

* Prepare Beacon 2.0.0 (#105)

* Region-filtered scope lists (#113)

* Move channel scope filter into the header (#114)

* Clear map borders when the toggle is turned off (#115)

* Collapse the analyzer's observations list by default (#116)

* Show the observer list beside the dashboard (#117)

* Match the observation card to the theme (#118)

* Shrink the region filter text on desktop (#119)

* Keep the packet drawer and windows open across tabs (#120)

* Open the observer dashboard as a mobile overlay (#121)

* Show observers as a full-width list on mobile (#122)

* Tidy the routes search and range toggle on mobile (#123)

* Add an optional instance banner above the header (#124)

* List keyed channels up front and load the rest on demand (#125)

* Fold route evidence into Route Detail with a recent packets list (#126)

* Plain wording across panels and fix retention-bound views (#127)

* Open the observer dashboard from Inspect observer (#128)

* Translate the remaining hardcoded UI text (#129)

* Close an inspected observer back to its packet on mobile (#130)

* Drop analytics refresh buttons and repeated titles (#131)

* Serve analytics from hourly rollups and handle empty regions (#132)

* Wait for the selected region before loading lists (#133)

* Add hourly sparklines to analytics stat cards (#134)

* Sparklines for most path entries and scope activity (#135)

* Hourly observer and node sparklines on scope cards (#136)

* Fix issues from the 2.0.0 release review (#137)

* Show activity before the first rollup as a gap (#138)

* docs: move deployment docs to beacon-docs (#139)

* docs: move deployment to beacon-docs and add SHOULDERS

* docs: keep only web-specific contributor notes

* docs: show the dev proxy settings needed for live updates

* Add Swedish translation (#142)

* Bump version to 2.0.1 (#143)

* Fix channels page shifting and dropped bottom-nav taps on mobile (#146)

* chore: add .github/CODEOWNERS for main branch policy

Signed-off-by: MrAlders0n <MrAlders0n@smal.ca>

* chore: add .github/workflows/main-source-guard.yml for main branch policy

Signed-off-by: MrAlders0n <MrAlders0n@smal.ca>

* Add server-sorted observer directory pagination

* fix(observers): rank available traffic over rolling seven days

* Bump version to 2.0.2

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com>
Signed-off-by: MrAlders0n <MrAlders0n@smal.ca>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: n30nex <blushellneon@outlook.com>
Co-authored-by: Jesper B <git.lunacy614@passmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-05 09:02:55 -04:00
1 parent 82fb683506
commit 30bfb0bf02
23 files changed
+822 -170

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "beacon-web",
"version": "2.0.1",
"version": "2.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beacon-web",
"version": "2.0.1",
"version": "2.0.2",
"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.0.1",
"version": "2.0.2",
"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,
@@ -304,6 +304,46 @@ export function getNodesPage(
});
}
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,
+20 -12
View File
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BottomSheet } from "./BottomSheet";
import { ENABLED_TABS } from "../lib/constants";
import { touchClick } from "../lib/touch-click";
// Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet.
const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const;
@@ -62,6 +63,7 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
ariaHasPopup?: "menu";
ariaExpanded?: boolean;
}) {
const tap = touchClick(onClick);
return (
<button
type="button"
@@ -69,7 +71,7 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
aria-selected={ariaSelected}
aria-haspopup={ariaHasPopup}
aria-expanded={ariaExpanded}
onClick={onClick}
{...tap}
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-1.5 text-[10px] font-medium tracking-wide cursor-pointer transition-colors ${
active ? "text-primary" : "text-text-muted hover:text-text-normal"
}`}
@@ -80,23 +82,29 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
);
}
function SheetItem({ label, active, onPick }: { label: string; active: boolean; onPick: () => void }) {
const tap = touchClick(onPick);
return (
<button
type="button"
role="menuitem"
{...tap}
className={`w-full flex items-center gap-3 px-5 py-3 text-left text-sm font-medium cursor-pointer transition-colors ${
active ? "text-primary" : "text-text-normal hover:bg-text-normal/3"
}`}
>
{label}
</button>
);
}
// Bottom sheet listing the overflow tabs.
function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activeTab: string; onPick: (tab: string) => void; onClose: () => void }) {
const { t } = useTranslation();
return (
<BottomSheet onClose={onClose} role="menu" label={t("navigation.moreTabs")}>
{tabs.map((tab) => (
<button
key={tab}
type="button"
role="menuitem"
onClick={() => onPick(tab)}
className={`w-full flex items-center gap-3 px-5 py-3 text-left text-sm font-medium cursor-pointer transition-colors ${
activeTab === tab ? "text-primary" : "text-text-normal hover:bg-text-normal/3"
}`}
>
{t(`tabs.${tab}`, { defaultValue: tab })}
</button>
<SheetItem key={tab} label={t(`tabs.${tab}`, { defaultValue: tab })} active={activeTab === tab} onPick={() => onPick(tab)} />
))}
</BottomSheet>
);
+2 -3
View File
@@ -94,7 +94,6 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
[messages],
);
const bottomRef = useRef<HTMLDivElement>(null);
const [userScrolled, setUserScrolled] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const viewKey = `${channel?.id ?? ""}:${scope}`;
@@ -136,7 +135,8 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
}
if (sorted.length > anchor.count && !userScrolled) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); // live message arrived; follow it down
// live message arrived; follow it down. Not scrollIntoView — that also scrolls the page shell
el?.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}
anchor.count = sorted.length;
}, [sorted.length, viewKey, isLoading, userScrolled]);
@@ -221,7 +221,6 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
{sorted.map((msg) => (
<MessageRow key={msg.packetHash || msg.id} msg={msg} heardCount={heardCounts[msg.packetHash]} onAnalyze={onAnalyze} />
))}
<div ref={bottomRef} />
</div>
) : (
<div className="flex items-center justify-center h-32 text-text-muted text-xs font-mono">
+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
@@ -333,6 +333,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
@@ -341,6 +341,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",
+16
View File
@@ -0,0 +1,16 @@
import type { TouchEvent } from "react";
// iOS Safari drops a tap's click when new tappable content appears mid-tap (the live packet list
// does that constantly), so a touch acts on touchend and cancels the click iOS might still send.
export function touchClick(onActivate: () => void) {
return {
onClick: onActivate,
onTouchEnd: (e: TouchEvent<HTMLElement>) => {
const touch = e.changedTouches[0];
const r = e.currentTarget.getBoundingClientRect();
if (!touch || touch.clientX < r.left || touch.clientX > r.right || touch.clientY < r.top || touch.clientY > r.bottom) return;
e.preventDefault();
onActivate();
},
};
}
+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></>;
+28
View File
@@ -41,4 +41,32 @@ describe("BottomNav", () => {
const more = screen.getByText("More").closest("button")!;
expect(more.className).toContain("text-primary");
});
// iOS drops the click when live content lands mid-tap, so a touch acts on touchend alone
const tapEnd = (el: Element, x = 0, y = 0) => fireEvent.touchEnd(el, { changedTouches: [{ clientX: x, clientY: y }] });
it("switches tab on touchend and cancels the trailing click", () => {
const onTabChange = vi.fn();
render(<BottomNav activeTab="Packets" onTabChange={onTabChange} />);
const notPrevented = tapEnd(screen.getByRole("tab", { name: "Channels" }));
expect(onTabChange).toHaveBeenCalledTimes(1);
expect(onTabChange).toHaveBeenCalledWith("Channels");
expect(notPrevented).toBe(false);
});
it("ignores a touch that lifts off the button", () => {
const onTabChange = vi.fn();
render(<BottomNav activeTab="Packets" onTabChange={onTabChange} />);
tapEnd(screen.getByRole("tab", { name: "Channels" }), 50, 50);
expect(onTabChange).not.toHaveBeenCalled();
});
it("opens More and picks an overflow tab by touch", () => {
const onTabChange = vi.fn();
render(<BottomNav activeTab="Packets" onTabChange={onTabChange} />);
tapEnd(screen.getByText("More").closest("button")!);
tapEnd(screen.getByRole("menuitem", { name: "Routes" }));
expect(onTabChange).toHaveBeenCalledWith("Routes");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
});
@@ -48,7 +48,7 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getChannelMessagesPage).mockResolvedValue(page(messages));
vi.mocked(getChannels).mockResolvedValue({ items: [channel], hasMore: false, nextCursor: null });
Element.prototype.scrollIntoView = vi.fn();
Element.prototype.scrollTo = vi.fn();
});
describe("channel scope evidence", () => {
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MessagePanel } from "../../../src/features/channels/MessagePanel";
import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types";
@@ -121,3 +121,30 @@ describe("MessagePanel heard badge", () => {
expect(screen.getByTitle("Entendu 1 fois")).toBeInTheDocument();
});
});
describe("MessagePanel live follow", () => {
it("scrolls only its own list when a live message arrives, not the page", async () => {
const scrollTo = vi.fn();
window.HTMLElement.prototype.scrollTo = scrollTo as unknown as typeof HTMLElement.prototype.scrollTo;
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<MessagePanel channel={channel} heardCounts={{}} regionKey="*" />
</QueryClientProvider>,
);
await screen.findByText("live two");
const live = { ...liveMsgB, packetHash: "ph-live-c", content: "live three", sentAt: 5000 } as ChannelMessage;
act(() => {
qc.setQueryData(["channel-messages", channel.id, "*", ""], (old: { pages: { items: ChannelMessage[] }[] }) => ({
...old,
pages: [{ ...old.pages[0], items: [live, ...old.pages[0].items] }, ...old.pages.slice(1)],
}));
});
await screen.findByText("live three");
// scrollIntoView also scrolls every ancestor up to the document, shoving the whole app shell up
expect(window.HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
expect(scrollTo).toHaveBeenCalled();
});
});
@@ -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,
};
}