- Remove @entangle which caused server round-trip on modal open - Pass statistic data inline via Alpine event dispatch instead of fetching from DB - Add direct deleteStatistic(id) method for simpler deletion - Modal now opens immediately without any server communication
5254 lines
388 KiB
PHP
5254 lines
388 KiB
PHP
<x-proxmox::layout :server="$server" view="statistics" :status="$status" :status-polling="true" :status-polling-interval="'2s'">
|
||
@once
|
||
<script src="https://cdn.jsdelivr.net/npm/apexcharts" onload="window.apexChartsLoaded = true"></script>
|
||
@endonce
|
||
<div class="flex flex-col gap-6">
|
||
<style>
|
||
.formRight select {
|
||
background-color: hsl(var(--color-background));
|
||
border: 1px solid hsl(var(--color-neutral));
|
||
border-radius: 0.375rem;
|
||
padding: 0.5rem 0.75rem;
|
||
font-size: 0.875rem;
|
||
line-height: 1.25rem;
|
||
outline: none;
|
||
}
|
||
|
||
.formRight select:focus {
|
||
outline: none;
|
||
box-shadow: 0 0 0 2px hsl(var(--color-secondary));
|
||
}
|
||
</style>
|
||
<!-- Header Section -->
|
||
<div class="flex flex-col gap-4">
|
||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||
<div class="flex flex-col gap-1">
|
||
<div class="flex items-center gap-2">
|
||
<h1 class="text-xl font-semibold">Statistics</h1>
|
||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||
<x-ri-question-line class="size-5" />
|
||
</button>
|
||
<!-- Modal Backdrop -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-5" />
|
||
</button>
|
||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||
<x-ri-bar-chart-line class="size-5 text-primary" />
|
||
</div>
|
||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||
Statistics
|
||
</h6>
|
||
</div>
|
||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||
The Statistics page provides comprehensive monitoring and analysis tools for your virtual machine's performance. This page includes real-time telemetry graphs, historical data snapshots, task execution history, lifecycle events tracking, and comparison tools to help you understand your server's behavior over time.
|
||
</p>
|
||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Live Telemetry:</span> Four interactive performance graphs display real-time data from the Proxmox RRD API, including Processor Utilization, Network Traffic, Memory Utilization, and Storage I/O. Each graph supports time range selection (Hour, Day, Week, Month, Year), aggregation methods (Average/Maximum), and CSV export functionality. Graphs automatically refresh every 2 minutes.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Statistics History:</span> View historical performance snapshots in a sortable and filterable table. The system automatically records statistics hourly (up to 24 entries), and you can create up to 5 manual snapshots. Each entry includes CPU, memory, disk usage, network traffic, disk I/O, uptime, and VM status. All data can be exported to CSV.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Task History:</span> Monitor all executed tasks on your virtual machine, including their type, status, start/end times, and duration. Expand any task to view detailed execution logs and metadata. The table supports sorting, filtering, and CSV export for analysis and troubleshooting.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Recent Lifecycle Events:</span> Track critical state changes and operations such as power state changes (start, shutdown, reboot, reset) and backup operations. Events are automatically extracted from task history and displayed with status indicators, timing information, and detailed metadata. The table auto-refreshes every 10 seconds and supports sorting, filtering, and export.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Historical Data Comparison:</span> Compare current server statistics with historical data from a previous time period. Select both historical and current periods (with custom date/time options), then view side-by-side comparisons of all metrics with percentage change indicators. This helps identify trends, performance changes, and resource utilization patterns over time. All comparison data can be exported to CSV.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="text-base/70">
|
||
Monitor your server's performance metrics and resource utilization over time.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Performance Telemetry -->
|
||
<section
|
||
wire:poll.120s="stats"
|
||
x-data="{ chartsLoaded: false }"
|
||
@proxmox-all-stats-loaded.window="chartsLoaded = true"
|
||
class="relative bg-background-secondary border border-neutral rounded-2xl p-6 space-y-6 shadow-sm overflow-hidden transition-opacity duration-300 ease-out"
|
||
:class="chartsLoaded ? 'opacity-100' : 'opacity-80'"
|
||
>
|
||
<div>
|
||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||
<div>
|
||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||
<x-ri-line-chart-line class="size-5" />
|
||
Live Telemetry
|
||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||
<x-ri-question-line class="size-4" />
|
||
</button>
|
||
<!-- Modal Backdrop -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-5" />
|
||
</button>
|
||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||
<x-ri-line-chart-line class="size-5 text-primary" />
|
||
</div>
|
||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||
Live Telemetry
|
||
</h6>
|
||
</div>
|
||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||
This section displays four interactive performance graphs that visualize real-time telemetry data from your virtual machine. Each graph represents a different aspect of system performance, sourced directly from the Proxmox RRD (Round Robin Database) API, providing high-resolution metrics for monitoring resource utilization and identifying performance patterns.
|
||
</p>
|
||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Processor Utilization Graph:</span> Displays CPU usage as a percentage over time, showing how much of the allocated CPU resources are being consumed. This graph helps identify CPU-intensive periods and potential performance bottlenecks.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Network Traffic Graph:</span> Visualizes network activity by showing both incoming (download) and outgoing (upload) data transfer rates. This graph helps monitor bandwidth usage and network performance, useful for identifying network-intensive operations or potential connectivity issues.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Memory Utilization Graph:</span> Shows RAM usage over time, displaying both current memory consumption and the maximum allocated memory. This graph is essential for monitoring memory pressure and identifying potential memory leaks or insufficient allocation.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Storage I/O Graph:</span> Tracks disk input/output operations by displaying both read and write rates. This graph helps monitor storage performance, identify I/O bottlenecks, and track disk-intensive operations such as backups or data transfers.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Time Range Selection:</span> Each graph allows you to select from Hour, Day, Week, Month, or Year time ranges to view performance data over different periods. This flexibility enables both short-term troubleshooting and long-term trend analysis.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Aggregation Method:</span> Choose between Average (mean values over the time period) or Maximum (peak values) aggregation methods. Average helps identify typical usage patterns, while Maximum reveals peak resource demands and potential capacity issues.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Auto-Refresh:</span> Graphs automatically refresh every 2 minutes to ensure you're always viewing current performance data. The refresh interval is optimized to balance real-time monitoring with server load.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Export Functionality:</span> Each graph includes an export button that allows you to download the currently displayed data as a CSV file. This enables detailed analysis in spreadsheet applications, reporting, and long-term archival of performance metrics.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</h2>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="grid grid-cols-1 gap-6 xl:grid-cols-2 transition-[max-height] duration-300 ease-out"
|
||
:class="chartsLoaded ? 'max-h-none' : 'max-h-[260px] overflow-hidden'"
|
||
>
|
||
<!-- Processor Utilization Graph -->
|
||
<div>
|
||
<div class="bg-background-secondary hover:bg-background-secondary/80 border border-neutral p-4 rounded-lg">
|
||
<div class="flex flex-col gap-2">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||
<x-ri-cpu-line class="size-5 rotate-45" />
|
||
Processor Utilization
|
||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||
<x-ri-question-line class="size-4" />
|
||
</button>
|
||
<!-- Modal Backdrop -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-5" />
|
||
</button>
|
||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||
<x-ri-cpu-line class="size-5 text-primary" />
|
||
</div>
|
||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||
Processor Utilization
|
||
</h6>
|
||
</div>
|
||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||
This graph displays the CPU utilization percentage over time. It shows how much of the server's processing capacity is being used, helping you monitor performance and identify potential bottlenecks or resource constraints.
|
||
</p>
|
||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Time Range:</span> Select from Hour, Day, Week, Month, or Year to view CPU usage over different time periods.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Aggregation:</span> Choose between Average (mean CPU usage) or Maximum (peak CPU usage) to see different perspectives of CPU performance.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Export:</span> CPU usage data can be exported to CSV format for further analysis or reporting.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</h2>
|
||
</div>
|
||
<div class="formRight flex items-center gap-2">
|
||
<select wire:model.live="cpuTimeRange">
|
||
<option value="hour">Hour</option>
|
||
<option value="day">Day</option>
|
||
<option value="week">Week</option>
|
||
<option value="month">Month</option>
|
||
<option value="year">Year</option>
|
||
</select>
|
||
<select wire:model.live="cpuAggregation">
|
||
<option value="average">Average</option>
|
||
<option value="maximum">Maximum</option>
|
||
</select>
|
||
<button type="button" onclick="exportCpuChartToCSV()"
|
||
class="bg-background border border-neutral px-3 py-2 rounded-md text-sm font-semibold hover:bg-background/80 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
||
title="Export to CSV">
|
||
<x-ri-download-line class="size-4" />
|
||
Export
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="cpu-chart" wire:ignore></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Network Traffic Graph -->
|
||
<div>
|
||
<div class="bg-background-secondary hover:bg-background-secondary/80 border border-neutral p-4 rounded-lg">
|
||
<div class="flex flex-col gap-2">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||
<x-ri-arrow-left-right-line class="size-5 rotate-45" />
|
||
Network Traffic
|
||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||
<x-ri-question-line class="size-4" />
|
||
</button>
|
||
<!-- Modal Backdrop -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-5" />
|
||
</button>
|
||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||
<x-ri-arrow-left-right-line class="size-5 text-primary" />
|
||
</div>
|
||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||
Network Traffic
|
||
</h6>
|
||
</div>
|
||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||
This graph displays network traffic over time, showing both incoming (Network In) and outgoing (Network Out) data rates. It helps you monitor bandwidth utilization, identify network bottlenecks, and track data transfer patterns.
|
||
</p>
|
||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Time Range:</span> Select from Hour, Day, Week, Month, or Year to view network usage over different time periods.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Aggregation:</span> Choose between Average (mean network traffic) or Maximum (peak network traffic) to see different perspectives of network performance.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Export:</span> Network usage data can be exported to CSV format for further analysis or reporting.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</h2>
|
||
</div>
|
||
<div class="formRight flex flex-wrap items-center gap-2 text-sm">
|
||
<select wire:model.live="networkTimeRange">
|
||
<option value="hour">Hour</option>
|
||
<option value="day">Day</option>
|
||
<option value="week">Week</option>
|
||
<option value="month">Month</option>
|
||
<option value="year">Year</option>
|
||
</select>
|
||
<select wire:model.live="networkAggregation">
|
||
<option value="average">Average</option>
|
||
<option value="maximum">Maximum</option>
|
||
</select>
|
||
<button type="button" onclick="exportNetworkChartToCSV()"
|
||
class="bg-background border border-neutral px-3 py-2 rounded-md text-sm font-semibold hover:bg-background/80 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
||
title="Export to CSV">
|
||
<x-ri-download-line class="size-4" />
|
||
Export
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="network-chart" wire:ignore></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Memory Utilization Graph -->
|
||
<div>
|
||
<div class="bg-background-secondary hover:bg-background-secondary/80 border border-neutral p-4 rounded-lg">
|
||
<div class="flex flex-col gap-2">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||
<x-ri-ram-line class="size-5 rotate-45" />
|
||
Memory Utilization
|
||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||
<x-ri-question-line class="size-4" />
|
||
</button>
|
||
<!-- Modal Backdrop -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-5" />
|
||
</button>
|
||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||
<x-ri-ram-line class="size-5 text-primary" />
|
||
</div>
|
||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||
Memory Utilization
|
||
</h6>
|
||
</div>
|
||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||
This graph displays memory utilization over time, showing both the current memory usage and the maximum available memory. It helps you monitor RAM consumption, identify memory leaks, and track memory allocation patterns.
|
||
</p>
|
||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Time Range:</span> Select from Hour, Day, Week, Month, or Year to view memory usage over different time periods.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Aggregation:</span> Choose between Average (mean memory usage) or Maximum (peak memory usage) to see different perspectives of memory performance.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Export:</span> Memory usage data can be exported to CSV format for further analysis or reporting.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</h2>
|
||
</div>
|
||
<div class="formRight flex flex-wrap items-center gap-2 text-sm">
|
||
<select wire:model.live="memoryTimeRange">
|
||
<option value="hour">Hour</option>
|
||
<option value="day">Day</option>
|
||
<option value="week">Week</option>
|
||
<option value="month">Month</option>
|
||
<option value="year">Year</option>
|
||
</select>
|
||
<select wire:model.live="memoryAggregation">
|
||
<option value="average">Average</option>
|
||
<option value="maximum">Maximum</option>
|
||
</select>
|
||
<button type="button" onclick="exportMemoryChartToCSV()"
|
||
class="bg-background border border-neutral px-3 py-2 rounded-md text-sm font-semibold hover:bg-background/80 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
||
title="Export to CSV">
|
||
<x-ri-download-line class="size-4" />
|
||
Export
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="memory-chart" wire:ignore></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Storage I/O Graph -->
|
||
<div>
|
||
<div class="bg-background-secondary hover:bg-background-secondary/80 border border-neutral p-4 rounded-lg">
|
||
<div class="flex flex-col gap-2">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||
<x-ri-hard-drive-line class="size-5 rotate-45" />
|
||
Storage I/O
|
||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||
<x-ri-question-line class="size-4" />
|
||
</button>
|
||
<!-- Modal Backdrop -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-5" />
|
||
</button>
|
||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||
<x-ri-hard-drive-line class="size-5 text-primary" />
|
||
</div>
|
||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||
Storage I/O
|
||
</h6>
|
||
</div>
|
||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||
This graph displays disk I/O throughput over time, showing both disk read and disk write rates. It helps you monitor storage performance, identify I/O bottlenecks, and track disk activity patterns.
|
||
</p>
|
||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Time Range:</span> Select from Hour, Day, Week, Month, or Year to view disk usage over different time periods.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Aggregation:</span> Choose between Average (mean disk I/O) or Maximum (peak disk I/O) to see different perspectives of disk performance.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Export:</span> Disk usage data can be exported to CSV format for further analysis or reporting.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</h2>
|
||
</div>
|
||
<div class="formRight flex flex-wrap items-center gap-2 text-sm">
|
||
<select wire:model.live="diskTimeRange">
|
||
<option value="hour">Hour</option>
|
||
<option value="day">Day</option>
|
||
<option value="week">Week</option>
|
||
<option value="month">Month</option>
|
||
<option value="year">Year</option>
|
||
</select>
|
||
<select wire:model.live="diskAggregation">
|
||
<option value="average">Average</option>
|
||
<option value="maximum">Maximum</option>
|
||
</select>
|
||
<button type="button" onclick="exportDiskChartToCSV()"
|
||
class="bg-background border border-neutral px-3 py-2 rounded-md text-sm font-semibold hover:bg-background/80 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
||
title="Export to CSV">
|
||
<x-ri-download-line class="size-4" />
|
||
Export
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="disk-chart" wire:ignore></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Statistics History Table -->
|
||
@php
|
||
$statisticsExportRows = [];
|
||
@endphp
|
||
<div class="bg-background-secondary border border-neutral rounded-xl overflow-hidden" id="statistics-history-card">
|
||
<div class="p-6 border-b border-neutral flex items-center justify-between gap-3 flex-wrap">
|
||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||
<x-ri-history-line class="size-5" />
|
||
Statistics History
|
||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||
<x-ri-question-line class="size-4" />
|
||
</button>
|
||
<!-- Modal Backdrop -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-5" />
|
||
</button>
|
||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||
<x-ri-history-line class="size-5 text-primary" />
|
||
</div>
|
||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||
Statistics History
|
||
</h6>
|
||
</div>
|
||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||
View historical performance data for your virtual machine. Each entry represents a snapshot of your server's resource utilization at a specific point in time.
|
||
</p>
|
||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Automatic Statistics:</span> Collected hourly in the background for all VPS. The system maintains a maximum of 24 automatic statistics, automatically removing the oldest entries when the limit is reached.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Manual Statistics:</span> You can create up to 5 manual statistics snapshots. These appear alongside automatic statistics and can be deleted individually if needed.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Metrics Displayed:</span> CPU and Memory show percentage utilization. Network In/Out and Disk I/O display the difference since the previous statistic. Disk shows percentage of total disk space used. Uptime indicates how long the VM has been running.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Deleting Statistics:</span> You can delete individual statistics entries by clicking the delete icon. Manual statistics can be removed to free up space for new manual snapshots.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</h2>
|
||
<div class="flex items-center gap-2"
|
||
x-data
|
||
@statistics-export:no-data.window="$wire.showExportInfo($event.detail.message)">
|
||
<x-button.primary wire:click="createLiveStatistic" class="whitespace-nowrap">
|
||
<x-loading target="createLiveStatistic" />
|
||
<x-ri-refresh-line class="size-4" />
|
||
<div wire:loading.remove wire:target="createLiveStatistic">
|
||
New Report
|
||
</div>
|
||
<div wire:loading wire:target="createLiveStatistic">
|
||
Creating...
|
||
</div>
|
||
</x-button.primary>
|
||
<button type="button"
|
||
wire:click="openClearStatisticsModal"
|
||
wire:loading.attr="disabled"
|
||
wire:target="clearStatisticsHistory"
|
||
class="bg-danger/10 border border-danger/30 text-danger px-3 py-2 rounded-md text-sm font-semibold hover:bg-danger/20 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
||
title="Clear all statistics history entries">
|
||
<x-ri-delete-bin-line class="size-4" />
|
||
<span wire:loading.remove wire:target="clearStatisticsHistory">Clear</span>
|
||
<span wire:loading wire:target="clearStatisticsHistory">Clearing...</span>
|
||
</button>
|
||
<button type="button"
|
||
@click="exportStatisticsHistoryToCSV()"
|
||
class="bg-background border border-neutral px-3 py-2 rounded-md text-sm font-semibold hover:bg-background/80 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
||
title="Export statistics to CSV">
|
||
<x-ri-download-line class="size-4" />
|
||
Export
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div x-data="tableFilterSort('statistics-table')">
|
||
<div class="p-4 border-b border-neutral bg-background/50">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<div class="flex-1 min-w-[200px]">
|
||
<input type="text"
|
||
x-model="search"
|
||
@input="filterTable()"
|
||
placeholder="Search statistics..."
|
||
class="w-full px-3 py-2 text-sm bg-background border border-neutral rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50">
|
||
</div>
|
||
<button @click="search = ''; filterTable()"
|
||
x-show="search"
|
||
class="px-3 py-2 text-sm text-base/70 hover:text-base border border-neutral rounded-md hover:bg-background/80">
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full" id="statistics-table">
|
||
<thead>
|
||
<tr class="border-b border-neutral bg-background">
|
||
<th @click.stop="sortTable('date-time')" data-sort="date-time" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Date & Time
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('status')" data-sort="status" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Status
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('cpu')" data-sort="cpu" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
CPU
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('memory')" data-sort="memory" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Memory
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('disk')" data-sort="disk" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Disk
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('network-in')" data-sort="network-in" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Network In
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('network-out')" data-sort="network-out" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Network Out
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('disk-io')" data-sort="disk-io" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Disk I/O
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('uptime')" data-sort="uptime" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Uptime
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-neutral/40">
|
||
@forelse($this->statistics as $stat)
|
||
@php
|
||
$networkStats = $this->getCurrentNetworkStats($stat);
|
||
$diskStats = $this->getCurrentDiskStats($stat);
|
||
$cpuPercent = $stat->cpumax > 0 ? ($stat->cpuused / $stat->cpumax * 100) : 0;
|
||
$memPercent = $stat->memorymax > 0 ? ($stat->memoryused / $stat->memorymax * 100) : 0;
|
||
$diskPercent = ($stat->diskmax ?? 0) > 0 ? (($stat->diskused ?? 0) / ($stat->diskmax ?? 1) * 100) : 0;
|
||
$statisticsExportRows[] = [
|
||
'date_time' => $stat->DateTime ? $stat->DateTime->format('M d, Y H:i:s') : '-',
|
||
'status' => $stat->vmstatus ? ucfirst($stat->vmstatus) : '-',
|
||
'cpu' => number_format($cpuPercent, 1) . '%',
|
||
'memory' => number_format($memPercent, 1) . '%',
|
||
'disk' => (($stat->diskmax ?? 0) > 0 && ($stat->diskused ?? 0) > 0) ? number_format($diskPercent, 1) . '%' : 'N/A',
|
||
'network_in' => Illuminate\Support\Number::fileSize(bytes: $networkStats['netin']),
|
||
'network_out' => Illuminate\Support\Number::fileSize(bytes: $networkStats['netout']),
|
||
'disk_io' => sprintf(
|
||
'%s / %s',
|
||
Illuminate\Support\Number::fileSize(bytes: $diskStats['diskread'] ?? 0),
|
||
Illuminate\Support\Number::fileSize(bytes: $diskStats['diskwrite'] ?? 0)
|
||
),
|
||
'uptime' => $stat->uptime > 0 ? $this->formatUptime($stat->uptime) : '-',
|
||
];
|
||
@endphp
|
||
<tr wire:key="stat-{{ $stat->id }}"
|
||
class="hover:bg-background-secondary/50 transition-colors table-row"
|
||
data-date-time="{{ $stat->DateTime ? $stat->DateTime->format('Y-m-d H:i:s') : '' }}"
|
||
data-status="{{ strtolower($stat->vmstatus ?? '') }}"
|
||
data-cpu="{{ number_format($cpuPercent, 2) }}"
|
||
data-memory="{{ number_format($memPercent, 2) }}"
|
||
data-disk="{{ number_format($diskPercent, 2) }}"
|
||
data-network-in="{{ $networkStats['netin'] }}"
|
||
data-network-out="{{ $networkStats['netout'] }}"
|
||
data-disk-io="{{ ($diskStats['diskread'] ?? 0) + ($diskStats['diskwrite'] ?? 0) }}"
|
||
data-uptime="{{ $stat->uptime ?? 0 }}">
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="font-medium">{{ $stat->DateTime->format('M d, Y') }}</div>
|
||
<div class="text-xs text-base/60">{{ $stat->DateTime->format('H:i:s') }}</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@if($stat->vmstatus)
|
||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-semibold {{ $stat->vmstatus === 'running' ? 'bg-success/20 text-success' : 'bg-inactive/20 text-inactive' }}">
|
||
{{ ucfirst($stat->vmstatus) }}
|
||
</span>
|
||
@else
|
||
<span class="text-base/50">-</span>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex items-center gap-2">
|
||
<div class="w-12 bg-background rounded-full h-1.5 overflow-hidden">
|
||
<div class="h-full bg-blue-500 rounded-full" style="width: {{ min($cpuPercent, 100) }}%"></div>
|
||
</div>
|
||
<span class="font-medium">{{ number_format($cpuPercent, 1) }}%</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex items-center gap-2">
|
||
<div class="w-12 bg-background rounded-full h-1.5 overflow-hidden">
|
||
<div class="h-full bg-green-500 rounded-full" style="width: {{ min($memPercent, 100) }}%"></div>
|
||
</div>
|
||
<span class="font-medium">{{ number_format($memPercent, 1) }}%</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@if(($stat->diskmax ?? 0) > 0 && ($stat->diskused ?? 0) > 0)
|
||
<div class="flex items-center gap-2">
|
||
<div class="w-12 bg-background rounded-full h-1.5 overflow-hidden">
|
||
<div class="h-full bg-purple-500 rounded-full" style="width: {{ min($diskPercent, 100) }}%"></div>
|
||
</div>
|
||
<span class="font-medium">{{ number_format($diskPercent, 1) }}%</span>
|
||
</div>
|
||
@else
|
||
<span class="font-medium">N/A</span>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex items-center gap-1">
|
||
<x-ri-arrow-down-line class="size-3 text-blue-400" />
|
||
<span class="font-medium">{{ Illuminate\Support\Number::fileSize(bytes: $networkStats['netin']) }}</span>
|
||
</div>
|
||
@if($networkStats['netin_rate'] > 0)
|
||
<div class="text-xs text-base/50 mt-0.5">{{ Illuminate\Support\Number::fileSize(bytes: (int)$networkStats['netin_rate']) }}/s</div>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex items-center gap-1">
|
||
<x-ri-arrow-up-line class="size-3 text-orange-400" />
|
||
<span class="font-medium">{{ Illuminate\Support\Number::fileSize(bytes: $networkStats['netout']) }}</span>
|
||
</div>
|
||
@if($networkStats['netout_rate'] > 0)
|
||
<div class="text-xs text-base/50 mt-0.5">{{ Illuminate\Support\Number::fileSize(bytes: (int)$networkStats['netout_rate']) }}/s</div>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex items-center gap-3 text-xs">
|
||
<div class="flex items-center gap-1 whitespace-nowrap">
|
||
<x-ri-file-download-line class="size-3 text-purple-400" />
|
||
<span>{{ Illuminate\Support\Number::fileSize(bytes: $diskStats['diskread'] ?? 0) }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-1 whitespace-nowrap">
|
||
<x-ri-file-upload-line class="size-3 text-purple-400" />
|
||
<span>{{ Illuminate\Support\Number::fileSize(bytes: $diskStats['diskwrite'] ?? 0) }}</span>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@if($stat->uptime > 0)
|
||
<span class="text-xs">{{ $this->formatUptime($stat->uptime) }}</span>
|
||
@else
|
||
<span class="text-base/50">-</span>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<button
|
||
x-data
|
||
@click="$dispatch('open-delete-stat-modal', { id: {{ $stat->id }}, date: '{{ $stat->DateTime->format('Y-m-d H:i:s') }}', cpu: '{{ number_format($stat->cpumax > 0 ? ($stat->cpuused / $stat->cpumax * 100) : 0, 2) }}', memory: '{{ number_format($stat->memorymax > 0 ? ($stat->memoryused / $stat->memorymax * 100) : 0, 2) }}', disk: '{{ number_format(($stat->diskmax ?? 0) > 0 ? (($stat->diskused ?? 0) / ($stat->diskmax ?? 1) * 100) : 0, 2) }}', status: '{{ $stat->vmstatus ?? '' }}' })"
|
||
class="p-1.5 text-danger hover:text-danger/80 hover:bg-danger/10 rounded transition-colors"
|
||
title="Delete statistics entry"
|
||
>
|
||
<x-ri-delete-bin-line class="size-4" />
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
@empty
|
||
<tr>
|
||
<td colspan="10" class="py-12 text-center">
|
||
<div class="flex flex-col items-center gap-3">
|
||
<div class="p-3 bg-base/10 rounded-full">
|
||
<x-ri-database-line class="size-6 text-base/60" />
|
||
</div>
|
||
<div>
|
||
<p class="text-base font-semibold mb-1">No statistics available</p>
|
||
<p class="text-sm text-base/60">Statistics are collected every 5 minutes automatically.</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
@endforelse
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<template id="statistics-history-export" type="application/json">
|
||
@json($statisticsExportRows)
|
||
</template>
|
||
@if($this->statistics->hasPages())
|
||
<div class="p-4 border-t border-neutral">
|
||
{{ $this->statistics->links() }}
|
||
</div>
|
||
@endif
|
||
</div>
|
||
|
||
<!-- Task History Section -->
|
||
@php
|
||
$taskHistoryExportRows = [];
|
||
@endphp
|
||
<div class="bg-background-secondary border border-neutral rounded-xl overflow-hidden" wire:poll.10s="$refresh" id="task-history-card">
|
||
<div class="p-6 border-b border-neutral flex items-center justify-between gap-3">
|
||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||
<x-ri-task-line class="size-5" />
|
||
Task History
|
||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||
<x-ri-question-line class="size-4" />
|
||
</button>
|
||
<!-- Modal Backdrop -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-5" />
|
||
</button>
|
||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||
<x-ri-task-line class="size-5 text-primary" />
|
||
</div>
|
||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||
Task History
|
||
</h6>
|
||
</div>
|
||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||
View the execution history of tasks performed on your virtual machine. This includes operations like VM creation, configuration changes, backups, and other administrative tasks.
|
||
</p>
|
||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Task Types:</span> Tasks can include VM operations (start, stop, reboot), configuration changes, backup operations, migration tasks, and other administrative actions performed on your virtual machine.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Task Details:</span> Click the expand arrow next to any task to view detailed information including execution logs, status messages, and task-specific parameters. The details are fetched from the server API when expanded.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Status Indicators:</span> Tasks show their completion status (Success or Failure) and include start time, end time, and duration. Running tasks will show the current elapsed time.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Auto-Refresh:</span> The task history automatically refreshes every 10 seconds to show the latest tasks and their current status.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</h2>
|
||
<div class="flex items-center gap-2"
|
||
x-data
|
||
@task-export:no-data.window="$wire.showExportInfo($event.detail.message)">
|
||
<button type="button"
|
||
@click="exportTaskHistoryToCSV()"
|
||
class="bg-background border border-neutral px-3 py-2 rounded-md text-sm font-semibold hover:bg-background/80 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
||
title="Export to CSV">
|
||
<x-ri-download-line class="size-4" />
|
||
Export
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div x-data="tableFilterSort('task-history-table')">
|
||
<div class="p-4 border-b border-neutral bg-background/50">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<div class="flex-1 min-w-[200px]">
|
||
<input type="text"
|
||
x-model="search"
|
||
@input="filterTable()"
|
||
placeholder="Search tasks..."
|
||
class="w-full px-3 py-2 text-sm bg-background border border-neutral rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50">
|
||
</div>
|
||
<button @click="search = ''; filterTable()"
|
||
x-show="search"
|
||
class="px-3 py-2 text-sm text-base/70 hover:text-base border border-neutral rounded-md hover:bg-background/80">
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full" id="task-history-table">
|
||
<thead>
|
||
<tr class="border-b border-neutral bg-background">
|
||
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap w-8"></th>
|
||
<th @click.stop="sortTable('type')" data-sort="type" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Type
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('status')" data-sort="status" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Status
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('start-time')" data-sort="start-time" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Start Time
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('end-time')" data-sort="end-time" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
End Time
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('duration')" data-sort="duration" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Duration
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-neutral/40">
|
||
@forelse($this->taskHistory as $task)
|
||
@php
|
||
$startTime = isset($task['starttime']) ? \Carbon\Carbon::createFromTimestamp($task['starttime']) : null;
|
||
$endTime = isset($task['endtime']) && $task['endtime'] > 0 ? \Carbon\Carbon::createFromTimestamp($task['endtime']) : null;
|
||
$duration = null;
|
||
if ($startTime && $endTime) {
|
||
$duration = $startTime->diffInSeconds($endTime);
|
||
} elseif ($startTime && isset($task['status']) && $task['status'] === 'running') {
|
||
$duration = $startTime->diffInSeconds(now());
|
||
}
|
||
$taskUpid = $task['upid'] ?? $task['id'] ?? '';
|
||
$taskKey = md5($taskUpid . ($task['starttime'] ?? ''));
|
||
$isExpanded = in_array($taskUpid, $this->expandedTaskUpids ?? []);
|
||
$taskHistoryExportRows[] = [
|
||
'type' => $task['type'] ?? 'N/A',
|
||
'status' => $task['status'] ?? '-',
|
||
'start_time' => $startTime ? $startTime->format('M d, Y H:i:s') : '-',
|
||
'end_time' => $endTime ? $endTime->format('M d, Y H:i:s') : '-',
|
||
'duration' => $duration !== null ? $this->formatUptime($duration) : '-',
|
||
];
|
||
@endphp
|
||
<tbody
|
||
wire:key="task-{{ $taskUpid }}"
|
||
x-data="{
|
||
expanded: {{ $isExpanded ? 'true' : 'false' }},
|
||
taskDetails: null,
|
||
loading: false,
|
||
taskUpid: '{{ $taskUpid }}'
|
||
}"
|
||
x-init="
|
||
// Function to restore task details
|
||
var restoreTaskDetails = function() {
|
||
if (expanded && !taskDetails && !loading) {
|
||
loading = true;
|
||
$wire.getTaskDetails('{{ $taskUpid }}').then(function(data) {
|
||
taskDetails = data;
|
||
loading = false;
|
||
}).catch(function() {
|
||
loading = false;
|
||
});
|
||
}
|
||
};
|
||
|
||
// Restore on initial load
|
||
restoreTaskDetails();
|
||
|
||
// Restore after Livewire updates
|
||
$el.addEventListener('livewire:update', function() {
|
||
setTimeout(function() {
|
||
restoreTaskDetails();
|
||
}, 100);
|
||
});
|
||
|
||
$watch('expanded', function(value) {
|
||
if (value) {
|
||
$wire.call('expandTask', '{{ $taskUpid }}');
|
||
// Fetch details if not already loaded
|
||
if (!taskDetails && !loading) {
|
||
loading = true;
|
||
$wire.getTaskDetails('{{ $taskUpid }}').then(function(data) {
|
||
taskDetails = data;
|
||
loading = false;
|
||
}).catch(function() {
|
||
loading = false;
|
||
});
|
||
}
|
||
} else {
|
||
$wire.call('collapseTask', '{{ $taskUpid }}');
|
||
}
|
||
});
|
||
"
|
||
class="divide-y divide-neutral/40"
|
||
>
|
||
<tr
|
||
class="hover:bg-background-secondary/50 transition-colors table-row"
|
||
data-type="{{ strtolower($task['type'] ?? '') }}"
|
||
data-status="{{ strtolower($task['status'] ?? '') }}"
|
||
data-start-time="{{ $startTime ? $startTime->format('Y-m-d H:i:s') : '' }}"
|
||
data-end-time="{{ $endTime ? $endTime->format('Y-m-d H:i:s') : '' }}"
|
||
data-duration="{{ $duration ?? 0 }}"
|
||
>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<button
|
||
@click="
|
||
expanded = !expanded;
|
||
if (expanded && !taskDetails && !loading) {
|
||
loading = true;
|
||
$wire.getTaskDetails('{{ $taskUpid }}').then(data => {
|
||
taskDetails = data;
|
||
loading = false;
|
||
}).catch(() => {
|
||
loading = false;
|
||
});
|
||
}
|
||
"
|
||
class="p-1 hover:bg-background rounded transition-colors"
|
||
title="Toggle details"
|
||
>
|
||
<x-ri-arrow-down-s-line
|
||
class="size-4 text-base/60 transition-transform duration-200"
|
||
x-bind:class="expanded ? 'rotate-180' : ''"
|
||
/>
|
||
</button>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium">{{ $task['type'] ?? 'N/A' }}</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@if(isset($task['status']))
|
||
@if($task['status'] === 'OK')
|
||
<span class="inline-flex items-center justify-center px-2 py-1 rounded-md text-xs font-semibold bg-success/20 text-success w-20">
|
||
Success
|
||
</span>
|
||
@else
|
||
<span class="inline-flex items-center justify-center px-2 py-1 rounded-md text-xs font-semibold bg-red-500/20 text-red-600 dark:text-red-400 w-20">
|
||
Failure
|
||
</span>
|
||
@endif
|
||
@else
|
||
<span class="text-base/50">-</span>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@if($startTime)
|
||
<div class="font-medium">{{ $startTime->format('M d, Y') }}</div>
|
||
<div class="text-xs text-base/60">{{ $startTime->format('H:i:s') }}</div>
|
||
@else
|
||
<span class="text-base/50">-</span>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@if($endTime)
|
||
<div class="font-medium">{{ $endTime->format('M d, Y') }}</div>
|
||
<div class="text-xs text-base/60">{{ $endTime->format('H:i:s') }}</div>
|
||
@else
|
||
<span class="text-base/50">-</span>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@if($duration !== null)
|
||
<span class="font-medium">{{ $this->formatUptime($duration) }}</span>
|
||
@else
|
||
<span class="text-base/50">-</span>
|
||
@endif
|
||
</td>
|
||
</tr>
|
||
<!-- Expanded Details Row -->
|
||
<tr
|
||
x-show="expanded"
|
||
x-cloak
|
||
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"
|
||
class="bg-background/30"
|
||
>
|
||
<td colspan="6" class="px-6 py-6">
|
||
<div class="space-y-3">
|
||
<h4 class="text-sm font-semibold text-base/80 flex items-center gap-2">
|
||
<x-ri-information-line class="size-4 text-base/60" />
|
||
Task Information
|
||
</h4>
|
||
|
||
<!-- Loading State -->
|
||
<div x-show="loading" class="text-center py-8">
|
||
<div class="inline-flex items-center gap-2 text-base/60">
|
||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
<span>Loading task details...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Task Details -->
|
||
<div x-show="!loading && taskDetails" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<!-- Status, Type, Exit Status -->
|
||
<template x-for="entry in Object.entries(taskDetails || {}).filter(e => ['status', 'type', 'exitstatus'].includes(e[0].toLowerCase()))" :key="entry[0]">
|
||
<div class="flex flex-col p-2 bg-background rounded border border-neutral/40">
|
||
<span class="text-xs text-base/70 mb-1" x-text="entry[0].charAt(0).toUpperCase() + entry[0].slice(1).replace(/_/g, ' ')"></span>
|
||
<span class="text-sm font-semibold" x-text="entry[1]"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<div x-show="!loading && taskDetails" class="grid grid-cols-1 gap-4">
|
||
<!-- Log -->
|
||
<template x-for="entry in Object.entries(taskDetails || {}).filter(e => e[0].toLowerCase() === 'log')" :key="entry[0]">
|
||
<div class="flex flex-col p-2 bg-background rounded border border-neutral/40" x-data="{ copied: false }">
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-xs text-base/70 mb-1" x-text="entry[0].charAt(0).toUpperCase() + entry[0].slice(1).replace(/_/g, ' ')"></span>
|
||
<button @click="navigator.clipboard.writeText(entry[1]); copied = true; setTimeout(() => copied = false, 2000)" class="text-base/70 hover:text-base/60 mt-1 mr-1">
|
||
<span x-show="!copied" x-transition>
|
||
<x-ri-file-copy-fill class="size-4" />
|
||
</span>
|
||
<span x-show="copied" x-transition>
|
||
<x-ri-check-line class="size-4 text-success" />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<pre class="text-xs font-mono whitespace-pre-wrap break-words overflow-x-auto max-h-64 overflow-y-auto bg-background-secondary/50 rounded p-2 text-base/80" x-text="entry[1]"></pre>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Other details -->
|
||
<template x-for="entry in Object.entries(taskDetails || {}).filter(e => !['status', 'type', 'exitstatus', 'log', 'tokenid', 'user', 'pstart', 'upid', 'id', 'starttime', 'node', 'pid'].includes(e[0].toLowerCase()))" :key="entry[0]">
|
||
<div class="flex flex-col p-2 bg-background rounded border border-neutral/40">
|
||
<span class="text-xs text-base/70 mb-1" x-text="entry[0].charAt(0).toUpperCase() + entry[0].slice(1).replace(/_/g, ' ')"></span>
|
||
<span class="text-sm font-semibold">
|
||
<template x-if="Array.isArray(entry[1])">
|
||
<pre class="text-xs whitespace-pre-wrap" x-text="JSON.stringify(entry[1], null, 2)"></pre>
|
||
</template>
|
||
<template x-if="!Array.isArray(entry[1]) && typeof entry[1] === 'object' && entry[1] !== null">
|
||
<pre class="text-xs whitespace-pre-wrap" x-text="JSON.stringify(entry[1], null, 2)"></pre>
|
||
</template>
|
||
<template x-if="typeof entry[1] === 'boolean'">
|
||
<span x-text="entry[1] ? 'Yes' : 'No'"></span>
|
||
</template>
|
||
<template x-if="typeof entry[1] !== 'object' || entry[1] === null">
|
||
<span x-text="entry[1]"></span>
|
||
</template>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- No Details Available -->
|
||
<div x-show="!loading && !taskDetails" class="text-center py-8 text-base/60">
|
||
<p>No detailed information available for this task.</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
@empty
|
||
<tbody>
|
||
<tr>
|
||
<td colspan="6" class="py-12 text-center">
|
||
<div class="flex flex-col items-center gap-3">
|
||
<div class="p-3 bg-base/10 rounded-full">
|
||
<x-ri-task-line class="size-6 text-base/60" />
|
||
</div>
|
||
<div>
|
||
<p class="text-base font-semibold mb-1">No task history available</p>
|
||
<p class="text-sm text-base/60">Task history will appear here as tasks are executed on the server.</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
@endforelse
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<template id="task-history-export" type="application/json">
|
||
@json($taskHistoryExportRows)
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Recent Lifecycle Events Section -->
|
||
@php
|
||
$lifecycleEventsExportRows = [];
|
||
@endphp
|
||
<div class="bg-background-secondary border border-neutral rounded-xl overflow-hidden" wire:poll.10s="$refresh" id="lifecycle-events-card">
|
||
<div class="p-6 border-b border-neutral flex items-center justify-between gap-3">
|
||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||
<x-ri-shut-down-line class="size-5" />
|
||
Recent Lifecycle Events
|
||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||
<x-ri-question-line class="size-4" />
|
||
</button>
|
||
<!-- Modal Backdrop -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-5" />
|
||
</button>
|
||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||
<x-ri-shut-down-line class="size-5 text-primary" />
|
||
</div>
|
||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||
Recent Lifecycle Events
|
||
</h6>
|
||
</div>
|
||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||
This section displays the lifecycle events of your virtual machine, tracking critical state changes and operations. Lifecycle events represent key moments in your VM's operation, including power state changes (start, shutdown, reboot, reset) and backup operations. These events are automatically extracted from the task history and provide a chronological view of your virtual machine's operational history.
|
||
</p>
|
||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Event Types:</span> The section tracks five primary lifecycle event types: Start (VM power-on), Shutdown (graceful power-off), Reboot (restart operation), Reset (hard reset), and Backup (backup operations). Each event type is color-coded for easy identification.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Event Details:</span> Click the expand arrow next to any event to view comprehensive information including the original Proxmox task type, execution status, task ID, and precise timing details. This helps correlate lifecycle events with specific operations in the system.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Status Indicators:</span> Each event displays its completion status: Success (green) for completed operations, Running (blue) for operations in progress, or Failure (red) for failed operations. Start and end times are shown when available, along with calculated duration.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Auto-Refresh:</span> The lifecycle events table automatically refreshes every 10 seconds to ensure you always see the most current operational state and event history of your virtual machine.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Export:</span> All lifecycle events can be exported to CSV format, allowing you to analyze operational patterns, track system changes over time, and maintain records of your virtual machine's lifecycle events.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</h2>
|
||
<div class="flex items-center gap-2"
|
||
x-data
|
||
@lifecycle-export:no-data.window="$wire.showExportInfo($event.detail.message)">
|
||
<button type="button"
|
||
@click="exportLifecycleEventsToCSV()"
|
||
class="bg-background border border-neutral px-3 py-2 rounded-md text-sm font-semibold hover:bg-background/80 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
||
title="Export to CSV">
|
||
<x-ri-download-line class="size-4" />
|
||
Export
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div x-data="tableFilterSort('lifecycle-events-table')">
|
||
<div class="p-4 border-b border-neutral bg-background/50">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<div class="flex-1 min-w-[200px]">
|
||
<input type="text"
|
||
x-model="search"
|
||
@input="filterTable()"
|
||
placeholder="Search events..."
|
||
class="w-full px-3 py-2 text-sm bg-background border border-neutral rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50">
|
||
</div>
|
||
<button @click="search = ''; filterTable()"
|
||
x-show="search"
|
||
class="px-3 py-2 text-sm text-base/70 hover:text-base border border-neutral rounded-md hover:bg-background/80">
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full" id="lifecycle-events-table">
|
||
<thead>
|
||
<tr class="border-b border-neutral bg-background">
|
||
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap w-8"></th>
|
||
<th @click.stop="sortTable('type')" data-sort="type" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Type
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('status')" data-sort="status" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Status
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('start-time')" data-sort="start-time" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Start Time
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('end-time')" data-sort="end-time" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
End Time
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('duration')" data-sort="duration" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Duration
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-neutral/40">
|
||
@forelse(collect($this->chartEvents)->take(20) as $index => $event)
|
||
@php
|
||
$startTime = isset($event['starttime']) ? \Carbon\Carbon::createFromTimestamp($event['starttime']) : null;
|
||
$endTime = isset($event['endtime']) && $event['endtime'] > 0 ? \Carbon\Carbon::createFromTimestamp($event['endtime']) : null;
|
||
$duration = $event['duration'] ?? null;
|
||
$eventUpid = $event['upid'] ?? $event['timestamp'] ?? '';
|
||
$eventKey = md5($eventUpid . ($event['starttime'] ?? ''));
|
||
$isExpanded = in_array($eventUpid, $this->expandedTaskUpids ?? []);
|
||
|
||
$eventColorMap = [
|
||
'start' => 'text-success',
|
||
'shutdown' => 'text-danger',
|
||
'reboot' => 'text-info',
|
||
'reset' => 'text-warning',
|
||
'backup' => 'text-purple-200',
|
||
];
|
||
$eventColor = $eventColorMap[$event['type']] ?? 'text-base/70';
|
||
|
||
$lifecycleEventsExportRows[] = [
|
||
'type' => $event['label'] ?? 'N/A',
|
||
'status' => ucfirst($event['status'] ?? '-'),
|
||
'start_time' => $startTime ? $startTime->format('M d, Y H:i:s') : '-',
|
||
'end_time' => $endTime ? $endTime->format('M d, Y H:i:s') : '-',
|
||
'duration' => $duration !== null ? $this->formatUptime($duration) : '-',
|
||
];
|
||
@endphp
|
||
<tbody
|
||
wire:key="lifecycle-{{ $eventKey }}"
|
||
x-data="{
|
||
expanded: {{ $isExpanded ? 'true' : 'false' }}
|
||
}"
|
||
x-init="
|
||
$watch('expanded', function(value) {
|
||
if (value) {
|
||
$wire.call('expandTask', '{{ $eventUpid }}');
|
||
} else {
|
||
$wire.call('collapseTask', '{{ $eventUpid }}');
|
||
}
|
||
});
|
||
"
|
||
class="divide-y divide-neutral/40"
|
||
>
|
||
<tr
|
||
class="hover:bg-background-secondary/50 transition-colors table-row"
|
||
data-type="{{ strtolower($event['label'] ?? '') }}"
|
||
data-status="{{ strtolower($event['status'] ?? '') }}"
|
||
data-start-time="{{ $startTime ? $startTime->format('Y-m-d H:i:s') : '' }}"
|
||
data-end-time="{{ $endTime ? $endTime->format('Y-m-d H:i:s') : '' }}"
|
||
data-duration="{{ $duration ?? 0 }}"
|
||
>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<button
|
||
@click="expanded = !expanded"
|
||
class="p-1 hover:bg-background rounded transition-colors"
|
||
title="Toggle details"
|
||
>
|
||
<x-ri-arrow-down-s-line
|
||
class="size-4 text-base/60 transition-transform duration-200"
|
||
x-bind:class="expanded ? 'rotate-180' : ''"
|
||
/>
|
||
</button>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium {{ $eventColor }}">{{ $event['label'] ?? 'N/A' }}</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@if(isset($event['status']))
|
||
@if(strtolower($event['status']) === 'ok')
|
||
<span class="inline-flex items-center justify-center px-2 py-1 rounded-md text-xs font-semibold bg-success/20 text-success w-20">
|
||
Success
|
||
</span>
|
||
@elseif(strtolower($event['status']) === 'running')
|
||
<span class="inline-flex items-center justify-center px-2 py-1 rounded-md text-xs font-semibold bg-info/20 text-info w-20">
|
||
Running
|
||
</span>
|
||
@else
|
||
<span class="inline-flex items-center justify-center px-2 py-1 rounded-md text-xs font-semibold bg-red-500/20 text-red-600 dark:text-red-400 w-20">
|
||
Failure
|
||
</span>
|
||
@endif
|
||
@else
|
||
<span class="text-base/50">-</span>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@if($startTime)
|
||
<div class="font-medium">{{ $startTime->format('M d, Y') }}</div>
|
||
<div class="text-xs text-base/60">{{ $startTime->format('H:i:s') }}</div>
|
||
@else
|
||
<span class="text-base/50">-</span>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@if($endTime)
|
||
<div class="font-medium">{{ $endTime->format('M d, Y') }}</div>
|
||
<div class="text-xs text-base/60">{{ $endTime->format('H:i:s') }}</div>
|
||
@else
|
||
<span class="text-base/50">-</span>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@if($duration !== null)
|
||
<span class="font-medium">{{ $this->formatUptime($duration) }}</span>
|
||
@else
|
||
<span class="text-base/50">-</span>
|
||
@endif
|
||
</td>
|
||
</tr>
|
||
<!-- Expanded Details Row -->
|
||
<tr
|
||
x-show="expanded"
|
||
x-cloak
|
||
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"
|
||
class="bg-background/30"
|
||
>
|
||
<td colspan="6" class="px-6 py-6">
|
||
<div class="space-y-3">
|
||
<h4 class="text-sm font-semibold text-base/80 flex items-center gap-2">
|
||
<x-ri-information-line class="size-4 text-base/60" />
|
||
Event Information
|
||
</h4>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div class="flex flex-col p-2 bg-background rounded border border-neutral/40">
|
||
<span class="text-xs text-base/70 mb-1">Type</span>
|
||
<span class="text-sm font-semibold">{{ $event['original_type'] ?? 'N/A' }}</span>
|
||
</div>
|
||
<div class="flex flex-col p-2 bg-background rounded border border-neutral/40">
|
||
<span class="text-xs text-base/70 mb-1">Status</span>
|
||
<span class="text-sm font-semibold">{{ ucfirst($event['status'] ?? 'Unknown') }}</span>
|
||
</div>
|
||
<div class="flex flex-col p-2 bg-background rounded border border-neutral/40">
|
||
<span class="text-xs text-base/70 mb-1">Event Type</span>
|
||
<span class="text-sm font-semibold {{ $eventColor }}">{{ $event['label'] ?? 'N/A' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 gap-4">
|
||
@if(isset($event['upid']))
|
||
<div class="flex flex-col p-2 bg-background rounded border border-neutral/40">
|
||
<span class="text-xs text-base/70 mb-1">Task ID</span>
|
||
<span class="text-sm font-semibold">{{ str_replace([':root@pam!paymenter:', ':root@pam:'], '', $event['upid']) }}</span>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
@empty
|
||
<tbody>
|
||
<tr>
|
||
<td colspan="6" class="py-12 text-center">
|
||
<div class="flex flex-col items-center gap-3">
|
||
<div class="p-3 bg-base/10 rounded-full">
|
||
<x-ri-shut-down-line class="size-6 text-base/60" />
|
||
</div>
|
||
<div>
|
||
<p class="text-base font-semibold mb-1">No lifecycle events available</p>
|
||
<p class="text-sm text-base/60">Lifecycle events will appear here as operations are performed on the server.</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
@endforelse
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<template id="lifecycle-events-export" type="application/json">
|
||
@json($lifecycleEventsExportRows)
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Historical Data Comparison Section -->
|
||
@php
|
||
$historicalComparison = $this->historicalComparison;
|
||
@endphp
|
||
<div class="bg-background-secondary border border-neutral rounded-xl overflow-hidden mt-6" id="historical-comparison-card">
|
||
<div class="p-6 border-b border-neutral flex items-center justify-between gap-3 flex-wrap">
|
||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||
<x-ri-bar-chart-2-line class="size-5" />
|
||
Historical Data Comparison
|
||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||
<x-ri-question-line class="size-4" />
|
||
</button>
|
||
<!-- Modal Backdrop -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-5" />
|
||
</button>
|
||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||
<x-ri-bar-chart-2-line class="size-5 text-primary" />
|
||
</div>
|
||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||
Historical Data Comparison
|
||
</h6>
|
||
</div>
|
||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||
Compare current server statistics with historical data from a previous time period. This helps you identify trends, performance changes, and resource utilization patterns over time.
|
||
</p>
|
||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Period Selection:</span> Choose both a historical period (to compare against) and a current period (to compare from). You can select predefined time periods or use custom date/time ranges.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Metrics Compared:</span> All major statistics are compared including CPU usage, memory utilization, disk usage, network traffic rates (in/out), and disk I/O rates (read/write).
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Visualizations:</span> Side-by-side comparisons with visual indicators show percentage changes, absolute differences, and trend directions for each metric.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Export:</span> Comparison data can be exported to CSV format for further analysis or reporting.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</h2>
|
||
<div class="flex items-center gap-2">
|
||
<button type="button"
|
||
wire:click="exportHistoricalComparison"
|
||
class="bg-background border border-neutral px-3 py-2 rounded-md text-sm font-semibold hover:bg-background/80 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
||
title="Export comparison data to CSV">
|
||
<x-ri-download-line class="size-4" />
|
||
Export
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-4 border-b border-neutral bg-background/50">
|
||
<!-- Period Selection -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div class="flex items-center gap-2">
|
||
<label class="text-xs font-semibold text-base/70 whitespace-nowrap">Historical:</label>
|
||
<select wire:model.live="historicalPeriod" class="flex-1 bg-background border border-neutral rounded-md px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-secondary">
|
||
<option value="1_hour_ago">1 Hour Ago</option>
|
||
<option value="2_hours_ago">2 Hours Ago</option>
|
||
<option value="6_hours_ago">6 Hours Ago</option>
|
||
<option value="12_hours_ago">12 Hours Ago</option>
|
||
<option value="24_hours_ago">24 Hours Ago</option>
|
||
<option value="custom">Custom Date/Time</option>
|
||
</select>
|
||
@if($historicalPeriod === 'custom')
|
||
<input type="datetime-local" wire:model.live="historicalCustomDate" class="flex-1 bg-background border border-neutral rounded-md px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-secondary">
|
||
@endif
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<label class="text-xs font-semibold text-base/70 whitespace-nowrap">Current:</label>
|
||
<select wire:model.live="currentPeriod" class="flex-1 bg-background border border-neutral rounded-md px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-secondary">
|
||
<option value="now">Now (Latest)</option>
|
||
<option value="1_hour_ago">1 Hour Ago</option>
|
||
<option value="2_hours_ago">2 Hours Ago</option>
|
||
<option value="custom">Custom Date/Time</option>
|
||
</select>
|
||
@if($currentPeriod === 'custom')
|
||
<input type="datetime-local" wire:model.live="currentCustomDate" class="flex-1 bg-background border border-neutral rounded-md px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-secondary">
|
||
@endif
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
@if($historicalComparison && !isset($historicalComparison['error']))
|
||
<template id="historical-comparison-export" type="application/json" data-historical-comparison-export>
|
||
@json($this->historicalComparisonExportData ?? [])
|
||
</template>
|
||
@php
|
||
$current = $historicalComparison['current'];
|
||
$historical = $historicalComparison['historical'];
|
||
$diff = $historicalComparison['differences'];
|
||
@endphp
|
||
|
||
<!-- Comparison Display -->
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full">
|
||
<thead>
|
||
<tr class="border-b border-neutral bg-background">
|
||
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap">Metric</th>
|
||
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap">
|
||
<div class="flex items-center gap-2">
|
||
<x-ri-history-line class="size-3" />
|
||
Historical
|
||
<span class="text-xs font-normal normal-case text-base/50">({{ $historical->DateTime->format('M d, Y H:i') }})</span>
|
||
</div>
|
||
</th>
|
||
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap">
|
||
<div class="flex items-center gap-2">
|
||
<x-ri-time-line class="size-3" />
|
||
Current
|
||
<span class="text-xs font-normal normal-case text-base/50">({{ $current->DateTime->format('M d, Y H:i') }})</span>
|
||
</div>
|
||
</th>
|
||
<th class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap">Change</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-neutral/40">
|
||
<!-- Processor Utilization -->
|
||
<tr class="hover:bg-background-secondary/50 transition-colors">
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex items-center gap-2">
|
||
<x-ri-cpu-line class="size-4 text-primary" />
|
||
<span class="font-medium">Processor Utilization</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium">{{ number_format($diff['cpu']['historical_percent'], 1) }}%</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium">{{ number_format($diff['cpu']['current_percent'], 1) }}%</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-semibold {{ $diff['cpu']['percent_change'] > 0 ? 'bg-warning/20 text-warning' : ($diff['cpu']['percent_change'] < 0 ? 'bg-success/20 text-success' : 'bg-base/20 text-base/70') }}">
|
||
{{ $diff['cpu']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['cpu']['percent_change'], 1) }}%
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Memory Utilization -->
|
||
<tr class="hover:bg-background-secondary/50 transition-colors">
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex items-center gap-2">
|
||
<x-ri-ram-line class="size-4 text-success" />
|
||
<span class="font-medium">Memory Utilization</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium">{{ number_format($diff['memory']['historical_percent'], 1) }}%</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium">{{ number_format($diff['memory']['current_percent'], 1) }}%</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-semibold {{ $diff['memory']['percent_change'] > 0 ? 'bg-warning/20 text-warning' : ($diff['memory']['percent_change'] < 0 ? 'bg-success/20 text-success' : 'bg-base/20 text-base/70') }}">
|
||
{{ $diff['memory']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['memory']['percent_change'], 1) }}%
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Disk Read Rate -->
|
||
<tr class="hover:bg-background-secondary/50 transition-colors">
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex items-center gap-2">
|
||
<x-ri-hard-drive-line class="size-4" style="color: #9256b5" />
|
||
<span class="font-medium">Disk Read Rate</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium" style="color: #9256b5">{{ $this->formatBytes($diff['disk_read']['historical_rate']) }}/s</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium" style="color: #9256b5">{{ $this->formatBytes($diff['disk_read']['current_rate']) }}/s</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-semibold {{ $diff['disk_read']['percent_change'] > 0 ? 'bg-warning/20 text-warning' : ($diff['disk_read']['percent_change'] < 0 ? 'bg-success/20 text-success' : 'bg-base/20 text-base/70') }}">
|
||
{{ $diff['disk_read']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['disk_read']['percent_change'], 1) }}%
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Disk Write Rate -->
|
||
<tr class="hover:bg-background-secondary/50 transition-colors">
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex items-center gap-2">
|
||
<x-ri-hard-drive-line class="size-4" style="color: #60496f" />
|
||
<span class="font-medium">Disk Write Rate</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium" style="color: #60496f">{{ $this->formatBytes($diff['disk_write']['historical_rate']) }}/s</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium" style="color: #60496f">{{ $this->formatBytes($diff['disk_write']['current_rate']) }}/s</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-semibold {{ $diff['disk_write']['percent_change'] > 0 ? 'bg-warning/20 text-warning' : ($diff['disk_write']['percent_change'] < 0 ? 'bg-success/20 text-success' : 'bg-base/20 text-base/70') }}">
|
||
{{ $diff['disk_write']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['disk_write']['percent_change'], 1) }}%
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Network Download Rate -->
|
||
<tr class="hover:bg-background-secondary/50 transition-colors">
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex items-center gap-2">
|
||
<x-ri-arrow-left-right-line class="size-4 text-success" />
|
||
<span class="font-medium">Network Download Rate</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium text-success">{{ $this->formatBytes($diff['network_in']['historical_rate']) }}/s</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium text-success">{{ $this->formatBytes($diff['network_in']['current_rate']) }}/s</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-semibold {{ $diff['network_in']['percent_change'] > 0 ? 'bg-warning/20 text-warning' : ($diff['network_in']['percent_change'] < 0 ? 'bg-success/20 text-success' : 'bg-base/20 text-base/70') }}">
|
||
{{ $diff['network_in']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['network_in']['percent_change'], 1) }}%
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
|
||
<!-- Network Upload Rate -->
|
||
<tr class="hover:bg-background-secondary/50 transition-colors">
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex items-center gap-2">
|
||
<x-ri-arrow-left-right-line class="size-4 text-info" />
|
||
<span class="font-medium">Network Upload Rate</span>
|
||
</div>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium text-info">{{ $this->formatBytes($diff['network_out']['historical_rate']) }}/s</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="font-medium text-info">{{ $this->formatBytes($diff['network_out']['current_rate']) }}/s</span>
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-semibold {{ $diff['network_out']['percent_change'] > 0 ? 'bg-warning/20 text-warning' : ($diff['network_out']['percent_change'] < 0 ? 'bg-success/20 text-success' : 'bg-base/20 text-base/70') }}">
|
||
{{ $diff['network_out']['percent_change'] > 0 ? '+' : '' }}{{ number_format($diff['network_out']['percent_change'], 1) }}%
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
@elseif($historicalComparison && isset($historicalComparison['error']))
|
||
<div class="p-6">
|
||
<div class="p-4 bg-warning/10 border border-warning/30 rounded-lg">
|
||
<div class="flex items-start gap-3">
|
||
<div class="p-2 bg-warning/20 rounded-full flex-shrink-0">
|
||
<x-ri-error-warning-line class="size-5 text-warning" />
|
||
</div>
|
||
<div>
|
||
<p class="text-sm font-semibold text-warning mb-1">{{ $historicalComparison['message'] }}</p>
|
||
@if(isset($historicalComparison['total_stats']))
|
||
<p class="text-xs text-warning/70">
|
||
Total statistics in database: {{ $historicalComparison['total_stats'] }}
|
||
@if($historicalComparison['total_stats'] == 0)
|
||
- Statistics are collected automatically every hour. You can also create manual statistics using the "Create Statistics" button above.
|
||
@elseif($historicalComparison['total_stats'] < 2)
|
||
- At least 2 statistics are needed for comparison. Wait for more data to be collected or create a manual statistic.
|
||
@endif
|
||
</p>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@else
|
||
<div class="text-center py-8">
|
||
<div class="flex flex-col items-center gap-2">
|
||
<div class="p-2 bg-base/10 rounded-full">
|
||
<x-ri-bar-chart-2-line class="size-5 text-base/60" />
|
||
</div>
|
||
<div>
|
||
<p class="text-sm font-semibold mb-1">No comparison data available</p>
|
||
<p class="text-xs text-base/60">Please select periods with available statistics data.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Network Analysis Section -->
|
||
<div
|
||
class="relative bg-background-secondary border border-neutral rounded-xl overflow-hidden mt-6"
|
||
id="network-analysis-card"
|
||
style="width: 100%;"
|
||
wire:init="loadNetworkAnalysis"
|
||
>
|
||
<div>
|
||
<div class="p-6 border-b border-neutral flex items-center justify-between gap-3 flex-wrap">
|
||
<div class="flex items-center gap-2">
|
||
<x-ri-router-line class="size-5" />
|
||
<div>
|
||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||
Network Analysis
|
||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||
<x-ri-question-line class="size-4" />
|
||
</button>
|
||
<!-- Modal Backdrop -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-5" />
|
||
</button>
|
||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||
<x-ri-router-line class="size-5 text-primary" />
|
||
</div>
|
||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||
Network Analysis
|
||
</h6>
|
||
</div>
|
||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||
Deep visibility into firewall sessions, security threats, and top consumers of bandwidth powered by the Core Router API. This section provides real-time monitoring of active network connections, traffic analysis, and security threat detection.
|
||
</p>
|
||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Active Connections:</span> View all active network connections where the source or destination matches an IP assigned to your server. Connections are automatically filtered to show only relevant traffic. The table supports sorting by any column and filtering by search terms.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Top Peers:</span> Identify the endpoints that have the most connections and highest traffic volume. This helps you understand which IPs or domains are consuming the most bandwidth.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Top Ports:</span> See which network ports are most active, helping you identify the services and protocols generating the most traffic.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Security:</span> Monitor security threats by detecting connections to known malicious IPs from blocklists (Spamhaus DROPv6 for IPv6, blocklist.de for IPv4). Security warnings display the threat type, malicious IP, port, and connection details.
|
||
</p>
|
||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||
<span class="font-semibold text-base/90">Export:</span> All connection data can be exported to CSV format for further analysis, reporting, or archiving.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</h2>
|
||
<p class="text-xs text-base/60">
|
||
Deep visibility into firewall sessions, security threats, and top consumers of bandwidth powered by the Core Router API.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
wire:click="exportNetworkAnalysis"
|
||
class="bg-background border border-neutral px-3 py-2 rounded-md text-sm font-semibold hover:bg-background/80 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
||
title="Export to CSV"
|
||
>
|
||
<x-ri-download-line class="size-4" />
|
||
Export
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div x-data="{ tab: 'connections' }" style="width: 100%; min-width: 100%;">
|
||
<div class="p-6 pb-4">
|
||
<div class="flex flex-wrap gap-2 border-b border-neutral/40 pb-4">
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 text-sm font-semibold rounded-md transition-colors"
|
||
:class="tab === 'connections' ? 'bg-primary/10 text-primary' : 'text-base/60 hover:text-base'"
|
||
@click="tab = 'connections'"
|
||
>
|
||
Active Connections
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 text-sm font-semibold rounded-md transition-colors"
|
||
:class="tab === 'top-connections' ? 'bg-primary/10 text-primary' : 'text-base/60 hover:text-base'"
|
||
@click="tab = 'top-connections'"
|
||
>
|
||
Top Peers
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 text-sm font-semibold rounded-md transition-colors"
|
||
:class="tab === 'ports' ? 'bg-primary/10 text-primary' : 'text-base/60 hover:text-base'"
|
||
@click="tab = 'ports'"
|
||
>
|
||
Top Ports
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="px-3 py-2 text-sm font-semibold rounded-md transition-colors"
|
||
:class="tab === 'security' ? 'bg-primary/10 text-primary' : 'text-base/60 hover:text-base'"
|
||
@click="tab = 'security'"
|
||
>
|
||
Security
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
@if($networkAnalysisLoading || ($networkAnalysis['lastUpdated'] === null && $networkAnalysis['error'] === null))
|
||
<div class="p-6 py-10 flex flex-col items-center gap-3 text-base/60" style="width: 100%; min-width: 100%;">
|
||
<svg fill="currentColor" stroke="currentColor" viewBox="0 0 128 128" class="size-6">
|
||
<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 font-semibold">
|
||
Fetching data from Core Router...
|
||
<a href="javascript:void(0)" onclick="window.location.reload()" class="text-primary hover:text-primary/80 underline">reload page</a>
|
||
</p>
|
||
</div>
|
||
@elseif($networkAnalysis['error'])
|
||
<div class="p-6" style="width: 100%; min-width: 100%;">
|
||
<div class="p-4 bg-warning/10 border border-warning/30 rounded-md">
|
||
<p class="text-sm font-semibold text-warning">{{ $networkAnalysis['error'] }}</p>
|
||
<p class="text-xs text-warning/70 mt-1">Ensure the Core Router API credentials are configured in your environment.</p>
|
||
</div>
|
||
</div>
|
||
@else
|
||
<div style="width: 100%; min-width: 100%; display: flex; flex-direction: column; box-sizing: border-box;">
|
||
<div x-show="tab === 'connections'" style="width: 100%; min-width: 100%; flex-shrink: 0; box-sizing: border-box;">
|
||
@if(empty($networkAnalysis['connections']))
|
||
<div class="text-center py-6 text-sm text-base/60">No matching connections tracked for this server.</div>
|
||
@else
|
||
<div x-data="tableFilterSort('network-connections-table')" style="width: 100%;">
|
||
<div class="p-4 border-b border-neutral bg-background/50">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<div class="flex-1 min-w-[200px]">
|
||
<input type="text"
|
||
x-model="search"
|
||
@input="filterTable()"
|
||
placeholder="Search connections..."
|
||
class="w-full px-3 py-2 text-sm bg-background border border-neutral rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50">
|
||
</div>
|
||
<button @click="search = ''; filterTable()"
|
||
x-show="search"
|
||
class="px-3 py-2 text-sm text-base/70 hover:text-base border border-neutral rounded-md hover:bg-background/80">
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full" id="network-connections-table">
|
||
<thead>
|
||
<tr class="border-b border-neutral bg-background">
|
||
<th @click.stop="sortTable('direction')" data-sort="direction" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Direction
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('protocol')" data-sort="protocol" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Protocol
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('source')" data-sort="source" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Source
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('destination')" data-sort="destination" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Destination
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('state')" data-sort="state" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
State
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('traffic')" data-sort="traffic" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Traffic
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-neutral/40">
|
||
@foreach($networkAnalysis['connections'] as $index => $connection)
|
||
@php
|
||
// Use raw bytes value from backend for reliable numeric sorting
|
||
$bytesForSort = (int)($connection['bytes'] ?? 0);
|
||
@endphp
|
||
<tr wire:key="conn-{{ $index }}"
|
||
class="hover:bg-background-secondary/50 transition-colors table-row"
|
||
data-direction="{{ strtolower($connection['direction'] ?? '') }}"
|
||
data-protocol="{{ strtolower($connection['protocol'] ?? '') }}"
|
||
data-source="{{ strtolower($connection['source'] ?? '') }}"
|
||
data-destination="{{ strtolower($connection['destination'] ?? '') }}"
|
||
data-state="{{ strtolower($connection['state'] ?? '') }}"
|
||
data-traffic="{{ $bytesForSort }}">
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@php
|
||
$direction = $connection['direction'] ?? '—';
|
||
@endphp
|
||
@if($direction !== '—')
|
||
<div class="flex items-center gap-1">
|
||
<span class="font-semibold text-base/80">{{ $direction }}</span>
|
||
<div class="relative" x-data="{ open: false, directionDescription: window.getDirectionDescription('{{ $direction }}') }" 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 Backdrop -->
|
||
<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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- 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" style="width: 100%; max-width: 100%; box-sizing: border-box;">
|
||
<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" style="overflow-wrap: break-word; word-wrap: break-word;">Direction: <span class="font-mono break-all">{{ $direction }}</span></h3>
|
||
</div>
|
||
<div class="text-sm text-base/80 prose prose-sm max-w-none" style="overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; white-space: normal;" x-html="directionDescription"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@else
|
||
—
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@php
|
||
$protocol = $connection['protocol'] ?? '—';
|
||
@endphp
|
||
@if($protocol !== '—')
|
||
<div class="flex items-center gap-1">
|
||
<span>{{ $protocol }}</span>
|
||
<div class="relative" x-data="{ open: false, protocolDescription: window.getProtocolDescription('{{ $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 Backdrop -->
|
||
<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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- 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" style="width: 100%; max-width: 100%; box-sizing: border-box;">
|
||
<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" style="overflow-wrap: break-word; word-wrap: break-word;">Protocol: <span class="font-mono break-all">{{ $protocol }}</span></h3>
|
||
</div>
|
||
<div class="text-sm text-base/80 prose prose-sm max-w-none" style="overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; white-space: normal;" x-html="protocolDescription"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@else
|
||
—
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@php
|
||
$sourceIP = $connection['source'] ?? '—';
|
||
$isSourcePublic = $sourceIP !== '—' && $this->checkIsPublicIP($sourceIP);
|
||
@endphp
|
||
<div class="flex items-center gap-1">
|
||
<span class="block font-mono text-xs">{{ $sourceIP }}</span>
|
||
@if($isSourcePublic)
|
||
<div class="relative" x-data="{ open: false, loading: false, ipData: null, ip: '{{ $sourceIP }}' }" 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">{{ $sourceIP }}</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" style="max-width: 100%; box-sizing: border-box;">
|
||
<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; box-sizing: border-box;">
|
||
<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; display: block; margin: 0; padding: 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>
|
||
@endif
|
||
</div>
|
||
@if(isset($connection['source_port']))
|
||
<span class="text-xs text-base/60">{{ $connection['source_port'] }}</span>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@php
|
||
$destIP = $connection['destination'] ?? '—';
|
||
$isDestPublic = $destIP !== '—' && $this->checkIsPublicIP($destIP);
|
||
@endphp
|
||
<div class="flex items-center gap-1">
|
||
<span class="block font-mono text-xs">{{ $destIP }}</span>
|
||
@if($isDestPublic)
|
||
<div class="relative" x-data="{ open: false, loading: false, ipData: null, ip: '{{ $destIP }}' }" 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">{{ $destIP }}</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" style="max-width: 100%; box-sizing: border-box;">
|
||
<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; box-sizing: border-box;">
|
||
<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; display: block; margin: 0; padding: 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>
|
||
@endif
|
||
</div>
|
||
@if(isset($connection['destination_port']))
|
||
<span class="text-xs text-base/60">{{ $connection['destination_port'] }}</span>
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
@php
|
||
$state = $connection['state'] ?? '—';
|
||
@endphp
|
||
@if($state !== '—')
|
||
<div class="flex items-center gap-1">
|
||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-semibold bg-base/10 text-base/70 uppercase">
|
||
{{ $state }}
|
||
</span>
|
||
<div class="relative" x-data="{ open: false, stateDescription: window.getStateDescription('{{ $state }}') }" 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 Backdrop -->
|
||
<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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- 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" style="width: 100%; max-width: 100%; box-sizing: border-box;">
|
||
<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" style="overflow-wrap: break-word; word-wrap: break-word;">Connection State: <span class="font-mono break-all uppercase">{{ $state }}</span></h3>
|
||
</div>
|
||
<div class="text-sm text-base/80 prose prose-sm max-w-none" style="overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; white-space: normal;" x-html="stateDescription"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@else
|
||
—
|
||
@endif
|
||
</td>
|
||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||
<div class="flex flex-col">
|
||
<span class="font-semibold">{{ $connection['bytes_human'] ?? '—' }}</span>
|
||
@if(isset($connection['packets']))
|
||
<span class="text-xs text-base/60">{{ number_format($connection['packets']) }} packets</span>
|
||
@endif
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
@endforeach
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
|
||
<div x-show="tab === 'top-connections'" style="width: 100%; min-width: 100%; flex-shrink: 0; box-sizing: border-box;">
|
||
@if(empty($networkAnalysis['topConnections']))
|
||
<div class="text-center py-6 text-sm text-base/60">No connection data available.</div>
|
||
@else
|
||
<div x-data="tableFilterSort('top-peers-table')" style="width: 100%;">
|
||
<div class="p-4 border-b border-neutral bg-background/50">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<div class="flex-1 min-w-[200px]">
|
||
<input type="text"
|
||
x-model="search"
|
||
@input="filterTable()"
|
||
placeholder="Search peers..."
|
||
class="w-full px-3 py-2 text-sm bg-background border border-neutral rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50">
|
||
</div>
|
||
<button @click="search = ''; filterTable()"
|
||
x-show="search"
|
||
class="px-3 py-2 text-sm text-base/70 hover:text-base border border-neutral rounded-md hover:bg-background/80">
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto" style="width: 100%;">
|
||
<table class="w-full text-sm" id="top-peers-table">
|
||
<thead>
|
||
<tr class="border-b border-neutral bg-background">
|
||
<th @click.stop="sortTable('endpoint')" data-sort="endpoint" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Endpoint
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('port')" data-sort="port" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Port
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('connections')" data-sort="connections" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Connections
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('traffic')" data-sort="traffic" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Traffic
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-neutral/30">
|
||
@foreach($networkAnalysis['topConnections'] as $index => $row)
|
||
@php
|
||
$endpointIP = $row['endpoint'] ?? '—';
|
||
$port = $row['port'] ?? 0;
|
||
$connections = $row['count'] ?? 0;
|
||
$bytes = $row['bytes'] ?? 0;
|
||
$bytesForSort = $bytes;
|
||
@endphp
|
||
<tr class="hover:bg-background-secondary/60 table-row"
|
||
data-endpoint="{{ strtolower($endpointIP) }}"
|
||
data-port="{{ $port }}"
|
||
data-connections="{{ $connections }}"
|
||
data-traffic="{{ $bytesForSort }}">
|
||
<td class="py-2 px-3 font-mono text-xs">
|
||
@php
|
||
$isEndpointPublic = $endpointIP !== '—' && $this->checkIsPublicIP($endpointIP);
|
||
@endphp
|
||
<div class="flex items-center gap-1">
|
||
<span>{{ $endpointIP }}</span>
|
||
@if($isEndpointPublic)
|
||
<div class="relative" x-data="{ open: false, loading: false, ipData: null, ip: '{{ $endpointIP }}' }" 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 - Reusing same structure as Active Connections -->
|
||
<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-2xl 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">{{ $endpointIP }}</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 overflow-hidden rounded-lg border border-neutral/20" style="min-height: 256px;">
|
||
<iframe :src="'https://www.openstreetmap.org/export/embed.html?bbox=' + (parseFloat(ipData.lon) - 0.01) + ',' + (parseFloat(ipData.lat) - 0.01) + ',' + (parseFloat(ipData.lon) + 0.01) + ',' + (parseFloat(ipData.lat) + 0.01) + '&layer=mapnik&marker=' + ipData.lat + ',' + ipData.lon"
|
||
class="w-full h-64 rounded-lg border-0"
|
||
frameborder="0"
|
||
loading="lazy"
|
||
allowfullscreen></iframe>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template x-if="ipData && ipData.status !== 'success'">
|
||
<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>
|
||
@endif
|
||
</div>
|
||
</td>
|
||
<td class="py-2 px-3 text-xs">
|
||
@if(isset($row['port']) && $row['port'] !== '—')
|
||
<div class="flex items-center gap-1">
|
||
<span>{{ $row['port'] }}</span>
|
||
<div class="relative" x-data="{ open: false, portDescription: window.getPortDescription({{ (int)($row['port'] ?? 0) }}) }" 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 Backdrop -->
|
||
<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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-200"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-150"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-md bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-4">
|
||
<button @click="open = false" class="absolute top-3 right-3 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-4" />
|
||
</button>
|
||
<div class="pr-6">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<x-ri-information-line class="size-5 text-primary" />
|
||
<h3 class="text-sm font-semibold">Port {{ $row['port'] }} Description</h3>
|
||
</div>
|
||
<p class="text-xs text-base/80" x-text="portDescription"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@else
|
||
—
|
||
@endif
|
||
</td>
|
||
<td class="py-2 px-3 text-xs">{{ number_format($row['count']) }}</td>
|
||
<td class="py-2 px-3 text-xs">
|
||
<div class="flex flex-col">
|
||
<span class="font-semibold">{{ $row['bytes_human'] }}</span>
|
||
<span class="text-base/50">{{ number_format($row['packets']) }} packets</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
@endforeach
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
|
||
<div x-show="tab === 'ports'" style="width: 100%; min-width: 100%; flex-shrink: 0; box-sizing: border-box;">
|
||
@if(empty($networkAnalysis['topPorts']))
|
||
<div class="text-center py-6 text-sm text-base/60">No port statistics are available.</div>
|
||
@else
|
||
<div x-data="tableFilterSort('top-ports-table')" style="width: 100%;">
|
||
<div class="p-4 border-b border-neutral bg-background/50">
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<div class="flex-1 min-w-[200px]">
|
||
<input type="text"
|
||
x-model="search"
|
||
@input="filterTable()"
|
||
placeholder="Search ports..."
|
||
class="w-full px-3 py-2 text-sm bg-background border border-neutral rounded-md focus:outline-none focus:ring-2 focus:ring-primary/50">
|
||
</div>
|
||
<button @click="search = ''; filterTable()"
|
||
x-show="search"
|
||
class="px-3 py-2 text-sm text-base/70 hover:text-base border border-neutral rounded-md hover:bg-background/80">
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto" style="width: 100%;">
|
||
<table class="w-full text-sm" id="top-ports-table">
|
||
<thead>
|
||
<tr class="border-b border-neutral bg-background">
|
||
<th @click.stop="sortTable('port')" data-sort="port" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Port
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('connections')" data-sort="connections" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Connections
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
<th @click.stop="sortTable('traffic')" data-sort="traffic" class="text-left py-3 px-4 text-xs font-semibold text-base/70 uppercase tracking-wider whitespace-nowrap cursor-pointer hover:bg-background-secondary/50 select-none">
|
||
<div class="flex items-center gap-2">
|
||
Traffic
|
||
<x-ri-arrow-up-down-line class="size-3" />
|
||
</div>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-neutral/30">
|
||
@foreach($networkAnalysis['topPorts'] as $index => $row)
|
||
@php
|
||
$port = $row['port'] ?? 0;
|
||
$connections = $row['connections'] ?? 0;
|
||
$bytes = $row['bytes'] ?? 0;
|
||
$bytesForSort = $bytes;
|
||
@endphp
|
||
<tr class="hover:bg-background-secondary/60 table-row"
|
||
data-port="{{ $port }}"
|
||
data-connections="{{ $connections }}"
|
||
data-traffic="{{ $bytesForSort }}">
|
||
<td class="py-2 px-3 font-semibold text-base/80">
|
||
<div class="flex items-center gap-1">
|
||
<span>{{ $row['port'] }}</span>
|
||
<div class="relative" x-data="{ open: false, portDescription: window.getPortDescription({{ (int)($row['port'] ?? 0) }}) }" 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 Backdrop -->
|
||
<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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-show="open"
|
||
x-transition:enter="transition ease-out duration-200"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-150"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative w-full max-w-md bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-4">
|
||
<button @click="open = false" class="absolute top-3 right-3 text-base/50 hover:text-base transition-colors">
|
||
<x-ri-close-line class="size-4" />
|
||
</button>
|
||
<div class="pr-6">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<x-ri-information-line class="size-5 text-primary" />
|
||
<h3 class="text-sm font-semibold">Port {{ $row['port'] }} Description</h3>
|
||
</div>
|
||
<p class="text-xs text-base/80" x-text="portDescription"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="py-2 px-3 text-xs">{{ number_format($row['connections']) }}</td>
|
||
<td class="py-2 px-3 text-xs">
|
||
<div class="flex flex-col">
|
||
<span class="font-semibold">{{ $row['bytes_human'] }}</span>
|
||
<span class="text-base/50">{{ number_format($row['packets']) }} packets</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
@endforeach
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
|
||
<div x-show="tab === 'security'" style="width: 100%; min-width: 100%; flex-shrink: 0; box-sizing: border-box;">
|
||
@if(empty($networkAnalysis['security']))
|
||
<div class="text-center py-6 text-sm text-base/60">No security threats detected. All connections appear to be safe.</div>
|
||
@else
|
||
<div class="space-y-3" style="width: 100%;">
|
||
@foreach($networkAnalysis['security'] as $warning)
|
||
@php
|
||
$conn = $warning['connection'] ?? [];
|
||
@endphp
|
||
@php
|
||
$riskLevel = $warning['risk_level'] ?? 'Medium';
|
||
$attackType = $warning['attack_type'] ?? 'Unknown Threat';
|
||
$riskDescription = $warning['risk_description'] ?? 'Malicious IP detected from blocklist';
|
||
$riskBadgeClass = match($riskLevel) {
|
||
'Critical' => 'bg-danger/20 text-danger border-danger/30',
|
||
'High' => 'bg-warning/20 text-warning border-warning/30',
|
||
'Medium' => 'bg-warning/10 text-warning border-warning/20',
|
||
'Low' => 'bg-base/10 text-base/70 border-base/20',
|
||
default => 'bg-warning/10 text-warning border-warning/20',
|
||
};
|
||
@endphp
|
||
<div class="border border-warning/50 rounded-lg p-4 bg-warning/10" x-data="{ expanded: false }">
|
||
<div class="flex items-start gap-3">
|
||
<x-ri-alert-line class="size-5 text-warning flex-shrink-0 mt-0.5" />
|
||
<div class="flex-1">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<span class="text-sm font-semibold text-warning">Security Threat Detected</span>
|
||
<span class="text-xs px-2 py-1 rounded-full font-semibold bg-warning/20 text-warning uppercase">
|
||
{{ $warning['type'] }}
|
||
</span>
|
||
<span class="text-xs px-2 py-1 rounded-full font-semibold border {{ $riskBadgeClass }} uppercase">
|
||
{{ $riskLevel }} Risk
|
||
</span>
|
||
<span class="text-xs px-2 py-1 rounded-full font-semibold bg-background border border-neutral/30 text-base/90">
|
||
{{ $attackType }}
|
||
</span>
|
||
@if(isset($warning['quantity']) && $warning['quantity'] > 1)
|
||
<span class="text-xs px-2 py-1 rounded-full font-semibold bg-primary/20 text-primary border border-primary/30">
|
||
{{ $warning['quantity'] }}x Repeated
|
||
</span>
|
||
@endif
|
||
</div>
|
||
<button
|
||
@click="expanded = !expanded"
|
||
class="text-base/50 hover:text-base/70 transition-colors flex-shrink-0"
|
||
x-bind:title="expanded ? 'Collapse details' : 'Expand details'"
|
||
>
|
||
<x-ri-arrow-down-s-line
|
||
class="size-4 transition-transform duration-200"
|
||
x-bind:class="expanded ? 'rotate-180' : ''"
|
||
/>
|
||
</button>
|
||
</div>
|
||
<p class="text-sm text-base/90 mb-2">
|
||
{{ $warning['threat'] }}
|
||
@if(isset($warning['quantity']) && $warning['quantity'] > 1)
|
||
<span class="text-xs text-base/60 ml-2">({{ $warning['quantity'] }} similar connection{{ $warning['quantity'] > 1 ? 's' : '' }} detected)</span>
|
||
@endif
|
||
</p>
|
||
@php
|
||
$threatIP = $warning['ip'] ?? '—';
|
||
$isThreatPublic = $threatIP !== '—' && $this->checkIsPublicIP($threatIP);
|
||
@endphp
|
||
<div class="text-xs mb-2">
|
||
<span class="text-base/60">IP Address:</span>
|
||
<span class="font-mono font-semibold ml-1">{{ $threatIP }}</span>
|
||
@if($isThreatPublic)
|
||
<div class="relative inline-block ml-1" x-data="{ open: false, loading: false, ipData: null, ip: '{{ $threatIP }}' }" 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>
|
||
<!-- IP Location Modal (same as before) -->
|
||
<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-warning flex-shrink-0" />
|
||
<h3 class="text-lg font-semibold break-words">IP Address: <span class="font-mono break-all">{{ $threatIP }}</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" style="max-width: 100%; box-sizing: border-box;">
|
||
<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; box-sizing: border-box;">
|
||
<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; display: block; margin: 0; padding: 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>
|
||
@endif
|
||
</div>
|
||
<div x-show="expanded"
|
||
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"
|
||
class="space-y-3"
|
||
style="display: none;">
|
||
<div class="mb-3 p-3 rounded-md bg-background/50 border border-neutral/20">
|
||
<div class="text-xs font-semibold text-base/90 mb-1">Risk Analysis:</div>
|
||
<p class="text-xs text-base/75 leading-relaxed">{{ $riskDescription }}</p>
|
||
</div>
|
||
@if(isset($warning['mitigations']) && !empty($warning['mitigations']))
|
||
<div class="mb-3">
|
||
<div class="text-xs font-semibold text-base/90 mb-2">Suggested Mitigations:</div>
|
||
<div class="space-y-2">
|
||
@foreach(array_slice($warning['mitigations'], 0, 5) as $mitigation)
|
||
@php
|
||
$priorityColor = match($mitigation['priority'] ?? 'Medium') {
|
||
'Critical' => 'border-danger/30 bg-danger/5',
|
||
'High' => 'border-warning/30 bg-warning/5',
|
||
'Medium' => 'border-primary/30 bg-primary/5',
|
||
'Low' => 'border-base/20 bg-base/5',
|
||
default => 'border-base/20 bg-base/5',
|
||
};
|
||
$priorityText = match($mitigation['priority'] ?? 'Medium') {
|
||
'Critical' => 'text-danger',
|
||
'High' => 'text-warning',
|
||
'Medium' => 'text-primary',
|
||
'Low' => 'text-base/70',
|
||
default => 'text-base/70',
|
||
};
|
||
@endphp
|
||
<div class="p-2 rounded-md border {{ $priorityColor }}">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<span class="text-xs font-semibold {{ $priorityText }} uppercase">
|
||
{{ $mitigation['priority'] ?? 'Medium' }}
|
||
</span>
|
||
<span class="text-xs font-semibold text-base/90">
|
||
{{ $mitigation['title'] ?? 'Mitigation' }}
|
||
</span>
|
||
</div>
|
||
<p class="text-xs text-base/75 leading-relaxed mb-1">{{ $mitigation['description'] ?? '' }}</p>
|
||
<div class="mt-1 p-1.5 rounded bg-background/50 border border-neutral/10">
|
||
<p class="text-xs font-mono text-base/70 break-all">{{ $mitigation['action'] ?? '' }}</p>
|
||
</div>
|
||
</div>
|
||
@endforeach
|
||
@if(count($warning['mitigations']) > 5)
|
||
<p class="text-xs text-base/60 italic">+ {{ count($warning['mitigations']) - 5 }} more mitigation suggestion{{ count($warning['mitigations']) - 5 > 1 ? 's' : '' }} available</p>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
@endif
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
|
||
@if(isset($conn['protocol']))
|
||
<div>
|
||
<span class="text-base/60">Protocol:</span>
|
||
<span class="font-semibold ml-1">{{ $conn['protocol'] }}</span>
|
||
</div>
|
||
@endif
|
||
@if(isset($conn['destination']))
|
||
<div>
|
||
<span class="text-base/60">{{ $warning['type'] === 'source' ? 'Destination' : 'Source' }}:</span>
|
||
<span class="font-mono font-semibold ml-1">{{ $warning['type'] === 'source' ? $conn['destination'] : $conn['source'] }}</span>
|
||
@if(isset($conn[$warning['type'] === 'source' ? 'destination_port' : 'source_port']))
|
||
<span class="font-mono text-base/70">:{{ $conn[$warning['type'] === 'source' ? 'destination_port' : 'source_port'] }}</span>
|
||
@endif
|
||
</div>
|
||
@endif
|
||
@if(isset($conn['bytes_human']))
|
||
<div>
|
||
<span class="text-base/60">Traffic:</span>
|
||
<span class="font-semibold ml-1">{{ $conn['bytes_human'] }}</span>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Delete Confirmation Modal -->
|
||
<div x-data="{ open: false, statId: null, statDate: '', statCpu: '', statMemory: '', statDisk: '', statStatus: '' }"
|
||
@open-delete-stat-modal.window="open = true; statId = $event.detail.id; statDate = $event.detail.date; statCpu = $event.detail.cpu; statMemory = $event.detail.memory; statDisk = $event.detail.disk; statStatus = $event.detail.status">
|
||
<!-- Modal Backdrop -->
|
||
<div x-cloak x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
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"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-cloak x-show="open"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="open = false"
|
||
@keydown.escape.window="open = false"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative bg-background-secondary border border-neutral rounded-xl shadow-xl max-w-md w-full mx-4">
|
||
<div class="p-6">
|
||
<div class="flex items-center gap-3 mb-4">
|
||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-danger/20 flex items-center justify-center">
|
||
<x-ri-delete-bin-line class="size-5 text-danger" />
|
||
</div>
|
||
<div>
|
||
<h3 class="text-lg font-semibold">Delete Statistics Entry</h3>
|
||
<p class="text-sm text-base/70">This action cannot be undone.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-4 p-4 bg-background rounded-lg border border-neutral/40">
|
||
<p class="text-sm text-base/70 mb-2">You are about to delete the following statistics entry:</p>
|
||
<div class="space-y-1 text-sm">
|
||
<div class="flex justify-between">
|
||
<span class="text-base/70">Date & Time:</span>
|
||
<span class="font-semibold" x-text="statDate"></span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-base/70">Processor Utilization:</span>
|
||
<span class="font-semibold"><span x-text="statCpu"></span>%</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-base/70">Memory Utilization:</span>
|
||
<span class="font-semibold"><span x-text="statMemory"></span>%</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-base/70">Storage I/O:</span>
|
||
<span class="font-semibold"><span x-text="statDisk"></span>%</span>
|
||
</div>
|
||
<template x-if="statStatus">
|
||
<div class="flex justify-between">
|
||
<span class="text-base/70">VM Status:</span>
|
||
<span class="font-semibold">
|
||
<span class="px-2 py-1 rounded text-xs font-semibold"
|
||
:class="statStatus === 'running' ? 'bg-success/20 text-success' : 'bg-inactive/20 text-inactive'"
|
||
x-text="statStatus.charAt(0).toUpperCase() + statStatus.slice(1)"></span>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-3 justify-end">
|
||
<x-button.secondary @click="open = false">
|
||
Cancel
|
||
</x-button.secondary>
|
||
<x-button.danger @click="$wire.deleteStatistic(statId); open = false">
|
||
<x-loading target="deleteStatistic" />
|
||
<div wire:loading.remove wire:target="deleteStatistic">
|
||
Delete
|
||
</div>
|
||
<div wire:loading wire:target="deleteStatistic">
|
||
Deleting...
|
||
</div>
|
||
</x-button.danger>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Clear Statistics Confirmation Modal -->
|
||
<div x-data="{ showClearStatisticsModal: @entangle('showClearStatisticsModal') }">
|
||
<!-- Modal Backdrop -->
|
||
<div x-cloak x-show="showClearStatisticsModal"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100"
|
||
x-transition:leave-end="opacity-0"
|
||
@click="$wire.closeClearStatisticsModal()"
|
||
@keydown.escape.window="$wire.closeClearStatisticsModal()"
|
||
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-cloak x-show="showClearStatisticsModal"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="$wire.closeClearStatisticsModal()"
|
||
@keydown.escape.window="$wire.closeClearStatisticsModal()"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative bg-background-secondary border border-neutral rounded-xl shadow-xl max-w-md w-full mx-4">
|
||
<div class="p-6">
|
||
<div class="flex items-center gap-3 mb-4">
|
||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-danger/20 flex items-center justify-center">
|
||
<x-ri-alert-line class="size-5 text-danger" />
|
||
</div>
|
||
<div>
|
||
<h3 class="text-lg font-semibold">Clear Statistics History</h3>
|
||
<p class="text-sm text-base/70">This action removes every statistics entry for this server.</p>
|
||
</div>
|
||
</div>
|
||
<div class="mb-4 p-4 bg-background rounded-lg border border-neutral/40 text-sm text-base/80 space-y-2">
|
||
<p>All automatic and manual statistics snapshots will be permanently deleted.</p>
|
||
<p class="text-base/60">This cannot be undone.</p>
|
||
</div>
|
||
<div class="flex gap-3 justify-end">
|
||
<x-button.secondary wire:click="closeClearStatisticsModal">
|
||
Cancel
|
||
</x-button.secondary>
|
||
<x-button.danger wire:click="clearStatisticsHistory">
|
||
<x-loading target="clearStatisticsHistory" />
|
||
<div wire:loading.remove wire:target="clearStatisticsHistory">
|
||
Clear History
|
||
</div>
|
||
<div wire:loading wire:target="clearStatisticsHistory">
|
||
Clearing...
|
||
</div>
|
||
</x-button.danger>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Export Info Modal -->
|
||
<div x-data="{ showExportInfoModal: @entangle('showExportInfoModal') }">
|
||
<!-- Modal Backdrop -->
|
||
<div x-cloak x-show="showExportInfoModal"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0"
|
||
x-transition:enter-end="opacity-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100"
|
||
x-transition:leave-end="opacity-0"
|
||
@click="$wire.closeExportInfoModal()"
|
||
@keydown.escape.window="$wire.closeExportInfoModal()"
|
||
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||
style="display: none;">
|
||
</div>
|
||
<!-- Modal -->
|
||
<div x-cloak x-show="showExportInfoModal"
|
||
x-transition:enter="transition ease-out duration-300"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
@click.away="$wire.closeExportInfoModal()"
|
||
@keydown.escape.window="$wire.closeExportInfoModal()"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style="display: none;">
|
||
<div class="relative bg-background-secondary border border-neutral rounded-xl shadow-xl max-w-md w-full mx-4">
|
||
<div class="p-6 space-y-4">
|
||
<div class="flex items-center gap-3">
|
||
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-info/20 flex items-center justify-center">
|
||
<x-ri-information-line class="size-5 text-info" />
|
||
</div>
|
||
<div>
|
||
<h3 class="text-lg font-semibold">Nothing to Export</h3>
|
||
<p class="text-sm text-base/70">We couldn't find any rows to include in the export.</p>
|
||
</div>
|
||
</div>
|
||
<p class="text-sm text-base/80">{{ $exportInfoMessage }}</p>
|
||
<div class="flex justify-end gap-3">
|
||
<x-button.secondary wire:click="closeExportInfoModal">
|
||
Close
|
||
</x-button.secondary>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
@script
|
||
<script>
|
||
// Extract IP from string, handling ports for both IPv4 and IPv6
|
||
window.extractIP = function(ip) {
|
||
if (!ip || typeof ip !== 'string') return ip;
|
||
|
||
ip = ip.trim();
|
||
|
||
// Handle IPv4 with port (e.g., "192.168.1.1:8080")
|
||
const ipv4WithPort = /^(\d+\.\d+\.\d+\.\d+):(\d+)$/.exec(ip);
|
||
if (ipv4WithPort) {
|
||
return ipv4WithPort[1];
|
||
}
|
||
|
||
// Handle IPv6 with port in brackets (e.g., "[2001:db8::1]:8080")
|
||
const ipv6WithPort = /^\[([^\]]+)\]:(\d+)$/.exec(ip);
|
||
if (ipv6WithPort) {
|
||
return ipv6WithPort[1];
|
||
}
|
||
|
||
// Check if it's a valid IPv6 address (contains multiple colons)
|
||
if (ip.includes(':') && (ip.match(/:/g) || []).length >= 2) {
|
||
// Might be IPv6, return as-is
|
||
return ip;
|
||
}
|
||
|
||
return ip;
|
||
};
|
||
|
||
// Check if an IP address is public (not private/localhost)
|
||
window.isPublicIP = function(ip) {
|
||
if (!ip || ip === '—' || typeof ip !== 'string') return false;
|
||
|
||
// Extract IP address (handles ports properly for both IPv4 and IPv6)
|
||
ip = window.extractIP(ip);
|
||
|
||
// Check for localhost
|
||
if (ip === 'localhost' || ip === '127.0.0.1' || ip === '::1') return false;
|
||
|
||
// Check for IPv4 private ranges
|
||
const ipv4PrivateRanges = [
|
||
/^10\./, // 10.0.0.0/8
|
||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
|
||
/^192\.168\./, // 192.168.0.0/16
|
||
/^169\.254\./, // 169.254.0.0/16 (link-local)
|
||
/^127\./, // 127.0.0.0/8
|
||
/^0\./ // 0.0.0.0/8
|
||
];
|
||
|
||
// Check if it's IPv4
|
||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||
if (ipv4Regex.test(ip)) {
|
||
// Check against private ranges
|
||
if (ipv4PrivateRanges.some(range => range.test(ip))) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Check if it's IPv6
|
||
if (ip.includes(':')) {
|
||
// Check for IPv6 private ranges
|
||
// 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
|
||
// Basic IPv6 validation
|
||
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 (uses HTTP protocol server-side)
|
||
window.fetchIPLocation = async function(ip) {
|
||
if (!window.isPublicIP(ip)) {
|
||
return { status: 'fail', message: 'Not a public IP address' };
|
||
}
|
||
|
||
// Extract IP address (handles ports properly for both IPv4 and IPv6)
|
||
ip = window.extractIP(ip);
|
||
|
||
try {
|
||
// Use Livewire to call server-side method which fetches from http://ip-api.com/json/{ip}
|
||
// This avoids mixed content issues when the site is served over HTTPS
|
||
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 (used by Direction, Protocol, and State modals)
|
||
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;
|
||
};
|
||
|
||
// Direction descriptions mapping
|
||
window.getDirectionDescription = function(direction) {
|
||
if (!direction || typeof direction !== 'string') {
|
||
return '<div class="space-y-4"><p>Unknown direction</p></div>';
|
||
}
|
||
|
||
const directionUpper = direction.toUpperCase();
|
||
const directionDescriptions = {
|
||
'INBOUND': formatStateDescription(
|
||
'Inbound traffic represents network connections coming TO your server. This means data is being sent from an external source (another computer, device, or service) and arriving at your server.',
|
||
'All directions are shown from your server\'s perspective. "Inbound" means the connection is coming IN to your server - external clients or services are connecting to your server.',
|
||
'<p>Inbound connections typically include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>External clients connecting to your server (e.g., web browsers accessing your website)</li><li>API requests from external services</li><li>File uploads or data being sent to your server</li><li>Remote connections (SSH, RDP) to your server</li><li>Email being received by your mail server</li><li>DNS queries to your DNS server</li></ul>'
|
||
),
|
||
'OUTBOUND': formatStateDescription(
|
||
'Outbound traffic represents network connections going FROM your server. This means data is being sent from your server to an external destination (another computer, device, or service).',
|
||
'All directions are shown from your server\'s perspective. "Outbound" means the connection is going OUT from your server - your server is initiating connections to external systems.',
|
||
'<p>Outbound connections typically include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Your server making requests to external APIs or services</li><li>Database connections to remote servers</li><li>Your server downloading files or updates</li><li>Your server initiating connections to other systems</li><li>DNS queries from your server to external DNS servers</li><li>Email being sent from your mail server</li></ul>'
|
||
),
|
||
};
|
||
|
||
return directionDescriptions[directionUpper] || formatStateDescription(
|
||
`Direction "${direction}" represents the flow of network traffic relative to your server.`,
|
||
'All directions are shown from your server\'s perspective, indicating whether traffic is coming into your server (inbound) or going out from your server (outbound).',
|
||
''
|
||
);
|
||
};
|
||
|
||
// 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.).'
|
||
),
|
||
'IGMP': formatStateDescription(
|
||
'IGMP (Internet Group Management Protocol) is a communications protocol used by hosts and adjacent routers to establish multicast group memberships.',
|
||
'IGMP is used to manage multicast group membership on IPv4 networks, allowing hosts to join and leave multicast groups and routers to track which hosts are members of which groups.',
|
||
'<p>Common uses include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Multicast video streaming</li><li>Software distribution to multiple hosts</li><li>Network discovery protocols</li><li>IP television (IPTV)</li></ul>',
|
||
'IGMP enables efficient one-to-many communication by allowing routers to forward multicast traffic only to networks with active group members.'
|
||
),
|
||
'ESP': formatStateDescription(
|
||
'ESP (Encapsulating Security Payload) is a protocol used in IPsec to provide confidentiality, data origin authentication, connectionless integrity, and anti-replay protection for IP packets.',
|
||
'ESP encrypts the payload (data) of IP packets and provides authentication to ensure data integrity and origin verification.',
|
||
'<p>Common uses include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>VPN connections (Virtual Private Networks)</li><li>Secure site-to-site connections</li><li>Encrypted network tunnels</li><li>Secure remote access</li></ul>',
|
||
'ESP is part of the IPsec suite and can be used alone or in combination with AH (Authentication Header) for enhanced security.'
|
||
),
|
||
'AH': formatStateDescription(
|
||
'AH (Authentication Header) is a protocol used in IPsec to provide connectionless integrity, data origin authentication, and optional anti-replay protection for IP packets.',
|
||
'AH authenticates IP packets to ensure they have not been modified in transit and verifies the origin of the data. Unlike ESP, AH does not provide encryption.',
|
||
'<p>Common uses include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>VPN connections where integrity is critical</li><li>Secure network tunnels</li><li>Site-to-site connections</li><li>Combined with ESP for enhanced security</li></ul>',
|
||
'AH provides authentication without encryption, making it useful when you need to verify data integrity but encryption is not required or is handled separately.'
|
||
),
|
||
'GRE': formatStateDescription(
|
||
'GRE (Generic Routing Encapsulation) is a tunneling protocol used to encapsulate a wide variety of network layer protocols inside virtual point-to-point links over an Internet Protocol network.',
|
||
'GRE creates tunnels that can carry many different protocols, allowing you to transport protocols that would not normally be routable over an IP network.',
|
||
'<p>Common uses include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>VPN tunnels</li><li>Site-to-site connections</li><li>Multicast tunneling</li><li>Carrying non-IP protocols over IP networks</li><li>Network virtualization</li></ul>',
|
||
'GRE is a simple, lightweight tunneling protocol that adds minimal overhead and is widely supported across network equipment.'
|
||
),
|
||
'SCTP': formatStateDescription(
|
||
'SCTP (Stream Control Transmission Protocol) is a transport layer protocol that provides reliable, message-oriented communication with congestion control and multi-homing support.',
|
||
'SCTP combines features of TCP (reliability, congestion control) with some UDP characteristics (message-oriented) and adds unique features like multi-homing and message bundling.',
|
||
'<p>Common uses include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Telecommunications signaling (SIGTRAN)</li><li>WebRTC data channels</li><li>Database replication</li><li>Applications requiring multi-homed connections</li></ul>',
|
||
'SCTP is less common than TCP or UDP but provides advantages for specific use cases, especially in telecommunications and applications requiring multiple network paths.'
|
||
),
|
||
'DCCP': formatStateDescription(
|
||
'DCCP (Datagram Congestion Control Protocol) is a message-oriented transport layer protocol that provides congestion control for unreliable datagrams.',
|
||
'DCCP provides congestion control (like TCP) but without reliability guarantees (like UDP), making it suitable for applications that need congestion control but can tolerate packet loss.',
|
||
'<p>Common uses include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Streaming media applications</li><li>Online gaming where congestion control is needed</li><li>Real-time applications requiring flow control</li></ul>',
|
||
'DCCP is relatively uncommon and provides a middle ground between TCP (reliable but slower) and UDP (fast but no congestion control).'
|
||
),
|
||
'OSPF': formatStateDescription(
|
||
'OSPF (Open Shortest Path First) is a routing protocol used in IP networks to determine the best path for data packets based on link-state routing algorithms.',
|
||
'OSPF is an Interior Gateway Protocol (IGP) that routers use to exchange routing information and build a map of the network topology to calculate optimal routes.',
|
||
'<p>Key features include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Link-state routing for efficient path calculation</li><li>Fast convergence after network changes</li><li>Support for variable-length subnet masks (VLSM)</li><li>Authentication support for security</li></ul>',
|
||
'OSPF is commonly used in enterprise networks and internet service provider networks for internal routing.'
|
||
),
|
||
'EIGRP': formatStateDescription(
|
||
'EIGRP (Enhanced Interior Gateway Routing Protocol) is a Cisco proprietary routing protocol that combines the benefits of distance-vector and link-state routing protocols.',
|
||
'EIGRP is an advanced Interior Gateway Protocol that uses Diffusing Update Algorithm (DUAL) to provide fast convergence and efficient use of bandwidth.',
|
||
'<p>Key features include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Fast convergence</li><li>Low bandwidth usage</li><li>Support for multiple network layer protocols</li><li>Route summarization capabilities</li></ul>',
|
||
'EIGRP is primarily used in Cisco-based networks and provides efficient routing with minimal network overhead.'
|
||
),
|
||
};
|
||
|
||
return protocolDescriptions[protocolUpper] || formatStateDescription(
|
||
`The protocol "${protocol}" is a network protocol identifier that defines the rules and format for data communication between network devices.`,
|
||
'Network protocols specify how data is formatted, transmitted, and received over a network.',
|
||
''
|
||
);
|
||
};
|
||
|
||
// Connection state descriptions mapping
|
||
window.getStateDescription = function(state) {
|
||
if (!state || typeof state !== 'string') {
|
||
return 'Unknown connection state';
|
||
}
|
||
|
||
// Handle special case for ICMP (0:0)
|
||
if (state === '0:0' || state.trim() === '0:0') {
|
||
return `<div class="space-y-4">
|
||
<div>
|
||
<h4 class="font-semibold text-base mb-2">Overview</h4>
|
||
<p>0:0 indicates that there is no state level data for this connection. This state is typically only found on ICMP (Internet Control Message Protocol) states, since ICMP does not have state levels like other protocols (TCP, UDP, etc.).</p>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-semibold text-base mb-2">What This Means</h4>
|
||
<p>ICMP is a stateless protocol used for diagnostic and control purposes such as ping (echo request/reply), traceroute, or error reporting. The "0:0" format means there is no connection state tracking because ICMP packets are connectionless and don't establish sessions like TCP connections do.</p>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-semibold text-base mb-2">Is This Normal?</h4>
|
||
<p>Yes, this is completely normal for ICMP traffic and simply indicates the protocol's stateless nature. ICMP connections will always show 0:0 since they don't use connection states.</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Handle colon-separated states (e.g., "TIME_WAIT:TIME_WAIT" or "SINGLE:MULTIPLE")
|
||
let stateParts = state.split(':');
|
||
let primaryState = stateParts[0] ? stateParts[0].trim().toUpperCase() : '';
|
||
let secondaryState = stateParts[1] ? stateParts[1].trim().toUpperCase() : '';
|
||
|
||
// Handle UDP states (SINGLE:MULTIPLE or MULTIPLE:SINGLE) - common DNS pattern
|
||
if ((primaryState === 'SINGLE' && secondaryState === 'MULTIPLE') ||
|
||
(primaryState === 'MULTIPLE' && secondaryState === 'SINGLE')) {
|
||
return `<div class="space-y-4">
|
||
<div>
|
||
<h4 class="font-semibold text-base mb-2">Overview</h4>
|
||
<p>SINGLE:MULTIPLE (or MULTIPLE:SINGLE) is commonly observed with DNS queries and other UDP-based services. According to pfSense documentation, this pattern indicates asymmetric state tracking for UDP connections.</p>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-semibold text-base mb-2">State Format Explanation</h4>
|
||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||
<li>The part before the colon represents the connection state for the source side</li>
|
||
<li>The part after the colon represents the connection state for the destination side</li>
|
||
<li><strong>SINGLE</strong> means the firewall has observed a single packet from that direction</li>
|
||
<li><strong>MULTIPLE</strong> means the firewall has observed multiple packets from that direction</li>
|
||
</ul>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-semibold text-base mb-2">Common Scenarios</h4>
|
||
<p>This pattern typically occurs when a single request packet was sent, but the response came in multiple packets. This is very common with DNS queries where a small query packet is sent, but the response with DNS records is larger and may be split into multiple packets.</p>
|
||
</div>
|
||
<div>
|
||
<h4 class="font-semibold text-base mb-2">Protocol Behavior</h4>
|
||
<p>For UDP and other connectionless protocols, the firewall tracks state separately for each direction since UDP has no formal connection establishment like TCP does. This is completely normal for UDP connections.</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// If both parts are the same (e.g., "TIME_WAIT:TIME_WAIT", "ESTABLISHED:ESTABLISHED")
|
||
// Extract just the primary state for the description
|
||
const stateUpper = primaryState || state.toUpperCase();
|
||
const stateDescriptions = {
|
||
'ESTABLISHED': formatStateDescription(
|
||
'ESTABLISHED means a TCP connection is considered fully established by this side. According to pfSense documentation, this indicates the connection handshake (three-way handshake) has been completed and this side is actively exchanging data.',
|
||
'When you see ESTABLISHED:ESTABLISHED, it means both sides have a fully established two-way TCP connection. This is the normal, healthy state for active network sessions. Both directions are synchronized and actively communicating.',
|
||
'<p>Common examples include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Web browsing (HTTP/HTTPS)</li><li>File transfers</li><li>API communications</li><li>Database connections</li><li>SSH sessions</li><li>Any active TCP service interaction</li></ul>'
|
||
),
|
||
'SYN_SENT': formatStateDescription(
|
||
'SYN_SENT means, for TCP connections, the side showing this state sent a TCP SYN packet attempting to start a connection handshake. According to pfSense documentation, this indicates the connection is being initiated.',
|
||
'This is a normal intermediate state that should quickly transition to ESTABLISHED or SYN_RECEIVED. If it persists, it indicates a connection attempt that failed or timed out.',
|
||
'<p>When you see SYN_SENT:CLOSED, it means the side showing SYN_SENT has sent a TCP SYN packet but no response has been received from the far side. This often occurs when:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>The packet was blocked by a firewall or security rule</li><li>The packet was dropped or filtered along the network path</li><li>The destination is not responding or unavailable</li><li>Network routing issues prevent the packet from reaching its destination</li><li>The destination service is not running or not listening on the target port</li></ul>'
|
||
),
|
||
'SYN_RECEIVED': formatStateDescription(
|
||
'SYN_RECEIVED means this side has received a TCP SYN packet from the remote endpoint and has sent a SYN-ACK response.',
|
||
'The connection is in the process of being established and is waiting for the final ACK packet to complete the three-way handshake.',
|
||
'<p>This state typically appears:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>On the receiving side when accepting inbound connection requests</li><li>During the second step of the three-way handshake</li><li>As a transitional state that should quickly move to ESTABLISHED</li></ul><p class="mt-2">The connection handshake is almost complete and should quickly transition to ESTABLISHED once the final acknowledgment is received from the initiating side.</p>'
|
||
),
|
||
'FIN_WAIT_1': formatStateDescription(
|
||
'FIN_WAIT_1 means a TCP connection is in the process of closing. According to pfSense documentation, TIME_WAIT/FIN_WAIT indicates a TCP connection is in the process of closing and finishing up.',
|
||
'FIN_WAIT_1 specifically means this side has sent a FIN (finish) packet to request closing the connection and is waiting for an acknowledgment (ACK) from the remote endpoint. This is the first step in the graceful connection termination process.',
|
||
'<p>This state appears during normal connection shutdown sequences. The connection is being closed, but the remote side has not yet acknowledged the close request.</p>'
|
||
),
|
||
'FIN_WAIT_2': formatStateDescription(
|
||
'FIN_WAIT_2 means a TCP connection is in the process of closing.',
|
||
'This side\'s FIN packet has been acknowledged by the remote endpoint, but the remote endpoint has not yet sent its own FIN packet to close the connection. The connection is partially closed from this side\'s perspective - it has signaled it wants to close and received acknowledgment, but is waiting for the remote side to also signal its intention to close.',
|
||
'<p>This is a transitional state during connection termination where one side has initiated closure. The connection remains in this state until the remote endpoint sends its own FIN packet to complete the closure.</p>'
|
||
),
|
||
'TIME_WAIT': formatStateDescription(
|
||
'TIME_WAIT means a TCP connection is in the process of closing and finishing up.',
|
||
'The connection has been closed, but this side is keeping it in a waiting state for a period of time (typically 2-4 minutes) before fully releasing the connection resources.',
|
||
'<p>This ensures that any delayed, out-of-order, or duplicate packets from the recently closed connection can be properly handled and discarded before a new connection potentially reuses the same port and sequence numbers.</p><p class="mt-2">This is a normal part of TCP connection cleanup and helps prevent issues where old packets from a closed connection could interfere with new connections.</p>'
|
||
),
|
||
'CLOSE_WAIT': formatStateDescription(
|
||
'CLOSE_WAIT means this side has received a FIN packet from the remote endpoint (meaning the remote side wants to close the connection), and this side is waiting for the local application to finish processing and send its own FIN packet to complete the closure.',
|
||
'This state appears when the remote endpoint initiates the connection closure.',
|
||
'<p>The local application needs to:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Complete any pending operations</li><li>Clean up resources</li><li>Respond with its own close signal to complete the graceful shutdown</li></ul>'
|
||
),
|
||
'CLOSING': formatStateDescription(
|
||
'CLOSING means both sides of the connection are simultaneously trying to close it.',
|
||
'Both this side and the remote endpoint have sent FIN packets and are waiting for acknowledgments from each other.',
|
||
'<p>This is relatively uncommon but can occur when both sides decide to close the connection at nearly the same time. The connection is in the process of closing from both directions simultaneously, with both sides having signaled their intention to close.</p>'
|
||
),
|
||
'LAST_ACK': formatStateDescription(
|
||
'LAST_ACK means this side has sent a FIN packet and received an acknowledgment, but is waiting for the final acknowledgment (ACK) from the remote endpoint to complete the connection termination process.',
|
||
'This is the final stage of closing a connection.',
|
||
'<p>Once this final ACK is received, the connection will transition to CLOSED and all resources will be fully released. This state indicates the connection is almost completely closed and just awaiting final confirmation.</p>'
|
||
),
|
||
'LISTEN': formatStateDescription(
|
||
'LISTEN means this side is waiting for incoming connection requests on a specific port.',
|
||
'This state appears on servers that are ready to accept new connections from clients. The service is actively listening and waiting for clients to initiate connections.',
|
||
'<p>Common examples include:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Web servers listening on port 80 (HTTP) or 443 (HTTPS)</li><li>SSH daemons on port 22</li><li>Database servers on their respective ports</li><li>Any service waiting to accept incoming connections</li></ul><p class="mt-2">This is normal for server applications that need to accept incoming connections.</p>'
|
||
),
|
||
'CLOSED': formatStateDescription(
|
||
'CLOSED means, for TCP connections, the side with this status considers the connection closed, or the firewall has not received any traffic matching this state.',
|
||
'According to pfSense documentation, this indicates either the connection was never established, has been fully closed, or no packets have been observed.',
|
||
'<p>All connection resources have been released, and the connection can no longer be used for data exchange. This is the final state after a connection has been completely closed, or the initial state before a connection is established.</p>'
|
||
),
|
||
'NEW': formatStateDescription(
|
||
'NEW means a new connection has been detected but is not yet fully established.',
|
||
'This state appears during the very initial phase of connection setup, typically after the first packet is received but before the connection handshake is completed or recognized.',
|
||
'<p>It\'s a transitional state that should quickly move to:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>SYN_RECEIVED (for inbound connections)</li><li>ESTABLISHED (for fully established connections)</li></ul><p class="mt-2">Connections should not remain in NEW state for long periods. If they do, it may indicate an issue with connection establishment.</p>'
|
||
),
|
||
'RELATED': formatStateDescription(
|
||
'RELATED means this connection is associated with an existing connection, often used by firewalls for state tracking and security purposes.',
|
||
'The firewall marks these connections as RELATED so they can be properly managed together, allowing the firewall to recognize that the connection is legitimate and related to an established primary connection.',
|
||
'<p>This commonly appears with protocols like FTP that use multiple connections:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>A control connection for commands</li><li>Separate data connections for file transfers</li></ul><p class="mt-2">This helps maintain security while allowing complex protocols that require multiple connections to function properly.</p>'
|
||
),
|
||
'INVALID': formatStateDescription(
|
||
'INVALID means the connection state does not conform to standard TCP/IP connection state machine rules, or it has been explicitly marked as invalid by the firewall due to policy violations or security concerns.',
|
||
'This indicates that the connection has violated protocol rules or security policies.',
|
||
'<p>This may indicate:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>Malformed or corrupted connection attempts that violate protocol rules</li><li>Connection attempts that don\'t follow proper state transitions</li><li>Connections flagged by security policies or intrusion detection systems</li><li>Potential security threats, port scans, or attack attempts</li><li>Protocol anomalies or implementation errors</li><li>Packets that don\'t match expected state machine transitions</li></ul>',
|
||
'<strong>Security Note:</strong> If you see many INVALID connections, especially from the same source, it may warrant security investigation as it could indicate malicious activity, port scanning, or misconfigured applications.'
|
||
),
|
||
'NO_TRAFFIC': formatStateDescription(
|
||
'NO_TRAFFIC means the firewall has not received any packets that match this state from this direction. According to pfSense documentation, this indicates no packets have been observed from this side.',
|
||
'For UDP and other connectionless protocols, this means no response has been received from the destination side after an initial packet was sent.',
|
||
'<p>This is common when you see SINGLE:NO_TRAFFIC, where one side sent a packet (SINGLE) but received no response (NO_TRAFFIC). According to pfSense, this is similar to SYN_SENT:CLOSED but for UDP and other connectionless protocols - no response has been received from the destination side.</p><p class="mt-2">This could indicate:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>The destination is not responding</li><li>Packets are being dropped by a firewall</li><li>The service is not available</li><li>Network connectivity issues</li></ul>'
|
||
),
|
||
'SINGLE': formatStateDescription(
|
||
'SINGLE means the firewall has observed a single packet on this state from this side. According to pfSense documentation, this indicates only one packet has been seen from this direction.',
|
||
'For UDP and other connectionless protocols, this is common for UDP-based services where requests and responses may be asymmetric.',
|
||
'<p>Common patterns:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li><strong>SINGLE:NO_TRAFFIC</strong> - A request was sent but no response was received</li><li><strong>SINGLE:MULTIPLE</strong> - According to pfSense documentation, this is commonly observed with DNS where the client sends one packet but receives a large response in multiple packets</li></ul><p class="mt-2">This pattern is normal for UDP connections, especially DNS queries where the query is small but the response with DNS records is larger.</p>'
|
||
),
|
||
'MULTIPLE': formatStateDescription(
|
||
'MULTIPLE means the firewall has observed multiple packets on this state from this side. According to pfSense documentation, this indicates several packets have been seen from this direction.',
|
||
'For UDP and other connectionless protocols, this means multiple packets have been observed, indicating ongoing communication.',
|
||
'<p>When you see MULTIPLE:MULTIPLE, according to pfSense documentation, there are multiple packets in both directions, which is normal for a fully operational UDP connection.</p><p class="mt-2">This suggests active, ongoing bidirectional communication between both endpoints and indicates a healthy UDP-based service interaction.</p>'
|
||
),
|
||
};
|
||
|
||
// If we have a colon-separated state and the parts are the same (e.g., "TIME_WAIT:TIME_WAIT", "ESTABLISHED:ESTABLISHED")
|
||
if (state.includes(':') && primaryState === secondaryState && stateDescriptions[stateUpper]) {
|
||
const baseDescription = stateDescriptions[stateUpper];
|
||
const formatNote = `<div class="mt-4 pt-4 border-t border-neutral/20">
|
||
<h4 class="font-semibold text-base mb-2">State Format</h4>
|
||
<p>The state "${state}" shows the same state for both directions (${primaryState}:${secondaryState}). The colon (:) separates:</p>
|
||
<ul class="list-disc list-inside space-y-1 ml-2 mt-2">
|
||
<li><strong>Left side (${primaryState})</strong> = connection state for source-to-destination direction</li>
|
||
<li><strong>Right side (${secondaryState})</strong> = connection state for destination-to-source direction</li>
|
||
</ul>
|
||
<p class="mt-2">When both parts are identical, it indicates both directions are synchronized in the same state, which is typical for TCP connections.</p>
|
||
</div>`;
|
||
return baseDescription.replace('</div>', formatNote + '</div>');
|
||
}
|
||
|
||
// Handle asymmetric states (e.g., SYN_SENT:CLOSED, SINGLE:NO_TRAFFIC)
|
||
if (state.includes(':') && primaryState !== secondaryState) {
|
||
// Special handling for SYN_SENT:CLOSED - common pfSense pattern
|
||
if (primaryState === 'SYN_SENT' && secondaryState === 'CLOSED') {
|
||
return formatStateDescription(
|
||
'SYN_SENT:CLOSED is a common pairing according to pfSense documentation.',
|
||
'The side showing SYN_SENT has sent a TCP SYN packet to initiate a connection, but no response has been received from the far side (hence CLOSED).',
|
||
'<p>According to pfSense documentation, this often occurs when:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>The packet was blocked by a firewall or security rule</li><li>The packet did not reach its destination</li><li>The destination is not responding or unavailable</li><li>Network routing issues prevent the packet from being delivered</li><li>The destination service is not running or not listening on the target port</li></ul>',
|
||
'This is a normal state for connection attempts that fail or timeout. It indicates the connection attempt was initiated but never completed.'
|
||
);
|
||
}
|
||
|
||
// Special handling for SINGLE:NO_TRAFFIC - common UDP pattern
|
||
if ((primaryState === 'SINGLE' && secondaryState === 'NO_TRAFFIC') ||
|
||
(primaryState === 'NO_TRAFFIC' && secondaryState === 'SINGLE')) {
|
||
return formatStateDescription(
|
||
'SINGLE:NO_TRAFFIC is a common pairing for UDP and other connectionless protocols according to pfSense documentation.',
|
||
'One side has sent a single packet (SINGLE), but no response has been received from the destination side (NO_TRAFFIC). According to pfSense documentation, this is similar to SYN_SENT:CLOSED but for UDP and other connectionless protocols.',
|
||
'<p>This commonly occurs when:</p><ul class="list-disc list-inside space-y-1 ml-2 mt-2"><li>A UDP request was sent but the destination did not respond</li><li>The response packet was dropped or filtered by a firewall</li><li>The destination service is not available or not responding</li><li>Network connectivity issues prevent the response from being received</li></ul>',
|
||
'This is typical for UDP-based protocols like DNS when queries go unanswered.'
|
||
);
|
||
}
|
||
|
||
// Generic asymmetric state explanation
|
||
const primarySummary = stateDescriptions[primaryState] ?
|
||
(stateDescriptions[primaryState].match(/<p>(.*?)<\/p>/)?.[1] || `The ${primaryState} state`) :
|
||
`The ${primaryState} state`;
|
||
const secondarySummary = stateDescriptions[secondaryState] ?
|
||
(stateDescriptions[secondaryState].match(/<p>(.*?)<\/p>/)?.[1] || `The ${secondaryState} state`) :
|
||
`The ${secondaryState} state`;
|
||
|
||
return formatStateDescription(
|
||
'This connection shows different states for each direction.',
|
||
`According to pfSense documentation, each state entry contains two values with a colon between them, marking which value represents the state of the source (left) and which represents the destination (right). This asymmetric state pattern indicates that the connection is not synchronized between both directions.`,
|
||
`<div class="space-y-3">
|
||
<div>
|
||
<p class="font-semibold mb-1">Source → Destination (${primaryState}):</p>
|
||
<p class="ml-4 text-base/80">${primarySummary}</p>
|
||
</div>
|
||
<div>
|
||
<p class="font-semibold mb-1">Destination → Source (${secondaryState}):</p>
|
||
<p class="ml-4 text-base/80">${secondarySummary}</p>
|
||
</div>
|
||
</div>`,
|
||
'Each direction may be at a different stage of connection establishment, maintenance, or termination. This is common during connection setup, closure, or when one side is not responding.'
|
||
);
|
||
}
|
||
|
||
// Try to look up the state (for non-colon-separated states or if we couldn't match above)
|
||
if (stateDescriptions[stateUpper]) {
|
||
return stateDescriptions[stateUpper];
|
||
}
|
||
|
||
// Fallback for unknown states
|
||
return formatStateDescription(
|
||
`The "${primaryState || state}" state represents the current phase of the network connection lifecycle.`,
|
||
'This connection is in a state that indicates how it is being established, maintained, or terminated between your server and the remote endpoint.',
|
||
'<p>The state format uses a colon (:) to separate the state for each direction when both directions are tracked.</p>'
|
||
);
|
||
};
|
||
|
||
// Port descriptions mapping
|
||
window.getPortDescription = function(port) {
|
||
const portDescriptions = {
|
||
20: 'FTP Data Transfer - File Transfer Protocol data connection',
|
||
21: 'FTP Control - File Transfer Protocol control connection',
|
||
22: 'SSH - Secure Shell, used for secure remote login and command execution',
|
||
23: 'Telnet - Unencrypted remote terminal access (insecure)',
|
||
25: 'SMTP - Simple Mail Transfer Protocol for sending email',
|
||
53: 'DNS - Domain Name System for resolving domain names to IP addresses',
|
||
67: 'DHCP Server - Dynamic Host Configuration Protocol server',
|
||
68: 'DHCP Client - Dynamic Host Configuration Protocol client',
|
||
69: 'TFTP - Trivial File Transfer Protocol',
|
||
80: 'HTTP - Hypertext Transfer Protocol for web traffic',
|
||
88: 'Kerberos - Network authentication protocol',
|
||
110: 'POP3 - Post Office Protocol version 3 for receiving email',
|
||
119: 'NNTP - Network News Transfer Protocol',
|
||
123: 'NTP - Network Time Protocol for time synchronization',
|
||
135: 'MSRPC - Microsoft Remote Procedure Call',
|
||
139: 'NetBIOS - Network Basic Input/Output System',
|
||
143: 'IMAP - Internet Message Access Protocol for email',
|
||
161: 'SNMP - Simple Network Management Protocol',
|
||
162: 'SNMP Trap - Simple Network Management Protocol trap',
|
||
389: 'LDAP - Lightweight Directory Access Protocol',
|
||
443: 'HTTPS - Hypertext Transfer Protocol Secure (encrypted web traffic)',
|
||
445: 'SMB - Server Message Block for file sharing (Windows)',
|
||
465: 'SMTPS - SMTP over SSL/TLS',
|
||
514: 'Syslog - System logging protocol',
|
||
515: 'LPD/LPR - Line Printer Daemon/Line Printer Remote',
|
||
587: 'SMTP Submission - Mail submission port',
|
||
636: 'LDAPS - LDAP over SSL/TLS',
|
||
993: 'IMAPS - IMAP over SSL/TLS',
|
||
995: 'POP3S - POP3 over SSL/TLS',
|
||
1433: 'MSSQL - Microsoft SQL Server database',
|
||
1521: 'Oracle Database - Oracle database listener',
|
||
3306: 'MySQL - MySQL database server',
|
||
3389: 'RDP - Remote Desktop Protocol (Windows)',
|
||
5432: 'PostgreSQL - PostgreSQL database server',
|
||
5900: 'VNC - Virtual Network Computing remote desktop',
|
||
6379: 'Redis - Redis in-memory data store',
|
||
8080: 'HTTP Alternate - Alternative HTTP port, often used for web proxies',
|
||
8443: 'HTTPS Alternate - Alternative HTTPS port',
|
||
27017: 'MongoDB - MongoDB database server',
|
||
27018: 'MongoDB Shard - MongoDB sharding port',
|
||
27019: 'MongoDB Config - MongoDB configuration server',
|
||
};
|
||
|
||
// Check for common port ranges
|
||
if (port >= 1024 && port <= 49151) {
|
||
return 'Registered/User Port - Port in the registered range, commonly used by applications';
|
||
} else if (port >= 49152 && port <= 65535) {
|
||
return 'Dynamic/Private Port - Ephemeral port range used for temporary connections';
|
||
}
|
||
|
||
return portDescriptions[port] || 'Unknown Port - No standard service associated with this port number';
|
||
};
|
||
|
||
window.formatBytes = function formatBytes(a, b) {
|
||
if (b === undefined) b = 2;
|
||
if (!+a) return "0 Bytes";
|
||
var c = 0 > b ? 0 : b;
|
||
var d = Math.floor(Math.log(a) / Math.log(1024));
|
||
return parseFloat((a/Math.pow(1024,d)).toFixed(c)) + " " + ["Bytes","KiB","MiB","GiB","TiB","PiB","EiB","ZiB","YiB"][d];
|
||
};
|
||
|
||
window.tableFilterSort = function(tableId) {
|
||
// Check for network_search query parameter for the connections table
|
||
var initialSearch = '';
|
||
if (tableId === 'network-connections-table') {
|
||
var urlParams = new URLSearchParams(window.location.search);
|
||
var networkSearch = urlParams.get('network_search');
|
||
if (networkSearch) {
|
||
initialSearch = networkSearch;
|
||
// Clean up URL by removing the query parameter (optional)
|
||
// window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
|
||
}
|
||
}
|
||
|
||
var component = {
|
||
search: initialSearch,
|
||
sortColumn: null,
|
||
sortDirection: 'asc',
|
||
init: function() {
|
||
// If there's an initial search, trigger the filter after a short delay
|
||
// to ensure the table is fully rendered
|
||
if (this.search) {
|
||
var self = this;
|
||
setTimeout(function() {
|
||
self.filterTable();
|
||
}, 100);
|
||
}
|
||
},
|
||
filterTable: function() {
|
||
var table = document.getElementById(tableId);
|
||
if (!table) {
|
||
console.warn('Table not found:', tableId);
|
||
return;
|
||
}
|
||
|
||
var searchTerm = component.search.toLowerCase().trim();
|
||
|
||
// Get all tbody elements in the table
|
||
var allTbodies = [];
|
||
if (table.tBodies && table.tBodies.length > 0) {
|
||
allTbodies = Array.from(table.tBodies);
|
||
} else {
|
||
allTbodies = Array.from(table.querySelectorAll('tbody'));
|
||
}
|
||
|
||
if (allTbodies.length === 0) {
|
||
console.warn('No tbody found in table:', tableId);
|
||
return;
|
||
}
|
||
|
||
var mainTbody = allTbodies[0];
|
||
|
||
// Helper function to check if a row matches the search term
|
||
// Uses data-* attributes for accurate matching instead of textContent
|
||
// (textContent includes hidden modal content causing false positives)
|
||
function rowMatchesSearch(row, term) {
|
||
if (!term) return true;
|
||
|
||
// Define searchable data attributes for each table type
|
||
var searchableAttrs = [];
|
||
|
||
if (tableId === 'network-connections-table') {
|
||
// Active Connections table
|
||
searchableAttrs = ['direction', 'protocol', 'source', 'destination', 'state'];
|
||
} else if (tableId === 'top-peers-table') {
|
||
// Top Peers table
|
||
searchableAttrs = ['endpoint', 'port'];
|
||
} else if (tableId === 'top-ports-table') {
|
||
// Top Ports table
|
||
searchableAttrs = ['port'];
|
||
}
|
||
|
||
// Search in defined data attributes
|
||
for (var i = 0; i < searchableAttrs.length; i++) {
|
||
var attrValue = row.getAttribute('data-' + searchableAttrs[i]);
|
||
if (attrValue && attrValue.toLowerCase().includes(term)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
// Check if nested tbodies are children of mainTbody or siblings
|
||
var nestedTbodiesInFirst = [];
|
||
var firstTbodyChildren = mainTbody.children;
|
||
for (var i = 0; i < firstTbodyChildren.length; i++) {
|
||
var child = firstTbodyChildren[i];
|
||
if (child.tagName && child.tagName.toUpperCase() === 'TBODY') {
|
||
nestedTbodiesInFirst.push(child);
|
||
}
|
||
}
|
||
|
||
// Also check childNodes
|
||
if (nestedTbodiesInFirst.length === 0) {
|
||
var firstTbodyChildNodes = mainTbody.childNodes;
|
||
for (var j = 0; j < firstTbodyChildNodes.length; j++) {
|
||
var node = firstTbodyChildNodes[j];
|
||
if (node.nodeType === 1 && node.tagName && node.tagName.toUpperCase() === 'TBODY') {
|
||
nestedTbodiesInFirst.push(node);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Filter nested tbodies that are children of mainTbody
|
||
if (nestedTbodiesInFirst.length > 0) {
|
||
nestedTbodiesInFirst.forEach(function(tbody) {
|
||
var row = tbody.querySelector('tr.table-row');
|
||
if (row) {
|
||
var matches = rowMatchesSearch(row, searchTerm);
|
||
tbody.style.display = matches ? '' : 'none';
|
||
}
|
||
});
|
||
} else if (allTbodies.length > 1) {
|
||
// Multiple tbodies - likely browser moved nested ones to be siblings
|
||
// Skip the first one (main/empty) and filter the rest
|
||
for (var k = 1; k < allTbodies.length; k++) {
|
||
var tbody = allTbodies[k];
|
||
var row = tbody.querySelector('tr.table-row');
|
||
if (row) {
|
||
var matches = rowMatchesSearch(row, searchTerm);
|
||
tbody.style.display = matches ? '' : 'none';
|
||
}
|
||
}
|
||
} else {
|
||
// Simple structure - rows directly in the first tbody
|
||
var rows = mainTbody.querySelectorAll('tr.table-row');
|
||
rows.forEach(function(row) {
|
||
var matches = rowMatchesSearch(row, searchTerm);
|
||
row.style.display = matches ? '' : 'none';
|
||
});
|
||
}
|
||
},
|
||
sortTable: function(column, event) {
|
||
// Prevent any event propagation issues
|
||
if (event) {
|
||
event.stopPropagation();
|
||
event.preventDefault();
|
||
}
|
||
|
||
var table = document.getElementById(tableId);
|
||
if (!table) {
|
||
console.warn('Table not found:', tableId);
|
||
return;
|
||
}
|
||
|
||
// Fast path: simple numeric sort for Network Analysis "Traffic" column
|
||
if (tableId === 'network-connections-table' && column === 'traffic') {
|
||
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : table.querySelector('tbody');
|
||
if (!tbody) {
|
||
console.warn('No tbody found for network-connections-table');
|
||
return;
|
||
}
|
||
|
||
// Toggle sort direction
|
||
if (component.sortColumn === column) {
|
||
component.sortDirection = component.sortDirection === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
component.sortColumn = column;
|
||
component.sortDirection = 'desc'; // default to highest traffic first
|
||
}
|
||
|
||
var rows = Array.from(tbody.querySelectorAll('tr.table-row')).filter(function (row) {
|
||
return row.style.display !== 'none';
|
||
});
|
||
|
||
rows.sort(function (a, b) {
|
||
var aBytes = parseFloat(a.getAttribute('data-traffic') || '0') || 0;
|
||
var bBytes = parseFloat(b.getAttribute('data-traffic') || '0') || 0;
|
||
|
||
if (component.sortDirection === 'asc') {
|
||
return aBytes - bBytes;
|
||
}
|
||
return bBytes - aBytes;
|
||
});
|
||
|
||
rows.forEach(function (row) {
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
return;
|
||
}
|
||
|
||
if (component.sortColumn === column) {
|
||
component.sortDirection = component.sortDirection === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
component.sortColumn = column;
|
||
component.sortDirection = 'asc';
|
||
}
|
||
|
||
// Get all tbody elements in the table
|
||
// In nested structures, browsers may move nested tbodies to be siblings
|
||
var allTbodies = [];
|
||
if (table.tBodies && table.tBodies.length > 0) {
|
||
allTbodies = Array.from(table.tBodies);
|
||
} else {
|
||
allTbodies = Array.from(table.querySelectorAll('tbody'));
|
||
}
|
||
|
||
if (allTbodies.length === 0) {
|
||
console.warn('No tbody found in table:', tableId);
|
||
return;
|
||
}
|
||
|
||
// Check if this is a nested tbody structure
|
||
// If we have multiple tbodies, and the first one is empty or has nested tbodies as children
|
||
var mainTbody = allTbodies[0];
|
||
var itemsToSort = [];
|
||
|
||
// First, check if the first tbody has nested tbody children
|
||
var nestedTbodiesInFirst = [];
|
||
var firstTbodyChildren = mainTbody.children;
|
||
for (var i = 0; i < firstTbodyChildren.length; i++) {
|
||
var child = firstTbodyChildren[i];
|
||
if (child.tagName && child.tagName.toUpperCase() === 'TBODY') {
|
||
nestedTbodiesInFirst.push(child);
|
||
}
|
||
}
|
||
|
||
// Also check childNodes for nested tbodies
|
||
if (nestedTbodiesInFirst.length === 0) {
|
||
var firstTbodyChildNodes = mainTbody.childNodes;
|
||
for (var j = 0; j < firstTbodyChildNodes.length; j++) {
|
||
var node = firstTbodyChildNodes[j];
|
||
if (node.nodeType === 1 && node.tagName && node.tagName.toUpperCase() === 'TBODY') {
|
||
nestedTbodiesInFirst.push(node);
|
||
}
|
||
}
|
||
}
|
||
|
||
// If we found nested tbodies in the first tbody, use those
|
||
if (nestedTbodiesInFirst.length > 0) {
|
||
var tbodies = nestedTbodiesInFirst.filter(function(el) {
|
||
return el.style.display !== 'none';
|
||
});
|
||
|
||
tbodies.forEach(function(tbody) {
|
||
var row = tbody.querySelector('tr.table-row');
|
||
if (row) {
|
||
itemsToSort.push({ element: tbody, row: row });
|
||
}
|
||
});
|
||
} else if (allTbodies.length > 1) {
|
||
// Multiple tbodies - likely browser moved nested ones to be siblings
|
||
// Skip the first one (main/empty) and use the rest
|
||
for (var k = 1; k < allTbodies.length; k++) {
|
||
var tbody = allTbodies[k];
|
||
if (tbody.style.display !== 'none') {
|
||
var row = tbody.querySelector('tr.table-row');
|
||
if (row) {
|
||
itemsToSort.push({ element: tbody, row: row });
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Simple structure - rows directly in the first tbody
|
||
var directRows = Array.from(mainTbody.querySelectorAll('tr.table-row')).filter(function(row) {
|
||
return row.style.display !== 'none';
|
||
});
|
||
|
||
directRows.forEach(function(row) {
|
||
itemsToSort.push({ element: row, row: row });
|
||
});
|
||
}
|
||
|
||
if (itemsToSort.length === 0) {
|
||
console.warn('No items to sort in table:', tableId, 'isNestedTbody:', isNestedTbody, 'mainTbody children:', mainTbody.children.length, 'directChildTbodies:', directChildTbodies.length);
|
||
return;
|
||
}
|
||
|
||
// Convert column name to data attribute format (hyphens)
|
||
var dataAttr = 'data-' + column.replace(/_/g, '-');
|
||
|
||
itemsToSort.sort(function(a, b) {
|
||
var aVal = a.row.getAttribute(dataAttr) || '';
|
||
var bVal = b.row.getAttribute(dataAttr) || '';
|
||
|
||
// Column helpers
|
||
var isDateColumn = column === 'date-time' || column === 'start-time' || column === 'end-time';
|
||
// Treat these columns as numeric even if stored as strings/data-attributes
|
||
var isNumericColumn = (
|
||
column === 'traffic' ||
|
||
column === 'duration' ||
|
||
column === 'bytes' ||
|
||
column === 'size'
|
||
);
|
||
|
||
if (isDateColumn) {
|
||
// Handle date values (format: YYYY-MM-DD HH:mm:ss)
|
||
// Parse dates explicitly with multiple fallback methods
|
||
var parseDate = function(dateStr) {
|
||
if (!dateStr || dateStr === '') {
|
||
return null;
|
||
}
|
||
|
||
// Method 1: Convert YYYY-MM-DD HH:mm:ss to YYYY-MM-DDTHH:mm:ss for ISO parsing
|
||
var isoStr = dateStr.replace(' ', 'T');
|
||
var date = new Date(isoStr);
|
||
if (!isNaN(date.getTime())) {
|
||
return date.getTime();
|
||
}
|
||
|
||
// Method 2: Try parsing as-is
|
||
date = new Date(dateStr);
|
||
if (!isNaN(date.getTime())) {
|
||
return date.getTime();
|
||
}
|
||
|
||
// Method 3: Manual parsing (YYYY-MM-DD HH:mm:ss)
|
||
var parts = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})$/);
|
||
if (parts && parts.length === 7) {
|
||
// parts[0] is full match, parts[1-6] are the groups
|
||
date = new Date(
|
||
parseInt(parts[1], 10), // year
|
||
parseInt(parts[2], 10) - 1, // month (0-indexed)
|
||
parseInt(parts[3], 10), // day
|
||
parseInt(parts[4], 10), // hour
|
||
parseInt(parts[5], 10), // minute
|
||
parseInt(parts[6], 10) // second
|
||
);
|
||
if (!isNaN(date.getTime())) {
|
||
return date.getTime();
|
||
}
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
var aTime = parseDate(aVal);
|
||
var bTime = parseDate(bVal);
|
||
|
||
// Handle empty dates - put them at the end
|
||
if (aTime === null && bTime === null) {
|
||
return 0; // Both empty, equal
|
||
}
|
||
if (aTime === null) {
|
||
return 1; // a is empty, put it last
|
||
}
|
||
if (bTime === null) {
|
||
return -1; // b is empty, put it last
|
||
}
|
||
|
||
// Both have valid dates
|
||
if (component.sortDirection === 'asc') {
|
||
return aTime - bTime;
|
||
} else {
|
||
return bTime - aTime;
|
||
}
|
||
}
|
||
|
||
// Handle numeric values (for traffic, duration, etc.)
|
||
if (isNumericColumn) {
|
||
var aNum = parseFloat(aVal);
|
||
var bNum = parseFloat(bVal);
|
||
|
||
// Normalise NaN and empty values to 0 so rows still participate in sort
|
||
if (isNaN(aNum)) aNum = 0;
|
||
if (isNaN(bNum)) bNum = 0;
|
||
|
||
if (component.sortDirection === 'asc') {
|
||
return aNum - bNum;
|
||
} else {
|
||
return bNum - aNum;
|
||
}
|
||
} else {
|
||
// Generic numeric handling for other cases where both values are valid numbers
|
||
var aNumGeneric = parseFloat(aVal);
|
||
var bNumGeneric = parseFloat(bVal);
|
||
if (!isNaN(aNumGeneric) && !isNaN(bNumGeneric) && aVal !== '' && bVal !== '') {
|
||
return component.sortDirection === 'asc' ? aNumGeneric - bNumGeneric : bNumGeneric - aNumGeneric;
|
||
}
|
||
}
|
||
|
||
// Handle string values
|
||
aVal = (aVal || '').toLowerCase();
|
||
bVal = (bVal || '').toLowerCase();
|
||
if (component.sortDirection === 'asc') {
|
||
return aVal.localeCompare(bVal);
|
||
} else {
|
||
return bVal.localeCompare(aVal);
|
||
}
|
||
});
|
||
|
||
// Reorder elements - appendChild will move existing elements
|
||
// which preserves Alpine.js state and event handlers
|
||
itemsToSort.forEach(function(item) {
|
||
// Determine the correct parent based on where the element currently is
|
||
var currentParent = item.element.parentElement;
|
||
if (currentParent === mainTbody) {
|
||
// Nested tbody is a child of mainTbody
|
||
mainTbody.appendChild(item.element);
|
||
} else if (currentParent === table) {
|
||
// Nested tbody is a sibling of mainTbody (browser moved it)
|
||
table.appendChild(item.element);
|
||
} else {
|
||
// Fallback - try mainTbody first
|
||
mainTbody.appendChild(item.element);
|
||
}
|
||
});
|
||
}
|
||
};
|
||
return component;
|
||
};
|
||
|
||
var maxMem = {{ $status->maxmem }};
|
||
|
||
var baseOptions = {
|
||
chart: {
|
||
type: 'area',
|
||
height: 300,
|
||
zoom: {
|
||
enabled: false
|
||
},
|
||
toolbar: {
|
||
show: false
|
||
},
|
||
background: 'transparent'
|
||
},
|
||
grid: {
|
||
show: true,
|
||
borderColor: 'hsl(var(--color-neutral))',
|
||
strokeDashArray: 3,
|
||
position: 'back',
|
||
xaxis: {
|
||
lines: {
|
||
show: false,
|
||
}
|
||
},
|
||
yaxis: {
|
||
lines: {
|
||
show: true,
|
||
}
|
||
},
|
||
row: {
|
||
colors: 'hsl(var(--color-neutral))',
|
||
opacity: 0
|
||
}
|
||
},
|
||
theme: {
|
||
mode: document.documentElement.classList.contains('dark') ? 'light' : 'dark',
|
||
},
|
||
xaxis: {
|
||
type: 'datetime',
|
||
labels: {
|
||
datetimeUTC: false,
|
||
style: {
|
||
colors: 'color-mix(in oklab, hsl(var(--color-base)) 60%, transparent)',
|
||
},
|
||
},
|
||
},
|
||
stroke: {
|
||
curve: 'smooth',
|
||
width: 2,
|
||
},
|
||
dataLabels: {
|
||
enabled: false
|
||
},
|
||
legend: {
|
||
show: false,
|
||
position: 'top',
|
||
horizontalAlign: 'right',
|
||
floating: true,
|
||
showForSingleSeries: true,
|
||
},
|
||
noData: {
|
||
text: 'Loading...'
|
||
}
|
||
}
|
||
|
||
var cpuOptions = Object.assign({}, baseOptions, {
|
||
series: [{
|
||
name: 'Processor Utilization',
|
||
data: [],
|
||
}],
|
||
yaxis: {
|
||
labels: {
|
||
formatter: function(val) {
|
||
return val.toFixed(1) + "%";
|
||
},
|
||
style: {
|
||
colors: ['color-mix(in oklab, hsl(var(--color-base)) 60%, transparent)'],
|
||
},
|
||
}
|
||
},
|
||
colors: ['#008ffb'],
|
||
});
|
||
|
||
var memoryOptions = Object.assign({}, baseOptions, {
|
||
// Single series for memory usage; capacity is reflected via the y-axis max
|
||
series: [{
|
||
name: 'Memory Utilization',
|
||
data: [],
|
||
}],
|
||
yaxis: {
|
||
labels: {
|
||
formatter: function(val) {
|
||
return formatBytes(val, 1);
|
||
},
|
||
style: {
|
||
colors: ['color-mix(in oklab, hsl(var(--color-base)) 60%, transparent)'],
|
||
},
|
||
},
|
||
// Only lock the max when we have a valid positive value;
|
||
// otherwise let ApexCharts auto-scale so the graph is never flat or broken.
|
||
max: (typeof maxMem === 'number' && maxMem > 0) ? maxMem : undefined,
|
||
min: 0,
|
||
},
|
||
colors: ['#00E396'],
|
||
});
|
||
|
||
var networkOptions = Object.assign({}, baseOptions, {
|
||
series: [{
|
||
name: 'Network In',
|
||
data: []
|
||
}, {
|
||
name: 'Network Out',
|
||
data: []
|
||
}],
|
||
yaxis: {
|
||
labels: {
|
||
formatter: function(val) {
|
||
return formatBytes(val);
|
||
},
|
||
style: {
|
||
colors: ['color-mix(in oklab, hsl(var(--color-base)) 60%, transparent)'],
|
||
},
|
||
}
|
||
},
|
||
colors: ['#a37443', '#dcce58'],
|
||
});
|
||
|
||
var diskOptions = Object.assign({}, baseOptions, {
|
||
series: [{
|
||
name: 'Disk Read',
|
||
data: []
|
||
}, {
|
||
name: 'Disk Write',
|
||
data: []
|
||
}],
|
||
yaxis: {
|
||
labels: {
|
||
formatter: function(val) {
|
||
return formatBytes(val);
|
||
},
|
||
style: {
|
||
colors: ['color-mix(in oklab, hsl(var(--color-base)) 60%, transparent)'],
|
||
},
|
||
}
|
||
},
|
||
colors: ['#9256b5', '#60496f'],
|
||
});
|
||
|
||
var cpuChart;
|
||
var memoryChart;
|
||
var networkChart;
|
||
var diskChart;
|
||
|
||
// Track which charts are present on the page and whether they have received data.
|
||
var cpuChartExpected = false;
|
||
var memoryChartExpected = false;
|
||
var networkChartExpected = false;
|
||
var diskChartExpected = false;
|
||
|
||
var cpuChartHasData = false;
|
||
var memoryChartHasData = false;
|
||
var networkChartHasData = false;
|
||
var diskChartHasData = false;
|
||
|
||
var statsRetryAttempts = 0;
|
||
var maxStatsRetryAttempts = 5;
|
||
|
||
function dispatchStatsWithRetry() {
|
||
function requestStatsOnce() {
|
||
statsRetryAttempts++;
|
||
|
||
try {
|
||
console.log('Requesting Proxmox stats (attempt ' + statsRetryAttempts + ')');
|
||
Livewire.dispatch('proxmox:stats');
|
||
} catch (e) {
|
||
console.warn('Failed to dispatch Proxmox stats event:', e);
|
||
}
|
||
|
||
// After a shorter delay, check if expected charts have received any data.
|
||
// Reduced from 3000ms to 800ms for faster loading
|
||
setTimeout(function () {
|
||
var allExpectedChartsHaveData =
|
||
(!cpuChartExpected || cpuChartHasData) &&
|
||
(!memoryChartExpected || memoryChartHasData) &&
|
||
(!networkChartExpected || networkChartHasData) &&
|
||
(!diskChartExpected || diskChartHasData);
|
||
|
||
if (allExpectedChartsHaveData) {
|
||
console.log('All expected charts have received data – stopping stats retries.');
|
||
try {
|
||
window.dispatchEvent(new CustomEvent('proxmox-all-stats-loaded'));
|
||
} catch (e) {}
|
||
return;
|
||
}
|
||
|
||
if (statsRetryAttempts < maxStatsRetryAttempts) {
|
||
requestStatsOnce();
|
||
} else {
|
||
console.warn('Max stats retry attempts reached; some charts may not have data.');
|
||
}
|
||
}, 800);
|
||
}
|
||
|
||
// Start the retry loop immediately
|
||
statsRetryAttempts = 0;
|
||
requestStatsOnce();
|
||
}
|
||
|
||
function initializeCharts() {
|
||
// Destroy existing charts if they exist
|
||
if (cpuChart) {
|
||
try {
|
||
cpuChart.destroy();
|
||
} catch (e) {}
|
||
cpuChart = null;
|
||
}
|
||
if (memoryChart) {
|
||
try {
|
||
memoryChart.destroy();
|
||
} catch (e) {}
|
||
memoryChart = null;
|
||
}
|
||
if (networkChart) {
|
||
try {
|
||
networkChart.destroy();
|
||
} catch (e) {}
|
||
networkChart = null;
|
||
}
|
||
if (diskChart) {
|
||
try {
|
||
diskChart.destroy();
|
||
} catch (e) {}
|
||
diskChart = null;
|
||
}
|
||
|
||
requestAnimationFrame(function() {
|
||
if (typeof ApexCharts === 'undefined') {
|
||
console.warn('ApexCharts not loaded, retrying...');
|
||
setTimeout(initializeCharts, 200);
|
||
return;
|
||
}
|
||
|
||
var cpuElement = document.querySelector("#cpu-chart");
|
||
var memoryElement = document.querySelector("#memory-chart");
|
||
var networkElement = document.querySelector("#network-chart");
|
||
var diskElement = document.querySelector("#disk-chart");
|
||
|
||
cpuChartExpected = !!cpuElement;
|
||
memoryChartExpected = !!memoryElement;
|
||
networkChartExpected = !!networkElement;
|
||
diskChartExpected = !!diskElement;
|
||
|
||
// Reset data flags on re-initialization
|
||
cpuChartHasData = false;
|
||
memoryChartHasData = false;
|
||
networkChartHasData = false;
|
||
diskChartHasData = false;
|
||
|
||
if (cpuElement) {
|
||
cpuChart = new ApexCharts(cpuElement, cpuOptions);
|
||
cpuChart.render();
|
||
}
|
||
|
||
if (memoryElement) {
|
||
memoryChart = new ApexCharts(memoryElement, memoryOptions);
|
||
memoryChart.render();
|
||
}
|
||
|
||
if (networkElement) {
|
||
networkChart = new ApexCharts(networkElement, networkOptions);
|
||
networkChart.render();
|
||
}
|
||
|
||
if (diskElement) {
|
||
diskChart = new ApexCharts(diskElement, diskOptions);
|
||
diskChart.render();
|
||
}
|
||
|
||
// Request stats immediately - don't wait for charts to fully render
|
||
// This speeds up initial data loading
|
||
dispatchStatsWithRetry();
|
||
});
|
||
}
|
||
|
||
// Trigger initial stats request as soon as possible, before charts are ready
|
||
// This allows backend to start fetching data in parallel with chart initialization
|
||
if (typeof Livewire !== 'undefined') {
|
||
setTimeout(function() {
|
||
try {
|
||
Livewire.dispatch('proxmox:stats');
|
||
} catch (e) {
|
||
console.warn('Early stats dispatch failed:', e);
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
// Set up event listeners - these persist across Livewire navigation
|
||
function setupEventListeners() {
|
||
// Note: Livewire 3 automatically cleans up listeners on navigation
|
||
|
||
Livewire.on('proxmox:stats:{{ $server->id }}:cpu', function(eventData) {
|
||
if (!cpuChart) {
|
||
console.warn('CPU chart not initialized');
|
||
return;
|
||
}
|
||
try {
|
||
// Handle Livewire event data format - could be wrapped in array or direct
|
||
var data = eventData;
|
||
if (Array.isArray(eventData) && eventData.length > 0) {
|
||
// If it's an array, take first element (Livewire might wrap it)
|
||
data = eventData[0];
|
||
}
|
||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||
console.warn('No CPU data received', eventData);
|
||
return;
|
||
}
|
||
cpuChart.updateSeries([{
|
||
name: 'Processor Utilization',
|
||
data: data.map(function(item) {
|
||
return {
|
||
x: item.time * 1000,
|
||
y: item.cpu ? item.cpu * 100 : 0,
|
||
}
|
||
}),
|
||
}]);
|
||
|
||
// Mark that this chart has successfully received data
|
||
cpuChartHasData = true;
|
||
} catch (e) {
|
||
console.error('Error updating CPU chart:', e, eventData);
|
||
}
|
||
});
|
||
|
||
Livewire.on('proxmox:stats:{{ $server->id }}:memory', function(eventData) {
|
||
if (!memoryChart) {
|
||
console.warn('Memory chart not initialized');
|
||
return;
|
||
}
|
||
try {
|
||
var data = eventData;
|
||
if (Array.isArray(eventData) && eventData.length > 0) {
|
||
data = eventData[0];
|
||
}
|
||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||
console.warn('No Memory data received', eventData);
|
||
return;
|
||
}
|
||
memoryChart.updateSeries([{
|
||
name: 'Memory Utilization',
|
||
data: data.map(function(item) {
|
||
return {
|
||
x: item.time * 1000,
|
||
y: item.mem ? item.mem : 0,
|
||
}
|
||
}),
|
||
}]);
|
||
|
||
// Mark that this chart has successfully received data
|
||
memoryChartHasData = true;
|
||
} catch (e) {
|
||
console.error('Error updating Memory chart:', e, eventData);
|
||
}
|
||
});
|
||
|
||
Livewire.on('proxmox:stats:{{ $server->id }}:network', function(eventData) {
|
||
if (!networkChart) {
|
||
console.warn('Network chart not initialized');
|
||
return;
|
||
}
|
||
try {
|
||
var data = eventData;
|
||
if (Array.isArray(eventData) && eventData.length > 0) {
|
||
data = eventData[0];
|
||
}
|
||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||
console.warn('No Network data received', eventData);
|
||
return;
|
||
}
|
||
networkChart.updateSeries([{
|
||
name: 'Network In',
|
||
data: data.map(function(item) {
|
||
return {
|
||
x: item.time * 1000,
|
||
y: item.netin ? item.netin : 0,
|
||
}
|
||
}),
|
||
}, {
|
||
name: 'Network Out',
|
||
data: data.map(function(item) {
|
||
return {
|
||
x: item.time * 1000,
|
||
y: item.netout ? item.netout : 0,
|
||
}
|
||
})
|
||
}]);
|
||
|
||
// Mark that this chart has successfully received data
|
||
networkChartHasData = true;
|
||
} catch (e) {
|
||
console.error('Error updating Network chart:', e, eventData);
|
||
}
|
||
});
|
||
|
||
Livewire.on('proxmox:stats:{{ $server->id }}:disk', function(eventData) {
|
||
if (!diskChart) {
|
||
console.warn('Disk chart not initialized');
|
||
return;
|
||
}
|
||
try {
|
||
var data = eventData;
|
||
if (Array.isArray(eventData) && eventData.length > 0) {
|
||
data = eventData[0];
|
||
}
|
||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||
console.warn('No Disk data received', eventData);
|
||
return;
|
||
}
|
||
diskChart.updateSeries([{
|
||
name: 'Disk Read',
|
||
data: data.map(function(item) {
|
||
return {
|
||
x: item.time * 1000,
|
||
y: item.diskread ? item.diskread : 0,
|
||
}
|
||
}),
|
||
}, {
|
||
name: 'Disk Write',
|
||
data: data.map(function(item) {
|
||
return {
|
||
x: item.time * 1000,
|
||
y: item.diskwrite ? item.diskwrite : 0,
|
||
}
|
||
})
|
||
}]);
|
||
|
||
// Mark that this chart has successfully received data
|
||
diskChartHasData = true;
|
||
} catch (e) {
|
||
console.error('Error updating Disk chart:', e, eventData);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Set up event listeners
|
||
setupEventListeners();
|
||
|
||
// Initialize charts
|
||
initializeCharts();
|
||
|
||
// Load Network Analysis after charts are initialized (non-blocking)
|
||
function loadNetworkAnalysisData() {
|
||
// Start the timeout FIRST before loading, so it's guaranteed to start
|
||
console.log('Starting Network Analysis load process - timeout will begin');
|
||
startNetworkAnalysisTimeout();
|
||
|
||
setTimeout(function() {
|
||
try {
|
||
// Use a Livewire event instead of @this directly so that
|
||
// the initial load is reliable even on first-ever visits.
|
||
Livewire.dispatch('proxmox:network-analysis-load');
|
||
console.log('Network Analysis load event dispatched');
|
||
} catch (e) {
|
||
console.warn('Failed to dispatch Network Analysis load event:', e);
|
||
// Even if dispatch fails, timeout is already started
|
||
}
|
||
}, 500);
|
||
}
|
||
|
||
// Function to start the Network Analysis timeout if not already started
|
||
function startNetworkAnalysisTimeout() {
|
||
// Get server ID for unique session tracking
|
||
var serverId = {{ $server->id ?? 0 }};
|
||
var refreshKey = 'networkAnalysisRefreshed_' + serverId;
|
||
|
||
// Check if we've already refreshed once in this session for this server
|
||
var hasAlreadyRefreshed = sessionStorage.getItem(refreshKey);
|
||
if (hasAlreadyRefreshed === 'true') {
|
||
console.log('Network Analysis timeout skipped - page has already been refreshed once in this session for server ' + serverId);
|
||
return;
|
||
}
|
||
|
||
// Clear any existing timeout
|
||
if (window.networkAnalysisTimeoutId) {
|
||
clearTimeout(window.networkAnalysisTimeoutId);
|
||
}
|
||
|
||
console.log('Network Analysis timeout started - page will refresh in 30 seconds if data does not load (one-time refresh only)');
|
||
console.log('Timeout ID will be stored at: window.networkAnalysisTimeoutId');
|
||
|
||
// Simple timeout: refresh after 30 seconds unless we can confirm it loaded
|
||
window.networkAnalysisTimeoutId = setTimeout(function() {
|
||
console.warn('=== Network Analysis timeout callback FIRED (30 seconds elapsed) ===');
|
||
|
||
// Get server ID for unique session tracking
|
||
var serverId = {{ $server->id ?? 0 }};
|
||
var refreshKey = 'networkAnalysisRefreshed_' + serverId;
|
||
|
||
// Double-check we haven't already refreshed (in case of race conditions)
|
||
var hasAlreadyRefreshedCheck = sessionStorage.getItem(refreshKey);
|
||
if (hasAlreadyRefreshedCheck === 'true') {
|
||
console.log('Network Analysis timeout - page was already refreshed for server ' + serverId + ', skipping');
|
||
return;
|
||
}
|
||
|
||
var networkAnalysisCard = document.getElementById('network-analysis-card');
|
||
if (!networkAnalysisCard) {
|
||
console.warn('Network Analysis card not found, refreshing page NOW (one-time) for server ' + serverId);
|
||
sessionStorage.setItem(refreshKey, 'true');
|
||
window.location.reload();
|
||
return;
|
||
}
|
||
|
||
// Check for loading spinner
|
||
var spinner = networkAnalysisCard.querySelector('.animate-spin');
|
||
var isLoadingVisible = spinner && spinner.offsetParent !== null;
|
||
|
||
// Check for "Fetching data" text
|
||
var loadingTextElements = Array.from(networkAnalysisCard.querySelectorAll('p'));
|
||
var hasLoadingText = loadingTextElements.some(function(p) {
|
||
return p && p.textContent && p.textContent.includes('Fetching data from Core Router');
|
||
});
|
||
|
||
console.log('Loading check:', { isLoadingVisible: isLoadingVisible, hasLoadingText: hasLoadingText });
|
||
|
||
// If loading indicator is visible, definitely refresh (but only once)
|
||
if (isLoadingVisible || hasLoadingText) {
|
||
console.warn('Network Analysis timeout - loading indicator still visible, refreshing page NOW (one-time refresh) for server ' + serverId);
|
||
sessionStorage.setItem(refreshKey, 'true');
|
||
window.location.reload();
|
||
return;
|
||
}
|
||
|
||
// Check if there's definitive content that shows it loaded
|
||
var hasTable = networkAnalysisCard.querySelector('table');
|
||
var hasError = networkAnalysisCard.querySelector('[class*="border-warning"]');
|
||
var hasEmptyState = networkAnalysisCard.querySelector('.text-center.py-6');
|
||
var hasTabs = networkAnalysisCard.querySelector('[x-data*="tab"]');
|
||
|
||
console.log('Content check:', { hasTable: !!hasTable, hasError: !!hasError, hasEmptyState: !!hasEmptyState, hasTabs: !!hasTabs });
|
||
|
||
// Only skip refresh if we have clear evidence it loaded (tabs + content)
|
||
if ((hasTabs && (hasTable || hasError || hasEmptyState)) || hasError) {
|
||
console.log('Network Analysis loaded successfully (found tabs and content), NOT refreshing');
|
||
cancelNetworkAnalysisTimeout();
|
||
// Clear the refresh flag since we loaded successfully
|
||
sessionStorage.removeItem(refreshKey);
|
||
return;
|
||
}
|
||
|
||
// Default to refreshing if we can't definitively prove it loaded (but only once)
|
||
console.warn('Network Analysis timeout - cannot confirm data loaded, refreshing page NOW (one-time refresh) for server ' + serverId);
|
||
sessionStorage.setItem(refreshKey, 'true');
|
||
window.location.reload();
|
||
}, 30000); // 30 seconds
|
||
|
||
console.log('Timeout set with ID:', window.networkAnalysisTimeoutId);
|
||
}
|
||
|
||
// Function to cancel the Network Analysis timeout
|
||
function cancelNetworkAnalysisTimeout() {
|
||
if (window.networkAnalysisTimeoutId) {
|
||
clearTimeout(window.networkAnalysisTimeoutId);
|
||
window.networkAnalysisTimeoutId = null;
|
||
console.log('Network Analysis timeout cancelled - data has loaded');
|
||
}
|
||
// Clear the refresh flag since data loaded successfully (server-specific)
|
||
var serverId = {{ $server->id ?? 0 }};
|
||
var refreshKey = 'networkAnalysisRefreshed_' + serverId;
|
||
sessionStorage.removeItem(refreshKey);
|
||
}
|
||
|
||
// Check if Livewire is already initialized, otherwise wait for it
|
||
if (typeof Livewire !== 'undefined' && Livewire.initialized) {
|
||
loadNetworkAnalysisData();
|
||
} else {
|
||
document.addEventListener('livewire:initialized', loadNetworkAnalysisData, { once: true });
|
||
}
|
||
|
||
// Monitor for Network Analysis completion and cancel timeout when loaded
|
||
function checkNetworkAnalysisLoaded() {
|
||
var networkAnalysisCard = document.getElementById('network-analysis-card');
|
||
if (!networkAnalysisCard) {
|
||
return false;
|
||
}
|
||
|
||
// Check if loading spinner is gone
|
||
var spinner = networkAnalysisCard.querySelector('.animate-spin');
|
||
var hasSpinner = spinner && spinner.offsetParent !== null;
|
||
|
||
// Check if content is present (tables, error messages, or empty state messages)
|
||
var hasContent = networkAnalysisCard.querySelector('[x-data*="tableFilterSort"]') ||
|
||
networkAnalysisCard.querySelector('.text-center') ||
|
||
networkAnalysisCard.querySelector('[class*="border-warning"]');
|
||
|
||
// If spinner is gone and content exists, it's loaded
|
||
return !hasSpinner && hasContent;
|
||
}
|
||
|
||
// Listen for Livewire updates to clear timeout if Network Analysis loads successfully
|
||
document.addEventListener('livewire:update', function() {
|
||
if (window.networkAnalysisTimeoutId && checkNetworkAnalysisLoaded()) {
|
||
cancelNetworkAnalysisTimeout();
|
||
}
|
||
});
|
||
|
||
// Also check periodically to catch cases where Livewire update event doesn't fire
|
||
setInterval(function() {
|
||
if (window.networkAnalysisTimeoutId && checkNetworkAnalysisLoaded()) {
|
||
cancelNetworkAnalysisTimeout();
|
||
}
|
||
}, 2000); // Check every 2 seconds
|
||
|
||
// Clear the refresh flag on successful page load (if Network Analysis loaded immediately)
|
||
// This allows the timeout to work again in future sessions (server-specific)
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
setTimeout(function() {
|
||
if (checkNetworkAnalysisLoaded()) {
|
||
var serverId = {{ $server->id ?? 0 }};
|
||
var refreshKey = 'networkAnalysisRefreshed_' + serverId;
|
||
sessionStorage.removeItem(refreshKey);
|
||
console.log('Network Analysis loaded successfully on page load, refresh flag cleared for server ' + serverId);
|
||
}
|
||
}, 2000);
|
||
|
||
// Handle cross-reference from firewall logs
|
||
// If URL has network_search parameter and #network-analysis-card hash, scroll to it
|
||
var urlParams = new URLSearchParams(window.location.search);
|
||
var networkSearch = urlParams.get('network_search');
|
||
if (networkSearch && window.location.hash === '#network-analysis-card') {
|
||
// Wait a bit for page to fully render, then scroll to Network Analysis
|
||
setTimeout(function() {
|
||
var networkCard = document.getElementById('network-analysis-card');
|
||
if (networkCard) {
|
||
networkCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
|
||
// Flash highlight the card briefly to draw attention
|
||
networkCard.style.transition = 'box-shadow 0.3s ease';
|
||
networkCard.style.boxShadow = '0 0 0 3px rgba(var(--color-primary), 0.5)';
|
||
setTimeout(function() {
|
||
networkCard.style.boxShadow = '';
|
||
}, 2000);
|
||
}
|
||
}, 500);
|
||
}
|
||
});
|
||
|
||
// Listen for 502 retry events from Core Router API
|
||
// Livewire dispatches events as custom browser events with the event name
|
||
document.addEventListener('network-analysis-502-retry', function(event) {
|
||
var retryCount = (event.detail && event.detail.retryCount) ? event.detail.retryCount : 1;
|
||
var delay = Math.min(2000 * retryCount, 5000); // Exponential backoff, max 5 seconds
|
||
|
||
console.log('Core Router API 502 error detected, retrying in ' + delay + 'ms (Attempt ' + retryCount + ')');
|
||
|
||
setTimeout(function() {
|
||
try {
|
||
// Dispatch the same event used for initial load so that
|
||
// retries are decoupled from any specific @this instance.
|
||
Livewire.dispatch('proxmox:network-analysis-load');
|
||
console.log('Network Analysis retry event dispatched after 502 (delay ' + delay + 'ms)');
|
||
} catch (e) {
|
||
console.error('Failed to retry Network Analysis after 502 error:', e);
|
||
}
|
||
}, delay);
|
||
});
|
||
|
||
// Reinitialize on Livewire navigation
|
||
document.addEventListener('livewire:navigated', function() {
|
||
setupEventListeners();
|
||
initializeCharts();
|
||
});
|
||
|
||
setInterval(function() {
|
||
Livewire.dispatch('proxmox:stats');
|
||
}, 120000);
|
||
|
||
window.downloadCSV = window.downloadCSV || function(csvContent, filename) {
|
||
var blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||
var link = document.createElement('a');
|
||
var url = URL.createObjectURL(blob);
|
||
link.setAttribute('href', url);
|
||
link.setAttribute('download', filename);
|
||
link.style.visibility = 'hidden';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
};
|
||
|
||
window.exportCpuChartToCSV = function() {
|
||
if (!cpuChart) {
|
||
alert('Chart not initialized');
|
||
return;
|
||
}
|
||
try {
|
||
var series = cpuChart.w.config.series;
|
||
if (!series || !series[0] || !series[0].data || series[0].data.length === 0) {
|
||
alert('No data available to export');
|
||
return;
|
||
}
|
||
var csv = 'Timestamp,Processor Utilization (%)\n';
|
||
var dataPoints = series[0].data;
|
||
dataPoints.forEach(function(point) {
|
||
var timestamp = new Date(point.x).toISOString();
|
||
var value = point.y !== undefined ? point.y.toFixed(2) : '0';
|
||
csv += timestamp + ',' + value + '\n';
|
||
});
|
||
window.downloadCSV(csv, 'cpu-usage-' + new Date().toISOString().split('T')[0] + '.csv');
|
||
} catch (error) {
|
||
console.error('Export error:', error);
|
||
alert('Error exporting data: ' + error.message);
|
||
}
|
||
};
|
||
|
||
window.exportMemoryChartToCSV = function() {
|
||
if (!memoryChart) {
|
||
alert('Chart not initialized');
|
||
return;
|
||
}
|
||
try {
|
||
var series = memoryChart.w.config.series;
|
||
if (!series || !series[0] || !series[0].data || series[0].data.length === 0) {
|
||
alert('No data available to export');
|
||
return;
|
||
}
|
||
var csv = 'Timestamp,Memory Utilization (bytes),Memory Max (bytes)\n';
|
||
var usageData = series[0].data;
|
||
var maxData = series[1] ? series[1].data : [];
|
||
var maxLength = Math.max(usageData.length, maxData.length);
|
||
for (var i = 0; i < maxLength; i++) {
|
||
var timestamp = usageData[i] ? new Date(usageData[i].x).toISOString() :
|
||
(maxData[i] ? new Date(maxData[i].x).toISOString() : '');
|
||
var memUsage = usageData[i] && usageData[i].y !== undefined ? usageData[i].y : '0';
|
||
var memMax = maxData[i] && maxData[i].y !== undefined ? maxData[i].y : '0';
|
||
csv += timestamp + ',' + memUsage + ',' + memMax + '\n';
|
||
}
|
||
window.downloadCSV(csv, 'memory-usage-' + new Date().toISOString().split('T')[0] + '.csv');
|
||
} catch (error) {
|
||
console.error('Export error:', error);
|
||
alert('Error exporting data: ' + error.message);
|
||
}
|
||
};
|
||
|
||
window.exportNetworkChartToCSV = function() {
|
||
if (!networkChart) {
|
||
alert('Chart not initialized');
|
||
return;
|
||
}
|
||
try {
|
||
var series = networkChart.w.config.series;
|
||
if (!series || !series[0] || !series[0].data || series[0].data.length === 0) {
|
||
alert('No data available to export');
|
||
return;
|
||
}
|
||
var csv = 'Timestamp,Network In (bytes),Network Out (bytes)\n';
|
||
var inData = series[0].data;
|
||
var outData = series[1] ? series[1].data : [];
|
||
var maxLength = Math.max(inData.length, outData.length);
|
||
for (var i = 0; i < maxLength; i++) {
|
||
var timestamp = inData[i] ? new Date(inData[i].x).toISOString() :
|
||
(outData[i] ? new Date(outData[i].x).toISOString() : '');
|
||
var netIn = inData[i] && inData[i].y !== undefined ? inData[i].y : '0';
|
||
var netOut = outData[i] && outData[i].y !== undefined ? outData[i].y : '0';
|
||
csv += timestamp + ',' + netIn + ',' + netOut + '\n';
|
||
}
|
||
window.downloadCSV(csv, 'network-usage-' + new Date().toISOString().split('T')[0] + '.csv');
|
||
} catch (error) {
|
||
console.error('Export error:', error);
|
||
alert('Error exporting data: ' + error.message);
|
||
}
|
||
};
|
||
|
||
window.exportDiskChartToCSV = function() {
|
||
if (!diskChart) {
|
||
alert('Chart not initialized');
|
||
return;
|
||
}
|
||
try {
|
||
var series = diskChart.w.config.series;
|
||
if (!series || !series[0] || !series[0].data || series[0].data.length === 0) {
|
||
alert('No data available to export');
|
||
return;
|
||
}
|
||
var csv = 'Timestamp,Disk Read (bytes),Disk Write (bytes)\n';
|
||
var readData = series[0].data;
|
||
var writeData = series[1] ? series[1].data : [];
|
||
var maxLength = Math.max(readData.length, writeData.length);
|
||
for (var i = 0; i < maxLength; i++) {
|
||
var timestamp = readData[i] ? new Date(readData[i].x).toISOString() :
|
||
(writeData[i] ? new Date(writeData[i].x).toISOString() : '');
|
||
var diskRead = readData[i] && readData[i].y !== undefined ? readData[i].y : '0';
|
||
var diskWrite = writeData[i] && writeData[i].y !== undefined ? writeData[i].y : '0';
|
||
csv += timestamp + ',' + diskRead + ',' + diskWrite + '\n';
|
||
}
|
||
window.downloadCSV(csv, 'disk-usage-' + new Date().toISOString().split('T')[0] + '.csv');
|
||
} catch (error) {
|
||
console.error('Export error:', error);
|
||
alert('Error exporting data: ' + error.message);
|
||
}
|
||
};
|
||
|
||
function sanitizeCellText(value) {
|
||
return (value || '').toString().replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function csvEscape(value) {
|
||
var normalized = sanitizeCellText((value != null) ? value : '');
|
||
if (/[",\n]/.test(normalized)) {
|
||
return '"' + normalized.replace(/"/g, '""') + '"';
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function dispatchNoDataEvent(eventName, message) {
|
||
window.dispatchEvent(new CustomEvent(eventName, { detail: { message: message } }));
|
||
}
|
||
|
||
function getStatisticsExportData() {
|
||
return Array.isArray(window.statisticsHistoryExportData)
|
||
? window.statisticsHistoryExportData
|
||
: [];
|
||
}
|
||
|
||
function getTaskExportData() {
|
||
return Array.isArray(window.taskHistoryExportData)
|
||
? window.taskHistoryExportData
|
||
: [];
|
||
}
|
||
|
||
window.exportStatisticsHistoryToCSV = function() {
|
||
var data = getStatisticsExportData();
|
||
if (!data.length) {
|
||
dispatchNoDataEvent('statistics-export:no-data', 'No statistics history available to export.');
|
||
return;
|
||
}
|
||
var csv = 'Date & Time,Status,CPU,Memory,Disk,Network In,Network Out,Disk I/O,Uptime\n';
|
||
data.forEach(function(row) {
|
||
csv += [
|
||
csvEscape(row.date_time),
|
||
csvEscape(row.status),
|
||
csvEscape(row.cpu),
|
||
csvEscape(row.memory),
|
||
csvEscape(row.disk),
|
||
csvEscape(row.network_in),
|
||
csvEscape(row.network_out),
|
||
csvEscape(row.disk_io),
|
||
csvEscape(row.uptime),
|
||
].join(',') + '\n';
|
||
});
|
||
window.downloadCSV(csv, 'statistics-history-' + new Date().toISOString().split('T')[0] + '.csv');
|
||
};
|
||
|
||
window.exportTaskHistoryToCSV = function() {
|
||
var data = getTaskExportData();
|
||
if (!data.length) {
|
||
dispatchNoDataEvent('task-export:no-data', 'No task history available to export.');
|
||
return;
|
||
}
|
||
var csv = 'Type,Status,Start Time,End Time,Duration\n';
|
||
data.forEach(function(row) {
|
||
csv += [
|
||
csvEscape(row.type),
|
||
csvEscape(row.status),
|
||
csvEscape(row.start_time),
|
||
csvEscape(row.end_time),
|
||
csvEscape(row.duration),
|
||
].join(',') + '\n';
|
||
});
|
||
window.downloadCSV(csv, 'task-history-' + new Date().toISOString().split('T')[0] + '.csv');
|
||
};
|
||
|
||
function getLifecycleEventsExportData() {
|
||
return Array.isArray(window.lifecycleEventsExportData)
|
||
? window.lifecycleEventsExportData
|
||
: [];
|
||
}
|
||
|
||
window.exportLifecycleEventsToCSV = function() {
|
||
var data = getLifecycleEventsExportData();
|
||
if (!data.length) {
|
||
dispatchNoDataEvent('lifecycle-export:no-data', 'No lifecycle events available to export.');
|
||
return;
|
||
}
|
||
var csv = 'Type,Status,Start Time,End Time,Duration\n';
|
||
data.forEach(function(row) {
|
||
csv += [
|
||
csvEscape(row.type),
|
||
csvEscape(row.status),
|
||
csvEscape(row.start_time),
|
||
csvEscape(row.end_time),
|
||
csvEscape(row.duration),
|
||
].join(',') + '\n';
|
||
});
|
||
window.downloadCSV(csv, 'lifecycle-events-' + new Date().toISOString().split('T')[0] + '.csv');
|
||
};
|
||
|
||
function getHistoricalComparisonExportData() {
|
||
var template = document.getElementById('historical-comparison-export');
|
||
if (!template) {
|
||
console.warn('Historical comparison export template not found');
|
||
return [];
|
||
}
|
||
|
||
if (!template.textContent || !template.textContent.trim()) {
|
||
console.warn('Historical comparison export template is empty');
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
var data = JSON.parse(template.textContent.trim());
|
||
if (!Array.isArray(data)) {
|
||
console.warn('Historical comparison export data is not an array:', data);
|
||
return [];
|
||
}
|
||
return data;
|
||
} catch (e) {
|
||
console.error('Failed to parse historical comparison export data:', e, 'Content:', template.textContent);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
window.exportHistoricalComparisonToCSV = function() {
|
||
var data = getHistoricalComparisonExportData();
|
||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||
dispatchNoDataEvent('historical-comparison-export:no-data', 'No historical comparison data available to export. Please ensure comparison data is displayed before exporting.');
|
||
return;
|
||
}
|
||
var csv = 'Metric,Historical Value,Current Value,Difference,Percent Change\n';
|
||
data.forEach(function(row) {
|
||
if (row && row.metric) {
|
||
csv += [
|
||
csvEscape(row.metric),
|
||
csvEscape(row.historical_value || ''),
|
||
csvEscape(row.current_value || ''),
|
||
csvEscape(row.difference || ''),
|
||
csvEscape(row.percent_change || ''),
|
||
].join(',') + '\n';
|
||
}
|
||
});
|
||
window.downloadCSV(csv, 'historical-comparison-' + new Date().toISOString().split('T')[0] + '.csv');
|
||
};
|
||
</script>
|
||
@endscript
|
||
|
||
<script>
|
||
window.statisticsHistoryExportData = @json($statisticsExportRows ?? []);
|
||
window.taskHistoryExportData = @json($taskHistoryExportRows ?? []);
|
||
window.lifecycleEventsExportData = @json($lifecycleEventsExportRows ?? []);
|
||
</script>
|
||
|
||
</x-proxmox::layout>
|