Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 .build/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ set -e
# Runtime config lives in /config.js (served no-cache), not the hashed bundle, so env changes take
# effect on any restart and reach returning visitors. Empty values fall back to the app defaults.
OUT="${BEACON_CONFIG_OUT:-/srv/config.js}"
KEYS="VITE_API_BASE VITE_WS_URL VITE_MAP_CENTER VITE_MAP_ZOOM VITE_DISABLED_TABS VITE_ENABLED_THEMES VITE_APP_NAME VITE_SKIP_SPLASH VITE_BANNER"
KEYS="VITE_API_BASE VITE_WS_URL VITE_MAP_CENTER VITE_MAP_ZOOM VITE_DISABLED_TABS VITE_ENABLED_THEMES VITE_APP_NAME VITE_SKIP_SPLASH VITE_BANNER VITE_CHANGELOG_URL"

# Values are read from ENVIRON and JSON-escaped, so any character is safe.
awk -v keys="$KEYS" 'BEGIN {
Expand Down
5 changes: 4 additions & 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,Topology,Channels,Map,Nodes,Observers,Routes,Traces,Analytics
# VITE_DISABLED_TABS=Map,Traces

# Only offer these themes in the picker (comma list of theme ids). Unset = the default set.
Expand All @@ -28,3 +28,6 @@

# Notice shown above the header on every page; [label](url) and bare URLs become links. Unset = none.
# VITE_BANNER=This is the Beacon development instance and may be unstable. For everyday use, visit [live.meshcore.ca](https://live.meshcore.ca).

