Files
Virtnet.bond/extensions/Servers/Proxmox/views/partials/statistics/tasks-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

506 lines
36 KiB
PHP

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