diff --git a/src/App.tsx b/src/App.tsx
index 0c11974d..eeb8e87c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -221,7 +221,7 @@ function AppInner() {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", tab);
- if (tab !== "Routes") for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key);
+ if (tab !== "Routes") for (const key of ["route", "routeIata"]) next.delete(key);
// the analyzer is URL-backed, so its mobile close lives here rather than above
if (isMobile) next.delete("analyze");
// AppShell fires onTabChange even for a no-op click on the already-active tab — only an actual
diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx
index 3b25973f..50df4926 100644
--- a/src/components/DetailPanel.tsx
+++ b/src/components/DetailPanel.tsx
@@ -3,7 +3,7 @@ import { CloseButton } from "./CloseButton";
// shared scaffolding for the right-hand entity detail panels (observers, nodes, …)
-export function Section({ title, children, first }: { title: string; children: ReactNode; first?: boolean }) {
+export function Section({ title, children, first }: { title: ReactNode; children: ReactNode; first?: boolean }) {
return (
{title}
diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts
index 75e2a4f8..0fb90aef 100644
--- a/src/features/observers/observer-navigation.ts
+++ b/src/features/observers/observer-navigation.ts
@@ -5,7 +5,7 @@ export const observerRange = (value: string | null): StatsRange => value === "24
export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) {
const next = new URLSearchParams(params);
next.set("tab", "Observers");
- for (const key of ["statsTab", "observerId", "node", "route", "routeIata", "routeRange", "routeSince", "routeUntil", "compareA", "compareB", "compareSince"]) next.delete(key);
+ for (const key of ["statsTab", "observerId", "node", "route", "routeIata", "compareA", "compareB", "compareSince"]) next.delete(key);
if (id) { next.set("observer", id); next.set("range", range); }
else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); next.delete("compareUntil"); }
return next;
diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx
index 4f7abdc6..8e4e333c 100644
--- a/src/features/packets/PacketAnalyzerDrawer.tsx
+++ b/src/features/packets/PacketAnalyzerDrawer.tsx
@@ -84,7 +84,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
}
-
- >}
- >}
- ;
-}
diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx
index 583bab6c..95a84225 100644
--- a/src/features/routes/RouteTable.tsx
+++ b/src/features/routes/RouteTable.tsx
@@ -1,7 +1,6 @@
import { useState, useMemo, useCallback, useEffect, useRef, memo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
-import { RouteEvidencePanel, type RouteActions } from "./RouteEvidencePanel";
import { getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getIatas } from "../../api/client";
import { useRegion, useRegionSelection } from "../../hooks/useRegion";
import { useInfinitePages } from "../../hooks/useInfinitePages";
@@ -11,7 +10,7 @@ import { DataTable, type Column } from "../../components/DataTable";
import { LoadingPill } from "../../components/LoadingPill";
import { MultiSelectDropdown } from "../../components/MultiSelectDropdown";
import { useIsMobile } from "../../hooks/useMediaQuery";
-import { RouteDetailPanel } from "./RouteDetailPanel";
+import { RouteDetailPanel, type RouteActions } from "./RouteDetailPanel";
import { ResolvedHopBlock } from "../packets/PathData";
import { formatHex } from "../../lib/formatters";
import type { KnownRoute, CrossIATARoute, ResolvedHop, ResolvedNode, RouteHop } from "../../types/api";
@@ -154,7 +153,7 @@ export function RouteTable(actions: RouteActions) {
// no-op when nothing is open, so callers (e.g. the region-change effect) can call it unconditionally
if (!pathKey && !selectedKey) return;
setSelectedKey(null);
- setParams(previous => { const next = new URLSearchParams(previous); for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); return next; }, { replace: true });
+ setParams(previous => { const next = new URLSearchParams(previous); for (const key of ["route", "routeIata"]) next.delete(key); return next; }, { replace: true });
}, [pathKey, selectedKey, setParams]);
@@ -241,7 +240,7 @@ export function RouteTable(actions: RouteActions) {
const route = rows?.find(row => String(row.id) === id);
if (route?.pathKey) {
setSelectedKey(null);
- setParams(previous => { const next = new URLSearchParams(previous); next.set("route", route.pathKey!); next.set("routeIata", route.iata); next.delete("routeSince"); next.delete("routeUntil"); return next; }, { replace: true });
+ setParams(previous => { const next = new URLSearchParams(previous); next.set("route", route.pathKey!); next.set("routeIata", route.iata); return next; }, { replace: true });
} else {
closeRoute();
setSelectedKey(id);
@@ -361,8 +360,10 @@ export function RouteTable(actions: RouteActions) {
)}
)}
- {pathKey && routeIata ? : selectedRoute && (
- setSelectedKey(null)} />
+ {pathKey && routeIata ? (
+ row.pathKey === pathKey && row.iata === routeIata)} iata={routeIata} pathKey={pathKey} onClose={closeRoute} {...actions} />
+ ) : selectedRoute && (
+ setSelectedKey(null)} {...actions} />
)}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 45df1cca..c57b6ad7 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -389,29 +389,18 @@
"loadMore": "Load more ({{count}} left)"
},
"routeEvidence": {
- "title": "Retained reports",
- "window": "Time window",
- "shared": "Shared time window",
- "invalid": "Invalid shared time window. Choose a range to continue.",
- "loading": "Loading retained reports…",
- "missing": "The saved route or evidence API is unavailable on this server.",
- "error": "Could not load retained reports. Try again.",
+ "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.",
"retry": "Retry",
- "savedPath": "Full saved route",
- "match": "Matches the complete saved prefix sequence at {{width}} byte(s) per hop. Other widths, subsegments, TRACE and unclassified reports are excluded.",
- "caution": "Matching bytes are not proof of the same physical route. Hop names are the saved route identities; inspect each packet for its candidate matches.",
- "retention": "These are retained reports, not distinct packets or the route’s lifetime count. Reports can expire while the saved route remains. The time window ends at the exclusive UTC boundary shown above.",
- "reports_one": "{{count}} report loaded",
- "reports_other": "{{count}} reports loaded",
- "unavailable": "The saved prefixes cannot be used to match retained reports.",
- "empty": "No retained reports match this saved prefix sequence in this window. History may have expired or used a different prefix width.",
- "more": "Load more reports",
- "cap": "Showing up to 500 reports. Choose a shorter window to narrow the investigation.",
- "legacy": "Retained report links need a server that supplies stable route keys.",
- "width": "Saved prefixes · {{width}} byte(s) per hop",
- "brief": "Prefix matches, not confirmed physical routes. Raw reports may expire.",
- "definition": "How these reports are matched and counted",
- "close": "Close route reports"
+ "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.",
+ "more": "Load more",
+ "cap": "Showing the newest 500 packets."
},
"channelMessages": {
"select": "Select a channel",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 1be2ee59..205998e1 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -393,29 +393,18 @@
"loadMore": "Afficher plus ({{count}} restants)"
},
"routeEvidence": {
- "title": "Rapports conservés",
- "window": "Période",
- "shared": "Période partagée",
- "invalid": "Période partagée invalide. Choisissez une période pour continuer.",
- "loading": "Chargement des rapports conservés…",
- "missing": "Le trajet enregistré ou l’API des rapports est indisponible sur ce serveur.",
- "error": "Impossible de charger les rapports conservés. Réessayez.",
+ "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.",
"retry": "Réessayer",
- "savedPath": "Trajet complet enregistré",
- "match": "Correspond à la séquence complète des préfixes enregistrés, avec {{width}} octet(s) par saut. Les autres tailles, les sous-trajets, TRACE et les rapports non classés sont exclus.",
- "caution": "Des octets identiques ne prouvent pas un même trajet physique. Les noms des sauts correspondent aux identités enregistrées ; consultez chaque paquet pour voir ses correspondances possibles.",
- "retention": "Il s’agit de rapports conservés, et non de paquets distincts ni du total historique du trajet. Les rapports peuvent expirer alors que le trajet reste enregistré. La fin de période UTC affichée ci-dessus est exclusive.",
- "reports_one": "{{count}} rapport chargé",
- "reports_other": "{{count}} rapports chargés",
- "unavailable": "Les préfixes enregistrés ne permettent pas de rechercher les rapports conservés.",
- "empty": "Aucun rapport conservé ne correspond à ces préfixes dans cette période. L’historique a pu expirer ou utiliser une autre taille de préfixe.",
- "more": "Charger plus de rapports",
- "cap": "Affichage limité à 500 rapports. Choisissez une période plus courte pour affiner la recherche.",
- "legacy": "Les liens vers les rapports conservés nécessitent un serveur fournissant des clés de trajet stables.",
- "width": "Préfixes enregistrés · {{width}} octet(s) par saut",
- "brief": "Préfixes correspondants, pas des trajets physiques confirmés. Les rapports bruts peuvent expirer.",
- "definition": "Correspondance et comptage des rapports",
- "close": "Fermer les rapports du trajet"
+ "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.",
+ "more": "Charger plus",
+ "cap": "Affichage des 500 paquets les plus récents."
},
"channelMessages": {
"select": "Choisir un canal",
diff --git a/tests/features/routes/RouteEvidencePanel.test.tsx b/tests/features/routes/RouteDetailPanel.test.tsx
similarity index 51%
rename from tests/features/routes/RouteEvidencePanel.test.tsx
rename to tests/features/routes/RouteDetailPanel.test.tsx
index baf6df2d..7ba42d81 100644
--- a/tests/features/routes/RouteEvidencePanel.test.tsx
+++ b/tests/features/routes/RouteDetailPanel.test.tsx
@@ -2,114 +2,112 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, useSearchParams } from "react-router-dom";
-import { RouteEvidencePanel } from "../../../src/features/routes/RouteEvidencePanel";
+import { RouteDetailPanel } from "../../../src/features/routes/RouteDetailPanel";
import { getRouteEvidence } from "../../../src/api/client";
-import type { RouteEvidence } from "../../../src/types/api";
+import type { KnownRoute, RouteEvidence } from "../../../src/types/api";
import i18n from "../../../src/i18n";
vi.mock("../../../src/api/client", () => ({ getRouteEvidence: vi.fn(), isNotFound: () => false }));
const key = "a".repeat(32);
+const route: KnownRoute = { id: 9, pathKey: key, iata: "YOW", hopCount: 2, hops: [{ nodeId: "n1", hashBytes: "ab", node: { id: "n1", publicKey: "abcd", name: "North" } }], firstSeen: 1, lastSeen: 2, observationCount: 1234 };
const page: RouteEvidence = {
- route: { id: 9, pathKey: key, iata: "YOW", hopCount: 2, hops: [{ nodeId: "n1", hashBytes: "ab", node: { id: "n1", publicKey: "abcd", name: "North" } }], firstSeen: 1, lastSeen: 2, observationCount: 200 },
+ route,
windowStart: 1700000000000, windowEnd: 1700086400000, generatedAt: 1700086400001,
matchType: "saved_path_prefixes", matchAvailable: true, hashSize: 1, pathBytes: "abcd",
items: [{ id: 17, packetHash: "aabb", observerId: "o1", observerName: "Garden", heardAt: 1700000000001, payloadType: 2, payloadTypeName: "TXT_MSG", snr: 0 }], hasMore: true, nextPageCursor: "pinned-cursor",
};
-const inspect = vi.fn(); const observer = vi.fn(); const node = vi.fn();
-function Harness() {
+const inspect = vi.fn(); const observer = vi.fn();
+function Harness({ listed }: { listed?: KnownRoute }) {
const [params, set] = useSearchParams();
- return <>
- {}} onAnalyzePacket={inspect} onViewObserver={observer} onViewNode={node} />>;
+ return <>
+ {}} onAnalyzePacket={inspect} onViewObserver={observer} />>;
}
-function mount(url = "/?tab=Routes") {
- render();
+function mount({ url = "/?tab=Routes", listed }: { url?: string; listed?: KnownRoute } = {}) {
+ render();
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getRouteEvidence).mockResolvedValue(page); });
-describe("retained route evidence", () => {
- it("keeps a shared month link and offers every period the server serves", async () => {
- mount("/?routeRange=30d");
+
+describe("route detail", () => {
+ it("lists recent packets as far back as the server keeps them, with no window picker", async () => {
+ mount({ url: "/?routeRange=7d&routeSince=1&routeUntil=2" });
await screen.findByText("Garden");
expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { range: "720h", limit: 50 }, expect.anything());
- expect(screen.getByRole("button", { name: "30d" })).toHaveAttribute("aria-pressed", "true");
- expect(screen.getByRole("button", { name: "24h" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "7d" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "24h" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument();
});
- it("falls back to 24h for a retired 3d link", async () => {
- mount("/?routeRange=3d");
- await screen.findByText("Garden");
- expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { range: "24h", limit: 50 }, expect.anything());
+
+ it("shows the route's lifetime summary from the listed route before packets load", () => {
+ vi.mocked(getRouteEvidence).mockReturnValue(new Promise(() => {}));
+ mount({ listed: route });
+ expect(screen.getByText("1,234")).toBeInTheDocument();
+ expect(screen.getByText("North")).toBeInTheDocument();
+ expect(screen.getByText("First seen")).toBeInTheDocument();
});
- it("requests a shared five-day window", async () => {
- mount("/?routeSince=1700000000000&routeUntil=1700432000000");
- await screen.findByText("Garden");
- expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { since: 1700000000000, until: 1700432000000, limit: 50 }, expect.anything());
+
+ it("fills the summary from the packet response when opened from a shared link", async () => {
+ mount();
+ expect(await screen.findByText("1,234")).toBeInTheDocument();
});
- it("does not request a shared period longer than thirty days", async () => {
- mount("/?routeSince=1700000000000&routeUntil=1702678400001");
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(getRouteEvidence).not.toHaveBeenCalled();
+
+ it("keeps the matching caveats in an info tip instead of inline text", async () => {
+ mount();
+ await screen.findByText("Garden");
+ expect(screen.getByRole("button", { name: /don't prove the same physical path/ })).toBeInTheDocument();
});
- it("opens the exact report and observer, names the saved route, and pins cursor pages", async () => {
+
+ it("opens the exact report and observer, and pins cursor pages", async () => {
mount();
await screen.findByText("Garden");
- expect(screen.getByText(/not proof of the same physical route/)).toBeInTheDocument();
- expect(screen.getByText("North")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Inspect report" }));
expect(inspect).toHaveBeenCalledWith("aabb", 17);
fireEvent.click(screen.getByRole("button", { name: "Inspect observer" }));
expect(observer).toHaveBeenCalledWith("o1");
vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false });
- fireEvent.click(screen.getByRole("button", { name: "Load more reports" }));
+ fireEvent.click(screen.getByRole("button", { name: "Load more" }));
await waitFor(() => expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { pageCursor: "pinned-cursor", limit: 50 }, expect.anything()));
});
+
it("replaces results when the route changes, even while its request is pending", async () => {
mount(); await screen.findByText("Garden");
vi.mocked(getRouteEvidence).mockReturnValue(new Promise(() => {}));
fireEvent.click(screen.getByText("Other route"));
expect(screen.queryByText("Garden")).not.toBeInTheDocument();
- expect(screen.getByText("Loading retained reports…")).toBeInTheDocument();
});
- it("uses shared fixed boundaries, then drops them when a new range is selected", async () => {
- mount("/?routeSince=1700000000000&routeUntil=1700086400000");
- await screen.findByText("Garden");
- expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { since: 1700000000000, until: 1700086400000, limit: 50 }, expect.anything());
- expect(screen.getByText("Shared time window")).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "7d" }));
- await waitFor(() => expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { range: "168h", limit: 50 }, expect.anything()));
- expect(screen.getByText(/routeRange=7d/)).not.toHaveTextContent("routeSince");
+
+ it("says when no packets 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();
});
- it("does not replace an invalid shared window with an unrelated default", async () => {
- mount("/?routeSince=bad");
- expect(await screen.findByRole("alert")).toHaveTextContent("Invalid shared time window");
+
+ it("does not fetch packets for a legacy route without a path key", () => {
+ render( {}} />);
+ expect(screen.getByText("North")).toBeInTheDocument();
expect(getRouteEvidence).not.toHaveBeenCalled();
});
- it("explains expired or absent evidence without presenting the historical counter as a total", async () => {
- vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false });
- mount();
- expect(await screen.findByText(/No retained reports match/)).toBeInTheDocument();
- expect(screen.queryByText("200")).not.toBeInTheDocument();
- });
+
it("distinguishes unavailable saved prefixes 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(screen.getByText("Rapports conservés")).toBeInTheDocument();
+ await i18n.changeLanguage("en");
});
+
it("keeps a failed next page separate from the reports already loaded", async () => {
mount(); await screen.findByText("Garden");
vi.mocked(getRouteEvidence).mockRejectedValue(new Error("offline"));
- fireEvent.click(screen.getByRole("button", { name: "Load more reports" }));
- expect(await screen.findByRole("alert")).toHaveTextContent("Could not load retained reports");
+ fireEvent.click(screen.getByRole("button", { name: "Load more" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent("Could not load");
expect(screen.getByText("Garden")).toBeInTheDocument();
vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false });
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
});
+
it("colours a good SNR report the same way every other SNR cell does", async () => {
vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [{ ...page.items[0]!, snr: 12 }], hasMore: false });
mount();
- const snr = await screen.findByText("12.00");
- expect(snr).toHaveClass("text-green");
+ expect(await screen.findByText("12.00")).toHaveClass("text-green");
});
});
diff --git a/tests/features/routes/RouteTable.test.tsx b/tests/features/routes/RouteTable.test.tsx
index cb1389a9..5f598471 100644
--- a/tests/features/routes/RouteTable.test.tsx
+++ b/tests/features/routes/RouteTable.test.tsx
@@ -25,7 +25,7 @@ vi.mock("../../../src/api/client", () => ({
getRegion: vi.fn(),
}));
-vi.mock("../../../src/features/routes/RouteEvidencePanel", () => ({ RouteEvidencePanel: ({ pathKey }: { pathKey: string }) =>