feat(wg): traffic-history SVG chart + window selector on /wireguard
parent
e1fb24203d
commit
0c9aa12aab
|
|
@ -3,20 +3,36 @@
|
|||
namespace App\Livewire\Wireguard;
|
||||
|
||||
use App\Services\WgStatus;
|
||||
use App\Services\WgTraffic;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* WireGuard dashboard — live status (read-only, Phase 1). Renders the host-collected
|
||||
* run/wg-status.json (via WgStatus) and wire:polls every 5s. No writes here (P3/P4).
|
||||
* 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
|
||||
{
|
||||
public function render(WgStatus $wg)
|
||||
/** 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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,4 +26,11 @@ return [
|
|||
'last_handshake' => 'letzter Handshake',
|
||||
'never' => 'nie',
|
||||
'down_up' => '↓ :rx · ↑ :tx',
|
||||
'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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -26,4 +26,11 @@ return [
|
|||
'last_handshake' => 'last handshake',
|
||||
'never' => 'never',
|
||||
'down_up' => '↓ :rx · ↑ :tx',
|
||||
'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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -36,6 +36,59 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
@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];
|
||||
};
|
||||
$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 = round($i / $n * 600, 1);
|
||||
$y = round(140 - ($p[$key] / $peak) * 132, 1);
|
||||
$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>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<x-panel :title="__('wireguard.peers_title')" :subtitle="trans_choice('wireguard.peers_count', count($status['peers']), ['count' => count($status['peers'])])" :padded="false">
|
||||
<div class="divide-y divide-line">
|
||||
|
|
|
|||
|
|
@ -53,4 +53,23 @@ class WireguardPageTest extends TestCase
|
|||
->assertSee('laptop')
|
||||
->assertSee('10.99.0.0/24');
|
||||
}
|
||||
|
||||
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)
|
||||
->assertSet('window', 3600);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue