refactor: enhance Proxmox Firewall log display with improved sorting, searching, and structured log entries for better user experience

This commit is contained in:
2025-12-01 17:31:49 -05:00
parent 2f5dddf858
commit 79c76a4aa2
3 changed files with 285 additions and 81 deletions

View File

@@ -6,6 +6,7 @@ use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
use Carbon\Carbon;
class Firewall extends Component
{
@@ -803,8 +804,8 @@ class Firewall extends Component
'get',
['limit' => $this->logLimit]
);
if (!$response->successful()) {
if (! $response->successful()) {
$errorData = [];
try {
$errorData = $response->json();
@@ -817,45 +818,60 @@ class Firewall extends Component
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
'server_id' => $this->server->id,
'vm_id' => $this->server->vm_id
'vm_id' => $this->server->vm_id,
]);
$this->firewallLogs = [];
return;
}
// Proxmox firewall log endpoint returns text/plain format
// Each log entry is on a new line
$body = $response->body();
$rawLogs = [];
if (!empty($body)) {
if (! empty($body)) {
// Split by newlines and filter empty lines
$lines = explode("\n", trim($body));
$rawLogs = array_filter($lines, function($line) {
return !empty(trim($line));
$rawLogs = array_filter($lines, static function ($line) {
return ! empty(trim($line));
});
}
\Log::debug('Firewall logs fetched from Proxmox API', [
'raw_count' => count($rawLogs),
'body_length' => strlen($body),
'content_type' => $response->header('Content-Type'),
'sample' => !empty($rawLogs) ? substr($rawLogs[0], 0, 200) : null
'sample' => ! empty($rawLogs) ? substr($rawLogs[array_key_first($rawLogs)], 0, 200) : null,
]);
$this->firewallLogs = [];
// Store raw log entries exactly as received from Proxmox (no parsing/sanitization)
foreach ($rawLogs as $logEntry) {
$logText = '';
if (is_string($logEntry)) {
$logText = trim($logEntry);
if (!empty($logText)) {
$this->firewallLogs[] = ['raw' => $logText];
}
} elseif (is_array($logEntry)) {
$logText = trim(
$logEntry['raw']
?? $logEntry['n']
?? $logEntry['t']
?? $logEntry['text']
?? $logEntry['log']
?? $logEntry['message']
?? $logEntry['line']
?? json_encode($logEntry)
);
}
if ($logText === '') {
continue;
}
$this->firewallLogs[] = $this->parseFirewallLog($logText);
}
if (empty($this->firewallLogs) && !empty($rawLogs)) {
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).';
@@ -877,80 +893,112 @@ class Firewall extends Component
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'server_id' => $this->server->id,
'vm_id' => $this->server->vm_id
'vm_id' => $this->server->vm_id,
]);
$this->firewallLogs = [];
}
}
protected function parseFirewallLog(string $logText): ?array
protected function parseFirewallLog(string $logText): array
{
// Example log: 202500009 2 tap202500009i0-IN 01/Dec/2025:09:00:39 -1000 REJECT: IN=fwbr202500009i0 OUT=fwbr202500009i0 PHYSIN=fwln202500009i0 PHYSOUT=tap202500009i0 MAC=8e:ef:c1:86:d4:20:58:9c:fc:10:ff:d3:08:00 SRC=24.210.66.222 DST=45.154.99.21 LEN=52 TOS=0x00 PREC=0x00 TTL=115 ID=58510 DF PROTO=TCP SPT=50692 DPT=22 SEQ=2584199599 ACK=0 WINDOW=65535 SYN
// Extract SRC, DST, TTL, PROTO, SPT, DPT, ACK
$result = [
'source' => null,
'destination' => null,
'ttl' => null,
'proto' => null,
'spt' => null,
'dpt' => null,
'ack' => null,
'raw' => $logText,
'timestamp_display' => null,
'timestamp_iso' => null,
'action' => null,
'timestamp' => null,
'action_badge_class' => 'bg-base/20 text-base/70',
'level_numeric' => null,
'level_label' => null,
'level_badge_class' => 'bg-base/20 text-base/70',
'protocol' => null,
'source' => null,
'source_port' => null,
'destination' => null,
'destination_port' => null,
'iface' => null,
];
// Extract action (REJECT, ACCEPT, DROP) - usually before the colon
if (preg_match('/(REJECT|ACCEPT|DROP):/i', $logText, $matches)) {
$result['action'] = strtoupper($matches[1]);
// Extract level and interface (first tokens)
if (preg_match('/^\S+\s+(\d+)\s+([^\s]+)/', $logText, $matches)) {
$levelNumeric = (int) $matches[1];
$iface = $matches[2];
$priMap = [
0 => 'emerg',
1 => 'alert',
2 => 'crit',
3 => 'err',
4 => 'warning',
5 => 'notice',
6 => 'info',
7 => 'debug',
];
$levelLabel = $priMap[$levelNumeric] ?? 'info';
$levelBadgeMap = [
'emerg' => 'bg-error text-white',
'alert' => 'bg-error/80 text-white',
'crit' => 'bg-error/60 text-white',
'err' => 'bg-error/20 text-error',
'warning' => 'bg-warning/20 text-warning',
'notice' => 'bg-primary/20 text-primary',
'info' => 'bg-primary/20 text-primary',
'debug' => 'bg-base/20 text-base/70',
];
$result['level_numeric'] = $levelNumeric;
$result['level_label'] = strtoupper($levelLabel);
$result['level_badge_class'] = $levelBadgeMap[$levelLabel] ?? 'bg-base/20 text-base/70';
$result['iface'] = $iface;
}
// Extract timestamp - look for date pattern like "01/Dec/2025:09:00:39"
if (preg_match('/(\d{2}\/\w{3}\/\d{4}:\d{2}:\d{2}:\d{2})/', $logText, $matches)) {
$result['timestamp'] = $matches[1];
// Extract timestamp and timezone
if (preg_match('/(\d{2}\/\w{3}\/\d{4}:\d{2}:\d{2}:\d{2})\s+([+-]\d{4})/', $logText, $matches)) {
$timestamp = $matches[1];
$tz = $matches[2];
$tzNormalized = substr($tz, 0, 3) . ':' . substr($tz, 3);
try {
$carbon = Carbon::createFromFormat('d/M/Y:H:i:s P', $timestamp . ' ' . $tzNormalized);
$result['timestamp_iso'] = $carbon->toIso8601String();
$result['timestamp_display'] = $carbon->format('Y-m-d H:i:s T');
} catch (\Exception $e) {
$result['timestamp_display'] = str_replace('/', '-', $timestamp) . ' ' . $tzNormalized;
}
}
// Extract SRC
// Extract action (REJECT, ACCEPT, DROP)
if (preg_match('/\b(REJECT|ACCEPT|DROP):/i', $logText, $matches)) {
$action = strtoupper($matches[1]);
$actionBadgeMap = [
'ACCEPT' => 'bg-success/20 text-success',
'DROP' => 'bg-error/20 text-error',
'REJECT' => 'bg-warning/20 text-warning',
];
$result['action'] = $action;
$result['action_badge_class'] = $actionBadgeMap[$action] ?? 'bg-base/20 text-base/70';
}
// Extract protocol
if (preg_match('/PROTO=([^\s]+)/', $logText, $matches)) {
$result['protocol'] = strtoupper($matches[1]);
}
// Extract source/destination IPs and ports
if (preg_match('/SRC=([^\s]+)/', $logText, $matches)) {
$result['source'] = $matches[1];
}
// Extract DST
if (preg_match('/DST=([^\s]+)/', $logText, $matches)) {
$result['destination'] = $matches[1];
}
// Extract TTL
if (preg_match('/TTL=(\d+)/', $logText, $matches)) {
$result['ttl'] = (int) $matches[1];
}
// Extract PROTO
if (preg_match('/PROTO=(\w+)/', $logText, $matches)) {
$result['proto'] = strtoupper($matches[1]);
}
// Extract SPT (Source Port)
if (preg_match('/SPT=(\d+)/', $logText, $matches)) {
$result['spt'] = (int) $matches[1];
$result['source_port'] = $matches[1];
}
// Extract DPT (Destination Port)
if (preg_match('/DPT=(\d+)/', $logText, $matches)) {
$result['dpt'] = (int) $matches[1];
$result['destination_port'] = $matches[1];
}
// Extract ACK
if (preg_match('/ACK=(\d+)/', $logText, $matches)) {
$result['ack'] = (int) $matches[1];
}
// Only return if we have at least some data
if ($result['source'] || $result['destination'] || $result['proto']) {
return $result;
}
return null;
return $result;
}
public function render()

View File

@@ -225,6 +225,17 @@
}
</style>
<div class="hidden lg:flex flex-row gap-2 items-baseline">
<style>
.proxmox-tab-link {
font-size: 0.8125rem; /* text-[13px] */
font-weight: 600;
padding: 0.35rem 0.6rem;
border-radius: 0.4rem;
letter-spacing: -0.01em;
gap: 0.35rem;
white-space: nowrap;
}
</style>
@foreach($tabs as $tab)
@php
$isAdvancedTab = $tab['advanced'] ?? false;
@@ -235,13 +246,13 @@
x-transition
@endif>
<a href="{{ $tab['route'] }}" wire:navigate
class="flex items-center gap-2 justify-center text-base font-semibold py-2.5 lg:py-2 px-3 rounded-md hover:bg-background-secondary/80 transition-colors duration-300 {{ $view === $tab['view'] ? 'text-base' : 'text-base/70' }}">
class="flex items-center proxmox-tab-link justify-center hover:bg-background-secondary/80 transition-colors duration-200 {{ $view === $tab['view'] ? 'text-base' : 'text-base/70' }}">
{{ $tab['label'] }}
</a>
@if($view === $tab['view'])
<span class="block h-0.5 w-full mt-1.5 rounded-full bg-base"></span>
<span class="block h-0.5 w-full mt-1 rounded-full bg-base"></span>
@else
<span class="block h-0.5 w-full mt-1.5 rounded-full bg-transparent"></span>
<span class="block h-0.5 w-full mt-1 rounded-full bg-transparent"></span>
@endif
</div>
@endforeach

View File

@@ -319,13 +319,158 @@
@endif
@if(count($firewallLogs) > 0)
<div class="overflow-x-auto">
<div class="space-y-2">
@foreach($firewallLogs as $log)
<div class="bg-background-secondary border border-neutral/20 rounded-lg p-3">
<code class="text-xs text-base/90 whitespace-pre-wrap break-all font-mono">{{ $log['raw'] ?? '' }}</code>
</div>
@endforeach
<div
x-data="{
logs: [],
searchTerm: '',
sortField: 'timestamp_iso',
sortDirection: 'desc',
initialize(initialLogs) {
this.logs = (initialLogs || []).map((log, index) => ({
index,
...log,
}));
},
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;
});
},
}"
x-init="initialize(@js($firewallLogs))"
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="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">
<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">
<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" 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 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>
</div>
<div x-show="filteredLogs.length === 0" x-cloak class="text-center py-8 text-base/60">
<x-ri-file-list-3-line class="size-12 mx-auto mb-3 opacity-50" />
<p>No logs matched your search.</p>
</div>
</div>
@elseif(empty($logError))