diff --git a/docs/translations.md b/docs/translations.md index b747d283..5736818d 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -1,24 +1,7 @@ # Translating Beacon The header language picker starts in English and saves the selected language in -this browser. Its trigger and panel use the same dropdown styling as the region -control. Open it with Enter/Space, Tab to a language and activate that button; -Escape closes it and returns focus to the trigger. French is the first -additional language. Current coverage includes desktop/mobile navigation, -region/theme controls, connection/retry labels, lazy-page loading, shared -analytics section/range controls and chart states, and Traffic, RF / Signal, Paths & Hashes, Scopes and Clock Drift -(headings, legends, descriptive chart labels, explanations and exact tables). -Shared Timestamp labels and their relative tooltips also follow the selected -language. Packet reception-evidence controls and path-map controls explain retained -reports, intended TRACE routes, unavailable selections and ambiguous/missing map -locations in both languages. Packet bytes, identifiers and routing values stay unchanged. Other feature pages and general dialogs remain follow-up work. Measurement units -and existing UTC/date/number formatting, including automatic chart time labels, -are unchanged; selecting French does not mean every screen is translated yet. - -The channel message panel also translates scope labels, evidence help, retained-history/key -availability states, pagination and packet-inspection actions. Scope names, hashes and message -bodies remain unchanged. The channel directory and its existing search controls are separate -translation follow-ups. +this browser; an unset or empty string in a catalog falls back to English. ## Add a language @@ -46,42 +29,22 @@ function LoadingMessage() { ``` Add the English key/value and translate it in other catalogs where possible. -Use `t("region.count", { count })` with i18next's language-specific plural -suffixes (`_one`, `_other`, and `_many` where applicable). Preserve placeholders -such as `{{seconds}}`; translate the whole phrase rather than joining words. -React escapes interpolated text; do not use `dangerouslySetInnerHTML` for it. - -Translate display labels, never the tab IDs (`Packets`, `Analytics`, etc.), URL -parameters, region slugs/IATA codes, query keys or WebSocket subscriptions. -Names and packet contents come from the network and remain unchanged. This slice -does not change date/time/number formatting or measurement units. HTML language -and direction follow the selected catalog; a future right-to-left translation -also needs layout review before being offered to users. - -Traffic, signal, path and scope chart helpers receive `t` explicitly, and their memoized options depend on -`t` so labels redraw when the language changes. Keep numerical series, null gaps, -half-open bin bounds and query keys unchanged. Use the real catalogs in tests; -check that a language-only change reuses the same cached request. Traffic keeps -IATA/model identifiers unchanged and translates only display labels for grouped -and unassigned areas; its heatmap tooltip uses the raw count for plural selection -and the existing formatted value for display. Scope names and search values stay -unchanged; only the chart remainder label and interface text are translated. -Clock Drift also supplies translated direction labels to the shared formatter; -its signs, magnitude, units and rounding remain unchanged. Shared Timestamp uses -the whole `timestamp.ago` phrase with `{{duration}}`, so French can put "il y a" -before the duration. Compact `s/m/h/d` units, flooring, future-time clamping, -local absolute timestamps and optional milliseconds are unchanged. Its existing -shared ticker keeps relative text fresh in either language. Per-page relative -phrases that bypass Timestamp and broader date/number formatting remain follow-ups. - -For translated sortable tables, give each `Column` a stable `id` and use -`defaultSort={{ id: "drift", direction: "desc" }}` (for example). The visible -`header` can then change language without losing sorting or focus. Existing -header-based callers remain supported. -Run `npm run build`, `npm run lint` and `npm test`. Test saved/unknown language -preferences, English fallback and switching without losing the selected view. -Check the picker and menus at narrow widths, including translated status text. -The language preference tolerates unavailable browser storage; the choice then -lasts only for that visit. Tests use the real i18next resources, reset to English -after each test, and keep route/state assertions on canonical identifiers. +- Use `t("region.count", { count })` with i18next's language-specific plural + suffixes (`_one`, `_other`, and `_many` where applicable). +- Preserve placeholders such as `{{seconds}}`; translate the whole phrase rather + than joining words. +- Never translate tab IDs, URL parameters, IATA codes, query keys, or packet + contents. +- Chart helpers receive `t` explicitly, and their memoized options must depend + on `t` so labels redraw when the language changes. +- Sortable tables need a stable `Column.id` (e.g. `defaultSort={{ id: "drift", + direction: "desc" }}`) so the visible `header` can change language without + losing sorting or focus. +- Shared `Timestamp` uses the whole `timestamp.ago` phrase with `{{duration}}`, + so a translation can reorder duration and phrase (e.g. French's "il y a"). + +## Verify + +Run `npm run build`, `npm run lint` and `npm test`. Tests use the real i18next +catalogs and reset to English after each test. diff --git a/src/App.tsx b/src/App.tsx index 5f87031d..02c5975d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -239,7 +239,11 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: if (tab !== "Routes") for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); // the analyzer is URL-backed, so its mobile close lives here rather than above if (isMobile) next.delete("analyze"); - if (tab !== "Observers") next.delete("observer"); + // AppShell fires onTabChange even for a no-op click on the already-active tab — only an actual + // tab switch should drop the other tab's compare state (compareUntil is shared by both lists). + const changed = prev.get("tab") !== tab; + if (changed && tab !== "Observers") for (const key of ["observer", "compareWith", "compareUntil"]) next.delete(key); + if (changed && tab !== "Analytics") for (const key of ["compareA", "compareB", "compareSince", "compareUntil"]) next.delete(key); // lat/lng/zoom only seed the map on mount, so they'd re-frame it on every later visit if (tab !== "Map" || mapFocus) for (const key of ["lat", "lng", "zoom"]) next.delete(key); if (mapFocus) { diff --git a/src/api/client.ts b/src/api/client.ts index 4990248c..bfdd2fed 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -233,7 +233,7 @@ export function getTraceDetail(tag: string): Promise { export async function getObserver(observerId: string): Promise { const observer = await request(`/observers/${observerId}`); - // Older servers marshal the stored JSON byte slice as base64 instead of an object. + // The server marshals the stored JSON byte slice as base64; decode it, and accept an object once json.RawMessage ships. let metadata: unknown = observer.statusMetadata; if (typeof metadata === "string") { try { metadata = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(metadata), c => c.charCodeAt(0)))); } diff --git a/src/components/ModalOverlay.tsx b/src/components/ModalOverlay.tsx index 94882eef..3363eaed 100644 --- a/src/components/ModalOverlay.tsx +++ b/src/components/ModalOverlay.tsx @@ -1,12 +1,10 @@ import { useRef, type ReactNode } from "react"; import { useFocusTrap } from "../hooks/useFocusTrap"; -// Right-anchored modal: dims the surface, focuses and traps keyboard focus within the panel, and -// closes on a backdrop click — only when the press started there, so releasing a text selection over -// the backdrop doesn't close. Escape closes only this active layer and stops before parent handlers. -// Pass `inactive` when another overlay is stacked on top so this one steps out of the modal/a11y -// path — it stops being an active modal and is hidden from assistive tech, while staying mounted so -// focus can return into it when the overlay above closes. +// Backdrop close fires only when the press started on the backdrop, so releasing a text +// selection there doesn't close it. Escape stops at this layer. +// `inactive` steps a stacked-under overlay out of the modal/a11y path while keeping it +// mounted, so focus can return to it when the overlay above closes. export function ModalOverlay({ label, onClose, inactive = false, children }: { label: string; onClose: () => void; diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 9190fdd7..8148ee4c 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -113,7 +113,7 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange, hideF aria-label={inputLabel} onChange={(e) => handleChange(e.target.value)} placeholder={inputLabel ?? `Search by ${(currentField?.label ?? "").toLowerCase()}...`} - className={`w-full font-mono bg-bg-surface border border-border pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim transition-colors ${hideField ? "min-h-11 rounded-sm text-base md:text-sm" : "rounded-r-sm text-[11px]"}`} + className={`w-full font-mono bg-bg-surface border border-border pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim transition-colors ${hideField ? "rounded-sm text-[11px]" : "rounded-r-sm text-[11px]"}`} /> {localValue && ( )} diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index aa7cc169..19eb6327 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { getNodesPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; @@ -7,7 +8,8 @@ import { useTick } from "../../hooks/useTick"; import { useInfinitePages } from "../../hooks/useInfinitePages"; import { patchInfinitePages } from "../../lib/infinite-pages"; import { useWsNodeUpdateHandler } from "../../hooks/useWsHandlers"; -import { formatHex, timeAgoMs, formatRadio } from "../../lib/formatters"; +import { formatHex, timeAgoParts, formatRadio } from "../../lib/formatters"; +import { hasMapLocation } from "../map/location"; import { Badge } from "../../components/Badge"; import { Tooltip } from "../../components/Tooltip"; import { ObserverIcon } from "../../components/ObserverIcon"; @@ -17,13 +19,26 @@ import { NodeFilterBar, type MultibyteFilter } from "./NodeFilterBar"; import { nodeSearchParams } from "./node-search"; import { patchNodeSummary } from "./node-updates"; import { ForeignNodeBadge } from "./ForeignNodeBadge"; -import type { NodeSummary } from "./types"; +import type { NodeSummary, NodeIATA } from "./types"; import type { CursorPage } from "../../types/api"; import type { WsManager } from "../../api/ws-manager"; import type { WsNodeUpdate } from "../../types/ws"; const nodeId = (n: NodeSummary) => n.id; // stable id accessor for the paged hook's dedup +// A column cell and renderNodeCard sit outside any component, so the tooltip's translated +// "last heard" label needs its own tiny component to call useTranslation. +function IataBadge({ entry }: { entry: NodeIATA }) { + const { t } = useTranslation(); + const { count, unit } = timeAgoParts(entry.lastHeard); + const ago = t("timestamp.ago", { duration: t(`timestamp.unit.${unit}`, { count }) }); + return ( + + {entry.iata} + + ); +} + interface NodeTableProps { wsManager: WsManager; // shared with the Map tab (lifted to AppInner) so the detail panel persists across tab switches @@ -68,9 +83,7 @@ const COLUMNS: Column[] = [ node.iatas && node.iatas.length > 0 ? (
{node.iatas.map((entry) => ( - - {entry.iata} - + ))}
) : ( @@ -87,17 +100,16 @@ const COLUMNS: Column[] = [ header: "Location", className: "text-text-muted", cell: (node) => - node.lat != null && node.lng != null + hasMapLocation(node) ? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}` : "—", }, ]; function renderNodeCard(node: NodeSummary) { - const location = - node.lat != null && node.lng != null - ? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}` - : null; + const location = hasMapLocation(node) + ? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}` + : null; return (
@@ -122,9 +134,7 @@ function renderNodeCard(node: NodeSummary) { {node.iatas && node.iatas.length > 0 && (
{node.iatas.map((entry) => ( - - {entry.iata} - + ))}
)} diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx index 7d27770c..bbfe6100 100644 --- a/src/features/observers/ObserverComparison.tsx +++ b/src/features/observers/ObserverComparison.tsx @@ -2,8 +2,11 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getObserverComparison } from "../../api/client"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; import { formatBattery, formatUptime } from "../../lib/formatters"; import { ObserverPicker } from "./ObserverPicker"; +import { observerNoiseFloor } from "./observer-stats"; +import { OBSERVER_UUID } from "./observer-id"; import { Card, ChartCard } from "../stats/cards"; import { activityParamsFor, useObserver, useObserverActivity } from "../stats/useTelemetry"; import { useChartColors } from "../stats/chartTheme"; @@ -12,10 +15,9 @@ import { fillActivity, intervalToMs } from "../stats/transforms"; import { RANGE_MS, type ObserverActivity, type StatsRange } from "../stats/types"; import type { Observer } from "./types"; -const uuid = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; function statusNoise(observer: Observer) { - const stats = observer.statusMetadata?.stats; - return stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? `${stats.noise_floor} dBm` : "—"; + const n = observerNoiseFloor(observer); + return n == null ? "—" : `${n} dBm`; } export function ObserverComparison({ observerA, activityA, range, observerBId, until, onSelect, onRefresh }: { @@ -23,7 +25,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u onSelect: (id: string) => void; onRefresh: () => number; }) { const { t, i18n } = useTranslation(); const colors = useChartColors(); - const valid = until != null && uuid.test(observerA.id) && uuid.test(observerBId) && observerA.id.toLowerCase() !== observerBId.toLowerCase(); + const valid = until != null && OBSERVER_UUID.test(observerA.id) && OBSERVER_UUID.test(observerBId) && observerA.id.toLowerCase() !== observerBId.toLowerCase(); const b = useObserver(valid ? observerBId : null); const activityB = useObserverActivity(valid ? observerBId : null, range, until ?? undefined); const dataB = activityB.isError ? undefined : activityB.data; @@ -52,9 +54,9 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u }, [activityA, dataB, aligned, intervalMs, start, end, colors]); const date = (value: number | null | undefined) => value == null ? "—" : new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" }); const groups = overlap.data && !overlap.isError ? [ - { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.primary }, - { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green }, - { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary }, + { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.primary, textClass: "text-primary" }, + { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green, textClass: "text-green" }, + { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary, textClass: "text-secondary" }, ] : []; const refresh = () => { const nextUntil = onRefresh(); @@ -76,7 +78,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u ] : []; return

{t("observerCompare.title")}

-
+

A: {observerA.displayName ?? observerA.id} {b.data && <>· B: {b.data.displayName ?? b.data.id}}

{until == null ?

{t("observerCompare.invalidTime")}

: !valid ?

{t(observerBId ? "observerCompare.invalidObserver" : "observerCompare.choose")}

: b.isError || activityB.isError ?

{t("observerPage.loadFailed")}

: !activityA || activityB.isPending || b.isPending ?

{t("common.loading")}

: !aligned ?

{t("observerCompare.unavailable")}

: <> @@ -88,7 +90,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u {overlap.isError ?

{t("common.loadFailed")}

: !overlap.data ?

{t("common.loading")}

: <>

{t("observerCompare.total", { count: overlap.data.totalPackets })}

{overlap.data.totalPackets === 0 ?

{t("observerCompare.empty")}

:
{groups.map(g =>
)}
} -
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}
+
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}

{t("observerCompare.definition")}

} diff --git a/src/features/observers/ObserverDetailPanel.tsx b/src/features/observers/ObserverDetailPanel.tsx index 535e2be6..f65dd869 100644 --- a/src/features/observers/ObserverDetailPanel.tsx +++ b/src/features/observers/ObserverDetailPanel.tsx @@ -10,6 +10,7 @@ import { formatUptime, formatBattery, formatHex, formatSnr, snrLevel, SIGNAL_LEV import { Timestamp } from "../../components/Timestamp"; import { useTick } from "../../hooks/useTick"; import { deriveObserverStatus } from "./observer-status"; +import { getStats } from "./observer-stats"; import type { BadgeVariant } from "../../components/badge-utils"; import { IataChip } from "../../components/IataChip"; import { ScopeTag } from "../../components/ScopeTag"; @@ -48,16 +49,6 @@ function AdvertRow({ advert, onClick }: { advert: AdvertObservation; onClick?: ( ); } -interface Stats { - noise_floor?: number; - rx_air_secs?: number; - tx_air_secs?: number; - queue_len?: number; - recv_errors?: number; - errors?: number; - internal_heap?: number; -} - // broker freshness badge: <5m = live, <30m = stale function brokerStatusVariant(lastPacketAt: number | null): BadgeVariant { if (!lastPacketAt) return "offline"; @@ -65,12 +56,6 @@ function brokerStatusVariant(lastPacketAt: number | null): BadgeVariant { return ageMs < 5 * 60_000 ? "live" : ageMs < 30 * 60_000 ? "stale" : "offline"; } -// stats shape depends on the observer's firmware, so we just grab what we recognize -function getStats(metadata: Record | undefined): Stats | null { - if (!metadata?.stats || typeof metadata.stats !== "object") return null; - return metadata.stats as Stats; -} - function formatAirtime(secs: number): string { if (secs < 60) return `${secs}s`; if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`; @@ -131,7 +116,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi title="Observer Detail" onClose={onClose} closeLabel={t("investigation.closeObserver")} - headerAction={} + headerAction={} isLoading={isLoading} notFound={!observer} notFoundLabel="Observer not found" diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index b860fc50..7c9fbca1 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -2,9 +2,11 @@ import { lazy, Suspense, useState, useEffect, useRef } from "react"; import { useLocation, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { CopyButton } from "../../components/CopyButton"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; import { useTick } from "../../hooks/useTick"; import { ObserverTable } from "./ObserverTable"; import { observerDestination, observerRange } from "./observer-navigation"; +import { Segmented } from "../stats/Segmented"; import type { WsManager } from "../../api/ws-manager"; const ObserverTab = lazy(() => import("../stats/ObserverTab").then(m => ({ default: m.ObserverTab }))); @@ -53,16 +55,14 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager: }; return
{(id || onReturn) &&
- {onReturn && } + {onReturn && } {id && <> - + - - + + {t("observerPage.range")} + setParams(observerDestination(params, id, observerRange(v)), visitOptions)} /> + }
}
diff --git a/src/features/observers/ObserverPicker.tsx b/src/features/observers/ObserverPicker.tsx index 39b5fbff..c15d3e0d 100644 --- a/src/features/observers/ObserverPicker.tsx +++ b/src/features/observers/ObserverPicker.tsx @@ -1,24 +1,32 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getObserversPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; +import { SearchBar } from "../../components/SearchBar"; +import { SelectDropdown } from "../../components/SelectDropdown"; export function ObserverPicker({ id, name, onSelect, excludeId, label }: { id: string; name: string; onSelect: (id: string) => void; excludeId?: string; label?: string }) { const { t } = useTranslation(); const { iatas, regionKey } = useRegion(); - const [text, setText] = useState(""); const [query, setQuery] = useState(""); - useEffect(() => { const timer = setTimeout(() => setQuery(text.trim()), 250); return () => clearTimeout(timer); }, [text]); - const options = useQuery({ queryKey: ["observer-picker", regionKey, query], queryFn: () => getObserversPage(iatas, { name: query || undefined, limit: 50 }), staleTime: 30_000 }); + const options = useQuery({ queryKey: ["observer-picker", regionKey, query.trim()], queryFn: () => getObserversPage(iatas, { name: query.trim() || undefined, limit: 50 }), staleTime: 30_000 }); const rows = (options.data?.items ?? []).filter(o => o.id !== excludeId); return
- setText(e.target.value)} className="min-h-11 min-w-0 flex-1 rounded border border-border bg-bg-base px-3 text-text-normal" /> - + {}} value={query} onChange={setQuery} /> + o.id === id) ? [{ value: id, label: name }] : []), + ...rows.map(o => ({ value: o.id, label: `${o.displayName ?? o.id.slice(0, 8)} · ${o.iata}` })), + ]} + />
{options.isError ? :

{options.isFetching ? t("common.loading") : t("observerPage.searchHelp")}

}
; diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx index d5513dca..e7f5fdab 100644 --- a/src/features/observers/ObserverSummary.tsx +++ b/src/features/observers/ObserverSummary.tsx @@ -1,8 +1,11 @@ import { useTranslation } from "react-i18next"; import { Timestamp } from "../../components/Timestamp"; import { CopyButton } from "../../components/CopyButton"; -import { formatBattery, formatRadioParts, formatUptime } from "../../lib/formatters"; +import { formatBattery, formatRadioParts, formatUptime, formatUtc } from "../../lib/formatters"; import { useTick } from "../../hooks/useTick"; +import { observerNoiseFloor } from "./observer-stats"; +import { Card, StatCard } from "../stats/cards"; +import { Field } from "../../components/DetailPanel"; import type { Observer } from "./types"; import type { ObserverActivity, TelemetryPoint } from "../stats/types"; @@ -10,8 +13,7 @@ export function ObserverSummary({ observer, activity, points, pending = false }: const { t, i18n } = useTranslation(); const now = useTick(); const summary = activity?.summary; const statusFresh = observer.lastStatusAt != null && now - observer.lastStatusAt < 300_000; - const stats = observer.statusMetadata?.stats; - const reportedNoise = stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? stats.noise_floor : null; + const reportedNoise = observerNoiseFloor(observer); const noise = reportedNoise ?? points.at(-1)?.noiseFloorDb; const cards = [ ["records", summary?.recordedPackets.toLocaleString(i18n.resolvedLanguage) ?? "—"], @@ -23,7 +25,7 @@ export function ObserverSummary({ observer, activity, points, pending = false }: ] as const; return <>
-

{observer.displayName ?? observer.id.slice(0, 8)}

+

{observer.displayName ?? observer.id.slice(0, 8)}

@@ -32,13 +34,16 @@ export function ObserverSummary({ observer, activity, points, pending = false }:
-
    - {cards.map(([key, value]) =>
  • -
    {t(`observerPage.${key}`)}
    -
    {value}
    - {key === "lastHour" && summary &&
    {new Date(summary.lastCompleteHourStart).toISOString().slice(11, 16)}–{new Date(summary.lastCompleteHourEnd).toISOString().slice(11, 16)} UTC
    } - {key === "noise" && noise != null &&
    {t(reportedNoise != null ? "observerPage.latestStatus" : "observerPage.latestTelemetry")}
    } -
  • )} +
      + {cards.map(([key, value]) => { + const note = key === "lastHour" && summary ? `${formatUtc(summary.lastCompleteHourStart, { timeOnly: true })}–${formatUtc(summary.lastCompleteHourEnd, { timeOnly: true })} UTC` + : key === "noise" && noise != null ? t(reportedNoise != null ? "observerPage.latestStatus" : "observerPage.latestTelemetry") + : undefined; + return
    • + + {note &&

      {note}

      } +
    • ; + })}
    {!summary && !pending &&

    {t("observerPage.summaryMissing")}

    } ; @@ -47,13 +52,12 @@ export function ObserverSummary({ observer, activity, points, pending = false }: export function ObserverDeviceDetails({ observer }: { observer: Observer }) { const { t } = useTranslation(); const radio = formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr }); - return
    - {t("observerPage.details")} -
    - {([ ["model", observer.hardwareModel], ["firmware", observer.firmwareVersion], ["client", observer.softwareVersion], ["radio", radio] ] as const).map(([key, value]) =>
    {t(`observerPage.${key}`)}
    {value ?? "—"}
    )} -
    {t("observerPage.publicKey")}
    {observer.publicKey}
    -
    {t("observerPage.firstSeen")}
    -
    -
      {observer.brokers.map(b =>
    • {b.name}{t("observerPage.presence")}: {t("observerPage.packetArrival")}: {b.lastPacketAt > 0 ? : "—"}
    • )}
    -
    ; + return +
    + {([ ["model", observer.hardwareModel], ["firmware", observer.firmwareVersion], ["client", observer.softwareVersion], ["radio", radio] ] as const).map(([key, value]) => )} +
    {observer.publicKey}} />
    + } /> +
    +
      {observer.brokers.map(b =>
    • {b.name}{t("observerPage.presence")}: {t("observerPage.packetArrival")}: {b.lastPacketAt > 0 ? : "—"}
    • )}
    +
    ; } diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index 110fa1ed..0eb39b43 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -114,7 +114,7 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, const [typeFilter, setTypeFilter] = useState(""); const [brokerFilter, setBrokerFilter] = useState(""); const [scopeFilter, setScopeFilter] = useState(""); // "" = Any; applied client-side over the loaded set - // the compact sidebar only has a name search, so the directory's other filters sit out until it returns + // Compact mode has only the name search, so the other filters are ignored there. const status = compact ? "" : statusFilter; const type = compact ? "" : typeFilter; const broker = compact ? "" : brokerFilter; diff --git a/src/features/observers/observer-id.ts b/src/features/observers/observer-id.ts new file mode 100644 index 00000000..3695d5f8 --- /dev/null +++ b/src/features/observers/observer-id.ts @@ -0,0 +1,2 @@ +// excludes the nil UUID, which the API uses as a placeholder rather than a real observer id +export const OBSERVER_UUID = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts index b31869bb..b33fee79 100644 --- a/src/features/observers/observer-navigation.ts +++ b/src/features/observers/observer-navigation.ts @@ -5,7 +5,7 @@ export const observerRange = (value: string | null): StatsRange => value === "24 export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) { const next = new URLSearchParams(params); next.set("tab", "Observers"); - for (const key of ["statsTab", "observerId", "node", "hash", "analyze", "path", "observation", "route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); + for (const key of ["statsTab", "observerId", "node", "hash", "analyze", "path", "observation", "route", "routeIata", "routeRange", "routeSince", "routeUntil", "compareA", "compareB", "compareSince"]) next.delete(key); if (id) { next.set("observer", id); next.set("range", range); } else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); next.delete("compareUntil"); } return next; diff --git a/src/features/observers/observer-stats.ts b/src/features/observers/observer-stats.ts new file mode 100644 index 00000000..2cd114b5 --- /dev/null +++ b/src/features/observers/observer-stats.ts @@ -0,0 +1,22 @@ +import type { Observer } from "./types"; + +export interface Stats { + noise_floor?: number; + rx_air_secs?: number; + tx_air_secs?: number; + queue_len?: number; + recv_errors?: number; + errors?: number; + internal_heap?: number; +} + +// stats shape depends on the observer's firmware, so we just grab what we recognize +export function getStats(metadata: Record | undefined): Stats | null { + if (!metadata?.stats || typeof metadata.stats !== "object") return null; + return metadata.stats as Stats; +} + +export function observerNoiseFloor(observer: Pick): number | null { + const stats = getStats(observer.statusMetadata); + return stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? stats.noise_floor : null; +} diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index ec0ec763..26c1ab36 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -49,8 +49,9 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o const [searchParams, setSearchParams] = useSearchParams(); const { t } = useTranslation(); - const hasPath = useMemo(() => (detail ? buildPacketPaths(detail).length > 0 : false), [detail]); - const observerCount = new Set(detail?.observations.map(o => o.observerId)).size; + const pathKeys = useMemo(() => new Set(detail ? buildPacketPaths(detail).map(p => p.key) : []), [detail]); + const hasPath = pathKeys.size > 0; + const observerCount = useMemo(() => new Set(detail?.observations.map(o => o.observerId)).size, [detail]); // drop ?analyze so a reload doesn't reopen the drawer; ?hash stays, leaving the row expanded const handleClose = useCallback(() => { @@ -132,7 +133,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o {unavailable &&

    {t("investigation.unavailableReport")}

    } - +
    - {onViewObserver && } - + + {onViewObserver && } +
    {!canMap &&

    {t("investigation.unmappable")}

    } ; })}
)} - {groups.length > 3 && } + {groups.length > 3 && }

