mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 17:41:23 +00:00
Fix issues from the 2.0.0 release review (#137)
This commit is contained in:
1 parent
4b9e48a35a
commit
acf46342a1
100 files changed
+1410
-474
No files matched your search
+13
-4
@@ -1,7 +1,5 @@
|
||||
:80 {
|
||||
root * /srv
|
||||
file_server
|
||||
try_files {path} /index.html
|
||||
encode gzip
|
||||
|
||||
header {
|
||||
@@ -10,6 +8,17 @@
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
}
|
||||
|
||||
@hashed path /assets/*
|
||||
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
||||
# A missing hashed asset is a 404, never the app shell cached for a year
|
||||
handle /assets/* {
|
||||
@exists file
|
||||
header @exists Cache-Control "public, max-age=31536000, immutable"
|
||||
file_server
|
||||
}
|
||||
|
||||
# Revalidate the shell so a deploy's new asset hashes are picked up
|
||||
handle {
|
||||
header Cache-Control "no-cache"
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,6 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ENV VITE_API_BASE=__VITE_API_BASE__
|
||||
ENV VITE_WS_URL=__VITE_WS_URL__
|
||||
ENV VITE_MAP_CENTER=__VITE_MAP_CENTER__
|
||||
ENV VITE_MAP_ZOOM=__VITE_MAP_ZOOM__
|
||||
ENV VITE_DISABLED_TABS=__VITE_DISABLED_TABS__
|
||||
ENV VITE_ENABLED_THEMES=__VITE_ENABLED_THEMES__
|
||||
ENV VITE_APP_NAME=__VITE_APP_NAME__
|
||||
ENV VITE_SKIP_SPLASH=__VITE_SKIP_SPLASH__
|
||||
ENV VITE_BANNER=__VITE_BANNER__
|
||||
RUN npm run build
|
||||
|
||||
FROM caddy:2-alpine
|
||||
|
||||
+23
-24
@@ -1,30 +1,29 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
API_BASE="${VITE_API_BASE:-http://localhost:8080/api/v1}"
|
||||
WS_URL="${VITE_WS_URL:-ws://localhost:8080/ws}"
|
||||
# Optional map view — leave empty when unset; the app falls back to a world overview.
|
||||
MAP_CENTER="${VITE_MAP_CENTER:-}"
|
||||
MAP_ZOOM="${VITE_MAP_ZOOM:-}"
|
||||
# Optional branding/customization — empty means default behaviour (all tabs, hidden themes hidden).
|
||||
DISABLED_TABS="${VITE_DISABLED_TABS:-}"
|
||||
ENABLED_THEMES="${VITE_ENABLED_THEMES:-}"
|
||||
SKIP_SPLASH="${VITE_SKIP_SPLASH:-}"
|
||||
# APP_NAME is substituted verbatim; avoid '&' and '|' (sed replacement metachar and delimiter).
|
||||
APP_NAME="${VITE_APP_NAME:-BEACON}"
|
||||
# Same caveat as APP_NAME, plus no quotes (it lands inside a JS string).
|
||||
BANNER="${VITE_BANNER:-}"
|
||||
# Runtime config lives in /config.js (served no-cache), not the hashed bundle, so env changes take
|
||||
# effect on any restart and reach returning visitors. Empty values fall back to the app defaults.
|
||||
OUT="${BEACON_CONFIG_OUT:-/srv/config.js}"
|
||||
KEYS="VITE_API_BASE VITE_WS_URL VITE_MAP_CENTER VITE_MAP_ZOOM VITE_DISABLED_TABS VITE_ENABLED_THEMES VITE_APP_NAME VITE_SKIP_SPLASH VITE_BANNER"
|
||||
|
||||
find /srv -name '*.js' -exec sed -i \
|
||||
-e "s|__VITE_API_BASE__|${API_BASE}|g" \
|
||||
-e "s|__VITE_WS_URL__|${WS_URL}|g" \
|
||||
-e "s|__VITE_MAP_CENTER__|${MAP_CENTER}|g" \
|
||||
-e "s|__VITE_MAP_ZOOM__|${MAP_ZOOM}|g" \
|
||||
-e "s|__VITE_DISABLED_TABS__|${DISABLED_TABS}|g" \
|
||||
-e "s|__VITE_ENABLED_THEMES__|${ENABLED_THEMES}|g" \
|
||||
-e "s|__VITE_APP_NAME__|${APP_NAME}|g" \
|
||||
-e "s|__VITE_SKIP_SPLASH__|${SKIP_SPLASH}|g" \
|
||||
-e "s|__VITE_BANNER__|${BANNER}|g" \
|
||||
{} +
|
||||
# Values are read from ENVIRON and JSON-escaped, so any character is safe.
|
||||
awk -v keys="$KEYS" 'BEGIN {
|
||||
for (i = 1; i < 32; i++) esc[sprintf("%c", i)] = sprintf("\\u%04x", i)
|
||||
esc["\\"] = "\\\\"
|
||||
esc["\""] = "\\\""
|
||||
n = split(keys, k, " ")
|
||||
printf "window.__BEACON_CONFIG__ = {"
|
||||
for (i = 1; i <= n; i++) {
|
||||
v = ENVIRON[k[i]]
|
||||
s = ""
|
||||
for (j = 1; j <= length(v); j++) {
|
||||
c = substr(v, j, 1)
|
||||
s = s ((c in esc) ? esc[c] : c)
|
||||
}
|
||||
printf "%s\n \"%s\": \"%s\"", (i > 1 ? "," : ""), k[i], s
|
||||
}
|
||||
printf "\n};\n"
|
||||
}' > "$OUT.tmp"
|
||||
mv "$OUT.tmp" "$OUT"
|
||||
|
||||
exec "$@"
|
||||
@@ -12,7 +12,6 @@ colour-palettes
|
||||
!.env.example
|
||||
|
||||
.DS_Store
|
||||
.claude
|
||||
.github
|
||||
.gitignore
|
||||
.vscode
|
||||
|
||||
+4
-4
@@ -16,15 +16,15 @@
|
||||
# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics
|
||||
# VITE_DISABLED_TABS=Map,Traces
|
||||
|
||||
# Reveal normally-hidden themes in the picker (comma list of theme ids). Unset = none.
|
||||
# Options: meshmapper_dark,meshmapper_light
|
||||
# Only offer these themes in the picker (comma list of theme ids). Unset = the default set.
|
||||
# Listing a hidden theme (meshmapper_dark, meshmapper_light) is the only way to enable it.
|
||||
# VITE_ENABLED_THEMES=meshmapper_dark,meshmapper_light
|
||||
|
||||
# Custom top-left app name (default: BEACON). Avoid the & and | characters.
|
||||
# Custom top-left app name (default: BEACON).
|
||||
# VITE_APP_NAME=Beacon - MeshMapper
|
||||
|
||||
# Skip the once-per-session load splash. Unset = shown.
|
||||
# VITE_SKIP_SPLASH=true
|
||||
|
||||
# Notice shown above the header on every page; [label](url) and bare URLs become links. Unset = none. Avoid &, | and quotes.
|
||||
# Notice shown above the header on every page; [label](url) and bare URLs become links. Unset = none.
|
||||
# VITE_BANNER=This is the Beacon development instance and may be unstable. For everyday use, visit [live.meshcore.ca](https://live.meshcore.ca).
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install
|
||||
|
||||
@@ -24,9 +24,6 @@ build/
|
||||
coverage/
|
||||
.references/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
+9
-6
@@ -76,7 +76,6 @@ because the root `tsconfig.json` has `files: []`.
|
||||
- Keep components small and single-purpose
|
||||
- Comments state the non-obvious *why* in one terse line; a wrong comment is
|
||||
worse than none
|
||||
- See [.claude/CLAUDE.md](.claude/CLAUDE.md) for the full project conventions
|
||||
|
||||
---
|
||||
|
||||
@@ -111,7 +110,8 @@ docs: expand CONTRIBUTING.md
|
||||
```
|
||||
|
||||
Scopes are optional but helpful. Common scopes mirror the feature folders:
|
||||
`packets`, `nodes`, `observers`, `channels`, `map`, `stats`.
|
||||
`packets`, `nodes`, `observers`, `channels`, `map`, `routes`, `traces`, `stats`,
|
||||
`i18n`.
|
||||
|
||||
---
|
||||
|
||||
@@ -121,9 +121,11 @@ Scopes are optional but helpful. Common scopes mirror the feature folders:
|
||||
src/
|
||||
api/ — typed REST client and WebSocket manager (singleton)
|
||||
components/ — shared UI primitives (DataTable, DetailPanel, Badge, …)
|
||||
features/ — one folder per domain: packets, nodes, observers, channels,
|
||||
map, stats — each with its components, types.ts, and hooks
|
||||
features/ — one folder per domain (packets, nodes, observers, channels,
|
||||
map, routes, traces, stats), each with its components,
|
||||
types.ts, and hooks
|
||||
hooks/ — shared hooks (useRegion, useTheme, useWsHandlers, …)
|
||||
i18n/ — i18next setup and locale catalogs
|
||||
lib/ — constants, formatters, theme utilities
|
||||
types/ — REST shapes (api.ts), enums (enums.ts), WS union (ws.ts)
|
||||
App.tsx — providers, routing, region watcher, WS init
|
||||
@@ -137,8 +139,9 @@ Key patterns to understand before contributing:
|
||||
- **Live + historical merge:** the WebSocket pushes into a capped live buffer;
|
||||
history comes from cursor-paginated `useInfiniteQuery`. A `useMemo` merges and
|
||||
dedupes by `packetHash`.
|
||||
- **Filters are client-side**, applied in a `useMemo` over the cached region
|
||||
dataset — they are not part of the query key, so toggling is instant.
|
||||
- **Packet filters:** payload-type, route-type and scope filters go to the
|
||||
server and into the query key, so paging covers the full filtered history.
|
||||
The observer filter and hash/path search stay client-side in a `useMemo`.
|
||||
- **Payload rendering** switches on `parsedPayload.type` in
|
||||
`features/packets/payload-renderers.tsx`. When the server adds a payload type,
|
||||
add a matching renderer.
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# Packet deep link investigation — 2026-08-07
|
||||
|
||||
## Optional hardening added
|
||||
|
||||
A `hash` selection outside the loaded or filtered results now shows an explicit **Open analyzer** action, using the existing detail query. Loading, request errors, retry and dismissal are visible. Loaded selections continue expanding inline, and `analyze=1` remains the flag that opens the drawer. Filters and region selection are preserved. The historical investigation below explains the original behavior.
|
||||
|
||||
## Outcome: no bug in beacon-web
|
||||
|
||||
A packet deep link that "doesn't pop up" was traced to a hand-built URL missing the
|
||||
`analyze=1` flag. The app behaves as designed and its own generated links are correct.
|
||||
|
||||
Reported URL (nothing opens):
|
||||
|
||||
https://analyzer.meshmapper.net/?iata=YOW&hash=FBF4D4678D849833&tab=Packets
|
||||
|
||||
Same URL with the flag (analyzer opens, fully populated):
|
||||
|
||||
https://analyzer.meshmapper.net/?iata=YOW&hash=FBF4D4678D849833&tab=Packets&analyze=1
|
||||
|
||||
## Why
|
||||
|
||||
`hash` alone is not "open this packet" — it's the *expanded row* selector, and the two
|
||||
features are deliberately separate.
|
||||
|
||||
- `App.tsx:157` — `analyze=1` gates the analyzer drawer:
|
||||
`const analyzerHash = searchParams.get("analyze") === "1" ? searchParams.get("hash") : null;`
|
||||
- `PacketList.tsx:80` — `hash` expands a row inline: `const expandedHash = searchParams.get("hash");`
|
||||
|
||||
Row expansion only works if that row is in the loaded list. The reported packet was ~7h old
|
||||
and the list holds the live window (50 packets, minutes), so there was no row to expand and
|
||||
nothing rendered.
|
||||
|
||||
Both existing comments already say this (`App.tsx:156`, `PacketList.tsx:79`).
|
||||
|
||||
## Links the app generates are fine
|
||||
|
||||
`PacketAnalyzerDrawer.tsx:75` sets the flag explicitly:
|
||||
|
||||
<CopyLinkButton params={{ tab: "Packets", hash: detail.packetHash, analyze: "1" }} ... />
|
||||
|
||||
`App.tsx:191` does the same when the analyzer opens:
|
||||
|
||||
if (hash) { n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); }
|
||||
|
||||
So "Copy Link" always produces a working link. Only hand-written URLs can miss the flag.
|
||||
|
||||
## Verified
|
||||
|
||||
Against prod, with the fix from beacon-server#100 deployed:
|
||||
|
||||
- `GET /api/v1/packets/fbf4d4678d849833` → 200, 16 YOW observations
|
||||
- Packet reachable at page 9 of the paginated YOW list
|
||||
- Without `analyze=1`: list renders, no analyzer, no console errors, and one
|
||||
`/api/v1/packets/FBF4D4678D849833` request fires (`usePacketDetail(expandedHash)` at
|
||||
`PacketList.tsx:92`) whose result has no row to render into
|
||||
- With `analyze=1`: analyzer opens — `FBF4D467 ADVERT ×16`, first/last 7h ago, all 16
|
||||
observations, raw packet, payload breakdown, `FITZZROY_ONE Repeater`
|
||||
|
||||
Note the "Search by hash..." box is a *different* feature bound to `q`, filtering the loaded
|
||||
list client-side with no API call. It can't find packets outside the live window. Working as
|
||||
designed, but easy to mistake for a broken search.
|
||||
|
||||
## Optional hardening (not a defect)
|
||||
|
||||
A `hash` with no matching loaded row silently does nothing and still spends a detail fetch.
|
||||
Two small options, in preference order:
|
||||
|
||||
1. If `hash` is set on load and no row matches, open the analyzer instead of waiting for a row.
|
||||
Makes `?hash=` alone behave the way people expect from a shared link, and reuses the
|
||||
detail request already in flight.
|
||||
2. Leave behaviour alone but surface the miss — a one-line "packet not in the live window"
|
||||
affordance with a button to open the analyzer.
|
||||
|
||||
Neither is required for correctness. Option 1 removes the whole class of report.
|
||||
|
||||
## Related
|
||||
|
||||
beacon-server#100 fixed a genuine backend bug found while chasing this: IATA-filtered packet
|
||||
lists stopped paginating early, stranding older history. Real bug, separate cause — it was
|
||||
never what broke this deep link.
|
||||
@@ -37,6 +37,21 @@ EOF
|
||||
| `VITE_API_BASE` | Backend REST API base URL |
|
||||
| `VITE_WS_URL` | Backend WebSocket URL |
|
||||
|
||||
Optional, all blank by default:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `VITE_MAP_CENTER` / `VITE_MAP_ZOOM` | Fallback map view as decimal `lat,lon` and zoom 0-22, used before airports load or when a selection has none. Unset is a world view |
|
||||
| `VITE_DISABLED_TABS` | Comma list of tabs to hide: `Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics` |
|
||||
| `VITE_ENABLED_THEMES` | Comma list of theme ids. When set, only these themes are offered in the picker (this is also how the hidden `meshmapper_dark` / `meshmapper_light` themes are enabled) |
|
||||
| `VITE_APP_NAME` | Wordmark text in the top-left. Default `BEACON` |
|
||||
| `VITE_SKIP_SPLASH` | `true` skips the once-per-session load splash |
|
||||
| `VITE_BANNER` | Notice shown above the header on every page. `[label](url)` and bare URLs become links |
|
||||
|
||||
See `docker/.env.example` for examples. These are read when the container starts, so after editing
|
||||
`.env` run `docker compose up -d` and visitors get the new values on their next page load. Any
|
||||
characters are fine in values.
|
||||
|
||||
### 3. Start the services
|
||||
|
||||
```bash
|
||||
@@ -58,37 +73,43 @@ and `dev` tracks the `dev` branch. Web and server releases share major.minor ver
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # edit with your backend URLs
|
||||
npm run dev # starts Vite dev server at http://localhost:5173
|
||||
cp .env.example .env.local # optional, edit with your backend URLs
|
||||
npm run dev # starts Vite dev server at http://localhost:5173
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `npm run dev` | Start dev server |
|
||||
| `npm run build` | Type-check and build for production |
|
||||
| `npm run dev` | Start dev server in the foreground |
|
||||
| `npm run start` / `stop` / `restart` | Run the dev server in the background (`scripts/devserver.sh`) |
|
||||
| `npm run dev:status` / `dev:stop-all` | Show or stop background dev servers |
|
||||
| `npm run build` | Type-check and build for production. This is the only real typecheck |
|
||||
| `npm run preview` | Preview production build locally |
|
||||
| `npm run lint` | Run ESLint |
|
||||
| `npx vitest run` | Run tests |
|
||||
| `npx tsc --noEmit` | Type-check without emitting |
|
||||
| `npm test` | Run tests |
|
||||
|
||||
The UI is translatable; see [docs/translations.md](docs/translations.md) to add a language.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.build/
|
||||
Dockerfile # multi-stage build (Node + Caddy)
|
||||
Caddyfile # internal Caddy config (static file serving)
|
||||
docker-entrypoint.sh # runtime env var injection
|
||||
docker/
|
||||
docker-compose.yml # production deployment compose file
|
||||
Caddyfile # internal Caddy config (static file serving)
|
||||
Caddyfile.proxy # reverse proxy config (HTTPS termination)
|
||||
docker-entrypoint.sh # runtime env var injection
|
||||
Dockerfile # multi-stage build (Node + Caddy)
|
||||
.env.example # deployment variables
|
||||
data/Caddy/ # reverse proxy config (Caddyfile.proxy) and cert storage
|
||||
src/
|
||||
api/
|
||||
client.ts # typed REST client (fetch wrapper)
|
||||
ws-manager.ts # WebSocket connection, reconnect, subscription management
|
||||
components/ # shared UI components
|
||||
features/ # feature modules (packets, nodes, channels, map, stats)
|
||||
features/ # feature modules (packets, nodes, observers, channels, map, routes, traces, stats)
|
||||
hooks/ # React hooks (region, theme, WebSocket)
|
||||
i18n/ # i18next setup and locale catalogs
|
||||
lib/ # constants, formatters, theme utilities
|
||||
types/ # TypeScript types and enums
|
||||
App.tsx # providers + routing + WS init
|
||||
@@ -100,7 +121,7 @@ src/
|
||||
|
||||
- **Region-driven**: All data queries and WS subscriptions are scoped to an IATA region code. Changing region resets the cache and resubscribes.
|
||||
- **Live + historical merge**: WebSocket pushes live packets into a `LivePacketStore` buffer (capped at 500). Historical data comes from cursor-paginated REST via `useInfiniteQuery` (max 20 pages). Both are merged and deduped at render time.
|
||||
- **Client-side filtering**: Filters are not part of the query key. The cache holds all packets for the current region; filters are applied via `useMemo`. Toggling a filter is instant with no refetch.
|
||||
- **Packet filtering**: Payload-type, route-type and scope filters are sent to the server and added to the query key, so paging walks the full filtered history. The observer filter and hash/path search stay client-side; one `useMemo` predicate runs over the merged list, so live packets are filtered too.
|
||||
- **Reconnect with jitter**: Exponential backoff with +/-25% random jitter prevents thundering herd on server bounce.
|
||||
|
||||
## Contributing
|
||||
|
||||
+3
-3
@@ -19,11 +19,11 @@ VITE_MAP_ZOOM=3.2
|
||||
# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics
|
||||
VITE_DISABLED_TABS=
|
||||
|
||||
# Reveal normally-hidden themes in the picker (comma list of theme ids). Leave empty for none.
|
||||
# Options: meshmapper_dark,meshmapper_light
|
||||
# Only offer these themes in the picker (comma list of theme ids). Leave empty for the default set.
|
||||
# Listing a hidden theme (meshmapper_dark, meshmapper_light) is the only way to enable it.
|
||||
VITE_ENABLED_THEMES=
|
||||
|
||||
# Custom app name shown in the top-left wordmark. Default: BEACON. Avoid the & and | characters.
|
||||
# Custom app name shown in the top-left wordmark. Default: BEACON.
|
||||
VITE_APP_NAME=
|
||||
|
||||
# Skip the once-per-session load splash. Set to true to skip; leave empty to show it.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -221,4 +221,4 @@
|
||||
font-display: swap;
|
||||
src: url("data:font/woff2;base64,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 truncated
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}</style></head><body style="margin:0"><div class="dc-card" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; vertical-align: baseline; anchor-name: none; anchor-scope: none; animation-composition: replace; appearance: none; aspect-ratio: auto; backdrop-filter: none; backface-visibility: visible; background: rgb(255, 255, 255); background-blend-mode: normal; block-size: 320px; border-block: 0px rgb(0, 0, 0); border-bottom: 0px rgb(0, 0, 0); border-collapse: separate; border-end-end-radius: 2px; border-end-start-radius: 2px; border-image: none; border-inline: 0px rgb(0, 0, 0); border-left: 0px rgb(0, 0, 0); border-right: 0px rgb(0, 0, 0); border-spacing: 0px; border-start-end-radius: 2px; border-start-start-radius: 2px; border-top: 0px rgb(0, 0, 0); border-radius: 0px; bottom: auto; box-decoration-break: slice; box-shadow: none; box-sizing: content-box; break-after: auto; break-before: auto; break-inside: auto; caption-side: top; caret-color: rgb(0, 0, 0); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgb(0, 0, 0); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-scheme: normal; columns: auto; column-fill: balance; gap: normal; column-rule: 3px none rgb(0, 0, 0); column-span: none; contain: none; contain-intrinsic-block-size: none; contain-intrinsic-height: none; contain-intrinsic-inline-size: none; contain-intrinsic-width: none; container: none; content: normal; content-visibility: visible; counter-increment: none; counter-reset: none; counter-set: none; cursor: auto; cx: 0px; cy: 0px; d: none; direction: ltr; display: block; dominant-baseline: auto; empty-cells: show; fill: rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; font-palette: normal; font-synthesis: weight style small-caps position; forced-color-adjust: auto; grid: none; grid-area: auto; height: 320px; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; ime-mode: auto; inline-size: 260px; inset-block: auto; inset-inline: auto; isolation: isolate; left: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; list-style: outside; margin-block: 0px; margin-bottom: 0px; margin-inline: 0px; margin-left: 0px; margin-right: 0px; margin-top: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; offset: normal; opacity: 1; order: 0; outline: rgb(0, 0, 0) 3px; outline-offset: 0px; overflow-anchor: auto; overflow-block: hidden; overflow-clip-margin: 0px; overflow-inline: hidden; overflow-wrap: normal; overflow: hidden; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; overscroll-behavior: auto; padding-block: 0px; padding-bottom: 0px; padding-inline: 0px; padding-left: 0px; padding-right: 0px; padding-top: 0px; page: auto; paint-order: normal; perspective: none; perspective-origin: 130px 160px; pointer-events: auto; position: static; position-anchor: normal; position-area: none; position-try: none; position-visibility: anchors-visible; print-color-adjust: economy; quotes: auto; r: 0px; resize: none; right: auto; rotate: none; ruby-align: space-around; ruby-position: alternate; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-margin-block: 0px; scroll-margin-bottom: 0px; scroll-margin-inline: 0px; scroll-margin-left: 0px; scroll-margin-right: 0px; scroll-margin-top: 0px; scroll-padding-block: auto; scroll-padding-bottom: auto; scroll-padding-inline: auto; scroll-padding-left: auto; scroll-padding-right: auto; scroll-padding-top: auto; scroll-snap-align: none; scroll-snap-stop: normal; scroll-snap-type: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: none; stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 1px; tab-size: 8; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-autospace: no-autospace; text-combine-upright: none; text-decoration: rgb(0, 0, 0); text-decoration-inset: 0px; text-decoration-skip-ink: auto; text-emphasis: none rgb(0, 0, 0); text-emphasis-position: auto; text-indent: 0px; text-justify: auto; text-orientation: mixed; text-overflow: clip; text-rendering: auto; text-shadow: none; text-transform: none; text-underline-offset: auto; text-underline-position: auto;Line truncated
|
||||
}</style></head><body style="margin:0"><div class="dc-card" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; vertical-align: baseline; anchor-name: none; anchor-scope: none; animation-composition: replace; appearance: none; aspect-ratio: auto; backdrop-filter: none; backface-visibility: visible; background: rgb(255, 255, 255); background-blend-mode: normal; block-size: 320px; border-block: 0px rgb(0, 0, 0); border-bottom: 0px rgb(0, 0, 0); border-collapse: separate; border-end-end-radius: 2px; border-end-start-radius: 2px; border-image: none; border-inline: 0px rgb(0, 0, 0); border-left: 0px rgb(0, 0, 0); border-right: 0px rgb(0, 0, 0); border-spacing: 0px; border-start-end-radius: 2px; border-start-start-radius: 2px; border-top: 0px rgb(0, 0, 0); border-radius: 0px; bottom: auto; box-decoration-break: slice; box-shadow: none; box-sizing: content-box; break-after: auto; break-before: auto; break-inside: auto; caption-side: top; caret-color: rgb(0, 0, 0); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgb(0, 0, 0); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-scheme: normal; columns: auto; column-fill: balance; gap: normal; column-rule: 3px none rgb(0, 0, 0); column-span: none; contain: none; contain-intrinsic-block-size: none; contain-intrinsic-height: none; contain-intrinsic-inline-size: none; contain-intrinsic-width: none; container: none; content: normal; content-visibility: visible; counter-increment: none; counter-reset: none; counter-set: none; cursor: auto; cx: 0px; cy: 0px; d: none; direction: ltr; display: block; dominant-baseline: auto; empty-cells: show; fill: rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; font-palette: normal; font-synthesis: weight style small-caps position; forced-color-adjust: auto; grid: none; grid-area: auto; height: 320px; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; ime-mode: auto; inline-size: 260px; inset-block: auto; inset-inline: auto; isolation: isolate; left: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; list-style: outside; margin-block: 0px; margin-bottom: 0px; margin-inline: 0px; margin-left: 0px; margin-right: 0px; margin-top: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; offset: normal; opacity: 1; order: 0; outline: rgb(0, 0, 0) 3px; outline-offset: 0px; overflow-anchor: auto; overflow-block: hidden; overflow-clip-margin: 0px; overflow-inline: hidden; overflow-wrap: normal; overflow: hidden; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; overscroll-behavior: auto; padding-block: 0px; padding-bottom: 0px; padding-inline: 0px; padding-left: 0px; padding-right: 0px; padding-top: 0px; page: auto; paint-order: normal; perspective: none; perspective-origin: 130px 160px; pointer-events: auto; position: static; position-anchor: normal; position-area: none; position-try: none; position-visibility: anchors-visible; print-color-adjust: economy; quotes: auto; r: 0px; resize: none; right: auto; rotate: none; ruby-align: space-around; ruby-position: alternate; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-margin-block: 0px; scroll-margin-bottom: 0px; scroll-margin-inline: 0px; scroll-margin-left: 0px; scroll-margin-right: 0px; scroll-margin-top: 0px; scroll-padding-block: auto; scroll-padding-bottom: auto; scroll-padding-inline: auto; scroll-padding-left: auto; scroll-padding-right: auto; scroll-padding-top: auto; scroll-snap-align: none; scroll-snap-stop: normal; scroll-snap-type: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: none; stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 1px; tab-size: 8; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-autospace: no-autospace; text-combine-upright: none; text-decoration: rgb(0, 0, 0); text-decoration-inset: 0px; text-decoration-skip-ink: auto; text-emphasis: none rgb(0, 0, 0); text-emphasis-position: auto; text-indent: 0px; text-justify: auto; text-orientation: mixed; text-overflow: clip; text-rendering: auto; text-shadow: none; text-transform: none; text-underline-offset: auto; text-underline-position: auto;Line truncated
|
||||
@@ -221,4 +221,4 @@
|
||||
font-display: swap;
|
||||
src: url("data:font/woff2;base64,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 truncated
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}</style></head><body style="margin:0"><div class="dc-card" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; vertical-align: baseline; anchor-name: none; anchor-scope: none; animation-composition: replace; appearance: none; aspect-ratio: auto; backdrop-filter: none; backface-visibility: visible; background: rgb(255, 255, 255); background-blend-mode: normal; block-size: 150px; border-block: 0px rgb(0, 0, 0); border-bottom: 0px rgb(0, 0, 0); border-collapse: separate; border-end-end-radius: 2px; border-end-start-radius: 2px; border-image: none; border-inline: 0px rgb(0, 0, 0); border-left: 0px rgb(0, 0, 0); border-right: 0px rgb(0, 0, 0); border-spacing: 0px; border-start-end-radius: 2px; border-start-start-radius: 2px; border-top: 0px rgb(0, 0, 0); border-radius: 0px; bottom: auto; box-decoration-break: slice; box-shadow: none; box-sizing: content-box; break-after: auto; break-before: auto; break-inside: auto; caption-side: top; caret-color: rgb(0, 0, 0); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgb(0, 0, 0); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-scheme: normal; columns: auto; column-fill: balance; gap: normal; column-rule: 3px none rgb(0, 0, 0); column-span: none; contain: none; contain-intrinsic-block-size: none; contain-intrinsic-height: none; contain-intrinsic-inline-size: none; contain-intrinsic-width: none; container: none; content: normal; content-visibility: visible; counter-increment: none; counter-reset: none; counter-set: none; cursor: auto; cx: 0px; cy: 0px; d: none; direction: ltr; display: block; dominant-baseline: auto; empty-cells: show; fill: rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; font-palette: normal; font-synthesis: weight style small-caps position; forced-color-adjust: auto; grid: none; grid-area: auto; height: 150px; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; ime-mode: auto; inline-size: 480px; inset-block: auto; inset-inline: auto; isolation: isolate; left: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; list-style: outside; margin-block: 0px; margin-bottom: 0px; margin-inline: 0px; margin-left: 0px; margin-right: 0px; margin-top: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; offset: normal; opacity: 1; order: 0; outline: rgb(0, 0, 0) 3px; outline-offset: 0px; overflow-anchor: auto; overflow-block: hidden; overflow-clip-margin: 0px; overflow-inline: hidden; overflow-wrap: normal; overflow: hidden; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; overscroll-behavior: auto; padding-block: 0px; padding-bottom: 0px; padding-inline: 0px; padding-left: 0px; padding-right: 0px; padding-top: 0px; page: auto; paint-order: normal; perspective: none; perspective-origin: 240px 75px; pointer-events: auto; position: static; position-anchor: normal; position-area: none; position-try: none; position-visibility: anchors-visible; print-color-adjust: economy; quotes: auto; r: 0px; resize: none; right: auto; rotate: none; ruby-align: space-around; ruby-position: alternate; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-margin-block: 0px; scroll-margin-bottom: 0px; scroll-margin-inline: 0px; scroll-margin-left: 0px; scroll-margin-right: 0px; scroll-margin-top: 0px; scroll-padding-block: auto; scroll-padding-bottom: auto; scroll-padding-inline: auto; scroll-padding-left: auto; scroll-padding-right: auto; scroll-padding-top: auto; scroll-snap-align: none; scroll-snap-stop: normal; scroll-snap-type: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: none; stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 1px; tab-size: 8; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-autospace: no-autospace; text-combine-upright: none; text-decoration: rgb(0, 0, 0); text-decoration-inset: 0px; text-decoration-skip-ink: auto; text-emphasis: none rgb(0, 0, 0); text-emphasis-position: auto; text-indent: 0px; text-justify: auto; text-orientation: mixed; text-overflow: clip; text-rendering: auto; text-shadow: none; text-transform: none; text-underline-offset: auto; text-underline-position: auto; Line truncated
|
||||
}</style></head><body style="margin:0"><div class="dc-card" style="accent-color: auto; place-content: normal; place-items: normal; place-self: auto; vertical-align: baseline; anchor-name: none; anchor-scope: none; animation-composition: replace; appearance: none; aspect-ratio: auto; backdrop-filter: none; backface-visibility: visible; background: rgb(255, 255, 255); background-blend-mode: normal; block-size: 150px; border-block: 0px rgb(0, 0, 0); border-bottom: 0px rgb(0, 0, 0); border-collapse: separate; border-end-end-radius: 2px; border-end-start-radius: 2px; border-image: none; border-inline: 0px rgb(0, 0, 0); border-left: 0px rgb(0, 0, 0); border-right: 0px rgb(0, 0, 0); border-spacing: 0px; border-start-end-radius: 2px; border-start-start-radius: 2px; border-top: 0px rgb(0, 0, 0); border-radius: 0px; bottom: auto; box-decoration-break: slice; box-shadow: none; box-sizing: content-box; break-after: auto; break-before: auto; break-inside: auto; caption-side: top; caret-color: rgb(0, 0, 0); clear: none; clip: auto; clip-path: none; clip-rule: nonzero; color: rgb(0, 0, 0); color-interpolation: srgb; color-interpolation-filters: linearrgb; color-scheme: normal; columns: auto; column-fill: balance; gap: normal; column-rule: 3px none rgb(0, 0, 0); column-span: none; contain: none; contain-intrinsic-block-size: none; contain-intrinsic-height: none; contain-intrinsic-inline-size: none; contain-intrinsic-width: none; container: none; content: normal; content-visibility: visible; counter-increment: none; counter-reset: none; counter-set: none; cursor: auto; cx: 0px; cy: 0px; d: none; direction: ltr; display: block; dominant-baseline: auto; empty-cells: show; fill: rgb(0, 0, 0); fill-opacity: 1; fill-rule: nonzero; filter: none; flex: 0 1 auto; flex-flow: row; float: none; flood-color: rgb(0, 0, 0); flood-opacity: 1; font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; font-palette: normal; font-synthesis: weight style small-caps position; forced-color-adjust: auto; grid: none; grid-area: auto; height: 150px; hyphenate-character: auto; hyphenate-limit-chars: auto; hyphens: manual; image-orientation: from-image; image-rendering: auto; ime-mode: auto; inline-size: 480px; inset-block: auto; inset-inline: auto; isolation: isolate; left: auto; letter-spacing: normal; lighting-color: rgb(255, 255, 255); line-break: auto; list-style: outside; margin-block: 0px; margin-bottom: 0px; margin-inline: 0px; margin-left: 0px; margin-right: 0px; margin-top: 0px; marker: none; mask: none; mask-type: luminance; math-depth: 0; math-shift: normal; math-style: normal; max-block-size: none; max-height: none; max-inline-size: none; max-width: none; min-block-size: 0px; min-height: 0px; min-inline-size: 0px; min-width: 0px; mix-blend-mode: normal; object-fit: fill; object-position: 50% 50%; offset: normal; opacity: 1; order: 0; outline: rgb(0, 0, 0) 3px; outline-offset: 0px; overflow-anchor: auto; overflow-block: hidden; overflow-clip-margin: 0px; overflow-inline: hidden; overflow-wrap: normal; overflow: hidden; overscroll-behavior-block: auto; overscroll-behavior-inline: auto; overscroll-behavior: auto; padding-block: 0px; padding-bottom: 0px; padding-inline: 0px; padding-left: 0px; padding-right: 0px; padding-top: 0px; page: auto; paint-order: normal; perspective: none; perspective-origin: 240px 75px; pointer-events: auto; position: static; position-anchor: normal; position-area: none; position-try: none; position-visibility: anchors-visible; print-color-adjust: economy; quotes: auto; r: 0px; resize: none; right: auto; rotate: none; ruby-align: space-around; ruby-position: alternate; rx: auto; ry: auto; scale: none; scroll-behavior: auto; scroll-margin-block: 0px; scroll-margin-bottom: 0px; scroll-margin-inline: 0px; scroll-margin-left: 0px; scroll-margin-right: 0px; scroll-margin-top: 0px; scroll-padding-block: auto; scroll-padding-bottom: auto; scroll-padding-inline: auto; scroll-padding-left: auto; scroll-padding-right: auto; scroll-padding-top: auto; scroll-snap-align: none; scroll-snap-stop: normal; scroll-snap-type: none; scrollbar-color: auto; scrollbar-gutter: auto; scrollbar-width: auto; shape-image-threshold: 0; shape-margin: 0px; shape-outside: none; shape-rendering: auto; stop-color: rgb(0, 0, 0); stop-opacity: 1; stroke: none; stroke-dasharray: none; stroke-dashoffset: 0px; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-opacity: 1; stroke-width: 1px; tab-size: 8; table-layout: auto; text-align: start; text-align-last: auto; text-anchor: start; text-autospace: no-autospace; text-combine-upright: none; text-decoration: rgb(0, 0, 0); text-decoration-inset: 0px; text-decoration-skip-ink: auto; text-emphasis: none rgb(0, 0, 0); text-emphasis-position: auto; text-indent: 0px; text-justify: auto; text-orientation: mixed; text-overflow: clip; text-rendering: auto; text-shadow: none; text-transform: none; text-underline-offset: auto; text-underline-position: auto; Line truncated
|
||||
@@ -0,0 +1,2 @@
|
||||
// Replaced at container start by .build/docker-entrypoint.sh; empty here so the VITE_* build env applies.
|
||||
window.__BEACON_CONFIG__ = {};
|
||||
+4
-2
@@ -246,6 +246,8 @@ function AppInner() {
|
||||
});
|
||||
};
|
||||
|
||||
// offered only off the Map tab, and only when the deployment has one
|
||||
const canViewOnMap = activeTab !== "Map" && ENABLED_TABS.includes("Map");
|
||||
const handleViewOnMap = (nodeId: string, lat: number, lng: number) => {
|
||||
handleTabChange("Map", { lat, lng });
|
||||
if (!isMobile) setSelectedNodeId(nodeId);
|
||||
@@ -356,10 +358,10 @@ function AppInner() {
|
||||
onViewObserver={viewObserver}
|
||||
onViewNode={setSelectedNodeId}
|
||||
onAnalyzePacket={viewPacket}
|
||||
onViewOnMap={activeTab === "Map" ? undefined : (lat, lng) => handleViewOnMap(selectedNodeId, lat, lng)}
|
||||
onViewOnMap={canViewOnMap ? (lat, lng) => handleViewOnMap(selectedNodeId, lat, lng) : undefined}
|
||||
/>
|
||||
)}
|
||||
{panels.map((panel, index) => <InvestigationPanel key={panel.key} target={panel.target} inactive={index !== panels.length - 1} onClose={() => closePanel(index)} onOpen={openPanel} onViewObserver={viewObserver} onViewOnMap={activeTab === "Map" ? undefined : handleViewOnMap} />)}
|
||||
{panels.map((panel, index) => <InvestigationPanel key={panel.key} target={panel.target} inactive={index !== panels.length - 1} onClose={() => closePanel(index)} onOpen={openPanel} onViewObserver={viewObserver} onViewOnMap={canViewOnMap ? handleViewOnMap : undefined} />)}
|
||||
</div>
|
||||
</AppShell>
|
||||
</RegionProvider>
|
||||
|
||||
+13
-7
@@ -183,26 +183,32 @@ export function getScopes(iatas?: string[]): Promise<string[]> {
|
||||
|
||||
// Wrap a bare-array endpoint into a CursorPage so it can drive the cursor-paginated hooks. A page that
|
||||
// fills the limit may have more behind it; the next cursor is the last (boundary) row's sort key.
|
||||
function toCursorPage<T>(items: T[], limit: number, cursorOf: (last: T) => number): CursorPage<T> {
|
||||
function toCursorPage<T, C = number>(items: T[], limit: number, cursorOf: (last: T) => C): CursorPage<T, C> {
|
||||
const hasMore = items.length === limit;
|
||||
return { items, nextCursor: hasMore ? cursorOf(items[items.length - 1]!) : null, hasMore };
|
||||
}
|
||||
|
||||
// Known routes. /routes returns a bare array ordered last_seen DESC; its cursor pages by last_seen (ms),
|
||||
// so the last row carries the page's smallest last_seen — the cursor for the next (older) batch. We wrap
|
||||
// Known routes. /routes returns a bare array ordered last_seen DESC, id DESC; the next (older) batch
|
||||
// starts after the last row's lastSeen (ms), with its id breaking ties inside that millisecond. We wrap
|
||||
// it into a CursorPage here so RouteTable can stream pages via useInfinitePages. `iata` is a single code
|
||||
// ("" = all), unlike the comma-separated `iatas` used elsewhere.
|
||||
export interface RouteCursor {
|
||||
lastSeen: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export async function getKnownRoutesPage(
|
||||
params?: { iata?: string; hopCount?: number; cursor?: number; limit?: number },
|
||||
): Promise<CursorPage<KnownRoute>> {
|
||||
params?: { iata?: string; hopCount?: number; cursor?: RouteCursor; limit?: number },
|
||||
): Promise<CursorPage<KnownRoute, RouteCursor>> {
|
||||
const limit = params?.limit ?? DEFAULT_PAGE_SIZE;
|
||||
const items = await request<KnownRoute[]>("/routes", {
|
||||
iata: params?.iata,
|
||||
hopCount: params?.hopCount,
|
||||
cursor: params?.cursor,
|
||||
cursor: params?.cursor?.lastSeen,
|
||||
cursorId: params?.cursor?.id,
|
||||
limit,
|
||||
});
|
||||
return toCursorPage(items, limit, (r) => r.lastSeen);
|
||||
return toCursorPage(items, limit, (r) => ({ lastSeen: r.lastSeen, id: r.id }));
|
||||
}
|
||||
|
||||
export function getRouteEvidence(iata: string, pathKey: string, params: { range?: string; since?: number; until?: number; pageCursor?: string; limit?: number }, signal?: AbortSignal): Promise<RouteEvidence> {
|
||||
|
||||
@@ -39,6 +39,8 @@ export class WsManager {
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private msgCounter = 0;
|
||||
private lastEventTimestamp: number = Date.now();
|
||||
// liveness for the ping check; unlike lastEventTimestamp it restarts with each connection
|
||||
private lastHeardAt: number = Date.now();
|
||||
|
||||
private packetHandlers: PacketHandler[] = [];
|
||||
private laggedHandlers: LaggedHandler[] = [];
|
||||
@@ -190,6 +192,7 @@ export class WsManager {
|
||||
case "hello": {
|
||||
const isReconnect = this.everConnected;
|
||||
this.everConnected = true;
|
||||
this.lastHeardAt = Date.now();
|
||||
this.setStatus("connected");
|
||||
this.startPing();
|
||||
this.sendSubscribe();
|
||||
@@ -219,11 +222,11 @@ export class WsManager {
|
||||
|
||||
case "pong":
|
||||
// a pong proves the link is alive, so it counts as recent activity
|
||||
this.lastEventTimestamp = Date.now();
|
||||
this.lastEventTimestamp = this.lastHeardAt = Date.now();
|
||||
break;
|
||||
|
||||
case "event":
|
||||
this.lastEventTimestamp = Date.now();
|
||||
this.lastEventTimestamp = this.lastHeardAt = Date.now();
|
||||
if (msg.event === "packetObservation") {
|
||||
for (const handler of this.packetHandlers) {
|
||||
handler(msg.data);
|
||||
@@ -245,7 +248,7 @@ export class WsManager {
|
||||
|
||||
case "lagged":
|
||||
// a lag notice is still server traffic, so it counts as recent activity
|
||||
this.lastEventTimestamp = Date.now();
|
||||
this.lastEventTimestamp = this.lastHeardAt = Date.now();
|
||||
for (const handler of this.laggedHandlers) {
|
||||
handler(msg);
|
||||
}
|
||||
@@ -257,6 +260,8 @@ export class WsManager {
|
||||
}
|
||||
|
||||
private sendSubscribe(): void {
|
||||
// any subscribe still awaiting its ack is superseded, even when nothing replaces it
|
||||
this.lastSubscribeId = null;
|
||||
// the server reads an empty IATA list as "all", so a region with no IATAs subscribes to nothing
|
||||
if (!this.filter || this.filter.iatas?.length === 0) return;
|
||||
const id = `sub-${this.nextId()}`;
|
||||
@@ -276,7 +281,7 @@ export class WsManager {
|
||||
private startPing(): void {
|
||||
if (this.pingTimer) clearInterval(this.pingTimer); // a second hello must not double the interval
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (Date.now() - this.lastEventTimestamp > WS_PING_INTERVAL_MS * 2 + 5_000) {
|
||||
if (Date.now() - this.lastHeardAt > WS_PING_INTERVAL_MS * 2 + 5_000) {
|
||||
// pongs stopped coming back — the link is half-open, rebuild it
|
||||
this.forceReconnect();
|
||||
return;
|
||||
|
||||
@@ -481,7 +481,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe
|
||||
</nav>
|
||||
|
||||
<main className="flex-1 flex flex-col min-h-0">
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
<ErrorBoundary resetKey={activeTab}>{children}</ErrorBoundary>
|
||||
</main>
|
||||
|
||||
<footer className="hidden md:flex items-center px-4 py-1.5 bg-bg-surface border-t border-border font-mono text-[11px] text-text-dim shrink-0">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VARIANT_CLASSES } from "./badge-utils";
|
||||
import { VARIANT_CLASSES, COPY_STATE_VARIANT } from "./badge-utils";
|
||||
import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
|
||||
|
||||
// Copy-to-clipboard pill, styled to match the analyzer's "Copy Link" button: flips to a green
|
||||
// "Copied" state for 1.5s after a click. aria-label defaults to the visible label.
|
||||
// "Copied" state for 1.5s after a click (or a red "Copy failed"). aria-label defaults to the visible label.
|
||||
export function CopyButton({
|
||||
value,
|
||||
label,
|
||||
@@ -18,24 +18,18 @@ export function CopyButton({
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { state, copy } = useCopyToClipboard();
|
||||
label ??= t("common.copy");
|
||||
copiedLabel ??= t("common.copied");
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider uppercase cursor-pointer transition-colors ${copied ? VARIANT_CLASSES.live : VARIANT_CLASSES.text} ${className ?? ""}`}
|
||||
onClick={handleCopy}
|
||||
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider uppercase cursor-pointer transition-colors ${VARIANT_CLASSES[COPY_STATE_VARIANT[state]]} ${className ?? ""}`}
|
||||
onClick={() => copy(value)}
|
||||
aria-label={ariaLabel ?? label}
|
||||
>
|
||||
{copied ? copiedLabel : label}
|
||||
{state === "copied" ? copiedLabel : state === "failed" ? t("common.copyFailed") : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VARIANT_CLASSES } from "./badge-utils";
|
||||
import { VARIANT_CLASSES, COPY_STATE_VARIANT } from "./badge-utils";
|
||||
import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
|
||||
|
||||
// Copies a shareable deep link to the current page with the given query params set (built fresh from
|
||||
// the address bar at click time, so region/other params are preserved). Flips to "Copied" for 1.5s.
|
||||
@@ -20,7 +21,7 @@ export function CopyLinkButton({
|
||||
preserveParams?: readonly string[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { state, copy } = useCopyToClipboard();
|
||||
const idleLabel = label ?? t("common.copyLink");
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
@@ -31,19 +32,17 @@ export function CopyLinkButton({
|
||||
if (value === null) url.searchParams.delete(key);
|
||||
else url.searchParams.set(key, value);
|
||||
}
|
||||
navigator.clipboard.writeText(url.toString());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [params, preserveParams]);
|
||||
copy(url.toString());
|
||||
}, [params, preserveParams, copy]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider uppercase cursor-pointer transition-colors ${copied ? VARIANT_CLASSES.live : VARIANT_CLASSES.text}`}
|
||||
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider uppercase cursor-pointer transition-colors ${VARIANT_CLASSES[COPY_STATE_VARIANT[state]]}`}
|
||||
onClick={handleCopy}
|
||||
aria-label={ariaLabel ?? idleLabel}
|
||||
>
|
||||
{copied ? copiedLabel ?? t("common.copied") : idleLabel}
|
||||
{state === "copied" ? copiedLabel ?? t("common.copied") : state === "failed" ? t("common.copyFailed") : idleLabel}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
// a new value clears a caught error, so one broken tab doesn't block the others
|
||||
resetKey?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -17,12 +19,16 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidUpdate(prev: Props) {
|
||||
if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
// React.lazy caches a rejected import, so remounting can't recover a failed chunk load
|
||||
// (e.g. a deploy swapped the hashed assets out from under us) — only a real reload can
|
||||
const chunkFailure = /dynamically imported module|Loading chunk|error loading/i.test(this.state.error.message);
|
||||
const chunkFailure = /dynamically imported module|Loading chunk|error loading|Importing a module script failed/i.test(this.state.error.message);
|
||||
return <ErrorFallback chunkFailure={chunkFailure} onRetry={() => this.setState({ error: null })} />;
|
||||
}
|
||||
return this.props.children;
|
||||
|
||||
@@ -34,7 +34,9 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
if (e.key !== "Escape") return;
|
||||
e.stopPropagation(); // the filter sheet listens on window; only the dropdown should close
|
||||
setOpen(false);
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CopyState } from "../hooks/useCopyToClipboard";
|
||||
|
||||
// variant styles for badge colors
|
||||
|
||||
export type BadgeVariant = "advert" | "text" | "trace" | "ack" | "group" | "request" | "default" | "live" | "stale" | "offline";
|
||||
@@ -15,6 +17,9 @@ export const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||
offline: "bg-danger/8 text-danger border-danger/15",
|
||||
};
|
||||
|
||||
// copy-button pill color per copy outcome
|
||||
export const COPY_STATE_VARIANT: Record<CopyState, BadgeVariant> = { idle: "text", copied: "live", failed: "offline" };
|
||||
|
||||
// maps MeshCore payload type codes to badge variants
|
||||
|
||||
const PAYLOAD_TYPE_TO_VARIANT: Record<number, BadgeVariant> = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery, useIsFetching, useQueryClient, type InfiniteData } from "@tanstack/react-query";
|
||||
import { getChannels } from "../../api/client";
|
||||
import { isRateLimited, subscribeRateLimit } from "../../api/rate-limit";
|
||||
import { LIVE_BUFFER_CAP, MAX_INFINITE_PAGES } from "../../lib/constants";
|
||||
import { LIVE_BUFFER_CAP } from "../../lib/constants";
|
||||
import { useRegion } from "../../hooks/useRegion";
|
||||
import { useIsMobile } from "../../hooks/useMediaQuery";
|
||||
import { useWsChannelMessageHandler } from "../../hooks/useWsHandlers";
|
||||
@@ -101,7 +101,6 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
|
||||
queryFn: ({ pageParam }) => getChannels({ iatas, cursor: pageParam, keyKnown: true, limit: KEYED_PAGE_SIZE }),
|
||||
initialPageParam: undefined as number | string | undefined,
|
||||
getNextPageParam: nextChannelPage,
|
||||
maxPages: MAX_INFINITE_PAGES,
|
||||
staleTime: 60_000,
|
||||
enabled: !regionPending,
|
||||
});
|
||||
@@ -111,7 +110,6 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
|
||||
queryFn: ({ pageParam }) => getChannels({ iatas, cursor: pageParam, keyKnown: false }),
|
||||
initialPageParam: undefined as number | string | undefined,
|
||||
getNextPageParam: nextChannelPage,
|
||||
maxPages: MAX_INFINITE_PAGES,
|
||||
staleTime: 60_000,
|
||||
enabled: !regionPending && (showOthers || keyFilter === "unknown"),
|
||||
});
|
||||
|
||||
@@ -13,12 +13,13 @@ import { PacketFlowButton } from "./PacketFlowButton";
|
||||
import { useMapNodesData } from "./useMapNodesData";
|
||||
import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, buildFocusedNeighborEdges, neighborFocusIds, type NeighborEdgeProps } from "./node-geojson";
|
||||
import { MapSettingsPanel } from "./MapSettingsPanel";
|
||||
import { parseMapView, buildMapParams, type MapViewSnapshot } from "./map-url";
|
||||
import { parseMapView, parseNodeType, buildMapParams, type MapViewSnapshot } from "./map-url";
|
||||
import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle, MAP_NEIGHBOR_LINES_STORAGE_KEY, MAP_CLUSTER_STORAGE_KEY, MAP_NODE_TYPE_STORAGE_KEY, MAP_BORDERS_STORAGE_KEY, DEFAULT_CENTER, DEFAULT_ZOOM, type NeighborLinesMode } from "./types";
|
||||
import type { FeatureCollection, LineString } from "geojson";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { LoadingPill } from "../../components/LoadingPill";
|
||||
import { useRegion } from "../../hooks/useRegion";
|
||||
import { useRegion, useRegionSelection } from "../../hooks/useRegion";
|
||||
import { serializeSelection } from "../../hooks/region-selection";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { useWsNodeUpdateHandler } from "../../hooks/useWsHandlers";
|
||||
import { getIatas, getNodeNeighbors } from "../../api/client";
|
||||
@@ -63,7 +64,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
|
||||
}, []);
|
||||
|
||||
const [typeFilter, setTypeFilter] = useState(
|
||||
() => urlView.nodeType ?? localStorage.getItem(MAP_NODE_TYPE_STORAGE_KEY) ?? "",
|
||||
() => urlView.nodeType ?? parseNodeType(localStorage.getItem(MAP_NODE_TYPE_STORAGE_KEY)) ?? "",
|
||||
); // "" = All
|
||||
const handleTypeChange = useCallback((t: string) => {
|
||||
setTypeFilter(t);
|
||||
@@ -105,6 +106,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
|
||||
);
|
||||
|
||||
const { iatas: selectedIatas, regionKey, isResolved } = useRegion();
|
||||
const { selection } = useRegionSelection();
|
||||
const regionPending = isResolved === false;
|
||||
const queryClient = useQueryClient();
|
||||
// marker/cluster icons are canvas-drawn from the active --palette-* vars, so useMapNodes has to
|
||||
@@ -170,7 +172,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
|
||||
);
|
||||
|
||||
// IATA coords to frame: the selection's airports, or every airport for "All". Regions carry no
|
||||
// bounds from the API, so their member IATAs stand in for the extent. See CLAUDE.md (map framing).
|
||||
// bounds from the API, so their member IATAs stand in for the extent.
|
||||
const fitPoints = useMemo<[number, number][] | null>(() => {
|
||||
const withCoords = (iatas ?? []).filter((i) => i.lat != null && i.lon != null);
|
||||
if (withCoords.length === 0) return null;
|
||||
@@ -187,7 +189,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
|
||||
}, [iatas, selectedIatas]);
|
||||
const borderData = useMapBordersData(borderIatas, borders);
|
||||
|
||||
const { containerRef, mapRef, isReady, error, nodeIconResolverRef } = useMapLibre(styleId, fitPoints, handleStyleError, initialCamera);
|
||||
const { containerRef, mapRef, isReady, error, nodeIconResolverRef } = useMapLibre(styleId, fitPoints, handleStyleError, initialCamera, serializeSelection(selection));
|
||||
const isDark = resolveMapStyle(styleId).dark; // drives marker theming + maplibre control chrome
|
||||
|
||||
// Snapshot the current view (live camera + settings) into deep-link params for the copy button.
|
||||
|
||||
@@ -53,6 +53,12 @@ function parseBool(raw: string | null): boolean | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// A canonical node-type name, or undefined for anything else (All, a label, a stale saved value).
|
||||
export function parseNodeType(raw: string | null | undefined): string | undefined {
|
||||
const type = raw?.toLowerCase();
|
||||
return type && NODE_TYPE_NAMES.includes(type as (typeof NODE_TYPE_NAMES)[number]) ? type : undefined;
|
||||
}
|
||||
|
||||
export function parseMapView(params: URLSearchParams): ParsedMapView {
|
||||
const view: ParsedMapView = {};
|
||||
|
||||
@@ -65,8 +71,8 @@ export function parseMapView(params: URLSearchParams): ParsedMapView {
|
||||
const clustered = parseBool(params.get("clustering"));
|
||||
if (clustered !== undefined) view.clustered = clustered;
|
||||
|
||||
const type = params.get("node_type")?.toLowerCase();
|
||||
if (type && NODE_TYPE_NAMES.includes(type as (typeof NODE_TYPE_NAMES)[number])) view.nodeType = type;
|
||||
const type = parseNodeType(params.get("node_type"));
|
||||
if (type) view.nodeType = type;
|
||||
|
||||
const neighbor = params.get("neighbor_lines")?.toLowerCase();
|
||||
if (neighbor && NEIGHBOR_MODES.includes(neighbor as NeighborLinesMode)) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Map feature config: pure data and lookups, no maplibre import, so it stays unit-testable
|
||||
// without pulling in the WebGL engine.
|
||||
|
||||
import { runtimeEnv } from "../../lib/runtime-env";
|
||||
|
||||
export interface MapStyleOption {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -40,7 +42,7 @@ export const TERRAIN_EXAGGERATION = 1.5;
|
||||
|
||||
// The fallback/initial map view, configured per deployment via .env (VITE_MAP_CENTER as decimal
|
||||
// "lat,lon", VITE_MAP_ZOOM). Used before airports load and when a selection has no airport coords;
|
||||
// otherwise MapView fits bounds over the airports (see CLAUDE.md, map framing). With neither set,
|
||||
// otherwise MapView fits bounds over the airports. With neither set,
|
||||
// fall back to a wide world overview.
|
||||
const FALLBACK_CENTER: [number, number] = [0, 20]; // [lng, lat] — neutral world view
|
||||
const FALLBACK_ZOOM = 1.5;
|
||||
@@ -72,9 +74,9 @@ export function parseMapZoom(raw: string | undefined): number {
|
||||
}
|
||||
|
||||
export const DEFAULT_CENTER: [number, number] = parseMapCenter(
|
||||
import.meta.env.VITE_MAP_CENTER as string | undefined,
|
||||
runtimeEnv("VITE_MAP_CENTER", import.meta.env.VITE_MAP_CENTER),
|
||||
);
|
||||
export const DEFAULT_ZOOM = parseMapZoom(import.meta.env.VITE_MAP_ZOOM as string | undefined);
|
||||
export const DEFAULT_ZOOM = parseMapZoom(runtimeEnv("VITE_MAP_ZOOM", import.meta.env.VITE_MAP_ZOOM));
|
||||
export const DEFAULT_PITCH = 0; // flat overview, no tilt
|
||||
export const DEFAULT_BEARING = 0;
|
||||
export const MAX_PITCH = 85;
|
||||
|
||||
@@ -75,7 +75,7 @@ export function useMapBorders(
|
||||
if (src) src.setData(data);
|
||||
}, [mapRef, isReady, data]);
|
||||
|
||||
// remove layers (before the source) on unmount; runs before useMapLibre tears the map down
|
||||
// remove layers (before the source) on unmount; useMapLibre's map.remove() runs first, hence the try
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
return () => {
|
||||
|
||||
@@ -34,6 +34,13 @@ export function mapLocale(): Record<string, string> {
|
||||
const fitKey = (points: [number, number][] | null) =>
|
||||
points && points.length ? points.map((p) => `${p[0]},${p[1]}`).join(";") : null;
|
||||
|
||||
// Sprite/glyph fetches fail with their own url; only a urlless error (parse/validation) or one for the
|
||||
// style document itself means the style won't load.
|
||||
function isStyleLoadFailure(error: unknown, styleUrl: string): boolean {
|
||||
const url = (error as { url?: unknown } | undefined)?.url;
|
||||
return typeof url !== "string" || url === styleUrl;
|
||||
}
|
||||
|
||||
// Keeps the imperative MapLibre lifecycle out of MapView; exposes mapRef + isReady for overlays.
|
||||
|
||||
const TERRAIN_SOURCE_ID = "terrain-dem";
|
||||
@@ -85,6 +92,8 @@ export function useMapLibre(
|
||||
// a deep-link camera ([lng, lat] + zoom); when set it opens the map here and wins over the initial
|
||||
// region fitBounds. Later region changes still auto-fit.
|
||||
initialCamera?: { center: [number, number]; zoom: number },
|
||||
// identity of the selection being framed; the deep-link camera only wins while it's unchanged
|
||||
fitScope?: string,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
@@ -97,6 +106,7 @@ export function useMapLibre(
|
||||
const onStyleErrorRef = useRef(onStyleError);
|
||||
const lastFitKeyRef = useRef<string | null>(null); // last applied fit target; skips redundant re-fits
|
||||
const skipInitialFitRef = useRef(!!initialCamera); // let a deep-link camera win over the first fit
|
||||
const initialFitScopeRef = useRef(fitScope);
|
||||
const initialCameraRef = useRef(initialCamera); // read once at map creation (deep link is load-time only)
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
@@ -172,6 +182,8 @@ export function useMapLibre(
|
||||
// blip) is transient and non-fatal — the rest of the map stays usable — so never blank the map
|
||||
// for it. maplibre tags tile/source errors with a tile/sourceId; style-level errors have neither.
|
||||
if (err.sourceId != null || err.tile != null) return;
|
||||
// a sprite 404 mid-swap is non-fatal too; the new style still loads without its icons
|
||||
if (swapPendingRef.current && !isStyleLoadFailure(err.error, resolveMapStyle(lastStyleIdRef.current).url)) return;
|
||||
// The new basemap failed mid-swap. setStyle keeps the old style (and our node layers)
|
||||
// rendered, so roll back to the last good style and tell MapView to revert the picker rather
|
||||
// than blanking the map under a fatal overlay.
|
||||
@@ -213,6 +225,8 @@ export function useMapLibre(
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !isReady) return;
|
||||
// the linked region never got its fit (e.g. it has no coords); don't spend the skip on another one
|
||||
if (fitScope !== initialFitScopeRef.current) skipInitialFitRef.current = false;
|
||||
const key = fitKey(fitPoints);
|
||||
if (key === lastFitKeyRef.current) return;
|
||||
lastFitKeyRef.current = key;
|
||||
@@ -222,8 +236,8 @@ export function useMapLibre(
|
||||
return;
|
||||
}
|
||||
|
||||
// First real fit after a deep-link camera: keep the URL-supplied view instead of framing the
|
||||
// region. Consumed once, so later region changes fit normally.
|
||||
// First real fit of the linked region: keep the URL-supplied view instead of framing it.
|
||||
// Consumed once, so later region changes fit normally.
|
||||
if (skipInitialFitRef.current) {
|
||||
skipInitialFitRef.current = false;
|
||||
return;
|
||||
@@ -239,7 +253,7 @@ export function useMapLibre(
|
||||
pitch: fitPoints.length === 1 ? IATA_PITCH : DEFAULT_PITCH,
|
||||
bearing: DEFAULT_BEARING,
|
||||
});
|
||||
}, [fitPoints, isReady]);
|
||||
}, [fitPoints, isReady, fitScope]);
|
||||
|
||||
return { containerRef, mapRef, isReady, error, nodeIconResolverRef };
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export function useMapNeighbors(
|
||||
}, [mapRef, isReady, edges]);
|
||||
|
||||
// remove the layer + source on unmount. Capturing map here is safe: it's the same instance for the
|
||||
// component's life, and this cleanup runs before useMapLibre tears the map down.
|
||||
// component's life. useMapLibre's map.remove() runs first, hence the try.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
return () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
NODES_POINT_LAYER_ID,
|
||||
NODES_SELECTED_LAYER_ID,
|
||||
NODES_SELECTED_LEAF_LAYER_ID,
|
||||
PACKET_FLOW_TRAIL_LAYER_ID,
|
||||
CLUSTER_RADIUS,
|
||||
CLUSTER_MAX_ZOOM,
|
||||
NODES_SOURCE_MAXZOOM,
|
||||
@@ -179,6 +180,9 @@ export function useMapNodes(
|
||||
});
|
||||
}
|
||||
|
||||
// a clustering toggle re-adds these after packet flow built its layers; keep the flow on top
|
||||
const beforeFlow = map.getLayer(PACKET_FLOW_TRAIL_LAYER_ID) ? PACKET_FLOW_TRAIL_LAYER_ID : undefined;
|
||||
|
||||
// Cluster as a SYMBOL layer (hexagon icon + count) — spiderfy requires a symbol layer. The icon
|
||||
// is a density level picked by point_count; the count is drawn as centered text (the icon has
|
||||
// none baked in). text-size isn't scaled by icon-size, so both are interpolated together.
|
||||
@@ -198,7 +202,7 @@ export function useMapNodes(
|
||||
"text-allow-overlap": true,
|
||||
},
|
||||
paint: { "text-color": "#FFFFFF", "text-halo-color": "rgba(0,0,0,0.55)", "text-halo-width": 1.2 },
|
||||
} as SymbolLayerSpecification);
|
||||
} as SymbolLayerSpecification, beforeFlow);
|
||||
}
|
||||
|
||||
if (!map.getLayer(NODES_POINT_LAYER_ID)) {
|
||||
@@ -224,7 +228,7 @@ export function useMapNodes(
|
||||
"text-halo-width": 1.3,
|
||||
"text-opacity": LABEL_OPACITY, // labels fade in only at high zoom
|
||||
},
|
||||
} as SymbolLayerSpecification);
|
||||
} as SymbolLayerSpecification, beforeFlow);
|
||||
}
|
||||
|
||||
// Ring under the selected node's icon. Only matches an unclustered point (clusters carry no id);
|
||||
@@ -358,9 +362,9 @@ export function useMapNodes(
|
||||
}, [mapRef, isReady, geojson]);
|
||||
|
||||
// Build spiderfy + node/cluster interactions, and tear them down on cleanup. Re-runs on every
|
||||
// style switch and clustering toggle, so body and cleanup must stay symmetric: setStyle does NOT
|
||||
// drop delegated layer listeners (stable ids in maplibre's Evented registry), so every map.on
|
||||
// must be matched by a map.off here or handlers pile up across switches.
|
||||
// style switch, clustering toggle and dataset reset, so body and cleanup must stay symmetric:
|
||||
// setStyle does NOT drop delegated layer listeners (stable ids in maplibre's Evented registry), so
|
||||
// every map.on must be matched by a map.off here or handlers pile up across switches.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !isReady) return;
|
||||
@@ -434,15 +438,7 @@ export function useMapNodes(
|
||||
/* map may already be removed */
|
||||
}
|
||||
};
|
||||
// themeKey is a dep so spiderfy rebuilds and its legs + leaf icons pick up the new palette
|
||||
}, [mapRef, isReady, clustered, themeKey]);
|
||||
|
||||
// close any open fan when the dataset identity changes — its leaves no longer exist
|
||||
useEffect(() => {
|
||||
try {
|
||||
spiderRef.current?.unspiderfyAll();
|
||||
} catch {
|
||||
/* map may already be removed */
|
||||
}
|
||||
}, [resetKey]);
|
||||
// themeKey rebuilds the legs + leaf icons in the new palette; resetKey closes a fan whose leaves
|
||||
// are gone (unspiderfyAll also unbinds the cluster click, so it has to be a full rebuild)
|
||||
}, [mapRef, isReady, clustered, themeKey, resetKey]);
|
||||
}
|
||||
@@ -200,7 +200,7 @@ export function useMapPacketFlow(
|
||||
clearFlows(mapRef.current);
|
||||
}, [resetKey, mapRef, clearFlows]);
|
||||
|
||||
// remove layers + sources on unmount (runs before useMapLibre's map.remove())
|
||||
// remove layers + sources on unmount (after useMapLibre's map.remove(), hence the try)
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
return () => {
|
||||
|
||||
@@ -44,8 +44,8 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u
|
||||
});
|
||||
const option = useMemo(() => {
|
||||
const window = { start, end };
|
||||
const aPoints = activityA && aligned ? fillActivity(activityA.points, intervalMs, window) : [];
|
||||
const bPoints = dataB && aligned ? fillActivity(dataB.points, intervalMs, window) : [];
|
||||
const aPoints = activityA && aligned ? fillActivity(activityA.points, intervalMs, window, activityA) : [];
|
||||
const bPoints = dataB && aligned ? fillActivity(dataB.points, intervalMs, window, dataB) : [];
|
||||
return { ...heardOption(aPoints, colors, window), grid: { left: 48, right: 14, top: 32, bottom: 22 },
|
||||
legend: { data: ["A", "B"], top: 0, textStyle: { color: colors.textNormal } },
|
||||
series: [["A", aPoints, colors.primary], ["B", bPoints, colors.secondary]].map(([name, points, color]) => ({
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { getTopObservers } from "../../api/client";
|
||||
import { formatCount } from "../../lib/formatters";
|
||||
import { Segmented } from "../stats/Segmented";
|
||||
import { useStatsRegion } from "../stats/useStats";
|
||||
import { RANGE_MS, type StatsRange } from "../stats/types";
|
||||
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";
|
||||
@@ -18,12 +18,12 @@ export function ObserverSidebar({ observers, filtered, isPending, isError, onRet
|
||||
range: StatsRange; selectedId: string | null; onSelect: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { where, regionKey } = useStatsRegion();
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
const [sort, setSort] = useState<Sort>("activity");
|
||||
|
||||
const activity = useQuery({
|
||||
queryKey: ["observer-sidebar-activity", regionKey, range],
|
||||
queryFn: () => getTopObservers(where, Date.now() - RANGE_MS[range], TOP),
|
||||
...gatedOn(isResolved, () => getTopObservers(where, sinceFor(range), TOP)),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ export function observerDestination(params: URLSearchParams, id: string | null,
|
||||
const next = new URLSearchParams(params);
|
||||
next.set("tab", "Observers");
|
||||
for (const key of ["statsTab", "observerId", "node", "route", "routeIata", "compareA", "compareB", "compareSince"]) next.delete(key);
|
||||
// compareUntil is shared with the analytics compare; it only belongs here alongside compareWith
|
||||
if (!id || !next.has("compareWith")) next.delete("compareUntil");
|
||||
if (id) { next.set("observer", id); next.set("range", range); }
|
||||
else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); next.delete("compareUntil"); }
|
||||
else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); }
|
||||
return next;
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
// single observation with signal stats and resolved path
|
||||
|
||||
export function ObservationCard({ observation: obs, selected, onClick, onViewNode, isTrace, selectLabel, actions }: {
|
||||
export function ObservationCard({ observation: obs, selected, onClick, onViewNode, isTrace, snrValues, selectLabel, actions }: {
|
||||
observation: Observation; selected?: boolean; onClick?: () => void; onViewNode?: (nodeId: string) => void; isTrace?: boolean;
|
||||
// TRACE only: SNR per hop from the packet's parsed payload
|
||||
snrValues?: number[];
|
||||
// when set, the summary becomes a keyboard-selectable button with this name
|
||||
selectLabel?: string;
|
||||
actions?: ReactNode;
|
||||
@@ -79,18 +81,8 @@ export function ObservationCard({ observation: obs, selected, onClick, onViewNod
|
||||
|
||||
{obs.pathBytes && (
|
||||
<div className="flex items-center gap-1 mt-2 font-mono text-[11px] pt-1.5 border-t border-border-subtle">
|
||||
{isTrace ? (
|
||||
// TRACE path bytes are per-hop SNR samples, not hop hashes — show them raw, never as a resolvable path.
|
||||
<>
|
||||
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-1">{t("observation.pathSnr")}</span>
|
||||
<span className="text-text-normal break-all">{obs.pathBytes.toUpperCase()}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-1">{t("observation.path")}</span>
|
||||
<PathData pathBytes={obs.pathBytes} hashSize={obs.pathLength.hashSize} resolvedPath={obs.resolvedPath} size="sm" onViewNode={onViewNode} />
|
||||
</>
|
||||
)}
|
||||
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-1">{t("observation.path")}</span>
|
||||
<PathData pathBytes={obs.pathBytes} hashSize={obs.pathLength.hashSize} resolvedPath={obs.resolvedPath} size="sm" onViewNode={onViewNode} snrValues={isTrace ? snrValues ?? [] : undefined} />
|
||||
</div>
|
||||
)}
|
||||
{actions && <div className="mt-2 flex flex-wrap items-center justify-end gap-1.5 border-t border-border-subtle pt-1.5" onClick={(e) => e.stopPropagation()}>{actions}</div>}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ObservationCard } from "./ObservationCard";
|
||||
import { useIsMobile } from "../../hooks/useMediaQuery";
|
||||
import { PayloadType } from "../../types/enums";
|
||||
import { buildPacketPaths } from "../map/packet-path";
|
||||
import { traceSnrValues } from "./packet-investigation";
|
||||
|
||||
// Roughly what fits the scroll cap; observations are unbounded server-side.
|
||||
const SKELETON_ROW_CAP = 12;
|
||||
@@ -111,6 +112,7 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb
|
||||
selected={o.id === selectedObservationId}
|
||||
onClick={() => handleSelectObservation(o.id)}
|
||||
isTrace={data.header.payloadType === PayloadType.TRACE}
|
||||
snrValues={traceSnrValues(data)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Tooltip } from "../../components/Tooltip";
|
||||
import type { PacketDetail } from "../../types/api";
|
||||
import { PayloadType } from "../../types/enums";
|
||||
import { ObservationCard } from "./ObservationCard";
|
||||
import { groupPacketReports } from "./packet-investigation";
|
||||
import { groupPacketReports, traceSnrValues } from "./packet-investigation";
|
||||
|
||||
const PAGE = 3;
|
||||
|
||||
@@ -51,6 +51,7 @@ export function PacketInvestigation({ detail, selectedId, onSelect, onViewObserv
|
||||
onClick={() => onSelect(report.id)}
|
||||
onViewNode={onViewNode}
|
||||
isTrace={isTrace}
|
||||
snrValues={traceSnrValues(detail)}
|
||||
selectLabel={`${t("investigation.inspect")}: ${name}`}
|
||||
actions={<>
|
||||
{onViewObserver && <button type="button" className={ACTION_BUTTON_CLASS} onClick={() => { onSelect(report.id); onViewObserver(report.observerId, report.id); }}>{t("investigation.observer")}</button>}
|
||||
|
||||
@@ -83,7 +83,8 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
|
||||
);
|
||||
|
||||
// ?hash is the selected packet — it expands the row inline. The analyzer is a separate state (?analyze=1).
|
||||
const expandedHash = searchParams.get("hash");
|
||||
// Lowercased because packet hashes are lowercase hex and a shared link may not be.
|
||||
const expandedHash = searchParams.get("hash")?.toLowerCase() ?? null;
|
||||
|
||||
const handleToggleExpand = useCallback((hash: string) => {
|
||||
const next = expandedHash === hash ? null : hash;
|
||||
|
||||
@@ -173,13 +173,22 @@ export function ResolvedHopBlock({ hop, label, onViewNode, showSnr = true }: {
|
||||
);
|
||||
}
|
||||
|
||||
// SNR sub-line under a trace hop; a "-" placeholder keeps the row aligned when there's no reading.
|
||||
export function HopSnr({ snr }: { snr: number | undefined }) {
|
||||
if (snr == null) return <span className="text-[11px] text-text-dim" aria-hidden>-</span>;
|
||||
const level = snrLevel(snr);
|
||||
return <span className={`text-[11px] ${level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"}`}>{formatSnr(snr)} dB</span>;
|
||||
}
|
||||
|
||||
// resolvedPath[i] lines up with the i-th hash (backend appends one hop per hash, in order).
|
||||
export function PathData({ pathBytes, hashSize, resolvedPath, size = "md", onViewNode }: {
|
||||
// snrValues turns on the trace layout: each hop gets its SNR (or a placeholder) underneath.
|
||||
export function PathData({ pathBytes, hashSize, resolvedPath, size = "md", onViewNode, snrValues }: {
|
||||
pathBytes: string;
|
||||
hashSize: number;
|
||||
resolvedPath: ResolvedHop[];
|
||||
size?: "sm" | "md";
|
||||
onViewNode?: (nodeId: string) => void;
|
||||
snrValues?: number[];
|
||||
}) {
|
||||
const chars = hashSize * 2;
|
||||
if (chars <= 0) return null; // splitter would be an invalid `.{1,0}` RegExp, and there's nothing to show anyway
|
||||
@@ -187,13 +196,21 @@ export function PathData({ pathBytes, hashSize, resolvedPath, size = "md", onVie
|
||||
const textClass = size === "sm" ? "text-[11px]" : "text-[13px]";
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-1 font-mono ${textClass}`}>
|
||||
{hops.map((hop, i) => (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && <span className="text-text-dim" aria-hidden>→</span>}
|
||||
<ResolvedHopBlock hop={resolvedPath[i]} label={hop.toUpperCase()} onViewNode={onViewNode} />
|
||||
</span>
|
||||
))}
|
||||
<div className={`flex flex-wrap items-center gap-1 font-mono ${textClass} ${snrValues ? "gap-y-2" : ""}`}>
|
||||
{hops.map((hop, i) => {
|
||||
const block = <ResolvedHopBlock hop={resolvedPath[i]} label={hop.toUpperCase()} onViewNode={onViewNode} showSnr={!snrValues} />;
|
||||
return (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && <span className="text-text-dim" aria-hidden>→</span>}
|
||||
{snrValues ? (
|
||||
<span className="inline-flex flex-col items-center gap-0.5">
|
||||
{block}
|
||||
<HopSnr snr={snrValues[i]} />
|
||||
</span>
|
||||
) : block}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Observation } from "../../types/api";
|
||||
import type { Observation, PacketDetail } from "../../types/api";
|
||||
|
||||
export interface ReportGroup {
|
||||
key: string;
|
||||
@@ -37,3 +37,9 @@ export function reportSelection(reports: Observation[], params: URLSearchParams,
|
||||
}
|
||||
return { selected: reports.find(o => o.id === fallback) ?? reports[0] ?? null, unavailable: false };
|
||||
}
|
||||
|
||||
// a trace's per-hop SNR rides on the packet's payload, not on each observation's path bytes
|
||||
export function traceSnrValues(detail: PacketDetail): number[] | undefined {
|
||||
const payload = detail.parsedPayload;
|
||||
return payload && typeof payload === "object" && Array.isArray(payload.snrValues) ? payload.snrValues as number[] : undefined;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PacketDetail, Observation } from "../../types/api";
|
||||
import { RouteType, PayloadType } from "../../types/enums";
|
||||
import { PayloadType } from "../../types/enums";
|
||||
|
||||
// Advert device-role (ADV_TYPE) low-nibble names, shared with the DISCOVER payload renderers.
|
||||
export const DEVICE_ROLE_NAMES: Record<number, string> = {
|
||||
@@ -53,10 +53,8 @@ export function computeFieldRanges(
|
||||
ranges.header = { start: 0, end: 1 };
|
||||
let offset = 1;
|
||||
|
||||
const hasTransport =
|
||||
detail.header.routeType === RouteType.TRANSPORT_FLOOD ||
|
||||
detail.header.routeType === RouteType.TRANSPORT_DIRECT;
|
||||
if (hasTransport) {
|
||||
// keyed off the codes, not the route type, so it matches what buildObservationFrame put in the frame
|
||||
if (detail.transportCodes) {
|
||||
ranges.transport = { start: offset, end: offset + 4 };
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"
|
||||
import { Timestamp } from "../../components/Timestamp";
|
||||
import { ColorAccentField, AdvertFlagsBitBreakdown, PathLengthBitBreakdown, FIELD_COLORS, DEVICE_ROLE_NAMES } from "./packet-structure";
|
||||
import type { FieldId } from "./packet-structure";
|
||||
import { ResolvedHopBlock } from "./PathData";
|
||||
import { HopSnr, ResolvedHopBlock } from "./PathData";
|
||||
import type { ResolvedHop } from "../../types/api";
|
||||
|
||||
// shared layout primitives for payload fields
|
||||
@@ -260,9 +260,6 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & {
|
||||
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">{t("packetAnalyzer.payload.tracePath")}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-2 text-[13px]">
|
||||
{pathHashes.map((hash, i) => {
|
||||
const snr = snrValues?.[i];
|
||||
const level = snr != null ? snrLevel(snr) : null;
|
||||
const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal";
|
||||
// When the packet detail resolved this trace's route, overlay it onto each hash block:
|
||||
// tint by match confidence and reveal the resolved node(s) on hover. Falls back to the
|
||||
// plain hash badge when there's no resolution (e.g. live/WS view).
|
||||
@@ -276,13 +273,7 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & {
|
||||
) : (
|
||||
<HexBadge value={hash} />
|
||||
)}
|
||||
{/* keep a sub-line on every hop (SNR, or a "-" placeholder when there's no reading)
|
||||
so the hash badges across the row stay aligned */}
|
||||
{snr != null ? (
|
||||
<span className={`text-[11px] ${sigClass}`}>{formatSnr(snr)} dB</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-text-dim" aria-hidden>-</span>
|
||||
)}
|
||||
<HopSnr snr={snrValues?.[i]} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
@@ -713,7 +704,8 @@ export function PayloadBreakdown({ payload, resolvedRoute, resolvedSource, resol
|
||||
const endpoints: EndpointProps = { resolvedSource, resolvedDestination, onViewNode };
|
||||
switch (payload.type) {
|
||||
case "ADVERT": return <AdvertPayload payload={payload} />;
|
||||
case "TRACE": return <TracePayload payload={payload} resolvedRoute={resolvedRoute} onViewNode={onViewNode} />;
|
||||
case "TRACE":
|
||||
case "PING": return <TracePayload payload={payload} resolvedRoute={resolvedRoute} onViewNode={onViewNode} />;
|
||||
case "GROUP_TEXT": return <GroupTextPayload payload={payload} />;
|
||||
case "TEXT_MESSAGE": return <TextPayload payload={payload} {...endpoints} />;
|
||||
case "REQUEST": return <RequestPayload payload={payload} {...endpoints} />;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRegion } from "../../hooks/useRegion";
|
||||
import type { WsPacketObservation, WsLagged } from "../../types/ws";
|
||||
import type { PacketSummary } from "../../types/api";
|
||||
import type { PacketServerFilter } from "./types";
|
||||
import { LIVE_BUFFER_CAP, MAX_INFINITE_PAGES } from "../../lib/constants";
|
||||
import { LIVE_BUFFER_CAP } from "../../lib/constants";
|
||||
|
||||
// merge and deduplicate live + paginated packets
|
||||
|
||||
@@ -154,6 +154,8 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi
|
||||
|
||||
const handlePacketObservation = useCallback(
|
||||
(data: WsPacketObservation["data"]) => {
|
||||
// the previous region's subscription can still deliver after the buffer reset
|
||||
if (iatas && !iatas.includes(data.observation.iata)) return;
|
||||
const summary: PacketSummary = {
|
||||
packetHash: data.packetHash,
|
||||
payloadType: data.packet.payloadType,
|
||||
@@ -179,7 +181,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi
|
||||
|
||||
store.pushOrUpdate(summary);
|
||||
},
|
||||
[store],
|
||||
[store, iatas],
|
||||
);
|
||||
|
||||
// Reset (drop to one fresh first page) instead of invalidate: an invalidate replays every cached
|
||||
@@ -221,7 +223,6 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
initialPageParam: undefined as number | undefined,
|
||||
staleTime: Infinity,
|
||||
maxPages: MAX_INFINITE_PAGES,
|
||||
enabled: !pending,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getIatas } from "../../api/client";
|
||||
import { getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getIatas, type RouteCursor } from "../../api/client";
|
||||
import { useRegion, useRegionSelection } from "../../hooks/useRegion";
|
||||
import { useInfinitePages } from "../../hooks/useInfinitePages";
|
||||
import { Badge } from "../../components/Badge";
|
||||
@@ -195,10 +195,10 @@ export function RouteTable(actions: RouteActions) {
|
||||
// client-side below (a region can span several IATAs, which the endpoint can't express).
|
||||
const serverIata = iatas && iatas.length === 1 ? iatas[0] : undefined;
|
||||
|
||||
// Page the route set on demand (50 at a time, cursor = last route's lastSeen ms) — the DataTable
|
||||
// Page the route set on demand (50 at a time, cursor = last route's lastSeen + id) — the DataTable
|
||||
// pulls the next page via loadMore() as you scroll, instead of eagerly loading the whole set.
|
||||
const { items: listRoutes, loadedCount, isPaging, isError, isLoading: listLoading, loadMore, hasMore } =
|
||||
useInfinitePages<KnownRoute>({
|
||||
useInfinitePages<KnownRoute, RouteCursor>({
|
||||
queryKey: ["routes", serverIata ?? ""],
|
||||
queryFn: (cursor) => getKnownRoutesPage({ iata: serverIata, cursor }),
|
||||
getId: routeId,
|
||||
|
||||
@@ -25,6 +25,7 @@ interface MeshTabProps {
|
||||
|
||||
export function MeshTab({ range, onSelectObserver }: MeshTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const rangeLabel = t(`stats.ranges.${range}`);
|
||||
const colors = useChartColors();
|
||||
const series = useStatsSeries("24h");
|
||||
const observations = useStatsSeries(range);
|
||||
@@ -115,6 +116,8 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) {
|
||||
iatas: spark(ov, (v) => v.activeIatas),
|
||||
}), [ov]);
|
||||
|
||||
// with no complete hours the summary is all zeros, which would read as a dead mesh
|
||||
const kpis = ov?.completeHours ? ov.summary : undefined;
|
||||
// top-row KPIs are the last 24 rollable hours, so they lag the clock by 35–95 min; range only drives the charts below
|
||||
const ovWindow = ov
|
||||
? t("mesh.lastHoursTo", { hours: Math.round((ov.until - ov.since) / 3_600_000), time: formatUtc(ov.until, { timeOnly: true }) })
|
||||
@@ -123,14 +126,14 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label={t("mesh.totalPackets")} sublabel={ovWindow} accent="var(--color-primary)" value={formatCount(ov?.summary.uniquePackets)} spark={sparks.packets} />
|
||||
<StatCard label={t("mesh.observations")} sublabel={ovWindow} accent="var(--color-green)" value={formatCount(ov?.summary.observations)} spark={sparks.observations} />
|
||||
<StatCard label={t("mesh.activeObservers")} sublabel={ovWindow} accent="var(--color-secondary)" value={ov?.summary.activeObservers ?? "—"} spark={sparks.observers} />
|
||||
<StatCard label={t("mesh.activeIatas")} sublabel={ovWindow} accent="var(--color-warn)" value={ov?.summary.activeIatas ?? "—"} spark={sparks.iatas} />
|
||||
<StatCard label={t("mesh.totalPackets")} sublabel={ovWindow} accent="var(--color-primary)" value={formatCount(kpis?.uniquePackets)} spark={sparks.packets} />
|
||||
<StatCard label={t("mesh.observations")} sublabel={ovWindow} accent="var(--color-green)" value={formatCount(kpis?.observations)} spark={sparks.observations} />
|
||||
<StatCard label={t("mesh.activeObservers")} sublabel={ovWindow} accent="var(--color-secondary)" value={kpis?.activeObservers ?? "—"} spark={sparks.observers} />
|
||||
<StatCard label={t("mesh.activeIatas")} sublabel={ovWindow} accent="var(--color-warn)" value={kpis?.activeIatas ?? "—"} spark={sparks.iatas} />
|
||||
</div>
|
||||
|
||||
<ChartCard
|
||||
title={t("mesh.observationsTitle", { range })}
|
||||
title={t("mesh.observationsTitle", { range: rangeLabel })}
|
||||
height={200}
|
||||
option={obsOption}
|
||||
isLoading={observations.isPending || observations.isPlaceholderData}
|
||||
@@ -141,10 +144,10 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) {
|
||||
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
|
||||
{/* range-driven charts lead the grid; the all-time ones follow below */}
|
||||
<div className="min-w-0">
|
||||
<ChartCard title={t("mesh.topObservers", { range })} height={208} option={observersOption} isLoading={topObservers.isPending || topObservers.isPlaceholderData} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} />
|
||||
<ChartCard title={t("mesh.topObservers", { range: rangeLabel })} height={208} option={observersOption} isLoading={topObservers.isPending || topObservers.isPlaceholderData} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} />
|
||||
</div>
|
||||
<ChartCard
|
||||
title={t("mesh.payloadTypes", { range })}
|
||||
title={t("mesh.payloadTypes", { range: rangeLabel })}
|
||||
right={<span className="font-mono text-[10px] text-text-muted">{t("mesh.obs", { value: formatCount(payloadData === undefined ? undefined : payloadTotal) })}</span>}
|
||||
height={208}
|
||||
option={payloadOption}
|
||||
@@ -152,7 +155,7 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) {
|
||||
isError={payload.isError}
|
||||
isEmpty={payloadItems.length === 0}
|
||||
/>
|
||||
<ChartCard title={t("mesh.topNodes", { range })} height={208} option={nodesOption} isLoading={topNodes.isPending || topNodes.isPlaceholderData} isError={topNodes.isError} isEmpty={nodeRows.length === 0} />
|
||||
<ChartCard title={t("mesh.topNodes", { range: rangeLabel })} height={208} option={nodesOption} isLoading={topNodes.isPending || topNodes.isPlaceholderData} isError={topNodes.isError} isEmpty={nodeRows.length === 0} />
|
||||
<ChartCard title={t("mesh.nodeTypes")} height={208} option={typesOption} isLoading={nodeTypes.isPending || nodeTypes.isPlaceholderData} isError={nodeTypes.isError} isEmpty={typeRows.length === 0} />
|
||||
<ChartCard title={t("mesh.radioPresets")} height={208} option={presetsOption} isLoading={radioPresets.isPending || radioPresets.isPlaceholderData} isError={radioPresets.isError} isEmpty={presetRows.length === 0} />
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison,
|
||||
return { start: heardData?.windowStart ?? end - span, end };
|
||||
}, [heardData, activity.dataUpdatedAt, range]);
|
||||
const heard = useMemo(
|
||||
() => (heardData && intervalMs ? fillActivity(heardData.points, intervalMs, heardWindow) : []),
|
||||
() => (heardData && intervalMs ? fillActivity(heardData.points, intervalMs, heardWindow, heardData) : []),
|
||||
[heardData, intervalMs, heardWindow],
|
||||
);
|
||||
const busy = useMemo(
|
||||
@@ -205,7 +205,7 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison,
|
||||
) : (
|
||||
<>
|
||||
<ChartCard
|
||||
title={`${t("observerPage.airtime")} · ${range}`}
|
||||
title={`${t("observerPage.airtime")} · ${t(`stats.ranges.${range}`)}`}
|
||||
right={<span className="text-xs text-text-muted">{airtimeLabel(latestAirtime)}</span>}
|
||||
height={200}
|
||||
option={airtime}
|
||||
@@ -259,7 +259,7 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison,
|
||||
)}
|
||||
{!heardUnavailable && costed && (
|
||||
<ChartCard
|
||||
title={`${t("observerPage.busy")} · ${range}`}
|
||||
title={`${t("observerPage.busy")} · ${t(`stats.ranges.${range}`)}`}
|
||||
right={<span className="text-xs text-text-muted">{radioLabel}</span>}
|
||||
height={180}
|
||||
option={busy}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InfoTip } from "../../components/InfoTip";
|
||||
import { SectionInfo } from "./SectionInfo";
|
||||
import { useScopes, useStatsSeries } from "./useStats";
|
||||
import { rolledWindow, useScopes, useStatsSeries } from "./useStats";
|
||||
import { useChartColors } from "./chartTheme";
|
||||
import { Card, ChartCard, StatCard } from "./cards";
|
||||
import { scopeChartOption, scopeHourly, scopeSummary } from "./scopes";
|
||||
@@ -20,8 +20,13 @@ export function ScopesTab({ range }: { range: StatsRange }) {
|
||||
const all = useMemo(() => unavailable ? [] : (query.data ?? []), [query.data, unavailable]);
|
||||
const rows = useMemo(() => all.filter((row) => row.name.toLowerCase().includes(search.trim().toLowerCase())).sort((a, b) => b.packetCount - a.packetCount || a.name.localeCompare(b.name)), [all, search]);
|
||||
const totals = useMemo(() => scopeSummary(rows), [rows]);
|
||||
const seriesHours = series.isSuccess && !series.isPlaceholderData ? series.data?.hours : undefined;
|
||||
const sparks = useMemo(() => unavailable ? null : scopeHourly(rows, seriesHours), [rows, seriesHours, unavailable]);
|
||||
const seriesData = series.isSuccess && !series.isPlaceholderData ? series.data : undefined;
|
||||
const seriesHours = seriesData?.hours;
|
||||
// packet counts come from rollups, so before the first rolled hour a zero means unknown
|
||||
const packetsUnknown = seriesData?.completeHours === 0;
|
||||
// the scopes fetch saw at most what was rolled when it landed, so its window can't reach past that
|
||||
const scopeWindow = useMemo(() => query.dataUpdatedAt ? rolledWindow(range, query.dataUpdatedAt) : undefined, [query.dataUpdatedAt, range]);
|
||||
const sparks = useMemo(() => unavailable ? null : scopeHourly(rows, seriesHours, scopeWindow), [rows, seriesHours, unavailable, scopeWindow]);
|
||||
const packets = useMemo(() => scopeChartOption(rows, "packetCount", colors, t), [rows, colors, t]);
|
||||
const observers = useMemo(() => scopeChartOption(rows, "observerCount", colors, t), [rows, colors, t]);
|
||||
const nodes = useMemo(() => scopeChartOption(rows, "nodeCount", colors, t), [rows, colors, t]);
|
||||
@@ -37,7 +42,7 @@ export function ScopesTab({ range }: { range: StatsRange }) {
|
||||
{query.isError && <p role="alert" className="text-sm text-danger">{t("scopes.error")}</p>}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard label={t("scopes.active")} value={value(totals.active)} accent={colors.secondary} spark={sparks?.active} />
|
||||
<StatCard label={t("scopes.scopedPackets")} value={value(totals.packets)} accent={colors.primary} spark={sparks?.packets} />
|
||||
<StatCard label={t("scopes.scopedPackets")} value={packetsUnknown ? "—" : value(totals.packets)} accent={colors.primary} spark={sparks?.packets} />
|
||||
<StatCard label={t("scopes.memberships")} value={value(totals.memberships)} accent={colors.green} spark={sparks?.observers} sublabel={sparks?.observers && t("scopes.observersLine")} />
|
||||
<StatCard label={t("scopes.defaultNodes")} value={value(totals.nodes)} accent={colors.warn} spark={sparks?.nodes} sublabel={sparks?.nodes && t("scopes.nodesLine")} />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChartColors } from "./chartTheme";
|
||||
import { useTopAdvertisers, useTopTalkers } from "./useStats";
|
||||
import { rolledWindow, useTopAdvertisers, useTopTalkers } from "./useStats";
|
||||
import { leaderboardOption } from "./chartOptions";
|
||||
import { Card, ChartCard } from "./cards";
|
||||
import { DataTable, type Column } from "../../components/DataTable";
|
||||
@@ -21,6 +21,12 @@ function leaderboardHeight(count: number) {
|
||||
return Math.max(260, count * 34 + 24);
|
||||
}
|
||||
|
||||
// The server reads rolled hours from the hour holding `since`, so the counts stop at the rolled edge.
|
||||
function coveredMs(range: StatsRange, fetchedAt: number) {
|
||||
const hour = 3_600_000;
|
||||
return rolledWindow(range, fetchedAt).until - Math.floor((fetchedAt - RANGE_MS[range]) / hour) * hour;
|
||||
}
|
||||
|
||||
// The "noisy nodes, politely" tab: who's loudest by adverts and by channel chatter. Advertisers list
|
||||
// their flood/direct advert split with a per-day rate; talkers are grouped by sender display-name.
|
||||
export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
|
||||
@@ -35,7 +41,7 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
|
||||
const advertisers = advertisersLoading || topAdvertisers.isError ? [] : (topAdvertisers.data ?? []);
|
||||
|
||||
const advertiserColumns = useMemo<Column<TopAdvertiser>[]>(() => {
|
||||
const windowMs = RANGE_MS[range];
|
||||
const windowMs = coveredMs(range, topAdvertisers.dataUpdatedAt);
|
||||
// count over the compacted total, then the per-day rate for the same window in muted text
|
||||
const split = (count: number) => (
|
||||
<span>
|
||||
@@ -59,7 +65,7 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
|
||||
{ header: t("talkers.flood"), className: "tabular-nums", cell: (a) => split(a.floodAdvertCount), sortValue: (a) => a.floodAdvertCount },
|
||||
{ header: t("talkers.direct"), className: "tabular-nums", cell: (a) => split(a.directAdvertCount), sortValue: (a) => a.directAdvertCount },
|
||||
];
|
||||
}, [range, t]);
|
||||
}, [range, t, topAdvertisers.dataUpdatedAt]);
|
||||
|
||||
const talkerRows = useMemo(
|
||||
() => (talkersUnavailable ? [] : (topTalkers.data ?? [])).map((row) => ({ name: row.senderName, value: row.messageCount, color: colors.secondary })),
|
||||
|
||||
@@ -37,6 +37,7 @@ export function observationsAreaOption(
|
||||
): EChartsOption {
|
||||
return {
|
||||
animation: false,
|
||||
useUTC: true,
|
||||
backgroundColor: "transparent",
|
||||
grid: { left: 48, right: 14, top: 12, bottom: 24 },
|
||||
tooltip: { trigger: "axis", ...tooltipStyle(c), axisPointer: { type: "line", lineStyle: { color: c.primary } } },
|
||||
@@ -318,6 +319,7 @@ const pctLabel = (v: unknown) => (typeof v === "number" ? `${v}%` : "—");
|
||||
export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketMs: number | null): EChartsOption {
|
||||
return {
|
||||
animation: false,
|
||||
useUTC: true,
|
||||
backgroundColor: "transparent",
|
||||
grid: { left: 48, right: 14, top: 24, bottom: 22 },
|
||||
legend: { data: ["RX", "TX"], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } },
|
||||
@@ -351,6 +353,7 @@ function metricLineOption(
|
||||
): EChartsOption {
|
||||
return {
|
||||
animation: false,
|
||||
useUTC: true,
|
||||
backgroundColor: "transparent",
|
||||
grid: { left: 50, right: 14, top: 14, bottom: 22 },
|
||||
tooltip: { trigger: "axis", ...tooltipStyle(c) },
|
||||
@@ -406,6 +409,7 @@ export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs:
|
||||
const pct = (p: ActivityPoint) => (intervalMs == null ? null : busyPct(p.airtimeMs, busySpanMs(p.t, intervalMs, w)));
|
||||
return {
|
||||
animation: false,
|
||||
useUTC: true,
|
||||
backgroundColor: "transparent",
|
||||
grid: { left: 48, right: 14, top: 14, bottom: 22 },
|
||||
tooltip: { trigger: "axis", ...tooltipStyle(c), valueFormatter: pctLabel },
|
||||
@@ -429,6 +433,7 @@ export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs:
|
||||
export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow, name = "Heard"): EChartsOption {
|
||||
return {
|
||||
animation: false,
|
||||
useUTC: true,
|
||||
backgroundColor: "transparent",
|
||||
grid: { left: 48, right: 14, top: 14, bottom: 22 },
|
||||
tooltip: { trigger: "axis", ...tooltipStyle(c) },
|
||||
@@ -453,6 +458,7 @@ const dbLabel = (v: unknown) => (typeof v === "number" ? `${v} dB` : "—");
|
||||
export function snrHeardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow, labels = { average: "Avg", minimum: "Min" }): EChartsOption {
|
||||
return {
|
||||
animation: false,
|
||||
useUTC: true,
|
||||
backgroundColor: "transparent",
|
||||
grid: { left: 54, right: 14, top: 24, bottom: 22 },
|
||||
legend: { data: [labels.average, labels.minimum], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } },
|
||||
|
||||
@@ -27,7 +27,8 @@ export function scopeChartOption(rows: ScopeStats[], metric: ScopeMetric, colors
|
||||
|
||||
// Per-hour activity for the shown rows. The series says which hours are rolled: an hour missing from a
|
||||
// scope's hourly is zero, an unrolled hour is a gap. Like the cards, observers and nodes add up per scope.
|
||||
export function scopeHourly(rows: ScopeStats[], hours: { hour: number; status: string }[] | undefined) {
|
||||
// Hours outside `window` (what the scopes fetch covered) are gaps too, since its hourly can't speak for them.
|
||||
export function scopeHourly(rows: ScopeStats[], hours: { hour: number; status: string }[] | undefined, window?: { since: number; until: number }) {
|
||||
if (!hours || rows.some((row) => !row.hourly)) return null;
|
||||
const zero = { packets: 0, active: 0, observers: 0, nodes: 0 };
|
||||
const byHour = new Map<number, typeof zero>();
|
||||
@@ -45,7 +46,8 @@ export function scopeHourly(rows: ScopeStats[], hours: { hour: number; status: s
|
||||
});
|
||||
}
|
||||
}
|
||||
const slots = hours.map((h) => (h.status === "complete" ? (byHour.get(h.hour) ?? zero) : null));
|
||||
const covered = (hour: number) => !window || (hour >= window.since && hour < window.until);
|
||||
const slots = hours.map((h) => (h.status === "complete" && covered(h.hour) ? (byHour.get(h.hour) ?? zero) : null));
|
||||
const line = (key: keyof typeof zero) => slots.map((slot) => slot && slot[key]);
|
||||
return {
|
||||
packets: line("packets"),
|
||||
|
||||
@@ -94,15 +94,26 @@ export function latestAirtimePct(points: TelemetryPoint[], bucketMs: number | nu
|
||||
// The server skips empty buckets; fill them so a quiet stretch draws as zero instead of a skipped line.
|
||||
// Starts at the first complete bucket (the server rounds its window start up the same way) and lets
|
||||
// one bucket past the window end through, so a client clock behind the server can't hide fresh data.
|
||||
export function fillActivity(points: ActivityPoint[], intervalMs: number, window: { start: number; end: number }): ActivityPoint[] {
|
||||
// Buckets between rolledUntil and rawFrom were never read (rollup backlog), so they stay gaps.
|
||||
export function fillActivity(
|
||||
points: ActivityPoint[],
|
||||
intervalMs: number,
|
||||
window: { start: number; end: number },
|
||||
coverage?: { rolledUntil?: number; rawFrom?: number },
|
||||
): ActivityPoint[] {
|
||||
const snap = (t: number) => Math.floor(t / intervalMs) * intervalMs;
|
||||
const first = Math.ceil(window.start / intervalMs) * intervalMs;
|
||||
const current = snap(window.end);
|
||||
const last = points.some((p) => snap(p.t) === current + intervalMs) ? current + intervalMs : current;
|
||||
const byBucket = new Map(points.map((p) => [snap(p.t), p]));
|
||||
const { rolledUntil, rawFrom } = coverage ?? {};
|
||||
const unread = (t: number) => rolledUntil != null && rawFrom != null && t >= rolledUntil && t < rawFrom;
|
||||
const out: ActivityPoint[] = [];
|
||||
for (let t = first; t <= last; t += intervalMs) {
|
||||
out.push(byBucket.get(t) ?? { t, observations: 0, airtimeMs: 0, snrAvg: null, snrMin: null, rssiAvg: null });
|
||||
const filled = unread(t)
|
||||
? { t, observations: null, airtimeMs: null, snrAvg: null, snrMin: null, rssiAvg: null }
|
||||
: { t, observations: 0, airtimeMs: 0, snrAvg: null, snrMin: null, rssiAvg: null };
|
||||
out.push(byBucket.get(t) ?? filled);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export interface ObserverTelemetry {
|
||||
// GET /observers/{id}/activity: what the observer heard per `interval`. The tab hides these charts on a 404.
|
||||
export interface ActivityPoint {
|
||||
t: number; // epoch ms, bucket start
|
||||
observations: number;
|
||||
observations: number | null; // null only for a filled gap the server never read
|
||||
airtimeMs: number | null; // summed LoRa time-on-air; null when no row in the bucket could be costed
|
||||
snrAvg: number | null;
|
||||
snrMin: number | null;
|
||||
@@ -180,6 +180,9 @@ export interface ObserverActivity {
|
||||
windowEnd?: number;
|
||||
generatedAt?: number;
|
||||
source?: "raw" | "hourly";
|
||||
// hourly only: rollups cover buckets before rolledUntil, raw rows from rawFrom; hours between are unread
|
||||
rolledUntil?: number;
|
||||
rawFrom?: number;
|
||||
summary?: ObserverActivitySummary;
|
||||
range: string;
|
||||
interval: string;
|
||||
|
||||
@@ -30,8 +30,19 @@ export function useStatsRegion() {
|
||||
return { where, regionKey, isResolved };
|
||||
}
|
||||
|
||||
// Holds a query until the selected region resolves; an unresolved slug would otherwise fetch every IATA.
|
||||
export function gatedOn<T>(isResolved: boolean | undefined, fetch: (signal: AbortSignal) => Promise<T>) {
|
||||
return {
|
||||
enabled: isResolved !== false,
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) => {
|
||||
if (isResolved === false) throw new Error("Selected region is not available yet");
|
||||
return fetch(signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// `since` is computed inside queryFn so refetches use a fresh window without churning the query key.
|
||||
const sinceFor = (range: StatsRange) => Date.now() - RANGE_MS[range];
|
||||
export const sinceFor = (range: StatsRange) => Date.now() - RANGE_MS[range];
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
// Same end as the server's overview window: the hour starting at H can be rolled from H + 95 min.
|
||||
@@ -44,12 +55,10 @@ export function useStatsSeries(range: StatsRange) {
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: ["stats-series", regionKey, range],
|
||||
enabled: isResolved !== false,
|
||||
queryFn: ({ signal }) => {
|
||||
if (isResolved === false) throw new Error("Selected region is not available yet");
|
||||
...gatedOn(isResolved, (signal) => {
|
||||
const { since, until } = rolledWindow(range);
|
||||
return getStatsSeries(since, until, where, signal);
|
||||
},
|
||||
}),
|
||||
...common,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
@@ -59,11 +68,7 @@ export function useStatsObservations(range: StatsRange) {
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: ["stats-observations", regionKey, range],
|
||||
enabled: isResolved !== false,
|
||||
queryFn: ({ signal }) => {
|
||||
if (isResolved === false) throw new Error("Selected region is not available yet");
|
||||
return getStatsObservations(where, sinceFor(range), signal);
|
||||
},
|
||||
...gatedOn(isResolved, (signal) => getStatsObservations(where, sinceFor(range), signal)),
|
||||
...common,
|
||||
// feeds the observations chart + sparklines and gets no WS bumps, so refetch to stay fresh
|
||||
refetchInterval: 60_000,
|
||||
@@ -71,89 +76,86 @@ export function useStatsObservations(range: StatsRange) {
|
||||
}
|
||||
|
||||
export function usePayloadBreakdown(range: StatsRange) {
|
||||
const { where, regionKey } = useStatsRegion();
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: ["stats-payload", regionKey, range],
|
||||
queryFn: () => getPayloadBreakdown(where, sinceFor(range)),
|
||||
...gatedOn(isResolved, () => getPayloadBreakdown(where, sinceFor(range))),
|
||||
...common,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopNodes(range: StatsRange, limit = 10) {
|
||||
const { where, regionKey } = useStatsRegion();
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: ["stats-top-nodes", regionKey, range, limit],
|
||||
queryFn: () => getTopNodes(where, sinceFor(range), limit),
|
||||
...gatedOn(isResolved, () => getTopNodes(where, sinceFor(range), limit)),
|
||||
...common,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopObservers(range: StatsRange, limit = 10) {
|
||||
const { where, regionKey } = useStatsRegion();
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: ["stats-top-observers", regionKey, range, limit],
|
||||
queryFn: () => getTopObservers(where, sinceFor(range), limit),
|
||||
...gatedOn(isResolved, () => getTopObservers(where, sinceFor(range), limit)),
|
||||
...common,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopAdvertisers(range: StatsRange, limit = 10) {
|
||||
const { where, regionKey } = useStatsRegion();
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: ["stats-top-advertisers", regionKey, range, limit],
|
||||
queryFn: () => getTopAdvertisers(where, sinceFor(range), limit),
|
||||
...gatedOn(isResolved, () => getTopAdvertisers(where, sinceFor(range), limit)),
|
||||
...common,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopTalkers(range: StatsRange, limit = 10) {
|
||||
const { where, regionKey } = useStatsRegion();
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: ["stats-top-talkers", regionKey, range, limit],
|
||||
queryFn: () => getTopTalkers(where, sinceFor(range), limit),
|
||||
...gatedOn(isResolved, () => getTopTalkers(where, sinceFor(range), limit)),
|
||||
...common,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRadioPresets() {
|
||||
const { where, regionKey } = useStatsRegion();
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: ["stats-radio-presets", regionKey],
|
||||
queryFn: () => getRadioPresets(where),
|
||||
...gatedOn(isResolved, () => getRadioPresets(where)),
|
||||
...common,
|
||||
});
|
||||
}
|
||||
|
||||
// node-types is a population census (no time window), so the key is region-only
|
||||
export function useNodeTypes() {
|
||||
const { where, regionKey } = useStatsRegion();
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: ["stats-node-types", regionKey],
|
||||
queryFn: () => getStatsNodeTypes(where),
|
||||
...gatedOn(isResolved, () => getStatsNodeTypes(where)),
|
||||
...common,
|
||||
});
|
||||
}
|
||||
|
||||
// clock drift reflects each node's latest measured drift, not a windowed aggregate, so region-only
|
||||
export function useClockDrift(limit = 100) {
|
||||
const { where, regionKey } = useStatsRegion();
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: ["stats-clock-drift", regionKey, limit],
|
||||
queryFn: () => getClockDrift(where, limit),
|
||||
...gatedOn(isResolved, () => getClockDrift(where, limit)),
|
||||
...common,
|
||||
});
|
||||
}
|
||||
|
||||
// Packet counts are windowed; observer and node counts are current membership.
|
||||
// Packet counts are windowed from the series' first rolled hour so both cover the same hours;
|
||||
// observer and node counts are current membership.
|
||||
export function useScopes(range: StatsRange) {
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: ["stats-scopes", regionKey, range],
|
||||
enabled: isResolved !== false,
|
||||
queryFn: ({ signal }) => {
|
||||
if (isResolved === false) throw new Error("Selected region is not available yet");
|
||||
return getStatsScopes(where, sinceFor(range), signal);
|
||||
},
|
||||
...gatedOn(isResolved, (signal) => getStatsScopes(where, rolledWindow(range).since, signal)),
|
||||
...common,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { StatsRegion } from "../../api/client";
|
||||
import { useStatsRegion } from "./useStats";
|
||||
import { gatedOn, useStatsRegion } from "./useStats";
|
||||
import { RANGE_MS, type StatsRange } from "./types";
|
||||
|
||||
// Shared minute boundaries let viewers reuse server aggregates without changing the key every render.
|
||||
const minuteFloor = () => Math.floor(Date.now() / 60_000) * 60_000;
|
||||
|
||||
// Shared by usePathStats/useSignalStats (and any future /stats/* window query): resolves the
|
||||
// region, floors the window to minute boundaries and polls every minute.
|
||||
export function useWindowedStats<T>(
|
||||
@@ -13,13 +16,10 @@ export function useWindowedStats<T>(
|
||||
const { where, regionKey, isResolved } = useStatsRegion();
|
||||
return useQuery({
|
||||
queryKey: [key, regionKey, range],
|
||||
enabled: isResolved !== false,
|
||||
queryFn: ({ signal }) => {
|
||||
if (isResolved === false) throw new Error("Selected region is not available yet");
|
||||
// Shared minute boundaries let viewers reuse server aggregates without changing the key every render.
|
||||
const until = Math.floor(Date.now() / 60_000) * 60_000;
|
||||
...gatedOn(isResolved, (signal) => {
|
||||
const until = minuteFloor();
|
||||
return fetch(until - RANGE_MS[range], until, where, signal);
|
||||
},
|
||||
}),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
@@ -4,9 +4,8 @@ import { getTraceDetail } from "../../api/client";
|
||||
import { DetailPanel, Section, Field } from "../../components/DetailPanel";
|
||||
import { Badge } from "../../components/Badge";
|
||||
import { Timestamp } from "../../components/Timestamp";
|
||||
import { ResolvedHopBlock } from "../packets/PathData";
|
||||
import { HopSnr, ResolvedHopBlock } from "../packets/PathData";
|
||||
import { ScopeTag } from "../../components/ScopeTag";
|
||||
import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
|
||||
import type { RawHop, ResolvedHop, TracePacket } from "../../types/api";
|
||||
|
||||
// A trace packet's path, rendered exactly like the TRACE payload view: the raw path-hash byte as the
|
||||
@@ -22,24 +21,15 @@ function TraceHopChain({ rawPath, resolvedRoute, onViewNode }: {
|
||||
if (rawPath.length === 0) return <span className="text-text-dim text-[11px] font-mono">{t("traces.noPath")}</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-2 font-mono text-[13px]">
|
||||
{rawPath.map((raw, i) => {
|
||||
const snr = raw.snr;
|
||||
const level = snr != null ? snrLevel(snr) : null;
|
||||
const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal";
|
||||
return (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && <span className="text-text-dim" aria-hidden>→</span>}
|
||||
<span className="inline-flex flex-col items-center gap-0.5">
|
||||
<ResolvedHopBlock hop={resolvedRoute[i]} label={raw.hash.toUpperCase()} onViewNode={onViewNode} showSnr={false} />
|
||||
{snr != null ? (
|
||||
<span className={`text-[11px] ${sigClass}`}>{formatSnr(snr)} dB</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-text-dim" aria-hidden>-</span>
|
||||
)}
|
||||
</span>
|
||||
{rawPath.map((raw, i) => (
|
||||
<span key={i} className="contents">
|
||||
{i > 0 && <span className="text-text-dim" aria-hidden>→</span>}
|
||||
<span className="inline-flex flex-col items-center gap-0.5">
|
||||
<ResolvedHopBlock hop={resolvedRoute[i]} label={raw.hash.toUpperCase()} onViewNode={onViewNode} showSnr={false} />
|
||||
<HopSnr snr={raw.snr} />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +67,7 @@ function TracePacketRow({ pkt, onAnalyze, onViewNode }: {
|
||||
<Field label={t("traces.last")} value={<Timestamp value={pkt.lastHeardAt} ms />} />
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<TraceHopChain rawPath={pkt.rawPath} resolvedRoute={pkt.resolvedRoute} onViewNode={onViewNode} />
|
||||
<TraceHopChain rawPath={pkt.rawPath ?? []} resolvedRoute={pkt.resolvedRoute ?? []} onViewNode={onViewNode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type CopyState = "idle" | "copied" | "failed";
|
||||
|
||||
// Copies text and flashes the outcome for 1.5s. navigator.clipboard is undefined outside a secure
|
||||
// context (plain http), so the call goes through a promise executor to turn that throw into a rejection.
|
||||
export function useCopyToClipboard(): { state: CopyState; copy: (text: string) => void } {
|
||||
const [state, setState] = useState<CopyState>("idle");
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => () => clearTimeout(timer.current), []);
|
||||
|
||||
const copy = useCallback((text: string) => {
|
||||
const flash = (next: CopyState) => {
|
||||
setState(next);
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setState("idle"), 1500);
|
||||
};
|
||||
new Promise<void>((resolve) => resolve(navigator.clipboard.writeText(text))).then(
|
||||
() => flash("copied"),
|
||||
() => flash("failed"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return { state, copy };
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useInfiniteQuery, keepPreviousData, type QueryKey } from "@tanstack/react-query";
|
||||
import type { CursorPage } from "../types/api";
|
||||
|
||||
interface UseInfinitePagesOptions<T> {
|
||||
interface UseInfinitePagesOptions<T, C> {
|
||||
queryKey: QueryKey;
|
||||
// fetch one page; cursor is the previous page's nextCursor (undefined for the first page)
|
||||
queryFn: (cursor: number | undefined) => Promise<CursorPage<T>>;
|
||||
queryFn: (cursor: C | undefined) => Promise<CursorPage<T, C>>;
|
||||
// stable id accessor for dedup — pass a module-level fn so the memo isn't rebuilt every render
|
||||
getId: (item: T) => string;
|
||||
// keep the prior key's rows on screen while a new key (e.g. a filter change) loads its first page
|
||||
@@ -23,13 +23,13 @@ interface UseInfinitePagesOptions<T> {
|
||||
// load only the first page and pull the rest on demand via loadMore(). Loads once per key (staleTime
|
||||
// Infinity, no maxPages); dedupes by id because a non-unique cursor can repeat a row across a page
|
||||
// boundary. Shared by the map and the entity tables.
|
||||
export function useInfinitePages<T>({ queryKey, queryFn, getId, keepPrevious, auto = true, enabled = true }: UseInfinitePagesOptions<T>) {
|
||||
export function useInfinitePages<T, C = number>({ queryKey, queryFn, getId, keepPrevious, auto = true, enabled = true }: UseInfinitePagesOptions<T, C>) {
|
||||
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isError, isFetchNextPageError, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn: ({ pageParam }) => queryFn(pageParam),
|
||||
queryFn: ({ pageParam }) => queryFn(pageParam as C | undefined),
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
initialPageParam: undefined as number | undefined,
|
||||
initialPageParam: undefined as C | undefined,
|
||||
staleTime: Infinity,
|
||||
enabled,
|
||||
placeholderData: keepPrevious ? keepPreviousData : undefined,
|
||||
|
||||
@@ -62,7 +62,13 @@ export function useRegions(): RegionsData {
|
||||
queryKey: ["regions"],
|
||||
queryFn: async () => {
|
||||
const summaries = await getRegions();
|
||||
return Promise.all(summaries.map((s) => getRegion(s.id)));
|
||||
// a listed region whose detail 404s is dropped like an unknown slug; 4xx is never retried,
|
||||
// so failing the whole query would leave the selection unresolved for good
|
||||
const regions = await Promise.all(summaries.map((s) => getRegion(s.id).catch((err: unknown) => {
|
||||
if ((err as { status?: unknown } | null)?.status !== 404) throw err;
|
||||
return null;
|
||||
})));
|
||||
return regions.filter((r): r is Region => r !== null);
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"loadingDots": "Loading...",
|
||||
"copyLink": "Copy link",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Copy failed",
|
||||
"filters": "Filters",
|
||||
"closeFilters": "Close filters",
|
||||
"clearAll": "Clear all",
|
||||
@@ -686,7 +687,6 @@
|
||||
},
|
||||
"observation": {
|
||||
"prop": "Prop",
|
||||
"pathSnr": "Path SNR",
|
||||
"path": "Path",
|
||||
"observer": "Observer",
|
||||
"area": "Area",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"loadingDots": "Chargement…",
|
||||
"copyLink": "Copier le lien",
|
||||
"copied": "Copié",
|
||||
"copyFailed": "Échec de la copie",
|
||||
"filters": "Filtres",
|
||||
"closeFilters": "Fermer les filtres",
|
||||
"clearAll": "Tout effacer",
|
||||
@@ -702,7 +703,6 @@
|
||||
},
|
||||
"observation": {
|
||||
"prop": "Prop.",
|
||||
"pathSnr": "SNR du trajet",
|
||||
"path": "Trajet",
|
||||
"observer": "Observateur",
|
||||
"area": "Zone",
|
||||
|
||||
+14
-15
@@ -1,8 +1,9 @@
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8080/api/v1";
|
||||
export const WS_URL = import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws";
|
||||
import { runtimeEnv } from "./runtime-env";
|
||||
|
||||
export const API_BASE = runtimeEnv("VITE_API_BASE", import.meta.env.VITE_API_BASE) ?? "http://localhost:8080/api/v1";
|
||||
export const WS_URL = runtimeEnv("VITE_WS_URL", import.meta.env.VITE_WS_URL) ?? "ws://localhost:8080/ws";
|
||||
|
||||
export const LIVE_BUFFER_CAP = 500;
|
||||
export const MAX_INFINITE_PAGES = 20;
|
||||
export const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
export const SCROLL_TOP_THRESHOLD_PX = 100;
|
||||
@@ -24,11 +25,10 @@ export const RATE_LIMIT_MAX_MS = 300_000;
|
||||
// app tab names, in display order; the ?tab URL param is validated against this list
|
||||
export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const;
|
||||
|
||||
// Per-deployment .env config (VITE_* prefix is required so the build can expose it to the browser;
|
||||
// the Docker entrypoint sed-substitutes each sentinel at container start — see .build/).
|
||||
// Per-deployment config: read via runtimeEnv, so the Docker image's /config.js (written at container
|
||||
// start, see .build/) overrides the VITE_* build-time env.
|
||||
|
||||
// Split a comma-separated env value into trimmed, non-empty entries. An unset var or an
|
||||
// un-substituted "__VITE_..__" sentinel yields a harmless single entry that matches nothing.
|
||||
// Split a comma-separated env value into trimmed, non-empty entries.
|
||||
export function parseEnvList(raw: string | undefined): string[] {
|
||||
return (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
@@ -39,8 +39,8 @@ export function filterEnabledTabs(tabs: readonly string[], disabledRaw: string |
|
||||
return tabs.filter((t) => !disabled.has(t.toLowerCase()));
|
||||
}
|
||||
|
||||
// Truthy env flag: "1"/"true"/"yes"/"on" (case-insensitive). Anything else — empty, unset, or an
|
||||
// un-substituted sentinel — is false, so the gated behaviour only turns on when explicitly asked for.
|
||||
// Truthy env flag: "1"/"true"/"yes"/"on" (case-insensitive). Anything else, empty or unset included,
|
||||
// is false, so the gated behaviour only turns on when explicitly asked for.
|
||||
export function parseEnvBool(raw: string | undefined): boolean {
|
||||
return ["1", "true", "yes", "on"].includes((raw ?? "").trim().toLowerCase());
|
||||
}
|
||||
@@ -58,20 +58,19 @@ export function selectableThemes<T extends { id: string; hidden?: boolean }>(
|
||||
return themes.filter((t) => !t.hidden);
|
||||
}
|
||||
|
||||
const enabledTabs = filterEnabledTabs(TABS, import.meta.env.VITE_DISABLED_TABS);
|
||||
const enabledTabs = filterEnabledTabs(TABS, runtimeEnv("VITE_DISABLED_TABS", import.meta.env.VITE_DISABLED_TABS));
|
||||
// Floor: an all-disabled misconfig would otherwise leave a blank app.
|
||||
export const ENABLED_TABS = enabledTabs.length > 0 ? enabledTabs : [...TABS];
|
||||
|
||||
export const ENABLED_THEME_IDS = new Set(
|
||||
parseEnvList(import.meta.env.VITE_ENABLED_THEMES).map((s) => s.toLowerCase()),
|
||||
parseEnvList(runtimeEnv("VITE_ENABLED_THEMES", import.meta.env.VITE_ENABLED_THEMES)).map((s) => s.toLowerCase()),
|
||||
);
|
||||
|
||||
// "||" (not "??") so an empty sed substitution falls back to the default too.
|
||||
export const APP_NAME = import.meta.env.VITE_APP_NAME || "BEACON";
|
||||
export const APP_NAME = runtimeEnv("VITE_APP_NAME", import.meta.env.VITE_APP_NAME) || "BEACON";
|
||||
export const GITHUB_URL = "https://github.com/MeshCore-Beacon";
|
||||
|
||||
// Notice shown above the header on every page; unset = no banner.
|
||||
export const BANNER = import.meta.env.VITE_BANNER ?? "";
|
||||
export const BANNER = runtimeEnv("VITE_BANNER", import.meta.env.VITE_BANNER) ?? "";
|
||||
|
||||
// Skip the once-per-session load splash entirely (e.g. an embedded/branded deployment).
|
||||
export const SKIP_SPLASH = parseEnvBool(import.meta.env.VITE_SKIP_SPLASH);
|
||||
export const SKIP_SPLASH = parseEnvBool(runtimeEnv("VITE_SKIP_SPLASH", import.meta.env.VITE_SKIP_SPLASH));
|
||||
@@ -0,0 +1,14 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__BEACON_CONFIG__?: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
// Container config (written to /config.js at start) wins; otherwise the build-time value, so
|
||||
// `npm run dev` and .env.local keep working. Callers pass import.meta.env.VITE_X explicitly so
|
||||
// Vite inlines only that key, not the whole env object.
|
||||
export function runtimeEnv(key: string, buildValue: string | undefined): string | undefined {
|
||||
const value = typeof window === "undefined" ? undefined : window.__BEACON_CONFIG__?.[key];
|
||||
if (typeof value === "string" && value !== "") return value;
|
||||
return buildValue;
|
||||
}
|
||||
+5
-4
@@ -2,9 +2,9 @@ import type { PathConfidence } from "./enums";
|
||||
|
||||
// response wrappers
|
||||
|
||||
export interface CursorPage<T> {
|
||||
export interface CursorPage<T, C = number> {
|
||||
items: T[];
|
||||
nextCursor: number | null;
|
||||
nextCursor: C | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
@@ -239,8 +239,9 @@ export interface TracePacket {
|
||||
scope?: string; // matched transport scope name, when any
|
||||
firstHeardAt: number; // epoch ms
|
||||
lastHeardAt: number; // epoch ms
|
||||
rawPath: RawHop[]; // one hop per trace path hash, index-aligned with resolvedRoute
|
||||
resolvedRoute: ResolvedHop[]; // one hop per trace path hash; nodes empty when unresolved
|
||||
// both null when the payload didn't parse; resolvedRoute also when the packet has no IATAs
|
||||
rawPath: RawHop[] | null; // one hop per trace path hash, index-aligned with resolvedRoute
|
||||
resolvedRoute: ResolvedHop[] | null; // one hop per trace path hash; nodes empty when unresolved
|
||||
}
|
||||
|
||||
export interface TraceDetail {
|
||||
|
||||
@@ -32,13 +32,13 @@ vi.mock("../src/api/client", () => ({
|
||||
}));
|
||||
|
||||
const packet: PacketSummary = {
|
||||
packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT",
|
||||
packetHash: "aa11", payloadType: 1, payloadTypeName: "ADVERT",
|
||||
routeType: 1, routeTypeName: "FLOOD",
|
||||
firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, observationCount: 3,
|
||||
};
|
||||
|
||||
const detail = {
|
||||
packetHash: "AA11",
|
||||
packetHash: "aa11",
|
||||
header: { raw: "12", routeType: 1, routeTypeName: "FLOOD", payloadType: 1, payloadTypeName: "ADVERT", payloadVersion: 1 },
|
||||
firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, firstToLastMs: 2000, observationCount: 3,
|
||||
rawPayload: "", decrypted: false,
|
||||
@@ -70,7 +70,7 @@ vi.mock("../src/features/packets/usePackets", () => ({
|
||||
|
||||
vi.mock("../src/features/packets/usePacketDetail", () => ({
|
||||
usePacketDetail: (hash: string | null) => ({
|
||||
data: hash === "AA11" ? detail : undefined,
|
||||
data: hash === "aa11" ? detail : undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: () => {},
|
||||
@@ -129,7 +129,7 @@ describe("opening the analyzer from an expanded row", () => {
|
||||
it("keeps the observation selected in the expanded row", async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "AA11" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "aa11" }));
|
||||
fireEvent.click(await screen.findByText("Observer Three"));
|
||||
|
||||
const drawer = await screen.findByTestId("packet-analyzer-drawer");
|
||||
@@ -138,7 +138,7 @@ describe("opening the analyzer from an expanded row", () => {
|
||||
});
|
||||
|
||||
it("replaces a shared observation when another report is selected in the row", async () => {
|
||||
window.history.replaceState({}, "", "/?tab=Packets&hash=AA11&observation=3");
|
||||
window.history.replaceState({}, "", "/?tab=Packets&hash=aa11&observation=3");
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByText("Observer Two"));
|
||||
const drawer = await screen.findByTestId("packet-analyzer-drawer");
|
||||
|
||||
@@ -36,13 +36,13 @@ vi.mock("../src/api/client", () => ({
|
||||
vi.mock("../src/features/observers/ObserverPage", () => ({ ObserverPage: () => <h1>Observer dashboard</h1> }));
|
||||
|
||||
const packet: PacketSummary = {
|
||||
packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT",
|
||||
packetHash: "aa11", payloadType: 1, payloadTypeName: "ADVERT",
|
||||
routeType: 1, routeTypeName: "FLOOD",
|
||||
firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, observationCount: 1,
|
||||
};
|
||||
|
||||
const detail = {
|
||||
packetHash: "AA11",
|
||||
packetHash: "aa11",
|
||||
header: { raw: "12", routeType: 1, routeTypeName: "FLOOD", payloadType: 1, payloadTypeName: "ADVERT", payloadVersion: 1 },
|
||||
firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, firstToLastMs: 2000, observationCount: 1,
|
||||
rawPayload: "", decrypted: false,
|
||||
@@ -72,7 +72,7 @@ vi.mock("../src/features/packets/usePackets", () => ({
|
||||
|
||||
vi.mock("../src/features/packets/usePacketDetail", () => ({
|
||||
usePacketDetail: (hash: string | null) => ({
|
||||
data: hash === "AA11" ? detail : hash === "BB22" ? { ...detail, packetHash: "BB22" } : undefined,
|
||||
data: hash === "aa11" ? detail : hash === "bb22" ? { ...detail, packetHash: "bb22" } : undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: () => {},
|
||||
@@ -140,7 +140,7 @@ afterEach(() => {
|
||||
describe("Packets deep links", () => {
|
||||
it("keeps the packet drawer beside the observer dashboard and returns to the same packet, report and filters", async () => {
|
||||
setMobile(false);
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1&observation=1&iata=YOW&q=missing");
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=aa11&analyze=1&observation=1&iata=YOW&q=missing");
|
||||
render(<App />);
|
||||
const section = await screen.findByRole("region", { name: "Observations" });
|
||||
expect(within(section).getByText("No path entries").closest("details")).toHaveAttribute("open");
|
||||
@@ -152,12 +152,12 @@ describe("Packets deep links", () => {
|
||||
await waitFor(() => expect(new URLSearchParams(window.location.search).get("tab")).toBe("Packets"));
|
||||
expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
expect(params.get("hash")).toBe("AA11"); expect(params.get("observation")).toBe("1");
|
||||
expect(params.get("hash")).toBe("aa11"); expect(params.get("observation")).toBe("1");
|
||||
expect(params.get("iata")).toBe("YOW"); expect(params.get("q")).toBe("missing");
|
||||
});
|
||||
it.each([false, true])("opens an unloaded selection explicitly and preserves region and filters (mobile=%s)", async (mobile) => {
|
||||
setMobile(mobile);
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=BB22&iata=YOW&q=missing");
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=bb22&iata=YOW&q=missing");
|
||||
render(<App />);
|
||||
const notice = await screen.findByRole("region", { name: "Selected packet" });
|
||||
expect(screen.queryByTestId("packet-analyzer-drawer")).not.toBeInTheDocument();
|
||||
@@ -166,7 +166,7 @@ describe("Packets deep links", () => {
|
||||
expect(screen.queryByRole("region", { name: "Selected packet" })).not.toBeInTheDocument();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
expect(params.get("analyze")).toBe("1");
|
||||
expect(params.get("hash")).toBe("BB22");
|
||||
expect(params.get("hash")).toBe("bb22");
|
||||
expect(params.get("iata")).toBe("YOW");
|
||||
expect(params.get("q")).toBe("missing");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close analyzer" }));
|
||||
@@ -174,11 +174,11 @@ describe("Packets deep links", () => {
|
||||
});
|
||||
|
||||
it("updates the selection notice on URL navigation", async () => {
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=BB22");
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=bb22");
|
||||
render(<App />);
|
||||
expect(await screen.findByRole("region", { name: "Selected packet" })).toBeInTheDocument();
|
||||
act(() => {
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=AA11");
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=aa11");
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
});
|
||||
expect(screen.queryByRole("region", { name: "Selected packet" })).not.toBeInTheDocument();
|
||||
@@ -186,7 +186,7 @@ describe("Packets deep links", () => {
|
||||
});
|
||||
|
||||
it("restores the expanded row and the drawer from ?hash&analyze=1", async () => {
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1");
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=aa11&analyze=1");
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByTestId("packet-expansion")).toBeInTheDocument();
|
||||
@@ -194,7 +194,7 @@ describe("Packets deep links", () => {
|
||||
});
|
||||
|
||||
it("expands the row without the drawer from ?hash alone", async () => {
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=AA11");
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=aa11");
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByTestId("packet-expansion")).toBeInTheDocument();
|
||||
@@ -205,7 +205,7 @@ describe("Packets deep links", () => {
|
||||
window.history.pushState({}, "", "/?tab=Packets&analyze=1");
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "AA11" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: "aa11" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("packet-expansion")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("packet-analyzer-drawer")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -216,7 +216,7 @@ describe("leaving the Packets tab", () => {
|
||||
// to drop ?analyze or the analyzer covers the tab the user just asked for.
|
||||
it("closes the analyzer on mobile", async () => {
|
||||
setMobile(true);
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1");
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=aa11&analyze=1");
|
||||
render(<App />);
|
||||
expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument();
|
||||
|
||||
@@ -227,7 +227,7 @@ describe("leaving the Packets tab", () => {
|
||||
|
||||
it("keeps the analyzer open on desktop", async () => {
|
||||
setMobile(false);
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1");
|
||||
window.history.pushState({}, "", "/?tab=Packets&hash=aa11&analyze=1");
|
||||
render(<App />);
|
||||
expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { App } from "../src/App";
|
||||
|
||||
// any on*/subscribe call the Packets tab makes gets a no-op unsubscribe
|
||||
vi.mock("../src/api/ws-manager", () => ({
|
||||
WsManager: class {
|
||||
constructor() {
|
||||
return new Proxy(this, { get: (target, key) => (key in target ? target[key as keyof typeof target] : () => () => {}) });
|
||||
}
|
||||
connect() {}
|
||||
disconnect() {}
|
||||
updateSubscription() {}
|
||||
},
|
||||
}));
|
||||
vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }), getScopes: async () => [] }));
|
||||
vi.mock("../src/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/lib/constants")>();
|
||||
return { ...actual, ENABLED_TABS: actual.ENABLED_TABS.filter((tab) => tab !== "Map") };
|
||||
});
|
||||
vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null }));
|
||||
vi.mock("../src/components/AppShell", () => ({
|
||||
AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => (
|
||||
<>
|
||||
<button onClick={() => onTabChange("Nodes")}>Nodes tab</button>
|
||||
<button onClick={() => onTabChange("Packets")}>Packets tab</button>
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
vi.mock("../src/features/nodes/NodeTable", () => ({ NodeTable: () => null }));
|
||||
vi.mock("../src/features/map/MapView", () => ({ MapView: () => <p>Map view</p> }));
|
||||
vi.mock("../src/features/nodes/NodeDetailPanel", () => ({
|
||||
NodeDetailPanel: ({ nodeId, onViewOnMap }: { nodeId: string; onViewOnMap?: (lat: number, lng: number) => void }) => (
|
||||
<section aria-label={`Node ${nodeId}`}>
|
||||
{onViewOnMap && <button onClick={() => onViewOnMap(45.42153, -75.69719)}>View on map</button>}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
vi.mock("../src/features/packets/usePacketDetail", () => ({
|
||||
usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 4 } } : undefined, isLoading: false }),
|
||||
}));
|
||||
vi.mock("../src/features/packets/PacketTable", () => ({ PacketTable: () => null }));
|
||||
vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({
|
||||
PacketAnalyzerDrawer: ({ onViewNode }: { onViewNode: (id: string) => void }) => <button onClick={() => onViewNode("node-b")}>Hop node</button>,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} });
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("View on map with the Map tab disabled", () => {
|
||||
it("is not offered", async () => {
|
||||
window.history.replaceState({}, "", "/?tab=Nodes&node=node-a");
|
||||
render(<App />);
|
||||
expect(await screen.findByRole("region", { name: "Node node-a" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -149,16 +149,17 @@ describe("getKnownRoutesPage", () => {
|
||||
lastSeen: 2,
|
||||
};
|
||||
|
||||
it("hits /routes, forwards iata/hopCount/cursor/limit", async () => {
|
||||
it("hits /routes, forwards iata/hopCount/cursor/cursorId/limit", async () => {
|
||||
const getUrl = mockFetchOnce([route]);
|
||||
|
||||
await getKnownRoutesPage({ iata: "YYC", hopCount: 1, cursor: 1234, limit: 50 });
|
||||
await getKnownRoutesPage({ iata: "YYC", hopCount: 1, cursor: { lastSeen: 1234, id: 7 }, limit: 50 });
|
||||
|
||||
const url = getUrl();
|
||||
expect(url).toContain("/routes");
|
||||
expect(url).toContain("iata=YYC");
|
||||
expect(url).toContain("hopCount=1");
|
||||
expect(url).toContain("cursor=1234");
|
||||
expect(url).toContain("cursorId=7");
|
||||
expect(url).toContain("limit=50");
|
||||
});
|
||||
|
||||
@@ -175,15 +176,15 @@ describe("getKnownRoutesPage", () => {
|
||||
expect(url).not.toContain("hopCount=");
|
||||
});
|
||||
|
||||
it("wraps a full page: nextCursor is the last route's lastSeen", async () => {
|
||||
it("wraps a full page: nextCursor is the last route's lastSeen and id", async () => {
|
||||
mockFetchOnce([{ ...route, lastSeen: 9 }]);
|
||||
|
||||
// a page that fills the limit means there may be more — cursor = last (oldest) lastSeen
|
||||
// a page that fills the limit means there may be more; the id breaks lastSeen ties
|
||||
const page = await getKnownRoutesPage({ limit: 1 });
|
||||
|
||||
expect(page.items).toHaveLength(1);
|
||||
expect(page.hasMore).toBe(true);
|
||||
expect(page.nextCursor).toBe(9);
|
||||
expect(page.nextCursor).toEqual({ lastSeen: 9, id: 7 });
|
||||
});
|
||||
|
||||
it("wraps a short page: nextCursor null, hasMore false", async () => {
|
||||
|
||||
@@ -320,6 +320,24 @@ describe("WsManager", () => {
|
||||
expect(unsubs2[1].subscriptionId).toBe("s-yow");
|
||||
});
|
||||
|
||||
it("drops a pending subscribe's late ack after switching to a region with no IATAs", () => {
|
||||
const mgr = new WsManager("ws://test/ws");
|
||||
mgr.connect({ iatas: ["YOW"] });
|
||||
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.simulateOpen();
|
||||
ws.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" });
|
||||
mgr.updateSubscription({ iatas: [] });
|
||||
|
||||
const sent = () => ws.sent.map((s) => JSON.parse(s));
|
||||
const [sub] = sent().filter((m) => m.type === "subscribe");
|
||||
ws.simulateMessage({ v: 1, type: "subscribed", id: sub.id, subscriptionId: "s-yow" });
|
||||
|
||||
const unsubs = sent().filter((m) => m.type === "unsubscribe");
|
||||
expect(unsubs).toHaveLength(1);
|
||||
expect(unsubs[0].subscriptionId).toBe("s-yow");
|
||||
});
|
||||
|
||||
it("ignores close events from a torn-down socket (no reconnect treadmill)", () => {
|
||||
const mgr = new WsManager("ws://test/ws");
|
||||
mgr.connect({ iatas: ["YOW"] });
|
||||
@@ -360,6 +378,48 @@ describe("WsManager", () => {
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("pings a fresh connection after a long outage instead of dropping it", () => {
|
||||
const mgr = new WsManager("ws://test/ws");
|
||||
mgr.connect({ iatas: ["YOW"] });
|
||||
const ws1 = MockWebSocket.instances[0]!;
|
||||
ws1.simulateOpen();
|
||||
ws1.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" });
|
||||
|
||||
// the socket dies and stays down for longer than the liveness window
|
||||
ws1.simulateClose(1006);
|
||||
vi.setSystemTime(Date.now() + 120_000);
|
||||
vi.advanceTimersByTime(1500);
|
||||
const ws2 = MockWebSocket.instances[1]!;
|
||||
ws2.simulateOpen();
|
||||
ws2.simulateMessage({ v: 1, type: "hello", serverTime: 2, connectionId: "c2" });
|
||||
|
||||
ws2.sent = [];
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
expect(ws2.sent.map((s) => JSON.parse(s)).filter((m) => m.type === "ping")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still reports the pre-outage time as the lag start after a reconnect", () => {
|
||||
const handler = vi.fn();
|
||||
const mgr = new WsManager("ws://test/ws");
|
||||
mgr.onLagged(handler);
|
||||
mgr.connect({ iatas: ["YOW"] });
|
||||
const ws1 = MockWebSocket.instances[0]!;
|
||||
ws1.simulateOpen();
|
||||
ws1.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" });
|
||||
ws1.simulateMessage({ v: 1, type: "pong", id: "p" });
|
||||
const lastHeard = Date.now();
|
||||
|
||||
ws1.simulateClose(1006);
|
||||
vi.setSystemTime(Date.now() + 120_000);
|
||||
vi.advanceTimersByTime(1500);
|
||||
const ws2 = MockWebSocket.instances[1]!;
|
||||
ws2.simulateOpen();
|
||||
ws2.simulateMessage({ v: 1, type: "hello", serverTime: 2, connectionId: "c2" });
|
||||
|
||||
expect(handler.mock.calls[0]![0].since).toBe(lastHeard);
|
||||
});
|
||||
|
||||
it("clears the stale subscriptionId across reconnects", () => {
|
||||
const mgr = new WsManager("ws://test/ws");
|
||||
mgr.connect({ iatas: ["YOW"] });
|
||||
|
||||
@@ -27,12 +27,13 @@ describe("CopyButton", () => {
|
||||
expect(writeText).toHaveBeenCalledWith(key);
|
||||
});
|
||||
|
||||
it("swaps to 'Copied' after clicking, then reverts", () => {
|
||||
it("swaps to 'Copied' after clicking, then reverts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<CopyButton value="deadbeef" />);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
await act(async () => {});
|
||||
expect(button).toHaveTextContent("Copied");
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
@@ -53,6 +54,28 @@ describe("CopyButton", () => {
|
||||
render(<CopyButton value="deadbeef" />);
|
||||
const button = screen.getByRole("button", { name: "Copier" });
|
||||
fireEvent.click(button);
|
||||
await act(async () => {});
|
||||
expect(button).toHaveTextContent("Copié");
|
||||
});
|
||||
|
||||
it("doesn't claim success when the clipboard API is missing (plain http)", async () => {
|
||||
Object.defineProperty(navigator, "clipboard", { value: undefined, writable: true, configurable: true });
|
||||
render(<CopyButton value="deadbeef" />);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
await act(async () => {});
|
||||
expect(button).not.toHaveTextContent("Copied");
|
||||
expect(button).toHaveTextContent("Copy failed");
|
||||
});
|
||||
|
||||
it("shows 'Copied' only once the write resolves, and a failure when it rejects", async () => {
|
||||
let reject!: (e: Error) => void;
|
||||
writeText.mockReturnValueOnce(new Promise((_, r) => (reject = r)));
|
||||
render(<CopyButton value="deadbeef" />);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
expect(button).not.toHaveTextContent("Copied");
|
||||
await act(async () => reject(new Error("denied")));
|
||||
expect(button).toHaveTextContent("Copy failed");
|
||||
});
|
||||
});
|
||||
@@ -32,12 +32,13 @@ describe("CopyLinkButton", () => {
|
||||
expect(copied.searchParams.get("node")).toBe("abc123");
|
||||
});
|
||||
|
||||
it("swaps to 'Copied' after clicking, then reverts", () => {
|
||||
it("swaps to 'Copied' after clicking, then reverts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<CopyLinkButton params={{ tab: "Observers", observer: "xyz" }} />);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
await act(async () => {});
|
||||
expect(button).toHaveTextContent("Copied");
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
@@ -72,6 +73,27 @@ describe("CopyLinkButton", () => {
|
||||
expect(copied.searchParams.get("keep")).toBe("1");
|
||||
expect(copied.searchParams.get("tab")).toBe("Map");
|
||||
});
|
||||
|
||||
it("doesn't claim success when the clipboard API is missing (plain http)", async () => {
|
||||
Object.defineProperty(navigator, "clipboard", { value: undefined, writable: true, configurable: true });
|
||||
render(<CopyLinkButton params={{ tab: "Nodes", node: "abc" }} />);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
await act(async () => {});
|
||||
expect(button).not.toHaveTextContent("Copied");
|
||||
expect(button).toHaveTextContent("Copy failed");
|
||||
});
|
||||
|
||||
it("shows 'Copied' only once the write resolves, and a failure when it rejects", async () => {
|
||||
let reject!: (e: Error) => void;
|
||||
writeText.mockReturnValueOnce(new Promise((_, r) => (reject = r)));
|
||||
render(<CopyLinkButton params={{ tab: "Nodes", node: "abc" }} />);
|
||||
const button = screen.getByRole("button");
|
||||
fireEvent.click(button);
|
||||
expect(button).not.toHaveTextContent("Copied");
|
||||
await act(async () => reject(new Error("denied")));
|
||||
expect(button).toHaveTextContent("Copy failed");
|
||||
});
|
||||
});
|
||||
|
||||
it("retains only an explicit allowlist when building a focused investigation link", () => {
|
||||
|
||||
@@ -25,6 +25,23 @@ describe("ErrorBoundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ErrorBoundary recovery", () => {
|
||||
it("offers a reload for Safari's failed module import", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await i18n.changeLanguage("en");
|
||||
render(<ErrorBoundary><Boom message="Importing a module script failed." /></ErrorBoundary>);
|
||||
expect(screen.getByRole("button", { name: "reload" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the error when its reset key changes", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await i18n.changeLanguage("en");
|
||||
const { rerender } = render(<ErrorBoundary resetKey="Map"><Boom message="bad" /></ErrorBoundary>);
|
||||
rerender(<ErrorBoundary resetKey="Packets"><p>packets</p></ErrorBoundary>);
|
||||
expect(screen.getByText("packets")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CloseButton", () => {
|
||||
it("defaults its label per language", async () => {
|
||||
const { unmount } = render(<CloseButton onClose={() => {}} />);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MultiSelectDropdown } from "../../src/components/MultiSelectDropdown";
|
||||
|
||||
describe("MultiSelectDropdown", () => {
|
||||
const onWindowKey = vi.fn();
|
||||
afterEach(() => window.removeEventListener("keydown", onWindowKey));
|
||||
|
||||
it("closes on Escape without letting an enclosing sheet close too", () => {
|
||||
window.addEventListener("keydown", onWindowKey);
|
||||
render(<MultiSelectDropdown label="Types" options={[{ value: "a", label: "A" }]} selected={[]} onChange={() => {}} />);
|
||||
const trigger = screen.getByRole("button", { name: /Types/ });
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(onWindowKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const SCRIPT = join(process.cwd(), ".build/docker-entrypoint.sh");
|
||||
|
||||
let dir: string | undefined;
|
||||
afterEach(() => {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
dir = undefined;
|
||||
});
|
||||
|
||||
// Runs the entrypoint (exec'ing `true` instead of caddy) and evaluates the config.js it writes.
|
||||
function runEntrypoint(env: Record<string, string>): Record<string, string> {
|
||||
dir ??= mkdtempSync(join(tmpdir(), "beacon-entrypoint-"));
|
||||
const out = join(dir, "config.js");
|
||||
execFileSync("sh", [SCRIPT, "true"], { env: { PATH: process.env.PATH, BEACON_CONFIG_OUT: out, ...env } });
|
||||
const window: { __BEACON_CONFIG__?: Record<string, string> } = {};
|
||||
runInNewContext(readFileSync(out, "utf8"), { window });
|
||||
return window.__BEACON_CONFIG__ ?? {};
|
||||
}
|
||||
|
||||
describe("docker-entrypoint.sh", () => {
|
||||
it("writes every key, empty when unset", () => {
|
||||
const cfg = runEntrypoint({ VITE_API_BASE: "https://api.example.com/api/v1" });
|
||||
expect(cfg.VITE_API_BASE).toBe("https://api.example.com/api/v1");
|
||||
expect(cfg.VITE_BANNER).toBe("");
|
||||
expect(Object.keys(cfg).sort()).toEqual([
|
||||
"VITE_API_BASE", "VITE_APP_NAME", "VITE_BANNER", "VITE_DISABLED_TABS", "VITE_ENABLED_THEMES",
|
||||
"VITE_MAP_CENTER", "VITE_MAP_ZOOM", "VITE_SKIP_SPLASH", "VITE_WS_URL",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips values with JS and sed metacharacters", () => {
|
||||
const banner = 'a `tick` "quote" \'single\' ${x} & | \\ back\\slash </script> café\nline2\ttab';
|
||||
expect(runEntrypoint({ VITE_BANNER: banner, VITE_APP_NAME: "R&D | Mesh" })).toMatchObject({
|
||||
VITE_BANNER: banner,
|
||||
VITE_APP_NAME: "R&D | Mesh",
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites the file on every start so a restart picks up new values", () => {
|
||||
expect(runEntrypoint({ VITE_BANNER: "first" }).VITE_BANNER).toBe("first");
|
||||
expect(runEntrypoint({ VITE_BANNER: "second" }).VITE_BANNER).toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseMapView, buildMapParams, type MapViewSnapshot } from "../../../src/features/map/map-url";
|
||||
import { parseMapView, parseNodeType, buildMapParams, type MapViewSnapshot } from "../../../src/features/map/map-url";
|
||||
|
||||
// URLSearchParams from a build result, skipping deleted (null) keys — models what the copy button does.
|
||||
function toParams(built: Record<string, string | null>): URLSearchParams {
|
||||
@@ -142,3 +142,12 @@ describe("buildMapParams", () => {
|
||||
expect(parseMapView(toParams(built)).nodeType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNodeType", () => {
|
||||
it("accepts a known type name and rejects stale or empty values", () => {
|
||||
expect(parseNodeType("repeater")).toBe("repeater");
|
||||
expect(parseNodeType("room")).toBeUndefined(); // an older saved value no type matches
|
||||
expect(parseNodeType("")).toBeUndefined();
|
||||
expect(parseNodeType(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
type Handler = (e?: unknown) => void;
|
||||
|
||||
const maps = vi.hoisted(() => [] as FakeMap[]);
|
||||
|
||||
interface FakeMap {
|
||||
fire: (type: string, e?: unknown) => void;
|
||||
fitBounds: ReturnType<typeof vi.fn>;
|
||||
flyTo: ReturnType<typeof vi.fn>;
|
||||
setStyle: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
vi.mock("../../../src/features/map/maplibre-worker", () => ({}));
|
||||
vi.mock("maplibre-gl", () => {
|
||||
class Map {
|
||||
handlers: Record<string, Handler[]> = {};
|
||||
fitBounds = vi.fn();
|
||||
flyTo = vi.fn();
|
||||
setStyle = vi.fn();
|
||||
constructor() {
|
||||
maps.push(this as unknown as FakeMap);
|
||||
}
|
||||
on(type: string, fn: Handler) {
|
||||
(this.handlers[type] ??= []).push(fn);
|
||||
}
|
||||
fire(type: string, e?: unknown) {
|
||||
for (const fn of this.handlers[type] ?? []) fn(e);
|
||||
}
|
||||
addControl() {}
|
||||
getContainer() {
|
||||
return document.createElement("div");
|
||||
}
|
||||
setMissingStyleImageResolver() {}
|
||||
getSource() {
|
||||
return {};
|
||||
}
|
||||
getLayer() {
|
||||
return {};
|
||||
}
|
||||
setTerrain() {}
|
||||
remove() {}
|
||||
}
|
||||
class LngLatBounds {
|
||||
extend() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
class Control {}
|
||||
return { Map, LngLatBounds, NavigationControl: Control, ScaleControl: Control, AttributionControl: Control };
|
||||
});
|
||||
|
||||
const { useMapLibre } = await import("../../../src/features/map/useMapLibre");
|
||||
const { resolveMapStyle } = await import("../../../src/features/map/types");
|
||||
|
||||
const camera = { center: [-75.7, 45.4] as [number, number], zoom: 9 };
|
||||
|
||||
function mount(props: { styleId?: string; fitPoints: [number, number][] | null; fitScope?: string; onStyleError?: (id: string) => void }) {
|
||||
const hook = renderHook(
|
||||
(p: typeof props) => {
|
||||
const r = useMapLibre(p.styleId ?? "dark", p.fitPoints, p.onStyleError, camera, p.fitScope);
|
||||
// the map attaches to the container ref, so hand it a node before the init effect runs
|
||||
if (!r.containerRef.current) r.containerRef.current = document.createElement("div");
|
||||
return r;
|
||||
},
|
||||
{ initialProps: props },
|
||||
);
|
||||
const map = maps[maps.length - 1]!;
|
||||
act(() => map.fire("load"));
|
||||
return { ...hook, map };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
maps.length = 0;
|
||||
});
|
||||
|
||||
describe("useMapLibre deep-link fit", () => {
|
||||
it("keeps the deep-link camera over the first fit of the linked region", () => {
|
||||
const { map, rerender } = mount({ fitPoints: null, fitScope: "ottawa" });
|
||||
rerender({ fitPoints: [[-75.7, 45.3]], fitScope: "ottawa" });
|
||||
expect(map.fitBounds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("frames a newly selected region when the linked one had no airport coords", () => {
|
||||
const { map, rerender } = mount({ fitPoints: null, fitScope: "nowhere" });
|
||||
rerender({ fitPoints: [[-79.6, 43.7]], fitScope: "toronto" });
|
||||
expect(map.fitBounds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMapLibre basemap swap errors", () => {
|
||||
function swap(onStyleError: (id: string) => void) {
|
||||
const { map, rerender } = mount({ fitPoints: null, onStyleError });
|
||||
rerender({ styleId: "liberty", fitPoints: null, onStyleError });
|
||||
expect(map.setStyle).toHaveBeenCalled();
|
||||
return map;
|
||||
}
|
||||
|
||||
it("ignores a sprite failure while the new style is still loading", () => {
|
||||
const onStyleError = vi.fn();
|
||||
const map = swap(onStyleError);
|
||||
act(() =>
|
||||
map.fire("error", { error: Object.assign(new Error("sprite"), { status: 404, url: "https://tiles.example/sprite.json" }) }),
|
||||
);
|
||||
expect(onStyleError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reverts when the style document itself fails to load", () => {
|
||||
const onStyleError = vi.fn();
|
||||
const map = swap(onStyleError);
|
||||
act(() =>
|
||||
map.fire("error", { error: Object.assign(new Error("offline"), { status: 0, url: resolveMapStyle("liberty").url }) }),
|
||||
);
|
||||
expect(onStyleError).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import {
|
||||
NODES_CLUSTER_LAYER_ID,
|
||||
NODES_POINT_LAYER_ID,
|
||||
PACKET_FLOW_TRAIL_LAYER_ID,
|
||||
PACKET_FLOW_DOT_HALO_LAYER_ID,
|
||||
PACKET_FLOW_DOT_LAYER_ID,
|
||||
} from "../../../src/features/map/types";
|
||||
|
||||
vi.mock("@nazka/map-gl-js-spiderfy", () => ({
|
||||
default: class {
|
||||
applyTo = vi.fn();
|
||||
unspiderfyAll = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
import { useMapNodes } from "../../../src/features/map/useMapNodes";
|
||||
|
||||
// map stub that keeps a real layer stack so addLayer's beforeId placement can be asserted
|
||||
function stackMap(layers: string[]): MapLibreMap {
|
||||
const sources = new Set<string>();
|
||||
const fns = new Map<PropertyKey, unknown>();
|
||||
const canvas = { style: {} as Record<string, string> };
|
||||
const impl: Record<string, unknown> = {
|
||||
getCanvas: () => canvas,
|
||||
getLayer: (id: string) => (layers.includes(id) ? { id } : undefined),
|
||||
addLayer: (spec: { id: string }, beforeId?: string) => {
|
||||
const at = beforeId ? layers.indexOf(beforeId) : -1;
|
||||
if (at >= 0) layers.splice(at, 0, spec.id);
|
||||
else layers.push(spec.id);
|
||||
},
|
||||
removeLayer: (id: string) => layers.splice(layers.indexOf(id), 1),
|
||||
getSource: (id: string) => (sources.has(id) ? { setData: vi.fn() } : undefined),
|
||||
addSource: (id: string) => sources.add(id),
|
||||
removeSource: (id: string) => sources.delete(id),
|
||||
getStyle: () => ({ layers: layers.map((id) => ({ id })) }),
|
||||
};
|
||||
return new Proxy({} as MapLibreMap, {
|
||||
get(_, key) {
|
||||
if (typeof key === "string" && key in impl) return impl[key];
|
||||
if (key === "then") return undefined;
|
||||
if (!fns.has(key)) fns.set(key, vi.fn());
|
||||
return fns.get(key);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("useMapNodes layer order", () => {
|
||||
it("keeps node layers beneath the packet-flow layers after a clustering toggle", () => {
|
||||
const layers: string[] = [];
|
||||
const mapRef = { current: stackMap(layers) };
|
||||
const geojson = { type: "FeatureCollection" as const, features: [] };
|
||||
const { rerender } = renderHook(
|
||||
({ clustered }) =>
|
||||
useMapNodes(mapRef, { current: null }, true, geojson, true, "dark", clustered, () => {}, null, true, null),
|
||||
{ initialProps: { clustered: true } },
|
||||
);
|
||||
// packet flow builds its layers after the nodes, on top
|
||||
layers.push(PACKET_FLOW_TRAIL_LAYER_ID, PACKET_FLOW_DOT_HALO_LAYER_ID, PACKET_FLOW_DOT_LAYER_ID);
|
||||
|
||||
rerender({ clustered: false });
|
||||
|
||||
const trail = layers.indexOf(PACKET_FLOW_TRAIL_LAYER_ID);
|
||||
expect(layers.indexOf(NODES_CLUSTER_LAYER_ID)).toBeLessThan(trail);
|
||||
expect(layers.indexOf(NODES_POINT_LAYER_ID)).toBeLessThan(trail);
|
||||
expect(layers.indexOf(NODES_CLUSTER_LAYER_ID)).toBeLessThan(layers.indexOf(NODES_POINT_LAYER_ID));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
|
||||
const spiders = vi.hoisted(() => [] as { applyTo: ReturnType<typeof vi.fn>; unspiderfyAll: ReturnType<typeof vi.fn> }[]);
|
||||
|
||||
vi.mock("@nazka/map-gl-js-spiderfy", () => ({
|
||||
default: class {
|
||||
// unspiderfyAll also unbinds the cluster click, so a reset must re-apply on a fresh instance
|
||||
applyTo = vi.fn();
|
||||
unspiderfyAll = vi.fn();
|
||||
constructor() {
|
||||
spiders.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { useMapNodes } from "../../../src/features/map/useMapNodes";
|
||||
|
||||
function stubMap(): MapLibreMap {
|
||||
const fns = new Map<PropertyKey, unknown>();
|
||||
const canvas = { style: {} as Record<string, string> };
|
||||
return new Proxy({} as MapLibreMap, {
|
||||
get(_, key) {
|
||||
if (key === "getCanvas") return () => canvas;
|
||||
if (key === "getSource") return () => ({ setData: vi.fn() });
|
||||
if (key === "then") return undefined;
|
||||
if (!fns.has(key)) fns.set(key, vi.fn());
|
||||
return fns.get(key);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("useMapNodes spiderfy", () => {
|
||||
it("re-applies spiderfy to clusters when the dataset resets", () => {
|
||||
const mapRef = { current: stubMap() };
|
||||
const iconRef = { current: null };
|
||||
const geojson = { type: "FeatureCollection" as const, features: [] };
|
||||
const { rerender } = renderHook(
|
||||
({ resetKey }) =>
|
||||
useMapNodes(mapRef, iconRef, true, geojson, true, "dark", true, () => {}, null, false, null, resetKey),
|
||||
{ initialProps: { resetKey: "YOW:all" } },
|
||||
);
|
||||
const before = spiders.length;
|
||||
|
||||
rerender({ resetKey: "YYZ:all" });
|
||||
|
||||
const latest = spiders[spiders.length - 1]!;
|
||||
expect(spiders.length).toBe(before + 1);
|
||||
expect(latest.applyTo).toHaveBeenCalled();
|
||||
expect(latest.unspiderfyAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import { ObserverSidebar } from "../../../src/features/observers/ObserverSidebar
|
||||
import { getTopObservers } from "../../../src/api/client";
|
||||
import type { ObserverSummary } from "../../../src/features/observers/types";
|
||||
|
||||
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ regionKey: "YOW", iatas: ["YOW"] }) }));
|
||||
const region = { regionKey: "YOW", iatas: ["YOW"], isResolved: true };
|
||||
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region }));
|
||||
vi.mock("../../../src/api/client", () => ({ getTopObservers: vi.fn() }));
|
||||
|
||||
const observers: ObserverSummary[] = [
|
||||
@@ -58,3 +59,11 @@ it("says when the filters match nothing", () => {
|
||||
view({ list: [], filtered: true });
|
||||
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;
|
||||
});
|
||||
@@ -26,6 +26,13 @@ describe("observerDestination", () => {
|
||||
expect(p.has("compareB")).toBe(false);
|
||||
expect(p.has("compareSince")).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the analytics compare end but keeps it for an open observer comparison", () => {
|
||||
expect(observerDestination(new URLSearchParams("compareA=x&compareB=y&compareSince=1&compareUntil=2"), "b").has("compareUntil")).toBe(false);
|
||||
const comparing = observerDestination(new URLSearchParams("observer=a&compareWith=c&compareUntil=2"), "b");
|
||||
expect(comparing.get("compareWith")).toBe("c");
|
||||
expect(comparing.get("compareUntil")).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("observerRange", () => {
|
||||
|
||||
@@ -79,6 +79,33 @@ describe("ObservationCard", () => {
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// the server swaps a TRACE's pathBytes to its hop hashes; per-hop SNR comes from the parsed payload
|
||||
it("renders a TRACE path as resolvable hops with each hop's SNR underneath", () => {
|
||||
render(
|
||||
<ObservationCard
|
||||
observation={obs({
|
||||
pathBytes: "abcdef", pathLength: { raw: "03", hashSize: 1, hopCount: 3 },
|
||||
resolvedPath: [{ confidence: "high", nodes: [{ id: "n1", publicKey: "ab", name: "Alpha" }] }],
|
||||
})}
|
||||
isTrace
|
||||
snrValues={[-5, 7.25]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Path")).toBeInTheDocument();
|
||||
expect(screen.queryByText("ABCDEF")).not.toBeInTheDocument();
|
||||
for (const hop of ["AB", "CD", "EF"]) expect(screen.getByText(hop)).toBeInTheDocument();
|
||||
expect(screen.getByText("AB").className).toContain("text-green");
|
||||
expect(screen.getByText("-5.00 dB")).toBeInTheDocument();
|
||||
expect(screen.getByText("7.25 dB")).toBeInTheDocument();
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps non-trace paths free of SNR sub-lines", () => {
|
||||
render(<ObservationCard observation={obs({ pathBytes: "4142", pathLength: { raw: "02", hashSize: 1, hopCount: 2 } })} />);
|
||||
expect(screen.queryByText("-")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels the stats and path in French", async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
render(<ObservationCard observation={obs({ pathBytes: "41", radio: { freqMhz: 910.525 } })} />);
|
||||
|
||||
@@ -92,15 +92,20 @@ describe("PacketExpansion below md", () => {
|
||||
expect(onOpenAnalyzer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// TRACE reuses the path field for per-hop SNR samples, so its bytes must never render as hops.
|
||||
it("labels a TRACE packet's path bytes as SNR samples", () => {
|
||||
// GET /packets/{hash} swaps a TRACE's pathBytes to its hop hashes; the SNR per hop rides on parsedPayload.
|
||||
it("shows a TRACE packet's hops with the per-hop SNR from its payload", () => {
|
||||
usePacketDetail.mockReturnValue({
|
||||
data: detail([obs(1, { pathBytes: "3201e0", pathLength: { raw: "3201e0", hashSize: 1, hopCount: 3 } })], PayloadType.TRACE),
|
||||
data: {
|
||||
...detail([obs(1, { pathBytes: "abcd", pathLength: { raw: "02", hashSize: 1, hopCount: 2 } })], PayloadType.TRACE),
|
||||
parsedPayload: { type: "TRACE", pathHashes: ["ab", "cd"], snrValues: [8] },
|
||||
},
|
||||
});
|
||||
render(<PacketExpansion {...props} packet={pkt({ payloadType: PayloadType.TRACE })} />);
|
||||
|
||||
expect(screen.getByText("Path SNR")).toBeInTheDocument();
|
||||
expect(screen.getByText("3201E0")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Path SNR")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("AB")).toBeInTheDocument();
|
||||
expect(screen.getByText("CD")).toBeInTheDocument();
|
||||
expect(screen.getByText("8.00 dB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// the expansion is the unfolded bottom half of the tapped card, not a separate square slab
|
||||
|
||||
@@ -202,47 +202,57 @@ describe("PacketList expanded row", () => {
|
||||
});
|
||||
|
||||
it("expands a row from ?hash without opening the analyzer", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
|
||||
|
||||
const { onAnalyze } = renderList("/?tab=Packets&hash=AA11");
|
||||
const { onAnalyze } = renderList("/?tab=Packets&hash=aa11");
|
||||
|
||||
expect(screen.getByRole("button", { name: /AA11/ })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("button", { name: /aa11/ })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(onAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// packet hashes are lowercase hex; a hand-typed or uppercased link must still match the row
|
||||
it("expands the row from an uppercase ?hash", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
|
||||
|
||||
renderList("/?tab=Packets&hash=AA11");
|
||||
|
||||
expect(screen.getByRole("button", { name: /aa11/ })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(usePacketDetail).toHaveBeenLastCalledWith("aa11");
|
||||
});
|
||||
|
||||
it("clicking a row sets ?hash and does not open the analyzer", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
|
||||
|
||||
const { onAnalyze } = renderList("/?tab=Packets");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /AA11/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /aa11/ }));
|
||||
expect(onAnalyze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes the expansion's Open analyzer through onAnalyze with the expanded hash", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
|
||||
|
||||
const { onAnalyze } = renderList("/?tab=Packets&hash=AA11");
|
||||
const { onAnalyze } = renderList("/?tab=Packets&hash=aa11");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open analyzer" }));
|
||||
expect(onAnalyze).toHaveBeenCalledWith("AA11");
|
||||
expect(onAnalyze).toHaveBeenCalledWith("aa11");
|
||||
});
|
||||
|
||||
it("hands the loaded detail to onViewPath", () => {
|
||||
const detail = { packetHash: "AA11", observations: [] } as unknown as PacketDetail;
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
const detail = { packetHash: "aa11", observations: [] } as unknown as PacketDetail;
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), data: detail });
|
||||
|
||||
const { onViewPath } = renderList("/?tab=Packets&hash=AA11");
|
||||
const { onViewPath } = renderList("/?tab=Packets&hash=aa11");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Map all paths" }));
|
||||
expect(onViewPath).toHaveBeenCalledWith(detail);
|
||||
});
|
||||
|
||||
it("does not call onViewPath before the detail has loaded", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
|
||||
|
||||
const { onViewPath } = renderList("/?tab=Packets&hash=AA11");
|
||||
const { onViewPath } = renderList("/?tab=Packets&hash=aa11");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Map all paths" }));
|
||||
expect(onViewPath).not.toHaveBeenCalled();
|
||||
@@ -257,23 +267,23 @@ describe("PacketList live observation invalidation", () => {
|
||||
|
||||
it("refetches the expanded row's detail when an observation arrives for it", () => {
|
||||
const handlePacketObservation = vi.fn();
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")], handlePacketObservation }));
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")], handlePacketObservation }));
|
||||
|
||||
const { invalidate } = renderList("/?tab=Packets&hash=AA11");
|
||||
const { invalidate } = renderList("/?tab=Packets&hash=aa11");
|
||||
invalidate.mockClear();
|
||||
packetHandler!(observation("AA11"));
|
||||
packetHandler!(observation("aa11"));
|
||||
|
||||
expect(handlePacketObservation).toHaveBeenCalledTimes(1);
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["packet-detail", "AA11"] });
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["packet-detail", "aa11"] });
|
||||
});
|
||||
|
||||
it("leaves the detail query alone for observations on other packets", () => {
|
||||
const handlePacketObservation = vi.fn();
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")], handlePacketObservation }));
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")], handlePacketObservation }));
|
||||
|
||||
const { invalidate } = renderList("/?tab=Packets&hash=AA11");
|
||||
const { invalidate } = renderList("/?tab=Packets&hash=aa11");
|
||||
invalidate.mockClear();
|
||||
packetHandler!(observation("BB22"));
|
||||
packetHandler!(observation("bb22"));
|
||||
|
||||
expect(handlePacketObservation).toHaveBeenCalledTimes(1);
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
@@ -281,11 +291,11 @@ describe("PacketList live observation invalidation", () => {
|
||||
|
||||
it("does not invalidate when no row is expanded", () => {
|
||||
const handlePacketObservation = vi.fn();
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")], handlePacketObservation }));
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")], handlePacketObservation }));
|
||||
|
||||
const { invalidate } = renderList("/?tab=Packets");
|
||||
invalidate.mockClear();
|
||||
packetHandler!(observation("AA11"));
|
||||
packetHandler!(observation("aa11"));
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -300,55 +310,55 @@ describe("PacketList selected packet outside the loaded results", () => {
|
||||
it("offers the existing analyzer without scanning history for an unloaded packet", () => {
|
||||
const fetchNextPage = vi.fn();
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), hasNextPage: true, fetchNextPage }));
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "AA11" } as PacketDetail });
|
||||
const { onAnalyze } = renderList("/?hash=AA11");
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "aa11" } as PacketDetail });
|
||||
const { onAnalyze } = renderList("/?hash=aa11");
|
||||
const notice = screen.getByRole("region", { name: "Selected packet" });
|
||||
expect(notice).toHaveTextContent("outside the loaded results");
|
||||
fireEvent.click(within(notice).getByRole("button", { name: "Open analyzer" }));
|
||||
expect(onAnalyze).toHaveBeenCalledWith("AA11");
|
||||
expect(onAnalyze).toHaveBeenCalledWith("aa11");
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers the action when current filters hide a loaded selection", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "AA11" } as PacketDetail });
|
||||
renderList("/?hash=AA11&q=other");
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "aa11" } as PacketDetail });
|
||||
renderList("/?hash=aa11&q=other");
|
||||
expect(screen.getByRole("region", { name: "Selected packet" })).toHaveTextContent("outside the loaded results");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear", exact: true }));
|
||||
expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "AA11", exact: true })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("button", { name: "aa11", exact: true })).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it.each(["/?hash=AA11", "/?hash=AA11&analyze=1", "/"])("does not duplicate a loaded row or an analyzer (%s)", (url) => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] }));
|
||||
it.each(["/?hash=aa11", "/?hash=aa11&analyze=1", "/"])("does not duplicate a loaded row or an analyzer (%s)", (url) => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("aa11")] }));
|
||||
renderList(url);
|
||||
expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull();
|
||||
});
|
||||
|
||||
it("waits for the initial list before declaring the selection outside it", () => {
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: true }));
|
||||
renderList("/?hash=AA11");
|
||||
renderList("/?hash=aa11");
|
||||
expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the selected detail loading state", () => {
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), isLoading: true });
|
||||
renderList("/?hash=AA11");
|
||||
renderList("/?hash=aa11");
|
||||
expect(screen.getByRole("region", { name: "Selected packet" })).toHaveTextContent("Loading selected packet");
|
||||
});
|
||||
|
||||
it.each([[400, "Invalid packet hash"], [404, "Packet not found"], [503, "Failed to load selected packet"]])("explains HTTP %i and lets the user retry", (status, message) => {
|
||||
const refetch = vi.fn();
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), isError: true, error: Object.assign(new Error("request failed"), { status }), refetch });
|
||||
renderList("/?hash=AA11");
|
||||
renderList("/?hash=aa11");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(message);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry selected packet" }));
|
||||
expect(refetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("dismisses only the selection, leaving the current filters in place", () => {
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "AA11" } as PacketDetail });
|
||||
renderList("/?hash=AA11&q=other&types=4");
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "aa11" } as PacketDetail });
|
||||
renderList("/?hash=aa11&q=other&types=4");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss selected packet" }));
|
||||
expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull();
|
||||
expect(screen.getByTestId("expanded")).toHaveTextContent("null");
|
||||
@@ -361,8 +371,8 @@ describe("PacketList in French", () => {
|
||||
it("translates the banners and the selected-packet card", async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
usePackets.mockImplementation(() => ({ ...basePackets(), laggedCount: 2 }));
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "AA11" } as PacketDetail });
|
||||
renderList("/?hash=AA11");
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "aa11" } as PacketDetail });
|
||||
renderList("/?hash=aa11");
|
||||
expect(screen.getByText("2 paquets perdus — les données peuvent être incomplètes")).toBeInTheDocument();
|
||||
expect(screen.getByText("Paquets en direct")).toBeInTheDocument();
|
||||
const card = screen.getByRole("region", { name: "Paquet sélectionné" });
|
||||
@@ -375,7 +385,7 @@ describe("PacketList in French", () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
usePackets.mockImplementation(basePackets);
|
||||
usePacketDetail.mockReturnValue({ ...baseDetail(), isError: true, error: Object.assign(new Error("x"), { status: 404 }) });
|
||||
renderList("/?hash=AA11&sf=path");
|
||||
renderList("/?hash=aa11&sf=path");
|
||||
expect(screen.getByText("Paquet introuvable.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Réessayer de charger le paquet sélectionné" })).toHaveTextContent("Réessayer");
|
||||
expect(screen.getByText(/Recherche dans le dernier trajet de relais/)).toBeInTheDocument();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { groupPacketReports, reportSelection } from "../../../src/features/packets/packet-investigation";
|
||||
import type { Observation } from "../../../src/types/api";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupPacketReports, reportSelection, traceSnrValues } from "../../../src/features/packets/packet-investigation";
|
||||
import type { Observation, PacketDetail } from "../../../src/types/api";
|
||||
|
||||
const observation = (id: number, pathBytes?: string, hashSize = 1, hopCount = 2): Observation => ({
|
||||
id, observerId: `observer-${id}`, iata: "YOW", heardAt: id * 1000, sourceBroker: "mqtt1",
|
||||
@@ -28,3 +28,17 @@ it("validates a requested report instead of silently substituting another", () =
|
||||
expect(reportSelection(rows, new URLSearchParams(), 9)).toMatchObject({ selected: { id: 1 }, unavailable: false });
|
||||
expect(reportSelection([], new URLSearchParams(), null).selected).toBeNull();
|
||||
});
|
||||
|
||||
describe("traceSnrValues", () => {
|
||||
const detail = (parsedPayload: PacketDetail["parsedPayload"]) => ({ parsedPayload } as PacketDetail);
|
||||
|
||||
it("reads the per-hop SNR off the parsed payload", () => {
|
||||
expect(traceSnrValues(detail({ type: "TRACE", snrValues: [-5, 8] }))).toEqual([-5, 8]);
|
||||
});
|
||||
|
||||
it("returns undefined when the payload has none or wasn't parsed", () => {
|
||||
expect(traceSnrValues(detail({ type: "TRACE" }))).toBeUndefined();
|
||||
expect(traceSnrValues(detail("raw"))).toBeUndefined();
|
||||
expect(traceSnrValues(detail(undefined))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -159,4 +159,15 @@ describe("transport frames", () => {
|
||||
const ranges = computeFieldRanges(d, obs, frame.length / 2);
|
||||
expect(frame.slice(ranges.pathLength!.start * 2, ranges.pathLength!.end * 2)).toBe("81");
|
||||
});
|
||||
// older rows can carry a transport route without stored codes; the frame has no bytes for them
|
||||
it("keeps the field ranges aligned when a transport route has no codes", () => {
|
||||
const d = makeDetail({ payloadType: PayloadType.GROUP_TEXT, rawPayload: "3b5c22" });
|
||||
d.header = { ...d.header, raw: "14", routeType: RouteType.TRANSPORT_FLOOD, routeTypeName: "TRANSPORT_FLOOD" };
|
||||
const obs = makeObs({ raw: "81", hashSize: 3, hopCount: 1, pathBytes: "bd6089" });
|
||||
const frame = buildObservationFrame(d, obs);
|
||||
const ranges = computeFieldRanges(d, obs, frame.length / 2);
|
||||
expect(ranges.transport).toBeUndefined();
|
||||
expect(frame.slice(ranges.pathLength!.start * 2, ranges.pathLength!.end * 2)).toBe("81");
|
||||
expect(frame.slice(ranges.payload!.start * 2, ranges.payload!.end * 2)).toBe("3b5c22");
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,14 @@ describe("PayloadBreakdown — trace resolvedRoute overlay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayloadBreakdown — PING", () => {
|
||||
it("renders a single-hop PING with the trace layout", () => {
|
||||
const ping = { type: "PING", flags: 0, pathHashes: ["ab"], snrValues: [-5] };
|
||||
render(<PayloadBreakdown payload={ping} resolvedRoute={[resolvedRoute[0]!]} />);
|
||||
expect(screen.getByText("AB").className).toContain("text-green");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayloadBreakdown — resolved source/destination endpoints", () => {
|
||||
const envelope = {
|
||||
type: "TEXT_MESSAGE",
|
||||
|
||||
@@ -283,7 +283,7 @@ describe("usePackets path and endpoint fields", () => {
|
||||
observation: {
|
||||
observerId: "obs-1",
|
||||
observerName: "Raven",
|
||||
iata: "YVR",
|
||||
iata: "YOW",
|
||||
heardAt: 1700000000,
|
||||
rssi: -94,
|
||||
snr: -7.5,
|
||||
@@ -416,6 +416,44 @@ describe("usePackets live heard window", () => {
|
||||
expect(packet.latestObserver?.id).toBe("o2");
|
||||
expect(packet.observationCount).toBe(2);
|
||||
});
|
||||
|
||||
// the old subscription keeps delivering until the server takes the new one, after the buffer reset
|
||||
it("drops observations from an IATA outside the current region", async () => {
|
||||
Object.assign(region, { iatas: ["YVR"], regionKey: "YVR" });
|
||||
try {
|
||||
const { result } = renderHook(() => usePackets(), { wrapper });
|
||||
await waitFor(() => expect(getPackets).toHaveBeenCalled());
|
||||
|
||||
act(() => {
|
||||
result.current.handlePacketObservation(observation("stale"));
|
||||
const fresh = observation("fresh");
|
||||
fresh.observation.iata = "YVR";
|
||||
result.current.handlePacketObservation(fresh);
|
||||
flushRaf();
|
||||
});
|
||||
|
||||
expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["fresh"]);
|
||||
} finally {
|
||||
Object.assign(region, { iatas: ["YOW"], regionKey: "YOW" });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every observation when all regions are selected", async () => {
|
||||
Object.assign(region, { iatas: undefined, regionKey: "*" });
|
||||
try {
|
||||
const { result } = renderHook(() => usePackets(), { wrapper });
|
||||
await waitFor(() => expect(getPackets).toHaveBeenCalled());
|
||||
|
||||
act(() => {
|
||||
result.current.handlePacketObservation(observation("any"));
|
||||
flushRaf();
|
||||
});
|
||||
|
||||
expect(result.current.allPackets.map((p) => p.packetHash)).toEqual(["any"]);
|
||||
} finally {
|
||||
Object.assign(region, { iatas: ["YOW"], regionKey: "YOW" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePackets freeze while scrolled away", () => {
|
||||
@@ -526,3 +564,26 @@ describe("usePackets while the region loads", () => {
|
||||
await waitFor(() => expect(getPackets).toHaveBeenCalledWith(["YOW"], expect.anything()));
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePackets deep paging", () => {
|
||||
it("keeps the newest history page after many older pages load", async () => {
|
||||
getPackets.mockReset();
|
||||
getPackets.mockImplementation((_iatas: unknown, opts: { cursor?: number }) => {
|
||||
const n = opts.cursor ?? 0;
|
||||
return Promise.resolve({ items: [packet(`p${n}`)], nextCursor: n + 1 });
|
||||
});
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => usePackets(), { wrapper });
|
||||
await waitFor(() => expect(result.current.allPackets).toHaveLength(1));
|
||||
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
await act(() => result.current.fetchNextPage());
|
||||
}
|
||||
|
||||
await waitFor(() => expect(result.current.allPackets).toHaveLength(26));
|
||||
expect(result.current.allPackets[0]!.packetHash).toBe("p0");
|
||||
});
|
||||
});
|
||||
@@ -183,7 +183,13 @@ it("distinguishes initial loading from successful empty results and zero totals"
|
||||
await act(() => next.resolve({ ...series, completeHours: 0, hours: [], summary: values({}) }));
|
||||
await screen.findByText("0 obs");
|
||||
expect(screen.getAllByText("No data")).toHaveLength(7);
|
||||
expect(within(card("Total packets")).getByText("0")).toBeInTheDocument();
|
||||
for (const label of ["Total packets", "Observations", "Active observers", "Active areas"]) expect(within(card(label)).getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows real zero KPIs once some hours are complete", async () => {
|
||||
vi.mocked(getStatsSeries).mockResolvedValue({ ...series, summary: values({}) });
|
||||
mount(); await loaded();
|
||||
for (const label of ["Total packets", "Observations", "Active observers", "Active areas"]) expect(within(card(label)).getByText("0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the scope list collapsed until requested", async () => {
|
||||
@@ -206,8 +212,10 @@ it("shows French labels for the KPIs, chart titles and scope table", async () =>
|
||||
mount(); await loaded();
|
||||
expect(within(card("Paquets totaux")).getByText("111")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Dernières 24 h · jusqu’à 03:00 UTC")).toHaveLength(4);
|
||||
expect(screen.getByText("Observations · 24h")).toBeInTheDocument();
|
||||
expect(screen.getByText("Meilleurs nœuds · 24h")).toBeInTheDocument();
|
||||
expect(screen.getByText("Observations · 24 h")).toBeInTheDocument();
|
||||
expect(screen.getByText("Meilleurs nœuds · 24 h")).toBeInTheDocument();
|
||||
expect(screen.getByText("Meilleurs observateurs · 24 h")).toBeInTheDocument();
|
||||
expect(screen.getByText("Types de paquets · 24 h")).toBeInTheDocument();
|
||||
const typesChart = within(card("Types de nœuds · historique complet")).getByTestId("chart");
|
||||
expect(typesChart.textContent).toContain("NŒUDS");
|
||||
fireEvent.click(screen.getByText("Scopes", { exact: true }));
|
||||
@@ -220,7 +228,7 @@ it("shows French labels for the KPIs, chart titles and scope table", async () =>
|
||||
it("names the observation and preset chart series in French", async () => {
|
||||
await act(() => i18n.changeLanguage("fr"));
|
||||
mount(); await loaded();
|
||||
const obsChart = within(card("Observations · 24h")).getByTestId("chart").textContent;
|
||||
const obsChart = within(card("Observations · 24 h")).getByTestId("chart").textContent;
|
||||
expect(obsChart).toContain('"legend":{"data":["Observations","Paquets uniques"]');
|
||||
const presetChart = within(card("Préréglages radio · historique complet")).getByTestId("chart").textContent;
|
||||
expect(presetChart).toContain('"Nœuds","Observateurs"');
|
||||
|
||||
@@ -202,6 +202,7 @@ describe("Observer dashboard hierarchy", () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
renderTab();
|
||||
expect(screen.getByText("Paquets reçus")).toBeInTheDocument();
|
||||
expect(screen.getByText("Temps radio TX / RX · 24 h")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Détails de l’appareil")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Copier la clé publique/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -113,3 +113,14 @@ it("draws hourly sparks on all four scope cards from the scopes left after searc
|
||||
expect(points()).toHaveLength(4);
|
||||
expect(points()).not.toEqual(before);
|
||||
});
|
||||
|
||||
it("shows no scoped packet total before any hour is rolled, keeping the membership counts", async () => {
|
||||
await act(() => i18n.changeLanguage("en"));
|
||||
const saved = seriesQuery.data;
|
||||
seriesQuery.data = { ...saved, completeHours: 0 } as typeof saved;
|
||||
try {
|
||||
render(<ScopesTab range="24h" />);
|
||||
expect(within(screen.getByText("Scoped packets").parentElement!.parentElement!).getByText("—")).toBeInTheDocument();
|
||||
expect(within(screen.getAllByText("Observers")[0]!.parentElement!.parentElement!).getByText("4")).toBeInTheDocument();
|
||||
} finally { seriesQuery.data = saved; }
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TalkersTab } from "../../../src/features/stats/TalkersTab";
|
||||
@@ -93,6 +93,10 @@ it("keeps advertisers usable when only the sender request fails", async () => {
|
||||
});
|
||||
|
||||
it("retains valid rows and rates during an ordinary background refresh", async () => {
|
||||
// past :35 the rolled window is a full 24h
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(Date.UTC(2026, 9, 2, 12, 40));
|
||||
onTestFinished(() => { vi.useRealTimers(); });
|
||||
const { client } = mount();
|
||||
await screen.findByText("Old advertiser"); await screen.findByText("Old sender");
|
||||
const next = deferred<TopAdvertiser[]>();
|
||||
@@ -143,3 +147,14 @@ it("renders the leaderboards in French", async () => {
|
||||
expect(screen.getByText("Principaux émetteurs · 24 h")).toBeInTheDocument();
|
||||
expect(screen.getByText("Nœud")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("divides advert rates by the hours the rollup actually covers", async () => {
|
||||
// at 12:10 UTC the 11:00 hour isn't rolled yet, so a 24h request covers 23 rolled hours
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(Date.UTC(2026, 9, 2, 12, 10));
|
||||
onTestFinished(() => { vi.useRealTimers(); });
|
||||
vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, floodAdvertCount: 20, directAdvertCount: 4 }]);
|
||||
mount();
|
||||
expect(await screen.findByText("21/d")).toBeInTheDocument();
|
||||
expect(screen.getByText("4.2/d")).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- poking into loose ECharts option shapes */
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { typeBarOption, leaderboardOption, donutOption, presetBarsOption, airtimeOption, receiveErrorsOption, busyOption, heardOption, snrHeardOption } from "../../../src/features/stats/chartOptions";
|
||||
import { typeBarOption, leaderboardOption, donutOption, presetBarsOption, airtimeOption, receiveErrorsOption, busyOption, heardOption, snrHeardOption, observationsAreaOption, batteryOption, noiseFloorOption, queueOption } from "../../../src/features/stats/chartOptions";
|
||||
import type { ChartColors } from "../../../src/features/stats/chartTheme";
|
||||
import type { ActivityPoint, TelemetryPoint } from "../../../src/features/stats/types";
|
||||
|
||||
@@ -249,3 +249,14 @@ describe("receiveErrorsOption", () => {
|
||||
expect(opt.series[0].data).toEqual([[1000, 5], [2000, 8], [3000, 8]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("time-axis charts", () => {
|
||||
it("label hours in UTC like the rest of analytics", () => {
|
||||
const w = { start: 0, end: 4 * 3_600_000 };
|
||||
const options = [
|
||||
observationsAreaOption([], colors), airtimeOption([], colors, null), batteryOption([], colors), noiseFloorOption([], colors),
|
||||
queueOption([], colors), receiveErrorsOption([], colors, true), busyOption([], colors, null, w), heardOption([], colors, w), snrHeardOption([], colors, w),
|
||||
] as Record<string, any>[];
|
||||
for (const opt of options) expect(opt.useUTC).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import * as client from "../../../src/api/client";
|
||||
import {
|
||||
usePayloadBreakdown, useTopNodes, useTopObservers, useTopAdvertisers, useTopTalkers, useRadioPresets, useNodeTypes, useClockDrift,
|
||||
} from "../../../src/features/stats/useStats";
|
||||
|
||||
const region = { iatas: undefined as string[] | undefined, regionKey: "west", isResolved: false };
|
||||
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region }));
|
||||
vi.mock("../../../src/api/client", () => {
|
||||
const empty = () => vi.fn(() => Promise.resolve([]));
|
||||
return {
|
||||
getPayloadBreakdown: empty(), getTopNodes: empty(), getTopObservers: empty(), getTopAdvertisers: empty(),
|
||||
getTopTalkers: empty(), getRadioPresets: empty(), getStatsNodeTypes: empty(), getClockDrift: empty(),
|
||||
};
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
const hooks = [
|
||||
["usePayloadBreakdown", () => usePayloadBreakdown("24h"), client.getPayloadBreakdown],
|
||||
["useTopNodes", () => useTopNodes("24h"), client.getTopNodes],
|
||||
["useTopObservers", () => useTopObservers("24h"), client.getTopObservers],
|
||||
["useTopAdvertisers", () => useTopAdvertisers("24h"), client.getTopAdvertisers],
|
||||
["useTopTalkers", () => useTopTalkers("24h"), client.getTopTalkers],
|
||||
["useRadioPresets", () => useRadioPresets(), client.getRadioPresets],
|
||||
["useNodeTypes", () => useNodeTypes(), client.getStatsNodeTypes],
|
||||
["useClockDrift", () => useClockDrift(), client.getClockDrift],
|
||||
] as const;
|
||||
|
||||
it.each(hooks)("%s waits for the selected region instead of fetching every IATA", async (_name, hook, fetcher) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const { result, unmount } = renderHook(hook, { wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> });
|
||||
await Promise.resolve();
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect(result.current.fetchStatus).toBe("idle");
|
||||
unmount(); queryClient.clear();
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { useScopes } from "../../../src/features/stats/useStats";
|
||||
import { rolledWindow, useScopes } from "../../../src/features/stats/useStats";
|
||||
import { getStatsScopes } from "../../../src/api/client";
|
||||
import { ScopesTab } from "../../../src/features/stats/ScopesTab";
|
||||
import i18n from "../../../src/i18n";
|
||||
@@ -43,13 +43,13 @@ it("reuses the regional query when only the language changes", async () => {
|
||||
unmount(); client.clear();
|
||||
});
|
||||
|
||||
it("windows scope counts by the selected range", async () => {
|
||||
it("windows scope counts like the series, from the same rolled hour", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(Date.UTC(2026, 0, 2, 12, 10));
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const before = Date.now();
|
||||
const { unmount } = renderHook(() => useScopes("24h"), { wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
|
||||
await waitFor(() => expect(getStatsScopes).toHaveBeenCalledOnce());
|
||||
const since = vi.mocked(getStatsScopes).mock.calls[0]![1]!;
|
||||
expect(since).toBeGreaterThanOrEqual(before - 24 * 3_600_000);
|
||||
expect(since).toBeLessThanOrEqual(Date.now() - 24 * 3_600_000);
|
||||
unmount(); client.clear();
|
||||
expect(vi.mocked(getStatsScopes).mock.calls[0]![1]).toBe(rolledWindow("24h").since);
|
||||
expect(rolledWindow("24h").since).toBe(Date.UTC(2026, 0, 1, 11));
|
||||
unmount(); client.clear(); vi.useRealTimers();
|
||||
});
|
||||
@@ -52,3 +52,10 @@ it("sums the shown scopes per rolled hour, counting omitted hours as zero and un
|
||||
// a server without hourly breakdowns must not read as a flat zero line
|
||||
expect(scopeHourly([...rows, { name: "#old", packetCount: 1, observerCount: 1, nodeCount: 0 }], hours)).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves hours outside the scope response's window as gaps rather than zero", () => {
|
||||
const H = 3_600_000;
|
||||
const rows = [{ name: "#a", packetCount: 2, observerCount: 1, nodeCount: 0, hourly: [{ hour: H, packets: 2, observers: 1, nodes: 0 }] }];
|
||||
const hours = [0, H, 2 * H, 3 * H].map((hour) => ({ hour, status: "complete" }));
|
||||
expect(scopeHourly(rows, hours, { since: H, until: 3 * H })?.packets).toEqual([null, 2, 0, null]);
|
||||
});
|
||||
@@ -101,6 +101,14 @@ describe("fillActivity", () => {
|
||||
expect(out[4]).toEqual(activity(4 * I));
|
||||
});
|
||||
|
||||
it("leaves hours neither the rollup nor the raw tail covered as gaps", () => {
|
||||
const H = 3_600_000;
|
||||
// rolled through hour 2, raw rows only from hour 5: hours 2-4 were never read
|
||||
const out = fillActivity([activity(H, { observations: 4 })], H, { start: 0, end: 6 * H }, { rolledUntil: 2 * H, rawFrom: 5 * H });
|
||||
expect(out.map((p) => p.observations)).toEqual([0, 4, null, null, null, 0, 0]);
|
||||
expect(out[3]!.airtimeMs).toBeNull();
|
||||
});
|
||||
|
||||
it("starts at the first complete bucket and ends at the one in progress", () => {
|
||||
// the server rounds the window start up to the next bucket, so a floored first bucket would be a fake zero
|
||||
const out = fillActivity([], I, { start: I + 1, end: 3 * I - 1 });
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TraceDetailPanel } from "../../../src/features/traces/TraceDetailPanel";
|
||||
import { getTraceDetail } from "../../../src/api/client";
|
||||
import type { TracePacket } from "../../../src/types/api";
|
||||
|
||||
vi.mock("../../../src/api/client", () => ({ getTraceDetail: vi.fn() }));
|
||||
|
||||
const pkt = (over: Partial<TracePacket>): TracePacket => ({
|
||||
packetHash: "aa11", routeType: 1, routeTypeName: "FLOOD", firstHeardAt: 1, lastHeardAt: 2,
|
||||
rawPath: [], resolvedRoute: [], ...over,
|
||||
});
|
||||
|
||||
function renderPanel(packets: TracePacket[]) {
|
||||
vi.mocked(getTraceDetail).mockResolvedValue({ traceTag: "deadbeef", packets });
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<TraceDetailPanel tag="deadbeef" onClose={() => {}} onAnalyze={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// the server sends null for both when the payload didn't parse or the packet has no IATAs
|
||||
describe("TraceDetailPanel", () => {
|
||||
it("renders hops when resolvedRoute is null", async () => {
|
||||
renderPanel([pkt({ rawPath: [{ hash: "ab", snr: 4 }], resolvedRoute: null })]);
|
||||
expect(await screen.findByText("AB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the no-path note when rawPath is null", async () => {
|
||||
renderPanel([pkt({ rawPath: null, resolvedRoute: null })]);
|
||||
expect(await screen.findByText("no path")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { RegionProvider, useRegion } from "../../src/hooks/useRegion";
|
||||
import { shouldRetryQuery } from "../../src/api/rate-limit";
|
||||
|
||||
const getRegions = vi.fn();
|
||||
const getRegion = vi.fn();
|
||||
vi.mock("../../src/api/client", () => ({
|
||||
getRegions: () => getRegions(),
|
||||
getRegion: (id: number) => getRegion(id),
|
||||
}));
|
||||
|
||||
const notFound = Object.assign(new Error("region not found"), { status: 404 });
|
||||
|
||||
function setup(regions: string[]) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: shouldRetryQuery, retryDelay: 0 } } });
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<RegionProvider defaultSelection={{ regions, iatas: [] }}>{children}</RegionProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return renderHook(() => useRegion(), { wrapper });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getRegions.mockReset();
|
||||
getRegion.mockReset();
|
||||
getRegions.mockResolvedValue([{ id: 1, slug: "west", name: "West" }, { id: 2, slug: "gone", name: "Gone" }]);
|
||||
});
|
||||
|
||||
describe("useRegion", () => {
|
||||
// the list and detail are cached separately server-side, so a listed region can 404 on its detail
|
||||
it("still resolves when one region's detail is missing", async () => {
|
||||
getRegion.mockImplementation((id: number) =>
|
||||
id === 1 ? Promise.resolve({ id: 1, slug: "west", name: "West", iatas: ["YVR"] }) : Promise.reject(notFound));
|
||||
const { result } = setup(["west"]);
|
||||
|
||||
await waitFor(() => expect(result.current.isResolved).toBe(true));
|
||||
expect(result.current.iatas).toEqual(["YVR"]);
|
||||
});
|
||||
|
||||
it("treats a selected region whose detail 404s like any other unknown slug", async () => {
|
||||
getRegion.mockImplementation((id: number) =>
|
||||
id === 1 ? Promise.resolve({ id: 1, slug: "west", name: "West", iatas: ["YVR"] }) : Promise.reject(notFound));
|
||||
const { result } = setup(["gone"]);
|
||||
|
||||
await waitFor(() => expect(result.current.isResolved).toBe(true));
|
||||
expect(result.current.regionKey).toBe("*");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runtimeEnv } from "../../src/lib/runtime-env";
|
||||
|
||||
describe("runtimeEnv", () => {
|
||||
afterEach(() => {
|
||||
delete window.__BEACON_CONFIG__;
|
||||
});
|
||||
|
||||
it("prefers a non-empty value from window.__BEACON_CONFIG__", () => {
|
||||
window.__BEACON_CONFIG__ = { VITE_BANNER: "from container" };
|
||||
expect(runtimeEnv("VITE_BANNER", "from build")).toBe("from container");
|
||||
});
|
||||
|
||||
it("falls back to import.meta.env when the runtime value is empty or missing", () => {
|
||||
window.__BEACON_CONFIG__ = { VITE_BANNER: "" };
|
||||
expect(runtimeEnv("VITE_BANNER", "from build")).toBe("from build");
|
||||
delete window.__BEACON_CONFIG__;
|
||||
expect(runtimeEnv("VITE_BANNER", "from build")).toBe("from build");
|
||||
});
|
||||
|
||||
it("ignores non-string runtime values", () => {
|
||||
window.__BEACON_CONFIG__ = { VITE_MAP_ZOOM: 7 };
|
||||
expect(runtimeEnv("VITE_MAP_ZOOM", "4")).toBe("4");
|
||||
});
|
||||
|
||||
it("returns undefined when neither source has a value", () => {
|
||||
expect(runtimeEnv("VITE_NOT_SET_ANYWHERE", undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user