mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Add Leaflet map to stats dashboard #123
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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='© <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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user