Files
Virtnet.bond/extensions/Servers/Proxmox/views/firewall.blade.php

2996 lines
237 KiB
PHP

<x-proxmox::layout :server="$server" view="firewall" :status="$status" :status-polling="true" :status-polling-interval="'2s'">
<div class="h-9/10 bg-background-secondary/80 backdrop-blur-md border border-neutral/50 rounded-2xl shadow-xl overflow-hidden">
<div class="flex flex-col h-full overflow-visible">
<div class="p-6 border-b border-neutral/50">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-primary to-secondary rounded-xl flex items-center justify-center shadow-lg">
<x-ri-shield-line class="w-6 h-6 text-white" />
</div>
<div>
<h2 class="text-lg font-bold text-foreground">Firewall</h2>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4">
<style>
.firewall-toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.firewall-toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.firewall-toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #e5e7eb;
transition: 0.3s;
border-radius: 24px;
}
.dark .firewall-toggle-slider {
background-color: #374151;
}
.firewall-toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.firewall-toggle-switch input:checked + .firewall-toggle-slider {
background-color: #2563eb;
}
.firewall-toggle-switch input:checked + .firewall-toggle-slider:before {
transform: translateX(20px);
}
.firewall-toggle-switch input:disabled + .firewall-toggle-slider {
opacity: 0.5;
cursor: not-allowed;
}
.delete-rule-button {
background-color: #ef4444;
color: white;
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 10px 15px -3px rgba(239, 68, 68, 0.2);
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
.delete-rule-button:hover {
background-color: #dc2626;
}
.delete-rule-button:active {
transform: scale(0.95);
}
.delete-rule-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.firewall-log-search-input {
padding-left: 2.75rem !important;
}
/* Drag and Drop Styles */
.rule-row {
transition: all 0.2s ease;
cursor: default;
}
.rule-row[draggable="true"] {
cursor: move;
}
.rule-row.dragging {
opacity: 0.5;
background-color: rgba(var(--color-primary), 0.1);
}
.rule-row.drag-over {
border-top: 3px solid rgba(var(--color-primary), 1);
}
.drag-handle {
cursor: grab;
color: rgba(var(--color-base), 0.4);
transition: color 0.2s;
display: flex;
align-items: center;
padding: 0.5rem;
}
.drag-handle:hover {
color: rgba(var(--color-primary), 1);
}
.drag-handle:active {
cursor: grabbing;
}
.rule-row:hover .drag-handle {
color: rgba(var(--color-base), 0.7);
}
/* Firewall Log Filter Buttons */
.firewall-filter-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(var(--color-base), 0.5);
margin-right: 0.25rem;
}
.firewall-filter-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.5rem;
border: 1px solid rgba(var(--color-neutral), 1);
background-color: rgba(var(--color-background), 1);
color: rgba(var(--color-base), 0.7);
cursor: pointer;
transition: all 0.15s ease;
}
.firewall-filter-btn:hover {
border-color: rgba(var(--color-primary), 0.5);
color: rgba(var(--color-base), 1);
}
.firewall-filter-btn-count {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
background-color: rgba(var(--color-base), 0.1);
}
/* Active states for Policy filters */
.firewall-filter-btn.active-all {
background-color: #1e3a5f;
border-color: #1e3a5f;
color: #ffffff;
box-shadow: 0 2px 8px rgba(30, 58, 95, 0.4);
}
.firewall-filter-btn.active-all .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
color: #ffffff;
}
/* DROP - Red */
.firewall-filter-btn.active-drop {
background-color: #dc2626;
border-color: #dc2626;
color: white;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.3);
}
.firewall-filter-btn.active-drop .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
}
.firewall-filter-btn:not(.active-drop) .firewall-filter-btn-count.count-drop {
background-color: rgba(220, 38, 38, 0.1);
color: #dc2626;
}
/* REJECT - Orange-Red */
.firewall-filter-btn.active-reject {
background-color: #ea580c;
border-color: #ea580c;
color: white;
box-shadow: 0 2px 8px rgba(234, 88, 12, 0.3);
}
.firewall-filter-btn.active-reject .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
}
.firewall-filter-btn:not(.active-reject) .firewall-filter-btn-count.count-reject {
background-color: rgba(234, 88, 12, 0.1);
color: #ea580c;
}
/* ACCEPT - Green */
.firewall-filter-btn.active-accept {
background-color: #16a34a;
border-color: #16a34a;
color: white;
box-shadow: 0 2px 8px rgba(22, 163, 74, 0.3);
}
.firewall-filter-btn.active-accept .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
}
.firewall-filter-btn:not(.active-accept) .firewall-filter-btn-count.count-accept {
background-color: rgba(22, 163, 74, 0.1);
color: #16a34a;
}
/* LOG - Cyan */
.firewall-filter-btn.active-log {
background-color: #0891b2;
border-color: #0891b2;
color: white;
box-shadow: 0 2px 8px rgba(8, 145, 178, 0.3);
}
.firewall-filter-btn.active-log .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
}
.firewall-filter-btn:not(.active-log) .firewall-filter-btn-count.count-log {
background-color: rgba(8, 145, 178, 0.1);
color: #0891b2;
}
/* Action Other - Gray */
.firewall-filter-btn.active-action-other {
background-color: #525252;
border-color: #525252;
color: white;
box-shadow: 0 2px 8px rgba(82, 82, 82, 0.3);
}
.firewall-filter-btn.active-action-other .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
}
/* Active states for Protocol filters */
.firewall-filter-btn.active-proto-all {
background-color: #374151;
border-color: #374151;
color: #ffffff;
box-shadow: 0 2px 8px rgba(55, 65, 81, 0.4);
}
.firewall-filter-btn.active-proto-all .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
color: #ffffff;
}
.firewall-filter-btn.active-tcp {
background-color: #3b82f6;
border-color: #3b82f6;
color: white;
}
.firewall-filter-btn.active-tcp .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.2);
}
.firewall-filter-btn:not(.active-tcp) .firewall-filter-btn-count.count-tcp {
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.firewall-filter-btn.active-udp {
background-color: #f59e0b;
border-color: #f59e0b;
color: white;
}
.firewall-filter-btn.active-udp .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.2);
}
.firewall-filter-btn:not(.active-udp) .firewall-filter-btn-count.count-udp {
background-color: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.firewall-filter-btn.active-icmp {
background-color: #a855f7;
border-color: #a855f7;
color: white;
}
.firewall-filter-btn.active-icmp .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.2);
}
.firewall-filter-btn:not(.active-icmp) .firewall-filter-btn-count.count-icmp {
background-color: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
/* GRE - Teal */
.firewall-filter-btn.active-gre {
background-color: #0d9488;
border-color: #0d9488;
color: white;
box-shadow: 0 2px 8px rgba(13, 148, 136, 0.3);
}
.firewall-filter-btn.active-gre .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
}
.firewall-filter-btn:not(.active-gre) .firewall-filter-btn-count.count-gre {
background-color: rgba(13, 148, 136, 0.1);
color: #0d9488;
}
/* ESP - Indigo */
.firewall-filter-btn.active-esp {
background-color: #4f46e5;
border-color: #4f46e5;
color: white;
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.3);
}
.firewall-filter-btn.active-esp .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
}
.firewall-filter-btn:not(.active-esp) .firewall-filter-btn-count.count-esp {
background-color: rgba(79, 70, 229, 0.1);
color: #4f46e5;
}
/* AH - Violet */
.firewall-filter-btn.active-ah {
background-color: #7c3aed;
border-color: #7c3aed;
color: white;
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3);
}
.firewall-filter-btn.active-ah .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
}
.firewall-filter-btn:not(.active-ah) .firewall-filter-btn-count.count-ah {
background-color: rgba(124, 58, 237, 0.1);
color: #7c3aed;
}
/* SCTP - Pink */
.firewall-filter-btn.active-sctp {
background-color: #db2777;
border-color: #db2777;
color: white;
box-shadow: 0 2px 8px rgba(219, 39, 119, 0.3);
}
.firewall-filter-btn.active-sctp .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
}
.firewall-filter-btn:not(.active-sctp) .firewall-filter-btn-count.count-sctp {
background-color: rgba(219, 39, 119, 0.1);
color: #db2777;
}
/* IGMP - Lime */
.firewall-filter-btn.active-igmp {
background-color: #65a30d;
border-color: #65a30d;
color: white;
box-shadow: 0 2px 8px rgba(101, 163, 13, 0.3);
}
.firewall-filter-btn.active-igmp .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
}
.firewall-filter-btn:not(.active-igmp) .firewall-filter-btn-count.count-igmp {
background-color: rgba(101, 163, 13, 0.1);
color: #65a30d;
}
/* Protocol Other - Dark Gray */
.firewall-filter-btn.active-proto-other {
background-color: #525252;
border-color: #525252;
color: white;
box-shadow: 0 2px 8px rgba(82, 82, 82, 0.3);
}
.firewall-filter-btn.active-proto-other .firewall-filter-btn-count {
background-color: rgba(255, 255, 255, 0.25);
}
.firewall-filter-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.firewall-filter-divider {
padding-top: 0.5rem;
border-top: 1px solid rgba(var(--color-neutral), 0.3);
}
/* State Details Button */
.firewall-state-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.7rem;
font-weight: 500;
border-radius: 0.375rem;
background-color: rgba(var(--color-info), 0.1);
color: rgba(var(--color-info), 1);
border: 1px solid rgba(var(--color-info), 0.3);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.firewall-state-btn:hover {
background-color: rgba(var(--color-info), 0.2);
border-color: rgba(var(--color-info), 0.5);
color: rgba(var(--color-info), 1);
}
</style>
<div class="space-y-6">
<!-- Firewall Status Card -->
<div class="bg-background border border-neutral rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<x-ri-shield-line class="size-5 text-primary" />
</div>
<div>
<h2 class="text-xl font-semibold">Firewall Status</h2>
<p class="text-sm text-base/60 mt-1">Enable or disable the firewall for this virtual machine</p>
</div>
</div>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-base/70">Disabled</span>
<label class="firewall-toggle-switch" wire:loading.attr="disabled" wire:target="toggleFirewall">
<input
type="checkbox"
@if($firewallEnabled) checked @endif
wire:click="toggleFirewall"
wire:loading.attr="disabled">
<span class="firewall-toggle-slider"></span>
</label>
<span class="text-sm font-medium text-base/70">Enabled</span>
</div>
</div>
</div>
<!-- Network Adapters Card -->
<div class="bg-background border border-neutral rounded-lg p-6">
<div class="flex items-center gap-3 mb-4">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<x-ri-wifi-line class="size-5 text-primary" />
</div>
<div>
<h2 class="text-xl font-semibold">Network Adapters</h2>
<p class="text-sm text-base/60 mt-1">Enable or disable firewall for each network adapter</p>
</div>
</div>
@if(count($networkAdapters) > 0)
<div class="space-y-4">
@foreach($networkAdapters as $adapter)
<div class="bg-background-secondary border border-neutral/20 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-base font-semibold">{{ $adapter['name'] }}</h3>
<span class="px-2 py-1 rounded text-xs font-medium bg-primary/20 text-primary">
{{ ucfirst($adapter['model'] ?? 'Unknown') }}
</span>
</div>
<div class="text-sm text-base/60 space-y-1">
@if($adapter['mac'])
<div>MAC: <code class="text-xs">{{ $adapter['mac'] }}</code></div>
@endif
</div>
<p class="text-xs text-base/50 mt-2">Enables or disables the firewall ability for this network adapter</p>
</div>
<div class="flex flex-col items-center gap-2 ml-4">
<span class="text-xs font-medium text-base/60">Network Adapter Firewall</span>
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-base/70">Disabled</span>
<label class="firewall-toggle-switch" wire:loading.attr="disabled">
<input
type="checkbox"
@if($adapterFirewallStates[$adapter['name']] ?? false) checked @endif
wire:click="toggleAdapterFirewall('{{ $adapter['name'] }}')"
wire:loading.attr="disabled">
<span class="firewall-toggle-slider"></span>
</label>
<span class="text-sm font-medium text-base/70">Enabled</span>
</div>
</div>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-8 text-base/60">
<x-ri-wifi-line class="size-12 mx-auto mb-3 opacity-50" />
<p>No network adapters found</p>
</div>
@endif
</div>
@if($firewallEnabled)
<!-- Firewall Rules Card -->
<div class="bg-background border border-neutral rounded-lg p-6"
x-data="{
editingPositions: false,
rules: @js($rules),
draggedIndex: null,
dragOverIndex: null,
init() {
// Listen for Livewire updates to sync the rules array
$wire.on('rulesUpdated', () => {
this.rules = @js($rules);
});
},
startDrag(event, index) {
if (!this.editingPositions) {
event.preventDefault();
return;
}
this.draggedIndex = index;
event.target.closest('tr').classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/html', event.target.closest('tr').innerHTML);
},
endDrag(event) {
event.target.closest('tr').classList.remove('dragging');
this.draggedIndex = null;
this.dragOverIndex = null;
// Remove all drag-over classes
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
},
dragOver(event, index) {
event.preventDefault();
if (this.draggedIndex !== null && this.draggedIndex !== index) {
this.dragOverIndex = index;
}
},
dragLeave(event) {
event.target.closest('tr')?.classList.remove('drag-over');
},
async drop(event, targetIndex) {
event.preventDefault();
event.target.closest('tr')?.classList.remove('drag-over');
if (this.draggedIndex === null || this.draggedIndex === targetIndex) {
return;
}
// Capture ORIGINAL positions before modifying the array
const originalDraggedPos = this.rules[this.draggedIndex].pos;
const originalTargetPos = this.rules[targetIndex].pos;
// Optimistically update UI
const movedRule = this.rules.splice(this.draggedIndex, 1)[0];
this.rules.splice(targetIndex, 0, movedRule);
// Update positions in the array
this.rules.forEach((rule, idx) => {
rule.pos = idx;
});
// Call Livewire to persist the change using ORIGINAL positions
try {
await @this.reorderRules(originalDraggedPos, originalTargetPos);
} catch (error) {
console.error('Failed to reorder rules:', error);
// Reload rules on error
window.location.reload();
}
},
getActionClass(action) {
if (action === 'ACCEPT') return 'bg-success/20 text-success';
if (action === 'REJECT') return 'bg-warning/20 text-warning';
if (action === 'DROP') return 'bg-error/20 text-error';
return '';
}
}">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<x-ri-list-check class="size-5 text-primary" />
</div>
<div>
<h2 class="text-xl font-semibold">Firewall Rules</h2>
<p class="text-sm text-base/60 mt-1">Manage allow and block rules for network traffic</p>
</div>
</div>
<div class="flex gap-2">
<button
@click="editingPositions = !editingPositions"
class="px-4 py-2 rounded-lg transition-colors text-sm font-medium border"
:class="editingPositions ? 'bg-success text-white border-success hover:bg-success/90' : 'bg-background-secondary text-base border-neutral/20 hover:bg-background-secondary/80'"
:title="editingPositions ? 'Save order changes' : 'Edit rule order'">
<x-ri-draggable class="size-4 inline mr-1" x-show="!editingPositions" />
<x-ri-save-line class="size-4 inline mr-1" x-show="editingPositions" />
<span x-text="editingPositions ? 'Save' : 'Edit Order'"></span>
</button>
<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" />
Add Rule
</button>
</div>
</div>
<!-- Rules Table -->
@if(count($rules) > 0)
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-neutral">
<th class="text-left py-3 px-2 font-semibold text-sm w-8"></th>
<th class="text-left py-3 px-4 font-semibold text-sm">Order</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Action</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Source</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Destination</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Protocol</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Port</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Status</th>
<th class="text-left py-3 px-4 font-semibold text-sm">Actions</th>
</tr>
</thead>
<tbody>
<template x-for="(rule, index) in rules" :key="rule.pos">
<tr class="rule-row border-b border-neutral/20 hover:bg-background-secondary/50"
:draggable="editingPositions"
@dragstart="startDrag($event, index)"
@dragend="endDrag($event)"
@dragover="dragOver($event, index)"
@dragleave="dragLeave($event)"
@drop="drop($event, index)"
:class="{ 'drag-over': dragOverIndex === index }">
<td class="py-3 px-2 text-sm">
<div x-show="editingPositions" class="drag-handle" title="Drag to reorder">
<x-ri-menu-line class="size-4" />
</div>
</td>
<td class="py-3 px-4 text-sm" x-text="rule.pos ?? '-'"></td>
<td class="py-3 px-4 text-sm">
<span class="px-2 py-1 rounded text-xs font-medium"
:class="getActionClass(rule.action)"
x-text="rule.action ?? '-'"></span>
</td>
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-1">
<span class="font-mono text-xs" x-text="rule.source ?? '-'"></span>
<template x-if="rule.source && rule.source !== '-' && window.isPublicIP(rule.source)">
<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(rule.source).then(data => {
ipData = data;
loading = false;
}).catch(err => {
ipData = { status: 'error', message: 'Failed to load location data' };
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>
<!-- 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 overflow-x-hidden">
<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 break-words">
<div class="flex items-center gap-2 mb-4">
<x-ri-map-pin-line class="size-5 text-primary flex-shrink-0" />
<h3 class="text-lg font-semibold break-words">IP Address: <span class="font-mono break-all" x-text="rule.source"></span></h3>
</div>
<div x-show="loading" class="text-center py-8">
<svg fill="currentColor" stroke="currentColor" viewBox="0 0 128 128" class="size-6 mx-auto mb-2">
<g>
<path stroke-width="5" d="M31.6 8.98a4.02 4.02 0 0 0 1.7 5.3l6.12 3.22a52.56 52.56 0 1 0 63.13 10.73L96 33.97a43.88 43.88 0 1 1-53-8.5l-3.65 7.4a4.02 4.02 0 0 0 1.72 5.3 3.73 3.73 0 0 0 5.13-1.67l7.66-15.63a4.18 4.18 0 0 0-1.75-5.4L36.74 7.32a3.73 3.73 0 0 0-5.13 1.66z" fill="currentColor" stroke="currentColor"/>
<animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="1000ms" repeatCount="indefinite"></animateTransform>
</g>
</svg>
<p class="text-sm text-base/60">Loading location data...</p>
</div>
<div x-show="ipData && !loading">
<template x-if="ipData && (ipData.status === 'success')">
<div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">Country:</span> <span class="font-semibold text-sm break-words" x-text="ipData.country"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Region:</span> <span class="font-semibold text-sm break-words" x-text="ipData.regionName + ' (' + ipData.region + ')'"></span></div>
<div class="break-words"><span class="text-xs text-base/60">City:</span> <span class="font-semibold text-sm break-words" x-text="ipData.city"></span></div>
<div class="break-words"><span class="text-xs text-base/60">ZIP Code:</span> <span class="font-semibold text-sm break-words" x-text="ipData.zip"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Timezone:</span> <span class="font-semibold text-sm break-words" x-text="ipData.timezone"></span></div>
</div>
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">ISP:</span> <span class="font-semibold text-sm break-words" x-text="ipData.isp"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Organization:</span> <span class="font-semibold text-sm break-words" x-text="ipData.org"></span></div>
<div class="break-words"><span class="text-xs text-base/60">AS:</span> <span class="font-semibold text-sm break-all" x-text="ipData.as"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Coordinates:</span> <span class="font-semibold text-sm font-mono break-all" x-text="ipData.lat + ', ' + ipData.lon"></span></div>
</div>
</div>
<div x-show="ipData.lat && ipData.lon" class="mt-4 w-full" style="max-width: 100%; box-sizing: border-box;">
<p class="text-xs text-base/60 mb-2">Location Map:</p>
<div class="w-full rounded-lg border border-neutral/20 overflow-hidden bg-background/50" style="position: relative; width: 100%; max-width: 100%; height: 256px; min-height: 256px; box-sizing: border-box;">
<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; display: block; margin: 0; padding: 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>
</template>
</div>
</td>
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-1">
<span class="font-mono text-xs" x-text="rule.dest ?? '-'"></span>
<template x-if="rule.dest && rule.dest !== '-' && window.isPublicIP(rule.dest)">
<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(rule.dest).then(data => {
ipData = data;
loading = false;
}).catch(err => {
ipData = { status: 'error', message: 'Failed to load location data' };
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>
<!-- 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 overflow-x-hidden">
<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 break-words">
<div class="flex items-center gap-2 mb-4">
<x-ri-map-pin-line class="size-5 text-primary flex-shrink-0" />
<h3 class="text-lg font-semibold break-words">IP Address: <span class="font-mono break-all" x-text="rule.dest"></span></h3>
</div>
<div x-show="loading" class="text-center py-8">
<svg fill="currentColor" stroke="currentColor" viewBox="0 0 128 128" class="size-6 mx-auto mb-2">
<g>
<path stroke-width="5" d="M31.6 8.98a4.02 4.02 0 0 0 1.7 5.3l6.12 3.22a52.56 52.56 0 1 0 63.13 10.73L96 33.97a43.88 43.88 0 1 1-53-8.5l-3.65 7.4a4.02 4.02 0 0 0 1.72 5.3 3.73 3.73 0 0 0 5.13-1.67l7.66-15.63a4.18 4.18 0 0 0-1.75-5.4L36.74 7.32a3.73 3.73 0 0 0-5.13 1.66z" fill="currentColor" stroke="currentColor"/>
<animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="1000ms" repeatCount="indefinite"></animateTransform>
</g>
</svg>
<p class="text-sm text-base/60">Loading location data...</p>
</div>
<div x-show="ipData && !loading">
<template x-if="ipData && (ipData.status === 'success')">
<div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">Country:</span> <span class="font-semibold text-sm break-words" x-text="ipData.country"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Region:</span> <span class="font-semibold text-sm break-words" x-text="ipData.regionName + ' (' + ipData.region + ')'"></span></div>
<div class="break-words"><span class="text-xs text-base/60">City:</span> <span class="font-semibold text-sm break-words" x-text="ipData.city"></span></div>
<div class="break-words"><span class="text-xs text-base/60">ZIP Code:</span> <span class="font-semibold text-sm break-words" x-text="ipData.zip"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Timezone:</span> <span class="font-semibold text-sm break-words" x-text="ipData.timezone"></span></div>
</div>
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">ISP:</span> <span class="font-semibold text-sm break-words" x-text="ipData.isp"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Organization:</span> <span class="font-semibold text-sm break-words" x-text="ipData.org"></span></div>
<div class="break-words"><span class="text-xs text-base/60">AS:</span> <span class="font-semibold text-sm break-all" x-text="ipData.as"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Coordinates:</span> <span class="font-semibold text-sm font-mono break-all" x-text="ipData.lat + ', ' + ipData.lon"></span></div>
</div>
</div>
<div x-show="ipData.lat && ipData.lon" class="mt-4 w-full" style="max-width: 100%; box-sizing: border-box;">
<p class="text-xs text-base/60 mb-2">Location Map:</p>
<div class="w-full rounded-lg border border-neutral/20 overflow-hidden bg-background/50" style="position: relative; width: 100%; max-width: 100%; height: 256px; min-height: 256px; box-sizing: border-box;">
<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; display: block; margin: 0; padding: 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>
</template>
</div>
</td>
<td class="py-3 px-4 text-sm" x-text="(rule.proto ?? 'any').toUpperCase()"></td>
<td class="py-3 px-4 text-sm" x-text="rule.dport ?? rule.sport ?? '-'"></td>
<td class="py-3 px-4 text-sm">
<span x-show="rule.enable == 0" class="px-2 py-1 rounded text-xs font-medium bg-base/20 text-base/60">Disabled</span>
<span x-show="rule.enable != 0" class="px-2 py-1 rounded text-xs font-medium bg-success/20 text-success">Enabled</span>
</td>
<td class="py-3 px-4 text-sm">
<div class="flex gap-2">
<button
wire:click="toggleRule(rule.pos)"
wire:loading.attr="disabled"
class="transition-colors"
:class="rule.enable == 0 ? 'text-warning hover:text-warning/80' : 'text-success hover:text-success/80'"
:title="rule.enable == 0 ? 'Enable rule' : 'Disable rule'">
<x-ri-toggle-line x-show="rule.enable == 0" class="size-4" />
<x-ri-toggle-fill x-show="rule.enable != 0" class="size-4" />
</button>
<button
wire:click="startEditRule(rule.pos)"
class="text-primary hover:text-primary/80 transition-colors"
title="Edit rule">
<x-ri-edit-line class="size-4" />
</button>
<button
wire:click="confirmDeleteRule(rule.pos)"
class="text-error hover:text-error/80 transition-colors"
title="Delete rule">
<x-ri-delete-bin-line class="size-4" />
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-base/60">
<x-ri-list-check class="size-12 mx-auto mb-3 opacity-50" />
<p>No firewall rules configured</p>
</div>
@endif
</div>
<!-- Firewall Logs Card -->
<div class="bg-background border border-neutral rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<x-ri-file-list-3-line class="size-5 text-primary" />
</div>
<div>
<h2 class="text-xl font-semibold">Firewall Logs</h2>
<p class="text-sm text-base/60 mt-1">
Recent firewall activity and blocked/allowed connections
<span
class="inline-block transition-all duration-300"
wire:key="log-count-{{ count($firewallLogs) }}"
x-data="{ updated: false }"
x-init="$watch('$wire.firewallLogs', () => { updated = true; setTimeout(() => updated = false, 1000) })"
:class="{ 'text-success font-semibold scale-110': updated }">
({{ count($firewallLogs) }} entries loaded)
</span>
</p>
</div>
</div>
<button
wire:click="loadFirewallLogs"
wire:loading.attr="disabled"
wire:loading.class="opacity-75 cursor-not-allowed"
class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-all duration-200 text-sm font-medium flex items-center gap-2 disabled:opacity-75 disabled:cursor-not-allowed">
<x-ri-refresh-line
class="size-4 transition-transform duration-500"
wire:loading.class="animate-spin"
wire:target="loadFirewallLogs" />
<span wire:loading.remove wire:target="loadFirewallLogs">Refresh</span>
<span wire:loading wire:target="loadFirewallLogs">Refreshing...</span>
</button>
</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" />
<div class="flex-1">
<p class="text-sm text-warning/90">{{ $logError }}</p>
</div>
</div>
</div>
@endif
@if(count($firewallLogs) > 0)
<div
x-data="{
logs: @js($firewallLogs),
searchTerm: '',
sortField: 'timestamp_iso',
sortDirection: 'desc',
currentPage: 1,
perPage: 10,
actionFilters: [],
protocolFilters: [],
isRefreshing: false,
initialize(initialLogs) {
console.log('Initializing logs, received:', initialLogs ? initialLogs.length : 0, 'entries');
this.logs = (initialLogs || []).map((log, index) => ({
index,
...log,
}));
console.log('Logs after initialization:', this.logs.length);
},
sort(field) {
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDirection = 'asc';
}
this.currentPage = 1;
},
toggleActionFilter(filter) {
const idx = this.actionFilters.indexOf(filter);
if (idx === -1) {
this.actionFilters.push(filter);
} else {
this.actionFilters.splice(idx, 1);
}
this.currentPage = 1;
},
toggleProtocolFilter(filter) {
const idx = this.protocolFilters.indexOf(filter);
if (idx === -1) {
this.protocolFilters.push(filter);
} else {
this.protocolFilters.splice(idx, 1);
}
this.currentPage = 1;
},
clearActionFilters() {
this.actionFilters = [];
this.currentPage = 1;
},
clearProtocolFilters() {
this.protocolFilters = [];
this.currentPage = 1;
},
isActionActive(filter) {
return this.actionFilters.includes(filter);
},
isProtocolActive(filter) {
return this.protocolFilters.includes(filter);
},
get actionCounts() {
const counts = { all: this.logs.length, drop: 0, reject: 0, accept: 0, log: 0, other: 0 };
this.logs.forEach(log => {
const action = (log.action || '').toUpperCase();
if (action === 'DROP' || action.includes('DROP')) {
counts.drop++;
} else if (action === 'REJECT' || action.includes('REJECT')) {
counts.reject++;
} else if (action === 'ACCEPT' || action.includes('ACCEPT')) {
counts.accept++;
} else if (action === 'LOG' || action.includes('LOG')) {
counts.log++;
} else if (action) {
counts.other++;
}
});
return counts;
},
get protocolCounts() {
const counts = { all: this.logs.length, tcp: 0, udp: 0, icmp: 0, gre: 0, esp: 0, ah: 0, sctp: 0, igmp: 0, other: 0 };
this.logs.forEach(log => {
const proto = (log.protocol || '').toUpperCase();
if (proto === 'TCP') counts.tcp++;
else if (proto === 'UDP') counts.udp++;
else if (proto === 'ICMP' || proto === 'ICMPV6') counts.icmp++;
else if (proto === 'GRE') counts.gre++;
else if (proto === 'ESP') counts.esp++;
else if (proto === 'AH') counts.ah++;
else if (proto === 'SCTP') counts.sctp++;
else if (proto === 'IGMP') counts.igmp++;
else if (proto) counts.other++;
});
return counts;
},
get filteredLogs() {
const term = this.searchTerm.trim().toLowerCase();
let entries = this.logs;
// Apply action filters (multi-select)
if (this.actionFilters.length > 0) {
entries = entries.filter(log => {
const action = (log.action || '').toUpperCase();
for (const filter of this.actionFilters) {
if (filter === 'drop' && (action === 'DROP' || action.includes('DROP'))) return true;
if (filter === 'reject' && (action === 'REJECT' || action.includes('REJECT'))) return true;
if (filter === 'accept' && (action === 'ACCEPT' || action.includes('ACCEPT'))) return true;
if (filter === 'log' && (action === 'LOG' || action.includes('LOG'))) return true;
if (filter === 'other' && action && !['DROP', 'REJECT', 'ACCEPT', 'LOG'].some(a => action.includes(a))) return true;
}
return false;
});
}
// Apply protocol filters (multi-select)
if (this.protocolFilters.length > 0) {
entries = entries.filter(log => {
const proto = (log.protocol || '').toUpperCase();
for (const filter of this.protocolFilters) {
if (filter === 'tcp' && proto === 'TCP') return true;
if (filter === 'udp' && proto === 'UDP') return true;
if (filter === 'icmp' && (proto === 'ICMP' || proto === 'ICMPV6')) return true;
if (filter === 'gre' && proto === 'GRE') return true;
if (filter === 'esp' && proto === 'ESP') return true;
if (filter === 'ah' && proto === 'AH') return true;
if (filter === 'sctp' && proto === 'SCTP') return true;
if (filter === 'igmp' && proto === 'IGMP') return true;
if (filter === 'other' && proto && !['TCP', 'UDP', 'ICMP', 'ICMPV6', 'GRE', 'ESP', 'AH', 'SCTP', 'IGMP'].includes(proto)) return true;
}
return false;
});
}
// Apply search filter
if (term) {
entries = entries.filter((log) => {
return Object.values(log).some((value) => {
if (value === null || value === undefined) {
return false;
}
return String(value).toLowerCase().includes(term);
});
});
}
const dir = this.sortDirection === 'asc' ? 1 : -1;
const field = this.sortField;
return entries.slice().sort((a, b) => {
let aVal = a[field] ?? '';
let bVal = b[field] ?? '';
if (field === 'timestamp_iso') {
const aTime = aVal ? new Date(aVal).getTime() : 0;
const bTime = bVal ? new Date(bVal).getTime() : 0;
return (aTime - bTime) * dir;
}
if (!Number.isNaN(parseFloat(aVal)) && !Number.isNaN(parseFloat(bVal))) {
return (parseFloat(aVal) - parseFloat(bVal)) * dir;
}
return String(aVal).localeCompare(String(bVal)) * dir;
});
},
get paginatedLogs() {
const filtered = this.filteredLogs;
const start = (this.currentPage - 1) * this.perPage;
const end = start + this.perPage;
return filtered.slice(start, end);
},
get totalPages() {
return Math.ceil(this.filteredLogs.length / this.perPage);
},
get paginationRange() {
const total = this.totalPages;
const current = this.currentPage;
const delta = 2; // Show 2 pages on each side of current
if (total <= 0) return [];
if (total <= 7) {
// If 7 or fewer pages, show all pages
return Array.from({ length: total }, (_, i) => i + 1);
}
const range = [];
const left = Math.max(2, current - delta);
const right = Math.min(total - 1, current + delta);
// Always include first page
range.push(1);
// Add left ellipsis if there's a gap
if (left > 2) {
range.push('...');
}
// Add middle pages (avoiding duplicates with first/last)
for (let i = left; i <= right; i++) {
if (i !== 1 && i !== total) {
range.push(i);
}
}
// Add right ellipsis if there's a gap
if (right < total - 1) {
range.push('...');
}
// Always include last page (if different from first)
if (total > 1) {
range.push(total);
}
return range;
},
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
}
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++;
}
},
prevPage() {
if (this.currentPage > 1) {
this.currentPage--;
}
},
init() {
// Listen for Livewire updates to refresh logs with animation
this.$watch('$wire.firewallLogs', (newLogs) => {
if (newLogs && Array.isArray(newLogs)) {
this.isRefreshing = true;
// Small delay for animation effect
setTimeout(() => {
this.initialize(newLogs);
this.currentPage = 1;
this.isRefreshing = false;
}, 300);
}
});
}
}"
x-effect="if (currentPage > totalPages && totalPages > 0) currentPage = totalPages"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
class="space-y-4"
>
<!-- Filter Tabs -->
<div style="display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1rem;">
<!-- Action/Policy Filter (Multi-select) -->
<div class="firewall-filter-row">
<span class="firewall-filter-label">Policy:</span>
<button
type="button"
@click="clearActionFilters()"
:class="actionFilters.length === 0 ? 'active-all' : ''"
class="firewall-filter-btn">
<x-ri-list-check style="width: 14px; height: 14px;" />
All
<span class="firewall-filter-btn-count" x-text="actionCounts.all"></span>
</button>
<button
type="button"
@click="toggleActionFilter('drop')"
x-show="actionCounts.drop > 0"
:class="isActionActive('drop') ? 'active-drop' : ''"
class="firewall-filter-btn">
<x-ri-close-circle-line style="width: 14px; height: 14px;" />
DROP
<span class="firewall-filter-btn-count count-drop" x-text="actionCounts.drop"></span>
</button>
<button
type="button"
@click="toggleActionFilter('reject')"
x-show="actionCounts.reject > 0"
:class="isActionActive('reject') ? 'active-reject' : ''"
class="firewall-filter-btn">
<x-ri-forbid-line style="width: 14px; height: 14px;" />
REJECT
<span class="firewall-filter-btn-count count-reject" x-text="actionCounts.reject"></span>
</button>
<button
type="button"
@click="toggleActionFilter('accept')"
x-show="actionCounts.accept > 0"
:class="isActionActive('accept') ? 'active-accept' : ''"
class="firewall-filter-btn">
<x-ri-checkbox-circle-line style="width: 14px; height: 14px;" />
ACCEPT
<span class="firewall-filter-btn-count count-accept" x-text="actionCounts.accept"></span>
</button>
<button
type="button"
@click="toggleActionFilter('log')"
x-show="actionCounts.log > 0"
:class="isActionActive('log') ? 'active-log' : ''"
class="firewall-filter-btn">
<x-ri-file-list-line style="width: 14px; height: 14px;" />
LOG
<span class="firewall-filter-btn-count count-log" x-text="actionCounts.log"></span>
</button>
<button
type="button"
@click="toggleActionFilter('other')"
x-show="actionCounts.other > 0"
:class="isActionActive('other') ? 'active-action-other' : ''"
class="firewall-filter-btn">
Other
<span class="firewall-filter-btn-count" x-text="actionCounts.other"></span>
</button>
</div>
<!-- Protocol Filter (Multi-select) -->
<div class="firewall-filter-row">
<span class="firewall-filter-label">Protocol:</span>
<button
type="button"
@click="clearProtocolFilters()"
:class="protocolFilters.length === 0 ? 'active-proto-all' : ''"
class="firewall-filter-btn">
All
<span class="firewall-filter-btn-count" x-text="protocolCounts.all"></span>
</button>
<button
type="button"
@click="toggleProtocolFilter('tcp')"
x-show="protocolCounts.tcp > 0"
:class="isProtocolActive('tcp') ? 'active-tcp' : ''"
class="firewall-filter-btn">
TCP
<span class="firewall-filter-btn-count count-tcp" x-text="protocolCounts.tcp"></span>
</button>
<button
type="button"
@click="toggleProtocolFilter('udp')"
x-show="protocolCounts.udp > 0"
:class="isProtocolActive('udp') ? 'active-udp' : ''"
class="firewall-filter-btn">
UDP
<span class="firewall-filter-btn-count count-udp" x-text="protocolCounts.udp"></span>
</button>
<button
type="button"
@click="toggleProtocolFilter('icmp')"
x-show="protocolCounts.icmp > 0"
:class="isProtocolActive('icmp') ? 'active-icmp' : ''"
class="firewall-filter-btn">
ICMP
<span class="firewall-filter-btn-count count-icmp" x-text="protocolCounts.icmp"></span>
</button>
<button
type="button"
@click="toggleProtocolFilter('gre')"
x-show="protocolCounts.gre > 0"
:class="isProtocolActive('gre') ? 'active-gre' : ''"
class="firewall-filter-btn">
GRE
<span class="firewall-filter-btn-count count-gre" x-text="protocolCounts.gre"></span>
</button>
<button
type="button"
@click="toggleProtocolFilter('esp')"
x-show="protocolCounts.esp > 0"
:class="isProtocolActive('esp') ? 'active-esp' : ''"
class="firewall-filter-btn">
ESP
<span class="firewall-filter-btn-count count-esp" x-text="protocolCounts.esp"></span>
</button>
<button
type="button"
@click="toggleProtocolFilter('ah')"
x-show="protocolCounts.ah > 0"
:class="isProtocolActive('ah') ? 'active-ah' : ''"
class="firewall-filter-btn">
AH
<span class="firewall-filter-btn-count count-ah" x-text="protocolCounts.ah"></span>
</button>
<button
type="button"
@click="toggleProtocolFilter('sctp')"
x-show="protocolCounts.sctp > 0"
:class="isProtocolActive('sctp') ? 'active-sctp' : ''"
class="firewall-filter-btn">
SCTP
<span class="firewall-filter-btn-count count-sctp" x-text="protocolCounts.sctp"></span>
</button>
<button
type="button"
@click="toggleProtocolFilter('igmp')"
x-show="protocolCounts.igmp > 0"
:class="isProtocolActive('igmp') ? 'active-igmp' : ''"
class="firewall-filter-btn">
IGMP
<span class="firewall-filter-btn-count count-igmp" x-text="protocolCounts.igmp"></span>
</button>
<button
type="button"
@click="toggleProtocolFilter('other')"
x-show="protocolCounts.other > 0"
:class="isProtocolActive('other') ? 'active-proto-other' : ''"
class="firewall-filter-btn">
Other
<span class="firewall-filter-btn-count" x-text="protocolCounts.other"></span>
</button>
</div>
<!-- Search and Stats Row -->
<div class="firewall-filter-divider" style="display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.75rem;">
<div style="position: relative; width: 100%; max-width: 18rem;">
<x-ri-search-line style="width: 16px; height: 16px; position: absolute; left: 12px; top: 50%; transform: translateY(-50%); opacity: 0.4;" />
<input
type="text"
x-model="searchTerm"
@input="currentPage = 1"
placeholder="Search logs..."
class="firewall-log-search-input"
style="width: 100%; padding: 0.5rem 1rem; border: 1px solid rgba(var(--color-neutral), 1); border-radius: 0.5rem; background-color: rgba(var(--color-background), 1);"
/>
</div>
<div style="display: flex; align-items: center; gap: 1rem; font-size: 0.875rem;">
<div style="padding: 0.375rem 0.75rem; background-color: rgba(var(--color-background-secondary), 1); border: 1px solid rgba(var(--color-neutral), 0.3); border-radius: 0.5rem;">
<span style="opacity: 0.7;">Showing </span>
<span style="font-weight: 600; color: rgba(var(--color-primary), 1);" x-text="paginatedLogs.length"></span>
<span style="opacity: 0.7;"> of </span>
<span style="font-weight: 600;" x-text="filteredLogs.length"></span>
</div>
<div style="padding: 0.375rem 0.75rem; background-color: rgba(var(--color-background-secondary), 1); border: 1px solid rgba(var(--color-neutral), 0.3); border-radius: 0.5rem;">
<span style="opacity: 0.7;">Page </span>
<span style="font-weight: 600; color: rgba(var(--color-primary), 1);" x-text="currentPage"></span>
<span style="opacity: 0.7;"> / </span>
<span style="font-weight: 600;" x-text="totalPages || 1"></span>
</div>
</div>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-neutral">
<th class="text-left py-3 px-4 font-semibold text-sm">
<button type="button" class="flex items-center gap-1 hover:text-primary transition" @click="sort('timestamp_iso')">
Timestamp
<span x-show="sortField === 'timestamp_iso'" x-text="sortDirection === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</button>
</th>
<th class="text-left py-3 px-4 font-semibold text-sm">
<button type="button" class="flex items-center gap-1 hover:text-primary transition" @click="sort('level_numeric')">
Level
<span x-show="sortField === 'level_numeric'" x-text="sortDirection === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</button>
</th>
<th class="text-left py-3 px-4 font-semibold text-sm">
<button type="button" class="flex items-center gap-1 hover:text-primary transition" @click="sort('action')">
Action
<span x-show="sortField === 'action'" x-text="sortDirection === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</button>
</th>
<th class="text-left py-3 px-4 font-semibold text-sm">
<button type="button" class="flex items-center gap-1 hover:text-primary transition" @click="sort('protocol')">
Protocol
<span x-show="sortField === 'protocol'" x-text="sortDirection === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</button>
</th>
<th class="text-left py-3 px-4 font-semibold text-sm">
<button type="button" class="flex items-center gap-1 hover:text-primary transition" @click="sort('source')">
Source
<span x-show="sortField === 'source'" x-text="sortDirection === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</button>
</th>
<th class="text-left py-3 px-4 font-semibold text-sm">
<button type="button" class="flex items-center gap-1 hover:text-primary transition" @click="sort('source_port')">
Source Port
<span x-show="sortField === 'source_port'" x-text="sortDirection === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</button>
</th>
<th class="text-left py-3 px-4 font-semibold text-sm">
<button type="button" class="flex items-center gap-1 hover:text-primary transition" @click="sort('destination')">
Destination
<span x-show="sortField === 'destination'" x-text="sortDirection === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</button>
</th>
<th class="text-left py-3 px-4 font-semibold text-sm">
<button type="button" class="flex items-center gap-1 hover:text-primary transition" @click="sort('destination_port')">
Dest Port
<span x-show="sortField === 'destination_port'" x-text="sortDirection === 'asc' ? '▲' : '▼'" class="text-[10px]"></span>
</button>
</th>
<th class="text-left py-3 px-4 font-semibold text-sm">
Actions
</th>
</tr>
</thead>
<tbody>
<template x-for="(log, index) in paginatedLogs" :key="log.index || index">
<tr
class="border-b border-neutral/20 hover:bg-background-secondary/50 transition-all duration-200"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0">
<!-- Timestamp -->
<td class="py-3 px-4 text-sm" x-text="log.timestamp_display || '-' "></td>
<!-- Level -->
<td class="py-3 px-4 text-sm">
<span class="px-2 py-1 rounded text-xs font-semibold uppercase" :class="log.level_badge_class" x-text="log.level_label || '-' "></span>
</td>
<!-- Action -->
<td class="py-3 px-4 text-sm">
<span class="px-2 py-1 rounded text-xs font-semibold" :class="log.action_badge_class" x-text="log.action || '-' "></span>
</td>
<!-- Protocol with info modal -->
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-1">
<span x-text="log.protocol || '-' "></span>
<template x-if="log.protocol && log.protocol !== '-'">
<div class="relative" x-data="{ open: false }" x-cloak>
<button type="button" @click="open = true" class="text-base/50 hover:text-primary transition-colors">
<x-ri-information-line class="size-3" />
</button>
<!-- Modal Backdrop -->
<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"
style="display: none;">
</div>
<!-- Modal -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.away="open = false"
@keydown.escape.window="open = false"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="display: none;">
<div class="relative w-full max-w-2xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6 max-h-[80vh] overflow-y-auto">
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
<x-ri-close-line class="size-5" />
</button>
<div class="pr-8">
<div class="flex items-center gap-2 mb-4">
<x-ri-information-line class="size-6 text-primary" />
<h3 class="text-lg font-semibold" x-text="`${log.protocol} Protocol`"></h3>
</div>
<div class="text-sm text-base/90" x-html="window.getProtocolDescription(log.protocol)"></div>
</div>
</div>
</div>
</div>
</template>
</div>
</td>
<!-- Source IP with location lookup -->
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-1">
<span class="font-mono text-xs" x-text="log.source || '-' "></span>
<template x-if="log.source && log.source !== '-' && window.isPublicIP(log.source)">
<div class="relative" x-data="{ open: false, loading: false, ipData: null }" x-cloak>
<button type="button" @click="
if (!ipData && !loading) {
loading = true;
open = true;
window.fetchIPLocation(log.source).then(data => {
ipData = data;
loading = false;
}).catch(err => {
ipData = { status: 'error', message: 'Failed to load location data' };
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>
<!-- 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 overflow-x-hidden">
<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 break-words">
<div class="flex items-center gap-2 mb-4">
<x-ri-map-pin-line class="size-5 text-primary flex-shrink-0" />
<h3 class="text-lg font-semibold break-words">IP Address: <span class="font-mono break-all" x-text="log.source"></span></h3>
</div>
<div x-show="loading" class="text-center py-8">
<svg fill="currentColor" stroke="currentColor" viewBox="0 0 128 128" class="size-6 mx-auto mb-2">
<g>
<path stroke-width="5" d="M31.6 8.98a4.02 4.02 0 0 0 1.7 5.3l6.12 3.22a52.56 52.56 0 1 0 63.13 10.73L96 33.97a43.88 43.88 0 1 1-53-8.5l-3.65 7.4a4.02 4.02 0 0 0 1.72 5.3 3.73 3.73 0 0 0 5.13-1.67l7.66-15.63a4.18 4.18 0 0 0-1.75-5.4L36.74 7.32a3.73 3.73 0 0 0-5.13 1.66z" fill="currentColor" stroke="currentColor"/>
<animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="1000ms" repeatCount="indefinite"></animateTransform>
</g>
</svg>
<p class="text-sm text-base/60">Loading location data...</p>
</div>
<div x-show="ipData && !loading">
<template x-if="ipData && (ipData.status === 'success')">
<div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">Country:</span> <span class="font-semibold text-sm break-words" x-text="ipData.country"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Region:</span> <span class="font-semibold text-sm break-words" x-text="ipData.regionName + ' (' + ipData.region + ')'"></span></div>
<div class="break-words"><span class="text-xs text-base/60">City:</span> <span class="font-semibold text-sm break-words" x-text="ipData.city"></span></div>
<div class="break-words"><span class="text-xs text-base/60">ZIP Code:</span> <span class="font-semibold text-sm break-words" x-text="ipData.zip"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Timezone:</span> <span class="font-semibold text-sm break-words" x-text="ipData.timezone"></span></div>
</div>
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">ISP:</span> <span class="font-semibold text-sm break-words" x-text="ipData.isp"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Organization:</span> <span class="font-semibold text-sm break-words" x-text="ipData.org"></span></div>
<div class="break-words"><span class="text-xs text-base/60">AS:</span> <span class="font-semibold text-sm break-all" x-text="ipData.as"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Coordinates:</span> <span class="font-semibold text-sm font-mono break-all" x-text="ipData.lat + ', ' + ipData.lon"></span></div>
</div>
</div>
<div x-show="ipData.lat && ipData.lon" class="mt-4 w-full" style="max-width: 100%; box-sizing: border-box;">
<p class="text-xs text-base/60 mb-2">Location Map:</p>
<div class="w-full rounded-lg border border-neutral/20 overflow-hidden bg-background/50" style="position: relative; width: 100%; max-width: 100%; height: 256px; min-height: 256px; box-sizing: border-box;">
<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; display: block; margin: 0; padding: 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>
</template>
</div>
</td>
<!-- Source Port with description -->
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-1">
<span x-text="log.source_port || '-' "></span>
<template x-if="log.source_port && log.source_port !== '-'">
<div class="relative" x-data="{ open: false }" x-cloak>
<button type="button" @click="open = true" class="text-base/50 hover:text-primary transition-colors">
<x-ri-information-line class="size-3" />
</button>
<!-- Modal Backdrop -->
<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"
style="display: none;">
</div>
<!-- Modal -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.away="open = false"
@keydown.escape.window="open = false"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="display: none;">
<div class="relative w-full max-w-md bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
<x-ri-close-line class="size-5" />
</button>
<div class="pr-8">
<div class="flex items-center gap-2 mb-4">
<x-ri-information-line class="size-6 text-primary" />
<h3 class="text-lg font-semibold">Port <span x-text="log.source_port"></span></h3>
</div>
<p class="text-sm text-base/90" x-text="window.getPortDescription(parseInt(log.source_port) || 0)"></p>
</div>
</div>
</div>
</div>
</template>
</div>
</td>
<!-- Destination IP with location lookup -->
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-1">
<span class="font-mono text-xs" x-text="log.destination || '-' "></span>
<template x-if="log.destination && log.destination !== '-' && window.isPublicIP(log.destination)">
<div class="relative" x-data="{ open: false, loading: false, ipData: null }" x-cloak>
<button type="button" @click="
if (!ipData && !loading) {
loading = true;
open = true;
window.fetchIPLocation(log.destination).then(data => {
ipData = data;
loading = false;
}).catch(err => {
ipData = { status: 'error', message: 'Failed to load location data' };
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>
<!-- 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 overflow-x-hidden">
<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 break-words">
<div class="flex items-center gap-2 mb-4">
<x-ri-map-pin-line class="size-5 text-primary flex-shrink-0" />
<h3 class="text-lg font-semibold break-words">IP Address: <span class="font-mono break-all" x-text="log.destination"></span></h3>
</div>
<div x-show="loading" class="text-center py-8">
<svg fill="currentColor" stroke="currentColor" viewBox="0 0 128 128" class="size-6 mx-auto mb-2">
<g>
<path stroke-width="5" d="M31.6 8.98a4.02 4.02 0 0 0 1.7 5.3l6.12 3.22a52.56 52.56 0 1 0 63.13 10.73L96 33.97a43.88 43.88 0 1 1-53-8.5l-3.65 7.4a4.02 4.02 0 0 0 1.72 5.3 3.73 3.73 0 0 0 5.13-1.67l7.66-15.63a4.18 4.18 0 0 0-1.75-5.4L36.74 7.32a3.73 3.73 0 0 0-5.13 1.66z" fill="currentColor" stroke="currentColor"/>
<animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="1000ms" repeatCount="indefinite"></animateTransform>
</g>
</svg>
<p class="text-sm text-base/60">Loading location data...</p>
</div>
<div x-show="ipData && !loading">
<template x-if="ipData && (ipData.status === 'success')">
<div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">Country:</span> <span class="font-semibold text-sm break-words" x-text="ipData.country"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Region:</span> <span class="font-semibold text-sm break-words" x-text="ipData.regionName + ' (' + ipData.region + ')'"></span></div>
<div class="break-words"><span class="text-xs text-base/60">City:</span> <span class="font-semibold text-sm break-words" x-text="ipData.city"></span></div>
<div class="break-words"><span class="text-xs text-base/60">ZIP Code:</span> <span class="font-semibold text-sm break-words" x-text="ipData.zip"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Timezone:</span> <span class="font-semibold text-sm break-words" x-text="ipData.timezone"></span></div>
</div>
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">ISP:</span> <span class="font-semibold text-sm break-words" x-text="ipData.isp"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Organization:</span> <span class="font-semibold text-sm break-words" x-text="ipData.org"></span></div>
<div class="break-words"><span class="text-xs text-base/60">AS:</span> <span class="font-semibold text-sm break-all" x-text="ipData.as"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Coordinates:</span> <span class="font-semibold text-sm font-mono break-all" x-text="ipData.lat + ', ' + ipData.lon"></span></div>
</div>
</div>
<div x-show="ipData.lat && ipData.lon" class="mt-4 w-full" style="max-width: 100%; box-sizing: border-box;">
<p class="text-xs text-base/60 mb-2">Location Map:</p>
<div class="w-full rounded-lg border border-neutral/20 overflow-hidden bg-background/50" style="position: relative; width: 100%; max-width: 100%; height: 256px; min-height: 256px; box-sizing: border-box;">
<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; display: block; margin: 0; padding: 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>
</template>
</div>
</td>
<!-- Destination Port with description -->
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-1">
<span x-text="log.destination_port || '-' "></span>
<template x-if="log.destination_port && log.destination_port !== '-'">
<div class="relative" x-data="{ open: false }" x-cloak>
<button type="button" @click="open = true" class="text-base/50 hover:text-primary transition-colors">
<x-ri-information-line class="size-3" />
</button>
<!-- Modal Backdrop -->
<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"
style="display: none;">
</div>
<!-- Modal -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.away="open = false"
@keydown.escape.window="open = false"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="display: none;">
<div class="relative w-full max-w-md bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
<x-ri-close-line class="size-5" />
</button>
<div class="pr-8">
<div class="flex items-center gap-2 mb-4">
<x-ri-information-line class="size-6 text-primary" />
<h3 class="text-lg font-semibold">Port <span x-text="log.destination_port"></span></h3>
</div>
<p class="text-sm text-base/90" x-text="window.getPortDescription(parseInt(log.destination_port) || 0)"></p>
</div>
</div>
</div>
</div>
</template>
</div>
</td>
<!-- Actions -->
<td class="py-3 px-4 text-sm">
<div x-data="{ showStateModal: false }">
<button type="button"
@click="showStateModal = true"
class="firewall-state-btn"
title="View connection state details">
<x-ri-information-line style="width: 14px; height: 14px;" />
<span>Details</span>
</button>
<!-- State Details Modal -->
<div x-show="showStateModal"
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="showStateModal = false"
@keydown.escape.window="showStateModal = false"
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4"
style="display: none;"
x-data="{
activeTab: 'overview',
parseRawLog(raw) {
if (!raw) return {};
const fields = {};
// Parse common firewall log fields
const patterns = {
LEN: /LEN=(\d+)/,
TOS: /TOS=(0x[0-9A-Fa-f]+)/,
PREC: /PREC=(0x[0-9A-Fa-f]+)/,
TTL: /TTL=(\d+)/,
ID: /ID=(\d+)/,
DF: /\bDF\b/,
MF: /\bMF\b/,
FRAG: /FRAG=(\d+)/,
WINDOW: /WINDOW=(\d+)/,
RES: /RES=(0x[0-9A-Fa-f]+)/,
SYN: /\bSYN\b/,
ACK: /\bACK\b/,
FIN: /\bFIN\b/,
RST: /\bRST\b/,
PSH: /\bPSH\b/,
URG: /\bURG\b/,
URGP: /URGP=(\d+)/,
SEQ: /SEQ=(\d+)/,
TYPE: /TYPE=(\d+)/,
CODE: /CODE=(\d+)/
};
for (const [key, pattern] of Object.entries(patterns)) {
const match = raw.match(pattern);
if (match) {
fields[key] = match[1] || 'Yes';
}
}
return fields;
}
}">
<div @click.stop class="relative w-full max-w-3xl bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl max-h-[90vh] overflow-hidden flex flex-col">
<button @click="showStateModal = 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>
<!-- Header with gradient -->
<div class="p-6 pb-4 bg-gradient-to-br from-background via-background-secondary to-background border-b border-neutral/20">
<div class="flex items-center gap-4">
<div class="p-3 rounded-xl shadow-lg" :class="log.action === 'DROP' || log.action === 'REJECT' ? 'bg-danger/20 ring-2 ring-danger/30' : 'bg-success/20 ring-2 ring-success/30'">
<template x-if="log.action === 'DROP' || log.action === 'REJECT'">
<x-ri-shield-cross-line class="size-7 text-danger" />
</template>
<template x-if="log.action === 'ACCEPT'">
<x-ri-shield-check-line class="size-7 text-success" />
</template>
<template x-if="log.action !== 'DROP' && log.action !== 'REJECT' && log.action !== 'ACCEPT'">
<x-ri-shield-line class="size-7 text-base/70" />
</template>
</div>
<div class="flex-1">
<div class="flex items-center gap-3 mb-1">
<h3 class="text-xl font-bold">Firewall Event Details</h3>
<span class="px-3 py-1 rounded-lg text-xs font-bold" :class="log.action_badge_class" x-text="log.action || 'UNKNOWN'"></span>
</div>
<div class="flex items-center gap-2 text-sm text-base/60">
<x-ri-time-line class="size-4" />
<span x-text="log.timestamp_display || 'Unknown time'"></span>
</div>
</div>
</div>
</div>
<!-- Tab Navigation -->
<div class="flex border-b border-neutral/20 bg-background px-6">
<button @click="activeTab = 'overview'"
class="px-4 py-3 text-sm font-semibold transition-all relative"
:class="activeTab === 'overview' ? 'text-primary' : 'text-base/60 hover:text-base'">
<div class="flex items-center gap-2">
<x-ri-dashboard-line class="size-4" />
<span>Overview</span>
</div>
<div x-show="activeTab === 'overview'"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
</button>
<button @click="activeTab = 'packet'"
class="px-4 py-3 text-sm font-semibold transition-all relative"
:class="activeTab === 'packet' ? 'text-primary' : 'text-base/60 hover:text-base'">
<div class="flex items-center gap-2">
<x-ri-file-code-line class="size-4" />
<span>Packet Details</span>
</div>
<div x-show="activeTab === 'packet'"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
</button>
<button @click="activeTab = 'raw'"
class="px-4 py-3 text-sm font-semibold transition-all relative"
:class="activeTab === 'raw' ? 'text-primary' : 'text-base/60 hover:text-base'">
<div class="flex items-center gap-2">
<x-ri-code-line class="size-4" />
<span>Raw Data</span>
</div>
<div x-show="activeTab === 'raw'"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"></div>
</button>
</div>
<!-- Tab Content with scroll -->
<div class="flex-1 overflow-y-auto p-6">
<!-- Overview Tab -->
<div x-show="activeTab === 'overview'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
<!-- Connection Flow -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-4">
<x-ri-route-line class="size-5 text-primary" />
<h4 class="text-sm font-bold text-base/80 uppercase tracking-wide">Connection Flow</h4>
</div>
<div class="flex items-center gap-3">
<!-- Source -->
<div class="flex-1 p-4 bg-gradient-to-br from-info/10 to-info/5 rounded-xl border border-info/30">
<div class="flex items-center gap-2 mb-3">
<div class="p-1.5 bg-info/20 rounded-lg">
<x-ri-arrow-right-up-line class="size-4 text-info" />
</div>
<span class="text-xs font-bold text-info uppercase tracking-wide">Source</span>
</div>
<div class="space-y-2">
<div>
<div class="text-xs text-base/50 mb-0.5">IP Address</div>
<div class="flex items-center gap-1">
<div class="font-mono text-sm font-semibold break-all" x-text="log.source || '-'"></div>
<template x-if="log.source && log.source !== '-' && window.isPublicIP(log.source)">
<div class="relative" x-data="{ open: false, loading: false, ipData: null }" x-cloak>
<button type="button" @click="
if (!ipData && !loading) {
loading = true;
open = true;
window.fetchIPLocation(log.source).then(data => {
ipData = data;
loading = false;
}).catch(err => {
ipData = { status: 'error', message: 'Failed to load location data' };
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>
<!-- 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 overflow-x-hidden">
<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 break-words">
<div class="flex items-center gap-2 mb-4">
<x-ri-map-pin-line class="size-5 text-primary flex-shrink-0" />
<h3 class="text-lg font-semibold break-words">IP Address: <span class="font-mono break-all" x-text="log.source"></span></h3>
</div>
<div x-show="loading" class="text-center py-8">
<svg fill="currentColor" stroke="currentColor" viewBox="0 0 128 128" class="size-6 mx-auto mb-2">
<g>
<path stroke-width="5" d="M31.6 8.98a4.02 4.02 0 0 0 1.7 5.3l6.12 3.22a52.56 52.56 0 1 0 63.13 10.73L96 33.97a43.88 43.88 0 1 1-53-8.5l-3.65 7.4a4.02 4.02 0 0 0 1.72 5.3 3.73 3.73 0 0 0 5.13-1.67l7.66-15.63a4.18 4.18 0 0 0-1.75-5.4L36.74 7.32a3.73 3.73 0 0 0-5.13 1.66z" fill="currentColor" stroke="currentColor"/>
<animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="1000ms" repeatCount="indefinite"></animateTransform>
</g>
</svg>
<p class="text-sm text-base/60">Loading location data...</p>
</div>
<div x-show="ipData && !loading">
<template x-if="ipData && (ipData.status === 'success')">
<div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">Country:</span> <span class="font-semibold text-sm break-words" x-text="ipData.country"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Region:</span> <span class="font-semibold text-sm break-words" x-text="ipData.regionName + ' (' + ipData.region + ')'"></span></div>
<div class="break-words"><span class="text-xs text-base/60">City:</span> <span class="font-semibold text-sm break-words" x-text="ipData.city"></span></div>
<div class="break-words"><span class="text-xs text-base/60">ZIP Code:</span> <span class="font-semibold text-sm break-words" x-text="ipData.zip"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Timezone:</span> <span class="font-semibold text-sm break-words" x-text="ipData.timezone"></span></div>
</div>
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">ISP:</span> <span class="font-semibold text-sm break-words" x-text="ipData.isp"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Organization:</span> <span class="font-semibold text-sm break-words" x-text="ipData.org"></span></div>
<div class="break-words"><span class="text-xs text-base/60">AS:</span> <span class="font-semibold text-sm break-all" x-text="ipData.as"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Coordinates:</span> <span class="font-semibold text-sm font-mono break-all" x-text="ipData.lat + ', ' + ipData.lon"></span></div>
</div>
</div>
<div x-show="ipData.lat && ipData.lon" class="mt-4 w-full" style="max-width: 100%; box-sizing: border-box;">
<p class="text-xs text-base/60 mb-2">Location Map:</p>
<div class="w-full rounded-lg border border-neutral/20 overflow-hidden bg-background/50" style="position: relative; width: 100%; max-width: 100%; height: 256px; min-height: 256px; box-sizing: border-box;">
<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; display: block; margin: 0; padding: 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>
</template>
</div>
</div>
<div>
<div class="text-xs text-base/50 mb-0.5">Port</div>
<div class="font-mono text-sm font-semibold" x-text="log.source_port || '-'"></div>
</div>
</div>
</div>
<!-- Arrow -->
<div class="flex flex-col items-center gap-1">
<x-ri-arrow-right-line class="size-6 text-base/40" />
<div class="px-2 py-0.5 bg-primary/10 rounded text-xs font-bold text-primary" x-text="log.protocol || 'N/A'"></div>
</div>
<!-- Destination -->
<div class="flex-1 p-4 bg-gradient-to-br from-warning/10 to-warning/5 rounded-xl border border-warning/30">
<div class="flex items-center gap-2 mb-3">
<div class="p-1.5 bg-warning/20 rounded-lg">
<x-ri-arrow-right-down-line class="size-4 text-warning" />
</div>
<span class="text-xs font-bold text-warning uppercase tracking-wide">Destination</span>
</div>
<div class="space-y-2">
<div>
<div class="text-xs text-base/50 mb-0.5">IP Address</div>
<div class="flex items-center gap-1">
<div class="font-mono text-sm font-semibold break-all" x-text="log.destination || '-'"></div>
<template x-if="log.destination && log.destination !== '-' && window.isPublicIP(log.destination)">
<div class="relative" x-data="{ open: false, loading: false, ipData: null }" x-cloak>
<button type="button" @click="
if (!ipData && !loading) {
loading = true;
open = true;
window.fetchIPLocation(log.destination).then(data => {
ipData = data;
loading = false;
}).catch(err => {
ipData = { status: 'error', message: 'Failed to load location data' };
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>
<!-- 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 overflow-x-hidden">
<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 break-words">
<div class="flex items-center gap-2 mb-4">
<x-ri-map-pin-line class="size-5 text-primary flex-shrink-0" />
<h3 class="text-lg font-semibold break-words">IP Address: <span class="font-mono break-all" x-text="log.destination"></span></h3>
</div>
<div x-show="loading" class="text-center py-8">
<svg fill="currentColor" stroke="currentColor" viewBox="0 0 128 128" class="size-6 mx-auto mb-2">
<g>
<path stroke-width="5" d="M31.6 8.98a4.02 4.02 0 0 0 1.7 5.3l6.12 3.22a52.56 52.56 0 1 0 63.13 10.73L96 33.97a43.88 43.88 0 1 1-53-8.5l-3.65 7.4a4.02 4.02 0 0 0 1.72 5.3 3.73 3.73 0 0 0 5.13-1.67l7.66-15.63a4.18 4.18 0 0 0-1.75-5.4L36.74 7.32a3.73 3.73 0 0 0-5.13 1.66z" fill="currentColor" stroke="currentColor"/>
<animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="1000ms" repeatCount="indefinite"></animateTransform>
</g>
</svg>
<p class="text-sm text-base/60">Loading location data...</p>
</div>
<div x-show="ipData && !loading">
<template x-if="ipData && (ipData.status === 'success')">
<div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">Country:</span> <span class="font-semibold text-sm break-words" x-text="ipData.country"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Region:</span> <span class="font-semibold text-sm break-words" x-text="ipData.regionName + ' (' + ipData.region + ')'"></span></div>
<div class="break-words"><span class="text-xs text-base/60">City:</span> <span class="font-semibold text-sm break-words" x-text="ipData.city"></span></div>
<div class="break-words"><span class="text-xs text-base/60">ZIP Code:</span> <span class="font-semibold text-sm break-words" x-text="ipData.zip"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Timezone:</span> <span class="font-semibold text-sm break-words" x-text="ipData.timezone"></span></div>
</div>
<div class="space-y-2 break-words">
<div class="break-words"><span class="text-xs text-base/60">ISP:</span> <span class="font-semibold text-sm break-words" x-text="ipData.isp"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Organization:</span> <span class="font-semibold text-sm break-words" x-text="ipData.org"></span></div>
<div class="break-words"><span class="text-xs text-base/60">AS:</span> <span class="font-semibold text-sm break-all" x-text="ipData.as"></span></div>
<div class="break-words"><span class="text-xs text-base/60">Coordinates:</span> <span class="font-semibold text-sm font-mono break-all" x-text="ipData.lat + ', ' + ipData.lon"></span></div>
</div>
</div>
<div x-show="ipData.lat && ipData.lon" class="mt-4 w-full" style="max-width: 100%; box-sizing: border-box;">
<p class="text-xs text-base/60 mb-2">Location Map:</p>
<div class="w-full rounded-lg border border-neutral/20 overflow-hidden bg-background/50" style="position: relative; width: 100%; max-width: 100%; height: 256px; min-height: 256px; box-sizing: border-box;">
<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; display: block; margin: 0; padding: 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>
</template>
</div>
</div>
<div>
<div class="text-xs text-base/50 mb-0.5">Port</div>
<div class="font-mono text-sm font-semibold" x-text="log.destination_port || '-'"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Connection Metadata -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="text-xs text-base/50 mb-1">Protocol</div>
<div class="text-base font-bold" x-text="log.protocol || '-'"></div>
</div>
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="text-xs text-base/50 mb-1">Level</div>
<div>
<span class="px-2 py-1 rounded-lg text-xs font-bold uppercase" :class="log.level_badge_class" x-text="log.level_label || '-'"></span>
</div>
</div>
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="text-xs text-base/50 mb-1">Interface</div>
<div class="font-mono text-sm font-semibold" x-text="log.interface || '-'"></div>
</div>
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="text-xs text-base/50 mb-1">MAC Address</div>
<div class="font-mono text-xs font-semibold break-all" x-text="log.mac || '-'"></div>
</div>
</div>
</div>
<!-- Packet Details Tab -->
<div x-show="activeTab === 'packet'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0"
x-data="{ parsedFields: {} }"
x-init="parsedFields = parseRawLog(log.raw)">
<!-- IP Header Information -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-4">
<x-ri-global-line class="size-5 text-primary" />
<h4 class="text-sm font-bold text-base/80 uppercase tracking-wide">IP Header</h4>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<template x-if="parsedFields.LEN">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-2">
<x-ri-ruler-line class="size-4 text-base/50" />
<div class="text-xs text-base/50">Packet Length</div>
</div>
<div class="font-mono text-base font-bold" x-text="parsedFields.LEN + ' bytes'"></div>
</div>
</template>
<template x-if="parsedFields.TTL">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-2">
<x-ri-time-line class="size-4 text-base/50" />
<div class="text-xs text-base/50">Time to Live (TTL)</div>
</div>
<div class="font-mono text-base font-bold" x-text="parsedFields.TTL"></div>
</div>
</template>
<template x-if="parsedFields.ID">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-2">
<x-ri-hashtag class="size-4 text-base/50" />
<div class="text-xs text-base/50">Packet ID</div>
</div>
<div class="font-mono text-base font-bold" x-text="parsedFields.ID"></div>
</div>
</template>
<template x-if="parsedFields.TOS">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-2">
<x-ri-service-line class="size-4 text-base/50" />
<div class="text-xs text-base/50">Type of Service (TOS)</div>
</div>
<div class="font-mono text-base font-bold" x-text="parsedFields.TOS"></div>
</div>
</template>
<template x-if="parsedFields.PREC">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-2">
<x-ri-medal-line class="size-4 text-base/50" />
<div class="text-xs text-base/50">Precedence</div>
</div>
<div class="font-mono text-base font-bold" x-text="parsedFields.PREC"></div>
</div>
</template>
</div>
<!-- Fragmentation Flags -->
<template x-if="parsedFields.DF || parsedFields.MF || parsedFields.FRAG">
<div class="mt-4 p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-3">
<x-ri-file-damage-line class="size-4 text-warning" />
<div class="text-xs font-bold text-base/70 uppercase tracking-wide">Fragmentation</div>
</div>
<div class="flex gap-2 flex-wrap">
<template x-if="parsedFields.DF">
<span class="px-3 py-1.5 bg-info/20 text-info rounded-lg text-xs font-bold">Don't Fragment (DF)</span>
</template>
<template x-if="parsedFields.MF">
<span class="px-3 py-1.5 bg-warning/20 text-warning rounded-lg text-xs font-bold">More Fragments (MF)</span>
</template>
<template x-if="parsedFields.FRAG">
<span class="px-3 py-1.5 bg-base/20 text-base rounded-lg text-xs font-bold">Fragment Offset: <span x-text="parsedFields.FRAG"></span></span>
</template>
</div>
</div>
</template>
</div>
<!-- TCP Specific Information -->
<template x-if="log.protocol && log.protocol.toUpperCase() === 'TCP'">
<div class="mb-6">
<div class="flex items-center gap-2 mb-4">
<x-ri-flashlight-line class="size-5 text-success" />
<h4 class="text-sm font-bold text-base/80 uppercase tracking-wide">TCP Information</h4>
</div>
<!-- TCP Flags -->
<template x-if="parsedFields.SYN || parsedFields.ACK || parsedFields.FIN || parsedFields.RST || parsedFields.PSH || parsedFields.URG">
<div class="mb-4 p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-3">
<x-ri-flag-line class="size-4 text-primary" />
<div class="text-xs font-bold text-base/70 uppercase tracking-wide">TCP Flags</div>
</div>
<div class="flex gap-2 flex-wrap">
<template x-if="parsedFields.SYN">
<span class="px-3 py-1.5 bg-info/20 text-info rounded-lg text-xs font-bold flex items-center gap-1">
<x-ri-play-circle-line class="size-3" />
SYN
</span>
</template>
<template x-if="parsedFields.ACK">
<span class="px-3 py-1.5 bg-success/20 text-success rounded-lg text-xs font-bold flex items-center gap-1">
<x-ri-checkbox-circle-line class="size-3" />
ACK
</span>
</template>
<template x-if="parsedFields.FIN">
<span class="px-3 py-1.5 bg-warning/20 text-warning rounded-lg text-xs font-bold flex items-center gap-1">
<x-ri-stop-circle-line class="size-3" />
FIN
</span>
</template>
<template x-if="parsedFields.RST">
<span class="px-3 py-1.5 bg-danger/20 text-danger rounded-lg text-xs font-bold flex items-center gap-1">
<x-ri-close-circle-line class="size-3" />
RST
</span>
</template>
<template x-if="parsedFields.PSH">
<span class="px-3 py-1.5 bg-primary/20 text-primary rounded-lg text-xs font-bold flex items-center gap-1">
<x-ri-send-plane-line class="size-3" />
PSH
</span>
</template>
<template x-if="parsedFields.URG">
<span class="px-3 py-1.5 bg-danger/20 text-danger rounded-lg text-xs font-bold flex items-center gap-1">
<x-ri-alert-line class="size-3" />
URG
</span>
</template>
</div>
</div>
</template>
<!-- TCP Details -->
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<template x-if="parsedFields.WINDOW">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-2">
<x-ri-window-line class="size-4 text-base/50" />
<div class="text-xs text-base/50">Window Size</div>
</div>
<div class="font-mono text-base font-bold" x-text="parsedFields.WINDOW + ' bytes'"></div>
</div>
</template>
<template x-if="parsedFields.SEQ">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-2">
<x-ri-sort-number-asc class="size-4 text-base/50" />
<div class="text-xs text-base/50">Sequence Number</div>
</div>
<div class="font-mono text-sm font-bold break-all" x-text="parsedFields.SEQ"></div>
</div>
</template>
<template x-if="parsedFields.URGP">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-2">
<x-ri-alert-line class="size-4 text-base/50" />
<div class="text-xs text-base/50">Urgent Pointer</div>
</div>
<div class="font-mono text-base font-bold" x-text="parsedFields.URGP"></div>
</div>
</template>
<template x-if="parsedFields.RES">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-2">
<x-ri-reserved-line class="size-4 text-base/50" />
<div class="text-xs text-base/50">Reserved</div>
</div>
<div class="font-mono text-base font-bold" x-text="parsedFields.RES"></div>
</div>
</template>
</div>
</div>
</template>
<!-- ICMP Specific Information -->
<template x-if="log.protocol && log.protocol.toUpperCase() === 'ICMP'">
<div>
<div class="flex items-center gap-2 mb-4">
<x-ri-pulse-line class="size-5 text-info" />
<h4 class="text-sm font-bold text-base/80 uppercase tracking-wide">ICMP Information</h4>
</div>
<div class="grid grid-cols-2 gap-4">
<template x-if="parsedFields.TYPE">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-2">
<x-ri-file-list-line class="size-4 text-base/50" />
<div class="text-xs text-base/50">ICMP Type</div>
</div>
<div class="font-mono text-base font-bold" x-text="parsedFields.TYPE"></div>
</div>
</template>
<template x-if="parsedFields.CODE">
<div class="p-4 bg-background rounded-xl border border-neutral/20">
<div class="flex items-center gap-2 mb-2">
<x-ri-code-s-slash-line class="size-4 text-base/50" />
<div class="text-xs text-base/50">ICMP Code</div>
</div>
<div class="font-mono text-base font-bold" x-text="parsedFields.CODE"></div>
</div>
</template>
</div>
</div>
</template>
<!-- No additional details message -->
<template x-if="Object.keys(parsedFields).length === 0">
<div class="text-center py-12">
<x-ri-file-info-line class="size-12 mx-auto mb-3 text-base/30" />
<p class="text-base/60 text-sm">No additional packet details available</p>
<p class="text-base/40 text-xs mt-1">Switch to Raw Data tab to view complete log entry</p>
</div>
</template>
</div>
<!-- Raw Data Tab -->
<div x-show="activeTab === 'raw'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
<template x-if="log.raw">
<div>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<x-ri-code-box-line class="size-5 text-primary" />
<h4 class="text-sm font-bold text-base/80 uppercase tracking-wide">Complete Log Entry</h4>
</div>
<button @click="navigator.clipboard.writeText(log.raw).then(() => alert('Copied to clipboard!'))"
class="px-3 py-1.5 text-xs font-semibold bg-primary/10 text-primary border border-primary/30 rounded-lg hover:bg-primary/20 transition-colors inline-flex items-center gap-2">
<x-ri-file-copy-line class="size-3" />
Copy
</button>
</div>
<div class="bg-background rounded-xl border border-neutral/20 p-4">
<pre class="text-xs font-mono text-base/80 whitespace-pre-wrap break-all leading-relaxed" x-text="log.raw"></pre>
</div>
</div>
</template>
<template x-if="!log.raw">
<div class="text-center py-12">
<x-ri-file-damage-line class="size-12 mx-auto mb-3 text-base/30" />
<p class="text-base/60 text-sm">No raw log data available</p>
</div>
</template>
</div>
</div>
<!-- Footer Actions -->
<div class="p-4 bg-background border-t border-neutral/20 flex flex-wrap justify-between gap-3">
<a :href="'{{ route('proxmox.server.statistics', ['server' => $server->id]) }}?network_search=' + encodeURIComponent(log.source || '') + '#network-analysis-card'"
class="px-4 py-2.5 text-sm font-semibold bg-primary text-white rounded-lg hover:bg-primary/90 transition-all inline-flex items-center gap-2 shadow-lg shadow-primary/20">
<x-ri-line-chart-line class="size-4" />
View in Network Analysis
</a>
<button @click="showStateModal = false"
class="px-4 py-2.5 text-sm font-semibold bg-base/10 text-base/80 border border-neutral/30 rounded-lg hover:bg-base/20 transition-colors">
Close
</button>
</div>
</div>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Pagination Controls -->
<div x-show="totalPages > 1" class="flex items-center justify-center gap-2 mt-4 flex-wrap">
<button
type="button"
@click="prevPage()"
:disabled="currentPage === 1"
class="px-3 py-2 border border-neutral rounded-lg transition-all text-sm font-medium flex items-center justify-center"
:class="currentPage === 1 ? 'opacity-50 cursor-not-allowed bg-background-secondary' : 'hover:bg-primary/10 hover:border-primary/50'">
<x-ri-arrow-left-s-line class="size-4" />
</button>
<template x-for="(page, index) in paginationRange" :key="`page-${index}-${page}`">
<button
type="button"
@click="typeof page === 'number' ? goToPage(page) : null"
:disabled="typeof page !== 'number'"
class="px-4 py-2 border rounded-lg transition-all text-sm font-medium min-w-[44px] flex items-center justify-center"
:class="{
'bg-primary text-white border-primary hover:bg-primary/90': page === currentPage && typeof page === 'number',
'border-neutral hover:bg-primary/10 hover:border-primary/50': typeof page === 'number' && page !== currentPage,
'border-transparent cursor-default opacity-60': typeof page !== 'number'
}"
x-text="page">
</button>
</template>
<button
type="button"
@click="nextPage()"
:disabled="currentPage === totalPages"
class="px-3 py-2 border border-neutral rounded-lg transition-all text-sm font-medium flex items-center justify-center"
:class="currentPage === totalPages ? 'opacity-50 cursor-not-allowed bg-background-secondary' : 'hover:bg-primary/10 hover:border-primary/50'">
<x-ri-arrow-right-s-line class="size-4" />
</button>
</div>
<div x-show="filteredLogs.length === 0" x-cloak class="text-center py-8 text-base/60">
<x-ri-file-list-3-line class="size-12 mx-auto mb-3 opacity-50" />
<p>No logs matched your search.</p>
</div>
</div>
@elseif(empty($logError))
<div class="text-center py-8 text-base/60">
<x-ri-file-list-3-line class="size-12 mx-auto mb-3 opacity-50" />
<p>No firewall logs available</p>
<p class="text-xs mt-2">Ensure firewall logging is enabled in Proxmox (Datacenter > Firewall > Options)</p>
</div>
@endif
</div>
@else
<div class="bg-background-secondary border border-neutral/20 rounded-lg p-8 text-center">
<x-ri-shield-line class="size-16 mx-auto mb-4 opacity-50" />
<h3 class="text-lg font-semibold mb-2">Firewall is Disabled</h3>
<p class="text-base/60">Enable the firewall above to manage firewall rules.</p>
</div>
@endif
</div>
@script
<script>
// Extract IP from string, handling ports for both IPv4 and IPv6
window.extractIP = function(ip) {
if (!ip || typeof ip !== 'string') return ip;
ip = ip.trim();
// Handle IPv4 with port (e.g., "192.168.1.1:8080")
const ipv4WithPort = /^(\d+\.\d+\.\d+\.\d+):(\d+)$/.exec(ip);
if (ipv4WithPort) {
return ipv4WithPort[1];
}
// Handle IPv6 with port in brackets (e.g., "[2001:db8::1]:8080")
const ipv6WithPort = /^\[([^\]]+)\]:(\d+)$/.exec(ip);
if (ipv6WithPort) {
return ipv6WithPort[1];
}
// Check if it's a valid IPv6 address (contains multiple colons)
if (ip.includes(':') && (ip.match(/:/g) || []).length >= 2) {
// Might be IPv6, return as-is
return ip;
}
return ip;
};
// Check if an IP address is public (not private/localhost)
window.isPublicIP = function(ip) {
if (!ip || ip === '—' || ip === '-' || typeof ip !== 'string') return false;
// Extract IP address (handles ports properly for both IPv4 and IPv6)
ip = window.extractIP(ip);
ip = ip.trim();
// Check for localhost
if (ip === 'localhost' || ip === '127.0.0.1' || ip === '::1') return false;
// Check for IPv4 private ranges
const ipv4PrivateRanges = [
/^10\./, // 10.0.0.0/8
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
/^192\.168\./, // 192.168.0.0/16
/^169\.254\./, // 169.254.0.0/16 (link-local)
/^127\./, // 127.0.0.0/8
/^0\./ // 0.0.0.0/8
];
for (const range of ipv4PrivateRanges) {
if (range.test(ip)) return false;
}
// Check for IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)
const ipv4MappedMatch = ip.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
if (ipv4MappedMatch) {
// Recursively check if the embedded IPv4 address is public
return window.isPublicIP(ipv4MappedMatch[1]);
}
// Check if it's IPv4 (basic validation)
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(ip)) {
// Already checked private ranges above, so it's public
return true;
}
// Check if it's IPv6
if (ip.includes(':')) {
// Check for IPv6 private ranges
// Link-local (fe80::/10)
if (/^fe[89ab][0-9a-fA-F]:/i.test(ip)) {
return false;
}
// Unique Local Address - ULA (fc00::/7)
if (/^f[cd][0-9a-fA-F]/i.test(ip)) {
return false;
}
// IPv4-mapped IPv6 (::ffff:x.x.x.x)
if (ip.startsWith('::ffff:')) {
const ipv4Part = ip.substring(7);
return window.isPublicIP(ipv4Part);
}
// If it's a valid-looking IPv6 and not in private ranges, consider it public
// Basic IPv6 validation
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
return ipv6Regex.test(ip);
}
return false;
};
// Fetch IP geolocation data via server-side proxy (uses HTTP protocol server-side)
window.fetchIPLocation = async function(ip) {
if (!window.isPublicIP(ip)) {
return { status: 'fail', message: 'Not a public IP address' };
}
// Extract IP address (handles ports properly for both IPv4 and IPv6)
ip = window.extractIP(ip);
try {
// Use Livewire to call server-side method which fetches from http://ip-api.com/json/{ip}
// This avoids mixed content issues when the site is served over HTTPS
const data = await @this.call('fetchIPLocation', ip);
return data || { status: 'fail', message: 'No data returned' };
} catch (error) {
console.error('Error fetching IP location:', error);
return { status: 'fail', message: 'Failed to fetch location data: ' + (error.message || 'Unknown error') };
}
};
// Get protocol description
window.getProtocolDescription = function(protocol) {
if (!protocol) return 'Unknown Protocol';
const descriptions = {
'TCP': '<p class="mb-2"><strong>Transmission Control Protocol (TCP)</strong> is a connection-oriented protocol that provides reliable, ordered delivery of data between applications.</p><ul class="list-disc ml-5 space-y-1 text-sm"><li>Connection-oriented with three-way handshake</li><li>Guaranteed delivery with error checking</li><li>Used by HTTP, HTTPS, FTP, SSH, and many other services</li></ul>',
'UDP': '<p class="mb-2"><strong>User Datagram Protocol (UDP)</strong> is a connectionless protocol that sends messages without establishing a connection.</p><ul class="list-disc ml-5 space-y-1 text-sm"><li>Connectionless and lightweight</li><li>No guaranteed delivery or ordering</li><li>Lower latency than TCP</li><li>Used by DNS, DHCP, streaming, and gaming</li></ul>',
'ICMP': '<p class="mb-2"><strong>Internet Control Message Protocol (ICMP)</strong> is used for diagnostic and control purposes in IP networks.</p><ul class="list-disc ml-5 space-y-1 text-sm"><li>Used by ping and traceroute tools</li><li>Reports errors and network conditions</li><li>Not used for data transfer</li></ul>',
'GRE': '<p class="mb-2"><strong>Generic Routing Encapsulation (GRE)</strong> is a tunneling protocol that encapsulates network layer protocols.</p><ul class="list-disc ml-5 space-y-1 text-sm"><li>Used for VPNs and tunneling</li><li>Can encapsulate various protocols</li></ul>',
'ESP': '<p class="mb-2"><strong>Encapsulating Security Payload (ESP)</strong> provides confidentiality, authentication, and integrity in IPsec.</p><ul class="list-disc ml-5 space-y-1 text-sm"><li>Part of IPsec protocol suite</li><li>Encrypts packet payload</li><li>Used in VPNs</li></ul>',
'AH': '<p class="mb-2"><strong>Authentication Header (AH)</strong> provides connectionless integrity and data origin authentication for IP datagrams.</p><ul class="list-disc ml-5 space-y-1 text-sm"><li>Part of IPsec protocol suite</li><li>Provides authentication but not encryption</li></ul>',
'SCTP': '<p class="mb-2"><strong>Stream Control Transmission Protocol (SCTP)</strong> is a transport layer protocol similar to TCP and UDP.</p><ul class="list-disc ml-5 space-y-1 text-sm"><li>Message-oriented like UDP</li><li>Reliable like TCP</li><li>Supports multi-homing and multi-streaming</li></ul>',
'IGMP': '<p class="mb-2"><strong>Internet Group Management Protocol (IGMP)</strong> manages IP multicast group membership.</p><ul class="list-disc ml-5 space-y-1 text-sm"><li>Used for multicast groups</li><li>Allows hosts to join/leave multicast groups</li></ul>'
};
return descriptions[protocol.toUpperCase()] || `<p>${protocol} protocol</p>`;
};
// Get port description
window.getPortDescription = function(port) {
if (!port || port === '-') return 'No specific port';
const portDescriptions = {
'20': 'FTP Data Transfer',
'21': 'FTP Control',
'22': 'SSH (Secure Shell)',
'23': 'Telnet',
'25': 'SMTP (Email)',
'53': 'DNS',
'80': 'HTTP (Web)',
'110': 'POP3 (Email)',
'143': 'IMAP (Email)',
'443': 'HTTPS (Secure Web)',
'465': 'SMTPS (Secure Email)',
'587': 'SMTP Submission',
'993': 'IMAPS (Secure IMAP)',
'995': 'POP3S (Secure POP3)',
'3306': 'MySQL Database',
'3389': 'RDP (Remote Desktop)',
'5432': 'PostgreSQL Database',
'5900': 'VNC',
'8080': 'HTTP Alternate',
'8443': 'HTTPS Alternate'
};
return portDescriptions[port] || 'Unknown Port - No standard service associated with this port number';
};
</script>
@endscript
</div>
</div>
</div>
</x-proxmox::layout>
<!-- Modals (placed outside layout to properly darken entire viewport) -->
<!-- Error Message Modal -->
@if($error && $showErrorModal)
<!-- Modal Backdrop -->
<div wire:click="$set('error', ''); $set('showErrorModal', false)"
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
style="display: block;">
</div>
<!-- Modal -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="display: block;">
<div class="relative w-full max-w-3xl max-h-[80vh] overflow-y-auto bg-error/20 border-2 border-error rounded-xl shadow-2xl p-6" wire:click.stop>
<button wire:click="$set('error', ''); $set('showErrorModal', false)"
class="absolute top-4 right-4 text-error/70 hover:text-error transition-colors">
<x-ri-close-line class="size-5" />
</button>
<div class="flex items-start gap-3">
<x-ri-error-warning-fill class="size-6 text-error flex-shrink-0 mt-0.5" />
<div class="flex-1">
<h3 class="text-lg font-semibold text-error mb-2 text-center">Firewall Error</h3>
<div class="bg-background/50 rounded p-3 border border-error/30">
<p class="text-sm text-error/90 leading-relaxed whitespace-pre-wrap break-words">{{ $error }}</p>
</div>
</div>
</div>
</div>
</div>
@endif
<!-- Add/Edit Rule Modal -->
@if($showRuleForm)
<!-- Modal Backdrop -->
<div wire:click="$set('showRuleForm', false); $set('editingRulePos', null); resetRuleForm()"
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
style="display: block;">
</div>
<!-- Modal -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="display: block;">
<div class="relative w-full max-w-4xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6" wire:click.stop>
<button wire:click="$set('showRuleForm', false); $set('editingRulePos', null); resetRuleForm()"
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="mb-4 pr-8">
<h3 class="text-lg font-semibold">{{ $editingRulePos !== null ? 'Edit Rule' : 'Create New Rule' }}</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<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">
<option value="ACCEPT">Accept</option>
<option value="REJECT">Reject</option>
<option value="DROP">Drop</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2">Protocol</label>
<select wire:model="ruleProto" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
<option value="icmp">ICMP</option>
<option value="igmp">IGMP</option>
<option value="ggp">GGP</option>
<option value="ipencap">IPENCAP</option>
<option value="st">ST</option>
<option value="egp">EGP</option>
<option value="igp">IGP</option>
<option value="pip">PIP</option>
<option value="hmp">HMP</option>
<option value="xns-idp">XNS-IDP</option>
<option value="rdp">RDP</option>
<option value="iso-tp4">ISO-TP4</option>
<option value="dccp">DCCP</option>
<option value="xtp">XTP</option>
<option value="ddp">DDP</option>
<option value="idpr-cmtp">IDPR-CMTP</option>
<option value="ipv6">IPv6</option>
<option value="ipv6-route">IPv6-Route</option>
<option value="ipv6-frag">IPv6-Frag</option>
<option value="idrp">IDRP</option>
<option value="rsvp">RSVP</option>
<option value="gre">GRE</option>
<option value="esp">ESP</option>
<option value="ah">AH</option>
<option value="skip">SKIP</option>
<option value="ipv6-icmp">IPv6-ICMP</option>
<option value="ipv6-nonxt">IPv6-Nonxt</option>
<option value="ipv6-opts">IPv6-Opts</option>
<option value="vmtp">VMTP</option>
<option value="eigrp">EIGRP</option>
<option value="ospf">OSPF</option>
<option value="ax.25">AX.25</option>
<option value="ipip">IPIP</option>
<option value="etherip">EtherIP</option>
<option value="encap">ENCAP</option>
<option value="pim">PIM</option>
<option value="ipcomp">IPComp</option>
<option value="vrrp">VRRP</option>
<option value="l2tp">L2TP</option>
<option value="isis">ISIS</option>
<option value="sctp">SCTP</option>
<option value="fc">FC</option>
<option value="mobility-header">Mobility-Header</option>
<option value="udplite">UDPLite</option>
<option value="mpls-in-ip">MPLS-in-IP</option>
<option value="hip">HIP</option>
<option value="shim6">Shim6</option>
<option value="wesp">WESP</option>
<option value="rohc">ROHC</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2">Source</label>
<input type="text" wire:model="ruleSource" placeholder="e.g., 192.168.1.0/24" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background">
</div>
<div>
<label class="block text-sm font-medium mb-2">Destination</label>
<select wire:model="ruleDest" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background">
@foreach($availableIps as $ipData)
<option value="{{ $ipData['ip'] }}">{{ $ipData['ip'] }} ({{ $ipData['type'] }} - {{ $ipData['label'] }})</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2">Destination Port</label>
<input type="text" wire:model="ruleDport" placeholder="e.g., 80,443 or 1000:2000" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background">
</div>
<div>
<label class="block text-sm font-medium mb-2">Source Port</label>
<input type="text" wire:model="ruleSport" placeholder="e.g., 8080" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background">
</div>
<div>
<label class="block text-sm font-medium mb-2">Interface</label>
<select wire:model="ruleIface" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background">
@foreach($networkAdapters as $adapter)
<option value="{{ $adapter['name'] }}">{{ $adapter['name'] }} ({{ ucfirst($adapter['model'] ?? 'Unknown') }})</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2">Log Level</label>
<select wire:model="ruleLog" class="w-full px-3 py-2 border border-neutral rounded-lg bg-background">
<option value="nolog">nolog</option>
<option value="emerg">emerg</option>
<option value="alert">alert</option>
<option value="crit">crit</option>
<option value="err">err</option>
<option value="warning">warning</option>
<option value="notice">notice</option>
<option value="info">info</option>
<option value="debug">debug</option>
</select>
</div>
<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">
</div>
<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">Enable this rule</label>
</div>
</div>
<div class="flex gap-2 mt-6">
<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>
<button
wire:click="$set('showRuleForm', false); $set('editingRulePos', null); resetRuleForm()"
class="px-4 py-2 bg-background-secondary border border-neutral rounded-lg hover:bg-background transition-colors text-sm font-medium">
Cancel
</button>
</div>
</div>
</div>
@endif
<!-- Delete Rule Confirmation Modal -->
@if($showDeleteModal && $deletingRulePos !== null)
@php
$ruleToDelete = collect($rules)->firstWhere('pos', $deletingRulePos);
@endphp
<!-- Modal Backdrop -->
<div wire:click="cancelDeleteRule"
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
style="display: block;">
</div>
<!-- Modal -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="display: block;">
<div class="relative w-full max-w-lg max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6" wire:click.stop>
<button wire:click="cancelDeleteRule"
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="mb-6 pr-8">
<div class="flex items-start gap-4">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-error/10 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 leading-relaxed">
Are you sure you want to delete this firewall rule? This action cannot be undone and may affect your network security.
</p>
@if($ruleToDelete)
<div class="bg-background-secondary/50 border border-neutral/20 rounded-lg p-4 mb-4">
<div class="text-sm space-y-2">
<div class="flex items-center gap-2">
<span class="font-semibold text-base/80 min-w-[80px]">Action:</span>
<span class="px-2 py-1 rounded text-xs font-medium
{{ ($ruleToDelete['action'] ?? '') === 'ACCEPT' ? 'bg-success/20 text-success' : '' }}
{{ ($ruleToDelete['action'] ?? '') === 'REJECT' ? 'bg-warning/20 text-warning' : '' }}
{{ ($ruleToDelete['action'] ?? '') === 'DROP' ? 'bg-error/20 text-error' : '' }}">
{{ $ruleToDelete['action'] ?? '-' }}
</span>
</div>
@if(!empty($ruleToDelete['source']))
<div class="flex items-center gap-2">
<span class="font-semibold text-base/80 min-w-[80px]">Source:</span>
<code class="text-xs bg-background px-2 py-1 rounded">{{ $ruleToDelete['source'] }}</code>
</div>
@endif
@if(!empty($ruleToDelete['dest']))
<div class="flex items-center gap-2">
<span class="font-semibold text-base/80 min-w-[80px]">Destination:</span>
<code class="text-xs bg-background px-2 py-1 rounded">{{ $ruleToDelete['dest'] }}</code>
</div>
@endif
@if(!empty($ruleToDelete['proto']))
<div class="flex items-center gap-2">
<span class="font-semibold text-base/80 min-w-[80px]">Protocol:</span>
<span class="text-base/90">{{ strtoupper($ruleToDelete['proto']) }}</span>
</div>
@endif
@if(!empty($ruleToDelete['dport']))
<div class="flex items-center gap-2">
<span class="font-semibold text-base/80 min-w-[80px]">Port:</span>
<span class="text-base/90">{{ $ruleToDelete['dport'] }}</span>
</div>
@endif
@if(!empty($ruleToDelete['comment']))
<div class="flex items-start gap-2 pt-1 border-t border-neutral/20">
<span class="font-semibold text-base/80 min-w-[80px]">Comment:</span>
<span class="text-base/90 flex-1">{{ $ruleToDelete['comment'] }}</span>
</div>
@endif
</div>
</div>
@endif
</div>
</div>
</div>
<div class="flex gap-3 justify-end pt-4 border-t border-neutral/20">
<button
wire:click="cancelDeleteRule"
class="px-5 py-2.5 bg-background-secondary border border-neutral/30 rounded-lg hover:bg-background hover:border-neutral/50 transition-all text-sm font-medium text-base/80">
Cancel
</button>
<button
wire:click="deleteRule"
wire:loading.attr="disabled"
class="delete-rule-button">
<svg wire:loading.remove wire:target="deleteRule" class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 6h5v2h-2v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8H2V6h5V3a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v3zm1 2H6v12h12V8zm-9 3h2v6H9v-6zm4 0h2v6h-2v-6zM9 4v2h6V4H9z"/>
</svg>
<svg wire:loading wire:target="deleteRule" class="animate-spin size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span wire:loading.remove wire:target="deleteRule">Delete Rule</span>
<span wire:loading wire:target="deleteRule">Deleting...</span>
</button>
</div>
</div>
</div>
@endif