1455 lines
118 KiB
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>
|
|
|