clusev/docs/superpowers/plans/2026-06-20-wireguard-dashbo...

28 KiB

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

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

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

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

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

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

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:
// 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

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:
  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
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

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

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

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:
    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

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):
    // 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.',
    // 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):
        @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

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.