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 };