feat: Add anonymous RX stats

ref:
This commit is contained in:
Janez T
2026-04-03 19:52:45 +02:00
parent 7c1bc3b84f
commit c089db5c62
16 changed files with 1023 additions and 649 deletions

View File

@@ -0,0 +1,449 @@
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";
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 LocationPoint = {
key6: string;
city: string;
country: string;
latitude: number;
longitude: 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[];
locationPoints: LocationPoint[];
};
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" },
];
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 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),
[summary],
);
const activePathModes = useMemo(
() => (summary?.pathModeTotals ?? []).filter((entry) => entry.total > 0),
[summary],
);
const maxPacketMix = Math.max(...topPacketMix.map((entry) => entry.total), 1);
const maxTrend = Math.max(...(summary?.chartPoints ?? []).map((point) => point.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>
<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>
<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." />
</section>
<section className="grid gap-5 xl:grid-cols-[1.25fr_0.95fr]">
<Card>
<CardHeader>
<CardTitle>Traffic trend</CardTitle>
<CardDescription>Packets per reporting bucket.</CardDescription>
</CardHeader>
<CardContent>
{isLoading && !summary ? (
<EmptyState label="Loading traffic trend…" />
) : summary?.chartPoints.length ? (
<div className="grid min-h-[220px] grid-cols-[repeat(auto-fit,minmax(32px,1fr))] items-end gap-2">
{summary.chartPoints.map((point) => {
const height = Math.max((point.totalPackets / maxTrend) * 180, 8);
return (
<div key={point.label} className="grid min-h-[220px] content-end gap-3">
<div className="text-center text-xs font-semibold">
{point.totalPackets}
</div>
<div
className="rounded-[14px_14px_8px_8px] bg-gradient-to-b from-sky-600 to-teal-600"
style={{ height }}
/>
<div className="text-center text-[0.7rem] text-muted-foreground">
{point.label.slice(5)}
</div>
</div>
);
})}
</div>
) : (
<EmptyState label="No data yet for this window." />
)}
</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>
))
) : (
<EmptyState label="No packet mix data yet." />
)}
</CardContent>
</Card>
</section>
</TabsContent>
</Tabs>
</div>
);
}
function MetricCard({
label,
value,
note,
}: {
label: string;
value: number;
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>
<CardDescription>{note}</CardDescription>
</CardHeader>
</Card>
);
}
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">
{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`;
}

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-white/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-white/70 py-6 shadow-[0_18px_48px_rgba(16,33,47,0.08)] 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 };

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

@@ -0,0 +1,67 @@
@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: 206 46% 97%;
--foreground: 208 50% 13%;
--card: 0 0% 100%;
--card-foreground: 208 50% 13%;
--primary: 178 83% 31%;
--primary-foreground: 0 0% 100%;
--secondary: 207 66% 92%;
--secondary-foreground: 208 50% 13%;
--muted: 204 31% 93%;
--muted-foreground: 208 19% 45%;
--accent: 28 100% 62%;
--accent-foreground: 208 50% 13%;
--destructive: 347 54% 48%;
--destructive-foreground: 0 0% 100%;
--border: 206 22% 87%;
--input: 206 22% 87%;
--ring: 178 83% 31%;
--radius: 1rem;
}
* {
border-color: hsl(var(--border));
}
html {
font-family: var(--font-sans);
}
body {
min-height: 100vh;
background:
radial-gradient(circle at top left, hsl(178 83% 31% / 0.14), transparent 30%),
radial-gradient(circle at top right, hsl(211 64% 39% / 0.12), transparent 24%),
linear-gradient(180deg, #f8fbfd 0%, hsl(var(--background)) 100%);
color: hsl(var(--foreground));
}
}

View File

@@ -4,6 +4,8 @@ interface Props {
}
const { title = "MeshCore SAR RX Stats" } = Astro.props;
import "../index.css";
---
<!doctype html>
@@ -12,328 +14,6 @@ const { title = "MeshCore SAR RX Stats" } = Astro.props;
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<style>
:root {
color-scheme: light;
--bg: #ecf2f7;
--panel: rgba(255, 255, 255, 0.86);
--panel-strong: #ffffff;
--text: #10212f;
--muted: #607284;
--line: rgba(16, 33, 47, 0.1);
--brand: #0d8f8a;
--brand-2: #235fa4;
--accent: #ff8f3c;
--danger: #b9384f;
--shadow: 0 18px 48px rgba(16, 33, 47, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(13, 143, 138, 0.16), transparent 32%),
radial-gradient(circle at top right, rgba(35, 95, 164, 0.12), transparent 26%),
linear-gradient(180deg, #f7fafc 0%, var(--bg) 100%);
color: var(--text);
}
.shell {
max-width: 1320px;
margin: 0 auto;
padding: 28px 20px 44px;
}
.hero,
.kpi-grid,
.content-grid {
display: grid;
gap: 20px;
}
.hero {
grid-template-columns: 1.8fr 1fr;
margin-bottom: 22px;
}
.kpi-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 20px;
}
.content-grid {
grid-template-columns: 1.25fr 0.95fr;
margin-bottom: 20px;
}
.panel,
.hero-card,
.kpi {
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 28px;
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.hero-card,
.panel,
.kpi {
padding: 24px;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: clamp(2rem, 4vw, 3.4rem);
line-height: 0.94;
max-width: 10ch;
}
h2 {
font-size: 1.1rem;
margin-bottom: 14px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.72rem;
color: var(--muted);
margin-bottom: 12px;
}
.hero-copy {
max-width: 58ch;
color: var(--muted);
line-height: 1.55;
margin-top: 14px;
}
.window-tabs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 22px;
}
.window-tab {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.48);
color: var(--text);
border-radius: 999px;
padding: 10px 14px;
font-weight: 600;
cursor: pointer;
}
.window-tab[data-active="true"] {
background: linear-gradient(135deg, var(--brand), var(--brand-2));
color: #fff;
border-color: transparent;
}
.hero-value,
.kpi-value {
font-weight: 700;
}
.hero-value {
font-size: 2.3rem;
line-height: 1;
}
.hero-note,
.kpi-note,
.empty,
.legend {
color: var(--muted);
}
.hero-note,
.kpi-note,
.legend,
.table-note,
.empty {
font-size: 0.92rem;
line-height: 1.5;
}
.kpi-value {
font-size: 2rem;
margin: 8px 0 4px;
}
.bars {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(26px, 1fr));
gap: 8px;
align-items: end;
min-height: 220px;
}
.bar-card {
display: grid;
gap: 10px;
align-content: end;
min-height: 220px;
}
.bar {
min-height: 6px;
border-radius: 14px 14px 8px 8px;
background: linear-gradient(180deg, #235fa4 0%, #0d8f8a 100%);
}
.bar-label {
font-size: 0.75rem;
color: var(--muted);
writing-mode: vertical-rl;
transform: rotate(180deg);
height: 72px;
margin: 0 auto;
}
.bar-value {
text-align: center;
font-size: 0.8rem;
font-weight: 600;
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.pill {
min-width: 150px;
border-radius: 20px;
padding: 12px 14px;
background: rgba(13, 143, 138, 0.08);
}
.pill strong {
display: block;
font-size: 1.1rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 10px 0;
border-bottom: 1px solid var(--line);
font-size: 0.95rem;
}
th {
color: var(--muted);
font-weight: 600;
}
code {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.88rem;
}
.meter-stack {
display: grid;
gap: 14px;
}
.meter-row {
display: grid;
gap: 6px;
}
.meter-head {
display: flex;
justify-content: space-between;
gap: 12px;
}
.meter-track {
height: 12px;
border-radius: 999px;
overflow: hidden;
background: rgba(35, 95, 164, 0.1);
}
.meter-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #235fa4, #0d8f8a);
}
.map {
position: relative;
min-height: 320px;
border-radius: 20px;
overflow: hidden;
background:
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%);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.map::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(transparent 49.5%, rgba(255, 255, 255, 0.05) 50%, transparent 50.5%),
linear-gradient(90deg, transparent 49.5%, rgba(255, 255, 255, 0.05) 50%, transparent 50.5%);
opacity: 0.5;
}
.continent {
position: absolute;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
filter: blur(1px);
}
.continent.one {
inset: 18% auto auto 8%;
width: 26%;
height: 22%;
}
.continent.two {
inset: 20% auto auto 38%;
width: 20%;
height: 18%;
}
.continent.three {
inset: 30% 10% auto auto;
width: 26%;
height: 26%;
}
.continent.four {
inset: auto auto 12% 34%;
width: 18%;
height: 20%;
}
.map-dot {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
border: 2px solid rgba(255, 255, 255, 0.92);
transform: translate(-50%, -50%);
box-shadow: 0 0 0 8px rgba(255, 143, 60, 0.16);
}
.map-dot::after {
content: attr(data-label);
position: absolute;
left: 16px;
top: -8px;
padding: 5px 8px;
border-radius: 999px;
background: rgba(16, 33, 47, 0.74);
color: #fff;
white-space: nowrap;
font-size: 0.72rem;
}
.banner {
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(185, 56, 79, 0.1);
color: var(--danger);
border: 1px solid rgba(185, 56, 79, 0.16);
}
[hidden] {
display: none !important;
}
@media (max-width: 1040px) {
.hero,
.kpi-grid,
.content-grid {
grid-template-columns: 1fr;
}
.bar-label {
writing-mode: initial;
transform: none;
height: auto;
}
.bars {
grid-template-columns: repeat(auto-fit, minmax(52px, 1fr));
}
}
</style>
</head>
<body>
<slot />

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

@@ -1,333 +1,8 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
const windowOptions = [
{ key: "24h", label: "Last 24 hours" },
{ key: "7d", label: "Last 7 days" },
{ key: "30d", label: "Last 30 days" },
];
import { DashboardShell } from "@/components/dashboard/dashboard-shell";
---
<BaseLayout title="MeshCore SAR RX Stats">
<div class="shell">
<div class="banner" id="error-banner" hidden></div>
<section class="hero">
<div class="hero-card">
<div class="eyebrow">MeshCore SAR</div>
<h1>Anonymous RX traffic stats</h1>
<p class="hero-copy">
Static Astro dashboard for RX live-traffic packet types and path-hash modes.
Location comes from Cloudflare ingress metadata, while device identity is reduced to key6.
</p>
<div class="window-tabs">
{windowOptions.map((option) => (
<button class="window-tab" data-window={option.key} data-active={option.key === "24h"}>
{option.label}
</button>
))}
</div>
</div>
<aside class="hero-card">
<div class="eyebrow">Current range</div>
<div class="hero-value" id="range-label">Loading…</div>
<p class="hero-note" id="range-note">Fetching dashboard data from D1.</p>
</aside>
</section>
<section class="kpi-grid">
<article class="kpi">
<div class="eyebrow">Reports</div>
<div class="kpi-value" id="kpi-reports">0</div>
<p class="kpi-note">Accepted upload windows in this range.</p>
</article>
<article class="kpi">
<div class="eyebrow">Reporters</div>
<div class="kpi-value" id="kpi-reporters">0</div>
<p class="kpi-note">Unique key6 reporters.</p>
</article>
<article class="kpi">
<div class="eyebrow">Decoded RX</div>
<div class="kpi-value" id="kpi-decoded">0</div>
<p class="kpi-note">Packets grouped by known payload type.</p>
</article>
<article class="kpi">
<div class="eyebrow">Decode Fail</div>
<div class="kpi-value" id="kpi-failures">0</div>
<p class="kpi-note">Malformed or undecodable RX packets.</p>
</article>
</section>
<section class="content-grid">
<article class="panel">
<h2>Traffic trend</h2>
<div id="trend-chart" class="empty">No data yet.</div>
</article>
<article class="panel">
<h2>Path mode distribution</h2>
<div id="path-mode-list" class="pill-row"></div>
</article>
</section>
<section class="content-grid">
<article class="panel">
<h2>Top packet types</h2>
<table>
<thead>
<tr>
<th>Label</th>
<th>Column</th>
<th>Total</th>
</tr>
</thead>
<tbody id="packet-type-table"></tbody>
</table>
</article>
<article class="panel">
<h2>Recent reporters</h2>
<table>
<thead>
<tr>
<th>key6</th>
<th>City</th>
<th>Country</th>
<th>Packets</th>
<th>Last seen</th>
</tr>
</thead>
<tbody id="reporter-table"></tbody>
</table>
</article>
</section>
<section class="content-grid">
<article class="panel">
<h2>Cloudflare geo map</h2>
<div class="map" id="geo-map">
<div class="continent one"></div>
<div class="continent two"></div>
<div class="continent three"></div>
<div class="continent four"></div>
</div>
<p class="legend">
Dots reflect Cloudflare ingress latitude and longitude, not device GPS coordinates.
</p>
</article>
<article class="panel">
<h2>Packet type mix</h2>
<div id="packet-mix" class="meter-stack"></div>
</article>
</section>
</div>
<script is:inline>
const state = { windowKey: "24h" };
const elements = {
errorBanner: document.getElementById("error-banner"),
rangeLabel: document.getElementById("range-label"),
rangeNote: document.getElementById("range-note"),
reports: document.getElementById("kpi-reports"),
reporters: document.getElementById("kpi-reporters"),
decoded: document.getElementById("kpi-decoded"),
failures: document.getElementById("kpi-failures"),
trendChart: document.getElementById("trend-chart"),
pathModeList: document.getElementById("path-mode-list"),
packetTypeTable: document.getElementById("packet-type-table"),
reporterTable: document.getElementById("reporter-table"),
packetMix: document.getElementById("packet-mix"),
geoMap: document.getElementById("geo-map"),
};
const windowButtons = [...document.querySelectorAll("[data-window]")];
function setActiveWindow(windowKey) {
state.windowKey = windowKey;
for (const button of windowButtons) {
button.dataset.active = String(button.dataset.window === windowKey);
}
}
async function loadDashboard(windowKey) {
setActiveWindow(windowKey);
elements.errorBanner.hidden = true;
elements.rangeLabel.textContent = "Loading…";
elements.rangeNote.textContent = "Fetching dashboard data from D1.";
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 summary = await response.json();
renderDashboard(summary);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
elements.errorBanner.hidden = false;
elements.errorBanner.textContent = `Unable to load dashboard data: ${message}`;
elements.rangeLabel.textContent = "Unavailable";
elements.rangeNote.textContent = "The dashboard API did not return data.";
}
}
function renderDashboard(summary) {
elements.rangeLabel.textContent = summary.filter.label;
elements.rangeNote.textContent = `Showing reports with window end after ${formatTimestamp(summary.filter.sinceIso)}. Generated ${formatTimestamp(summary.generatedAt)}.`;
elements.reports.textContent = String(summary.reportCount);
elements.reporters.textContent = String(summary.uniqueDevices);
elements.decoded.textContent = String(summary.decodedPackets);
elements.failures.textContent = String(summary.decodeFailures);
renderTrend(summary.chartPoints);
renderPathModes(summary.pathModeTotals);
renderPacketTypes(summary.packetTypeTotals);
renderReporters(summary.recentReporters);
renderPacketMix(summary.packetTypeTotals);
renderMap(summary.locationPoints);
}
function renderTrend(points) {
if (!points.length) {
elements.trendChart.innerHTML = '<div class="empty">No data yet for this window.</div>';
return;
}
const maxValue = Math.max(...points.map((point) => point.totalPackets), 1);
elements.trendChart.innerHTML = `
<div class="bars">
${points
.map((point) => {
const height = Math.max((point.totalPackets / maxValue) * 180, 6);
return `
<div class="bar-card">
<div class="bar-value">${point.totalPackets}</div>
<div class="bar" style="height:${height}px"></div>
<div class="bar-label">${escapeHtml(point.label.slice(5))}</div>
</div>`;
})
.join("")}
</div>`;
}
function renderPathModes(entries) {
const rows = entries.filter((entry) => entry.total > 0);
if (!rows.length) {
elements.pathModeList.innerHTML = '<div class="empty">No path mode samples yet.</div>';
return;
}
elements.pathModeList.innerHTML = rows
.map(
(entry) => `
<div class="pill">
<strong>${entry.total}</strong>
${escapeHtml(entry.label)}
</div>`,
)
.join("");
}
function renderPacketTypes(entries) {
const rows = entries.filter((entry) => entry.total > 0).slice(0, 8);
if (!rows.length) {
elements.packetTypeTable.innerHTML = '<tr><td colspan="3" class="empty">No packet data yet.</td></tr>';
return;
}
elements.packetTypeTable.innerHTML = rows
.map(
(entry) => `
<tr>
<td>${escapeHtml(entry.label)}</td>
<td><code>${entry.key}</code></td>
<td>${entry.total}</td>
</tr>`,
)
.join("");
}
function renderReporters(reporters) {
if (!reporters.length) {
elements.reporterTable.innerHTML = '<tr><td colspan="5" class="empty">No reporter activity yet.</td></tr>';
return;
}
elements.reporterTable.innerHTML = reporters
.map(
(reporter) => `
<tr>
<td><code>${escapeHtml(reporter.key6)}</code></td>
<td>${escapeHtml(reporter.city)}</td>
<td>${escapeHtml(reporter.country)}</td>
<td>${reporter.packetTotal}</td>
<td>${escapeHtml(formatTimestamp(reporter.lastSeen))}</td>
</tr>`,
)
.join("");
}
function renderPacketMix(entries) {
const rows = entries.filter((entry) => entry.total > 0).slice(0, 6);
if (!rows.length) {
elements.packetMix.innerHTML = '<div class="empty">No packet mix data yet.</div>';
return;
}
const maxValue = Math.max(...rows.map((entry) => entry.total), 1);
elements.packetMix.innerHTML = rows
.map(
(entry) => `
<div class="meter-row">
<div class="meter-head">
<span>${escapeHtml(entry.label)}</span>
<strong>${entry.total}</strong>
</div>
<div class="meter-track">
<div class="meter-fill" style="width:${(entry.total / maxValue) * 100}%"></div>
</div>
</div>`,
)
.join("");
}
function renderMap(points) {
const dots = [...elements.geoMap.querySelectorAll(".map-dot")];
for (const dot of dots) {
dot.remove();
}
for (const point of points) {
const dot = document.createElement("div");
dot.className = "map-dot";
dot.style.left = `${((point.longitude + 180) / 360) * 100}%`;
dot.style.top = `${((90 - point.latitude) / 180) * 100}%`;
dot.dataset.label = `${point.key6} · ${point.city}`;
elements.geoMap.appendChild(dot);
}
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatTimestamp(value) {
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`;
}
for (const button of windowButtons) {
button.addEventListener("click", () => {
loadDashboard(button.dataset.window ?? "24h");
});
}
loadDashboard(state.windowKey);
</script>
<DashboardShell client:load />
</BaseLayout>