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:
2026-03-28 11:38:56 -04:00
parent ecb2c545c1
commit 9e899cb947
9 changed files with 5856 additions and 5776 deletions

View File

@@ -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>