docs(wg): SP2 Phase 2 plan — traffic history + SVG chart
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/v1-foundation
parent
16b854ae50
commit
eff836a1fb
|
|
@ -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.
|
||||
Loading…
Reference in New Issue