1633 lines
131 KiB
PHP
1633 lines
131 KiB
PHP
<div>
|
|
{{-- Page Header --}}
|
|
<div class="mb-6">
|
|
<h1 class="text-2xl font-bold">Firewall Management</h1>
|
|
<p class="text-sm text-base/60 mt-1">Configure firewall rules, policies, aliases, and IPSets for your virtual machine</p>
|
|
</div>
|
|
|
|
{{-- Error Modal --}}
|
|
@if($error && $showErrorModal)
|
|
<div class="fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<div class="relative w-full max-w-md bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button wire:click="$set('showErrorModal', false)" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-start gap-3 mb-4">
|
|
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-error/20 flex items-center justify-center">
|
|
<x-ri-error-warning-line class="size-5 text-error" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-lg font-semibold">Error</h3>
|
|
<p class="text-sm text-base/70 mt-1">{{ $error }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end">
|
|
<button wire:click="$set('showErrorModal', false)" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Main Firewall Toggle & Status --}}
|
|
<div class="bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20 rounded-xl p-6 mb-6">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<div class="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
|
|
<x-ri-shield-line class="size-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h2 class="text-xl font-semibold">Master Firewall Control</h2>
|
|
<p class="text-sm text-base/60 mt-1">
|
|
@if($firewallEnabled)
|
|
<span class="text-success font-medium">✓ Active - Your VM is protected by firewall rules</span>
|
|
@else
|
|
<span class="text-warning font-medium">⚠ Disabled - Your VM has no firewall protection</span>
|
|
@endif
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
@if($firewallEnabled)
|
|
<button type="button"
|
|
wire:click="toggleFirewall"
|
|
wire:loading.attr="disabled"
|
|
style="padding: 0.5rem 1rem; background-color: #ef4444; color: white; border-radius: 0.5rem; transition: all 0.2s; font-size: 0.875rem; font-weight: 500; display: flex; align-items: center; gap: 0.5rem;"
|
|
onmouseover="this.style.backgroundColor='#dc2626'"
|
|
onmouseout="this.style.backgroundColor='#ef4444'">
|
|
<x-ri-close-line class="size-4" />
|
|
<span wire:loading.remove wire:target="toggleFirewall">Disable Firewall</span>
|
|
<span wire:loading wire:target="toggleFirewall">Disabling...</span>
|
|
</button>
|
|
@else
|
|
<button type="button"
|
|
wire:click="toggleFirewall"
|
|
wire:loading.attr="disabled"
|
|
class="px-4 py-2 bg-success text-white rounded-lg hover:bg-success/90 transition-colors text-sm font-medium flex items-center gap-2">
|
|
<x-ri-shield-line class="size-4" />
|
|
<span wire:loading.remove wire:target="toggleFirewall">Enable Firewall</span>
|
|
<span wire:loading wire:target="toggleFirewall">Enabling...</span>
|
|
</button>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Firewall Policies & Network Adapters Grid --}}
|
|
@if($firewallEnabled)
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
{{-- Default Policies Card --}}
|
|
<div class="bg-background border border-neutral rounded-xl p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-10 h-10 rounded-lg bg-warning/10 flex items-center justify-center">
|
|
<x-ri-filter-3-line class="size-5 text-warning" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-lg font-semibold">Default Policies</h3>
|
|
<p class="text-sm text-base/60">Control default traffic behavior</p>
|
|
</div>
|
|
</div>
|
|
<button type="button" wire:click="openPolicyInfoModal" class="text-info hover:text-info/80 transition-colors" title="Learn about policies">
|
|
<x-ri-information-line class="size-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Incoming Traffic (Policy In)</label>
|
|
<select wire:model="policyIn" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
<option value="ACCEPT">Accept</option>
|
|
<option value="REJECT">Reject</option>
|
|
<option value="DROP">Drop</option>
|
|
</select>
|
|
<p class="text-xs text-base/60 mt-1">Default action for incoming traffic not matching any rule</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Outgoing Traffic (Policy Out)</label>
|
|
<select wire:model="policyOut" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
<option value="ACCEPT">Accept</option>
|
|
<option value="REJECT">Reject</option>
|
|
<option value="DROP">Drop</option>
|
|
</select>
|
|
<p class="text-xs text-base/60 mt-1">Default action for outgoing traffic not matching any rule</p>
|
|
</div>
|
|
|
|
<button type="button" wire:click="updatePolicies" wire:loading.attr="disabled" class="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium mt-4">
|
|
<span wire:loading.remove wire:target="updatePolicies">Update Policies</span>
|
|
<span wire:loading wire:target="updatePolicies">Updating...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Network Adapters --}}
|
|
<div class="bg-background border border-neutral rounded-xl p-6">
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<div class="w-10 h-10 rounded-lg bg-success/10 flex items-center justify-center">
|
|
<x-ri-router-line class="size-5 text-success" />
|
|
</div>
|
|
<div class="flex-1">
|
|
<h3 class="text-lg font-semibold">Network Adapters</h3>
|
|
<p class="text-sm text-base/60">Per-device firewall control</p>
|
|
</div>
|
|
</div>
|
|
|
|
@if(count($networkAdapters) > 0)
|
|
<div class="space-y-3">
|
|
@foreach($networkAdapters as $adapter)
|
|
<div class="p-3 bg-background-secondary/50 border border-neutral/20 rounded-lg hover:border-neutral/40 transition-all">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span class="font-semibold text-sm">{{ $adapter['name'] }}</span>
|
|
@if($adapterFirewallStates[$adapter['name']] ?? false)
|
|
<span class="px-2 py-0.5 bg-success/20 text-success text-xs font-medium rounded-full">Active</span>
|
|
@else
|
|
<span class="px-2 py-0.5 bg-base/20 text-base/60 text-xs font-medium rounded-full">Off</span>
|
|
@endif
|
|
</div>
|
|
<div class="text-xs text-base/60">
|
|
{{ ucfirst($adapter['model'] ?? 'Unknown') }}
|
|
@if($adapter['mac'])
|
|
<span class="ml-2">{{ $adapter['mac'] }}</span>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
@if($adapterFirewallStates[$adapter['name']] ?? false)
|
|
<button type="button"
|
|
wire:click="toggleAdapterFirewall('{{ $adapter['name'] }}')"
|
|
wire:loading.attr="disabled"
|
|
wire:key="adapter-{{ $adapter['name'] }}"
|
|
style="padding: 0.375rem 0.75rem; background-color: #ef4444; color: white; border-radius: 0.5rem; transition: all 0.2s; font-size: 0.75rem; font-weight: 500; display: flex; align-items: center; gap: 0.375rem;"
|
|
onmouseover="this.style.backgroundColor='#dc2626'"
|
|
onmouseout="this.style.backgroundColor='#ef4444'">
|
|
<x-ri-close-line class="size-3.5" />
|
|
<span wire:loading.remove wire:target="toggleAdapterFirewall('{{ $adapter['name'] }}')">Disable</span>
|
|
<span wire:loading wire:target="toggleAdapterFirewall('{{ $adapter['name'] }}')">...</span>
|
|
</button>
|
|
@else
|
|
<button type="button"
|
|
wire:click="toggleAdapterFirewall('{{ $adapter['name'] }}')"
|
|
wire:loading.attr="disabled"
|
|
wire:key="adapter-{{ $adapter['name'] }}"
|
|
class="px-3 py-1.5 bg-success text-white rounded-lg hover:bg-success/90 transition-colors text-xs font-medium flex items-center gap-1.5">
|
|
<x-ri-shield-line class="size-3.5" />
|
|
<span wire:loading.remove wire:target="toggleAdapterFirewall('{{ $adapter['name'] }}')">Enable</span>
|
|
<span wire:loading wire:target="toggleAdapterFirewall('{{ $adapter['name'] }}')">...</span>
|
|
</button>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<div class="text-center py-4 text-base/60 text-sm">
|
|
<x-ri-router-line class="size-8 mx-auto mb-2 opacity-50" />
|
|
<p>No network adapters found</p>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Policy Info Modal --}}
|
|
@if($showPolicyInfoModal)
|
|
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<div class="relative w-full max-w-3xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl max-h-[85vh] overflow-y-auto">
|
|
<button wire:click="closePolicyInfoModal" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors z-10">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="p-6">
|
|
<div class="flex items-center gap-3 mb-6">
|
|
<div class="w-12 h-12 rounded-xl bg-info/20 flex items-center justify-center">
|
|
<x-ri-information-line class="size-6 text-info" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-bold">Understanding Firewall Policies</h3>
|
|
<p class="text-sm text-base/60">How default policies control your network traffic</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-6 prose prose-sm max-w-none">
|
|
<div class="p-4 bg-background border border-neutral/20 rounded-lg">
|
|
<h4 class="font-semibold text-base mb-2 flex items-center gap-2">
|
|
<x-ri-arrow-down-line class="size-4 text-warning" />
|
|
Policy In (Incoming Traffic)
|
|
</h4>
|
|
<p class="text-sm text-base/80 mb-3">Controls what happens to traffic coming INTO your VM when it doesn't match any explicit firewall rule.</p>
|
|
<ul class="space-y-2 text-sm">
|
|
<li><strong class="text-success">ACCEPT:</strong> Allow all incoming traffic by default (permissive, requires explicit DENY rules)</li>
|
|
<li><strong class="text-warning">REJECT:</strong> Block incoming traffic and send rejection notification to sender</li>
|
|
<li><strong class="text-error">DROP:</strong> Silently discard incoming traffic without notifying sender (recommended)</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="p-4 bg-background border border-neutral/20 rounded-lg">
|
|
<h4 class="font-semibold text-base mb-2 flex items-center gap-2">
|
|
<x-ri-arrow-up-line class="size-4 text-info" />
|
|
Policy Out (Outgoing Traffic)
|
|
</h4>
|
|
<p class="text-sm text-base/80 mb-3">Controls what happens to traffic going OUT of your VM when it doesn't match any explicit firewall rule.</p>
|
|
<ul class="space-y-2 text-sm">
|
|
<li><strong class="text-success">ACCEPT:</strong> Allow all outgoing traffic by default (recommended for most VMs)</li>
|
|
<li><strong class="text-warning">REJECT:</strong> Block outgoing traffic and notify your VM</li>
|
|
<li><strong class="text-error">DROP:</strong> Silently discard outgoing traffic</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="p-4 bg-warning/10 border border-warning/30 rounded-lg">
|
|
<h4 class="font-semibold text-base mb-2 flex items-center gap-2">
|
|
<x-ri-lock-line class="size-4 text-warning" />
|
|
Creating a Whitelist Configuration
|
|
</h4>
|
|
<p class="text-sm text-base/80 mb-2">To create a strict whitelist (only allow specific traffic):</p>
|
|
<ol class="list-decimal list-inside space-y-1 text-sm text-base/80">
|
|
<li>Set <strong>Policy In</strong> to <strong>DROP</strong></li>
|
|
<li>Set <strong>Policy Out</strong> to <strong>DROP</strong> (if restricting outbound)</li>
|
|
<li>Create explicit <strong>ACCEPT</strong> rules for allowed traffic</li>
|
|
<li>Everything not explicitly allowed will be blocked</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div class="p-4 bg-info/10 border border-info/30 rounded-lg">
|
|
<h4 class="font-semibold text-base mb-2">Additional Filters</h4>
|
|
<p class="text-sm text-base/80 mb-2"><strong>IP Filter:</strong> Validates that source/destination IP addresses in packets match the VM's assigned IPs. Helps prevent IP spoofing.</p>
|
|
<p class="text-sm text-base/80"><strong>MAC Filter:</strong> Validates that MAC addresses match the VM's network adapters. Provides additional layer of security.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end mt-6">
|
|
<button wire:click="closePolicyInfoModal" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
|
|
Got it
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Tabs Navigation --}}
|
|
<div class="bg-background border border-neutral rounded-xl mb-6" x-data="{ activeTab: 'rules' }">
|
|
<div class="flex border-b border-neutral/20 overflow-x-auto">
|
|
<button @click="activeTab = 'rules'"
|
|
class="px-6 py-4 text-sm font-semibold transition-all relative whitespace-nowrap"
|
|
:class="activeTab === 'rules' ? 'text-primary' : 'text-base/60 hover:text-base'">
|
|
<div class="flex items-center gap-2">
|
|
<x-ri-list-check class="size-4" />
|
|
<span>Firewall Rules</span>
|
|
</div>
|
|
<div x-show="activeTab === 'rules'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
|
|
</button>
|
|
<button @click="activeTab = 'aliases'"
|
|
class="px-6 py-4 text-sm font-semibold transition-all relative whitespace-nowrap"
|
|
:class="activeTab === 'aliases' ? 'text-primary' : 'text-base/60 hover:text-base'">
|
|
<div class="flex items-center gap-2">
|
|
<x-ri-bookmark-line class="size-4" />
|
|
<span>IP Aliases</span>
|
|
</div>
|
|
<div x-show="activeTab === 'aliases'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
|
|
</button>
|
|
<button @click="activeTab = 'ipsets'"
|
|
class="px-6 py-4 text-sm font-semibold transition-all relative whitespace-nowrap"
|
|
:class="activeTab === 'ipsets' ? 'text-primary' : 'text-base/60 hover:text-base'">
|
|
<div class="flex items-center gap-2">
|
|
<x-ri-folder-3-line class="size-4" />
|
|
<span>IP Sets</span>
|
|
</div>
|
|
<div x-show="activeTab === 'ipsets'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
|
|
</button>
|
|
<button @click="activeTab = 'logs'"
|
|
class="px-6 py-4 text-sm font-semibold transition-all relative whitespace-nowrap"
|
|
:class="activeTab === 'logs' ? 'text-primary' : 'text-base/60 hover:text-base'">
|
|
<div class="flex items-center gap-2">
|
|
<x-ri-file-list-3-line class="size-4" />
|
|
<span>Firewall Logs</span>
|
|
<span class="px-2 py-0.5 bg-primary/10 text-primary rounded-full text-xs font-bold">{{ count($firewallLogs) }}</span>
|
|
</div>
|
|
<div x-show="activeTab === 'logs'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
|
|
</button>
|
|
</div>
|
|
|
|
{{-- Tab Contents --}}
|
|
<div class="p-6">
|
|
{{-- RULES TAB --}}
|
|
<div x-show="activeTab === 'rules'" x-transition>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-semibold">Firewall Rules</h3>
|
|
<button type="button" wire:click="startCreateRule" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium flex items-center gap-2">
|
|
<x-ri-add-line class="size-4" />
|
|
Add Rule
|
|
</button>
|
|
</div>
|
|
|
|
@if(count($rules) > 0)
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr class="border-b border-neutral/20">
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Order</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Pos</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Action</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Direction</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Source</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Destination</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Protocol</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Ports</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Status</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach($rules as $index => $rule)
|
|
<tr class="border-b border-neutral/10 hover:bg-background-secondary/30 transition-colors">
|
|
<td class="py-3 px-4 text-sm">
|
|
<div class="flex flex-col gap-1">
|
|
@if($index > 0)
|
|
<button type="button"
|
|
wire:click="reorderRules({{ $rule['pos'] }}, {{ $rules[$index - 1]['pos'] }})"
|
|
wire:key="move-up-{{ $index }}"
|
|
class="text-base/50 hover:text-primary transition-colors p-0.5 hover:bg-primary/10 rounded"
|
|
title="Move up">
|
|
<x-ri-arrow-up-s-line class="size-4" />
|
|
</button>
|
|
@else
|
|
<div class="size-4 p-0.5"></div>
|
|
@endif
|
|
@if($index < count($rules) - 1)
|
|
<button type="button"
|
|
wire:click="reorderRules({{ $rule['pos'] }}, {{ $rules[$index + 1]['pos'] }})"
|
|
wire:key="move-down-{{ $index }}"
|
|
class="text-base/50 hover:text-primary transition-colors p-0.5 hover:bg-primary/10 rounded"
|
|
title="Move down">
|
|
<x-ri-arrow-down-s-line class="size-4" />
|
|
</button>
|
|
@else
|
|
<div class="size-4 p-0.5"></div>
|
|
@endif
|
|
</div>
|
|
</td>
|
|
<td class="py-3 px-4 text-sm font-mono">{{ $rule['pos'] ?? '-' }}</td>
|
|
<td class="py-3 px-4 text-sm">
|
|
@php
|
|
$actionClass = match($rule['action'] ?? '') {
|
|
'ACCEPT' => 'bg-success/20 text-success',
|
|
'REJECT' => 'bg-warning/20 text-warning',
|
|
'DROP' => 'bg-error/20 text-error',
|
|
default => 'bg-base/20 text-base/60'
|
|
};
|
|
@endphp
|
|
<span class="px-2 py-1 rounded text-xs font-medium {{ $actionClass }}">
|
|
{{ $rule['action'] ?? '-' }}
|
|
</span>
|
|
</td>
|
|
<td class="py-3 px-4 text-sm">
|
|
<span class="px-2 py-1 rounded text-xs font-medium bg-info/20 text-info">
|
|
{{ strtoupper($rule['type'] ?? 'IN') }}
|
|
</span>
|
|
</td>
|
|
<td class="py-3 px-4 text-sm font-mono text-xs">
|
|
<div class="flex items-center gap-1">
|
|
<span>{{ $rule['source'] ?? '-' }}</span>
|
|
@php $ruleSourceIP = $rule['source'] ?? ''; @endphp
|
|
@if($ruleSourceIP && $ruleSourceIP !== '-' && $this->checkIsPublicIP($ruleSourceIP))
|
|
<div class="relative" x-data="{ open: false, loading: false, ipData: null }" x-cloak>
|
|
<button type="button" @click.stop="
|
|
if (!ipData && !loading) {
|
|
loading = true;
|
|
open = true;
|
|
window.fetchIPLocation('{{ $ruleSourceIP }}').then(data => {
|
|
ipData = data;
|
|
loading = false;
|
|
}).catch(err => {
|
|
ipData = { status: 'error', message: 'Failed to load' };
|
|
loading = false;
|
|
});
|
|
} else {
|
|
open = true;
|
|
}
|
|
" class="text-base/50 hover:text-base/70 transition-colors" title="View IP Location">
|
|
<x-ri-map-pin-line class="size-3" />
|
|
</button>
|
|
|
|
{{-- IP Location Modal (same as logs) --}}
|
|
<div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="open = false" @keydown.escape.window="open = false" class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4" style="display: none;">
|
|
<div @click.stop class="relative w-full max-w-4xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl max-h-[90vh] overflow-y-auto">
|
|
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors z-10">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="p-6">
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<x-ri-map-pin-line class="size-5 text-primary" />
|
|
<h3 class="text-lg font-semibold">IP Address: <span class="font-mono">{{ $ruleSourceIP }}</span></h3>
|
|
</div>
|
|
<div x-show="loading" class="text-center py-8">
|
|
<svg fill="currentColor" stroke="currentColor" viewBox="0 0 128 128" class="size-6 mx-auto mb-2">
|
|
<g><path stroke-width="5" d="M31.6 8.98a4.02 4.02 0 0 0 1.7 5.3l6.12 3.22a52.56 52.56 0 1 0 63.13 10.73L96 33.97a43.88 43.88 0 1 1-53-8.5l-3.65 7.4a4.02 4.02 0 0 0 1.72 5.3 3.73 3.73 0 0 0 5.13-1.67l7.66-15.63a4.18 4.18 0 0 0-1.75-5.4L36.74 7.32a3.73 3.73 0 0 0-5.13 1.66z" fill="currentColor" stroke="currentColor"/><animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="1000ms" repeatCount="indefinite"></animateTransform></g>
|
|
</svg>
|
|
<p class="text-sm text-base/60">Loading location data...</p>
|
|
</div>
|
|
<div x-show="ipData && !loading">
|
|
<template x-if="ipData && ipData.status === 'success'">
|
|
<div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div class="space-y-2">
|
|
<div><span class="text-xs text-base/60">Country:</span> <span class="font-semibold text-sm" x-text="ipData.country"></span></div>
|
|
<div><span class="text-xs text-base/60">Region:</span> <span class="font-semibold text-sm" x-text="ipData.regionName + ' (' + ipData.region + ')'"></span></div>
|
|
<div><span class="text-xs text-base/60">City:</span> <span class="font-semibold text-sm" x-text="ipData.city"></span></div>
|
|
<div><span class="text-xs text-base/60">ZIP:</span> <span class="font-semibold text-sm" x-text="ipData.zip"></span></div>
|
|
<div><span class="text-xs text-base/60">Timezone:</span> <span class="font-semibold text-sm" x-text="ipData.timezone"></span></div>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div><span class="text-xs text-base/60">ISP:</span> <span class="font-semibold text-sm" x-text="ipData.isp"></span></div>
|
|
<div><span class="text-xs text-base/60">Organization:</span> <span class="font-semibold text-sm" x-text="ipData.org"></span></div>
|
|
<div><span class="text-xs text-base/60">AS:</span> <span class="font-semibold text-sm" x-text="ipData.as"></span></div>
|
|
<div><span class="text-xs text-base/60">Coordinates:</span> <span class="font-semibold text-sm font-mono" x-text="ipData.lat + ', ' + ipData.lon"></span></div>
|
|
</div>
|
|
</div>
|
|
<div x-show="ipData.lat && ipData.lon" class="mt-4">
|
|
<p class="text-xs text-base/60 mb-2">Location Map:</p>
|
|
<div class="rounded-lg border border-neutral/20 overflow-hidden" style="height: 256px;">
|
|
<iframe x-bind:src="'https://www.openstreetmap.org/export/embed.html?bbox=' + encodeURIComponent((parseFloat(ipData.lon) - 0.01) + ',' + (parseFloat(ipData.lat) - 0.01) + ',' + (parseFloat(ipData.lon) + 0.01) + ',' + (parseFloat(ipData.lat) + 0.01)) + '&layer=mapnik&marker=' + encodeURIComponent(ipData.lat + ',' + ipData.lon)" style="width: 100%; height: 100%; border: 0;" frameborder="0" loading="lazy" allowfullscreen title="Location Map"></iframe>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="ipData && (ipData.status === 'fail' || ipData.status === 'error')">
|
|
<div class="text-center py-4">
|
|
<p class="text-sm text-warning" x-text="ipData.message || 'Failed to load location data'"></p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</td>
|
|
<td class="py-3 px-4 text-sm font-mono text-xs">
|
|
<div class="flex items-center gap-1">
|
|
<span>{{ $rule['dest'] ?? '-' }}</span>
|
|
@php $ruleDestIP = $rule['dest'] ?? ''; @endphp
|
|
@if($ruleDestIP && $ruleDestIP !== '-' && $this->checkIsPublicIP($ruleDestIP))
|
|
<div class="relative" x-data="{ open: false, loading: false, ipData: null }" x-cloak>
|
|
<button type="button" @click.stop="
|
|
if (!ipData && !loading) {
|
|
loading = true;
|
|
open = true;
|
|
window.fetchIPLocation('{{ $ruleDestIP }}').then(data => {
|
|
ipData = data;
|
|
loading = false;
|
|
}).catch(err => {
|
|
ipData = { status: 'error', message: 'Failed to load' };
|
|
loading = false;
|
|
});
|
|
} else {
|
|
open = true;
|
|
}
|
|
" class="text-base/50 hover:text-base/70 transition-colors" title="View IP Location">
|
|
<x-ri-map-pin-line class="size-3" />
|
|
</button>
|
|
|
|
{{-- IP Location Modal (same structure) --}}
|
|
<div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="open = false" @keydown.escape.window="open = false" class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4" style="display: none;">
|
|
<div @click.stop class="relative w-full max-w-4xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl max-h-[90vh] overflow-y-auto">
|
|
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors z-10">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="p-6">
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<x-ri-map-pin-line class="size-5 text-primary" />
|
|
<h3 class="text-lg font-semibold">IP Address: <span class="font-mono">{{ $ruleDestIP }}</span></h3>
|
|
</div>
|
|
<div x-show="loading" class="text-center py-8">
|
|
<svg fill="currentColor" stroke="currentColor" viewBox="0 0 128 128" class="size-6 mx-auto mb-2">
|
|
<g><path stroke-width="5" d="M31.6 8.98a4.02 4.02 0 0 0 1.7 5.3l6.12 3.22a52.56 52.56 0 1 0 63.13 10.73L96 33.97a43.88 43.88 0 1 1-53-8.5l-3.65 7.4a4.02 4.02 0 0 0 1.72 5.3 3.73 3.73 0 0 0 5.13-1.67l7.66-15.63a4.18 4.18 0 0 0-1.75-5.4L36.74 7.32a3.73 3.73 0 0 0-5.13 1.66z" fill="currentColor" stroke="currentColor"/><animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="1000ms" repeatCount="indefinite"></animateTransform></g>
|
|
</svg>
|
|
<p class="text-sm text-base/60">Loading location data...</p>
|
|
</div>
|
|
<div x-show="ipData && !loading">
|
|
<template x-if="ipData && ipData.status === 'success'">
|
|
<div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div class="space-y-2">
|
|
<div><span class="text-xs text-base/60">Country:</span> <span class="font-semibold text-sm" x-text="ipData.country"></span></div>
|
|
<div><span class="text-xs text-base/60">Region:</span> <span class="font-semibold text-sm" x-text="ipData.regionName + ' (' + ipData.region + ')'"></span></div>
|
|
<div><span class="text-xs text-base/60">City:</span> <span class="font-semibold text-sm" x-text="ipData.city"></span></div>
|
|
<div><span class="text-xs text-base/60">ZIP:</span> <span class="font-semibold text-sm" x-text="ipData.zip"></span></div>
|
|
<div><span class="text-xs text-base/60">Timezone:</span> <span class="font-semibold text-sm" x-text="ipData.timezone"></span></div>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div><span class="text-xs text-base/60">ISP:</span> <span class="font-semibold text-sm" x-text="ipData.isp"></span></div>
|
|
<div><span class="text-xs text-base/60">Organization:</span> <span class="font-semibold text-sm" x-text="ipData.org"></span></div>
|
|
<div><span class="text-xs text-base/60">AS:</span> <span class="font-semibold text-sm" x-text="ipData.as"></span></div>
|
|
<div><span class="text-xs text-base/60">Coordinates:</span> <span class="font-semibold text-sm font-mono" x-text="ipData.lat + ', ' + ipData.lon"></span></div>
|
|
</div>
|
|
</div>
|
|
<div x-show="ipData.lat && ipData.lon" class="mt-4">
|
|
<p class="text-xs text-base/60 mb-2">Location Map:</p>
|
|
<div class="rounded-lg border border-neutral/20 overflow-hidden" style="height: 256px;">
|
|
<iframe x-bind:src="'https://www.openstreetmap.org/export/embed.html?bbox=' + encodeURIComponent((parseFloat(ipData.lon) - 0.01) + ',' + (parseFloat(ipData.lat) - 0.01) + ',' + (parseFloat(ipData.lon) + 0.01) + ',' + (parseFloat(ipData.lat) + 0.01)) + '&layer=mapnik&marker=' + encodeURIComponent(ipData.lat + ',' + ipData.lon)" style="width: 100%; height: 100%; border: 0;" frameborder="0" loading="lazy" allowfullscreen title="Location Map"></iframe>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="ipData && (ipData.status === 'fail' || ipData.status === 'error')">
|
|
<div class="text-center py-4">
|
|
<p class="text-sm text-warning" x-text="ipData.message || 'Failed to load location data'"></p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</td>
|
|
<td class="py-3 px-4 text-sm">{{ strtoupper($rule['proto'] ?? ($rule['macro'] ?? 'ANY')) }}</td>
|
|
<td class="py-3 px-4 text-sm font-mono text-xs">{{ $rule['dport'] ?? $rule['sport'] ?? '-' }}</td>
|
|
<td class="py-3 px-4 text-sm">
|
|
@if(isset($rule['enable']) && $rule['enable'] == 0)
|
|
<span class="px-2 py-1 rounded text-xs font-medium bg-base/20 text-base/60">Disabled</span>
|
|
@else
|
|
<span class="px-2 py-1 rounded text-xs font-medium bg-success/20 text-success">Enabled</span>
|
|
@endif
|
|
</td>
|
|
<td class="py-3 px-4 text-sm">
|
|
<div class="flex gap-2">
|
|
<button type="button" wire:click="toggleRule({{ $index }})" wire:key="toggle-{{ $index }}" class="text-warning hover:text-warning/80 transition-colors" title="Toggle rule">
|
|
@if(isset($rule['enable']) && $rule['enable'] == 0)
|
|
<x-ri-toggle-line class="size-4" />
|
|
@else
|
|
<x-ri-toggle-fill class="size-4" />
|
|
@endif
|
|
</button>
|
|
<button type="button" wire:click="startEditRule({{ $index }})" wire:key="edit-{{ $index }}" class="text-primary hover:text-primary/80 transition-colors" title="Edit rule">
|
|
<x-ri-edit-line class="size-4" />
|
|
</button>
|
|
<button type="button" wire:click="confirmDeleteRule({{ $index }})" wire:key="delete-{{ $index }}" class="text-error hover:text-error/80 transition-colors" title="Delete rule">
|
|
<x-ri-delete-bin-line class="size-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
@else
|
|
<div class="text-center py-12 text-base/60">
|
|
<x-ri-shield-line class="size-16 mx-auto mb-4 opacity-30" />
|
|
<h3 class="text-lg font-semibold mb-2">No Firewall Rules</h3>
|
|
<p class="text-sm mb-4">Create your first rule to control network traffic</p>
|
|
<button type="button" wire:click="startCreateRule" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
|
|
<x-ri-add-line class="size-4 inline mr-1" />
|
|
Create Rule
|
|
</button>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- ALIASES TAB --}}
|
|
<div x-show="activeTab === 'aliases'" x-transition>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 class="text-lg font-semibold">IP Aliases</h3>
|
|
<p class="text-sm text-base/60">Create named references to IP addresses or CIDR ranges</p>
|
|
</div>
|
|
<button type="button" wire:click="startCreateAlias" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium flex items-center gap-2">
|
|
<x-ri-add-line class="size-4" />
|
|
Add Alias
|
|
</button>
|
|
</div>
|
|
|
|
@if(count($aliases) > 0)
|
|
<div class="grid gap-3">
|
|
@foreach($aliases as $alias)
|
|
<div class="p-4 bg-background-secondary/50 border border-neutral/20 rounded-lg hover:border-neutral/40 transition-colors">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1">
|
|
<div class="font-semibold text-sm mb-1">{{ $alias['name'] }}</div>
|
|
<div class="text-xs font-mono text-base/70 mb-2">{{ $alias['cidr'] }}</div>
|
|
@if(!empty($alias['comment']))
|
|
<div class="text-xs text-base/60">{{ $alias['comment'] }}</div>
|
|
@endif
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button type="button" wire:click="startEditAlias('{{ $alias['name'] }}')" class="text-primary hover:text-primary/80 transition-colors" title="Edit alias">
|
|
<x-ri-edit-line class="size-4" />
|
|
</button>
|
|
<button type="button" wire:click="confirmDeleteAlias('{{ $alias['name'] }}')" class="text-error hover:text-error/80 transition-colors" title="Delete alias">
|
|
<x-ri-delete-bin-line class="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<div class="text-center py-12 text-base/60">
|
|
<x-ri-bookmark-line class="size-16 mx-auto mb-4 opacity-30" />
|
|
<h3 class="text-lg font-semibold mb-2">No IP Aliases</h3>
|
|
<p class="text-sm mb-4">Create aliases to simplify rule management</p>
|
|
<button type="button" wire:click="startCreateAlias" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
|
|
<x-ri-add-line class="size-4 inline mr-1" />
|
|
Create Alias
|
|
</button>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- IPSETS TAB --}}
|
|
<div x-show="activeTab === 'ipsets'" x-transition>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 class="text-lg font-semibold">IP Sets</h3>
|
|
<p class="text-sm text-base/60">Group multiple IPs/CIDRs for easier rule management</p>
|
|
</div>
|
|
<button type="button" wire:click="startCreateIPSet" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium flex items-center gap-2">
|
|
<x-ri-add-line class="size-4" />
|
|
Create IP Set
|
|
</button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{{-- IPSets List --}}
|
|
<div>
|
|
<h4 class="text-sm font-semibold text-base/70 uppercase tracking-wide mb-3">Available IP Sets</h4>
|
|
@if(count($ipsets) > 0)
|
|
<div class="space-y-2">
|
|
@foreach($ipsets as $ipset)
|
|
<div class="p-3 bg-background-secondary/50 border border-neutral/20 rounded-lg hover:border-primary/40 transition-colors cursor-pointer"
|
|
wire:click="selectIPSet('{{ $ipset['name'] }}')"
|
|
class="{{ $selectedIPSetName === $ipset['name'] ? 'border-primary/60 bg-primary/5' : '' }}">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1">
|
|
<div class="font-semibold text-sm">{{ $ipset['name'] }}</div>
|
|
@if(!empty($ipset['comment']))
|
|
<div class="text-xs text-base/60 mt-1">{{ $ipset['comment'] }}</div>
|
|
@endif
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button type="button" wire:click.stop="confirmDeleteIPSet('{{ $ipset['name'] }}')" class="text-error hover:text-error/80 transition-colors" title="Delete IPSet">
|
|
<x-ri-delete-bin-line class="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<div class="text-center py-8 text-base/60 bg-background-secondary/30 rounded-lg border border-dashed border-neutral/30">
|
|
<x-ri-folder-3-line class="size-12 mx-auto mb-3 opacity-30" />
|
|
<p class="text-sm">No IP Sets created yet</p>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- IPSet Members --}}
|
|
<div>
|
|
@if($selectedIPSetName)
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h4 class="text-sm font-semibold text-base/70 uppercase tracking-wide">Members of: <span class="text-primary">{{ $selectedIPSetName }}</span></h4>
|
|
<button type="button" wire:click="startAddIPSetMember" class="px-3 py-1.5 bg-success text-white rounded-lg hover:bg-success/90 transition-colors text-xs font-medium flex items-center gap-1">
|
|
<x-ri-add-line class="size-3" />
|
|
Add IP/CIDR
|
|
</button>
|
|
</div>
|
|
|
|
@if(count($ipsetMembers) > 0)
|
|
<div class="space-y-2 max-h-96 overflow-y-auto">
|
|
@foreach($ipsetMembers as $member)
|
|
<div class="p-3 bg-background border border-neutral/20 rounded-lg">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1">
|
|
<div class="font-mono text-sm font-semibold">{{ $member['cidr'] }}</div>
|
|
@if(!empty($member['comment']))
|
|
<div class="text-xs text-base/60 mt-1">{{ $member['comment'] }}</div>
|
|
@endif
|
|
@if(isset($member['nomatch']) && $member['nomatch'])
|
|
<span class="inline-block mt-1 px-2 py-0.5 bg-warning/20 text-warning text-xs font-medium rounded">No Match</span>
|
|
@endif
|
|
</div>
|
|
<button type="button" wire:click="confirmDeleteIPSetMember('{{ $member['cidr'] }}')" class="text-error hover:text-error/80 transition-colors" title="Remove">
|
|
<x-ri-close-circle-line class="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<div class="text-center py-8 text-base/60 bg-background-secondary/30 rounded-lg border border-dashed border-neutral/30">
|
|
<x-ri-inbox-line class="size-10 mx-auto mb-2 opacity-30" />
|
|
<p class="text-sm">No members in this IPSet</p>
|
|
<button type="button" wire:click="startAddIPSetMember" class="mt-3 px-3 py-1.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-xs font-medium">
|
|
Add First Member
|
|
</button>
|
|
</div>
|
|
@endif
|
|
@else
|
|
<div class="text-center py-12 text-base/60">
|
|
<x-ri-arrow-left-line class="size-12 mx-auto mb-3 opacity-30" />
|
|
<p class="text-sm">Select an IP Set to view its members</p>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- LOGS TAB --}}
|
|
<div x-show="activeTab === 'logs'"
|
|
x-transition
|
|
wire:poll.30s="loadFirewallLogs"
|
|
x-init="$watch('activeTab', value => { if (value === 'logs' && !@json($logsLoaded)) { $wire.loadFirewallLogs(); } })">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 class="text-lg font-semibold">Firewall Logs</h3>
|
|
<div class="flex items-center gap-3 mt-1">
|
|
<p class="text-xs text-base/60">Showing the latest logs first (most recent at top)</p>
|
|
@if($lastLogRefresh)
|
|
<span class="text-xs text-success/80">• Last updated: {{ $lastLogRefresh }}</span>
|
|
@endif
|
|
<span class="text-xs text-info/80">• Auto-refreshes every 30s</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<button type="button" wire:click="loadFirewallLogs" wire:loading.attr="disabled" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium flex items-center gap-2">
|
|
<span wire:loading.remove wire:target="loadFirewallLogs">
|
|
<x-ri-refresh-line class="size-4" />
|
|
</span>
|
|
<div wire:loading wire:target="loadFirewallLogs" class="loader-container">
|
|
<style>
|
|
.loader-container {
|
|
width: 16px;
|
|
height: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.loader {
|
|
width: 16px;
|
|
aspect-ratio: 1;
|
|
border-radius: 50%;
|
|
padding: 2px;
|
|
background:
|
|
conic-gradient(from 135deg at top, currentColor 90deg, #0000 0) 0 calc(50% - 1.5px)/6px 3px,
|
|
radial-gradient(farthest-side at bottom left, #0000 calc(100% - 2px), currentColor calc(100% - 1.5px) 99%, #0000) top right/50% 50% content-box content-box,
|
|
radial-gradient(farthest-side at top, #0000 calc(100% - 2px), currentColor calc(100% - 1.5px) 99%, #0000) bottom/100% 50% content-box content-box;
|
|
background-repeat: no-repeat;
|
|
animation: loader-spin 1s infinite linear;
|
|
}
|
|
@keyframes loader-spin {
|
|
100% { transform: rotate(1turn); }
|
|
}
|
|
</style>
|
|
<div class="loader"></div>
|
|
</div>
|
|
<span wire:loading.remove wire:target="loadFirewallLogs">Refresh</span>
|
|
<span wire:loading wire:target="loadFirewallLogs">Loading...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
@if(!empty($logError))
|
|
<div class="bg-warning/10 border border-warning/30 rounded-lg p-4 mb-4">
|
|
<div class="flex items-start gap-3">
|
|
<x-ri-error-warning-line class="size-5 text-warning flex-shrink-0 mt-0.5" />
|
|
<p class="text-sm text-warning/90">{{ $logError }}</p>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
@if(!$logsLoaded && count($firewallLogs) === 0)
|
|
<div class="text-center py-12 text-base/60">
|
|
<div class="loader-container-large" style="width: 50px; height: 50px; margin: 0 auto 1rem;">
|
|
<style>
|
|
.loader-container-large {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.loader-large {
|
|
width: 50px;
|
|
aspect-ratio: 1;
|
|
border-radius: 50%;
|
|
padding: 6px;
|
|
background:
|
|
conic-gradient(from 135deg at top, currentColor 90deg, #0000 0) 0 calc(50% - 4px)/17px 8.5px,
|
|
radial-gradient(farthest-side at bottom left, #0000 calc(100% - 6px), currentColor calc(100% - 5px) 99%, #0000) top right/50% 50% content-box content-box,
|
|
radial-gradient(farthest-side at top, #0000 calc(100% - 6px), currentColor calc(100% - 5px) 99%, #0000) bottom/100% 50% content-box content-box;
|
|
background-repeat: no-repeat;
|
|
animation: loader-spin-large 1s infinite linear;
|
|
}
|
|
@keyframes loader-spin-large {
|
|
100% { transform: rotate(1turn); }
|
|
}
|
|
</style>
|
|
<div class="loader-large"></div>
|
|
</div>
|
|
<p class="text-sm">Loading firewall logs...</p>
|
|
</div>
|
|
@elseif(count($firewallLogs) > 0)
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr class="border-b border-neutral/20">
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Time</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Action</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Source</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Destination</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Protocol</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Port</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach($this->paginatedLogs as $log)
|
|
<tr class="border-b border-neutral/10 hover:bg-background-secondary/30 transition-colors">
|
|
<td class="py-3 px-4 text-xs text-base/70">{{ $log['timestamp'] ?? '-' }}</td>
|
|
<td class="py-3 px-4 text-sm">
|
|
<span class="px-2 py-1 rounded text-xs font-medium {{ $log['level_badge_class'] ?? '' }}">
|
|
{{ $log['action'] ?? '-' }}
|
|
</span>
|
|
</td>
|
|
<td class="py-3 px-4 text-xs font-mono">
|
|
<div class="flex items-center gap-1">
|
|
<span>{{ $log['source'] ?? '-' }}</span>
|
|
@php $sourceIP = $log['source'] ?? ''; @endphp
|
|
@if($sourceIP && $sourceIP !== '-' && $this->checkIsPublicIP($sourceIP))
|
|
<div class="relative" x-data="{ open: false, loading: false, ipData: null }" x-cloak>
|
|
<button type="button" @click.stop="
|
|
if (!ipData && !loading) {
|
|
loading = true;
|
|
open = true;
|
|
window.fetchIPLocation('{{ $sourceIP }}').then(data => {
|
|
ipData = data;
|
|
loading = false;
|
|
}).catch(err => {
|
|
ipData = { status: 'error', message: 'Failed to load' };
|
|
loading = false;
|
|
});
|
|
} else {
|
|
open = true;
|
|
}
|
|
" class="text-base/50 hover:text-base/70 transition-colors" title="View IP Location">
|
|
<x-ri-map-pin-line class="size-3" />
|
|
</button>
|
|
|
|
{{-- IP Location Modal --}}
|
|
<div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="open = false" @keydown.escape.window="open = false" class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4" style="display: none;">
|
|
<div @click.stop class="relative w-full max-w-4xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl max-h-[90vh] overflow-y-auto">
|
|
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors z-10">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="p-6">
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<x-ri-map-pin-line class="size-5 text-primary" />
|
|
<h3 class="text-lg font-semibold">IP Address: <span class="font-mono">{{ $sourceIP }}</span></h3>
|
|
</div>
|
|
<div x-show="loading" class="text-center py-8">
|
|
<svg fill="currentColor" stroke="currentColor" viewBox="0 0 128 128" class="size-6 mx-auto mb-2">
|
|
<g><path stroke-width="5" d="M31.6 8.98a4.02 4.02 0 0 0 1.7 5.3l6.12 3.22a52.56 52.56 0 1 0 63.13 10.73L96 33.97a43.88 43.88 0 1 1-53-8.5l-3.65 7.4a4.02 4.02 0 0 0 1.72 5.3 3.73 3.73 0 0 0 5.13-1.67l7.66-15.63a4.18 4.18 0 0 0-1.75-5.4L36.74 7.32a3.73 3.73 0 0 0-5.13 1.66z" fill="currentColor" stroke="currentColor"/><animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="1000ms" repeatCount="indefinite"></animateTransform></g>
|
|
</svg>
|
|
<p class="text-sm text-base/60">Loading location data...</p>
|
|
</div>
|
|
<div x-show="ipData && !loading">
|
|
<template x-if="ipData && ipData.status === 'success'">
|
|
<div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div class="space-y-2">
|
|
<div><span class="text-xs text-base/60">Country:</span> <span class="font-semibold text-sm" x-text="ipData.country"></span></div>
|
|
<div><span class="text-xs text-base/60">Region:</span> <span class="font-semibold text-sm" x-text="ipData.regionName + ' (' + ipData.region + ')'"></span></div>
|
|
<div><span class="text-xs text-base/60">City:</span> <span class="font-semibold text-sm" x-text="ipData.city"></span></div>
|
|
<div><span class="text-xs text-base/60">ZIP:</span> <span class="font-semibold text-sm" x-text="ipData.zip"></span></div>
|
|
<div><span class="text-xs text-base/60">Timezone:</span> <span class="font-semibold text-sm" x-text="ipData.timezone"></span></div>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div><span class="text-xs text-base/60">ISP:</span> <span class="font-semibold text-sm" x-text="ipData.isp"></span></div>
|
|
<div><span class="text-xs text-base/60">Organization:</span> <span class="font-semibold text-sm" x-text="ipData.org"></span></div>
|
|
<div><span class="text-xs text-base/60">AS:</span> <span class="font-semibold text-sm" x-text="ipData.as"></span></div>
|
|
<div><span class="text-xs text-base/60">Coordinates:</span> <span class="font-semibold text-sm font-mono" x-text="ipData.lat + ', ' + ipData.lon"></span></div>
|
|
</div>
|
|
</div>
|
|
<div x-show="ipData.lat && ipData.lon" class="mt-4">
|
|
<p class="text-xs text-base/60 mb-2">Location Map:</p>
|
|
<div class="rounded-lg border border-neutral/20 overflow-hidden" style="height: 256px;">
|
|
<iframe x-bind:src="'https://www.openstreetmap.org/export/embed.html?bbox=' + encodeURIComponent((parseFloat(ipData.lon) - 0.01) + ',' + (parseFloat(ipData.lat) - 0.01) + ',' + (parseFloat(ipData.lon) + 0.01) + ',' + (parseFloat(ipData.lat) + 0.01)) + '&layer=mapnik&marker=' + encodeURIComponent(ipData.lat + ',' + ipData.lon)" style="width: 100%; height: 100%; border: 0;" frameborder="0" loading="lazy" allowfullscreen title="Location Map"></iframe>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="ipData && (ipData.status === 'fail' || ipData.status === 'error')">
|
|
<div class="text-center py-4">
|
|
<p class="text-sm text-warning" x-text="ipData.message || 'Failed to load location data'"></p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</td>
|
|
<td class="py-3 px-4 text-xs font-mono">
|
|
<div class="flex items-center gap-1">
|
|
<span>{{ $log['destination'] ?? '-' }}</span>
|
|
@php $destIP = $log['destination'] ?? ''; @endphp
|
|
@if($destIP && $destIP !== '-' && $this->checkIsPublicIP($destIP))
|
|
<div class="relative" x-data="{ open: false, loading: false, ipData: null }" x-cloak>
|
|
<button type="button" @click.stop="
|
|
if (!ipData && !loading) {
|
|
loading = true;
|
|
open = true;
|
|
window.fetchIPLocation('{{ $destIP }}').then(data => {
|
|
ipData = data;
|
|
loading = false;
|
|
}).catch(err => {
|
|
ipData = { status: 'error', message: 'Failed to load' };
|
|
loading = false;
|
|
});
|
|
} else {
|
|
open = true;
|
|
}
|
|
" class="text-base/50 hover:text-base/70 transition-colors" title="View IP Location">
|
|
<x-ri-map-pin-line class="size-3" />
|
|
</button>
|
|
|
|
{{-- IP Location Modal --}}
|
|
<div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" @click="open = false" @keydown.escape.window="open = false" class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4" style="display: none;">
|
|
<div @click.stop class="relative w-full max-w-4xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl max-h-[90vh] overflow-y-auto">
|
|
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors z-10">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="p-6">
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<x-ri-map-pin-line class="size-5 text-primary" />
|
|
<h3 class="text-lg font-semibold">IP Address: <span class="font-mono">{{ $destIP }}</span></h3>
|
|
</div>
|
|
<div x-show="loading" class="text-center py-8">
|
|
<svg fill="currentColor" stroke="currentColor" viewBox="0 0 128 128" class="size-6 mx-auto mb-2">
|
|
<g><path stroke-width="5" d="M31.6 8.98a4.02 4.02 0 0 0 1.7 5.3l6.12 3.22a52.56 52.56 0 1 0 63.13 10.73L96 33.97a43.88 43.88 0 1 1-53-8.5l-3.65 7.4a4.02 4.02 0 0 0 1.72 5.3 3.73 3.73 0 0 0 5.13-1.67l7.66-15.63a4.18 4.18 0 0 0-1.75-5.4L36.74 7.32a3.73 3.73 0 0 0-5.13 1.66z" fill="currentColor" stroke="currentColor"/><animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="1000ms" repeatCount="indefinite"></animateTransform></g>
|
|
</svg>
|
|
<p class="text-sm text-base/60">Loading location data...</p>
|
|
</div>
|
|
<div x-show="ipData && !loading">
|
|
<template x-if="ipData && ipData.status === 'success'">
|
|
<div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div class="space-y-2">
|
|
<div><span class="text-xs text-base/60">Country:</span> <span class="font-semibold text-sm" x-text="ipData.country"></span></div>
|
|
<div><span class="text-xs text-base/60">Region:</span> <span class="font-semibold text-sm" x-text="ipData.regionName + ' (' + ipData.region + ')'"></span></div>
|
|
<div><span class="text-xs text-base/60">City:</span> <span class="font-semibold text-sm" x-text="ipData.city"></span></div>
|
|
<div><span class="text-xs text-base/60">ZIP:</span> <span class="font-semibold text-sm" x-text="ipData.zip"></span></div>
|
|
<div><span class="text-xs text-base/60">Timezone:</span> <span class="font-semibold text-sm" x-text="ipData.timezone"></span></div>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div><span class="text-xs text-base/60">ISP:</span> <span class="font-semibold text-sm" x-text="ipData.isp"></span></div>
|
|
<div><span class="text-xs text-base/60">Organization:</span> <span class="font-semibold text-sm" x-text="ipData.org"></span></div>
|
|
<div><span class="text-xs text-base/60">AS:</span> <span class="font-semibold text-sm" x-text="ipData.as"></span></div>
|
|
<div><span class="text-xs text-base/60">Coordinates:</span> <span class="font-semibold text-sm font-mono" x-text="ipData.lat + ', ' + ipData.lon"></span></div>
|
|
</div>
|
|
</div>
|
|
<div x-show="ipData.lat && ipData.lon" class="mt-4">
|
|
<p class="text-xs text-base/60 mb-2">Location Map:</p>
|
|
<div class="rounded-lg border border-neutral/20 overflow-hidden" style="height: 256px;">
|
|
<iframe x-bind:src="'https://www.openstreetmap.org/export/embed.html?bbox=' + encodeURIComponent((parseFloat(ipData.lon) - 0.01) + ',' + (parseFloat(ipData.lat) - 0.01) + ',' + (parseFloat(ipData.lon) + 0.01) + ',' + (parseFloat(ipData.lat) + 0.01)) + '&layer=mapnik&marker=' + encodeURIComponent(ipData.lat + ',' + ipData.lon)" style="width: 100%; height: 100%; border: 0;" frameborder="0" loading="lazy" allowfullscreen title="Location Map"></iframe>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="ipData && (ipData.status === 'fail' || ipData.status === 'error')">
|
|
<div class="text-center py-4">
|
|
<p class="text-sm text-warning" x-text="ipData.message || 'Failed to load location data'"></p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</td>
|
|
<td class="py-3 px-4 text-sm">{{ $log['protocol'] ?? '-' }}</td>
|
|
<td class="py-3 px-4 text-xs font-mono">{{ $log['destination_port'] ?? '-' }}</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{{-- Pagination Controls --}}
|
|
<div class="mt-6 flex items-center justify-between border-t border-neutral/20 pt-4">
|
|
{{-- Records per page selector --}}
|
|
<div class="flex items-center gap-3">
|
|
<label class="text-sm text-base/70">Show:</label>
|
|
<select wire:model.live="perPage" class="px-3 py-1.5 border border-neutral rounded-lg bg-background text-sm">
|
|
<option value="10">10</option>
|
|
<option value="25">25</option>
|
|
<option value="50">50</option>
|
|
<option value="100">100</option>
|
|
<option value="250">250</option>
|
|
</select>
|
|
<span class="text-sm text-base/60">per page</span>
|
|
</div>
|
|
|
|
{{-- Page info and navigation --}}
|
|
<div class="flex items-center gap-4">
|
|
<span class="text-sm text-base/60">
|
|
Showing {{ (($currentPage - 1) * $perPage) + 1 }} - {{ min($currentPage * $perPage, count($firewallLogs)) }} of {{ count($firewallLogs) }} logs
|
|
</span>
|
|
|
|
<div class="flex items-center gap-1">
|
|
{{-- First page --}}
|
|
<button type="button"
|
|
wire:click="goToPage(1)"
|
|
@if($currentPage === 1) disabled @endif
|
|
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="First page">
|
|
<x-ri-arrow-left-double-line class="size-4" />
|
|
</button>
|
|
|
|
{{-- Previous page --}}
|
|
<button type="button"
|
|
wire:click="previousPage"
|
|
@if($currentPage === 1) disabled @endif
|
|
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="Previous page">
|
|
<x-ri-arrow-left-s-line class="size-4" />
|
|
</button>
|
|
|
|
{{-- Page numbers --}}
|
|
@php
|
|
$totalPages = $this->totalPages;
|
|
$startPage = max(1, $currentPage - 2);
|
|
$endPage = min($totalPages, $currentPage + 2);
|
|
@endphp
|
|
|
|
@if($startPage > 1)
|
|
<button type="button" wire:click="goToPage(1)" class="px-3 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors text-sm">1</button>
|
|
@if($startPage > 2)
|
|
<span class="px-2 text-base/60">...</span>
|
|
@endif
|
|
@endif
|
|
|
|
@for($i = $startPage; $i <= $endPage; $i++)
|
|
<button type="button"
|
|
wire:click="goToPage({{ $i }})"
|
|
class="px-3 py-1 border rounded text-sm transition-colors {{ $currentPage === $i ? 'bg-primary text-white border-primary' : 'border-neutral hover:bg-background-secondary' }}">
|
|
{{ $i }}
|
|
</button>
|
|
@endfor
|
|
|
|
@if($endPage < $totalPages)
|
|
@if($endPage < $totalPages - 1)
|
|
<span class="px-2 text-base/60">...</span>
|
|
@endif
|
|
<button type="button" wire:click="goToPage({{ $totalPages }})" class="px-3 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors text-sm">{{ $totalPages }}</button>
|
|
@endif
|
|
|
|
{{-- Next page --}}
|
|
<button type="button"
|
|
wire:click="nextPage"
|
|
@if($currentPage === $totalPages) disabled @endif
|
|
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="Next page">
|
|
<x-ri-arrow-right-s-line class="size-4" />
|
|
</button>
|
|
|
|
{{-- Last page --}}
|
|
<button type="button"
|
|
wire:click="goToPage({{ $totalPages }})"
|
|
@if($currentPage === $totalPages) disabled @endif
|
|
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="Last page">
|
|
<x-ri-arrow-right-double-line class="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div class="text-center py-12 text-base/60">
|
|
<x-ri-file-list-3-line class="size-16 mx-auto mb-4 opacity-30" />
|
|
<h3 class="text-lg font-semibold mb-2">No Firewall Logs</h3>
|
|
<p class="text-sm">Logs will appear here when the firewall blocks or allows traffic</p>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div class="text-center py-16 bg-background border border-neutral rounded-xl">
|
|
<x-ri-shield-line class="size-20 mx-auto mb-4 opacity-30" />
|
|
<h3 class="text-xl font-semibold mb-2">Firewall is Disabled</h3>
|
|
<p class="text-base/60 mb-6">Enable the firewall to start protecting your virtual machine</p>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Rule Form Modal --}}
|
|
@if($showRuleForm)
|
|
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
|
|
x-data="{
|
|
macroDefinitions: @js($this->macroDefinitions),
|
|
currentMacro: @entangle('ruleMacro'),
|
|
handleMacroChange() {
|
|
if (this.currentMacro && this.currentMacro !== '' && this.macroDefinitions[this.currentMacro]) {
|
|
const macroDef = this.macroDefinitions[this.currentMacro];
|
|
// Auto-populate protocol
|
|
if (macroDef.proto) {
|
|
$wire.set('ruleProto', macroDef.proto);
|
|
}
|
|
// Auto-populate destination port
|
|
if (macroDef.dport) {
|
|
$wire.set('ruleDport', macroDef.dport);
|
|
}
|
|
}
|
|
}
|
|
}"
|
|
x-init="$watch('currentMacro', () => handleMacroChange())">
|
|
<div class="relative w-full max-w-4xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl max-h-[90vh] overflow-y-auto">
|
|
<button wire:click="cancelRuleForm" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors z-10">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="p-6">
|
|
<h3 class="text-xl font-semibold mb-6">{{ $editingRuleIndex !== null ? 'Edit' : 'Create' }} Firewall Rule</h3>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{{-- Action --}}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Action</label>
|
|
<select wire:model="ruleAction" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
<option value="ACCEPT">Accept</option>
|
|
<option value="REJECT">Reject</option>
|
|
<option value="DROP">Drop</option>
|
|
</select>
|
|
</div>
|
|
|
|
{{-- Type --}}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Direction</label>
|
|
<select wire:model="ruleType" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
<option value="in">Incoming</option>
|
|
<option value="out">Outgoing</option>
|
|
</select>
|
|
</div>
|
|
|
|
{{-- Macro --}}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Macro (Pre-defined Service)</label>
|
|
<select wire:model="ruleMacro" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
@foreach($availableMacros as $value => $label)
|
|
<option value="{{ $value }}">{{ $label }}</option>
|
|
@endforeach
|
|
</select>
|
|
<p class="text-xs text-base/60 mt-1">
|
|
<x-ri-lightbulb-line class="size-3 inline mr-0.5" />
|
|
Selecting a macro will automatically set the protocol and port(s)
|
|
</p>
|
|
</div>
|
|
|
|
{{-- Interface --}}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Network Interface</label>
|
|
<select wire:model="ruleIface" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
<option value="">Any</option>
|
|
@foreach($networkAdapters as $adapter)
|
|
<option value="{{ $adapter['name'] }}">{{ $adapter['name'] }} ({{ ucfirst($adapter['model'] ?? 'Unknown') }})</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
{{-- Source --}}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Source IP/CIDR</label>
|
|
<input type="text" wire:model="ruleSource" placeholder="e.g., 192.168.1.0/24 or leave empty for any" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
<p class="text-xs text-base/60 mt-1">Can use alias names or leave empty for any source</p>
|
|
</div>
|
|
|
|
{{-- Destination --}}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Destination IP/CIDR</label>
|
|
<select wire:model="ruleDest" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
<option value="">Any</option>
|
|
@foreach($availableIps as $ipData)
|
|
<option value="{{ $ipData['ip'] }}">{{ $ipData['ip'] }} ({{ $ipData['label'] }})</option>
|
|
@endforeach
|
|
@foreach($aliases as $alias)
|
|
<option value="{{ $alias['name'] }}">{{ $alias['name'] }} (Alias)</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
{{-- Protocol --}}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">
|
|
Protocol
|
|
<span class="text-xs font-normal text-base/50">(auto-filled by macro)</span>
|
|
</label>
|
|
<select wire:model="ruleProto" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
<option value="">Any</option>
|
|
<option value="tcp">TCP</option>
|
|
<option value="udp">UDP</option>
|
|
<option value="icmp">ICMP</option>
|
|
<option value="igmp">IGMP</option>
|
|
<option value="ah">AH (IPsec Authentication Header)</option>
|
|
<option value="esp">ESP (IPsec Encapsulating Security Payload)</option>
|
|
<option value="ospf">OSPF</option>
|
|
</select>
|
|
</div>
|
|
|
|
{{-- Destination Port --}}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">
|
|
Destination Port
|
|
<span class="text-xs font-normal text-base/50">(auto-filled by macro)</span>
|
|
</label>
|
|
<input type="text" wire:model="ruleDport" placeholder="e.g., 80, 443, 1000:2000" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
</div>
|
|
|
|
{{-- Source Port --}}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Source Port (Optional)</label>
|
|
<input type="text" wire:model="ruleSport" placeholder="e.g., 80, 443" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
</div>
|
|
|
|
{{-- Log Level --}}
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Logging</label>
|
|
<select wire:model="ruleLog" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
<option value="nolog">No Logging</option>
|
|
<option value="emerg">Emergency</option>
|
|
<option value="alert">Alert</option>
|
|
<option value="crit">Critical</option>
|
|
<option value="err">Error</option>
|
|
<option value="warning">Warning</option>
|
|
<option value="notice">Notice</option>
|
|
<option value="info">Info</option>
|
|
<option value="debug">Debug</option>
|
|
</select>
|
|
</div>
|
|
|
|
{{-- Comment --}}
|
|
<div class="md:col-span-2">
|
|
<label class="block text-sm font-medium mb-2">Comment</label>
|
|
<input type="text" wire:model="ruleComment" placeholder="Optional description" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
</div>
|
|
|
|
{{-- Enable --}}
|
|
<div class="md:col-span-2 flex items-center gap-2">
|
|
<input type="checkbox" wire:model="ruleEnable" id="ruleEnable" class="rounded border-neutral">
|
|
<label for="ruleEnable" class="text-sm font-medium">Enable this rule immediately</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 justify-end mt-6 pt-4 border-t border-neutral/20">
|
|
<button type="button" wire:click="cancelRuleForm" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
|
Cancel
|
|
</button>
|
|
<button type="button" wire:click="saveRule" wire:loading.attr="disabled" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
|
|
<span wire:loading.remove wire:target="saveRule">Save Rule</span>
|
|
<span wire:loading wire:target="saveRule">Saving...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Delete Rule Modal --}}
|
|
@if($showDeleteRuleModal && $deletingRuleIndex !== null && isset($rules[$deletingRuleIndex]))
|
|
@php $ruleToDelete = $rules[$deletingRuleIndex]; @endphp
|
|
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<div class="relative w-full max-w-lg bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button wire:click="cancelDeleteRule" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-start gap-4 mb-4">
|
|
<div class="w-12 h-12 rounded-full bg-error/10 flex items-center justify-center flex-shrink-0">
|
|
<x-ri-delete-bin-fill class="size-6 text-error" />
|
|
</div>
|
|
<div class="flex-1">
|
|
<h3 class="text-xl font-bold mb-2">Delete Firewall Rule</h3>
|
|
<p class="text-sm text-base/70 mb-4">Are you sure you want to delete this rule? This action cannot be undone.</p>
|
|
<div class="p-3 bg-background border border-neutral/20 rounded-lg text-sm space-y-1">
|
|
<div><span class="text-base/60">Action:</span> <span class="font-semibold">{{ $ruleToDelete['action'] ?? '-' }}</span></div>
|
|
<div><span class="text-base/60">Source:</span> <span class="font-mono text-xs">{{ $ruleToDelete['source'] ?? 'Any' }}</span></div>
|
|
<div><span class="text-base/60">Destination:</span> <span class="font-mono text-xs">{{ $ruleToDelete['dest'] ?? 'Any' }}</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 12px; justify-content: flex-end;">
|
|
<button wire:click="cancelDeleteRule"
|
|
type="button"
|
|
style="padding: 8px 16px; background-color: rgba(var(--background), 1); border: 1px solid rgba(var(--neutral), 0.3); border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s;">
|
|
Cancel
|
|
</button>
|
|
<button wire:click="deleteRule"
|
|
wire:loading.attr="disabled"
|
|
type="button"
|
|
style="padding: 8px 16px; background-color: #ef4444; color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s;">
|
|
<span wire:loading.remove wire:target="deleteRule">Delete Rule</span>
|
|
<span wire:loading wire:target="deleteRule">Deleting...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Alias Form Modal --}}
|
|
@if($showAliasForm)
|
|
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<div class="relative w-full max-w-lg bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button wire:click="cancelAliasForm" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<h3 class="text-xl font-semibold mb-6">{{ $editingAliasName ? 'Edit' : 'Create' }} IP Alias</h3>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Alias Name</label>
|
|
<input type="text" wire:model="aliasName" placeholder="e.g., web-servers" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm" {{ $editingAliasName ? 'readonly' : '' }}>
|
|
@error('aliasName') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">IP Address / CIDR</label>
|
|
<input type="text" wire:model="aliasCidr" placeholder="e.g., 192.168.1.100 or 10.0.0.0/24" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
@error('aliasCidr') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Comment (Optional)</label>
|
|
<input type="text" wire:model="aliasComment" placeholder="Description of this alias" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 justify-end mt-6 pt-4 border-t border-neutral/20">
|
|
<button wire:click="cancelAliasForm" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
|
Cancel
|
|
</button>
|
|
<button wire:click="saveAlias" wire:loading.attr="disabled" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
|
|
<span wire:loading.remove wire:target="saveAlias">Save Alias</span>
|
|
<span wire:loading wire:target="saveAlias">Saving...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Delete Alias Modal --}}
|
|
@if($showDeleteAliasModal && $deletingAliasName)
|
|
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<div class="relative w-full max-w-md bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button wire:click="cancelDeleteAlias" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-start gap-4 mb-4">
|
|
<div class="w-12 h-12 rounded-full bg-error/10 flex items-center justify-center flex-shrink-0">
|
|
<x-ri-delete-bin-fill class="size-6 text-error" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-bold mb-2">Delete IP Alias</h3>
|
|
<p class="text-sm text-base/70 mb-2">Delete alias "<span class="font-semibold">{{ $deletingAliasName }}</span>"?</p>
|
|
<p class="text-xs text-base/60">This may affect firewall rules using this alias.</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 justify-end">
|
|
<button wire:click="cancelDeleteAlias" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
|
Cancel
|
|
</button>
|
|
<button wire:click="deleteAlias" wire:loading.attr="disabled"
|
|
style="padding: 0.5rem 1rem; background-color: #ef4444; color: white; border-radius: 0.5rem; transition: all 0.2s; font-size: 0.875rem; font-weight: 500;"
|
|
onmouseover="this.style.backgroundColor='#dc2626'"
|
|
onmouseout="this.style.backgroundColor='#ef4444'">
|
|
<span wire:loading.remove wire:target="deleteAlias">Delete</span>
|
|
<span wire:loading wire:target="deleteAlias">Deleting...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- IPSet Form Modal --}}
|
|
@if($showIPSetForm)
|
|
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<div class="relative w-full max-w-lg bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button wire:click="cancelIPSetForm" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<h3 class="text-xl font-semibold mb-6">Create IP Set</h3>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">IPSet Name</label>
|
|
<input type="text" wire:model="ipsetName" placeholder="e.g., trusted-networks" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
@error('ipsetName') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Comment (Optional)</label>
|
|
<input type="text" wire:model="ipsetComment" placeholder="Description of this IPSet" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
</div>
|
|
|
|
<div class="p-3 bg-info/10 border border-info/30 rounded-lg">
|
|
<p class="text-xs text-info/90">After creating the IPSet, you can add IP addresses and CIDR ranges to it.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 justify-end mt-6 pt-4 border-t border-neutral/20">
|
|
<button wire:click="cancelIPSetForm" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
|
Cancel
|
|
</button>
|
|
<button wire:click="saveIPSet" wire:loading.attr="disabled" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
|
|
<span wire:loading.remove wire:target="saveIPSet">Create IPSet</span>
|
|
<span wire:loading wire:target="saveIPSet">Creating...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Delete IPSet Modal --}}
|
|
@if($showDeleteIPSetModal && $deletingIPSetName)
|
|
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<div class="relative w-full max-w-md bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button wire:click="cancelDeleteIPSet" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-start gap-4 mb-4">
|
|
<div class="w-12 h-12 rounded-full bg-error/10 flex items-center justify-center flex-shrink-0">
|
|
<x-ri-delete-bin-fill class="size-6 text-error" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-bold mb-2">Delete IP Set</h3>
|
|
<p class="text-sm text-base/70 mb-2">Delete IPSet "<span class="font-semibold">{{ $deletingIPSetName }}</span>"?</p>
|
|
<p class="text-xs text-base/60">All members will be removed and rules using this IPSet may be affected.</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 justify-end">
|
|
<button wire:click="cancelDeleteIPSet" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
|
Cancel
|
|
</button>
|
|
<button wire:click="deleteIPSet" wire:loading.attr="disabled"
|
|
style="padding: 0.5rem 1rem; background-color: #ef4444; color: white; border-radius: 0.5rem; transition: all 0.2s; font-size: 0.875rem; font-weight: 500;"
|
|
onmouseover="this.style.backgroundColor='#dc2626'"
|
|
onmouseout="this.style.backgroundColor='#ef4444'">
|
|
<span wire:loading.remove wire:target="deleteIPSet">Delete</span>
|
|
<span wire:loading wire:target="deleteIPSet">Deleting...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- IPSet Member Form Modal --}}
|
|
@if($showIPSetMemberForm && $selectedIPSetName)
|
|
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<div class="relative w-full max-w-lg bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button wire:click="cancelIPSetMemberForm" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<h3 class="text-xl font-semibold mb-6">Add IP/CIDR to {{ $selectedIPSetName }}</h3>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">IP Address / CIDR</label>
|
|
<input type="text" wire:model="memberCidr" placeholder="e.g., 192.168.1.100 or 10.0.0.0/16" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
@error('memberCidr') <span class="text-error text-xs mt-1">{{ $message }}</span> @enderror
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Comment (Optional)</label>
|
|
<input type="text" wire:model="memberComment" placeholder="Description" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background text-sm">
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<input type="checkbox" wire:model="memberNomatch" id="memberNomatch" class="rounded border-neutral">
|
|
<label for="memberNomatch" class="text-sm">
|
|
<span class="font-medium">No Match</span>
|
|
<span class="text-base/60">(Exclude this IP from the set)</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 justify-end mt-6 pt-4 border-t border-neutral/20">
|
|
<button wire:click="cancelIPSetMemberForm" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
|
Cancel
|
|
</button>
|
|
<button wire:click="saveIPSetMember" wire:loading.attr="disabled" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
|
|
<span wire:loading.remove wire:target="saveIPSetMember">Add Member</span>
|
|
<span wire:loading wire:target="saveIPSetMember">Adding...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Delete IPSet Member Modal --}}
|
|
@if($showDeleteIPSetMemberModal && $deletingMemberCidr)
|
|
<div class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
|
<div class="relative w-full max-w-md bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button wire:click="cancelDeleteIPSetMember" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-start gap-4 mb-4">
|
|
<div class="w-12 h-12 rounded-full bg-error/10 flex items-center justify-center flex-shrink-0">
|
|
<x-ri-close-circle-fill class="size-6 text-error" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-bold mb-2">Remove Member</h3>
|
|
<p class="text-sm text-base/70 mb-2">Remove "<span class="font-mono font-semibold">{{ $deletingMemberCidr }}</span>" from {{ $selectedIPSetName }}?</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 justify-end">
|
|
<button wire:click="cancelDeleteIPSetMember" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
|
Cancel
|
|
</button>
|
|
<button wire:click="deleteIPSetMember" wire:loading.attr="disabled"
|
|
style="padding: 0.5rem 1rem; background-color: #ef4444; color: white; border-radius: 0.5rem; transition: all 0.2s; font-size: 0.875rem; font-weight: 500;"
|
|
onmouseover="this.style.backgroundColor='#dc2626'"
|
|
onmouseout="this.style.backgroundColor='#ef4444'">
|
|
<span wire:loading.remove wire:target="deleteIPSetMember">Remove</span>
|
|
<span wire:loading wire:target="deleteIPSetMember">Removing...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
@script
|
|
<script>
|
|
// IP location helper functions
|
|
window.extractIP = function(ip) {
|
|
if (!ip || typeof ip !== 'string') return ip;
|
|
ip = ip.trim();
|
|
|
|
const ipv4WithPort = /^(\d+\.\d+\.\d+\.\d+):(\d+)$/.exec(ip);
|
|
if (ipv4WithPort) return ipv4WithPort[1];
|
|
|
|
const ipv6WithPort = /^\[([^\]]+)\]:(\d+)$/.exec(ip);
|
|
if (ipv6WithPort) return ipv6WithPort[1];
|
|
|
|
if (ip.includes(':') && (ip.match(/:/g) || []).length >= 2) {
|
|
return ip;
|
|
}
|
|
|
|
return ip;
|
|
};
|
|
|
|
window.isPublicIP = function(ip) {
|
|
if (!ip || ip === '—' || ip === '-' || typeof ip !== 'string') return false;
|
|
ip = window.extractIP(ip).trim();
|
|
|
|
if (ip === 'localhost' || ip === '127.0.0.1' || ip === '::1') return false;
|
|
|
|
const ipv4PrivateRanges = [
|
|
/^10\./, /^172\.(1[6-9]|2[0-9]|3[0-1])\./, /^192\.168\./,
|
|
/^169\.254\./, /^127\./, /^0\./
|
|
];
|
|
|
|
for (const range of ipv4PrivateRanges) {
|
|
if (range.test(ip)) return false;
|
|
}
|
|
|
|
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
if (ipv4Regex.test(ip)) return true;
|
|
|
|
if (ip.includes(':')) {
|
|
if (/^fe[89ab][0-9a-fA-F]:/i.test(ip)) return false;
|
|
if (/^f[cd][0-9a-fA-F]/i.test(ip)) return false;
|
|
if (ip.startsWith('::ffff:')) {
|
|
return window.isPublicIP(ip.substring(7));
|
|
}
|
|
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
|
|
return ipv6Regex.test(ip);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
window.fetchIPLocation = async function(ip) {
|
|
if (!window.isPublicIP(ip)) {
|
|
return { status: 'fail', message: 'Not a public IP address' };
|
|
}
|
|
|
|
ip = window.extractIP(ip);
|
|
|
|
try {
|
|
const data = await @this.call('fetchIPLocation', ip);
|
|
return data || { status: 'fail', message: 'No data returned' };
|
|
} catch (error) {
|
|
console.error('Error fetching IP location:', error);
|
|
return { status: 'fail', message: 'Failed to fetch location data: ' + (error.message || 'Unknown error') };
|
|
}
|
|
};
|
|
</script>
|
|
@endscript
|
|
|