@gravito/zenith 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +88 -0
- package/BATCH_OPERATIONS_IMPLEMENTATION.md +159 -0
- package/DEMO.md +156 -0
- package/DEPLOYMENT.md +157 -0
- package/DOCS_INTERNAL.md +73 -0
- package/Dockerfile +46 -0
- package/Dockerfile.demo-worker +29 -0
- package/EVOLUTION_BLUEPRINT.md +112 -0
- package/JOBINSPECTOR_SCROLL_FIX.md +152 -0
- package/PULSE_IMPLEMENTATION_PLAN.md +111 -0
- package/QUICK_TEST_GUIDE.md +72 -0
- package/README.md +33 -0
- package/ROADMAP.md +85 -0
- package/TESTING_BATCH_OPERATIONS.md +252 -0
- package/bin/flux-console.ts +2 -0
- package/dist/bin.js +108196 -0
- package/dist/client/assets/index-DGYEwTDL.css +1 -0
- package/dist/client/assets/index-oyTdySX0.js +421 -0
- package/dist/client/index.html +13 -0
- package/dist/server/index.js +108191 -0
- package/docker-compose.yml +40 -0
- package/docs/integrations/LARAVEL.md +207 -0
- package/package.json +50 -0
- package/postcss.config.js +6 -0
- package/scripts/flood-logs.ts +21 -0
- package/scripts/seed.ts +213 -0
- package/scripts/verify-throttle.ts +45 -0
- package/scripts/worker.ts +123 -0
- package/src/bin.ts +6 -0
- package/src/client/App.tsx +70 -0
- package/src/client/Layout.tsx +644 -0
- package/src/client/Sidebar.tsx +102 -0
- package/src/client/ThroughputChart.tsx +135 -0
- package/src/client/WorkerStatus.tsx +170 -0
- package/src/client/components/ConfirmDialog.tsx +103 -0
- package/src/client/components/JobInspector.tsx +524 -0
- package/src/client/components/LogArchiveModal.tsx +383 -0
- package/src/client/components/NotificationBell.tsx +203 -0
- package/src/client/components/Toaster.tsx +80 -0
- package/src/client/components/UserProfileDropdown.tsx +177 -0
- package/src/client/contexts/AuthContext.tsx +93 -0
- package/src/client/contexts/NotificationContext.tsx +103 -0
- package/src/client/index.css +174 -0
- package/src/client/index.html +12 -0
- package/src/client/main.tsx +15 -0
- package/src/client/pages/LoginPage.tsx +153 -0
- package/src/client/pages/MetricsPage.tsx +408 -0
- package/src/client/pages/OverviewPage.tsx +511 -0
- package/src/client/pages/QueuesPage.tsx +372 -0
- package/src/client/pages/SchedulesPage.tsx +531 -0
- package/src/client/pages/SettingsPage.tsx +449 -0
- package/src/client/pages/WorkersPage.tsx +316 -0
- package/src/client/pages/index.ts +7 -0
- package/src/client/utils.ts +6 -0
- package/src/server/index.ts +556 -0
- package/src/server/middleware/auth.ts +127 -0
- package/src/server/services/AlertService.ts +160 -0
- package/src/server/services/QueueService.ts +828 -0
- package/tailwind.config.js +73 -0
- package/tests/placeholder.test.ts +7 -0
- package/tsconfig.json +38 -0
- package/tsconfig.node.json +12 -0
- package/vite.config.ts +27 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { motion } from 'framer-motion'
|
|
3
|
+
import { AlertCircle, ArrowRight, CheckCircle2, Clock, Search } from 'lucide-react'
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { createPortal } from 'react-dom'
|
|
6
|
+
import { cn } from '../utils'
|
|
7
|
+
import { ConfirmDialog } from './ConfirmDialog'
|
|
8
|
+
|
|
9
|
+
interface Job {
|
|
10
|
+
id: string
|
|
11
|
+
name?: string
|
|
12
|
+
data?: any
|
|
13
|
+
status?: string
|
|
14
|
+
timestamp?: number
|
|
15
|
+
scheduledAt?: string
|
|
16
|
+
error?: string
|
|
17
|
+
failedAt?: number
|
|
18
|
+
_raw?: string
|
|
19
|
+
_archived?: boolean
|
|
20
|
+
_status?: 'completed' | 'failed'
|
|
21
|
+
_archivedAt?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface JobInspectorProps {
|
|
25
|
+
queueName: string
|
|
26
|
+
onClose: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function JobInspector({ queueName, onClose }: JobInspectorProps) {
|
|
30
|
+
const [view, setView] = React.useState<'waiting' | 'delayed' | 'failed' | 'archive'>('waiting')
|
|
31
|
+
const [page, setPage] = React.useState(1)
|
|
32
|
+
const [selectedIndices, setSelectedIndices] = React.useState<Set<number>>(new Set())
|
|
33
|
+
const [totalCount, setTotalCount] = React.useState<number>(0)
|
|
34
|
+
const [isProcessing, setIsProcessing] = React.useState(false)
|
|
35
|
+
const [confirmDialog, setConfirmDialog] = React.useState<{
|
|
36
|
+
open: boolean
|
|
37
|
+
title: string
|
|
38
|
+
message: string
|
|
39
|
+
action: () => void
|
|
40
|
+
variant?: 'danger' | 'warning' | 'info'
|
|
41
|
+
} | null>(null)
|
|
42
|
+
|
|
43
|
+
const queryClient = useQueryClient()
|
|
44
|
+
|
|
45
|
+
const { isPending, error, data } = useQuery<{ jobs: Job[]; total?: number }>({
|
|
46
|
+
queryKey: ['jobs', queueName, view, page],
|
|
47
|
+
queryFn: () => {
|
|
48
|
+
const url =
|
|
49
|
+
view === 'archive'
|
|
50
|
+
? `/api/queues/${queueName}/archive?page=${page}&limit=50`
|
|
51
|
+
: `/api/queues/${queueName}/jobs?type=${view}`
|
|
52
|
+
return fetch(url).then((res) => res.json())
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Fetch total count for non-archive views
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
if (view !== 'archive') {
|
|
59
|
+
fetch(`/api/queues/${queueName}/jobs/count?type=${view}`)
|
|
60
|
+
.then((res) => res.json())
|
|
61
|
+
.then((data) => setTotalCount(data.count))
|
|
62
|
+
.catch(() => setTotalCount(0))
|
|
63
|
+
} else {
|
|
64
|
+
setTotalCount(data?.total || 0)
|
|
65
|
+
}
|
|
66
|
+
}, [queueName, view, data?.total])
|
|
67
|
+
|
|
68
|
+
// Reset selection when view changes
|
|
69
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: We want to reset when view changes
|
|
70
|
+
React.useEffect(() => {
|
|
71
|
+
setSelectedIndices(new Set())
|
|
72
|
+
setPage(1)
|
|
73
|
+
}, [view])
|
|
74
|
+
|
|
75
|
+
const toggleSelection = (index: number) => {
|
|
76
|
+
const next = new Set(selectedIndices)
|
|
77
|
+
if (next.has(index)) {
|
|
78
|
+
next.delete(index)
|
|
79
|
+
} else {
|
|
80
|
+
next.add(index)
|
|
81
|
+
}
|
|
82
|
+
setSelectedIndices(next)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const toggleSelectAll = React.useCallback(() => {
|
|
86
|
+
if (!data?.jobs) return
|
|
87
|
+
const availableCount = data.jobs.filter((j) => j._raw && !j._archived).length
|
|
88
|
+
if (selectedIndices.size === availableCount && availableCount > 0) {
|
|
89
|
+
setSelectedIndices(new Set())
|
|
90
|
+
} else {
|
|
91
|
+
const indices = new Set<number>()
|
|
92
|
+
data.jobs.forEach((j, i) => {
|
|
93
|
+
if (j._raw && !j._archived) indices.add(i)
|
|
94
|
+
})
|
|
95
|
+
setSelectedIndices(indices)
|
|
96
|
+
}
|
|
97
|
+
}, [data?.jobs, selectedIndices])
|
|
98
|
+
|
|
99
|
+
// Keyboard shortcuts
|
|
100
|
+
React.useEffect(() => {
|
|
101
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
102
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
|
103
|
+
e.preventDefault()
|
|
104
|
+
toggleSelectAll()
|
|
105
|
+
}
|
|
106
|
+
if (e.key === 'Escape') {
|
|
107
|
+
if (confirmDialog?.open) {
|
|
108
|
+
setConfirmDialog(null)
|
|
109
|
+
} else if (selectedIndices.size > 0) {
|
|
110
|
+
setSelectedIndices(new Set())
|
|
111
|
+
} else {
|
|
112
|
+
onClose()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
118
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
119
|
+
}, [selectedIndices, confirmDialog, toggleSelectAll, onClose])
|
|
120
|
+
|
|
121
|
+
// Lock body scroll when modal opens
|
|
122
|
+
React.useEffect(() => {
|
|
123
|
+
const originalOverflow = document.body.style.overflow
|
|
124
|
+
const originalPaddingRight = document.body.style.paddingRight
|
|
125
|
+
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
|
126
|
+
|
|
127
|
+
document.body.style.overflow = 'hidden'
|
|
128
|
+
document.body.style.paddingRight = `${scrollbarWidth}px`
|
|
129
|
+
|
|
130
|
+
return () => {
|
|
131
|
+
document.body.style.overflow = originalOverflow
|
|
132
|
+
document.body.style.paddingRight = originalPaddingRight
|
|
133
|
+
}
|
|
134
|
+
}, [])
|
|
135
|
+
|
|
136
|
+
const handleAction = async (action: 'delete' | 'retry', job: Job) => {
|
|
137
|
+
if (!job._raw) return
|
|
138
|
+
const endpoint = action === 'delete' ? 'delete' : 'retry'
|
|
139
|
+
const body: any = { raw: job._raw }
|
|
140
|
+
if (action === 'delete') body.type = view
|
|
141
|
+
|
|
142
|
+
await fetch(`/api/queues/${queueName}/jobs/${endpoint}`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'Content-Type': 'application/json' },
|
|
145
|
+
body: JSON.stringify(body),
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
queryClient.invalidateQueries({ queryKey: ['jobs', queueName] })
|
|
149
|
+
queryClient.invalidateQueries({ queryKey: ['queues'] })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const handleBulkAction = async (action: 'delete' | 'retry') => {
|
|
153
|
+
if (selectedIndices.size === 0 || !data?.jobs) return
|
|
154
|
+
|
|
155
|
+
const count = selectedIndices.size
|
|
156
|
+
setConfirmDialog({
|
|
157
|
+
open: true,
|
|
158
|
+
title: `${action === 'delete' ? 'Delete' : 'Retry'} ${count} Jobs?`,
|
|
159
|
+
message: `Are you sure you want to ${action} ${count} selected ${view} jobs in "${queueName}"?\n\nThis action cannot be undone.`,
|
|
160
|
+
variant: action === 'delete' ? 'danger' : 'warning',
|
|
161
|
+
action: async () => {
|
|
162
|
+
setIsProcessing(true)
|
|
163
|
+
try {
|
|
164
|
+
const endpoint = action === 'delete' ? 'bulk-delete' : 'bulk-retry'
|
|
165
|
+
const raws = Array.from(selectedIndices)
|
|
166
|
+
.map((i) => data?.jobs[i]?._raw)
|
|
167
|
+
.filter(Boolean) as string[]
|
|
168
|
+
|
|
169
|
+
await fetch(`/api/queues/${queueName}/jobs/${endpoint}`, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'Content-Type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ type: view, raws }),
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
setSelectedIndices(new Set())
|
|
176
|
+
queryClient.invalidateQueries({ queryKey: ['jobs', queueName] })
|
|
177
|
+
queryClient.invalidateQueries({ queryKey: ['queues'] })
|
|
178
|
+
setConfirmDialog(null)
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.error(`Failed to ${action} jobs:`, err)
|
|
181
|
+
} finally {
|
|
182
|
+
setIsProcessing(false)
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const handleBulkActionAll = async (action: 'delete' | 'retry') => {
|
|
189
|
+
if (view === 'archive') return
|
|
190
|
+
|
|
191
|
+
setConfirmDialog({
|
|
192
|
+
open: true,
|
|
193
|
+
title: `${action === 'delete' ? 'Delete' : 'Retry'} ALL ${totalCount} Jobs?`,
|
|
194
|
+
message: `⚠️ WARNING: This will ${action} ALL ${totalCount} ${view} jobs in "${queueName}".\n\nThis is a destructive operation that cannot be undone.`,
|
|
195
|
+
variant: 'danger',
|
|
196
|
+
action: async () => {
|
|
197
|
+
setIsProcessing(true)
|
|
198
|
+
try {
|
|
199
|
+
const endpoint = action === 'delete' ? 'bulk-delete-all' : 'bulk-retry-all'
|
|
200
|
+
await fetch(`/api/queues/${queueName}/jobs/${endpoint}`, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: { 'Content-Type': 'application/json' },
|
|
203
|
+
body: JSON.stringify({ type: view }),
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
setSelectedIndices(new Set())
|
|
207
|
+
queryClient.invalidateQueries({ queryKey: ['jobs', queueName] })
|
|
208
|
+
queryClient.invalidateQueries({ queryKey: ['queues'] })
|
|
209
|
+
setConfirmDialog(null)
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.error(`Failed to ${action} all jobs:`, err)
|
|
212
|
+
} finally {
|
|
213
|
+
setIsProcessing(false)
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return createPortal(
|
|
220
|
+
<div className="fixed inset-0 z-[1001] flex items-center justify-end p-4 sm:p-6 outline-none pointer-events-none">
|
|
221
|
+
<motion.div
|
|
222
|
+
initial={{ opacity: 0 }}
|
|
223
|
+
animate={{ opacity: 1 }}
|
|
224
|
+
exit={{ opacity: 0 }}
|
|
225
|
+
className="absolute inset-0 bg-black/60 backdrop-blur-md cursor-default pointer-events-auto"
|
|
226
|
+
onClick={onClose}
|
|
227
|
+
/>
|
|
228
|
+
|
|
229
|
+
<motion.div
|
|
230
|
+
initial={{ x: '100%', opacity: 0 }}
|
|
231
|
+
animate={{ x: 0, opacity: 1 }}
|
|
232
|
+
exit={{ x: '100%', opacity: 0 }}
|
|
233
|
+
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
|
234
|
+
className="bg-card border border-border/50 h-[85vh] w-full max-w-2xl shadow-2xl flex flex-col overflow-hidden rounded-2xl relative z-[1002] pointer-events-auto"
|
|
235
|
+
onClick={(e) => e.stopPropagation()}
|
|
236
|
+
>
|
|
237
|
+
<div className="p-6 border-b flex justify-between items-center bg-muted/20 flex-shrink-0">
|
|
238
|
+
<div>
|
|
239
|
+
<h2 className="text-xl font-bold flex items-center gap-2">
|
|
240
|
+
<Search className="text-primary" size={20} />
|
|
241
|
+
Queue Insight: <span className="text-primary">{queueName}</span>
|
|
242
|
+
</h2>
|
|
243
|
+
<div className="flex items-center gap-4 mt-2">
|
|
244
|
+
{(['waiting', 'delayed', 'failed', 'archive'] as const).map((v) => (
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
key={v}
|
|
248
|
+
onClick={() => setView(v)}
|
|
249
|
+
className={cn(
|
|
250
|
+
'text-[9px] font-black px-3 py-1 rounded-sm transition-all border shrink-0 uppercase tracking-widest',
|
|
251
|
+
view === v
|
|
252
|
+
? v === 'failed'
|
|
253
|
+
? 'bg-red-500 text-white border-red-500 shadow-lg shadow-red-500/20'
|
|
254
|
+
: v === 'delayed'
|
|
255
|
+
? 'bg-amber-500 text-white border-amber-500 shadow-lg shadow-amber-500/20'
|
|
256
|
+
: v === 'archive'
|
|
257
|
+
? 'bg-indigo-500 text-white border-indigo-500 shadow-lg shadow-indigo-500/20'
|
|
258
|
+
: 'bg-primary text-primary-foreground border-primary shadow-lg shadow-primary/20'
|
|
259
|
+
: 'bg-muted text-muted-foreground border-transparent hover:bg-muted/80'
|
|
260
|
+
)}
|
|
261
|
+
>
|
|
262
|
+
{v}
|
|
263
|
+
</button>
|
|
264
|
+
))}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
onClick={onClose}
|
|
270
|
+
className="w-10 h-10 rounded-full hover:bg-muted flex items-center justify-center transition-colors"
|
|
271
|
+
>
|
|
272
|
+
✕
|
|
273
|
+
</button>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<div className="flex-1 overflow-y-auto bg-muted/5 min-h-0">
|
|
277
|
+
{isPending && (
|
|
278
|
+
<div className="p-12 text-center text-muted-foreground font-medium animate-pulse">
|
|
279
|
+
Loading jobs...
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
{error && (
|
|
283
|
+
<div className="p-12 text-center text-red-500 font-bold">Error loading jobs</div>
|
|
284
|
+
)}
|
|
285
|
+
|
|
286
|
+
{data?.jobs && data.jobs.length > 0 && (
|
|
287
|
+
<>
|
|
288
|
+
<div className="px-6 py-3 border-b bg-muted/5 flex items-center gap-3">
|
|
289
|
+
<input
|
|
290
|
+
type="checkbox"
|
|
291
|
+
className="w-4 h-4 rounded border-border"
|
|
292
|
+
checked={
|
|
293
|
+
selectedIndices.size ===
|
|
294
|
+
data.jobs.filter((j) => j._raw && !j._archived).length &&
|
|
295
|
+
selectedIndices.size > 0
|
|
296
|
+
}
|
|
297
|
+
onChange={toggleSelectAll}
|
|
298
|
+
/>
|
|
299
|
+
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
|
300
|
+
Select All (Page)
|
|
301
|
+
</span>
|
|
302
|
+
{view !== 'archive' && totalCount > 0 && (
|
|
303
|
+
<span className="text-[9px] text-muted-foreground/60 ml-2">
|
|
304
|
+
{data.jobs.filter((j) => !j._archived).length} of {totalCount} total
|
|
305
|
+
</span>
|
|
306
|
+
)}
|
|
307
|
+
{selectedIndices.size > 0 && (
|
|
308
|
+
<div className="ml-auto flex items-center gap-2">
|
|
309
|
+
<span className="text-[10px] font-black uppercase text-primary mr-2">
|
|
310
|
+
{selectedIndices.size} items selected
|
|
311
|
+
</span>
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
onClick={() => handleBulkAction('delete')}
|
|
315
|
+
className="px-3 py-1 bg-red-500/10 text-red-500 rounded-md text-[10px] font-black uppercase hover:bg-red-500 hover:text-white transition-all"
|
|
316
|
+
>
|
|
317
|
+
Delete Selected
|
|
318
|
+
</button>
|
|
319
|
+
{(view === 'delayed' || view === 'failed') && (
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
onClick={() => handleBulkAction('retry')}
|
|
323
|
+
className="px-3 py-1 bg-primary/10 text-primary rounded-md text-[10px] font-black uppercase hover:bg-primary hover:text-primary-foreground transition-all"
|
|
324
|
+
>
|
|
325
|
+
Retry Selected
|
|
326
|
+
</button>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
{view !== 'archive' && totalCount > data.jobs.filter((j) => !j._archived).length && (
|
|
332
|
+
<div className="px-6 py-3 border-b bg-amber-500/5 flex items-center justify-between">
|
|
333
|
+
<span className="text-xs font-bold text-amber-600 flex items-center gap-2">
|
|
334
|
+
<AlertCircle size={14} />
|
|
335
|
+
Showing {data.jobs.filter((j) => !j._archived).length} of {totalCount} total{' '}
|
|
336
|
+
{view} jobs
|
|
337
|
+
</span>
|
|
338
|
+
<div className="flex items-center gap-2">
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
onClick={() => handleBulkActionAll('delete')}
|
|
342
|
+
className="px-3 py-1.5 bg-red-500/10 text-red-500 rounded-md text-[10px] font-black uppercase hover:bg-red-500 hover:text-white transition-all"
|
|
343
|
+
>
|
|
344
|
+
Delete All {totalCount}
|
|
345
|
+
</button>
|
|
346
|
+
{(view === 'delayed' || view === 'failed') && (
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
onClick={() => handleBulkActionAll('retry')}
|
|
350
|
+
className="px-3 py-1.5 bg-amber-500/10 text-amber-600 rounded-md text-[10px] font-black uppercase hover:bg-amber-500 hover:text-white transition-all"
|
|
351
|
+
>
|
|
352
|
+
Retry All {totalCount}
|
|
353
|
+
</button>
|
|
354
|
+
)}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
358
|
+
</>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{data?.jobs && data.jobs.length === 0 && (
|
|
362
|
+
<div className="p-12 text-center text-muted-foreground flex flex-col items-center gap-4">
|
|
363
|
+
<div className="w-16 h-16 bg-muted/50 rounded-full flex items-center justify-center text-muted-foreground/30">
|
|
364
|
+
<CheckCircle2 size={32} />
|
|
365
|
+
</div>
|
|
366
|
+
<p className="text-lg font-bold">Clear Sky!</p>
|
|
367
|
+
<p className="text-sm opacity-60">No jobs found in this queue.</p>
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
{data?.jobs && (
|
|
371
|
+
<div className="p-6 space-y-4">
|
|
372
|
+
{data.jobs.map((job, i) => (
|
|
373
|
+
<div
|
|
374
|
+
key={i}
|
|
375
|
+
className={cn(
|
|
376
|
+
'bg-card border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-all group border-border/50',
|
|
377
|
+
selectedIndices.has(i) && 'ring-2 ring-primary border-primary'
|
|
378
|
+
)}
|
|
379
|
+
>
|
|
380
|
+
<div className="p-4 border-b bg-muted/10 flex justify-between items-center text-[10px]">
|
|
381
|
+
<div className="flex items-center gap-3">
|
|
382
|
+
{job._raw && !job._archived && (
|
|
383
|
+
<input
|
|
384
|
+
type="checkbox"
|
|
385
|
+
className="w-4 h-4 rounded border-border"
|
|
386
|
+
checked={selectedIndices.has(i)}
|
|
387
|
+
onChange={() => toggleSelection(i)}
|
|
388
|
+
/>
|
|
389
|
+
)}
|
|
390
|
+
<span className="font-mono bg-primary/10 text-primary px-2 py-1 rounded-md font-bold uppercase tracking-wider flex items-center gap-2">
|
|
391
|
+
ID: {job.id || 'N/A'}
|
|
392
|
+
{job._archived && (
|
|
393
|
+
<span
|
|
394
|
+
className={cn(
|
|
395
|
+
'px-1.5 py-0.5 rounded text-[8px] border',
|
|
396
|
+
job._status === 'completed'
|
|
397
|
+
? 'bg-green-500/20 text-green-500 border-green-500/20'
|
|
398
|
+
: 'bg-red-500/20 text-red-500 border-red-500/20'
|
|
399
|
+
)}
|
|
400
|
+
>
|
|
401
|
+
Archive: {job._status}
|
|
402
|
+
</span>
|
|
403
|
+
)}
|
|
404
|
+
</span>
|
|
405
|
+
</div>
|
|
406
|
+
<span className="text-muted-foreground font-semibold flex items-center gap-3">
|
|
407
|
+
{view === 'delayed' && job.scheduledAt && (
|
|
408
|
+
<span className="text-amber-500 flex items-center gap-1 font-bold">
|
|
409
|
+
<Clock size={12} /> {new Date(job.scheduledAt).toLocaleString()}
|
|
410
|
+
</span>
|
|
411
|
+
)}
|
|
412
|
+
{view === 'failed' && job.failedAt && (
|
|
413
|
+
<span className="text-red-500 flex items-center gap-1 font-bold">
|
|
414
|
+
<AlertCircle size={12} /> {new Date(job.failedAt).toLocaleString()}
|
|
415
|
+
</span>
|
|
416
|
+
)}
|
|
417
|
+
{job._archivedAt && (
|
|
418
|
+
<span className="text-indigo-400 flex items-center gap-1 font-bold">
|
|
419
|
+
<ArrowRight size={12} /> {new Date(job._archivedAt).toLocaleString()}
|
|
420
|
+
</span>
|
|
421
|
+
)}
|
|
422
|
+
{job.timestamp &&
|
|
423
|
+
!job._archivedAt &&
|
|
424
|
+
new Date(job.timestamp).toLocaleString()}
|
|
425
|
+
</span>
|
|
426
|
+
</div>
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
onClick={() => job._raw && !job._archived && toggleSelection(i)}
|
|
430
|
+
className="w-full text-left cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-inset"
|
|
431
|
+
>
|
|
432
|
+
{job.error && (
|
|
433
|
+
<div className="p-4 bg-red-500/10 text-red-500 text-xs font-semibold border-b border-red-500/10 flex items-start gap-2">
|
|
434
|
+
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
|
435
|
+
<p>{job.error}</p>
|
|
436
|
+
</div>
|
|
437
|
+
)}
|
|
438
|
+
<pre className="text-[11px] font-mono p-4 overflow-x-auto text-foreground/80 leading-relaxed bg-muted/5">
|
|
439
|
+
{JSON.stringify(job, null, 2)}
|
|
440
|
+
</pre>
|
|
441
|
+
</button>
|
|
442
|
+
<div className="p-3 bg-muted/5 border-t border-border/50 flex justify-end gap-2">
|
|
443
|
+
{!job._archived && (
|
|
444
|
+
<button
|
|
445
|
+
type="button"
|
|
446
|
+
onClick={() => handleAction('delete', job)}
|
|
447
|
+
className="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors"
|
|
448
|
+
>
|
|
449
|
+
Terminate
|
|
450
|
+
</button>
|
|
451
|
+
)}
|
|
452
|
+
{!job._archived && (view === 'delayed' || view === 'failed') && (
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
onClick={() => handleAction('retry', job)}
|
|
456
|
+
className={cn(
|
|
457
|
+
'text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-lg text-white shadow-sm transition-all',
|
|
458
|
+
view === 'delayed'
|
|
459
|
+
? 'bg-amber-500 hover:bg-amber-600'
|
|
460
|
+
: 'bg-blue-500 hover:bg-blue-600'
|
|
461
|
+
)}
|
|
462
|
+
>
|
|
463
|
+
{view === 'delayed' ? 'Process Now' : 'Retry Job'}
|
|
464
|
+
</button>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
))}
|
|
469
|
+
|
|
470
|
+
{view === 'archive' && data?.total && data.total > 50 && (
|
|
471
|
+
<div className="flex items-center justify-between py-6 border-t border-border/30">
|
|
472
|
+
<p className="text-[10px] font-black text-muted-foreground uppercase tracking-widest">
|
|
473
|
+
Total {data.total} archived jobs
|
|
474
|
+
</p>
|
|
475
|
+
<div className="flex items-center gap-2">
|
|
476
|
+
<button
|
|
477
|
+
type="button"
|
|
478
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
479
|
+
disabled={page === 1}
|
|
480
|
+
className="p-2 rounded-lg bg-muted text-muted-foreground disabled:opacity-30 hover:bg-primary hover:text-white transition-all"
|
|
481
|
+
>
|
|
482
|
+
←
|
|
483
|
+
</button>
|
|
484
|
+
<span className="text-xs font-bold px-4">{page}</span>
|
|
485
|
+
<button
|
|
486
|
+
type="button"
|
|
487
|
+
onClick={() => setPage((p) => p + 1)}
|
|
488
|
+
disabled={page * 50 >= (data.total || 0)}
|
|
489
|
+
className="p-2 rounded-lg bg-muted text-muted-foreground disabled:opacity-30 hover:bg-primary hover:text-white transition-all"
|
|
490
|
+
>
|
|
491
|
+
→
|
|
492
|
+
</button>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
</div>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
499
|
+
<div className="p-4 border-t bg-card text-right flex-shrink-0">
|
|
500
|
+
<button
|
|
501
|
+
type="button"
|
|
502
|
+
onClick={onClose}
|
|
503
|
+
className="px-8 py-3 bg-muted text-foreground rounded-xl hover:bg-muted/80 text-sm font-bold transition-all active:scale-95 uppercase tracking-widest"
|
|
504
|
+
>
|
|
505
|
+
Dismiss
|
|
506
|
+
</button>
|
|
507
|
+
</div>
|
|
508
|
+
</motion.div>
|
|
509
|
+
|
|
510
|
+
{confirmDialog && (
|
|
511
|
+
<ConfirmDialog
|
|
512
|
+
open={confirmDialog.open}
|
|
513
|
+
title={confirmDialog.title}
|
|
514
|
+
message={confirmDialog.message}
|
|
515
|
+
variant={confirmDialog.variant}
|
|
516
|
+
isProcessing={isProcessing}
|
|
517
|
+
onConfirm={confirmDialog.action}
|
|
518
|
+
onCancel={() => setConfirmDialog(null)}
|
|
519
|
+
/>
|
|
520
|
+
)}
|
|
521
|
+
</div>,
|
|
522
|
+
document.body
|
|
523
|
+
)
|
|
524
|
+
}
|