mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Reorder stats cards cache dashboard
This commit is contained in:
@@ -254,35 +254,57 @@ export function DashboardShell() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Map + Traffic trend */}
|
{/* Traffic breakdown */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Reporter Locations</CardTitle>
|
<CardTitle>Traffic Breakdown</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Observed traffic composition across all reporting nodes</CardDescription>
|
||||||
Approximate locations from Cloudflare edge nodes, not device GPS.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-5">
|
||||||
<ReporterMap locations={summary?.locationPoints ?? []} />
|
{comp && compTotal > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="flex h-6 overflow-hidden rounded-full">
|
||||||
|
<div className="bg-primary" style={{ width: `${(comp.human / compTotal) * 100}%` }} title={`Messages: ${comp.human}`} />
|
||||||
|
<div className="bg-amber-400" style={{ width: `${(comp.acks / compTotal) * 100}%` }} title={`Acks: ${comp.acks}`} />
|
||||||
|
<div className="bg-secondary" style={{ width: `${(comp.overhead / compTotal) * 100}%` }} title={`Overhead: ${comp.overhead}`} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-primary" />
|
||||||
|
<div className="text-lg font-semibold tabular-nums">{comp.human.toLocaleString()}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Messages</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-amber-400" />
|
||||||
|
<div className="text-lg font-semibold tabular-nums">{comp.acks.toLocaleString()}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Acks</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-secondary" />
|
||||||
|
<div className="text-lg font-semibold tabular-nums">{comp.overhead.toLocaleString()}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Overhead</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState label="No traffic data yet." />
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Observations Over Time</CardTitle>
|
<CardTitle>Messages vs Protocol Overhead</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Per-node average observations per {summary?.filter.bucket ?? "time"} bucket.
|
Human messages (text + group text) compared to protocol overhead (acks, advertisements, routing, control) over time.
|
||||||
Normalizing by reporter count removes the bias of more nodes = higher numbers.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading && !summary ? (
|
{summary?.compositionOverTime.length ? (
|
||||||
<EmptyState label="Loading..." />
|
<CompositionChart points={summary.compositionOverTime} />
|
||||||
) : summary?.chartPoints.length ? (
|
|
||||||
<TrafficChart points={summary.chartPoints} maxValue={maxTrend} />
|
|
||||||
) : (
|
) : (
|
||||||
<EmptyState label="No data for this window." />
|
<EmptyState label="No composition data yet." />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -340,91 +362,67 @@ export function DashboardShell() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Path Routing Modes</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Path hash byte length determines routing precision.
|
|
||||||
Longer hashes allow more specific multi-hop paths.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{activePathModes.length ? (
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
|
||||||
{activePathModes.map((entry) => {
|
|
||||||
const pct = totalPackets > 0 ? ((entry.total / totalPackets) * 100).toFixed(1) : "0";
|
|
||||||
return (
|
|
||||||
<div key={entry.key} className="rounded-xl border border-border/50 bg-secondary/30 p-4 text-center">
|
|
||||||
<div className="mx-auto mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
|
|
||||||
{PATH_MODE_ICONS[entry.key] ?? "?"}
|
|
||||||
</div>
|
|
||||||
<div className="text-lg font-semibold tabular-nums">{entry.total.toLocaleString()}</div>
|
|
||||||
<div className="mt-0.5 text-xs text-muted-foreground">{entry.label}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{pct}%</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState label="No path mode samples." />
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Messages vs Overhead over time */}
|
|
||||||
<section className="space-y-4">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Messages vs Protocol Overhead</CardTitle>
|
<CardTitle>Path Routing Modes</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Human messages (text + group text) compared to protocol overhead (acks, advertisements, routing, control) over time.
|
Path hash byte length determines routing precision.
|
||||||
|
Longer hashes allow more specific multi-hop paths.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{summary?.compositionOverTime.length ? (
|
{activePathModes.length ? (
|
||||||
<CompositionChart points={summary.compositionOverTime} />
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
|
{activePathModes.map((entry) => {
|
||||||
|
const pct = totalPackets > 0 ? ((entry.total / totalPackets) * 100).toFixed(1) : "0";
|
||||||
|
return (
|
||||||
|
<div key={entry.key} className="rounded-xl border border-border/50 bg-secondary/30 p-4 text-center">
|
||||||
|
<div className="mx-auto mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
|
||||||
|
{PATH_MODE_ICONS[entry.key] ?? "?"}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums">{entry.total.toLocaleString()}</div>
|
||||||
|
<div className="mt-0.5 text-xs text-muted-foreground">{entry.label}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">{pct}%</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState label="No composition data yet." />
|
<EmptyState label="No path mode samples." />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Map + Traffic trend */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Reporter Locations</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Approximate locations from Cloudflare edge nodes, not device GPS.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ReporterMap locations={summary?.locationPoints ?? []} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Traffic Breakdown</CardTitle>
|
<CardTitle>Observations Over Time</CardTitle>
|
||||||
<CardDescription>Observed traffic composition across all reporting nodes</CardDescription>
|
<CardDescription>
|
||||||
|
Per-node average observations per {summary?.filter.bucket ?? "time"} bucket.
|
||||||
|
Normalizing by reporter count removes the bias of more nodes = higher numbers.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">
|
<CardContent>
|
||||||
{comp && compTotal > 0 ? (
|
{isLoading && !summary ? (
|
||||||
<>
|
<EmptyState label="Loading..." />
|
||||||
{/* stacked bar */}
|
) : summary?.chartPoints.length ? (
|
||||||
<div className="flex h-6 overflow-hidden rounded-full">
|
<TrafficChart points={summary.chartPoints} maxValue={maxTrend} />
|
||||||
<div className="bg-primary" style={{ width: `${(comp.human / compTotal) * 100}%` }} title={`Messages: ${comp.human}`} />
|
|
||||||
<div className="bg-amber-400" style={{ width: `${(comp.acks / compTotal) * 100}%` }} title={`Acks: ${comp.acks}`} />
|
|
||||||
<div className="bg-secondary" style={{ width: `${(comp.overhead / compTotal) * 100}%` }} title={`Overhead: ${comp.overhead}`} />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-3 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-primary" />
|
|
||||||
<div className="text-lg font-semibold tabular-nums">{comp.human.toLocaleString()}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Messages</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-amber-400" />
|
|
||||||
<div className="text-lg font-semibold tabular-nums">{comp.acks.toLocaleString()}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Acks</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mx-auto mb-1 h-2.5 w-2.5 rounded-full bg-secondary" />
|
|
||||||
<div className="text-lg font-semibold tabular-nums">{comp.overhead.toLocaleString()}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">Overhead</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<EmptyState label="No traffic data yet." />
|
<EmptyState label="No data for this window." />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -500,8 +498,8 @@ function CompositionChart({ points }: { points: CompositionPoint[] }) {
|
|||||||
<div className="relative select-none">
|
<div className="relative select-none">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`${-pad.left} ${-pad.top} ${innerW + pad.left + pad.right} ${innerH + pad.top + pad.bottom}`}
|
viewBox={`${-pad.left} ${-pad.top} ${innerW + pad.left + pad.right} ${innerH + pad.top + pad.bottom}`}
|
||||||
className="h-auto w-full"
|
className="h-auto max-h-[280px] w-full"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
onMouseLeave={() => setHover(null)}
|
onMouseLeave={() => setHover(null)}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
@@ -626,8 +624,8 @@ function TrafficChart({ points, maxValue: _rawMax }: { points: ChartPoint[]; max
|
|||||||
<div className="relative select-none">
|
<div className="relative select-none">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`${-CHART_PAD.left} ${-CHART_PAD.top} ${innerW + CHART_PAD.left + CHART_PAD.right} ${CHART_H}`}
|
viewBox={`${-CHART_PAD.left} ${-CHART_PAD.top} ${innerW + CHART_PAD.left + CHART_PAD.right} ${CHART_H}`}
|
||||||
className="h-auto w-full"
|
className="h-auto max-h-[280px] w-full"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
onMouseLeave={() => setHover(null)}
|
onMouseLeave={() => setHover(null)}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const summary = await loadDashboardSummary(env, url.searchParams.get("window"));
|
const windowParam = url.searchParams.get("window");
|
||||||
|
const summary = await loadDashboardSummary(env, windowParam);
|
||||||
|
const cacheTtl = windowParam === "7d" || windowParam === "30d" ? 86400 : 60;
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
@@ -114,8 +116,8 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
...jsonHeaders,
|
...jsonHeaders,
|
||||||
"cache-control": "public, max-age=60, s-maxage=60",
|
"cache-control": `public, max-age=${cacheTtl}, s-maxage=${cacheTtl}`,
|
||||||
"cdn-cache-control": "max-age=60",
|
"cdn-cache-control": `max-age=${cacheTtl}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user