diff --git a/src/App.tsx b/src/App.tsx index 8c98e73..5f87031 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -223,7 +223,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: }, { replace: true }); }, [setSearchParams]); - const handleTabChange = (tab: string) => { + const handleTabChange = (tab: string, mapFocus?: { lat: number; lng: number }) => { setPanels([]); dashboardFocus.current = null; onExitVisit(); @@ -240,6 +240,13 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: // the analyzer is URL-backed, so its mobile close lives here rather than above if (isMobile) next.delete("analyze"); if (tab !== "Observers") next.delete("observer"); + // lat/lng/zoom only seed the map on mount, so they'd re-frame it on every later visit + if (tab !== "Map" || mapFocus) for (const key of ["lat", "lng", "zoom"]) next.delete(key); + if (mapFocus) { + next.set("lat", mapFocus.lat.toFixed(5)); + next.set("lng", mapFocus.lng.toFixed(5)); + next.set("zoom", "14"); + } // stats sub-state shouldn't haunt the URL on other tabs if (tab !== "Analytics" && tab !== "Observers") { next.delete("statsTab"); @@ -250,6 +257,11 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: }); }; + const handleViewOnMap = (nodeId: string, lat: number, lng: number) => { + handleTabChange("Map", { lat, lng }); + if (!isMobile) setSelectedNodeId(nodeId); + }; + const clearSelection = useCallback(() => { setSelectedNodeId(null); setPanels([]); @@ -346,9 +358,10 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: onViewObserver={viewObserver} onViewNode={setSelectedNodeId} onAnalyzePacket={viewPacket} + onViewOnMap={activeTab === "Map" ? undefined : (lat, lng) => handleViewOnMap(selectedNodeId, lat, lng)} /> )} - {panels.map((panel, index) => closePanel(index)} onOpen={openPanel} onObserverDashboard={handleViewObserverStats} />)} + {panels.map((panel, index) => closePanel(index)} onOpen={openPanel} onObserverDashboard={handleViewObserverStats} onViewOnMap={activeTab === "Map" ? undefined : handleViewOnMap} />)} {observerVisit && } />} diff --git a/src/features/InvestigationPanels.tsx b/src/features/InvestigationPanels.tsx index c402ad7..66f7e38 100644 --- a/src/features/InvestigationPanels.tsx +++ b/src/features/InvestigationPanels.tsx @@ -20,9 +20,10 @@ export interface Investigation { } // Each panel stays mounted underneath the next one so its selection, scroll and trigger survive. -export function InvestigationPanel({ target, inactive, onClose, onOpen, onObserverDashboard }: { +export function InvestigationPanel({ target, inactive, onClose, onOpen, onObserverDashboard, onViewOnMap }: { target: InvestigationTarget; inactive: boolean; onClose: () => void; onOpen: (target: InvestigationTarget) => void; onObserverDashboard: (id: string) => void; + onViewOnMap?: (nodeId: string, lat: number, lng: number) => void; }) { const { t } = useTranslation(); const { data: detail, isLoading } = usePacketDetail(target.kind === "packet" ? target.hash : null); @@ -35,7 +36,7 @@ export function InvestigationPanel({ target, inactive, onClose, onOpen, onObserv {target.kind === "observer" ? ( ) : target.kind === "node" ? ( - + onViewOnMap(target.id, lat, lng))} /> ) : ( void; onViewNode?: (nodeId: string) => void; onAnalyzePacket?: (hash: string) => void; + onViewOnMap?: (lat: number, lng: number) => void; } -export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, onAnalyzePacket }: NodeDetailPanelProps) { +export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, onAnalyzePacket, onViewOnMap }: NodeDetailPanelProps) { const { data: node, isLoading } = useQuery({ queryKey: ["node", nodeId], queryFn: () => getNode(nodeId), @@ -145,6 +146,15 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o {node.lng != null && } {node.locationSource && } + {hasLocation && onViewOnMap && ( + + )} )} diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index 9b85cd7..b860fc5 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -55,12 +55,12 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager: {(id || onReturn) &&
{onReturn && } {id && <> - + } diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index b298424..110fa1e 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -114,6 +114,12 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, const [typeFilter, setTypeFilter] = useState(""); const [brokerFilter, setBrokerFilter] = useState(""); const [scopeFilter, setScopeFilter] = useState(""); // "" = Any; applied client-side over the loaded set + // the compact sidebar only has a name search, so the directory's other filters sit out until it returns + const status = compact ? "" : statusFilter; + const type = compact ? "" : typeFilter; + const broker = compact ? "" : brokerFilter; + const scope = compact ? "" : scopeFilter; + const field = compact ? "name" : searchField; useTick(); // keep recency-derived status badges fresh @@ -130,8 +136,8 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, ); const queryKey = useMemo( - () => ["observers", regionKey, statusFilter, typeFilter, brokerFilter, search, searchField], - [regionKey, statusFilter, typeFilter, brokerFilter, search, searchField], + () => ["observers", regionKey, status, type, broker, search, field], + [regionKey, status, type, broker, search, field], ); // page the region's observers 50 at a time (filters stay server-side, in the query key); rows @@ -141,10 +147,10 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, queryFn: (cursor) => getObserversPage(iatas, { cursor, - status: statusFilter || undefined, - type: typeFilter || undefined, - broker: brokerFilter || undefined, - name: searchField === "name" ? search || undefined : undefined, + status: status || undefined, + type: type || undefined, + broker: broker || undefined, + name: field === "name" ? search || undefined : undefined, }), getId: observerId, keepPrevious: true, @@ -162,8 +168,8 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, const scopeOptions = useScopes(); const displayObservers = useMemo( - () => (scopeFilter ? observers.filter((o) => o.scopes?.includes(scopeFilter)) : observers), - [observers, scopeFilter], + () => (scope ? observers.filter((o) => o.scopes?.includes(scope)) : observers), + [observers, scope], ); // patch the live status into the paged cache (mirrors NodeTable). A brand-new observer not on any diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts index 39a7c7c..b31869b 100644 --- a/src/features/observers/observer-navigation.ts +++ b/src/features/observers/observer-navigation.ts @@ -1,6 +1,6 @@ import type { StatsRange } from "../stats/types"; -export const observerRange = (value: string | null): StatsRange => value === "24h" ? "24h" : "3d"; +export const observerRange = (value: string | null): StatsRange => value === "24h" || value === "30d" ? value : "7d"; export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) { const next = new URLSearchParams(params); diff --git a/src/features/packets/PacketEndpoints.tsx b/src/features/packets/PacketEndpoints.tsx index cd9e5df..1aabaa8 100644 --- a/src/features/packets/PacketEndpoints.tsx +++ b/src/features/packets/PacketEndpoints.tsx @@ -1,4 +1,4 @@ -import type { PacketSummary, ResolvedHop } from "../../types/api"; +import type { PacketSummary, ResolvedHop, ResolvedNode } from "../../types/api"; import { PayloadType, type PathConfidence } from "../../types/enums"; import { HopPopover } from "./PathData"; @@ -9,12 +9,17 @@ const CONFIDENCE_CLASSES: Record = { none: "bg-text-muted/8 text-text-dim", }; +const nodeLabel = (node: ResolvedNode) => node.name ?? node.publicKey.slice(0, 8); + function Chip({ hop }: { hop: ResolvedHop }) { const node = hop.nodes[0]; - const label = node ? node.name ?? node.publicKey.slice(0, 8) : "?"; + const label = node ? nodeLabel(node) : "?"; return ( - + {label}{hop.nodes.length > 1 ? ` +${hop.nodes.length - 1}` : ""} diff --git a/src/features/packets/PathData.tsx b/src/features/packets/PathData.tsx index 59db7f9..f0e6285 100644 --- a/src/features/packets/PathData.tsx +++ b/src/features/packets/PathData.tsx @@ -94,7 +94,6 @@ export function HopPopover({ hop, onViewNode, showSnr = true, children }: { onMouseEnter={hasHover ? open : undefined} onMouseLeave={hasHover ? scheduleClose : undefined} onClick={hasHover ? undefined : toggle} - title={nodes.map(nodeLabel).join(", ") || "No Path Resolutions Available"} className="inline-flex min-w-0 max-w-full" > {children} diff --git a/src/features/routes/RouteEvidencePanel.tsx b/src/features/routes/RouteEvidencePanel.tsx index 5b270f0..7f7e4ec 100644 --- a/src/features/routes/RouteEvidencePanel.tsx +++ b/src/features/routes/RouteEvidencePanel.tsx @@ -21,14 +21,14 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on const { t } = useTranslation(); const [params, setParams] = useSearchParams(); const [openedAt] = useState(Date.now); - const range = ["3d", "7d", "30d"].includes(params.get("routeRange") ?? "") ? "3d" : "24h"; + const range = params.get("routeRange") === "7d" ? "7d" : params.get("routeRange") === "30d" ? "30d" : "24h"; const rawSince = params.get("routeSince"), rawUntil = params.get("routeUntil"); const fixed = rawSince !== null || rawUntil !== null; const since = Number(rawSince), until = Number(rawUntil); - const invalid = fixed && (!rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until > openedAt || until - since > 3 * 86400000); + const invalid = fixed && (!rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until > openedAt || until - since > 30 * 86400000); const query = useInfiniteQuery({ queryKey: ["route-evidence", iata, pathKey, range, rawSince, rawUntil], - queryFn: ({ pageParam, signal }) => getRouteEvidence(iata, pathKey, pageParam ? { pageCursor: pageParam, limit: 50 } : fixed ? { since, until, limit: 50 } : { range: range === "3d" ? "72h" : "24h", limit: 50 }, signal), + queryFn: ({ pageParam, signal }) => getRouteEvidence(iata, pathKey, pageParam ? { pageCursor: pageParam, limit: 50 } : fixed ? { since, until, limit: 50 } : { range: range === "7d" ? "168h" : range === "30d" ? "720h" : "24h", limit: 50 }, signal), initialPageParam: undefined as string | undefined, getNextPageParam: (last, pages) => pages.length < 10 && last.hasMore ? last.nextPageCursor : undefined, enabled: !invalid, @@ -41,7 +41,7 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on const capped = (query.data?.pages.length ?? 0) >= 10 && query.data?.pages.at(-1)?.hasMore; return ({ tab: "Routes", route: pathKey, routeIata: iata, routeRange: range, routeSince: String(first.windowStart), routeUntil: String(first.windowEnd), hash: null, analyze: null, observation: null, path: null, observer: null, node: null })} />}>
- setParams(previous => { const next = new URLSearchParams(previous); next.set("routeRange", value); next.delete("routeSince"); next.delete("routeUntil"); return next; })} /> + setParams(previous => { const next = new URLSearchParams(previous); next.set("routeRange", value); next.delete("routeSince"); next.delete("routeUntil"); return next; })} />

