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 (mirrorsBannedIp:$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 aschedulerservice (php artisan schedule:work) — currently absent, so the existingclusev:prune-auditdaily 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$windowproperty + 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, extendtests/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 existingSchedule::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.ymland find thequeue:service (it uses the YAML anchor<<: *image+depends_onmariadb/redis). Add a siblingschedulerservice immediately after thequeueservice 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 aschedulerservice mirroring the devqueueservice withcommand: 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.phpandlang/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 usecurrentColor):
@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
/wireguardwith 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:clickre-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
WgTrafficSamplerows for one pubkey with rising rx/tx over the last hour sototal_down>0.
- Seed example (tinker): insert ~20
-
Step 3: Release. Bump
config/clusev.phpto0.9.35; CHANGELOG## [0.9.35](Hinzugefügt: WireGuard-Traffic-Verlauf — Graph mit Zeitfenster; Scheduler-Dienst). Commitchore: release 0.9.35, tagv0.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.