refactor: enhance Proxmox Firewall log handling with improved pagination, batch fetching, and error handling for log retrieval
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 = 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', [
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user