{t("observerCompare.retainedWindow")}

{fixed &&

{t("routeEvidence.shared")}

} {first &&

{new Date(first.windowStart).toISOString().slice(0, 19).replace("T", " ")} → {new Date(first.windowEnd).toISOString().slice(0, 19).replace("T", " ")} UTC

} diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx index 0f3a6b6..56158ba 100644 --- a/src/features/stats/CompareObserversTab.tsx +++ b/src/features/stats/CompareObserversTab.tsx @@ -17,7 +17,6 @@ function validation(value: Selection): string | null { if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) { return "Choose a valid start and a later end time."; } - if (value.until - value.since > 3 * 86_400_000) return "observerCompare.windowLimit"; return null; } diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 7e4d198..84fb573 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -15,19 +15,18 @@ import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "compare", "graph"]; -const RANGES: StatsRange[] = ["24h", "3d", "30d"]; +const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); -const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "3d"); +const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "7d"); interface StatsOverviewProps { wsManager: WsManager; onObserverDashboard?: (id: string) => void; } -// Stats page shell: an analytics sub-header and range over the active -// sub-tab. Sub-tab, range, and selected observer live in the URL (?statsTab/?range/?observerId) so the -// view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant. +// 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; replace:true keeps it out of history. export function StatsOverview({ wsManager, onObserverDashboard }: StatsOverviewProps) { const [params, setParams] = useSearchParams(); const tab = asTab(params.get("statsTab")); diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index c95357a..083bfe3 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -90,7 +90,7 @@ const TAB_OPTIONS = [ { value: "graph", icon: }, ]; -const RANGES: StatsRange[] = ["24h", "3d", "30d"]; +const RANGES: StatsRange[] = ["24h", "7d", "30d"]; interface Props { tab: StatsTab; diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index c5ac853..fe00fcf 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -165,12 +165,11 @@ export interface ObserverActivity { } // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; -export type StatsRange = "24h" | "3d" | "7d" | "30d"; +export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "compare" | "graph"; +export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { "24h": 24 * 60 * 60 * 1000, - "3d": 3 * 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000, "30d": 30 * 24 * 60 * 60 * 1000, }; diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index 383ee1a..a65e309 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -5,7 +5,6 @@ import type { StatsRange } from "./types"; // Go time.ParseDuration strings the telemetry endpoint expects, per selected range. const RANGE_PARAM: Record = { "24h": "24h", - "3d": "72h", "7d": "168h", "30d": "720h", }; @@ -14,7 +13,6 @@ const RANGE_PARAM: Record = { // charts don't drown in points. const INTERVAL_PARAM: Record = { "24h": "1h", - "3d": "1h", "7d": "6h", "30d": "24h", }; @@ -33,7 +31,6 @@ export function useObserver(observerId: string | null, poll = false) { // Bucket per range: a quiet hour stays visible at 24h, the longer windows stay under ~200 points. const ACTIVITY_INTERVAL: Record = { "24h": "15m", - "3d": "1h", "7d": "1h", "30d": "6h", }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 999ec3f..4ae076e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -66,10 +66,8 @@ "ranges": { "24h": "24h", "7d": "7d", - "30d": "30d", - "3d": "3d" - }, - "archiveHelp": "Hourly analytics keep summaries for up to 30 days. Packet details and overlap use retained raw records; older summary history accumulates after archiving is enabled." + "30d": "30d" + } }, "signal": { "title": "RF / Signal", @@ -243,12 +241,9 @@ "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.", - "trafficRecent": "Recent packet traffic", - "trafficQuiet": "No recent recorded packets", "statusRecent": "Recent status", "statusStale": "Status is stale", "statusMissing": "No status report", - "scopeNote": "History for this observer across all received regions.", "activity": "Recorded packets per {{interval}}", "mix": "Packet-type mix", "signal": "Received signal", @@ -270,15 +265,11 @@ "publicKey": "Public key", "copy": "Copy", "copied": "Copied", - "broker": "Broker", "presence": "Last contact", "packetArrival": "Packet arrival", "firstSeen": "First seen", "copyLink": "Copy observer link", - "updated": "Calculated", "window": "UTC window", - "windowHelp": "The latest bucket may be partial; gaps are missing records, not proof of an outage. Repeated deliveries of the same retained packet/observer pair count once.", - "exact": "Exact activity values", "time": "Time (UTC)", "mean": "Average", "minimum": "Minimum", @@ -288,7 +279,6 @@ "recording": "Loading observer…", "hour": "hour", "day": "day", - "trafficUnknown": "Packet freshness unavailable", "returnTo": "Back to {{page}}", "directory": "All observers", "sidebarSearch": "Search observers" @@ -316,8 +306,7 @@ "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.", - "windowLimit": "Choose a period of 3 days or less for retained packet records.", - "retainedWindow": "Packet details and overlap use retained raw records, with a maximum 3-day window." + "retainedWindow": "Packet details and overlap use retained raw records, so periods older than the server's packet retention come back incomplete." }, "investigation": { "title": "Reception evidence", @@ -358,15 +347,11 @@ "fewer": "Show fewer paths", "nodeDialog": "Node detail", "packetDialog": "Packet analyzer", - "closeObserver": "Close observer details", - "observerList": "Inspect an observer" + "closeObserver": "Close observer details" }, "routeEvidence": { "title": "Retained reports", "window": "Time window", - "day": "24 hours", - "week": "7 days", - "month": "30 days", "shared": "Shared time window", "invalid": "Invalid shared time window. Choose a range to continue.", "loading": "Loading retained reports…", @@ -399,8 +384,6 @@ "keyMissing": "No decryption key is configured for this channel.", "scope": "Transport scope", "allScopes": "All scopes", - "aboutScopes": "About scope labels", - "scopeHelp": "Labels describe the first recorded packet, not the channel key. Other receptions may differ; inspect the packet for its reports. Only retained messages are shown.", "unscoped": "No transport scope", "unknownScope": "Unresolved scope", "unavailableScope": "Scope unavailable", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ec00f6d..35178e7 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -67,10 +67,8 @@ "ranges": { "24h": "24 h", "7d": "7 j", - "30d": "30 j", - "3d": "3 j" - }, - "archiveHelp": "Les statistiques horaires conservent des résumés jusqu’à 30 jours. Les détails et les recoupements utilisent les paquets bruts conservés ; l’historique s’accumule après l’activation de l’archivage." + "30d": "30 j" + } }, "signal": { "title": "RF / Signal", @@ -247,12 +245,9 @@ "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.", - "trafficRecent": "Trafic de paquets récent", - "trafficQuiet": "Aucun paquet récent enregistré", "statusRecent": "État récent", "statusStale": "État périmé", "statusMissing": "Aucun rapport d’état", - "scopeNote": "Historique de cet observateur, toutes régions de réception confondues.", "activity": "Paquets enregistrés par {{interval}}", "mix": "Répartition des types", "signal": "Signal reçu", @@ -274,15 +269,11 @@ "publicKey": "Clé publique", "copy": "Copier", "copied": "Copié", - "broker": "Courtier", "presence": "Dernier contact", "packetArrival": "Réception de paquet", "firstSeen": "Première réception", "copyLink": "Copier le lien de l’observateur", - "updated": "Calculé", "window": "Période UTC", - "windowHelp": "La dernière période peut être partielle ; les lacunes sont des données manquantes, pas la preuve d’une panne. Les livraisons répétées du même couple paquet/observateur conservé ne comptent qu’une fois.", - "exact": "Valeurs exactes de l’activité", "time": "Heure (UTC)", "mean": "Moyenne", "minimum": "Minimum", @@ -292,7 +283,6 @@ "recording": "Chargement de l’observateur…", "hour": "heure", "day": "jour", - "trafficUnknown": "Fraîcheur des paquets inconnue", "returnTo": "Retour à « {{page}} »", "directory": "Tous les observateurs", "sidebarSearch": "Rechercher les observateurs" @@ -320,8 +310,7 @@ "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.", - "windowLimit": "Choisissez une période de 3 jours ou moins pour les paquets conservés.", - "retainedWindow": "Les détails et les recoupements utilisent les paquets bruts conservés, sur une période maximale de 3 jours." + "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." }, "investigation": { "title": "Rapports de réception", @@ -362,15 +351,11 @@ "fewer": "Afficher moins de trajets", "nodeDialog": "Détails du nœud", "packetDialog": "Analyseur de paquets", - "closeObserver": "Fermer les détails de l’observateur", - "observerList": "Examiner un observateur" + "closeObserver": "Fermer les détails de l’observateur" }, "routeEvidence": { "title": "Rapports conservés", "window": "Période", - "day": "24 heures", - "week": "7 jours", - "month": "30 jours", "shared": "Période partagée", "invalid": "Période partagée invalide. Choisissez une période pour continuer.", "loading": "Chargement des rapports conservés…", @@ -403,8 +388,6 @@ "keyMissing": "Aucune clé de déchiffrement n’est configurée pour ce canal.", "scope": "Portée de transport", "allScopes": "Toutes les portées", - "aboutScopes": "À propos des portées", - "scopeHelp": "Les étiquettes décrivent le premier paquet enregistré, pas la clé du canal. D’autres réceptions peuvent différer ; inspectez le paquet pour consulter ses observations. Seuls les messages conservés sont affichés.", "unscoped": "Sans portée de transport", "unknownScope": "Portée non résolue", "unavailableScope": "Portée indisponible", diff --git a/tests/App.observerInvestigation.test.tsx b/tests/App.observerInvestigation.test.tsx index ac0191d..cf258de 100644 --- a/tests/App.observerInvestigation.test.tsx +++ b/tests/App.observerInvestigation.test.tsx @@ -81,7 +81,7 @@ describe("observer investigation return", () => { fireEvent.change(screen.getByLabelText("Route filter"), { target: { value: "roof" } }); screen.getByTestId("route-scroll").scrollTop = 180; click("Route observer"); click("Open dashboard"); - expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 3d"); + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 7d"); expect(window.location.search).toContain("tab=Observers&observer=o1"); expect(screen.getByTestId("origin-url")).toHaveTextContent("route=full-route"); fireEvent.change(screen.getByRole("combobox", { name: "Time range" }), { target: { value: "24h" } }); diff --git a/tests/App.quickObserver.test.tsx b/tests/App.quickObserver.test.tsx index 80b8142..99bc5f5 100644 --- a/tests/App.quickObserver.test.tsx +++ b/tests/App.quickObserver.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { App } from "../src/App"; @@ -16,7 +16,11 @@ vi.mock("../src/features/packets/PacketAnalyzerOverlay", () => ({ PacketAnalyzer vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ PacketAnalyzerDrawer: ({ detail, onViewObserver }: { detail?: { packetHash: string }; onViewObserver: (id: string) => void }) => <>

