diff --git a/docs/superpowers/plans/2026-06-20-wireguard-dashboard-p2-history.md b/docs/superpowers/plans/2026-06-20-wireguard-dashboard-p2-history.md new file mode 100644 index 0000000..5de22a4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-wireguard-dashboard-p2-history.md @@ -0,0 +1,672 @@ +# WireGuard dashboard — Phase 2 (traffic history) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a traffic-history graph to `/wireguard` — aggregate down/up throughput over a selectable window (1h / 24h / 7d) — from periodic per-peer samples persisted in the DB. + +**Architecture:** A scheduled `clusev:wg-sample` command (every minute) reads the live status (`WgStatus`) and stores one `WgTrafficSample` row per peer (cumulative rx/tx counters), pruning rows older than 7 days. A `WgTraffic` service turns those rows into a bucketed down/up **throughput** series (per-peer deltas, negative-clamped for counter resets, summed into time buckets). The page renders it as a **server-side SVG polyline** chart (the established dashboard pattern — NO JS chart library) with a window selector. + +**Deviation from the spec (justified):** SP2 spec §3 named ApexCharts. The codebase already charts via server-side SVG polylines (`resources/views/livewire/dashboard.blade.php`), so this plan reuses that pattern instead of adding a heavy JS dep + an Alpine island + prod-JS-env risk. YAGNI + house-consistency. Per-peer breakdown + interactive tooltips are deferred (P1 already lists per-peer cumulative rx/tx). + +**Tech Stack:** Laravel 13 (migration/model/command/scheduler), Tailwind v4 tokens + inline SVG, Pint. No npm changes. Spec: `docs/superpowers/specs/2026-06-20-wireguard-dashboard-sp2-design.md` §3. + +**Run tooling (R8):** tests `docker compose --project-directory /home/nexxo/clusev exec -T -u 1002:1002 app sh -lc 'mkdir -p /tmp/views-test && VIEW_COMPILED_PATH=/tmp/views-test php artisan test --filter='`; Pint `… vendor/bin/pint `. Commit on `feat/v1-foundation`; push only at the release task. + +--- + +## File Structure + +- **`database/migrations/2026_06_20_000010_create_wg_traffic_samples_table.php`** (new) — the samples table. +- **`app/Models/WgTrafficSample.php`** (new) — the model (mirrors `BannedIp`: `$guarded=[]`, `$casts`). +- **`app/Console/Commands/WgSample.php`** (new) — `clusev:wg-sample`: persist a sample per peer + prune old rows. +- **`routes/console.php`** (modify) — `Schedule::command('clusev:wg-sample')->everyMinute()`. +- **`docker-compose.prod.yml`** + **`docker-compose.yml`** (modify) — add a `scheduler` service (`php artisan schedule:work`) — currently absent, so the existing `clusev:prune-audit` daily task does not run either; this fixes both. +- **`app/Services/WgTraffic.php`** (new) — window → bucketed down/up throughput series (one clear responsibility: the math). +- **`app/Livewire/Wireguard/Index.php`** (modify) — a `$window` property + pass the series to the view. +- **`resources/views/livewire/wireguard/index.blade.php`** (modify) — a Traffic panel: the SVG chart + window selector + totals. +- **`lang/{de,en}/wireguard.php`** (modify) — traffic strings. +- **Tests:** `tests/Feature/WgTrafficSampleTest.php`, `tests/Feature/WgSampleCommandTest.php`, `tests/Feature/WgTrafficTest.php`, extend `tests/Feature/WireguardPageTest.php`. + +--- + +## Task 1: migration + model + +**Files:** Create `database/migrations/2026_06_20_000010_create_wg_traffic_samples_table.php`, `app/Models/WgTrafficSample.php`; Test `tests/Feature/WgTrafficSampleTest.php` + +- [ ] **Step 1: Write the failing test** (`tests/Feature/WgTrafficSampleTest.php`): + +```php + 'abc', + 'peer_name' => 'laptop', + 'rx' => 1000, + 'tx' => 2000, + 'sampled_at' => now(), + ]); + + $this->assertDatabaseHas('wg_traffic_samples', ['peer_pubkey' => 'abc', 'rx' => 1000]); + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $s->fresh()->sampled_at); + } +} +``` + +- [ ] **Step 2: Run it → fails** (table/model missing). + +Run: `… php artisan test --filter=WgTrafficSampleTest` +Expected: FAIL (no `wg_traffic_samples` table / `WgTrafficSample` class). + +- [ ] **Step 3: Write the migration** (`database/migrations/2026_06_20_000010_create_wg_traffic_samples_table.php`): + +```php +id(); + $table->string('peer_pubkey'); + $table->string('peer_name')->nullable(); + $table->unsignedBigInteger('rx')->default(0); // cumulative bytes received (server←peer) + $table->unsignedBigInteger('tx')->default(0); // cumulative bytes sent (server→peer) + $table->timestamp('sampled_at')->index(); + $table->index(['peer_pubkey', 'sampled_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('wg_traffic_samples'); + } +}; +``` + +- [ ] **Step 4: Write the model** (`app/Models/WgTrafficSample.php`): + +```php + 'integer', + 'tx' => 'integer', + 'sampled_at' => 'datetime', + ]; +} +``` + +- [ ] **Step 5: Run the test → passes.** Run: `… php artisan test --filter=WgTrafficSampleTest`. Pint the two files + test. + +- [ ] **Step 6: Commit** + +```bash +git add database/migrations/2026_06_20_000010_create_wg_traffic_samples_table.php app/Models/WgTrafficSample.php tests/Feature/WgTrafficSampleTest.php +git commit -m "feat(wg): wg_traffic_samples table + model" +``` + +--- + +## Task 2: `clusev:wg-sample` command + schedule + +**Files:** Create `app/Console/Commands/WgSample.php`; Modify `routes/console.php`; Test `tests/Feature/WgSampleCommandTest.php` + +- [ ] **Step 1: Write the failing test** (`tests/Feature/WgSampleCommandTest.php`): + +```php +file = $dir.'/wg-status.json'; + @unlink($this->file); + } + + protected function tearDown(): void + { + @unlink($this->file); + parent::tearDown(); + } + + private function writeStatus(array $peers, bool $configured = true): void + { + file_put_contents($this->file, json_encode([ + 'at' => time(), 'configured' => $configured, + 'gate' => ['marker' => false, 'chain' => false], 'wg_quick' => 'active', + 'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => 'x:1', 'pubkey' => 'k'], + 'peers' => $peers, + ])); + } + + public function test_stores_one_row_per_peer_when_configured(): void + { + $this->writeStatus([ + ['name' => 'laptop', 'pubkey' => 'p1', 'endpoint' => '', 'latest_handshake' => time(), 'rx' => 100, 'tx' => 200], + ['name' => 'phone', 'pubkey' => 'p2', 'endpoint' => '', 'latest_handshake' => time(), 'rx' => 5, 'tx' => 6], + ]); + + $this->artisan('clusev:wg-sample')->assertExitCode(0); + + $this->assertSame(2, WgTrafficSample::count()); + $this->assertDatabaseHas('wg_traffic_samples', ['peer_pubkey' => 'p1', 'rx' => 100, 'tx' => 200]); + } + + public function test_does_nothing_when_unconfigured_or_stale(): void + { + @unlink($this->file); // absent → unconfigured + $this->artisan('clusev:wg-sample')->assertExitCode(0); + $this->assertSame(0, WgTrafficSample::count()); + } + + public function test_prunes_samples_older_than_retention(): void + { + WgTrafficSample::create(['peer_pubkey' => 'old', 'peer_name' => 'x', 'rx' => 1, 'tx' => 1, 'sampled_at' => now()->subDays(8)]); + $this->writeStatus([['name' => 'a', 'pubkey' => 'p1', 'endpoint' => '', 'latest_handshake' => time(), 'rx' => 1, 'tx' => 1]]); + + $this->artisan('clusev:wg-sample')->assertExitCode(0); + + $this->assertDatabaseMissing('wg_traffic_samples', ['peer_pubkey' => 'old']); // pruned + $this->assertDatabaseHas('wg_traffic_samples', ['peer_pubkey' => 'p1']); // fresh kept + } +} +``` + +- [ ] **Step 2: Run it → fails** (command missing). + +- [ ] **Step 3: Write the command** (`app/Console/Commands/WgSample.php`): + +```php +read(); + + // Only sample a live, configured collector — never write rows from a stale/absent feed. + if (($status['configured'] ?? false) && ! ($status['stale'] ?? true)) { + $now = now(); + $rows = []; + foreach ($status['peers'] ?? [] as $peer) { + $rows[] = [ + 'peer_pubkey' => (string) ($peer['pubkey'] ?? ''), + 'peer_name' => ($peer['name'] ?? '') !== '' ? (string) $peer['name'] : null, + 'rx' => (int) ($peer['rx'] ?? 0), + 'tx' => (int) ($peer['tx'] ?? 0), + 'sampled_at' => $now, + ]; + } + if ($rows !== []) { + WgTrafficSample::insert($rows); + } + } + + $retention = max(1, (int) $this->option('retention')); + WgTrafficSample::where('sampled_at', '<', now()->subDays($retention))->delete(); + + return self::SUCCESS; + } +} +``` + +- [ ] **Step 4: Register the schedule.** In `routes/console.php`, after the existing `Schedule::command('clusev:prune-audit')->daily();` line, add: + +```php +// Sample WireGuard peer traffic every minute (no-op when the collector is unconfigured/stale). +Schedule::command('clusev:wg-sample')->everyMinute(); +``` + +- [ ] **Step 5: Run the test → passes.** Pint the command + test. + +- [ ] **Step 6: Commit** + +```bash +git add app/Console/Commands/WgSample.php routes/console.php tests/Feature/WgSampleCommandTest.php +git commit -m "feat(wg): clusev:wg-sample command (sample peers + prune) + schedule" +``` + +--- + +## Task 3: scheduler service in compose + +The prod stack runs no scheduler, so neither `clusev:wg-sample` nor the existing `clusev:prune-audit` actually fire. Add a `scheduler` service running `php artisan schedule:work`. + +**Files:** Modify `docker-compose.prod.yml`, `docker-compose.yml` + +- [ ] **Step 1: Read `docker-compose.prod.yml`** and find the `queue:` service (it uses the YAML anchor `<<: *image` + `depends_on` mariadb/redis). Add a sibling `scheduler` service immediately after the `queue` service block, mirroring it exactly but with the schedule command: + +```yaml + scheduler: + <<: *image + command: php artisan schedule:work + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped +``` + +(Match the existing `queue` service's exact indentation, anchor name, `restart:` policy, and `depends_on` shape — copy them from the real `queue` block; the snippet above is the shape, not necessarily the exact keys. If `queue` has `env_file`/`networks`/`volumes`, the scheduler needs the same.) + +- [ ] **Step 2: Do the same in `docker-compose.yml`** (dev) — add a `scheduler` service mirroring the dev `queue` service with `command: php artisan schedule:work`. + +- [ ] **Step 3: Validate compose syntax.** + +Run: `docker compose -f docker-compose.prod.yml config -q && echo PROD_OK` +Run: `docker compose -f docker-compose.yml config -q && echo DEV_OK` +Expected: both print OK with no error. (`config -q` parses + validates without starting anything.) + +- [ ] **Step 4: Start the dev scheduler so history accrues locally (optional but useful for R12).** + +Run: `docker compose --project-directory /home/nexxo/clusev up -d scheduler` and `docker compose --project-directory /home/nexxo/clusev ps scheduler` → state `running`. + +- [ ] **Step 5: Commit** + +```bash +git add docker-compose.prod.yml docker-compose.yml +git commit -m "feat(ops): add a scheduler service (schedule:work) — runs wg-sample + prune-audit" +``` + +--- + +## Task 4: `WgTraffic` series service + +The math: per-peer cumulative counters → bucketed aggregate down/up **throughput**. Negative deltas (counter reset on wg restart) clamp to 0. + +**Files:** Create `app/Services/WgTraffic.php`; Test `tests/Feature/WgTrafficTest.php` + +- [ ] **Step 1: Write the failing test** (`tests/Feature/WgTrafficTest.php`): + +```php + $pk, 'peer_name' => $pk, 'rx' => $rx, 'tx' => $tx, + 'sampled_at' => now()->subSeconds($agoSeconds), + ]); + } + + public function test_empty_when_no_samples(): void + { + $s = app(WgTraffic::class)->series(3600, 6); + $this->assertSame(0, $s['total_down']); + $this->assertSame(0, $s['total_up']); + $this->assertCount(6, $s['points']); // always `buckets` points + $this->assertSame(0, array_sum(array_column($s['points'], 'down'))); + } + + public function test_aggregates_positive_deltas_across_peers(): void + { + // peer A: rx 0→100→300 ; peer B: rx 0→50. Total down delta = 100+200+50 = 350. + $this->sample('A', 0, 0, 3000); + $this->sample('A', 100, 10, 1800); + $this->sample('A', 300, 30, 600); + $this->sample('B', 0, 0, 3000); + $this->sample('B', 50, 5, 600); + + $s = app(WgTraffic::class)->series(3600, 6); + $this->assertSame(350, $s['total_down']); + $this->assertSame(45, $s['total_up']); // A:10+20 + B:5 + $this->assertGreaterThan(0, $s['peak_down']); + } + + public function test_counter_reset_clamps_to_zero(): void + { + // rx goes 500 → 20 (wg restart). The negative delta must NOT subtract. + $this->sample('A', 500, 0, 1800); + $this->sample('A', 20, 0, 600); + + $s = app(WgTraffic::class)->series(3600, 6); + $this->assertSame(0, $s['total_down']); + } +} +``` + +- [ ] **Step 2: Run it → fails** (service missing). + +- [ ] **Step 3: Write `app/Services/WgTraffic.php`:** + +```php +, + * total_down:int, total_up:int, peak_down:int, peak_up:int} + */ + public function series(int $windowSeconds, int $buckets = 60): array + { + $windowSeconds = max(60, $windowSeconds); + $buckets = max(1, $buckets); + $now = time(); + $from = $now - $windowSeconds; + $width = $windowSeconds / $buckets; + + $down = array_fill(0, $buckets, 0); + $up = array_fill(0, $buckets, 0); + + // Pull samples from one window back; include a little extra so the first in-window delta has + // a predecessor. Ordered per peer + time so consecutive deltas are correct. + $rows = WgTrafficSample::query() + ->where('sampled_at', '>=', now()->subSeconds($windowSeconds + (int) ceil($width) + 5)) + ->orderBy('peer_pubkey') + ->orderBy('sampled_at') + ->get(['peer_pubkey', 'rx', 'tx', 'sampled_at']); + + $prev = []; // peer_pubkey => ['rx'=>int,'tx'=>int] + foreach ($rows as $r) { + $pk = $r->peer_pubkey; + $t = $r->sampled_at->getTimestamp(); + if (isset($prev[$pk])) { + $dRx = max(0, (int) $r->rx - $prev[$pk]['rx']); + $dTx = max(0, (int) $r->tx - $prev[$pk]['tx']); + $idx = (int) floor(($t - $from) / $width); + if ($idx >= 0 && $idx < $buckets) { + $down[$idx] += $dRx; + $up[$idx] += $dTx; + } + } + $prev[$pk] = ['rx' => (int) $r->rx, 'tx' => (int) $r->tx]; + } + + $points = []; + for ($i = 0; $i < $buckets; $i++) { + $points[] = [ + 't' => (int) round($from + ($i + 0.5) * $width), + 'down' => $down[$i], + 'up' => $up[$i], + ]; + } + + return [ + 'window' => $windowSeconds, + 'buckets' => $buckets, + 'points' => $points, + 'total_down' => array_sum($down), + 'total_up' => array_sum($up), + 'peak_down' => $down === [] ? 0 : max($down), + 'peak_up' => $up === [] ? 0 : max($up), + ]; + } +} +``` + +- [ ] **Step 4: Run the test → passes.** Pint the service + test. + +- [ ] **Step 5: Commit** + +```bash +git add app/Services/WgTraffic.php tests/Feature/WgTrafficTest.php +git commit -m "feat(wg): WgTraffic — bucketed down/up throughput series" +``` + +--- + +## Task 5: the chart in the page (SVG + window selector) + +**Files:** Modify `app/Livewire/Wireguard/Index.php`, `resources/views/livewire/wireguard/index.blade.php`, `lang/de/wireguard.php`, `lang/en/wireguard.php`; Test extend `tests/Feature/WireguardPageTest.php` + +- [ ] **Step 1: Extend the page test** — add to `tests/Feature/WireguardPageTest.php`: + +```php + public function test_window_selector_switches_and_passes_traffic(): void + { + \App\Models\WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 0, 'tx' => 0, 'sampled_at' => now()->subMinutes(10)]); + \App\Models\WgTrafficSample::create(['peer_pubkey' => 'p1', 'peer_name' => 'a', 'rx' => 500, 'tx' => 100, 'sampled_at' => now()->subMinutes(1)]); + + \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class) + ->assertViewHas('traffic', fn ($t) => $t['total_down'] === 500) + ->call('setWindow', 86400) + ->assertSet('window', 86400) + ->assertViewHas('traffic', fn ($t) => $t['window'] === 86400); + } + + public function test_window_clamps_to_allowed_values(): void + { + \Livewire\Livewire::test(\App\Livewire\Wireguard\Index::class) + ->call('setWindow', 999) // not allowed + ->assertSet('window', 3600); // falls back to default + } +``` + +- [ ] **Step 2: Run → fails** (no `window`/`setWindow`/`traffic`). + +- [ ] **Step 3: Update the component** (`app/Livewire/Wireguard/Index.php`): + +```php +window = in_array($seconds, self::WINDOWS, true) ? $seconds : 3600; + } + + public function render(WgStatus $wg, WgTraffic $traffic) + { + $window = in_array($this->window, self::WINDOWS, true) ? $this->window : 3600; + + return view('livewire.wireguard.index', [ + 'status' => $wg->read(), + 'traffic' => $traffic->series($window), + 'windows' => self::WINDOWS, + ])->title(__('wireguard.title')); + } +} +``` + +- [ ] **Step 4: Add the lang strings.** In BOTH `lang/de/wireguard.php` and `lang/en/wireguard.php`, add (DE then EN values): + +```php + // Traffic chart (DE) + 'traffic_title' => 'Traffic', + 'window_1h' => '1 Std', + 'window_24h' => '24 Std', + 'window_7d' => '7 Tage', + 'total_down' => 'Empfangen', + 'total_up' => 'Gesendet', + 'no_traffic' => 'Noch keine Verlaufsdaten — sie sammeln sich, sobald Peers verbunden sind.', +``` + +```php + // Traffic chart (EN) + 'traffic_title' => 'Traffic', + 'window_1h' => '1h', + 'window_24h' => '24h', + 'window_7d' => '7d', + 'total_down' => 'Received', + 'total_up' => 'Sent', + 'no_traffic' => 'No history yet — it accrues once peers are connected.', +``` + +- [ ] **Step 5: Add the Traffic panel to the view.** In `resources/views/livewire/wireguard/index.blade.php`, INSIDE the `@else` (configured) branch, ABOVE the existing `
` peers/aside grid, insert this block. It computes SVG polyline points in Blade (mirroring the dashboard chart) and uses token stroke colours via wrapper text colour (R3/R4 — the only allowed inline width-style is not used here; SVG strokes use `currentColor`): + +```blade + @php + $win = collect($windows)->mapWithKeys(fn ($s) => [$s => match ($s) { + 3600 => __('wireguard.window_1h'), 86400 => __('wireguard.window_24h'), default => __('wireguard.window_7d'), + }])->all(); + $fmtB = function (int $n): string { + $u = ['B', 'KB', 'MB', 'GB', 'TB']; $i = 0; $v = (float) $n; + while ($v >= 1024 && $i < count($u) - 1) { $v /= 1024; $i++; } + return ($i === 0 ? (string) $n : number_format($v, 1)).' '.$u[$i]; + }; + // Build polyline point strings normalised to a 600x140 viewBox (y down). Peak scales both + // series together so down/up are comparable. Avoid div-by-zero with a floor of 1. + $pts = $traffic['points']; + $n = max(1, count($pts) - 1); + $peak = max(1, $traffic['peak_down'], $traffic['peak_up']); + $line = function (string $key) use ($pts, $n, $peak) { + $out = []; + foreach ($pts as $i => $p) { + $x = $n === 0 ? 0 : round($i / $n * 600, 1); + $y = round(140 - ($p[$key] / $peak) * 132, 1); // 4px top/bottom padding-ish + $out[] = $x.','.$y; + } + return implode(' ', $out); + }; + $hasTraffic = $traffic['total_down'] > 0 || $traffic['total_up'] > 0; + @endphp + + +
+
+ {{ __('wireguard.total_down') }}: {{ $fmtB($traffic['total_down']) }} + {{ __('wireguard.total_up') }}: {{ $fmtB($traffic['total_up']) }} +
+
+ @foreach ($windows as $w) + + @endforeach +
+
+
+ @if ($hasTraffic) + + + + + @else +