# Optional header link to this deployment's changelog/build details. Unset = hidden.
# VITE_CHANGELOG_URL=/beacon-dev/source.html
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: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</head>
<body>
<div id="root"></div>
<script src="/config.js"></script>
<script src="%BASE_URL%config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "beacon-web",
"private": true,
"version": "2.0.2",
"version": "2.2.2-n30nex.1",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
Expand Down
18 changes: 15 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ 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 NodePage = lazy(() => import("./features/nodes/NodePage").then(m => ({ default: m.NodePage })));
const MyAtlasPage = lazy(() => import("./features/atlas/MyAtlasPage").then(m => ({ default: m.MyAtlasPage })));
const TopologyPage = lazy(() => import("./features/topology/TopologyPage").then(m => ({ default: m.TopologyPage })));
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 @@ -87,13 +90,19 @@ function RegionWatcher({ wsManager: mgr }: { wsManager: WsManager }) {
// wouldn't otherwise know was shareable). All-regions clears both params; any legacy ?region is folded
// in. The guard skips redundant writes (and any setSearchParams feedback loop); replace keeps it out of
// history.
function RegionUrlSync() {
export function RegionUrlSync() {
const { selection } = useRegionSelection();
const previousSelection = useRef(selection);
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();

useEffect(() => {
const next = selectionToParams(selection, searchParams);
if (previousSelection.current !== selection) {
next.delete("topoNode");
next.delete("topoFocus");
}
previousSelection.current = selection;
if (next.toString() === searchParams.toString()) return;
setSearchParams(next, { replace: true, state: location.state });
}, [selection, searchParams, setSearchParams, location.state]);
Expand Down Expand Up @@ -221,7 +230,8 @@ function AppInner() {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", tab);
if (tab !== "Routes") for (const key of ["route", "routeIata"]) next.delete(key);
if (tab !== "Nodes") { next.delete("nodePage"); next.delete("nodeRange"); }
if (tab !== "Routes") for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil", "routeHashSize", "routePathBytes"]) 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
Expand Down Expand Up @@ -301,6 +311,8 @@ function AppInner() {
}, []);

const tabContent: Record<string, React.ReactNode> = {
MyAtlas: <MyAtlasPage onViewNode={viewNode} onViewObserver={viewObserver} onAnalyzePacket={viewPacket} />,
Topology: <TopologyPage wsManager={wsManager} onViewNode={viewNode} onViewObserver={viewObserver} onAnalyzePacket={viewPacket} />,
Packets: (
<PacketList
wsManager={wsManager}
Expand All @@ -310,7 +322,7 @@ function AppInner() {
onSelectObservation={setSelectedObservationId}
/>
),
Nodes: <NodeTable wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
Nodes: searchParams.get("nodePage") ? <NodePage nodeId={searchParams.get("nodePage")!} onViewNode={viewNode} onViewObserver={viewObserver} onAnalyzePacket={viewPacket} /> : <NodeTable wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
Observers: <ObserverPage wsManager={wsManager} onAnalyzePacket={handleAnalyze} />,
Routes: <RouteTable onAnalyzePacket={viewPacket} onViewObserver={viewObserver} onViewNode={viewNode} />,
// analyze opens the packet overlay (modal) rather than the side drawer, which suits the
Expand Down
54 changes: 46 additions & 8 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ async function request<T>(path: string, params?: Record<string, string | number

// endpoint functions

export interface CollectedNodeSample {
nodeKey: string;
collectorKey: string;
radioKey: string;
receivedAt: number;
intervalHours?: number;
values?: Record<string, number> | null;
sensors?: { channel: number; kind: string; value: number; unit: string }[] | null;
}

export async function getCollectedNodeTelemetry(key: string, signal?: AbortSignal): Promise<{ items: CollectedNodeSample[]; limit: number }> {
try { return await request(`/node-telemetry/${encodeURIComponent(key)}`, undefined, signal); }
catch (error) { if (isNotFound(error)) return { items: [], limit: 500 }; throw error; }
}

export function getObserverComparison(
iatas: StatsRegion,
params: { observerA: string; observerB: string; since: number; until: number },
Expand Down Expand Up @@ -109,8 +124,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 @@ -199,6 +214,7 @@ export interface RouteCursor {

export async function getKnownRoutesPage(
params?: { iata?: string; hopCount?: number; cursor?: RouteCursor; limit?: number },
signal?: AbortSignal,
): Promise<CursorPage<KnownRoute, RouteCursor>> {
const limit = params?.limit ?? DEFAULT_PAGE_SIZE;
const items = await request<KnownRoute[]>("/routes", {
Expand All @@ -207,11 +223,11 @@ export async function getKnownRoutesPage(
cursor: params?.cursor?.lastSeen,
cursorId: params?.cursor?.id,
limit,
});
}, signal);
return toCursorPage(items, limit, (r) => ({ lastSeen: r.lastSeen, id: r.id }));
}

export function getRouteEvidence(iata: string, pathKey: string, params: { range?: string; since?: number; until?: number; pageCursor?: string; limit?: number }, signal?: AbortSignal): Promise<RouteEvidence> {
export function getRouteEvidence(iata: string, pathKey: string, params: { range?: string; since?: number; until?: number; pageCursor?: string; limit?: number; hashSize?: number; pathBytes?: string }, signal?: AbortSignal): Promise<RouteEvidence> {
return request(`/routes/${encodeURIComponent(iata)}/${encodeURIComponent(pathKey)}/observations`, params, signal);
}

Expand Down Expand Up @@ -289,6 +305,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 @@ -301,7 +318,7 @@ export function getNodesPage(
supportsMultibytePaths: params?.supportsMultibytePaths,
supportsMultibyteTraces: params?.supportsMultibyteTraces,
neighbors: params?.neighbors ? "true" : undefined,
});
}, signal);
}

export interface ObserverDirectoryRequest {
Expand Down Expand Up @@ -361,18 +378,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 Expand Up @@ -456,3 +474,23 @@ export function isNotFound(err: unknown): boolean {
}

export { ApiError };

// Public cached MeshMapper metadata; counts are regional, never per-link evidence.
export interface ScopeCatalogue {
iata: string;
url: string;
generatedAt: number;
checkedAt: number;
freshUntil: number;
lastError?: string;
repeaters: number;
scoped: number;
scopes: { name: string; repeaters: number; default: number; monitored: boolean; wardriving: boolean }[];
}
export function getScopeCatalogues(): Promise<ScopeCatalogue[]> {
return request("/scope-catalogues");
}

export function getTopologyLinks(iatas: string[] | undefined, window: string, signal?: AbortSignal): Promise<{ links: [string,string][]; capped: boolean; since: number; until: number }> {
return request("/routes/topology", { iatas: iatas?.join(","), window }, signal);
}
26 changes: 21 additions & 5 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useIsMobile } from "../hooks/useMediaQuery";
import { BeaconWordmark } from "./BeaconWordmark";
import { getIatas } from "../api/client";
import { ENABLED_TABS, ENABLED_THEME_IDS, selectableThemes, APP_NAME, GITHUB_URL, BANNER } from "../lib/constants";
import { runtimeEnv } from "../lib/runtime-env";
import { InstanceBanner } from "./InstanceBanner";
import type { WsManager } from "../api/ws-manager";

Expand All @@ -39,12 +40,12 @@ function LiveBadge({ wsManager, compact = false }: { wsManager: WsManager; compa
if (status === "connected") {
if (compact) return (
<span role="status" aria-label={t("connection.live")} title={t("connection.live")} className="flex h-6 w-6 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-green animate-pulse" />
<span className="h-2 w-2 rounded-full bg-green animate-pulse beacon-live-dot" />
</span>
);
return (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-green bg-green/8 border border-green/15 px-2 py-0.5 rounded-sm">
<span className="w-1.5 h-1.5 rounded-full bg-green animate-pulse" />
<span className="w-1.5 h-1.5 rounded-full bg-green animate-pulse beacon-live-dot" />
{t("connection.live")}
</div>
);
Expand Down Expand Up @@ -132,11 +133,11 @@ function RegionSelector() {
renderTrigger={({ toggle }) => (
<button
type="button"
className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-3 py-1 text-text-bright font-mono text-xs font-semibold hover:border-text-dim/30 transition-colors"
className="flex min-w-0 max-w-full items-center gap-1.5 bg-bg-raised border border-border rounded px-3 py-1 text-text-bright font-mono text-xs font-semibold hover:border-text-dim/30 transition-colors"
onClick={toggle}
>
<span className="text-text-muted font-normal text-[11px] uppercase">{t("region.label")}</span>
{regionSummaryLabel(selection, t)}
<span className="sr-only text-text-muted font-normal text-[11px] uppercase sm:not-sr-only sm:shrink-0">{t("region.label")}</span>
<span className="min-w-0 truncate">{regionSummaryLabel(selection, t)}</span>
<span className="text-text-dim text-[11px]">▾</span>
</button>
)}
Expand Down Expand Up @@ -380,6 +381,19 @@ function GitHubLink() {
);
}

function ChangelogLink({ compact = false }: { compact?: boolean }) {
const { t } = useTranslation();
const href = runtimeEnv("VITE_CHANGELOG_URL", import.meta.env.VITE_CHANGELOG_URL)?.trim();
if (!href) return null;
try {
if (!["http:", "https:"].includes(new URL(href, window.location.href).protocol)) return null;
} catch { return null; }
return <a href={href} target="_blank" rel="noopener noreferrer" aria-label={t("header.changelog")} title={t("header.changelog")}
className={`flex shrink-0 items-center justify-center rounded border border-border bg-bg-raised text-text-muted hover:text-text-bright ${compact ? "h-7 w-7" : "min-h-7 px-2 font-mono text-[11px]"}`}>
{compact ? <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true"><path d="M3 1.5h7l3 3v10H3zM10 1.5v3h3M5 7h6M5 9.5h6M5 12h4" /></svg> : t("header.changelog")}
</a>;
}

const MENU_GROUP = "px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-wide text-text-dim";

// Phones fold theme, language and the repo link into one menu so the header stays a single row.
Expand Down Expand Up @@ -441,6 +455,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<RateLimitBadge />
<ChangelogLink compact />
<LiveBadge wsManager={wsManager} compact />
<SettingsMenu />
</div>
Expand All @@ -456,6 +471,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe
<div className="flex items-center gap-3">
<LiveBadge wsManager={wsManager} />
<RateLimitBadge />
<ChangelogLink />
<GitHubLink />
</div>
</header>
Expand Down
Loading
Loading