Introduce starter firewall rule templates, one-click rule creation from log entries, and quick log filter presets to speed up incident response. Add a shared quick actions hub plus refreshed help and overview surfaces to improve discoverability and make key VPS tasks faster. Made-with: Cursor
2613 lines
208 KiB
PHP
2613 lines
208 KiB
PHP
<x-proxmox::layout :server="$server" view="firewall" :status="$status" :status-polling="true" :status-polling-interval="'2s'">
|
|
{{-- Global Loader Styles --}}
|
|
<style>
|
|
.fw-loader {
|
|
width: 24px;
|
|
aspect-ratio: 1;
|
|
border-radius: 50%;
|
|
padding: 3px;
|
|
background:
|
|
conic-gradient(from 135deg at top, currentColor 90deg, #0000 0) 0 calc(50% - 2px)/10px 5px,
|
|
radial-gradient(farthest-side at bottom left, #0000 calc(100% - 3px), currentColor calc(100% - 2px) 99%, #0000) top right/50% 50% content-box content-box,
|
|
radial-gradient(farthest-side at top, #0000 calc(100% - 3px), currentColor calc(100% - 2px) 99%, #0000) bottom/100% 50% content-box content-box;
|
|
background-repeat: no-repeat;
|
|
animation: fw-loader-spin 1s infinite linear;
|
|
}
|
|
.fw-loader-sm {
|
|
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: fw-loader-spin 1s infinite linear;
|
|
}
|
|
.fw-loader-lg {
|
|
width: 32px;
|
|
aspect-ratio: 1;
|
|
border-radius: 50%;
|
|
padding: 4px;
|
|
background:
|
|
conic-gradient(from 135deg at top, currentColor 90deg, #0000 0) 0 calc(50% - 3px)/12px 6px,
|
|
radial-gradient(farthest-side at bottom left, #0000 calc(100% - 4px), currentColor calc(100% - 3px) 99%, #0000) top right/50% 50% content-box content-box,
|
|
radial-gradient(farthest-side at top, #0000 calc(100% - 4px), currentColor calc(100% - 3px) 99%, #0000) bottom/100% 50% content-box content-box;
|
|
background-repeat: no-repeat;
|
|
animation: fw-loader-spin 1s infinite linear;
|
|
}
|
|
.fw-loader-xl {
|
|
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: fw-loader-spin 1s infinite linear;
|
|
}
|
|
@keyframes fw-loader-spin {
|
|
100% { transform: rotate(1turn); }
|
|
}
|
|
</style>
|
|
|
|
<div class="flex flex-col gap-6">
|
|
{{-- Page Header --}}
|
|
<div>
|
|
<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" wire:click.self="$set('showErrorModal', false)" x-data @keydown.escape.window="$wire.set('showErrorModal', false)">
|
|
<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" aria-label="Close error dialog">
|
|
<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">
|
|
<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"
|
|
class="px-4 py-2 bg-error text-white rounded-lg hover:bg-error/80 transition-all text-sm font-medium flex items-center gap-2">
|
|
<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">
|
|
{{-- 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'] ?? null)
|
|
<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'] }}"
|
|
class="px-3 py-1.5 bg-error text-white rounded-lg hover:bg-error/80 transition-all text-xs font-medium flex items-center gap-1.5">
|
|
<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" aria-label="Close policy info">
|
|
<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" x-data="{ activeTab: 'rules', logsLoaded: false }">
|
|
<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 bg-primary/5' : 'text-base/60 hover:text-base hover:bg-background-secondary/50'">
|
|
<div class="flex items-center gap-2">
|
|
<x-ri-list-check class="size-4" />
|
|
<span>Firewall Rules</span>
|
|
<span class="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-bold rounded-full bg-primary/15 text-primary min-w-[1.25rem]">{{ count($rules) }}</span>
|
|
</div>
|
|
<div x-show="activeTab === 'rules'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t"></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 bg-primary/5' : 'text-base/60 hover:text-base hover:bg-background-secondary/50'">
|
|
<div class="flex items-center gap-2">
|
|
<x-ri-bookmark-line class="size-4" />
|
|
<span>IP Aliases</span>
|
|
<span class="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-bold rounded-full bg-primary/15 text-primary min-w-[1.25rem]">{{ count($aliases) }}</span>
|
|
</div>
|
|
<div x-show="activeTab === 'aliases'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t"></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 bg-primary/5' : 'text-base/60 hover:text-base hover:bg-background-secondary/50'">
|
|
<div class="flex items-center gap-2">
|
|
<x-ri-folder-3-line class="size-4" />
|
|
<span>IP Sets</span>
|
|
<span class="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-bold rounded-full bg-primary/15 text-primary min-w-[1.25rem]">{{ count($ipsets) }}</span>
|
|
</div>
|
|
<div x-show="activeTab === 'ipsets'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t"></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 bg-primary/5' : 'text-base/60 hover:text-base hover:bg-background-secondary/50'">
|
|
<div class="flex items-center gap-2">
|
|
<x-ri-file-list-3-line class="size-4" />
|
|
<span>Firewall Logs</span>
|
|
<span class="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-bold rounded-full bg-primary/15 text-primary min-w-[1.25rem]">{{ count($this->filteredLogs) }}</span>
|
|
</div>
|
|
<div x-show="activeTab === 'logs'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t"></div>
|
|
</button>
|
|
<button @click="activeTab = 'saved'"
|
|
class="px-6 py-4 text-sm font-semibold transition-all relative whitespace-nowrap"
|
|
:class="activeTab === 'saved' ? 'text-primary bg-primary/5' : 'text-base/60 hover:text-base hover:bg-background-secondary/50'">
|
|
<div class="flex items-center gap-2">
|
|
<x-ri-save-3-line class="size-4" />
|
|
<span>Saved Configurations</span>
|
|
<span class="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-bold rounded-full bg-primary/15 text-primary min-w-[1.25rem]">{{ count($savedConfigurations) }}</span>
|
|
</div>
|
|
<div x-show="activeTab === 'saved'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t"></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" wire:loading.attr="disabled" wire:target="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 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
<x-ri-hourglass-fill wire:loading wire:target="startCreateRule" class="size-4 animate-pulse" />
|
|
<x-ri-add-line wire:loading.remove wire:target="startCreateRule" class="size-4" />
|
|
Add Rule
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mb-5">
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-base/60 mb-2">Starter templates</p>
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-2">
|
|
@foreach($this->ruleTemplates as $templateKey => $template)
|
|
<button type="button"
|
|
wire:click="applyRuleTemplate('{{ $templateKey }}')"
|
|
class="text-left p-3 rounded-lg border border-neutral/30 bg-background-secondary/40 hover:border-primary/40 hover:bg-primary/5 transition-colors">
|
|
<p class="text-sm font-semibold text-base">{{ $template['label'] }}</p>
|
|
<p class="text-xs text-base/60 mt-0.5">{{ $template['description'] }}</p>
|
|
</button>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
@if(count($rules) > 0)
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full">
|
|
<thead class="sticky top-0 z-[5] bg-background">
|
|
<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 wire:key="rule-{{ $rule['pos'] ?? $index }}" class="border-b border-neutral/10 hover:bg-background-secondary/30 transition-colors {{ $index % 2 === 1 ? 'bg-background-secondary/20' : '' }}">
|
|
<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"
|
|
aria-label="Move rule 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"
|
|
aria-label="Move rule 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('{{ addslashes($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">
|
|
<div class="fw-loader size-6 mx-auto mb-2 text-primary"></div>
|
|
<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('{{ addslashes($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">
|
|
<div class="fw-loader size-6 mx-auto mb-2 text-primary"></div>
|
|
<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" aria-label="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" aria-label="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" aria-label="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" wire:loading.attr="disabled" wire:target="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 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
<x-ri-hourglass-fill wire:loading wire:target="startCreateAlias" class="size-4 animate-pulse" />
|
|
<x-ri-add-line wire:loading.remove wire:target="startCreateAlias" 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" aria-label="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" aria-label="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" wire:loading.attr="disabled" wire:target="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 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
<x-ri-hourglass-fill wire:loading wire:target="startCreateIPSet" class="size-4 animate-pulse" />
|
|
<x-ri-add-line wire:loading.remove wire:target="startCreateIPSet" 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 {{ $selectedIPSetName === $ipset['name'] ? 'border-primary/60 bg-primary/5' : '' }}"
|
|
wire:click="selectIPSet('{{ $ipset['name'] }}')">
|
|
<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" aria-label="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" aria-label="Remove IP set member">
|
|
<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.visible="loadFirewallLogs"
|
|
x-init="$watch('activeTab', value => { if (value === 'logs' && !logsLoaded) { logsLoaded = true; $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"
|
|
x-data
|
|
@click="
|
|
const logs = @js($this->filteredLogs);
|
|
if (!logs || logs.length === 0) {
|
|
alert('No logs to export');
|
|
return;
|
|
}
|
|
const headers = ['Timestamp', 'Action', 'Source', 'Destination', 'Protocol', 'Source Port', 'Destination Port', 'Chain', 'Interface'];
|
|
const rows = logs.map(log => [
|
|
log.timestamp_display || log.timestamp || '',
|
|
log.action || '',
|
|
log.source || '',
|
|
log.destination || '',
|
|
log.protocol || '',
|
|
log.source_port || '',
|
|
log.destination_port || '',
|
|
log.chain || '',
|
|
log.interface || ''
|
|
]);
|
|
let csvContent = headers.join(',') + '\n';
|
|
const dq = String.fromCharCode(34);
|
|
rows.forEach(row => {
|
|
csvContent += row.map(cell => {
|
|
const str = String(cell);
|
|
if (str.indexOf(',') !== -1 || str.indexOf(dq) !== -1 || str.indexOf('\n') !== -1) {
|
|
return dq + str.replace(new RegExp(dq, 'g'), dq + dq) + dq;
|
|
}
|
|
return str;
|
|
}).join(',') + '\n';
|
|
});
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = 'firewall-logs-' + timestamp + '.csv';
|
|
link.click();
|
|
URL.revokeObjectURL(link.href);
|
|
"
|
|
class="px-4 py-2 bg-secondary text-white rounded-lg hover:bg-secondary/90 transition-colors text-sm font-medium flex items-center gap-2">
|
|
<x-ri-download-line class="size-4" />
|
|
Export CSV
|
|
</button>
|
|
<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="fw-loader-sm"></div>
|
|
<span wire:loading.remove wire:target="loadFirewallLogs">Refresh</span>
|
|
<span wire:loading wire:target="loadFirewallLogs">Loading...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<p class="text-xs font-semibold uppercase tracking-wide text-base/60 mb-2">Quick filters</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
@foreach($this->logPresets as $presetKey => $presetLabel)
|
|
<button type="button"
|
|
wire:click="setLogPreset('{{ $presetKey }}')"
|
|
class="px-3 py-1.5 rounded-full border text-xs font-semibold transition-colors {{ $activeLogPreset === $presetKey ? 'border-primary/60 text-primary bg-primary/10' : 'border-neutral/30 text-base/70 hover:border-primary/40 hover:text-primary' }}">
|
|
{{ $presetLabel }}
|
|
</button>
|
|
@endforeach
|
|
</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($this->filteredLogs) === 0)
|
|
<div class="text-center py-12 text-base/60">
|
|
<div class="fw-loader-xl text-primary mx-auto mb-4"></div>
|
|
<p class="text-sm">Loading firewall logs...</p>
|
|
</div>
|
|
@elseif(count($this->filteredLogs) > 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">Src Port</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Dest Port</th>
|
|
<th class="text-center py-3 px-4 text-xs font-semibold text-base/70 uppercase">Analysis</th>
|
|
<th class="text-center py-3 px-4 text-xs font-semibold text-base/70 uppercase">Rule</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach($this->paginatedLogs as $log)
|
|
<tr wire:key="log-{{ $loop->index }}-{{ $currentPage ?? 1 }}" class="border-b border-neutral/10 hover:bg-background-secondary/30 transition-colors">
|
|
<td class="py-3 px-4 text-xs text-base/70">
|
|
@if(isset($log['timestamp_iso']) && $log['timestamp_iso'] !== '-')
|
|
<div x-data="{
|
|
timestamp: '{{ $log['timestamp_iso'] }}',
|
|
formatTime() {
|
|
try {
|
|
const date = new Date(this.timestamp);
|
|
if (isNaN(date.getTime())) return '{{ $log['timestamp'] }}';
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false
|
|
});
|
|
} catch(e) {
|
|
return '{{ $log['timestamp'] }}';
|
|
}
|
|
},
|
|
getRelativeTime() {
|
|
try {
|
|
const date = new Date(this.timestamp);
|
|
if (isNaN(date.getTime())) return '';
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffSec = Math.floor(diffMs / 1000);
|
|
const diffMin = Math.floor(diffSec / 60);
|
|
const diffHour = Math.floor(diffMin / 60);
|
|
const diffDay = Math.floor(diffHour / 24);
|
|
|
|
if (diffSec < 60) return diffSec + ' seconds ago';
|
|
if (diffMin < 60) return diffMin + ' minute' + (diffMin !== 1 ? 's' : '') + ' ago';
|
|
if (diffHour < 24) return diffHour + ' hour' + (diffHour !== 1 ? 's' : '') + ' ago';
|
|
return diffDay + ' day' + (diffDay !== 1 ? 's' : '') + ' ago';
|
|
} catch(e) {
|
|
return '';
|
|
}
|
|
}
|
|
}"
|
|
class="cursor-help"
|
|
:title="getRelativeTime()"
|
|
x-text="formatTime()"></div>
|
|
@else
|
|
{{ $log['timestamp_display'] ?? $log['timestamp'] ?? '-' }}
|
|
@endif
|
|
</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('{{ addslashes($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">
|
|
<div class="fw-loader text-primary mx-auto mb-2"></div>
|
|
<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('{{ addslashes($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">
|
|
<div class="fw-loader text-primary mx-auto mb-2"></div>
|
|
<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['source_port'] ?? '-' }}</td>
|
|
<td class="py-3 px-4 text-xs font-mono">{{ $log['destination_port'] ?? '-' }}</td>
|
|
<td class="py-3 px-4 text-center">
|
|
@php
|
|
$sourceIP = $log['source'] ?? '';
|
|
$destIP = $log['destination'] ?? '';
|
|
$sourcePort = $log['source_port'] ?? '';
|
|
$destPort = $log['destination_port'] ?? '';
|
|
$protocol = $log['protocol'] ?? '';
|
|
@endphp
|
|
@if($coreRouterConfigured && $sourceIP && $sourceIP !== '-')
|
|
<div class="relative"
|
|
x-data="{
|
|
open: false,
|
|
loading: false,
|
|
connectionData: null,
|
|
error: null,
|
|
sourceIP: '{{ addslashes($sourceIP) }}',
|
|
destIP: '{{ addslashes($destIP) }}',
|
|
sourcePort: '{{ addslashes($sourcePort) }}',
|
|
destPort: '{{ addslashes($destPort) }}',
|
|
protocol: '{{ addslashes($protocol) }}',
|
|
loadConnectionData() {
|
|
this.loading = true;
|
|
this.error = null;
|
|
$wire.getNetworkAnalysisForLog(this.sourceIP, this.destIP, this.sourcePort, this.destPort, this.protocol)
|
|
.then(data => {
|
|
this.connectionData = data;
|
|
this.loading = false;
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load connection data:', err);
|
|
this.error = 'Failed to load connection data';
|
|
this.loading = false;
|
|
});
|
|
},
|
|
exportToCsv() {
|
|
if (!this.connectionData || !this.connectionData.connections || this.connectionData.connections.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const headers = ['Protocol', 'Source IP', 'Source Port', 'Destination IP', 'Destination Port', 'Direction', 'State', 'Interface', 'Bytes', 'Packets'];
|
|
const rows = this.connectionData.connections.map(conn => [
|
|
conn.protocol || '',
|
|
conn.source || '',
|
|
conn.source_port || '',
|
|
conn.destination || '',
|
|
conn.destination_port || '',
|
|
conn.direction || '',
|
|
conn.state || '',
|
|
conn.interface || '',
|
|
conn.bytes || 0,
|
|
conn.packets || 0
|
|
]);
|
|
|
|
let csvContent = headers.join(',') + '\n';
|
|
rows.forEach(row => {
|
|
csvContent += row.map(cell => {
|
|
const str = String(cell);
|
|
const dq = String.fromCharCode(34);
|
|
if (str.indexOf(',') !== -1 || str.indexOf(dq) !== -1 || str.indexOf('\n') !== -1) {
|
|
return dq + str.replace(new RegExp(dq, 'g'), dq + dq) + dq;
|
|
}
|
|
return str;
|
|
}).join(',') + '\n';
|
|
});
|
|
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = 'network-analysis-' + this.sourceIP + '-' + timestamp + '.csv';
|
|
link.click();
|
|
URL.revokeObjectURL(link.href);
|
|
}
|
|
}"
|
|
x-cloak>
|
|
<button type="button"
|
|
@click="open = true; if (!connectionData && !loading) { loadConnectionData(); }"
|
|
class="text-primary hover:text-primary/80 transition-colors p-1 rounded hover:bg-primary/10"
|
|
title="View Network Analysis">
|
|
<x-ri-line-chart-line class="size-4" />
|
|
</button>
|
|
|
|
{{-- Network Analysis 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-5xl bg-gradient-to-br from-background via-background-secondary to-background rounded-2xl border border-primary/20 shadow-2xl max-h-[90vh] overflow-hidden">
|
|
<div class="absolute top-4 right-4 flex items-center gap-2 z-10">
|
|
<button @click="loadConnectionData()"
|
|
:disabled="loading"
|
|
class="w-9 h-9 flex items-center justify-center text-base/50 hover:text-primary transition-colors bg-background/80 backdrop-blur-sm rounded-full hover:bg-background disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="Refresh data">
|
|
<x-ri-refresh-line x-show="!loading" class="size-5" />
|
|
<div x-show="loading" x-cloak class="fw-loader-sm text-primary"></div>
|
|
</button>
|
|
<button @click="open = false"
|
|
class="w-9 h-9 flex items-center justify-center text-base/50 hover:text-base transition-colors bg-background/80 backdrop-blur-sm rounded-full hover:bg-background">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{{-- Modal Header --}}
|
|
<div class="bg-gradient-to-r from-primary/10 to-secondary/10 border-b border-primary/20 p-6">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
|
|
<x-ri-line-chart-line class="size-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-bold text-base">Network Connection Analysis</h3>
|
|
<p class="text-sm text-base/60 mt-1">Detailed connection information from firewall state</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Modal Content --}}
|
|
<div class="p-6 overflow-y-auto" style="max-height: calc(90vh - 180px);">
|
|
{{-- Loading State --}}
|
|
<div x-show="loading && !connectionData" class="text-center py-12">
|
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-4">
|
|
<div class="fw-loader-lg text-primary"></div>
|
|
</div>
|
|
<p class="text-sm text-base/60">Fetching connection data...</p>
|
|
</div>
|
|
|
|
{{-- Error State --}}
|
|
<div x-show="error && !loading && !connectionData" class="text-center py-12">
|
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-warning/10 mb-4">
|
|
<x-ri-error-warning-line class="size-8 text-warning" />
|
|
</div>
|
|
<p class="text-sm text-warning" x-text="error"></p>
|
|
</div>
|
|
|
|
{{-- Connection Data Display --}}
|
|
<div x-show="connectionData && !loading">
|
|
<template x-if="connectionData && connectionData.connections && connectionData.connections.length > 0">
|
|
<div>
|
|
<template x-for="(conn, index) in connectionData.connections" :key="index">
|
|
<div class="mb-6 last:mb-0 bg-background/50 border border-neutral/40 rounded-xl p-5 hover:border-primary/30 transition-colors">
|
|
{{-- Connection Header --}}
|
|
<div class="flex items-center justify-between mb-4 pb-3 border-b border-neutral/20">
|
|
<div class="flex items-center gap-2">
|
|
<span class="px-3 py-1 rounded-lg text-xs font-semibold bg-primary/20 text-primary border border-primary/30" x-text="'Connection #' + (index + 1)"></span>
|
|
<span class="px-2 py-1 rounded text-xs font-medium bg-secondary/20 text-secondary" x-text="conn.protocol || 'N/A'"></span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span :class="{
|
|
'bg-success/20 text-success border-success/30': conn.direction === 'Inbound',
|
|
'bg-info/20 text-info border-info/30': conn.direction === 'Outbound'
|
|
}" class="px-2 py-1 rounded text-xs font-medium border" x-text="conn.direction || 'N/A'"></span>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Connection Details Grid --}}
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{{-- Source Information --}}
|
|
<div class="space-y-3">
|
|
<h4 class="text-sm font-semibold text-success flex items-center gap-2">
|
|
<x-ri-arrow-right-line class="size-4" />
|
|
Source
|
|
</h4>
|
|
<div class="pl-6 space-y-2">
|
|
<div class="flex items-center justify-between py-1.5 px-3 bg-background rounded-lg">
|
|
<span class="text-xs text-base/60">IP Address</span>
|
|
<span class="text-sm font-mono font-semibold" x-text="conn.source || 'N/A'"></span>
|
|
</div>
|
|
<div class="flex items-center justify-between py-1.5 px-3 bg-background rounded-lg">
|
|
<span class="text-xs text-base/60">Port</span>
|
|
<span class="text-sm font-mono font-semibold" x-text="conn.source_port || 'N/A'"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Destination Information --}}
|
|
<div class="space-y-3">
|
|
<h4 class="text-sm font-semibold text-error flex items-center gap-2">
|
|
<x-ri-arrow-left-line class="size-4" />
|
|
Destination
|
|
</h4>
|
|
<div class="pl-6 space-y-2">
|
|
<div class="flex items-center justify-between py-1.5 px-3 bg-background rounded-lg">
|
|
<span class="text-xs text-base/60">IP Address</span>
|
|
<span class="text-sm font-mono font-semibold" x-text="conn.destination || 'N/A'"></span>
|
|
</div>
|
|
<div class="flex items-center justify-between py-1.5 px-3 bg-background rounded-lg">
|
|
<span class="text-xs text-base/60">Port</span>
|
|
<span class="text-sm font-mono font-semibold" x-text="conn.destination_port || 'N/A'"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Connection Statistics --}}
|
|
<div class="mt-4 pt-4 border-t border-neutral/20">
|
|
<h4 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
|
<x-ri-bar-chart-line class="size-4 text-primary" />
|
|
Traffic Statistics
|
|
</h4>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<div class="bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20 rounded-lg p-3">
|
|
<p class="text-xs text-base/60 mb-1">Data Transferred</p>
|
|
<p class="text-lg font-bold text-primary" x-text="conn.bytes_human || '0 B'"></p>
|
|
</div>
|
|
<div class="bg-gradient-to-br from-secondary/10 to-secondary/5 border border-secondary/20 rounded-lg p-3">
|
|
<p class="text-xs text-base/60 mb-1">Packets</p>
|
|
<p class="text-lg font-bold text-secondary" x-text="conn.packets || '0'"></p>
|
|
</div>
|
|
<div class="bg-gradient-to-br from-info/10 to-info/5 border border-info/20 rounded-lg p-3">
|
|
<p class="text-xs text-base/60 mb-1">State</p>
|
|
<p class="text-sm font-bold text-info" x-text="conn.state || 'UNKNOWN'"></p>
|
|
</div>
|
|
<div class="bg-gradient-to-br from-success/10 to-success/5 border border-success/20 rounded-lg p-3">
|
|
<p class="text-xs text-base/60 mb-1">Interface</p>
|
|
<p class="text-sm font-bold text-success" x-text="conn.interface || 'N/A'"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
{{-- No Connections Found --}}
|
|
<template x-if="connectionData && (!connectionData.connections || connectionData.connections.length === 0)">
|
|
<div class="text-center py-12">
|
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-base/10 mb-4">
|
|
<x-ri-information-line class="size-8 text-base/60" />
|
|
</div>
|
|
<p class="text-sm text-base/60">No matching network analysis data found for this log entry.</p>
|
|
<p class="text-xs text-base/50 mt-2">The connection may have already closed or network analysis data may not be available.</p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Modal Footer --}}
|
|
<div class="border-t border-neutral/20 bg-background/50 p-4">
|
|
<div class="flex items-center justify-between">
|
|
<p class="text-xs text-base/50">Data from Core Router network state tracking</p>
|
|
<div class="flex items-center gap-2">
|
|
<button @click="exportToCsv()"
|
|
x-show="connectionData && connectionData.connections && connectionData.connections.length > 0"
|
|
class="px-4 py-2 bg-secondary text-white rounded-lg hover:bg-secondary/90 transition-colors text-sm font-medium flex items-center gap-2">
|
|
<x-ri-download-line class="size-4" />
|
|
Export CSV
|
|
</button>
|
|
<button @click="open = false"
|
|
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</td>
|
|
<td class="py-3 px-4 text-center">
|
|
@php
|
|
$logAbsoluteIndex = (($currentPage - 1) * $perPage) + $loop->index;
|
|
@endphp
|
|
<button type="button"
|
|
wire:click="startCreateRuleFromLog({{ $logAbsoluteIndex }})"
|
|
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold border border-primary/30 text-primary bg-primary/10 hover:bg-primary/15 transition-colors"
|
|
title="Create firewall rule from this log">
|
|
<x-ri-add-circle-line class="size-3.5" />
|
|
Rule
|
|
</button>
|
|
</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($this->filteredLogs)) }} of {{ count($this->filteredLogs) }} 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>
|
|
|
|
{{-- SAVED CONFIGURATIONS TAB --}}
|
|
<div x-show="activeTab === 'saved'" x-transition>
|
|
<div class="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h3 class="text-lg font-semibold">Saved Configurations</h3>
|
|
<p class="text-sm text-base/60 mt-1">Save and reuse firewall configurations across your VPS instances</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
{{-- Apply to All Button --}}
|
|
<button type="button"
|
|
wire:click="openApplyToAllModal"
|
|
@if(!$this->canApplyToAll()) disabled @endif
|
|
class="px-4 py-2 bg-success text-white rounded-lg transition-colors text-sm font-medium flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed {{ $this->canApplyToAll() ? 'hover:bg-success/90' : '' }}"
|
|
title="{{ !$this->currentConfigMatchesSaved() ? 'Save the current configuration first' : (count($this->getUserOtherServers()) === 0 ? 'No other servers to apply to' : 'Apply to all your other servers') }}">
|
|
<x-ri-global-line class="size-4" />
|
|
Apply to All
|
|
</button>
|
|
{{-- Save Current Configuration Button --}}
|
|
<button type="button"
|
|
wire:click="openSaveConfigModal"
|
|
@if($this->currentConfigMatchesSaved()) disabled @endif
|
|
class="px-4 py-2 bg-primary text-white rounded-lg transition-colors text-sm font-medium flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed {{ !$this->currentConfigMatchesSaved() ? 'hover:bg-primary/90' : '' }}"
|
|
title="{{ $this->currentConfigMatchesSaved() ? 'This configuration is already saved' : 'Save the current configuration' }}">
|
|
<x-ri-save-3-line class="size-4" />
|
|
Save Current Configuration
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
@if(count($savedConfigurations) > 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">Configuration Name</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Rules</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Policies</th>
|
|
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase">Created</th>
|
|
<th class="text-center py-3 px-4 text-xs font-semibold text-base/70 uppercase">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach($savedConfigurations as $config)
|
|
<tr wire:key="saved-config-{{ $config['id'] ?? $loop->index }}" class="border-b border-neutral/10 hover:bg-background-secondary/30 transition-colors">
|
|
<td class="py-3 px-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
<x-ri-shield-star-line class="size-4 text-primary" />
|
|
</div>
|
|
<span class="font-medium">{{ $config['configuration_name'] }}</span>
|
|
</div>
|
|
</td>
|
|
<td class="py-3 px-4">
|
|
<span class="px-2 py-1 bg-info/10 text-info rounded-full text-xs font-medium">
|
|
{{ is_array($config['firewall_rules']) ? count($config['firewall_rules']) : 0 }} rules
|
|
</span>
|
|
</td>
|
|
<td class="py-3 px-4">
|
|
<div class="flex items-center gap-2">
|
|
<span class="px-2 py-0.5 text-xs rounded {{ $config['incoming_policy'] === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
|
|
IN: {{ $config['incoming_policy'] }}
|
|
</span>
|
|
<span class="px-2 py-0.5 text-xs rounded {{ $config['outgoing_policy'] === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
|
|
OUT: {{ $config['outgoing_policy'] }}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="py-3 px-4 text-sm text-base/70">
|
|
{{ \Carbon\Carbon::parse($config['created_at'])->format('M d, Y H:i') }}
|
|
</td>
|
|
<td class="py-3 px-4">
|
|
<div class="flex items-center justify-center gap-2">
|
|
<button type="button" wire:click="confirmApplyConfiguration({{ $config['id'] }})" class="p-2 text-success hover:bg-success/10 rounded-lg transition-colors" title="Apply Configuration" aria-label="Apply configuration">
|
|
<x-ri-play-circle-line class="size-5" />
|
|
</button>
|
|
<button type="button" wire:click="viewConfiguration({{ $config['id'] }})" class="p-2 text-info hover:bg-info/10 rounded-lg transition-colors" title="View Details" aria-label="View configuration details">
|
|
<x-ri-eye-line class="size-5" />
|
|
</button>
|
|
<button type="button" wire:click="openRenameConfigModal({{ $config['id'] }})" class="p-2 text-warning hover:bg-warning/10 rounded-lg transition-colors" title="Rename Configuration" aria-label="Rename configuration">
|
|
<x-ri-edit-line class="size-5" />
|
|
</button>
|
|
<button type="button" wire:click="confirmDeleteConfiguration({{ $config['id'] }})" class="p-2 text-error hover:bg-error/10 rounded-lg transition-colors" title="Delete Configuration" aria-label="Delete configuration">
|
|
<x-ri-delete-bin-line class="size-5" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
@else
|
|
<div class="text-center py-12 text-base/60">
|
|
<x-ri-save-3-line class="size-16 mx-auto mb-4 opacity-30" />
|
|
<h3 class="text-lg font-semibold mb-2">No Saved Configurations</h3>
|
|
<p class="text-sm">Save your current firewall configuration to reuse it later or apply it to other VPS instances</p>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div class="bg-background border border-neutral rounded-xl p-8">
|
|
<div class="max-w-2xl mx-auto text-center">
|
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-warning/10 border-2 border-warning/20 mb-4">
|
|
<x-ri-alert-line class="size-8 text-warning" />
|
|
</div>
|
|
<h3 class="text-xl font-semibold mb-2">Firewall Protection Disabled</h3>
|
|
<p class="text-base/60 mb-6 max-w-md mx-auto">Your virtual machine is currently unprotected. Enable the firewall above to secure your network traffic with customizable rules.</p>
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-left">
|
|
<div class="p-4 bg-background-secondary/50 rounded-lg border border-neutral/20">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<x-ri-shield-check-line class="size-5 text-primary" />
|
|
<h4 class="font-semibold text-sm">Traffic Control</h4>
|
|
</div>
|
|
<p class="text-xs text-base/60">Define rules to allow or block specific connections</p>
|
|
</div>
|
|
<div class="p-4 bg-background-secondary/50 rounded-lg border border-neutral/20">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<x-ri-eye-line class="size-5 text-primary" />
|
|
<h4 class="font-semibold text-sm">Monitor Activity</h4>
|
|
</div>
|
|
<p class="text-xs text-base/60">Track and log all firewall events in real-time</p>
|
|
</div>
|
|
<div class="p-4 bg-background-secondary/50 rounded-lg border border-neutral/20">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<x-ri-settings-3-line class="size-5 text-primary" />
|
|
<h4 class="font-semibold text-sm">Flexible Policies</h4>
|
|
</div>
|
|
<p class="text-xs text-base/60">Configure default behavior for incoming and outgoing traffic</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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($this->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" required>
|
|
<option value="" disabled selected>Select Protocol</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 class="flex gap-3 justify-end">
|
|
<button wire:click="cancelDeleteRule"
|
|
type="button"
|
|
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="deleteRule"
|
|
wire:loading.attr="disabled"
|
|
type="button"
|
|
class="px-4 py-2 bg-error text-white rounded-lg hover:bg-error/80 transition-all text-sm font-medium">
|
|
<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"
|
|
class="px-4 py-2 bg-error text-white rounded-lg hover:bg-error/80 transition-all text-sm font-medium">
|
|
<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"
|
|
class="px-4 py-2 bg-error text-white rounded-lg hover:bg-error/80 transition-all text-sm font-medium">
|
|
<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"
|
|
class="px-4 py-2 bg-error text-white rounded-lg hover:bg-error/80 transition-all text-sm font-medium">
|
|
<span wire:loading.remove wire:target="deleteIPSetMember">Remove</span>
|
|
<span wire:loading wire:target="deleteIPSetMember">Removing...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Save Configuration Modal --}}
|
|
@if($showSaveConfigModal)
|
|
<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="closeSaveConfigModal" 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-6">
|
|
<div class="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
<x-ri-save-3-line class="size-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-bold mb-1">Save Current Configuration</h3>
|
|
<p class="text-sm text-base/70">Save your current firewall rules and policies as a reusable configuration</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">Configuration Name</label>
|
|
<input type="text" wire:model="newConfigName" placeholder="e.g., Web Server Rules"
|
|
class="w-full px-4 py-2 bg-background border border-neutral rounded-lg focus:border-primary focus:outline-none transition-colors">
|
|
@error('newConfigName')
|
|
<p class="text-error text-xs mt-1">{{ $message }}</p>
|
|
@enderror
|
|
</div>
|
|
|
|
<div class="bg-background/50 border border-neutral/30 rounded-lg p-4">
|
|
<h4 class="text-sm font-semibold mb-3">Configuration Summary</h4>
|
|
<div class="grid grid-cols-2 gap-3 text-sm">
|
|
<div class="flex items-center justify-between p-2 bg-background rounded">
|
|
<span class="text-base/60">Rules</span>
|
|
<span class="font-semibold">{{ count($rules) }}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between p-2 bg-background rounded">
|
|
<span class="text-base/60">Incoming Policy</span>
|
|
<span class="font-semibold {{ $policyIn === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyIn }}</span>
|
|
</div>
|
|
<div class="flex items-center justify-between p-2 bg-background rounded">
|
|
<span class="text-base/60">Outgoing Policy</span>
|
|
<span class="font-semibold {{ $policyOut === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyOut }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 justify-end mt-6 pt-4 border-t border-neutral/20">
|
|
<button wire:click="closeSaveConfigModal" 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="saveConfiguration" 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="saveConfiguration">Save Configuration</span>
|
|
<span wire:loading wire:target="saveConfiguration">Saving...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- View Configuration Modal --}}
|
|
@if($showViewConfigModal && $viewingConfiguration)
|
|
<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-4xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
|
<div class="p-6 border-b border-neutral/20">
|
|
<button wire:click="closeViewConfigModal" 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">
|
|
<div class="w-12 h-12 rounded-full bg-info/10 flex items-center justify-center flex-shrink-0">
|
|
<x-ri-eye-line class="size-6 text-info" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-bold mb-1">{{ $viewingConfiguration->configuration_name }}</h3>
|
|
<p class="text-sm text-base/70">Created on {{ $viewingConfiguration->created_at->format('M d, Y H:i') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-6 overflow-y-auto flex-1">
|
|
{{-- Policies --}}
|
|
<div class="mb-6">
|
|
<h4 class="text-sm font-semibold mb-3">Traffic Policies</h4>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="p-4 bg-background rounded-lg border border-neutral/20">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm text-base/60">Incoming Traffic</span>
|
|
<span class="px-3 py-1 rounded-full text-sm font-semibold {{ $viewingConfiguration->incoming_policy === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
|
|
{{ $viewingConfiguration->incoming_policy }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="p-4 bg-background rounded-lg border border-neutral/20">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-sm text-base/60">Outgoing Traffic</span>
|
|
<span class="px-3 py-1 rounded-full text-sm font-semibold {{ $viewingConfiguration->outgoing_policy === 'DROP' ? 'bg-error/10 text-error' : 'bg-success/10 text-success' }}">
|
|
{{ $viewingConfiguration->outgoing_policy }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Compatibility Warnings --}}
|
|
@if(count($configCompatibilityIssues) > 0)
|
|
<div class="mb-6 p-4 bg-warning/10 border border-warning/30 rounded-lg">
|
|
<div class="flex items-start gap-3">
|
|
<x-ri-error-warning-line class="size-5 text-warning flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<h4 class="text-sm font-semibold text-warning mb-2">Compatibility Issues</h4>
|
|
<p class="text-xs text-warning/80 mb-2">The following rules may not be compatible with this VPS:</p>
|
|
<ul class="text-xs text-warning/80 space-y-1">
|
|
@foreach($configCompatibilityIssues as $issue)
|
|
<li>• Rule #{{ $issue['rule_index'] + 1 }}: {{ implode(', ', $issue['issues']) }}</li>
|
|
@endforeach
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Rules --}}
|
|
<div>
|
|
<h4 class="text-sm font-semibold mb-3">Firewall Rules ({{ count($viewingConfiguration->firewall_rules) }})</h4>
|
|
@if(count($viewingConfiguration->firewall_rules) > 0)
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="border-b border-neutral/20">
|
|
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">#</th>
|
|
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Action</th>
|
|
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Direction</th>
|
|
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Source</th>
|
|
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Destination</th>
|
|
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Protocol</th>
|
|
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Port</th>
|
|
<th class="text-left py-2 px-3 text-xs font-semibold text-base/70 uppercase">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach($viewingConfiguration->firewall_rules as $index => $rule)
|
|
@php
|
|
$hasIssue = collect($configCompatibilityIssues)->contains('rule_index', $index);
|
|
@endphp
|
|
<tr class="border-b border-neutral/10 {{ $hasIssue ? 'bg-warning/5' : '' }}">
|
|
<td class="py-2 px-3 text-base/60">{{ $index + 1 }}</td>
|
|
<td class="py-2 px-3">
|
|
<span class="px-2 py-0.5 rounded text-xs font-medium {{ ($rule['action'] ?? 'ACCEPT') === 'ACCEPT' ? 'bg-success/10 text-success' : 'bg-error/10 text-error' }}">
|
|
{{ $rule['action'] ?? 'ACCEPT' }}
|
|
</span>
|
|
</td>
|
|
<td class="py-2 px-3">{{ strtoupper($rule['type'] ?? '-') }}</td>
|
|
<td class="py-2 px-3 font-mono text-xs">{{ $rule['source'] ?? 'any' }}</td>
|
|
<td class="py-2 px-3 font-mono text-xs">{{ $rule['dest'] ?? 'any' }}</td>
|
|
<td class="py-2 px-3">{{ $rule['proto'] ?? '-' }}</td>
|
|
<td class="py-2 px-3 font-mono text-xs">{{ $rule['dport'] ?? '-' }}</td>
|
|
<td class="py-2 px-3">
|
|
@if($hasIssue)
|
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-warning/10 text-warning">Incompatible</span>
|
|
@elseif(isset($rule['enable']) && $rule['enable'] == 0)
|
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-base/10 text-base/60">Disabled</span>
|
|
@else
|
|
<span class="px-2 py-0.5 rounded text-xs font-medium bg-success/10 text-success">Enabled</span>
|
|
@endif
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
@else
|
|
<p class="text-sm text-base/60 text-center py-4">No rules in this configuration</p>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-4 border-t border-neutral/20 bg-background/50">
|
|
<div class="flex gap-3 justify-end">
|
|
<button wire:click="closeViewConfigModal" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
|
Close
|
|
</button>
|
|
<button wire:click="confirmApplyConfiguration({{ $viewingConfiguration->id }})" 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-play-circle-line class="size-4" />
|
|
Apply Configuration
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Apply Configuration Modal --}}
|
|
@if($showApplyConfigModal && $applyingConfiguration)
|
|
<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="closeApplyConfigModal" 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-6">
|
|
<div class="w-12 h-12 rounded-full bg-success/10 flex items-center justify-center flex-shrink-0">
|
|
<x-ri-play-circle-line class="size-6 text-success" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-bold mb-1">Apply Configuration</h3>
|
|
<p class="text-sm text-base/70">Apply "{{ $applyingConfiguration->configuration_name }}" to this VPS</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-warning/10 border border-warning/30 rounded-lg p-4 mb-6">
|
|
<div class="flex items-start gap-3">
|
|
<x-ri-error-warning-line class="size-5 text-warning flex-shrink-0 mt-0.5" />
|
|
<div class="text-sm">
|
|
<p class="font-semibold text-warning mb-1">Warning</p>
|
|
<p class="text-warning/80">This will replace all existing firewall rules with the rules from this configuration. This action cannot be undone.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if(count($applyCompatibilityWarnings) > 0)
|
|
<div class="bg-info/10 border border-info/30 rounded-lg p-4 mb-6">
|
|
<div class="flex items-start gap-3">
|
|
<x-ri-information-line class="size-5 text-info flex-shrink-0 mt-0.5" />
|
|
<div class="text-sm">
|
|
<p class="font-semibold text-info mb-1">Compatibility Notice</p>
|
|
<p class="text-info/80">{{ count($applyCompatibilityWarnings) }} rule(s) in this configuration are not fully compatible with this VPS and will be disabled.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="bg-background/50 border border-neutral/30 rounded-lg p-4 mb-6">
|
|
<h4 class="text-sm font-semibold mb-3">Configuration Details</h4>
|
|
<div class="grid grid-cols-3 gap-3 text-sm">
|
|
<div class="text-center p-2 bg-background rounded">
|
|
<p class="text-xs text-base/60 mb-1">Rules</p>
|
|
<p class="font-semibold">{{ count($applyingConfiguration->firewall_rules) }}</p>
|
|
</div>
|
|
<div class="text-center p-2 bg-background rounded">
|
|
<p class="text-xs text-base/60 mb-1">IN Policy</p>
|
|
<p class="font-semibold {{ $applyingConfiguration->incoming_policy === 'DROP' ? 'text-error' : 'text-success' }}">{{ $applyingConfiguration->incoming_policy }}</p>
|
|
</div>
|
|
<div class="text-center p-2 bg-background rounded">
|
|
<p class="text-xs text-base/60 mb-1">OUT Policy</p>
|
|
<p class="font-semibold {{ $applyingConfiguration->outgoing_policy === 'DROP' ? 'text-error' : 'text-success' }}">{{ $applyingConfiguration->outgoing_policy }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 justify-end">
|
|
<button wire:click="closeApplyConfigModal" 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="applyConfiguration" 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">
|
|
<span wire:loading.remove wire:target="applyConfiguration">Apply Configuration</span>
|
|
<span wire:loading wire:target="applyConfiguration">Applying...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Delete Configuration Modal --}}
|
|
@if($showDeleteConfigModal && $deletingConfigId)
|
|
<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="cancelDeleteConfiguration" 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-line class="size-6 text-error" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-bold mb-2">Delete Configuration</h3>
|
|
<p class="text-sm text-base/70">Are you sure you want to delete this saved configuration? This action cannot be undone.</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 justify-end">
|
|
<button wire:click="cancelDeleteConfiguration" 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="deleteConfiguration" wire:loading.attr="disabled"
|
|
class="px-4 py-2 bg-error text-white rounded-lg hover:bg-error/80 transition-all text-sm font-medium">
|
|
<span wire:loading.remove wire:target="deleteConfiguration">Delete</span>
|
|
<span wire:loading wire:target="deleteConfiguration">Deleting...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Apply to All Servers Modal --}}
|
|
@if($showApplyToAllModal)
|
|
<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-2xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button wire:click="closeApplyToAllModal" 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-6">
|
|
<div class="w-12 h-12 rounded-full bg-success/10 flex items-center justify-center flex-shrink-0">
|
|
<x-ri-global-line class="size-6 text-success" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-bold mb-2">Apply to All Servers</h3>
|
|
<p class="text-sm text-base/70">Apply the current firewall configuration to all your other VPS instances.</p>
|
|
</div>
|
|
</div>
|
|
|
|
@php $otherServers = $this->getUserOtherServers(); @endphp
|
|
|
|
@if(count($applyToAllResults) > 0)
|
|
{{-- Show Results --}}
|
|
<div class="mb-6">
|
|
<h4 class="text-sm font-semibold mb-3">Results</h4>
|
|
<div class="space-y-2 max-h-64 overflow-y-auto">
|
|
@foreach($applyToAllResults as $result)
|
|
<div class="flex items-center justify-between p-3 rounded-lg {{ $result['success'] ? 'bg-success/10 border border-success/20' : 'bg-error/10 border border-error/20' }}">
|
|
<div class="flex items-center gap-3">
|
|
@if($result['success'])
|
|
<x-ri-checkbox-circle-fill class="size-5 text-success" />
|
|
@else
|
|
<x-ri-close-circle-fill class="size-5 text-error" />
|
|
@endif
|
|
<span class="font-medium">{{ $result['hostname'] }}</span>
|
|
</div>
|
|
<span class="text-xs {{ $result['success'] ? 'text-success' : 'text-error' }}">{{ $result['message'] }}</span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 justify-end">
|
|
<button wire:click="closeApplyToAllModal" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors text-sm font-medium">
|
|
Done
|
|
</button>
|
|
</div>
|
|
@else
|
|
{{-- Show Confirmation --}}
|
|
<div class="bg-warning/10 border border-warning/30 rounded-lg p-4 mb-6">
|
|
<div class="flex items-start gap-3">
|
|
<x-ri-alert-line class="size-5 text-warning flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<h4 class="font-semibold text-warning mb-1">Warning</h4>
|
|
<p class="text-sm text-base/70">This will replace the firewall configuration on all your other servers. Existing rules will be deleted and replaced with the current configuration.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if(count($otherServers) > 0)
|
|
<div class="mb-6">
|
|
<h4 class="text-sm font-semibold mb-3">Servers to Update ({{ count($otherServers) }})</h4>
|
|
<div class="space-y-2 max-h-48 overflow-y-auto">
|
|
@foreach($otherServers as $otherServer)
|
|
<div class="flex items-center justify-between p-3 bg-background/50 border border-neutral/20 rounded-lg">
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
<x-ri-server-line class="size-4 text-primary" />
|
|
</div>
|
|
<div>
|
|
<span class="font-medium">{{ $otherServer['hostname'] ?? 'VPS ' . $otherServer['vm_id'] }}</span>
|
|
<span class="text-xs text-base/50 ml-2">VM {{ $otherServer['vm_id'] }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-background/50 border border-neutral/30 rounded-lg p-4 mb-6">
|
|
<h4 class="text-sm font-semibold mb-2">Configuration to Apply</h4>
|
|
<div class="grid grid-cols-3 gap-3 text-sm">
|
|
<div>
|
|
<span class="text-base/60">Rules:</span>
|
|
<span class="font-medium ml-1">{{ count($rules) }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-base/60">Policy In:</span>
|
|
<span class="font-medium ml-1 {{ $policyIn === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyIn }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="text-base/60">Policy Out:</span>
|
|
<span class="font-medium ml-1 {{ $policyOut === 'DROP' ? 'text-error' : 'text-success' }}">{{ $policyOut }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div class="text-center py-8 text-base/60">
|
|
<x-ri-server-line class="size-12 mx-auto mb-3 opacity-30" />
|
|
<p>No other servers found to apply the configuration to.</p>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="flex gap-3 justify-end">
|
|
<button wire:click="closeApplyToAllModal" class="px-4 py-2 bg-background border border-neutral rounded-lg hover:bg-background-secondary transition-colors text-sm font-medium">
|
|
Cancel
|
|
</button>
|
|
@if(count($otherServers) > 0)
|
|
<button wire:click="applyToAllServers" 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 disabled:opacity-50">
|
|
<span wire:loading.remove wire:target="applyToAllServers">Apply to {{ count($otherServers) }} Server{{ count($otherServers) > 1 ? 's' : '' }}</span>
|
|
<span wire:loading wire:target="applyToAllServers">Applying...</span>
|
|
</button>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Rename Configuration Modal --}}
|
|
@if($showRenameConfigModal && $renamingConfigId)
|
|
<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="closeRenameConfigModal" 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-6">
|
|
<div class="w-12 h-12 rounded-full bg-warning/10 flex items-center justify-center flex-shrink-0">
|
|
<x-ri-edit-line class="size-6 text-warning" />
|
|
</div>
|
|
<div>
|
|
<h3 class="text-xl font-bold mb-1">Rename Configuration</h3>
|
|
<p class="text-sm text-base/70">Enter a new name for this configuration</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-6">
|
|
<label for="renameConfigName" class="block text-sm font-medium mb-2">Configuration Name</label>
|
|
<input type="text"
|
|
id="renameConfigName"
|
|
wire:model="renameConfigName"
|
|
class="w-full px-4 py-2 bg-background border border-neutral rounded-lg focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
|
|
placeholder="Enter configuration name"
|
|
maxlength="255">
|
|
@error('renameConfigName')
|
|
<p class="mt-1 text-sm text-error">{{ $message }}</p>
|
|
@enderror
|
|
</div>
|
|
|
|
<div class="flex gap-3 justify-end">
|
|
<button wire:click="closeRenameConfigModal" 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="renameConfiguration" wire:loading.attr="disabled" class="px-4 py-2 bg-warning text-white rounded-lg hover:bg-warning/90 transition-colors text-sm font-medium disabled:opacity-50">
|
|
<span wire:loading.remove wire:target="renameConfiguration">Rename</span>
|
|
<span wire:loading wire:target="renameConfiguration">Renaming...</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
@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 $wire.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
|
|
</x-proxmox::layout> |