Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
e65e8e4
fix(packets): expose all endpoint candidates
n30nex Sep 26, 2026
f4092a4
fix: address release review findings for #75
n30nex Sep 30, 2026
e59e78b
feat(observers): open a unified shareable monitoring page
n30nex Sep 27, 2026
f27f13a
fix: address release review findings for #79
n30nex Sep 30, 2026
ab5bc0c
test: cover both close and stacked observer inspection
n30nex Sep 30, 2026
7d9fa69
feat(observers): prioritize activity and readable status cards
n30nex Sep 27, 2026
6852b4f
fix: address release review findings for #80
n30nex Sep 30, 2026
368ba18
fix: use the active locale when formatting observer noise
n30nex Sep 30, 2026
74f98a8
feat(observers): compare aligned dashboard activity and retained overlap
n30nex Sep 27, 2026
48b3ad1
fix: address release review findings for #81
n30nex Sep 30, 2026
4b207fa
fix(observers): refresh only the selected comparison window
n30nex Sep 30, 2026
4717f86
feat(packets): connect retained reports to observer and path investig…
n30nex Sep 27, 2026
dccf69e
fix(packets): keep selected evidence visible in compact investigations
n30nex Sep 27, 2026
5e6bf27
fix: address release review findings for #83
n30nex Sep 30, 2026
327ea06
fix(packets): select a report and open its analyzer atomically
n30nex Sep 30, 2026
9f00e88
feat(routes): connect retained reports to packet and observer inspection
n30nex Sep 27, 2026
93d26d5
fix(routes): keep phone reports visible and within the viewport
n30nex Sep 27, 2026
4030d52
fix: address release review findings for #85
n30nex Sep 30, 2026
ef8de07
fix(routes): remove obsolete region-key binding
n30nex Sep 30, 2026
7a82605
fix(routes): validate fixed windows against their opening time
n30nex Sep 30, 2026
416f076
fix(navigation): preserve observer investigation context and keyboard…
n30nex Sep 27, 2026
c73339b
fix(navigation): preserve observer investigation context and keyboard…
n30nex Sep 27, 2026
ba79a85
fix(navigation): retain analytics context and expose observer chart l…
n30nex Sep 27, 2026
0fe2981
fix(navigation): preserve virtualized list geometry during dashboard …
n30nex Sep 27, 2026
e0c63dc
fix: address release review findings for #87
n30nex Sep 30, 2026
c0c1182
fix(navigation): retain observation callback dependencies
n30nex Sep 30, 2026
cad2003
feat(channels): show and filter recorded transport scopes
n30nex Sep 27, 2026
19bbbd1
fix: address release review findings for #89
n30nex Sep 30, 2026
04d56bc
fix(packets): contain route labels and scope badges
n30nex Sep 28, 2026
fa64057
fix: address release review findings for #92
n30nex Sep 30, 2026
524ba5e
fix(observers): describe partial live activity buckets
n30nex Sep 29, 2026
d7b087a
fix(history): align visible periods with retained data
n30nex Sep 29, 2026
7002923
test(history): reconcile observer fixtures with the unified destination
n30nex Sep 30, 2026
041a000
docs(observers): identify the click-time callback for purity checks
n30nex Sep 30, 2026
c115c08
fix(ui): simplify monitoring and keep observers alongside charts
n30nex Sep 30, 2026
905753d
fix(ui): coalesce sidebar search and refresh imported borders
n30nex Sep 30, 2026
da552cb
fix: type the border cache freshness callback
n30nex Sep 30, 2026
9676bf2
chore: prepare web version 1.3.2
n30nex Sep 30, 2026
3513f17
fix(observers): contain the compact sidebar width
n30nex Sep 30, 2026
fb35d4c
fix(observers): make sidebar selections keyboard accessible
n30nex Sep 30, 2026
c952fbf
perf(observers): skip unused sidebar broker options
n30nex Sep 30, 2026
eb241ca
perf(map): load the path canvas only when requested
n30nex Sep 30, 2026
42c1c08
feat: add My Atlas saved node monitoring cards
n30nex Sep 30, 2026
66ae0cc
refine Atlas copy and post-release navigation placement
n30nex Sep 30, 2026
d1eb973
feat(atlas): add saved node cards on the 2.0 baseline
n30nex Oct 2, 2026
91a9541
Prepare the held My Atlas PR for the upstream 2.0 baseline
n30nex Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# VITE_MAP_ZOOM=3.2

