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

1455 lines
118 KiB
PHP

<x-proxmox::layout :server="$server" view="firewall" :status="$status" :status-polling="true" :status-polling-interval="'2s'">
<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;
}
</style>
<div class="space-y-6">
<!-- Error Message - Centered Modal -->
@if($error && $showErrorModal)
<div class="fixed inset-0 flex items-center justify-center z-50 bg-black/50 backdrop-blur-sm"
wire:click="$set('error', ''); $set('showErrorModal', false)">
<div class="bg-error/20 border-2 border-error rounded-lg p-6 shadow-lg max-w-3xl w-full mx-4 max-h-[80vh] overflow-y-auto" wire:click.stop>
<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>
<button
wire:click="$set('error', ''); $set('showErrorModal', false)"
class="text-error/70 hover:text-error transition-colors flex-shrink-0">
<x-ri-close-line class="size-5" />
</button>
</div>
</div>
</div>
@endif
<!-- 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">
<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>
<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>
<!-- 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-4 font-semibold text-sm">Position</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>
@foreach($rules as $rule)
<tr class="border-b border-neutral/20 hover:bg-background-secondary/50">
<td class="py-3 px-4 text-sm">{{ $rule['pos'] ?? '-' }}</td>
<td class="py-3 px-4 text-sm">
<span class="px-2 py-1 rounded text-xs font-medium
{{ ($rule['action'] ?? '') === 'ACCEPT' ? 'bg-success/20 text-success' : '' }}
{{ ($rule['action'] ?? '') === 'REJECT' ? 'bg-warning/20 text-warning' : '' }}
{{ ($rule['action'] ?? '') === 'DROP' ? 'bg-error/20 text-error' : '' }}">
{{ $rule['action'] ?? '-' }}
</span>
</td>
<td class="py-3 px-4 text-sm">{{ $rule['source'] ?? '-' }}</td>
<td class="py-3 px-4 text-sm">{{ $rule['dest'] ?? '-' }}</td>
<td class="py-3 px-4 text-sm">{{ strtoupper($rule['proto'] ?? 'any') }}</td>
<td class="py-3 px-4 text-sm">{{ $rule['dport'] ?? ($rule['sport'] ?? '-') }}</td>
<td class="py-3 px-4 text-sm">
@if(isset($rule['enable']) && $rule['enable'] == 0)
<span class="px-2 py-1 rounded text-xs font-medium bg-base/20 text-base/60">Disabled</span>
@else
<span class="px-2 py-1 rounded text-xs font-medium bg-success/20 text-success">Enabled</span>
@endif
</td>
<td class="py-3 px-4 text-sm">
<div class="flex gap-2">
<button
wire:click="toggleRule({{ $rule['pos'] ?? 0 }})"
wire:loading.attr="disabled"
class="transition-colors
{{ (isset($rule['enable']) && $rule['enable'] == 0) ? 'text-warning hover:text-warning/80' : 'text-success hover:text-success/80' }}"
title="{{ (isset($rule['enable']) && $rule['enable'] == 0) ? 'Enable rule' : 'Disable rule' }}">
@if(isset($rule['enable']) && $rule['enable'] == 0)
<x-ri-toggle-line class="size-4" />
@else
<x-ri-toggle-fill class="size-4" />
@endif
</button>
<button
wire:click="startEditRule({{ $rule['pos'] ?? 0 }})"
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'] ?? 0 }})"
class="text-error hover:text-error/80 transition-colors"
title="Delete rule">
<x-ri-delete-bin-line class="size-4" />
</button>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-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 (loaded on page load)</p>
</div>
</div>
</div>
<script>
// Initialize firewall logs storage
if (typeof window.firewallLogsData === 'undefined') {
window.firewallLogsData = [];
window.firewallLogsLoaded = false;
window.firewallLogsError = '';
}
// Listen for Livewire dispatch event
document.addEventListener('firewall-logs-loaded', (event) => {
window.firewallLogsData = event.detail.logs || [];
window.firewallLogsError = event.detail.error || '';
window.firewallLogsLoaded = true;
// Trigger Alpine update
const container = document.getElementById('firewall-logs-container');
if (container && container.__x_data) {
container.__x_data.logs = window.firewallLogsData;
container.__x_data.error = window.firewallLogsError;
container.__x_data.loading = false;
}
});
</script>
<div id="firewall-logs-container"
x-data="{
logs: window.firewallLogsData || [],
loading: !window.firewallLogsLoaded,
error: window.firewallLogsError || '',
searchTerm: '',
sortField: 'timestamp_iso',
sortDirection: 'desc',
async initializeLogs() {
// Only load logs once on page load
if (window.firewallLogsLoaded) {
return;
}
try {
this.loading = true;
// Dispatch to Livewire to fetch logs
@this.dispatch('fetchFirewallLogsAction');
} catch (err) {
this.error = 'Failed to load firewall logs: ' + (err.message || 'Unknown error');
console.error('Error loading firewall logs:', err);
}
},
sort(field) {
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDirection = 'asc';
}
},
get filteredLogs() {
const term = this.searchTerm.trim().toLowerCase();
let entries = this.logs;
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;
});
},
}"
@load="initializeLogs()"
>
<div x-show="loading" x-cloak class="text-center py-8 text-base/60">
<div class="animate-spin inline-block">
<x-ri-loader-4-line class="size-8 mx-auto mb-3 opacity-50" />
</div>
<p>Loading firewall logs...</p>
</div>
<div x-show="error" x-cloak class="bg-error/10 border border-error/30 rounded-lg p-4">
<div class="flex items-start gap-3">
<x-ri-error-warning-line class="size-5 text-error flex-shrink-0 mt-0.5" />
<div class="flex-1">
<p class="text-sm text-error/90" x-text="error"></p>
</div>
</div>
</div>
<template x-if="!loading && !error && logs.length > 0">
<div class="space-y-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="relative w-full md:w-72">
<x-ri-search-line class="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-base/40" />
<input
type="text"
x-model="searchTerm"
placeholder="Search logs..."
class="firewall-log-search-input w-full pr-4 py-2 border border-neutral rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</div>
<div class="text-xs text-base/50">
<span x-text="`${filteredLogs.length} / ${logs.length} results`"></span>
</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>
</tr>
</thead>
<tbody>
<template x-for="(log, index) in filteredLogs" :key="index">
<template x-if="true">
<template x-if="log.is_group">
<!-- Grouped log row -->
<template x-data="{ expanded: false }">
<tr class="border-b border-neutral/20 hover:bg-background-secondary/50 cursor-pointer" @click="expanded = !expanded">
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-2">
<svg class="size-3 text-base/50 transition-transform flex-shrink-0" :class="{ 'rotate-90': expanded }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span x-text="log.timestamp_display || '-'"></span>
</div>
</td>
<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>
<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>
<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, protocolDescription: window.getProtocolDescription(log.protocol) }" x-cloak>
<button type="button" @click.stop="open = true" class="text-base/50 hover:text-base/70 transition-colors">
<x-ri-information-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.stop="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-information-line class="size-5 text-primary flex-shrink-0" />
<h3 class="text-lg font-semibold break-words">Protocol: <span class="font-mono break-all" x-text="log.protocol"></span></h3>
</div>
<div class="text-sm text-base/80 prose prose-sm max-w-none" x-html="protocolDescription"></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" 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, ip: log.source }" x-cloak>
<button type="button" @click.stop="
if (!ipData && !loading) {
loading = true;
open = true;
window.fetchIPLocation(ip).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.stop="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">
<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;">
<iframe
x-bind:src="'https://www.openstreetmap.org/export/embed.html?bbox=' + encodeURIComponent((parseFloat(ipData.lon) - 0.01) + ',' + (parseFloat(ipData.lat) - 0.01) + ',' + (parseFloat(ipData.lon) + 0.01) + ',' + (parseFloat(ipData.lat) + 0.01)) + '&layer=mapnik&marker=' + encodeURIComponent(ipData.lat + ',' + ipData.lon)"
style="width: 100%; height: 100%; border: 0;"
frameborder="0"
loading="lazy"
allowfullscreen
title="Location Map"></iframe>
</div>
</div>
</div>
</template>
<template x-if="ipData && (ipData.status === 'fail' || ipData.status === 'error')">
<div class="text-center py-4">
<p class="text-sm text-warning" x-text="ipData.message || 'Failed to load location data'"></p>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</td>
<td class="py-3 px-4 text-sm" x-text="log.source_port || '-'"></td>
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-1">
<span class="font-mono" 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, ip: log.destination }" x-cloak>
<button type="button" @click.stop="
if (!ipData && !loading) {
loading = true;
open = true;
window.fetchIPLocation(ip).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.stop="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">
<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;">
<iframe
x-bind:src="'https://www.openstreetmap.org/export/embed.html?bbox=' + encodeURIComponent((parseFloat(ipData.lon) - 0.01) + ',' + (parseFloat(ipData.lat) - 0.01) + ',' + (parseFloat(ipData.lon) + 0.01) + ',' + (parseFloat(ipData.lat) + 0.01)) + '&layer=mapnik&marker=' + encodeURIComponent(ipData.lat + ',' + ipData.lon)"
style="width: 100%; height: 100%; border: 0;"
frameborder="0"
loading="lazy"
allowfullscreen
title="Location Map"></iframe>
</div>
</div>
</div>
</template>
<template x-if="ipData && (ipData.status === 'fail' || ipData.status === 'error')">
<div class="text-center py-4">
<p class="text-sm text-warning" x-text="ipData.message || 'Failed to load location data'"></p>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</td>
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-2">
<span x-text="log.destination_port || '-'"></span>
<span class="px-2 py-1 bg-primary/10 text-primary rounded-full text-xs font-semibold" x-text="log.group_count"></span>
</div>
</td>
</tr>
<!-- Expanded details row -->
<tr x-show="expanded" x-collapse class="border-b border-neutral/20">
<td colspan="8" class="p-0">
<div class="bg-background/30 px-4 py-3">
<div class="text-xs text-base/60 mb-3 font-semibold">
<span x-text="log.group_count"></span> similar events
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
<template x-for="(subLog, subIndex) in log.grouped_logs" :key="subIndex">
<div class="bg-background-secondary border border-neutral/10 rounded-lg p-3">
<div class="flex items-center gap-3 mb-2">
<span class="text-xs text-base/70" x-text="subLog.timestamp_display"></span>
<span class="px-2 py-0.5 rounded text-xs font-semibold uppercase" :class="subLog.level_badge_class" x-text="subLog.level_label"></span>
<span class="px-2 py-0.5 rounded text-xs font-semibold" :class="subLog.action_badge_class" x-text="subLog.action"></span>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs mb-2">
<div><span class="text-base/50">Protocol:</span> <span class="font-semibold" x-text="subLog.protocol || '-'"></span></div>
<div><span class="text-base/50">Interface:</span> <span class="font-semibold" x-text="subLog.iface || '-'"></span></div>
<div><span class="text-base/50">Source:</span> <span class="font-mono font-semibold" x-text="subLog.source || '-'"></span><span x-show="subLog.source_port" class="text-base/50">:<span class="font-semibold" x-text="subLog.source_port"></span></span></div>
<div><span class="text-base/50">Destination:</span> <span class="font-mono font-semibold" x-text="subLog.destination || '-'"></span><span x-show="subLog.destination_port" class="text-base/50">:<span class="font-semibold" x-text="subLog.destination_port"></span></span></div>
</div>
<details class="text-xs">
<summary class="cursor-pointer text-base/50 hover:text-base/70 transition-colors">View raw log</summary>
<div class="mt-2 p-2 bg-background/50 rounded border border-neutral/10 font-mono text-[10px] text-base/60 break-all" x-text="subLog.raw"></div>
</details>
</div>
</template>
</div>
</div>
</td>
</tr>
</template>
</template>
<template x-if="!log.is_group">
<!-- Single log row -->
<tr class="border-b border-neutral/20 hover:bg-background-secondary/50">
<td class="py-3 px-4 text-sm" x-text="log.timestamp_display || '-' "></td>
<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>
<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>
<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, protocolDescription: window.getProtocolDescription(log.protocol) }" x-cloak>
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
<x-ri-information-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-information-line class="size-5 text-primary flex-shrink-0" />
<h3 class="text-lg font-semibold break-words">Protocol: <span class="font-mono break-all" x-text="log.protocol"></span></h3>
</div>
<div class="text-sm text-base/80 prose prose-sm max-w-none" x-html="protocolDescription"></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" 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, ip: log.source }" x-cloak>
<button type="button" @click="
if (!ipData && !loading) {
loading = true;
open = true;
window.fetchIPLocation(ip).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">
<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;">
<iframe
x-bind:src="'https://www.openstreetmap.org/export/embed.html?bbox=' + encodeURIComponent((parseFloat(ipData.lon) - 0.01) + ',' + (parseFloat(ipData.lat) - 0.01) + ',' + (parseFloat(ipData.lon) + 0.01) + ',' + (parseFloat(ipData.lat) + 0.01)) + '&layer=mapnik&marker=' + encodeURIComponent(ipData.lat + ',' + ipData.lon)"
style="width: 100%; height: 100%; border: 0;"
frameborder="0"
loading="lazy"
allowfullscreen
title="Location Map"></iframe>
</div>
</div>
</div>
</template>
<template x-if="ipData && (ipData.status === 'fail' || ipData.status === 'error')">
<div class="text-center py-4">
<p class="text-sm text-warning" x-text="ipData.message || 'Failed to load location data'"></p>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</td>
<td class="py-3 px-4 text-sm" x-text="log.source_port || '-' "></td>
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-1">
<span class="font-mono" 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, ip: log.destination }" x-cloak>
<button type="button" @click="
if (!ipData && !loading) {
loading = true;
open = true;
window.fetchIPLocation(ip).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">
<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;">
<iframe
x-bind:src="'https://www.openstreetmap.org/export/embed.html?bbox=' + encodeURIComponent((parseFloat(ipData.lon) - 0.01) + ',' + (parseFloat(ipData.lat) - 0.01) + ',' + (parseFloat(ipData.lon) + 0.01) + ',' + (parseFloat(ipData.lat) + 0.01)) + '&layer=mapnik&marker=' + encodeURIComponent(ipData.lat + ',' + ipData.lon)"
style="width: 100%; height: 100%; border: 0;"
frameborder="0"
loading="lazy"
allowfullscreen
title="Location Map"></iframe>
</div>
</div>
</div>
</template>
<template x-if="ipData && (ipData.status === 'fail' || ipData.status === 'error')">
<div class="text-center py-4">
<p class="text-sm text-warning" x-text="ipData.message || 'Failed to load location data'"></p>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</td>
<td class="py-3 px-4 text-sm" x-text="log.destination_port || '-' "></td>
</tr>
</template>
</template>
</template>
</tbody>
</table>
</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>
</template>
<template x-if="!loading && !error && logs.length === 0">
<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>
</template>
</div>
</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
<!-- Add/Edit Rule Modal -->
@if($showRuleForm)
<div class="fixed inset-0 flex items-center justify-center z-50 bg-black/50 backdrop-blur-sm"
wire:click="$set('showRuleForm', false); $set('editingRulePos', null); resetRuleForm()">
<div class="bg-background border border-neutral rounded-lg p-6 shadow-lg max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto" wire:click.stop>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">{{ $editingRulePos !== null ? 'Edit Rule' : 'Create New Rule' }}</h3>
<button
wire:click="$set('showRuleForm', false); $set('editingRulePos', null); resetRuleForm()"
class="text-base/50 hover:text-base transition-colors">
<x-ri-close-line class="size-5" />
</button>
</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
<div class="fixed inset-0 flex items-center justify-center z-50 bg-black/60 backdrop-blur-sm transition-opacity"
wire:click="cancelDeleteRule">
<div class="bg-background border border-neutral/30 rounded-xl p-6 shadow-2xl max-w-lg w-full mx-4 transform transition-all" wire:click.stop>
<div class="flex items-start gap-4 mb-6">
<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>
<button
wire:click="cancelDeleteRule"
class="text-base/40 hover:text-base transition-colors flex-shrink-0">
<x-ri-close-line class="size-5" />
</button>
</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
</div>
<script>
// Helper function to extract IP from string (removes port)
window.extractIP = function(input) {
if (!input) return '';
input = String(input).trim();
// Handle IPv6 with port: [2001:db8::1]:8080
const ipv6WithPortMatch = input.match(/^\[([^\]]+)\](?::\d+)?$/);
if (ipv6WithPortMatch) {
return ipv6WithPortMatch[1];
}
// Handle IPv4 with port: 192.168.1.1:8080
const ipv4WithPortMatch = input.match(/^([^:]+):\d+$/);
if (ipv4WithPortMatch && !ipv4WithPortMatch[1].includes(':')) {
return ipv4WithPortMatch[1];
}
return input;
};
// Check if IP is public
window.isPublicIP = function(ip) {
if (!ip || typeof ip !== 'string') {
return false;
}
ip = window.extractIP(ip);
// Check for localhost
if (['localhost', '127.0.0.1', '::1'].includes(ip.toLowerCase())) {
return false;
}
// Check if it's IPv4
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
const parts = ip.split('.').map(Number);
// Check for valid IPv4 range
if (parts.some(part => part > 255)) {
return false;
}
// Check for private ranges
if (parts[0] === 10) return false;
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false;
if (parts[0] === 192 && parts[1] === 168) return false;
if (parts[0] === 127) return false;
if (parts[0] === 169 && parts[1] === 254) return false;
return true;
}
// Check if it's IPv6
if (ip.includes(':')) {
// 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
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
window.fetchIPLocation = async function(ip) {
if (!window.isPublicIP(ip)) {
return { status: 'fail', message: 'Not a public IP address' };
}
ip = window.extractIP(ip);
try {
const data = await @this.call('fetchIPLocation', ip);
return data || { status: 'fail', message: 'No data returned' };
} catch (error) {
console.error('Error fetching IP location:', error);
return { status: 'fail', message: 'Failed to fetch location data: ' + (error.message || 'Unknown error') };
}
};
// Helper function to format descriptions with sections
const formatStateDescription = (overview, meaning, scenarios, notes = null) => {
let html = '<div class="space-y-4">';
if (overview) {
html += `<div><h4 class="font-semibold text-base mb-2">Overview</h4><p>${overview}</p></div>`;
}
if (meaning) {
html += `<div><h4 class="font-semibold text-base mb-2">What This Means</h4><p>${meaning}</p></div>`;
}
if (scenarios) {
html += `<div><h4 class="font-semibold text-base mb-2">Common Scenarios</h4>${scenarios}</div>`;
}
if (notes) {
html += `<div><h4 class="font-semibold text-base mb-2">Additional Notes</h4><p>${notes}</p></div>`;
}
html += '</div>';
return html;
};
// Protocol descriptions mapping
window.getProtocolDescription = function(protocol) {
if (!protocol || typeof protocol !== 'string') {
return formatStateDescription('Unknown protocol', '', '');
}
const protocolUpper = protocol.toUpperCase();
const protocolDescriptions = {
'TCP': formatStateDescription(
'TCP (Transmission Control Protocol) is a connection-oriented protocol that provides reliable, ordered, and error-checked delivery of data between applications.',
'TCP establishes a connection before data transfer and ensures data integrity through acknowledgments and retransmissions. It guarantees that data is delivered in order and without errors.',
'<p>Common uses include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Web browsing (HTTP/HTTPS)</li><li>Email (SMTP, IMAP, POP3)</li><li>File transfers (FTP)</li><li>Remote access (SSH, RDP)</li><li>Database connections</li><li>API communications</li></ul>',
'TCP is the most commonly used transport protocol on the Internet and provides reliable communication at the cost of slightly higher overhead compared to UDP.'
),
'UDP': formatStateDescription(
'UDP (User Datagram Protocol) is a connectionless protocol that provides fast, low-overhead data transmission without guarantees of delivery, ordering, or duplicate protection.',
'UDP does not establish connections before sending data and does not provide reliability features like acknowledgments or retransmissions. It prioritizes speed over reliability.',
'<p>Common uses include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>DNS queries</li><li>Video streaming</li><li>Online gaming</li><li>VoIP (Voice over IP)</li><li>Real-time applications</li><li>DHCP (Dynamic Host Configuration Protocol)</li></ul>',
'UDP is ideal for applications where speed is more important than reliability, or where occasional packet loss is acceptable.'
),
'ICMP': formatStateDescription(
'ICMP (Internet Control Message Protocol) is a network layer protocol used for diagnostic and control purposes.',
'ICMP is used by network devices to send error messages and operational information. It is essential for network troubleshooting and does not carry application data.',
'<p>Common uses include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Ping (echo request/reply) for connectivity testing</li><li>Traceroute for path discovery</li><li>Error reporting (destination unreachable, time exceeded)</li><li>Network diagnostics</li></ul>',
'ICMP is a stateless protocol and does not use connection states like TCP or UDP. It operates at the network layer alongside IP.'
),
'ICMPV6': formatStateDescription(
'ICMPv6 (Internet Control Message Protocol version 6) is the IPv6 equivalent of ICMP, used for error reporting and diagnostic functions in IPv6 networks.',
'ICMPv6 includes additional features beyond what ICMP provides for IPv4, including integrated support for address configuration and network discovery.',
'<p>Key features include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Neighbor Discovery Protocol (NDP) for IPv6</li><li>Path MTU Discovery for IPv6</li><li>Router discovery and configuration</li><li>Error reporting and diagnostics</li></ul>',
'ICMPv6 is essential for IPv6 network operation and provides functions that in IPv4 are handled by multiple protocols (ARP, ICMP, etc.).'
),
};
return protocolDescriptions[protocolUpper] || formatStateDescription(
`${protocol} is a network protocol used for communication between devices.`,
'This protocol handles specific types of network traffic and follows particular rules for data transmission.',
''
);
};
</script>
</x-proxmox::layout>