feat(proxmox): rebuild statistics UI and client runtime
New Blade layout, copy, and visual system for the monitoring tab; RRD hosts use data-statistics-chart hooks; tables use pxStatTableKit and data-statistics-data-row; network and comparison panels use new selectors; CSV helpers renamed (pxExport*); modals and telemetry refreshed. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
<!-- Historical Data Comparison Section -->
|
||||
@php
|
||||
$historicalComparison = $this->historicalComparison;
|
||||
@endphp
|
||||
<div class="mt-10 ring-1 ring-neutral/40 rounded-2xl overflow-hidden bg-background-secondary/80 shadow-sm" data-statistics-panel="period-compare" id="px-stat-period-compare">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between p-4 border-b border-neutral bg-gradient-to-r from-primary/10 via-primary/5 to-transparent">
|
||||
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/15 shrink-0">
|
||||
<x-ri-bar-chart-2-line class="size-5 text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="text-base font-semibold">Historical Data Comparison</h3>
|
||||
<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.self="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<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']))
|
||||
<script type="application/json" data-statistics-json="period-compare-export" class="hidden">
|
||||
@json($this->historicalComparisonExportData ?? [])
|
||||
</script>
|
||||
@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 text-purple-500 dark:text-purple-400" />
|
||||
<span class="font-medium">Disk Read Rate</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||||
<span class="font-medium text-purple-500 dark:text-purple-400">{{ $this->formatBytes($diff['disk_read']['historical_rate']) }}/s</span>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||||
<span class="font-medium text-purple-500 dark:text-purple-400">{{ $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 text-purple-700 dark:text-purple-300" />
|
||||
<span class="font-medium">Disk Write Rate</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||||
<span class="font-medium text-purple-700 dark:text-purple-300">{{ $this->formatBytes($diff['disk_write']['historical_rate']) }}/s</span>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-sm whitespace-nowrap">
|
||||
<span class="font-medium text-purple-700 dark:text-purple-300">{{ $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>
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
<!-- Statistics History Table -->
|
||||
@php
|
||||
$statisticsExportRows = [];
|
||||
@endphp
|
||||
<div class="mt-10 ring-1 ring-neutral/40 rounded-2xl overflow-hidden bg-background-secondary/80 shadow-sm" data-statistics-panel="samples-archive" id="px-stat-samples-card">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between p-5 border-b border-neutral/50 bg-gradient-to-br from-indigo-500/12 via-transparent to-transparent">
|
||||
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center w-11 h-11 rounded-2xl bg-indigo-500/15 shrink-0 ring-1 ring-indigo-500/20">
|
||||
<x-ri-database-2-line class="size-5 text-indigo-500" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="text-base font-semibold tracking-tight">Archived guest samples</h3>
|
||||
<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.self="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-indigo-500/15">
|
||||
<x-ri-database-2-line class="size-5 text-indigo-500" />
|
||||
</div>
|
||||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||||
How archives work
|
||||
</h6>
|
||||
</div>
|
||||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||||
Each row is a frozen reading of CPU, RAM, disks, and network counters. Use it to audit drift, correlate incidents, or prove capacity before upgrades.
|
||||
</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">Rolling automation:</span> The platform captures hourly samples for every VPS and trims the backlog at 24 automatic rows.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Manual pins:</span> You may stash up to five on-demand snapshots beside the automated series and drop them individually.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Reading the grid:</span> CPU/RAM/Disk are percentages. Network columns show deltas versus the prior row; disk I/O pairs read/write volume; uptime counts guest runtime.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Removing rows:</span> The trash control on each line purges that sample; clearing everything opens the dedicated confirmation modal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap"
|
||||
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">
|
||||
Capture now
|
||||
</div>
|
||||
<div wire:loading wire:target="createLiveStatistic">
|
||||
Saving…
|
||||
</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="Remove every archived sample">
|
||||
<x-ri-delete-bin-line class="size-4" />
|
||||
<span wire:loading.remove wire:target="clearStatisticsHistory">Purge all</span>
|
||||
<span wire:loading wire:target="clearStatisticsHistory">Purging…</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
@click="pxExportSamplesArchiveCsv()"
|
||||
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="Download CSV">
|
||||
<x-ri-download-line class="size-4" />
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-data="pxStatTableKit('px-stat-samples')">
|
||||
<div class="p-4 border-b border-neutral/40 bg-background/40">
|
||||
<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="Filter by date, state, or metric…"
|
||||
class="w-full px-3 py-2 text-sm bg-background border border-neutral rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500/30">
|
||||
</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-lg hover:bg-background/80">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full" id="px-stat-samples">
|
||||
<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">
|
||||
Captured
|
||||
<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">
|
||||
Power
|
||||
<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">
|
||||
RAM
|
||||
<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">
|
||||
Net 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">
|
||||
Net 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">
|
||||
Block 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 }}"
|
||||
data-statistics-data-row
|
||||
class="hover:bg-background-secondary/40 transition-colors"
|
||||
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 samples yet</p>
|
||||
<p class="text-sm text-base/60">Hourly automation will populate this grid; you can also pin a manual capture.</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<template id="statistics-history-export" type="application/json">
|
||||
@json($statisticsExportRows)
|
||||
</template>
|
||||
<!-- Statistics History Pagination -->
|
||||
@if($this->statistics->total() > 0)
|
||||
<div class="p-4 border-t border-neutral flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
{{-- Records per page selector --}}
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm text-base/70">Show:</label>
|
||||
<select wire:model.live="statisticsPerPage" class="px-3 py-1.5 border border-neutral rounded-lg bg-background text-sm">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<span class="text-sm text-base/60">each page</span>
|
||||
</div>
|
||||
|
||||
{{-- Page info and navigation --}}
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-base/60">
|
||||
Showing {{ (($this->statistics->currentPage() - 1) * $this->statistics->perPage()) + 1 }} - {{ min($this->statistics->currentPage() * $this->statistics->perPage(), $this->statistics->total()) }} of {{ $this->statistics->total() }} entries
|
||||
</span>
|
||||
|
||||
@if($this->statistics->hasPages())
|
||||
<div class="flex items-center gap-1">
|
||||
{{-- First page --}}
|
||||
<button type="button"
|
||||
wire:click="gotoPage(1)"
|
||||
@if($this->statistics->onFirstPage()) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="First page">
|
||||
<x-ri-arrow-left-double-line class="size-4" />
|
||||
</button>
|
||||
|
||||
{{-- Previous page --}}
|
||||
<button type="button"
|
||||
wire:click="previousPage"
|
||||
@if($this->statistics->onFirstPage()) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Previous page">
|
||||
<x-ri-arrow-left-s-line class="size-4" />
|
||||
</button>
|
||||
|
||||
{{-- Page numbers --}}
|
||||
@php
|
||||
$statsTotalPages = $this->statistics->lastPage();
|
||||
$statsCurrentPage = $this->statistics->currentPage();
|
||||
$statsStartPage = max(1, $statsCurrentPage - 2);
|
||||
$statsEndPage = min($statsTotalPages, $statsCurrentPage + 2);
|
||||
@endphp
|
||||
|
||||
@if($statsStartPage > 1)
|
||||
<button type="button" wire:click="gotoPage(1)" class="px-3 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors text-sm">1</button>
|
||||
@if($statsStartPage > 2)
|
||||
<span class="px-2 text-base/60">...</span>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@for($i = $statsStartPage; $i <= $statsEndPage; $i++)
|
||||
<button type="button"
|
||||
wire:click="gotoPage({{ $i }})"
|
||||
class="px-3 py-1 border rounded text-sm transition-colors {{ $statsCurrentPage === $i ? 'bg-primary text-white border-primary' : 'border-neutral hover:bg-background-secondary' }}">
|
||||
{{ $i }}
|
||||
</button>
|
||||
@endfor
|
||||
|
||||
@if($statsEndPage < $statsTotalPages)
|
||||
@if($statsEndPage < $statsTotalPages - 1)
|
||||
<span class="px-2 text-base/60">...</span>
|
||||
@endif
|
||||
<button type="button" wire:click="gotoPage({{ $statsTotalPages }})" class="px-3 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors text-sm">{{ $statsTotalPages }}</button>
|
||||
@endif
|
||||
|
||||
{{-- Next page --}}
|
||||
<button type="button"
|
||||
wire:click="nextPage"
|
||||
@if($this->statistics->onLastPage()) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Next page">
|
||||
<x-ri-arrow-right-s-line class="size-4" />
|
||||
</button>
|
||||
|
||||
{{-- Last page --}}
|
||||
<button type="button"
|
||||
wire:click="gotoPage({{ $this->statistics->lastPage() }})"
|
||||
@if($this->statistics->onLastPage()) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Last page">
|
||||
<x-ri-arrow-right-double-line class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.statisticsHistoryExportData = @json($statisticsExportRows ?? []);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
<!-- Recent Lifecycle Events Section -->
|
||||
@php
|
||||
$lifecycleEventsExportRows = [];
|
||||
@endphp
|
||||
<div class="ring-1 ring-neutral/40 rounded-2xl overflow-hidden bg-background-secondary/80 shadow-sm" wire:poll.30s="refreshTaskData" data-statistics-panel="lifecycle-audit" id="px-stat-lifecycle-card">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between p-5 border-b border-neutral/50 bg-gradient-to-br from-rose-500/10 via-transparent to-transparent">
|
||||
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center w-11 h-11 rounded-2xl bg-rose-500/15 shrink-0 ring-1 ring-rose-500/20">
|
||||
<x-ri-shut-down-line class="size-5 text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="text-base font-semibold tracking-tight">Guest lifecycle timeline</h3>
|
||||
<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.self="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-rose-500/15">
|
||||
<x-ri-shut-down-line class="size-5 text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||||
Why this strip exists
|
||||
</h6>
|
||||
</div>
|
||||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||||
We distill noisy Proxmox task streams into a concise narrative: power transitions, hard resets, backups, and anything else that changed the guest’s run state.
|
||||
</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">Signals we highlight:</span> Boot, graceful shutdown, reboot, reset, and backup milestones—each tinted for quick scanning.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Expandable receipts:</span> Open a row to see the originating task name, UPID, status, and exact timestamps pulled live from the node.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Outcome chips:</span> Success, running, and failure states mirror the upstream job result so you can spot regressions fast.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Cadence:</span> Like the job console, this block refreshes every 30 seconds while the tab stays open.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">CSV:</span> Download the filtered grid for auditors or change-management tickets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap"
|
||||
x-data
|
||||
@lifecycle-export:no-data.window="$wire.showExportInfo($event.detail.message)">
|
||||
<button type="button"
|
||||
@click="pxExportLifecycleCsv()"
|
||||
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="Download CSV">
|
||||
<x-ri-download-line class="size-4" />
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-data="pxStatTableKit('px-stat-lifecycle')">
|
||||
<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>
|
||||
@forelse($this->paginatedLifecycleEvents 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
|
||||
data-statistics-data-row
|
||||
class="hover:bg-background-secondary/40 transition-colors"
|
||||
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>
|
||||
<!-- Lifecycle Events Pagination -->
|
||||
@php
|
||||
$lifecycleTotalCount = count($this->chartEvents);
|
||||
@endphp
|
||||
@if($lifecycleTotalCount > 0)
|
||||
<div class="p-4 border-t border-neutral flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
{{-- Records per page selector --}}
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm text-base/70">Show:</label>
|
||||
<select wire:model.live="lifecycleEventsPerPage" class="px-3 py-1.5 border border-neutral rounded-lg bg-background text-sm">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<span class="text-sm text-base/60">per page</span>
|
||||
</div>
|
||||
|
||||
{{-- Page info and navigation --}}
|
||||
@php
|
||||
$lifecycleTotalPages = $this->lifecycleEventsTotalPages;
|
||||
$lifecycleCurrentPage = $lifecycleEventsCurrentPage;
|
||||
@endphp
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-base/60">
|
||||
Showing {{ (($lifecycleCurrentPage - 1) * $lifecycleEventsPerPage) + 1 }} - {{ min($lifecycleCurrentPage * $lifecycleEventsPerPage, $lifecycleTotalCount) }} of {{ $lifecycleTotalCount }} events
|
||||
</span>
|
||||
|
||||
@if($lifecycleTotalPages > 1)
|
||||
<div class="flex items-center gap-1">
|
||||
{{-- First page --}}
|
||||
<button type="button"
|
||||
wire:click="goToLifecycleEventsPage(1)"
|
||||
@if($lifecycleCurrentPage === 1) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="First page">
|
||||
<x-ri-arrow-left-double-line class="size-4" />
|
||||
</button>
|
||||
|
||||
{{-- Previous page --}}
|
||||
<button type="button"
|
||||
wire:click="previousLifecycleEventsPage"
|
||||
@if($lifecycleCurrentPage === 1) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Previous page">
|
||||
<x-ri-arrow-left-s-line class="size-4" />
|
||||
</button>
|
||||
|
||||
{{-- Page numbers --}}
|
||||
@php
|
||||
$lifecycleStartPage = max(1, $lifecycleCurrentPage - 2);
|
||||
$lifecycleEndPage = min($lifecycleTotalPages, $lifecycleCurrentPage + 2);
|
||||
@endphp
|
||||
|
||||
@if($lifecycleStartPage > 1)
|
||||
<button type="button" wire:click="goToLifecycleEventsPage(1)" class="px-3 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors text-sm">1</button>
|
||||
@if($lifecycleStartPage > 2)
|
||||
<span class="px-2 text-base/60">...</span>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@for($i = $lifecycleStartPage; $i <= $lifecycleEndPage; $i++)
|
||||
<button type="button"
|
||||
wire:click="goToLifecycleEventsPage({{ $i }})"
|
||||
class="px-3 py-1 border rounded text-sm transition-colors {{ $lifecycleCurrentPage === $i ? 'bg-primary text-white border-primary' : 'border-neutral hover:bg-background-secondary' }}">
|
||||
{{ $i }}
|
||||
</button>
|
||||
@endfor
|
||||
|
||||
@if($lifecycleEndPage < $lifecycleTotalPages)
|
||||
@if($lifecycleEndPage < $lifecycleTotalPages - 1)
|
||||
<span class="px-2 text-base/60">...</span>
|
||||
@endif
|
||||
<button type="button" wire:click="goToLifecycleEventsPage({{ $lifecycleTotalPages }})" class="px-3 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors text-sm">{{ $lifecycleTotalPages }}</button>
|
||||
@endif
|
||||
|
||||
{{-- Next page --}}
|
||||
<button type="button"
|
||||
wire:click="nextLifecycleEventsPage"
|
||||
@if($lifecycleCurrentPage === $lifecycleTotalPages) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Next page">
|
||||
<x-ri-arrow-right-s-line class="size-4" />
|
||||
</button>
|
||||
|
||||
{{-- Last page --}}
|
||||
<button type="button"
|
||||
wire:click="goToLifecycleEventsPage({{ $lifecycleTotalPages }})"
|
||||
@if($lifecycleCurrentPage === $lifecycleTotalPages) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Last page">
|
||||
<x-ri-arrow-right-double-line class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.lifecycleEventsExportData = @json($lifecycleEventsExportRows ?? []);
|
||||
</script>
|
||||
@@ -0,0 +1,201 @@
|
||||
<!-- 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.self="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 ring-1 ring-neutral/40 rounded-2xl 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 tracking-tight">Drop this sample?</h3>
|
||||
<p class="text-sm text-base/70">Removal is permanent.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 p-4 bg-background rounded-xl ring-1 ring-neutral/30">
|
||||
<p class="text-sm text-base/70 mb-2">Snapshot on the chopping block:</p>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base/70">Captured:</span>
|
||||
<span class="font-semibold" x-text="statDate"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base/70">CPU load:</span>
|
||||
<span class="font-semibold"><span x-text="statCpu"></span>%</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base/70">RAM load:</span>
|
||||
<span class="font-semibold"><span x-text="statMemory"></span>%</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base/70">Disk load:</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">Guest state:</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">
|
||||
Keep
|
||||
</x-button.secondary>
|
||||
<x-button.danger @click="$wire.deleteStatistic(statId); open = false">
|
||||
<x-loading target="deleteStatistic" />
|
||||
<div wire:loading.remove wire:target="deleteStatistic">
|
||||
Remove
|
||||
</div>
|
||||
<div wire:loading wire:target="deleteStatistic">
|
||||
Removing…
|
||||
</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.self="$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 ring-1 ring-neutral/40 rounded-2xl 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 tracking-tight">Wipe the entire archive?</h3>
|
||||
<p class="text-sm text-base/70">Every stored sample for this VM disappears.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 p-4 bg-background rounded-xl ring-1 ring-neutral/30 text-sm text-base/80 space-y-2">
|
||||
<p>Scheduled captures and manual pins are removed together.</p>
|
||||
<p class="text-base/60">There is no recycle bin.</p>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<x-button.secondary wire:click="closeClearStatisticsModal">
|
||||
Abort
|
||||
</x-button.secondary>
|
||||
<x-button.danger wire:click="clearStatisticsHistory">
|
||||
<x-loading target="clearStatisticsHistory" />
|
||||
<div wire:loading.remove wire:target="clearStatisticsHistory">
|
||||
Purge everything
|
||||
</div>
|
||||
<div wire:loading wire:target="clearStatisticsHistory">
|
||||
Purging…
|
||||
</div>
|
||||
</x-button.danger>
|
||||
</div>
|
||||
</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.self="$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 ring-1 ring-neutral/40 rounded-2xl 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 tracking-tight">CSV needs data</h3>
|
||||
<p class="text-sm text-base/70">Populate the grid first, then retry the download.</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">
|
||||
Got it
|
||||
</x-button.secondary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,505 @@
|
||||
<!-- Task History Section -->
|
||||
@php
|
||||
$taskHistoryExportRows = [];
|
||||
@endphp
|
||||
<div class="ring-1 ring-neutral/40 rounded-2xl overflow-hidden min-h-0 flex flex-col bg-background-secondary/80 shadow-sm" wire:poll.30s="refreshTaskData" data-statistics-panel="job-log" id="px-stat-job-log-card">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between p-5 border-b border-neutral/50 bg-gradient-to-br from-cyan-500/12 via-transparent to-transparent">
|
||||
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center w-11 h-11 rounded-2xl bg-cyan-500/15 shrink-0 ring-1 ring-cyan-500/20">
|
||||
<x-ri-list-check class="size-5 text-cyan-600 dark:text-cyan-400" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="text-base font-semibold tracking-tight">Hypervisor jobs</h3>
|
||||
<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.self="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-cyan-500/15">
|
||||
<x-ri-list-check class="size-5 text-cyan-600 dark:text-cyan-400" />
|
||||
</div>
|
||||
<h6 class="font-sans text-lg font-semibold leading-tight">
|
||||
Job console primer
|
||||
</h6>
|
||||
</div>
|
||||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||||
Every Proxmox UPID-backed action surfaces here—boot cycles, storage jobs, network tweaks, backup runs, and anything else the API recorded for this guest.
|
||||
</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">Kinds of work:</span> Expect lifecycle verbs (start/stop/migrate), disk and template tasks, firewall edits, and backup or snapshot orchestration.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Deep dive:</span> Expand a row to lazy-load the raw log lines and structured fields straight from the node API.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">State chips:</span> Success, failure, and in-flight jobs include precise start/stop stamps plus rolling duration for anything still executing.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Freshness:</span> This column polls every 30 seconds while you stay on the tab so new UPIDs appear without a manual reload.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap"
|
||||
x-data
|
||||
@task-export:no-data.window="$wire.showExportInfo($event.detail.message)">
|
||||
<button type="button"
|
||||
@click="pxExportJobLogCsv()"
|
||||
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="Download CSV">
|
||||
<x-ri-download-line class="size-4" />
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div x-data="pxStatTableKit('px-stat-job-log')">
|
||||
<div class="p-4 border-b border-neutral/40 bg-background/40">
|
||||
<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="Filter jobs by keyword…"
|
||||
class="w-full px-3 py-2 text-sm bg-background border border-neutral rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500/30">
|
||||
</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="px-stat-job-log">
|
||||
<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>
|
||||
@forelse($this->paginatedTaskHistory 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 }}"
|
||||
class="divide-y divide-neutral/40"
|
||||
x-data="{
|
||||
expanded: {{ $isExpanded ? 'true' : 'false' }},
|
||||
taskDetails: null,
|
||||
loading: false,
|
||||
taskUpid: '{{ $taskUpid }}'
|
||||
}"
|
||||
x-init="
|
||||
// Function to restore task details
|
||||
let 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
|
||||
data-statistics-data-row
|
||||
class="hover:bg-background-secondary/40 transition-colors"
|
||||
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>
|
||||
<!-- Task History Pagination -->
|
||||
@if(count($this->taskHistory) > 0)
|
||||
<div class="p-4 border-t border-neutral flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
{{-- Records per page selector --}}
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm text-base/70">Show:</label>
|
||||
<select wire:model.live="taskHistoryPerPage" class="px-3 py-1.5 border border-neutral rounded-lg bg-background text-sm">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<span class="text-sm text-base/60">per page</span>
|
||||
</div>
|
||||
|
||||
{{-- Page info and navigation --}}
|
||||
@php
|
||||
$taskTotalPages = $this->taskHistoryTotalPages;
|
||||
$taskCurrentPage = $taskHistoryCurrentPage;
|
||||
$taskTotalCount = count($this->taskHistory);
|
||||
@endphp
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-base/60">
|
||||
Showing {{ (($taskCurrentPage - 1) * $taskHistoryPerPage) + 1 }} - {{ min($taskCurrentPage * $taskHistoryPerPage, $taskTotalCount) }} of {{ $taskTotalCount }} tasks
|
||||
</span>
|
||||
|
||||
@if($taskTotalPages > 1)
|
||||
<div class="flex items-center gap-1">
|
||||
{{-- First page --}}
|
||||
<button type="button"
|
||||
wire:click="goToTaskHistoryPage(1)"
|
||||
@if($taskCurrentPage === 1) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="First page">
|
||||
<x-ri-arrow-left-double-line class="size-4" />
|
||||
</button>
|
||||
|
||||
{{-- Previous page --}}
|
||||
<button type="button"
|
||||
wire:click="previousTaskHistoryPage"
|
||||
@if($taskCurrentPage === 1) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Previous page">
|
||||
<x-ri-arrow-left-s-line class="size-4" />
|
||||
</button>
|
||||
|
||||
{{-- Page numbers --}}
|
||||
@php
|
||||
$taskStartPage = max(1, $taskCurrentPage - 2);
|
||||
$taskEndPage = min($taskTotalPages, $taskCurrentPage + 2);
|
||||
@endphp
|
||||
|
||||
@if($taskStartPage > 1)
|
||||
<button type="button" wire:click="goToTaskHistoryPage(1)" class="px-3 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors text-sm">1</button>
|
||||
@if($taskStartPage > 2)
|
||||
<span class="px-2 text-base/60">...</span>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@for($i = $taskStartPage; $i <= $taskEndPage; $i++)
|
||||
<button type="button"
|
||||
wire:click="goToTaskHistoryPage({{ $i }})"
|
||||
class="px-3 py-1 border rounded text-sm transition-colors {{ $taskCurrentPage === $i ? 'bg-primary text-white border-primary' : 'border-neutral hover:bg-background-secondary' }}">
|
||||
{{ $i }}
|
||||
</button>
|
||||
@endfor
|
||||
|
||||
@if($taskEndPage < $taskTotalPages)
|
||||
@if($taskEndPage < $taskTotalPages - 1)
|
||||
<span class="px-2 text-base/60">...</span>
|
||||
@endif
|
||||
<button type="button" wire:click="goToTaskHistoryPage({{ $taskTotalPages }})" class="px-3 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors text-sm">{{ $taskTotalPages }}</button>
|
||||
@endif
|
||||
|
||||
{{-- Next page --}}
|
||||
<button type="button"
|
||||
wire:click="nextTaskHistoryPage"
|
||||
@if($taskCurrentPage === $taskTotalPages) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Next page">
|
||||
<x-ri-arrow-right-s-line class="size-4" />
|
||||
</button>
|
||||
|
||||
{{-- Last page --}}
|
||||
<button type="button"
|
||||
wire:click="goToTaskHistoryPage({{ $taskTotalPages }})"
|
||||
@if($taskCurrentPage === $taskTotalPages) disabled @endif
|
||||
class="px-2 py-1 border border-neutral rounded hover:bg-background-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Last page">
|
||||
<x-ri-arrow-right-double-line class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.taskHistoryExportData = @json($taskHistoryExportRows ?? []);
|
||||
</script>
|
||||
@@ -0,0 +1,308 @@
|
||||
<style>
|
||||
.px-stat-rrd-orbit {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 3px solid color-mix(in oklab, hsl(var(--color-primary)) 25%, transparent);
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: pxStatRrdSpin 0.85s linear infinite;
|
||||
}
|
||||
@keyframes pxStatRrdSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.px-stat-rrd-canvas-host { min-height: 288px; }
|
||||
</style>
|
||||
<div class="mt-10 rounded-2xl ring-1 ring-neutral/40 bg-background-secondary/80 p-6 shadow-sm relative overflow-hidden" data-statistics-panel="rrd-suite">
|
||||
<section
|
||||
wire:poll.120s="stats"
|
||||
x-data="{ chartsLoaded: false }"
|
||||
@proxmox-all-stats-loaded.window="chartsLoaded = true"
|
||||
class="relative"
|
||||
>
|
||||
<div
|
||||
x-show="!chartsLoaded"
|
||||
x-transition:leave="transition ease-in duration-500"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="px-stat-rrd-orbit" aria-hidden="true"></div>
|
||||
<span class="text-sm text-base/70">Synthesizing RRD frames…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between mb-8">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h3 class="text-lg font-semibold tracking-tight">Node RRD studio</h3>
|
||||
<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" aria-label="Open RRD help">
|
||||
<x-ri-question-line class="size-4" />
|
||||
</button>
|
||||
<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>
|
||||
<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.self="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 type="button" @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors" aria-label="Close help">
|
||||
<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/12">
|
||||
<x-ri-line-chart-line class="size-5 text-primary" />
|
||||
</div>
|
||||
<h6 class="font-sans text-lg font-semibold leading-tight">Reading the RRD studio</h6>
|
||||
</div>
|
||||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||||
Proxmox exposes canonical <code class="rounded bg-neutral/15 px-1 py-0.5 text-xs">rrddata</code> streams. Pick a horizon, choose average vs peak smoothing, and export any series as CSV for offline math.
|
||||
</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">CPU pane:</span> Shows guest CPU share as a percent of entitlement; CSV lists ISO timestamps with load.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Network pane:</span> Plots ingress, egress, and a dashed P95 envelope of combined throughput.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Memory pane:</span> Bytes in use; axis caps at provisioned RAM when the API reports it.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Disk pane:</span> Read/write throughput—pair with the job console when chasing spikes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-base/70">Four synchronized canvases fed straight from the node RRD cache.</p>
|
||||
</div>
|
||||
<p class="text-xs text-base/50">Auto-refresh every two minutes while this screen stays mounted.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 xl:grid-cols-2">
|
||||
{{-- CPU --}}
|
||||
<div class="rounded-2xl ring-1 ring-neutral/30 overflow-hidden bg-gradient-to-b from-sky-500/5 to-transparent">
|
||||
<div class="flex flex-col gap-3 border-b border-neutral/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-sky-500/15 ring-1 ring-sky-500/25">
|
||||
<x-ri-cpu-line class="size-5 text-sky-600 dark:text-sky-400" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 class="text-sm font-semibold truncate">Processor load</h4>
|
||||
<p class="text-xs text-base/50 truncate">Percent of allocated vCPU</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select wire:model.live="cpuTimeRange" aria-label="CPU horizon" class="bg-background border border-neutral rounded-lg px-2 py-1.5 text-sm">
|
||||
<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" aria-label="CPU blend" class="bg-background border border-neutral rounded-lg px-2 py-1.5 text-sm">
|
||||
<option value="average">Average</option>
|
||||
<option value="maximum">Maximum</option>
|
||||
</select>
|
||||
<button type="button" onclick="pxExportRrdCpuCsv()"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-neutral bg-background px-2.5 py-1.5 text-xs font-semibold hover:bg-background/80"
|
||||
title="Download CPU series">
|
||||
<x-ri-download-line class="size-3.5" />
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative p-4">
|
||||
<div class="relative px-stat-rrd-canvas-host" x-data="{ ready: false }" x-init="
|
||||
let n = 0;
|
||||
const t = setInterval(() => {
|
||||
n++;
|
||||
if (document.querySelector('[data-statistics-chart=\'cpu\'] .apexcharts-canvas')) { ready = true; clearInterval(t); }
|
||||
else if (n >= 150) clearInterval(t);
|
||||
}, 200);
|
||||
">
|
||||
<div class="absolute inset-0 z-[1] flex items-center justify-center rounded-xl border border-dashed border-neutral/25 bg-background/50"
|
||||
x-show="!ready" x-transition:leave="transition ease-in duration-300"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||
<span class="text-xs text-base/50">Mounting canvas…</span>
|
||||
</div>
|
||||
<div data-statistics-chart="cpu" wire:ignore class="min-h-[288px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Network --}}
|
||||
<div class="rounded-2xl ring-1 ring-neutral/30 overflow-hidden bg-gradient-to-b from-amber-500/5 to-transparent">
|
||||
<div class="flex flex-col gap-3 border-b border-neutral/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-amber-500/15 ring-1 ring-amber-500/25">
|
||||
<x-ri-arrow-left-right-line class="size-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 class="text-sm font-semibold truncate">NIC throughput</h4>
|
||||
<p class="text-xs text-base/50 truncate">In / out / P95</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select wire:model.live="networkTimeRange" aria-label="Network horizon" class="bg-background border border-neutral rounded-lg px-2 py-1.5 text-sm">
|
||||
<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" aria-label="Network blend" class="bg-background border border-neutral rounded-lg px-2 py-1.5 text-sm">
|
||||
<option value="average">Average</option>
|
||||
<option value="maximum">Maximum</option>
|
||||
</select>
|
||||
<button type="button" onclick="pxExportRrdNetworkCsv()"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-neutral bg-background px-2.5 py-1.5 text-xs font-semibold hover:bg-background/80"
|
||||
title="Download network series">
|
||||
<x-ri-download-line class="size-3.5" />
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative p-4">
|
||||
<div class="relative px-stat-rrd-canvas-host" x-data="{ ready: false }" x-init="
|
||||
let n = 0;
|
||||
const t = setInterval(() => {
|
||||
n++;
|
||||
if (document.querySelector('[data-statistics-chart=\'network\'] .apexcharts-canvas')) { ready = true; clearInterval(t); }
|
||||
else if (n >= 150) clearInterval(t);
|
||||
}, 200);
|
||||
">
|
||||
<div class="absolute inset-0 z-[1] flex items-center justify-center rounded-xl border border-dashed border-neutral/25 bg-background/50"
|
||||
x-show="!ready" x-transition:leave="transition ease-in duration-300"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||
<span class="text-xs text-base/50">Mounting canvas…</span>
|
||||
</div>
|
||||
<div data-statistics-chart="network" wire:ignore class="min-h-[288px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Memory --}}
|
||||
<div class="rounded-2xl ring-1 ring-neutral/30 overflow-hidden bg-gradient-to-b from-emerald-500/5 to-transparent">
|
||||
<div class="flex flex-col gap-3 border-b border-neutral/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-emerald-500/15 ring-1 ring-emerald-500/25">
|
||||
<x-ri-ram-line class="size-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 class="text-sm font-semibold truncate">RAM footprint</h4>
|
||||
<p class="text-xs text-base/50 truncate">Bytes from RRD</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select wire:model.live="memoryTimeRange" aria-label="Memory horizon" class="bg-background border border-neutral rounded-lg px-2 py-1.5 text-sm">
|
||||
<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" aria-label="Memory blend" class="bg-background border border-neutral rounded-lg px-2 py-1.5 text-sm">
|
||||
<option value="average">Average</option>
|
||||
<option value="maximum">Maximum</option>
|
||||
</select>
|
||||
<button type="button" onclick="pxExportRrdMemoryCsv()"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-neutral bg-background px-2.5 py-1.5 text-xs font-semibold hover:bg-background/80"
|
||||
title="Download memory series">
|
||||
<x-ri-download-line class="size-3.5" />
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative p-4">
|
||||
<div class="relative px-stat-rrd-canvas-host" x-data="{ ready: false }" x-init="
|
||||
let n = 0;
|
||||
const t = setInterval(() => {
|
||||
n++;
|
||||
if (document.querySelector('[data-statistics-chart=\'memory\'] .apexcharts-canvas')) { ready = true; clearInterval(t); }
|
||||
else if (n >= 150) clearInterval(t);
|
||||
}, 200);
|
||||
">
|
||||
<div class="absolute inset-0 z-[1] flex items-center justify-center rounded-xl border border-dashed border-neutral/25 bg-background/50"
|
||||
x-show="!ready" x-transition:leave="transition ease-in duration-300"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||
<span class="text-xs text-base/50">Mounting canvas…</span>
|
||||
</div>
|
||||
<div data-statistics-chart="memory" wire:ignore class="min-h-[288px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Disk --}}
|
||||
<div class="rounded-2xl ring-1 ring-neutral/30 overflow-hidden bg-gradient-to-b from-violet-500/5 to-transparent">
|
||||
<div class="flex flex-col gap-3 border-b border-neutral/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-violet-500/15 ring-1 ring-violet-500/25">
|
||||
<x-ri-hard-drive-line class="size-5 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 class="text-sm font-semibold truncate">Block throughput</h4>
|
||||
<p class="text-xs text-base/50 truncate">Read vs write</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select wire:model.live="diskTimeRange" aria-label="Disk horizon" class="bg-background border border-neutral rounded-lg px-2 py-1.5 text-sm">
|
||||
<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" aria-label="Disk blend" class="bg-background border border-neutral rounded-lg px-2 py-1.5 text-sm">
|
||||
<option value="average">Average</option>
|
||||
<option value="maximum">Maximum</option>
|
||||
</select>
|
||||
<button type="button" onclick="pxExportRrdDiskCsv()"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-neutral bg-background px-2.5 py-1.5 text-xs font-semibold hover:bg-background/80"
|
||||
title="Download disk series">
|
||||
<x-ri-download-line class="size-3.5" />
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative p-4">
|
||||
<div class="relative px-stat-rrd-canvas-host" x-data="{ ready: false }" x-init="
|
||||
let n = 0;
|
||||
const t = setInterval(() => {
|
||||
n++;
|
||||
if (document.querySelector('[data-statistics-chart=\'disk\'] .apexcharts-canvas')) { ready = true; clearInterval(t); }
|
||||
else if (n >= 150) clearInterval(t);
|
||||
}, 200);
|
||||
">
|
||||
<div class="absolute inset-0 z-[1] flex items-center justify-center rounded-xl border border-dashed border-neutral/25 bg-background/50"
|
||||
x-show="!ready" x-transition:leave="transition ease-in duration-300"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||
<span class="text-xs text-base/50">Mounting canvas…</span>
|
||||
</div>
|
||||
<div data-statistics-chart="disk" wire:ignore class="min-h-[288px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user