# 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=<X>'`; Pint `… vendor/bin/pint <files>`. 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
<?php

namespace Tests\Feature;

use App\Models\WgTrafficSample;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class WgTrafficSampleTest extends TestCase
{
    use RefreshDatabase;

    public function test_persists_and_casts_a_sample(): void
    {
        $s = WgTrafficSample::create([
            'peer_pubkey' => '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
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('wg_traffic_samples', function (Blueprint $table) {
            $table->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
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class WgTrafficSample extends Model
{
    public $timestamps = false;

    protected $guarded = [];

    protected $casts = [
        'rx' => '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
<?php

namespace Tests\Feature;

use App\Models\WgTrafficSample;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class WgSampleCommandTest extends TestCase
{
    use RefreshDatabase;

    private string $file;

    protected function setUp(): void
    {
        parent::setUp();
        $dir = storage_path('app/restart-signal');
        @mkdir($dir, 0775, true);
        $this->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
<?php

namespace App\Console\Commands;

use App\Models\WgTrafficSample;
use App\Services\WgStatus;
use Illuminate\Console\Command;

class WgSample extends Command
{
    protected $signature = 'clusev:wg-sample {--retention=7 : Days of history to keep}';

    protected $description = 'Persist a WireGuard traffic sample per peer (and prune old samples)';

    public function handle(WgStatus $wg): int
    {
        $status = $wg->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
<?php

namespace Tests\Feature;

use App\Models\WgTrafficSample;
use App\Services\WgTraffic;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class WgTrafficTest extends TestCase
{
    use RefreshDatabase;

    private function sample(string $pk, int $rx, int $tx, int $agoSeconds): void
    {
        WgTrafficSample::create([
            'peer_pubkey' => $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
<?php

namespace App\Services;

use App\Models\WgTrafficSample;

/**
 * Turns per-peer cumulative traffic samples into a bucketed aggregate down/up THROUGHPUT series
 * for the dashboard SVG chart. Per peer, consecutive samples give a delta; negative deltas
 * (counter reset on a wg restart) clamp to 0; deltas are summed into time buckets across peers.
 */
class WgTraffic
{
    /**
     * @return array{window:int, buckets:int, points:array<int,array{t:int,down:int,up:int}>,
     *               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
<?php

namespace App\Livewire\Wireguard;

use App\Services\WgStatus;
use App\Services\WgTraffic;
use Livewire\Attributes\Layout;
use Livewire\Component;

/**
 * WireGuard dashboard — live status (P1) + traffic history (P2). Renders the host-collected
 * status (WgStatus) and a bucketed throughput series (WgTraffic), wire:polls every 5s. Read-only.
 */
#[Layout('layouts.app')]
class Index extends Component
{
    /** Selected traffic window in seconds. One of WINDOWS. */
    public int $window = 3600;

    /** Allowed windows (seconds): 1h / 24h / 7d. */
    public const WINDOWS = [3600, 86400, 604800];

    public function setWindow(int $seconds): void
    {
        $this->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 `<div class="grid grid-cols-1 gap-5 lg:grid-cols-[...]">` 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

        <x-panel :title="__('wireguard.traffic_title')" :padded="false">
            <div class="flex flex-wrap items-center justify-between gap-3 border-b border-line px-4 py-3 sm:px-5">
                <div class="flex items-center gap-4 font-mono text-[11px]">
                    <span class="text-accent-text">{{ __('wireguard.total_down') }}: <span class="text-ink-2">{{ $fmtB($traffic['total_down']) }}</span></span>
                    <span class="text-cyan">{{ __('wireguard.total_up') }}: <span class="text-ink-2">{{ $fmtB($traffic['total_up']) }}</span></span>
                </div>
                <div class="flex items-center gap-1">
                    @foreach ($windows as $w)
                        <button type="button" wire:click="setWindow({{ $w }})"
                            @class([
                                'rounded-md border px-2.5 py-1 font-mono text-[11px] transition-colors',
                                'border-accent/40 bg-accent/15 text-accent-text' => $window === $w,
                                'border-line bg-raised text-ink-3 hover:border-accent/30 hover:text-ink' => $window !== $w,
                            ])>{{ $win[$w] }}</button>
                    @endforeach
                </div>
            </div>
            <div class="px-4 py-4 sm:px-5">
                @if ($hasTraffic)
                    <svg viewBox="0 0 600 140" preserveAspectRatio="none" class="h-32 w-full" role="img" aria-label="{{ __('wireguard.traffic_title') }}">
                        <g class="text-cyan"><polyline points="{{ $line('up') }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></g>
                        <g class="text-accent"><polyline points="{{ $line('down') }}" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></g>
                    </svg>
                @else
                    <p class="font-mono text-[11px] text-ink-3">{{ __('wireguard.no_traffic') }}</p>
                @endif
            </div>
        </x-panel>
```

- [ ] **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 `<svg>` with two `<polyline>`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.
