|
| 1 | +<script lang="ts" setup> |
| 2 | +import { ref, onMounted } from 'vue' |
| 3 | +import { useHead } from '@vueuse/head' |
| 4 | +
|
| 5 | +useHead({ |
| 6 | + title: 'Dashboard - Jobs History', |
| 7 | +}) |
| 8 | +
|
| 9 | +interface JobHistoryEntry { |
| 10 | + id: string |
| 11 | + name: string |
| 12 | + queue: string |
| 13 | + status: 'queued' | 'processing' | 'failed' | 'completed' |
| 14 | + attempts: number |
| 15 | + runtime?: number |
| 16 | + started_at?: string |
| 17 | + finished_at?: string |
| 18 | + error?: string |
| 19 | + payload: any |
| 20 | +} |
| 21 | +
|
| 22 | +const jobs = ref<JobHistoryEntry[]>([]) |
| 23 | +const isLoading = ref(true) |
| 24 | +const currentPage = ref(1) |
| 25 | +const perPage = ref(25) |
| 26 | +const totalJobs = ref(0) |
| 27 | +const selectedQueue = ref<string>('all') |
| 28 | +const selectedStatus = ref<string>('all') |
| 29 | +const searchQuery = ref('') |
| 30 | +
|
| 31 | +const queues = ['all', 'default', 'high', 'low'] |
| 32 | +const statuses = ['all', 'queued', 'processing', 'completed', 'failed'] |
| 33 | +
|
| 34 | +const jobStatusColors: Record<string, string> = { |
| 35 | + queued: 'text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/50 ring-blue-600/20', |
| 36 | + processing: 'text-yellow-700 dark:text-yellow-300 bg-yellow-50 dark:bg-yellow-900/50 ring-yellow-600/20', |
| 37 | + completed: 'text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-900/50 ring-green-600/20', |
| 38 | + failed: 'text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/50 ring-red-600/20', |
| 39 | +} |
| 40 | +
|
| 41 | +const getJobStatusColor = (status: string): string => { |
| 42 | + return jobStatusColors[status] || 'text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-900/50 ring-gray-600/20' |
| 43 | +} |
| 44 | +
|
| 45 | +// Mock data for demonstration |
| 46 | +onMounted(async () => { |
| 47 | + // Simulate API call |
| 48 | + await new Promise(resolve => setTimeout(resolve, 1000)) |
| 49 | +
|
| 50 | + type JobName = 'ProcessPayment' | 'SendWelcomeEmail' | 'GenerateReport' | 'SyncInventory' |
| 51 | + type QueueName = 'default' | 'high' | 'low' |
| 52 | + type StatusType = 'queued' | 'processing' | 'completed' | 'failed' |
| 53 | +
|
| 54 | + const jobNames: readonly JobName[] = ['ProcessPayment', 'SendWelcomeEmail', 'GenerateReport', 'SyncInventory'] |
| 55 | + const queueNames: readonly QueueName[] = ['default', 'high', 'low'] |
| 56 | + const statusTypes: readonly StatusType[] = ['queued', 'processing', 'completed', 'failed'] |
| 57 | +
|
| 58 | + const randomItem = <T>(arr: readonly T[]): T => { |
| 59 | + return arr[Math.floor(Math.random() * arr.length)]! |
| 60 | + } |
| 61 | +
|
| 62 | + jobs.value = Array.from({ length: 100 }, (_, i) => ({ |
| 63 | + id: `${i + 1}`, |
| 64 | + name: randomItem(jobNames), |
| 65 | + queue: randomItem(queueNames), |
| 66 | + status: randomItem(statusTypes), |
| 67 | + attempts: Math.floor(Math.random() * 3) + 1, |
| 68 | + runtime: Math.random() * 10, |
| 69 | + started_at: new Date(Date.now() - Math.random() * 86400000).toISOString(), |
| 70 | + finished_at: new Date(Date.now() - Math.random() * 3600000).toISOString(), |
| 71 | + payload: { data: 'sample' }, |
| 72 | + })) |
| 73 | +
|
| 74 | + totalJobs.value = jobs.value.length |
| 75 | + isLoading.value = false |
| 76 | +}) |
| 77 | +
|
| 78 | +const filteredJobs = computed(() => { |
| 79 | + return jobs.value.filter((job) => { |
| 80 | + if (selectedQueue.value !== 'all' && job.queue !== selectedQueue.value) return false |
| 81 | + if (selectedStatus.value !== 'all' && job.status !== selectedStatus.value) return false |
| 82 | + if (searchQuery.value && !job.name.toLowerCase().includes(searchQuery.value.toLowerCase())) return false |
| 83 | + return true |
| 84 | + }) |
| 85 | +}) |
| 86 | +
|
| 87 | +const paginatedJobs = computed(() => { |
| 88 | + const start = (currentPage.value - 1) * perPage.value |
| 89 | + const end = start + perPage.value |
| 90 | + return filteredJobs.value.slice(start, end) |
| 91 | +}) |
| 92 | +
|
| 93 | +const totalPages = computed(() => Math.ceil(filteredJobs.value.length / perPage.value)) |
| 94 | +
|
| 95 | +const handleRetry = async (jobId: string) => { |
| 96 | + // Implement retry logic |
| 97 | + console.log('Retrying job:', jobId) |
| 98 | +} |
| 99 | +</script> |
| 100 | + |
| 101 | +<template> |
| 102 | + <div class="min-h-screen py-4 dark:bg-blue-gray-800 lg:py-8"> |
| 103 | + <div class="px-4 sm:px-6 lg:px-8"> |
| 104 | + <div class="sm:flex sm:items-center"> |
| 105 | + <div class="sm:flex-auto"> |
| 106 | + <h1 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">Jobs History</h1> |
| 107 | + <p class="mt-2 text-sm text-gray-700 dark:text-gray-300"> |
| 108 | + A complete history of all jobs processed by the system |
| 109 | + </p> |
| 110 | + </div> |
| 111 | + </div> |
| 112 | + |
| 113 | + <!-- Filters --> |
| 114 | + <div class="mt-4 flex flex-col space-y-4 sm:flex-row sm:items-center sm:space-x-4 sm:space-y-0"> |
| 115 | + <div class="flex-1 min-w-0"> |
| 116 | + <label for="search" class="sr-only">Search jobs</label> |
| 117 | + <div class="relative rounded-md shadow-sm"> |
| 118 | + <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> |
| 119 | + <div class="i-heroicons-magnifying-glass h-5 w-5 text-gray-400" /> |
| 120 | + </div> |
| 121 | + <input |
| 122 | + v-model="searchQuery" |
| 123 | + type="search" |
| 124 | + name="search" |
| 125 | + id="search" |
| 126 | + class="block w-full rounded-md border-0 py-1.5 pl-10 text-gray-900 dark:text-gray-100 dark:bg-blue-gray-600 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" |
| 127 | + placeholder="Search jobs..." |
| 128 | + > |
| 129 | + </div> |
| 130 | + </div> |
| 131 | + |
| 132 | + <div class="sm:flex-shrink-0"> |
| 133 | + <select |
| 134 | + v-model="selectedQueue" |
| 135 | + class="h-9 w-full rounded-md border-0 py-1.5 pl-3 pr-8 text-gray-900 dark:text-gray-100 dark:bg-blue-gray-600 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6" |
| 136 | + > |
| 137 | + <option v-for="queue in queues" :key="queue" :value="queue"> |
| 138 | + {{ queue.charAt(0).toUpperCase() + queue.slice(1) }} Queue |
| 139 | + </option> |
| 140 | + </select> |
| 141 | + </div> |
| 142 | + |
| 143 | + <div class="sm:flex-shrink-0"> |
| 144 | + <select |
| 145 | + v-model="selectedStatus" |
| 146 | + class="h-9 w-full rounded-md border-0 py-1.5 pl-3 pr-8 text-gray-900 dark:text-gray-100 dark:bg-blue-gray-600 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6" |
| 147 | + > |
| 148 | + <option v-for="status in statuses" :key="status" :value="status"> |
| 149 | + {{ status.charAt(0).toUpperCase() + status.slice(1) }} |
| 150 | + </option> |
| 151 | + </select> |
| 152 | + </div> |
| 153 | + </div> |
| 154 | + |
| 155 | + <!-- Table --> |
| 156 | + <div class="mt-8 flow-root"> |
| 157 | + <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> |
| 158 | + <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> |
| 159 | + <div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 dark:ring-opacity-20 rounded-lg"> |
| 160 | + <table class="min-w-full divide-y divide-gray-300 dark:divide-gray-600"> |
| 161 | + <thead class="bg-gray-50 dark:bg-blue-gray-600"> |
| 162 | + <tr> |
| 163 | + <th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-6">Job</th> |
| 164 | + <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Queue</th> |
| 165 | + <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</th> |
| 166 | + <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Attempts</th> |
| 167 | + <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Runtime</th> |
| 168 | + <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Started</th> |
| 169 | + <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6"> |
| 170 | + <span class="sr-only">Actions</span> |
| 171 | + </th> |
| 172 | + </tr> |
| 173 | + </thead> |
| 174 | + <tbody class="divide-y divide-gray-200 dark:divide-gray-600 bg-white dark:bg-blue-gray-700"> |
| 175 | + <tr v-if="isLoading" class="animate-pulse"> |
| 176 | + <td colspan="7" class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400"> |
| 177 | + Loading jobs... |
| 178 | + </td> |
| 179 | + </tr> |
| 180 | + <tr v-else-if="paginatedJobs.length === 0" class="hover:bg-gray-50 dark:hover:bg-blue-gray-600/50"> |
| 181 | + <td colspan="7" class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400 text-center"> |
| 182 | + No jobs found matching your criteria |
| 183 | + </td> |
| 184 | + </tr> |
| 185 | + <tr v-for="job in paginatedJobs" :key="job.id" class="hover:bg-gray-50 dark:hover:bg-blue-gray-600/50"> |
| 186 | + <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm sm:pl-6"> |
| 187 | + <div class="font-medium text-gray-900 dark:text-gray-100">{{ job.name }}</div> |
| 188 | + <div class="text-gray-500 dark:text-gray-400 font-mono text-xs">{{ job.id }}</div> |
| 189 | + </td> |
| 190 | + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ job.queue }}</td> |
| 191 | + <td class="whitespace-nowrap px-3 py-4 text-sm"> |
| 192 | + <span class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset" |
| 193 | + :class="getJobStatusColor(job.status)"> |
| 194 | + {{ job.status }} |
| 195 | + </span> |
| 196 | + </td> |
| 197 | + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ job.attempts }}</td> |
| 198 | + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400 font-mono"> |
| 199 | + {{ job.runtime ? `${job.runtime.toFixed(1)}s` : '-' }} |
| 200 | + </td> |
| 201 | + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ job.started_at }}</td> |
| 202 | + <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"> |
| 203 | + <button |
| 204 | + v-if="job.status === 'failed'" |
| 205 | + type="button" |
| 206 | + class="text-blue-600 hover:text-blue-900 dark:hover:text-blue-400" |
| 207 | + @click="handleRetry(job.id)" |
| 208 | + > |
| 209 | + Retry |
| 210 | + </button> |
| 211 | + </td> |
| 212 | + </tr> |
| 213 | + </tbody> |
| 214 | + </table> |
| 215 | + </div> |
| 216 | + </div> |
| 217 | + </div> |
| 218 | + </div> |
| 219 | + |
| 220 | + <!-- Pagination --> |
| 221 | + <div class="mt-4 flex items-center justify-between"> |
| 222 | + <div class="flex items-center"> |
| 223 | + <p class="text-sm text-gray-700 dark:text-gray-300"> |
| 224 | + Showing |
| 225 | + <span class="font-medium">{{ (currentPage - 1) * perPage + 1 }}</span> |
| 226 | + to |
| 227 | + <span class="font-medium">{{ Math.min(currentPage * perPage, filteredJobs.length) }}</span> |
| 228 | + of |
| 229 | + <span class="font-medium">{{ filteredJobs.length }}</span> |
| 230 | + results |
| 231 | + </p> |
| 232 | + </div> |
| 233 | + <div class="flex items-center space-x-2"> |
| 234 | + <button |
| 235 | + type="button" |
| 236 | + class="relative inline-flex items-center rounded-md bg-white dark:bg-blue-gray-600 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-blue-gray-500 focus:z-10" |
| 237 | + :disabled="currentPage === 1" |
| 238 | + @click="currentPage--" |
| 239 | + > |
| 240 | + Previous |
| 241 | + </button> |
| 242 | + <button |
| 243 | + type="button" |
| 244 | + class="relative inline-flex items-center rounded-md bg-white dark:bg-blue-gray-600 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-blue-gray-500 focus:z-10" |
| 245 | + :disabled="currentPage === totalPages" |
| 246 | + @click="currentPage++" |
| 247 | + > |
| 248 | + Next |
| 249 | + </button> |
| 250 | + </div> |
| 251 | + </div> |
| 252 | + </div> |
| 253 | + </div> |
| 254 | +</template> |
0 commit comments