feat: Add Leaflet map to stats dashboard #123

This commit is contained in:
Janez T
2026-04-03 22:17:23 +02:00
parent 9244368b49
commit 0cd563063a
3 changed files with 370 additions and 297 deletions

View File

@@ -11,11 +11,14 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.18",
"@types/leaflet": "^1.9.21",
"astro": "^5.17.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
},
@@ -276,6 +279,8 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
@@ -392,8 +397,12 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
@@ -664,6 +673,8 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -854,6 +865,8 @@
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],

View File

@@ -19,12 +19,14 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.17.1"
,
"@types/leaflet": "^1.9.21",
"astro": "^5.17.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18"
},

View File

@@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type WindowKey = "24h" | "7d" | "30d";
@@ -64,11 +63,38 @@ type DashboardResponse = {
};
const WINDOW_OPTIONS: Array<{ key: WindowKey; label: string }> = [
{ key: "24h", label: "Last 24 hours" },
{ key: "7d", label: "Last 7 days" },
{ key: "30d", label: "Last 30 days" },
{ key: "24h", label: "24h" },
{ key: "7d", label: "7 days" },
{ key: "30d", label: "30 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);
@@ -83,325 +109,259 @@ export function DashboardShell() {
setError(null);
try {
const response = await fetch(`/api/dashboard?window=${windowKey}`, {
headers: {
accept: "application/json",
},
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);
}
if (!isCancelled) setSummary(nextSummary);
} catch (nextError) {
if (!isCancelled) {
setError(
nextError instanceof Error ? nextError.message : String(nextError),
);
setError(nextError instanceof Error ? nextError.message : String(nextError));
}
} finally {
if (!isCancelled) {
setIsLoading(false);
}
if (!isCancelled) setIsLoading(false);
}
}
void load();
return () => {
isCancelled = true;
};
return () => { isCancelled = true; };
}, [windowKey]);
const topPacketTypes = useMemo(
() => (summary?.packetTypeTotals ?? []).filter((entry) => entry.total > 0).slice(0, 8),
[summary],
);
const topPacketMix = useMemo(
() => (summary?.packetTypeTotals ?? []).filter((entry) => entry.total > 0).slice(0, 6),
const activePacketTypes = useMemo(
() => (summary?.packetTypeTotals ?? []).filter((e) => e.total > 0),
[summary],
);
const activePathModes = useMemo(
() => (summary?.pathModeTotals ?? []).filter((entry) => entry.total > 0),
() => (summary?.pathModeTotals ?? []).filter((e) => e.total > 0),
[summary],
);
const maxPacketMix = Math.max(...topPacketMix.map((entry) => entry.total), 1);
const maxTrend = Math.max(...(summary?.chartPoints ?? []).map((point) => point.totalPackets), 1);
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);
return (
<div className="mx-auto max-w-[1320px] px-5 py-8">
<Tabs value={windowKey} onValueChange={(value) => setWindowKey(value as WindowKey)}>
<section className="grid gap-5 lg:grid-cols-[1.8fr_1fr]">
<Card className="overflow-hidden">
<CardHeader className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary" className="rounded-full px-3 py-1 text-[0.65rem] uppercase tracking-[0.18em]">
MeshCore SAR
</Badge>
<Badge variant="outline" className="bg-white/70">
Anonymous RX ingest
</Badge>
</div>
<div className="space-y-3">
<CardTitle className="max-w-[10ch] text-4xl leading-none md:text-6xl">
Anonymous RX traffic stats
</CardTitle>
<CardDescription className="max-w-3xl text-sm leading-6 md:text-base">
shadcn-based Cloudflare dashboard for RX live-traffic packet types and
path-hash modes. Location comes from Cloudflare ingress metadata, while
device identity is reduced to key6.
</CardDescription>
</div>
<TabsList className="h-auto flex-wrap justify-start gap-1 rounded-[999px] bg-white/70 p-1">
{WINDOW_OPTIONS.map((option) => (
<TabsTrigger key={option.key} value={option.key}>
{option.label}
</TabsTrigger>
))}
</TabsList>
</CardHeader>
</Card>
<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">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground">M</span>
<h1 className="text-2xl font-semibold tracking-tight">MeshCore SAR</h1>
</div>
<p className="max-w-xl text-sm text-muted-foreground">
Anonymous mesh network traffic overview. Location is derived from Cloudflare ingress metadata.
</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>
<Card className="justify-between">
<CardHeader>
<Badge variant="outline" className="w-fit bg-white/70">
Current range
</Badge>
<CardTitle className="text-4xl">
{summary?.filter.label ?? (isLoading ? "Loading…" : "Unavailable")}
</CardTitle>
<CardDescription className="leading-6">
{summary
? `Showing reports with window end after ${formatTimestamp(summary.filter.sinceIso)}. Generated ${formatTimestamp(summary.generatedAt)}.`
: isLoading
? "Fetching dashboard data from D1."
: "The dashboard API did not return data."}
</CardDescription>
</CardHeader>
{error ? (
<CardContent>
<div className="rounded-2xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
<div className="mt-4">
<Button onClick={() => setWindowKey((current) => current)}>Retry</Button>
</div>
</CardContent>
) : null}
</Card>
</section>
{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-5">
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
<MetricCard label="Reports" value={summary?.reportCount ?? 0} note="Accepted upload windows in this range." />
<MetricCard label="Reporters" value={summary?.uniqueDevices ?? 0} note="Unique key6 reporters." />
<MetricCard label="Decoded RX" value={summary?.decodedPackets ?? 0} note="Packets grouped by known payload type." />
<MetricCard label="Decode Fail" value={summary?.decodeFailures ?? 0} note="Malformed or undecodable RX packets." />
<TabsContent value={windowKey} className="space-y-6">
{/* Key metrics */}
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="Active Nodes"
value={summary?.uniqueDevices ?? 0}
note="Unique reporting nodes in this window"
/>
<MetricCard
label="Total Packets"
value={totalPackets}
note="All received packets (decoded + failed)"
/>
<MetricCard
label="Decode Rate"
value={`${decodeRate}%`}
note={`${summary?.decodedPackets ?? 0} decoded, ${summary?.decodeFailures ?? 0} failed`}
/>
<MetricCard
label="Packet Types"
value={activePacketTypes.length}
note={`of 16 protocol types observed`}
/>
</section>
<section className="grid gap-5 xl:grid-cols-[1.25fr_0.95fr]">
{/* Map + Traffic trend */}
<section className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">
<Card>
<CardHeader>
<CardTitle>Traffic trend</CardTitle>
<CardDescription>Packets per reporting bucket.</CardDescription>
<CardTitle>Reporter Locations</CardTitle>
<CardDescription>
Approximate locations from Cloudflare edge nodes, not device GPS.
</CardDescription>
</CardHeader>
<CardContent>
<ReporterMap locations={summary?.locationPoints ?? []} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Traffic Trend</CardTitle>
<CardDescription>Packets per {summary?.filter.bucket ?? "time"} bucket</CardDescription>
</CardHeader>
<CardContent>
{isLoading && !summary ? (
<EmptyState label="Loading traffic trend…" />
<EmptyState label="Loading..." />
) : summary?.chartPoints.length ? (
<div className="grid min-h-[220px] grid-cols-[repeat(auto-fit,minmax(32px,1fr))] items-end gap-2">
<div className="flex min-h-[280px] items-end gap-[3px]">
{summary.chartPoints.map((point) => {
const height = Math.max((point.totalPackets / maxTrend) * 180, 8);
const pct = Math.max((point.totalPackets / maxTrend) * 100, 3);
return (
<div key={point.label} className="grid min-h-[220px] content-end gap-3">
<div className="text-center text-xs font-semibold">
<div key={point.label} className="group flex flex-1 flex-col items-center gap-1" title={`${point.label}\n${point.totalPackets} packets`}>
<span className="text-[0.6rem] font-medium opacity-0 transition-opacity group-hover:opacity-100">
{point.totalPackets}
</div>
</span>
<div
className="rounded-[14px_14px_8px_8px] bg-gradient-to-b from-sky-600 to-teal-600"
style={{ height }}
className="w-full rounded-t-md bg-gradient-to-t from-primary/80 to-primary transition-all group-hover:from-primary group-hover:to-primary"
style={{ height: `${pct}%` }}
/>
<div className="text-center text-[0.7rem] text-muted-foreground">
<span className="max-w-full truncate text-[0.55rem] text-muted-foreground">
{point.label.slice(5)}
</span>
</div>
);
})}
</div>
) : (
<EmptyState label="No data for this window." />
)}
</CardContent>
</Card>
</section>
{/* Protocol breakdown */}
<section className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">
<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()}</span>
</div>
</div>
</div>
);
})}
</div>
) : (
<EmptyState label="No data yet for this window." />
<EmptyState label="No packet data yet." />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Path mode distribution</CardTitle>
<CardDescription>1-byte, 2-byte, 3-byte, none, and unknown path hashes.</CardDescription>
</CardHeader>
<CardContent>
{activePathModes.length ? (
<div className="flex flex-wrap gap-3">
{activePathModes.map((entry) => (
<Badge
key={entry.key}
variant="outline"
className="rounded-2xl bg-white/70 px-4 py-3 text-left"
>
<span className="block text-lg font-semibold">{entry.total}</span>
<span className="text-xs text-muted-foreground">{entry.label}</span>
</Badge>
))}
</div>
) : (
<EmptyState label="No path mode samples yet." />
)}
</CardContent>
</Card>
</section>
<section className="grid gap-5 xl:grid-cols-[1.25fr_0.95fr]">
<Card>
<CardHeader>
<CardTitle>Top packet types</CardTitle>
<CardDescription>Top fixed columns aggregated from D1.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Label</TableHead>
<TableHead>Column</TableHead>
<TableHead className="text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topPacketTypes.length ? (
topPacketTypes.map((entry) => (
<TableRow key={entry.key}>
<TableCell>{entry.label}</TableCell>
<TableCell>
<code>{entry.key}</code>
</TableCell>
<TableCell className="text-right font-semibold">
{entry.total}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
No packet data yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent reporters</CardTitle>
<CardDescription>Latest key6 reporters with Cloudflare ingress geo.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>key6</TableHead>
<TableHead>City</TableHead>
<TableHead>Country</TableHead>
<TableHead className="text-right">Packets</TableHead>
<TableHead className="text-right">Last seen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary?.recentReporters.length ? (
summary.recentReporters.map((reporter) => (
<TableRow key={`${reporter.key6}-${reporter.lastSeen}`}>
<TableCell>
<code>{reporter.key6}</code>
</TableCell>
<TableCell>{reporter.city}</TableCell>
<TableCell>{reporter.country}</TableCell>
<TableCell className="text-right font-semibold">
{reporter.packetTotal}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{formatTimestamp(reporter.lastSeen)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No reporter activity yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</section>
<section className="grid gap-5 xl:grid-cols-[1.25fr_0.95fr]">
<Card>
<CardHeader>
<CardTitle>Cloudflare geo map</CardTitle>
<CardDescription>
Dots reflect Cloudflare ingress latitude and longitude, not device GPS coordinates.
</CardDescription>
</CardHeader>
<CardContent>
<div className="relative min-h-[320px] overflow-hidden rounded-[1.25rem] border border-white/10 bg-[radial-gradient(circle_at_25%_35%,rgba(255,255,255,0.14),transparent_16%),radial-gradient(circle_at_72%_48%,rgba(255,255,255,0.12),transparent_18%),linear-gradient(180deg,#10263d_0%,#173858_100%)]">
<div className="absolute inset-[18%_auto_auto_8%] h-[22%] w-[26%] rounded-full bg-white/10 blur-[1px]" />
<div className="absolute inset-[20%_auto_auto_38%] h-[18%] w-[20%] rounded-full bg-white/10 blur-[1px]" />
<div className="absolute inset-[30%_10%_auto_auto] h-[26%] w-[26%] rounded-full bg-white/10 blur-[1px]" />
<div className="absolute inset-[auto_auto_12%_34%] h-[20%] w-[18%] rounded-full bg-white/10 blur-[1px]" />
{(summary?.locationPoints ?? []).map((point) => (
<div
key={`${point.key6}-${point.latitude}-${point.longitude}`}
className="absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-orange-400 shadow-[0_0_0_8px_rgba(255,143,60,0.16)]"
style={{
left: `${((point.longitude + 180) / 360) * 100}%`,
top: `${((90 - point.latitude) / 180) * 100}%`,
}}
title={`${point.key6} · ${point.city}, ${point.country}`}
/>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Packet type mix</CardTitle>
<CardDescription>Top packet types as share of the busiest series.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{topPacketMix.length ? (
topPacketMix.map((entry) => (
<div key={entry.key} className="space-y-2">
<div className="flex items-center justify-between gap-3 text-sm">
<span>{entry.label}</span>
<span className="font-semibold">{entry.total}</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-gradient-to-r from-sky-600 to-teal-600"
style={{ width: `${(entry.total / maxPacketMix) * 100}%` }}
/>
</div>
<div className="space-y-4">
<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-3">
{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 packet mix data yet." />
)}
</CardContent>
</Card>
) : (
<EmptyState label="No path mode samples." />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Reporting Regions</CardTitle>
<CardDescription>Geographic distribution of mesh nodes</CardDescription>
</CardHeader>
<CardContent>
{summary?.locationPoints.length ? (
<div className="flex flex-wrap gap-2">
{dedupeLocations(summary.locationPoints).map((loc) => (
<Badge key={`${loc.city}-${loc.country}`} variant="outline" className="gap-1.5 px-3 py-1.5">
<span className="font-medium">{loc.city}</span>
<span className="text-muted-foreground">{loc.country}</span>
</Badge>
))}
</div>
) : (
<EmptyState label="No location data yet." />
)}
</CardContent>
</Card>
</div>
</section>
</TabsContent>
</Tabs>
@@ -409,22 +369,12 @@ export function DashboardShell() {
);
}
function MetricCard({
label,
value,
note,
}: {
label: string;
value: number;
note: string;
}) {
function MetricCard({ label, value, note }: { label: string; value: number | string; note: string }) {
return (
<Card>
<CardHeader className="gap-3">
<Badge variant="outline" className="w-fit bg-white/70">
{label}
</Badge>
<CardTitle className="text-4xl">{value}</CardTitle>
<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>
@@ -433,17 +383,125 @@ function MetricCard({
function EmptyState({ label }: { label: string }) {
return (
<div className="rounded-2xl border border-dashed border-border bg-white/55 px-4 py-6 text-sm text-muted-foreground">
<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 formatTimestamp(value: string) {
const date = new Date(value);
const month = `${date.getUTCMonth() + 1}`.padStart(2, "0");
const day = `${date.getUTCDate()}`.padStart(2, "0");
const hour = `${date.getUTCHours()}`.padStart(2, "0");
const minute = `${date.getUTCMinutes()}`.padStart(2, "0");
return `${date.getUTCFullYear()}-${month}-${day} ${hour}:${minute} UTC`;
function ReporterMap({ locations }: { locations: LocationPoint[] }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted || typeof window === "undefined") {
return (
<div className="flex min-h-[360px] items-center justify-center rounded-xl bg-secondary/30 text-sm text-muted-foreground">
Loading map...
</div>
);
}
return <LeafletMap locations={locations} />;
}
function LeafletMap({ locations }: { locations: LocationPoint[] }) {
const [leaflet, setLeaflet] = useState<{
MapContainer: typeof import("react-leaflet").MapContainer;
TileLayer: typeof import("react-leaflet").TileLayer;
CircleMarker: typeof import("react-leaflet").CircleMarker;
Tooltip: typeof import("react-leaflet").Tooltip;
L: typeof import("leaflet");
} | null>(null);
useEffect(() => {
Promise.all([
import("react-leaflet"),
import("leaflet"),
]).then(([rl, L]) => {
setLeaflet({
MapContainer: rl.MapContainer,
TileLayer: rl.TileLayer,
CircleMarker: rl.CircleMarker,
Tooltip: rl.Tooltip,
L: L.default ?? L,
});
});
}, []);
if (!leaflet) {
return (
<div className="flex min-h-[360px] items-center justify-center rounded-xl bg-secondary/30 text-sm text-muted-foreground">
Loading map...
</div>
);
}
const { MapContainer, TileLayer, CircleMarker, Tooltip } = leaflet;
const center: [number, number] = locations.length
? [
locations.reduce((s, l) => s + l.latitude, 0) / locations.length,
locations.reduce((s, l) => s + l.longitude, 0) / locations.length,
]
: [46.0, 14.5];
return (
<>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<div className="overflow-hidden rounded-xl">
<MapContainer
center={center}
zoom={locations.length > 1 ? 4 : 8}
scrollWheelZoom={true}
style={{ height: 360, width: "100%" }}
attributionControl={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{locations.map((loc) => (
<CircleMarker
key={`${loc.latitude}-${loc.longitude}-${loc.city}`}
center={[loc.latitude, loc.longitude]}
radius={8}
pathOptions={{
color: "hsl(178, 83%, 31%)",
fillColor: "hsl(178, 83%, 45%)",
fillOpacity: 0.6,
weight: 2,
}}
>
<Tooltip>
{loc.city}, {loc.country}
</Tooltip>
</CircleMarker>
))}
</MapContainer>
</div>
</>
);
}
function dedupeLocations(locations: LocationPoint[]) {
const seen = new Set<string>();
return locations.filter((loc) => {
const key = `${loc.city}-${loc.country}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
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`;
}