feat(wg): /wireguard live-status page (read-only) + nav (DE/EN)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>feat/v1-foundation
parent
9b835e9dbe
commit
e640a96ca0
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Wireguard;
|
||||||
|
|
||||||
|
use App\Services\WgStatus;
|
||||||
|
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).
|
||||||
|
*/
|
||||||
|
#[Layout('layouts.app')]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public function render(WgStatus $wg)
|
||||||
|
{
|
||||||
|
return view('livewire.wireguard.index', [
|
||||||
|
'status' => $wg->read(),
|
||||||
|
])->title(__('wireguard.title'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ return [
|
||||||
'nav_services' => 'Dienste',
|
'nav_services' => 'Dienste',
|
||||||
'nav_files' => 'Dateien',
|
'nav_files' => 'Dateien',
|
||||||
'nav_audit' => 'Audit-Log',
|
'nav_audit' => 'Audit-Log',
|
||||||
|
'nav_wireguard' => 'WireGuard',
|
||||||
'nav_settings' => 'Einstellungen',
|
'nav_settings' => 'Einstellungen',
|
||||||
'nav_system' => 'System',
|
'nav_system' => 'System',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'WireGuard — Clusev',
|
||||||
|
'eyebrow' => 'Netzwerk',
|
||||||
|
'heading' => 'WireGuard',
|
||||||
|
'subtitle' => 'Tunnel-Zugang zum Panel — Live-Status.',
|
||||||
|
'not_configured_title' => 'WireGuard ist nicht eingerichtet',
|
||||||
|
'not_configured_body' => 'Auf dem Host einrichten: per SSH "sudo clusev wg setup". Danach erscheint hier der Live-Status.',
|
||||||
|
'stale' => 'Status veraltet — der Host-Collector antwortet nicht. Läuft der Stack?',
|
||||||
|
'gate' => 'Gate (Panel-Sperre)',
|
||||||
|
'gate_on' => 'aktiv — Panel nur über den Tunnel',
|
||||||
|
'gate_off' => 'aus — Panel öffentlich erreichbar',
|
||||||
|
'gate_escape' => 'Notausgang per SSH: "clusev wg down".',
|
||||||
|
'server_title' => 'Server',
|
||||||
|
'subnet' => 'Subnetz',
|
||||||
|
'server_ip' => 'Server-IP',
|
||||||
|
'port' => 'Port',
|
||||||
|
'endpoint' => 'Endpoint',
|
||||||
|
'server_key' => 'Server-Schlüssel',
|
||||||
|
'service' => 'Dienst',
|
||||||
|
'peers_title' => 'Peers',
|
||||||
|
'peers_count' => ':count Peer|:count Peers',
|
||||||
|
'no_peers' => 'Noch keine Peers — per SSH "clusev wg add-peer <name>".',
|
||||||
|
'online' => 'online',
|
||||||
|
'offline' => 'offline',
|
||||||
|
'last_handshake' => 'letzter Handshake',
|
||||||
|
'never' => 'nie',
|
||||||
|
'transfer' => 'Übertragung',
|
||||||
|
'down_up' => '↓ :rx · ↑ :tx',
|
||||||
|
];
|
||||||
|
|
@ -18,6 +18,7 @@ return [
|
||||||
'nav_services' => 'Services',
|
'nav_services' => 'Services',
|
||||||
'nav_files' => 'Files',
|
'nav_files' => 'Files',
|
||||||
'nav_audit' => 'Audit log',
|
'nav_audit' => 'Audit log',
|
||||||
|
'nav_wireguard' => 'WireGuard',
|
||||||
'nav_settings' => 'Settings',
|
'nav_settings' => 'Settings',
|
||||||
'nav_system' => 'System',
|
'nav_system' => 'System',
|
||||||
'nav_versions' => 'Version',
|
'nav_versions' => 'Version',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'WireGuard — Clusev',
|
||||||
|
'eyebrow' => 'Network',
|
||||||
|
'heading' => 'WireGuard',
|
||||||
|
'subtitle' => 'Tunnel access to the panel — live status.',
|
||||||
|
'not_configured_title' => 'WireGuard is not set up',
|
||||||
|
'not_configured_body' => 'Set it up on the host: over SSH run "sudo clusev wg setup". The live status appears here afterwards.',
|
||||||
|
'stale' => 'Status is stale — the host collector is not responding. Is the stack running?',
|
||||||
|
'gate' => 'Gate (panel lock)',
|
||||||
|
'gate_on' => 'on — panel only via the tunnel',
|
||||||
|
'gate_off' => 'off — panel publicly reachable',
|
||||||
|
'gate_escape' => 'Escape over SSH: "clusev wg down".',
|
||||||
|
'server_title' => 'Server',
|
||||||
|
'subnet' => 'Subnet',
|
||||||
|
'server_ip' => 'Server IP',
|
||||||
|
'port' => 'Port',
|
||||||
|
'endpoint' => 'Endpoint',
|
||||||
|
'server_key' => 'Server key',
|
||||||
|
'service' => 'Service',
|
||||||
|
'peers_title' => 'Peers',
|
||||||
|
'peers_count' => ':count peer|:count peers',
|
||||||
|
'no_peers' => 'No peers yet — over SSH run "clusev wg add-peer <name>".',
|
||||||
|
'online' => 'online',
|
||||||
|
'offline' => 'offline',
|
||||||
|
'last_handshake' => 'last handshake',
|
||||||
|
'never' => 'never',
|
||||||
|
'transfer' => 'Transfer',
|
||||||
|
'down_up' => '↓ :rx · ↑ :tx',
|
||||||
|
];
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item>
|
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item>
|
||||||
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item>
|
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item>
|
||||||
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item>
|
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item>
|
||||||
|
<x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item>
|
||||||
|
|
||||||
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
|
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
|
||||||
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item>
|
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">{{ __('shell.nav_settings') }}</x-nav-item>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
@php
|
||||||
|
$fmtBytes = 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];
|
||||||
|
};
|
||||||
|
$ago = function (int $ts): string {
|
||||||
|
if ($ts <= 0) return __('wireguard.never');
|
||||||
|
$d = max(0, time() - $ts);
|
||||||
|
if ($d < 60) return $d.'s';
|
||||||
|
if ($d < 3600) return intdiv($d, 60).'m';
|
||||||
|
if ($d < 86400) return intdiv($d, 3600).'h';
|
||||||
|
return intdiv($d, 86400).'d';
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-5" wire:poll.5s>
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('wireguard.eyebrow') }}</p>
|
||||||
|
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('wireguard.heading') }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-ink-3">{{ __('wireguard.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (! $status['configured'])
|
||||||
|
<x-panel :title="__('wireguard.not_configured_title')">
|
||||||
|
<p class="text-sm leading-relaxed text-ink-2">{{ __('wireguard.not_configured_body') }}</p>
|
||||||
|
<pre class="mt-3 overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] text-ink-2">sudo clusev wg setup</pre>
|
||||||
|
</x-panel>
|
||||||
|
@else
|
||||||
|
@if ($status['stale'])
|
||||||
|
<div class="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/10 px-4 py-3">
|
||||||
|
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
|
||||||
|
<span class="text-sm text-ink-2">{{ __('wireguard.stale') }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<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">
|
||||||
|
@forelse ($status['peers'] as $peer)
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 sm:px-5" wire:key="peer-{{ $peer['pubkey'] }}">
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<x-status-dot :status="$peer['online'] ? 'online' : 'offline'" />
|
||||||
|
<span class="font-mono text-sm font-semibold text-ink">{{ $peer['name'] !== '' ? $peer['name'] : \Illuminate\Support\Str::limit($peer['pubkey'], 10, '…') }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-[11px] text-ink-4">{{ $peer['online'] ? __('wireguard.online') : __('wireguard.offline') }}</span>
|
||||||
|
<span class="ml-auto font-mono text-[11px] text-ink-3">{{ __('wireguard.last_handshake') }}: {{ $ago($peer['latest_handshake']) }}</span>
|
||||||
|
<span class="w-full font-mono text-[11px] text-ink-3">
|
||||||
|
{{ $peer['endpoint'] !== '' ? $peer['endpoint'] : '—' }}
|
||||||
|
<span class="ml-2 text-ink-4">{{ __('wireguard.down_up', ['rx' => $fmtBytes($peer['rx']), 'tx' => $fmtBytes($peer['tx'])]) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="px-4 py-4 font-mono text-[11px] text-ink-3 sm:px-5">{{ __('wireguard.no_peers') }}</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</x-panel>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<x-panel :title="__('wireguard.gate')">
|
||||||
|
@if ($status['gate']['marker'] && $status['gate']['chain'])
|
||||||
|
<x-badge tone="cyan">{{ __('wireguard.gate_on') }}</x-badge>
|
||||||
|
@else
|
||||||
|
<x-badge tone="neutral">{{ __('wireguard.gate_off') }}</x-badge>
|
||||||
|
@endif
|
||||||
|
<p class="mt-2 font-mono text-[11px] text-ink-4">{{ __('wireguard.gate_escape') }}</p>
|
||||||
|
</x-panel>
|
||||||
|
|
||||||
|
<x-panel :title="__('wireguard.server_title')" :padded="false">
|
||||||
|
<dl class="space-y-2 px-4 py-4 font-mono text-[11px] sm:px-5">
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.subnet') }}</dt><dd class="text-ink-2">{{ $status['server']['subnet'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.server_ip') }}</dt><dd class="text-ink-2">{{ $status['server']['server_ip'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.port') }}</dt><dd class="text-ink-2">{{ $status['server']['port'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.endpoint') }}</dt><dd class="truncate text-ink-2">{{ $status['server']['endpoint'] ?: '—' }}</dd></div>
|
||||||
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('wireguard.service') }}</dt><dd class="text-ink-2">{{ $status['wg_quick'] }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</x-panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
@ -12,6 +12,7 @@ use App\Livewire\Services;
|
||||||
use App\Livewire\Settings;
|
use App\Livewire\Settings;
|
||||||
use App\Livewire\System;
|
use App\Livewire\System;
|
||||||
use App\Livewire\Versions;
|
use App\Livewire\Versions;
|
||||||
|
use App\Livewire\Wireguard;
|
||||||
use App\Services\DeploymentService;
|
use App\Services\DeploymentService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth as AuthFacade;
|
use Illuminate\Support\Facades\Auth as AuthFacade;
|
||||||
|
|
@ -131,6 +132,7 @@ Route::middleware('auth')->group(function () {
|
||||||
Route::get('/versions', Versions\Index::class)->name('versions');
|
Route::get('/versions', Versions\Index::class)->name('versions');
|
||||||
Route::get('/system', System\Index::class)->name('system');
|
Route::get('/system', System\Index::class)->name('system');
|
||||||
Route::get('/help', Help\Index::class)->name('help');
|
Route::get('/help', Help\Index::class)->name('help');
|
||||||
|
Route::get('/wireguard', Wireguard\Index::class)->name('wireguard');
|
||||||
|
|
||||||
Route::get('/wg-status.json', fn (\App\Services\WgStatus $wg) => response()->json($wg->read()))->name('wg.status');
|
Route::get('/wg-status.json', fn (\App\Services\WgStatus $wg) => response()->json($wg->read()))->name('wg.status');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Wireguard\Index;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class WireguardPageTest 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);
|
||||||
|
$this->actingAs(User::factory()->create(['must_change_password' => false]));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
@unlink($this->file);
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_renders_unconfigured_state(): void
|
||||||
|
{
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertViewHas('status', fn ($s) => $s['configured'] === false)
|
||||||
|
->assertSee(__('wireguard.not_configured_title'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_renders_peers_when_configured(): void
|
||||||
|
{
|
||||||
|
file_put_contents($this->file, json_encode([
|
||||||
|
'at' => time(), 'configured' => true,
|
||||||
|
'gate' => ['marker' => true, 'chain' => true], 'wg_quick' => 'active',
|
||||||
|
'server' => ['subnet' => '10.99.0.0/24', 'server_ip' => '10.99.0.1', 'port' => 51820, 'endpoint' => '1.2.3.4:51820', 'pubkey' => 'srvpub'],
|
||||||
|
'peers' => [['name' => 'laptop', 'pubkey' => 'p1', 'endpoint' => '5.6.7.8:41820', 'latest_handshake' => time(), 'rx' => 100, 'tx' => 200]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
Livewire::test(Index::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertViewHas('status', fn ($s) => $s['configured'] === true && count($s['peers']) === 1)
|
||||||
|
->assertSee('laptop')
|
||||||
|
->assertSee('10.99.0.0/24');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue