Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 19 additions & 56 deletions docs/translations.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
6 changes: 5 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ export function getTraceDetail(tag: string): Promise<TraceDetail> {

export async function getObserver(observerId: string): Promise<Observer> {
const observer = await request<Observer>(`/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)))); }
Expand Down
10 changes: 4 additions & 6 deletions src/components/ModalOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
<button
Expand Down
5 changes: 3 additions & 2 deletions src/components/Timestamp.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { useTick } from "../hooks/useTick";
import { Tooltip } from "./Tooltip";
import { timeAgoMs, formatAbsolute } from "../lib/formatters";
import { timeAgoParts, formatAbsolute } from "../lib/formatters";

interface TimestampProps {
value: number; // epoch ms
Expand All @@ -18,7 +18,8 @@ export function Timestamp({ value, mode = "relative", ms, className }: Timestamp
const { t } = useTranslation();
useTick(); // keep the relative label fresh

const relative = t("timestamp.ago", { duration: timeAgoMs(value) });
const { count, unit } = timeAgoParts(value);
const relative = t("timestamp.ago", { duration: t(`timestamp.unit.${unit}`, { count }) });
const absolute = formatAbsolute(value, { ms });

return (
Expand Down
2 changes: 2 additions & 0 deletions src/components/action-button.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Compact bordered action used inside panels and page toolbars.
export const ACTION_BUTTON_CLASS = "rounded border border-border px-2 py-1 font-mono text-[11px] text-primary hover:bg-bg-raised disabled:opacity-40 disabled:cursor-not-allowed";
22 changes: 16 additions & 6 deletions src/features/channels/ChannelList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useInfiniteQuery, useIsFetching, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import { getChannels } from "../../api/client";
import { isRateLimited } from "../../api/rate-limit";
import { isRateLimited, subscribeRateLimit } from "../../api/rate-limit";
import { LIVE_BUFFER_CAP, MAX_INFINITE_PAGES } from "../../lib/constants";
import { useRegion } from "../../hooks/useRegion";
import { useIsMobile } from "../../hooks/useMediaQuery";
Expand Down Expand Up @@ -47,9 +47,10 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
const messageKey = useMemo(() => ["channel-messages", selectedId, regionKey, messageScope], [selectedId, regionKey, messageScope]);
const messageFetching = useIsFetching({ queryKey: messageKey, exact: true });

useEffect(() => {
if (messageFetching) return;
if (messagesOverflowed.current && !isRateLimited()) {
const flushPending = useCallback(() => {
if (queryClient.isFetching({ queryKey: messageKey, exact: true })) return;
if (messagesOverflowed.current) {
if (isRateLimited()) return;
messagesOverflowed.current = false;
pendingMessages.current.clear();
void queryClient.invalidateQueries({ queryKey: messageKey, exact: true });
Expand All @@ -59,7 +60,14 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
pendingMessages.current.clear();
queryClient.setQueryData<InfiniteData<CursorPage<ChannelMessage>>>(messageKey, (old) => appendMessages(old, queued));
}
}, [messageFetching, messageKey, queryClient]);
}, [messageKey, queryClient]);

useEffect(() => {
flushPending();
}, [messageFetching, flushPending]);

// An overflow parked behind a 429 would otherwise sit until the user switches channel.
useEffect(() => subscribeRateLimit(flushPending), [flushPending]);

const prevRegion = useRef(regionKey);
useEffect(() => {
Expand Down Expand Up @@ -164,6 +172,8 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
messagesOverflowed.current = true;
} else pendingMessages.current.set(data.packetHash, data);
}
// A fresh arrival is the only signal left once idle-and-overflowed, so try recovery here too.
flushPending();
// The WS event has no retained observation total; repeats do not add observers.
setHeardCounts((prev) => ({
...prev,
Expand All @@ -176,7 +186,7 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
);
}
},
[queryClient, selectedId, selection, regionKey, messageScope, messageKey],
[queryClient, selectedId, selection, regionKey, messageScope, messageKey, flushPending],
);

useWsChannelMessageHandler(wsManager, handleChannelMessage);
Expand Down
4 changes: 1 addition & 3 deletions src/features/map/location.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
// Beacon preserves explicit 0/0 advert resets in the data. They are unlocated
// radio nodes, not markers at Null Island. Either axis alone may validly be zero.
// This is a node-location rule, not a restriction on map camera coordinates.
// 0/0 is an advert reset, not Null Island; a single zero axis is still a valid location.
export function hasMapLocation(
node: { lat?: number | null; lng?: number | null } | null | undefined,
): node is { lat: number; lng: number } {
Expand Down
8 changes: 3 additions & 5 deletions src/features/map/packet-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ import { hasMapLocation } from "./location";
// LiveViz) and the path map share them. No maplibre import, so they stay unit-testable; the hook
// owns the layers, the rAF loop, and the node flashes.

// The full chain for one observation: source → relay hops → destination. Both maps plot one marker
// per hop, so an ambiguous endpoint (a 1-byte prefix matching several candidate nodes) would force
// us to guess which node actually sent or received the packet — only plot endpoints the backend
// resolved unambiguously. Rendering also checks relay confidence and location. WS types the
// endpoints nullable where REST leaves them optional, hence both here.
// The full chain for one observation: source → relay hops → destination. An ambiguous endpoint
// (a 1-byte prefix matching several candidate nodes) would force us to guess which node sent or
// received the packet, so only plot endpoints the backend resolved unambiguously.
export function packetChain(
source: ResolvedHop | null | undefined,
path: ResolvedHop[],
Expand Down
13 changes: 12 additions & 1 deletion src/features/map/useMapBordersData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ import { useQueries } from "@tanstack/react-query";
import type { Feature, FeatureCollection, Polygon, MultiPolygon } from "geojson";
import { getIataBorder, type IataBorder } from "../../api/client";

// Identity ids for border data objects, so the sig below reflects a real data change rather than a
// refetch timestamp (TanStack's structural sharing keeps unchanged geometry referentially equal).
const idsByData = new WeakMap<object, number>();
let nextId = 1;
function idOf(data: unknown): number {
if (data == null || typeof data !== "object") return 0;
let id = idsByData.get(data);
if (id == null) { id = nextId++; idsByData.set(data, id); }
return id;
}

export type BorderProps = { iata: string; [key: string]: unknown };
export type BorderFeatureCollection = FeatureCollection<Polygon | MultiPolygon, BorderProps>;

Expand Down Expand Up @@ -30,7 +41,7 @@ export function useMapBordersData(iataCodes: string[], enabled: boolean): Border
});

// Keep the collection stable between renders, but replace geometry after a successful refresh.
const sig = iataCodes.map((iata, i) => `${iata}:${results[i]?.data ? 1 : 0}:${results[i]?.dataUpdatedAt ?? 0}`).join("|");
const sig = iataCodes.map((iata, i) => `${iata}:${idOf(results[i]?.data)}`).join("|");
return useMemo(
() => mergeBorders(iataCodes.map((iata, i) => ({ iata, border: results[i]?.data ?? null }))),
// eslint-disable-next-line react-hooks/exhaustive-deps -- sig captures iataCodes + which borders loaded
Expand Down
15 changes: 9 additions & 6 deletions src/features/nodes/NodeDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getNode, getNodeObservations, getNodeNeighbors } from "../../api/client";
import { Badge } from "../../components/Badge";
import { DetailPanel, Section, Field } from "../../components/DetailPanel";
Expand All @@ -7,6 +8,7 @@ import { CopyLinkButton } from "../../components/CopyLinkButton";
import { IataChip } from "../../components/IataChip";
import { formatHex, formatSnr, snrLevel, formatRadio, formatClockDrift, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
import { Timestamp } from "../../components/Timestamp";
import { hasMapLocation } from "../map/location";
import type { NodeObservation, NodeNeighbor } from "./types";
import { ForeignNodeBadge } from "./ForeignNodeBadge";

Expand Down Expand Up @@ -77,6 +79,7 @@ interface NodeDetailPanelProps {
}

export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, onAnalyzePacket, onViewOnMap }: NodeDetailPanelProps) {
const { t } = useTranslation();
const { data: node, isLoading } = useQuery({
queryKey: ["node", nodeId],
queryFn: () => getNode(nodeId),
Expand All @@ -95,7 +98,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
staleTime: 30_000,
});

const hasLocation = node != null && node.lat != null && node.lng != null;
const hasLocation = hasMapLocation(node);

return (
<DetailPanel
Expand Down Expand Up @@ -142,17 +145,17 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
{(hasLocation || node.locationSource) && (
<Section title="Location">
<div className="flex flex-wrap gap-x-4 gap-y-0.5 font-mono text-[13px]">
{node.lat != null && <Field label="Lat" value={node.lat.toFixed(5)} />}
{node.lng != null && <Field label="Lng" value={node.lng.toFixed(5)} />}
{hasLocation && <Field label="Lat" value={node.lat!.toFixed(5)} />}
{hasLocation && <Field label="Lng" value={node.lng!.toFixed(5)} />}
{node.locationSource && <Field label="Source" value={node.locationSource} />}
</div>
{hasLocation && onViewOnMap && (
{hasMapLocation(node) && onViewOnMap && (
<button
type="button"
onClick={() => onViewOnMap(node.lat!, node.lng!)}
onClick={() => onViewOnMap(node.lat, node.lng)}
className="mt-2 block font-mono text-[11px] text-primary hover:underline"
>
View on map
{t("nodeDetail.viewOnMap")}
</button>
)}
</Section>
Expand Down
Loading
Loading