chore: Initial commit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
Janez Troha
2026-09-04 16:53:52 +02:00
commit 7924803351
516 changed files with 207265 additions and 0 deletions

29
worker/README.md Normal file
View File

@@ -0,0 +1,29 @@
# MeshCore SAR Stats Worker
This worker follows the same split as `/Users/dz0ny/site-vendorvigilance`:
- Astro builds the dashboard UI into `dist`
- a small Cloudflare Worker handles `/api/*` before assets are served
## Layout
- `src/pages/index.astro` - dashboard shell
- `worker/index.ts` - Cloudflare Worker entrypoint
- `worker/stats.ts` - D1 queries, payload validation, and aggregation helpers
- `public/.assetsignore` - keeps Astro's private `_worker.js` bundle out of public asset uploads
- `schema.sql` - D1 schema
## Setup
1. Install dependencies with `bun install`.
2. Create a D1 database with `bunx wrangler d1 create meshcore_sar_rx_stats`.
3. Apply the schema with `bunx wrangler d1 execute meshcore_sar_rx_stats --remote --file=./schema.sql`.
4. Add the real D1 binding id to `wrangler.toml` when deploying.
5. Build the dashboard with `bun run build`.
6. Run checks with `bun run check`, `bun run test`, and `bun run typecheck`.
## Routes
- `GET /` - static Astro dashboard
- `GET /api/dashboard?window=24h|7d|30d` - aggregated dashboard JSON
- `POST /api/ingest` - anonymous RX stats ingest

30
worker/astro.config.mjs Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
imageService: "compile",
integrations: [react()],
vite: {
cacheDir: ".astro/vite",
plugins: [tailwindcss()],
resolve: {
alias: {
"@": "/src",
},
},
},
build: {
concurrency: 4,
},
server: {
port: 4321,
host: "0.0.0.0",
allowedHosts: true,
},
devToolbar: {
enabled: false,
},
adapter: cloudflare(),
});

1291
worker/bun.lock Normal file

File diff suppressed because it is too large Load Diff

38
worker/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "meshcore-sar-stats",
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "bun@1.3.8",
"scripts": {
"dev": "astro dev --host 0.0.0.0",
"build": "astro build",
"check": "astro check",
"test": "bun test",
"typecheck": "tsc --noEmit",
"deploy": "wrangler deploy"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/cloudflare": "^12.6.12",
"@astrojs/react": "^4.4.2",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.17.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260403.1",
"@types/bun": "^1.3.11",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3",
"wrangler": "^4.80.0"
}
}

View File

@@ -0,0 +1,2 @@
_worker.js
_routes.json

BIN
worker/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

42
worker/schema.sql Normal file
View File