{detail?.packetHash}

})); -beforeEach(() => { window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); }); +beforeEach(() => { + window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} }); +}); +afterEach(() => vi.unstubAllGlobals()); describe("quick observer investigation", () => { it("closes the quick observer before opening its advert analyzer", () => { diff --git a/tests/App.viewOnMap.test.tsx b/tests/App.viewOnMap.test.tsx new file mode 100644 index 0000000..12e90f3 --- /dev/null +++ b/tests/App.viewOnMap.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { App } from "../src/App"; + +// any on*/subscribe call the Packets tab makes gets a no-op unsubscribe +vi.mock("../src/api/ws-manager", () => ({ + WsManager: class { + constructor() { + return new Proxy(this, { get: (target, key) => (key in target ? target[key as keyof typeof target] : () => () => {}) }); + } + connect() {} + disconnect() {} + updateSubscription() {} + }, +})); +vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }), getScopes: async () => [] })); +vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); +vi.mock("../src/components/AppShell", () => ({ + AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => ( + <> + + + {children} + + ), +})); +vi.mock("../src/features/nodes/NodeTable", () => ({ NodeTable: () => null })); +vi.mock("../src/features/map/MapView", () => ({ MapView: () =>

Map view

})); +vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ + NodeDetailPanel: ({ nodeId, onViewOnMap }: { nodeId: string; onViewOnMap?: (lat: number, lng: number) => void }) => ( +
+ {onViewOnMap && } +
+ ), +})); +vi.mock("../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 4 } } : undefined, isLoading: false }), +})); +vi.mock("../src/features/packets/PacketTable", () => ({ PacketTable: () => null })); +vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ + PacketAnalyzerDrawer: ({ onViewNode }: { onViewNode: (id: string) => void }) => , +})); + +const params = () => new URLSearchParams(window.location.search); + +beforeEach(() => { + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("View on map", () => { + it("frames the node on the Map tab and keeps its panel open there", async () => { + window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); + render(); + fireEvent.click(screen.getByRole("button", { name: "View on map" })); + expect(await screen.findByText("Map view")).toBeInTheDocument(); + expect(params().get("tab")).toBe("Map"); + expect(params().get("lat")).toBe("45.42153"); + expect(params().get("lng")).toBe("-75.69719"); + expect(params().get("zoom")).toBe("14"); + expect(screen.getByRole("region", { name: "Node node-a" })).toBeInTheDocument(); + }); + + it("is not offered while the map is already showing", async () => { + window.history.replaceState({}, "", "/?tab=Map&node=node-a"); + render(); + await screen.findByText("Map view"); + expect(screen.getByRole("region", { name: "Node node-a" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); + + it("drops the one-off map framing when leaving the Map tab", async () => { + window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); + render(); + fireEvent.click(screen.getByRole("button", { name: "View on map" })); + await screen.findByText("Map view"); + fireEvent.click(screen.getByText("Nodes tab")); + expect(params().get("tab")).toBe("Nodes"); + expect(params().has("lat")).toBe(false); + expect(params().has("lng")).toBe(false); + expect(params().has("zoom")).toBe(false); + }); + + it("closes stacked investigation panels and selects that node on the map", async () => { + window.history.replaceState({}, "", "/?tab=Packets&hash=abcd&analyze=1"); + render(); + fireEvent.click(screen.getByText("Hop node")); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "View on map" })); + expect(await screen.findByText("Map view")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Node node-b" })).toBeInTheDocument(); + }); +}); diff --git a/tests/features/channels/MessagePanel.scope.test.tsx b/tests/features/channels/MessagePanel.scope.test.tsx index bed1637..947ed91 100644 --- a/tests/features/channels/MessagePanel.scope.test.tsx +++ b/tests/features/channels/MessagePanel.scope.test.tsx @@ -153,29 +153,29 @@ describe("channel scope evidence", () => { ).toBeInTheDocument(); expect(screen.queryByText("No transport scope")).not.toBeInTheDocument(); }); -}); -it("preserves the newest/live page after more than twenty older pages", async () => { - vi.mocked(getChannelMessagesPage).mockImplementation(async (_id, params) => { - const index = params?.cursor ?? 0; - return page( - [ - { - ...base, - id: 1000 - index, - packetHash: `history-${index}`, - content: `History ${index}`, - sentAt: 1000 - index, - }, - ], - index + 1, - ); + it("preserves the newest/live page after more than twenty older pages", async () => { + vi.mocked(getChannelMessagesPage).mockImplementation(async (_id, params) => { + const index = params?.cursor ?? 0; + return page( + [ + { + ...base, + id: 1000 - index, + packetHash: `history-${index}`, + content: `History ${index}`, + sentAt: 1000 - index, + }, + ], + index + 1, + ); + }); + show(); + await screen.findByText("History 0"); + for (let index = 1; index <= 21; index++) { + fireEvent.click(screen.getByRole("button", { name: /Load older/ })); + await screen.findByText(`History ${index}`); + } + expect(screen.getByText("History 0")).toBeInTheDocument(); }); - show(); - await screen.findByText("History 0"); - for (let index = 1; index <= 21; index++) { - fireEvent.click(screen.getByRole("button", { name: /Load older/ })); - await screen.findByText(`History ${index}`); - } - expect(screen.getByText("History 0")).toBeInTheDocument(); }); diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx index c71685c..f95a023 100644 --- a/tests/features/nodes/NodeDetailPanel.test.tsx +++ b/tests/features/nodes/NodeDetailPanel.test.tsx @@ -125,3 +125,35 @@ describe("NodeDetailPanel clock drift", () => { expect(screen.queryByText(/Clock drift/i)).not.toBeInTheDocument(); }); }); + +describe("NodeDetailPanel View on map", () => { + function renderWithMap(onViewOnMap?: (lat: number, lng: number) => void) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + } + + it("sends the node's coordinates", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); + const onViewOnMap = vi.fn(); + renderWithMap(onViewOnMap); + fireEvent.click(await screen.findByRole("button", { name: "View on map" })); + expect(onViewOnMap).toHaveBeenCalledWith(45.42153, -75.69719); + }); + + it("is not offered for a node without a location", async () => { + renderWithMap(vi.fn()); + await screen.findByText("Self Node"); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); + + it("is not offered when the caller has no map to open", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); + renderWithMap(); + await screen.findByText("Self Node"); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/observers/ObserverComparison.test.tsx b/tests/features/observers/ObserverComparison.test.tsx index 119445d..72b6444 100644 --- a/tests/features/observers/ObserverComparison.test.tsx +++ b/tests/features/observers/ObserverComparison.test.tsx @@ -11,7 +11,7 @@ const A = "11111111-1111-1111-1111-111111111111"; const B = "22222222-2222-2222-2222-222222222222"; const until = Math.floor(Date.now() / 3_600_000) * 3_600_000; const observer = { id: A, displayName: "Roof A", brokers: [], iata: "YOW" } as unknown as Observer; -const activity: ObserverActivity = { range: "72h", interval: "1h", windowStart: until - 259_200_000, windowEnd: until, generatedAt: until, source: "hourly", radio: null, payloadTypes: [], points: [], summary: { recordedPackets: 19, lastCompleteHour: 1, lastCompleteHourStart: until - 3_600_000, lastCompleteHourEnd: until, latestRecordedAt: null } }; +const activity: ObserverActivity = { range: "168h", interval: "1h", windowStart: until - 604_800_000, windowEnd: until, generatedAt: until, source: "hourly", radio: null, payloadTypes: [], points: [], summary: { recordedPackets: 19, lastCompleteHour: 1, lastCompleteHourStart: until - 3_600_000, lastCompleteHourEnd: until, latestRecordedAt: null } }; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ regionKey: "YOW", iatas: ["YOW"] }) })); vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option }: { option: unknown }) => {JSON.stringify(option)} })); vi.mock("../../../src/api/client", async original => ({ @@ -27,7 +27,7 @@ beforeEach(() => { }); function view(id = B, data = activity, anchor: number | null = until) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const props = { observerA: observer, activityA: data, range: "3d" as const, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) }; + const props = { observerA: observer, activityA: data, range: "7d" as const, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) }; const wrapper = ({ children }: { children: React.ReactNode }) => {children}; return { ...render(, { wrapper }), props }; } @@ -35,7 +35,7 @@ it("aligns activity and retained overlap to the primary observer's effective win view(); expect(await screen.findByText("23")).toBeInTheDocument(); expect(screen.getByText("19")).toBeInTheDocument(); - expect(getObserverActivity).toHaveBeenCalledWith(B, "72h", "1h", until); + 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.getByText(/Raw packet history may expire/)).toBeInTheDocument(); diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx index d53089e..d2a0496 100644 --- a/tests/features/observers/ObserverPage.test.tsx +++ b/tests/features/observers/ObserverPage.test.tsx @@ -65,7 +65,7 @@ describe("Observer destination", () => { view(); fireEvent.change(screen.getByLabelText("Directory search"), { target: { value: "roof" } }); fireEvent.click(screen.getByText("Open A")); - expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 3d"); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 7d"); expect(screen.getByRole("status").textContent).toContain("observer=observer-a"); expect(screen.getByRole("status").textContent).toContain("iata=YOW"); fireEvent.click(screen.getByText("Browser back")); @@ -76,11 +76,16 @@ describe("Observer destination", () => { }); it("restores a deep link and has a directory return without prior history", async () => { view("?tab=Observers&observer=observer-b&range=30d"); - expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-b 3d"); - expect(screen.getAllByRole("option").map(option => option.getAttribute("value"))).toEqual(["24h", "3d"]); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-b 30d"); + expect(screen.getAllByRole("option").map(option => option.getAttribute("value"))).toEqual(["24h", "7d", "30d"]); fireEvent.click(screen.getByRole("button", { name: /Back to observers/ })); expect(screen.getByLabelText("Directory search")).toBeVisible(); }); + it("keeps the directory return on desktop, where the sidebar can't deselect", async () => { + view("?tab=Observers&observer=observer-a&range=7d"); + await screen.findByRole("heading"); + expect(screen.getByRole("button", { name: /Back to observers/ }).className).not.toMatch(/\bmd:hidden\b/); + }); }); it("anchors a new comparison at the click time and accepts Refresh across an hour boundary", async () => { @@ -106,6 +111,10 @@ it("rejects duplicate, malformed and future comparison anchors", async () => { const viewResult = view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=bad"); expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); viewResult.unmount(); - view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=" + (Date.now() + 86400000)); + const future = view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=" + (Date.now() + 86400000)); + expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); + future.unmount(); + const hour = Math.floor(Date.now() / 3_600_000) * 3_600_000 - 3_600_000; + view(`?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=${hour}&compareUntil=${hour}`); expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); }); diff --git a/tests/features/observers/ObserverTable.test.tsx b/tests/features/observers/ObserverTable.test.tsx new file mode 100644 index 0000000..7dab0ee --- /dev/null +++ b/tests/features/observers/ObserverTable.test.tsx @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "../../../src/i18n"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { WsManager } from "../../../src/api/ws-manager"; +import { ObserverTable } from "../../../src/features/observers/ObserverTable"; +import { getObserversPage } from "../../../src/api/client"; +import type { ObserverSummary } from "../../../src/features/observers/types"; + +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }) })); +vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] })); +vi.mock("../../../src/hooks/useWsHandlers", () => ({ useWsObserverStatusHandler: () => {} })); +vi.mock("../../../src/api/client", () => ({ + getObserversPage: vi.fn(), + getBrokers: vi.fn(async () => []), +})); + +const online = { id: "a", displayName: "Rooftop", iata: "YOW", status: "online" } as ObserverSummary; +const offline = { id: "b", displayName: "Hilltop", iata: "YOW", status: "offline" } as ObserverSummary; + +function table(compact: boolean) { + return {}} />; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getObserversPage).mockImplementation(async (_iatas, opts) => ({ + items: opts?.status === "offline" ? [offline] : [online, offline], + hasMore: false, + nextCursor: null, + })); +}); + +describe("compact observer sidebar", () => { + it("does not apply directory filters it has no controls for", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { rerender } = render({table(false)}); + fireEvent.click(screen.getByRole("button", { name: /Status/ })); + fireEvent.click(screen.getByText("Offline")); + await waitFor(() => expect(screen.queryByText("Rooftop")).not.toBeInTheDocument()); + + rerender({table(true)}); + expect(await screen.findByText("Rooftop")).toBeInTheDocument(); + expect(screen.getByText("Hilltop")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/observers/observer-navigation.test.ts b/tests/features/observers/observer-navigation.test.ts index 691cb03..de849cb 100644 --- a/tests/features/observers/observer-navigation.test.ts +++ b/tests/features/observers/observer-navigation.test.ts @@ -1,10 +1,36 @@ -import { expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { observerDestination, observerRange } from "../../../src/features/observers/observer-navigation"; -it("normalizes legacy analytics links without losing region",()=>{ - const p=observerDestination(new URLSearchParams("tab=Analytics&statsTab=observer&observerId=abc&range=30d&iata=YOW"),"abc"); - expect(p.get("tab")).toBe("Observers");expect(p.get("observer")).toBe("abc");expect(p.get("range")).toBe("3d");expect(p.get("iata")).toBe("YOW");expect(p.has("observerId")).toBe(false);expect(p.has("statsTab")).toBe(false); + +describe("observerDestination", () => { + it("normalizes legacy analytics links without losing region or range", () => { + const p = observerDestination( + new URLSearchParams("tab=Analytics&statsTab=observer&observerId=abc&range=30d&iata=YOW"), + "abc", + ); + expect(p.get("tab")).toBe("Observers"); + expect(p.get("observer")).toBe("abc"); + expect(p.get("range")).toBe("30d"); + expect(p.get("iata")).toBe("YOW"); + expect(p.has("observerId")).toBe(false); + expect(p.has("statsTab")).toBe(false); + }); + + it("drops unrelated investigation selectors and defaults the range to 7d", () => { + const p = observerDestination(new URLSearchParams("hash=abc&analyze=1&node=n&path=p"), "b"); + expect(p.toString()).toBe("tab=Observers&observer=b&range=7d"); + }); }); -it("drops unrelated investigation selectors and bounds range",()=>{ - const p=observerDestination(new URLSearchParams("hash=abc&analyze=1&node=n&path=p"),"b"); - expect(p.toString()).toBe("tab=Observers&observer=b&range=3d");expect(observerRange("invalid")).toBe("3d"); + +describe("observerRange", () => { + it("accepts the ranges the server can serve", () => { + expect(observerRange("24h")).toBe("24h"); + expect(observerRange("7d")).toBe("7d"); + expect(observerRange("30d")).toBe("30d"); + }); + + it("falls back to 7d for unknown or retired values", () => { + expect(observerRange("invalid")).toBe("7d"); + expect(observerRange("3d")).toBe("7d"); + expect(observerRange(null)).toBe("7d"); + }); }); diff --git a/tests/features/packets/PacketEndpoints.test.tsx b/tests/features/packets/PacketEndpoints.test.tsx index 1c75ded..09ad7c5 100644 --- a/tests/features/packets/PacketEndpoints.test.tsx +++ b/tests/features/packets/PacketEndpoints.test.tsx @@ -102,7 +102,7 @@ describe("PacketEndpoints", () => { const tip = screen.getByRole("tooltip"); expect(within(tip).getByText("Alpha")).toBeInTheDocument(); expect(within(tip).getByText("Beta")).toBeInTheDocument(); - expect(trigger).toHaveAttribute("title", "Alpha, Beta"); + expect(screen.getByText("Alpha +1")).toHaveAttribute("title", "Alpha, Beta"); expect(within(tip).queryByText(/SNR/)).not.toBeInTheDocument(); }); @@ -111,7 +111,7 @@ describe("PacketEndpoints", () => { expect(screen.getAllByRole("button")).toHaveLength(1); const chip = screen.getByText("Alpha +1").parentElement!; expect(chip).not.toHaveAttribute("tabindex"); - expect(chip).toHaveAttribute("title", "Alpha, Beta"); + expect(screen.getByText("Alpha +1")).toHaveAttribute("title", "Alpha, Beta"); }); it("opens all candidates on touch without activating the packet row", () => { diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx index 079e5cd..4aab86c 100644 --- a/tests/features/packets/PacketTableRow.test.tsx +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -123,12 +123,12 @@ describe("PacketTableRow", () => { render( {}} />); expect(screen.getByText("Unknown")).toBeInTheDocument(); }); -}); -it("keeps long route labels inside their dedicated track beside a scope", () => { - render( {}} />); - const route = screen.getByText("TRANSPORT_FLOOD"); - expect(route).toHaveClass("truncate"); - expect(route.parentElement).toHaveClass("min-w-0"); - expect(screen.getByText("#ykf")).toBeInTheDocument(); + it("keeps long route labels inside their dedicated track beside a scope", () => { + render( {}} />); + const route = screen.getByText("TRANSPORT_FLOOD"); + expect(route).toHaveClass("truncate"); + expect(route.parentElement).toHaveClass("min-w-0"); + expect(screen.getByText("#ykf")).toBeInTheDocument(); + }); }); diff --git a/tests/features/packets/PathData.test.tsx b/tests/features/packets/PathData.test.tsx index 283c617..7ddc96c 100644 --- a/tests/features/packets/PathData.test.tsx +++ b/tests/features/packets/PathData.test.tsx @@ -42,6 +42,13 @@ describe("ResolvedHopBlock per-hop SNR", () => { }); }); +describe("ResolvedHopBlock tooltip", () => { + it("relies on its popover rather than a native title", () => { + const { container } = render(); + expect(container.querySelector("[title]")).toBeNull(); + }); +}); + describe("ResolvedHopBlock (desktop)", () => { it("opens the node directly when the single-match block is clicked", () => { setMobile(false); diff --git a/tests/features/routes/RouteEvidencePanel.test.tsx b/tests/features/routes/RouteEvidencePanel.test.tsx index 511558a..94bd737 100644 --- a/tests/features/routes/RouteEvidencePanel.test.tsx +++ b/tests/features/routes/RouteEvidencePanel.test.tsx @@ -26,16 +26,26 @@ function mount(url = "/?tab=Routes") { } beforeEach(() => { vi.clearAllMocks(); vi.mocked(getRouteEvidence).mockResolvedValue(page); }); describe("retained route evidence", () => { - it("normalizes an old month link to 72 hours and offers only retained periods", async () => { + it("keeps a shared month link and offers every period the server serves", async () => { mount("/?routeRange=30d"); await screen.findByText("Garden"); - expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { range: "72h", limit: 50 }, expect.anything()); - expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.queryByRole("button", { name: "7 days" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "30 days" })).not.toBeInTheDocument(); + 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(); }); - it("does not request a shared raw-evidence period longer than three days", async () => { - mount("/?routeSince=1700000000000&routeUntil=1700345600000"); + 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("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("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(); }); @@ -64,9 +74,9 @@ describe("retained route evidence", () => { 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: "3d" })); - await waitFor(() => expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { range: "72h", limit: 50 }, expect.anything())); - expect(screen.getByText(/routeRange=3d/)).not.toHaveTextContent("routeSince"); + 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("does not replace an invalid shared window with an unrelated default", async () => { mount("/?routeSince=bad"); diff --git a/tests/features/stats/CompareObserversTab.test.tsx b/tests/features/stats/CompareObserversTab.test.tsx index c7e29aa..7b0483b 100644 --- a/tests/features/stats/CompareObserversTab.test.tsx +++ b/tests/features/stats/CompareObserversTab.test.tsx @@ -35,7 +35,7 @@ beforeEach(() => { }); describe("observer comparison", () => { - it("rejects a raw packet comparison beyond three days", async () => { + it("accepts a comparison longer than three days", async () => { mount(); await screen.findAllByRole("option", { name: /Rooftop/ }); fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } }); @@ -43,8 +43,8 @@ describe("observer comparison", () => { fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } }); fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-01-05T00:00" } }); fireEvent.click(screen.getByRole("button", { name: "Compare" })); - expect(await screen.findByRole("alert")).toHaveTextContent("3 days or less"); - expect(getObserverComparison).not.toHaveBeenCalled(); + await waitFor(() => expect(getObserverComparison).toHaveBeenCalled()); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); it("waits for Compare, sends explicit dates and region, and puts the selection in the URL", async () => { mount(); diff --git a/tests/features/stats/ObserverTab.test.tsx b/tests/features/stats/ObserverTab.test.tsx index 7006f09..2df538b 100644 --- a/tests/features/stats/ObserverTab.test.tsx +++ b/tests/features/stats/ObserverTab.test.tsx @@ -204,30 +204,30 @@ describe("Observer dashboard hierarchy", () => { expect(screen.getByText("Paquets enregistrés")).toBeInTheDocument(); expect(screen.getByText("Détails de l’appareil")).toBeInTheDocument(); }); -}); -it("keeps packet metrics without the removed traffic text badge", () => { - const now = Date.now(); - observer.brokers = [{ name: "one", lastSeenAt: now, lastPacketAt: now }]; - activityResult.data = { - ...activity, - summary: { - recordedPackets: 9, - lastCompleteHour: 2, - lastCompleteHourStart: now - 7200000, - lastCompleteHourEnd: now - 3600000, - latestRecordedAt: now - 3600000, - }, - }; - renderTab(); - expect(screen.queryByText("Recent packet traffic")).not.toBeInTheDocument(); - expect(within(screen.getByRole("list", { name: "Observer metrics" })).getByText("9")).toBeInTheDocument(); -}); + it("keeps packet metrics without the removed traffic text badge", () => { + const now = Date.now(); + observer.brokers = [{ name: "one", lastSeenAt: now, lastPacketAt: now }]; + activityResult.data = { + ...activity, + summary: { + recordedPackets: 9, + lastCompleteHour: 2, + lastCompleteHourStart: now - 7200000, + lastCompleteHourEnd: now - 3600000, + latestRecordedAt: now - 3600000, + }, + }; + renderTab(); + expect(screen.queryByText("Recent packet traffic")).not.toBeInTheDocument(); + expect(within(screen.getByRole("list", { name: "Observer metrics" })).getByText("9")).toBeInTheDocument(); + }); -it("omits the duplicate observer picker and marked explanatory sections", () => { - renderTab(); - expect(screen.queryByRole("searchbox", { name: "Find an observer" })).not.toBeInTheDocument(); - expect(screen.queryByRole("combobox", { name: "Choose an observer" })).not.toBeInTheDocument(); - expect(screen.queryByText("Exact activity values")).not.toBeInTheDocument(); - expect(screen.queryByText(/History for this observer across all received regions/)).not.toBeInTheDocument(); + it("omits the duplicate observer picker and marked explanatory sections", () => { + renderTab(); + expect(screen.queryByRole("searchbox", { name: "Find an observer" })).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "Choose an observer" })).not.toBeInTheDocument(); + expect(screen.queryByText("Exact activity values")).not.toBeInTheDocument(); + expect(screen.queryByText(/History for this observer across all received regions/)).not.toBeInTheDocument(); + }); }); diff --git a/tests/features/stats/StatsSubHeader.test.tsx b/tests/features/stats/StatsSubHeader.test.tsx index 52e3d8b..c794093 100644 --- a/tests/features/stats/StatsSubHeader.test.tsx +++ b/tests/features/stats/StatsSubHeader.test.tsx @@ -8,27 +8,27 @@ afterEach(() => vi.restoreAllMocks()); describe("translated analytics controls", () => { it("keeps archived analytics ranges without the legacy Observer destination", async () => { const props = { onTabChange: vi.fn(), onRangeChange: vi.fn() }; - const { rerender } = render(); - expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument(); + const { rerender } = render(); + expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "3d" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Observer", exact: true })).not.toBeInTheDocument(); await act(() => i18n.changeLanguage("fr")); - fireEvent.click(screen.getByRole("button", { name: "3 j" })); - expect(props.onRangeChange).toHaveBeenCalledWith("3d"); - rerender(); - expect(screen.queryByRole("button", { name: "7 j" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "7 j" })); + expect(props.onRangeChange).toHaveBeenCalledWith("7d"); + rerender(); + expect(screen.queryByRole("button", { name: "3 j" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "30 j" })).toBeInTheDocument(); }); it("keeps section and range identifiers when labels change", async () => { const onTabChange = vi.fn(), onRangeChange = vi.fn(); - render(); - expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); + render(); + expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); await act(() => i18n.changeLanguage("fr")); expect(screen.getByRole("group", { name: "Section des statistiques" })).toBeInTheDocument(); const range = screen.getByRole("group", { name: "Période" }); - expect(within(range).getByRole("button", { name: "3 j" })).toHaveAttribute("aria-pressed", "true"); + expect(within(range).getByRole("button", { name: "7 j" })).toHaveAttribute("aria-pressed", "true"); expect(onTabChange).not.toHaveBeenCalled(); expect(onRangeChange).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Chemins et hachages" })); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx index 4b65d4d..65b8043 100644 --- a/tests/features/stats/analytics-navigation.test.tsx +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -11,11 +11,14 @@ vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () =>

