mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 01:51:26 +00:00
Translate the remaining hardcoded UI text (#129)
This commit is contained in:
1 parent
e3f98efea9
commit
609dbc384f
94 files changed
+2344
-548
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
</>}
|
||||
|
||||
@@ -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 }} />;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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é");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" }));
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user