{{ __('wireguard.no_traffic') }}

+ @endif +
+
+``` + +- [ ] **Step 6: Run the page tests → pass.** Run: `… php artisan test --filter=WireguardPageTest`. Pint the component. + +- [ ] **Step 7: Commit** + +```bash +git add app/Livewire/Wireguard/Index.php resources/views/livewire/wireguard/index.blade.php lang/de/wireguard.php lang/en/wireguard.php tests/Feature/WireguardPageTest.php +git commit -m "feat(wg): traffic-history SVG chart + window selector on /wireguard" +``` + +--- + +## Task 6: verify + release + +- [ ] **Step 1: Pint + full suite + shellcheck (unchanged scripts).** `… vendor/bin/pint --dirty`; `… php artisan test` → all pass. + +- [ ] **Step 2: R12 browser-verify** `/wireguard` with traffic. Because the dev collector may not run, seed the DB with samples directly (tinker) to populate the chart, then verify: load `/wireguard` (DE) at 1280 + 375 → HTTP 200, the chart renders (an `` with two ``s), the window buttons switch (click 24h → `wire:click` re-renders), totals show, zero console errors, no leaked tokens; screenshot. Then EN. Restore the gkonrad password afterwards and clean up seeded rows. + - Seed example (tinker): insert ~20 `WgTrafficSample` rows for one pubkey with rising rx/tx over the last hour so `total_down>0`. + +- [ ] **Step 3: Release.** Bump `config/clusev.php` to `0.9.35`; CHANGELOG `## [0.9.35]` (Hinzugefügt: WireGuard-Traffic-Verlauf — Graph mit Zeitfenster; Scheduler-Dienst). Commit `chore: release 0.9.35`, tag `v0.9.35`, push branch + tag (token sanitised). + +--- + +## Self-Review + +**Spec coverage (P2 = spec §3 traffic history + graph):** +- Per-peer samples table + model → Task 1. ✓ +- Periodic sampling (scheduler) + prune/retention → Tasks 2, 3. ✓ +- Throughput series (deltas, reset-clamp, buckets) → Task 4. ✓ +- Chart + selectable window → Task 5. ✓ +- Deviation: server-side SVG instead of ApexCharts (justified above) — no npm/Alpine/island. Per-peer breakdown deferred (documented). ✓ + +**Placeholder scan:** none — full code in every step. + +**Type/name consistency:** `WgTrafficSample` columns (`peer_pubkey,peer_name,rx,tx,sampled_at`) are written by the migration (Task 1), inserted by `WgSample` (Task 2), and queried by `WgTraffic::series()` (Task 4) — all use the same names. `WgTraffic::series()` returns `window/buckets/points[{t,down,up}]/total_down/total_up/peak_down/peak_up`, consumed by the component (`traffic` view var, Task 5) and asserted in tests (Task 4 + the page test). `Index::WINDOWS`/`$window`/`setWindow()` used consistently in the component + view + tests. `clusev:wg-sample` signature matches the schedule registration + the test's `artisan('clusev:wg-sample')`. + +**Risk note:** the chart only populates once the scheduler has run for a while on the deployed host (and WireGuard is set up with connected peers). On a fresh deploy the panel shows the `no_traffic` empty state until samples accrue — by design.