# Hide tabs completely (comma list, case-insensitive). Unset = show all.
# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics
# Options: Packets,MyAtlas,Channels,Map,Nodes,Observers,Routes,Traces,Analytics
# VITE_DISABLED_TABS=Map,Traces

# Reveal normally-hidden themes in the picker (comma list of theme ids). Unset = none.
Expand Down
62 changes: 62 additions & 0 deletions docs/my-atlas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# My Atlas

Open **My Atlas** in the desktop tab row, or **More > My Atlas** on a phone. The
canonical URL is `?tab=MyAtlas`; this opens the visitor's own collection. It does
not publish someone else's saved nodes.

Expand **Add nodes** to search all regions by name or hexadecimal public-key prefix and add up to twelve
nodes. **Manage card** moves or removes a card. The selected nodes, their order
and the 24-hour/3-day activity window are stored in this browser on this origin
under `beacon-my-atlas-v1`. There is no account or cross-device synchronization.
Clearing site data removes the collection. When browser storage is unavailable,
the page explains that edits may only last for the current visit.

Cards use the current full node identity, not an ambiguous short path prefix.
Names refresh from the node record. If an old server ID disappears or points at
a different key, the page searches for the exact full key before loading reports.
An unavailable node stays in the collection and remains removable.

## Reading the cards

- **Received reports** are stored receptions of packets originating from the exact
node key, after server deduplication. The page loads the latest 200 reports per
node, then filters that sample to the selected rolling window. Counts are not
a total of all node traffic. The card flags when more reports are available.
- Each bar covers one hour of the rolling window. Blank bars mean no reports in
the loaded sample; they do not prove silence or packet loss. **Statistics &
chart values** exposes the numeric hourly values and sample definition.
- **Mean SNR / RSSI** average finite, available measurements from that sample.
The zero-RSSI/zero-SNR unavailable sentinel is excluded; a measured zero SNR
with a real RSSI is included. These are last-hop measurements at the observers,
not a measurement of the origin's radio health. Meters include numbers and
accessible values; missing measurements display a dash.
- Freshness uses the server's `stale` verdict. It does not infer online/offline
status from the report count or invent a local threshold.
- **Heard by** lazily opens the reporting observers for the latest loaded packet
in the selected window. It is not lifetime reach or all observers of the node.
Repeated reports from the same observer are grouped. Observer buttons open
inspection; the packet action opens the exact packet and observed paths.
- **Open node** reuses Beacon's node inspection. Statistics also show existing
neighbour count, radio settings, default scope and full public key.

## Cost and compatibility

The page is lazy-loaded and uses native SVG/CSS, without a new chart dependency.
No query runs for an empty collection before a valid search. Search is debounced,
limited to eight results and rejects invalid key input before issuing a request.
Each saved card normally makes two requests (node plus 200-report page) per
minute while the page is active and the document is visible. Error refreshes stop
until Retry or a later visit. A changed server ID can require two extra identity
requests. A single expanded Heard by panel uses the existing packet-detail cache.
Requests consume TanStack Query's abort signal when the page leaves.

No server schema, retention settings, MQTT behavior or authentication changes are
needed. Existing raw-retention limits still apply. Longer-lived analytics remain
separate. English and French labels ship together. Deployers can hide the page
with `VITE_DISABLED_TABS=MyAtlas`, following the existing tab configuration.

