mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Add anonymous RX stats
ref:
This commit is contained in:
449
worker/src/components/dashboard/dashboard-shell.tsx
Normal file
449
worker/src/components/dashboard/dashboard-shell.tsx
Normal 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`;
|
||||
}
|
||||
31
worker/src/components/ui/badge.tsx
Normal file
31
worker/src/components/ui/badge.tsx
Normal 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 };
|
||||
54
worker/src/components/ui/button.tsx
Normal file
54
worker/src/components/ui/button.tsx
Normal 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 };
|
||||
52
worker/src/components/ui/card.tsx
Normal file
52
worker/src/components/ui/card.tsx
Normal 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 };
|
||||
64
worker/src/components/ui/table.tsx
Normal file
64
worker/src/components/ui/table.tsx
Normal 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 };
|
||||
50
worker/src/components/ui/tabs.tsx
Normal file
50
worker/src/components/ui/tabs.tsx
Normal 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
67
worker/src/index.css
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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
6
worker/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user