@gravito/zenith 1.1.2 → 1.1.6

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