{t("investigation.retention")}

; } diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx index 3cfa8870..165d5203 100644 --- a/src/features/packets/PacketTableRow.tsx +++ b/src/features/packets/PacketTableRow.tsx @@ -15,8 +15,7 @@ interface PacketTableRowProps { onToggle: () => void; } -// Fixed-height table row sharing GRID_TEMPLATE with the sticky header. The observer lives in the -// expansion instead, which frees the wide column for the packet's endpoints. +// The observer lives in the expansion, which frees the wide column for the packet's endpoints. export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) { // ?? not ||, so a legitimate 0-hop direct packet still shows its count const pathLength = packet.latestObserver?.pathLength; diff --git a/src/features/packets/PathData.tsx b/src/features/packets/PathData.tsx index f0e62850..6009a080 100644 --- a/src/features/packets/PathData.tsx +++ b/src/features/packets/PathData.tsx @@ -1,5 +1,6 @@ import { useRef, useState, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; import { useHasHover } from "../../hooks/useMediaQuery"; import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import type { ResolvedHop, ResolvedNode } from "../../types/api"; @@ -23,6 +24,7 @@ export function HopPopover({ hop, onViewNode, showSnr = true, children }: { showSnr?: boolean; children: ReactNode; }) { + const { t } = useTranslation(); const hasHover = useHasHover(); const ref = useRef(null); const tipRef = useRef(null); @@ -108,7 +110,7 @@ export function HopPopover({ hop, onViewNode, showSnr = true, children }: { className={`fixed z-50 flex flex-col gap-0.5 whitespace-nowrap rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg ${clickable ? "" : "pointer-events-none"}`} > {nodes.length === 0 ? ( - "No Path Resolutions Available" + t("packetRow.noResolution") ) : clickable ? ( nodes.map((node) => (
} + {query.isError &&

{t(isNotFound(query.error) ? "routeEvidence.missing" : "routeEvidence.error")}

} {first && <>

{t("routeEvidence.width", { width: first.matchAvailable && first.hashSize ? first.hashSize : "—" })}

@@ -56,13 +56,13 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on

{t("routeEvidence.brief")}

{t("routeEvidence.definition")}

{t("routeEvidence.match", { width: first.matchAvailable && first.hashSize ? first.hashSize : "—" })}

{t("routeEvidence.caution")}

{t("routeEvidence.retention")}

- {!first.matchAvailable ?

{t("routeEvidence.unavailable")}

: reports.length === 0 ?

{t("routeEvidence.empty")}

:
    {reports.map(report =>
  • + {!first.matchAvailable ?

    {t("routeEvidence.unavailable")}

    : reports.length === 0 ?

    {t("routeEvidence.empty")}

    :
      {reports.map(report => { const level = snrLevel(report.snr); return
    • {report.packetHash.slice(0, 8).toUpperCase()}{report.payloadTypeName}

      {report.observerName ?? report.observerId.slice(0, 8)}

      -

      · SNR {report.snr == null ? "—" : `${report.snr} dB`} · RSSI {report.rssi == null ? "—" : `${report.rssi} dBm`}

      -
      {onAnalyzePacket && }{onViewObserver && }
      -
    • )}
    } - {query.hasNextPage && } +

    · SNR {formatSnr(report.snr)}{report.snr != null && " dB"} · RSSI {report.rssi == null ? "—" : `${report.rssi} dBm`}

    +
    {onAnalyzePacket && }{onViewObserver && }
    +
  • ; })}
} + {query.hasNextPage && } {capped &&

{t("routeEvidence.cap")}

}
} diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index 9ba9e45c..ba16d5a6 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -150,9 +150,11 @@ export function RouteTable(actions: RouteActions) { const pathKey = params.get("route"), routeIata = params.get("routeIata"); const closeRoute = useCallback(() => { + // no-op when nothing is open, so callers (e.g. the region-change effect) can call it unconditionally + if (!pathKey && !selectedKey) return; setSelectedKey(null); setParams(previous => { const next = new URLSearchParams(previous); for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); return next; }, { replace: true }); - }, [setParams]); + }, [pathKey, selectedKey, setParams]); // drop the selection when the region changes — the selected route may not be in the new region diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx index 56158bae..2956a376 100644 --- a/src/features/stats/CompareObserversTab.tsx +++ b/src/features/stats/CompareObserversTab.tsx @@ -4,14 +4,14 @@ import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { getObserver, getObserverComparison, getObserversPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; +import { OBSERVER_UUID } from "../observers/observer-id"; import { Card } from "./cards"; type Selection = { observerA: string; observerB: string; since: number; until: number }; -const uuid = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; const fieldClass = "min-w-0 w-full rounded border border-border bg-bg-base px-2.5 py-2 text-base text-text-normal"; function validation(value: Selection): string | null { - if (!uuid.test(value.observerA) || !uuid.test(value.observerB) || value.observerA.toLowerCase() === value.observerB.toLowerCase()) { + if (!OBSERVER_UUID.test(value.observerA) || !OBSERVER_UUID.test(value.observerB) || value.observerA.toLowerCase() === value.observerB.toLowerCase()) { return "Choose two different observers."; } if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) { @@ -40,7 +40,7 @@ function ObserverSelect({ label, value, onChange }: { label: string; value: stri }); const selected = useQuery({ queryKey: ["observer", value], queryFn: () => getObserver(value), - enabled: uuid.test(value), staleTime: 30_000, retry: false, + enabled: OBSERVER_UUID.test(value), staleTime: 30_000, retry: false, }); const rows = options.data?.items ?? []; return ( diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 7a0cc23d..30f960a8 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -103,7 +103,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { [nodeTypesData, colors], ); const typeTotal = useMemo(() => typeRows.reduce((a, t) => a + t.value, 0), [typeRows]); - const typesOption = useMemo(() => donutOption(typeRows, colors, formatCount(typeTotal), "NODES"), [typeRows, colors, typeTotal]); + const typesOption = useMemo(() => donutOption(typeRows, colors, formatCount(typeTotal), t("mesh.nodesCenter")), [typeRows, colors, typeTotal, t]); const presetRows = useMemo( () => aggregatePresets(radioPresetsData ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })), @@ -126,14 +126,14 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { return (
- - - - + + + +
Observations · {range}} + title={t("mesh.observationsTitle", { range })} height={200} option={obsOption} isLoading={observations.isPending || observations.isPlaceholderData} @@ -144,11 +144,11 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
{/* range-driven charts lead the grid; the all-time ones follow below */}
- Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isPending || topObservers.isPlaceholderData} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> +
Payload types · {range}} - right={{formatCount(payloadData === undefined ? undefined : payloadTotal)} obs} + title={t("mesh.payloadTypes", { range })} + right={{t("mesh.obs", { value: formatCount(payloadData === undefined ? undefined : payloadTotal) })}} height={208} option={payloadOption} isLoading={payload.isPending || payload.isPlaceholderData} @@ -156,27 +156,27 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { isEmpty={payloadItems.length === 0} /> {/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */} - - - + + + -
- {t("stats.tabs.scopes")} +
+ {t("stats.tabs.scopes")}
{scopes.isError ? ( -
Failed to load
+
{t("common.loadFailed")}
) : scopes.isPending || scopes.isLoading || scopes.isPlaceholderData ? ( -
Loading…
+
{t("common.loading")}
) : scopeRows.length === 0 ? ( -
No data
+
{t("common.noData")}
) : ( - - - - + + + + diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index c101d383..70640715 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { EmptyState } from "../../components/EmptyState"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; import { formatRadioParts } from "../../lib/formatters"; import { ObserverComparison } from "../observers/ObserverComparison"; import { ObserverSummary, ObserverDeviceDetails } from "../observers/ObserverSummary"; @@ -52,8 +53,8 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison } () => (bucketMs == null && hasTelemetry(points) ? latestAirtimePct(points, null) : { rx: null, tx: null }), [points, bucketMs], ); - const battery = useMemo(() => batteryOption(points, colors, t("observerPage.battery") + " V"), [points, colors, t]); - const noise = useMemo(() => noiseFloorOption(points, colors, t("observerPage.noise") + " dBm"), [points, colors, t]); + const battery = useMemo(() => batteryOption(points, colors, t("observerPage.batteryV")), [points, colors, t]); + const noise = useMemo(() => noiseFloorOption(points, colors, t("observerPage.noiseDbm")), [points, colors, t]); const queue = useMemo(() => queueOption(points, colors, t("observerPage.queue")), [points, colors, t]); const recvErrors = useMemo( () => receiveErrorsOption(points, colors, bucketed, t("observerPage.errors")), @@ -124,7 +125,7 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison } return (

{t("observerPage.loadFailed")}

-
@@ -186,7 +187,7 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison } /> {activity.isError && ( - )} @@ -246,7 +247,7 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison } /> {telemetry.isError && ( - )} diff --git a/src/features/stats/PathsTab.tsx b/src/features/stats/PathsTab.tsx index 97c512fb..5cbe271e 100644 --- a/src/features/stats/PathsTab.tsx +++ b/src/features/stats/PathsTab.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { formatCount } from "../../lib/formatters"; +import { formatCount, formatUtc } from "../../lib/formatters"; import { Card, ChartCard, StatCard } from "./cards"; import { donutOption } from "./chartOptions"; import { tooltipStyle, useChartColors } from "./chartTheme"; @@ -8,8 +8,6 @@ import { pathHours, pathLengthOption, pathTrendOption } from "./paths"; import { usePathStats } from "./usePathStats"; import type { StatsRange } from "./types"; -const utc = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " "); - export function PathsTab({ range }: { range: StatsRange }) { const { t } = useTranslation(); const query = usePathStats(range), c = useChartColors(); @@ -48,7 +46,7 @@ export function PathsTab({ range }: { range: StatsRange }) {

{t("paths.measurement")}

- {data &&

{t("paths.window", { since: utc(data.since), until: utc(data.until) })}

} + {data &&

{t("paths.window", { since: formatUtc(data.since), until: formatUtc(data.until) })}

}
@@ -65,13 +63,13 @@ export function PathsTab({ range }: { range: StatsRange }) {
{data && data.receptions > 0 &&
- {t("paths.details")} -
+ {t("paths.details")} +
ScopePacketsObserversNodes{t("mesh.scope")}{t("mesh.packets")}{t("mesh.observers")}{t("mesh.nodes")}
{data.hashWidths.map((bin) => )}
{t("paths.bytesPerHash")}{t("paths.receptionsColumn")}
{bin.bytes}{bin.receptions.toLocaleString()}
{data.pathLengths.map((bin) => )}
{t("paths.headerEntries")}{t("paths.receptionsColumn")}
{bin.entries}{bin.receptions.toLocaleString()}

{t("paths.lengthsHelp")}

-
{[t("paths.utcHour"), t("paths.receptionsColumn"), ...[1, 2, 3].map((count) => t("paths.hashWidth", { count })), t("paths.categories.empty"), t("paths.categories.trace"), t("paths.categories.unclassified")].map((label) => )}{data.hourly.map((row) => {[row.receptions, row.oneByte, row.twoByte, row.threeByte, row.empty, row.trace, row.unclassified].map((n, i) => )})}
{label}
{utc(row.hour)}{n.toLocaleString()}
+
{[t("paths.utcHour"), t("paths.receptionsColumn"), ...[1, 2, 3].map((count) => t("paths.hashWidth", { count })), t("paths.categories.empty"), t("paths.categories.trace"), t("paths.categories.unclassified")].map((label) => )}{data.hourly.map((row) => {[row.receptions, row.oneByte, row.twoByte, row.threeByte, row.empty, row.trace, row.unclassified].map((n, i) => )})}
{label}
{formatUtc(row.hour)}{n.toLocaleString()}
}
; } diff --git a/src/features/stats/SignalTab.tsx b/src/features/stats/SignalTab.tsx index 400dbf59..e7188dc8 100644 --- a/src/features/stats/SignalTab.tsx +++ b/src/features/stats/SignalTab.tsx @@ -1,13 +1,12 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { formatCount } from "../../lib/formatters"; +import { formatCount, formatUtc } from "../../lib/formatters"; import { Card, ChartCard, StatCard } from "./cards"; import { useChartColors } from "./chartTheme"; import { signalBinLabel, signalCoverageOption, signalHistogramOption, signalHours, signalTrendOption } from "./signal"; import { useSignalStats } from "./useSignalStats"; import type { StatsRange } from "./types"; -const utc = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " "); const average = (value: number | null | undefined, unit = "") => value == null ? "—" : `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`; export function SignalTab({ range }: { range: StatsRange }) { @@ -32,12 +31,12 @@ export function SignalTab({ range }: { range: StatsRange }) { {query.isError &&

{t("signal.error")}

}
- {average(data?.snr.average, "dB")}} accent={c.secondary} /> - {average(data?.rssi.average, "dBm")}} accent={c.green} /> + +

{t("signal.measurement")}

- {data &&

{t("signal.window", { since: utc(data.since), until: utc(data.until) })}

} + {data &&

{t("signal.window", { since: formatUtc(data.since), until: formatUtc(data.until) })}

}
@@ -57,8 +56,8 @@ export function SignalTab({ range }: { range: StatsRange }) {
{data && data.receptions > 0 &&
- {t("signal.details")} -
+ {t("signal.details")} +
{(["snr", "rssi"] as const).map((metric) => {data[metric].histogram.map((bin, i) => )} @@ -67,7 +66,7 @@ export function SignalTab({ range }: { range: StatsRange }) {

{t("signal.binsHelp")}

{metric.toUpperCase()} · {metric === "snr" ? "dB" : "dBm"}{t("signal.samples")}
{signalBinLabel(bin, t)}{bin.count.toLocaleString()}
- {data.hourly.map((row) => )} + {data.hourly.map((row) => )}
{t("signal.utcHour")}{t("signal.receptionsColumn")}{t("signal.metricSamples", { metric: "SNR" })}{t("signal.meanUnit", { unit: "dB" })}{t("signal.metricSamples", { metric: "RSSI" })}{t("signal.meanUnit", { unit: "dBm" })}
{utc(row.hour)}{row.receptions.toLocaleString()}{row.snrSamples.toLocaleString()}{average(row.snrAverage)}{row.rssiSamples.toLocaleString()}{average(row.rssiAverage)}
{formatUtc(row.hour)}{row.receptions.toLocaleString()}{row.snrSamples.toLocaleString()}{average(row.snrAverage)}{row.rssiSamples.toLocaleString()}{average(row.rssiAverage)}
}
diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index 6caf0f19..17114b2f 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -26,8 +26,8 @@ export function TalkersTab({ range }: TalkersTabProps) { const topAdvertisers = useTopAdvertisers(range, 20); const topTalkers = useTopTalkers(range, 20); - const advertisersLoading = topAdvertisers.isPending || topAdvertisers.isLoading || topAdvertisers.isPlaceholderData; - const talkersLoading = topTalkers.isPending || topTalkers.isLoading || topTalkers.isPlaceholderData; + const advertisersLoading = topAdvertisers.isPending || topAdvertisers.isPlaceholderData; + const talkersLoading = topTalkers.isPending || topTalkers.isPlaceholderData; const talkersUnavailable = talkersLoading || topTalkers.isError; const advertisers = advertisersLoading || topAdvertisers.isError ? [] : (topAdvertisers.data ?? []); diff --git a/src/features/stats/useLiveStats.ts b/src/features/stats/useLiveStats.ts index 50624d36..e0c9f19f 100644 --- a/src/features/stats/useLiveStats.ts +++ b/src/features/stats/useLiveStats.ts @@ -21,6 +21,8 @@ export function useLiveOverview(wsManager: WsManager) { const { packets, obs } = pending.current; if (!packets && !obs) return; pending.current = { packets: 0, obs: 0 }; + // setQueryData would clear an error state and show stale numbers as healthy — drop the deltas instead. + if (qc.getQueryState(["stats-overview", regionKey])?.status !== "success") return; qc.setQueryData(["stats-overview", regionKey], (old) => old ? { ...old, totalPackets: old.totalPackets + packets, totalObservations: old.totalObservations + obs } diff --git a/src/features/stats/usePathStats.ts b/src/features/stats/usePathStats.ts index 6a9668cf..3fe7e7c7 100644 --- a/src/features/stats/usePathStats.ts +++ b/src/features/stats/usePathStats.ts @@ -1,22 +1,7 @@ -import { useQuery } from "@tanstack/react-query"; import { getPathStats } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; -import { RANGE_MS, type StatsRange } from "./types"; +import { useWindowedStats } from "./useWindowedStats"; +import type { StatsRange } from "./types"; export function usePathStats(range: StatsRange) { - const { iatas, regionKey, isResolved } = useRegion(); - return useQuery({ - queryKey: ["stats-paths", isResolved === false ? `${regionKey}:pending` : 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; - return getPathStats(until - RANGE_MS[range], until, iatas, signal); - }, - staleTime: 30_000, - refetchInterval: 60_000, - refetchOnWindowFocus: false, - retry: false, - }); + return useWindowedStats("stats-paths", range, getPathStats); } diff --git a/src/features/stats/useSignalStats.ts b/src/features/stats/useSignalStats.ts index 604d26fb..297b55a6 100644 --- a/src/features/stats/useSignalStats.ts +++ b/src/features/stats/useSignalStats.ts @@ -1,22 +1,7 @@ -import { useQuery } from "@tanstack/react-query"; import { getSignalStats } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; -import { RANGE_MS, type StatsRange } from "./types"; +import { useWindowedStats } from "./useWindowedStats"; +import type { StatsRange } from "./types"; export function useSignalStats(range: StatsRange) { - const { iatas, regionKey, isResolved } = useRegion(); - return useQuery({ - queryKey: ["stats-signal", isResolved === false ? `${regionKey}:pending` : 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; - return getSignalStats(until - RANGE_MS[range], until, iatas, signal); - }, - staleTime: 30_000, - refetchInterval: 60_000, - refetchOnWindowFocus: false, - retry: false, - }); + return useWindowedStats("stats-signal", range, getSignalStats); } diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index 4736e7c1..d2edd306 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -15,7 +15,7 @@ import { } from "../../api/client"; import { RANGE_MS, type StatsRange } from "./types"; -// Shared query options: cache for 30s, keep previous data so region/range switches don't flash. +// Shared query options: cache for 30s; previous data stays as placeholder so tabs can decide whether to show it during a switch. const common = { staleTime: 30_000, placeholderData: keepPreviousData, diff --git a/src/features/stats/useWindowedStats.ts b/src/features/stats/useWindowedStats.ts new file mode 100644 index 00000000..f6209283 --- /dev/null +++ b/src/features/stats/useWindowedStats.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { useRegion } from "../../hooks/useRegion"; +import { RANGE_MS, type StatsRange } from "./types"; + +// 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( + key: string, + range: StatsRange, + fetch: (since: number, until: number, iatas: string[] | undefined, signal?: AbortSignal) => Promise, +) { + const { iatas, regionKey, isResolved } = useRegion(); + return useQuery({ + queryKey: [key, isResolved === false ? `${regionKey}:pending` : 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; + return fetch(until - RANGE_MS[range], until, iatas, signal); + }, + staleTime: 30_000, + refetchInterval: 60_000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index f4cab08b..51cc930a 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -31,6 +31,8 @@ i18n.on("languageChanged", () => { const language = i18n.resolvedLanguage ?? "en"; document.documentElement.lang = language; document.documentElement.dir = i18n.dir(language); + // init() itself fires this event before any user choice exists — don't persist that first, implicit one. + if (!i18n.isInitialized) return; try { localStorage.setItem("beacon-language", language); } catch { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4ae076e6..f096e633 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -47,6 +47,24 @@ "noData": "No data", "dataUnavailable": "Data unavailable" }, + "mesh": { + "totalPackets": "Total packets", + "observations": "Observations", + "activeObservers": "Active observers", + "activeIatas": "Active IATAs", + "observationsTitle": "Observations · {{range}}", + "topObservers": "Top observers · {{range}}", + "payloadTypes": "Payload types · {{range}}", + "obs": "{{value}} obs", + "topNodes": "Top nodes · all time", + "nodeTypes": "Node types · all time", + "radioPresets": "Radio presets · all time", + "scope": "Scope", + "packets": "Packets", + "observers": "Observers", + "nodes": "Nodes", + "nodesCenter": "NODES" + }, "stats": { "section": "Stats section", "mobileSection": "Section", @@ -211,6 +229,15 @@ "other": "Other scopes", "chartDescription": "Counts by transport scope. Exact values for all matching scopes are listed in the table." }, + "nodeDetail": { + "viewOnMap": "View on map" + }, + "nodes": { + "lastHeard": "last heard {{ago}}" + }, + "packetRow": { + "noResolution": "No path resolution available" + }, "clockDrift": { "caption": "Repeaters & room servers out of sync · worst first", "node": "Node", @@ -222,7 +249,13 @@ "empty": "No repeaters out of sync" }, "timestamp": { - "ago": "{{duration}} ago" + "ago": "{{duration}} ago", + "unit": { + "s": "{{count}}s", + "m": "{{count}}m", + "h": "{{count}}h", + "d": "{{count}}d" + } }, "observerPage": { "back": "Back to observers", @@ -234,8 +267,10 @@ "lastHour": "Last complete hour", "lastPacket": "Last recorded packet", "battery": "Battery", + "batteryV": "Battery V", "uptime": "Device uptime", "noise": "Noise floor", + "noiseDbm": "Noise dBm", "packets": "Packets", "search": "Find an observer", "choose": "Choose an observer", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 35178e7e..e7d5c956 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -24,7 +24,7 @@ "live": "EN DIRECT", "stale": "EN RETARD {{age}}", "offline": "HORS LIGNE", - "rateLimited": "RÉESSAI DANS {{seconds}} s" + "rateLimited": "DÉBIT LIMITÉ {{seconds}} s" }, "region": { "label": "RÉGION", @@ -48,6 +48,24 @@ "noData": "Aucune donnée", "dataUnavailable": "Données indisponibles" }, + "mesh": { + "totalPackets": "Paquets totaux", + "observations": "Observations", + "activeObservers": "Observateurs actifs", + "activeIatas": "IATA actifs", + "observationsTitle": "Observations · {{range}}", + "topObservers": "Meilleurs observateurs · {{range}}", + "payloadTypes": "Types de paquets · {{range}}", + "obs": "{{value}} obs", + "topNodes": "Meilleurs nœuds · historique complet", + "nodeTypes": "Types de nœuds · historique complet", + "radioPresets": "Préréglages radio · historique complet", + "scope": "Scope", + "packets": "Paquets", + "observers": "Observateurs", + "nodes": "Nœuds", + "nodesCenter": "NŒUDS" + }, "stats": { "section": "Section des statistiques", "mobileSection": "Section", @@ -215,6 +233,15 @@ "other": "Autres scopes", "chartDescription": "Effectifs par scope de transport. Les valeurs exactes de tous les scopes correspondants figurent dans le tableau." }, + "nodeDetail": { + "viewOnMap": "Voir sur la carte" + }, + "nodes": { + "lastHeard": "dernier contact {{ago}}" + }, + "packetRow": { + "noResolution": "Aucune résolution de chemin disponible" + }, "clockDrift": { "caption": "Répéteurs et serveurs de salon désynchronisés · écart maximal en premier", "node": "Nœud", @@ -226,7 +253,13 @@ "empty": "Aucun répéteur désynchronisé" }, "timestamp": { - "ago": "il y a {{duration}}" + "ago": "il y a {{duration}}", + "unit": { + "s": "{{count}} s", + "m": "{{count}} min", + "h": "{{count}} h", + "d": "{{count}} j" + } }, "observerPage": { "back": "Retour aux observateurs", @@ -238,8 +271,10 @@ "lastHour": "Dernière heure complète", "lastPacket": "Dernier paquet enregistré", "battery": "Batterie", + "batteryV": "Batterie V", "uptime": "Durée de fonctionnement", "noise": "Bruit de fond", + "noiseDbm": "Bruit dBm", "packets": "Paquets", "search": "Rechercher un observateur", "choose": "Choisir un observateur", diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 22b30a59..b30eb4a4 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -16,6 +16,14 @@ export function formatAbsolute(epochMs: number, opts?: { ms?: boolean }): string return opts?.ms ? `${base}.${pad(d.getMilliseconds(), 3)}` : base; } +// The UTC counterpart to formatAbsolute, for charts/tables anchored to server-side hour/minute +// boundaries rather than the viewer's local time. +export function formatUtc(epochMs: number, opts?: { seconds?: boolean; timeOnly?: boolean }): string { + const iso = new Date(epochMs).toISOString(); + const date = iso.slice(0, 10), time = iso.slice(11, opts?.seconds ? 19 : 16); + return opts?.timeOnly ? time : `${date} ${time}`; +} + // signal quality and radio metric formatting export type SignalLevel = "good" | "mid" | "bad"; @@ -99,15 +107,24 @@ export function formatRatePerDay(count: number | null | undefined, windowMs: num return `${shown}/d`; } +export type TimeAgoUnit = "s" | "m" | "h" | "d"; + // clamp negative values from clock skew -export function timeAgoMs(epochMs: number): string { +export function timeAgoParts(epochMs: number): { count: number; unit: TimeAgoUnit } { const seconds = Math.max(0, Math.floor((Date.now() - epochMs) / 1000)); - if (seconds < 60) return `${seconds}s`; + if (seconds < 60) return { count: seconds, unit: "s" }; const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m`; + if (minutes < 60) return { count: minutes, unit: "m" }; const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h`; - return `${Math.floor(hours / 24)}d`; + if (hours < 24) return { count: hours, unit: "h" }; + return { count: Math.floor(hours / 24), unit: "d" }; +} + +// Compact English "7d" matching Timestamp's English relative phrasing; kept for tests that assert +// against it, not used by app code (see timeAgoParts for the i18n path). +export function timeAgoMs(epochMs: number): string { + const { count, unit } = timeAgoParts(epochMs); + return `${count}${unit}`; } // One radio config format for every panel ("915 MHz · SF11 · 250 kHz · CR 4/5"); unknown or zero parts drop out. diff --git a/src/types/api.ts b/src/types/api.ts index 5de0906a..7f4ab364 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -35,7 +35,7 @@ export interface PacketSummary { observationCount: number; latestObserver?: LatestObserver; scope?: string; // matched transport scope name, e.g. "#bc" - summary?: string; // packet-derived display text, currently the advertised name + summary?: string; // packet-derived display text (advert name, TRACE tag, ACK checksum, …) } export interface ResolvedNode { diff --git a/tests/App.observerInvestigation.test.tsx b/tests/App.observerInvestigation.test.tsx index cf258dee..69dbd851 100644 --- a/tests/App.observerInvestigation.test.tsx +++ b/tests/App.observerInvestigation.test.tsx @@ -14,7 +14,7 @@ vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: asy vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => { const { setSelection } = useRegionSelection(); - return <>{children}; + return <>{children}; } })); vi.mock("../src/features/routes/RouteTable", () => ({ RouteTable: ({ onViewObserver, onAnalyzePacket, onViewNode }: { onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string, id: number) => void; onViewNode: (id: string) => void }) => { const [filter, setFilter] = useState(""); const [params] = useSearchParams(); @@ -84,7 +84,7 @@ describe("observer investigation return", () => { expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 7d"); expect(window.location.search).toContain("tab=Observers&observer=o1"); expect(screen.getByTestId("origin-url")).toHaveTextContent("route=full-route"); - fireEvent.change(screen.getByRole("combobox", { name: "Time range" }), { target: { value: "24h" } }); + fireEvent.click(within(screen.getByRole("group", { name: "Time range" })).getByRole("button", { name: "24h" })); click("Compare with…"); expect(window.location.search).toContain("compareWith="); click("Back to Routes"); @@ -132,4 +132,30 @@ describe("observer investigation return", () => { await i18n.changeLanguage("fr"); render(); click("Route observer"); click("Open dashboard"); expect(await screen.findByRole("button", { name: /Retour à « Routes »/ })).toBeInTheDocument(); }); + it("clears comparison params from both the observer dashboard and analytics compare tabs on tab change", () => { + window.history.replaceState({}, "", "/?tab=Observers&observer=o1&compareWith=o2&compareUntil=1700000000000&compareA=x&compareB=y&compareSince=1"); + render(); + click("Route tab"); + expect(window.location.search).toContain("tab=Routes"); + for (const key of ["observer", "compareWith", "compareUntil", "compareA", "compareB", "compareSince"]) { + expect(window.location.search).not.toContain(key); + } + }); + it("keeps analytics compare params when re-clicking the already-active Analytics tab", async () => { + await import("../src/features/stats/StatsOverview"); + window.history.replaceState({}, "", "/?tab=Analytics&statsTab=compare&compareA=a&compareB=b&compareSince=1&compareUntil=2"); + render(); + click("Analytics tab"); + for (const pair of ["compareA=a", "compareB=b", "compareSince=1", "compareUntil=2"]) { + expect(window.location.search).toContain(pair); + } + }); + it("keeps the observer dashboard's compare params when re-clicking the already-active Observer tab", () => { + window.history.replaceState({}, "", "/?tab=Observers&observer=o1&compareWith=o2&compareUntil=2"); + render(); + click("Observer tab"); + for (const pair of ["observer=o1", "compareWith=o2", "compareUntil=2"]) { + expect(window.location.search).toContain(pair); + } + }); }); diff --git a/tests/components/Timestamp.test.tsx b/tests/components/Timestamp.test.tsx index c8bd64ad..2bb39648 100644 --- a/tests/components/Timestamp.test.tsx +++ b/tests/components/Timestamp.test.tsx @@ -33,21 +33,22 @@ describe("Timestamp", () => { }); it.each([ - [59_999, "59s"], - [60_000, "1m"], - [3_600_000, "1h"], - [86_400_000, "1d"], - [-60_000, "0s"], - ])("translates the phrase while preserving duration %s", async (age, duration) => { + [59_999, "59s", "59 s"], + [60_000, "1m", "1 min"], + [3_600_000, "1h", "1 h"], + [86_400_000, "1d", "1 j"], + [7 * 86_400_000, "7d", "7 j"], + [-60_000, "0s", "0 s"], + ])("translates the phrase while preserving duration %s", async (age, enDuration, frDuration) => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 8, 25, 12, 0, 0)); render(); - const label = screen.getByText(`${duration} ago`); + const label = screen.getByText(`${enDuration} ago`); await act(() => i18n.changeLanguage("fr")); - expect(screen.getByText(`il y a ${duration}`)).toBe(label); + expect(screen.getByText(`il y a ${frDuration}`)).toBe(label); await act(() => i18n.changeLanguage("en")); - expect(screen.getByText(`${duration} ago`)).toBe(label); + expect(screen.getByText(`${enDuration} ago`)).toBe(label); }); it("updates an open relative tooltip without changing the absolute value or milliseconds", async () => { @@ -61,7 +62,7 @@ describe("Timestamp", () => { expect(screen.getByRole("tooltip")).toHaveTextContent("5m ago"); await act(() => i18n.changeLanguage("fr")); - expect(screen.getByRole("tooltip")).toHaveTextContent("il y a 5m"); + expect(screen.getByRole("tooltip")).toHaveTextContent("il y a 5 min"); expect(screen.getByText("2026-09-25 12:34:56.123")).toBe(label); expect(label).toHaveClass("timestamp-label"); }); @@ -76,10 +77,10 @@ describe("Timestamp", () => { expect(vi.getTimerCount()).toBe(1); await act(() => i18n.changeLanguage("fr")); - expect(screen.getAllByText("il y a 1m")).toHaveLength(100); + expect(screen.getAllByText("il y a 1 min")).toHaveLength(100); expect(vi.getTimerCount()).toBe(1); act(() => vi.advanceTimersByTime(60_000)); - expect(screen.getAllByText("il y a 2m")).toHaveLength(100); + expect(screen.getAllByText("il y a 2 min")).toHaveLength(100); expect(vi.getTimerCount()).toBe(1); unmount(); expect(vi.getTimerCount()).toBe(0); diff --git a/tests/features/channels/ChannelList.test.tsx b/tests/features/channels/ChannelList.test.tsx index 30ea605e..3850f9eb 100644 --- a/tests/features/channels/ChannelList.test.tsx +++ b/tests/features/channels/ChannelList.test.tsx @@ -1,7 +1,9 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, type InfiniteData } from "@tanstack/react-query"; import { ChannelList } from "../../../src/features/channels/ChannelList"; +import { noteRateLimited, noteRequestOk } from "../../../src/api/rate-limit"; +import { LIVE_BUFFER_CAP } from "../../../src/lib/constants"; import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types"; import type { CursorPage } from "../../../src/types/api"; import type { WsManager } from "../../../src/api/ws-manager"; @@ -29,13 +31,21 @@ function show(client = new QueryClient({ defaultOptions: { queries: { retry: fal return { ...render(app()), client, app }; } function lastURL() { return new URL(String(fetchMock.mock.calls.at(-1)![0])); } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} beforeEach(() => { region = { iatas: ["YYZ"], regionKey: "YYZ" }; fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.unstubAllGlobals(); + noteRequestOk(); +}); describe("channel directory paging", () => { it("prefers the precise server cursor and preserves it through live updates", async () => { @@ -174,3 +184,46 @@ describe("channel directory paging", () => { expect(lastURL().searchParams.has("cursor")).toBe(false); }); }); + +describe("channel message overflow recovery", () => { + it("recovers a rate-limit-stuck overflow once the rate limit clears", async () => { + fetchMock.mockResolvedValueOnce(response(first())); + const { client } = show(); + await screen.findByText("#Channel 1"); + fireEvent.click(screen.getByText("#Channel 1")); + const key = ["channel-messages", 1, "YYZ", ""]; + const fetch = deferred>>(); + void client.fetchQuery({ queryKey: key, queryFn: () => fetch.promise }); + await waitFor(() => expect(client.isFetching({ queryKey: key, exact: true })).toBe(1)); + act(() => { + for (let i = 0; i <= LIVE_BUFFER_CAP; i++) { + onMessage({ id: i, packetHash: `h${i}`, channelHash: "01", senderName: "s", content: "c", sentAt: 20000 + i }); + } + }); + noteRateLimited(60_000); + await act(async () => fetch.resolve({ pages: [{ items: [], nextCursor: null, hasMore: false }], pageParams: [undefined] })); + await waitFor(() => expect(client.isFetching({ queryKey: key, exact: true })).toBe(0)); + expect(client.getQueryState(key)?.isInvalidated).toBeFalsy(); + act(() => noteRequestOk()); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + }); + + it("invalidates an idle overflow directly from the WS handler once a further message arrives", async () => { + fetchMock.mockResolvedValueOnce(response(first())); + const { client } = show(); + await screen.findByText("#Channel 1"); + fireEvent.click(screen.getByText("#Channel 1")); + const key = ["channel-messages", 1, "YYZ", ""]; + // A registered-but-unfetched query: getQueryData(key) stays undefined, so messages keep hitting the queue/overflow path. + client.getQueryCache().build(client, { queryKey: key }); + expect(client.isFetching({ queryKey: key, exact: true })).toBe(0); + act(() => { + for (let i = 0; i < LIVE_BUFFER_CAP; i++) { + onMessage({ id: i, packetHash: `h${i}`, channelHash: "01", senderName: "s", content: "c", sentAt: 20000 + i }); + } + }); + expect(client.getQueryState(key)?.isInvalidated).toBeFalsy(); + act(() => onMessage({ id: LIVE_BUFFER_CAP, packetHash: `h${LIVE_BUFFER_CAP}`, channelHash: "01", senderName: "s", content: "c", sentAt: 30000 })); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + }); +}); diff --git a/tests/features/map/useMapBordersData.refresh.test.tsx b/tests/features/map/useMapBordersData.refresh.test.tsx index 5add918f..297c861e 100644 --- a/tests/features/map/useMapBordersData.refresh.test.tsx +++ b/tests/features/map/useMapBordersData.refresh.test.tsx @@ -26,4 +26,21 @@ describe("border refresh", () => { rerender(); expect(result.current.features).toHaveLength(1); }); + it("keeps the same reference when a refetch returns the same data object", () => { + const same = border(0); + state.data = same; state.updated = 1; + const { result, rerender } = renderHook(() => useMapBordersData(["YKF"], true)); + const previous = result.current; + state.updated = 2; + rerender(); + expect(result.current).toBe(previous); + }); + it("keeps the same reference when a null-border query refetches", () => { + state.data = null; state.updated = 1; + const { result, rerender } = renderHook(() => useMapBordersData(["YOW"], true)); + const previous = result.current; + state.updated = 2; + rerender(); + expect(result.current).toBe(previous); + }); }); diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx index f95a0235..923226cb 100644 --- a/tests/features/nodes/NodeDetailPanel.test.tsx +++ b/tests/features/nodes/NodeDetailPanel.test.tsx @@ -1,8 +1,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { NodeDetailPanel } from "../../../src/features/nodes/NodeDetailPanel"; +import i18n from "../../../src/i18n"; import { getNode, getNodeObservations, getNodeNeighbors } from "../../../src/api/client"; import type { Node, NodeNeighbor } from "../../../src/features/nodes/types"; @@ -99,6 +100,18 @@ describe("NodeDetailPanel neighbors", () => { }); }); +describe("NodeDetailPanel location", () => { + it("hides Lat/Lng but keeps the source for a 0/0 advert reset", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 0, locationSource: "advert" }); + + renderPanel(); + + expect(await screen.findByText("Source")).toBeInTheDocument(); + expect(screen.queryByText("Lat")).not.toBeInTheDocument(); + expect(screen.queryByText("Lng")).not.toBeInTheDocument(); + }); +}); + describe("NodeDetailPanel clock drift", () => { it("shows a repeater's clock drift in amber when the server flags it out of sync", async () => { mockGetNode.mockResolvedValue({ ...node, lastAdvertAt: 2, clockDriftSeconds: 432, clockOutOfSync: true, clockCheckedAt: 2 }); @@ -156,4 +169,27 @@ describe("NodeDetailPanel View on map", () => { await screen.findByText("Self Node"); expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); }); + + it("shows the French label", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); + await act(() => i18n.changeLanguage("fr")); + renderWithMap(vi.fn()); + expect(await screen.findByRole("button", { name: "Voir sur la carte" })).toBeInTheDocument(); + await act(() => i18n.changeLanguage("en")); + }); + + it("is not offered for an explicit 0/0 advert reset", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 0 }); + renderWithMap(vi.fn()); + await screen.findByText("Self Node"); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); + + it("is still offered when only one axis is zero", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 10 }); + const onViewOnMap = vi.fn(); + renderWithMap(onViewOnMap); + fireEvent.click(await screen.findByRole("button", { name: "View on map" })); + expect(onViewOnMap).toHaveBeenCalledWith(0, 10); + }); }); diff --git a/tests/features/nodes/NodeTable.test.tsx b/tests/features/nodes/NodeTable.test.tsx new file mode 100644 index 00000000..35836c7a --- /dev/null +++ b/tests/features/nodes/NodeTable.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, within, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NodeTable } from "../../../src/features/nodes/NodeTable"; +import { getNodesPage } from "../../../src/api/client"; +import i18n from "../../../src/i18n"; +import type { NodeSummary } from "../../../src/features/nodes/types"; +import type { WsManager } from "../../../src/api/ws-manager"; + +const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] })); +vi.mock("../../../src/api/client", () => ({ getNodesPage: vi.fn() })); + +const fakeWsManager = { onNodeUpdate: () => () => {} } as unknown as WsManager; + +const node = (over: Partial): NodeSummary => ({ + id: "node-a", + publicKey: "aabbccdd", + nodeType: 2, + nodeTypeName: "REPEATER", + name: "Node A", + lat: null, + lng: null, + iatas: [], + knownNeighborCount: 0, + ...over, +}); + +function mount(items: NodeSummary[]) { + vi.mocked(getNodesPage).mockResolvedValue({ items, nextCursor: null, hasMore: false }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +beforeEach(() => { + vi.mocked(getNodesPage).mockReset(); +}); + +describe("NodeTable location column", () => { + it("shows a dash for an explicit 0/0 advert reset, not the coordinates", async () => { + mount([node({ id: "node-zero", name: "Zeroed node", lat: 0, lng: 0 })]); + const nameCell = await screen.findByText("Zeroed node"); + const row = nameCell.closest("tr")!; + const cells = within(row).getAllByRole("cell"); + expect(cells[cells.length - 1]).toHaveTextContent("—"); + expect(screen.queryByText("0.00, 0.00")).not.toBeInTheDocument(); + }); + + it("still shows coordinates when only one axis is zero", async () => { + mount([node({ id: "node-partial", name: "Partial node", lat: 0, lng: 10 })]); + await screen.findByText("Partial node"); + expect(screen.getByText("0.00, 10.00")).toBeInTheDocument(); + }); +}); + +describe("NodeTable IATA badge tooltip", () => { + it("shows a translated last-heard label, in English and French", async () => { + const lastHeard = Date.now() - 7 * 86_400_000; + mount([node({ id: "node-iata", name: "IATA node", iatas: [{ iata: "YOW", lastHeard }] })]); + const badge = await screen.findByText("YOW"); + const trigger = badge.parentElement!; + + fireEvent.mouseEnter(trigger); + expect(screen.getByRole("tooltip")).toHaveTextContent("last heard 7d ago"); + fireEvent.mouseLeave(trigger); + + await i18n.changeLanguage("fr"); + fireEvent.mouseEnter(trigger); + expect(screen.getByRole("tooltip")).toHaveTextContent("dernier contact il y a 7 j"); + }); +}); diff --git a/tests/features/observers/ObserverDetailPanel.test.tsx b/tests/features/observers/ObserverDetailPanel.test.tsx index 4e7c7b7f..2b710a2d 100644 --- a/tests/features/observers/ObserverDetailPanel.test.tsx +++ b/tests/features/observers/ObserverDetailPanel.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; @@ -76,6 +76,34 @@ describe("ObserverDetailPanel status", () => { }); }); +describe("ObserverDetailPanel copy link", () => { + const writeText = vi.fn(); + + beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true, configurable: true }); + writeText.mockClear(); + }); + + afterEach(() => window.history.replaceState({}, "", "/")); + + it("copies a link scoped to the observer tab, dropping foreign params", async () => { + window.history.replaceState({}, "", "/?tab=Packets&hash=abc&analyze=1&observation=7&iata=YVR"); + + renderPanel(); + + await screen.findByText("online"); + fireEvent.click(screen.getByRole("button", { name: "Copy observer link" })); + + const url = new URL(writeText.mock.calls[0][0]); + expect(url.searchParams.get("tab")).toBe("Observers"); + expect(url.searchParams.get("observer")).toBe("obs-1"); + expect(url.searchParams.get("iata")).toBe("YVR"); + expect(url.searchParams.has("hash")).toBe(false); + expect(url.searchParams.has("analyze")).toBe(false); + expect(url.searchParams.has("observation")).toBe(false); + }); +}); + describe("ObserverDetailPanel adverts", () => { it("lists adverts heard by the observer", async () => { mockGetObserverAdverts.mockResolvedValue({ diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx index d2a0496a..6f0f9409 100644 --- a/tests/features/observers/ObserverPage.test.tsx +++ b/tests/features/observers/ObserverPage.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import "../../../src/i18n"; import { useState } from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; import { MemoryRouter, useNavigate, useLocation } from "react-router-dom"; import type { WsManager } from "../../../src/api/ws-manager"; import { ObserverPage } from "../../../src/features/observers/ObserverPage"; @@ -77,7 +77,10 @@ describe("Observer destination", () => { it("restores a deep link and has a directory return without prior history", async () => { view("?tab=Observers&observer=observer-b&range=30d"); expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-b 30d"); - expect(screen.getAllByRole("option").map(option => option.getAttribute("value"))).toEqual(["24h", "7d", "30d"]); + const range = screen.getByRole("group", { name: "Time range" }); + const options = within(range).getAllByRole("button"); + expect(options.map(option => option.textContent)).toEqual(["24h", "7d", "30d"]); + expect(options[2]).toHaveAttribute("aria-pressed", "true"); fireEvent.click(screen.getByRole("button", { name: /Back to observers/ })); expect(screen.getByLabelText("Directory search")).toBeVisible(); }); diff --git a/tests/features/observers/ObserverPicker.test.tsx b/tests/features/observers/ObserverPicker.test.tsx new file mode 100644 index 00000000..dc0003eb --- /dev/null +++ b/tests/features/observers/ObserverPicker.test.tsx @@ -0,0 +1,40 @@ +import { it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "../../../src/i18n"; +import { ObserverPicker } from "../../../src/features/observers/ObserverPicker"; +import { getObserversPage } from "../../../src/api/client"; + +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ regionKey: "YOW", iatas: ["YOW"] }) })); +vi.mock("../../../src/api/client", () => ({ getObserversPage: vi.fn(async () => ({ items: [], hasMore: false })) })); + +beforeEach(() => { vi.mocked(getObserversPage).mockClear(); }); + +function view(id: string, name: string) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +it("shows the placeholder name, not the dropdown's default All label, before a partner is chosen", () => { + view("", "Choose an observer to compare…"); + const trigger = screen.getByRole("button", { name: /Choose an observer to compare…/ }); + expect(trigger).not.toHaveTextContent("All"); +}); + +it("keeps a trailing space visible while typing and trims only the query sent to the server", async () => { + vi.useFakeTimers(); + try { + view("", "Choose an observer"); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "Node " } }); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(input).toHaveValue("Node "); + expect(getObserversPage).toHaveBeenCalledWith(["YOW"], { name: "Node", limit: 50 }); + } finally { + vi.useRealTimers(); + } +}); diff --git a/tests/features/observers/ObserverSummary.compact.test.tsx b/tests/features/observers/ObserverSummary.compact.test.tsx index 189f95f7..254a8c77 100644 --- a/tests/features/observers/ObserverSummary.compact.test.tsx +++ b/tests/features/observers/ObserverSummary.compact.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import { ObserverSummary } from "../../../src/features/observers/ObserverSummary"; import type { Observer } from "../../../src/features/observers/types"; +import type { ObserverActivity, TelemetryPoint } from "../../../src/features/stats/types"; const observer = { id: "observer", displayName: "Roof", iata: "YKF", brokers: [], lastStatusAt: Date.now() - 600_000 } as unknown as Observer; @@ -19,3 +20,23 @@ describe("compact observer header", () => { expect(screen.getByRole("img", { name: "No status report" })).toHaveClass("text-text-muted"); }); }); + +describe("observer summary card notes", () => { + it("wraps the last-complete-hour window under its card instead of a non-wrapping sublabel", () => { + const activity = { summary: { + recordedPackets: 5, lastCompleteHour: 3, lastCompleteHourStart: 0, lastCompleteHourEnd: 3_600_000, latestRecordedAt: null, + } } as unknown as ObserverActivity; + render(); + const note = screen.getByText("00:00–01:00 UTC"); + expect(note).not.toHaveClass("whitespace-nowrap"); + expect(note).toHaveClass("mt-1"); + }); + + it("wraps the noise-floor telemetry note under its card the same way", () => { + const points = [{ noiseFloorDb: -91 } as unknown as TelemetryPoint]; + render(); + const note = screen.getByText("Latest telemetry interval"); + expect(note).not.toHaveClass("whitespace-nowrap"); + expect(note).toHaveClass("mt-1"); + }); +}); diff --git a/tests/features/observers/observer-id.test.ts b/tests/features/observers/observer-id.test.ts new file mode 100644 index 00000000..32cb672a --- /dev/null +++ b/tests/features/observers/observer-id.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { OBSERVER_UUID } from "../../../src/features/observers/observer-id"; + +describe("OBSERVER_UUID", () => { + it("matches a well-formed UUID, case-insensitively", () => { + expect(OBSERVER_UUID.test("3fa85f64-5717-4562-b3fc-2c963f66afa6")).toBe(true); + expect(OBSERVER_UUID.test("3FA85F64-5717-4562-B3FC-2C963F66AFA6")).toBe(true); + }); + + it("rejects the nil UUID and malformed strings", () => { + expect(OBSERVER_UUID.test("00000000-0000-0000-0000-000000000000")).toBe(false); + expect(OBSERVER_UUID.test("not-a-uuid")).toBe(false); + }); +}); diff --git a/tests/features/observers/observer-navigation.test.ts b/tests/features/observers/observer-navigation.test.ts index de849cb7..05cfa9d8 100644 --- a/tests/features/observers/observer-navigation.test.ts +++ b/tests/features/observers/observer-navigation.test.ts @@ -19,6 +19,13 @@ describe("observerDestination", () => { const p = observerDestination(new URLSearchParams("hash=abc&analyze=1&node=n&path=p"), "b"); expect(p.toString()).toBe("tab=Observers&observer=b&range=7d"); }); + + it("drops the analytics compare params so they don't ride along into the dashboard URL", () => { + const p = observerDestination(new URLSearchParams("compareA=x&compareB=y&compareSince=1"), "b"); + expect(p.has("compareA")).toBe(false); + expect(p.has("compareB")).toBe(false); + expect(p.has("compareSince")).toBe(false); + }); }); describe("observerRange", () => { diff --git a/tests/features/observers/observer-stats.test.ts b/tests/features/observers/observer-stats.test.ts new file mode 100644 index 00000000..1f793415 --- /dev/null +++ b/tests/features/observers/observer-stats.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { getStats, observerNoiseFloor } from "../../../src/features/observers/observer-stats"; + +describe("getStats", () => { + it("returns null when metadata is missing", () => { + expect(getStats(undefined)).toBeNull(); + }); + + it("returns null when stats is not an object", () => { + expect(getStats({ stats: "nope" })).toBeNull(); + }); + + it("returns the stats object when present", () => { + expect(getStats({ stats: { noise_floor: -95, queue_len: 3 } })).toEqual({ noise_floor: -95, queue_len: 3 }); + }); +}); + +describe("observerNoiseFloor", () => { + it("returns null when statusMetadata is missing", () => { + expect(observerNoiseFloor({ statusMetadata: undefined })).toBeNull(); + }); + + it("returns null when noise_floor is not a finite number", () => { + expect(observerNoiseFloor({ statusMetadata: { stats: { noise_floor: NaN } } })).toBeNull(); + expect(observerNoiseFloor({ statusMetadata: { stats: { noise_floor: "cold" } } })).toBeNull(); + }); + + it("returns the noise floor when it is a finite number", () => { + expect(observerNoiseFloor({ statusMetadata: { stats: { noise_floor: -102.5 } } })).toBe(-102.5); + }); +}); diff --git a/tests/features/packets/PacketEndpoints.test.tsx b/tests/features/packets/PacketEndpoints.test.tsx index 09ad7c5f..53cc440a 100644 --- a/tests/features/packets/PacketEndpoints.test.tsx +++ b/tests/features/packets/PacketEndpoints.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, it, expect, vi } from "vitest"; -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { PacketEndpoints } from "../../../src/features/packets/PacketEndpoints"; +import i18n from "../../../src/i18n"; import type { LatestObserver, PacketSummary } from "../../../src/types/api"; const pkt = (observer?: LatestObserver): PacketSummary => ({ @@ -72,7 +73,18 @@ describe("PacketEndpoints", () => { render(); - expect(screen.getByText("?")).toBeInTheDocument(); + const chip = screen.getByText("?"); + expect(chip).toBeInTheDocument(); + expect(chip).toHaveAttribute("title", "No path resolution available"); + }); + + it("translates the no-resolution title to French", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("?")).toHaveAttribute("title", "Aucune résolution de chemin disponible"); + await act(() => i18n.changeLanguage("en")); }); it("shows an advert as its single source node with no destination", () => { diff --git a/tests/features/packets/PathData.test.tsx b/tests/features/packets/PathData.test.tsx index 7ddc96cd..636b2fa9 100644 --- a/tests/features/packets/PathData.test.tsx +++ b/tests/features/packets/PathData.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; import { ResolvedHopBlock } from "../../../src/features/packets/PathData"; +import i18n from "../../../src/i18n"; import type { ResolvedHop } from "../../../src/types/api"; // mobile/touch == no hover-capable pointer; desktop == has hover. Interaction modality keys off @@ -49,6 +50,26 @@ describe("ResolvedHopBlock tooltip", () => { }); }); +describe("ResolvedHopBlock unresolved popover", () => { + const noMatchHop: ResolvedHop = { confidence: "none", nodes: [] }; + + it("shows the same no-resolution message PacketEndpoints uses", () => { + setMobile(true); + render(); + fireEvent.click(screen.getByText("ABC1")); + expect(screen.getByRole("tooltip")).toHaveTextContent("No path resolution available"); + }); + + it("translates it to French", async () => { + setMobile(true); + await act(() => i18n.changeLanguage("fr")); + render(); + fireEvent.click(screen.getByText("ABC1")); + expect(screen.getByRole("tooltip")).toHaveTextContent("Aucune résolution de chemin disponible"); + await act(() => i18n.changeLanguage("en")); + }); +}); + describe("ResolvedHopBlock (desktop)", () => { it("opens the node directly when the single-match block is clicked", () => { setMobile(false); diff --git a/tests/features/routes/RouteEvidencePanel.test.tsx b/tests/features/routes/RouteEvidencePanel.test.tsx index 94bd7372..baf6df2d 100644 --- a/tests/features/routes/RouteEvidencePanel.test.tsx +++ b/tests/features/routes/RouteEvidencePanel.test.tsx @@ -106,4 +106,10 @@ describe("retained route evidence", () => { fireEvent.click(screen.getByRole("button", { name: "Retry" })); await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); }); + it("colours a good SNR report the same way every other SNR cell does", async () => { + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [{ ...page.items[0]!, snr: 12 }], hasMore: false }); + mount(); + const snr = await screen.findByText("12.00"); + expect(snr).toHaveClass("text-green"); + }); }); diff --git a/tests/features/routes/RouteTable.test.tsx b/tests/features/routes/RouteTable.test.tsx index f1172333..cb1389a9 100644 --- a/tests/features/routes/RouteTable.test.tsx +++ b/tests/features/routes/RouteTable.test.tsx @@ -1,10 +1,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; import { RouteTable } from "../../../src/features/routes/RouteTable"; -import { MemoryRouter } from "react-router-dom"; -import { RegionProvider } from "../../../src/hooks/useRegion"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { RegionProvider, useRegionSelection } from "../../../src/hooks/useRegion"; import { ALL_REGIONS } from "../../../src/hooks/region-selection"; import { getKnownRoutesPage, @@ -145,6 +145,42 @@ describe("RouteTable search", () => { }); }); +function LocationKeyProbe({ keys }: { keys: string[] }) { + const location = useLocation(); + useEffect(() => { + if (keys[keys.length - 1] !== location.key) keys.push(location.key); + }, [location.key, keys]); + return null; +} + +function ChangeRegionButton() { + const { setSelection } = useRegionSelection(); + return ; +} + +it("does not navigate on a region change with no route selected", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const keys: string[] = []; + render( + + + + + + + + + , + ); + await screen.findByText("Find path"); + const initialKeyCount = keys.length; + + fireEvent.click(screen.getByRole("button", { name: "Change region" })); + + await waitFor(() => expect(mockGetKnownRoutesPage).toHaveBeenCalledWith(expect.objectContaining({ iata: "AAA" }))); + expect(keys.length).toBe(initialKeyCount); +}); + it("preserves a shared saved route while a named region resolves", async () => { vi.mocked(getRegions).mockResolvedValue([{ id: 1, slug: "ontario", name: "Ontario" }]); vi.mocked(getRegion).mockResolvedValue({ id: 1, slug: "ontario", name: "Ontario", iatas: ["YOW"] }); diff --git a/tests/features/stats/MeshTab.test.tsx b/tests/features/stats/MeshTab.test.tsx index 3fc0dafc..c04ca526 100644 --- a/tests/features/stats/MeshTab.test.tsx +++ b/tests/features/stats/MeshTab.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MeshTab } from "../../../src/features/stats/MeshTab"; +import i18n from "../../../src/i18n"; import { getStatsOverview, getStatsObservations, getPayloadBreakdown, getTopNodes, getTopObservers, getRadioPresets, getStatsScopes, getStatsNodeTypes } from "../../../src/api/client"; import type { WsManager } from "../../../src/api/ws-manager"; import type { EChartsOption } from "../../../src/features/stats/echarts-setup"; @@ -163,3 +164,18 @@ it("keeps the scope list collapsed until requested", async () => { expect(scopeList).toHaveAttribute("open"); expect(within(scopeList).getByText("#old")).toBeVisible(); }); + +it("shows French labels for the KPIs, chart titles and scope table", async () => { + await act(() => i18n.changeLanguage("fr")); + mount(); await loaded(); + expect(within(card("Paquets totaux")).getByText("111")).toBeInTheDocument(); + expect(screen.getByText("Observations · 24h")).toBeInTheDocument(); + expect(screen.getByText("Meilleurs nœuds · historique complet")).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 })); + const scopeList = screen.getByText("Scopes", { exact: true }).closest("details")!; + expect(within(scopeList).getByText("Scope")).toBeInTheDocument(); + expect(within(scopeList).getByText("Nœuds")).toBeInTheDocument(); + await act(() => i18n.changeLanguage("en")); +}); diff --git a/tests/features/stats/useLiveStats.test.tsx b/tests/features/stats/useLiveStats.test.tsx index 45a49094..9c0f64b9 100644 --- a/tests/features/stats/useLiveStats.test.tsx +++ b/tests/features/stats/useLiveStats.test.tsx @@ -65,6 +65,25 @@ describe("useLiveOverview", () => { expect(after?.totalPackets).toBe(101); expect(after?.totalObservations).toBe(202); }); + + it("drops pending deltas instead of resurrecting a failed overview query", () => { + const qc = new QueryClient(); + qc.setQueryData(["stats-overview", "YOW"], overview); + qc.getQueryCache().find({ queryKey: ["stats-overview", "YOW"] })?.setState({ status: "error", error: new Error("refetch failed") }); + const { manager, emit } = fakeManager(); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + renderHook(() => useLiveOverview(manager), { wrapper }); + + emit({ packet: { isFirstObservation: true } } as WsPacketObservation["data"]); + rafCallbacks.forEach((cb) => cb(0)); + + const state = qc.getQueryState(["stats-overview", "YOW"]); + expect(state?.status).toBe("error"); + expect(state?.data).toEqual(overview); + }); }); describe("useLiveObserver", () => { diff --git a/tests/i18n/index.test.ts b/tests/i18n/index.test.ts index 77cd557c..e60acc75 100644 --- a/tests/i18n/index.test.ts +++ b/tests/i18n/index.test.ts @@ -27,13 +27,41 @@ describe("language preferences and catalogs", () => { }); it("still changes language when browser storage is unavailable", async () => { - vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); }); - vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("blocked"); }); - expect(readLanguagePreference()).toBe("en"); - await i18n.changeLanguage("fr"); - expect(i18n.t("tabs.Packets")).toBe("Paquets"); - expect(document.documentElement.lang).toBe("fr"); - expect(document.documentElement.dir).toBe("ltr"); + vi.stubGlobal("localStorage", { + getItem() { throw new Error("blocked"); }, + setItem() { throw new Error("blocked"); }, + removeItem() {}, + }); + try { + expect(readLanguagePreference()).toBe("en"); + await i18n.changeLanguage("fr"); + expect(i18n.t("tabs.Packets")).toBe("Paquets"); + expect(document.documentElement.lang).toBe("fr"); + expect(document.documentElement.dir).toBe("ltr"); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("does not persist a language on module init, only on an explicit change", async () => { + // Isolated in-memory store: the real localStorage is a single process-wide object (Node's + // localStorage shadows jsdom's per test file), so a leftover "beacon-language" from another + // suite sharing this worker could otherwise land here before the dynamic import reads it. + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + }); + try { + vi.resetModules(); + const fresh = await import("../../src/i18n"); + expect(localStorage.getItem("beacon-language")).toBeNull(); + await fresh.default.changeLanguage("fr"); + expect(localStorage.getItem("beacon-language")).toBe("fr"); + } finally { + vi.unstubAllGlobals(); + } }); it("persists only the language preference and restores English document metadata", async () => { @@ -59,6 +87,20 @@ describe("language preferences and catalogs", () => { expect(i18n.t("region.count", { lng: "en", count: 2 })).toBe("2 regions"); expect(i18n.t("region.count", { lng: "fr", count: 1 })).toBe("1 région"); expect(i18n.t("region.count", { lng: "fr", count: 2 })).toBe("2 régions"); - expect(i18n.t("connection.rateLimited", { lng: "fr", seconds: 5 })).toBe("RÉESSAI DANS 5 s"); + expect(i18n.t("connection.rateLimited", { lng: "fr", seconds: 5 })).toBe("DÉBIT LIMITÉ 5 s"); + }); + + it("has whole-phrase battery/noise labels in both catalogs (no joined-word strings)", () => { + expect(i18n.t("observerPage.batteryV", { lng: "en" })).toBe("Battery V"); + expect(i18n.t("observerPage.noiseDbm", { lng: "en" })).toBe("Noise dBm"); + expect(i18n.t("observerPage.batteryV", { lng: "fr" })).toBe("Batterie V"); + expect(i18n.t("observerPage.noiseDbm", { lng: "fr" })).toBe("Bruit dBm"); + }); + + it("interpolates the payload-type total as a plain value, not a plural count", () => { + // formatCount can return a non-numeric string like "1.2k"; a `count` placeholder would feed that + // into plural resolution instead of a straight interpolation. + expect(i18n.t("mesh.obs", { lng: "en", value: "1.2k" })).toBe("1.2k obs"); + expect(i18n.t("mesh.obs", { lng: "fr", value: "1,2k" })).toBe("1,2k obs"); }); }); diff --git a/tests/lib/formatters.test.ts b/tests/lib/formatters.test.ts index 1064263d..844f71af 100644 --- a/tests/lib/formatters.test.ts +++ b/tests/lib/formatters.test.ts @@ -4,7 +4,9 @@ import { formatRadioParts, formatHex, formatAbsolute, + formatUtc, timeAgoMs, + timeAgoParts, formatSnr, snrLevel, formatPropagation, @@ -38,6 +40,26 @@ describe("formatAbsolute", () => { }); }); +describe("formatUtc", () => { + const t = 3_665_000; // 1970-01-01 01:01:05 UTC + + it("formats as YYYY-MM-DD HH:MM by default", () => { + expect(formatUtc(t)).toBe("1970-01-01 01:01"); + }); + + it("appends :SS when seconds is requested", () => { + expect(formatUtc(t, { seconds: true })).toBe("1970-01-01 01:01:05"); + }); + + it("shows only HH:MM when timeOnly is requested", () => { + expect(formatUtc(t, { timeOnly: true })).toBe("01:01"); + }); + + it("shows HH:MM:SS when both timeOnly and seconds are requested", () => { + expect(formatUtc(t, { timeOnly: true, seconds: true })).toBe("01:01:05"); + }); +}); + describe("timeAgoMs", () => { it("renders sub-minute as seconds and minutes/hours/days above that", () => { const now = Date.now(); @@ -52,6 +74,20 @@ describe("timeAgoMs", () => { }); }); +describe("timeAgoParts", () => { + it("splits the same clamping/flooring timeAgoMs uses into a count and unit", () => { + const now = Date.now(); + expect(timeAgoParts(now - 5_000)).toEqual({ count: 5, unit: "s" }); + expect(timeAgoParts(now - 5 * 60_000)).toEqual({ count: 5, unit: "m" }); + expect(timeAgoParts(now - 3 * 3_600_000)).toEqual({ count: 3, unit: "h" }); + expect(timeAgoParts(now - 2 * 86_400_000)).toEqual({ count: 2, unit: "d" }); + }); + + it("clamps future timestamps (clock skew) to 0s", () => { + expect(timeAgoParts(Date.now() + 60_000)).toEqual({ count: 0, unit: "s" }); + }); +}); + describe("snrLevel", () => { it("returns good for SNR >= 10", () => { expect(snrLevel(10.5)).toBe("good"); diff --git a/tests/setup.ts b/tests/setup.ts index f889c4a3..315c9a7c 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -21,7 +21,25 @@ if (!window.matchMedia) { })); } +// Node 26 exposes `localStorage` as undefined without `--localstorage-file`, shadowing jsdom's. +if (!globalThis.localStorage) { + const store = new Map(); + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => store.set(key, String(value)), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { return store.size; }, + }, + configurable: true, + writable: true, + }); +} + afterEach(async () => { cleanup(); await i18n.changeLanguage("en"); + try { localStorage.clear(); } catch { /* stubbed per-test */ } });