@@ -0,0 +1,42 @@
CREATE TABLE IF NOT EXISTS reports (
report_id TEXT PRIMARY KEY,
device_key6 TEXT NOT NULL,
window_start TEXT NOT NULL,
window_end TEXT NOT NULL,
received_at TEXT NOT NULL,
app_version TEXT,
cf_country TEXT,
cf_region TEXT,
cf_city TEXT,
cf_latitude REAL,
cf_longitude REAL,
cf_colo TEXT,
pt_00 INTEGER NOT NULL DEFAULT 0,
pt_01 INTEGER NOT NULL DEFAULT 0,
pt_02 INTEGER NOT NULL DEFAULT 0,
pt_03 INTEGER NOT NULL DEFAULT 0,
pt_04 INTEGER NOT NULL DEFAULT 0,
pt_05 INTEGER NOT NULL DEFAULT 0,
pt_06 INTEGER NOT NULL DEFAULT 0,
pt_07 INTEGER NOT NULL DEFAULT 0,
pt_08 INTEGER NOT NULL DEFAULT 0,
pt_09 INTEGER NOT NULL DEFAULT 0,
pt_0a INTEGER NOT NULL DEFAULT 0,
pt_0b INTEGER NOT NULL DEFAULT 0,
pt_0c INTEGER NOT NULL DEFAULT 0,
pt_0d INTEGER NOT NULL DEFAULT 0,
pt_0e INTEGER NOT NULL DEFAULT 0,
pt_0f INTEGER NOT NULL DEFAULT 0,
decode_fail INTEGER NOT NULL DEFAULT 0,
path_mode_1b INTEGER NOT NULL DEFAULT 0,
path_mode_2b INTEGER NOT NULL DEFAULT 0,
path_mode_3b INTEGER NOT NULL DEFAULT 0,
path_mode_none INTEGER NOT NULL DEFAULT 0,
path_mode_unknown INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_reports_device_window_end
ON reports (device_key6, window_end);
CREATE INDEX IF NOT EXISTS idx_reports_window_end
ON reports (window_end);

View File

@@ -0,0 +1,809 @@
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type WindowKey = "24h" | "7d";
type PacketTypeEntry = {
key: string;
label: string;
total: number;
};
type PathModeEntry = {
key: string;
label: string;
total: number;
};
type ReporterSummary = {
key6: string;
lastSeen: string;
packetTotal: number;
country: string;
city: string;
latitude: number | null;
longitude: number | null;
};
type ChartPoint = {
label: string;
totalPackets: number;
reports: number;
};
type AppVersionEntry = {
version: string;
reporters: number;
packets: number;
};
type TrafficComposition = {
human: number;
overhead: number;
acks: number;
};
type MultiHopRatio = {
direct: number;
multiHop: number;
};
type CompositionPoint = {
label: string;
human: number;
overhead: number;
};
type DashboardResponse = {
generatedAt: string;
filter: {
windowKey: WindowKey;
label: string;
sinceIso: string;
bucket: "hour" | "day";
};
reportCount: number;
uniqueDevices: number;
decodedPackets: number;
decodeFailures: number;
packetTypeTotals: PacketTypeEntry[];
pathModeTotals: PathModeEntry[];
recentReporters: ReporterSummary[];
chartPoints: ChartPoint[];
appVersions: AppVersionEntry[];
trafficComposition: TrafficComposition;
multiHopRatio: MultiHopRatio;
compositionOverTime: CompositionPoint[];
};
const WINDOW_OPTIONS: Array<{ key: WindowKey; label: string }> = [
{ key: "24h", label: "24h" },
{ key: "7d", label: "7 days" },
];
const PACKET_TYPE_INFO: Record<string, { title: string; summary: string }> = {
pt_00: { title: "FLOOD REQUEST", summary: "Encrypted request to a known peer" },
pt_01: { title: "FLOOD RESPONSE", summary: "Encrypted reply to a request" },
pt_02: { title: "FLOOD TEXT", summary: "Encrypted direct text with timestamp and retry flags" },
pt_03: { title: "FLOOD ACK", summary: "4-byte acknowledgement for an earlier message" },
pt_04: { title: "FLOOD ADVERTISEMENT", summary: "Signed node identity broadcast" },
pt_05: { title: "FLOOD GROUP_TEXT", summary: "Encrypted channel text matched by channel hash" },
pt_06: { title: "FLOOD GROUP_DATA", summary: "Encrypted channel data with type and length" },
pt_07: { title: "FLOOD ANON_REQUEST", summary: "Request using an ephemeral sender key" },
pt_08: { title: "FLOOD RETURNED_PATH", summary: "Return route back to sender, with optional bundled ACK" },
pt_09: { title: "FLOOD TRACE_PATH", summary: "Direct trace that records SNR at each hop" },
pt_0a: { title: "FLOOD MULTIPART", summary: "Wrapper for one packet in a multipart sequence" },
pt_0b: { title: "FLOOD CONTROL", summary: "Discovery or other control data" },
pt_0c: { title: "RESERVED 0x0C", summary: "Reserved protocol type" },
pt_0d: { title: "RESERVED 0x0D", summary: "Reserved protocol type" },
pt_0e: { title: "RESERVED 0x0E", summary: "Reserved protocol type" },
pt_0f: { title: "RAW CUSTOM", summary: "Application-defined custom packet" },
};
const PATH_MODE_ICONS: Record<string, string> = {
path_mode_1b: "1B",
path_mode_2b: "2B",
path_mode_3b: "3B",
path_mode_none: "--",
path_mode_unknown: "??",
};
export function DashboardShell() {
const [windowKey, setWindowKey] = useState<WindowKey>("24h");
const [summary, setSummary] = useState<DashboardResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isCancelled = false;
async function load() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/dashboard?window=${windowKey}`, {
headers: { accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Dashboard request failed (${response.status})`);
}
const nextSummary = (await response.json()) as DashboardResponse;
if (!isCancelled) setSummary(nextSummary);
} catch (nextError) {
if (!isCancelled) {
setError(nextError instanceof Error ? nextError.message : String(nextError));
}
} finally {
if (!isCancelled) setIsLoading(false);
}
}
void load();
return () => { isCancelled = true; };
}, [windowKey]);
const activePacketTypes = useMemo(
() => (summary?.packetTypeTotals ?? []).filter((e) => e.total > 0),
[summary],
);
const activePathModes = useMemo(
() => (summary?.pathModeTotals ?? []).filter((e) => e.total > 0),
[summary],
);
const totalPackets = useMemo(
() => (summary ? summary.decodedPackets + summary.decodeFailures : 0),
[summary],
);
const decodeRate = totalPackets > 0
? ((summary!.decodedPackets / totalPackets) * 100).toFixed(1)
: "0";
const maxTrend = Math.max(...(summary?.chartPoints ?? []).map((p) => p.totalPackets), 1);
const multiHopTotal = (summary?.multiHopRatio.direct ?? 0) + (summary?.multiHopRatio.multiHop ?? 0);
const multiHopPct = multiHopTotal > 0
? ((summary!.multiHopRatio.multiHop / multiHopTotal) * 100).toFixed(1)
: "0";
const comp = summary?.trafficComposition;
const compTotal = comp ? comp.human + comp.overhead + comp.acks : 0;
const humanPct = compTotal > 0 ? ((comp!.human / compTotal) * 100).toFixed(1) : "0";
const nodeCount = summary?.uniqueDevices ?? 0;
const avgPerNode = nodeCount > 0 ? Math.round(totalPackets / nodeCount) : 0;
return (
<div className="mx-auto max-w-[1320px] px-5 py-8">
<Tabs value={windowKey} onValueChange={(v) => setWindowKey(v as WindowKey)}>
{/* Header */}
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2.5">
<img src="/favicon.png" alt="MeshCore SAR" className="h-8 w-8 rounded-lg" />
<h1 className="text-2xl font-semibold tracking-tight">MeshCore SAR</h1>
</div>
<p className="max-w-xl text-sm text-muted-foreground">
Aggregated observations from opt-in mesh nodes. Counts reflect what reporting nodes observed, not unique network packets.
Ratios and percentages are statistically valid; absolute numbers scale with reporter count.
</p>
</div>
<div className="flex items-center gap-3">
<TabsList className="h-9 rounded-full bg-secondary/60 p-1">
{WINDOW_OPTIONS.map((o) => (
<TabsTrigger key={o.key} value={o.key} className="rounded-full px-4 text-xs">
{o.label}
</TabsTrigger>
))}
</TabsList>
{summary && (
<span className="text-xs text-muted-foreground">
Updated {formatRelative(summary.generatedAt)}
</span>
)}
</div>
</header>
{error && (
<div className="mb-6 flex items-center gap-3 rounded-2xl border border-destructive/20 bg-destructive/10 px-5 py-3 text-sm text-destructive">
<span className="flex-1">{error}</span>
<Button size="sm" variant="outline" onClick={() => setWindowKey((c) => c)}>Retry</Button>
</div>
)}
<TabsContent value={windowKey} className="space-y-6">
{/* Key metrics */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<MetricCard
label="Reporting Nodes"
value={nodeCount}
note="Opt-in nodes in this window"
/>
<MetricCard
label="Avg / Node"
value={avgPerNode}
note={`${totalPackets.toLocaleString()} total obs.`}
/>
<MetricCard
label="Decode Rate"
value={`${decodeRate}%`}
note={`${summary?.decodeFailures ?? 0} failed`}
/>
<MetricCard
label="Multi-Hop"
value={`${multiHopPct}%`}
note="Relayed through mesh"
/>
<MetricCard
label="Messages"
value={`${humanPct}%`}
note="Text + group text"
/>
<MetricCard
label="Protocol Types"
value={activePacketTypes.length}
note="of 16 types observed"
/>
</section>
{/* Traffic breakdown */}
<section className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Traffic Breakdown</CardTitle>
<CardDescription>Observed traffic composition across all reporting nodes</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{comp && compTotal > 0 ? (
<>
<div className="flex h-6 overflow-hidden rounded-full">
<div className="bg-primary" style={{ width: `${(comp.human / compTotal) * 100}%` }} title={`Messages: ${comp.human}`} />
<div className="bg-amber-400" style={{ width: `${(comp.acks / compTotal) * 100}%` }} title={`Acks: ${comp.acks}`} />
<div className="bg-secondary" style={{ width: `${(comp.overhead / compTotal) * 100}%` }} title={`Overhead: ${comp.overhead}`} />
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-primary" />
<div className="text-lg font-semibold tabular-nums">{comp.human.toLocaleString()}</div>
<div className="text-xs text-muted-foreground">Messages</div>
</div>
<div>
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-amber-400" />
<div className="text-lg font-semibold tabular-nums">{comp.acks.toLocaleString()}</div>
<div className="text-xs text-muted-foreground">Acks</div>
</div>
<div>
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-secondary" />
<div className="text-lg font-semibold tabular-nums">{comp.overhead.toLocaleString()}</div>
<div className="text-xs text-muted-foreground">Overhead</div>
</div>
</div>
</>
) : (
<EmptyState label="No traffic data yet." />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Messages vs Protocol Overhead</CardTitle>
<CardDescription>
Human messages (text + group text) compared to protocol overhead (acks, advertisements, routing, control) over time.
</CardDescription>
</CardHeader>
<CardContent>
{summary?.compositionOverTime.length ? (
<CompositionChart points={summary.compositionOverTime} />
) : (
<EmptyState label="No composition data yet." />
)}
</CardContent>
</Card>
</section>
{/* Protocol breakdown */}
<section className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Protocol Packet Types</CardTitle>
<CardDescription>
MeshCore protocol uses 16 packet type codes (0x00 - 0x0F).
Showing types with traffic in this window.
</CardDescription>
</CardHeader>
<CardContent>
{activePacketTypes.length ? (
<div className="grid gap-2 sm:grid-cols-2">
{activePacketTypes.map((entry) => {
const pct = totalPackets > 0 ? ((entry.total / totalPackets) * 100).toFixed(1) : "0";
const info = PACKET_TYPE_INFO[entry.key];
return (
<div key={entry.key} className="flex gap-3 rounded-xl border border-border/50 bg-secondary/30 p-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-mono font-semibold text-primary">
{entry.key.replace("pt_", "0x").toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium">{entry.label}</span>
<span className="shrink-0 text-xs text-muted-foreground">{pct}%</span>
</div>
<div className="mt-0.5 font-mono text-[0.65rem] text-muted-foreground/70">
{info?.title ?? entry.key}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{info?.summary ?? ""}
</p>
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary/70"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs font-semibold tabular-nums">{entry.total.toLocaleString()} obs.</span>
</div>
</div>
</div>
);
})}
</div>
) : (
<EmptyState label="No packet data yet." />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Path Routing Modes</CardTitle>
<CardDescription>
Path hash byte length determines routing precision.
Longer hashes allow more specific multi-hop paths.
</CardDescription>
</CardHeader>
<CardContent>
{activePathModes.length ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
{activePathModes.map((entry) => {
const pct = totalPackets > 0 ? ((entry.total / totalPackets) * 100).toFixed(1) : "0";
return (
<div key={entry.key} className="rounded-xl border border-border/50 bg-secondary/30 p-4 text-center">
<div className="mx-auto mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
{PATH_MODE_ICONS[entry.key] ?? "?"}
</div>
<div className="text-lg font-semibold tabular-nums">{entry.total.toLocaleString()}</div>
<div className="mt-0.5 text-xs text-muted-foreground">{entry.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{pct}%</div>
</div>
);
})}
</div>
) : (
<EmptyState label="No path mode samples." />
)}
</CardContent>
</Card>
</section>
{/* Map + Traffic trend */}
<section className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Observations Over Time</CardTitle>
<CardDescription>
Per-node average observations per {summary?.filter.bucket ?? "time"} bucket.
Normalizing by reporter count removes the bias of more nodes = higher numbers.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading && !summary ? (
<EmptyState label="Loading..." />
) : summary?.chartPoints.length ? (
<TrafficChart points={summary.chartPoints} maxValue={maxTrend} />
) : (
<EmptyState label="No data for this window." />
)}
</CardContent>
</Card>
</section>
{/* App versions */}
<section>
<Card>
<CardHeader>
<CardTitle>App Versions</CardTitle>
<CardDescription>Distribution of reporting app versions by node count and observations</CardDescription>
</CardHeader>
<CardContent>
{summary?.appVersions.length ? (
<div className="space-y-3">
{summary.appVersions.map((entry) => {
const maxPkts = summary.appVersions[0].packets;
return (
<div key={entry.version} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="font-mono font-medium">{entry.version}</span>
<span className="text-xs text-muted-foreground">
{entry.reporters} {entry.reporters === 1 ? "node" : "nodes"} / {entry.packets.toLocaleString()} obs.
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary/70"
style={{ width: `${(entry.packets / maxPkts) * 100}%` }}
/>
</div>
</div>
);
})}
</div>
) : (
<EmptyState label="No version data yet." />
)}
</CardContent>
</Card>
</section>
</TabsContent>
</Tabs>
</div>
);
}
// --- Composition stacked area chart ---
const MIN_CHART_W = 320;
const COMPOSITION_CHART_H = 236;
function useResponsiveChartWidth(minWidth = MIN_CHART_W) {
const [element, setElement] = useState<HTMLDivElement | null>(null);
const [width, setWidth] = useState(minWidth);
useEffect(() => {
if (!element) return;
const sync = () => setWidth(Math.max(element.clientWidth, minWidth));
sync();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(sync);
observer.observe(element);
return () => { observer.disconnect(); };
}, [element, minWidth]);
return { setElement, width };
}
function CompositionChart({ points }: { points: CompositionPoint[] }) {
const [hover, setHover] = useState<number | null>(null);
const { setElement, width } = useResponsiveChartWidth();
const count = points.length;
if (count === 0) return null;
const maxVal = Math.max(...points.map((p) => p.human + p.overhead), 1);
const pad = { top: 16, right: 16, bottom: 32, left: 48 };
const innerW = Math.max(width - pad.left - pad.right, 240);
const innerH = COMPOSITION_CHART_H - pad.top - pad.bottom;
const xs = points.map((_, i) => i / Math.max(count - 1, 1));
// stacked: overhead on bottom, human on top
const overheadYs = points.map((p) => 1 - p.overhead / maxVal);
const totalYs = points.map((p) => 1 - (p.overhead + p.human) / maxVal);
const overheadArea = buildAreaPath(xs, overheadYs, innerW, innerH);
const humanArea = buildStackedAreaPath(xs, totalYs, overheadYs, innerW, innerH);
const labelStep = Math.max(1, Math.floor(count / 6));
const gridLines = niceGridLines(maxVal, 3);
return (
<div ref={setElement} className="relative select-none">
<svg
viewBox={`${-pad.left} ${-pad.top} ${innerW + pad.left + pad.right} ${innerH + pad.top + pad.bottom}`}
className="h-[236px] w-full"
onMouseLeave={() => setHover(null)}
>
<defs>
<linearGradient id="humanFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-primary))" stopOpacity={0.5} />
<stop offset="100%" stopColor="hsl(var(--chart-primary))" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="overheadFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-secondary))" stopOpacity={0.5} />
<stop offset="100%" stopColor="hsl(var(--chart-secondary))" stopOpacity={0.1} />
</linearGradient>
</defs>
{gridLines.map((val) => {
const y = (1 - val / maxVal) * innerH;
return (
<g key={val}>
<line x1={0} y1={y} x2={innerW} y2={y} stroke="hsl(var(--chart-grid))" strokeWidth={0.3} />
<text x={-6} y={y} textAnchor="end" dominantBaseline="middle" fill="hsl(var(--chart-label))" fontSize={3.2} fontFamily="var(--font-sans)">
{formatCompact(val)}
</text>
</g>
);
})}
<line x1={0} y1={innerH} x2={innerW} y2={innerH} stroke="hsl(var(--chart-grid))" strokeWidth={0.4} />
<path d={overheadArea} fill="url(#overheadFill)" />
<path d={humanArea} fill="url(#humanFill)" />
{/* X labels */}
{points.map((p, i) => (i % labelStep === 0 || i === count - 1) ? (
<text key={i} x={xs[i] * innerW} y={innerH + 10} textAnchor="middle" fill="hsl(var(--chart-label))" fontSize={2.8} fontFamily="var(--font-sans)">
{p.label.slice(5)}
</text>
) : null)}
{/* hover zones */}
{points.map((point, i) => (
<rect
key={point.label}
x={xs[i] * innerW - innerW / count / 2}
y={0}
width={innerW / count}
height={innerH}
fill="transparent"
onMouseEnter={() => setHover(i)}
/>
))}
{hover !== null && (
<line x1={xs[hover] * innerW} y1={0} x2={xs[hover] * innerW} y2={innerH} stroke="hsl(var(--chart-primary))" strokeWidth={0.3} strokeDasharray="1.5 1" />
)}
</svg>
{hover !== null && (
<div
className="pointer-events-none absolute -top-2 z-10 -translate-x-1/2 rounded-lg border border-border/50 bg-card px-3 py-1.5 text-xs shadow-lg"
style={{ left: `${(pad.left + xs[hover] * innerW) / (innerW + pad.left + pad.right) * 100}%` }}
>
<div className="font-semibold">{points[hover].label}</div>
<div className="flex items-center gap-1.5"><span className="inline-block h-2 w-2 rounded-full bg-primary" /> Messages: {points[hover].human.toLocaleString()}</div>
<div className="flex items-center gap-1.5"><span className="inline-block h-2 w-2 rounded-full bg-secondary" /> Overhead: {points[hover].overhead.toLocaleString()}</div>
</div>
)}
<div className="mt-2 flex items-center justify-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-full bg-primary/60" /> Messages</span>
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-full bg-secondary/70" /> Overhead</span>
</div>
</div>
);
}
function buildAreaPath(xs: number[], ys: number[], w: number, h: number): string {
const line = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * w},${ys[i] * h}`).join(" ");
return `${line} L${xs[xs.length - 1] * w},${h} L0,${h} Z`;
}
function buildStackedAreaPath(xs: number[], topYs: number[], bottomYs: number[], w: number, h: number): string {
const top = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * w},${topYs[i] * h}`).join(" ");
const bottom = [...xs].reverse().map((x, i) => `L${x * w},${bottomYs[xs.length - 1 - i] * h}`).join(" ");
return `${top} ${bottom} Z`;
}
// --- Traffic trend chart ---
const CHART_H = 240;
const CHART_PAD = { top: 20, right: 16, bottom: 32, left: 48 };
function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; maxValue: number }) {
const [hover, setHover] = useState<number | null>(null);
const { setElement, width } = useResponsiveChartWidth();
const count = points.length;
if (count === 0) return null;
// Normalize: per-node average per bucket
const normalized = points.map((p) => ({
...p,
perNode: p.reports > 0 ? Math.round(p.totalPackets / p.reports) : 0,
}));
const maxValue = Math.max(...normalized.map((p) => p.perNode), 1);
const innerW = Math.max(width - CHART_PAD.left - CHART_PAD.right, 240);
const innerH = CHART_H - CHART_PAD.top - CHART_PAD.bottom;
const peakIdx = normalized.reduce((best, p, i) => (p.perNode > normalized[best].perNode ? i : best), 0);
const gridLines = niceGridLines(maxValue, 4);
const xs = normalized.map((_, i) => i / Math.max(count - 1, 1));
const ys = normalized.map((p) => 1 - p.perNode / maxValue);
const linePath = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * innerW},${ys[i] * innerH}`).join(" ");
const areaPath = `${linePath} L${xs[xs.length - 1] * innerW},${innerH} L0,${innerH} Z`;
// X-axis labels: show ~6 evenly spaced
const labelStep = Math.max(1, Math.floor(count / 6));
const xLabels = points
.map((p, i) => ({ i, label: p.label.slice(5) }))
.filter((_, i) => i % labelStep === 0 || i === count - 1);
return (
<div ref={setElement} className="relative select-none">
<svg
viewBox={`${-CHART_PAD.left} ${-CHART_PAD.top} ${innerW + CHART_PAD.left + CHART_PAD.right} ${CHART_H}`}
className="h-[240px] w-full"
onMouseLeave={() => setHover(null)}
>
<defs>
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-primary))" stopOpacity={0.35} />
<stop offset="100%" stopColor="hsl(var(--chart-primary))" stopOpacity={0.03} />
</linearGradient>
<linearGradient id="lineStroke" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="hsl(var(--chart-primary-light))" />
<stop offset="100%" stopColor="hsl(var(--chart-primary-dark))" />
</linearGradient>
</defs>
{/* Y grid lines */}
{gridLines.map((val) => {
const y = (1 - val / maxValue) * innerH;
return (
<g key={val}>
<line x1={0} y1={y} x2={innerW} y2={y} stroke="hsl(var(--chart-grid))" strokeWidth={0.3} />
<text x={-6} y={y} textAnchor="end" dominantBaseline="middle" fill="hsl(var(--chart-label))" fontSize={3.2} fontFamily="var(--font-sans)">
{formatCompact(val)}
</text>
</g>
);
})}
{/* baseline */}
<line x1={0} y1={innerH} x2={innerW} y2={innerH} stroke="hsl(var(--chart-grid))" strokeWidth={0.4} />
{/* area fill */}
<path d={areaPath} fill="url(#areaFill)" />
{/* line */}
<path d={linePath} fill="none" stroke="url(#lineStroke)" strokeWidth={0.7} strokeLinecap="round" strokeLinejoin="round" />
{/* peak dot */}
<circle
cx={xs[peakIdx] * innerW}
cy={ys[peakIdx] * innerH}
r={1.5}
fill="hsl(var(--chart-primary))"
stroke="white"
strokeWidth={0.6}
/>
{/* peak label */}
<text
x={xs[peakIdx] * innerW}
y={ys[peakIdx] * innerH - 4}
textAnchor="middle"
fill="hsl(var(--chart-primary-accent))"
fontSize={3}
fontWeight={600}
fontFamily="var(--font-sans)"
>
{normalized[peakIdx].perNode.toLocaleString()}
</text>
{/* X-axis labels */}
{xLabels.map(({ i, label }) => (
<text
key={i}
x={xs[i] * innerW}
y={innerH + 10}
textAnchor="middle"
fill="hsl(var(--chart-label))"
fontSize={2.8}
fontFamily="var(--font-sans)"
>
{label}
</text>
))}
{/* invisible hover zones */}
{points.map((point, i) => {
const sliceW = innerW / count;
return (
<rect
key={point.label}
x={xs[i] * innerW - sliceW / 2}
y={0}
width={sliceW}
height={innerH}
fill="transparent"
onMouseEnter={() => setHover(i)}
/>
);
})}
{/* hover indicator */}
{hover !== null && (
<>
<line
x1={xs[hover] * innerW}
y1={0}
x2={xs[hover] * innerW}
y2={innerH}
stroke="hsl(var(--chart-primary))"
strokeWidth={0.3}
strokeDasharray="1.5 1"
/>
<circle
cx={xs[hover] * innerW}
cy={ys[hover] * innerH}
r={1.2}
fill="white"
stroke="hsl(var(--chart-primary))"
strokeWidth={0.6}
/>
</>
)}
</svg>
{/* hover tooltip */}
{hover !== null && (
<div
className="pointer-events-none absolute -top-2 z-10 -translate-x-1/2 rounded-lg border border-border/50 bg-card px-3 py-1.5 text-xs shadow-lg"
style={{
left: `${(CHART_PAD.left + xs[hover] * innerW) / (innerW + CHART_PAD.left + CHART_PAD.right) * 100}%`,
}}
>
<div className="font-semibold tabular-nums">{normalized[hover].perNode.toLocaleString()} avg/node</div>
<div className="text-muted-foreground">{normalized[hover].totalPackets.toLocaleString()} total from {normalized[hover].reports} {normalized[hover].reports === 1 ? "node" : "nodes"}</div>
<div className="text-muted-foreground">{normalized[hover].label}</div>
</div>
)}
</div>
);
}
function niceGridLines(max: number, count: number): number[] {
if (max <= 0) return [0];
const rough = max / count;
const magnitude = 10 ** Math.floor(Math.log10(rough));
const residual = rough / magnitude;
const nice = residual <= 1.5 ? 1 : residual <= 3 ? 2 : residual <= 7 ? 5 : 10;
const step = nice * magnitude;
const lines: number[] = [];
for (let v = step; v <= max; v += step) {
lines.push(Math.round(v));
}
return lines;
}
function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
return String(n);
}
function MetricCard({ label, value, note }: { label: string; value: number | string; note: string }) {
return (
<Card>
<CardHeader className="gap-1.5">
<CardDescription className="text-xs uppercase tracking-wider">{label}</CardDescription>
<CardTitle className="text-3xl tabular-nums">{typeof value === "number" ? value.toLocaleString() : value}</CardTitle>
<CardDescription>{note}</CardDescription>
</CardHeader>
</Card>
);
}
function EmptyState({ label }: { label: string }) {
return (
<div className="rounded-2xl border border-dashed border-border bg-secondary/20 px-4 py-8 text-center text-sm text-muted-foreground">
{label}
</div>
);
}
function formatRelative(value: string) {
const diffMs = Date.now() - new Date(value).getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return `${Math.floor(diffHr / 24)}d ago`;
}

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground bg-card/60",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border bg-card hover:bg-muted",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
ghost: "hover:bg-muted",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3",
lg: "h-10 rounded-md px-6",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants, type ButtonProps };

View File

@@ -0,0 +1,52 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card/88 text-card-foreground flex flex-col gap-6 rounded-[1.5rem] border border-border/50 py-6 shadow-[0_18px_48px_rgba(16,33,47,0.08)] dark:shadow-[0_18px_48px_rgba(0,0,0,0.3)] backdrop-blur-md",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("grid auto-rows-min items-start gap-2 px-6", className)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
export { Card, CardHeader, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,64 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
));
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50",
className,
)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn("h-12 px-4 text-left align-middle font-medium text-muted-foreground", className)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-4 align-middle", className)} {...props} />
));
TableCell.displayName = "TableCell";
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };

View File

@@ -0,0 +1,50 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-11 items-center justify-center rounded-full border border-white/70 bg-white/60 p-1 text-muted-foreground shadow-sm backdrop-blur",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn("mt-6 focus-visible:outline-none", className)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

99
worker/src/index.css Normal file
View File

@@ -0,0 +1,99 @@
@import "tailwindcss";
@theme {
--font-sans: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", "SFMono-Regular", monospace;
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
}
@layer base {
:root {
--background: 220 33% 97%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 220 50% 93%;
--secondary-foreground: 222 47% 11%;
--muted: 220 26% 93%;
--muted-foreground: 220 13% 46%;
--accent: 217 91% 60%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 220 20% 88%;
--input: 220 20% 88%;
--ring: 217 91% 60%;
--radius: 1rem;
--chart-grid: 220 20% 88%;
--chart-label: 220 13% 46%;
--chart-primary: 217 91% 60%;
--chart-primary-light: 217 91% 65%;
--chart-primary-dark: 217 70% 50%;
--chart-primary-accent: 217 91% 45%;
--chart-secondary: 206 22% 75%;
}
.dark {
--background: 222 47% 8%;
--foreground: 220 20% 90%;
--card: 222 40% 12%;
--card-foreground: 220 20% 90%;
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 220 30% 18%;
--secondary-foreground: 220 20% 90%;
--muted: 220 20% 16%;
--muted-foreground: 220 13% 55%;
--accent: 217 91% 60%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 220 20% 20%;
--input: 220 20% 20%;
--ring: 217 91% 60%;
--chart-grid: 220 20% 22%;
--chart-label: 220 13% 50%;
--chart-primary: 217 91% 65%;
--chart-primary-light: 217 91% 70%;
--chart-primary-dark: 217 70% 55%;
--chart-primary-accent: 217 91% 55%;
--chart-secondary: 220 20% 35%;
}
* {
border-color: hsl(var(--border));
}
html {
font-family: var(--font-sans);
color-scheme: light dark;
}
body {
min-height: 100vh;
background: hsl(var(--background));
color: hsl(var(--foreground));
}
}

View File

@@ -0,0 +1,28 @@
---
interface Props {
title?: string;
}
const { title = "MeshCore SAR RX Stats" } = Astro.props;
import "../index.css";
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>{title}</title>
</head>
<body>
<slot />
<script>
const mq = window.matchMedia("(prefers-color-scheme: dark)");
function apply(e: MediaQueryList | MediaQueryListEvent) { document.documentElement.classList.toggle("dark", e.matches); }
apply(mq);
mq.addEventListener("change", apply);
</script>
</body>
</html>

6
worker/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,8 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
---
<BaseLayout title="MeshCore SAR RX Stats">
<DashboardShell client:load />
</BaseLayout>

169
worker/test/index.test.ts Normal file
View File

@@ -0,0 +1,169 @@
import { describe, expect, test } from 'bun:test';
import {
REPORT_INSERT_SQL,
buildWindowFilter,
createEmptyCounts,
extractCfGeo,
loadDashboardSummary,
summarizeRows,
validateIngestPayload,
} from '../worker/stats';
describe('validateIngestPayload', () => {
test('accepts a complete payload with fixed columns', () => {
const counts = createEmptyCounts();
counts.pt_04 = 12;
counts.path_mode_2b = 4;
const payload = validateIngestPayload({
reportId: 'a1b2c3d4e5f6:2026-04-03T10:00:00.000Z',
deviceKey6: 'a1b2c3d4e5f6',
windowStart: '2026-04-03T10:00:00.000Z',
windowEnd: '2026-04-03T10:05:00.000Z',
appVersion: '2026.0402.1+44',
counts,
});
expect(payload.counts.pt_04).toBe(12);
expect(payload.counts.path_mode_2b).toBe(4);
});
test('rejects missing fixed count keys', () => {
expect(() =>
validateIngestPayload({
reportId: 'a1b2c3d4e5f6:2026-04-03T10:00:00.000Z',
deviceKey6: 'a1b2c3d4e5f6',
windowStart: '2026-04-03T10:00:00.000Z',
windowEnd: '2026-04-03T10:05:00.000Z',
appVersion: '2026.0402.1+44',
counts: {},
}),
).toThrow('counts.pt_00 must be a number.');
});
});
describe('worker helpers', () => {
test('uses insert-or-ignore semantics for idempotent reports', () => {
expect(REPORT_INSERT_SQL).toContain('INSERT OR IGNORE INTO reports');
});
test('extractCfGeo reads Cloudflare request metadata', () => {
const request = new Request('https://example.com/') as Request & {
cf?: Record<string, unknown>;
};
request.cf = {
country: 'SI',
region: 'Ljubljana',
city: 'Ljubljana',
latitude: '46.0569',
longitude: '14.5058',
colo: 'LJU',
};
const geo = extractCfGeo(request);
expect(geo.country).toBe('SI');
expect(geo.latitude).toBe(46.0569);
expect(geo.longitude).toBe(14.5058);
expect(geo.colo).toBe('LJU');
});
test('summarizeRows aggregates packet and path mode totals', () => {
const filter = buildWindowFilter('24h', new Date('2026-04-03T12:00:00.000Z'));
const rows = [
{
...createEmptyCounts(),
report_id: 'a1',
device_key6: 'a1b2c3d4e5f6',
window_start: '2026-04-03T10:00:00.000Z',
window_end: '2026-04-03T10:05:00.000Z',
received_at: '2026-04-03T10:05:03.000Z',
app_version: '2026.0402.1+44',
cf_country: 'SI',
cf_region: 'Ljubljana',
cf_city: 'Ljubljana',
cf_latitude: 46.0569,
cf_longitude: 14.5058,
cf_colo: 'LJU',
pt_04: 12,
path_mode_2b: 12,
},
{
...createEmptyCounts(),
report_id: 'a2',
device_key6: '001122334455',
window_start: '2026-04-03T11:00:00.000Z',
window_end: '2026-04-03T11:05:00.000Z',
received_at: '2026-04-03T11:05:02.000Z',
app_version: '2026.0402.1+44',
cf_country: 'DE',
cf_region: 'Berlin',
cf_city: 'Berlin',
cf_latitude: 52.52,
cf_longitude: 13.405,
cf_colo: 'FRA',
pt_05: 3,
decode_fail: 1,
path_mode_none: 1,
path_mode_3b: 2,
},
];
const summary = summarizeRows(rows, filter);
expect(summary.reportCount).toBe(2);
expect(summary.uniqueDevices).toBe(2);
expect(summary.decodedPackets).toBe(15);
expect(summary.decodeFailures).toBe(1);
expect(summary.pathModeTotals[0]?.total).toBe(12);
expect(summary.locationPoints).toHaveLength(2);
});
test('loadDashboardSummary reads D1 rows for the selected window', async () => {
const rows = [
{
...createEmptyCounts(),
report_id: 'a1',
device_key6: 'a1b2c3d4e5f6',
window_start: '2026-04-03T10:00:00.000Z',
window_end: '2026-04-03T10:05:00.000Z',
received_at: '2026-04-03T10:05:03.000Z',
app_version: '2026.0402.1+44',
cf_country: 'SI',
cf_region: 'Ljubljana',
cf_city: 'Ljubljana',
cf_latitude: 46.0569,
cf_longitude: 14.5058,
cf_colo: 'LJU',
pt_04: 5,
},
];
const env = {
DB: {
prepare() {
return {
bind() {
return {
async all() {
return { results: rows };
},
};
},
};
},
},
} as any;
const summary = await loadDashboardSummary(
env,
'24h',
new Date('2026-04-03T12:00:00.000Z'),
);
expect(summary.reportCount).toBe(1);
expect(summary.packetTypeTotals[0]?.key).toBe('pt_04');
expect(summary.packetTypeTotals[0]?.total).toBe(5);
});
});

20
worker/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist",
"node_modules"
],
"compilerOptions": {
"baseUrl": ".",
"jsx": "react-jsx",
"jsxImportSource": "react",
"paths": {
"@/*": ["./src/*"]
},
"types": ["@cloudflare/workers-types", "bun-types"]
}
}

128
worker/worker/index.ts Normal file
View File

@@ -0,0 +1,128 @@
import {
COUNT_KEYS,
REPORT_INSERT_SQL,
extractCfGeo,
jsonHeaders,
loadDashboardSummary,
purgeOldReports,
validateIngestPayload,
type Env,
type IngestPayload,
} from "./stats";
const ROUTES = {
"/api/ingest": handleIngest,
"/api/dashboard": handleDashboard,
} as const;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const pathname = new URL(request.url).pathname;
const route = Object.entries(ROUTES).find(([prefix]) =>
pathname.startsWith(prefix),
);
if (!route) {
return new Response("Not Found", { status: 404 });
}
try {
return await route[1](request, env);
} catch (error) {
console.error(`Worker route failed for ${pathname}:`, error);
return Response.json(
{ error: "Internal Server Error" },
{
headers: jsonHeaders,
status: 500,
},
);
}
},
};
async function handleIngest(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return Response.json(
{ error: "Method not allowed" },
{
headers: jsonHeaders,
status: 405,
},
);
}
let payload: IngestPayload;
try {
payload = validateIngestPayload(await request.json());
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "Invalid payload" },
{
headers: jsonHeaders,
status: 400,
},
);
}
const geo = extractCfGeo(request);
const values = [
payload.reportId,
payload.deviceKey6,
payload.windowStart,
payload.windowEnd,
new Date().toISOString(),
payload.appVersion,
geo.country,
geo.region,
geo.city,
geo.latitude,
geo.longitude,
geo.colo,
...COUNT_KEYS.map((key) => payload.counts[key]),
];
const result = await env.DB.prepare(REPORT_INSERT_SQL).bind(...values).run();
const changes = Number((result.meta as { changes?: number }).changes ?? 0);
// Purge reports older than 7 days (best-effort, don't block response)
void purgeOldReports(env).catch(() => {});
return Response.json(
{
ok: true,
duplicate: changes === 0,
},
{
headers: jsonHeaders,
},
);
}
async function handleDashboard(request: Request, env: Env): Promise<Response> {
if (request.method !== "GET") {
return Response.json(
{ error: "Method not allowed" },
{
headers: jsonHeaders,
status: 405,
},
);
}
const url = new URL(request.url);
const windowParam = url.searchParams.get("window");
const summary = await loadDashboardSummary(env, windowParam);
const cacheTtl = windowParam === "7d" ? 86400 : 60;
return Response.json(
{
generatedAt: new Date().toISOString(),
...summary,
},
{
headers: {
...jsonHeaders,
"cache-control": `public, max-age=${cacheTtl}, s-maxage=${cacheTtl}`,
"cdn-cache-control": `max-age=${cacheTtl}`,
},
},
);
}

516
worker/worker/stats.ts Normal file
View File

@@ -0,0 +1,516 @@
export const PACKET_TYPE_KEYS = [
"pt_00",
"pt_01",
"pt_02",
"pt_03",
"pt_04",
"pt_05",
"pt_06",
"pt_07",
"pt_08",
"pt_09",
"pt_0a",
"pt_0b",
"pt_0c",
"pt_0d",
"pt_0e",
"pt_0f",
] as const;
export const PATH_MODE_KEYS = [
"path_mode_1b",
"path_mode_2b",
"path_mode_3b",
"path_mode_none",
"path_mode_unknown",
] as const;
export const COUNT_KEYS = [
...PACKET_TYPE_KEYS,
"decode_fail",
...PATH_MODE_KEYS,
] as const;
const REPORT_COLUMNS = [
"report_id",
"device_key6",
"window_start",
"window_end",
"received_at",
"app_version",
"cf_country",
"cf_region",
"cf_city",
"cf_latitude",
"cf_longitude",
"cf_colo",
...COUNT_KEYS,
] as const;
const PACKET_TYPE_LABELS: Record<(typeof PACKET_TYPE_KEYS)[number], string> = {
pt_00: "Request",
pt_01: "Response",
pt_02: "Text message",
pt_03: "Ack",
pt_04: "Advertisement",
pt_05: "Group text",
pt_06: "Group datagram",
pt_07: "Anonymous request",
pt_08: "Returned path",
pt_09: "Trace path",
pt_0a: "Multipart packet",
pt_0b: "Control packet",
pt_0c: "Reserved 0x0C",
pt_0d: "Reserved 0x0D",
pt_0e: "Reserved 0x0E",
pt_0f: "Custom packet",
};
const PATH_MODE_LABELS: Record<(typeof PATH_MODE_KEYS)[number], string> = {
path_mode_1b: "1-byte path hash",
path_mode_2b: "2-byte path hash",
path_mode_3b: "3-byte path hash",
path_mode_none: "No path bytes",
path_mode_unknown: "Unknown path mode",
};
const WINDOW_OPTIONS = {
"24h": {
label: "Last 24 hours",
durationMs: 24 * 60 * 60 * 1000,
bucket: "hour",
},
"7d": {
label: "Last 7 days",
durationMs: 7 * 24 * 60 * 60 * 1000,
bucket: "day",
},
} as const;
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
export const jsonHeaders = {
"content-type": "application/json; charset=utf-8",
} as const;
type CountKey = (typeof COUNT_KEYS)[number];
type PacketTypeKey = (typeof PACKET_TYPE_KEYS)[number];
type PathModeKey = (typeof PATH_MODE_KEYS)[number];
type WindowKey = keyof typeof WINDOW_OPTIONS;
export type Counts = Record<CountKey, number>;
export interface Env {
DB: D1Database;
}
export interface IngestPayload {
reportId: string;
deviceKey6: string;
windowStart: string;
windowEnd: string;
appVersion: string;
counts: Counts;
}
export interface CfGeo {
country: string | null;
region: string | null;
city: string | null;
latitude: number | null;
longitude: number | null;
colo: string | null;
}
export interface ReportRow extends Counts {
report_id: string;
device_key6: string;
window_start: string;
window_end: string;
received_at: string;
app_version: string | null;
cf_country: string | null;
cf_region: string | null;
cf_city: string | null;
cf_latitude: number | null;
cf_longitude: number | null;
cf_colo: string | null;
}
export interface WindowFilter {
windowKey: WindowKey;
label: string;
sinceIso: string;
bucket: "hour" | "day";
}
export interface ChartPoint {
label: string;
totalPackets: number;
reports: number;
}
export interface LocationPoint {
key6: string;
city: string;
country: string;
latitude: number;
longitude: number;
}
export interface ReporterSummary {
key6: string;
lastSeen: string;
packetTotal: number;
country: string;
city: string;
latitude: number | null;
longitude: number | null;
}
export interface AppVersionEntry {
version: string;
reporters: number;
packets: number;
}
export interface TrafficComposition {
human: number;
overhead: number;
acks: number;
}
export interface MultiHopRatio {
direct: number;
multiHop: number;
}
export interface CompositionPoint {
label: string;
human: number;
overhead: number;
}
export interface DashboardSummary {
filter: WindowFilter;
reportCount: number;
uniqueDevices: number;
decodedPackets: number;
decodeFailures: number;
packetTypeTotals: Array<{ key: PacketTypeKey; label: string; total: number }>;
pathModeTotals: Array<{ key: PathModeKey; label: string; total: number }>;
recentReporters: ReporterSummary[];
chartPoints: ChartPoint[];
locationPoints: LocationPoint[];
appVersions: AppVersionEntry[];
trafficComposition: TrafficComposition;
multiHopRatio: MultiHopRatio;
compositionOverTime: CompositionPoint[];
}
export const REPORT_INSERT_SQL = `
INSERT OR IGNORE INTO reports (
${REPORT_COLUMNS.join(", ")}
) VALUES (
${REPORT_COLUMNS.map(() => "?").join(", ")}
)`.trim();
export function createEmptyCounts(): Counts {
return Object.fromEntries(
COUNT_KEYS.map((key) => [key, 0]),
) as Counts;
}
export function validateIngestPayload(payload: unknown): IngestPayload {
if (typeof payload !== "object" || payload === null) {
throw new Error("Body must be a JSON object.");
}
const record = payload as Record<string, unknown>;
const reportId = asTrimmedString(record.reportId, "reportId");
const deviceKey6 = asTrimmedString(record.deviceKey6, "deviceKey6");
if (!/^[0-9a-f]{12}$/.test(deviceKey6)) {
throw new Error("deviceKey6 must be 12 lowercase hex characters.");
}
const windowStart = asIsoString(record.windowStart, "windowStart");
const windowEnd = asIsoString(record.windowEnd, "windowEnd");
if (Date.parse(windowEnd) < Date.parse(windowStart)) {
throw new Error("windowEnd must not be earlier than windowStart.");
}
const appVersion = asTrimmedString(record.appVersion, "appVersion");
const counts = validateCounts(record.counts);
return {
reportId,
deviceKey6,
windowStart,
windowEnd,
appVersion,
counts,
};
}
export function extractCfGeo(request: Request): CfGeo {
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
return {
country: asNullableString(cf?.country),
region: asNullableString(cf?.region),
city: asNullableString(cf?.city),
latitude: asNullableNumber(cf?.latitude),
longitude: asNullableNumber(cf?.longitude),
colo: asNullableString(cf?.colo),
};
}
export function buildWindowFilter(
requestedWindow: string | null,
now: Date = new Date(),
): WindowFilter {
const windowKey =
requestedWindow === "24h" ||
requestedWindow === "7d"
? requestedWindow
: "24h";
const option = WINDOW_OPTIONS[windowKey];
return {
windowKey,
label: option.label,
sinceIso: new Date(now.getTime() - option.durationMs).toISOString(),
bucket: option.bucket,
};
}
export async function purgeOldReports(
env: Env,
now: Date = new Date(),
): Promise<number> {
const cutoff = new Date(now.getTime() - RETENTION_MS).toISOString();
const result = await env.DB.prepare(
"DELETE FROM reports WHERE window_end < ?",
)
.bind(cutoff)
.run();
return Number((result.meta as { changes?: number }).changes ?? 0);
}
export async function loadDashboardSummary(
env: Env,
requestedWindow: string | null,
now: Date = new Date(),
): Promise<DashboardSummary> {
const filter = buildWindowFilter(requestedWindow, now);
const query = await env.DB.prepare(
"SELECT * FROM reports WHERE window_end >= ? ORDER BY window_end DESC",
)
.bind(filter.sinceIso)
.all<ReportRow>();
const rows = (query.results ?? []) as ReportRow[];
return summarizeRows(rows, filter);
}
export function summarizeRows(
rows: ReportRow[],
filter: WindowFilter,
): DashboardSummary {
const packetTypeTotals = PACKET_TYPE_KEYS.map((key) => ({
key,
label: PACKET_TYPE_LABELS[key],
total: sumRows(rows, key),
})).sort((left, right) => right.total - left.total);
const pathModeTotals = PATH_MODE_KEYS.map((key) => ({
key,
label: PATH_MODE_LABELS[key],
total: sumRows(rows, key),
})).sort((left, right) => right.total - left.total);
const decodedPackets = PACKET_TYPE_KEYS.reduce(
(total, key) => total + sumRows(rows, key),
0,
);
const decodeFailures = sumRows(rows, "decode_fail");
const reporterMap = new Map<string, ReporterSummary>();
const chartBuckets = new Map<string, ChartPoint>();
// per-version and per-colo accumulators
const versionMap = new Map<string, { reporters: Set<string>; packets: number }>();
const compositionBuckets = new Map<string, { human: number; overhead: number }>();
for (const row of rows) {
const packetTotal =
decodeFailuresForRow(row) +
PACKET_TYPE_KEYS.reduce((total, key) => total + row[key], 0);
const existingReporter = reporterMap.get(row.device_key6);
if (!existingReporter) {
reporterMap.set(row.device_key6, {
key6: row.device_key6,
lastSeen: row.window_end,
packetTotal,
country: row.cf_country ?? "Unknown",
city: row.cf_city ?? row.cf_region ?? "Unknown",
latitude: row.cf_latitude,
longitude: row.cf_longitude,
});
} else {
existingReporter.packetTotal += packetTotal;
if (row.window_end > existingReporter.lastSeen) {
existingReporter.lastSeen = row.window_end;
existingReporter.country = row.cf_country ?? existingReporter.country;
existingReporter.city =
row.cf_city ?? row.cf_region ?? existingReporter.city;
existingReporter.latitude = row.cf_latitude;
existingReporter.longitude = row.cf_longitude;
}
}
const bucketKey = formatBucket(row.window_end, filter.bucket);
const existingBucket = chartBuckets.get(bucketKey);
if (existingBucket) {
existingBucket.totalPackets += packetTotal;
existingBucket.reports += 1;
} else {
chartBuckets.set(bucketKey, {
label: bucketKey,
totalPackets: packetTotal,
reports: 1,
});
}
// app version
const ver = row.app_version ?? "unknown";
const verEntry = versionMap.get(ver);
if (verEntry) {
verEntry.reporters.add(row.device_key6);
verEntry.packets += packetTotal;
} else {
versionMap.set(ver, { reporters: new Set([row.device_key6]), packets: packetTotal });
}
// composition over time (human = text + group_text, overhead = rest)
const humanPackets = row.pt_02 + row.pt_05;
const overheadPackets = packetTotal - humanPackets;
const compBucket = compositionBuckets.get(bucketKey);
if (compBucket) {
compBucket.human += humanPackets;
compBucket.overhead += overheadPackets;
} else {
compositionBuckets.set(bucketKey, { human: humanPackets, overhead: overheadPackets });
}
}
const recentReporters = [...reporterMap.values()]
.sort((left, right) => right.lastSeen.localeCompare(left.lastSeen))
.slice(0, 12);
const locationPoints = recentReporters
.filter(
(
reporter,
): reporter is ReporterSummary & { latitude: number; longitude: number } =>
reporter.latitude !== null && reporter.longitude !== null,
)
.map((reporter) => ({
key6: reporter.key6,
city: reporter.city,
country: reporter.country,
latitude: reporter.latitude,
longitude: reporter.longitude,
}));
// human = text messages (pt_02 + pt_05), acks = pt_03, overhead = everything else
const humanTotal = sumRows(rows, "pt_02") + sumRows(rows, "pt_05");
const acksTotal = sumRows(rows, "pt_03");
const overheadTotal = decodedPackets - humanTotal - acksTotal;
// multi-hop: direct = path_mode_none, multiHop = 1b+2b+3b
const directTotal = sumRows(rows, "path_mode_none");
const multiHopTotal = sumRows(rows, "path_mode_1b") + sumRows(rows, "path_mode_2b") + sumRows(rows, "path_mode_3b");
return {
filter,
reportCount: rows.length,
uniqueDevices: reporterMap.size,
decodedPackets,
decodeFailures,
packetTypeTotals,
pathModeTotals,
recentReporters,
chartPoints: [...chartBuckets.values()].sort((left, right) =>
left.label.localeCompare(right.label),
),
locationPoints,
appVersions: [...versionMap.entries()]
.map(([version, entry]) => ({ version, reporters: entry.reporters.size, packets: entry.packets }))
.sort((left, right) => right.packets - left.packets),
trafficComposition: { human: humanTotal, overhead: overheadTotal, acks: acksTotal },
multiHopRatio: { direct: directTotal, multiHop: multiHopTotal },
compositionOverTime: [...compositionBuckets.entries()]
.map(([label, entry]) => ({ label, ...entry }))
.sort((left, right) => left.label.localeCompare(right.label)),
};
}
function validateCounts(value: unknown): Counts {
if (typeof value !== "object" || value === null) {
throw new Error("counts must be an object.");
}
const record = value as Record<string, unknown>;
const counts = createEmptyCounts();
for (const key of COUNT_KEYS) {
const rawValue = record[key];
if (typeof rawValue !== "number" || !Number.isFinite(rawValue)) {
throw new Error(`counts.${key} must be a number.`);
}
if (rawValue < 0) {
throw new Error(`counts.${key} must be zero or greater.`);
}
counts[key] = Math.trunc(rawValue);
}
return counts;
}
function sumRows(rows: ReportRow[], key: CountKey): number {
return rows.reduce((total, row) => total + (row[key] ?? 0), 0);
}
function decodeFailuresForRow(row: ReportRow): number {
return row.decode_fail ?? 0;
}
function formatBucket(value: string, bucket: "hour" | "day"): string {
const date = new Date(value);
const month = `${date.getUTCMonth() + 1}`.padStart(2, "0");
const day = `${date.getUTCDate()}`.padStart(2, "0");
if (bucket === "day") {
return `${date.getUTCFullYear()}-${month}-${day}`;
}
const hour = `${date.getUTCHours()}`.padStart(2, "0");
return `${date.getUTCFullYear()}-${month}-${day} ${hour}:00`;
}
function asTrimmedString(value: unknown, fieldName: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${fieldName} must be a non-empty string.`);
}
return value.trim();
}
function asIsoString(value: unknown, fieldName: string): string {
const stringValue = asTrimmedString(value, fieldName);
if (Number.isNaN(Date.parse(stringValue))) {
throw new Error(`${fieldName} must be a valid ISO-8601 timestamp.`);
}
return new Date(stringValue).toISOString();
}
function asNullableString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}
function asNullableNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}

20
worker/wrangler.toml Normal file
View File

@@ -0,0 +1,20 @@
name = "meshcore-sar-stats"
main = "worker/index.ts"
compatibility_date = "2026-04-03"
compatibility_flags = ["nodejs_compat"]
[observability.logs]
enabled = false
[placement]
mode = "smart"
[assets]
directory = "./dist"
not_found_handling = "404-page"
run_worker_first = ["/api/*"]
[[d1_databases]]
binding = "DB"
database_name = "meshcore_sar_rx_stats"
database_id = "94a3978f-13d9-4dc8-9a3c-2beb102699e5"