329 lines
24 KiB
PHP
329 lines
24 KiB
PHP
@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.setup_title')">
|
|
<p class="text-sm leading-relaxed text-ink-2">{{ __('wireguard.setup_intro') }}</p>
|
|
<form wire:submit="setupWg" class="mt-4 space-y-3">
|
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_subnet') }}</label>
|
|
<input wire:model="setupSubnet" type="text" maxlength="18" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink focus:border-accent/40 focus:outline-none" />
|
|
@error('setupSubnet') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
|
</div>
|
|
<div>
|
|
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_port') }}</label>
|
|
<input wire:model="setupPort" type="text" inputmode="numeric" maxlength="5" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink focus:border-accent/40 focus:outline-none" />
|
|
@error('setupPort') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
|
</div>
|
|
<div>
|
|
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_endpoint') }}</label>
|
|
<input wire:model="setupEndpoint" type="text" maxlength="128" placeholder="auto" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
|
@error('setupEndpoint') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
|
</div>
|
|
<div>
|
|
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_peer') }}</label>
|
|
<input wire:model="setupPeer" type="text" maxlength="64" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink focus:border-accent/40 focus:outline-none" />
|
|
@error('setupPeer') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
|
</div>
|
|
</div>
|
|
<p class="font-mono text-[10px] text-ink-4">{{ __('wireguard.setup_endpoint_hint') }}</p>
|
|
<div class="flex items-center gap-3">
|
|
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="setupWg">
|
|
<x-icon name="plus" class="h-3.5 w-3.5" />{{ __('wireguard.setup_submit') }}
|
|
</x-btn>
|
|
@if ($pendingId)
|
|
<span class="font-mono text-[11px] text-ink-3" wire:poll.2s="pollResult">{{ __('wireguard.pending') }}</span>
|
|
@endif
|
|
</div>
|
|
</form>
|
|
</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
|
|
|
|
@if ($pendingId)
|
|
<div class="flex items-center gap-2 rounded-lg border border-accent/30 bg-accent/5 px-4 py-3" wire:poll.2s="pollResult">
|
|
<svg class="h-4 w-4 shrink-0 animate-spin text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
|
<span class="font-mono text-[11px] text-ink-2">{{ __('wireguard.pending') }}</span>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Tabs: Peers (list + traffic) and Server (independent server configuration) --}}
|
|
<div class="flex items-center gap-1 border-b border-line">
|
|
@foreach ([['peers', __('wireguard.tab_peers')], ['server', __('wireguard.tab_server')]] as [$tabKey, $tabLabel])
|
|
<button type="button" wire:click="setTab('{{ $tabKey }}')"
|
|
@class([
|
|
'-mb-px border-b-2 px-4 py-2.5 font-mono text-[12px] uppercase tracking-wider transition-colors',
|
|
'border-accent text-accent-text' => $tab === $tabKey,
|
|
'border-transparent text-ink-3 hover:text-ink' => $tab !== $tabKey,
|
|
])>{{ $tabLabel }}</button>
|
|
@endforeach
|
|
</div>
|
|
|
|
@if ($tab === 'server')
|
|
{{-- ── Server configuration tab ──────────────────────────────────────────────── --}}
|
|
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_340px]">
|
|
<x-panel :title="__('wireguard.settings_title')" :padded="false">
|
|
<div class="divide-y divide-line">
|
|
{{-- Gate toggle --}}
|
|
<div class="px-4 py-3.5 sm:px-5">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<span class="font-mono text-[11px] text-ink-3">{{ __('wireguard.gate_toggle') }}</span>
|
|
@if ($status['gate']['marker'] && $status['gate']['chain'])
|
|
<x-btn variant="danger-soft" size="sm" wire:click="confirmGate(false)">{{ __('wireguard.gate_turn_off') }}</x-btn>
|
|
@else
|
|
<x-btn variant="accent" size="sm" wire:click="confirmGate(true)">{{ __('wireguard.gate_turn_on') }}</x-btn>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
{{-- SSH lock (port 22) — lock-out risk, warning shown inline (not just in the modal) --}}
|
|
<div class="px-4 py-3.5 sm:px-5">
|
|
<div class="space-y-2">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<span class="font-mono text-[11px] text-ink-3">{{ __('wireguard.ssh_lock') }}</span>
|
|
@if ($status['gate']['ssh'])
|
|
<div class="flex items-center gap-2">
|
|
<x-badge tone="warning" size="md">{{ __('wireguard.ssh_lock_on_state') }}</x-badge>
|
|
<x-btn variant="accent" size="sm" wire:click="confirmSshGate(false)">{{ __('wireguard.ssh_lock_turn_off') }}</x-btn>
|
|
</div>
|
|
@else
|
|
<div class="flex items-center gap-2">
|
|
<x-badge tone="neutral" size="md">{{ __('wireguard.ssh_lock_off_state') }}</x-badge>
|
|
<x-btn variant="danger-soft" size="sm" wire:click="confirmSshGate(true)">{{ __('wireguard.ssh_lock_turn_on') }}</x-btn>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
<p class="flex gap-1.5 rounded-md border border-warning/25 bg-warning/5 px-2.5 py-2 font-mono text-[11px] leading-relaxed text-ink-3">
|
|
<x-icon name="alert" class="mt-px size-3.5 shrink-0 text-warning" />
|
|
<span>{{ __('wireguard.ssh_lock_hint') }}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Endpoint --}}
|
|
<div class="px-4 py-3.5 sm:px-5">
|
|
<form wire:submit="setEndpoint" class="space-y-2">
|
|
<label class="block font-mono text-[11px] text-ink-3">{{ __('wireguard.endpoint_label') }}</label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<input wire:model="newEndpoint" type="text" maxlength="128"
|
|
placeholder="{{ \Illuminate\Support\Str::before($status['server']['endpoint'], ':') ?: $status['server']['server_ip'] ?: 'host' }}"
|
|
class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
|
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="setEndpoint">{{ __('wireguard.apply') }}</x-btn>
|
|
</div>
|
|
<p class="font-mono text-[11px] text-ink-4">{{ __('wireguard.endpoint_hint') }}</p>
|
|
@error('newEndpoint') <span class="font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
|
</form>
|
|
</div>
|
|
|
|
{{-- DNS --}}
|
|
<div class="px-4 py-3.5 sm:px-5">
|
|
<form wire:submit="setDns" class="space-y-2">
|
|
<label class="block font-mono text-[11px] text-ink-3">{{ __('wireguard.dns_label') }}</label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<input wire:model="newDns" type="text" maxlength="128"
|
|
placeholder="{{ $status['server']['dns'] ?: '1.1.1.1' }}"
|
|
class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
|
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="setDns">{{ __('wireguard.apply') }}</x-btn>
|
|
</div>
|
|
<p class="font-mono text-[11px] text-ink-4">{{ __('wireguard.dns_hint') }}</p>
|
|
@error('newDns') <span class="font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
|
</form>
|
|
</div>
|
|
|
|
{{-- Port --}}
|
|
<div class="px-4 py-3.5 sm:px-5">
|
|
<div class="space-y-2">
|
|
<label class="block font-mono text-[11px] text-ink-3">{{ __('wireguard.port_label') }}</label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<input wire:model="newPort" type="text" maxlength="5"
|
|
placeholder="{{ $status['server']['port'] ?: '51820' }}"
|
|
class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
|
<x-btn variant="danger-soft" size="sm" wire:click="confirmSetPort">{{ __('wireguard.apply') }}</x-btn>
|
|
</div>
|
|
<p class="font-mono text-[11px] text-ink-4">{{ __('wireguard.port_hint') }}</p>
|
|
@error('newPort') <span class="font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Subnet --}}
|
|
<div class="px-4 py-3.5 sm:px-5">
|
|
<div class="space-y-2">
|
|
<label class="block font-mono text-[11px] text-ink-3">{{ __('wireguard.subnet_label') }}</label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<input wire:model="newSubnet" type="text" maxlength="18"
|
|
placeholder="{{ $status['server']['subnet'] ?: '10.99.0.0/24' }}"
|
|
class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
|
<x-btn variant="danger-soft" size="sm" wire:click="confirmSetSubnet">{{ __('wireguard.apply') }}</x-btn>
|
|
</div>
|
|
<p class="font-mono text-[11px] text-ink-4">{{ __('wireguard.subnet_hint') }}</p>
|
|
@error('newSubnet') <span class="font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</x-panel>
|
|
|
|
<div class="space-y-5">
|
|
<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.dns') }}</dt><dd class="text-ink-2">{{ $status['server']['dns'] ?: '1.1.1.1' }}</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>
|
|
|
|
<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>
|
|
</div>
|
|
</div>
|
|
@else
|
|
{{-- ── Peers tab (traffic + peer management) ──────────────────────────────────── --}}
|
|
@php
|
|
$win = collect($windows)->mapWithKeys(fn ($s) => [$s => match ($s) {
|
|
3600 => __('wireguard.window_1h'), 86400 => __('wireguard.window_24h'), default => __('wireguard.window_7d'),
|
|
}])->all();
|
|
$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;
|
|
// Header = LIVE cumulative totals (sum of the peer counters), so it always matches the
|
|
// per-peer rows. The chart below is windowed THROUGHPUT history (needs the sampler).
|
|
$liveDown = array_sum(array_map(fn ($p) => $p['rx'], $status['peers']));
|
|
$liveUp = array_sum(array_map(fn ($p) => $p['tx'], $status['peers']));
|
|
@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">{{ $fmtBytes($liveDown) }}</span></span>
|
|
<span class="text-cyan">{{ __('wireguard.total_up') }}: <span class="text-ink-2">{{ $fmtBytes($liveUp) }}</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>
|
|
|
|
<x-panel :title="__('wireguard.peers_title')" :subtitle="trans_choice('wireguard.peers_count', count($status['peers']), ['count' => count($status['peers'])])" :padded="false">
|
|
<form wire:submit="addPeer" class="flex flex-wrap items-center gap-2 border-b border-line px-4 py-3 sm:px-5">
|
|
<input wire:model="newPeer" type="text" maxlength="64" placeholder="{{ __('wireguard.peer_name_placeholder') }}"
|
|
class="min-w-0 flex-1 rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
|
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="addPeer">
|
|
<x-icon name="plus" class="h-3.5 w-3.5" />{{ __('wireguard.add_peer') }}
|
|
</x-btn>
|
|
@error('newPeer') <span class="w-full font-mono text-[11px] text-offline">{{ $message }}</span> @enderror
|
|
</form>
|
|
<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 class="flex w-full justify-end">
|
|
<x-btn variant="danger-soft" size="sm" wire:click="confirmRemovePeer({{ \Illuminate\Support\Js::from($peer['name'] !== '' ? $peer['name'] : $peer['pubkey']) }})">
|
|
<x-icon name="trash" class="h-3.5 w-3.5" />{{ __('wireguard.remove') }}
|
|
</x-btn>
|
|
</div>
|
|
</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>
|
|
@endif
|
|
@endif
|
|
|
|
@if ($resultConfig)
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-void/80 p-4" wire:key="wg-result-modal">
|
|
<div class="w-full max-w-lg rounded-xl border border-line bg-surface p-6 shadow-panel">
|
|
<h3 class="font-display text-lg font-semibold text-ink">{{ __('wireguard.new_peer_title') }}</h3>
|
|
<p class="mt-1 text-sm text-warning">{{ __('wireguard.new_peer_once') }}</p>
|
|
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('wireguard.import_hint') }}</p>
|
|
@if ($resultQr)
|
|
{{-- White frame + crispEdges: a WireGuard config QR is dense (~57 modules); too
|
|
small or anti-aliased and a phone camera misreads it. --}}
|
|
<div class="mx-auto mt-4 w-full max-w-[15rem] rounded-lg bg-white p-3">
|
|
<div class="aspect-square w-full [&>svg]:h-full [&>svg]:w-full [&>svg]:[shape-rendering:crispEdges]">{!! $resultQr !!}</div>
|
|
</div>
|
|
@endif
|
|
<pre class="mt-4 max-h-48 overflow-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">{{ $resultConfig }}</pre>
|
|
<div class="mt-4 flex items-center justify-end gap-2">
|
|
<x-btn variant="primary" wire:click="downloadConfig">
|
|
<x-icon name="save" class="h-3.5 w-3.5" />{{ __('wireguard.download') }}
|
|
</x-btn>
|
|
<x-btn variant="secondary" wire:click="dismissResult">{{ __('wireguard.close') }}</x-btn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|