Skip to content
Merged
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
7 changes: 6 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Build and Publish Docker Image

on:
push:
branches: [main]
branches: [main, dev]
tags: ["v*"]

env:
Expand Down Expand Up @@ -33,8 +33,13 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
labels: |
org.opencontainers.image.title=beacon-web
org.opencontainers.image.description=Beacon Web — real-time LoRa mesh packet analyzer
org.opencontainers.image.licenses=AGPL-3.0-or-later
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
Expand Down
15 changes: 6 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,17 @@ EOF
| `VITE_API_BASE` | Backend REST API base URL |
| `VITE_WS_URL` | Backend WebSocket URL |

### 3. Authenticate with GitHub Container Registry

```bash
docker login ghcr.io -u YOUR_GITHUB_USERNAME
```

Use a Personal Access Token (classic) with `read:packages` scope as the password. You only need to do this once.

### 4. Start the services
### 3. Start the services

```bash
docker compose up -d
```

The images are public on GitHub Container Registry — no `docker login` required.
If a pull fails with `403 Forbidden`, the package visibility has regressed to
Private; a maintainer needs to set it back to Public (see the troubleshooting note
in [beacon-docs](https://github.com/MeshCore-Beacon/beacon-docs)).

Caddy will automatically obtain a TLS certificate for your domain. Ensure DNS is pointed at your server before starting.

## Local Development
Expand Down
37 changes: 21 additions & 16 deletions src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants";
import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, KnownRoute, CrossIATARoute, TraceTagSummary, TraceDetail } from "../types/api";
import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api";
import type { ChannelSummary, ChannelMessage } from "../features/channels/types";
import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types";
import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types";
Expand All @@ -12,6 +12,7 @@ import type {
RadioPreset,
ScopeStats,
ObserverTelemetry,
NodeTypeCount,
} from "../features/stats/types";

// typed fetch wrapper with query params
Expand Down Expand Up @@ -165,11 +166,12 @@ export function searchCrossIATARoutes(
// the last item's lastHeardAt); /traces/{tag} returns the tag's packets with resolved routes.
export function getTraces(
iatas: string[] | undefined,
params?: { scope?: string; since?: number; until?: number; cursor?: number; limit?: number },
params?: { scope?: string; type?: TraceType; since?: number; until?: number; cursor?: number; limit?: number },
): Promise<TraceTagSummary[]> {
return request("/traces", {
iatas: iatasParam(iatas),
scope: params?.scope,
type: params?.type, // TRACE or PING; omitted = both (request() drops undefined params)
since: params?.since,
until: params?.until,
cursor: params?.cursor,
Expand Down Expand Up @@ -253,31 +255,34 @@ export function getNodeNeighbors(nodeId: string): Promise<NodeNeighbor[]> {
return request(`/nodes/${nodeId}/neighbors`);
}

// stats endpoints. `iata` is a single code (undefined = all regions); the /stats/* endpoints filter
// by one IATA only, unlike the comma-separated `iatas` used elsewhere.
// stats endpoints

export function getStatsOverview(iata?: string): Promise<StatsOverview> {
return request("/stats/overview", { iata });
export function getStatsOverview(iatas?: string[]): Promise<StatsOverview> {
return request("/stats/overview", { iatas: iatasParam(iatas) });
}

export function getStatsObservations(iata?: string, since?: number): Promise<ObservationPoint[]> {
return request("/stats/observations", { iata, since });
export function getStatsObservations(iatas?: string[], since?: number): Promise<ObservationPoint[]> {
return request("/stats/observations", { iatas: iatasParam(iatas), since });
}

export function getPayloadBreakdown(iata?: string, since?: number): Promise<PayloadBreakdownItem[]> {
return request("/stats/payload-breakdown", { iata, since });
export function getPayloadBreakdown(iatas?: string[], since?: number): Promise<PayloadBreakdownItem[]> {
return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since });
}

export function getTopNodes(iata?: string, limit = 10): Promise<TopNode[]> {
return request("/stats/top-nodes", { iata, limit });
export function getTopNodes(iatas?: string[], limit = 10): Promise<TopNode[]> {
return request("/stats/top-nodes", { iatas: iatasParam(iatas), limit });
}

export function getTopObservers(iata?: string, since?: number, limit = 10): Promise<TopObserver[]> {
return request("/stats/top-observers", { iata, since, limit });
export function getTopObservers(iatas?: string[], since?: number, limit = 10): Promise<TopObserver[]> {
return request("/stats/top-observers", { iatas: iatasParam(iatas), since, limit });
}

export function getRadioPresets(iata?: string): Promise<RadioPreset[]> {
return request("/stats/radio-presets", { iata });
export function getRadioPresets(iatas?: string[]): Promise<RadioPreset[]> {
return request("/stats/radio-presets", { iatas: iatasParam(iatas) });
}

export function getStatsNodeTypes(iatas?: string[]): Promise<NodeTypeCount[]> {
return request("/stats/node-types", { iatas: iatasParam(iatas) });
}

// renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes
Expand Down
8 changes: 5 additions & 3 deletions src/features/nodes/NodeDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClic
<IataChip>{neighbor.iata}</IataChip>
<Timestamp value={neighbor.lastSeen} className="text-text-dim ml-auto font-mono text-[11px]" />
</div>
<div className="font-mono text-[11px] text-text-muted mt-1">
{neighbor.observationCount.toLocaleString()} obs
<div className="font-mono text-[11px] text-text-muted mt-1 flex items-center gap-2">
<span className="truncate" title={neighbor.publicKey}>{neighbor.publicKey}</span>
<span className="shrink-0 text-text-dim">·</span>
<span className="shrink-0">{neighbor.observationCount.toLocaleString()} obs</span>
</div>
</div>
);
Expand Down Expand Up @@ -155,7 +157,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
</div>
</Section>

<Section title="Neighbors">
<Section title={node.knownNeighborCount > 0 ? `Neighbors (${node.knownNeighborCount})` : "Neighbors"}>
{neighbors && neighbors.length > 0 ? (
<div className="flex flex-col gap-1.5">
{neighbors.map((n) => (
Expand Down
7 changes: 7 additions & 0 deletions src/features/nodes/NodeTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ const COLUMNS: Column<NodeSummary>[] = [
<span className="text-text-dim">—</span>
),
},
{
header: "Neighbors",
className: "text-text-muted",
sortValue: (node) => node.knownNeighborCount,
cell: (node) => node.knownNeighborCount.toLocaleString(),
},
{
header: "Location",
className: "text-text-muted",
Expand Down Expand Up @@ -105,6 +111,7 @@ function renderNodeCard(node: NodeSummary) {
<div className="flex items-center gap-2 text-text-muted">
<span>{formatRadio(node.radio) ?? "—"}</span>
{location && <span>· {location}</span>}
{node.knownNeighborCount > 0 && <span>· {node.knownNeighborCount.toLocaleString()} neighbors</span>}
</div>
{node.iatas && node.iatas.length > 0 && (
<div className="flex flex-wrap gap-1">
Expand Down
3 changes: 3 additions & 0 deletions src/features/nodes/node-updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export function upsertNodePages(
radio: data.radio,
defaultScope: data.defaultScope,
iatas: data.iatas,
// the nodeUpdate event rides on an advert and doesn't carry a neighbor count; a node we're
// meeting for the first time has none resolved yet, so start at 0 until a reload fills it in
knownNeighborCount: 0,
isObserver: data.isObserver,
};
const pages = [...old.pages];
Expand Down
2 changes: 2 additions & 0 deletions src/features/nodes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface NodeSummary {
radio?: string; // compact "freq,bw,sf" string, e.g. "915.0,250,11"; absent when unknown
defaultScope?: string; // most recently matched transport scope name, e.g. "#bc"
iatas: NodeIATA[];
knownNeighborCount: number; // distinct first-hop neighbors we've resolved for this node
// Set when this node also runs as an observer (watches traffic for uplink). isObserver drives the
// map's observer-pip marker variant; observerId, when present, links to that observer's detail.
isObserver?: boolean;
Expand All @@ -35,6 +36,7 @@ export interface Node extends NodeSummary {
export interface NodeNeighbor {
id: string;
name?: string;
publicKey: string; // hex-encoded prefix
nodeType: number;
nodeTypeName: string;
lat?: number;
Expand Down
49 changes: 29 additions & 20 deletions src/features/stats/MeshTab.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useMemo } from "react";
import { formatCount } from "../../lib/formatters";
import { useChartColors, type ChartColors } from "./chartTheme";
import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes } from "./useStats";
import { observationsAreaOption, leaderboardOption, typeBarOption } from "./chartOptions";
import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats";
import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions";
import { Card, ChartCard, StatCard } from "./cards";
import { useLiveOverview } from "./useLiveStats";
import { aggregatePresets, formatPreset } from "./transforms";
Expand Down Expand Up @@ -49,6 +49,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
const topObservers = useTopObservers(range, 8);
const radioPresets = useRadioPresets();
const scopes = useScopes();
const nodeTypes = useNodeTypes();

const obs = useMemo(() => aggregateByHour(observations.data ?? []), [observations.data]);
const obsOption = useMemo(() => observationsAreaOption(obs, colors), [obs, colors]);
Expand Down Expand Up @@ -90,11 +91,21 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
[observerIds, onSelectObserver],
);

const typeRows = useMemo(
() =>
[...(nodeTypes.data ?? [])]
.sort((a, b) => b.count - a.count)
.map((t) => ({ name: t.nodeTypeName, value: t.count, color: nodeTypeColor(t.nodeTypeName, colors) })),
[nodeTypes.data, colors],
);
const typeTotal = useMemo(() => typeRows.reduce((a, t) => a + t.value, 0), [typeRows]);
const typesOption = useMemo(() => donutOption(typeRows, colors, formatCount(typeTotal), "NODES"), [typeRows, colors, typeTotal]);

const presetRows = useMemo(
() => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), value: r.value, color: colors.primary })),
[radioPresets.data, colors],
() => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })),
[radioPresets.data],
);
const presetsOption = useMemo(() => leaderboardOption(presetRows, colors, 150), [presetRows, colors]);
const presetsOption = useMemo(() => presetBarsOption(presetRows, colors), [presetRows, colors]);

const scopeRows = useMemo(
() => [...(scopes.data ?? [])].sort((a, b) => b.packetCount - a.packetCount),
Expand All @@ -106,14 +117,16 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {

const ov = overview.data;
const kpiLoading = overview.isLoading;
// top-row KPIs are the overview endpoint's fixed 24h snapshot; range only drives the charts below
const ovWindow = `${ov?.windowHours ?? 24}h`;

return (
<div className="mx-auto flex max-w-[1100px] flex-col gap-3.5 px-4 py-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Total packets" sublabel="24h" accent="var(--color-primary)" value={kpiLoading ? "—" : formatCount(ov?.totalPackets)} />
<StatCard label="Observations" sublabel="24h" accent="var(--color-green)" value={kpiLoading ? "—" : formatCount(ov?.totalObservations)} spark={obsSpark} />
<StatCard label="Active observers" sublabel="24h" accent="var(--color-secondary)" value={kpiLoading ? "—" : (ov?.activeObservers ?? "—")} spark={observerSpark} />
<StatCard label="Active IATAs" sublabel="24h" accent="var(--color-warn)" value={kpiLoading ? "—" : (ov?.activeIatas ?? "—")} />
<StatCard label="Total packets" sublabel={ovWindow} accent="var(--color-primary)" value={kpiLoading ? "—" : formatCount(ov?.totalPackets)} />
<StatCard label="Observations" sublabel={ovWindow} accent="var(--color-green)" value={kpiLoading ? "—" : formatCount(ov?.totalObservations)} spark={obsSpark} />
<StatCard label="Active observers" sublabel={ovWindow} accent="var(--color-secondary)" value={kpiLoading ? "—" : (ov?.activeObservers ?? "—")} spark={observerSpark} />
<StatCard label="Active IATAs" sublabel={ovWindow} accent="var(--color-warn)" value={kpiLoading ? "—" : (ov?.activeIatas ?? "—")} />
</div>

<ChartCard
Expand All @@ -126,7 +139,8 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
/>

<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Top nodes" height={208} option={nodesOption} isLoading={topNodes.isLoading} isError={topNodes.isError} isEmpty={nodeRows.length === 0} />
{/* range-driven charts lead the grid; the all-time ones follow below */}
<ChartCard title={<>Top observers · {range}</>} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} />
<ChartCard
title={<>Payload types · {range}</>}
right={<span className="font-mono text-[10px] text-text-muted">{formatCount(payloadTotal)} obs</span>}
Expand All @@ -136,17 +150,12 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
isError={payload.isError}
isEmpty={payloadItems.length === 0}
/>
<ChartCard title={<>Top observers · {range}</>} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} />
{/* needs a /stats/node-types endpoint (ticket filed) — the old donut counted types among
the top-10 nodes only, which read as the region's whole population */}
<Card title="Node types">
<div className="flex h-[208px] items-center justify-center font-mono text-[11px] text-text-dim">
Coming soon
</div>
</Card>
<ChartCard title="Radio presets" height={208} option={presetsOption} isLoading={radioPresets.isLoading} isError={radioPresets.isError} isEmpty={presetRows.length === 0} />
{/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */}
<ChartCard title="Top nodes · all time" height={208} option={nodesOption} isLoading={topNodes.isLoading} isError={topNodes.isError} isEmpty={nodeRows.length === 0} />
<ChartCard title="Node types · all time" height={208} option={typesOption} isLoading={nodeTypes.isLoading} isError={nodeTypes.isError} isEmpty={typeRows.length === 0} />
<ChartCard title="Radio presets · all time" height={208} option={presetsOption} isLoading={radioPresets.isLoading} isError={radioPresets.isError} isEmpty={presetRows.length === 0} />

<Card title={<>Scopes · all regions</>}>
<Card title={<>Scopes · all regions · all time</>}>
{scopes.isError ? (
<div className="py-4 text-center font-mono text-[11px] text-text-dim">Failed to load</div>
) : scopes.isLoading ? (
Expand Down
6 changes: 4 additions & 2 deletions src/features/stats/ObserverTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,13 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan
}, [selectedObserverId, topObservers.data, onSelectObserver]);

const points = useMemo(() => telemetry.data?.points ?? [], [telemetry.data]);
const airtime = useMemo(() => airtimeOption(points, colors), [points, colors]);
// use the response's interval, not the range prop — keepPreviousData can briefly show the old range's points
const bucketed = telemetry.data != null && telemetry.data.interval !== "1h";
const airtime = useMemo(() => airtimeOption(points, colors, bucketed), [points, colors, bucketed]);
const battery = useMemo(() => batteryOption(points, colors), [points, colors]);
const noise = useMemo(() => noiseFloorOption(points, colors), [points, colors]);
const queue = useMemo(() => queueOption(points, colors), [points, colors]);
const recvErrors = useMemo(() => receiveErrorsOption(points, colors), [points, colors]);
const recvErrors = useMemo(() => receiveErrorsOption(points, colors, bucketed), [points, colors, bucketed]);

// Bots / MQTT bridges report status but no device telemetry — show one clear empty state rather
// than five flat-zero charts. When some telemetry exists, gate each chart on its own metric.
Expand Down
4 changes: 2 additions & 2 deletions src/features/stats/StatsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface StatsOverviewProps {
wsManager: WsManager;
}

// Stats page shell: a sub-header bar (Mesh / Observer pills + range + live dot) over the active
// Stats page shell: a sub-header bar (Mesh / Observer pills + 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.
export function StatsOverview({ wsManager }: StatsOverviewProps) {
Expand Down Expand Up @@ -48,7 +48,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {

return (
<div className="flex min-h-0 flex-1 flex-col">
<StatsSubHeader tab={tab} onTabChange={handleTab} range={range} onRangeChange={handleRange} wsManager={wsManager} />
<StatsSubHeader tab={tab} onTabChange={handleTab} range={range} onRangeChange={handleRange} />
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "mesh" ? (
<MeshTab range={range} onSelectObserver={handleSelectObserver} wsManager={wsManager} />
Expand Down
Loading
Loading