The focused tests cover persistence, corrupt/blocked storage, cross-tab changes,
exact-key recovery, rename display, bounded/lazy loading, missing signals,
expired evidence, range selection, order/removal and both languages. Release QA
also checks real Pi data, phone/desktop layout, keyboard controls, reload/Back,
and the matching public source offer.
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type { PacketDetail } from "./types/api";

// Map is the only heavy tab (maplibre-gl is ~1MB), so lazy-load it — its chunk is fetched the
// first time someone opens the Map tab instead of bloating the initial bundle.
const MyAtlasPage = lazy(() => import("./features/atlas/MyAtlasPage").then(m => ({ default: m.MyAtlasPage })));
const MapView = lazy(() => import("./features/map/MapView").then((m) => ({ default: m.MapView })));

// Stats pulls in ECharts (~150-200KB gz), so lazy-load it too — the chunk loads on first visit to Stats.
Expand Down Expand Up @@ -297,6 +298,7 @@ function AppInner() {
}, []);

const tabContent: Record<string, React.ReactNode> = {
MyAtlas: <MyAtlasPage onViewNode={viewNode} onViewObserver={viewObserver} onAnalyzePacket={viewPacket} />,
Packets: (
<PacketList
wsManager={wsManager}
Expand Down
14 changes: 8 additions & 6 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ export function getPackets(
});
}

export function getPacketDetail(packetHash: string): Promise<PacketDetail> {
return request(`/packets/${packetHash}`);
export function getPacketDetail(packetHash: string, signal?: AbortSignal): Promise<PacketDetail> {
return request(`/packets/${packetHash}`, undefined, signal);
}

export function getIatas(): Promise<IataCode[]> {
Expand Down Expand Up @@ -283,6 +283,7 @@ export function getNodesPage(
supportsMultibyteTraces?: "true" | "false";
neighbors?: boolean; // include each node's neighborIds (?neighbors=true)
},
signal?: AbortSignal,
): Promise<CursorPage<NodeSummary>> {
if (noIatas(iatas)) return Promise.resolve(emptyPage());
return request("/nodes", {
Expand All @@ -295,7 +296,7 @@ export function getNodesPage(
supportsMultibytePaths: params?.supportsMultibytePaths,
supportsMultibyteTraces: params?.supportsMultibyteTraces,
neighbors: params?.neighbors ? "true" : undefined,
});
}, signal);
}

// Paginated /observers, mirroring getNodesPage; used by the Observers table.
Expand All @@ -315,18 +316,19 @@ export function getObserversPage(
});
}

export function getNode(nodeId: string): Promise<Node> {
return request(`/nodes/${nodeId}`);
export function getNode(nodeId: string, signal?: AbortSignal): Promise<Node> {
return request(`/nodes/${nodeId}`, undefined, signal);
}

export function getNodeObservations(
nodeId: string,
params?: { cursor?: number; limit?: number },
signal?: AbortSignal,
): Promise<CursorPage<NodeObservation>> {
return request(`/nodes/${nodeId}/observations`, {
cursor: params?.cursor,
limit: params?.limit ?? DEFAULT_PAGE_SIZE,
});
}, signal);
}

