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
506 lines
36 KiB
PHP
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>
|