From 0cd563063aa7b90832893c927a40643fd91c72f3 Mon Sep 17 00:00:00 2001 From: Janez T Date: Fri, 3 Apr 2026 22:17:23 +0200 Subject: [PATCH] feat: Add Leaflet map to stats dashboard #123 --- worker/bun.lock | 13 + worker/package.json | 6 +- .../components/dashboard/dashboard-shell.tsx | 648 ++++++++++-------- 3 files changed, 370 insertions(+), 297 deletions(-) diff --git a/worker/bun.lock b/worker/bun.lock index fc1ccbe..eb0a7f8 100644 --- a/worker/bun.lock +++ b/worker/bun.lock @@ -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=="], diff --git a/worker/package.json b/worker/package.json index d7a3a6f..c32a605 100644 --- a/worker/package.json +++ b/worker/package.json @@ -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" }, diff --git a/worker/src/components/dashboard/dashboard-shell.tsx b/worker/src/components/dashboard/dashboard-shell.tsx index 4a3c3b9..c6eadc5 100644 --- a/worker/src/components/dashboard/dashboard-shell.tsx +++ b/worker/src/components/dashboard/dashboard-shell.tsx @@ -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 = { + 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 = { + 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("24h"); const [summary, setSummary] = useState(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 (
- setWindowKey(value as WindowKey)}> -
- - -
- - MeshCore SAR - - - Anonymous RX ingest - -
-
- - Anonymous RX traffic stats - - - 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. - -
- - {WINDOW_OPTIONS.map((option) => ( - - {option.label} - - ))} - -
-
+ setWindowKey(v as WindowKey)}> + {/* Header */} +
+
+
+ M +

MeshCore SAR

+
+

+ Anonymous mesh network traffic overview. Location is derived from Cloudflare ingress metadata. +

+
+
+ + {WINDOW_OPTIONS.map((o) => ( + + {o.label} + + ))} + + {summary && ( + + Updated {formatRelative(summary.generatedAt)} + + )} +
+
- - - - Current range - - - {summary?.filter.label ?? (isLoading ? "Loading…" : "Unavailable")} - - - {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."} - - - {error ? ( - -
- {error} -
-
- -
-
- ) : null} -
-
+ {error && ( +
+ {error} + +
+ )} - -
- - - - + + {/* Key metrics */} +
+ + + +
-
+ {/* Map + Traffic trend */} +
- Traffic trend - Packets per reporting bucket. + Reporter Locations + + Approximate locations from Cloudflare edge nodes, not device GPS. + + + + + + + + + + Traffic Trend + Packets per {summary?.filter.bucket ?? "time"} bucket {isLoading && !summary ? ( - + ) : summary?.chartPoints.length ? ( -
+
{summary.chartPoints.map((point) => { - const height = Math.max((point.totalPackets / maxTrend) * 180, 8); + const pct = Math.max((point.totalPackets / maxTrend) * 100, 3); return ( -
-
+
+ {point.totalPackets} -
+
-
+ {point.label.slice(5)} + +
+ ); + })} +
+ ) : ( + + )} + + +
+ + {/* Protocol breakdown */} +
+ + + Protocol Packet Types + + MeshCore protocol uses 16 packet type codes (0x00 - 0x0F). + Showing types with traffic in this window. + + + + {activePacketTypes.length ? ( +
+ {activePacketTypes.map((entry) => { + const pct = totalPackets > 0 ? ((entry.total / totalPackets) * 100).toFixed(1) : "0"; + const info = PACKET_TYPE_INFO[entry.key]; + return ( +
+
+ {entry.key.replace("pt_", "0x").toUpperCase()} +
+
+
+ {entry.label} + {pct}% +
+
+ {info?.title ?? entry.key} +
+

+ {info?.summary ?? ""} +

+
+
+
+
+ {entry.total.toLocaleString()} +
); })}
) : ( - + )} - - - Path mode distribution - 1-byte, 2-byte, 3-byte, none, and unknown path hashes. - - - {activePathModes.length ? ( -
- {activePathModes.map((entry) => ( - - {entry.total} - {entry.label} - - ))} -
- ) : ( - - )} -
-
-
- -
- - - Top packet types - Top fixed columns aggregated from D1. - - - - - - Label - Column - Total - - - - {topPacketTypes.length ? ( - topPacketTypes.map((entry) => ( - - {entry.label} - - {entry.key} - - - {entry.total} - - - )) - ) : ( - - - No packet data yet. - - - )} - -
-
-
- - - - Recent reporters - Latest key6 reporters with Cloudflare ingress geo. - - - - - - key6 - City - Country - Packets - Last seen - - - - {summary?.recentReporters.length ? ( - summary.recentReporters.map((reporter) => ( - - - {reporter.key6} - - {reporter.city} - {reporter.country} - - {reporter.packetTotal} - - - {formatTimestamp(reporter.lastSeen)} - - - )) - ) : ( - - - No reporter activity yet. - - - )} - -
-
-
-
- -
- - - Cloudflare geo map - - Dots reflect Cloudflare ingress latitude and longitude, not device GPS coordinates. - - - -
-
-
-
-
- {(summary?.locationPoints ?? []).map((point) => ( -
- ))} -
- - - - - - Packet type mix - Top packet types as share of the busiest series. - - - {topPacketMix.length ? ( - topPacketMix.map((entry) => ( -
-
- {entry.label} - {entry.total} -
-
-
-
+
+ + + Path Routing Modes + + Path hash byte length determines routing precision. + Longer hashes allow more specific multi-hop paths. + + + + {activePathModes.length ? ( +
+ {activePathModes.map((entry) => { + const pct = totalPackets > 0 ? ((entry.total / totalPackets) * 100).toFixed(1) : "0"; + return ( +
+
+ {PATH_MODE_ICONS[entry.key] ?? "?"} +
+
{entry.total.toLocaleString()}
+
{entry.label}
+
{pct}%
+
+ ); + })}
- )) - ) : ( - - )} -
-
+ ) : ( + + )} + + + + + + Reporting Regions + Geographic distribution of mesh nodes + + + {summary?.locationPoints.length ? ( +
+ {dedupeLocations(summary.locationPoints).map((loc) => ( + + {loc.city} + {loc.country} + + ))} +
+ ) : ( + + )} +
+
+
@@ -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 ( - - - {label} - - {value} + + {label} + {typeof value === "number" ? value.toLocaleString() : value} {note} @@ -433,17 +383,125 @@ function MetricCard({ function EmptyState({ label }: { label: string }) { return ( -
+
{label}
); } -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 ( +
+ Loading map... +
+ ); + } + + return ; +} + +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 ( +
+ Loading map... +
+ ); + } + + 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 ( + <> + +
+ 1 ? 4 : 8} + scrollWheelZoom={true} + style={{ height: 360, width: "100%" }} + attributionControl={true} + > + + {locations.map((loc) => ( + + + {loc.city}, {loc.country} + + + ))} + +
+ + ); +} + +function dedupeLocations(locations: LocationPoint[]) { + const seen = new Set(); + 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`; }