Translate the remaining hardcoded UI text (#129)

This commit is contained in:
MrAlders0n authored and GitHub committed 2026-10-01 17:55:54 -04:00
1 parent e3f98efea9
commit 609dbc384f
94 files changed
+2344 -548

No files matched your search

+4 -3
View File
@@ -29,12 +29,12 @@ function LiveBadge({ wsManager, compact = false }: { wsManager: WsManager; compa
if (status !== "connecting") return;
function update() {
const staleSec = Math.floor((Date.now() - wsManager.getLastEventTimestamp()) / 1000);
setStaleStr(staleSec > 60 ? `${Math.floor(staleSec / 60)}m` : `${staleSec}s`);
setStaleStr(staleSec > 60 ? t("timestamp.unit.m", { count: Math.floor(staleSec / 60) }) : t("timestamp.unit.s", { count: staleSec }));
}
update();
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, [status, wsManager]);
}, [status, wsManager, t]);
if (status === "connected") {
if (compact) return (
@@ -364,12 +364,13 @@ function ThemeOptions({ onPick }: { onPick?: () => void }) {
}
function GitHubLink() {
const { t } = useTranslation();
return (
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
aria-label={t("header.github")}
className="text-text-muted hover:text-text-normal transition-colors shrink-0"
>
<svg viewBox="0 0 16 16" width="18" height="18" fill="currentColor" aria-hidden="true">
+5 -2
View File
@@ -1,14 +1,17 @@
import { useTranslation } from "react-i18next";
// Shared dismiss control: a bold X with a hover background. Larger tap target on mobile, compact at md+.
export function CloseButton({ onClose, label = "Close", className }: {
export function CloseButton({ onClose, label, className }: {
onClose: () => void;
label?: string;
className?: string;
}) {
const { t } = useTranslation();
return (
<button
type="button"
onClick={onClose}
aria-label={label}
aria-label={label ?? t("common.close")}
className={`flex items-center justify-center w-9 h-9 md:w-7 md:h-7 rounded text-text-muted hover:text-text-bright hover:bg-text-normal/5 cursor-pointer transition-colors ${className ?? ""}`}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
+6 -2
View File
@@ -1,12 +1,13 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { VARIANT_CLASSES } from "./badge-utils";
// Copy-to-clipboard pill, styled to match the analyzer's "Copy Link" button: flips to a green
// "Copied" state for 1.5s after a click. aria-label defaults to the visible label.
export function CopyButton({
value,
label = "Copy",
copiedLabel = "Copied",
label,
copiedLabel,
ariaLabel,
className,
}: {
@@ -16,7 +17,10 @@ export function CopyButton({
ariaLabel?: string;
className?: string;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
label ??= t("common.copy");
copiedLabel ??= t("common.copied");
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(value);
+7 -4
View File
@@ -1,4 +1,5 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { VARIANT_CLASSES } from "./badge-utils";
// Copies a shareable deep link to the current page with the given query params set (built fresh from
@@ -7,8 +8,8 @@ import { VARIANT_CLASSES } from "./badge-utils";
// (e.g. the map camera) and use a null value to delete a key that's now at its default.
export function CopyLinkButton({
params,
label = "Copy link",
copiedLabel = "Copied",
label,
copiedLabel,
ariaLabel,
preserveParams,
}: {
@@ -18,7 +19,9 @@ export function CopyLinkButton({
ariaLabel?: string;
preserveParams?: readonly string[];
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const idleLabel = label ?? t("common.copyLink");
const handleCopy = useCallback(() => {
const url = new URL(window.location.href);
@@ -38,9 +41,9 @@ export function CopyLinkButton({
type="button"
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider uppercase cursor-pointer transition-colors ${copied ? VARIANT_CLASSES.live : VARIANT_CLASSES.text}`}
onClick={handleCopy}
aria-label={ariaLabel ?? label}
aria-label={ariaLabel ?? idleLabel}
>
{copied ? copiedLabel : label}
{copied ? copiedLabel ?? t("common.copied") : idleLabel}
</button>
);
}
+8 -5
View File
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CloseButton } from "./CloseButton";
// shared scaffolding for the right-hand entity detail panels (observers, nodes, …)
@@ -20,11 +21,12 @@ export function Field({ label, value }: { label: string; value: ReactNode }) {
// Minimize/expand toggle for the mobile overlay only; at md+ the panel is a sidebar so it's hidden.
export function MinimizeButton({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
const { t } = useTranslation();
return (
<button
type="button"
onClick={onToggle}
aria-label={collapsed ? "Expand detail panel" : "Minimize detail panel"}
aria-label={t(collapsed ? "common.expandDetailPanel" : "common.minimizeDetailPanel")}
className="md:hidden flex items-center justify-center w-9 h-9 rounded text-text-muted hover:text-text-bright hover:bg-text-normal/5 cursor-pointer transition-colors"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -50,7 +52,8 @@ interface DetailPanelProps {
children: ReactNode;
}
export function DetailPanel({ title, onClose, closeLabel = "Close detail panel", collapsible, isLoading, notFound, notFoundIcon, notFoundLabel = "Not found", headerAction, children }: DetailPanelProps) {
export function DetailPanel({ title, onClose, closeLabel, collapsible, isLoading, notFound, notFoundIcon, notFoundLabel, headerAction, children }: DetailPanelProps) {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState(false);
// collapse only touches the mobile overlay: shrink to a bottom bar and hide the body. The md:*
// classes below always win at desktop width, so a lingering collapsed state never hides the sidebar.
@@ -63,7 +66,7 @@ export function DetailPanel({ title, onClose, closeLabel = "Close detail panel",
{headerAction}
<div className="flex items-center gap-0.5">
{collapsible && <MinimizeButton collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />}
<CloseButton onClose={onClose} label={closeLabel} />
<CloseButton onClose={onClose} label={closeLabel ?? t("common.closeDetailPanel")} />
</div>
</div>
</div>
@@ -71,12 +74,12 @@ export function DetailPanel({ title, onClose, closeLabel = "Close detail panel",
<div className={`flex-1 overflow-y-auto min-h-0 ${minimized ? "hidden md:block" : ""}`}>
{isLoading ? (
<div className="flex flex-col items-center justify-center h-full gap-2.5 text-text-dim">
<span className="text-[13px] font-mono">Loading...</span>
<span className="text-[13px] font-mono">{t("common.loadingDots")}</span>
</div>
) : notFound ? (
<div className="flex flex-col items-center justify-center h-full gap-2.5 text-text-dim">
{notFoundIcon}
<span className="text-[13px] font-mono">{notFoundLabel}</span>
<span className="text-[13px] font-mono">{notFoundLabel ?? t("common.notFound")}</span>
</div>
) : (
children
+18 -12
View File
@@ -1,4 +1,5 @@
import { Component, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
interface Props {
children: ReactNode;
@@ -22,19 +23,24 @@ export class ErrorBoundary extends Component<Props, State> {
// React.lazy caches a rejected import, so remounting can't recover a failed chunk load
// (e.g. a deploy swapped the hashed assets out from under us) — only a real reload can
const chunkFailure = /dynamically imported module|Loading chunk|error loading/i.test(this.state.error.message);
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-8 text-text-dim">
<p className="text-sm font-mono">Something went wrong rendering this view.</p>
<button
type="button"
className="text-xs font-mono underline cursor-pointer text-primary"
onClick={() => (chunkFailure ? window.location.reload() : this.setState({ error: null }))}
>
{chunkFailure ? "reload" : "try again"}
</button>
</div>
);
return <ErrorFallback chunkFailure={chunkFailure} onRetry={() => this.setState({ error: null })} />;
}
return this.props.children;
}
}
function ErrorFallback({ chunkFailure, onRetry }: { chunkFailure: boolean; onRetry: () => void }) {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-8 text-text-dim">
<p className="text-sm font-mono">{t("errorBoundary.message")}</p>
<button
type="button"
className="text-xs font-mono underline cursor-pointer text-primary"
onClick={() => (chunkFailure ? window.location.reload() : onRetry())}
>
{chunkFailure ? t("errorBoundary.reload") : t("errorBoundary.retry")}
</button>
</div>
);
}
+17 -14
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { MultiSelectDropdown } from "./MultiSelectDropdown";
import { SearchBar, type SearchFieldOption } from "./SearchBar";
import { FilterSheet, FiltersButton } from "./FilterSheet";
@@ -10,11 +11,11 @@ interface FilterOption {
label: string;
}
const PACKET_SEARCH_FIELDS: SearchFieldOption[] = [
{ value: "hash", label: "Hash" },
{ value: "path", label: "Latest path" },
{ value: "payload", label: "Payload", disabled: true },
];
const PACKET_SEARCH_FIELDS = [
{ value: "hash", labelKey: "filters.field.hash" },
{ value: "path", labelKey: "filters.field.path" },
{ value: "payload", labelKey: "filters.field.payload", disabled: true },
] as const;
interface FilterBarProps {
typeOptions: FilterOption[];
@@ -57,7 +58,9 @@ export function FilterBar({
onSearchFieldChange,
onClear,
}: FilterBarProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const searchFields: SearchFieldOption[] = PACKET_SEARCH_FIELDS.map(({ labelKey, ...f }) => ({ ...f, label: t(labelKey) }));
const [sheetOpen, setSheetOpen] = useState(false);
// close the sheet when leaving mobile — derive during render, not in an effect
if (sheetOpen && !isMobile) setSheetOpen(false);
@@ -75,19 +78,19 @@ export function FilterBar({
// shared by the desktop inline bar and the mobile filter sheet
const controls = (fullWidth: boolean) => (
<>
<MultiSelectDropdown label="Types" options={typeOptions} selected={activeTypes} onChange={onTypesChange} align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label="Routes" options={routeOptions} selected={activeRoutes} onChange={onRoutesChange} align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label="Observers" options={observerOptions} selected={activeObservers} onChange={onObserversChange} searchable align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label={t("filters.types")} options={typeOptions} selected={activeTypes} onChange={onTypesChange} align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label={t("filters.routes")} options={routeOptions} selected={activeRoutes} onChange={onRoutesChange} align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label={t("filters.observers")} options={observerOptions} selected={activeObservers} onChange={onObserversChange} searchable align="right" fullWidth={fullWidth} />
{scopeOptions.length > 0 && (
<MultiSelectDropdown label="Scope" options={scopeOptions} selected={activeScopes} onChange={onScopesChange} align="right" fullWidth={fullWidth} />
<MultiSelectDropdown label={t("filters.scope")} options={scopeOptions} selected={activeScopes} onChange={onScopesChange} align="right" fullWidth={fullWidth} />
)}
</>
);
if (isMobile) {
return (
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base" role="toolbar" aria-label="Packet filters">
<SearchBar value={search} onChange={onSearchChange} fields={PACKET_SEARCH_FIELDS} field={searchField} onFieldChange={(f) => onSearchFieldChange(f as SearchField)} />
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base" role="toolbar" aria-label={t("filters.packetFilters")}>
<SearchBar value={search} onChange={onSearchChange} fields={searchFields} field={searchField} onFieldChange={(f) => onSearchFieldChange(f as SearchField)} />
<FiltersButton activeCount={activeCount} onClick={() => setSheetOpen(true)} />
{sheetOpen && (
<FilterSheet onClose={() => setSheetOpen(false)} onClear={hasFilters ? onClear : undefined}>
@@ -102,12 +105,12 @@ export function FilterBar({
<div
className="flex flex-wrap items-center gap-1.5 gap-y-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base"
role="toolbar"
aria-label="Packet filters"
aria-label={t("filters.packetFilters")}
>
<SearchBar
value={search}
onChange={onSearchChange}
fields={PACKET_SEARCH_FIELDS}
fields={searchFields}
field={searchField}
onFieldChange={(f) => onSearchFieldChange(f as SearchField)}
/>
@@ -122,7 +125,7 @@ export function FilterBar({
className="text-[11px] font-mono text-text-dim hover:text-danger px-1.5 py-0.5 cursor-pointer transition-colors"
onClick={onClear}
>
Clear
{t("common.clear")}
</button>
)}
</div>
+9 -6
View File
@@ -1,9 +1,11 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BottomSheet } from "./BottomSheet";
import { CloseButton } from "./CloseButton";
// Mobile filter trigger: funnel + active count, styled like a dropdown trigger.
export function FiltersButton({ activeCount, onClick }: { activeCount: number; onClick: () => void }) {
const { t } = useTranslation();
const active = activeCount > 0;
return (
<button
@@ -19,7 +21,7 @@ export function FiltersButton({ activeCount, onClick }: { activeCount: number; o
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M2 3h12l-4.5 5.5V13L6.5 11.5V8.5L2 3z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
</svg>
Filters
{t("common.filters")}
{active && (
<span className="text-[9px] px-1 rounded-sm bg-primary/15 min-w-[1ch] text-center">{activeCount}</span>
)}
@@ -33,11 +35,12 @@ export function FilterSheet({ onClose, onClear, children }: {
onClear?: () => void;
children: ReactNode;
}) {
const { t } = useTranslation();
return (
<BottomSheet onClose={onClose} label="Filters">
<BottomSheet onClose={onClose} label={t("common.filters")}>
<div className="flex items-center justify-between px-4 pb-2 shrink-0">
<span className="text-[13px] font-mono font-medium text-text-dim uppercase tracking-wider">Filters</span>
<CloseButton onClose={onClose} label="Close filters" className="-mr-1" />
<span className="text-[13px] font-mono font-medium text-text-dim uppercase tracking-wider">{t("common.filters")}</span>
<CloseButton onClose={onClose} label={t("common.closeFilters")} className="-mr-1" />
</div>
<div className="flex flex-col gap-3 px-4 py-2 overflow-y-auto">{children}</div>
@@ -49,7 +52,7 @@ export function FilterSheet({ onClose, onClear, children }: {
onClick={onClear}
className="text-xs font-mono text-text-dim hover:text-danger px-2 py-1.5 cursor-pointer transition-colors"
>
Clear all
{t("common.clearAll")}
</button>
) : (
<span />
@@ -59,7 +62,7 @@ export function FilterSheet({ onClose, onClear, children }: {
onClick={onClose}
className="text-xs font-mono font-medium text-primary border border-primary-dim bg-primary/6 rounded px-4 py-1.5 cursor-pointer transition-colors hover:bg-primary/10"
>
Done
{t("common.done")}
</button>
</div>
</BottomSheet>
+9 -4
View File
@@ -1,8 +1,12 @@
import { useTranslation } from "react-i18next";
type LoadingNoun = "nodes" | "routes" | "packets" | "messages" | "observers";
interface LoadingPillProps {
loading: boolean;
error?: boolean;
count: number; // rows loaded so far
noun: string; // plural entity name, e.g. "nodes" / "observers"
noun: LoadingNoun;
label?: string; // optional complete localized phrase
position?: string; // corner placement (the parent must be `relative`)
}
@@ -10,6 +14,7 @@ interface LoadingPillProps {
// Small floating status pill shared by the map and the entity tables: a muted "Loading … (N)" while
// pages stream in, a danger-toned message if a fetch fails. Renders nothing when idle.
export function LoadingPill({ loading, error, count, noun, label, position = "bottom-3 left-3" }: LoadingPillProps) {
const { t } = useTranslation();
if (!loading && !error) return null;
const tone = loading ? "text-text-muted" : "text-danger";
const dot = loading ? "bg-primary animate-pulse" : "bg-danger";
@@ -20,10 +25,10 @@ export function LoadingPill({ loading, error, count, noun, label, position = "bo
>
<span className={`size-1.5 rounded-full ${dot}`} aria-hidden />
{label ?? (loading
? `Loading ${noun}… (${count})`
? t(`loadingPill.${noun}.loading`, { count })
: count > 0
? `Some ${noun} failed to load (${count} shown)`
: `Failed to load ${noun}`)}
? t(`loadingPill.${noun}.partial`, { count })
: t(`loadingPill.${noun}.failed`))}
</div>
);
}
+6 -4
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useClickOutside } from "../hooks/useClickOutside";
interface Option {
@@ -19,6 +20,7 @@ interface MultiSelectDropdownProps {
// checkbox dropdown with optional search filter
export function MultiSelectDropdown({ label, options, selected, onChange, searchable, align = "left", fullWidth = false }: MultiSelectDropdownProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const ref = useRef<HTMLDivElement>(null);
@@ -92,7 +94,7 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter..."
placeholder={t("common.filterPlaceholder")}
className="w-full text-[11px] font-mono bg-bg-surface border border-border rounded px-2 py-1 text-text-bright placeholder:text-text-dim"
/>
</div>
@@ -106,7 +108,7 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search
}`}
onClick={() => onChange(options.map((o) => o.value))}
>
All
{t("common.all")}
</button>
<span className="text-border text-[11px]">·</span>
<button
@@ -116,7 +118,7 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search
}`}
onClick={() => onChange([])}
>
None
{t("common.none")}
</button>
</div>
@@ -158,7 +160,7 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search
);
})}
{filtered.length === 0 && (
<div className="px-2.5 py-2 text-[11px] font-mono text-text-dim">No matches</div>
<div className="px-2.5 py-2 text-[11px] font-mono text-text-dim">{t("common.noMatches")}</div>
)}
</div>
</div>
+3 -1
View File
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Dropdown } from "./Dropdown";
export interface SearchFieldOption {
@@ -20,6 +21,7 @@ interface SearchBarProps {
// debounced search input with field dropdown
export function SearchBar({ value, onChange, fields, field, onFieldChange, hideField = false, inputLabel }: SearchBarProps) {
const { t } = useTranslation();
const [localValue, setLocalValue] = useState(value);
const [prevValue, setPrevValue] = useState(value);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -112,7 +114,7 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange, hideF
value={localValue}
aria-label={inputLabel}
onChange={(e) => handleChange(e.target.value)}
placeholder={inputLabel ?? `Search by ${(currentField?.label ?? "").toLowerCase()}...`}
placeholder={inputLabel ?? t("search.placeholder", { field: (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 ? "rounded-sm text-[11px]" : "rounded-r-sm text-[11px]"}`}
/>
{localValue && (
+5 -2
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Dropdown } from "./Dropdown";
interface SelectOption {
@@ -18,7 +19,9 @@ interface SelectDropdownProps {
// single-select dropdown styled to match the packets MultiSelectDropdown trigger
export function SelectDropdown({ label, options, value, onChange, align = "right", allLabel = "All", hideAll = false, fullWidth = false }: SelectDropdownProps) {
export function SelectDropdown({ label, options, value, onChange, align = "right", allLabel: allLabelProp, hideAll = false, fullWidth = false }: SelectDropdownProps) {
const { t } = useTranslation();
const allLabel = allLabelProp ?? t("common.all");
const active = value !== "";
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
@@ -79,7 +82,7 @@ export function SelectDropdown({ label, options, value, onChange, align = "right
);
})}
{hideAll && options.length === 0 && (
<div className="px-2.5 py-1 text-xs font-mono text-text-dim">No options</div>
<div className="px-2.5 py-1 text-xs font-mono text-text-dim">{t("common.noOptions")}</div>
)}
</div>
)}
+3 -1
View File
@@ -6,6 +6,7 @@
// <SplashScreen /> line from src/App.tsx. Nothing else references it.
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BeaconLogo } from "./BeaconLogo";
import { SKIP_SPLASH } from "../lib/constants";
@@ -14,6 +15,7 @@ const VISIBLE_MS = 2000;
const FADE_MS = 400;
export function SplashScreen() {
const { t } = useTranslation();
// Synchronous gate: decided before first paint so StrictMode's double-mount
// (and any same-session reload) never re-shows it.
const [render, setRender] = useState(() => {
@@ -64,7 +66,7 @@ export function SplashScreen() {
className="text-text-muted text-xs tracking-[0.12em]"
style={{ fontFamily: "'Chakra Petch', sans-serif" }}
>
MeshCore Network Analyzer
{t("splash.tagline")}
</span>
</span>
</span>
+21 -22
View File
@@ -1,25 +1,11 @@
import { useState } from "react";
import { SearchBar, type SearchFieldOption } from "../../components/SearchBar";
import { useTranslation } from "react-i18next";
import { SearchBar } from "../../components/SearchBar";
import { SelectDropdown } from "../../components/SelectDropdown";
import { FilterSheet, FiltersButton } from "../../components/FilterSheet";
import { useIsMobile } from "../../hooks/useMediaQuery";
import type { ChannelKeyFilter, ChannelHashtagFilter } from "./channel-filters";
const SEARCH_FIELDS: SearchFieldOption[] = [
{ value: "name", label: "Name" },
{ value: "hash", label: "Hash" },
];
const KEY_OPTIONS = [
{ value: "known", label: "Known" },
{ value: "unknown", label: "Unknown" },
];
const HASHTAG_OPTIONS = [
{ value: "true", label: "Yes" },
{ value: "false", label: "No" },
];
interface ChannelFilterBarProps {
search: string;
onSearchChange: (v: string) => void;
@@ -41,7 +27,20 @@ export function ChannelFilterBar({
hashtagFilter,
onHashtagChange,
}: ChannelFilterBarProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const searchFields = [
{ value: "name", label: t("channels.fieldName") },
{ value: "hash", label: t("channels.fieldHash") },
];
const keyOptions = [
{ value: "known", label: t("channels.keyKnownOption") },
{ value: "unknown", label: t("channels.keyUnknownOption") },
];
const hashtagOptions = [
{ value: "true", label: t("channels.yes") },
{ value: "false", label: t("channels.no") },
];
const [sheetOpen, setSheetOpen] = useState(false);
// close the sheet when leaving mobile — derive during render, not in an effect
if (sheetOpen && !isMobile) setSheetOpen(false);
@@ -55,15 +54,15 @@ export function ChannelFilterBar({
// shared by the desktop inline bar and the mobile filter sheet
const controls = (fullWidth: boolean) => (
<>
<SelectDropdown label="Key" options={KEY_OPTIONS} allLabel="Any" value={keyFilter} onChange={(v) => onKeyChange(v as ChannelKeyFilter)} fullWidth={fullWidth} />
<SelectDropdown label="Hashtag" options={HASHTAG_OPTIONS} allLabel="Any" value={hashtagFilter} onChange={(v) => onHashtagChange(v as ChannelHashtagFilter)} fullWidth={fullWidth} />
<SelectDropdown label={t("channels.keyFilter")} options={keyOptions} allLabel={t("channels.any")} value={keyFilter} onChange={(v) => onKeyChange(v as ChannelKeyFilter)} fullWidth={fullWidth} />
<SelectDropdown label={t("channels.hashtagFilter")} options={hashtagOptions} allLabel={t("channels.any")} value={hashtagFilter} onChange={(v) => onHashtagChange(v as ChannelHashtagFilter)} fullWidth={fullWidth} />
</>
);
if (isMobile) {
return (
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base shrink-0" role="toolbar" aria-label="Channel filters">
<SearchBar value={search} onChange={onSearchChange} fields={SEARCH_FIELDS} field={searchField} onFieldChange={onSearchFieldChange} />
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base shrink-0" role="toolbar" aria-label={t("channels.filters")}>
<SearchBar value={search} onChange={onSearchChange} fields={searchFields} field={searchField} onFieldChange={onSearchFieldChange} />
<FiltersButton activeCount={activeCount} onClick={() => setSheetOpen(true)} />
{sheetOpen && (
<FilterSheet onClose={() => setSheetOpen(false)} onClear={activeCount > 0 ? clearAll : undefined}>
@@ -78,12 +77,12 @@ export function ChannelFilterBar({
<div
className="flex flex-wrap items-center gap-1.5 gap-y-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base shrink-0"
role="toolbar"
aria-label="Channel filters"
aria-label={t("channels.filters")}
>
<SearchBar
value={search}
onChange={onSearchChange}
fields={SEARCH_FIELDS}
fields={searchFields}
field={searchField}
onFieldChange={onSearchFieldChange}
/>
+7 -5
View File
@@ -1,4 +1,5 @@
import { useState, useCallback, useMemo, useEffect, useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useIsFetching, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import { getChannels } from "../../api/client";
import { isRateLimited, subscribeRateLimit } from "../../api/rate-limit";
@@ -33,6 +34,7 @@ function appendMessages(old: InfiniteData<CursorPage<ChannelMessage>> | undefine
}
export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
const { t } = useTranslation();
const { iatas, regionKey } = useRegion();
const isMobile = useIsMobile();
// Keep the open channel available when a directory page is evicted or refreshed.
@@ -251,18 +253,18 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) {
/>
)}
{!keyed.isLoading && filteredChannels.length === 0 && (
<p className="px-3 py-2 text-xs font-mono text-text-muted">No matching channels loaded.</p>
<p className="px-3 py-2 text-xs font-mono text-text-muted">{t("channels.noMatches")}</p>
)}
{(keyed.isError || others.isError) && <p role="alert" className="px-3 py-2 text-xs text-danger">Could not load channels.</p>}
{(keyed.isError || others.isError) && <p role="alert" className="px-3 py-2 text-xs text-danger">{t("channels.loadError")}</p>}
{keyed.isError ? (
<PagerButton disabled={keyed.isFetching} onClick={() => void keyed.refetch()}>
{keyed.isFetching ? "Loading channels..." : "Retry loading channels"}
{keyed.isFetching ? t("channels.loadingChannels") : t("channels.retryChannels")}
</PagerButton>
) : !others.isEnabled ? (
!keyed.isLoading && <PagerButton onClick={() => setOthersRegion(regionKey)}>Load other channels</PagerButton>
!keyed.isLoading && <PagerButton onClick={() => setOthersRegion(regionKey)}>{t("channels.loadOthers")}</PagerButton>
) : (others.hasNextPage || others.isError) && (
<PagerButton disabled={others.isFetching} onClick={() => void (others.hasNextPage ? others.fetchNextPage() : others.refetch())}>
{others.isFetching ? "Loading channels..." : others.isError ? "Retry loading channels" : "Load more channels"}
{others.isFetching ? t("channels.loadingChannels") : others.isError ? t("channels.retryChannels") : t("channels.loadMore")}
</PagerButton>
)}
</div>
+5 -3
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Badge } from "../../components/Badge";
import { Timestamp } from "../../components/Timestamp";
import { channelDisplayName } from "./types";
@@ -10,6 +11,7 @@ interface ChannelSidebarProps {
}
export function ChannelSidebar({ channels, selectedId, onSelect }: ChannelSidebarProps) {
const { t } = useTranslation();
return (
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col divide-y divide-border/40">
@@ -33,11 +35,11 @@ export function ChannelSidebar({ channels, selectedId, onSelect }: ChannelSideba
</div>
<div className="flex gap-1 mt-1">
{ch.keyKnown ? (
<Badge variant="advert">key</Badge>
<Badge variant="advert">{t("channels.key")}</Badge>
) : (
<Badge variant="offline">no key</Badge>
<Badge variant="offline">{t("channelMessages.noKey")}</Badge>
)}
{ch.isHashtag && <Badge variant="group">hashtag</Badge>}
{ch.isHashtag && <Badge variant="group">{t("channelMessages.hashtag")}</Badge>}
</div>
</button>
);
+3 -1
View File
@@ -41,7 +41,9 @@ function MessageRow({ msg, heardCount, onAnalyze }: { msg: ChannelMessage; heard
{msg.senderName}
</span>
<Timestamp value={msg.sentAt} className="text-[11px] text-text-dim" />
{reach > 0 && <Badge variant="text">×{reach}</Badge>}
{reach > 0 && <span title={t("channelMessages.heardTimes", { count: reach })}>
<Badge variant="text"><span aria-hidden>×{reach}</span><span className="sr-only">{t("channelMessages.heardTimes", { count: reach })}</span></Badge>
</span>}
{/* Unscoped is the default, so only a named or unresolved scope earns a chip. */}
{msg.scope ? <ScopeTag boxed className="max-w-full break-all">{msg.scope}</ScopeTag>
: msg.scopeStatus === "unknown" && <span className="rounded-sm bg-text-normal/5 px-1.5 py-px font-mono text-[11px] text-text-dim">{t("channelMessages.unknownScope")}</span>}
+30 -34
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { MapStyleSwitcher } from "./MapStyleSwitcher";
import { SegmentedControl } from "./SegmentedControl";
import { NODE_TYPE_FILTER_OPTIONS, type NeighborLinesMode } from "./types";
@@ -9,30 +10,16 @@ import { useIsMobile } from "../../hooks/useMediaQuery";
// Open/closed state persists across sessions; no click-outside dismiss, so it stays open while you pan.
const OPEN_STORAGE_KEY = "beacon-map-settings-open";
const TYPE_OPTIONS = [{ value: "", label: "All" }, ...NODE_TYPE_FILTER_OPTIONS];
const CLUSTER_OPTIONS = [
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
];
const NEIGHBOR_OPTIONS = [
{ value: "on", label: "On" },
{ value: "selected", label: "Selected" },
{ value: "off", label: "Off" },
];
const BORDER_OPTIONS = [
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
];
// Swatch matching the border layer paint (secondary line over a faint fill), so the legend tracks the theme.
function BorderLegend() {
const { t } = useTranslation();
return (
<div className="mt-2.5 flex items-center gap-1.5 text-[10px] text-text-dim">
<span
className="inline-block h-2.5 w-4 rounded-sm border"
style={{ borderColor: "var(--palette-secondary)", backgroundColor: "var(--palette-secondary)", opacity: 0.5 }}
/>
Area outline
{t("map.areaOutline")}
</div>
);
}
@@ -40,9 +27,10 @@ function BorderLegend() {
// Legend for a selected node's coloured edges. Gradient stops mirror the map paint's log anchors
// (red ~1, yellow ~20 at 60%, green ~150+); palette vars keep it in step with the active theme.
function NeighborLegend() {
const { t } = useTranslation();
return (
<div className="mt-2.5">
<div className="text-[10px] text-text-dim uppercase tracking-wider mb-1">Observations</div>
<div className="text-[10px] text-text-dim uppercase tracking-wider mb-1">{t("map.observations")}</div>
<div
className="h-2 rounded-sm border border-border-subtle"
style={{ background: "linear-gradient(to right, var(--palette-danger) 0%, var(--palette-warn) 60%, var(--palette-green) 100%)" }}
@@ -52,7 +40,7 @@ function NeighborLegend() {
<span className="absolute -translate-x-1/2" style={{ left: "60%" }}>20</span>
<span className="absolute right-0">150+</span>
</div>
<div className="text-[9px] text-text-dim mt-1">fainter = heard longer ago</div>
<div className="text-[9px] text-text-dim mt-1">{t("map.fainter")}</div>
</div>
);
}
@@ -85,7 +73,15 @@ export function MapSettingsPanel({
onBordersChange,
buildShareParams,
}: MapSettingsPanelProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const on = { value: "on", label: t("map.on") };
const off = { value: "off", label: t("map.off") };
const typeOptions = [
{ value: "", label: t("map.all") },
...NODE_TYPE_FILTER_OPTIONS.map((o) => ({ value: o.value, label: t(`nodeTypes.${o.value}`, { defaultValue: o.label }) })),
];
const neighborOptions = [on, { value: "selected", label: t("map.selected") }, off];
// collapsed by default on mobile (the card would cover the map); a saved preference still wins
const [open, setOpen] = useState(() => {
const stored = localStorage.getItem(OPEN_STORAGE_KEY);
@@ -117,48 +113,48 @@ export function MapSettingsPanel({
<circle cx="9" cy="11.5" r="1.7" fill="currentColor" />
<path d="M9 11.5h5M12 4.5h2" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
Map Settings
{t("map.settings")}
</span>
<span aria-hidden className="text-text-dim text-[9px]">{open ? "▾" : "▸"}</span>
</button>
{open && (
<div className="border-t border-border-subtle">
<Section title="Map Tiles" first>
<Section title={t("map.tiles")} first>
<MapStyleSwitcher styleId={styleId} onChange={onStyleChange} className="w-full" />
</Section>
<Section title="Node Type">
<Section title={t("map.nodeType")}>
<SegmentedControl
wrap
ariaLabel="Node type"
options={TYPE_OPTIONS}
ariaLabel={t("map.nodeTypeLabel")}
options={typeOptions}
value={typeFilter}
onChange={onTypeChange}
/>
</Section>
<Section title="Clustering">
<Section title={t("map.clustering")}>
<SegmentedControl
ariaLabel="Clustering"
options={CLUSTER_OPTIONS}
ariaLabel={t("map.clustering")}
options={[on, off]}
value={clustered ? "on" : "off"}
onChange={(v) => onClusteredChange(v === "on")}
className="w-full"
/>
</Section>
<Section title="Neighbor Lines">
<Section title={t("map.neighborLines")}>
<SegmentedControl
ariaLabel="Neighbor lines"
options={NEIGHBOR_OPTIONS}
ariaLabel={t("map.neighborLinesLabel")}
options={neighborOptions}
value={neighborLines}
onChange={(v) => onNeighborLinesChange(v as NeighborLinesMode)}
className="w-full"
/>
{neighborLines === "selected" && <NeighborLegend />}
</Section>
<Section title="Area Borders">
<Section title={t("map.areaBorders")}>
<SegmentedControl
ariaLabel="Area borders"
options={BORDER_OPTIONS}
ariaLabel={t("map.areaBordersLabel")}
options={[on, off]}
value={borders ? "on" : "off"}
onChange={(v) => onBordersChange(v === "on")}
className="w-full"
@@ -168,8 +164,8 @@ export function MapSettingsPanel({
<div className="px-3 py-2.5 border-t border-border-subtle flex justify-end">
<CopyLinkButton
params={buildShareParams}
label="Copy map link"
ariaLabel="Copy a link to this map view"
label={t("map.copyLink")}
ariaLabel={t("map.copyLinkLabel")}
/>
</div>
</div>
+4 -2
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { MAP_STYLES } from "./types";
import { SegmentedControl } from "./SegmentedControl";
@@ -8,10 +9,11 @@ interface MapStyleSwitcherProps {
}
export function MapStyleSwitcher({ styleId, onChange, className }: MapStyleSwitcherProps) {
const { t } = useTranslation();
return (
<SegmentedControl
ariaLabel="Map style"
options={MAP_STYLES.map((s) => ({ value: s.id, label: s.name }))}
ariaLabel={t("map.style")}
options={MAP_STYLES.map((s) => ({ value: s.id, label: t(`map.styles.${s.id}`, { defaultValue: s.name }) }))}
value={styleId}
onChange={onChange}
className={className}
+3 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import "maplibre-gl/dist/maplibre-gl.css";
import { useMapLibre } from "./useMapLibre";
@@ -37,6 +38,7 @@ interface MapViewProps {
}
export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProps) {
const { t } = useTranslation();
// Deep-link params, read once at mount (like the region's ?iata seed). Each setting below is seeded
// URL -> localStorage -> default; the URL wins for this session but is never written back to
// localStorage, so a shared link can't clobber the visitor's saved prefs.
@@ -235,7 +237,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp
{error && (
// z-20 so the failure overlay covers the settings card (z-10) instead of it floating on top
<div className="absolute inset-0 z-20 bg-bg-base">
<EmptyState title="Map failed to load" subtitle="Check your connection and reload" />
<EmptyState title={t("map.loadFailed")} subtitle={t("map.loadFailedHint")} />
</div>
)}
</div>
+7 -3
View File
@@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
interface PacketFlowButtonProps {
active: boolean;
onToggle: () => void;
@@ -7,13 +9,15 @@ interface PacketFlowButtonProps {
// border either way so it reads as a control, not decoration. Bottom-center, clear of the corner
// map controls. Labelled "Live Map" to distinguish it from the header's LIVE websocket badge.
export function PacketFlowButton({ active, onToggle }: PacketFlowButtonProps) {
const { t } = useTranslation();
const label = active ? t("map.stopFlow") : t("map.playFlow");
return (
<button
type="button"
onClick={onToggle}
aria-pressed={active}
aria-label={active ? "Stop live map packet flow" : "Play live map packet flow"}
title={active ? "Stop live map packet flow" : "Play live map packet flow"}
aria-label={label}
title={label}
className={`absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 px-3 py-1.5 rounded-full border shadow-lg font-mono text-[11px] uppercase tracking-wider transition-colors cursor-pointer ${
active
? "bg-primary/15 border-primary text-text-bright"
@@ -30,7 +34,7 @@ export function PacketFlowButton({ active, onToggle }: PacketFlowButtonProps) {
<path d="M2 1.5v7l6-3.5z" />
</svg>
)}
Live Map
{t("map.liveMap")}
</button>
);
}
+2
View File
@@ -13,6 +13,7 @@ import type { Point } from "geojson";
import type { PacketPath } from "./packet-path";
import { packetPathsToFeatures } from "./packet-path";
import { resolveMapStyle, DEFAULT_CENTER, DEFAULT_ZOOM, IATA_ZOOM } from "./types";
import { mapLocale } from "./useMapLibre";
// Private ids — this map instance is dedicated to the popup, so they can't collide with the main map.
const LINE_SOURCE = "pp-lines";
@@ -45,6 +46,7 @@ export function PacketPathMap({ paths, selectedKey, styleId }: {
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
attributionControl: false,
locale: mapLocale(),
});
mapRef.current = map;
map.addControl(new NavigationControl({ showCompass: false }), "top-right");
+15
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { Map as MapLibreMap, NavigationControl, ScaleControl, AttributionControl, LngLatBounds } from "maplibre-gl";
import type { RasterDEMSourceSpecification } from "maplibre-gl";
import "./maplibre-worker";
import i18n from "../../i18n";
import {
DEM_TILES,
DEM_ATTRIBUTION,
@@ -16,6 +17,19 @@ import {
resolveMapStyle,
} from "./types";
// MapLibre reads its control tooltips once at construction, so a language switch applies on next mount.
export function mapLocale(): Record<string, string> {
return {
"AttributionControl.ToggleAttribution": i18n.t("map.controls.toggleAttribution"),
"AttributionControl.MapFeedback": i18n.t("map.controls.mapFeedback"),
"Map.Title": i18n.t("map.controls.map"),
"NavigationControl.ResetBearing": i18n.t("map.controls.resetBearing"),
"NavigationControl.ZoomIn": i18n.t("map.controls.zoomIn"),
"NavigationControl.ZoomOut": i18n.t("map.controls.zoomOut"),
"Popup.Close": i18n.t("map.controls.closePopup"),
};
}
// serialized fit target, so the fit effect can skip redundant re-fits
const fitKey = (points: [number, number][] | null) =>
points && points.length ? points.map((p) => `${p[0]},${p[1]}`).join(";") : null;
@@ -114,6 +128,7 @@ export function useMapLibre(
bearing: DEFAULT_BEARING,
maxPitch: MAX_PITCH,
attributionControl: false, // replaced below with a compact (always-collapsed) control
locale: mapLocale(),
});
mapRef.current = map;
lastStyleIdRef.current = styleIdRef.current;
+4 -2
View File
@@ -1,11 +1,13 @@
import { useTranslation } from "react-i18next";
import { Badge } from "../../components/Badge";
import { Tooltip } from "../../components/Tooltip";
export function ForeignNodeBadge({ possiblyForeign }: { possiblyForeign?: boolean }) {
const { t } = useTranslation();
if (possiblyForeign !== true) return null;
return (
<Tooltip label="This node's position is outside this server's local area.">
<Badge variant="default">Possibly foreign</Badge>
<Tooltip label={t("nodes.possiblyForeignHint")}>
<Badge variant="default">{t("nodes.possiblyForeign")}</Badge>
</Tooltip>
);
}
+30 -28
View File
@@ -13,6 +13,7 @@ import type { NodeObservation, NodeNeighbor } from "./types";
import { ForeignNodeBadge } from "./ForeignNodeBadge";
function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) {
const { t } = useTranslation();
return (
<div
className={`bg-bg-base border border-border rounded px-3 py-2 ${onClick ? "cursor-pointer hover:bg-text-normal/3" : ""}`}
@@ -29,13 +30,14 @@ function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClic
<div className="font-mono text-[11px] text-text-muted mt-1 flex items-center gap-2">
<span className="truncate" title={neighbor.publicKey}>{neighbor.publicKey}</span>
<span className="shrink-0 text-text-dim">·</span>
<span className="shrink-0">{neighbor.observationCount.toLocaleString()} obs</span>
<span className="shrink-0">{t("nodeDetail.observations", { count: neighbor.observationCount, formatted: neighbor.observationCount.toLocaleString() })}</span>
</div>
</div>
);
}
function NodeObservationRow({ obs, onClick }: { obs: NodeObservation; onClick?: () => void }) {
const { t } = useTranslation();
const level = snrLevel(obs.snr);
return (
<div
@@ -61,7 +63,7 @@ function NodeObservationRow({ obs, onClick }: { obs: NodeObservation; onClick?:
</span>
</div>
<div className="flex flex-col">
<span className="text-text-dim text-[10px] font-medium uppercase tracking-wider">Hops</span>
<span className="text-text-dim text-[10px] font-medium uppercase tracking-wider">{t("nodeDetail.hops")}</span>
<span className="font-medium text-text-normal">{obs.hopCount ?? "—"}</span>
</div>
</div>
@@ -102,13 +104,13 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
return (
<DetailPanel
title="Node Detail"
title={t("nodeDetail.title")}
onClose={onClose}
collapsible
headerAction={<CopyLinkButton params={{ tab: "Nodes", node: nodeId }} ariaLabel="Copy node link" />}
headerAction={<CopyLinkButton params={{ tab: "Nodes", node: nodeId }} ariaLabel={t("nodeDetail.copyLink")} />}
isLoading={isLoading}
notFound={!node}
notFoundLabel="Node not found"
notFoundLabel={t("nodeDetail.notFound")}
notFoundIcon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" className="text-border">
<circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="1.2" />
@@ -117,7 +119,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
>
{node && (
<>
<Section title="Summary" first>
<Section title={t("nodeDetail.summary")} first>
<div className="flex flex-wrap items-center gap-2 mb-2">
<span className={`font-mono text-xs font-semibold tracking-wider ${node.name ? "text-primary" : "text-text-dim italic"}`}>
{node.name ?? formatHex(node.id)}
@@ -129,7 +131,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
<div className="font-mono text-[13px] text-text-muted truncate min-w-0 flex-1" title={node.publicKey}>
{node.publicKey}
</div>
<CopyButton value={node.publicKey} ariaLabel="Copy public key" className="shrink-0" />
<CopyButton value={node.publicKey} ariaLabel={t("nodeDetail.copyKey")} className="shrink-0" />
</div>
{node.observerId && (
<button
@@ -137,17 +139,17 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
onClick={() => onViewObserver(node.observerId!)}
className="mt-2 block font-mono text-[11px] text-primary hover:underline"
>
View observer →
{t("nodeDetail.viewObserver")}
</button>
)}
</Section>
{(hasLocation || node.locationSource) && (
<Section title="Location">
<Section title={t("nodeDetail.location")}>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 font-mono text-[13px]">
{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} />}
{hasLocation && <Field label={t("nodeDetail.lat")} value={node.lat!.toFixed(5)} />}
{hasLocation && <Field label={t("nodeDetail.lng")} value={node.lng!.toFixed(5)} />}
{node.locationSource && <Field label={t("nodeDetail.source")} value={node.locationSource} />}
</div>
{hasMapLocation(node) && onViewOnMap && (
<button
@@ -161,31 +163,31 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
</Section>
)}
<Section title="Capabilities">
<Section title={t("nodeDetail.capabilities")}>
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
{node.minFirmwareVersion && <Field label="Min firmware" value={node.minFirmwareVersion} />}
<Field label="Multi-byte paths" value={node.supportsMultibytePaths ? "yes" : "no"} />
<Field label="Multi-byte traces" value={node.supportsMultibyteTraces ? "yes" : "no"} />
{node.radio && <Field label="Radio" value={formatRadio(node.radio) ?? "—"} />}
{node.defaultScope && <Field label="Scope" value={node.defaultScope} />}
{node.minFirmwareVersion && <Field label={t("nodeDetail.minFirmware")} value={node.minFirmwareVersion} />}
<Field label={t("nodeDetail.multibytePaths")} value={node.supportsMultibytePaths ? t("nodeDetail.yes") : t("nodeDetail.no")} />
<Field label={t("nodeDetail.multibyteTraces")} value={node.supportsMultibyteTraces ? t("nodeDetail.yes") : t("nodeDetail.no")} />
{node.radio && <Field label={t("nodeDetail.radio")} value={formatRadio(node.radio) ?? "—"} />}
{node.defaultScope && <Field label={t("nodeDetail.scope")} value={node.defaultScope} />}
</div>
</Section>
<Section title="Timestamps">
<Section title={t("nodeDetail.timestamps")}>
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 font-mono text-[13px]">
<Field label="First" value={<Timestamp value={node.firstSeen} />} />
<Field label="Last" value={<Timestamp value={node.lastSeen} />} />
{node.lastAdvertAt != null && <Field label="Advert" value={<Timestamp value={node.lastAdvertAt} />} />}
<Field label={t("nodeDetail.first")} value={<Timestamp value={node.firstSeen} />} />
<Field label={t("nodeDetail.last")} value={<Timestamp value={node.lastSeen} />} />
{node.lastAdvertAt != null && <Field label={t("nodeDetail.advert")} value={<Timestamp value={node.lastAdvertAt} />} />}
{node.clockDriftSeconds != null && (
<Field
label="Clock drift"
value={<span className={node.clockOutOfSync ? "text-warn" : "text-green"}>{formatClockDrift(node.clockDriftSeconds)}</span>}
label={t("nodeDetail.clockDrift")}
value={<span className={node.clockOutOfSync ? "text-warn" : "text-green"}>{formatClockDrift(node.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") })}</span>}
/>
)}
</div>
</Section>
<Section title={node.knownNeighborCount > 0 ? `Neighbors (${node.knownNeighborCount})` : "Neighbors"}>
<Section title={node.knownNeighborCount > 0 ? t("nodeDetail.neighborsCount", { count: node.knownNeighborCount }) : t("nodeDetail.neighbors")}>
{neighbors && neighbors.length > 0 ? (
<div className="flex flex-col gap-1.5">
{neighbors.map((n) => (
@@ -198,11 +200,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
))}
</div>
) : (
<div className="font-mono text-[13px] text-text-dim">No known neighbors</div>
<div className="font-mono text-[13px] text-text-dim">{t("nodeDetail.noNeighbors")}</div>
)}
</Section>
<Section title="Recent packets">
<Section title={t("nodeDetail.recentPackets")}>
{observations && observations.items.length > 0 ? (
<div className="flex flex-col gap-1.5">
{observations.items.map((obs) => (
@@ -214,7 +216,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
))}
</div>
) : (
<div className="font-mono text-[13px] text-text-dim">No recent packets</div>
<div className="font-mono text-[13px] text-text-dim">{t("nodeDetail.noRecentPackets")}</div>
)}
</Section>
</>
+20 -18
View File
@@ -1,20 +1,11 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SearchBar, type SearchFieldOption } from "../../components/SearchBar";
import { SelectDropdown } from "../../components/SelectDropdown";
import { FilterSheet, FiltersButton } from "../../components/FilterSheet";
import { useIsMobile } from "../../hooks/useMediaQuery";
import { NODE_TYPE_OPTIONS } from "../../lib/node-types";
const MULTIBYTE_OPTIONS = [
{ value: "true", label: "Yes" },
{ value: "false", label: "No" },
];
const SEARCH_FIELDS: SearchFieldOption[] = [
{ value: "name", label: "Name" },
{ value: "pubkey", label: "Public Key" },
];
// "" means no filter (Any)
export type MultibyteFilter = "" | "true" | "false";
@@ -49,6 +40,7 @@ export function NodeFilterBar({
onScopeChange,
scopeOptions,
}: NodeFilterBarProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const [sheetOpen, setSheetOpen] = useState(false);
// close the sheet when leaving mobile — derive during render, not in an effect
@@ -62,22 +54,32 @@ export function NodeFilterBar({
onScopeChange("");
};
const multibyteOptions = [
{ value: "true", label: t("nodeFilters.yes") },
{ value: "false", label: t("nodeFilters.no") },
];
const typeOptions = NODE_TYPE_OPTIONS.map((o) => ({ ...o, label: t(`nodeTypes.${o.value}`, { defaultValue: o.label }) }));
const searchFields: SearchFieldOption[] = [
{ value: "name", label: t("nodeFilters.fieldName") },
{ value: "pubkey", label: t("nodeFilters.fieldPubkey") },
];
// shared by the desktop inline bar and the mobile filter sheet
const controls = (fullWidth: boolean) => (
<>
<SelectDropdown label="Type" options={NODE_TYPE_OPTIONS} value={typeFilter} onChange={onTypeChange} fullWidth={fullWidth} />
<SelectDropdown label="Multi-byte paths" options={MULTIBYTE_OPTIONS} allLabel="Any" value={pathsFilter} onChange={(v) => onPathsChange(v as MultibyteFilter)} fullWidth={fullWidth} />
<SelectDropdown label="Multi-byte traces" options={MULTIBYTE_OPTIONS} allLabel="Any" value={tracesFilter} onChange={(v) => onTracesChange(v as MultibyteFilter)} fullWidth={fullWidth} />
<SelectDropdown label={t("nodeFilters.type")} options={typeOptions} allLabel={t("nodeFilters.all")} value={typeFilter} onChange={onTypeChange} fullWidth={fullWidth} />
<SelectDropdown label={t("nodeFilters.multibytePaths")} options={multibyteOptions} allLabel={t("nodeFilters.any")} value={pathsFilter} onChange={(v) => onPathsChange(v as MultibyteFilter)} fullWidth={fullWidth} />
<SelectDropdown label={t("nodeFilters.multibyteTraces")} options={multibyteOptions} allLabel={t("nodeFilters.any")} value={tracesFilter} onChange={(v) => onTracesChange(v as MultibyteFilter)} fullWidth={fullWidth} />
{scopeOptions.length > 0 && (
<SelectDropdown label="Scope" options={scopeOptions.map((s) => ({ value: s, label: s }))} allLabel="Any" value={scopeFilter} onChange={onScopeChange} fullWidth={fullWidth} />
<SelectDropdown label={t("nodeFilters.scope")} options={scopeOptions.map((s) => ({ value: s, label: s }))} allLabel={t("nodeFilters.any")} value={scopeFilter} onChange={onScopeChange} fullWidth={fullWidth} />
)}
</>
);
if (isMobile) {
return (
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base shrink-0" role="toolbar" aria-label="Node filters">
<SearchBar value={search} onChange={onSearchChange} fields={SEARCH_FIELDS} field={searchField} onFieldChange={onSearchFieldChange} />
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base shrink-0" role="toolbar" aria-label={t("nodeFilters.label")}>
<SearchBar value={search} onChange={onSearchChange} fields={searchFields} field={searchField} onFieldChange={onSearchFieldChange} />
<FiltersButton activeCount={activeCount} onClick={() => setSheetOpen(true)} />
{sheetOpen && (
<FilterSheet onClose={() => setSheetOpen(false)} onClear={activeCount > 0 ? clearAll : undefined}>
@@ -92,12 +94,12 @@ export function NodeFilterBar({
<div
className="flex flex-wrap items-center gap-1.5 gap-y-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base shrink-0"
role="toolbar"
aria-label="Node filters"
aria-label={t("nodeFilters.label")}
>
<SearchBar
value={search}
onChange={onSearchChange}
fields={SEARCH_FIELDS}
fields={searchFields}
field={searchField}
onFieldChange={onSearchFieldChange}
/>
+76 -69
View File
@@ -19,6 +19,7 @@ import { NodeFilterBar, type MultibyteFilter } from "./NodeFilterBar";
import { nodeSearchParams } from "./node-search";
import { patchNodeSummary } from "./node-updates";
import { ForeignNodeBadge } from "./ForeignNodeBadge";
import type { TFunction } from "i18next";
import type { NodeSummary, NodeIATA } from "./types";
import type { CursorPage } from "../../types/api";
import type { WsManager } from "../../api/ws-manager";
@@ -26,8 +27,7 @@ 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.
// Cell and card renderers can't call hooks, so the computed "last heard" label gets its own component.
function IataBadge({ entry }: { entry: NodeIATA }) {
const { t } = useTranslation();
const { count, unit } = timeAgoParts(entry.lastHeard);
@@ -46,67 +46,7 @@ interface NodeTableProps {
onSelectNode: (id: string | null) => void;
}
const COLUMNS: Column<NodeSummary>[] = [
{
header: "Name",
sortValue: (node) => node.name ?? formatHex(node.id),
cell: (node) => (
<span className={`truncate ${node.name ? "text-text-normal" : "text-text-dim italic"}`}>
{node.name ?? formatHex(node.id)}
</span>
),
},
{
header: "Type",
sortValue: (node) => node.nodeTypeName,
cell: (node) => (
<div className="flex flex-wrap gap-1">
<Badge variant="default">
{node.isObserver && (
<Tooltip label="Observer" className="mr-1"><ObserverIcon /></Tooltip>
)}
{node.nodeTypeName}
</Badge>
<ForeignNodeBadge possiblyForeign={node.possiblyForeign} />
</div>
),
},
{
header: "Radio",
className: "text-text-muted",
sortValue: (node) => formatRadio(node.radio) ?? null,
cell: (node) => formatRadio(node.radio) ?? "—",
},
{
header: "Areas",
cell: (node) =>
node.iatas && node.iatas.length > 0 ? (
<div className="flex flex-wrap gap-1">
{node.iatas.map((entry) => (
<IataBadge key={entry.iata} entry={entry} />
))}
</div>
) : (
<span className="text-text-dim">—</span>
),
},
{
header: "Neighbors",
className: "text-text-muted",
sortValue: (node) => node.knownNeighborCount,
cell: (node) => node.knownNeighborCount.toLocaleString(),
},
{
header: "Location",
className: "text-text-muted",
cell: (node) =>
hasMapLocation(node)
? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}`
: "—",
},
];
function renderNodeCard(node: NodeSummary) {
function renderNodeCard(node: NodeSummary, t: TFunction) {
const location = hasMapLocation(node)
? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}`
: null;
@@ -119,7 +59,7 @@ function renderNodeCard(node: NodeSummary) {
<span className="shrink-0">
<Badge variant="default">
{node.isObserver && (
<Tooltip label="Observer" className="mr-1"><ObserverIcon /></Tooltip>
<Tooltip label={t("nodes.observer")} className="mr-1"><ObserverIcon /></Tooltip>
)}
{node.nodeTypeName}
</Badge>
@@ -128,7 +68,7 @@ function renderNodeCard(node: NodeSummary) {
<div className="flex items-center gap-2 text-text-muted">
<span>{formatRadio(node.radio) ?? "—"}</span>
{location && <span>· {location}</span>}
{node.knownNeighborCount > 0 && <span>· {node.knownNeighborCount.toLocaleString()} neighbors</span>}
{node.knownNeighborCount > 0 && <span>· {t("nodes.neighbors", { count: node.knownNeighborCount, formatted: node.knownNeighborCount.toLocaleString() })}</span>}
</div>
<ForeignNodeBadge possiblyForeign={node.possiblyForeign} />
{node.iatas && node.iatas.length > 0 && (
@@ -143,6 +83,7 @@ function renderNodeCard(node: NodeSummary) {
}
export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTableProps) {
const { t } = useTranslation();
const { iatas, regionKey } = useRegion();
const queryClient = useQueryClient();
const [typeFilter, setTypeFilter] = useState("");
@@ -152,6 +93,72 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable
const [search, setSearch] = useState("");
const [searchField, setSearchField] = useState("name");
const columns = useMemo<Column<NodeSummary>[]>(() => [
{
id: "name",
header: t("nodes.colName"),
sortValue: (node) => node.name ?? formatHex(node.id),
cell: (node) => (
<span className={`truncate ${node.name ? "text-text-normal" : "text-text-dim italic"}`}>
{node.name ?? formatHex(node.id)}
</span>
),
},
{
id: "type",
header: t("nodes.colType"),
sortValue: (node) => node.nodeTypeName,
cell: (node) => (
<div className="flex flex-wrap gap-1">
<Badge variant="default">
{node.isObserver && (
<Tooltip label={t("nodes.observer")} className="mr-1"><ObserverIcon /></Tooltip>
)}
{node.nodeTypeName}
</Badge>
<ForeignNodeBadge possiblyForeign={node.possiblyForeign} />
</div>
),
},
{
id: "radio",
header: t("nodes.colRadio"),
className: "text-text-muted",
sortValue: (node) => formatRadio(node.radio) ?? null,
cell: (node) => formatRadio(node.radio) ?? "—",
},
{
id: "areas",
header: t("nodes.colAreas"),
cell: (node) =>
node.iatas && node.iatas.length > 0 ? (
<div className="flex flex-wrap gap-1">
{node.iatas.map((entry) => (
<IataBadge key={entry.iata} entry={entry} />
))}
</div>
) : (
<span className="text-text-dim">—</span>
),
},
{
id: "neighbors",
header: t("nodes.colNeighbors"),
className: "text-text-muted",
sortValue: (node) => node.knownNeighborCount,
cell: (node) => node.knownNeighborCount.toLocaleString(),
},
{
id: "location",
header: t("nodes.colLocation"),
className: "text-text-muted",
cell: (node) =>
hasMapLocation(node)
? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}`
: "—",
},
], [t]);
useTick();
// switching the field flips what the box means (a name vs a hex prefix), so stale text mustn't carry over
@@ -227,15 +234,15 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable
/>
<DataTable
columns={COLUMNS}
columns={columns}
rows={displayNodes}
rowKey={(n) => n.id}
selectedKey={selectedNodeId}
onSelect={onSelectNode}
isLoading={isLoading}
emptyLabel="No nodes"
defaultSort={{ header: "Name" }}
renderCard={renderNodeCard}
emptyLabel={t("nodes.empty")}
defaultSort={{ id: "name" }}
renderCard={(node) => renderNodeCard(node, t)}
/>
<LoadingPill loading={isPaging} error={isError} count={loadedCount} noun="nodes" position="bottom-3 right-3" />
</div>
+15 -17
View File
@@ -1,18 +1,10 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { SearchBar, type SearchFieldOption } from "../../components/SearchBar";
import { SelectDropdown } from "../../components/SelectDropdown";
import { FilterSheet, FiltersButton } from "../../components/FilterSheet";
import { useIsMobile } from "../../hooks/useMediaQuery";
const STATUS_OPTIONS = [
{ value: "online", label: "Online" },
{ value: "offline", label: "Offline" },
];
const SEARCH_FIELDS: SearchFieldOption[] = [
{ value: "name", label: "Name" },
];
interface ObserverFilterBarProps {
search: string;
onSearchChange: (v: string) => void;
@@ -50,7 +42,13 @@ export function ObserverFilterBar({
scopeOptions,
trailing,
}: ObserverFilterBarProps) {
const { t } = useTranslation();
const isMobile = useIsMobile();
const statusOptions = [
{ value: "online", label: t("observerFilters.online") },
{ value: "offline", label: t("observerFilters.offline") },
];
const searchFields: SearchFieldOption[] = [{ value: "name", label: t("observerFilters.name") }];
const [sheetOpen, setSheetOpen] = useState(false);
// close the sheet when leaving mobile — derive during render, not in an effect
if (sheetOpen && !isMobile) setSheetOpen(false);
@@ -66,23 +64,23 @@ export function ObserverFilterBar({
// shared by the desktop inline bar and the mobile filter sheet
const controls = (fullWidth: boolean) => (
<>
<SelectDropdown label="Status" options={STATUS_OPTIONS} value={statusFilter} onChange={onStatusChange} fullWidth={fullWidth} />
<SelectDropdown label={t("observerFilters.status")} options={statusOptions} value={statusFilter} onChange={onStatusChange} fullWidth={fullWidth} />
{typeOptions.length > 0 && (
<SelectDropdown label="Type" options={typeOptions.map((t) => ({ value: t, label: t }))} value={typeFilter} onChange={onTypeChange} fullWidth={fullWidth} />
<SelectDropdown label={t("observerFilters.type")} options={typeOptions.map((o) => ({ value: o, label: o }))} value={typeFilter} onChange={onTypeChange} fullWidth={fullWidth} />
)}
{brokerOptions.length > 0 && (
<SelectDropdown label="Broker" options={brokerOptions.map((b) => ({ value: b, label: b }))} value={brokerFilter} onChange={onBrokerChange} fullWidth={fullWidth} />
<SelectDropdown label={t("observerFilters.broker")} options={brokerOptions.map((b) => ({ value: b, label: b }))} value={brokerFilter} onChange={onBrokerChange} fullWidth={fullWidth} />
)}
{scopeOptions.length > 0 && (
<SelectDropdown label="Scope" options={scopeOptions.map((s) => ({ value: s, label: s }))} value={scopeFilter} onChange={onScopeChange} fullWidth={fullWidth} />
<SelectDropdown label={t("observerFilters.scope")} options={scopeOptions.map((s) => ({ value: s, label: s }))} value={scopeFilter} onChange={onScopeChange} fullWidth={fullWidth} />
)}
</>
);
if (isMobile) {
return (
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base shrink-0" role="toolbar" aria-label="Observer filters">
<SearchBar value={search} onChange={onSearchChange} fields={SEARCH_FIELDS} field={searchField} onFieldChange={onSearchFieldChange} />
<div className="flex items-center gap-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base shrink-0" role="toolbar" aria-label={t("observerFilters.label")}>
<SearchBar value={search} onChange={onSearchChange} fields={searchFields} field={searchField} onFieldChange={onSearchFieldChange} />
<FiltersButton activeCount={activeCount} onClick={() => setSheetOpen(true)} />
{trailing}
{sheetOpen && (
@@ -98,12 +96,12 @@ export function ObserverFilterBar({
<div
className="flex flex-wrap items-center gap-1.5 gap-y-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base shrink-0"
role="toolbar"
aria-label="Observer filters"
aria-label={t("observerFilters.label")}
>
<SearchBar
value={search}
onChange={onSearchChange}
fields={SEARCH_FIELDS}
fields={searchFields}
field={searchField}
onFieldChange={onSearchFieldChange}
/>
+7 -5
View File
@@ -4,6 +4,7 @@ import { formatSnr, snrLevel, formatPropagation, SIGNAL_LEVEL_CLASSES, SIGNAL_LE
import { Timestamp } from "../../components/Timestamp";
import { PathData } from "./PathData";
import { IataChip } from "../../components/IataChip";
import { useTranslation } from "react-i18next";
// single observation with signal stats and resolved path
@@ -13,6 +14,7 @@ export function ObservationCard({ observation: obs, selected, onClick, onViewNod
selectLabel?: string;
actions?: ReactNode;
}) {
const { t } = useTranslation();
const level = snrLevel(obs.snr);
return (
@@ -55,18 +57,18 @@ export function ObservationCard({ observation: obs, selected, onClick, onViewNod
</span>
</div>
<div className="flex flex-col min-w-0">
<span className="text-text-dim text-[10px] font-medium uppercase tracking-wider">Prop</span>
<span className="text-text-dim text-[10px] font-medium uppercase tracking-wider">{t("observation.prop")}</span>
<span className="font-medium text-text-normal">{formatPropagation(obs.propagationTimeMs)}</span>
</div>
<div className="flex flex-col min-w-0">
<span className="text-text-dim text-[10px] font-medium uppercase tracking-wider">Hops</span>
<span className="text-text-dim text-[10px] font-medium uppercase tracking-wider">{t("observerPage.hops")}</span>
<span className="font-medium text-text-normal">{obs.pathLength.hopCount}</span>
</div>
</div>
{obs.radio && (
<div className="flex flex-wrap items-center gap-1.5 mt-1.5 font-mono text-[11px] text-text-muted">
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-0.5">Radio</span>
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-0.5">{t("observerPage.radio")}</span>
{obs.radio.freqMhz != null && <span>{obs.radio.freqMhz} MHz</span>}
{obs.radio.spreadFactor != null && <><span className="text-[6px] text-border" aria-hidden>·</span><span>SF{obs.radio.spreadFactor}</span></>}
{obs.radio.bandwidthKhz != null && <><span className="text-[6px] text-border" aria-hidden>·</span><span>{obs.radio.bandwidthKhz} kHz</span></>}
@@ -80,12 +82,12 @@ export function ObservationCard({ observation: obs, selected, onClick, onViewNod
{isTrace ? (
// TRACE path bytes are per-hop SNR samples, not hop hashes — show them raw, never as a resolvable path.
<>
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-1">Path SNR</span>
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-1">{t("observation.pathSnr")}</span>
<span className="text-text-normal break-all">{obs.pathBytes.toUpperCase()}</span>
</>
) : (
<>
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-1">Path</span>
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-1">{t("observation.path")}</span>
<PathData pathBytes={obs.pathBytes} hashSize={obs.pathLength.hashSize} resolvedPath={obs.resolvedPath} size="sm" onViewNode={onViewNode} />
</>
)}
+8 -6
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import type { Observation } from "../../types/api";
import { formatSnr, formatPropagation, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
import { Timestamp } from "../../components/Timestamp";
@@ -11,18 +12,19 @@ interface Props {
// Per-observer readings vary by distance; presentational component owned by caller.
export function ObservationTable({ observations, selectedId, onSelect }: Props) {
const { t } = useTranslation();
return (
<table className="w-full text-[10px] border-collapse">
<thead>
<tr className="text-text-dim uppercase tracking-wider text-[9px]">
<th className="text-left font-medium py-1 px-1.5">Observer</th>
<th className="text-left font-medium py-1 px-1.5">Area</th>
<th className="text-left font-medium py-1 px-1.5">Heard</th>
<th className="text-left font-medium py-1 px-1.5">{t("observation.observer")}</th>
<th className="text-left font-medium py-1 px-1.5">{t("observation.area")}</th>
<th className="text-left font-medium py-1 px-1.5">{t("observation.heard")}</th>
<th className="text-left font-medium py-1 px-1.5">SNR</th>
<th className="text-left font-medium py-1 px-1.5">RSSI</th>
<th className="text-left font-medium py-1 px-1.5">Prop</th>
<th className="text-left font-medium py-1 px-1.5">Hops</th>
<th className="text-left font-medium py-1 px-1.5">Path</th>
<th className="text-left font-medium py-1 px-1.5">{t("observation.prop")}</th>
<th className="text-left font-medium py-1 px-1.5">{t("observation.hops")}</th>
<th className="text-left font-medium py-1 px-1.5">{t("observation.path")}</th>
</tr>
</thead>
<tbody>
+23 -23
View File
@@ -82,10 +82,10 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
return (
<div data-testid="packet-analyzer-drawer" className="absolute inset-0 z-30 w-full md:static md:inset-auto md:z-auto md:shrink-0 md:w-[400px] md:border-l border-border bg-bg-surface flex flex-col min-h-0 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle shrink-0">
<span className="text-[13px] font-mono font-medium text-text-dim uppercase tracking-wider">Packet Analyzer</span>
<span className="text-[13px] font-mono font-medium text-text-dim uppercase tracking-wider">{t("packetAnalyzer.title")}</span>
<div className="flex items-center gap-1.5">
{detail && <CopyLinkButton params={() => ({ tab: "Packets", route: null, routeIata: null, hash: detail.packetHash, analyze: "1", path: null, node: null, observer: null, observerId: null, statsTab: null, compareWith: null, compareUntil: null, observation: selectedObs ? String(selectedObs.id) : null })} label={t("investigation.copy")} copiedLabel={t("observerPage.copied")} ariaLabel={t("investigation.copyPacket")} />}
<CloseButton onClose={handleClose} label="Close analyzer" className="-mr-1" />
<CloseButton onClose={handleClose} label={t("packetAnalyzer.close")} className="-mr-1" />
</div>
</div>
@@ -97,17 +97,17 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
<line x1="3" y1="9" x2="21" y2="9" stroke="currentColor" strokeWidth="1.2" />
<line x1="8" y1="9" x2="8" y2="19" stroke="currentColor" strokeWidth="1.2" />
</svg>
<span className="text-[13px] font-mono">{loading ? "Loading…" : "Select a packet to analyze"}</span>
<span className="text-[13px] font-mono">{loading ? t("common.loading") : t("packetAnalyzer.empty")}</span>
</div>
) : (
<>
<DrawerSection title="Summary" first>
<DrawerSection title={t("packetAnalyzer.summary")} first>
<div className="flex items-center gap-2 mb-2">
<span className="font-mono text-xs font-semibold text-primary tracking-wider">
{formatHex(detail.packetHash)}
</span>
<Badge variant={payloadTypeVariant(detail.header.payloadType)}>
{PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? "Unknown"}
{PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? t("observerPage.unknown")}
</Badge>
{detail.scope && <ScopeTag boxed>{detail.scope}</ScopeTag>}
<Tooltip
@@ -153,22 +153,22 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
defaultOpen={selectedObservationId != null || selectionParams.has("observation")} />
{rawHex && (
<DrawerSection title="Raw Packet">
<DrawerSection title={t("packetAnalyzer.rawPacket")}>
<div className="bg-bg-base border border-border rounded p-2 max-h-40 overflow-y-auto">
<ColoredHexDump data={rawHex} ranges={fieldRanges} />
</div>
</DrawerSection>
)}
<DrawerSection title="Packet Structure">
<DrawerSection title={t("packetAnalyzer.structure")}>
<div className="flex flex-col gap-2.5 font-mono text-[13px]">
{/* Header byte */}
<ColorAccentField field="header">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Header Byte</div>
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">{t("packetAnalyzer.headerByte")}</div>
<div className="flex gap-x-4">
<span><span className="text-text-dim">Ver </span><span className="text-text-normal">{detail.header.payloadVersion}</span></span>
<span><span className="text-text-dim">Type </span><span className="text-text-normal">{PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? "?"} ({detail.header.payloadType})</span></span>
<span><span className="text-text-dim">Route </span><span className="text-text-normal">{ROUTE_TYPE_NAMES[detail.header.routeType as RouteTypeValue] ?? "?"} ({detail.header.routeType})</span></span>
<span><span className="text-text-dim">{t("packetAnalyzer.ver")} </span><span className="text-text-normal">{detail.header.payloadVersion}</span></span>
<span><span className="text-text-dim">{t("packetAnalyzer.type")} </span><span className="text-text-normal">{PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? "?"} ({detail.header.payloadType})</span></span>
<span><span className="text-text-dim">{t("packetAnalyzer.route")} </span><span className="text-text-normal">{ROUTE_TYPE_NAMES[detail.header.routeType as RouteTypeValue] ?? "?"} ({detail.header.routeType})</span></span>
</div>
{headerHex && (
<HeaderBitBreakdown headerHex={headerHex} />
@@ -178,17 +178,17 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
{/* Transport codes */}
{detail.transportCodes && (
<ColorAccentField field="transport">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Transport Codes</div>
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">{t("packetAnalyzer.transportCodes")}</div>
<div>
<span className="text-text-dim">Scope code </span>
<span className="text-text-dim">{t("packetAnalyzer.scopeCode")} </span>
<span className="text-text-normal">{transportCodeHex(detail.transportCodes.regionCode).toUpperCase()}</span>
<span className="text-text-dim"> (2B) = </span>
<span className="text-text-normal">{detail.transportCodes.regionCode}</span>
<span className="text-text-dim"> → </span>
{detail.scope ? <ScopeTag>{detail.scope}</ScopeTag> : <span className="text-text-dim">no known scope</span>}
{detail.scope ? <ScopeTag>{detail.scope}</ScopeTag> : <span className="text-text-dim">{t("packetAnalyzer.noKnownScope")}</span>}
</div>
<div>
<span className="text-text-dim">Code 2 </span>
<span className="text-text-dim">{t("packetAnalyzer.code2")} </span>
<span className="text-text-normal">{transportCodeHex(detail.transportCodes.subRegionCode).toUpperCase()}</span>
<span className="text-text-dim"> (2B) = </span>
<span className="text-text-normal">{detail.transportCodes.subRegionCode}</span>
@@ -199,10 +199,10 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
{/* Path length */}
{selectedObs && (
<ColorAccentField field="pathLength">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Path Length</div>
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">{t("packetAnalyzer.pathLength")}</div>
<div className="flex gap-x-4">
<span><span className="text-text-dim">Hash Size </span><span className="text-text-normal">{selectedObs.pathLength.hashSize}B</span></span>
<span><span className="text-text-dim">Hops </span><span className="text-text-normal">{selectedObs.pathLength.hopCount}</span></span>
<span><span className="text-text-dim">{t("packetAnalyzer.hashSize")} </span><span className="text-text-normal">{selectedObs.pathLength.hashSize}B</span></span>
<span><span className="text-text-dim">{t("observerPage.hops")} </span><span className="text-text-normal">{selectedObs.pathLength.hopCount}</span></span>
</div>
<PathLengthBitBreakdown pathLengthByte={parseInt(selectedObs.pathLength.raw, 16)} />
</ColorAccentField>
@@ -212,14 +212,14 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
and resolvedPath), so it resolves through PathData like every other type. */}
{selectedObs?.pathBytes && (
<ColorAccentField field="pathData">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Path Data</div>
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">{t("packetAnalyzer.pathData")}</div>
<PathData pathBytes={selectedObs.pathBytes} hashSize={selectedObs.pathLength.hashSize} resolvedPath={selectedObs.resolvedPath} onViewNode={onViewNode} />
</ColorAccentField>
)}
{detail.originPubkey && detail.header.payloadType !== PayloadType.ADVERT && (
<ColorAccentField field="payload">
<span className="text-text-dim text-xs font-medium uppercase tracking-wider">Origin Pubkey</span>
<span className="text-text-dim text-xs font-medium uppercase tracking-wider">{t("packetAnalyzer.originPubkey")}</span>
<div className="text-text-normal break-all text-[13px]">{detail.originPubkey}</div>
</ColorAccentField>
)}
@@ -227,7 +227,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
</DrawerSection>
{detail.parsedPayload && typeof detail.parsedPayload === "object" && Object.keys(detail.parsedPayload).length > 0 && (
<DrawerSection title="Payload Breakdown">
<DrawerSection title={t("packetAnalyzer.payloadBreakdown")}>
<div className="font-mono text-[13px]">
<PayloadBreakdown payload={detail.parsedPayload} resolvedRoute={detail.resolvedRoute} resolvedSource={selectedObs?.resolvedSource} resolvedDestination={selectedObs?.resolvedDestination} onViewNode={onViewNode} />
</div>
@@ -238,7 +238,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
const hex = decodePayloadHex(detail.parsedPayload);
if (!hex) return null;
return (
<DrawerSection title="Payload Data">
<DrawerSection title={t("packetAnalyzer.payloadData")}>
<div className="bg-bg-base border border-border rounded p-2 max-h-40 overflow-y-auto">
<pre className="text-[13px] font-mono text-text-muted leading-relaxed whitespace-pre-wrap">
{(hex.match(/.{1,2}/g) ?? []).reduce((acc, b, i) => {
@@ -247,7 +247,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
}, "")}
</pre>
</div>
<div className="text-[11px] text-text-dim mt-1">{hex.length / 2} bytes</div>
<div className="text-[11px] text-text-dim mt-1">{t("packetAnalyzer.bytes", { count: hex.length / 2 })}</div>
</DrawerSection>
);
})()}
+4 -1
View File
@@ -28,7 +28,10 @@ function Chip({ hop }: { hop: ResolvedHop }) {
);
}
const Na = () => <span className="text-text-dim">n/a</span>;
function Na() {
const { t } = useTranslation();
return <span className="text-text-dim">{t("packetEndpoints.na")}</span>;
}
// Keep every candidate: a short endpoint hash can match several nodes.
export function PacketEndpoints({ packet }: { packet: PacketSummary }) {
+12 -10
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { PacketSummary } from "../../types/api";
import { formatPropagation } from "../../lib/formatters";
import { Timestamp } from "../../components/Timestamp";
@@ -29,6 +30,7 @@ interface Props {
export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: Props) {
const { data, isLoading, isError, refetch } = usePacketDetail(packet.packetHash);
const isMobile = useIsMobile();
const { t } = useTranslation();
const observer = packet.latestObserver;
// firstHeardAt/lastHeardAt are epoch ms (same unit Timestamp expects), so the difference is
// already in ms for formatPropagation -- no *1000 here.
@@ -38,7 +40,7 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb
// The summary already knows the count is zero, so skip the fetch-driven states entirely rather
// than showing a blank (0-row) skeleton while it loads.
const noObservations = packet.observationCount === 0;
const emptyState = <div className="text-[10px] text-text-dim py-2">No observations</div>;
const emptyState = <div className="text-[10px] text-text-dim py-2">{t("packetRow.noObservations")}</div>;
// Picking an observation is the way into the analyzer — it opens on the one you clicked.
const handleSelectObservation = useCallback(
(id: number) => {
@@ -55,22 +57,22 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] md:text-[10px] text-text-muted pb-2">
{/* the mobile card header right above already names the latest observer */}
<span className="hidden md:inline">
observer{" "}
{t("packetRow.observer")}{" "}
{observer
? <span className="text-text-normal">{observer.displayName ?? observer.id.slice(0, 8)}</span>
: <span className="text-text-dim">n/a</span>}
: <span className="text-text-dim">{t("packetEndpoints.na")}</span>}
</span>
<span>first <Timestamp value={packet.firstHeardAt} /></span>
<span>last <Timestamp value={packet.lastHeardAt} /></span>
<span>spread {formatPropagation(spread)}</span>
<span>{t("packetRow.first")} <Timestamp value={packet.firstHeardAt} /></span>
<span>{t("packetRow.last")} <Timestamp value={packet.lastHeardAt} /></span>
<span>{t("packetRow.spread")} {formatPropagation(spread)}</span>
<button
type="button"
onClick={onViewPath}
disabled={!ready || !hasPath}
title={hasPath ? undefined : "No resolved path to map"}
title={hasPath ? undefined : t("packetRow.noPath")}
className={ACTION_BUTTON_CLASS}
>
Map all paths
{t("investigation.viewAll")}
</button>
</div>
@@ -78,9 +80,9 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb
<div data-testid="observation-scroller" className="md:max-h-[360px] md:overflow-y-auto">
{isError ? (
<div className="flex flex-wrap items-center gap-3 text-[11px] md:text-[10px] text-danger py-2">
<span>Failed to load observations</span>
<span>{t("packetRow.loadFailed")}</span>
<button type="button" onClick={() => refetch()} className={ACTION_BUTTON_CLASS}>
Retry
{t("observerPage.retry")}
</button>
</div>
) : noObservations ? (
+16 -14
View File
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { usePackets } from "./usePackets";
import { usePacketDetail } from "./usePacketDetail";
@@ -42,6 +43,7 @@ interface PacketListProps {
export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservationId, onSelectObservation }: PacketListProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { filters, setFilter, setSearch, setSearchField, clearFilters } = usePacketFilters();
// single-value selections go to the server so scrolling pages through matching history
@@ -178,35 +180,35 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
{filters.searchField === "path" && (
<p role={pathSearch === null ? "alert" : undefined} className={`px-4 py-1.5 text-xs font-mono ${pathSearch === null ? "text-danger" : "text-text-muted"}`}>
{pathSearch === null
? "Enter whole hop hashes of the same length, separated by spaces, commas or → (e.g. 7f a4)."
: "Searches the latest relay path in loaded packets. Use whole hop hashes, e.g. 7f a4 (spaces, commas or →). Trace packets are excluded."}
? t("packetList.pathSearchInvalid")
: t("packetList.pathSearchHint")}
</p>
)}
{/* A hash-only link still selects a row; offer the existing analyzer when there is no row. */}
{expandedHash && !isLoading && searchParams.get("analyze") !== "1" && !packets.some(p => p.packetHash === expandedHash) && (
<section aria-label="Selected packet" className="mx-4 my-2 px-3 py-2 border border-border rounded-sm bg-bg-surface flex items-start justify-between gap-3 text-xs text-text-muted">
<section aria-label={t("packetList.selectedPacket")} className="mx-4 my-2 px-3 py-2 border border-border rounded-sm bg-bg-surface flex items-start justify-between gap-3 text-xs text-text-muted">
<div>
{selectedError ? (
<>
<p role="alert">{detailStatus === 400 ? "Invalid packet hash." : detailStatus === 404 ? "Packet not found." : "Failed to load selected packet."}</p>
<button type="button" className="mt-1 px-2 py-1 border border-border rounded-sm hover:bg-bg-raised cursor-pointer" onClick={() => retryDetail()} aria-label="Retry selected packet">Retry</button>
<p role="alert">{detailStatus === 400 ? t("packetList.invalidHash") : detailStatus === 404 ? t("packetList.notFound") : t("packetList.loadFailed")}</p>
<button type="button" className="mt-1 px-2 py-1 border border-border rounded-sm hover:bg-bg-raised cursor-pointer" onClick={() => retryDetail()} aria-label={t("packetList.retryLabel")}>{t("packetList.retry")}</button>
</>
) : expandedDetail ? (
<>
<p>Selected packet is outside the loaded results.</p>
<button type="button" className="mt-1 px-2 py-1 border border-border rounded-sm hover:bg-bg-raised cursor-pointer" onClick={() => handleOpenAnalyzer()}>Open analyzer</button>
<p>{t("packetList.outsideResults")}</p>
<button type="button" className="mt-1 px-2 py-1 border border-border rounded-sm hover:bg-bg-raised cursor-pointer" onClick={() => handleOpenAnalyzer()}>{t("packetList.openAnalyzer")}</button>
</>
) : <p role="status">Loading selected packet…</p>}
) : <p role="status">{t("packetList.loadingSelected")}</p>}
</div>
<CloseButton label="Dismiss selected packet" onClose={() => handleToggleExpand(expandedHash)} />
<CloseButton label={t("packetList.dismissSelected")} onClose={() => handleToggleExpand(expandedHash)} />
</section>
)}
{laggedCount > 0 && (
<div className="mx-4 px-3 py-1.5 bg-warn/6 border border-warn/12 text-warn text-xs font-medium font-mono rounded-b flex items-center justify-between">
<span>{laggedCount} packet{laggedCount === 1 ? "" : "s"} dropped — data may be incomplete</span>
<button type="button" className="underline cursor-pointer" onClick={dismissLagged}>dismiss</button>
<span>{t("packetList.dropped", { count: laggedCount })}</span>
<button type="button" className="underline cursor-pointer" onClick={dismissLagged}>{t("packetList.dismiss")}</button>
</div>
)}
@@ -217,13 +219,13 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati
onClick={handleScrollToTop}
>
<span aria-hidden>▲</span>
{bannerCount} new packet{bannerCount === 1 ? "" : "s"}
<span className="text-primary/60 font-normal">· scroll to top</span>
{t("packetList.newPackets", { count: bannerCount })}
<span className="text-primary/60 font-normal">{t("packetList.scrollToTop")}</span>
</button>
) : (
<div className="mx-4 flex items-center justify-center gap-1.5 px-3 py-1.5 bg-primary/8 border border-primary/15 border-t-0 text-primary text-[11px] font-medium tracking-wide font-mono rounded-b">
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
Live Packets
{t("packetList.live")}
</div>
)}
+6 -3
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { formatHex } from "../../lib/formatters";
import { Timestamp } from "../../components/Timestamp";
import type { PacketSummary } from "../../types/api";
@@ -17,6 +18,8 @@ interface PacketRowProps {
// selectable packet card; observations live in the analyzer drawer
export function PacketRow({ packet, expanded, isFresh, onToggle }: PacketRowProps) {
const { t } = useTranslation();
const heardBy = t("packetRow.heardBy", { count: packet.observationCount });
return (
<div
className={`group bg-bg-surface border rounded-md px-3.5 py-2.5 cursor-pointer ${
@@ -44,10 +47,10 @@ export function PacketRow({ packet, expanded, isFresh, onToggle }: PacketRowProp
<Badge variant={payloadTypeVariant(packet.payloadType)}>
{PAYLOAD_TYPE_NAMES[packet.payloadType as PayloadTypeValue] ?? packet.payloadTypeName}
</Badge>
<Tooltip label={`Heard by ${packet.observationCount} observer${packet.observationCount === 1 ? "" : "s"}`}>
<Tooltip label={heardBy}>
<span
className="font-mono text-[11px] text-primary font-semibold whitespace-nowrap bg-primary/6 px-1.5 rounded-sm"
aria-label={`Heard by ${packet.observationCount} observer${packet.observationCount === 1 ? "" : "s"}`}
aria-label={heardBy}
>
×{packet.observationCount}
</span>
@@ -58,7 +61,7 @@ export function PacketRow({ packet, expanded, isFresh, onToggle }: PacketRowProp
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1 text-[11px] text-text-dim">
<span className="font-mono text-[11px] text-text-muted uppercase tracking-wider bg-text-muted/8 px-1.5 py-px rounded-sm">
{packet.routeTypeName || "Unknown"}
{packet.routeTypeName || t("packetRow.unknown")}
</span>
{packet.scope && (
<>
+11 -9
View File
@@ -1,22 +1,24 @@
import { useTranslation } from "react-i18next";
import { GRID_TEMPLATE } from "./packet-grid";
// Sticky above the virtualizer's spacer, never inside measured item space.
export function PacketTableHeader() {
const { t } = useTranslation();
return (
<div
className="hidden md:grid sticky top-0 z-10 gap-x-3 px-3 py-1.5 bg-bg-surface border-b border-border text-[9px] uppercase tracking-wider text-text-muted"
style={{ gridTemplateColumns: GRID_TEMPLATE }}
>
<span aria-hidden />
<span>Hash</span>
<span>Type</span>
<span>Route</span>
<span>Obs</span>
<span>Hops</span>
<span>Hash Size</span>
<span className="truncate" title="Summary and source / destination">Summary / Src → Dst</span>
<span>Area</span>
<span className="text-right">Age</span>
<span>{t("packetTable.hash")}</span>
<span>{t("packetAnalyzer.type")}</span>
<span>{t("packetAnalyzer.route")}</span>
<span>{t("packetTable.obs")}</span>
<span>{t("packetTable.hops")}</span>
<span>{t("packetAnalyzer.hashSize")}</span>
<span className="truncate" title={t("packetTable.summaryTitle")}>{t("packetTable.summary")}</span>
<span>{t("packetTable.area")}</span>
<span className="text-right">{t("packetTable.age")}</span>
</div>
);
}
+4 -2
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { formatHex } from "../../lib/formatters";
import { Timestamp } from "../../components/Timestamp";
import { Badge } from "../../components/Badge";
@@ -17,10 +18,11 @@ interface PacketTableRowProps {
// The observer lives in the expansion, which frees the wide column for the packet's endpoints.
export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) {
const { t } = useTranslation();
// ?? not ||, so a legitimate 0-hop direct packet still shows its count
const pathLength = packet.latestObserver?.pathLength;
const routeName = packet.routeTypeName || "Unknown";
const na = <span className="text-text-dim">n/a</span>;
const routeName = packet.routeTypeName || t("packetTable.unknown");
const na = <span className="text-text-dim">{t("packetEndpoints.na")}</span>;
return (
<div
+4 -2
View File
@@ -1,5 +1,6 @@
import { useRef, useCallback, useLayoutEffect } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useTranslation } from "react-i18next";
import type { PacketSummary } from "../../types/api";
import { PacketTableHeader } from "./PacketTableHeader";
import { PacketTableRow } from "./PacketTableRow";
@@ -45,6 +46,7 @@ export function PacketVirtualList({
selectedObservationId,
onSelectObservation,
}: PacketVirtualListProps) {
const { t } = useTranslation();
const parentRef = useRef<HTMLDivElement>(null);
const freshHashes = useFreshHashes(packets);
const isMobile = useIsMobile();
@@ -153,7 +155,7 @@ export function PacketVirtualList({
})}
</div>
{packets.length === 0 && (
<p className="py-4 text-center text-xs font-mono text-text-muted">No matching packets loaded.</p>
<p className="py-4 text-center text-xs font-mono text-text-muted">{t("packetList.noMatching")}</p>
)}
{hasNextPage && (
<div className="flex justify-center py-4">
@@ -163,7 +165,7 @@ export function PacketVirtualList({
onClick={() => fetchNextPage()}
className="rounded border border-border px-3 py-1.5 text-xs font-mono text-text-normal hover:bg-text-normal/3 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{isFetching ? "Loading packets..." : "Load older packets"}
{isFetching ? t("packetList.loadingPackets") : t("packetList.loadOlder")}
</button>
</div>
)}
+11 -7
View File
@@ -1,5 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PacketDetail, Observation } from "../../types/api";
import { RouteType, PayloadType } from "../../types/enums";
@@ -279,6 +280,7 @@ export function ColoredHexDump({
}
export function HeaderBitBreakdown({ headerHex }: { headerHex: string }) {
const { t } = useTranslation();
const value = parseInt(headerHex, 16);
if (Number.isNaN(value)) return null;
@@ -293,17 +295,17 @@ export function HeaderBitBreakdown({ headerHex }: { headerHex: string }) {
<span className="text-text-dim leading-none pt-px">0x{headerHex.toUpperCase()} = [</span>
<span className="flex flex-col items-center leading-none">
<span className="text-secondary">{ver}</span>
<span className="text-secondary text-[11px] mt-0.5">ver</span>
<span className="text-secondary text-[11px] mt-0.5">{t("packetAnalyzer.bits.ver")}</span>
</span>
<span className="text-text-dim leading-none pt-px">|</span>
<span className="flex flex-col items-center leading-none">
<span className="text-warn">{typ}</span>
<span className="text-warn text-[11px] mt-0.5">type</span>
<span className="text-warn text-[11px] mt-0.5">{t("packetAnalyzer.bits.type")}</span>
</span>
<span className="text-text-dim leading-none pt-px">|</span>
<span className="flex flex-col items-center leading-none">
<span className="text-green">{route}</span>
<span className="text-green text-[11px] mt-0.5">route</span>
<span className="text-green text-[11px] mt-0.5">{t("packetAnalyzer.bits.route")}</span>
</span>
<span className="text-text-dim leading-none pt-px">]</span>
</div>
@@ -312,6 +314,7 @@ export function HeaderBitBreakdown({ headerHex }: { headerHex: string }) {
}
export function PathLengthBitBreakdown({ pathLengthByte }: { pathLengthByte: number }) {
const { t } = useTranslation();
if (Number.isNaN(pathLengthByte)) return null;
const bits = formatBinary(pathLengthByte);
const hashBits = bits.slice(0, 2);
@@ -326,12 +329,12 @@ export function PathLengthBitBreakdown({ pathLengthByte }: { pathLengthByte: num
<span className="text-text-dim leading-none pt-px">0x{pathLengthByte.toString(16).toUpperCase().padStart(2, "0")} = [</span>
<span className="flex flex-col items-center leading-none">
<span className="text-secondary">{hashBits}</span>
<span className="text-secondary text-[11px] mt-0.5">hash={hashSize + 1}B</span>
<span className="text-secondary text-[11px] mt-0.5">{t("packetAnalyzer.bits.hash", { size: hashSize + 1 })}</span>
</span>
<span className="text-text-dim leading-none pt-px">|</span>
<span className="flex flex-col items-center leading-none">
<span className="text-green">{hopBits}</span>
<span className="text-green text-[11px] mt-0.5">hops={hopCount}</span>
<span className="text-green text-[11px] mt-0.5">{t("packetAnalyzer.bits.hops", { value: hopCount })}</span>
</span>
<span className="text-text-dim leading-none pt-px">]</span>
</div>
@@ -340,6 +343,7 @@ export function PathLengthBitBreakdown({ pathLengthByte }: { pathLengthByte: num
}
export function AdvertFlagsBitBreakdown({ flagsByte }: { flagsByte: number }) {
const { t } = useTranslation();
const bits = formatBinary(flagsByte);
const roleBits = bits.slice(4, 8);
const loc = bits[3];
@@ -361,7 +365,7 @@ export function AdvertFlagsBitBreakdown({ flagsByte }: { flagsByte: number }) {
<span className="text-text-dim leading-none pt-px">|</span>
<span className="flex flex-col items-center leading-none">
<span className={loc === "1" ? "text-green" : "text-text-dim"}>{loc}</span>
<span className={`text-[11px] mt-0.5 ${loc === "1" ? "text-green" : "text-text-dim"}`}>loc</span>
<span className={`text-[11px] mt-0.5 ${loc === "1" ? "text-green" : "text-text-dim"}`}>{t("packetAnalyzer.bits.loc")}</span>
</span>
<span className="text-text-dim leading-none pt-px">|</span>
<span className="flex flex-col items-center leading-none">
@@ -376,7 +380,7 @@ export function AdvertFlagsBitBreakdown({ flagsByte }: { flagsByte: number }) {
<span className="text-text-dim leading-none pt-px">|</span>
<span className="flex flex-col items-center leading-none">
<span className={nm === "1" ? "text-green" : "text-text-dim"}>{nm}</span>
<span className={`text-[11px] mt-0.5 ${nm === "1" ? "text-green" : "text-text-dim"}`}>name</span>
<span className={`text-[11px] mt-0.5 ${nm === "1" ? "text-green" : "text-text-dim"}`}>{t("packetAnalyzer.bits.name")}</span>
</span>
<span className="text-text-dim leading-none pt-px">]</span>
</div>
+65 -49
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "../../components/Badge";
import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
import { Timestamp } from "../../components/Timestamp";
@@ -60,7 +61,8 @@ function HexBadge({ value }: { value: string }) {
}
function BooleanValue({ value }: { value: boolean }) {
return <span className={value ? "text-green" : "text-text-muted"}>{value ? "Yes" : "No"}</span>;
const { t } = useTranslation();
return <span className={value ? "text-green" : "text-text-muted"}>{value ? t("packetAnalyzer.payload.yes") : t("packetAnalyzer.payload.no")}</span>;
}
function FlagChip({ label, active, variant = "green" }: { label: string; active: boolean; variant?: "green" | "danger" }) {
@@ -77,13 +79,14 @@ function FlagChip({ label, active, variant = "green" }: { label: string; active:
}
function EncryptedIndicator() {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1.5 text-text-muted text-xs bg-text-muted/5 border border-text-muted/10 rounded px-2 py-1">
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" className="shrink-0">
<rect x="3" y="8" width="10" height="6" rx="1" stroke="currentColor" strokeWidth="1.3" />
<path d="M5 8V5.5C5 3.567 6.567 2 8.5 2V2C10.433 2 12 3.567 12 5.5V8" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
<span className="italic">Encrypted — key not available</span>
<span className="italic">{t("packetAnalyzer.payload.encrypted")}</span>
</div>
);
}
@@ -93,6 +96,7 @@ function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, reso
headerSlot?: ReactNode;
children: (decrypted: Record<string, unknown>) => ReactNode;
} & EndpointProps) {
const { t } = useTranslation();
const destinationHash = payload.destinationHash as string | undefined;
const sourceHash = payload.sourceHash as string | undefined;
const cipherMac = payload.cipherMac as string | undefined;
@@ -104,7 +108,7 @@ function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, reso
<div className="flex flex-col gap-1">
{destinationHash && (
<ColorAccentField field="destinationHash">
<span className="text-text-dim">To </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.to")} </span>
{resolvedDestination
? <ResolvedHopBlock hop={resolvedDestination} label={destinationHash.toUpperCase()} onViewNode={onViewNode} showSnr={false} />
: <HexBadge value={destinationHash} />}
@@ -113,7 +117,7 @@ function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, reso
)}
{sourceHash && (
<ColorAccentField field="sourceHash">
<span className="text-text-dim">From </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.from")} </span>
{resolvedSource
? <ResolvedHopBlock hop={resolvedSource} label={sourceHash.toUpperCase()} onViewNode={onViewNode} showSnr={false} />
: <HexBadge value={sourceHash} />}
@@ -122,12 +126,12 @@ function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, reso
)}
{headerSlot}
{cipherMac && <TruncatedHex label="MAC" value={cipherMac} maxChars={32} accentClass={FIELD_COLORS.cipherMac.accent} />}
{ciphertext && <TruncatedHex label="Ciphertext" value={ciphertext} accentClass={FIELD_COLORS.ciphertext.accent} />}
{ciphertext && <TruncatedHex label={t("packetAnalyzer.payload.ciphertext")} value={ciphertext} accentClass={FIELD_COLORS.ciphertext.accent} />}
</div>
{decrypted ? (
<div>
<SectionLabel>Decrypted</SectionLabel>
<SectionLabel>{t("packetAnalyzer.payload.decrypted")}</SectionLabel>
{children(decrypted)}
</div>
) : (
@@ -140,6 +144,7 @@ function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, reso
// per-payload-type renderers
function AdvertPayload({ payload }: PayloadProps) {
const { t } = useTranslation();
const p = payload;
const publicKey = p.publicKey as string | undefined;
const signature = p.signature as string | undefined;
@@ -156,7 +161,7 @@ function AdvertPayload({ payload }: PayloadProps) {
<div className="flex flex-col gap-2.5">
{publicKey && (
<ColorAccentField field="publicKey">
<span className="text-text-dim">Public Key </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.publicKey")} </span>
<span className="text-text-normal">{publicKey.slice(0, 16).toUpperCase()}…</span>
<span className="text-text-dim"> ({publicKey.length / 2}B)</span>
</ColorAccentField>
@@ -164,7 +169,7 @@ function AdvertPayload({ payload }: PayloadProps) {
{timestamp != null && (
<ColorAccentField field="advertTimestamp">
<span className="text-text-dim">Timestamp </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.timestamp")} </span>
<Timestamp value={timestamp * 1000} className="text-text-normal" />
<span className="text-text-dim"> (4B LE)</span>
</ColorAccentField>
@@ -172,20 +177,20 @@ function AdvertPayload({ payload }: PayloadProps) {
{signature && (
<ColorAccentField field="signature">
<span className="text-text-dim">Signature </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.signature")} </span>
<span className="text-text-normal">{signature.slice(0, 16).toUpperCase()}…</span>
<span className="text-text-dim"> ({signature.length / 2}B)</span>
</ColorAccentField>
)}
<ColorAccentField field="flags">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">App Flags</div>
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">{t("packetAnalyzer.payload.appFlags")}</div>
<div className="flex gap-x-4">
<span><span className="text-text-dim">Role </span><span className="text-text-normal">{String(flags.deviceRoleName ?? "?")}</span></span>
<span><span className="text-text-dim">Loc </span><BooleanValue value={!!flags.hasLocation} /></span>
<span><span className="text-text-dim">{t("packetAnalyzer.payload.role")} </span><span className="text-text-normal">{String(flags.deviceRoleName ?? "?")}</span></span>
<span><span className="text-text-dim">{t("packetAnalyzer.payload.loc")} </span><BooleanValue value={!!flags.hasLocation} /></span>
<span><span className="text-text-dim">F1 </span><BooleanValue value={!!flags.hasFeature1} /></span>
<span><span className="text-text-dim">F2 </span><BooleanValue value={!!flags.hasFeature2} /></span>
<span><span className="text-text-dim">Name </span><BooleanValue value={!!flags.hasName} /></span>
<span><span className="text-text-dim">{t("packetAnalyzer.payload.name")} </span><BooleanValue value={!!flags.hasName} /></span>
</div>
{flagsByte != null && !Number.isNaN(flagsByte) && <AdvertFlagsBitBreakdown flagsByte={flagsByte} />}
</ColorAccentField>
@@ -193,7 +198,7 @@ function AdvertPayload({ payload }: PayloadProps) {
{/* Coordinates render as raw decimal degrees by design — intentionally not D°M' / N·S·E·W formatted. */}
{hasLocation && (latitude != null || longitude != null) && (
<ColorAccentField field="location">
<span className="text-text-dim">Location </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.location")} </span>
<span className="text-text-normal">
{latitude != null ? latitude.toFixed(5) : "—"}
{", "}
@@ -205,7 +210,7 @@ function AdvertPayload({ payload }: PayloadProps) {
{name && (
<ColorAccentField field="advertName">
<span className="text-text-dim">Name </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.name")} </span>
<span className="text-text-normal">{name}</span>
<span className="text-text-dim"> ({new TextEncoder().encode(name).length}B UTF-8)</span>
</ColorAccentField>
@@ -218,6 +223,7 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & {
resolvedRoute?: ResolvedHop[];
onViewNode?: (nodeId: string) => void;
}) {
const { t } = useTranslation();
const traceTag = payload.traceTag as string | undefined;
const authCode = payload.authCode as number | undefined;
const flags = payload.flags as number | undefined;
@@ -228,7 +234,7 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & {
<div className="flex flex-col gap-2.5">
{traceTag && (
<ColorAccentField field="traceTag">
<span className="text-text-dim">Trace Tag </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.traceTag")} </span>
<span className="text-text-normal">{traceTag.toUpperCase()}</span>
<span className="text-text-dim"> ({traceTag.length / 2}B)</span>
</ColorAccentField>
@@ -236,7 +242,7 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & {
{authCode != null && (
<ColorAccentField field="authCode">
<span className="text-text-dim">Auth Code </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.authCode")} </span>
<span className="text-text-normal">0x{authCode.toString(16).toUpperCase()}</span>
<span className="text-text-dim"> (4B)</span>
</ColorAccentField>
@@ -244,14 +250,14 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & {
{flags != null && (
<ColorAccentField field="flags">
<span className="text-text-dim">Flags </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.flags")} </span>
<span className="text-text-normal">0x{flags.toString(16).toUpperCase().padStart(2, "0")}</span>
</ColorAccentField>
)}
{pathHashes && pathHashes.length > 0 && (
<ColorAccentField field="tracePath">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Trace Path</div>
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">{t("packetAnalyzer.payload.tracePath")}</div>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-2 text-[13px]">
{pathHashes.map((hash, i) => {
const snr = snrValues?.[i];
@@ -289,6 +295,7 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & {
}
function GroupTextPayload({ payload }: PayloadProps) {
const { t } = useTranslation();
const channelHash = payload.channelHash as string | undefined;
const cipherMac = payload.cipherMac as string | undefined;
const ciphertext = payload.ciphertext as string | undefined;
@@ -297,25 +304,25 @@ function GroupTextPayload({ payload }: PayloadProps) {
return (
<div className="flex flex-col gap-2.5">
<div className="flex flex-col gap-1">
{channelHash && <TruncatedHex label="Channel" value={channelHash} maxChars={32} accentClass={FIELD_COLORS.channelHash.accent} />}
{channelHash && <TruncatedHex label={t("packetAnalyzer.payload.channel")} value={channelHash} maxChars={32} accentClass={FIELD_COLORS.channelHash.accent} />}
{cipherMac && <TruncatedHex label="MAC" value={cipherMac} maxChars={32} accentClass={FIELD_COLORS.cipherMac.accent} />}
{ciphertext && <TruncatedHex label="Ciphertext" value={ciphertext} accentClass={FIELD_COLORS.ciphertext.accent} />}
{ciphertext && <TruncatedHex label={t("packetAnalyzer.payload.ciphertext")} value={ciphertext} accentClass={FIELD_COLORS.ciphertext.accent} />}
</div>
{decrypted ? (
<div>
<SectionLabel>Decrypted</SectionLabel>
<SectionLabel>{t("packetAnalyzer.payload.decrypted")}</SectionLabel>
<div className="flex flex-col gap-1">
{decrypted.sender != null && (
<div>
<span className="text-text-dim">Sender </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.sender")} </span>
<span className="text-text-normal font-semibold">{String(decrypted.sender)}</span>
</div>
)}
{decrypted.content != null && (
<div>
{/* label sits above the body so every line of a multi-line message shares a left edge */}
<div className="text-text-dim">Message</div>
<div className="text-text-dim">{t("packetAnalyzer.payload.message")}</div>
<div className="text-text-bright whitespace-pre-wrap break-words pl-2">{String(decrypted.content)}</div>
</div>
)}
@@ -332,6 +339,7 @@ function GroupTextPayload({ payload }: PayloadProps) {
}
function TextPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) {
const { t } = useTranslation();
return (
<EncryptedEnvelope payload={payload} {...endpoints}>
{(d) => (
@@ -341,8 +349,8 @@ function TextPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) {
)}
<div className="flex gap-x-4 text-text-dim">
{d.timestamp != null && <Timestamp value={(d.timestamp as number) * 1000} />}
{d.attempt != null && <span>Attempt {String(d.attempt)}</span>}
{d.flags != null && <span>Flags {String(d.flags)}</span>}
{d.attempt != null && <span>{t("packetAnalyzer.payload.attempt", { value: String(d.attempt) })}</span>}
{d.flags != null && <span>{t("packetAnalyzer.payload.flagsValue", { value: String(d.flags) })}</span>}
</div>
</div>
)}
@@ -351,6 +359,7 @@ function TextPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) {
}
function RequestPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) {
const { t } = useTranslation();
return (
<EncryptedEnvelope payload={payload} {...endpoints}>
{(d) => (
@@ -359,10 +368,10 @@ function RequestPayload({ payload, ...endpoints }: PayloadProps & EndpointProps)
<div><Badge variant="request">{String(d.requestTypeName)}</Badge></div>
)}
{d.requestData != null && (
<TruncatedHex label="Data" value={String(d.requestData)} />
<TruncatedHex label={t("packetAnalyzer.payload.data")} value={String(d.requestData)} />
)}
{d.timestamp != null && (
<FieldRow label="Time">
<FieldRow label={t("packetAnalyzer.payload.time")}>
<Timestamp value={(d.timestamp as number) * 1000} className="text-text-normal" />
</FieldRow>
)}
@@ -373,12 +382,13 @@ function RequestPayload({ payload, ...endpoints }: PayloadProps & EndpointProps)
}
function ResponsePayload({ payload, ...endpoints }: PayloadProps & EndpointProps) {
const { t } = useTranslation();
return (
<EncryptedEnvelope payload={payload} {...endpoints}>
{(d) => (
<div className="flex flex-col gap-1">
{d.tag != null && (
<FieldRow label="Tag"><span className="text-text-normal">{String(d.tag)}</span></FieldRow>
<FieldRow label={t("packetAnalyzer.payload.tag")}><span className="text-text-normal">{String(d.tag)}</span></FieldRow>
)}
{d.content != null && (
<div className="text-text-bright whitespace-pre-wrap break-words">{String(d.content)}</div>
@@ -390,12 +400,13 @@ function ResponsePayload({ payload, ...endpoints }: PayloadProps & EndpointProps
}
function AckPayload({ payload }: PayloadProps) {
const { t } = useTranslation();
const checksum = payload.checksum as string | undefined;
if (!checksum) return null;
return (
<ColorAccentField field="checksum">
<span className="text-text-dim">Checksum </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.checksum")} </span>
<HexBadge value={checksum} />
<span className="text-text-dim"> ({checksum.length / 2}B)</span>
</ColorAccentField>
@@ -411,6 +422,7 @@ function PathPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) {
}
function PathDecryptedContent({ decrypted }: { decrypted: Record<string, unknown> }) {
const { t } = useTranslation();
const pathLength = decrypted.pathLength as number | undefined;
const pathHashSize = decrypted.pathHashSize as number | undefined;
const pathHashes = decrypted.pathHashes as string[] | undefined;
@@ -426,10 +438,10 @@ function PathDecryptedContent({ decrypted }: { decrypted: Record<string, unknown
<div>
<div className="flex gap-x-4">
{pathLength != null && (
<FieldRow label="Hops"><span className="text-text-normal">{pathLength}</span></FieldRow>
<FieldRow label={t("observerPage.hops")}><span className="text-text-normal">{pathLength}</span></FieldRow>
)}
{pathHashSize != null && (
<FieldRow label="Hash Size"><span className="text-text-normal">{pathHashSize}B</span></FieldRow>
<FieldRow label={t("packetAnalyzer.hashSize")}><span className="text-text-normal">{pathHashSize}B</span></FieldRow>
)}
</div>
{pathLenByte != null && <PathLengthBitBreakdown pathLengthByte={pathLenByte} />}
@@ -437,7 +449,7 @@ function PathDecryptedContent({ decrypted }: { decrypted: Record<string, unknown
{pathHashes && pathHashes.length > 0 && (
<div>
<SectionLabel>Path</SectionLabel>
<SectionLabel>{t("observation.path")}</SectionLabel>
<div className="flex flex-wrap items-center gap-1">
{pathHashes.map((hash, i) => (
<span key={i} className="contents">
@@ -453,11 +465,11 @@ function PathDecryptedContent({ decrypted }: { decrypted: Record<string, unknown
<div className="flex flex-col gap-1">
{extraTypeName && (
<div>
<span className="text-text-dim">Bundled Type </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.bundledType")} </span>
<Badge variant="default">{extraTypeName}</Badge>
</div>
)}
{extraData && <TruncatedHex label="Bundled Data" value={extraData} />}
{extraData && <TruncatedHex label={t("packetAnalyzer.payload.bundledData")} value={extraData} />}
</div>
)}
</div>
@@ -465,6 +477,7 @@ function PathDecryptedContent({ decrypted }: { decrypted: Record<string, unknown
}
function DiscoverReqPayload({ payload }: PayloadProps) {
const { t } = useTranslation();
const tag = payload.tag as string | undefined;
const typeFilter = payload.typeFilter as number | undefined;
const since = payload.since as number | null | undefined;
@@ -478,37 +491,38 @@ function DiscoverReqPayload({ payload }: PayloadProps) {
return (
<div className="flex flex-col gap-1.5">
{tag && (
<FieldRow label="Tag">
<FieldRow label={t("packetAnalyzer.payload.tag")}>
<span className="text-text-normal">0x{tag.toUpperCase()}</span>
</FieldRow>
)}
{typeFilter != null && (
<div>
<span className="text-text-dim">Type Filter </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.typeFilter")} </span>
{roles.length > 0 ? (
<span className="inline-flex gap-1 flex-wrap">
{roles.map((i) => (
<FlagChip key={i} label={DEVICE_ROLE_NAMES[i] ?? `Type ${i}`} active />
<FlagChip key={i} label={DEVICE_ROLE_NAMES[i] ?? t("packetAnalyzer.payload.typeN", { value: i })} active />
))}
</span>
) : (
<span className="text-text-muted">any</span>
<span className="text-text-muted">{t("packetAnalyzer.payload.any")}</span>
)}
</div>
)}
{since != null && (
<FieldRow label="Since">
<FieldRow label={t("packetAnalyzer.payload.since")}>
<Timestamp value={since * 1000} className="text-text-normal" />
</FieldRow>
)}
{prefixOnly != null && (
<FieldRow label="Prefix Only"><BooleanValue value={prefixOnly} /></FieldRow>
<FieldRow label={t("packetAnalyzer.payload.prefixOnly")}><BooleanValue value={prefixOnly} /></FieldRow>
)}
</div>
);
}
function DiscoverRespPayload({ payload }: PayloadProps) {
const { t } = useTranslation();
const tag = payload.tag as string | undefined;
const nodeTypeName = payload.nodeTypeName as string | undefined;
const requestSnr = payload.requestSnr as number | undefined;
@@ -521,25 +535,25 @@ function DiscoverRespPayload({ payload }: PayloadProps) {
return (
<div className="flex flex-col gap-1.5">
{tag && (
<FieldRow label="Tag">
<FieldRow label={t("packetAnalyzer.payload.tag")}>
<span className="text-text-normal">0x{tag.toUpperCase()}</span>
</FieldRow>
)}
{nodeTypeName && (
<div>
<span className="text-text-dim">Node Type </span>
<span className="text-text-dim">{t("packetAnalyzer.payload.nodeType")} </span>
<Badge variant="default">{nodeTypeName}</Badge>
</div>
)}
{requestSnr != null && (
<FieldRow label="Request SNR">
<FieldRow label={t("packetAnalyzer.payload.requestSnr")}>
<span className={sigClass}>{formatSnr(requestSnr)} dB</span>
<span className="text-text-dim"> (node-to-node)</span>
<span className="text-text-dim"> {t("packetAnalyzer.payload.nodeToNode")}</span>
</FieldRow>
)}
{pubKey && (
<TruncatedHex
label={pubKeyPrefixOnly ? "Key Prefix" : "Public Key"}
label={pubKeyPrefixOnly ? t("packetAnalyzer.payload.keyPrefix") : t("packetAnalyzer.payload.publicKey")}
value={pubKey}
maxChars={32}
accentClass={FIELD_COLORS.publicKey.accent}
@@ -555,10 +569,11 @@ function ControlPayload({ payload }: PayloadProps) {
}
function RawPayload({ payload }: PayloadProps) {
const { t } = useTranslation();
// RAW packets carry the bytes under `data`; older shapes used `raw`.
const raw = (payload.raw ?? payload.data) as string | undefined;
if (!raw) return null;
return <TruncatedHex label="Raw" value={raw} maxChars={32} />;
return <TruncatedHex label={t("packetAnalyzer.payload.raw")} value={raw} maxChars={32} />;
}
// generic key-value renderer for unknown payload types
@@ -668,6 +683,7 @@ function GenericPayload({ payload }: PayloadProps) {
}
function AnonReqPayload({ payload, resolvedDestination, onViewNode }: PayloadProps & EndpointProps) {
const { t } = useTranslation();
const destination = payload.destination as number | undefined;
const ephemeralPubKey = payload.ephemeralPubKey as string | undefined;
const destLabel = destination != null ? `0x${destination.toString(16).toUpperCase().padStart(2, "0")}` : "";
@@ -675,14 +691,14 @@ function AnonReqPayload({ payload, resolvedDestination, onViewNode }: PayloadPro
return (
<div className="flex flex-col gap-2.5">
{destination != null && (
<FieldRow label="Dest Hash">
<FieldRow label={t("packetAnalyzer.payload.destHash")}>
{resolvedDestination
? <ResolvedHopBlock hop={resolvedDestination} label={destLabel} onViewNode={onViewNode} showSnr={false} />
: <span className="text-text-normal">{destLabel}</span>}
</FieldRow>
)}
{ephemeralPubKey && (
<TruncatedHex label="Ephemeral Key" value={ephemeralPubKey} accentClass={FIELD_COLORS.senderPublicKey.accent} />
<TruncatedHex label={t("packetAnalyzer.payload.ephemeralKey")} value={ephemeralPubKey} accentClass={FIELD_COLORS.senderPublicKey.accent} />
)}
</div>
);
+8 -8
View File
@@ -51,21 +51,21 @@ export function RouteDetailPanel({ route: listed, iata, pathKey, onClose, onAnal
return (
<DetailPanel
title="Route Detail"
title={t("routeDetail.title")}
onClose={onClose}
isLoading={!route && query.isPending && keyed}
headerAction={keyed && <CopyLinkButton label={t("investigation.copy")} copiedLabel={t("observerPage.copied")} params={() => ({ tab: "Routes", route: pathKey!, routeIata: iata!, hash: null, analyze: null, observation: null, path: null, observer: null, node: null })} />}
>
{route && <>
<Section title="Summary" first>
<Section title={t("routeDetail.summary")} first>
<div className="flex items-center gap-3 font-mono text-[13px]">
<Badge variant="default">{route.iata}</Badge>
<Field label="Hops" value={route.hopCount} />
<Field label="Heard" value={route.observationCount.toLocaleString()} />
<Field label={t("routeDetail.hops")} value={route.hopCount} />
<Field label={t("routeDetail.heard")} value={route.observationCount.toLocaleString()} />
</div>
</Section>
<Section title="Route">
<Section title={t("routeDetail.route")}>
<div className="flex flex-col gap-1.5">
{route.hops.map((hop, i) => {
const resolved: ResolvedHop = { confidence: "high", nodes: hop.node ? [hop.node] : [] };
@@ -80,10 +80,10 @@ export function RouteDetailPanel({ route: listed, iata, pathKey, onClose, onAnal
</div>
</Section>
<Section title="Timestamps">
<Section title={t("routeDetail.timestamps")}>
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
<Field label="First seen" value={<Timestamp value={route.firstSeen} />} />
<Field label="Last seen" value={<Timestamp value={route.lastSeen} />} />
<Field label={t("routeDetail.firstSeen")} value={<Timestamp value={route.firstSeen} />} />
<Field label={t("routeDetail.lastSeen")} value={<Timestamp value={route.lastSeen} />} />
</div>
</Section>
</>}
+40 -25
View File
@@ -1,5 +1,7 @@
import { useState, useMemo, useCallback, useEffect, useRef, memo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useSearchParams } from "react-router-dom";
import { getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getIatas } from "../../api/client";
import { useRegion, useRegionSelection } from "../../hooks/useRegion";
@@ -52,6 +54,7 @@ const RouteHopChain = memo(function RouteHopChain({ route }: { route: KnownRoute
// A cross-IATA route: source segment → boundary hop (the two nodes that bridge the IATAs) → target segment.
function CrossRouteCard({ route }: { route: CrossIATARoute }) {
const { t } = useTranslation();
const { crossHop } = route;
return (
<div className="bg-bg-base border border-border rounded px-3 py-2 flex flex-col gap-1.5">
@@ -61,7 +64,7 @@ function CrossRouteCard({ route }: { route: CrossIATARoute }) {
<span className="text-text-dim" aria-hidden>→</span>
<Badge variant="default">{crossHop.toIata}</Badge>
</div>
<span className="font-mono text-[11px] text-text-dim">{route.totalHops} hops</span>
<span className="font-mono text-[11px] text-text-dim">{t("routes.hops", { count: route.totalHops })}</span>
</div>
<div className="flex flex-wrap items-center gap-1 font-mono text-[13px]">
<HopChain hops={route.sourceSegment} />
@@ -76,53 +79,61 @@ function CrossRouteCard({ route }: { route: CrossIATARoute }) {
);
}
const COLUMNS: Column<KnownRoute>[] = [
// ids keep sorting stable when the headers are translated
const buildColumns = (t: TFunction): Column<KnownRoute>[] => [
{
header: "Area",
id: "area",
header: t("routes.columns.area"),
sortValue: (r) => r.iata,
cell: (r) => <Badge variant="default">{r.iata}</Badge>,
},
{
header: "Hops",
id: "hops",
header: t("routes.columns.hops"),
sortValue: (r) => r.hopCount,
cell: (r) => r.hopCount,
},
{
header: "Route",
id: "route",
header: t("routes.columns.route"),
cell: (r) => <RouteHopChain route={r} />,
},
{
header: "Obs",
id: "obs",
header: t("routes.columns.obs"),
className: "text-text-muted",
sortValue: (r) => r.observationCount,
cell: (r) => r.observationCount.toLocaleString(),
},
{
header: "First seen",
id: "firstSeen",
header: t("routes.columns.firstSeen"),
className: "text-text-muted",
sortValue: (r) => r.firstSeen,
cell: (r) => <Timestamp value={r.firstSeen} />,
},
{
header: "Last seen",
id: "lastSeen",
header: t("routes.columns.lastSeen"),
className: "text-text-muted",
sortValue: (r) => r.lastSeen,
cell: (r) => <Timestamp value={r.lastSeen} />,
},
];
function renderRouteCard(r: KnownRoute) {
function RouteCard({ route: r }: { route: KnownRoute }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Badge variant="default">{r.iata}</Badge>
<span className="font-mono text-[11px] text-text-dim">{r.hopCount} hops · {r.observationCount.toLocaleString()} obs</span>
<span className="font-mono text-[11px] text-text-dim">{t("routes.hops", { count: r.hopCount })} · {t("routes.obs", { count: r.observationCount, value: r.observationCount.toLocaleString() })}</span>
</div>
<RouteHopChain route={r} />
<div className="flex items-center gap-2 font-mono text-[11px] text-text-muted">
<span>first <Timestamp value={r.firstSeen} /></span>
<span>{t("routes.first")} <Timestamp value={r.firstSeen} /></span>
<span aria-hidden>·</span>
<span>last <Timestamp value={r.lastSeen} /></span>
<span>{t("routes.last")} <Timestamp value={r.lastSeen} /></span>
</div>
</div>
);
@@ -142,7 +153,11 @@ function directedPairs(iatas: string[]): [string, string][] {
return pairs;
}
const renderRouteCard = (r: KnownRoute) => <RouteCard route={r} />;
export function RouteTable(actions: RouteActions) {
const { t } = useTranslation();
const columns = useMemo(() => buildColumns(t), [t]);
const { iatas } = useRegion();
const { selection } = useRegionSelection();
const [params, setParams] = useSearchParams();
@@ -280,11 +295,11 @@ export function RouteTable(actions: RouteActions) {
{/* stacks into two rows on mobile (the inputs would otherwise wrap around the arrow); one row at md+ */}
<div className={`${panelOpen ? "hidden md:flex" : "flex"} flex-col md:flex-row md:flex-wrap md:items-center gap-1.5 gap-y-1.5 px-4 py-2 border-b border-border-subtle bg-bg-base shrink-0`}>
<div className="flex items-center gap-1.5">
<span className="hidden md:inline text-text-muted text-[11px] uppercase tracking-wider mr-1 shrink-0">Find path</span>
<span className="hidden md:inline text-text-muted text-[11px] uppercase tracking-wider mr-1 shrink-0">{t("routes.findPath")}</span>
<input
className={`${inputClass} h-7 flex-1 min-w-0 md:flex-none md:w-24`}
placeholder="from hash"
aria-label="From hash"
placeholder={t("routes.fromPlaceholder")}
aria-label={t("routes.fromLabel")}
value={from}
onChange={(e) => setFrom(e.target.value)}
onKeyDown={onKeyDown}
@@ -292,8 +307,8 @@ export function RouteTable(actions: RouteActions) {
<span className="text-text-dim text-xs shrink-0" aria-hidden>→</span>
<input
className={`${inputClass} h-7 flex-1 min-w-0 md:flex-none md:w-24`}
placeholder="to hash"
aria-label="To hash"
placeholder={t("routes.toPlaceholder")}
aria-label={t("routes.toLabel")}
value={to}
onChange={(e) => setTo(e.target.value)}
onKeyDown={onKeyDown}
@@ -302,7 +317,7 @@ export function RouteTable(actions: RouteActions) {
<div className="flex items-center gap-1.5">
<div className="flex-1 min-w-0 md:flex-none">
<MultiSelectDropdown
label="Areas"
label={t("routes.areas")}
options={iataOptions}
selected={searchIatas}
onChange={setSearchIatas}
@@ -316,7 +331,7 @@ export function RouteTable(actions: RouteActions) {
disabled={!canSearch}
className="h-7 text-[11px] font-mono px-3 rounded-sm border border-border bg-bg-surface text-text-normal hover:border-primary-dim disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
>
Search
{t("routes.search")}
</button>
{search && (
<button
@@ -324,7 +339,7 @@ export function RouteTable(actions: RouteActions) {
onClick={clearSearch}
className="h-7 text-[11px] font-mono px-2 rounded-sm text-text-dim hover:text-text-normal cursor-pointer transition-colors"
>
Clear
{t("routes.clear")}
</button>
)}
</div>
@@ -334,24 +349,24 @@ export function RouteTable(actions: RouteActions) {
{isCross ? (
<div className="flex-1 min-w-0 overflow-y-auto p-3 flex flex-col gap-2">
{crossLoading ? (
<div className="font-mono text-[13px] text-text-dim">Searching…</div>
<div className="font-mono text-[13px] text-text-dim">{t("routes.searching")}</div>
) : crossRoutes && crossRoutes.length > 0 ? (
crossRoutes.map((r, i) => <CrossRouteCard key={i} route={r} />)
) : (
<div className="font-mono text-[13px] text-text-dim">No routes between these areas</div>
<div className="font-mono text-[13px] text-text-dim">{t("routes.noCross")}</div>
)}
</div>
) : (
<div className={`relative flex-1 min-w-0 ${panelOpen ? "hidden md:flex" : "flex"} flex-col min-h-0`}>
<DataTable
columns={COLUMNS}
columns={columns}
rows={rows}
rowKey={(r) => String(r.id)}
selectedKey={pathKey ? String(rows?.find(row => row.pathKey === pathKey && row.iata === routeIata)?.id ?? "") : selectedKey}
onSelect={selectRoute}
isLoading={search ? searchLoading : listLoading}
emptyLabel={search ? "No matching routes" : "No routes"}
defaultSort={{ header: "Last seen", direction: "desc" }}
emptyLabel={t(search ? "routes.noMatches" : "routes.empty")}
defaultSort={{ id: "lastSeen", direction: "desc" }}
onEndReached={search ? undefined : loadMore}
renderCard={renderRouteCard}
/>
+26 -25
View File
@@ -15,10 +15,10 @@ const labelClass = "font-mono text-[10px] font-semibold uppercase tracking-wider
function validation(value: Selection): string | null {
if (!OBSERVER_UUID.test(value.observerA) || !OBSERVER_UUID.test(value.observerB) || value.observerA.toLowerCase() === value.observerB.toLowerCase()) {
return "Choose two different observers.";
return "observerCompare.invalidPair";
}
if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) {
return "Choose a valid start and a later end time.";
return "observerCompare.invalidRange";
}
return null;
}
@@ -32,6 +32,7 @@ function localTime(ms: number) {
}
function ObserverSelect({ label, value, onChange, excludeId }: { label: string; value: string; onChange: (id: string) => void; excludeId?: string }) {
const { t } = useTranslation();
const selected = useQuery({
queryKey: ["observer", value], queryFn: () => getObserver(value),
enabled: OBSERVER_UUID.test(value), staleTime: 30_000, retry: false,
@@ -39,7 +40,7 @@ function ObserverSelect({ label, value, onChange, excludeId }: { label: string;
return (
<div className="flex min-w-0 flex-col gap-1.5">
<span className={labelClass}>{label}</span>
<ObserverPicker label={label} id={value} name={value ? selected.data?.displayName ?? value.slice(0, 8) : "Choose an observer"} excludeId={excludeId} onSelect={onChange} />
<ObserverPicker label={label} id={value} name={value ? selected.data?.displayName ?? value.slice(0, 8) : t("observerCompare.chooseObserver")} excludeId={excludeId} onSelect={onChange} />
</div>
);
}
@@ -62,18 +63,18 @@ function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onC
return (
<form onSubmit={submit} className="space-y-3" noValidate>
<div className="grid gap-3 md:grid-cols-2">
<ObserverSelect label="Observer A" value={a} onChange={setA} excludeId={b} />
<ObserverSelect label="Observer B" value={b} onChange={setB} excludeId={a} />
<label className="flex min-w-0 flex-col gap-1.5"><span className={labelClass}>Start (local time)</span>
<ObserverSelect label={t("observerCompare.observerA")} value={a} onChange={setA} excludeId={b} />
<ObserverSelect label={t("observerCompare.partner")} value={b} onChange={setB} excludeId={a} />
<label className="flex min-w-0 flex-col gap-1.5"><span className={labelClass}>{t("observerCompare.start")}</span>
<input type="datetime-local" step="60" value={since} onChange={(e) => setSince(e.target.value)} className={fieldClass} />
</label>
<label className="flex min-w-0 flex-col gap-1.5"><span className={labelClass}>End (local time)</span>
<label className="flex min-w-0 flex-col gap-1.5"><span className={labelClass}>{t("observerCompare.end")}</span>
<input type="datetime-local" step="60" value={until} onChange={(e) => setUntil(e.target.value)} className={fieldClass} />
</label>
</div>
{new Date(since).getTime() < retainedSince && <p className="text-sm text-warn">Only recent packets are kept, so the start of this window may be empty.</p>}
{error && <p role="alert" className="text-sm text-danger">{t(error, { defaultValue: error })}</p>}
<button type="submit" className="rounded border border-primary-dim bg-primary/10 px-3 py-1.5 font-mono text-xs font-semibold text-primary hover:bg-primary/15">Compare</button>
{new Date(since).getTime() < retainedSince && <p className="text-sm text-warn">{t("observerCompare.retentionWarning")}</p>}
{error && <p role="alert" className="text-sm text-danger">{t(error)}</p>}
<button type="submit" className="rounded border border-primary-dim bg-primary/10 px-3 py-1.5 font-mono text-xs font-semibold text-primary hover:bg-primary/15">{t("observerCompare.compare")}</button>
</form>
);
}
@@ -118,28 +119,28 @@ export function CompareObserversTab() {
}
const data = result.data;
const groups = data ? [
{ name: "Only A", count: data.onlyA, color: "var(--color-primary)" },
{ name: "Both", count: data.both, color: "var(--color-green)" },
{ name: "Only B", count: data.onlyB, color: "var(--color-secondary)" },
{ name: t("observerCompare.onlyA"), count: data.onlyA, color: "var(--color-primary)" },
{ name: t("observerCompare.both"), count: data.both, color: "var(--color-green)" },
{ name: t("observerCompare.onlyB"), count: data.onlyB, color: "var(--color-secondary)" },
] : [];
return (
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="flex items-center gap-2"><h2 className="text-lg font-semibold text-text-bright">Compare observers</h2><InfoTip text={t("observerCompare.retainedWindow")} /></div>
<Card title="Selection">
{supplied && !valid && <p role="alert" className="mb-3 text-sm text-danger">This comparison link has invalid or missing values. Choose observers and dates below.</p>}
<div className="flex items-center gap-2"><h2 className="text-lg font-semibold text-text-bright">{t("observerCompare.title")}</h2><InfoTip text={t("observerCompare.retainedWindow")} /></div>
<Card title={t("observerCompare.selection")}>
{supplied && !valid && <p role="alert" className="mb-3 text-sm text-danger">{t("observerCompare.invalidLink")}</p>}
<ComparisonForm key={keys.map((key) => params.get(key)).join("|")} initial={selection} onCompare={compare} />
</Card>
{selection && <Card title="Flood packets heard">
<p className="mb-2 break-words text-sm text-text-normal">A: {observerA.data?.displayName ?? selection.observerA} · B: {observerB.data?.displayName ?? selection.observerB}</p>
<p className="mb-3 flex flex-wrap items-center gap-x-2 break-words text-sm text-text-muted">{new Date(selection.since).toLocaleString()} – {new Date(selection.until).toLocaleString()} · {iatas?.join(", ") || "All"}<InfoTip text="Local time; the end is excluded." /></p>
{result.isFetching && <p role="status" className="text-sm text-text-muted">Comparing…</p>}
{result.isError && <div role="alert" className="text-sm text-danger"><p>{result.error.message}</p><button type="button" onClick={() => void result.refetch()} className="mt-2 text-primary">Retry comparison</button></div>}
{selection && <Card title={t("observerCompare.floodHeard")}>
<p className="mb-2 break-words text-sm text-text-normal">{t("observerCompare.pair", { a: observerA.data?.displayName ?? selection.observerA, b: observerB.data?.displayName ?? selection.observerB })}</p>
<p className="mb-3 flex flex-wrap items-center gap-x-2 break-words text-sm text-text-muted">{new Date(selection.since).toLocaleString()} – {new Date(selection.until).toLocaleString()} · {iatas?.join(", ") || t("observerCompare.allRegions")}<InfoTip text={t("observerCompare.localTimeNote")} /></p>
{result.isFetching && <p role="status" className="text-sm text-text-muted">{t("observerCompare.comparing")}</p>}
{result.isError && <div role="alert" className="text-sm text-danger"><p>{result.error.message}</p><button type="button" onClick={() => void result.refetch()} className="mt-2 text-primary">{t("observerCompare.retryComparison")}</button></div>}
{data && !result.isError && <>
<p className="mb-3 flex items-center gap-2 text-lg font-semibold text-text-bright">{data.totalPackets.toLocaleString()} flood packets<InfoTip text="Percentages are of all flood packets either observer heard; each packet counts once. Not a packet-loss measure." /></p>
{data.totalPackets === 0 ? <p className="text-sm text-text-muted">Neither observer heard a flood packet in this period and region.</p> : <>
<p className="mb-3 flex items-center gap-2 text-lg font-semibold text-text-bright">{t("observerCompare.packetsTotal", { count: data.totalPackets, value: data.totalPackets.toLocaleString() })}<InfoTip text={t("observerCompare.percentNote")} /></p>
{data.totalPackets === 0 ? <p className="text-sm text-text-muted">{t("observerCompare.emptyRegion")}</p> : <>
<div aria-hidden className="mb-4 flex h-5 overflow-hidden rounded">{groups.map((g) => <div key={g.name} style={{ width: `${g.count / data.totalPackets * 100}%`, background: g.color }} />)}</div>
<table className="w-full text-left text-sm tabular-nums" aria-label="Flood packet comparison">
<thead className="text-text-muted"><tr><th scope="col">Heard by</th><th scope="col" className="text-right">Packets</th><th scope="col" className="text-right">% of union</th></tr></thead>
<table className="w-full text-left text-sm tabular-nums" aria-label={t("observerCompare.tableLabel")}>
<thead className="text-text-muted"><tr><th scope="col">{t("observerCompare.heardBy")}</th><th scope="col" className="text-right">{t("observerCompare.packets")}</th><th scope="col" className="text-right">{t("observerCompare.percentUnion")}</th></tr></thead>
<tbody>{groups.map((g) => <tr key={g.name} className="border-t border-border"><th scope="row" className="py-2 font-normal text-text-normal"><span aria-hidden className="mr-2 inline-block h-2 w-2 rounded-full" style={{ background: g.color }} />{g.name}</th><td className="text-right">{g.count.toLocaleString()}</td><td className="text-right">{(g.count / data.totalPackets * 100).toFixed(1)}%</td></tr>)}</tbody>
</table>
</>}
+9 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { echarts, type EChartsInstance, type EChartsOption } from "./echarts-setup";
interface EChartProps {
@@ -16,6 +17,9 @@ interface EChartProps {
// unmount, and re-apply the (memoized) option with notMerge so theme/data swaps fully replace state.
// Hand-rolled on purpose — we avoid the echarts-for-react dependency.
export function EChart({ option, className, style, onEvents, onInit }: EChartProps) {
const { i18n } = useTranslation();
// ECharts fixes its locale at init, so a language switch rebuilds the instance
const locale = i18n.resolvedLanguage === "fr" ? "FR" : "EN";
const elRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<EChartsInstance | null>(null);
const onInitRef = useRef(onInit);
@@ -25,7 +29,7 @@ export function EChart({ option, className, style, onEvents, onInit }: EChartPro
useEffect(() => {
if (!elRef.current) return;
const chart = echarts.init(elRef.current, null, { renderer: "canvas" });
const chart = echarts.init(elRef.current, null, { renderer: "canvas", locale });
chartRef.current = chart;
onInitRef.current?.(chart);
const ro = new ResizeObserver(() => chart.resize());
@@ -35,11 +39,11 @@ export function EChart({ option, className, style, onEvents, onInit }: EChartPro
chart.dispose();
chartRef.current = null;
};
}, []);
}, [locale]);
useEffect(() => {
chartRef.current?.setOption(option, { notMerge: true });
}, [option]);
}, [option, locale]);
useEffect(() => {
const chart = chartRef.current;
@@ -47,9 +51,10 @@ export function EChart({ option, className, style, onEvents, onInit }: EChartPro
const entries = Object.entries(onEvents);
for (const [ev, handler] of entries) chart.on(ev, handler);
return () => {
if (chart.isDisposed()) return;
for (const [ev, handler] of entries) chart.off(ev, handler);
};
}, [onEvents]);
}, [onEvents, locale]);
return <div ref={elRef} className={className} style={{ width: "100%", height: "100%", ...style }} />;
}
+8 -2
View File
@@ -62,7 +62,10 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
const nodeTypesData = readyData(nodeTypes);
const obs = useMemo(() => aggregateByHour(observationsData ?? []), [observationsData]);
const obsOption = useMemo(() => observationsAreaOption(obs, colors), [obs, colors]);
const obsOption = useMemo(
() => observationsAreaOption(obs, colors, { observations: t("mesh.observations"), uniquePackets: t("charts.uniquePackets") }),
[obs, colors, t],
);
const nodeRows = useMemo(
() =>
@@ -109,7 +112,10 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
() => aggregatePresets(radioPresetsData ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })),
[radioPresetsData],
);
const presetsOption = useMemo(() => presetBarsOption(presetRows, colors), [presetRows, colors]);
const presetsOption = useMemo(
() => presetBarsOption(presetRows, colors, undefined, { nodes: t("mesh.nodes"), observers: t("mesh.observers") }),
[presetRows, colors, t],
);
const scopeRows = useMemo(
() => [...(scopesData ?? [])].sort((a, b) => b.packetCount - a.packetCount),
+14 -13
View File
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useRegion } from "../../hooks/useRegion";
import { useMapNodesData } from "../map/useMapNodesData";
import { getNodeNeighbors } from "../../api/client";
@@ -8,15 +9,14 @@ import { buildNeighbourGraph, buildEgoGraph, neighbourGraphOption } from "./neig
import { NeighbourGraph } from "./NeighbourGraph";
import { EmptyState } from "../../components/EmptyState";
import { InfoTip } from "../../components/InfoTip";
import { SearchBar, type SearchFieldOption } from "../../components/SearchBar";
import { SearchBar } from "../../components/SearchBar";
// Most-connected nodes rendered; past this the canvas force layout bogs down. Reuses the map's node
// query (same cache), so the whole region still loads — this only caps what the full mesh draws.
const CAP = 1000;
const SEARCH_FIELDS: SearchFieldOption[] = [{ value: "name", label: "Name" }];
export function NeighbourGraphTab() {
const { t } = useTranslation();
const { iatas, regionKey } = useRegion();
// "All regions" is 5k+ nodes — too heavy for the canvas force layout, so gate the fetch off and
// prompt for a region instead of freezing the browser.
@@ -62,34 +62,35 @@ export function NeighbourGraphTab() {
return buildEgoGraph(center, neighbours, dataUpdatedAt);
}, [selectedId, selectedNode, neighbours, dataUpdatedAt]);
const option = useMemo(() => neighbourGraphOption(ego ?? graph, colors, { ego: !!ego }), [ego, graph, colors]);
const option = useMemo(() => neighbourGraphOption(ego ?? graph, colors, t, { ego: !!ego }), [ego, graph, colors, t]);
const searchFields = useMemo(() => [{ value: "name", label: t("neighbourGraph.searchName") }], [t]);
if (isAll)
return (
<EmptyState
title="Pick a region"
subtitle="All regions is 5,000+ nodes — choose a region from the REGION picker above, or narrow to a single area, to view its mesh."
title={t("neighbourGraph.pickRegion")}
subtitle={t("neighbourGraph.allTooBig")}
/>
);
if (isError) return <EmptyState title="Neighbor graph" subtitle="Failed to load nodes" />;
if (isError) return <EmptyState title={t("stats.tabs.graph")} subtitle={t("neighbourGraph.loadFailed")} />;
// build only once the pager settles, or the force layout would restart on every streamed page
if (isPaging) return <EmptyState title="Loading mesh…" subtitle={`${loadedCount} nodes`} />;
if (graph.nodes.length === 0) return <EmptyState title="Neighbor graph" subtitle="No nodes in this region" />;
if (isPaging) return <EmptyState title={t("neighbourGraph.loadingMesh")} subtitle={t("neighbourGraph.nodes", { count: loadedCount })} />;
if (graph.nodes.length === 0) return <EmptyState title={t("stats.tabs.graph")} subtitle={t("neighbourGraph.noNodes")} />;
return (
<div className="flex h-full min-h-0 flex-col">
{ego ? (
<div className="shrink-0 border-b border-border bg-bg-surface px-4 py-2 text-center text-xs font-mono text-text-muted">
Neighborhood of <span className="text-text-normal">{selectedNode?.name ?? selectedId}</span> · {ego.nodes.length - 1} neighbors <InfoTip text="Click empty space for the full mesh." />
{t("neighbourGraph.neighborhoodOf")} <span className="text-text-normal">{selectedNode?.name ?? selectedId}</span> · {t("neighbourGraph.neighbors", { count: ego.nodes.length - 1 })} <InfoTip text={t("neighbourGraph.clickEmpty")} />
</div>
) : (
<div className="flex shrink-0 items-center gap-3 border-b border-border bg-bg-surface px-4 py-2">
<span className="flex items-center gap-2 text-xs font-mono text-text-muted">
{graph.capped ? `Showing ${CAP} of ${graph.total} nodes` : `${graph.total} nodes`}
{graph.capped && <InfoTip text="Narrow to a single area for the rest." />}
{graph.capped ? t("neighbourGraph.showing", { cap: CAP, total: graph.total }) : t("neighbourGraph.nodes", { count: graph.total })}
{graph.capped && <InfoTip text={t("neighbourGraph.narrow")} />}
</span>
<div className="ml-auto">
<SearchBar value={search} onChange={setSearch} fields={SEARCH_FIELDS} field={searchField} onFieldChange={setSearchField} />
<SearchBar value={search} onChange={setSearch} fields={searchFields} field={searchField} onFieldChange={setSearchField} />
</div>
</div>
)}
+2 -2
View File
@@ -88,8 +88,8 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison,
[heardData, intervalMs, heardWindow],
);
const busy = useMemo(
() => busyOption(heard, colors, intervalMs, heardWindow),
[heard, colors, intervalMs, heardWindow],
() => busyOption(heard, colors, intervalMs, heardWindow, t("charts.busy")),
[heard, colors, intervalMs, heardWindow, t],
);
const heardCount = useMemo(
() => heardOption(heard, colors, heardWindow, t("observerPage.packets")),
+12 -9
View File
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useChartColors } from "./chartTheme";
import { useTopAdvertisers, useTopTalkers } from "./useStats";
import { leaderboardOption } from "./chartOptions";
@@ -23,6 +24,7 @@ function leaderboardHeight(count: number) {
// The "noisy nodes, politely" tab: who's loudest by adverts and by channel chatter. Advertisers list
// their flood/direct advert split with a per-day rate; talkers are grouped by sender display-name.
export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
const { t } = useTranslation();
const colors = useChartColors();
const topAdvertisers = useTopAdvertisers(range, 20);
const topTalkers = useTopTalkers(range, 20);
@@ -42,7 +44,7 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
);
return [
{
header: "Node",
header: t("talkers.node"),
cell: (a) => (
<div className="flex min-w-0 items-center gap-2">
<span className={`truncate ${a.nodeName ? "text-text-normal" : "italic text-text-dim"}`}>
@@ -54,20 +56,21 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
),
sortValue: (a) => a.nodeName ?? a.nodeId,
},
{ header: "Flood", className: "tabular-nums", cell: (a) => split(a.floodAdvertCount), sortValue: (a) => a.floodAdvertCount },
{ header: "Direct", className: "tabular-nums", cell: (a) => split(a.directAdvertCount), sortValue: (a) => a.directAdvertCount },
{ header: t("talkers.flood"), className: "tabular-nums", cell: (a) => split(a.floodAdvertCount), sortValue: (a) => a.floodAdvertCount },
{ header: t("talkers.direct"), className: "tabular-nums", cell: (a) => split(a.directAdvertCount), sortValue: (a) => a.directAdvertCount },
];
}, [range]);
}, [range, t]);
const talkerRows = useMemo(
() => (talkersUnavailable ? [] : (topTalkers.data ?? [])).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })),
() => (talkersUnavailable ? [] : (topTalkers.data ?? [])).map((row) => ({ name: row.senderName, value: row.messageCount, color: colors.secondary })),
[topTalkers.data, colors, talkersUnavailable],
);
const talkersOption = useMemo(() => leaderboardOption(talkerRows, colors), [talkerRows, colors]);
const rangeLabel = t(`stats.ranges.${range}`);
return (
<div className="mx-auto grid w-full min-w-0 max-w-[1200px] grid-cols-1 items-start gap-3.5 p-4 lg:grid-cols-2">
<Card title={<>Top advertisers · {range}</>} right={<span className="font-mono text-[10px] text-text-muted">flood · direct</span>}>
<Card title={t("talkers.topAdvertisers", { range: rangeLabel })} right={<span className="font-mono text-[10px] text-text-muted">{t("talkers.floodDirect")}</span>}>
<div className="flex flex-col" style={{ height: leaderboardHeight(advertisers.length) }}>
<DataTable
columns={advertiserColumns}
@@ -76,13 +79,13 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
selectedKey={null}
onSelect={(id) => { if (id) onViewNode?.(id); }}
isLoading={advertisersLoading}
emptyLabel={topAdvertisers.isError ? "Failed to load" : "No advertisers"}
emptyLabel={topAdvertisers.isError ? t("common.loadFailed") : t("talkers.noAdvertisers")}
/>
</div>
</Card>
<ChartCard
title={<>Top talkers · {range}</>}
right={<span className="font-mono text-[10px] text-text-muted">by name</span>}
title={t("talkers.topTalkers", { range: rangeLabel })}
right={<span className="font-mono text-[10px] text-text-muted">{t("talkers.byName")}</span>}
height={leaderboardHeight(talkerRows.length)}
option={talkersOption}
isLoading={talkersLoading}
+10 -8
View File
@@ -32,6 +32,7 @@ function valueAxis(c: ChartColors, extra: Record<string, unknown> = {}) {
export function observationsAreaOption(
points: { hour: number; observationCount: number; uniquePackets: number }[],
c: ChartColors,
labels = { observations: "Observations", uniquePackets: "Unique packets" },
): EChartsOption {
const obs = points.map((p) => [p.hour, p.observationCount]);
const uniq = points.map((p) => [p.hour, p.uniquePackets]);
@@ -41,7 +42,7 @@ export function observationsAreaOption(
grid: { left: 48, right: 14, top: 12, bottom: 24 },
tooltip: { trigger: "axis", ...tooltipStyle(c), axisPointer: { type: "line", lineStyle: { color: c.primary } } },
legend: {
data: ["Observations", "Unique packets"],
data: [labels.observations, labels.uniquePackets],
right: 8,
top: 0,
itemWidth: 10,
@@ -53,7 +54,7 @@ export function observationsAreaOption(
yAxis: valueAxis(c),
series: [
{
name: "Observations",
name: labels.observations,
type: "line",
smooth: true,
symbol: "none",
@@ -72,7 +73,7 @@ export function observationsAreaOption(
},
},
{
name: "Unique packets",
name: labels.uniquePackets,
type: "line",
smooth: true,
symbol: "none",
@@ -154,6 +155,7 @@ export function presetBarsOption(
rows: { name: string; nodes: number; observers: number }[],
c: ChartColors,
gridLeft = 172, // fits a full "910.525 · 62.5k · SF7" label
labels = { nodes: "Nodes", observers: "Observers" },
): EChartsOption {
const totals = rows.map((r) => r.nodes + r.observers);
const segment = (data: number[], color: string) => ({
@@ -170,7 +172,7 @@ export function presetBarsOption(
grid: { left: gridLeft, right: 56, top: 22, bottom: 6 },
tooltip: { trigger: "axis", ...tooltipStyle(c), axisPointer: { type: "shadow" } },
legend: {
data: ["Nodes", "Observers"],
data: [labels.nodes, labels.observers],
right: 8,
top: 0,
itemWidth: 10,
@@ -196,9 +198,9 @@ export function presetBarsOption(
},
},
series: [
{ name: "Nodes", ...segment(rows.map((r) => r.nodes), c.primary) },
{ name: labels.nodes, ...segment(rows.map((r) => r.nodes), c.primary) },
{
name: "Observers",
name: labels.observers,
...segment(rows.map((r) => r.observers), c.secondary),
// outer segment carries the row total so it sits at the end of the whole stack
label: {
@@ -401,7 +403,7 @@ function busySpanMs(t: number, intervalMs: number, w: TimeWindow): number {
return t < w.end && t + intervalMs > w.end ? w.end - t : intervalMs;
}
export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs: number | null, w: TimeWindow): EChartsOption {
export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs: number | null, w: TimeWindow, name = "Busy"): EChartsOption {
const pct = (p: ActivityPoint) => (intervalMs == null ? null : busyPct(p.airtimeMs, busySpanMs(p.t, intervalMs, w)));
return {
animation: false,
@@ -412,7 +414,7 @@ export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs:
yAxis: percentAxis(c),
series: [
{
name: "Busy",
name,
type: "line",
symbol: "none",
connectNulls: false,
+5
View File
@@ -0,0 +1,5 @@
// echarts ships no typings next to its ESM locale files
declare module "echarts/lib/i18n/langFR.js" {
const lang: Parameters<typeof import("echarts/core").registerLocale>[1];
export default lang;
}
+4
View File
@@ -16,6 +16,7 @@ import {
AriaComponent,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import langFR from "echarts/lib/i18n/langFR.js";
echarts.use([
LineChart,
@@ -36,6 +37,9 @@ echarts.use([
CanvasRenderer,
]);
// time-axis month/day names; EN is built in
echarts.registerLocale("FR", langFR);
export { echarts };
export type EChartsInstance = ReturnType<typeof echarts.init>;
export type EChartsOption = Parameters<EChartsInstance["setOption"]>[0];
+10 -8
View File
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import type { NodeSummary, NodeNeighbor } from "../nodes/types";
import { NODE_TYPE_NAMES, NODE_TYPES } from "../../lib/node-types";
import { blend, nodeTypeColor, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme";
@@ -172,10 +173,10 @@ export function buildEgoGraph(
// One legend/category per device type (in NODE_TYPES order) plus an "Other" bucket for unknowns; the
// GraphNode.category index lines up with this list.
function graphCategories(c: ChartColors) {
function graphCategories(c: ChartColors, t: TFunction) {
return [
...NODE_TYPES.map((t) => ({ name: t.label, itemStyle: { color: nodeTypeColor(t.name, c) } })),
{ name: "Other", itemStyle: { color: c.primaryDim } },
...NODE_TYPES.map((type) => ({ name: t(`nodeTypes.${type.name}`), itemStyle: { color: nodeTypeColor(type.name, c) } })),
{ name: t("neighbourGraph.other"), itemStyle: { color: c.primaryDim } },
];
}
@@ -185,6 +186,7 @@ function graphCategories(c: ChartColors) {
export function neighbourGraphOption(
graph: NeighbourGraph,
c: ChartColors,
t: TFunction,
opts: { ego?: boolean } = {},
): EChartsOption {
const ego = !!opts.ego;
@@ -207,16 +209,16 @@ export function neighbourGraphOption(
const obs = param.data.obs as number | undefined;
if (obs == null) return ""; // uniform mesh edge — nothing to show
const days = Math.round((param.data.ageDays as number) ?? 0);
return `${obs} obs · ${days === 0 ? "seen today" : `seen ${days}d ago`}`;
return days === 0 ? t("neighbourGraph.edgeToday", { obs }) : t("neighbourGraph.edgeAgo", { obs, days });
}
const d = param.data as unknown as GraphNode;
const type = d.nodeTypeName || "unknown";
return d.degree > 0 ? `${d.name}\n${type} · ${d.degree} neighbor${d.degree === 1 ? "" : "s"}` : `${d.name}\n${type}`;
const type = d.nodeTypeName || t("neighbourGraph.unknownType");
return d.degree > 0 ? `${d.name}\n${type} · ${t("neighbourGraph.neighbors", { count: d.degree })}` : `${d.name}\n${type}`;
},
},
legend: [
{
data: graphCategories(c).map((cat) => cat.name),
data: graphCategories(c, t).map((cat) => cat.name),
bottom: 4,
left: "center",
icon: "circle",
@@ -233,7 +235,7 @@ export function neighbourGraphOption(
roam: true,
draggable: true,
scaleLimit: { min: 0.2, max: 8 },
categories: graphCategories(c),
categories: graphCategories(c, t),
force: ego
? { repulsion: 320, edgeLength: 120, gravity: 0.05, friction: 0.15, layoutAnimation: true }
: { repulsion: big ? 60 : 120, edgeLength: big ? [20, 60] : [40, 90], gravity: 0.08, friction: 0.2, layoutAnimation: !big },
+12 -8
View File
@@ -1,4 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getTraceDetail } from "../../api/client";
import { DetailPanel, Section, Field } from "../../components/DetailPanel";
import { Badge } from "../../components/Badge";
@@ -17,7 +18,8 @@ function TraceHopChain({ rawPath, resolvedRoute, onViewNode }: {
resolvedRoute: ResolvedHop[];
onViewNode?: (nodeId: string) => void;
}) {
if (rawPath.length === 0) return <span className="text-text-dim text-[11px] font-mono">no path</span>;
const { t } = useTranslation();
if (rawPath.length === 0) return <span className="text-text-dim text-[11px] font-mono">{t("traces.noPath")}</span>;
return (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-2 font-mono text-[13px]">
{rawPath.map((raw, i) => {
@@ -49,6 +51,7 @@ function TracePacketRow({ pkt, onAnalyze, onViewNode }: {
onAnalyze: (hash: string) => void;
onViewNode?: (nodeId: string) => void;
}) {
const { t } = useTranslation();
// A div, not a button: the hop popover nests clickable node buttons, so the row can't itself be a
// button. Mirrors TraceTagCard's role/tabIndex/onKeyDown; hop clicks stopPropagation so they don't analyze.
return (
@@ -65,13 +68,13 @@ function TracePacketRow({ pkt, onAnalyze, onViewNode }: {
}}
>
<div className="flex items-center gap-2 text-[11px] text-text-dim">
<Badge variant="default">{pkt.routeTypeName || "Unknown"}</Badge>
<Badge variant="default">{pkt.routeTypeName || t("traces.unknown")}</Badge>
{pkt.scope && <ScopeTag>{pkt.scope}</ScopeTag>}
<span className="ml-auto font-mono text-text-dim">analyze →</span>
<span className="ml-auto font-mono text-text-dim">{t("traces.analyze")}</span>
</div>
<div className="mt-1.5 flex flex-col gap-0.5 font-mono text-[11px]">
<Field label="First" value={<Timestamp value={pkt.firstHeardAt} ms />} />
<Field label="Last" value={<Timestamp value={pkt.lastHeardAt} ms />} />
<Field label={t("traces.first")} value={<Timestamp value={pkt.firstHeardAt} ms />} />
<Field label={t("traces.last")} value={<Timestamp value={pkt.lastHeardAt} ms />} />
</div>
<div className="mt-1.5">
<TraceHopChain rawPath={pkt.rawPath} resolvedRoute={pkt.resolvedRoute} onViewNode={onViewNode} />
@@ -91,6 +94,7 @@ interface TraceDetailPanelProps {
// packets stand in for the packet analyzer's "Observations": a "Packets" section listing each packet,
// any of which opens the packet analyzer.
export function TraceDetailPanel({ tag, onClose, onAnalyze, onViewNode }: TraceDetailPanelProps) {
const { t } = useTranslation();
const { data: detail, isLoading } = useQuery({
queryKey: ["trace", tag],
queryFn: () => getTraceDetail(tag),
@@ -102,18 +106,18 @@ export function TraceDetailPanel({ tag, onClose, onAnalyze, onViewNode }: TraceD
return (
<DetailPanel title={tag.toUpperCase()} onClose={onClose} isLoading={isLoading}>
<Section title="Packets" first>
<Section title={t("traces.packets")} first>
{packets.length > 0 ? (
<div className="flex flex-col gap-2">
<span className="text-text-dim text-[11px] font-mono">
{packets.length} packet{packets.length === 1 ? "" : "s"}
{t("traces.packetCount", { count: packets.length })}
</span>
{packets.map((pkt) => (
<TracePacketRow key={pkt.packetHash} pkt={pkt} onAnalyze={onAnalyze} onViewNode={onViewNode} />
))}
</div>
) : (
<span className="text-text-dim text-[11px] font-mono">No packets</span>
<span className="text-text-dim text-[11px] font-mono">{t("traces.noPackets")}</span>
)}
</Section>
</DetailPanel>
+17 -15
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getTraces } from "../../api/client";
import { useRegion } from "../../hooks/useRegion";
import { SkeletonRows } from "../../components/SkeletonRows";
@@ -15,13 +16,6 @@ import type { TraceTagSummary, TraceType } from "../../types/api";
// the card list (the /traces cursor is sound if pagination is ever needed).
const TRACE_LIST_LIMIT = 200;
// "" = both; the backend takes TRACE or PING and omits the param to mean all.
const TYPE_OPTIONS = [
{ value: "", label: "All" },
{ value: "TRACE", label: "Trace" },
{ value: "PING", label: "Ping" },
];
interface TraceListProps {
onAnalyze: (hash: string | null) => void;
onViewNode?: (nodeId: string) => void;
@@ -63,6 +57,7 @@ function TraceTagCard({ tag, selected, onSelect }: {
selected: boolean;
onSelect: (tag: string) => void;
}) {
const { t } = useTranslation();
return (
<div
className={`bg-bg-surface border rounded-md px-3.5 py-2.5 cursor-pointer ${
@@ -85,7 +80,7 @@ function TraceTagCard({ tag, selected, onSelect }: {
<Timestamp value={tag.lastHeardAt} className="ml-auto text-[11px] text-text-dim" />
</div>
<div className="mt-1 text-[11px] text-text-dim font-mono">
{tag.packetCount} pkt · {tag.iataCount} iata
{t("traces.pkt", { count: tag.packetCount })} · {t("traces.iata", { count: tag.iataCount })}
</div>
{tag.pathHashes?.length ? <TracePathPreview hashes={tag.pathHashes} snrs={tag.snrValues ?? []} /> : null}
</div>
@@ -93,7 +88,14 @@ function TraceTagCard({ tag, selected, onSelect }: {
}
export function TraceList({ onAnalyze, onViewNode }: TraceListProps) {
const { t } = useTranslation();
const { iatas, regionKey } = useRegion();
// "" = both; the backend takes TRACE or PING and omits the param to mean all.
const typeOptions = useMemo(() => [
{ value: "", label: t("traces.all") },
{ value: "TRACE", label: t("traces.trace") },
{ value: "PING", label: t("traces.ping") },
], [t]);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<"" | TraceType>("");
@@ -117,23 +119,23 @@ export function TraceList({ onAnalyze, onViewNode }: TraceListProps) {
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
<span className="font-mono text-[11px] text-text-dim">
{tags ? `${tags.length} tag${tags.length === 1 ? "" : "s"}` : ""}
{tags ? t("traces.tagCount", { count: tags.length }) : ""}
</span>
<Segmented
options={TYPE_OPTIONS}
options={typeOptions}
value={typeFilter}
onChange={(v) => setTypeFilter(v as "" | TraceType)}
ariaLabel="Trace type"
ariaLabel={t("traces.typeLabel")}
/>
</div>
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
{isLoading ? (
<SkeletonRows rows={8} />
) : (tags?.length ?? 0) === 0 ? (
<EmptyState title="No traces" />
<EmptyState title={t("traces.empty")} />
) : (
tags!.map((t) => (
<TraceTagCard key={t.traceTag} tag={t} selected={t.traceTag === selectedTag} onSelect={setSelectedTag} />
tags!.map((tag) => (
<TraceTagCard key={tag.traceTag} tag={tag} selected={tag.traceTag === selectedTag} onSelect={setSelectedTag} />
))
)}
</div>
+475 -8
View File
@@ -3,7 +3,7 @@
"translation": {
"language": {
"label": "Language",
"help": "Choose a language. Some pages still use English."
"help": "Choose a language."
},
"tabs": {
"Packets": "Packets",
@@ -48,7 +48,26 @@
"loadFailed": "Failed to load",
"noData": "No data",
"dataUnavailable": "Data unavailable",
"info": "More information"
"info": "More information",
"closeDetailPanel": "Close detail panel",
"expandDetailPanel": "Expand detail panel",
"minimizeDetailPanel": "Minimize detail panel",
"notFound": "Not found",
"loadingDots": "Loading...",
"copyLink": "Copy link",
"copied": "Copied",
"filters": "Filters",
"closeFilters": "Close filters",
"clearAll": "Clear all",
"done": "Done",
"all": "All",
"noOptions": "No options",
"copy": "Copy",
"close": "Close",
"clear": "Clear",
"none": "None",
"noMatches": "No matches",
"filterPlaceholder": "Filter..."
},
"mesh": {
"totalPackets": "Total packets",
@@ -233,13 +252,66 @@
"chartDescription": "Counts by transport scope. Exact values for all matching scopes are listed in the table."
},
"nodeDetail": {
"viewOnMap": "View on map"
"viewOnMap": "View on map",
"title": "Node Detail",
"copyLink": "Copy node link",
"notFound": "Node not found",
"summary": "Summary",
"copyKey": "Copy public key",
"viewObserver": "View observer →",
"location": "Location",
"lat": "Lat",
"lng": "Lng",
"source": "Source",
"capabilities": "Capabilities",
"minFirmware": "Min firmware",
"multibytePaths": "Multi-byte paths",
"multibyteTraces": "Multi-byte traces",
"yes": "yes",
"no": "no",
"radio": "Radio",
"scope": "Scope",
"timestamps": "Timestamps",
"first": "First",
"last": "Last",
"advert": "Advert",
"clockDrift": "Clock drift",
"neighbors": "Neighbors",
"neighborsCount": "Neighbors ({{count}})",
"noNeighbors": "No known neighbors",
"recentPackets": "Recent packets",
"noRecentPackets": "No recent packets",
"observations_one": "{{formatted}} obs",
"observations_other": "{{formatted}} obs",
"hops": "Hops"
},
"nodes": {
"lastHeard": "last heard {{ago}}"
"lastHeard": "last heard {{ago}}",
"colName": "Name",
"colType": "Type",
"colRadio": "Radio",
"colAreas": "Areas",
"colNeighbors": "Neighbors",
"colLocation": "Location",
"observer": "Observer",
"empty": "No nodes",
"neighbors_one": "{{formatted}} neighbors",
"neighbors_other": "{{formatted}} neighbors",
"possiblyForeign": "Possibly foreign",
"possiblyForeignHint": "This node's position is outside this server's local area."
},
"packetRow": {
"noResolution": "No path resolution available"
"noResolution": "No path resolution available",
"observer": "observer",
"first": "first",
"last": "last",
"spread": "spread",
"noPath": "No resolved path to map",
"noObservations": "No observations",
"loadFailed": "Failed to load observations",
"heardBy_one": "Heard by {{count}} observer",
"heardBy_other": "Heard by {{count}} observers",
"unknown": "Unknown"
},
"clockDrift": {
"caption": "Repeaters & room servers out of sync · worst first",
@@ -354,7 +426,31 @@
"onlyB": "Only B",
"definition": "Each flood packet counts once. Not a packet-loss measure.",
"retainedWindow": "Packets are only kept for a few days, so older windows may be incomplete.",
"overlapShortRanges": "Packet overlap is available for 24h and 7d."
"overlapShortRanges": "Packet overlap is available for 24h and 7d.",
"invalidPair": "Choose two different observers.",
"invalidRange": "Choose a valid start and a later end time.",
"observerA": "Observer A",
"chooseObserver": "Choose an observer",
"start": "Start (local time)",
"end": "End (local time)",
"retentionWarning": "Only recent packets are kept, so the start of this window may be empty.",
"compare": "Compare",
"selection": "Selection",
"invalidLink": "This comparison link has invalid or missing values. Choose observers and dates below.",
"floodHeard": "Flood packets heard",
"pair": "A: {{a}} · B: {{b}}",
"allRegions": "All",
"localTimeNote": "Local time; the end is excluded.",
"comparing": "Comparing…",
"retryComparison": "Retry comparison",
"packetsTotal_one": "{{value}} flood packets",
"packetsTotal_other": "{{value}} flood packets",
"percentNote": "Percentages are of all flood packets either observer heard; each packet counts once. Not a packet-loss measure.",
"emptyRegion": "Neither observer heard a flood packet in this period and region.",
"tableLabel": "Flood packet comparison",
"heardBy": "Heard by",
"packets": "Packets",
"percentUnion": "% of union"
},
"investigation": {
"title": "Observations",
@@ -433,10 +529,381 @@
"emptyNoKey": "Messages can't be decrypted without the channel key.",
"error": "Could not load messages.",
"retry": "Retry",
"viewPacket": "View packet"
"viewPacket": "View packet",
"heardTimes_one": "Heard {{count}} time",
"heardTimes_other": "Heard {{count}} times"
},
"header": {
"settings": "Settings"
"settings": "Settings",
"github": "GitHub"
},
"channels": {
"noMatches": "No matching channels loaded.",
"loadError": "Could not load channels.",
"loadingChannels": "Loading channels...",
"retryChannels": "Retry loading channels",
"loadOthers": "Load other channels",
"loadMore": "Load more channels",
"key": "key",
"filters": "Channel filters",
"fieldName": "Name",
"fieldHash": "Hash",
"keyFilter": "Key",
"hashtagFilter": "Hashtag",
"keyKnownOption": "Known",
"keyUnknownOption": "Unknown",
"any": "Any",
"yes": "Yes",
"no": "No"
},
"map": {
"settings": "Map Settings",
"tiles": "Map Tiles",
"nodeType": "Node Type",
"nodeTypeLabel": "Node type",
"clustering": "Clustering",
"neighborLines": "Neighbor Lines",
"neighborLinesLabel": "Neighbor lines",
"areaBorders": "Area Borders",
"areaBordersLabel": "Area borders",
"all": "All",
"on": "On",
"off": "Off",
"selected": "Selected",
"areaOutline": "Area outline",
"observations": "Observations",
"fainter": "fainter = heard longer ago",
"copyLink": "Copy map link",
"copyLinkLabel": "Copy a link to this map view",
"liveMap": "Live Map",
"playFlow": "Play live map packet flow",
"stopFlow": "Stop live map packet flow",
"loadFailed": "Map failed to load",
"loadFailedHint": "Check your connection and reload",
"style": "Map style",
"styles": {
"dark": "Dark",
"liberty": "Liberty",
"positron": "Light"
},
"controls": {
"toggleAttribution": "Toggle attribution",
"mapFeedback": "Map feedback",
"map": "Map",
"resetBearing": "Drag to rotate map, click to reset north",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"closePopup": "Close popup"
}
},
"nodeFilters": {
"label": "Node filters",
"type": "Type",
"multibytePaths": "Multi-byte paths",
"multibyteTraces": "Multi-byte traces",
"scope": "Scope",
"any": "Any",
"yes": "Yes",
"no": "No",
"fieldName": "Name",
"fieldPubkey": "Public Key",
"all": "All"
},
"packetAnalyzer": {
"title": "Packet Analyzer",
"close": "Close analyzer",
"empty": "Select a packet to analyze",
"summary": "Summary",
"rawPacket": "Raw Packet",
"structure": "Packet Structure",
"headerByte": "Header Byte",
"ver": "Ver",
"type": "Type",
"route": "Route",
"transportCodes": "Transport Codes",
"scopeCode": "Scope code",
"noKnownScope": "no known scope",
"code2": "Code 2",
"pathLength": "Path Length",
"hashSize": "Hash Size",
"pathData": "Path Data",
"originPubkey": "Origin Pubkey",
"payloadBreakdown": "Payload Breakdown",
"payloadData": "Payload Data",
"bytes_one": "{{count}} bytes",
"bytes_other": "{{count}} bytes",
"bits": {
"ver": "ver",
"type": "type",
"route": "route",
"hash": "hash={{size}}B",
"hops": "hops={{value}}",
"loc": "loc",
"name": "name"
},
"payload": {
"yes": "Yes",
"no": "No",
"encrypted": "Encrypted — key not available",
"to": "To",
"from": "From",
"ciphertext": "Ciphertext",
"decrypted": "Decrypted",
"channel": "Channel",
"publicKey": "Public Key",
"timestamp": "Timestamp",
"signature": "Signature",
"appFlags": "App Flags",
"role": "Role",
"loc": "Loc",
"name": "Name",
"location": "Location",
"traceTag": "Trace Tag",
"authCode": "Auth Code",
"flags": "Flags",
"tracePath": "Trace Path",
"sender": "Sender",
"message": "Message",
"attempt": "Attempt {{value}}",
"flagsValue": "Flags {{value}}",
"data": "Data",
"time": "Time",
"tag": "Tag",
"checksum": "Checksum",
"bundledType": "Bundled Type",
"bundledData": "Bundled Data",
"typeFilter": "Type Filter",
"typeN": "Type {{value}}",
"any": "any",
"since": "Since",
"prefixOnly": "Prefix Only",
"nodeType": "Node Type",
"requestSnr": "Request SNR",
"nodeToNode": "(node-to-node)",
"keyPrefix": "Key Prefix",
"raw": "Raw",
"destHash": "Dest Hash",
"ephemeralKey": "Ephemeral Key"
}
},
"observation": {
"prop": "Prop",
"pathSnr": "Path SNR",
"path": "Path",
"observer": "Observer",
"area": "Area",
"heard": "Heard",
"hops": "Hops"
},
"packetEndpoints": {
"na": "n/a"
},
"routes": {
"columns": {
"area": "Area",
"hops": "Hops",
"route": "Route",
"obs": "Obs",
"firstSeen": "First seen",
"lastSeen": "Last seen"
},
"hops_one": "{{count}} hops",
"hops_other": "{{count}} hops",
"obs_one": "{{value}} obs",
"obs_other": "{{value}} obs",
"first": "first",
"last": "last",
"findPath": "Find path",
"fromPlaceholder": "from hash",
"fromLabel": "From hash",
"toPlaceholder": "to hash",
"toLabel": "To hash",
"areas": "Areas",
"search": "Search",
"clear": "Clear",
"searching": "Searching…",
"noCross": "No routes between these areas",
"noMatches": "No matching routes",
"empty": "No routes"
},
"routeDetail": {
"title": "Route Detail",
"summary": "Summary",
"hops": "Hops",
"heard": "Heard",
"route": "Route",
"timestamps": "Timestamps",
"firstSeen": "First seen",
"lastSeen": "Last seen"
},
"traces": {
"packets": "Packets",
"packetCount_one": "{{count}} packet",
"packetCount_other": "{{count}} packets",
"noPackets": "No packets",
"noPath": "no path",
"unknown": "Unknown",
"analyze": "analyze →",
"first": "First",
"last": "Last",
"tagCount_one": "{{count}} tag",
"tagCount_other": "{{count}} tags",
"pkt_one": "{{count}} pkt",
"pkt_other": "{{count}} pkt",
"iata_one": "{{count}} iata",
"iata_other": "{{count}} iata",
"typeLabel": "Trace type",
"all": "All",
"trace": "Trace",
"ping": "Ping",
"empty": "No traces"
},
"loadingPill": {
"nodes": {
"loading_one": "Loading nodes… ({{count}})",
"loading_other": "Loading nodes… ({{count}})",
"partial_one": "Some nodes failed to load ({{count}} shown)",
"partial_other": "Some nodes failed to load ({{count}} shown)",
"failed": "Failed to load nodes"
},
"routes": {
"loading_one": "Loading routes… ({{count}})",
"loading_other": "Loading routes… ({{count}})",
"partial_one": "Some routes failed to load ({{count}} shown)",
"partial_other": "Some routes failed to load ({{count}} shown)",
"failed": "Failed to load routes"
},
"packets": {
"loading_one": "Loading packets… ({{count}})",
"loading_other": "Loading packets… ({{count}})",
"partial_one": "Some packets failed to load ({{count}} shown)",
"partial_other": "Some packets failed to load ({{count}} shown)",
"failed": "Failed to load packets"
},
"messages": {
"loading_one": "Loading messages… ({{count}})",
"loading_other": "Loading messages… ({{count}})",
"partial_one": "Some messages failed to load ({{count}} shown)",
"partial_other": "Some messages failed to load ({{count}} shown)",
"failed": "Failed to load messages"
},
"observers": {
"loading_one": "Loading observers… ({{count}})",
"loading_other": "Loading observers… ({{count}})",
"partial_one": "Some observers failed to load ({{count}} shown)",
"partial_other": "Some observers failed to load ({{count}} shown)",
"failed": "Failed to load observers"
}
},
"talkers": {
"node": "Node",
"flood": "Flood",
"direct": "Direct",
"topAdvertisers": "Top advertisers · {{range}}",
"floodDirect": "flood · direct",
"noAdvertisers": "No advertisers",
"topTalkers": "Top talkers · {{range}}",
"byName": "by name"
},
"neighbourGraph": {
"pickRegion": "Pick a region",
"allTooBig": "All regions is 5,000+ nodes — choose a region from the REGION picker above, or narrow to a single area, to view its mesh.",
"loadFailed": "Failed to load nodes",
"loadingMesh": "Loading mesh…",
"nodes_one": "{{count}} nodes",
"nodes_other": "{{count}} nodes",
"noNodes": "No nodes in this region",
"neighborhoodOf": "Neighborhood of",
"neighbors_one": "{{count}} neighbors",
"neighbors_other": "{{count}} neighbors",
"clickEmpty": "Click empty space for the full mesh.",
"showing": "Showing {{cap}} of {{total}} nodes",
"narrow": "Narrow to a single area for the rest.",
"searchName": "Name",
"other": "Other",
"unknownType": "unknown",
"edgeToday": "{{obs}} obs · seen today",
"edgeAgo": "{{obs}} obs · seen {{days}}d ago"
},
"nodeTypes": {
"companion": "Companion",
"repeater": "Repeater",
"room_server": "Room",
"sensor": "Sensor"
},
"observerFilters": {
"label": "Observer filters",
"online": "Online",
"offline": "Offline",
"name": "Name",
"status": "Status",
"type": "Type",
"broker": "Broker",
"scope": "Scope"
},
"charts": {
"uniquePackets": "Unique packets",
"busy": "Busy"
},
"packetList": {
"pathSearchInvalid": "Enter whole hop hashes of the same length, separated by spaces, commas or → (e.g. 7f a4).",
"pathSearchHint": "Searches the latest relay path in loaded packets. Use whole hop hashes, e.g. 7f a4 (spaces, commas or →). Trace packets are excluded.",
"selectedPacket": "Selected packet",
"invalidHash": "Invalid packet hash.",
"notFound": "Packet not found.",
"loadFailed": "Failed to load selected packet.",
"retry": "Retry",
"retryLabel": "Retry selected packet",
"outsideResults": "Selected packet is outside the loaded results.",
"openAnalyzer": "Open analyzer",
"loadingSelected": "Loading selected packet…",
"dismissSelected": "Dismiss selected packet",
"dropped_one": "{{count}} packet dropped — data may be incomplete",
"dropped_other": "{{count}} packets dropped — data may be incomplete",
"dismiss": "dismiss",
"newPackets_one": "{{count}} new packet",
"newPackets_other": "{{count}} new packets",
"scrollToTop": "· scroll to top",
"live": "Live Packets",
"noMatching": "No matching packets loaded.",
"loadingPackets": "Loading packets...",
"loadOlder": "Load older packets"
},
"packetTable": {
"hash": "Hash",
"obs": "Obs",
"hops": "Hops",
"summary": "Summary / Src → Dst",
"summaryTitle": "Summary and source / destination",
"area": "Area",
"age": "Age",
"unknown": "Unknown"
},
"filters": {
"types": "Types",
"routes": "Routes",
"observers": "Observers",
"scope": "Scope",
"packetFilters": "Packet filters",
"field": {
"hash": "Hash",
"path": "Latest path",
"payload": "Payload"
}
},
"search": {
"placeholder": "Search by {{field}}..."
},
"errorBoundary": {
"message": "Something went wrong rendering this view.",
"reload": "reload",
"retry": "try again"
},
"splash": {
"tagline": "MeshCore Network Analyzer"
},
"units": {
"perDay": "{{value}}/d"
}
}
}
+506 -8
View File
@@ -3,7 +3,7 @@
"translation": {
"language": {
"label": "Langue",
"help": "Choisissez une langue. Certaines pages restent en anglais."
"help": "Choisissez une langue."
},
"tabs": {
"Packets": "Paquets",
@@ -50,7 +50,26 @@
"loadFailed": "Échec du chargement",
"noData": "Aucune donnée",
"dataUnavailable": "Données indisponibles",
"info": "Plus d’informations"
"info": "Plus d’informations",
"closeDetailPanel": "Fermer le panneau de détails",
"expandDetailPanel": "Agrandir le panneau de détails",
"minimizeDetailPanel": "Réduire le panneau de détails",
"notFound": "Introuvable",
"loadingDots": "Chargement…",
"copyLink": "Copier le lien",
"copied": "Copié",
"filters": "Filtres",
"closeFilters": "Fermer les filtres",
"clearAll": "Tout effacer",
"done": "Terminé",
"all": "Tous",
"noOptions": "Aucune option",
"copy": "Copier",
"close": "Fermer",
"clear": "Effacer",
"none": "Aucun",
"noMatches": "Aucun résultat",
"filterPlaceholder": "Filtrer…"
},
"mesh": {
"totalPackets": "Paquets totaux",
@@ -238,13 +257,69 @@
"chartDescription": "Effectifs par scope de transport. Les valeurs exactes de tous les scopes correspondants figurent dans le tableau."
},
"nodeDetail": {
"viewOnMap": "Voir sur la carte"
"viewOnMap": "Voir sur la carte",
"title": "Détail du nœud",
"copyLink": "Copier le lien du nœud",
"notFound": "Nœud introuvable",
"summary": "Résumé",
"copyKey": "Copier la clé publique",
"viewObserver": "Voir l’observateur →",
"location": "Position",
"lat": "Lat",
"lng": "Long",
"source": "Source",
"capabilities": "Capacités",
"minFirmware": "Firmware min.",
"multibytePaths": "Chemins multi-octets",
"multibyteTraces": "Traces multi-octets",
"yes": "oui",
"no": "non",
"radio": "Radio",
"scope": "Portée",
"timestamps": "Horodatages",
"first": "Premier",
"last": "Dernier",
"advert": "Annonce",
"clockDrift": "Dérive d’horloge",
"neighbors": "Voisins",
"neighborsCount": "Voisins ({{count}})",
"noNeighbors": "Aucun voisin connu",
"recentPackets": "Paquets récents",
"noRecentPackets": "Aucun paquet récent",
"observations_one": "{{formatted}} obs.",
"observations_other": "{{formatted}} obs.",
"observations_many": "{{formatted}} obs.",
"hops": "Sauts"
},
"nodes": {
"lastHeard": "dernier contact {{ago}}"
"lastHeard": "dernier contact {{ago}}",
"colName": "Nom",
"colType": "Type",
"colRadio": "Radio",
"colAreas": "Zones",
"colNeighbors": "Voisins",
"colLocation": "Position",
"observer": "Observateur",
"empty": "Aucun nœud",
"neighbors_one": "{{formatted}} voisin",
"neighbors_other": "{{formatted}} voisins",
"neighbors_many": "{{formatted}} voisins",
"possiblyForeign": "Possiblement étranger",
"possiblyForeignHint": "La position de ce nœud est hors de la zone locale de ce serveur."
},
"packetRow": {
"noResolution": "Aucune résolution de chemin disponible"
"noResolution": "Aucune résolution de chemin disponible",
"observer": "observateur",
"first": "premier",
"last": "dernier",
"spread": "écart",
"noPath": "Aucun trajet résolu à cartographier",
"noObservations": "Aucune observation",
"loadFailed": "Impossible de charger les observations",
"heardBy_one": "Reçu par {{count}} observateur",
"heardBy_many": "Reçu par {{count}} observateurs",
"heardBy_other": "Reçu par {{count}} observateurs",
"unknown": "Inconnu"
},
"clockDrift": {
"caption": "Répéteurs et serveurs de salon désynchronisés · écart maximal en premier",
@@ -353,24 +428,54 @@
"retention": "Seuls les paquets encore conservés par le serveur sont comparés.",
"total_one": "{{count}} paquet flood",
"total_other": "{{count}} paquets flood",
"total_many": "{{count}} paquets flood",
"empty": "Aucun des deux observateurs n’a reçu de paquet flood pendant cette période.",
"onlyA": "A seulement",
"both": "Les deux",
"onlyB": "B seulement",
"definition": "Chaque paquet flood compte une fois. Ce n’est pas une mesure de perte de paquets.",
"retainedWindow": "Les paquets ne sont conservés que quelques jours : les périodes plus anciennes peuvent être incomplètes.",
"overlapShortRanges": "Le chevauchement des paquets est disponible pour 24 h et 7 j."
"overlapShortRanges": "Le chevauchement des paquets est disponible pour 24 h et 7 j.",
"invalidPair": "Choisissez deux observateurs différents.",
"invalidRange": "Choisissez un début valide et une fin postérieure.",
"observerA": "Observateur A",
"chooseObserver": "Choisissez un observateur",
"start": "Début (heure locale)",
"end": "Fin (heure locale)",
"retentionWarning": "Seuls les paquets récents sont conservés : le début de cette période peut être vide.",
"compare": "Comparer",
"selection": "Sélection",
"invalidLink": "Ce lien de comparaison contient des valeurs invalides ou manquantes. Choisissez les observateurs et les dates ci-dessous.",
"floodHeard": "Paquets flood reçus",
"pair": "A : {{a}} · B : {{b}}",
"allRegions": "Toutes",
"localTimeNote": "Heure locale ; la fin est exclue.",
"comparing": "Comparaison…",
"retryComparison": "Relancer la comparaison",
"packetsTotal_one": "{{value}} paquet flood",
"packetsTotal_other": "{{value}} paquets flood",
"packetsTotal_many": "{{value}} paquets flood",
"percentNote": "Les pourcentages portent sur tous les paquets flood reçus par l’un ou l’autre observateur ; chaque paquet compte une fois. Ce n’est pas une mesure de perte de paquets.",
"emptyRegion": "Aucun des deux observateurs n’a reçu de paquet flood pendant cette période et dans cette région.",
"tableLabel": "Comparaison des paquets flood",
"heardBy": "Reçus par",
"packets": "Paquets",
"percentUnion": "% de l’union"
},
"investigation": {
"title": "Observations",
"observations_one": "{{count}} observation",
"observations_other": "{{count}} observations",
"observations_many": "{{count}} observations",
"heardBy_one": "Reçu par {{count}} observateur",
"heardBy_other": "Reçu par {{count}} observateurs",
"heardBy_many": "Reçu par {{count}} observateurs",
"heardTimes_one": "Reçu une fois",
"heardTimes_other": "Reçu {{count}} fois",
"heardTimes_many": "Reçu {{count}} fois",
"byObservers_one": "par {{count}} observateur",
"byObservers_other": "par {{count}} observateurs",
"byObservers_many": "par {{count}} observateurs",
"pathNote": "Regroupé selon les répéteurs par lesquels chaque copie est passée. Un identifiant court peut correspondre à plusieurs nœuds.",
"traceNote": "Les entrées TRACE montrent l’itinéraire prévu, pas le trajet réellement suivi.",
"empty": "Aucune observation pour ce paquet.",
@@ -438,10 +543,403 @@
"emptyNoKey": "Les messages ne peuvent pas être déchiffrés sans la clé du canal.",
"error": "Impossible de charger les messages.",
"retry": "Réessayer",
"viewPacket": "Voir le paquet"
"viewPacket": "Voir le paquet",
"heardTimes_one": "Entendu {{count}} fois",
"heardTimes_other": "Entendu {{count}} fois",
"heardTimes_many": "Entendu {{count}} fois"
},
"header": {
"settings": "Réglages"
"settings": "Réglages",
"github": "GitHub"
},
"channels": {
"noMatches": "Aucun canal correspondant chargé.",
"loadError": "Impossible de charger les canaux.",
"loadingChannels": "Chargement des canaux...",
"retryChannels": "Réessayer de charger les canaux",
"loadOthers": "Charger les autres canaux",
"loadMore": "Charger plus de canaux",
"key": "clé",
"filters": "Filtres des canaux",
"fieldName": "Nom",
"fieldHash": "Empreinte",
"keyFilter": "Clé",
"hashtagFilter": "Mot-dièse",
"keyKnownOption": "Connue",
"keyUnknownOption": "Inconnue",
"any": "Toutes",
"yes": "Oui",
"no": "Non"
},
"map": {
"settings": "Paramètres de la carte",
"tiles": "Fond de carte",
"nodeType": "Type de nœud",
"nodeTypeLabel": "Type de nœud",
"clustering": "Regroupement",
"neighborLines": "Liens de voisinage",
"neighborLinesLabel": "Liens de voisinage",
"areaBorders": "Limites de zone",
"areaBordersLabel": "Limites de zone",
"all": "Tous",
"on": "Activé",
"off": "Désactivé",
"selected": "Sélection",
"areaOutline": "Contour de zone",
"observations": "Observations",
"fainter": "plus pâle = entendu il y a plus longtemps",
"copyLink": "Copier le lien de la carte",
"copyLinkLabel": "Copier un lien vers cette vue de la carte",
"liveMap": "Carte en direct",
"playFlow": "Lancer le flux de paquets en direct sur la carte",
"stopFlow": "Arrêter le flux de paquets en direct sur la carte",
"loadFailed": "Échec du chargement de la carte",
"loadFailedHint": "Vérifiez votre connexion et rechargez la page",
"style": "Style de carte",
"styles": {
"dark": "Sombre",
"liberty": "Liberty",
"positron": "Clair"
},
"controls": {
"toggleAttribution": "Afficher ou masquer les crédits",
"mapFeedback": "Signaler un problème sur la carte",
"map": "Carte",
"resetBearing": "Faites glisser pour pivoter la carte, cliquez pour revenir au nord",
"zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière",
"closePopup": "Fermer la fenêtre"
}
},
"nodeFilters": {
"label": "Filtres des nœuds",
"type": "Type",
"multibytePaths": "Chemins multi-octets",
"multibyteTraces": "Traces multi-octets",
"scope": "Portée",
"any": "Indifférent",
"yes": "Oui",
"no": "Non",
"fieldName": "Nom",
"fieldPubkey": "Clé publique",
"all": "Tous"
},
"packetAnalyzer": {
"title": "Analyseur de paquets",
"close": "Fermer l’analyseur",
"empty": "Sélectionnez un paquet à analyser",
"summary": "Résumé",
"rawPacket": "Paquet brut",
"structure": "Structure du paquet",
"headerByte": "Octet d’en-tête",
"ver": "Ver",
"type": "Type",
"route": "Routage",
"transportCodes": "Codes de transport",
"scopeCode": "Code de portée",
"noKnownScope": "aucune portée connue",
"code2": "Code 2",
"pathLength": "Longueur du trajet",
"hashSize": "Taille du hash",
"pathData": "Données du trajet",
"originPubkey": "Clé publique d’origine",
"payloadBreakdown": "Détail de la charge utile",
"payloadData": "Données de la charge utile",
"bytes_one": "{{count}} octet",
"bytes_other": "{{count}} octets",
"bytes_many": "{{count}} octets",
"bits": {
"ver": "ver",
"type": "type",
"route": "routage",
"hash": "hash={{size}}B",
"hops": "sauts={{value}}",
"loc": "pos",
"name": "nom"
},
"payload": {
"yes": "Oui",
"no": "Non",
"encrypted": "Chiffré — clé non disponible",
"to": "Vers",
"from": "De",
"ciphertext": "Texte chiffré",
"decrypted": "Déchiffré",
"channel": "Canal",
"publicKey": "Clé publique",
"timestamp": "Horodatage",
"signature": "Signature",
"appFlags": "Indicateurs d’application",
"role": "Rôle",
"loc": "Pos",
"name": "Nom",
"location": "Position",
"traceTag": "Étiquette de trace",
"authCode": "Code d’authentification",
"flags": "Indicateurs",
"tracePath": "Trajet de trace",
"sender": "Expéditeur",
"message": "Message",
"attempt": "Tentative {{value}}",
"flagsValue": "Indicateurs {{value}}",
"data": "Données",
"time": "Heure",
"tag": "Étiquette",
"checksum": "Somme de contrôle",
"bundledType": "Type joint",
"bundledData": "Données jointes",
"typeFilter": "Filtre de type",
"typeN": "Type {{value}}",
"any": "tous",
"since": "Depuis",
"prefixOnly": "Préfixe seul",
"nodeType": "Type de nœud",
"requestSnr": "SNR de la requête",
"nodeToNode": "(nœud à nœud)",
"keyPrefix": "Préfixe de clé",
"raw": "Brut",
"destHash": "Hash de destination",
"ephemeralKey": "Clé éphémère"
}
},
"observation": {
"prop": "Prop.",
"pathSnr": "SNR du trajet",
"path": "Trajet",
"observer": "Observateur",
"area": "Zone",
"heard": "Reçu",
"hops": "Sauts"
},
"packetEndpoints": {
"na": "n/d"
},
"routes": {
"columns": {
"area": "Zone",
"hops": "Sauts",
"route": "Trajet",
"obs": "Obs.",
"firstSeen": "Première réception",
"lastSeen": "Dernière réception"
},
"hops_one": "{{count}} saut",
"hops_other": "{{count}} sauts",
"hops_many": "{{count}} sauts",
"obs_one": "{{value}} obs.",
"obs_other": "{{value}} obs.",
"obs_many": "{{value}} obs.",
"first": "premier",
"last": "dernier",
"findPath": "Chercher un trajet",
"fromPlaceholder": "hash de départ",
"fromLabel": "Hash de départ",
"toPlaceholder": "hash d’arrivée",
"toLabel": "Hash d’arrivée",
"areas": "Zones",
"search": "Rechercher",
"clear": "Effacer",
"searching": "Recherche…",
"noCross": "Aucun trajet entre ces zones",
"noMatches": "Aucun trajet correspondant",
"empty": "Aucun trajet"
},
"routeDetail": {
"title": "Détail du trajet",
"summary": "Résumé",
"hops": "Sauts",
"heard": "Reçu",
"route": "Trajet",
"timestamps": "Horodatages",
"firstSeen": "Première réception",
"lastSeen": "Dernière réception"
},
"traces": {
"packets": "Paquets",
"packetCount_one": "{{count}} paquet",
"packetCount_other": "{{count}} paquets",
"packetCount_many": "{{count}} paquets",
"noPackets": "Aucun paquet",
"noPath": "aucun chemin",
"unknown": "Inconnu",
"analyze": "analyser →",
"first": "Premier",
"last": "Dernier",
"tagCount_one": "{{count}} étiquette",
"tagCount_other": "{{count}} étiquettes",
"tagCount_many": "{{count}} étiquettes",
"pkt_one": "{{count}} paq.",
"pkt_other": "{{count}} paq.",
"pkt_many": "{{count}} paq.",
"iata_one": "{{count}} iata",
"iata_other": "{{count}} iata",
"iata_many": "{{count}} iata",
"typeLabel": "Type de trace",
"all": "Tous",
"trace": "Trace",
"ping": "Ping",
"empty": "Aucune trace"
},
"loadingPill": {
"nodes": {
"loading_one": "Chargement des nœuds… ({{count}})",
"loading_other": "Chargement des nœuds… ({{count}})",
"loading_many": "Chargement des nœuds… ({{count}})",
"partial_one": "Certains nœuds n’ont pas pu être chargés ({{count}} affiché)",
"partial_other": "Certains nœuds n’ont pas pu être chargés ({{count}} affichés)",
"partial_many": "Certains nœuds n’ont pas pu être chargés ({{count}} affichés)",
"failed": "Échec du chargement des nœuds"
},
"routes": {
"loading_one": "Chargement des routes… ({{count}})",
"loading_other": "Chargement des routes… ({{count}})",
"loading_many": "Chargement des routes… ({{count}})",
"partial_one": "Certaines routes n’ont pas pu être chargées ({{count}} affichée)",
"partial_other": "Certaines routes n’ont pas pu être chargées ({{count}} affichées)",
"partial_many": "Certaines routes n’ont pas pu être chargées ({{count}} affichées)",
"failed": "Échec du chargement des routes"
},
"packets": {
"loading_one": "Chargement des paquets… ({{count}})",
"loading_other": "Chargement des paquets… ({{count}})",
"loading_many": "Chargement des paquets… ({{count}})",
"partial_one": "Certains paquets n’ont pas pu être chargés ({{count}} affiché)",
"partial_other": "Certains paquets n’ont pas pu être chargés ({{count}} affichés)",
"partial_many": "Certains paquets n’ont pas pu être chargés ({{count}} affichés)",
"failed": "Échec du chargement des paquets"
},
"messages": {
"loading_one": "Chargement des messages… ({{count}})",
"loading_other": "Chargement des messages… ({{count}})",
"loading_many": "Chargement des messages… ({{count}})",
"partial_one": "Certains messages n’ont pas pu être chargés ({{count}} affiché)",
"partial_other": "Certains messages n’ont pas pu être chargés ({{count}} affichés)",
"partial_many": "Certains messages n’ont pas pu être chargés ({{count}} affichés)",
"failed": "Échec du chargement des messages"
},
"observers": {
"loading_one": "Chargement des observateurs… ({{count}})",
"loading_other": "Chargement des observateurs… ({{count}})",
"loading_many": "Chargement des observateurs… ({{count}})",
"partial_one": "Certains observateurs n’ont pas pu être chargés ({{count}} affiché)",
"partial_other": "Certains observateurs n’ont pas pu être chargés ({{count}} affichés)",
"partial_many": "Certains observateurs n’ont pas pu être chargés ({{count}} affichés)",
"failed": "Échec du chargement des observateurs"
}
},
"talkers": {
"node": "Nœud",
"flood": "Flood",
"direct": "Direct",
"topAdvertisers": "Principaux annonceurs · {{range}}",
"floodDirect": "flood · direct",
"noAdvertisers": "Aucun annonceur",
"topTalkers": "Principaux émetteurs · {{range}}",
"byName": "par nom"
},
"neighbourGraph": {
"pickRegion": "Choisissez une région",
"allTooBig": "Toutes les régions représentent plus de 5 000 nœuds — choisissez une région dans le sélecteur RÉGION ci-dessus, ou limitez-vous à une seule zone, pour afficher son réseau.",
"loadFailed": "Échec du chargement des nœuds",
"loadingMesh": "Chargement du réseau…",
"nodes_one": "{{count}} nœud",
"nodes_other": "{{count}} nœuds",
"nodes_many": "{{count}} nœuds",
"noNodes": "Aucun nœud dans cette région",
"neighborhoodOf": "Voisinage de",
"neighbors_one": "{{count}} voisin",
"neighbors_other": "{{count}} voisins",
"neighbors_many": "{{count}} voisins",
"clickEmpty": "Cliquez dans un espace vide pour revenir au réseau complet.",
"showing": "{{cap}} nœuds affichés sur {{total}}",
"narrow": "Limitez-vous à une seule zone pour voir les autres.",
"searchName": "Nom",
"other": "Autre",
"unknownType": "inconnu",
"edgeToday": "{{obs}} obs · vu aujourd’hui",
"edgeAgo": "{{obs}} obs · vu il y a {{days}} j"
},
"nodeTypes": {
"companion": "Compagnon",
"repeater": "Répéteur",
"room_server": "Salon",
"sensor": "Capteur"
},
"observerFilters": {
"label": "Filtres des observateurs",
"online": "En ligne",
"offline": "Hors ligne",
"name": "Nom",
"status": "État",
"type": "Type",
"broker": "Courtier",
"scope": "Portée"
},
"charts": {
"uniquePackets": "Paquets uniques",
"busy": "Occupation"
},
"packetList": {
"pathSearchInvalid": "Saisissez des hashs de saut complets de même longueur, séparés par des espaces, des virgules ou → (p. ex. 7f a4).",
"pathSearchHint": "Recherche dans le dernier trajet de relais des paquets chargés. Utilisez des hashs de saut complets, p. ex. 7f a4 (espaces, virgules ou →). Les paquets trace sont exclus.",
"selectedPacket": "Paquet sélectionné",
"invalidHash": "Hash de paquet invalide.",
"notFound": "Paquet introuvable.",
"loadFailed": "Échec du chargement du paquet sélectionné.",
"retry": "Réessayer",
"retryLabel": "Réessayer de charger le paquet sélectionné",
"outsideResults": "Le paquet sélectionné est hors des résultats chargés.",
"openAnalyzer": "Ouvrir l’analyseur",
"loadingSelected": "Chargement du paquet sélectionné…",
"dismissSelected": "Fermer le paquet sélectionné",
"dropped_one": "{{count}} paquet perdu — les données peuvent être incomplètes",
"dropped_many": "{{count}} paquets perdus — les données peuvent être incomplètes",
"dropped_other": "{{count}} paquets perdus — les données peuvent être incomplètes",
"dismiss": "ignorer",
"newPackets_one": "{{count}} nouveau paquet",
"newPackets_many": "{{count}} nouveaux paquets",
"newPackets_other": "{{count}} nouveaux paquets",
"scrollToTop": "· remonter en haut",
"live": "Paquets en direct",
"noMatching": "Aucun paquet correspondant chargé.",
"loadingPackets": "Chargement des paquets...",
"loadOlder": "Charger des paquets plus anciens"
},
"packetTable": {
"hash": "Hash",
"obs": "Obs.",
"hops": "Sauts",
"summary": "Résumé / Src → Dst",
"summaryTitle": "Résumé et source / destination",
"area": "Zone",
"age": "Âge",
"unknown": "Inconnu"
},
"filters": {
"types": "Types",
"routes": "Routes",
"observers": "Observateurs",
"scope": "Portée",
"packetFilters": "Filtres de paquets",
"field": {
"hash": "Hash",
"path": "Dernier trajet",
"payload": "Charge utile"
}
},
"search": {
"placeholder": "Rechercher par {{field}}…"
},
"errorBoundary": {
"message": "Une erreur est survenue lors de l’affichage de cette vue.",
"reload": "recharger",
"retry": "réessayer"
},
"splash": {
"tagline": "Analyseur de réseau MeshCore"
},
"units": {
"perDay": "{{value}}/j"
}
}
}
+9 -5
View File
@@ -1,5 +1,7 @@
// hex and time display helpers
import i18n from "../i18n";
export function formatHex(hex: string): string {
return hex.slice(0, 8).toUpperCase();
}
@@ -61,9 +63,10 @@ export function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
const unit = (u: "d" | "h" | "m", count: number) => i18n.t(`timestamp.unit.${u}`, { count });
if (d > 0) return `${unit("d", d)} ${unit("h", h)} ${unit("m", m)}`;
if (h > 0) return `${unit("h", h)} ${unit("m", m)}`;
return unit("m", m);
}
// Signed device-clock drift for the node detail, e.g. "+42s ahead", "-1h 1m behind", "in sync".
@@ -77,7 +80,8 @@ export function formatClockDrift(seconds: number, labels = { inSync: "in sync",
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const mag = h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${sec}s` : `${sec}s`;
const unit = (u: "h" | "m" | "s", count: number) => i18n.t(`timestamp.unit.${u}`, { count });
const mag = h > 0 ? `${unit("h", h)} ${unit("m", m)}` : m > 0 ? `${unit("m", m)} ${unit("s", sec)}` : unit("s", sec);
return `${sign}${mag} ${dir}`;
}
@@ -104,7 +108,7 @@ export function formatRatePerDay(count: number | null | undefined, windowMs: num
const days = windowMs / 86_400_000;
const rate = days > 0 ? count / days : 0;
const shown = rate >= 10 ? formatCount(Math.round(rate)) : String(Math.round(rate * 10) / 10);
return `${shown}/d`;
return i18n.t("units.perDay", { value: shown });
}
export type TimeAgoUnit = "s" | "m" | "h" | "d";
-1
View File
@@ -5,7 +5,6 @@ import { useSearchParams } from "react-router-dom";
import { App } from "../src/App";
import { useRegionSelection } from "../src/hooks/useRegion";
import type { PacketDetail } from "../src/types/api";
import i18n from "../src/i18n";
vi.mock("../src/api/ws-manager", () => ({ WsManager: class {
connect() {} disconnect() {} updateSubscription() {}
+9
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { CopyButton } from "../../src/components/CopyButton";
import i18n from "../../src/i18n";
const writeText = vi.fn();
@@ -46,4 +47,12 @@ describe("CopyButton", () => {
render(<CopyButton value="deadbeef" ariaLabel="Copy public key" />);
expect(screen.getByRole("button", { name: "Copy public key" })).toBeInTheDocument();
});
it("defaults to French labels in French", async () => {
await i18n.changeLanguage("fr");
render(<CopyButton value="deadbeef" />);
const button = screen.getByRole("button", { name: "Copier" });
fireEvent.click(button);
expect(button).toHaveTextContent("Copié");
});
});
+11
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DetailPanel } from "../../src/components/DetailPanel";
import i18n from "../../src/i18n";
function renderPanel(props: { collapsible?: boolean; onClose?: () => void } = {}) {
const onClose = props.onClose ?? vi.fn();
@@ -40,4 +41,14 @@ describe("DetailPanel", () => {
fireEvent.click(screen.getByRole("button", { name: "Close detail panel" }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("uses French defaults for its controls and states", async () => {
await i18n.changeLanguage("fr");
const { rerender } = render(<DetailPanel title="Nœud" onClose={vi.fn()} collapsible isLoading><p /></DetailPanel>);
expect(screen.getByRole("button", { name: "Fermer le panneau de détails" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Réduire le panneau de détails" })).toBeTruthy();
expect(screen.getByText("Chargement…")).toBeTruthy();
rerender(<DetailPanel title="Nœud" onClose={vi.fn()} notFound><p /></DetailPanel>);
expect(screen.getByText("Introuvable")).toBeTruthy();
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "../../src/components/ErrorBoundary";
import { CloseButton } from "../../src/components/CloseButton";
import i18n from "../../src/i18n";
function Boom({ message }: { message: string }): never {
throw new Error(message);
}
describe("ErrorBoundary", () => {
it("shows the English fallback", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
render(<ErrorBoundary><Boom message="bad" /></ErrorBoundary>);
expect(screen.getByText("Something went wrong rendering this view.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "try again" })).toBeInTheDocument();
});
it("translates the fallback into French", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
await i18n.changeLanguage("fr");
render(<ErrorBoundary><Boom message="Loading chunk 3 failed" /></ErrorBoundary>);
expect(screen.getByText("Une erreur est survenue lors de l’affichage de cette vue.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "recharger" })).toBeInTheDocument();
});
});
describe("CloseButton", () => {
it("defaults its label per language", async () => {
const { unmount } = render(<CloseButton onClose={() => {}} />);
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
unmount();
await i18n.changeLanguage("fr");
render(<CloseButton onClose={() => {}} />);
expect(screen.getByRole("button", { name: "Fermer" })).toBeInTheDocument();
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FilterBar } from "../../src/components/FilterBar";
import i18n from "../../src/i18n";
const noop = () => {};
const props = {
typeOptions: [{ value: "ADVERT", label: "ADVERT" }],
routeOptions: [{ value: "0", label: "Flood" }],
observerOptions: [{ value: "o1", label: "YVR" }],
scopeOptions: [{ value: "s1", label: "#east" }],
activeTypes: ["ADVERT"],
activeRoutes: [] as string[],
activeObservers: [] as string[],
activeScopes: [] as string[],
onTypesChange: noop,
onRoutesChange: noop,
onObserversChange: noop,
onScopesChange: noop,
search: "",
onSearchChange: noop,
searchField: "hash" as const,
onSearchFieldChange: noop,
onClear: noop,
};
describe("FilterBar in French", () => {
it("translates the toolbar, search field, and dropdown controls", async () => {
await i18n.changeLanguage("fr");
render(<FilterBar {...props} />);
expect(screen.getByRole("toolbar", { name: "Filtres de paquets" })).toBeInTheDocument();
expect(screen.getByPlaceholderText("Rechercher par hash…")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Effacer" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Observateurs/ }));
expect(screen.getByPlaceholderText("Filtrer…")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Tous" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Aucun" })).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("Filtrer…"), { target: { value: "zzz" } });
expect(screen.getByText("Aucun résultat")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^Hash/ }));
expect(screen.getByRole("button", { name: "Dernier trajet" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Charge utile" })).toBeDisabled();
expect(screen.getByRole("button", { name: /Portée/ })).toBeInTheDocument();
});
it("keeps the English placeholder wording", () => {
render(<FilterBar {...props} searchField="path" />);
expect(screen.getByPlaceholderText("Search by latest path...")).toBeInTheDocument();
});
});
+14
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FilterSheet, FiltersButton } from "../../src/components/FilterSheet";
import i18n from "../../src/i18n";
describe("FiltersButton", () => {
it("shows the active count only when there are active filters", () => {
@@ -72,3 +73,16 @@ describe("FilterSheet", () => {
expect(dialog).toHaveAttribute("aria-modal", "true");
});
});
describe("FilterSheet in French", () => {
it("translates the trigger, title and actions", async () => {
await i18n.changeLanguage("fr");
render(<FiltersButton activeCount={0} onClick={() => {}} />);
expect(screen.getByRole("button", { name: "Filtres" })).toBeInTheDocument();
render(<FilterSheet onClose={() => {}} onClear={() => {}}><div /></FilterSheet>);
expect(screen.getByRole("dialog", { name: "Filtres" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Fermer les filtres" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Tout effacer" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Terminé" })).toBeInTheDocument();
});
});
+13
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { LoadingPill } from "../../src/components/LoadingPill";
import i18n from "../../src/i18n";
describe("LoadingPill", () => {
it("shows a loading label with a live region", () => {
@@ -22,4 +23,16 @@ describe("LoadingPill", () => {
const { container } = render(<LoadingPill loading={false} count={0} noun="nodes" />);
expect(container).toBeEmptyDOMElement();
});
it("agrees in French with each noun's gender and the shown count", async () => {
await i18n.changeLanguage("fr");
const { rerender } = render(<LoadingPill loading count={42} noun="nodes" />);
expect(screen.getByRole("status")).toHaveTextContent("Chargement des nœuds… (42)");
rerender(<LoadingPill loading={false} error count={7} noun="routes" />);
expect(screen.getByRole("status")).toHaveTextContent("Certaines routes n’ont pas pu être chargées (7 affichées)");
rerender(<LoadingPill loading={false} error count={1} noun="packets" />);
expect(screen.getByRole("status")).toHaveTextContent("Certains paquets n’ont pas pu être chargés (1 affiché)");
rerender(<LoadingPill loading={false} error count={0} noun="observers" />);
expect(screen.getByRole("status")).toHaveTextContent("Échec du chargement des observateurs");
});
});
+13
View File
@@ -0,0 +1,13 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { SplashScreen } from "../../src/components/SplashScreen";
import i18n from "../../src/i18n";
describe("SplashScreen", () => {
it("translates the tagline but keeps the brand", async () => {
sessionStorage.clear();
await i18n.changeLanguage("fr");
render(<SplashScreen />);
expect(screen.getByText("Analyseur de réseau MeshCore")).toBeInTheDocument();
});
});
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ChannelSidebar } from "../../../src/features/channels/ChannelSidebar";
import { ChannelFilterBar } from "../../../src/features/channels/ChannelFilterBar";
import type { ChannelSummary } from "../../../src/features/channels/types";
import i18n from "../../../src/i18n";
const channels: ChannelSummary[] = [
{ id: 1, name: "Public", channelHash: "11", lastSeen: 2, isHashtag: false, keyKnown: true },
{ id: 2, name: "#ops", channelHash: "22", lastSeen: 1, isHashtag: true, keyKnown: false },
];
function mount() {
render(<>
<ChannelFilterBar search="" onSearchChange={() => {}} searchField="name" onSearchFieldChange={() => {}}
keyFilter="" onKeyChange={() => {}} hashtagFilter="" onHashtagChange={() => {}} />
<ChannelSidebar channels={channels} selectedId={null} onSelect={() => {}} />
</>);
}
describe("channel list chrome", () => {
it("renders in English", () => {
mount();
expect(screen.getByRole("toolbar", { name: "Channel filters" })).toBeInTheDocument();
expect(screen.getByText("key")).toBeInTheDocument();
expect(screen.getByText("no key")).toBeInTheDocument();
});
it("renders in French", async () => {
await i18n.changeLanguage("fr");
mount();
expect(screen.getByRole("toolbar", { name: "Filtres des canaux" })).toBeInTheDocument();
expect(screen.getByText("clé")).toBeInTheDocument();
expect(screen.getByText("sans clé")).toBeInTheDocument();
expect(screen.getByText("mot-dièse")).toBeInTheDocument();
});
});
@@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MessagePanel } from "../../../src/features/channels/MessagePanel";
import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types";
import i18n from "../../../src/i18n";
// Older live WS messages have no id — preserve that compatible runtime shape.
const restMsg: ChannelMessage = {
@@ -99,3 +100,24 @@ describe("MessagePanel multi-line messages", () => {
expect(body.className).toContain("break-words");
});
});
describe("MessagePanel heard badge", () => {
const mount = () => render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MessagePanel channel={channel} heardCounts={{ "ph-rest": 3, "ph-live-a": 1 }} regionKey="*" />
</QueryClientProvider>,
);
it("labels the repeat count", async () => {
mount();
expect(await screen.findByTitle("Heard 3 times")).toHaveTextContent("×3");
expect(screen.getByTitle("Heard 1 time")).toBeInTheDocument();
});
it("labels the repeat count in French", async () => {
await i18n.changeLanguage("fr");
mount();
expect(await screen.findByTitle("Entendu 3 fois")).toBeInTheDocument();
expect(screen.getByTitle("Entendu 1 fois")).toBeInTheDocument();
});
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MapSettingsPanel } from "../../../src/features/map/MapSettingsPanel";
import { PacketFlowButton } from "../../../src/features/map/PacketFlowButton";
import i18n from "../../../src/i18n";
function mount() {
render(<>
<MapSettingsPanel styleId="dark" onStyleChange={() => {}} typeFilter="" onTypeChange={() => {}}
clustered onClusteredChange={() => {}} neighborLines="selected" onNeighborLinesChange={() => {}}
borders onBordersChange={() => {}} buildShareParams={() => ({})} />
<PacketFlowButton active={false} onToggle={() => {}} />
</>);
}
describe("map settings", () => {
it("renders in English", () => {
mount();
expect(screen.getByText("Map Settings")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Repeater" })).toBeInTheDocument();
expect(screen.getByText("fainter = heard longer ago")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Play live map packet flow" })).toHaveTextContent("Live Map");
});
it("renders in French", async () => {
await i18n.changeLanguage("fr");
mount();
expect(screen.getByText("Paramètres de la carte")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Répéteur" })).toBeInTheDocument();
expect(screen.getByText("Contour de zone")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Lancer le flux de paquets en direct sur la carte" })).toHaveTextContent("Carte en direct");
});
});
@@ -0,0 +1,17 @@
import { expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MapStyleSwitcher } from "../../../src/features/map/MapStyleSwitcher";
import i18n from "../../../src/i18n";
it("labels the map styles in English", () => {
render(<MapStyleSwitcher styleId="dark" onChange={vi.fn()} />);
expect(screen.getByRole("group", { name: "Map style" })).toBeInTheDocument();
for (const name of ["Dark", "Liberty", "Light"]) expect(screen.getByText(name)).toBeInTheDocument();
});
it("labels the map styles in French", async () => {
await i18n.changeLanguage("fr");
render(<MapStyleSwitcher styleId="dark" onChange={vi.fn()} />);
expect(screen.getByRole("group", { name: "Style de carte" })).toBeInTheDocument();
for (const name of ["Sombre", "Liberty", "Clair"]) expect(screen.getByText(name)).toBeInTheDocument();
});
+16
View File
@@ -0,0 +1,16 @@
import { expect, it, vi } from "vitest";
import i18n from "../../../src/i18n";
vi.mock("maplibre-gl", () => ({}));
vi.mock("../../../src/features/map/maplibre-worker", () => ({}));
const { mapLocale } = await import("../../../src/features/map/useMapLibre");
it("keeps MapLibre's own English control tooltips", () => {
expect(mapLocale()).toMatchObject({ "NavigationControl.ZoomIn": "Zoom in", "AttributionControl.ToggleAttribution": "Toggle attribution" });
});
it("translates the MapLibre control tooltips to French", async () => {
await i18n.changeLanguage("fr");
expect(mapLocale()).toMatchObject({ "NavigationControl.ZoomIn": "Zoom avant", "NavigationControl.ZoomOut": "Zoom arrière" });
});
@@ -202,3 +202,28 @@ describe("NodeDetailPanel recent packets", () => {
expect(screen.queryByText("Observations")).not.toBeInTheDocument();
});
});
describe("NodeDetailPanel in French", () => {
it("translates section titles, labels, yes/no and empty states", async () => {
mockGetNode.mockResolvedValue({ ...node, knownNeighborCount: 2, clockDriftSeconds: 20, clockOutOfSync: false, clockCheckedAt: 2 });
await act(() => i18n.changeLanguage("fr"));
renderPanel();
expect(await screen.findByText("Capacités")).toBeInTheDocument();
expect(screen.getByText("Détail du nœud")).toBeInTheDocument();
expect(screen.getByText("Chemins multi-octets")).toBeInTheDocument();
expect(screen.getAllByText("non")).toHaveLength(2);
expect(screen.getByText("+20 s en avance")).toBeInTheDocument();
expect(screen.getByText("Voisins (2)")).toBeInTheDocument();
expect(await screen.findByText("Aucun voisin connu")).toBeInTheDocument();
expect(screen.getByText("Aucun paquet récent")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copier la clé publique" })).toBeInTheDocument();
});
it("translates the possibly-foreign badge", async () => {
mockGetNode.mockResolvedValue({ ...node, possiblyForeign: true });
await act(() => i18n.changeLanguage("fr"));
renderPanel();
expect(await screen.findByText("Possiblement étranger")).toBeInTheDocument();
});
});
+19
View File
@@ -74,3 +74,22 @@ describe("NodeTable IATA badge tooltip", () => {
expect(screen.getByRole("tooltip")).toHaveTextContent("dernier contact il y a 7 j");
});
});
describe("NodeTable in French", () => {
it("translates column headers and filter labels", async () => {
await i18n.changeLanguage("fr");
mount([node({ id: "node-fr", name: "Nœud FR" })]);
await screen.findByText("Nœud FR");
for (const header of ["Nom", "Zones", "Voisins", "Position"]) {
expect(screen.getByRole("columnheader", { name: new RegExp(header) })).toBeInTheDocument();
}
expect(screen.getByRole("toolbar", { name: "Filtres des nœuds" })).toHaveTextContent("Chemins multi-octets");
expect(screen.getByRole("toolbar")).toHaveTextContent("Indifférent");
});
it("shows the French empty state", async () => {
await i18n.changeLanguage("fr");
mount([]);
expect(await screen.findByText("Aucun nœud")).toBeInTheDocument();
});
});
@@ -0,0 +1,40 @@
import { expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ObserverFilterBar } from "../../../src/features/observers/ObserverFilterBar";
import i18n from "../../../src/i18n";
function renderBar() {
return render(
<ObserverFilterBar
search=""
onSearchChange={vi.fn()}
searchField="name"
onSearchFieldChange={vi.fn()}
statusFilter=""
onStatusChange={vi.fn()}
typeFilter=""
onTypeChange={vi.fn()}
typeOptions={["mqtt"]}
brokerFilter=""
onBrokerChange={vi.fn()}
brokerOptions={["broker-a"]}
scopeFilter=""
onScopeChange={vi.fn()}
scopeOptions={["#ca"]}
/>,
);
}
it("labels the observer filters in English", () => {
renderBar();
expect(screen.getByRole("toolbar", { name: "Observer filters" })).toBeInTheDocument();
for (const label of ["Status", "Type", "Broker", "Scope"]) expect(screen.getByText(label)).toBeInTheDocument();
});
it("labels the observer filters in French", async () => {
await i18n.changeLanguage("fr");
renderBar();
expect(screen.getByRole("toolbar", { name: "Filtres des observateurs" })).toBeInTheDocument();
for (const label of ["État", "Courtier", "Portée"]) expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/par nom/)).toBeInTheDocument();
});
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ObservationCard } from "../../../src/features/packets/ObservationCard";
import type { Observation } from "../../../src/types/api";
import i18n from "../../../src/i18n";
const obs = (over: Partial<Observation> = {}): Observation => ({
id: 1, observerId: "o1", observerName: "Observer 1", iata: "YVR",
@@ -77,4 +78,10 @@ describe("ObservationCard", () => {
fireEvent.click(screen.getByText("Observer 1"));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("labels the stats and path in French", async () => {
await i18n.changeLanguage("fr");
render(<ObservationCard observation={obs({ pathBytes: "41", radio: { freqMhz: 910.525 } })} />);
for (const label of ["Prop.", "Sauts", "Radio", "Trajet"]) expect(screen.getByText(label)).toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { ObservationTable } from "../../../src/features/packets/ObservationTable";
import i18n from "../../../src/i18n";
import type { Observation } from "../../../src/types/api";
const obs = (id: number, over: Partial<Observation> = {}): Observation => ({
@@ -97,3 +98,13 @@ describe("ObservationTable", () => {
expect(cell.className).toContain("text-text-dim");
});
});
describe("ObservationTable in French", () => {
it("translates the column headers", async () => {
await i18n.changeLanguage("fr");
render(<ObservationTable observations={[obs(1)]} selectedId={null} onSelect={() => {}} />);
for (const h of ["Observateur", "Zone", "Reçu", "Prop.", "Sauts", "Trajet"]) {
expect(screen.getByRole("columnheader", { name: h })).toBeInTheDocument();
}
});
});
@@ -118,6 +118,32 @@ describe("packet reception evidence", () => {
});
});
describe("PacketAnalyzerDrawer in French", () => {
it("translates the empty state and close control", async () => {
await i18n.changeLanguage("fr");
render(<MemoryRouter><PacketAnalyzerDrawer detail={undefined} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
expect(screen.getByText("Analyseur de paquets")).toBeInTheDocument();
expect(screen.getByText("Sélectionnez un paquet à analyser")).toBeInTheDocument();
expect(screen.getByLabelText("Fermer l’analyseur")).toBeInTheDocument();
});
it("translates the structure section and payload fields", async () => {
const d = makeDetail([]);
d.observations[0].pathBytes = "aabb";
d.header = { ...d.header, raw: "14", routeType: RouteType.TRANSPORT_FLOOD, routeTypeName: "TRANSPORT_FLOOD" };
d.transportCodes = { regionCode: 49240, subRegionCode: 0 };
d.parsedPayload = { type: "TEXT_MESSAGE", destinationHash: "aa", sourceHash: "bb", ciphertext: "0011" };
await i18n.changeLanguage("fr");
render(<MemoryRouter><PacketAnalyzerDrawer detail={d} selectedObservationId={1} onClose={() => {}} /></MemoryRouter>);
for (const text of ["Résumé", "Paquet brut", "Structure du paquet", "Octet d’en-tête", "Longueur du trajet", "Données du trajet", "Détail de la charge utile"]) {
expect(screen.getByText(text)).toBeInTheDocument();
}
expect(screen.getByText("Codes de transport").parentElement!).toHaveTextContent("Code de portée 58C0 (2B) = 49240");
expect(screen.getByText("Codes de transport").parentElement!).toHaveTextContent("aucune portée connue");
expect(screen.getByText("sauts=2")).toBeInTheDocument();
expect(screen.getByText("Chiffré — clé non disponible")).toBeInTheDocument();
});
});
describe("transport scope", () => {
const transport = () => {
const d = makeDetail([]);
@@ -87,6 +87,12 @@ describe("PacketEndpoints", () => {
await act(() => i18n.changeLanguage("en"));
});
it("shows a missing endpoint as n/d in French", async () => {
await act(() => i18n.changeLanguage("fr"));
render(<PacketEndpoints packet={pkt()} />);
expect(screen.getByText("n/d")).toBeInTheDocument();
});
it("shows an advert as its single source node with no destination", () => {
const advert = { ...pkt(obs({ resolvedSource: { confidence: "high", nodes: [{ id: "s", publicKey: "aa", name: "Fuzz HQ" }] } })), payloadType: 4, summary: "Fuzz HQ" };
render(<PacketEndpoints packet={advert} />);
@@ -3,6 +3,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
import { PacketExpansion } from "../../../src/features/packets/PacketExpansion";
import type { PacketSummary, Observation, PacketDetail } from "../../../src/types/api";
import { PayloadType, RouteType } from "../../../src/types/enums";
import i18n from "../../../src/i18n";
const usePacketDetail = vi.fn();
vi.mock("../../../src/features/packets/usePacketDetail", () => ({
@@ -191,4 +192,15 @@ describe("PacketExpansion", () => {
fireEvent.click(screen.getByRole("button", { name: "Map all paths" }));
expect(onViewPath).toHaveBeenCalledTimes(1);
});
it("renders the timing strip and error state in French", async () => {
await i18n.changeLanguage("fr");
usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() });
render(<PacketExpansion {...props} />);
expect(screen.getByText("Impossible de charger les observations")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Réessayer" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Carte de tous les trajets" })).toBeInTheDocument();
expect(screen.getByText(/^premier/)).toBeInTheDocument();
expect(screen.getByText("n/d")).toBeInTheDocument();
});
});
@@ -3,6 +3,7 @@ import { render, screen, fireEvent, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PacketList } from "../../../src/features/packets/PacketList";
import i18n from "../../../src/i18n";
import type { WsManager } from "../../../src/api/ws-manager";
import type { PacketSummary, PacketDetail } from "../../../src/types/api";
import type { WsPacketObservation } from "../../../src/types/ws";
@@ -355,3 +356,28 @@ describe("PacketList selected packet outside the loaded results", () => {
expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [4] });
});
});
describe("PacketList in French", () => {
it("translates the banners and the selected-packet card", async () => {
await i18n.changeLanguage("fr");
usePackets.mockImplementation(() => ({ ...basePackets(), laggedCount: 2 }));
usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "AA11" } as PacketDetail });
renderList("/?hash=AA11");
expect(screen.getByText("2 paquets perdus — les données peuvent être incomplètes")).toBeInTheDocument();
expect(screen.getByText("Paquets en direct")).toBeInTheDocument();
const card = screen.getByRole("region", { name: "Paquet sélectionné" });
expect(card).toHaveTextContent("Le paquet sélectionné est hors des résultats chargés.");
expect(within(card).getByRole("button", { name: "Ouvrir l’analyseur" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Fermer le paquet sélectionné" })).toBeInTheDocument();
});
it("translates the error state and the path-search hint", async () => {
await i18n.changeLanguage("fr");
usePackets.mockImplementation(basePackets);
usePacketDetail.mockReturnValue({ ...baseDetail(), isError: true, error: Object.assign(new Error("x"), { status: 404 }) });
renderList("/?hash=AA11&sf=path");
expect(screen.getByText("Paquet introuvable.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Réessayer de charger le paquet sélectionné" })).toHaveTextContent("Réessayer");
expect(screen.getByText(/Recherche dans le dernier trajet de relais/)).toBeInTheDocument();
});
});
+10
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PacketRow } from "../../../src/features/packets/PacketRow";
import i18n from "../../../src/i18n";
import type { PacketSummary } from "../../../src/types/api";
const pkt = (over: Partial<PacketSummary> = {}): PacketSummary => ({
@@ -30,3 +31,12 @@ describe("PacketRow", () => {
expect(onToggle).toHaveBeenCalledTimes(1);
});
});
describe("PacketRow in French", () => {
it("translates the observer count label and unknown route", async () => {
await i18n.changeLanguage("fr");
render(<PacketRow packet={pkt({ routeTypeName: "" })} expanded={false} onToggle={() => {}} />);
expect(screen.getByLabelText("Reçu par 2 observateurs")).toBeInTheDocument();
expect(screen.getByText("Inconnu")).toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PacketTableHeader } from "../../../src/features/packets/PacketTableHeader";
import i18n from "../../../src/i18n";
import { GRID_TEMPLATE } from "../../../src/features/packets/packet-grid";
describe("PacketTableHeader", () => {
@@ -48,3 +49,14 @@ describe("PacketTableHeader", () => {
expect(first?.textContent).toBe("");
});
});
describe("PacketTableHeader in French", () => {
it("translates the column headings", async () => {
await i18n.changeLanguage("fr");
render(<PacketTableHeader />);
for (const h of ["Routage", "Sauts", "Taille du hash", "Zone", "Âge"]) {
expect(screen.getByText(h)).toBeInTheDocument();
}
expect(screen.getByTitle("Résumé et source / destination")).toHaveTextContent("Résumé / Src → Dst");
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { PacketTableRow } from "../../../src/features/packets/PacketTableRow";
import i18n from "../../../src/i18n";
import type { LatestObserver, PacketSummary, ResolvedHop } from "../../../src/types/api";
const pkt = (over: Partial<PacketSummary> = {}): PacketSummary => ({
@@ -132,3 +133,12 @@ describe("PacketTableRow", () => {
expect(screen.getByText("#ykf")).toBeInTheDocument();
});
});
describe("PacketTableRow in French", () => {
it("translates the unknown route and n/a fallbacks", async () => {
await i18n.changeLanguage("fr");
render(<PacketTableRow packet={pkt({ routeTypeName: "" })} expanded={false} onToggle={() => {}} />);
expect(screen.getByText("Inconnu")).toBeInTheDocument();
expect(screen.queryByText("n/a")).not.toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { PacketVirtualList } from "../../../src/features/packets/PacketVirtualList";
import i18n from "../../../src/i18n";
import type { PacketSummary } from "../../../src/types/api";
// PacketExpansion fetches through usePacketDetail; stub it so the list renders without a query client.
@@ -323,3 +324,12 @@ describe("PacketVirtualList responsive row", () => {
expect(screen.getByTestId("packet-expansion")).toBeInTheDocument();
});
});
describe("PacketVirtualList in French", () => {
it("translates the empty state and the load-older button", async () => {
await i18n.changeLanguage("fr");
render(<PacketVirtualList packets={[]} expandedHash={null} {...makeHandlers()} hasNextPage />);
expect(screen.getByText("Aucun paquet correspondant chargé.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Charger des paquets plus anciens" })).toBeInTheDocument();
});
});
@@ -110,4 +110,14 @@ describe("route detail", () => {
mount();
expect(await screen.findByText("12.00")).toHaveClass("text-green");
});
it("translates the summary section titles and labels", async () => {
await i18n.changeLanguage("fr");
vi.mocked(getRouteEvidence).mockReturnValue(new Promise(() => {}));
mount({ listed: route });
expect(screen.getByText("Détail du trajet")).toBeInTheDocument();
expect(screen.getByText("Résumé")).toBeInTheDocument();
expect(screen.getByText("Horodatages")).toBeInTheDocument();
expect(screen.getByText("Première réception")).toBeInTheDocument();
});
});
+10
View File
@@ -15,6 +15,7 @@ import {
getRegion,
} from "../../../src/api/client";
import type { KnownRoute, CrossIATARoute } from "../../../src/types/api";
import i18n from "../../../src/i18n";
vi.mock("../../../src/api/client", () => ({
getKnownRoutesPage: vi.fn(),
@@ -191,3 +192,12 @@ it("preserves a shared saved route while a named region resolves", async () => {
expect(screen.getByTestId("saved-route-selection")).toHaveTextContent("shared-key");
client.clear();
});
it("translates the search bar, headers and empty state", async () => {
await i18n.changeLanguage("fr");
renderTable();
expect(await screen.findByText("Chercher un trajet")).toBeInTheDocument();
expect(screen.getByLabelText("Hash de départ")).toHaveAttribute("placeholder", "hash de départ");
expect(screen.getByRole("button", { name: "Rechercher" })).toBeInTheDocument();
expect(await screen.findByText("Aucun trajet")).toBeInTheDocument();
});
+2 -2
View File
@@ -21,8 +21,8 @@ it("translates clock labels and direction without changing signs, thresholds or
expect(screen.getByRole("button", { name: /^Dérive\s*▼$/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^Nœud\s*▲$/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^Vérifié\s*▲$/ })).toBeInTheDocument();
expect(screen.getByText("+59m 59s en avance")).toHaveClass("text-warn");
expect(screen.getByText("-1h 0m en retard")).toHaveClass("text-danger");
expect(screen.getByText("+59 min 59 s en avance")).toHaveClass("text-warn");
expect(screen.getByText("-1 h 0 min en retard")).toHaveClass("text-danger");
expect(screen.getByText("YVR")).toBeInTheDocument();
expect(screen.getByText("YOW")).toBeInTheDocument();
expect([...container.querySelectorAll("tbody tr")].map((row) => row.querySelector("td")?.textContent)).toEqual(["BetaRoom", "AlphaRepeater"]);
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, useLocation } from "react-router-dom";
import { CompareObserversTab } from "../../../src/features/stats/CompareObserversTab";
import { getObserverComparison, getObserversPage, getObserver } from "../../../src/api/client";
import i18n from "../../../src/i18n";
const region = { iatas: ["YVR"], regionKey: "YVR" };
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region }));
@@ -136,4 +137,15 @@ describe("observer comparison", () => {
fireEvent.change(start, { target: { value: "2020-01-01T00:00" } });
expect(screen.getByText(/Only recent packets are kept/)).toBeInTheDocument();
});
it("renders the comparison in French", async () => {
await i18n.changeLanguage("fr");
mount(query);
const table = await screen.findByRole("table", { name: "Comparaison des paquets flood" });
expect(within(table).getByRole("row", { name: /A seulement.*1.*25.0%/ })).toBeInTheDocument();
expect(screen.getByText("Comparer les observateurs")).toBeInTheDocument();
expect(screen.getByText("A : Rooftop · B : Hilltop")).toBeInTheDocument();
expect(screen.getByText(/4 paquets flood/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Comparer" })).toBeInTheDocument();
});
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, it, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { EChart } from "../../../src/features/stats/EChart";
import i18n from "../../../src/i18n";
const init = vi.hoisted(() =>
vi.fn(() => ({ setOption: vi.fn(), resize: vi.fn(), dispose: vi.fn(), on: vi.fn(), off: vi.fn(), isDisposed: () => false })),
);
vi.mock("../../../src/features/stats/echarts-setup", () => ({ echarts: { init } }));
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
it("initialises charts with the French locale and rebuilds on a language switch", async () => {
render(<EChart option={{}} />);
expect(init).toHaveBeenLastCalledWith(expect.anything(), null, expect.objectContaining({ locale: "EN" }));
await act(() => i18n.changeLanguage("fr"));
expect(init).toHaveBeenCalledTimes(2);
expect(init).toHaveBeenLastCalledWith(expect.anything(), null, expect.objectContaining({ locale: "FR" }));
});
+10
View File
@@ -186,3 +186,13 @@ it("shows French labels for the KPIs, chart titles and scope table", async () =>
expect(within(scopeList).getByText("Nœuds")).toBeInTheDocument();
await act(() => i18n.changeLanguage("en"));
});
it("names the observation and preset chart series in French", async () => {
await act(() => i18n.changeLanguage("fr"));
mount(); await loaded();
const obsChart = within(card("Observations · 24h")).getByTestId("chart").textContent;
expect(obsChart).toContain('"Observations","Paquets uniques"');
const presetChart = within(card("Préréglages radio · historique complet")).getByTestId("chart").textContent;
expect(presetChart).toContain('"Nœuds","Observateurs"');
await act(() => i18n.changeLanguage("en"));
});
@@ -0,0 +1,63 @@
import { beforeEach, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NeighbourGraphTab } from "../../../src/features/stats/NeighbourGraphTab";
import { neighbourGraphOption } from "../../../src/features/stats/neighbour-graph";
import type { NodeSummary } from "../../../src/features/nodes/types";
import type { ChartColors } from "../../../src/features/stats/chartTheme";
import i18n from "../../../src/i18n";
const region = { iatas: ["YVR"] as string[] | undefined, regionKey: "YVR" };
const nodes = { nodes: [] as NodeSummary[], loadedCount: 0, isPaging: false, isError: false };
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region }));
vi.mock("../../../src/features/map/useMapNodesData", () => ({ useMapNodesData: () => nodes }));
vi.mock("../../../src/features/stats/NeighbourGraph", () => ({ NeighbourGraph: () => null }));
vi.mock("../../../src/api/client", () => ({ getNodeNeighbors: vi.fn() }));
function node(id: string, neighborIds: string[]): NodeSummary {
return { id, publicKey: id, nodeType: 2, nodeTypeName: "repeater", name: id, lat: 45, lng: -75, iatas: ["YVR"], neighborIds } as NodeSummary;
}
function mount() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><NeighbourGraphTab /></QueryClientProvider>);
}
beforeEach(() => {
region.iatas = ["YVR"]; region.regionKey = "YVR";
Object.assign(nodes, { nodes: [], loadedCount: 0, isPaging: false, isError: false });
});
it("asks for a region in French when All is selected", async () => {
region.iatas = undefined; region.regionKey = "*";
await i18n.changeLanguage("fr");
mount();
expect(screen.getByText("Choisissez une région")).toBeInTheDocument();
});
it("shows French loading and empty states", async () => {
await i18n.changeLanguage("fr");
Object.assign(nodes, { isPaging: true, loadedCount: 3 });
const view = mount();
expect(screen.getByText("Chargement du réseau…")).toBeInTheDocument();
expect(screen.getByText("3 nœuds")).toBeInTheDocument();
Object.assign(nodes, { isPaging: false });
view.rerender(<QueryClientProvider client={new QueryClient()}><NeighbourGraphTab /></QueryClientProvider>);
expect(screen.getByText("Aucun nœud dans cette région")).toBeInTheDocument();
});
it("counts the full mesh in French", async () => {
await i18n.changeLanguage("fr");
Object.assign(nodes, { nodes: [node("a", ["b"]), node("b", ["a"])] });
mount();
expect(screen.getByText("2 nœuds")).toBeInTheDocument();
});
const grey = new Proxy({}, { get: () => "#888888" }) as ChartColors;
it("translates the graph legend", async () => {
await i18n.changeLanguage("fr");
const option = neighbourGraphOption({ nodes: [], links: [], total: 0, capped: false }, grey, i18n.t);
const legend = Array.isArray(option.legend) ? option.legend[0] : option.legend;
expect(legend?.data).toEqual(["Compagnon", "Répéteur", "Salon", "Capteur", "Autre"]);
});
+9 -1
View File
@@ -23,7 +23,7 @@ vi.mock("../../../src/features/stats/useLiveStats", () => ({
// ECharts needs a real canvas; the tab's behaviour is in which cards it renders, not the pixels
vi.mock("../../../src/features/stats/EChart", () => ({
EChart: () => <div data-testid="chart" />,
EChart: ({ option }: { option: unknown }) => <div data-testid="chart" data-option={JSON.stringify(option)} />,
}));
const telemetryResult = { data: undefined as ObserverTelemetry | undefined, isLoading: false, isError: false };
@@ -206,6 +206,14 @@ describe("Observer dashboard hierarchy", () => {
expect(screen.getByRole("button", { name: /Copier la clé publique/ })).toBeInTheDocument();
});
it("names the busy series in French", async () => {
await i18n.changeLanguage("fr");
renderTab();
const options = screen.getAllByTestId("chart").map((c) => c.getAttribute("data-option") ?? "");
expect(options.some((o) => o.includes('"name":"Occupation"'))).toBe(true);
expect(options.some((o) => o.includes('"name":"Busy"'))).toBe(false);
});
it("keeps packet metrics without the removed traffic text badge", () => {
const now = Date.now();
observer.brokers = [{ name: "one", lastSeenAt: now, lastPacketAt: now }];
+10
View File
@@ -5,6 +5,7 @@ import { TalkersTab } from "../../../src/features/stats/TalkersTab";
import { getTopAdvertisers, getTopTalkers } from "../../../src/api/client";
import type { StatsRange, TopAdvertiser, TopTalker } from "../../../src/features/stats/types";
import type { EChartsOption } from "../../../src/features/stats/echarts-setup";
import i18n from "../../../src/i18n";
const region = { iatas: ["YVR"], regionKey: "YVR" };
const advertisers: TopAdvertiser[] = [{ nodeId: "fixture-node", nodeName: "Old advertiser", nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", advertCount: 14, floodAdvertCount: 10, directAdvertCount: 4, lastHeard: 0 }];
@@ -125,3 +126,12 @@ it("opens the clicked advertiser's node", async () => {
fireEvent.click(await screen.findByText("Old advertiser"));
expect(onViewNode).toHaveBeenCalledWith("fixture-node");
});
it("renders the leaderboards in French", async () => {
await i18n.changeLanguage("fr");
mount();
await screen.findByText("Old advertiser");
expect(screen.getByText("Principaux annonceurs · 24 h")).toBeInTheDocument();
expect(screen.getByText("Principaux émetteurs · 24 h")).toBeInTheDocument();
expect(screen.getByText("Nœud")).toBeInTheDocument();
});
+15
View File
@@ -8,6 +8,7 @@ import { ALL_REGIONS } from "../../../src/hooks/region-selection";
import { getTraces, getTraceDetail, getRegions } from "../../../src/api/client";
import { timeAgoMs } from "../../../src/lib/formatters";
import type { TraceTagSummary, TraceDetail } from "../../../src/types/api";
import i18n from "../../../src/i18n";
vi.mock("../../../src/api/client", () => ({
getTraces: vi.fn(),
@@ -181,4 +182,18 @@ describe("TraceList", () => {
expect(await screen.findByText("No traces")).toBeInTheDocument();
});
it("translates the list and the detail panel", async () => {
await i18n.changeLanguage("fr");
mockGetTraces.mockResolvedValue([tag("3f2a11c0", 2)]);
mockGetTraceDetail.mockResolvedValue(detail);
renderTraces();
expect(await screen.findByText("1 étiquette")).toBeInTheDocument();
expect(screen.getByText("2 paq. · 1 iata")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Tous" })).toBeInTheDocument();
fireEvent.click(screen.getByText("3F2A11C0"));
expect(await screen.findByText("Paquets")).toBeInTheDocument();
expect(await screen.findByText("2 paquets")).toBeInTheDocument();
expect(screen.getAllByText("analyser →")).toHaveLength(2);
});
});
+17
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import i18n from "../../src/i18n";
import {
formatRadio,
formatRadioParts,
@@ -13,6 +14,7 @@ import {
formatCount,
formatClockDrift,
formatRatePerDay,
formatUptime,
} from "../../src/lib/formatters";
const DAY_MS = 86_400_000;
@@ -219,3 +221,18 @@ describe("formatRadioParts", () => {
expect(formatRadio("915,250,11")).toBe(formatRadioParts({ freqMhz: 915, sf: 11, bwKhz: 250 }));
});
});
describe("formatters in French", () => {
it("uses French unit words for uptime and daily rates", async () => {
await i18n.changeLanguage("fr");
expect(formatUptime(90061)).toBe("1 j 1 h 1 min");
expect(formatUptime(300)).toBe("5 min");
expect(formatRatePerDay(340, DAY_MS)).toBe("340/j");
expect(formatClockDrift(-3670, { inSync: "synchronisé", ahead: "en avance", behind: "en retard" })).toBe("-1 h 1 min en retard");
});
it("keeps the compact English units", () => {
expect(formatUptime(90061)).toBe("1d 1h 1m");
expect(formatUptime(3660)).toBe("1h 1m");
});
});