export function getNodeNeighbors(nodeId: string): Promise<NodeNeighbor[]> {
Expand Down
9 changes: 4 additions & 5 deletions src/components/BottomNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import { BottomSheet } from "./BottomSheet";
import { ENABLED_TABS } from "../lib/constants";

// Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet.
const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const;
const OVERFLOW_TABS = ["Observers", "Routes", "Traces", "Analytics"] as const;

// inline SVGs, 20px / 1.6 stroke to match the rest of the icons
function Icon({ name }: { name: string }) {
const common = { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
switch (name) {
case "MyAtlas":
return <svg {...common}><circle cx="12" cy="12" r="9" /><path d="m16 8-2.5 5.5L8 16l2.5-5.5L16 8z" /></svg>;
case "Packets":
return (
<svg {...common}>
Expand Down Expand Up @@ -105,9 +105,8 @@ function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activ
export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTabChange: (tab: string) => void }) {
const { t } = useTranslation();
const [sheetOpen, setSheetOpen] = useState(false);
const enabled = new Set<string>(ENABLED_TABS);
const primary: string[] = PRIMARY_TABS.filter((t) => enabled.has(t));
const overflow: string[] = OVERFLOW_TABS.filter((t) => enabled.has(t));
const primary = ENABLED_TABS.slice(0, 4);
const overflow = ENABLED_TABS.slice(4);
const overflowActive = overflow.includes(activeTab);

const pick = (tab: string) => {
Expand Down
113 changes: 113 additions & 0 deletions src/features/atlas/AtlasCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useId } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getPacketDetail } from "../../api/client";
import { Timestamp } from "../../components/Timestamp";
import { useTick } from "../../hooks/useTick";
import { loadAtlasNode, REPORT_LIMIT, summarizeReports, type AtlasPin, type AtlasRange } from "./atlas";

export interface AtlasActions {
onViewNode: (id: string) => void;
onViewObserver: (id: string) => void;
onAnalyzePacket: (hash: string, observationId?: number) => void;
}

function SignalMeter({ label, value, min, max, unit, color }: { label: string; value: number | null; min: number; max: number; unit: string; color: string }) {
const { t, i18n } = useTranslation();
const text = value === null ? t("atlas.unavailable") : `${value.toLocaleString(i18n.language, { maximumFractionDigits: 1 })} ${unit}`;
const level = value === null ? 0 : Math.round(16 * Math.max(0, Math.min(1, (value - min) / (max - min))));
return <div className="min-w-0">
<div className="mb-1 flex flex-col gap-0.5"><span className="text-xs">{label}</span><span className="whitespace-nowrap font-mono text-sm font-semibold text-text-bright">{value === null ? "—" : text}</span></div>
<div role={value === null ? "img" : "meter"} aria-label={`${label}: ${text}`} aria-valuemin={value === null ? undefined : min} aria-valuemax={value === null ? undefined : max} aria-valuenow={value === null ? undefined : Math.max(min, Math.min(max, value))} aria-valuetext={text} className="flex h-4 gap-0.5">
{Array.from({ length: 16 }, (_, index) => <span key={index} aria-hidden="true" className={`flex-1 rounded-[2px] ${index < level ? color : "bg-border-subtle"}`} />)}
</div>
</div>;
}

export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemove, onMove, first, last, onViewNode, onViewObserver, onAnalyzePacket }: AtlasActions & {
pin: AtlasPin; range: AtlasRange; active: boolean; heardOpen: boolean; onHeardToggle: () => void;
onRemove: () => void; onMove: (direction: -1 | 1) => void; first: boolean; last: boolean;
}) {
const { t, i18n } = useTranslation();
const now = useTick(60_000);
const id = useId();
const query = useQuery({ queryKey: ["atlas-node", pin.publicKey], queryFn: ({ signal }) => loadAtlasNode(pin, signal), enabled: active, staleTime: 60_000, retry: false,
refetchInterval: q => q.state.error ? false : 60_000, refetchIntervalInBackground: false });
const node = query.data?.node;
const sample = summarizeReports(query.data?.page.items ?? [], range, now);
const latest = sample.reports[0];
const heard = useQuery({ queryKey: ["packet-detail", latest?.packetHash], queryFn: ({ signal }) => getPacketDetail(latest!.packetHash, signal), enabled: active && heardOpen && !!latest, staleTime: 30_000, retry: false });
const detail = heard.data;
const correctOrigin = !detail || !detail.originPubkey || detail.originPubkey.toLowerCase() === pin.publicKey;
const observers = correctOrigin ? [...new Map([...(detail?.observations ?? [])].sort((a, b) => a.heardAt - b.heardAt).map(row => [row.observerId, row])).values()].sort((a, b) => b.heardAt - a.heardAt) : [];
const name = node?.name || pin.name || pin.publicKey.slice(0, 12).toUpperCase();
const number = (value: number) => value.toLocaleString(i18n.language, { maximumFractionDigits: 1 });
const maximum = Math.max(1, ...sample.bins);
const time = (value: number) => new Date(value).toLocaleString(i18n.language, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
const button = "min-h-11 rounded border border-border bg-bg-raised px-3 text-xs hover:border-primary disabled:opacity-40 disabled:cursor-default";
return <article aria-label={name} className="min-w-0 self-start overflow-hidden rounded-xl border border-border bg-bg-surface shadow-sm">
<div className="h-1 bg-gradient-to-r from-primary via-secondary to-green" />
<div className="space-y-4 p-4">
<header>
<div className="flex items-start justify-between gap-3">
<h2 className="min-w-0 break-words text-base font-semibold text-text-bright"><button type="button" disabled={!node} onClick={() => node && onViewNode(node.id)} className="text-left hover:underline">{name}</button></h2>
<span className="shrink-0 rounded bg-primary/10 px-2 py-0.5 text-[10px] font-mono text-primary">{t(`atlas.roles.${node?.nodeType ?? 0}`, { defaultValue: t("atlas.roles.0") })}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
<span aria-hidden="true" className={`size-2 rounded-full ${node?.stale === false ? "bg-green" : node?.stale ? "bg-warn" : "bg-text-muted"}`} />
<span>{t(node?.stale === false ? "atlas.recent" : node?.stale ? "atlas.stale" : "atlas.freshnessUnknown")}</span>
{node && node.lastSeen > 0 && <><span aria-hidden="true">·</span><Timestamp value={node.lastSeen} /></>}
</div>
</header>
{query.isPending ? <p role="status">{t("common.loading")}</p> : query.isError && <p role="status" className="text-warn">{t(node ? "atlas.refreshFailed" : "atlas.nodeUnavailable")} <button type="button" className="min-h-11 underline" onClick={() => void query.refetch()}>{t("atlas.retry")}</button></p>}
{node && <>
<section aria-label={t("atlas.activity")}>
<div className="flex items-baseline justify-between gap-2"><span className="text-xs">{t("atlas.reports")}</span><strong className="font-mono text-xl text-text-bright">{number(sample.reports.length)}</strong></div>
<svg role="img" aria-label={t("atlas.activityLabel", { count: sample.reports.length, range: t(`atlas.ranges.${range}`) })} viewBox={`0 0 ${sample.bins.length * 5} 40`} preserveAspectRatio="none" className="mt-2 h-14 w-full text-primary">
{sample.bins.map((count, index) => <rect key={index} x={index * 5} y={40 - Math.max(1, count / maximum * 38)} width="3.5" height={Math.max(1, count / maximum * 38)} rx="0.6" fill={count ? "currentColor" : "var(--color-border)"}><title>{time(sample.since + index * 3_600_000)}: {count}</title></rect>)}
</svg>
<div className="mt-1 flex justify-between text-[11px] text-text-muted"><span>{t(`atlas.ranges.${range}`)}</span><span>{t("atlas.now")}</span></div>
<p className="mt-1 text-[11px] text-text-normal">{t("atlas.sample", { limit: REPORT_LIMIT })}</p>
</section>
<div className="grid grid-cols-2 gap-4">
<SignalMeter label={t("atlas.snr")} value={sample.snr} min={-30} max={30} unit="dB" color="bg-green" />
<SignalMeter label={t("atlas.rssi")} value={sample.rssi} min={-140} max={0} unit="dBm" color="bg-secondary" />
</div>
{!sample.reports.length && <p className="text-xs">{t("atlas.noReports")}</p>}
<div className="grid grid-cols-2 gap-2">
<button type="button" className={button} aria-expanded={heardOpen} aria-controls={`${id}-heard`} onClick={onHeardToggle}>{t("atlas.heardBy")} <span aria-hidden="true">{heardOpen ? "−" : "+"}</span></button>
<button type="button" className={button} onClick={() => onViewNode(node.id)}>{t("atlas.openNode")} <span aria-hidden="true">↗</span></button>
</div>
{heardOpen && <section id={`${id}-heard`} aria-label={t("atlas.heardBy")} className="rounded-lg border border-border p-3 text-xs">
<p className="mb-2 text-text-bright">{t("atlas.latestAdvert")}</p>
{!latest ? <p>{t("atlas.noReports")}</p> : heard.isPending ? <p role="status">{t("common.loading")}</p> : heard.isError || !correctOrigin ? <p>{t("atlas.heardUnavailable")}</p> : <>
<p className="mb-2">{t("atlas.observers", { count: observers.length })}</p>
<ul className="max-h-44 overflow-y-auto">{observers.map(row => <li key={row.observerId} className="flex items-center justify-between gap-2 border-t border-border-subtle"><button type="button" className="min-h-11 min-w-0 break-words text-left text-primary hover:underline" onClick={() => onViewObserver(row.observerId)}>{row.observerName || row.observerId.slice(0, 8)}</button><span className="shrink-0 font-mono">{row.iata}</span></li>)}</ul>
<button type="button" className="mt-2 min-h-11 text-primary underline" onClick={() => onAnalyzePacket(latest.packetHash, latest.id)}>{t("atlas.openAdvert")}</button>
</>}
</section>}
<details className="text-xs">
<summary className="min-h-8 cursor-pointer content-center text-text-normal">{t("atlas.statistics")}</summary>
<div className="space-y-3 pt-2">
<p>{t("atlas.definition")}</p>
<p>{t("atlas.signalDefinition")}</p>
{query.data?.page.hasMore && <p className="text-warn">{t("atlas.capped")}</p>}
<dl className="grid grid-cols-2 gap-2">
<dt>{t("atlas.hops")}</dt><dd className="text-right font-mono">{sample.hops === null ? "—" : number(sample.hops)}</dd>
<dt>{t("atlas.neighbors")}</dt><dd className="text-right font-mono">{number(node.knownNeighborCount)}</dd>
<dt>{t("atlas.radio")}</dt><dd className="break-words text-right">{node.radio || "—"}</dd>
<dt>{t("atlas.scope")}</dt><dd className="break-words text-right">{node.defaultScope || "—"}</dd>
</dl>
<p className="break-all font-mono text-[10px]">{node.publicKey}</p>
<details><summary className="min-h-8 cursor-pointer content-center">{t("atlas.chartValues")}</summary><div className="max-h-40 overflow-auto"><table className="w-full"><thead><tr><th className="text-left">{t("atlas.hour")}</th><th className="text-right">{t("atlas.reports")}</th></tr></thead><tbody>{sample.bins.map((count, index) => <tr key={index}><td>{time(sample.since + index * 3_600_000)}</td><td className="text-right">{count}</td></tr>)}</tbody></table></div></details>
<p>{t("atlas.updated")} <Timestamp value={query.dataUpdatedAt} /></p>
</div>
</details>
</>}
<details className="border-t border-border-subtle pt-2 text-xs">
<summary className="min-h-8 cursor-pointer content-center">{t("atlas.manage")}</summary>
<div className="mt-2 flex flex-wrap gap-2"><button type="button" className={button} disabled={first} onClick={() => onMove(-1)}>{t("atlas.earlier")}</button><button type="button" className={button} disabled={last} onClick={() => onMove(1)}>{t("atlas.later")}</button><button type="button" className={`${button} text-danger`} onClick={onRemove}>{t("atlas.remove")}</button></div>
</details>
</div>
</article>;
}
Loading
Loading