diff --git a/lib/services/traffic_stats_reporting_service.dart b/lib/services/traffic_stats_reporting_service.dart index 83baee8..b4785e8 100644 --- a/lib/services/traffic_stats_reporting_service.dart +++ b/lib/services/traffic_stats_reporting_service.dart @@ -139,6 +139,7 @@ class TrafficStatsReportingService extends ChangeNotifier { static final Uri dashboardUri = Uri.parse(workerBaseUrl); static final Uri ingestUri = Uri.parse('$workerBaseUrl/api/ingest'); + static const bool defaultEnabled = true; static const int defaultIntervalMinutes = 5; static const String _enabledKey = 'traffic_stats_reporting_enabled'; static const String _legacyIntervalKey = @@ -161,7 +162,7 @@ class TrafficStatsReportingService extends ChangeNotifier { String? Function()? _deviceKey6Provider; Timer? _retryTimer; String? _appVersion; - bool _enabled = false; + bool _enabled = defaultEnabled; DateTime? _lastSuccessAt; String? _lastError; bool _isInitialized = false; @@ -191,7 +192,7 @@ class TrafficStatsReportingService extends ChangeNotifier { }) async { _deviceKey6Provider = deviceKey6Provider; final prefs = await _prefsProvider(); - _enabled = prefs.getBool(_enabledKey) ?? false; + _enabled = prefs.getBool(_enabledKey) ?? defaultEnabled; if (prefs.containsKey(_legacyIntervalKey)) { await prefs.remove(_legacyIntervalKey); } diff --git a/test/services/traffic_stats_reporting_service_test.dart b/test/services/traffic_stats_reporting_service_test.dart index ef39bfd..d5ced40 100644 --- a/test/services/traffic_stats_reporting_service_test.dart +++ b/test/services/traffic_stats_reporting_service_test.dart @@ -68,7 +68,7 @@ void main() { await service.initialize( deviceKey6Provider: () => 'a1b2c3d4e5f6', ); - await service.setEnabled(true); + expect(service.isEnabled, isTrue); await service.processLogs([ _log( timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5), diff --git a/test/widgets/traffic_stats_reporting_section_test.dart b/test/widgets/traffic_stats_reporting_section_test.dart index 2ed7734..7865e84 100644 --- a/test/widgets/traffic_stats_reporting_section_test.dart +++ b/test/widgets/traffic_stats_reporting_section_test.dart @@ -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, ) async { SharedPreferences.setMockInitialValues({}); @@ -76,12 +76,12 @@ void main() { findsOneWidget, ); expect(find.text('Reporting interval'), findsNothing); - expect(service.isEnabled, isFalse); + expect(service.isEnabled, isTrue); await tester.tap(find.byType(Switch)); await tester.pumpAndSettle(); - expect(service.isEnabled, isTrue); + expect(service.isEnabled, isFalse); expect(service.intervalMinutes, 5); await tester.tap(find.widgetWithText(TextButton, 'View public stats')); diff --git a/worker/public/favicon.png b/worker/public/favicon.png new file mode 100644 index 0000000..6b278d4 Binary files /dev/null and b/worker/public/favicon.png differ diff --git a/worker/src/components/dashboard/dashboard-shell.tsx b/worker/src/components/dashboard/dashboard-shell.tsx index 1845cbe..1f9854c 100644 --- a/worker/src/components/dashboard/dashboard-shell.tsx +++ b/worker/src/components/dashboard/dashboard-shell.tsx @@ -43,6 +43,35 @@ type LocationPoint = { 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 = { generatedAt: string; filter: { @@ -60,6 +89,11 @@ type DashboardResponse = { recentReporters: ReporterSummary[]; chartPoints: ChartPoint[]; locationPoints: LocationPoint[]; + appVersions: AppVersionEntry[]; + coloDistribution: ColoEntry[]; + trafficComposition: TrafficComposition; + multiHopRatio: MultiHopRatio; + compositionOverTime: CompositionPoint[]; }; const WINDOW_OPTIONS: Array<{ key: WindowKey; label: string }> = [ @@ -145,6 +179,13 @@ export function DashboardShell() { ? ((summary!.decodedPackets / totalPackets) * 100).toFixed(1) : "0"; 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 (
@@ -152,8 +193,8 @@ export function DashboardShell() { {/* Header */}
-
- M +
+ MeshCore SAR

MeshCore SAR

@@ -185,26 +226,36 @@ export function DashboardShell() { {/* Key metrics */} -

+
+ +
@@ -345,12 +396,248 @@ export function DashboardShell() {
+ + {/* Messages vs Overhead over time */} +
+ + + Messages vs Protocol Overhead + + Human messages (text + group text) compared to protocol overhead (acks, advertisements, routing, control) over time. + + + + {summary?.compositionOverTime.length ? ( + + ) : ( + + )} + + + + + + Traffic Breakdown + What the mesh is carrying + + + {comp && compTotal > 0 ? ( + <> + {/* stacked bar */} +
+
+
+
+
+
+
+
+
{comp.human.toLocaleString()}
+
Messages
+
+
+
+
{comp.acks.toLocaleString()}
+
Acks
+
+
+
+
{comp.overhead.toLocaleString()}
+
Overhead
+
+
+ + ) : ( + + )} + + +
+ + {/* App versions + CF edge */} +
+ + + App Versions + Distribution of reporting app versions + + + {summary?.appVersions.length ? ( +
+ {summary.appVersions.map((entry) => { + const maxPkts = summary.appVersions[0].packets; + return ( +
+
+ {entry.version} + + {entry.reporters} {entry.reporters === 1 ? "node" : "nodes"} / {entry.packets.toLocaleString()} pkts + +
+
+
+
+
+ ); + })} +
+ ) : ( + + )} + + + + + + Cloudflare Edge PoPs + Which Cloudflare datacenters are serving mesh traffic + + + {summary?.coloDistribution.length ? ( +
+ {summary.coloDistribution.map((entry) => ( +
+
{entry.colo}
+
+ {entry.reporters} {entry.reporters === 1 ? "node" : "nodes"} +
+
+ {entry.packets.toLocaleString()} pkts +
+
+ ))} +
+ ) : ( + + )} +
+
+
); } +// --- Composition stacked area chart --- + +function CompositionChart({ points }: { points: CompositionPoint[] }) { + const [hover, setHover] = useState(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 ( +
+ setHover(null)} + > + + + + + + + + + + + + {gridLines.map((val) => { + const y = (1 - val / maxVal) * innerH; + return ( + + + + {formatCompact(val)} + + + ); + })} + + + + + + {/* X labels */} + {points.map((p, i) => (i % labelStep === 0 || i === count - 1) ? ( + + {p.label.slice(5)} + + ) : null)} + + {/* hover zones */} + {points.map((point, i) => ( + setHover(i)} + /> + ))} + + {hover !== null && ( + + )} + + + {hover !== null && ( +
+
{points[hover].label}
+
Messages: {points[hover].human.toLocaleString()}
+
Overhead: {points[hover].overhead.toLocaleString()}
+
+ )} + +
+ Messages + Overhead +
+
+ ); +} + +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_PAD = { top: 20, right: 16, bottom: 32, left: 48 }; @@ -393,12 +680,12 @@ function TrafficChart({ points, maxValue }: { points: ChartPoint[]; maxValue: nu > - - + + - - + + @@ -429,7 +716,7 @@ function TrafficChart({ points, maxValue }: { points: ChartPoint[]; maxValue: nu cx={xs[peakIdx] * innerW} cy={ys[peakIdx] * innerH} r={1.5} - fill="hsl(178, 83%, 31%)" + fill="hsl(217, 91%, 60%)" stroke="white" strokeWidth={0.6} /> @@ -439,7 +726,7 @@ function TrafficChart({ points, maxValue }: { points: ChartPoint[]; maxValue: nu x={xs[peakIdx] * innerW} y={ys[peakIdx] * innerH - 4} textAnchor="middle" - fill="hsl(178, 83%, 28%)" + fill="hsl(217, 91%, 45%)" fontSize={3} fontWeight={600} fontFamily="var(--font-sans)" @@ -486,7 +773,7 @@ function TrafficChart({ points, maxValue }: { points: ChartPoint[]; maxValue: nu y1={0} x2={xs[hover] * innerW} y2={innerH} - stroke="hsl(178, 83%, 31%)" + stroke="hsl(217, 91%, 60%)" strokeWidth={0.3} strokeDasharray="1.5 1" /> @@ -495,7 +782,7 @@ function TrafficChart({ points, maxValue }: { points: ChartPoint[]; maxValue: nu cy={ys[hover] * innerH} r={1.2} fill="white" - stroke="hsl(178, 83%, 31%)" + stroke="hsl(217, 91%, 60%)" strokeWidth={0.6} /> @@ -638,8 +925,8 @@ function LeafletMap({ locations }: { locations: LocationPoint[] }) { center={[loc.latitude, loc.longitude]} radius={8} pathOptions={{ - color: "hsl(178, 83%, 31%)", - fillColor: "hsl(178, 83%, 45%)", + color: "hsl(217, 91%, 60%)", + fillColor: "hsl(217, 91%, 70%)", fillOpacity: 0.6, weight: 2, }} diff --git a/worker/src/index.css b/worker/src/index.css index 8c033d3..e0c2105 100644 --- a/worker/src/index.css +++ b/worker/src/index.css @@ -28,23 +28,23 @@ @layer base { :root { - --background: 206 46% 97%; - --foreground: 208 50% 13%; + --background: 220 33% 97%; + --foreground: 222 47% 11%; --card: 0 0% 100%; - --card-foreground: 208 50% 13%; - --primary: 178 83% 31%; + --card-foreground: 222 47% 11%; + --primary: 217 91% 60%; --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%; + --secondary: 220 50% 93%; + --secondary-foreground: 222 47% 11%; + --muted: 220 26% 93%; + --muted-foreground: 220 13% 46%; + --accent: 217 91% 60%; + --accent-foreground: 0 0% 100%; + --destructive: 0 72% 51%; --destructive-foreground: 0 0% 100%; - --border: 206 22% 87%; - --input: 206 22% 87%; - --ring: 178 83% 31%; + --border: 220 20% 88%; + --input: 220 20% 88%; + --ring: 217 91% 60%; --radius: 1rem; } @@ -59,9 +59,9 @@ 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%); + radial-gradient(circle at top left, hsl(217 91% 60% / 0.10), transparent 30%), + radial-gradient(circle at top right, hsl(217 70% 50% / 0.08), transparent 24%), + linear-gradient(180deg, #f5f8fc 0%, hsl(var(--background)) 100%); color: hsl(var(--foreground)); } } diff --git a/worker/src/layouts/BaseLayout.astro b/worker/src/layouts/BaseLayout.astro index be89a59..8584ea6 100644 --- a/worker/src/layouts/BaseLayout.astro +++ b/worker/src/layouts/BaseLayout.astro @@ -13,6 +13,7 @@ import "../index.css"; + {title} diff --git a/worker/worker/index.ts b/worker/worker/index.ts index 543db8c..ddc3b71 100644 --- a/worker/worker/index.ts +++ b/worker/worker/index.ts @@ -114,7 +114,8 @@ async function handleDashboard(request: Request, env: Env): Promise { { headers: { ...jsonHeaders, - "cache-control": "no-store", + "cache-control": "public, max-age=60, s-maxage=60", + "cdn-cache-control": "max-age=60", }, }, ); diff --git a/worker/worker/stats.ts b/worker/worker/stats.ts index ca4a5d3..49dde03 100644 --- a/worker/worker/stats.ts +++ b/worker/worker/stats.ts @@ -171,6 +171,35 @@ export interface ReporterSummary { 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 { filter: WindowFilter; reportCount: number; @@ -182,6 +211,11 @@ export interface DashboardSummary { recentReporters: ReporterSummary[]; chartPoints: ChartPoint[]; locationPoints: LocationPoint[]; + appVersions: AppVersionEntry[]; + coloDistribution: ColoEntry[]; + trafficComposition: TrafficComposition; + multiHopRatio: MultiHopRatio; + compositionOverTime: CompositionPoint[]; } export const REPORT_INSERT_SQL = ` @@ -292,6 +326,11 @@ export function summarizeRows( const reporterMap = new Map(); const chartBuckets = new Map(); + // per-version and per-colo accumulators + const versionMap = new Map; packets: number }>(); + const coloMap = new Map; packets: number }>(); + const compositionBuckets = new Map(); + for (const row of rows) { const packetTotal = decodeFailuresForRow(row) + @@ -331,6 +370,37 @@ export function summarizeRows( 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()] @@ -351,6 +421,15 @@ export function summarizeRows( 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 { filter, reportCount: rows.length, @@ -364,6 +443,17 @@ export function summarizeRows( left.label.localeCompare(right.label), ), 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)), }; }