refactor: simplify Proxmox Firewall log handling by removing unnecessary log grouping and optimizing log fetching logic
This commit is contained in:
@@ -21,7 +21,7 @@ class Firewall extends Component
|
||||
public array $networkAdapters = [];
|
||||
public array $adapterFirewallStates = [];
|
||||
public array $firewallLogs = [];
|
||||
public int $logLimit = 500;
|
||||
public int $logLimit = 100;
|
||||
public array $availableIps = [];
|
||||
public bool $showDeleteModal = false;
|
||||
public ?int $deletingRulePos = null;
|
||||
@@ -148,11 +148,6 @@ class Firewall extends Component
|
||||
$newState = $currentState ? 0 : 1;
|
||||
$payload = ['enable' => $newState];
|
||||
|
||||
// If enabling firewall, also disable DHCP
|
||||
if ($newState === 1) {
|
||||
$payload['dhcp'] = 0;
|
||||
}
|
||||
|
||||
$response = $this->firewallRequest(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options',
|
||||
'put',
|
||||
@@ -807,10 +802,7 @@ class Firewall extends Component
|
||||
$response = $this->firewallRequest(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/log',
|
||||
'get',
|
||||
[
|
||||
'limit' => $this->logLimit,
|
||||
'start' => 0
|
||||
]
|
||||
['limit' => $this->logLimit]
|
||||
);
|
||||
|
||||
if (! $response->successful()) {
|
||||
@@ -832,50 +824,17 @@ class Firewall extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
// Proxmox firewall log endpoint returns text/plain format
|
||||
// Each log entry is on a new line
|
||||
$body = $response->body();
|
||||
$rawLogs = [];
|
||||
$contentType = strtolower($response->header('Content-Type', ''));
|
||||
|
||||
if (! empty($body)) {
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
// When Accept header allows JSON, Proxmox returns structured data
|
||||
$jsonData = $response->json();
|
||||
$entries = [];
|
||||
|
||||
if (is_array($jsonData)) {
|
||||
$entries = $jsonData['data'] ?? $jsonData;
|
||||
}
|
||||
|
||||
if (is_array($entries)) {
|
||||
foreach ($entries as $entry) {
|
||||
$logText = null;
|
||||
|
||||
if (is_array($entry)) {
|
||||
$logText = $entry['log']
|
||||
?? $entry['text']
|
||||
?? $entry['t']
|
||||
?? $entry['n']
|
||||
?? null;
|
||||
} elseif (is_string($entry)) {
|
||||
$logText = $entry;
|
||||
}
|
||||
|
||||
if (is_string($logText) && trim($logText) !== '') {
|
||||
$rawLogs[] = trim($logText);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Proxmox firewall log endpoint may also return text/plain
|
||||
// Each log entry is on a new line (handle both \n and \r\n)
|
||||
$lines = preg_split('/\r\n|\r|\n/', $body);
|
||||
foreach ($lines as $line) {
|
||||
$line = trim((string) $line);
|
||||
if ($line !== '') {
|
||||
$rawLogs[] = $line;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Split by newlines and filter empty lines
|
||||
$lines = explode("\n", trim($body));
|
||||
$rawLogs = array_filter($lines, static function ($line) {
|
||||
return ! empty(trim($line));
|
||||
});
|
||||
}
|
||||
|
||||
\Log::debug('Firewall logs fetched from Proxmox API', [
|
||||
@@ -883,12 +842,11 @@ class Firewall extends Component
|
||||
'body_length' => strlen($body),
|
||||
'content_type' => $response->header('Content-Type'),
|
||||
'sample' => ! empty($rawLogs) ? substr($rawLogs[array_key_first($rawLogs)], 0, 200) : null,
|
||||
'first_5_lines' => array_slice($rawLogs, 0, 5),
|
||||
]);
|
||||
|
||||
$this->firewallLogs = [];
|
||||
|
||||
foreach ($rawLogs as $index => $logEntry) {
|
||||
foreach ($rawLogs as $logEntry) {
|
||||
$logText = '';
|
||||
|
||||
if (is_string($logEntry)) {
|
||||
@@ -910,36 +868,14 @@ class Firewall extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedLog = $this->parseFirewallLog($logText);
|
||||
$this->firewallLogs[] = $parsedLog;
|
||||
|
||||
// Debug first few parsed logs
|
||||
if ($index < 3) {
|
||||
\Log::debug('Parsed firewall log entry', [
|
||||
'index' => $index,
|
||||
'raw' => substr($logText, 0, 200),
|
||||
'parsed' => $parsedLog,
|
||||
]);
|
||||
}
|
||||
$this->firewallLogs[] = $this->parseFirewallLog($logText);
|
||||
}
|
||||
|
||||
// Group similar logs
|
||||
$this->firewallLogs = $this->groupSimilarLogs($this->firewallLogs);
|
||||
|
||||
\Log::debug('Firewall logs processing complete', [
|
||||
'raw_logs_count' => count($rawLogs),
|
||||
'parsed_logs_count' => count($this->firewallLogs),
|
||||
'sample_parsed_logs' => array_slice($this->firewallLogs, 0, 3),
|
||||
]);
|
||||
|
||||
if (empty($this->firewallLogs) && ! empty($rawLogs)) {
|
||||
$this->logError = 'Logs received but could not be processed.';
|
||||
} elseif (empty($this->firewallLogs)) {
|
||||
$this->logError = 'No firewall logs available. Ensure firewall logging is enabled in Proxmox (Datacenter > Firewall > Options).';
|
||||
}
|
||||
|
||||
// Dispatch event to update frontend without full re-render
|
||||
$this->dispatch('logs-updated', logs: $this->firewallLogs);
|
||||
} catch (\Exception $e) {
|
||||
$errorMessage = $e->getMessage();
|
||||
if (method_exists($e, 'response') && $e->response()) {
|
||||
@@ -963,76 +899,6 @@ class Firewall extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group similar log entries together
|
||||
*/
|
||||
protected function groupSimilarLogs(array $logs): array
|
||||
{
|
||||
if (empty($logs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$grouped = [];
|
||||
$groups = [];
|
||||
|
||||
foreach ($logs as $log) {
|
||||
// Create a signature for grouping: action + protocol + source + destination + dest_port
|
||||
$signature = sprintf(
|
||||
'%s|%s|%s|%s|%s',
|
||||
$log['action'] ?? '',
|
||||
$log['protocol'] ?? '',
|
||||
$log['source'] ?? '',
|
||||
$log['destination'] ?? '',
|
||||
$log['destination_port'] ?? ''
|
||||
);
|
||||
|
||||
if (!isset($groups[$signature])) {
|
||||
$groups[$signature] = [
|
||||
'signature' => $signature,
|
||||
'count' => 0,
|
||||
'first_log' => $log,
|
||||
'last_timestamp' => $log['timestamp_iso'] ?? null,
|
||||
'first_timestamp' => $log['timestamp_iso'] ?? null,
|
||||
'logs' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$groups[$signature]['count']++;
|
||||
$groups[$signature]['logs'][] = $log;
|
||||
|
||||
// Update timestamps
|
||||
if ($log['timestamp_iso']) {
|
||||
if (!$groups[$signature]['first_timestamp'] || $log['timestamp_iso'] < $groups[$signature]['first_timestamp']) {
|
||||
$groups[$signature]['first_timestamp'] = $log['timestamp_iso'];
|
||||
}
|
||||
if (!$groups[$signature]['last_timestamp'] || $log['timestamp_iso'] > $groups[$signature]['last_timestamp']) {
|
||||
$groups[$signature]['last_timestamp'] = $log['timestamp_iso'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert groups to array and add group metadata
|
||||
foreach ($groups as $group) {
|
||||
$firstLog = $group['first_log'];
|
||||
$firstLog['is_group'] = $group['count'] > 1;
|
||||
$firstLog['group_count'] = $group['count'];
|
||||
$firstLog['grouped_logs'] = $group['logs'];
|
||||
$firstLog['group_first_timestamp'] = $group['first_timestamp'];
|
||||
$firstLog['group_last_timestamp'] = $group['last_timestamp'];
|
||||
|
||||
$grouped[] = $firstLog;
|
||||
}
|
||||
|
||||
// Sort by most recent timestamp
|
||||
usort($grouped, function($a, $b) {
|
||||
$aTime = $a['group_last_timestamp'] ?? $a['timestamp_iso'] ?? '';
|
||||
$bTime = $b['group_last_timestamp'] ?? $b['timestamp_iso'] ?? '';
|
||||
return strcmp($bTime, $aTime); // Descending order (most recent first)
|
||||
});
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
protected function parseFirewallLog(string $logText): array
|
||||
{
|
||||
$result = [
|
||||
@@ -1135,101 +1001,6 @@ class Firewall extends Component
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address is public (callable from frontend)
|
||||
*/
|
||||
public function checkIsPublicIP(string $ip): bool
|
||||
{
|
||||
return $this->isPublicIP($ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address is public (not private/localhost)
|
||||
*/
|
||||
protected function isPublicIP(string $ip): bool
|
||||
{
|
||||
$ip = $this->extractIPFromString($ip);
|
||||
|
||||
if (empty($ip)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for localhost
|
||||
if (in_array(strtolower($ip), ['localhost', '127.0.0.1', '::1'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate IP format
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a private IP
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract IP address from a string (removes port if present)
|
||||
*/
|
||||
protected function extractIPFromString(string $input): string
|
||||
{
|
||||
$input = trim($input);
|
||||
|
||||
// Handle IPv6 with port: [2001:db8::1]:8080
|
||||
if (preg_match('/^\[([^\]]+)\](?::\d+)?$/', $input, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// Handle IPv4 with port: 192.168.1.1:8080
|
||||
if (preg_match('/^([^:]+):\d+$/', $input, $matches)) {
|
||||
// Make sure it's not IPv6 without brackets
|
||||
if (!str_contains($matches[1], ':')) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Return as-is if no port detected
|
||||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch IP geolocation data
|
||||
*/
|
||||
public function fetchIPLocation($ip): array
|
||||
{
|
||||
if (empty($ip)) {
|
||||
return ['status' => 'fail', 'message' => 'IP address is required'];
|
||||
}
|
||||
|
||||
// Extract and validate IP address
|
||||
$cleanIP = $this->extractIPFromString((string)$ip);
|
||||
|
||||
if (!$this->isPublicIP($cleanIP)) {
|
||||
return ['status' => 'fail', 'message' => 'Not a valid public IP address'];
|
||||
}
|
||||
|
||||
try {
|
||||
// Use HTTP protocol to fetch geolocation data
|
||||
$response = \Illuminate\Support\Facades\Http::timeout(5)->get("http://ip-api.com/json/{$cleanIP}");
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
return ['status' => 'fail', 'message' => 'Failed to fetch location data'];
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Error fetching IP location', [
|
||||
'ip' => $cleanIP,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['status' => 'fail', 'message' => 'Error: ' . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('proxmox::firewall')->layoutData([
|
||||
|
||||
@@ -74,9 +74,6 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.firewall-log-search-input {
|
||||
padding-left: 2.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -328,41 +325,11 @@
|
||||
searchTerm: '',
|
||||
sortField: 'timestamp_iso',
|
||||
sortDirection: 'desc',
|
||||
initialized: false,
|
||||
seenRaws: new Set(),
|
||||
initialize(initialLogs) {
|
||||
if (this.initialized) return;
|
||||
|
||||
if (initialLogs && initialLogs.length > 0) {
|
||||
this.logs = initialLogs.map((log, index) => ({
|
||||
index,
|
||||
...log,
|
||||
}));
|
||||
// Track which logs we've seen
|
||||
this.logs.forEach(log => {
|
||||
if (log.raw) this.seenRaws.add(log.raw);
|
||||
});
|
||||
this.initialized = true;
|
||||
}
|
||||
},
|
||||
updateLogs(newLogs) {
|
||||
if (!newLogs || newLogs.length === 0) return;
|
||||
|
||||
// Filter out logs we've already seen
|
||||
const logsToAdd = newLogs.filter(log => {
|
||||
if (!log.raw) return false;
|
||||
if (this.seenRaws.has(log.raw)) return false;
|
||||
this.seenRaws.add(log.raw);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (logsToAdd.length > 0) {
|
||||
// Add new logs to the beginning (most recent first)
|
||||
this.logs = [...logsToAdd, ...this.logs].map((log, index) => ({
|
||||
index,
|
||||
...log,
|
||||
}));
|
||||
}
|
||||
this.logs = (initialLogs || []).map((log, index) => ({
|
||||
index,
|
||||
...log,
|
||||
}));
|
||||
},
|
||||
sort(field) {
|
||||
if (this.sortField === field) {
|
||||
@@ -409,14 +376,6 @@
|
||||
},
|
||||
}"
|
||||
x-init="initialize(@js($firewallLogs))"
|
||||
@logs-updated.window="updateLogs($event.detail.logs || $event.detail[0]?.logs || $event.detail[0] || [])"
|
||||
x-effect="
|
||||
// Watch for changes in the backend data and merge new logs
|
||||
const backendLogs = @js($firewallLogs);
|
||||
if (initialized && backendLogs && backendLogs.length > 0) {
|
||||
updateLogs(backendLogs);
|
||||
}
|
||||
"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
@@ -426,7 +385,7 @@
|
||||
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"
|
||||
class="w-full pl-10 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">
|
||||
@@ -490,543 +449,20 @@
|
||||
</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>
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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" x-text="log.protocol || '-' "></td>
|
||||
<td class="py-3 px-4 text-sm font-mono" x-text="log.source || '-' "></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>
|
||||
<td class="py-3 px-4 text-sm font-mono" x-text="log.destination || '-' "></td>
|
||||
<td class="py-3 px-4 text-sm" x-text="log.destination_port || '-' "></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1295,161 +731,4 @@
|
||||
@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>
|
||||
|
||||
Reference in New Issue
Block a user