docs(wg): SP2 Phase 2 plan — traffic history + SVG chart

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-20 23:56:51 +02:00
parent 16b854ae50
commit eff836a1fb
1 changed files with 672 additions and 0 deletions

View File

@ -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=<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.