refactor: enhance Proxmox Firewall log handling with improved pagination, batch fetching, and error handling for log retrieval

This commit is contained in:
2025-12-03 03:45:54 -05:00
parent ccb82d86c0
commit 2329897941
2 changed files with 222 additions and 46 deletions

View File

@@ -21,7 +21,7 @@ class Firewall extends Component
public array $networkAdapters = [];
public array $adapterFirewallStates = [];
public array $firewallLogs = [];
public int $logLimit = 100;
public int $logLimit = 1000;
public array $availableIps = [];
public bool $showDeleteModal = false;
public ?int $deletingRulePos = null;
@@ -108,7 +108,14 @@ class Firewall extends Component
return $request->asForm()->$method($fullUrl, $data)->throw();
}
return $request->$method($fullUrl, $data)->throw();
// For GET requests, if data is provided, it should be query parameters
if (strtolower($method) === 'get' && !empty($data)) {
$queryString = http_build_query($data);
$fullUrl .= (str_contains($fullUrl, '?') ? '&' : '?') . $queryString;
return $request->$method($fullUrl)->throw();
}
return $request->$method($fullUrl)->throw();
}
public function loadFirewallOptions(): void
@@ -799,54 +806,119 @@ class Firewall extends Component
// Proxmox API: GET /api2/json/nodes/{node}/qemu/{vmid}/firewall/log
// Documentation: https://pve.proxmox.com/pve-docs/api-viewer/#/nodes/{node}/qemu/{vmid}/firewall/log
// Returns: text/plain format with log entries, one per line
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/log',
'get',
[
'limit' => $this->logLimit,
'start' => 0
]
);
// Fetch logs in batches and collect all of them
$allRawLogs = [];
$start = 0;
$batchSize = 500;
$maxLogs = $this->logLimit;
$fetchedCount = 0;
// Keep fetching until we have all logs or reach the limit
do {
$response = $this->firewallRequest(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/log',
'get',
[
'limit' => $batchSize,
'start' => $start
]
);
if (! $response->successful()) {
$errorData = [];
try {
$errorData = $response->json();
} catch (\Exception $e) {
// Response might not be JSON
if (! $response->successful()) {
$errorData = [];
try {
$errorData = $response->json();
} catch (\Exception $e) {
// Response might not be JSON
}
$errorMsg = $errorData['message'] ?? 'HTTP ' . $response->status();
$this->logError = 'Failed to fetch logs: ' . $errorMsg;
\Log::warning('Firewall log API request failed', [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
'server_id' => $this->server->id,
'vm_id' => $this->server->vm_id,
]);
$this->firewallLogs = [];
return;
}
$errorMsg = $errorData['message'] ?? 'HTTP ' . $response->status();
$this->logError = 'Failed to fetch logs: ' . $errorMsg;
\Log::warning('Firewall log API request failed', [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
'server_id' => $this->server->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)) {
// Split by newlines (handle both \n and \r\n)
$lines = preg_split('/\r\n|\r|\n/', $body);
$rawLogs = array_filter($lines, static function ($line) {
return ! empty(trim($line));
});
// Proxmox firewall log endpoint can return either text/plain or JSON format
// Try to parse as JSON first, then fall back to text/plain
$body = $response->body();
$batchRawLogs = [];
// Check if response is JSON
$contentType = $response->header('Content-Type');
$isJson = str_contains($contentType ?? '', 'json');
if ($isJson) {
try {
$jsonData = $response->json();
// Proxmox may return logs in different formats:
// 1. { "data": [ {line}, {line}, ... ] }
// 2. { "data": "line1\nline2\n..." }
// 3. [ {line}, {line}, ... ]
if (isset($jsonData['data'])) {
if (is_array($jsonData['data'])) {
$batchRawLogs = $jsonData['data'];
} elseif (is_string($jsonData['data'])) {
$lines = preg_split('/\r\n|\r|\n/', $jsonData['data']);
$batchRawLogs = array_filter($lines, static function ($line) {
return ! empty(trim($line));
});
}
} elseif (is_array($jsonData)) {
$batchRawLogs = $jsonData;
}
} catch (\Exception $e) {
// Fall through to text parsing
\Log::warning('Failed to parse JSON response, falling back to text', [
'error' => $e->getMessage()
]);
}
}
// If no JSON data or JSON parsing failed, try text/plain format
if (empty($batchRawLogs) && ! empty($body)) {
// Split by newlines (handle both \n and \r\n)
$lines = preg_split('/\r\n|\r|\n/', $body);
$batchRawLogs = array_filter($lines, static function ($line) {
return ! empty(trim($line));
});
}
// Re-index array after filtering
$rawLogs = array_values($rawLogs);
$batchRawLogs = array_values($batchRawLogs);
// Add this batch to all logs
$allRawLogs = array_merge($allRawLogs, $batchRawLogs);
$fetchedCount = count($batchRawLogs);
// Move to next batch
$start += $batchSize;
// Break if we got fewer logs than requested (meaning we've reached the end)
// or if we've reached our limit
if ($fetchedCount < $batchSize || count($allRawLogs) >= $maxLogs) {
break;
}
} while (true);
// Limit to maxLogs
if (count($allRawLogs) > $maxLogs) {
$allRawLogs = array_slice($allRawLogs, 0, $maxLogs);
}
$rawLogs = $allRawLogs;
\Log::info('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[array_key_first($rawLogs)], 0, 200) : null,
'content_type' => $contentType,
'is_json' => $isJson,
'sample' => ! empty($rawLogs) ? substr(is_array($rawLogs[0]) ? json_encode($rawLogs[0]) : $rawLogs[0], 0, 200) : null,
'first_5_lines' => array_slice($rawLogs, 0, 5),
]);
@@ -858,6 +930,7 @@ class Firewall extends Component
if (is_string($logEntry)) {
$logText = trim($logEntry);
} elseif (is_array($logEntry)) {
// Handle various possible array formats from Proxmox API
$logText = trim(
$logEntry['raw']
?? $logEntry['n']
@@ -870,11 +943,21 @@ class Firewall extends Component
);
}
if ($logText === '') {
if ($logText === '' || strlen($logText) < 10) {
continue;
}
$this->firewallLogs[] = $this->parseFirewallLog($logText);
try {
$parsed = $this->parseFirewallLog($logText);
if ($parsed !== null) {
$this->firewallLogs[] = $parsed;
}
} catch (\Exception $e) {
\Log::warning('Failed to parse firewall log entry', [
'entry' => substr($logText, 0, 200),
'error' => $e->getMessage()
]);
}
}
\Log::info('Firewall logs final count', [

View File

@@ -328,6 +328,8 @@
searchTerm: '',
sortField: 'timestamp_iso',
sortDirection: 'desc',
currentPage: 1,
perPage: 10,
initialize(initialLogs) {
console.log('Initializing logs, received:', initialLogs ? initialLogs.length : 0, 'entries');
this.logs = (initialLogs || []).map((log, index) => ({
@@ -343,6 +345,7 @@
this.sortField = field;
this.sortDirection = 'asc';
}
this.currentPage = 1; // Reset to first page when sorting
},
get filteredLogs() {
const term = this.searchTerm.trim().toLowerCase();
@@ -379,8 +382,59 @@
return String(aVal).localeCompare(String(bVal)) * dir;
});
},
get paginatedLogs() {
const filtered = this.filteredLogs;
const start = (this.currentPage - 1) * this.perPage;
const end = start + this.perPage;
return filtered.slice(start, end);
},
get totalPages() {
return Math.ceil(this.filteredLogs.length / this.perPage);
},
get paginationRange() {
const total = this.totalPages;
const current = this.currentPage;
const delta = 2;
const range = [];
for (let i = Math.max(2, current - delta); i <= Math.min(total - 1, current + delta); i++) {
range.push(i);
}
if (current - delta > 2) {
range.unshift('...');
}
if (current + delta < total - 1) {
range.push('...');
}
if (total > 0) {
range.unshift(1);
}
if (total > 1) {
range.push(total);
}
return range;
},
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.currentPage = page;
}
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++;
}
},
prevPage() {
if (this.currentPage > 1) {
this.currentPage--;
}
}
}"
x-init="initialize(@js($firewallLogs))"
x-effect="if (currentPage > totalPages && totalPages > 0) currentPage = totalPages"
class="space-y-4"
>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
@@ -389,12 +443,18 @@
<input
type="text"
x-model="searchTerm"
@input="currentPage = 1"
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 class="flex items-center gap-4">
<div class="text-xs text-base/50">
<span x-text="`${filteredLogs.length} / ${logs.length} results`"></span>
</div>
<div class="text-xs text-base/50">
<span x-text="`Page ${currentPage} of ${totalPages || 1}`"></span>
</div>
</div>
</div>
@@ -453,7 +513,7 @@
</tr>
</thead>
<tbody>
<template x-for="(log, index) in filteredLogs" :key="index">
<template x-for="(log, index) in paginatedLogs" :key="log.index || 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">
@@ -473,6 +533,39 @@
</table>
</div>
<!-- Pagination Controls -->
<div x-show="totalPages > 1" class="flex items-center justify-center gap-2 mt-4">
<button
@click="prevPage()"
:disabled="currentPage === 1"
:class="currentPage === 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-primary/10'"
class="px-3 py-2 border border-neutral rounded-lg transition-colors text-sm font-medium">
<x-ri-arrow-left-s-line class="size-4" />
</button>
<template x-for="page in paginationRange" :key="page">
<button
@click="typeof page === 'number' ? goToPage(page) : null"
:class="{
'bg-primary text-white': page === currentPage,
'hover:bg-primary/10': typeof page === 'number' && page !== currentPage,
'cursor-default': typeof page !== 'number'
}"
:disabled="typeof page !== 'number'"
class="px-4 py-2 border border-neutral rounded-lg transition-colors text-sm font-medium min-w-[40px]"
x-text="page">
</button>
</template>
<button
@click="nextPage()"
:disabled="currentPage === totalPages"
:class="currentPage === totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-primary/10'"
class="px-3 py-2 border border-neutral rounded-lg transition-colors text-sm font-medium">
<x-ri-arrow-right-s-line class="size-4" />
</button>
</div>
<div x-show="filteredLogs.length === 0" x-cloak class="text-center py-8 text-base/60">
<x-ri-file-list-3-line class="size-12 mx-auto mb-3 opacity-50" />
<p>No logs matched your search.</p>