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
428 lines
30 KiB
PHP
428 lines
30 KiB
PHP
<!-- 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>
|