mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 03:01:37 +00:00
Plain wording across panels and fix retention-bound views (#127)
This commit is contained in:
1 parent
bd32c0019f
commit
e491a822c9
58 files changed
+463
-394
No files matched your search
+1
-1
@@ -301,7 +301,7 @@ function AppInner() {
|
||||
// master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses
|
||||
Traces: <TraceList onAnalyze={hash => { if (hash) viewPacket(hash); }} onViewNode={viewNode} />,
|
||||
Channels: <ChannelList wsManager={wsManager} onAnalyze={handleAnalyze} />,
|
||||
Analytics: <StatsOverview wsManager={wsManager} />,
|
||||
Analytics: <StatsOverview wsManager={wsManager} onViewNode={viewNode} />,
|
||||
Map: <MapView wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ function CheckBox({ checked }: { checked: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Compact header summary of the active selection, e.g. "ALL", "YVR, YYJ", "2 regions", "1 region · 3 IATA".
|
||||
// Compact header summary of the active selection, e.g. "ALL", "YVR, YYJ", "2 regions", "1 region · 3 areas".
|
||||
function regionSummaryLabel(selection: RegionSelection, t: TFunction): string {
|
||||
if (isAllRegions(selection)) return t("region.allShort");
|
||||
const parts: string[] = [];
|
||||
@@ -135,7 +135,7 @@ function RegionSelector() {
|
||||
className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-3 py-1 text-text-bright font-mono text-xs font-semibold hover:border-text-dim/30 transition-colors"
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="text-text-muted font-normal text-[11px]">{t("region.label")}</span>
|
||||
<span className="text-text-muted font-normal text-[11px] uppercase">{t("region.label")}</span>
|
||||
{regionSummaryLabel(selection, t)}
|
||||
<span className="text-text-dim text-[11px]">▾</span>
|
||||
</button>
|
||||
@@ -277,7 +277,7 @@ function RegionSelectorPanel() {
|
||||
<>
|
||||
<div className={`px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-wide text-text-dim ${
|
||||
hasRowsAbove ? "border-t border-border-subtle mt-1" : ""
|
||||
}`}>IATA</div>
|
||||
}`}>{t("region.areaGroup")}</div>
|
||||
{iatas ? (
|
||||
shownIatas.map((i) => {
|
||||
const checked = selection.iatas.includes(i.iata);
|
||||
|
||||
@@ -7,7 +7,7 @@ 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",
|
||||
label = "Copy link",
|
||||
copiedLabel = "Copied",
|
||||
ariaLabel,
|
||||
preserveParams,
|
||||
|
||||
@@ -32,7 +32,7 @@ function BorderLegend() {
|
||||
className="inline-block h-2.5 w-4 rounded-sm border"
|
||||
style={{ borderColor: "var(--palette-secondary)", backgroundColor: "var(--palette-secondary)", opacity: 0.5 }}
|
||||
/>
|
||||
IATA region outline
|
||||
Area outline
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -155,9 +155,9 @@ export function MapSettingsPanel({
|
||||
/>
|
||||
{neighborLines === "selected" && <NeighborLegend />}
|
||||
</Section>
|
||||
<Section title="IATA Borders">
|
||||
<Section title="Area Borders">
|
||||
<SegmentedControl
|
||||
ariaLabel="IATA borders"
|
||||
ariaLabel="Area borders"
|
||||
options={BORDER_OPTIONS}
|
||||
value={borders ? "on" : "off"}
|
||||
onChange={(v) => onBordersChange(v === "on")}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Tooltip } from "../../components/Tooltip";
|
||||
export function ForeignNodeBadge({ possiblyForeign }: { possiblyForeign?: boolean }) {
|
||||
if (possiblyForeign !== true) return null;
|
||||
return (
|
||||
<Tooltip label="Reported position is outside this server's configured local borders.">
|
||||
<Tooltip label="This node's position is outside this server's local area.">
|
||||
<Badge variant="default">Possibly foreign</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -164,8 +164,8 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
|
||||
<Section title="Capabilities">
|
||||
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
|
||||
{node.minFirmwareVersion && <Field label="Min firmware" value={node.minFirmwareVersion} />}
|
||||
<Field label="Multibyte paths" value={node.supportsMultibytePaths ? "yes" : "no"} />
|
||||
<Field label="Multibyte traces" value={node.supportsMultibyteTraces ? "yes" : "no"} />
|
||||
<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} />}
|
||||
</div>
|
||||
@@ -202,7 +202,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Observations">
|
||||
<Section title="Recent packets">
|
||||
{observations && observations.items.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{observations.items.map((obs) => (
|
||||
@@ -214,7 +214,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-[13px] text-text-dim">No recent observations</div>
|
||||
<div className="font-mono text-[13px] text-text-dim">No recent packets</div>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
|
||||
@@ -66,8 +66,8 @@ export function NodeFilterBar({
|
||||
const controls = (fullWidth: boolean) => (
|
||||
<>
|
||||
<SelectDropdown label="Type" options={NODE_TYPE_OPTIONS} value={typeFilter} onChange={onTypeChange} fullWidth={fullWidth} />
|
||||
<SelectDropdown label="Multibyte paths" options={MULTIBYTE_OPTIONS} allLabel="Any" value={pathsFilter} onChange={(v) => onPathsChange(v as MultibyteFilter)} fullWidth={fullWidth} />
|
||||
<SelectDropdown label="Multibyte traces" options={MULTIBYTE_OPTIONS} allLabel="Any" value={tracesFilter} onChange={(v) => onTracesChange(v as MultibyteFilter)} 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} />
|
||||
{scopeOptions.length > 0 && (
|
||||
<SelectDropdown label="Scope" options={scopeOptions.map((s) => ({ value: s, label: s }))} allLabel="Any" value={scopeFilter} onChange={onScopeChange} fullWidth={fullWidth} />
|
||||
)}
|
||||
|
||||
@@ -78,7 +78,7 @@ const COLUMNS: Column<NodeSummary>[] = [
|
||||
cell: (node) => formatRadio(node.radio) ?? "—",
|
||||
},
|
||||
{
|
||||
header: "IATAs",
|
||||
header: "Areas",
|
||||
cell: (node) =>
|
||||
node.iatas && node.iatas.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
||||
@@ -33,11 +33,13 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u
|
||||
const intervalMs = intervalToMs(activityParamsFor(range).interval)!;
|
||||
const end = until == null ? 0 : Math.floor(until / intervalMs) * intervalMs;
|
||||
const start = end - RANGE_MS[range];
|
||||
// raw packets only outlive 24h/7d windows; at 30d the overlap would silently cover a few days
|
||||
const overlapRange = range !== "30d";
|
||||
const aligned = [activityA, dataB].every(data => data?.summary && data.windowStart === start && data.windowEnd === end && intervalToMs(data.interval) === intervalMs);
|
||||
const overlap = useQuery({
|
||||
queryKey: ["observer-dashboard-overlap", observerA.id, observerBId, start, end],
|
||||
queryFn: ({ signal }) => getObserverComparison(undefined, { observerA: observerA.id, observerB: observerBId, since: start, until: end }, signal),
|
||||
enabled: valid && aligned && !!b.data && !b.isError,
|
||||
enabled: overlapRange && valid && aligned && !!b.data && !b.isError,
|
||||
staleTime: 30_000, refetchInterval: false, refetchOnWindowFocus: false, retry: false,
|
||||
});
|
||||
const option = useMemo(() => {
|
||||
@@ -65,13 +67,13 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u
|
||||
void b.refetch();
|
||||
if (nextUntil === until) {
|
||||
void activityB.refetch();
|
||||
if (aligned) void overlap.refetch();
|
||||
if (aligned && overlapRange) void overlap.refetch();
|
||||
}
|
||||
}
|
||||
};
|
||||
const rows = aligned && b.data ? [
|
||||
[t("observerPage.records"), activityA!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage), dataB!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage)],
|
||||
[t("observerPage.lastHour"), `${activityA!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(activityA!.summary!.lastCompleteHourStart)})`, `${dataB!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(dataB!.summary!.lastCompleteHourStart)})`],
|
||||
[t("observerPage.packetsLastHour"), `${activityA!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(activityA!.summary!.lastCompleteHourStart)})`, `${dataB!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(dataB!.summary!.lastCompleteHourStart)})`],
|
||||
[t("observerPage.lastPacket"), date(activityA!.summary!.latestRecordedAt), date(dataB!.summary!.latestRecordedAt)],
|
||||
[t("observerPage.battery"), observerA.batteryLevel == null ? "—" : formatBattery(observerA.batteryLevel), b.data.batteryLevel == null ? "—" : formatBattery(b.data.batteryLevel)],
|
||||
[t("observerPage.uptime"), observerA.uptimeSeconds == null ? "—" : formatUptime(observerA.uptimeSeconds), b.data.uptimeSeconds == null ? "—" : formatUptime(b.data.uptimeSeconds)],
|
||||
@@ -86,13 +88,13 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u
|
||||
<p className="flex flex-wrap items-center gap-2 text-xs text-text-muted">{t("observerPage.window")}: {date(start)} – {date(end)}<InfoTip text={t("observerCompare.currentNote")} /></p>
|
||||
<div className="overflow-x-auto"><table aria-label={t("observerCompare.metrics")} className="w-full text-left text-sm tabular-nums"><thead><tr><th className="p-2">{t("observerCompare.metric")}</th><th className="p-2">A</th><th className="p-2">B</th></tr></thead><tbody>{rows.map(([label, a, bValue]) => <tr className="border-t border-border" key={label}><th scope="row" className="p-2 font-normal text-text-muted">{label}</th><td className="p-2">{a}</td><td className="p-2">{bValue}</td></tr>)}</tbody></table></div>
|
||||
<ChartCard title={t("observerCompare.activity")} option={option} height={240} />
|
||||
<Card title={t("observerCompare.overlap")} right={<InfoTip text={[t("observerCompare.retention"), t("observerCompare.definition")]} />}>
|
||||
{!overlapRange ? <p className="text-xs text-text-muted">{t("observerCompare.overlapShortRanges")}</p> : <Card title={t("observerCompare.overlap")} right={<InfoTip text={[t("observerCompare.retention"), t("observerCompare.definition")]} />}>
|
||||
{overlap.isError ? <p role="alert">{t("common.loadFailed")}</p> : !overlap.data ? <p role="status">{t("common.loading")}</p> : <>
|
||||
<p className="mb-3 text-lg font-semibold">{t("observerCompare.total", { count: overlap.data.totalPackets })}</p>
|
||||
{overlap.data.totalPackets === 0 ? <p>{t("observerCompare.empty")}</p> : <div aria-hidden className="mb-3 flex h-5 overflow-hidden rounded">{groups.map(g => <div key={g.label} style={{ width: `${g.count / overlap.data!.totalPackets * 100}%`, background: g.color }} />)}</div>}
|
||||
<dl className="grid grid-cols-3 gap-2 text-sm">{groups.map(g => <div key={g.label}><dt className="text-text-muted">{g.label}</dt><dd className={`text-lg font-semibold ${g.textClass}`}>{g.count.toLocaleString(i18n.resolvedLanguage)}</dd></div>)}</dl>
|
||||
</>}
|
||||
</Card>
|
||||
</Card>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function ObserverSummary({ observer, activity, points, pending = false, a
|
||||
{ key: "uptime", label: t("observerPage.uptime"), value: observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—" },
|
||||
{
|
||||
key: "noise",
|
||||
label: t("observerPage.lastNoise"),
|
||||
label: t("observerPage.noise"),
|
||||
value: noise != null && Number.isFinite(noise) ? `${noise.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} dBm` : "—",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ObservationTable({ observations, selectedId, onSelect }: Props)
|
||||
<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">IATA</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">SNR</th>
|
||||
<th className="text-left font-medium py-1 px-1.5">RSSI</th>
|
||||
|
||||
@@ -111,12 +111,12 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
|
||||
</Badge>
|
||||
{detail.scope && <ScopeTag boxed>{detail.scope}</ScopeTag>}
|
||||
<Tooltip
|
||||
label={t("investigation.reportedBy", { count: observerCount })}
|
||||
label={t("investigation.heardBy", { count: observerCount })}
|
||||
className="ml-auto"
|
||||
>
|
||||
<span
|
||||
className="font-mono text-[13px] text-primary font-semibold bg-primary/6 px-1.5 rounded-sm"
|
||||
aria-label={t("investigation.reportedBy", { count: observerCount })}
|
||||
aria-label={t("investigation.heardBy", { count: observerCount })}
|
||||
>
|
||||
×{observerCount}
|
||||
</span>
|
||||
|
||||
@@ -70,7 +70,7 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb
|
||||
title={hasPath ? undefined : "No resolved path to map"}
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
>
|
||||
View path on map
|
||||
Map all paths
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -32,13 +32,13 @@ export function PacketInvestigation({ detail, selectedId, onSelect, onViewObserv
|
||||
</button></h2>
|
||||
<InfoTip text={[t(isTrace ? "investigation.traceNote" : "investigation.pathNote"), t("investigation.retention")]} />
|
||||
</div>
|
||||
<p className="font-mono text-[11px] text-text-muted">{t("investigation.reports", { count: detail.observations.length })} · {t("investigation.observers", { count: observerCount })}</p>
|
||||
<p className="font-mono text-[11px] text-text-muted">{detail.observations.length === observerCount ? t("investigation.heardBy", { count: observerCount }) : `${t("investigation.heardTimes", { count: detail.observations.length })} ${t("investigation.byObservers", { count: observerCount })}`}</p>
|
||||
{groups.length === 0 ? <p role="status" className="text-sm text-text-muted">{t("investigation.empty")}</p> : open && visible.map(group => <details key={group.key} open={group.reports.some(o => o.id === selectedId)} className="group/path">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 rounded px-1 py-1.5 font-mono text-xs hover:bg-text-normal/3 [&::-webkit-details-marker]:hidden">
|
||||
<span aria-hidden className="text-[9px] text-text-dim transition-transform group-open/path:rotate-90">▶</span>
|
||||
<span className="shrink-0 text-text-normal">{t(group.kind === "empty" ? "investigation.noEntries" : group.kind === "unavailable" ? "investigation.unavailablePath" : isTrace ? "investigation.tracePath" : "investigation.path", { number: groups.indexOf(group) + 1 })}</span>
|
||||
{group.hashes.length > 0 && <code className="min-w-0 truncate text-primary">{group.hashes.join(" → ")}</code>}
|
||||
<span className="ml-auto shrink-0 rounded-sm bg-text-normal/5 px-1.5 text-[11px] text-text-muted" aria-label={t("investigation.reports", { count: group.reports.length })}>×{group.reports.length}</span>
|
||||
<span className="ml-auto shrink-0 rounded-sm bg-text-normal/5 px-1.5 text-[11px] text-text-muted" aria-label={t("investigation.observations", { count: group.reports.length })}>×{group.reports.length}</span>
|
||||
</summary>
|
||||
<ul className="mt-1 mb-2 flex flex-col gap-1.5">{group.reports.map(report => {
|
||||
const key = isTrace ? "trace" : report.observerId;
|
||||
|
||||
@@ -15,7 +15,7 @@ export function PacketTableHeader() {
|
||||
<span>Hops</span>
|
||||
<span>Hash Size</span>
|
||||
<span className="truncate" title="Summary and source / destination">Summary / Src → Dst</span>
|
||||
<span>IATA</span>
|
||||
<span>Area</span>
|
||||
<span className="text-right">Age</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -61,7 +61,7 @@ export function RouteDetailPanel({ route: listed, iata, pathKey, onClose, onAnal
|
||||
<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="Obs" value={route.observationCount.toLocaleString()} />
|
||||
<Field label="Heard" value={route.observationCount.toLocaleString()} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -109,7 +109,7 @@ export function RouteDetailPanel({ route: listed, iata, pathKey, onClose, onAnal
|
||||
<p className="break-words text-sm text-text-bright">{report.observerName ?? report.observerId.slice(0, 8)}</p>
|
||||
<p className="text-xs text-text-normal"><Timestamp value={report.heardAt} ms /> · SNR <span className={level ? SIGNAL_LEVEL_CLASSES[level] : ""}>{formatSnr(report.snr)}</span>{report.snr != null && " dB"} · RSSI {report.rssi == null ? "—" : `${report.rssi} dBm`}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{onAnalyzePacket && <button className={ACTION_BUTTON_CLASS} onClick={() => onAnalyzePacket(report.packetHash, report.id)}>{t("investigation.inspect")}</button>}
|
||||
{onAnalyzePacket && <button className={ACTION_BUTTON_CLASS} onClick={() => onAnalyzePacket(report.packetHash, report.id)}>{t("routeEvidence.inspect")}</button>}
|
||||
{onViewObserver && <button className={ACTION_BUTTON_CLASS} onClick={() => onViewObserver(report.observerId)}>{t("investigation.observer")}</button>}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -78,7 +78,7 @@ function CrossRouteCard({ route }: { route: CrossIATARoute }) {
|
||||
|
||||
const COLUMNS: Column<KnownRoute>[] = [
|
||||
{
|
||||
header: "IATA",
|
||||
header: "Area",
|
||||
sortValue: (r) => r.iata,
|
||||
cell: (r) => <Badge variant="default">{r.iata}</Badge>,
|
||||
},
|
||||
@@ -302,7 +302,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="IATA"
|
||||
label="Areas"
|
||||
options={iataOptions}
|
||||
selected={searchIatas}
|
||||
onChange={setSearchIatas}
|
||||
@@ -338,7 +338,7 @@ export function RouteTable(actions: RouteActions) {
|
||||
) : crossRoutes && crossRoutes.length > 0 ? (
|
||||
crossRoutes.map((r, i) => <CrossRouteCard key={i} route={r} />)
|
||||
) : (
|
||||
<div className="font-mono text-[13px] text-text-dim">No cross-IATA routes</div>
|
||||
<div className="font-mono text-[13px] text-text-dim">No routes between these areas</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -48,7 +48,7 @@ export function ClockDriftTab() {
|
||||
},
|
||||
{
|
||||
id: "iatas",
|
||||
header: "IATAs",
|
||||
header: t("clockDrift.areas"),
|
||||
cell: (e) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(e.iatas ?? []).map((i) => (
|
||||
|
||||
@@ -23,9 +23,12 @@ function validation(value: Selection): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// raw packets are kept 3-7 days depending on the instance
|
||||
const RETENTION_HINT_MS = 7 * 86_400_000;
|
||||
|
||||
function localTime(ms: number) {
|
||||
const date = new Date(ms);
|
||||
return new Date(ms - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 23);
|
||||
return new Date(ms - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function ObserverSelect({ label, value, onChange, excludeId }: { label: string; value: string; onChange: (id: string) => void; excludeId?: string }) {
|
||||
@@ -48,6 +51,7 @@ function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onC
|
||||
const [since, setSince] = useState(() => localTime(initial?.since ?? Math.floor((Date.now() - 86_400_000) / 60_000) * 60_000));
|
||||
const [until, setUntil] = useState(() => localTime(initial?.until ?? Math.floor(Date.now() / 60_000) * 60_000));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retainedSince] = useState(() => Date.now() - RETENTION_HINT_MS);
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const value = { observerA: a.toLowerCase(), observerB: b.toLowerCase(), since: new Date(since).getTime(), until: new Date(until).getTime() };
|
||||
@@ -61,12 +65,13 @@ function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onC
|
||||
<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>
|
||||
<input type="datetime-local" step="0.001" value={since} onChange={(e) => setSince(e.target.value)} className={fieldClass} />
|
||||
<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>
|
||||
<input type="datetime-local" step="0.001" value={until} onChange={(e) => setUntil(e.target.value)} className={fieldClass} />
|
||||
<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>
|
||||
</form>
|
||||
@@ -124,14 +129,14 @@ export function CompareObserversTab() {
|
||||
{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>}
|
||||
<ComparisonForm key={keys.map((key) => params.get(key)).join("|")} initial={selection} onCompare={compare} />
|
||||
</Card>
|
||||
{selection && <Card title="Reported flood packets">
|
||||
{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 reported packets…</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>}
|
||||
{data && !result.isError && <>
|
||||
<p className="mb-3 flex items-center gap-2 text-lg font-semibold text-text-bright">{data.totalPackets.toLocaleString()} distinct flood packets<InfoTip text="Percentages use the union of packets heard by either observer. Repeated receptions count once. These counts reflect retained reports, not radio packet loss; an offline observer, broker interruption or expired history can affect the result." /></p>
|
||||
{data.totalPackets === 0 ? <p className="text-sm text-text-muted">No flood packets were reported by either observer in this period and region.</p> : <>
|
||||
<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> : <>
|
||||
<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>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
|
||||
const observerSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.activeObservers), [kpiObs]);
|
||||
|
||||
// top-row KPIs are the overview endpoint's fixed 24h snapshot; range only drives the charts below
|
||||
const ovWindow = `${ov?.windowHours ?? 24}h`;
|
||||
const ovWindow = t("mesh.lastHours", { hours: ov?.windowHours ?? 24 });
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
|
||||
|
||||
@@ -68,25 +68,25 @@ export function NeighbourGraphTab() {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Pick a region"
|
||||
subtitle="All regions is 5,000+ nodes — choose a region from the REGION picker above, or narrow to an IATA, to view its mesh."
|
||||
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."
|
||||
/>
|
||||
);
|
||||
if (isError) return <EmptyState title="Neighbour Graph" subtitle="Failed to load nodes" />;
|
||||
if (isError) return <EmptyState title="Neighbor graph" subtitle="Failed to load nodes" />;
|
||||
// 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="Neighbour Graph" subtitle="No nodes in this region" />;
|
||||
if (graph.nodes.length === 0) return <EmptyState title="Neighbor graph" subtitle="No nodes in this region" />;
|
||||
|
||||
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">
|
||||
Neighbourhood of <span className="text-text-normal">{selectedNode?.name ?? selectedId}</span> · {ego.nodes.length - 1} neighbours <InfoTip text="Click empty space for the full mesh." />
|
||||
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." />
|
||||
</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 an IATA for the rest." />}
|
||||
{graph.capped && <InfoTip text="Narrow to a single area for the rest." />}
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<SearchBar value={search} onChange={setSearch} fields={SEARCH_FIELDS} field={searchField} onFieldChange={setSearchField} />
|
||||
|
||||
@@ -22,11 +22,12 @@ const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRan
|
||||
|
||||
interface StatsOverviewProps {
|
||||
wsManager: WsManager;
|
||||
onViewNode?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
// Stats page shell: an analytics sub-header and range over the active sub-tab. Sub-tab and range live
|
||||
// in the URL (?statsTab/?range) so the view is shareable.
|
||||
export function StatsOverview({ wsManager }: StatsOverviewProps) {
|
||||
export function StatsOverview({ wsManager, onViewNode }: StatsOverviewProps) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const tab = asTab(params.get("statsTab"));
|
||||
@@ -68,7 +69,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
|
||||
{tab === "signal" && <SignalTab range={range} />}
|
||||
{tab === "paths" && <PathsTab range={range} />}
|
||||
{tab === "scopes" && <ScopesTab />}
|
||||
{tab === "talkers" && <TalkersTab range={range} />}
|
||||
{tab === "talkers" && <TalkersTab range={range} onViewNode={onViewNode} />}
|
||||
{tab === "clockdrift" && <ClockDriftTab />}
|
||||
{tab === "graph" && <NeighbourGraphTab />}
|
||||
{tab === "compare" && <CompareObserversTab />}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { TopAdvertiser, StatsRange } from "./types";
|
||||
|
||||
interface TalkersTabProps {
|
||||
range: StatsRange;
|
||||
onViewNode?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
// grow with the roster so bars stay readable; a floor keeps the loading/empty state from collapsing
|
||||
@@ -21,7 +22,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 }: TalkersTabProps) {
|
||||
export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
|
||||
const colors = useChartColors();
|
||||
const topAdvertisers = useTopAdvertisers(range, 20);
|
||||
const topTalkers = useTopTalkers(range, 20);
|
||||
@@ -73,7 +74,7 @@ export function TalkersTab({ range }: TalkersTabProps) {
|
||||
rows={advertisers}
|
||||
rowKey={(a) => a.nodeId}
|
||||
selectedKey={null}
|
||||
onSelect={() => {}}
|
||||
onSelect={(id) => { if (id) onViewNode?.(id); }}
|
||||
isLoading={advertisersLoading}
|
||||
emptyLabel={topAdvertisers.isError ? "Failed to load" : "No advertisers"}
|
||||
/>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function TrafficTab({ range }: { range: StatsRange }) {
|
||||
<Card title={t("traffic.byIata")} right={<InfoTip text={t("traffic.groupingHelp")} />}>
|
||||
{unavailable ? <p className="py-6 text-sm text-text-muted">{query.isError ? t("common.dataUnavailable") : t("traffic.loading")}</p> : !model.areas.length ? <p className="py-6 text-sm text-text-muted">{t("traffic.empty")}</p> : (
|
||||
<div className="max-h-[300px] overflow-y-auto"><table aria-label={t("traffic.byIata")} className="w-full text-left font-mono text-xs">
|
||||
<thead className="text-text-muted"><tr><th scope="col" className="py-2">IATA</th><th scope="col" className="pl-2 text-right">{t("traffic.receptionsColumn")}</th><th scope="col" className="pl-2 text-right">{t("traffic.share")}</th></tr></thead>
|
||||
<thead className="text-text-muted"><tr><th scope="col" className="py-2">{t("traffic.area")}</th><th scope="col" className="pl-2 text-right">{t("traffic.receptionsColumn")}</th><th scope="col" className="pl-2 text-right">{t("traffic.share")}</th></tr></thead>
|
||||
<tbody>{model.areas.map((area, i) => <tr key={area.name} className="border-t border-border-subtle">
|
||||
<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: colors.series[Math.min(i, 7)] }} />{trafficAreaLabel(area.name, t)}</th>
|
||||
<td className="pl-2 text-right tabular-nums text-text-bright">{area.total.toLocaleString()}</td><td className="pl-2 text-right tabular-nums text-text-muted">{(model.total ? 100 * area.total / model.total : 0).toFixed(1)}%</td>
|
||||
|
||||
@@ -211,7 +211,7 @@ export function neighbourGraphOption(
|
||||
}
|
||||
const d = param.data as unknown as GraphNode;
|
||||
const type = d.nodeTypeName || "unknown";
|
||||
return d.degree > 0 ? `${d.name}\n${type} · ${d.degree} neighbour${d.degree === 1 ? "" : "s"}` : `${d.name}\n${type}`;
|
||||
return d.degree > 0 ? `${d.name}\n${type} · ${d.degree} neighbor${d.degree === 1 ? "" : "s"}` : `${d.name}\n${type}`;
|
||||
},
|
||||
},
|
||||
legend: [
|
||||
|
||||
+113
-106
@@ -27,14 +27,16 @@
|
||||
"rateLimited": "RATE LIMITED {{seconds}}s"
|
||||
},
|
||||
"region": {
|
||||
"label": "REGION",
|
||||
"label": "Region",
|
||||
"allShort": "ALL",
|
||||
"allRegions": "All Regions",
|
||||
"group": "Regions",
|
||||
"areaGroup": "Areas",
|
||||
"count_one": "{{count}} region",
|
||||
"count_other": "{{count}} regions",
|
||||
"iataCount": "{{count}} IATA",
|
||||
"filter": "Filter IATA or name…",
|
||||
"iataCount_one": "{{count}} area",
|
||||
"iataCount_other": "{{count}} areas",
|
||||
"filter": "Search region or area code…",
|
||||
"failed": "Failed to load",
|
||||
"noMatches": "No matches"
|
||||
},
|
||||
@@ -52,7 +54,8 @@
|
||||
"totalPackets": "Total packets",
|
||||
"observations": "Observations",
|
||||
"activeObservers": "Active observers",
|
||||
"activeIatas": "Active IATAs",
|
||||
"activeIatas": "Active areas",
|
||||
"lastHours": "Last {{hours}}h",
|
||||
"observationsTitle": "Observations · {{range}}",
|
||||
"topObservers": "Top observers · {{range}}",
|
||||
"payloadTypes": "Payload types · {{range}}",
|
||||
@@ -74,12 +77,12 @@
|
||||
"mesh": "Mesh",
|
||||
"traffic": "Traffic",
|
||||
"signal": "RF / Signal",
|
||||
"paths": "Paths & Hashes",
|
||||
"paths": "Paths & hashes",
|
||||
"scopes": "Scopes",
|
||||
"talkers": "Talkers",
|
||||
"clockdrift": "Clock Drift",
|
||||
"clockdrift": "Clock drift",
|
||||
"compare": "Compare observers",
|
||||
"graph": "Neighbour Graph"
|
||||
"graph": "Neighbor graph"
|
||||
},
|
||||
"ranges": {
|
||||
"24h": "24h",
|
||||
@@ -89,38 +92,38 @@
|
||||
},
|
||||
"signal": {
|
||||
"title": "RF / Signal",
|
||||
"subtitle": "The signal behind reported receptions.",
|
||||
"subtitle": "Signal strength of packets heard by observers.",
|
||||
"refresh": "Refresh signal",
|
||||
"error": "Could not load signal data. Try refreshing or choosing a shorter time period.",
|
||||
"receptions": "Reported receptions",
|
||||
"receptions": "Observations",
|
||||
"meanSnr": "Mean SNR",
|
||||
"meanRssi": "Mean RSSI",
|
||||
"hoursWithRecords": "Hours with records",
|
||||
"measurement": "Signal measured by observers on the last hop. Charts count retained receptions and use UTC time.",
|
||||
"window": "Window: {{since}} to {{until}} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours do not establish an outage.",
|
||||
"hoursWithRecords": "Active hours",
|
||||
"measurement": "Measured by each observer on the last hop. Times are UTC.",
|
||||
"window": "{{since}} to {{until}} UTC, current hour excluded. Blank hours don't mean an outage.",
|
||||
"histogramTitle": "{{metric}} distribution · {{unit}}",
|
||||
"meanTitle": "Hourly mean {{metric}} · {{unit}}",
|
||||
"availability": "Sample availability",
|
||||
"averagesTitle": "What the averages include",
|
||||
"loading": "Loading signal…",
|
||||
"empty": "No retained receptions in this window.",
|
||||
"empty": "No observations in this window.",
|
||||
"availabilityTable": "Signal sample availability",
|
||||
"metric": "Metric",
|
||||
"samples": "Samples",
|
||||
"missing": "Missing",
|
||||
"share": "Share",
|
||||
"averagesHelp": "Each mean weights every available reception equally. Missing includes absent, invalid and zero/zero unavailable readings; a measured zero SNR remains valid. SNR and RSSI can have different sample counts. These readings do not measure end-to-end quality or packet loss, and colors are not quality thresholds.",
|
||||
"averagesHelp": "Each observation counts equally; missing readings are left out.",
|
||||
"details": "Exact histogram and hourly values",
|
||||
"histogramTable": "{{metric}} histogram",
|
||||
"binsHelp": "Bins include the lower bound and exclude the upper bound. The outer bins include all values beyond the display range. Hourly means below are rounded to one decimal place.",
|
||||
"binsHelp": "Each bin includes its lower bound; the outer bins catch everything beyond.",
|
||||
"hourlyTable": "Hourly signal values",
|
||||
"utcHour": "UTC hour",
|
||||
"receptionsColumn": "Receptions",
|
||||
"receptionsColumn": "Observations",
|
||||
"metricSamples": "{{metric}} samples",
|
||||
"meanUnit": "Mean {{unit}}",
|
||||
"binInterval": "{{lower}} to < {{upper}}",
|
||||
"histogramDescription": "{{metric}} distribution in {{unit}}. Exact bin boundaries and counts follow below.",
|
||||
"trendDescription": "Hourly mean {{metric}} in {{unit}}, UTC. Missing readings remain gaps. Exact values are in the hourly table.",
|
||||
"trendDescription": "Hourly mean {{metric}} in {{unit}}, UTC. Gaps mean no readings. Exact values are in the hourly table.",
|
||||
"coverageDescription": "Available and unavailable readings for SNR and RSSI. Exact counts and percentages are in the sample availability table.",
|
||||
"available": "Available",
|
||||
"unavailable": "Unavailable",
|
||||
@@ -130,22 +133,22 @@
|
||||
"subtitle": "How received packets carry route hashes.",
|
||||
"refresh": "Refresh paths",
|
||||
"error": "Could not load path data. Try refreshing or choosing a shorter time period.",
|
||||
"receptions": "Reported receptions",
|
||||
"receptions": "Observations",
|
||||
"withHashPaths": "With hash paths",
|
||||
"multiByteShare": "Multi-byte share",
|
||||
"mostEntries": "Most path entries",
|
||||
"measurement": "Path entries reported by observers, counted per reception. Hash-width shares use nonempty hash paths.",
|
||||
"window": "Window: {{since}} to {{until}} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours lack retained records.",
|
||||
"measurement": "Path entries counted per observation.",
|
||||
"window": "{{since}} to {{until}} UTC, current hour excluded.",
|
||||
"widthTitle": "Observed hash widths",
|
||||
"lengthTitle": "Received path entries",
|
||||
"trendTitle": "Hash-path receptions over time",
|
||||
"trendTitle": "Hash-path observations over time",
|
||||
"coverageTitle": "Path format coverage",
|
||||
"countsTitle": "What the counts include",
|
||||
"loading": "Loading paths…",
|
||||
"empty": "No retained receptions in this window.",
|
||||
"empty": "No observations in this window.",
|
||||
"classificationTable": "Path classification counts",
|
||||
"category": "Category",
|
||||
"receptionsColumn": "Receptions",
|
||||
"receptionsColumn": "Observations",
|
||||
"share": "Share",
|
||||
"categories": {
|
||||
"hashed": "Hash paths",
|
||||
@@ -153,67 +156,68 @@
|
||||
"trace": "Trace",
|
||||
"unclassified": "Unclassified"
|
||||
},
|
||||
"countsHelp": "Flood paths accumulate entries; direct routes contain remaining entries. Empty paths have no hash-width vote. Trace headers carry signal readings. Unclassified records lack usable payload or path metadata.",
|
||||
"countsHelp": "Empty paths have no hash width, and unclassified packets had no usable path data.",
|
||||
"details": "Exact width, path-length and hourly values",
|
||||
"widthTable": "Hash width counts",
|
||||
"bytesPerHash": "Bytes per hash",
|
||||
"lengthTable": "Path entry counts",
|
||||
"headerEntries": "Header entries",
|
||||
"lengthsHelp": "Length counts include validated ordinary empty paths at zero. In the trend, recorded hours with no hash paths show zero; absent hours remain gaps.",
|
||||
"lengthsHelp": "Zero means an empty path; hours with no data are left as gaps.",
|
||||
"hourlyTable": "Hourly path counts",
|
||||
"utcHour": "UTC hour",
|
||||
"hashWidth_one": "{{count}}-byte",
|
||||
"hashWidth_other": "{{count}}-byte",
|
||||
"hashPathsCenter": "HASH PATHS",
|
||||
"receptionsCenter": "RECEPTIONS",
|
||||
"widthDescription": "Hash-width share among nonempty ordinary paths. Empty, trace and unclassified records are excluded. Exact counts follow below.",
|
||||
"coverageDescription": "Path classification for all retained receptions. The four categories partition the total; exact counts follow alongside.",
|
||||
"lengthDescription": "Received path entries per reception. Zero means an empty ordinary path. Trace and unclassified records are excluded. Exact counts follow in the table.",
|
||||
"receptionsCenter": "OBSERVATIONS",
|
||||
"widthDescription": "Hash-width share among non-empty paths. Exact counts follow below.",
|
||||
"coverageDescription": "Path type for every observation. Exact counts follow alongside.",
|
||||
"lengthDescription": "Path entries per observation. Zero means an empty path. Exact counts follow in the table.",
|
||||
"entries": "Path entries",
|
||||
"trendDescription": "Hourly receptions carrying nonempty 1, 2 or 3-byte hash paths, UTC. Absent hours remain gaps. Exact values follow in the hourly table."
|
||||
"trendDescription": "Hourly observations with 1, 2 or 3-byte hash paths, UTC. Gaps mean no data. Exact values follow in the hourly table."
|
||||
},
|
||||
"traffic": {
|
||||
"subtitle": "When and where observers report receptions.",
|
||||
"subtitle": "When and where observers hear packets.",
|
||||
"refresh": "Refresh traffic",
|
||||
"error": "Could not load traffic. Try refreshing.",
|
||||
"receptions": "Reported receptions",
|
||||
"reportingIatas": "Reporting IATAs",
|
||||
"receptions": "Observations",
|
||||
"reportingIatas": "Active areas",
|
||||
"busiestHour": "Busiest hour",
|
||||
"hoursWithRecords": "Hours with records",
|
||||
"measurement": "Counts are reported receptions, not unique packets. Blank hours have no retained hourly record; they do not establish an outage. Times are UTC and the latest hour may be partial.",
|
||||
"trendTitle": "Reception trend by IATA",
|
||||
"hourlyRecords": "Available hourly records",
|
||||
"hoursWithRecords": "Active hours",
|
||||
"measurement": "Counts are observations, not unique packets. Times are UTC; blank hours don't mean an outage.",
|
||||
"trendTitle": "Observations by area",
|
||||
"hourlyRecords": "Hourly · UTC",
|
||||
"hourlyTitle": "Hourly activity · {{range}}",
|
||||
"shareTitle": "Reception share",
|
||||
"byIata": "Receptions by IATA",
|
||||
"shareTitle": "Observation share",
|
||||
"byIata": "Observations by area",
|
||||
"loading": "Loading traffic…",
|
||||
"empty": "No retained receptions in this window.",
|
||||
"receptionsColumn": "Receptions",
|
||||
"empty": "No observations in this window.",
|
||||
"receptionsColumn": "Observations",
|
||||
"area": "Area",
|
||||
"share": "Share",
|
||||
"groupingHelp": "Charts group smaller areas into “Other IATAs”; this table keeps every area.",
|
||||
"receptionsCenter": "RECEPTIONS",
|
||||
"shareDescription": "Reception share by IATA. Exact counts and percentages follow in the table.",
|
||||
"groupingHelp": "Charts group smaller areas into “Other areas”; this table keeps every area.",
|
||||
"receptionsCenter": "OBSERVATIONS",
|
||||
"shareDescription": "Observation share by area. Exact counts and percentages follow in the table.",
|
||||
"unassigned": "Unassigned",
|
||||
"otherIatas": "Other IATAs",
|
||||
"trendDescription": "Hourly reported receptions by IATA. Gaps mean no retained hourly record. Exact totals follow in the data table.",
|
||||
"heatmapDescription": "Activity heatmap by UTC day and hour. Brighter cells indicate more reported receptions; blank cells have no retained record.",
|
||||
"otherIatas": "Other areas",
|
||||
"trendDescription": "Hourly observations by area. Gaps mean no data. Exact totals follow in the data table.",
|
||||
"heatmapDescription": "Activity by UTC day and hour. Brighter cells mean more observations.",
|
||||
"more": "More",
|
||||
"fewer": "Fewer",
|
||||
"heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} reception",
|
||||
"heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} receptions"
|
||||
"heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} observation",
|
||||
"heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} observations"
|
||||
},
|
||||
"scopes": {
|
||||
"title": "Transport scopes",
|
||||
"subtitle": "Retained data and memberships for the selected region.",
|
||||
"subtitle": "Scope activity for the selected region.",
|
||||
"search": "Find a scope",
|
||||
"error": "Could not load scopes. Try refreshing.",
|
||||
"active": "Scopes with data",
|
||||
"scopedPackets": "Scoped packets",
|
||||
"memberships": "Observer memberships",
|
||||
"memberships": "Observers",
|
||||
"defaultNodes": "Default-scope nodes",
|
||||
"measurement": "An observer can appear in more than one scope. Global observer counts include stored memberships; regional counts use retained scoped receptions. Nodes are counted in their default scope. These counts have no selected time window.",
|
||||
"measurement": "An observer can belong to more than one scope. These counts aren't limited to a time range.",
|
||||
"packetsTitle": "Packets by scope",
|
||||
"membershipsTitle": "Observer memberships by scope",
|
||||
"membershipsTitle": "Observers by scope",
|
||||
"countsTitle": "Scope counts",
|
||||
"visibleCount_one": "{{visible}} of {{count}} scope",
|
||||
"visibleCount_other": "{{visible}} of {{count}} scopes",
|
||||
@@ -245,7 +249,8 @@
|
||||
"inSync": "in sync",
|
||||
"ahead": "ahead",
|
||||
"behind": "behind",
|
||||
"empty": "No repeaters out of sync"
|
||||
"empty": "No repeaters out of sync",
|
||||
"areas": "Areas"
|
||||
},
|
||||
"timestamp": {
|
||||
"ago": "{{duration}} ago",
|
||||
@@ -261,10 +266,9 @@
|
||||
"detail": "Observer detail",
|
||||
"range": "Time range",
|
||||
"open": "Open observer dashboard",
|
||||
"legacy": "Legacy presence count",
|
||||
"legacy": "Observations (all time)",
|
||||
"metrics": "Observer metrics",
|
||||
"records": "Recorded packets",
|
||||
"lastHour": "Last complete hour",
|
||||
"records": "Packets heard",
|
||||
"lastPacket": "Last packet",
|
||||
"battery": "Battery",
|
||||
"batteryV": "Battery V",
|
||||
@@ -275,11 +279,11 @@
|
||||
"search": "Find an observer",
|
||||
"choose": "Choose an observer",
|
||||
"searchHelp": "Search by name to find observers outside the first 50 results.",
|
||||
"summaryMissing": "Packet summary unavailable on this server. Charts remain available.",
|
||||
"summaryMissing": "Packet totals unavailable.",
|
||||
"statusRecent": "Recent status",
|
||||
"statusStale": "Status is stale",
|
||||
"statusMissing": "No status report",
|
||||
"activity": "Recorded packets per {{interval}}",
|
||||
"activity": "Packets heard per {{interval}}",
|
||||
"mix": "Packet-type mix",
|
||||
"signal": "Received signal",
|
||||
"airtime": "Airtime TX / RX",
|
||||
@@ -287,9 +291,9 @@
|
||||
"queue": "Queue length",
|
||||
"errors": "Receive errors",
|
||||
"busy": "Channel busy",
|
||||
"empty": "No packets recorded in this period",
|
||||
"noTelemetry": "No telemetry reported",
|
||||
"noTelemetryHelp": "This observer reports no device telemetry for this period.",
|
||||
"empty": "No packets heard in this period",
|
||||
"noTelemetry": "No telemetry",
|
||||
"noTelemetryHelp": "This observer sent no device telemetry for this period.",
|
||||
"loadFailed": "Observer could not be loaded.",
|
||||
"retry": "Retry",
|
||||
"model": "Model",
|
||||
@@ -314,7 +318,6 @@
|
||||
"noMatches": "No matches",
|
||||
"none": "No observers",
|
||||
"packetsLastHour": "Packets last hour",
|
||||
"lastNoise": "Last noise floor",
|
||||
"copyKey": "Copy public key",
|
||||
"sort": "Sort observers",
|
||||
"sortActivity": "Activity",
|
||||
@@ -331,76 +334,80 @@
|
||||
"choose": "Choose an observer to compare",
|
||||
"invalidObserver": "Choose a different observer with a valid identifier.",
|
||||
"invalidTime": "This comparison time is invalid or too old. Refresh to use the latest complete window.",
|
||||
"refresh": "Refresh both observers",
|
||||
"unavailable": "Aligned summaries are unavailable. Refresh both observers, or update the server to support shared windows.",
|
||||
"currentNote": "Activity totals use this shared window. Device values and the latest complete hour describe current conditions; the hour start is shown in UTC.",
|
||||
"refresh": "Refresh",
|
||||
"unavailable": "Comparison not available on this server.",
|
||||
"currentNote": "Totals cover this window; battery, uptime and noise floor are current values.",
|
||||
"metrics": "Observer comparison metrics",
|
||||
"metric": "Metric",
|
||||
"activity": "Recorded packets · aligned activity",
|
||||
"overlap": "Retained flood-packet overlap",
|
||||
"retention": "Raw packet history may expire before the longer-lived activity summaries. Overlap counts only retained flood packets in the shared window, across all regions.",
|
||||
"total_one": "{{count}} distinct flood packet",
|
||||
"total_other": "{{count}} distinct flood packets",
|
||||
"empty": "No retained flood packets were reported by either observer in this window.",
|
||||
"activity": "Packets heard",
|
||||
"overlap": "Heard by both",
|
||||
"retention": "Only packets the server still keeps are compared.",
|
||||
"total_one": "{{count}} flood packet",
|
||||
"total_other": "{{count}} flood packets",
|
||||
"empty": "Neither observer heard a flood packet in this window.",
|
||||
"onlyA": "Only A",
|
||||
"both": "Both",
|
||||
"onlyB": "Only B",
|
||||
"definition": "Each flood packet hash counts once across both observers. Repeat receptions are deduplicated. This is not a packet-loss measurement: expiry, offline devices and broker interruptions affect the available reports.",
|
||||
"retainedWindow": "Packet details and overlap use retained raw records, so periods older than the server's packet retention come back incomplete."
|
||||
"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."
|
||||
},
|
||||
"investigation": {
|
||||
"title": "Observations",
|
||||
"reports_one": "{{count}} retained report",
|
||||
"reports_other": "{{count}} retained reports",
|
||||
"observers_one": "{{count}} reporting observer",
|
||||
"observers_other": "{{count}} reporting observers",
|
||||
"pathNote": "Grouped by path prefixes and hash width, not confirmed physical routes. Inspect each report for candidate identities.",
|
||||
"traceNote": "TRACE entries describe the intended route, not a measured reception path. Inspect the trace readings separately.",
|
||||
"empty": "No retained reports for this packet.",
|
||||
"observations_one": "{{count}} observation",
|
||||
"observations_other": "{{count}} observations",
|
||||
"heardBy_one": "Heard by {{count}} observer",
|
||||
"heardBy_other": "Heard by {{count}} observers",
|
||||
"heardTimes_one": "Heard once",
|
||||
"heardTimes_other": "Heard {{count}} times",
|
||||
"byObservers_one": "by {{count}} observer",
|
||||
"byObservers_other": "by {{count}} observers",
|
||||
"pathNote": "Grouped by the repeaters each copy came through. Short IDs can match more than one node.",
|
||||
"traceNote": "TRACE entries show the planned route, not the path the packet took.",
|
||||
"empty": "No observations for this packet.",
|
||||
"path": "Path {{number}}",
|
||||
"tracePath": "Intended route {{number}}",
|
||||
"noEntries": "No path entries",
|
||||
"unavailablePath": "Path bytes unavailable",
|
||||
"inspect": "Inspect report",
|
||||
"inspect": "Select observation",
|
||||
"observer": "Inspect observer",
|
||||
"map": "Map report",
|
||||
"unmappable": "Mapping needs at least two uniquely resolved, valid locations.",
|
||||
"retention": "Reports include all received regions and follow packet/observer deduplication, so not every reception or path is retained. Expired reports are unavailable; reported timing does not establish packet loss or delivery.",
|
||||
"unavailableReport": "Selected report is unavailable or its identifier is invalid. Choose a retained report below.",
|
||||
"map": "Map path",
|
||||
"unmappable": "Needs at least two repeaters with known locations.",
|
||||
"retention": "One entry per observer. Older packets expire after the server's packet retention.",
|
||||
"unavailableReport": "That observation is no longer available or the link is invalid. Choose one below.",
|
||||
"copy": "Copy link",
|
||||
"copyPacket": "Copy packet link",
|
||||
"viewAll": "View path on map",
|
||||
"viewAll": "Map all paths",
|
||||
"mapDialog": "Packet path map",
|
||||
"mapTitle": "Packet Path",
|
||||
"copyPath": "Copy path link",
|
||||
"closeMap": "Close path map",
|
||||
"allPaths": "All paths",
|
||||
"traceRoute": "Trace route",
|
||||
"mapNote": "Only uniquely resolved locations are shown. Unresolved or unlocated gaps are not connected; lines do not establish direct radio links or delivery.",
|
||||
"unavailableMap": "The selected path cannot be mapped from the retained evidence. Choose an available path or All paths.",
|
||||
"reportedBy_one": "Reported by {{count}} observer",
|
||||
"reportedBy_other": "Reported by {{count}} observers",
|
||||
"mapNote": "Lines join repeaters with known locations. They don't prove a direct radio link.",
|
||||
"unavailableMap": "This path can't be mapped. Choose another path or All paths.",
|
||||
"first": "First",
|
||||
"last": "Last",
|
||||
"span": "Reported span",
|
||||
"span": "Heard over",
|
||||
"nodeDialog": "Node detail",
|
||||
"packetDialog": "Packet analyzer",
|
||||
"closeObserver": "Close observer details",
|
||||
"loadMore": "Load more ({{count}} left)"
|
||||
},
|
||||
"routeEvidence": {
|
||||
"recent": "Recent packets",
|
||||
"loading": "Loading packets…",
|
||||
"missing": "Recent packets aren't available for this route on this server.",
|
||||
"error": "Could not load recent packets. Try again.",
|
||||
"recent": "Recent observations",
|
||||
"loading": "Loading observations…",
|
||||
"missing": "Recent observations aren't available for this route on this server.",
|
||||
"error": "Could not load recent observations. Try again.",
|
||||
"retry": "Retry",
|
||||
"match": "Packets heard along exactly this hop sequence at {{width}} byte(s) per hop, newest first. TRACE packets and other prefix widths aren't included.",
|
||||
"caution": "Matching prefixes don't prove the same physical path; inspect a packet to see its candidate nodes.",
|
||||
"retention": "Only packets the server still keeps are listed, so older ones drop off. The observation count above covers the route's whole life.",
|
||||
"unavailable": "This route's saved prefixes can't be matched against packets.",
|
||||
"empty": "No packets on this route are still kept. Older ones may have expired or used a different prefix width.",
|
||||
"match": "Observations along exactly these hops at {{width}} byte(s) per hop, newest first. TRACE packets aren't included.",
|
||||
"caution": "Matching short IDs don't prove the same physical path.",
|
||||
"retention": "Older packets drop off; the Heard count above covers the route's whole life.",
|
||||
"unavailable": "This route can't be matched against packets.",
|
||||
"empty": "No observations on this route are still kept. Older ones may have expired.",
|
||||
"more": "Load more",
|
||||
"cap": "Showing the newest 500 packets."
|
||||
"cap": "Showing the newest 500 observations.",
|
||||
"inspect": "Inspect packet"
|
||||
},
|
||||
"channelMessages": {
|
||||
"select": "Select a channel",
|
||||
@@ -412,14 +419,14 @@
|
||||
"keyMissing": "No decryption key is configured for this channel.",
|
||||
"scope": "Scope",
|
||||
"allScopes": "All scopes",
|
||||
"unknownScope": "Unresolved scope",
|
||||
"unknownScope": "Unknown scope",
|
||||
"inspect": "Inspect packet from {{sender}}",
|
||||
"loading": "Loading messages…",
|
||||
"loadingOlder": "Loading older messages… ({{count}} shown)",
|
||||
"loadOlder": "Load older messages",
|
||||
"empty": "No retained messages for this channel.",
|
||||
"emptyScope": "No retained messages match {{scope}}.",
|
||||
"emptyNoKey": "No decrypted messages are available without a known channel key.",
|
||||
"empty": "No messages for this channel.",
|
||||
"emptyScope": "No messages match {{scope}}.",
|
||||
"emptyNoKey": "Messages can't be decrypted without the channel key.",
|
||||
"error": "Could not load messages.",
|
||||
"retry": "Retry",
|
||||
"viewPacket": "View packet"
|
||||
|
||||
+112
-104
@@ -27,15 +27,18 @@
|
||||
"rateLimited": "DÉBIT LIMITÉ {{seconds}} s"
|
||||
},
|
||||
"region": {
|
||||
"label": "RÉGION",
|
||||
"label": "Région",
|
||||
"allShort": "TOUT",
|
||||
"allRegions": "Toutes les régions",
|
||||
"group": "Régions",
|
||||
"areaGroup": "Zones",
|
||||
"count_one": "{{count}} région",
|
||||
"count_many": "{{count}} régions",
|
||||
"count_other": "{{count}} régions",
|
||||
"iataCount": "{{count}} IATA",
|
||||
"filter": "Filtrer par IATA ou nom…",
|
||||
"iataCount_one": "{{count}} zone",
|
||||
"iataCount_many": "{{count}} zones",
|
||||
"iataCount_other": "{{count}} zones",
|
||||
"filter": "Rechercher une région ou un code de zone…",
|
||||
"failed": "Échec du chargement",
|
||||
"noMatches": "Aucun résultat"
|
||||
},
|
||||
@@ -53,7 +56,8 @@
|
||||
"totalPackets": "Paquets totaux",
|
||||
"observations": "Observations",
|
||||
"activeObservers": "Observateurs actifs",
|
||||
"activeIatas": "IATA actifs",
|
||||
"activeIatas": "Zones actives",
|
||||
"lastHours": "Dernières {{hours}} h",
|
||||
"observationsTitle": "Observations · {{range}}",
|
||||
"topObservers": "Meilleurs observateurs · {{range}}",
|
||||
"payloadTypes": "Types de paquets · {{range}}",
|
||||
@@ -90,38 +94,38 @@
|
||||
},
|
||||
"signal": {
|
||||
"title": "RF / Signal",
|
||||
"subtitle": "Le signal des réceptions déclarées.",
|
||||
"subtitle": "Force du signal des paquets reçus par les observateurs.",
|
||||
"refresh": "Actualiser le signal",
|
||||
"error": "Impossible de charger les données du signal. Réessayez ou choisissez une période plus courte.",
|
||||
"receptions": "Réceptions déclarées",
|
||||
"receptions": "Observations",
|
||||
"meanSnr": "SNR moyen",
|
||||
"meanRssi": "RSSI moyen",
|
||||
"hoursWithRecords": "Heures documentées",
|
||||
"measurement": "Signal mesuré par les observateurs sur le dernier saut. Les graphiques comptent les réceptions conservées et utilisent l’heure UTC.",
|
||||
"window": "Période : {{since}} à {{until}} UTC (fin exclue). Les instantanés d’heures complètes sont actualisés en arrière-plan ; l’heure en cours est exclue. Les heures vides ne prouvent pas une panne.",
|
||||
"hoursWithRecords": "Heures actives",
|
||||
"measurement": "Mesuré par chaque observateur sur le dernier saut. Heures en UTC.",
|
||||
"window": "{{since}} à {{until}} UTC, heure en cours exclue. Les heures vides ne signifient pas une panne.",
|
||||
"histogramTitle": "Distribution du {{metric}} · {{unit}}",
|
||||
"meanTitle": "Moyenne horaire du {{metric}} · {{unit}}",
|
||||
"availability": "Disponibilité des échantillons",
|
||||
"averagesTitle": "Ce que les moyennes incluent",
|
||||
"loading": "Chargement du signal…",
|
||||
"empty": "Aucune réception conservée dans cette période.",
|
||||
"empty": "Aucune observation dans cette période.",
|
||||
"availabilityTable": "Disponibilité des échantillons du signal",
|
||||
"metric": "Mesure",
|
||||
"samples": "Échantillons",
|
||||
"missing": "Manquants",
|
||||
"share": "Part",
|
||||
"averagesHelp": "Chaque moyenne accorde le même poids à chaque réception disponible. Les valeurs manquantes incluent les mesures absentes, invalides et les couples zéro/zéro indisponibles ; un SNR mesuré à zéro reste valide. Le SNR et le RSSI peuvent avoir des nombres d’échantillons différents. Ces mesures ne représentent ni la qualité de bout en bout ni la perte de paquets, et les couleurs ne sont pas des seuils de qualité.",
|
||||
"averagesHelp": "Chaque observation compte autant ; les mesures manquantes sont ignorées.",
|
||||
"details": "Valeurs exactes des histogrammes et par heure",
|
||||
"histogramTable": "Histogramme {{metric}}",
|
||||
"binsHelp": "Les classes incluent la borne inférieure et excluent la borne supérieure. Les classes extrêmes incluent toutes les valeurs au-delà de la plage affichée. Les moyennes horaires ci-dessous sont arrondies à une décimale.",
|
||||
"binsHelp": "Chaque classe inclut sa borne inférieure ; les classes extrêmes regroupent tout le reste.",
|
||||
"hourlyTable": "Valeurs horaires du signal",
|
||||
"utcHour": "Heure UTC",
|
||||
"receptionsColumn": "Réceptions",
|
||||
"receptionsColumn": "Observations",
|
||||
"metricSamples": "Échantillons {{metric}}",
|
||||
"meanUnit": "Moyenne {{unit}}",
|
||||
"binInterval": "{{lower}} à < {{upper}}",
|
||||
"histogramDescription": "Distribution du {{metric}} en {{unit}}. Les bornes exactes des classes et leurs effectifs figurent ci-dessous.",
|
||||
"trendDescription": "Moyenne horaire du {{metric}} en {{unit}}, UTC. Les mesures manquantes restent des lacunes. Les valeurs exactes figurent dans le tableau horaire.",
|
||||
"trendDescription": "Moyenne horaire du {{metric}} en {{unit}}, UTC. Les lacunes indiquent l’absence de mesures. Les valeurs exactes figurent dans le tableau horaire.",
|
||||
"coverageDescription": "Mesures disponibles et indisponibles du SNR et du RSSI. Les effectifs et pourcentages exacts figurent dans le tableau de disponibilité des échantillons.",
|
||||
"available": "Disponibles",
|
||||
"unavailable": "Indisponibles",
|
||||
@@ -131,22 +135,22 @@
|
||||
"subtitle": "Les hachages de route portés par les paquets reçus.",
|
||||
"refresh": "Actualiser les chemins",
|
||||
"error": "Impossible de charger les données des chemins. Réessayez ou choisissez une période plus courte.",
|
||||
"receptions": "Réceptions déclarées",
|
||||
"receptions": "Observations",
|
||||
"withHashPaths": "Avec chemins hachés",
|
||||
"multiByteShare": "Part multi-octets",
|
||||
"mostEntries": "Maximum d’entrées",
|
||||
"measurement": "Entrées de chemin déclarées par les observateurs, comptées par réception. Les parts par largeur de hachage utilisent les chemins hachés non vides.",
|
||||
"window": "Période : {{since}} à {{until}} UTC (fin exclue). Les instantanés d’heures complètes sont actualisés en arrière-plan ; l’heure en cours est exclue. Les heures vides n’ont pas de données conservées.",
|
||||
"measurement": "Entrées de chemin comptées par observation.",
|
||||
"window": "{{since}} à {{until}} UTC, heure en cours exclue.",
|
||||
"widthTitle": "Largeurs de hachage observées",
|
||||
"lengthTitle": "Entrées de chemin reçues",
|
||||
"trendTitle": "Réceptions avec chemins hachés au fil du temps",
|
||||
"trendTitle": "Observations avec chemins hachés au fil du temps",
|
||||
"coverageTitle": "Répartition des formats de chemin",
|
||||
"countsTitle": "Ce que les effectifs incluent",
|
||||
"loading": "Chargement des chemins…",
|
||||
"empty": "Aucune réception conservée dans cette période.",
|
||||
"empty": "Aucune observation dans cette période.",
|
||||
"classificationTable": "Effectifs par catégorie de chemin",
|
||||
"category": "Catégorie",
|
||||
"receptionsColumn": "Réceptions",
|
||||
"receptionsColumn": "Observations",
|
||||
"share": "Part",
|
||||
"categories": {
|
||||
"hashed": "Chemins hachés",
|
||||
@@ -154,69 +158,70 @@
|
||||
"trace": "Trace",
|
||||
"unclassified": "Non classés"
|
||||
},
|
||||
"countsHelp": "Les chemins de diffusion accumulent des entrées ; les routes directes contiennent les entrées restantes. Les chemins vides ne comptent dans aucune largeur de hachage. Les en-têtes de trace portent des mesures de signal. Les enregistrements non classés n’ont pas de données utiles ou de métadonnées de chemin exploitables.",
|
||||
"countsHelp": "Les chemins vides n’ont pas de largeur de hachage, et les paquets non classés n’avaient pas de données de chemin utilisables.",
|
||||
"details": "Valeurs exactes des largeurs, longueurs et par heure",
|
||||
"widthTable": "Effectifs par largeur de hachage",
|
||||
"bytesPerHash": "Octets par hachage",
|
||||
"lengthTable": "Effectifs par nombre d’entrées",
|
||||
"headerEntries": "Entrées d’en-tête",
|
||||
"lengthsHelp": "Les effectifs par longueur incluent les chemins ordinaires vides validés à zéro entrée. Dans la courbe, les heures documentées sans chemin haché affichent zéro ; les heures absentes restent des lacunes.",
|
||||
"lengthsHelp": "Zéro indique un chemin vide ; les heures sans données restent des lacunes.",
|
||||
"hourlyTable": "Effectifs horaires des chemins",
|
||||
"utcHour": "Heure UTC",
|
||||
"hashWidth_one": "{{count}} octet",
|
||||
"hashWidth_many": "{{count}} octets",
|
||||
"hashWidth_other": "{{count}} octets",
|
||||
"hashPathsCenter": "CHEMINS HACHÉS",
|
||||
"receptionsCenter": "RÉCEPTIONS",
|
||||
"widthDescription": "Part de chaque largeur de hachage parmi les chemins ordinaires non vides. Les chemins vides, les traces et les enregistrements non classés sont exclus. Les effectifs exacts figurent ci-dessous.",
|
||||
"coverageDescription": "Catégories de chemin pour toutes les réceptions conservées. Les quatre catégories répartissent le total ; les effectifs exacts figurent à côté.",
|
||||
"lengthDescription": "Entrées de chemin reçues par réception. Zéro indique un chemin ordinaire vide. Les traces et les enregistrements non classés sont exclus. Les effectifs exacts figurent dans le tableau.",
|
||||
"receptionsCenter": "OBSERVATIONS",
|
||||
"widthDescription": "Part de chaque largeur de hachage parmi les chemins non vides. Les effectifs exacts figurent ci-dessous.",
|
||||
"coverageDescription": "Type de chemin pour chaque observation. Les effectifs exacts figurent à côté.",
|
||||
"lengthDescription": "Entrées de chemin par observation. Zéro indique un chemin vide. Les effectifs exacts figurent dans le tableau.",
|
||||
"entries": "Entrées du chemin",
|
||||
"trendDescription": "Réceptions horaires portant des chemins non vides avec des hachages de 1, 2 ou 3 octets, UTC. Les heures absentes restent des lacunes. Les valeurs exactes figurent dans le tableau horaire."
|
||||
"trendDescription": "Observations horaires avec des chemins hachés de 1, 2 ou 3 octets, UTC. Les lacunes indiquent l’absence de données. Les valeurs exactes figurent dans le tableau horaire."
|
||||
},
|
||||
"traffic": {
|
||||
"subtitle": "Quand et où les observateurs déclarent des réceptions.",
|
||||
"subtitle": "Quand et où les observateurs reçoivent des paquets.",
|
||||
"refresh": "Actualiser le trafic",
|
||||
"error": "Impossible de charger le trafic. Réessayez.",
|
||||
"receptions": "Réceptions déclarées",
|
||||
"reportingIatas": "IATA actifs",
|
||||
"receptions": "Observations",
|
||||
"reportingIatas": "Zones actives",
|
||||
"busiestHour": "Pic horaire",
|
||||
"hoursWithRecords": "Heures documentées",
|
||||
"measurement": "Les effectifs comptent les réceptions déclarées, pas les paquets uniques. Les heures vides n’ont pas de données horaires conservées ; elles ne prouvent pas une panne. Les horaires utilisent l’heure UTC et la dernière heure peut être partielle.",
|
||||
"trendTitle": "Évolution des réceptions par IATA",
|
||||
"hourlyRecords": "Données horaires disponibles",
|
||||
"hoursWithRecords": "Heures actives",
|
||||
"measurement": "Les effectifs comptent les observations, pas les paquets uniques. Heures en UTC ; une heure vide ne signifie pas une panne.",
|
||||
"trendTitle": "Observations par zone",
|
||||
"hourlyRecords": "Par heure · UTC",
|
||||
"hourlyTitle": "Activité horaire · {{range}}",
|
||||
"shareTitle": "Part des réceptions",
|
||||
"byIata": "Réceptions par IATA",
|
||||
"shareTitle": "Part des observations",
|
||||
"byIata": "Observations par zone",
|
||||
"loading": "Chargement du trafic…",
|
||||
"empty": "Aucune réception conservée dans cette période.",
|
||||
"receptionsColumn": "Réceptions",
|
||||
"empty": "Aucune observation dans cette période.",
|
||||
"receptionsColumn": "Observations",
|
||||
"area": "Zone",
|
||||
"share": "Part",
|
||||
"groupingHelp": "Les graphiques regroupent les zones moins actives dans « Autres IATA » ; ce tableau conserve toutes les zones.",
|
||||
"receptionsCenter": "RÉCEPTIONS",
|
||||
"shareDescription": "Part des réceptions par IATA. Les effectifs et pourcentages exacts figurent dans le tableau.",
|
||||
"groupingHelp": "Les graphiques regroupent les zones moins actives dans « Autres zones » ; ce tableau conserve toutes les zones.",
|
||||
"receptionsCenter": "OBSERVATIONS",
|
||||
"shareDescription": "Part des observations par zone. Les effectifs et pourcentages exacts figurent dans le tableau.",
|
||||
"unassigned": "Non attribué",
|
||||
"otherIatas": "Autres IATA",
|
||||
"trendDescription": "Réceptions horaires déclarées par IATA. Les lacunes indiquent l’absence de données horaires conservées. Les totaux exacts figurent dans le tableau.",
|
||||
"heatmapDescription": "Carte thermique de l’activité par jour et heure UTC. Les cellules plus lumineuses indiquent davantage de réceptions déclarées ; les cellules vides n’ont pas de données conservées.",
|
||||
"otherIatas": "Autres zones",
|
||||
"trendDescription": "Observations horaires par zone. Les lacunes indiquent l’absence de données. Les totaux exacts figurent dans le tableau.",
|
||||
"heatmapDescription": "Activité par jour et heure UTC. Les cellules plus lumineuses indiquent davantage d’observations.",
|
||||
"more": "Plus",
|
||||
"fewer": "Moins",
|
||||
"heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} réception",
|
||||
"heatmapTooltip_many": "{{day}} {{hour}}:00 UTC\n{{value}} réceptions",
|
||||
"heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} réceptions"
|
||||
"heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} observation",
|
||||
"heatmapTooltip_many": "{{day}} {{hour}}:00 UTC\n{{value}} observations",
|
||||
"heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} observations"
|
||||
},
|
||||
"scopes": {
|
||||
"title": "Scopes de transport",
|
||||
"subtitle": "Données conservées et appartenances pour la région sélectionnée.",
|
||||
"subtitle": "Activité des scopes pour la région sélectionnée.",
|
||||
"search": "Rechercher un scope",
|
||||
"error": "Impossible de charger les scopes. Réessayez.",
|
||||
"active": "Scopes avec données",
|
||||
"scopedPackets": "Paquets avec scope",
|
||||
"memberships": "Appartenances d’observateurs",
|
||||
"memberships": "Observateurs",
|
||||
"defaultNodes": "Nœuds du scope par défaut",
|
||||
"measurement": "Un observateur peut figurer dans plusieurs scopes. Les effectifs globaux d’observateurs incluent les appartenances enregistrées ; les effectifs régionaux utilisent les réceptions conservées associées aux scopes. Les nœuds sont comptés dans leur scope par défaut. Ces effectifs sont calculés sans période sélectionnée.",
|
||||
"measurement": "Un observateur peut appartenir à plusieurs scopes. Ces effectifs ne sont pas limités à une période.",
|
||||
"packetsTitle": "Paquets par scope",
|
||||
"membershipsTitle": "Appartenances d’observateurs par scope",
|
||||
"membershipsTitle": "Observateurs par scope",
|
||||
"countsTitle": "Effectifs des scopes",
|
||||
"visibleCount_one": "{{visible}} sur {{count}} scope",
|
||||
"visibleCount_many": "{{visible}} sur {{count}} scopes",
|
||||
@@ -249,7 +254,8 @@
|
||||
"inSync": "synchronisé",
|
||||
"ahead": "en avance",
|
||||
"behind": "en retard",
|
||||
"empty": "Aucun répéteur désynchronisé"
|
||||
"empty": "Aucun répéteur désynchronisé",
|
||||
"areas": "Zones"
|
||||
},
|
||||
"timestamp": {
|
||||
"ago": "il y a {{duration}}",
|
||||
@@ -265,10 +271,9 @@
|
||||
"detail": "Détail de l'observateur",
|
||||
"range": "Période",
|
||||
"open": "Ouvrir le tableau de bord",
|
||||
"legacy": "Ancien compteur de présence",
|
||||
"legacy": "Observations (historique complet)",
|
||||
"metrics": "Mesures de l’observateur",
|
||||
"records": "Paquets enregistrés",
|
||||
"lastHour": "Dernière heure complète",
|
||||
"records": "Paquets reçus",
|
||||
"lastPacket": "Dernier paquet",
|
||||
"battery": "Batterie",
|
||||
"batteryV": "Batterie V",
|
||||
@@ -279,11 +284,11 @@
|
||||
"search": "Rechercher un observateur",
|
||||
"choose": "Choisir un observateur",
|
||||
"searchHelp": "Recherchez par nom pour trouver les observateurs au-delà des 50 premiers résultats.",
|
||||
"summaryMissing": "Résumé des paquets indisponible sur ce serveur. Les graphiques restent disponibles.",
|
||||
"summaryMissing": "Totaux de paquets indisponibles.",
|
||||
"statusRecent": "État récent",
|
||||
"statusStale": "État périmé",
|
||||
"statusMissing": "Aucun rapport d’état",
|
||||
"activity": "Paquets enregistrés par {{interval}}",
|
||||
"activity": "Paquets reçus par {{interval}}",
|
||||
"mix": "Répartition des types",
|
||||
"signal": "Signal reçu",
|
||||
"airtime": "Temps radio TX / RX",
|
||||
@@ -291,9 +296,9 @@
|
||||
"queue": "File d’attente",
|
||||
"errors": "Erreurs de réception",
|
||||
"busy": "Occupation du canal",
|
||||
"empty": "Aucun paquet enregistré sur cette période",
|
||||
"noTelemetry": "Aucune télémétrie reçue",
|
||||
"noTelemetryHelp": "Cet observateur ne rapporte aucune télémétrie pour cette période.",
|
||||
"empty": "Aucun paquet reçu sur cette période",
|
||||
"noTelemetry": "Aucune télémétrie",
|
||||
"noTelemetryHelp": "Cet observateur n’a envoyé aucune télémétrie pour cette période.",
|
||||
"loadFailed": "Impossible de charger l’observateur.",
|
||||
"retry": "Réessayer",
|
||||
"model": "Modèle",
|
||||
@@ -318,7 +323,6 @@
|
||||
"noMatches": "Aucun résultat",
|
||||
"none": "Aucun observateur",
|
||||
"packetsLastHour": "Paquets dernière heure",
|
||||
"lastNoise": "Dernier bruit de fond",
|
||||
"copyKey": "Copier la clé publique",
|
||||
"sort": "Trier les observateurs",
|
||||
"sortActivity": "Activité",
|
||||
@@ -335,76 +339,80 @@
|
||||
"choose": "Choisissez un observateur à comparer",
|
||||
"invalidObserver": "Choisissez un autre observateur avec un identifiant valide.",
|
||||
"invalidTime": "La date de comparaison est invalide ou trop ancienne. Actualisez pour utiliser la dernière période complète.",
|
||||
"refresh": "Actualiser les deux observateurs",
|
||||
"unavailable": "Les résumés alignés sont indisponibles. Actualisez les deux observateurs ou mettez le serveur à jour pour prendre en charge les périodes communes.",
|
||||
"currentNote": "Les totaux d’activité utilisent cette période commune. Les valeurs de l’appareil et la dernière heure complète décrivent les conditions actuelles; le début de l’heure est indiqué en UTC.",
|
||||
"refresh": "Actualiser",
|
||||
"unavailable": "Comparaison non disponible sur ce serveur.",
|
||||
"currentNote": "Les totaux couvrent cette période ; batterie, durée de fonctionnement et bruit de fond sont des valeurs actuelles.",
|
||||
"metrics": "Mesures de comparaison des observateurs",
|
||||
"metric": "Mesure",
|
||||
"activity": "Paquets enregistrés · activité alignée",
|
||||
"overlap": "Recoupement des paquets flood conservés",
|
||||
"retention": "L’historique des paquets bruts peut expirer avant les résumés d’activité. Le recoupement compte uniquement les paquets flood conservés dans la période commune, toutes régions confondues.",
|
||||
"total_one": "{{count}} paquet flood distinct",
|
||||
"total_other": "{{count}} paquets flood distincts",
|
||||
"empty": "Aucun paquet flood conservé n’a été signalé par ces observateurs pendant cette période.",
|
||||
"activity": "Paquets reçus",
|
||||
"overlap": "Reçus par les deux",
|
||||
"retention": "Seuls les paquets encore conservés par le serveur sont comparés.",
|
||||
"total_one": "{{count}} paquet flood",
|
||||
"total_other": "{{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 empreinte de paquet flood est comptée une seule fois pour les deux observateurs. Les réceptions répétées sont dédupliquées. Ce n’est pas une mesure de perte de paquets : l’expiration, les appareils hors ligne et les interruptions des courtiers affectent les rapports disponibles.",
|
||||
"retainedWindow": "Les détails et les recoupements utilisent les paquets bruts conservés : une période plus ancienne que la rétention du serveur sera incomplète."
|
||||
"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."
|
||||
},
|
||||
"investigation": {
|
||||
"title": "Observations",
|
||||
"reports_one": "{{count}} rapport conservé",
|
||||
"reports_other": "{{count}} rapports conservés",
|
||||
"observers_one": "{{count}} observateur déclarant",
|
||||
"observers_other": "{{count}} observateurs déclarants",
|
||||
"pathNote": "Regroupement par préfixes et largeur d’empreinte, pas par trajets physiques confirmés. Examinez les identités candidates de chaque rapport.",
|
||||
"traceNote": "Les entrées TRACE décrivent l’itinéraire prévu, pas un trajet de réception mesuré. Examinez les mesures de trace séparément.",
|
||||
"empty": "Aucun rapport conservé pour ce paquet.",
|
||||
"observations_one": "{{count}} observation",
|
||||
"observations_other": "{{count}} observations",
|
||||
"heardBy_one": "Reçu par {{count}} observateur",
|
||||
"heardBy_other": "Reçu par {{count}} observateurs",
|
||||
"heardTimes_one": "Reçu une fois",
|
||||
"heardTimes_other": "Reçu {{count}} fois",
|
||||
"byObservers_one": "par {{count}} observateur",
|
||||
"byObservers_other": "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.",
|
||||
"path": "Trajet {{number}}",
|
||||
"tracePath": "Itinéraire prévu {{number}}",
|
||||
"noEntries": "Aucune entrée de trajet",
|
||||
"unavailablePath": "Octets de trajet indisponibles",
|
||||
"inspect": "Examiner le rapport",
|
||||
"inspect": "Sélectionner l’observation",
|
||||
"observer": "Examiner l’observateur",
|
||||
"map": "Afficher le rapport sur la carte",
|
||||
"unmappable": "La carte nécessite au moins deux positions valides résolues sans ambiguïté.",
|
||||
"retention": "Les rapports incluent toutes les régions reçues et suivent la déduplication paquet/observateur : toutes les réceptions ou tous les trajets ne sont donc pas conservés. Les rapports expirés sont indisponibles; les horaires signalés ne prouvent ni perte ni livraison.",
|
||||
"unavailableReport": "Le rapport sélectionné est indisponible ou son identifiant est invalide. Choisissez un rapport conservé ci-dessous.",
|
||||
"map": "Carte du trajet",
|
||||
"unmappable": "Il faut au moins deux répéteurs dont la position est connue.",
|
||||
"retention": "Une entrée par observateur. Les paquets anciens expirent selon la rétention du serveur.",
|
||||
"unavailableReport": "Cette observation n’est plus disponible ou le lien est invalide. Choisissez-en une ci-dessous.",
|
||||
"copy": "Copier le lien",
|
||||
"copyPacket": "Copier le lien du paquet",
|
||||
"viewAll": "Afficher le trajet sur la carte",
|
||||
"viewAll": "Carte de tous les trajets",
|
||||
"mapDialog": "Carte du trajet du paquet",
|
||||
"mapTitle": "Trajet du paquet",
|
||||
"copyPath": "Copier le lien du trajet",
|
||||
"closeMap": "Fermer la carte du trajet",
|
||||
"allPaths": "Tous les trajets",
|
||||
"traceRoute": "Itinéraire de trace",
|
||||
"mapNote": "Seules les positions résolues sans ambiguïté sont affichées. Les étapes inconnues ou sans position interrompent les lignes, qui ne prouvent ni liaison radio directe ni livraison.",
|
||||
"unavailableMap": "Le trajet sélectionné ne peut pas être cartographié avec les données conservées. Choisissez un trajet disponible ou Tous les trajets.",
|
||||
"reportedBy_one": "Signalé par {{count}} observateur",
|
||||
"reportedBy_other": "Signalé par {{count}} observateurs",
|
||||
"mapNote": "Les lignes relient les répéteurs dont la position est connue. Elles ne prouvent pas une liaison radio directe.",
|
||||
"unavailableMap": "Ce trajet ne peut pas être cartographié. Choisissez un autre trajet ou Tous les trajets.",
|
||||
"first": "Premier",
|
||||
"last": "Dernier",
|
||||
"span": "Intervalle signalé",
|
||||
"span": "Durée de réception",
|
||||
"nodeDialog": "Détails du nœud",
|
||||
"packetDialog": "Analyseur de paquets",
|
||||
"closeObserver": "Fermer les détails de l’observateur",
|
||||
"loadMore": "Afficher plus ({{count}} restants)"
|
||||
},
|
||||
"routeEvidence": {
|
||||
"recent": "Paquets récents",
|
||||
"loading": "Chargement des paquets…",
|
||||
"missing": "Les paquets récents ne sont pas disponibles pour ce trajet sur ce serveur.",
|
||||
"error": "Impossible de charger les paquets récents. Réessayez.",
|
||||
"recent": "Observations récentes",
|
||||
"loading": "Chargement des observations…",
|
||||
"missing": "Les observations récentes ne sont pas disponibles pour ce trajet sur ce serveur.",
|
||||
"error": "Impossible de charger les observations récentes. Réessayez.",
|
||||
"retry": "Réessayer",
|
||||
"match": "Paquets reçus le long de cette séquence exacte de sauts, avec {{width}} octet(s) par saut, du plus récent au plus ancien. Les paquets TRACE et les autres tailles de préfixe sont exclus.",
|
||||
"caution": "Des préfixes identiques ne prouvent pas un même trajet physique ; consultez un paquet pour voir ses nœuds possibles.",
|
||||
"retention": "Seuls les paquets encore conservés par le serveur sont listés ; les plus anciens disparaissent. Le nombre d’observations ci-dessus couvre toute la vie du trajet.",
|
||||
"unavailable": "Les préfixes enregistrés ne permettent pas de rechercher les paquets de ce trajet.",
|
||||
"empty": "Aucun paquet de ce trajet n’est encore conservé. Les plus anciens ont pu expirer ou utiliser une autre taille de préfixe.",
|
||||
"match": "Observations le long de ces sauts exacts, avec {{width}} octet(s) par saut, de la plus récente à la plus ancienne. Les paquets TRACE sont exclus.",
|
||||
"caution": "Des identifiants courts identiques ne prouvent pas un même trajet physique.",
|
||||
"retention": "Les paquets anciens disparaissent ; le nombre « Reçu » ci-dessus couvre toute la vie du trajet.",
|
||||
"unavailable": "Ce trajet ne peut pas être comparé aux paquets.",
|
||||
"empty": "Aucune observation de ce trajet n’est encore conservée. Les plus anciennes ont pu expirer.",
|
||||
"more": "Charger plus",
|
||||
"cap": "Affichage des 500 paquets les plus récents."
|
||||
"cap": "Affichage des 500 observations les plus récentes.",
|
||||
"inspect": "Examiner le paquet"
|
||||
},
|
||||
"channelMessages": {
|
||||
"select": "Choisir un canal",
|
||||
@@ -416,14 +424,14 @@
|
||||
"keyMissing": "Aucune clé de déchiffrement n’est configurée pour ce canal.",
|
||||
"scope": "Portée",
|
||||
"allScopes": "Toutes les portées",
|
||||
"unknownScope": "Portée non résolue",
|
||||
"unknownScope": "Portée inconnue",
|
||||
"inspect": "Inspecter le paquet de {{sender}}",
|
||||
"loading": "Chargement des messages…",
|
||||
"loadingOlder": "Chargement des messages plus anciens… ({{count}} affichés)",
|
||||
"loadOlder": "Charger les messages plus anciens",
|
||||
"empty": "Aucun message conservé pour ce canal.",
|
||||
"emptyScope": "Aucun message conservé ne correspond à {{scope}}.",
|
||||
"emptyNoKey": "Aucun message déchiffré n’est disponible sans clé de canal connue.",
|
||||
"empty": "Aucun message pour ce canal.",
|
||||
"emptyScope": "Aucun message ne correspond à {{scope}}.",
|
||||
"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"
|
||||
|
||||
@@ -133,7 +133,7 @@ describe("opening the analyzer from an expanded row", () => {
|
||||
fireEvent.click(await screen.findByText("Observer Three"));
|
||||
|
||||
const drawer = await screen.findByTestId("packet-analyzer-drawer");
|
||||
expect(within(drawer).getByRole("button", { name: "Inspect report: Observer Three" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(within(drawer).getByRole("button", { name: "Select observation: Observer Three" })).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,6 +142,6 @@ it("replaces a shared observation when another report is selected in the row", a
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByText("Observer Two"));
|
||||
const drawer = await screen.findByTestId("packet-analyzer-drawer");
|
||||
expect(within(drawer).getByRole("button", { name: "Inspect report: Observer Two" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(within(drawer).getByRole("button", { name: "Select observation: Observer Two" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(new URLSearchParams(window.location.search).get("observation")).toBe("2");
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
// jsdom does not load Tailwind's generated CSS; inspect the rendered input instead.
|
||||
// Also check on iOS Safari: opening, filtering and closing must leave zoom unchanged.
|
||||
(function checkRegionInputZoom() {
|
||||
const input = document.querySelector('header input[placeholder="Filter IATA or name…"]');
|
||||
const input = document.querySelector('header input[placeholder="Search region or area code…"]');
|
||||
if (!input) throw new Error("Open the region picker first");
|
||||
const fontSize = Number.parseFloat(getComputedStyle(input).fontSize);
|
||||
if (fontSize < 16 || !Number.isFinite(fontSize)) {
|
||||
|
||||
@@ -69,7 +69,8 @@ describe("AppShell", () => {
|
||||
it("region picker shows an error state when the IATA list fails to load", async () => {
|
||||
vi.mocked(getIatas).mockRejectedValue(new Error("boom"));
|
||||
renderShell();
|
||||
fireEvent.click(screen.getByRole("button", { name: /REGION/ }));
|
||||
expect(screen.getByText("Region")).toHaveClass("uppercase");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Region/ }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Failed to load")).toBeInTheDocument());
|
||||
expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
|
||||
@@ -93,11 +94,11 @@ const REGIONS = [
|
||||
// Opens the picker and returns the filter input, once both region and IATA lists have landed.
|
||||
// The trigger is focused first because a real browser click focuses the button; jsdom's does not.
|
||||
async function openPicker() {
|
||||
const trigger = screen.getByRole("button", { name: /REGION/ });
|
||||
const trigger = screen.getByRole("button", { name: /Region/ });
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
await waitFor(() => expect(screen.getByText("Western Canada")).toBeInTheDocument());
|
||||
return screen.getByPlaceholderText(/Filter/);
|
||||
return screen.getByPlaceholderText(/Search region or area code/);
|
||||
}
|
||||
|
||||
describe("region picker filter", () => {
|
||||
@@ -111,16 +112,16 @@ describe("region picker filter", () => {
|
||||
renderShell();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Language: English" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Français", exact: true }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /RÉGION/ }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Région/ }));
|
||||
await screen.findByText("Western Canada");
|
||||
const input = screen.getByRole("textbox", { name: "Filtrer par IATA ou nom…" });
|
||||
const input = screen.getByRole("textbox", { name: "Rechercher une région ou un code de zone…" });
|
||||
fireEvent.change(input, { target: { value: "toutes" } });
|
||||
expect(screen.getByText("Toutes les régions")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: "yvr" } });
|
||||
expect(screen.queryByText("Toutes les régions")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Vancouver International/ }));
|
||||
expect(screen.getByRole("button", { name: /RÉGION\s*YVR/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Région\s*YVR/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("focuses the filter input when the picker opens", async () => {
|
||||
@@ -135,7 +136,7 @@ describe("region picker filter", () => {
|
||||
expect(input).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" }); // empty query, so this closes
|
||||
expect(screen.getByRole("button", { name: /REGION/ })).toHaveFocus();
|
||||
expect(screen.getByRole("button", { name: /Region/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("narrows the IATA list by code, ignoring surrounding whitespace", async () => {
|
||||
@@ -159,7 +160,7 @@ describe("region picker filter", () => {
|
||||
// no region name or member code contains "toronto"
|
||||
expect(screen.queryByText("Regions")).not.toBeInTheDocument();
|
||||
// nothing above it, so the header must not draw a divider
|
||||
expect(screen.getByText("IATA")).not.toHaveClass("border-t");
|
||||
expect(screen.getByText("Areas")).not.toHaveClass("border-t");
|
||||
});
|
||||
|
||||
it("surfaces a region whose member IATA matches, tagged with the matching code", async () => {
|
||||
@@ -171,7 +172,7 @@ describe("region picker filter", () => {
|
||||
expect(screen.getByText("· YVR")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Eastern Canada")).not.toBeInTheDocument();
|
||||
// something is above it now, so the header does draw a divider
|
||||
expect(screen.getByText("IATA")).toHaveClass("border-t");
|
||||
expect(screen.getByText("Areas")).toHaveClass("border-t");
|
||||
});
|
||||
|
||||
it("tags a code match but not a name match, for the same query", async () => {
|
||||
@@ -192,7 +193,7 @@ describe("region picker filter", () => {
|
||||
expect(screen.getByText("Western Canada")).toBeInTheDocument();
|
||||
expect(screen.queryByText("· YVR")).not.toBeInTheDocument();
|
||||
// nothing in the IATA group matches, so its header goes too
|
||||
expect(screen.queryByText("IATA")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Areas")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps an IATA with no display name matchable by code", async () => {
|
||||
@@ -226,7 +227,7 @@ describe("region picker filter", () => {
|
||||
|
||||
expect(screen.getByText("No matches")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Regions")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("IATA")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Areas")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("All Regions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -236,10 +237,10 @@ describe("region picker filter", () => {
|
||||
fireEvent.change(input, { target: { value: "yvr" } });
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(screen.getByPlaceholderText(/Filter/)).toHaveValue("");
|
||||
expect(screen.getByPlaceholderText(/Search region or area code/)).toHaveValue("");
|
||||
expect(screen.getByText("All Regions")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText(/Filter/), { key: "Escape" });
|
||||
fireEvent.keyDown(screen.getByPlaceholderText(/Search region or area code/), { key: "Escape" });
|
||||
expect(screen.queryByText("All Regions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -248,10 +249,10 @@ describe("region picker filter", () => {
|
||||
const input = await openPicker();
|
||||
fireEvent.change(input, { target: { value: "yvr" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /REGION/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /REGION/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Region/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Region/ }));
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(/Filter/)).toHaveValue(""));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(/Search region or area code/)).toHaveValue(""));
|
||||
expect(screen.getByText("Eastern Canada")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -300,8 +301,8 @@ describe("AppShell region picker alignment", () => {
|
||||
it("opens rightward only below md so the desktop row's controls stay visible", async () => {
|
||||
vi.mocked(getIatas).mockResolvedValue([]);
|
||||
renderShell();
|
||||
fireEvent.click(screen.getByRole("button", { name: /REGION/ }));
|
||||
const panel = screen.getByPlaceholderText("Filter IATA or name…").closest(".absolute")!;
|
||||
fireEvent.click(screen.getByRole("button", { name: /Region/ }));
|
||||
const panel = screen.getByPlaceholderText("Search region or area code…").closest(".absolute")!;
|
||||
expect(panel.className).toContain("left-0");
|
||||
expect(panel.className).toContain("md:right-0");
|
||||
});
|
||||
@@ -314,7 +315,7 @@ describe("AppShell on a phone", () => {
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ ...media, media: query, matches: query === "(max-width: 767px)" }));
|
||||
vi.mocked(getIatas).mockResolvedValue([]);
|
||||
renderShell();
|
||||
expect(screen.getByRole("button", { name: /REGION/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Region/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status", { name: "LIVE" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^Language:/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "GitHub" })).not.toBeInTheDocument();
|
||||
|
||||
@@ -18,9 +18,9 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("CopyLinkButton", () => {
|
||||
it("shows the default 'Copy Link' label", () => {
|
||||
it("shows the default 'Copy link' label", () => {
|
||||
render(<CopyLinkButton params={{ tab: "Nodes", node: "abc" }} />);
|
||||
expect(screen.getByRole("button")).toHaveTextContent("Copy Link");
|
||||
expect(screen.getByRole("button")).toHaveTextContent("Copy link");
|
||||
});
|
||||
|
||||
it("copies a URL carrying the given params on click", () => {
|
||||
@@ -42,7 +42,7 @@ describe("CopyLinkButton", () => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
expect(button).toHaveTextContent("Copy Link");
|
||||
expect(button).toHaveTextContent("Copy link");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ describe("channel scope evidence", () => {
|
||||
const inspect = vi.fn();
|
||||
show(<MessagePanel channel={channel} heardCounts={{}} regionKey="YOW" onAnalyze={inspect} />);
|
||||
expect(await screen.findByText("#yow")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unresolved scope")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unknown scope")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No transport scope")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Scope unavailable")).not.toBeInTheDocument();
|
||||
const view = screen.getAllByRole("button", { name: "Inspect packet from Fixture" })[0]!;
|
||||
@@ -74,7 +74,7 @@ describe("channel scope evidence", () => {
|
||||
show(<MessagePanel channel={channel} heardCounts={{}} regionKey="YOW" />);
|
||||
await screen.findByText("#yow");
|
||||
await act(() => i18n.changeLanguage("fr"));
|
||||
expect(screen.getByText("Portée non résolue")).toBeInTheDocument();
|
||||
expect(screen.getByText("Portée inconnue")).toBeInTheDocument();
|
||||
expect(screen.getByText("#yow")).toBeInTheDocument();
|
||||
expect(getChannelMessagesPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -84,13 +84,13 @@ describe("channel scope evidence", () => {
|
||||
);
|
||||
const { client } = show(<ChannelList wsManager={{} as WsManager} onAnalyze={() => {}} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /#test/ }));
|
||||
await screen.findByText("Unresolved scope");
|
||||
await screen.findByText("Unknown scope");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Scope.*All scopes/ }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "#yow", exact: true }));
|
||||
await waitFor(() =>
|
||||
expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1, expect.objectContaining({ scope: "#yow" })),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText("Unresolved scope")).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.queryByText("Unknown scope")).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(client.getQueryData(["channel-messages", 1, "YOW", "#yow"])).toBeDefined());
|
||||
const event = {
|
||||
...base,
|
||||
@@ -127,7 +127,7 @@ describe("channel scope evidence", () => {
|
||||
it("explains an empty filtered history", async () => {
|
||||
vi.mocked(getChannelMessagesPage).mockResolvedValue(page([]));
|
||||
show(<MessagePanel channel={channel} heardCounts={{}} regionKey="YOW" scope="#yow" onScopeChange={() => {}} />);
|
||||
expect(await screen.findByText("No retained messages match #yow.")).toBeInTheDocument();
|
||||
expect(await screen.findByText("No messages match #yow.")).toBeInTheDocument();
|
||||
});
|
||||
it("keeps messages received during history loading without an extra request", async () => {
|
||||
let resolveHistory!: (value: ReturnType<typeof page>) => void;
|
||||
@@ -155,7 +155,7 @@ describe("channel scope evidence", () => {
|
||||
vi.mocked(getChannelMessagesPage).mockResolvedValue(page([]));
|
||||
show(<MessagePanel channel={{ ...channel, keyKnown: false }} heardCounts={{}} regionKey="YOW" />);
|
||||
expect(
|
||||
await screen.findByText("No decrypted messages are available without a known channel key."),
|
||||
await screen.findByText("Messages can't be decrypted without the channel key."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("No transport scope")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("PacketPathMapModal", () => {
|
||||
it("does not silently substitute all paths for an unavailable selected path", async () => {
|
||||
render(<PacketPathMapModal detail={detail} onClose={() => {}} initialSelectedKey="nope" />);
|
||||
expect(await screen.findByTestId("mini-map")).toHaveTextContent("nope");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("selected path cannot be mapped");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("This path can't be mapped");
|
||||
fireEvent.click(screen.getByText("All paths"));
|
||||
expect(await screen.findByTestId("mini-map")).toHaveTextContent("all");
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
|
||||
@@ -193,3 +193,12 @@ describe("NodeDetailPanel View on map", () => {
|
||||
expect(onViewOnMap).toHaveBeenCalledWith(0, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeDetailPanel recent packets", () => {
|
||||
it("labels the expiring packet list as recent packets", async () => {
|
||||
renderPanel();
|
||||
expect(await screen.findByText("Recent packets")).toBeInTheDocument();
|
||||
expect(await screen.findByText("No recent packets")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Observations")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,9 @@ beforeEach(() => {
|
||||
vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, summary: { ...activity.summary!, recordedPackets: 23 } });
|
||||
vi.mocked(getObserverComparison).mockResolvedValue({ observerA: A, observerB: B, since: activity.windowStart!, until, onlyA: 2, both: 3, onlyB: 4, totalPackets: 9 });
|
||||
});
|
||||
function view(id = B, data = activity, anchor: number | null = until) {
|
||||
function view(id = B, data = activity, anchor: number | null = until, range: "24h" | "7d" | "30d" = "7d") {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const props = { observerA: observer, activityA: data, range: "7d" as const, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) };
|
||||
const props = { observerA: observer, activityA: data, range, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) };
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
return { ...render(<ObserverComparison {...props} />, { wrapper }), props };
|
||||
}
|
||||
@@ -37,15 +37,15 @@ it("aligns activity and retained overlap to the primary observer's effective win
|
||||
expect(screen.getByText("19")).toBeInTheDocument();
|
||||
expect(getObserverActivity).toHaveBeenCalledWith(B, "168h", "1h", until);
|
||||
expect(getObserverComparison).toHaveBeenCalledWith(undefined, { observerA: A, observerB: B, since: activity.windowStart, until }, expect.any(AbortSignal));
|
||||
expect(screen.getByText("Retained flood-packet overlap")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Raw packet history may expire/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Heard by both")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Only packets the server still keeps/ })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chart").textContent).toContain('"name":"A"');
|
||||
expect(screen.getByTestId("chart").textContent).toContain('"name":"B"');
|
||||
});
|
||||
it("does not compare mismatched or old-server windows", async () => {
|
||||
vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, windowEnd: until - 3_600_000 });
|
||||
view();
|
||||
expect(await screen.findByText(/Aligned summaries are unavailable/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Comparison not available on this server/)).toBeInTheDocument();
|
||||
expect(getObserverComparison).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("chart")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -58,7 +58,7 @@ it.each([A, "broken-id", ""]) ("never requests overlap for an invalid or missing
|
||||
it("rejects a bad anchor and offers a fresh common window", async () => {
|
||||
const { props } = view(B, activity, null);
|
||||
expect(await screen.findByText(/This comparison time is invalid/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(props.onRefresh).toHaveBeenCalled();
|
||||
expect(getObserverActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -76,12 +76,23 @@ it("refreshes the current window without requesting the obsolete window after re
|
||||
await waitFor(() => expect(getObserverComparison).toHaveBeenCalledTimes(1));
|
||||
const activityCalls = vi.mocked(getObserverActivity).mock.calls.length;
|
||||
props.onRefresh.mockReturnValue(until + 3_600_000);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls);
|
||||
expect(getObserverComparison).toHaveBeenCalledTimes(1);
|
||||
|
||||
props.onRefresh.mockReturnValue(until);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
await waitFor(() => expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls + 1));
|
||||
expect(getObserverComparison).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips the raw-packet overlap at 30d but keeps the aligned charts", async () => {
|
||||
const month: ObserverActivity = { ...activity, range: "720h", interval: "6h", windowStart: Math.floor(until / 21_600_000) * 21_600_000 - 2_592_000_000, windowEnd: Math.floor(until / 21_600_000) * 21_600_000 };
|
||||
vi.mocked(getObserverActivity).mockResolvedValue({ ...month, summary: { ...month.summary!, recordedPackets: 23 } });
|
||||
view(B, month, until, "30d");
|
||||
expect(await screen.findByText("23")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chart")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Packet overlap is available for 24h and 7d/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Heard by both")).not.toBeInTheDocument();
|
||||
expect(getObserverComparison).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -43,7 +43,7 @@ describe("observer summary card notes", () => {
|
||||
it("labels the noise tile as the last reading and sets the unit apart from the number", () => {
|
||||
const points = [{ noiseFloorDb: -91 } as unknown as TelemetryPoint];
|
||||
render(<ObserverSummary observer={observer} points={points} />);
|
||||
const tile = screen.getByText("Last noise floor").closest("li")!;
|
||||
const tile = screen.getByText("Noise floor").closest("li")!;
|
||||
expect(screen.queryByText(/Latest telemetry interval|Latest status/)).not.toBeInTheDocument();
|
||||
expect(within(tile).getByText("-91")).not.toHaveClass("text-sm");
|
||||
expect(within(tile).getByText("dBm")).toHaveClass("text-sm");
|
||||
|
||||
@@ -52,12 +52,12 @@ describe("packet reception evidence", () => {
|
||||
const section = screen.getByRole("region", { name: "Observations" });
|
||||
expect(within(section).getByText("Path 1").closest("details")).toHaveAttribute("open");
|
||||
const beta = within(section).getByText(/Beta/).closest("li")!;
|
||||
expect(within(beta).getByRole("button", { name: /Inspect report.*Beta/ })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(within(beta).getByRole("button", { name: /Select observation.*Beta/ })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(within(beta).getByText("SNR")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Observations \(/)).not.toBeInTheDocument();
|
||||
for (const name of ["Inspect observer", "Map report"]) expect(within(beta).getByRole("button", { name })).toHaveClass("border", "border-border", "text-primary");
|
||||
for (const name of ["Inspect observer", "Map path"]) expect(within(beta).getByRole("button", { name })).toHaveClass("border", "border-border", "text-primary");
|
||||
fireEvent.click(within(beta).getByRole("button", { name: "Inspect observer" })); expect(onViewObserver).toHaveBeenCalledWith("beta");
|
||||
fireEvent.click(within(beta).getByRole("button", { name: "Map report" })); expect(onViewPath).toHaveBeenCalledWith("beta");
|
||||
fireEvent.click(within(beta).getByRole("button", { name: "Map path" })); expect(onViewPath).toHaveBeenCalledWith("beta");
|
||||
expect(screen.getByTestId("search")).toHaveTextContent("observation=2"); expect(screen.getByTestId("search")).toHaveTextContent("q=keep");
|
||||
});
|
||||
it("collapses the observations list unless a report was picked", () => {
|
||||
@@ -65,7 +65,7 @@ describe("packet reception evidence", () => {
|
||||
const section = screen.getByRole("region", { name: "Observations" });
|
||||
const toggle = within(section).getByRole("button", { name: "Observations" });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(section).toHaveTextContent("2 retained reports");
|
||||
expect(section).toHaveTextContent("Heard by 2 observers");
|
||||
expect(within(section).queryByText("Path 1")).not.toBeInTheDocument();
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
@@ -74,27 +74,27 @@ describe("packet reception evidence", () => {
|
||||
it("keeps the evidence notes in an info tip instead of paragraphs", () => {
|
||||
render(<MemoryRouter><PacketAnalyzerDrawer detail={reports()} selectedObservationId={null} onClose={() => {}} onViewPath={() => {}} /></MemoryRouter>);
|
||||
const section = screen.getByRole("region", { name: "Observations" });
|
||||
expect(within(section).getByRole("button", { name: /Grouped by path prefixes.*Reports include all received regions/ })).toBeInTheDocument();
|
||||
expect(within(section).queryByText(/Grouped by path prefixes/, { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(within(section).queryByText(/Reports include all received regions/, { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(within(section).queryByText(/Mapping needs at least two/, { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(within(section).getByRole("button", { name: /Grouped by the repeaters.*One entry per observer/ })).toBeInTheDocument();
|
||||
expect(within(section).queryByText(/Grouped by the repeaters/, { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(within(section).queryByText(/One entry per observer/, { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(within(section).queryByText(/Needs at least two repeaters/, { selector: "p" })).not.toBeInTheDocument();
|
||||
});
|
||||
it("shows the selected report only in the observations list, above the raw packet", () => {
|
||||
render(<MemoryRouter><PacketAnalyzerDrawer detail={reports()} selectedObservationId={1} onClose={() => {}} /></MemoryRouter>);
|
||||
const list = screen.getByRole("region", { name: "Observations" });
|
||||
expect(screen.queryByText("Observation", { exact: true })).not.toBeInTheDocument();
|
||||
expect(within(list).getByRole("button", { name: /Inspect report.*Alpha/ })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(within(list).getByRole("button", { name: /Select observation.*Alpha/ })).toHaveAttribute("aria-pressed", "true");
|
||||
const raw = screen.getByText("Raw Packet");
|
||||
expect(list.compareDocumentPosition(raw) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
it("reports an expired selection without quietly showing another report", () => {
|
||||
render(<MemoryRouter initialEntries={["/?tab=Packets&hash=abcdef12&analyze=1&observation=99"]}><PacketAnalyzerDrawer detail={reports()} selectedObservationId={1} onClose={() => {}} /></MemoryRouter>);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Selected report is unavailable");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("That observation is no longer available");
|
||||
expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument();
|
||||
});
|
||||
it("keeps route evidence selections strict without requiring packet URL state", () => {
|
||||
render(<MemoryRouter initialEntries={["/?tab=Routes&route=keep"]}><PacketAnalyzerDrawer detail={reports()} selectedObservationId={99} requireSelectedObservation onClose={() => {}} /></MemoryRouter>);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Selected report is unavailable");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("That observation is no longer available");
|
||||
expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument();
|
||||
});
|
||||
it("explains TRACE and missing-report semantics in French", async () => {
|
||||
@@ -102,7 +102,7 @@ describe("packet reception evidence", () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
render(<MemoryRouter><PacketAnalyzerDrawer detail={d} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
|
||||
expect(screen.getByRole("region", { name: "Observations" })).toHaveTextContent("itinéraire prévu");
|
||||
expect(screen.getByText("Aucun rapport conservé pour ce paquet.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Aucune observation pour ce paquet.")).toBeInTheDocument();
|
||||
});
|
||||
it("keeps a later selected path visible while bounding the initial list", () => {
|
||||
const d = reports();
|
||||
@@ -191,7 +191,7 @@ describe("PacketAnalyzerDrawer view-path button", () => {
|
||||
<PacketAnalyzerDrawer detail={makeDetail([hop("a", -79, 43), hop("b", -75, 45)])} selectedObservationId={null} onClose={() => {}} onViewPath={onViewPath} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /view path on map/i });
|
||||
const btn = screen.getByRole("button", { name: /map all paths/i });
|
||||
expect(btn).toBeEnabled();
|
||||
fireEvent.click(btn);
|
||||
expect(onViewPath).toHaveBeenCalledOnce();
|
||||
@@ -203,7 +203,7 @@ describe("PacketAnalyzerDrawer view-path button", () => {
|
||||
<PacketAnalyzerDrawer detail={makeDetail([hop("a", -79, 43)])} selectedObservationId={null} onClose={() => {}} onViewPath={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /view path on map/i })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /map all paths/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ describe("PacketExpansion below md", () => {
|
||||
usePacketDetail.mockReturnValue({ data: detail([obs(1)]) });
|
||||
render(<PacketExpansion {...props} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "View path on map" });
|
||||
const button = screen.getByRole("button", { name: "Map all paths" });
|
||||
expect(tokens(button)).toEqual(
|
||||
expect.arrayContaining(["w-full", "md:w-auto", "py-2", "md:py-0.5", "text-[11px]", "md:text-[10px]"]),
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ const obs = (id: number, over: Partial<Observation> = {}): Observation => ({
|
||||
sourceBroker: "b1", resolvedPath: [], ...over,
|
||||
});
|
||||
|
||||
// minimal header so buildPacketPaths(data) (View path on map's hasPath check) doesn't crash on a
|
||||
// minimal header so buildPacketPaths(data) (Map all paths's hasPath check) doesn't crash on a
|
||||
// partial detail fixture — any non-TRACE payload type does
|
||||
const header = () => ({ raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.ADVERT, payloadTypeName: "ADVERT", payloadVersion: 1 });
|
||||
|
||||
@@ -158,28 +158,28 @@ describe("PacketExpansion", () => {
|
||||
expect(onOpenAnalyzer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables View path on map while loading", () => {
|
||||
it("disables Map all paths while loading", () => {
|
||||
usePacketDetail.mockReturnValue({ isLoading: true });
|
||||
render(<PacketExpansion {...props} />);
|
||||
expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Map all paths" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables View path on map on error", () => {
|
||||
it("disables Map all paths on error", () => {
|
||||
usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() });
|
||||
render(<PacketExpansion {...props} />);
|
||||
expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Map all paths" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables View path on map once the fetch resolves with a drawable path", () => {
|
||||
it("enables Map all paths once the fetch resolves with a drawable path", () => {
|
||||
usePacketDetail.mockReturnValue({ data: detailWithPath() });
|
||||
render(<PacketExpansion {...props} />);
|
||||
expect(screen.getByRole("button", { name: "View path on map" })).not.toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Map all paths" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables View path on map when the loaded detail has no resolvable path", () => {
|
||||
it("disables Map all paths when the loaded detail has no resolvable path", () => {
|
||||
usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1)] } });
|
||||
render(<PacketExpansion {...props} />);
|
||||
const viewPathBtn = screen.getByRole("button", { name: "View path on map" });
|
||||
const viewPathBtn = screen.getByRole("button", { name: "Map all paths" });
|
||||
expect(viewPathBtn).toBeDisabled();
|
||||
expect(viewPathBtn).toHaveAttribute("title", "No resolved path to map");
|
||||
});
|
||||
@@ -188,7 +188,7 @@ describe("PacketExpansion", () => {
|
||||
const onViewPath = vi.fn();
|
||||
usePacketDetail.mockReturnValue({ data: detailWithPath() });
|
||||
render(<PacketExpansion {...props} onViewPath={onViewPath} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "View path on map" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Map all paths" }));
|
||||
expect(onViewPath).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,7 @@ vi.mock("../../../src/features/packets/PacketVirtualList", () => ({
|
||||
<div>
|
||||
<div data-testid="expanded">{String(expandedHash)}</div>
|
||||
<button type="button" onClick={() => onOpenAnalyzer()}>Open analyzer</button>
|
||||
<button type="button" onClick={onViewPath}>View path on map</button>
|
||||
<button type="button" onClick={onViewPath}>Map all paths</button>
|
||||
{packets.map((p) => (
|
||||
<button
|
||||
key={p.packetHash}
|
||||
@@ -234,7 +234,7 @@ describe("PacketList expanded row", () => {
|
||||
|
||||
const { onViewPath } = renderList("/?tab=Packets&hash=AA11");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "View path on map" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Map all paths" }));
|
||||
expect(onViewPath).toHaveBeenCalledWith(detail);
|
||||
});
|
||||
|
||||
@@ -243,7 +243,7 @@ describe("PacketList expanded row", () => {
|
||||
|
||||
const { onViewPath } = renderList("/?tab=Packets&hash=AA11");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "View path on map" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Map all paths" }));
|
||||
expect(onViewPath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { GRID_TEMPLATE } from "../../../src/features/packets/packet-grid";
|
||||
describe("PacketTableHeader", () => {
|
||||
it("declares every column heading", () => {
|
||||
render(<PacketTableHeader />);
|
||||
for (const h of ["Hash", "Type", "Route", "Obs", "Hops", "Hash Size", "Summary / Src → Dst", "IATA", "Age"]) {
|
||||
for (const h of ["Hash", "Type", "Route", "Obs", "Hops", "Hash Size", "Summary / Src → Dst", "Area", "Age"]) {
|
||||
expect(screen.getByText(h)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -143,7 +143,7 @@ describe("PacketVirtualList expansion", () => {
|
||||
|
||||
// clicking an observation is what opens the analyzer now — there is no button for it
|
||||
fireEvent.click(screen.getByText("o1"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "View path on map" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Map all paths" }));
|
||||
|
||||
expect(handlers.onSelectObservation).toHaveBeenCalledWith(1);
|
||||
expect(handlers.onOpenAnalyzer).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("route detail", () => {
|
||||
it("opens the exact report and observer, and pins cursor pages", async () => {
|
||||
mount();
|
||||
await screen.findByText("Garden");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Inspect report" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Inspect packet" }));
|
||||
expect(inspect).toHaveBeenCalledWith("aabb", 17);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Inspect observer" }));
|
||||
expect(observer).toHaveBeenCalledWith("o1");
|
||||
@@ -74,10 +74,10 @@ describe("route detail", () => {
|
||||
expect(screen.queryByText("Garden")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says when no packets on the route are still kept", async () => {
|
||||
it("says when no observations on the route are still kept", async () => {
|
||||
vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false });
|
||||
mount({ listed: route });
|
||||
expect(await screen.findByText(/No packets on this route are still kept/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/No observations on this route are still kept/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not fetch packets for a legacy route without a path key", () => {
|
||||
@@ -86,11 +86,11 @@ describe("route detail", () => {
|
||||
expect(getRouteEvidence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("distinguishes unavailable saved prefixes and translates the panel", async () => {
|
||||
it("distinguishes an unmatchable route and translates the panel", async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, matchAvailable: false, items: [], hasMore: false });
|
||||
mount();
|
||||
expect(await screen.findByText(/préfixes enregistrés ne permettent pas/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Ce trajet ne peut pas être comparé/)).toBeInTheDocument();
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ function renderTable(selection = ALL_REGIONS) {
|
||||
render(<RouteTable />, { wrapper });
|
||||
}
|
||||
|
||||
const openIataPicker = () => fireEvent.click(screen.getByText("IATA"));
|
||||
const openIataPicker = () => fireEvent.click(screen.getByText("Areas"));
|
||||
const checkIata = (code: string) => fireEvent.click(screen.getByRole("option", { name: new RegExp(code) }));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("observer comparison", () => {
|
||||
expect(within(table).getByRole("row", { name: /Only A.*1.*25.0%/ })).toBeInTheDocument();
|
||||
expect(within(table).getByRole("row", { name: /Only B.*2.*50.0%/ })).toBeInTheDocument();
|
||||
expect(within(table).getByRole("row", { name: /Both.*1.*25.0%/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Percentages use the union/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Percentages are of all flood packets/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers only a different observer and rejects an incomplete pair without querying", async () => {
|
||||
@@ -90,7 +90,7 @@ describe("observer comparison", () => {
|
||||
it("renders an empty period without NaN percentages", async () => {
|
||||
vi.mocked(getObserverComparison).mockResolvedValue({ ...counts, totalPackets: 0, onlyA: 0, onlyB: 0, both: 0 });
|
||||
mount(query);
|
||||
expect(await screen.findByText(/No flood packets were reported/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Neither observer heard a flood packet/)).toBeInTheDocument();
|
||||
expect(document.body).not.toHaveTextContent("NaN");
|
||||
});
|
||||
|
||||
@@ -125,4 +125,15 @@ describe("observer comparison", () => {
|
||||
await screen.findByRole("table", { name: "Flood packet comparison" });
|
||||
expect(getObserverComparison).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses minute precision and flags a start older than packet retention", async () => {
|
||||
mount();
|
||||
const start = screen.getByLabelText("Start (local time)");
|
||||
expect(start).toHaveAttribute("step", "60");
|
||||
expect(screen.getByLabelText("End (local time)")).toHaveAttribute("step", "60");
|
||||
expect((start as HTMLInputElement).value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
||||
expect(screen.queryByText(/Only recent packets are kept/)).not.toBeInTheDocument();
|
||||
fireEvent.change(start, { target: { value: "2020-01-01T00:00" } });
|
||||
expect(screen.getByText(/Only recent packets are kept/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -165,10 +165,17 @@ it("keeps the scope list collapsed until requested", async () => {
|
||||
expect(within(scopeList).getByText("#old")).toBeVisible();
|
||||
});
|
||||
|
||||
it("labels the KPI cards as the last 24h whatever the selected range", async () => {
|
||||
mount("7d"); await loaded();
|
||||
expect(screen.getAllByText("Last 24h")).toHaveLength(4);
|
||||
expect(within(card("Active areas")).getByText("4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows French labels for the KPIs, chart titles and scope table", async () => {
|
||||
await act(() => i18n.changeLanguage("fr"));
|
||||
mount(); await loaded();
|
||||
expect(within(card("Paquets totaux")).getByText("111")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Dernières 24 h")).toHaveLength(4);
|
||||
expect(screen.getByText("Observations · 24h")).toBeInTheDocument();
|
||||
expect(screen.getByText("Meilleurs nœuds · historique complet")).toBeInTheDocument();
|
||||
const typesChart = within(card("Types de nœuds · historique complet")).getByTestId("chart");
|
||||
|
||||
@@ -125,7 +125,7 @@ describe("ObserverTab", () => {
|
||||
it("renders the heard charts when the server has activity for the observer", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText(/channel busy/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/recorded packets per 15 min/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/packets heard per 15 min/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/received signal/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/packet-type mix/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -141,7 +141,7 @@ describe("ObserverTab", () => {
|
||||
activityResult.error = new ApiError(404, "not_found", "no route");
|
||||
renderTab();
|
||||
expect(screen.queryByText(/channel busy/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/heard/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/heard per/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("leaves the header airtime stat out when the observer reports no telemetry", () => {
|
||||
@@ -155,7 +155,7 @@ describe("ObserverTab", () => {
|
||||
activityResult.isError = true;
|
||||
activityResult.error = new Error("temporary failure");
|
||||
renderTab();
|
||||
expect(screen.getByText(/recorded packets per 15 min/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/packets heard per 15 min/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Failed to load/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
});
|
||||
@@ -163,7 +163,7 @@ describe("ObserverTab", () => {
|
||||
it("shows one empty card instead of flat charts when nothing was heard", () => {
|
||||
activityResult.data = { ...activity, payloadTypes: [], points: [] };
|
||||
renderTab();
|
||||
expect(screen.getByText(/no packets recorded/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/no packets heard/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/channel busy/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -190,18 +190,18 @@ describe("Observer dashboard hierarchy", () => {
|
||||
expect(within(cards).getAllByRole("listitem")).toHaveLength(6);
|
||||
expect(within(cards).queryByText("12")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Recorded packets per 15 min").compareDocumentPosition(screen.getByText(/Airtime TX/)) &
|
||||
screen.getByText("Packets heard per 15 min").compareDocumentPosition(screen.getByText(/Airtime TX/)) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
it("does not invent zero packet metrics on an older server", () => {
|
||||
renderTab();
|
||||
expect(screen.getByText(/Packet summary unavailable/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Packet totals unavailable/)).toBeInTheDocument();
|
||||
});
|
||||
it("provides French monitoring labels", async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
renderTab();
|
||||
expect(screen.getByText("Paquets enregistrés")).toBeInTheDocument();
|
||||
expect(screen.getByText("Paquets reçus")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Détails de l’appareil")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Copier la clé publique/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ it("translates classification and exact tables while preserving counts and the r
|
||||
expect(within(table).getByRole("row", { name: /Chemins hachés.*60.*60.0%/ })).toBeInTheDocument();
|
||||
expect(within(table).getByRole("row", { name: /Vides.*20.*20.0%/ })).toBeInTheDocument();
|
||||
expect(within(table).getByRole("row", { name: /Non classés.*10.*10.0%/ })).toBeInTheDocument();
|
||||
expect(screen.getByText(/1970-01-01 00:00.*1970-01-01 01:00.*UTC/)).toHaveTextContent("fin exclue");
|
||||
expect(screen.getByText(/1970-01-01 00:00.*1970-01-01 01:00.*UTC/)).toHaveTextContent("heure en cours exclue");
|
||||
fireEvent.click(screen.getByText("Valeurs exactes des largeurs, longueurs et par heure"));
|
||||
expect(screen.getByRole("table", { name: "Effectifs par largeur de hachage" })).toHaveTextContent("Octets par hachage");
|
||||
expect(within(screen.getByRole("table", { name: "Effectifs par nombre d’entrées" })).getByRole("row", { name: "0 20" })).toBeInTheDocument();
|
||||
@@ -43,7 +43,7 @@ it("redraws all chart labels on language change and keeps exact details open", a
|
||||
expect(screen.getByText("Valeurs exactes des largeurs, longueurs et par heure").closest("details")).toHaveAttribute("open");
|
||||
const after = chartOptions();
|
||||
expect(after[0]).toMatchObject({ series: [{ data: [{ name: "1 octet", value: 20 }, { name: "2 octets", value: 30 }, { name: "3 octets", value: 10 }] }] });
|
||||
expect(after[1]).toMatchObject({ xAxis: { name: "Entrées du chemin" }, series: [{ name: "Réceptions", data: [{ value: 20 }, { value: 0 }, { value: 60 }] }] });
|
||||
expect(after[1]).toMatchObject({ xAxis: { name: "Entrées du chemin" }, series: [{ name: "Observations", data: [{ value: 20 }, { value: 0 }, { value: 60 }] }] });
|
||||
expect(after[2]).toMatchObject({ series: [{ name: "1 octet", data: [[0, 20]] }, { name: "2 octets", data: [[0, 30]] }, { name: "3 octets", data: [[0, 10]] }] });
|
||||
expect(after[3]).toMatchObject({ series: [{ data: [{ name: "Chemins hachés", value: 60 }, { name: "Vides", value: 20 }, { name: "Trace", value: 10 }, { name: "Non classés", value: 10 }] }] });
|
||||
after.forEach((option, i) => expect(option.aria).not.toEqual(before[i]!.aria));
|
||||
@@ -54,7 +54,7 @@ it("uses only nonempty hash paths for multi-byte share, exposes categories and e
|
||||
render(<PathsTab range="24h" />);
|
||||
expect(usePathStats).toHaveBeenCalledWith("24h");
|
||||
expect(screen.getByText("66.7%")).toBeInTheDocument();
|
||||
expect(screen.getByText(/direct routes/i)).toHaveTextContent("remaining");
|
||||
expect(screen.getByText(/unclassified packets/i)).toHaveTextContent("no usable path data");
|
||||
const table = screen.getByRole("table", { name: "Path classification counts" });
|
||||
expect(within(table).getByRole("row", { name: /Hash paths.*60.*60.0%/ })).toBeInTheDocument();
|
||||
expect(within(table).getByRole("row", { name: /Empty.*20.*20.0%/ })).toBeInTheDocument();
|
||||
@@ -85,7 +85,7 @@ it("translates empty coverage without inventing hash-width votes or hourly recor
|
||||
query.data = { ...originalData, receptions: 0, hashed: 0, empty: 0, trace: 0, unclassified: 0, hashWidths: [], pathLengths: [], hourly: [] };
|
||||
await act(() => i18n.changeLanguage("fr"));
|
||||
render(<PathsTab range="24h" />);
|
||||
expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Aucune donnée")).toHaveLength(4);
|
||||
expect(screen.queryAllByTestId("chart")).toHaveLength(0);
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
|
||||
@@ -19,8 +19,8 @@ it("translates scope counts and membership guidance without changing scope names
|
||||
const table = screen.getByRole("table", { name: "Effectifs des scopes" });
|
||||
expect(within(table).getByRole("row", { name: /#west.*3.*2.*2/ })).toBeInTheDocument();
|
||||
expect(within(table).getByRole("row", { name: /#east.*2.*2.*3/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Appartenances d’observateurs")).toBeInTheDocument();
|
||||
expect(screen.getByText(/plusieurs scopes/)).toHaveTextContent("sans période sélectionnée");
|
||||
expect(screen.getByText("Observateurs par scope")).toBeInTheDocument();
|
||||
expect(screen.getByText(/plusieurs scopes/)).toHaveTextContent("pas limités à une période");
|
||||
expect(screen.getByText("2 sur 2 scopes")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Actualiser/ })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -76,10 +76,10 @@ it("shows exact scope counts, keeps membership semantics explicit and filters th
|
||||
render(<ScopesTab />);
|
||||
const table = screen.getByRole("table", { name: "Scope counts" });
|
||||
expect(within(table).getByRole("row", { name: /#west.*3.*2.*2/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Observer memberships")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /An observer can appear/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/An observer can appear/, { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Retained data/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Observers by scope")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /An observer can belong/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/An observer can belong/, { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Scope activity/)).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole("searchbox", { name: "Find a scope" }), { target: { value: "WEST" } });
|
||||
expect(screen.queryByText("#east")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/1 of 2 scopes/)).toBeInTheDocument();
|
||||
|
||||
@@ -20,7 +20,7 @@ it("translates the signal view while retaining the requested window, true zero a
|
||||
expect(screen.getByText("0.0 dB")).toBeInTheDocument();
|
||||
const table = screen.getByRole("table", { name: "Disponibilité des échantillons du signal" });
|
||||
expect(within(table).getByRole("row", { name: /SNR.*80.*20.*80.0%/ })).toBeInTheDocument();
|
||||
expect(screen.getByText(/1970-01-01 00:00.*1970-01-01 01:00.*UTC/)).toHaveTextContent("fin exclue");
|
||||
expect(screen.getByText(/1970-01-01 00:00.*1970-01-01 01:00.*UTC/)).toHaveTextContent("heure en cours exclue");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actualiser le signal" }));
|
||||
expect(query.refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -80,8 +80,8 @@ it("shows a translated empty state without implying an outage or displaying samp
|
||||
query.data = { ...originalData, receptions: 0, hourly: [], snr: { samples: 0, average: null, histogram: [] }, rssi: { samples: 0, average: null, histogram: [] } };
|
||||
await act(() => i18n.changeLanguage("fr"));
|
||||
render(<SignalTab range="24h" />);
|
||||
expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument();
|
||||
expect(screen.getByText(/ne prouvent pas une panne/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument();
|
||||
expect(screen.getByText(/pas une panne/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Aucune donnée").length).toBeGreaterThan(0);
|
||||
expect(screen.queryAllByTestId("chart")).toHaveLength(0);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TalkersTab } from "../../../src/features/stats/TalkersTab";
|
||||
import { getTopAdvertisers, getTopTalkers } from "../../../src/api/client";
|
||||
@@ -30,10 +30,10 @@ function deferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function mount() {
|
||||
function mount(onViewNode?: (id: string) => void) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
clients.push(client);
|
||||
const view = (range: StatsRange) => <QueryClientProvider client={client}><TalkersTab range={range} /></QueryClientProvider>;
|
||||
const view = (range: StatsRange) => <QueryClientProvider client={client}><TalkersTab range={range} onViewNode={onViewNode} /></QueryClientProvider>;
|
||||
const result = render(view("24h"));
|
||||
return { client, rerender: (range: StatsRange) => result.rerender(view(range)) };
|
||||
}
|
||||
@@ -118,3 +118,10 @@ it("distinguishes initial loading from successful empty results", async () => {
|
||||
await screen.findByText("No advertisers");
|
||||
expect(screen.getByText("No data")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the clicked advertiser's node", async () => {
|
||||
const onViewNode = vi.fn();
|
||||
mount(onViewNode);
|
||||
fireEvent.click(await screen.findByText("Old advertiser"));
|
||||
expect(onViewNode).toHaveBeenCalledWith("fixture-node");
|
||||
});
|
||||
@@ -22,13 +22,13 @@ describe("Traffic page", () => {
|
||||
expect(screen.getByRole("heading", { name: "Trafic" })).toBeInTheDocument();
|
||||
expect(useStatsObservations).toHaveBeenCalledWith("7d");
|
||||
expect(screen.getByText("Activité horaire · 7 j")).toBeInTheDocument();
|
||||
const table = screen.getByRole("table", { name: "Réceptions par IATA" });
|
||||
const table = screen.getByRole("table", { name: "Observations par zone" });
|
||||
expect(within(table).getByRole("row", { name: /YOW.*1,200.*100.0%/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText("9,999")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/heure UTC/)).toHaveTextContent("partielle");
|
||||
expect(screen.getByText(/ne prouvent pas une panne/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /ne prouvent pas une panne/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/ne prouvent pas une panne/, { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Heures en UTC/)).toHaveTextContent("pas les paquets uniques");
|
||||
expect(screen.getByText(/pas une panne/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /pas une panne/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/pas une panne/, { selector: "p" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actualiser le trafic" }));
|
||||
expect(query.refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -46,11 +46,11 @@ describe("Traffic page", () => {
|
||||
const after = chartOptions();
|
||||
expect(numbers(after)).toEqual(values);
|
||||
after.forEach((option, i) => expect(option.aria).not.toEqual(before[i]!.aria));
|
||||
expect(after[0]).toMatchObject({ series: expect.arrayContaining([expect.objectContaining({ name: "Non attribué" }), expect.objectContaining({ name: "Autres IATA" })]) });
|
||||
expect(after[2]).toMatchObject({ series: [{ data: expect.arrayContaining([expect.objectContaining({ name: "Autres IATA", value: 6 }), expect.objectContaining({ name: "Non attribué", value: 9000 })]) }] });
|
||||
expect(after[0]).toMatchObject({ series: expect.arrayContaining([expect.objectContaining({ name: "Non attribué" }), expect.objectContaining({ name: "Autres zones" })]) });
|
||||
expect(after[2]).toMatchObject({ series: [{ data: expect.arrayContaining([expect.objectContaining({ name: "Autres zones", value: 6 }), expect.objectContaining({ name: "Non attribué", value: 9000 })]) }] });
|
||||
expect(screen.getByRole("row", { name: /Non attribué.*9,000/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("row", { name: /YOW.*1,200/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("row", { name: /Autres IATA/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("row", { name: /Autres zones/ })).not.toBeInTheDocument();
|
||||
expect(query.refetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -71,10 +71,10 @@ describe("Traffic page", () => {
|
||||
query.data = [];
|
||||
await act(() => i18n.changeLanguage("fr"));
|
||||
render(<TrafficTab range="24h" />);
|
||||
expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument();
|
||||
expect(screen.getByText(/ne prouvent pas une panne/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /ne prouvent pas une panne/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/ne prouvent pas une panne/, { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument();
|
||||
expect(screen.getByText(/pas une panne/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /pas une panne/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/pas une panne/, { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Aucune donnée")).toHaveLength(3);
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -82,11 +82,10 @@ describe("Traffic page", () => {
|
||||
it("shows exact reception counts, UTC/missing-history guidance and a refresh action", () => {
|
||||
render(<TrafficTab range="24h" />);
|
||||
expect(useStatsObservations).toHaveBeenCalledWith("24h");
|
||||
const table = screen.getByRole("table", { name: "Receptions by IATA" });
|
||||
const table = screen.getByRole("table", { name: "Observations by area" });
|
||||
expect(within(table).getByRole("row", { name: /YOW.*1,200.*100.0%/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText("9,999")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Blank hours/)).toHaveTextContent("retained");
|
||||
expect(screen.getByText(/UTC/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/not unique packets/)).toHaveTextContent(/UTC.*outage/);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh traffic" }));
|
||||
expect(query.refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ it("keeps seven-day links and falls back from retired 3d links", () => {
|
||||
it("opens Paths & Hashes from a shared URL and retains the region while changing range", () => {
|
||||
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=paths&range=24h&iata=YOW"]}><StatsOverview wsManager={{} as WsManager} /><Location /></MemoryRouter>);
|
||||
expect(screen.getByText("Paths range 24h")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Paths & Hashes" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Paths & hashes" })).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "30d" }));
|
||||
expect(screen.getByText("Paths range 30d")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW");
|
||||
|
||||
@@ -40,10 +40,10 @@ it("retains the same cached path request and window when only the language chang
|
||||
hashed: 0, empty: 0, trace: 0, unclassified: 0, hashWidths: [], pathLengths: [], hourly: [] });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const { unmount } = render(<QueryClientProvider client={client}><PathsTab range="7d" /></QueryClientProvider>);
|
||||
await screen.findByText("No retained receptions in this window.");
|
||||
await screen.findByText("No observations in this window.");
|
||||
expect(getPathStats).toHaveBeenCalledOnce();
|
||||
await act(() => i18n.changeLanguage("fr"));
|
||||
expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument();
|
||||
expect(getPathStats).toHaveBeenCalledOnce();
|
||||
expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-paths", "YVR", "7d"]]);
|
||||
unmount(); client.clear();
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("path analytics charts", () => {
|
||||
const data = (option: typeof before) => (Array.isArray(option.series) ? option.series : [option.series]).map((series) => series?.data);
|
||||
expect(data(after)).toEqual(data(before));
|
||||
const length = pathLengthOption(fixture.pathLengths, c, fr);
|
||||
expect(length).toMatchObject({ xAxis: { name: "Entrées du chemin" }, series: [{ name: "Réceptions" }], aria: { label: { description: expect.stringContaining("Zéro") } } });
|
||||
expect(length).toMatchObject({ xAxis: { name: "Entrées du chemin" }, series: [{ name: "Observations" }], aria: { label: { description: expect.stringContaining("Zéro") } } });
|
||||
expect(data(length)).toEqual(data(pathLengthOption(fixture.pathLengths, c, en)));
|
||||
});
|
||||
});
|
||||
@@ -40,10 +40,10 @@ it("keeps the cached query and time window when only the display language change
|
||||
snr: { samples: 0, average: null, histogram: [] }, rssi: { samples: 0, average: null, histogram: [] }, hourly: [] });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const { unmount } = render(<QueryClientProvider client={client}><SignalTab range="24h" /></QueryClientProvider>);
|
||||
await screen.findByText("No retained receptions in this window.");
|
||||
await screen.findByText("No observations in this window.");
|
||||
expect(getSignalStats).toHaveBeenCalledOnce();
|
||||
await act(() => i18n.changeLanguage("fr"));
|
||||
expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument();
|
||||
expect(getSignalStats).toHaveBeenCalledOnce();
|
||||
expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-signal", "YVR", "24h"]]);
|
||||
unmount(); client.clear();
|
||||
|
||||
@@ -26,10 +26,10 @@ it("keeps the same traffic cache entry and request when only the language change
|
||||
vi.mocked(getStatsObservations).mockResolvedValueOnce([]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const { unmount } = render(<QueryClientProvider client={client}><TrafficTab range="7d" /></QueryClientProvider>);
|
||||
await screen.findByText("No retained receptions in this window.");
|
||||
await screen.findByText("No observations in this window.");
|
||||
expect(getStatsObservations).toHaveBeenCalledOnce();
|
||||
await act(() => i18n.changeLanguage("fr"));
|
||||
expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Aucune observation dans cette période.")).toBeInTheDocument();
|
||||
expect(getStatsObservations).toHaveBeenCalledOnce();
|
||||
expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-observations", "YVR", "7d"]]);
|
||||
unmount(); client.clear();
|
||||
|
||||
@@ -58,19 +58,19 @@ describe("traffic exploration", () => {
|
||||
const before = JSON.stringify(model), colors = readChartColors(), en = i18n.getFixedT("en"), fr = i18n.getFixedT("fr");
|
||||
expect(trafficAreaLabel("YOW", fr)).toBe("YOW");
|
||||
expect(trafficAreaLabel("Unassigned", fr)).toBe("Non attribué");
|
||||
expect(trafficAreaLabel("Other IATAs", fr)).toBe("Autres IATA");
|
||||
expect(trafficAreaLabel("Other IATAs", fr)).toBe("Autres zones");
|
||||
const trend = trafficTrendOption(model, colors, fr);
|
||||
expect(trend).toMatchObject({ useUTC: true, series: [{ name: "YOW", connectNulls: false }, { name: "Non attribué" }], aria: { label: { description: expect.stringContaining("lacunes") } } });
|
||||
const chartData = (option: typeof trend) => (Array.isArray(option.series) ? option.series : [option.series]).map((series) => series?.data);
|
||||
expect(chartData(trend)).toEqual(chartData(trafficTrendOption(model, colors, en)));
|
||||
expect(model.hours.slice(-3).map((row) => row.total)).toEqual([0, null, 4]);
|
||||
const heatmap = trafficHeatmapOption(model, colors, fr);
|
||||
expect(heatmap).toMatchObject({ visualMap: { text: ["Plus", "Moins"], min: 0, max: 4 }, series: [{ name: "Réceptions", data: model.heatmap }] });
|
||||
expect(heatmap).toMatchObject({ visualMap: { text: ["Plus", "Moins"], min: 0, max: 4 }, series: [{ name: "Observations", data: model.heatmap }] });
|
||||
expect(chartData(heatmap)).toEqual(chartData(trafficHeatmapOption(model, colors, en)));
|
||||
const tooltip = (option: typeof heatmap) => (option as { tooltip: { formatter: (item: { value: number[] }) => string } }).tooltip.formatter;
|
||||
expect(tooltip(heatmap)({ value: [12, 1, 1] })).toBe("2026-09-19 12:00 UTC\n1 réception");
|
||||
expect(tooltip(heatmap)({ value: [12, 1, 1200] })).toBe(`2026-09-19 12:00 UTC\n${(1200).toLocaleString()} réceptions`);
|
||||
expect(tooltip(trafficHeatmapOption(model, colors, en))({ value: [12, 1, 1] })).toBe("2026-09-19 12:00 UTC\n1 reception");
|
||||
expect(tooltip(heatmap)({ value: [12, 1, 1] })).toBe("2026-09-19 12:00 UTC\n1 observation");
|
||||
expect(tooltip(heatmap)({ value: [12, 1, 1200] })).toBe(`2026-09-19 12:00 UTC\n${(1200).toLocaleString()} observations`);
|
||||
expect(tooltip(trafficHeatmapOption(model, colors, en))({ value: [12, 1, 1] })).toBe("2026-09-19 12:00 UTC\n1 observation");
|
||||
expect(JSON.stringify(model)).toBe(before);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user