Files
Virtnet.bond/extensions/Servers/Proxmox/views/partials/statistics/history-inner.blade.php
Joseph.Rawlings 9e899cb947 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
2026-03-28 11:38:56 -04:00

434 lines
32 KiB
PHP

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