Mesh c vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Scope charts

})); function Location() { return {useLocation().search}; } -it("normalizes old seven-day links without the removed retention banner", () => { - render(); - expect(screen.getByText("Signal range 3d")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument(); +it("keeps seven-day links and falls back from retired 3d links", () => { + const { unmount } = render(); + expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "3d" })).not.toBeInTheDocument(); + unmount(); + render(); + expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); expect(screen.queryByText(/Hourly analytics keep summaries for up to 30 days/)).not.toBeInTheDocument(); }); @@ -33,8 +36,8 @@ it("opens Signal from a shared URL and retains regional state when changing rang render(); expect(screen.getByText("Signal range 24h")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "RF / Signal" })).toHaveAttribute("aria-pressed", "true"); - fireEvent.click(screen.getByRole("button", { name: "3d" })); - expect(screen.getByText("Signal range 3d")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "7d" })); + expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); }); diff --git a/tests/features/stats/useTelemetry.test.ts b/tests/features/stats/useTelemetry.test.ts index d1c7016..9dd576f 100644 --- a/tests/features/stats/useTelemetry.test.ts +++ b/tests/features/stats/useTelemetry.test.ts @@ -5,7 +5,6 @@ import { ApiError } from "../../../src/api/client"; describe("activityParamsFor", () => { it("pairs each stats range with the bucket the charts are sized for", () => { expect(activityParamsFor("24h")).toEqual({ range: "24h", interval: "15m" }); - expect(activityParamsFor("3d")).toEqual({ range: "72h", interval: "1h" }); expect(activityParamsFor("7d")).toEqual({ range: "168h", interval: "1h" }); expect(activityParamsFor("30d")).toEqual({ range: "720h", interval: "6h" }); });