mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
feat: Enable stats by default and enrich public dashboard
This commit is contained in:
@@ -139,6 +139,7 @@ class TrafficStatsReportingService extends ChangeNotifier {
|
|||||||
static final Uri dashboardUri = Uri.parse(workerBaseUrl);
|
static final Uri dashboardUri = Uri.parse(workerBaseUrl);
|
||||||
static final Uri ingestUri = Uri.parse('$workerBaseUrl/api/ingest');
|
static final Uri ingestUri = Uri.parse('$workerBaseUrl/api/ingest');
|
||||||
|
|
||||||
|
static const bool defaultEnabled = true;
|
||||||
static const int defaultIntervalMinutes = 5;
|
static const int defaultIntervalMinutes = 5;
|
||||||
static const String _enabledKey = 'traffic_stats_reporting_enabled';
|
static const String _enabledKey = 'traffic_stats_reporting_enabled';
|
||||||
static const String _legacyIntervalKey =
|
static const String _legacyIntervalKey =
|
||||||
@@ -161,7 +162,7 @@ class TrafficStatsReportingService extends ChangeNotifier {
|
|||||||
String? Function()? _deviceKey6Provider;
|
String? Function()? _deviceKey6Provider;
|
||||||
Timer? _retryTimer;
|
Timer? _retryTimer;
|
||||||
String? _appVersion;
|
String? _appVersion;
|
||||||
bool _enabled = false;
|
bool _enabled = defaultEnabled;
|
||||||
DateTime? _lastSuccessAt;
|
DateTime? _lastSuccessAt;
|
||||||
String? _lastError;
|
String? _lastError;
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
@@ -191,7 +192,7 @@ class TrafficStatsReportingService extends ChangeNotifier {
|
|||||||
}) async {
|
}) async {
|
||||||
_deviceKey6Provider = deviceKey6Provider;
|
_deviceKey6Provider = deviceKey6Provider;
|
||||||
final prefs = await _prefsProvider();
|
final prefs = await _prefsProvider();
|
||||||
_enabled = prefs.getBool(_enabledKey) ?? false;
|
_enabled = prefs.getBool(_enabledKey) ?? defaultEnabled;
|
||||||
if (prefs.containsKey(_legacyIntervalKey)) {
|
if (prefs.containsKey(_legacyIntervalKey)) {
|
||||||
await prefs.remove(_legacyIntervalKey);
|
await prefs.remove(_legacyIntervalKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ void main() {
|
|||||||
await service.initialize(
|
await service.initialize(
|
||||||
deviceKey6Provider: () => 'a1b2c3d4e5f6',
|
deviceKey6Provider: () => 'a1b2c3d4e5f6',
|
||||||
);
|
);
|
||||||
await service.setEnabled(true);
|
expect(service.isEnabled, isTrue);
|
||||||
await service.processLogs(<BlePacketLog>[
|
await service.processLogs(<BlePacketLog>[
|
||||||
_log(
|
_log(
|
||||||
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5),
|
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5),
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('toggles reporting with a fixed 5 minute interval', (
|
testWidgets('starts enabled by default and can be disabled', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
SharedPreferences.setMockInitialValues({});
|
||||||
@@ -76,12 +76,12 @@ void main() {
|
|||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.text('Reporting interval'), findsNothing);
|
expect(find.text('Reporting interval'), findsNothing);
|
||||||
expect(service.isEnabled, isFalse);
|
expect(service.isEnabled, isTrue);
|
||||||
|
|
||||||
await tester.tap(find.byType(Switch));
|
await tester.tap(find.byType(Switch));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(service.isEnabled, isTrue);
|
expect(service.isEnabled, isFalse);
|
||||||
expect(service.intervalMinutes, 5);
|
expect(service.intervalMinutes, 5);
|
||||||
|
|
||||||
await tester.tap(find.widgetWithText(TextButton, 'View public stats'));
|
await tester.tap(find.widgetWithText(TextButton, 'View public stats'));
|
||||||
|
|||||||
BIN
worker/public/favicon.png
Normal file
BIN
worker/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 668 B |
@@ -43,6 +43,35 @@ type LocationPoint = {
|
|||||||
longitude: number;
|
longitude: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AppVersionEntry = {
|
||||||
|
version: string;
|
||||||
|
reporters: number;
|
||||||
|
packets: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ColoEntry = {
|
||||||
|
colo: string;
|
||||||
|
reporters: number;
|
||||||
|
packets: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TrafficComposition = {
|
||||||
|
human: number;
|
||||||
|
overhead: number;
|
||||||
|
acks: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MultiHopRatio = {
|
||||||
|
direct: number;
|
||||||
|
multiHop: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompositionPoint = {
|
||||||
|
label: string;
|
||||||
|
human: number;
|
||||||
|
overhead: number;
|
||||||
|
};
|
||||||
|
|
||||||
type DashboardResponse = {
|
type DashboardResponse = {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
filter: {
|
filter: {
|
||||||
@@ -60,6 +89,11 @@ type DashboardResponse = {
|
|||||||
recentReporters: ReporterSummary[];
|
recentReporters: ReporterSummary[];
|
||||||
chartPoints: ChartPoint[];
|
chartPoints: ChartPoint[];
|
||||||
locationPoints: LocationPoint[];
|
locationPoints: LocationPoint[];
|
||||||
|
appVersions: AppVersionEntry[];
|
||||||
|
coloDistribution: ColoEntry[];
|
||||||
|
trafficComposition: TrafficComposition;
|
||||||
|
multiHopRatio: MultiHopRatio;
|
||||||
|
compositionOverTime: CompositionPoint[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const WINDOW_OPTIONS: Array<{ key: WindowKey; label: string }> = [
|
const WINDOW_OPTIONS: Array<{ key: WindowKey; label: string }> = [
|
||||||
@@ -145,6 +179,13 @@ export function DashboardShell() {
|
|||||||
? ((summary!.decodedPackets / totalPackets) * 100).toFixed(1)
|
? ((summary!.decodedPackets / totalPackets) * 100).toFixed(1)
|
||||||
: "0";
|
: "0";
|
||||||
const maxTrend = Math.max(...(summary?.chartPoints ?? []).map((p) => p.totalPackets), 1);
|
const maxTrend = Math.max(...(summary?.chartPoints ?? []).map((p) => p.totalPackets), 1);
|
||||||
|
const multiHopTotal = (summary?.multiHopRatio.direct ?? 0) + (summary?.multiHopRatio.multiHop ?? 0);
|
||||||
|
const multiHopPct = multiHopTotal > 0
|
||||||
|
? ((summary!.multiHopRatio.multiHop / multiHopTotal) * 100).toFixed(1)
|
||||||
|
: "0";
|
||||||
|
const comp = summary?.trafficComposition;
|
||||||
|
const compTotal = comp ? comp.human + comp.overhead + comp.acks : 0;
|
||||||
|
const humanPct = compTotal > 0 ? ((comp!.human / compTotal) * 100).toFixed(1) : "0";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1320px] px-5 py-8">
|
<div className="mx-auto max-w-[1320px] px-5 py-8">
|
||||||
@@ -152,8 +193,8 @@ export function DashboardShell() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
|
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground">M</span>
|
<img src="/favicon.png" alt="MeshCore SAR" className="h-8 w-8 rounded-lg" />
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">MeshCore SAR</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">MeshCore SAR</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-xl text-sm text-muted-foreground">
|
<p className="max-w-xl text-sm text-muted-foreground">
|
||||||
@@ -185,26 +226,36 @@ export function DashboardShell() {
|
|||||||
|
|
||||||
<TabsContent value={windowKey} className="space-y-6">
|
<TabsContent value={windowKey} className="space-y-6">
|
||||||
{/* Key metrics */}
|
{/* Key metrics */}
|
||||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Active Nodes"
|
label="Active Nodes"
|
||||||
value={summary?.uniqueDevices ?? 0}
|
value={summary?.uniqueDevices ?? 0}
|
||||||
note="Unique reporting nodes in this window"
|
note="Unique reporting nodes"
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Total Packets"
|
label="Total Packets"
|
||||||
value={totalPackets}
|
value={totalPackets}
|
||||||
note="All received packets (decoded + failed)"
|
note="Decoded + failed"
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Decode Rate"
|
label="Decode Rate"
|
||||||
value={`${decodeRate}%`}
|
value={`${decodeRate}%`}
|
||||||
note={`${summary?.decodedPackets ?? 0} decoded, ${summary?.decodeFailures ?? 0} failed`}
|
note={`${summary?.decodeFailures ?? 0} failed`}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Packet Types"
|
label="Multi-Hop"
|
||||||
|
value={`${multiHopPct}%`}
|
||||||
|
note={`${summary?.multiHopRatio.multiHop ?? 0} relayed`}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Messages"
|
||||||
|
value={`${humanPct}%`}
|
||||||
|
note={`${comp?.human ?? 0} text + group`}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Protocol Types"
|
||||||
value={activePacketTypes.length}
|
value={activePacketTypes.length}
|
||||||
note={`of 16 protocol types observed`}
|
note="of 16 types active"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -345,12 +396,248 @@ export function DashboardShell() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Messages vs Overhead over time */}
|
||||||
|
<section className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Messages vs Protocol Overhead</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Human messages (text + group text) compared to protocol overhead (acks, advertisements, routing, control) over time.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{summary?.compositionOverTime.length ? (
|
||||||
|
<CompositionChart points={summary.compositionOverTime} />
|
||||||
|
) : (
|
||||||
|
<EmptyState label="No composition data yet." />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Traffic Breakdown</CardTitle>
|
||||||
|
<CardDescription>What the mesh is carrying</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{comp && compTotal > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* stacked bar */}
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* App versions + CF edge */}
|
||||||
|
<section className="grid gap-4 xl:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>App Versions</CardTitle>
|
||||||
|
<CardDescription>Distribution of reporting app versions</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{summary?.appVersions.length ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{summary.appVersions.map((entry) => {
|
||||||
|
const maxPkts = summary.appVersions[0].packets;
|
||||||
|
return (
|
||||||
|
<div key={entry.version} className="space-y-1.5">
|
||||||
|
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||||
|
<span className="font-mono font-medium">{entry.version}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{entry.reporters} {entry.reporters === 1 ? "node" : "nodes"} / {entry.packets.toLocaleString()} pkts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary/70"
|
||||||
|
style={{ width: `${(entry.packets / maxPkts) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState label="No version data yet." />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Cloudflare Edge PoPs</CardTitle>
|
||||||
|
<CardDescription>Which Cloudflare datacenters are serving mesh traffic</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{summary?.coloDistribution.length ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{summary.coloDistribution.map((entry) => (
|
||||||
|
<div key={entry.colo} className="rounded-xl border border-border/50 bg-secondary/30 px-4 py-3 text-center">
|
||||||
|
<div className="text-base font-bold font-mono">{entry.colo}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{entry.reporters} {entry.reporters === 1 ? "node" : "nodes"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{entry.packets.toLocaleString()} pkts
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState label="No edge data yet." />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Composition stacked area chart ---
|
||||||
|
|
||||||
|
function CompositionChart({ points }: { points: CompositionPoint[] }) {
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
const count = points.length;
|
||||||
|
if (count === 0) return null;
|
||||||
|
|
||||||
|
const maxVal = Math.max(...points.map((p) => p.human + p.overhead), 1);
|
||||||
|
const innerW = 100;
|
||||||
|
const innerH = 188;
|
||||||
|
const pad = { top: 16, right: 16, bottom: 32, left: 48 };
|
||||||
|
|
||||||
|
const xs = points.map((_, i) => i / Math.max(count - 1, 1));
|
||||||
|
|
||||||
|
// stacked: overhead on bottom, human on top
|
||||||
|
const overheadYs = points.map((p) => 1 - p.overhead / maxVal);
|
||||||
|
const totalYs = points.map((p) => 1 - (p.overhead + p.human) / maxVal);
|
||||||
|
|
||||||
|
const overheadArea = buildAreaPath(xs, overheadYs, innerW, innerH);
|
||||||
|
const humanArea = buildStackedAreaPath(xs, totalYs, overheadYs, innerW, innerH);
|
||||||
|
|
||||||
|
const labelStep = Math.max(1, Math.floor(count / 6));
|
||||||
|
const gridLines = niceGridLines(maxVal, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative select-none">
|
||||||
|
<svg
|
||||||
|
viewBox={`${-pad.left} ${-pad.top} ${innerW + pad.left + pad.right} ${innerH + pad.top + pad.bottom}`}
|
||||||
|
className="h-auto w-full"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="humanFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.5} />
|
||||||
|
<stop offset="100%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="overheadFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="hsl(206, 22%, 75%)" stopOpacity={0.5} />
|
||||||
|
<stop offset="100%" stopColor="hsl(206, 22%, 75%)" stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{gridLines.map((val) => {
|
||||||
|
const y = (1 - val / maxVal) * innerH;
|
||||||
|
return (
|
||||||
|
<g key={val}>
|
||||||
|
<line x1={0} y1={y} x2={innerW} y2={y} stroke="hsl(206, 22%, 87%)" strokeWidth={0.3} />
|
||||||
|
<text x={-6} y={y} textAnchor="end" dominantBaseline="middle" fill="hsl(208, 19%, 55%)" fontSize={3.2} fontFamily="var(--font-sans)">
|
||||||
|
{formatCompact(val)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<line x1={0} y1={innerH} x2={innerW} y2={innerH} stroke="hsl(206, 22%, 87%)" strokeWidth={0.4} />
|
||||||
|
|
||||||
|
<path d={overheadArea} fill="url(#overheadFill)" />
|
||||||
|
<path d={humanArea} fill="url(#humanFill)" />
|
||||||
|
|
||||||
|
{/* X labels */}
|
||||||
|
{points.map((p, i) => (i % labelStep === 0 || i === count - 1) ? (
|
||||||
|
<text key={i} x={xs[i] * innerW} y={innerH + 10} textAnchor="middle" fill="hsl(208, 19%, 55%)" fontSize={2.8} fontFamily="var(--font-sans)">
|
||||||
|
{p.label.slice(5)}
|
||||||
|
</text>
|
||||||
|
) : null)}
|
||||||
|
|
||||||
|
{/* hover zones */}
|
||||||
|
{points.map((point, i) => (
|
||||||
|
<rect
|
||||||
|
key={point.label}
|
||||||
|
x={xs[i] * innerW - innerW / count / 2}
|
||||||
|
y={0}
|
||||||
|
width={innerW / count}
|
||||||
|
height={innerH}
|
||||||
|
fill="transparent"
|
||||||
|
onMouseEnter={() => setHover(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hover !== null && (
|
||||||
|
<line x1={xs[hover] * innerW} y1={0} x2={xs[hover] * innerW} y2={innerH} stroke="hsl(217, 91%, 60%)" strokeWidth={0.3} strokeDasharray="1.5 1" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{hover !== null && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -top-2 z-10 -translate-x-1/2 rounded-lg border border-border/50 bg-card px-3 py-1.5 text-xs shadow-lg"
|
||||||
|
style={{ left: `${(pad.left + xs[hover] * innerW) / (innerW + pad.left + pad.right) * 100}%` }}
|
||||||
|
>
|
||||||
|
<div className="font-semibold">{points[hover].label}</div>
|
||||||
|
<div className="flex items-center gap-1.5"><span className="inline-block h-2 w-2 rounded-full bg-primary" /> Messages: {points[hover].human.toLocaleString()}</div>
|
||||||
|
<div className="flex items-center gap-1.5"><span className="inline-block h-2 w-2 rounded-full bg-secondary" /> Overhead: {points[hover].overhead.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-full bg-primary/60" /> Messages</span>
|
||||||
|
<span className="flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-full bg-secondary/70" /> Overhead</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAreaPath(xs: number[], ys: number[], w: number, h: number): string {
|
||||||
|
const line = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * w},${ys[i] * h}`).join(" ");
|
||||||
|
return `${line} L${xs[xs.length - 1] * w},${h} L0,${h} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStackedAreaPath(xs: number[], topYs: number[], bottomYs: number[], w: number, h: number): string {
|
||||||
|
const top = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * w},${topYs[i] * h}`).join(" ");
|
||||||
|
const bottom = [...xs].reverse().map((x, i) => `L${x * w},${bottomYs[xs.length - 1 - i] * h}`).join(" ");
|
||||||
|
return `${top} ${bottom} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Traffic trend chart ---
|
||||||
|
|
||||||
const CHART_H = 240;
|
const CHART_H = 240;
|
||||||
const CHART_PAD = { top: 20, right: 16, bottom: 32, left: 48 };
|
const CHART_PAD = { top: 20, right: 16, bottom: 32, left: 48 };
|
||||||
|
|
||||||
@@ -393,12 +680,12 @@ function TrafficChart({ points, maxValue }: { points: ChartPoint[]; maxValue: nu
|
|||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="hsl(178, 83%, 31%)" stopOpacity={0.35} />
|
<stop offset="0%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.35} />
|
||||||
<stop offset="100%" stopColor="hsl(178, 83%, 31%)" stopOpacity={0.03} />
|
<stop offset="100%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.03} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="lineStroke" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="lineStroke" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0%" stopColor="hsl(178, 83%, 38%)" />
|
<stop offset="0%" stopColor="hsl(217, 91%, 65%)" />
|
||||||
<stop offset="100%" stopColor="hsl(178, 60%, 28%)" />
|
<stop offset="100%" stopColor="hsl(217, 70%, 50%)" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
@@ -429,7 +716,7 @@ function TrafficChart({ points, maxValue }: { points: ChartPoint[]; maxValue: nu
|
|||||||
cx={xs[peakIdx] * innerW}
|
cx={xs[peakIdx] * innerW}
|
||||||
cy={ys[peakIdx] * innerH}
|
cy={ys[peakIdx] * innerH}
|
||||||
r={1.5}
|
r={1.5}
|
||||||
fill="hsl(178, 83%, 31%)"
|
fill="hsl(217, 91%, 60%)"
|
||||||
stroke="white"
|
stroke="white"
|
||||||
strokeWidth={0.6}
|
strokeWidth={0.6}
|
||||||
/>
|
/>
|
||||||
@@ -439,7 +726,7 @@ function TrafficChart({ points, maxValue }: { points: ChartPoint[]; maxValue: nu
|
|||||||
x={xs[peakIdx] * innerW}
|
x={xs[peakIdx] * innerW}
|
||||||
y={ys[peakIdx] * innerH - 4}
|
y={ys[peakIdx] * innerH - 4}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill="hsl(178, 83%, 28%)"
|
fill="hsl(217, 91%, 45%)"
|
||||||
fontSize={3}
|
fontSize={3}
|
||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
fontFamily="var(--font-sans)"
|
fontFamily="var(--font-sans)"
|
||||||
@@ -486,7 +773,7 @@ function TrafficChart({ points, maxValue }: { points: ChartPoint[]; maxValue: nu
|
|||||||
y1={0}
|
y1={0}
|
||||||
x2={xs[hover] * innerW}
|
x2={xs[hover] * innerW}
|
||||||
y2={innerH}
|
y2={innerH}
|
||||||
stroke="hsl(178, 83%, 31%)"
|
stroke="hsl(217, 91%, 60%)"
|
||||||
strokeWidth={0.3}
|
strokeWidth={0.3}
|
||||||
strokeDasharray="1.5 1"
|
strokeDasharray="1.5 1"
|
||||||
/>
|
/>
|
||||||
@@ -495,7 +782,7 @@ function TrafficChart({ points, maxValue }: { points: ChartPoint[]; maxValue: nu
|
|||||||
cy={ys[hover] * innerH}
|
cy={ys[hover] * innerH}
|
||||||
r={1.2}
|
r={1.2}
|
||||||
fill="white"
|
fill="white"
|
||||||
stroke="hsl(178, 83%, 31%)"
|
stroke="hsl(217, 91%, 60%)"
|
||||||
strokeWidth={0.6}
|
strokeWidth={0.6}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -638,8 +925,8 @@ function LeafletMap({ locations }: { locations: LocationPoint[] }) {
|
|||||||
center={[loc.latitude, loc.longitude]}
|
center={[loc.latitude, loc.longitude]}
|
||||||
radius={8}
|
radius={8}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
color: "hsl(178, 83%, 31%)",
|
color: "hsl(217, 91%, 60%)",
|
||||||
fillColor: "hsl(178, 83%, 45%)",
|
fillColor: "hsl(217, 91%, 70%)",
|
||||||
fillOpacity: 0.6,
|
fillOpacity: 0.6,
|
||||||
weight: 2,
|
weight: 2,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -28,23 +28,23 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 206 46% 97%;
|
--background: 220 33% 97%;
|
||||||
--foreground: 208 50% 13%;
|
--foreground: 222 47% 11%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 208 50% 13%;
|
--card-foreground: 222 47% 11%;
|
||||||
--primary: 178 83% 31%;
|
--primary: 217 91% 60%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 207 66% 92%;
|
--secondary: 220 50% 93%;
|
||||||
--secondary-foreground: 208 50% 13%;
|
--secondary-foreground: 222 47% 11%;
|
||||||
--muted: 204 31% 93%;
|
--muted: 220 26% 93%;
|
||||||
--muted-foreground: 208 19% 45%;
|
--muted-foreground: 220 13% 46%;
|
||||||
--accent: 28 100% 62%;
|
--accent: 217 91% 60%;
|
||||||
--accent-foreground: 208 50% 13%;
|
--accent-foreground: 0 0% 100%;
|
||||||
--destructive: 347 54% 48%;
|
--destructive: 0 72% 51%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 206 22% 87%;
|
--border: 220 20% 88%;
|
||||||
--input: 206 22% 87%;
|
--input: 220 20% 88%;
|
||||||
--ring: 178 83% 31%;
|
--ring: 217 91% 60%;
|
||||||
--radius: 1rem;
|
--radius: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,9 +59,9 @@
|
|||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, hsl(178 83% 31% / 0.14), transparent 30%),
|
radial-gradient(circle at top left, hsl(217 91% 60% / 0.10), transparent 30%),
|
||||||
radial-gradient(circle at top right, hsl(211 64% 39% / 0.12), transparent 24%),
|
radial-gradient(circle at top right, hsl(217 70% 50% / 0.08), transparent 24%),
|
||||||
linear-gradient(180deg, #f8fbfd 0%, hsl(var(--background)) 100%);
|
linear-gradient(180deg, #f5f8fc 0%, hsl(var(--background)) 100%);
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import "../index.css";
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ async function handleDashboard(request: Request, env: Env): Promise<Response> {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
...jsonHeaders,
|
...jsonHeaders,
|
||||||
"cache-control": "no-store",
|
"cache-control": "public, max-age=60, s-maxage=60",
|
||||||
|
"cdn-cache-control": "max-age=60",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -171,6 +171,35 @@ export interface ReporterSummary {
|
|||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppVersionEntry {
|
||||||
|
version: string;
|
||||||
|
reporters: number;
|
||||||
|
packets: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColoEntry {
|
||||||
|
colo: string;
|
||||||
|
reporters: number;
|
||||||
|
packets: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficComposition {
|
||||||
|
human: number;
|
||||||
|
overhead: number;
|
||||||
|
acks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiHopRatio {
|
||||||
|
direct: number;
|
||||||
|
multiHop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionPoint {
|
||||||
|
label: string;
|
||||||
|
human: number;
|
||||||
|
overhead: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
filter: WindowFilter;
|
filter: WindowFilter;
|
||||||
reportCount: number;
|
reportCount: number;
|
||||||
@@ -182,6 +211,11 @@ export interface DashboardSummary {
|
|||||||
recentReporters: ReporterSummary[];
|
recentReporters: ReporterSummary[];
|
||||||
chartPoints: ChartPoint[];
|
chartPoints: ChartPoint[];
|
||||||
locationPoints: LocationPoint[];
|
locationPoints: LocationPoint[];
|
||||||
|
appVersions: AppVersionEntry[];
|
||||||
|
coloDistribution: ColoEntry[];
|
||||||
|
trafficComposition: TrafficComposition;
|
||||||
|
multiHopRatio: MultiHopRatio;
|
||||||
|
compositionOverTime: CompositionPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const REPORT_INSERT_SQL = `
|
export const REPORT_INSERT_SQL = `
|
||||||
@@ -292,6 +326,11 @@ export function summarizeRows(
|
|||||||
const reporterMap = new Map<string, ReporterSummary>();
|
const reporterMap = new Map<string, ReporterSummary>();
|
||||||
const chartBuckets = new Map<string, ChartPoint>();
|
const chartBuckets = new Map<string, ChartPoint>();
|
||||||
|
|
||||||
|
// per-version and per-colo accumulators
|
||||||
|
const versionMap = new Map<string, { reporters: Set<string>; packets: number }>();
|
||||||
|
const coloMap = new Map<string, { reporters: Set<string>; packets: number }>();
|
||||||
|
const compositionBuckets = new Map<string, { human: number; overhead: number }>();
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const packetTotal =
|
const packetTotal =
|
||||||
decodeFailuresForRow(row) +
|
decodeFailuresForRow(row) +
|
||||||
@@ -331,6 +370,37 @@ export function summarizeRows(
|
|||||||
reports: 1,
|
reports: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// app version
|
||||||
|
const ver = row.app_version ?? "unknown";
|
||||||
|
const verEntry = versionMap.get(ver);
|
||||||
|
if (verEntry) {
|
||||||
|
verEntry.reporters.add(row.device_key6);
|
||||||
|
verEntry.packets += packetTotal;
|
||||||
|
} else {
|
||||||
|
versionMap.set(ver, { reporters: new Set([row.device_key6]), packets: packetTotal });
|
||||||
|
}
|
||||||
|
|
||||||
|
// CF colo
|
||||||
|
const colo = row.cf_colo ?? "unknown";
|
||||||
|
const coloEntry = coloMap.get(colo);
|
||||||
|
if (coloEntry) {
|
||||||
|
coloEntry.reporters.add(row.device_key6);
|
||||||
|
coloEntry.packets += packetTotal;
|
||||||
|
} else {
|
||||||
|
coloMap.set(colo, { reporters: new Set([row.device_key6]), packets: packetTotal });
|
||||||
|
}
|
||||||
|
|
||||||
|
// composition over time (human = text + group_text, overhead = rest)
|
||||||
|
const humanPackets = row.pt_02 + row.pt_05;
|
||||||
|
const overheadPackets = packetTotal - humanPackets;
|
||||||
|
const compBucket = compositionBuckets.get(bucketKey);
|
||||||
|
if (compBucket) {
|
||||||
|
compBucket.human += humanPackets;
|
||||||
|
compBucket.overhead += overheadPackets;
|
||||||
|
} else {
|
||||||
|
compositionBuckets.set(bucketKey, { human: humanPackets, overhead: overheadPackets });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const recentReporters = [...reporterMap.values()]
|
const recentReporters = [...reporterMap.values()]
|
||||||
@@ -351,6 +421,15 @@ export function summarizeRows(
|
|||||||
longitude: reporter.longitude,
|
longitude: reporter.longitude,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// human = text messages (pt_02 + pt_05), acks = pt_03, overhead = everything else
|
||||||
|
const humanTotal = sumRows(rows, "pt_02") + sumRows(rows, "pt_05");
|
||||||
|
const acksTotal = sumRows(rows, "pt_03");
|
||||||
|
const overheadTotal = decodedPackets - humanTotal - acksTotal;
|
||||||
|
|
||||||
|
// multi-hop: direct = path_mode_none, multiHop = 1b+2b+3b
|
||||||
|
const directTotal = sumRows(rows, "path_mode_none");
|
||||||
|
const multiHopTotal = sumRows(rows, "path_mode_1b") + sumRows(rows, "path_mode_2b") + sumRows(rows, "path_mode_3b");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filter,
|
filter,
|
||||||
reportCount: rows.length,
|
reportCount: rows.length,
|
||||||
@@ -364,6 +443,17 @@ export function summarizeRows(
|
|||||||
left.label.localeCompare(right.label),
|
left.label.localeCompare(right.label),
|
||||||
),
|
),
|
||||||
locationPoints,
|
locationPoints,
|
||||||
|
appVersions: [...versionMap.entries()]
|
||||||
|
.map(([version, entry]) => ({ version, reporters: entry.reporters.size, packets: entry.packets }))
|
||||||
|
.sort((left, right) => right.packets - left.packets),
|
||||||
|
coloDistribution: [...coloMap.entries()]
|
||||||
|
.map(([colo, entry]) => ({ colo, reporters: entry.reporters.size, packets: entry.packets }))
|
||||||
|
.sort((left, right) => right.packets - left.packets),
|
||||||
|
trafficComposition: { human: humanTotal, overhead: overheadTotal, acks: acksTotal },
|
||||||
|
multiHopRatio: { direct: directTotal, multiHop: multiHopTotal },
|
||||||
|
compositionOverTime: [...compositionBuckets.entries()]
|
||||||
|
.map(([label, entry]) => ({ label, ...entry }))
|
||||||
|
.sort((left, right) => left.label.localeCompare(right.label)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user