@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,372 @@
|
|
|
1
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { AnimatePresence, motion } from 'framer-motion'
|
|
3
|
+
import {
|
|
4
|
+
Activity,
|
|
5
|
+
AlertCircle,
|
|
6
|
+
ArrowRight,
|
|
7
|
+
CheckCircle2,
|
|
8
|
+
Clock,
|
|
9
|
+
Filter,
|
|
10
|
+
ListTree,
|
|
11
|
+
Pause,
|
|
12
|
+
Play,
|
|
13
|
+
RefreshCcw,
|
|
14
|
+
Search,
|
|
15
|
+
Trash2,
|
|
16
|
+
XCircle,
|
|
17
|
+
} from 'lucide-react'
|
|
18
|
+
import React from 'react'
|
|
19
|
+
import { JobInspector } from '../components/JobInspector'
|
|
20
|
+
import { cn } from '../utils'
|
|
21
|
+
|
|
22
|
+
interface QueueStats {
|
|
23
|
+
name: string
|
|
24
|
+
waiting: number
|
|
25
|
+
delayed: number
|
|
26
|
+
active: number
|
|
27
|
+
failed: number
|
|
28
|
+
paused?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function QueuesPage() {
|
|
32
|
+
const [selectedQueue, setSelectedQueue] = React.useState<string | null>(null)
|
|
33
|
+
const [searchQuery, setSearchQuery] = React.useState('')
|
|
34
|
+
const [statusFilter, setStatusFilter] = React.useState<'all' | 'active' | 'idle' | 'critical'>(
|
|
35
|
+
'all'
|
|
36
|
+
)
|
|
37
|
+
const queryClient = useQueryClient()
|
|
38
|
+
|
|
39
|
+
const { isPending, error, data } = useQuery<{ queues: QueueStats[] }>({
|
|
40
|
+
queryKey: ['queues'],
|
|
41
|
+
queryFn: () => fetch('/api/queues').then((res) => res.json()),
|
|
42
|
+
staleTime: Infinity, // No auto refetch
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Listen for real-time updates from Layout's global stream
|
|
46
|
+
React.useEffect(() => {
|
|
47
|
+
const handler = (e: CustomEvent) => {
|
|
48
|
+
if (e.detail?.queues) {
|
|
49
|
+
queryClient.setQueryData(['queues'], { queues: e.detail.queues })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
window.addEventListener('flux-stats-update', handler as EventListener)
|
|
53
|
+
return () => window.removeEventListener('flux-stats-update', handler as EventListener)
|
|
54
|
+
}, [queryClient])
|
|
55
|
+
|
|
56
|
+
// Note: We intentionally do NOT scroll to top when JobInspector opens
|
|
57
|
+
// This allows users to quickly inspect multiple queues without losing their scroll position
|
|
58
|
+
|
|
59
|
+
const queues = data?.queues || []
|
|
60
|
+
|
|
61
|
+
const filteredQueues = queues.filter((q) => {
|
|
62
|
+
const matchesSearch = q.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
63
|
+
const status = q.failed > 0 ? 'critical' : q.active > 0 ? 'active' : 'idle'
|
|
64
|
+
const matchesStatus = statusFilter === 'all' || status === statusFilter
|
|
65
|
+
return matchesSearch && matchesStatus
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const totalWaiting = queues.reduce((acc, q) => acc + q.waiting, 0)
|
|
69
|
+
const totalDelayed = queues.reduce((acc, q) => acc + q.delayed, 0)
|
|
70
|
+
const totalFailed = queues.reduce((acc, q) => acc + q.failed, 0)
|
|
71
|
+
const totalActive = queues.reduce((acc, q) => acc + q.active, 0)
|
|
72
|
+
|
|
73
|
+
if (isPending) {
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex flex-col items-center justify-center p-20 space-y-6">
|
|
76
|
+
<RefreshCcw className="animate-spin text-primary" size={48} />
|
|
77
|
+
<p className="text-muted-foreground font-bold uppercase tracking-[0.3em] text-xs">
|
|
78
|
+
Loading queues...
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (error) {
|
|
85
|
+
return (
|
|
86
|
+
<div className="text-center p-20">
|
|
87
|
+
<div className="bg-red-500/10 text-red-500 p-10 rounded-3xl border border-red-500/20 max-w-md mx-auto shadow-2xl">
|
|
88
|
+
<AlertCircle size={56} className="mx-auto mb-6 opacity-80" />
|
|
89
|
+
<h3 className="text-2xl font-black mb-2 uppercase tracking-tight">
|
|
90
|
+
Failed to Load Queues
|
|
91
|
+
</h3>
|
|
92
|
+
<p className="text-sm font-medium opacity-70 mb-8">{error.message}</p>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<>
|
|
100
|
+
{/* JobInspector as full-screen modal overlay */}
|
|
101
|
+
<AnimatePresence>
|
|
102
|
+
{selectedQueue && (
|
|
103
|
+
<JobInspector queueName={selectedQueue} onClose={() => setSelectedQueue(null)} />
|
|
104
|
+
)}
|
|
105
|
+
</AnimatePresence>
|
|
106
|
+
|
|
107
|
+
{/* Main page content */}
|
|
108
|
+
<div className="space-y-8">
|
|
109
|
+
{/* Header */}
|
|
110
|
+
<div className="flex justify-between items-end">
|
|
111
|
+
<div>
|
|
112
|
+
<h1 className="text-4xl font-black tracking-tighter">Processing Queues</h1>
|
|
113
|
+
<p className="text-muted-foreground mt-2 text-sm font-bold opacity-60 uppercase tracking-widest">
|
|
114
|
+
Manage and monitor all processing pipelines.
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
<div className="flex items-center gap-2 text-[10px] font-black text-green-500 bg-green-500/10 px-4 py-2 rounded-full border border-green-500/20 uppercase tracking-[0.2em] animate-pulse">
|
|
118
|
+
<span className="w-2 h-2 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.6)]"></span>
|
|
119
|
+
{queues.length} Queues
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Summary Cards */}
|
|
124
|
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
125
|
+
<div className="card-premium p-6">
|
|
126
|
+
<p className="text-[10px] font-black text-muted-foreground/50 uppercase tracking-widest mb-1">
|
|
127
|
+
Total Waiting
|
|
128
|
+
</p>
|
|
129
|
+
<p className="text-3xl font-black">{totalWaiting.toLocaleString()}</p>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="card-premium p-6">
|
|
132
|
+
<p className="text-[10px] font-black text-muted-foreground/50 uppercase tracking-widest mb-1">
|
|
133
|
+
Total Delayed
|
|
134
|
+
</p>
|
|
135
|
+
<p className="text-3xl font-black text-amber-500">{totalDelayed.toLocaleString()}</p>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="card-premium p-6">
|
|
138
|
+
<p className="text-[10px] font-black text-muted-foreground/50 uppercase tracking-widest mb-1">
|
|
139
|
+
Total Failed
|
|
140
|
+
</p>
|
|
141
|
+
<p className="text-3xl font-black text-red-500">{totalFailed.toLocaleString()}</p>
|
|
142
|
+
</div>
|
|
143
|
+
<div className="card-premium p-6">
|
|
144
|
+
<p className="text-[10px] font-black text-muted-foreground/50 uppercase tracking-widest mb-1">
|
|
145
|
+
Currently Active
|
|
146
|
+
</p>
|
|
147
|
+
<p className="text-3xl font-black text-green-500">{totalActive.toLocaleString()}</p>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Filters */}
|
|
152
|
+
<div className="card-premium p-4 flex flex-wrap gap-4 items-center">
|
|
153
|
+
<div className="relative flex-1 min-w-[200px]">
|
|
154
|
+
<Search
|
|
155
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
156
|
+
size={18}
|
|
157
|
+
/>
|
|
158
|
+
<input
|
|
159
|
+
type="text"
|
|
160
|
+
placeholder="Search queues..."
|
|
161
|
+
value={searchQuery}
|
|
162
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
163
|
+
className="w-full bg-muted/40 border border-border/50 rounded-xl py-2.5 pl-10 pr-4 text-sm font-medium placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/20"
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
<div className="flex items-center gap-2">
|
|
167
|
+
<Filter size={16} className="text-muted-foreground" />
|
|
168
|
+
{(['all', 'active', 'idle', 'critical'] as const).map((status) => (
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
key={status}
|
|
172
|
+
onClick={() => setStatusFilter(status)}
|
|
173
|
+
className={cn(
|
|
174
|
+
'px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
|
175
|
+
statusFilter === status
|
|
176
|
+
? 'bg-primary text-primary-foreground'
|
|
177
|
+
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
180
|
+
{status}
|
|
181
|
+
</button>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{/* Queue List */}
|
|
187
|
+
<div className="card-premium overflow-hidden">
|
|
188
|
+
<div className="overflow-x-auto">
|
|
189
|
+
<table className="w-full text-left">
|
|
190
|
+
<thead className="bg-muted/10 text-muted-foreground uppercase text-[10px] font-black tracking-[0.2em]">
|
|
191
|
+
<tr>
|
|
192
|
+
<th className="px-6 py-5">Queue Name</th>
|
|
193
|
+
<th className="px-6 py-5 text-center">Waiting</th>
|
|
194
|
+
<th className="px-6 py-5 text-center">Delayed</th>
|
|
195
|
+
<th className="px-6 py-5 text-center">Active</th>
|
|
196
|
+
<th className="px-6 py-5 text-center">Failed</th>
|
|
197
|
+
<th className="px-6 py-5 text-center">Status</th>
|
|
198
|
+
<th className="px-6 py-5 text-right">Actions</th>
|
|
199
|
+
</tr>
|
|
200
|
+
</thead>
|
|
201
|
+
<tbody className="divide-y divide-border/30 text-sm">
|
|
202
|
+
{filteredQueues.map((queue) => {
|
|
203
|
+
const status =
|
|
204
|
+
queue.failed > 0 ? 'critical' : queue.active > 0 ? 'active' : 'idle'
|
|
205
|
+
return (
|
|
206
|
+
<tr key={queue.name} className="hover:bg-muted/5 transition-colors group">
|
|
207
|
+
<td className="px-6 py-5">
|
|
208
|
+
<div className="flex items-center gap-3">
|
|
209
|
+
<div className="w-10 h-10 bg-primary/5 rounded-xl flex items-center justify-center text-primary group-hover:scale-110 transition-transform">
|
|
210
|
+
<ListTree size={20} />
|
|
211
|
+
</div>
|
|
212
|
+
<span className="font-black tracking-tight">{queue.name}</span>
|
|
213
|
+
</div>
|
|
214
|
+
</td>
|
|
215
|
+
<td className="px-6 py-5 text-center font-mono font-bold">
|
|
216
|
+
{queue.waiting.toLocaleString()}
|
|
217
|
+
</td>
|
|
218
|
+
<td className="px-6 py-5 text-center font-mono text-amber-500">
|
|
219
|
+
{queue.delayed}
|
|
220
|
+
</td>
|
|
221
|
+
<td className="px-6 py-5 text-center font-mono text-green-500">
|
|
222
|
+
{queue.active}
|
|
223
|
+
</td>
|
|
224
|
+
<td className="px-6 py-5 text-center">
|
|
225
|
+
<span
|
|
226
|
+
className={cn(
|
|
227
|
+
'font-mono font-black',
|
|
228
|
+
queue.failed > 0 ? 'text-red-500' : 'text-muted-foreground/40'
|
|
229
|
+
)}
|
|
230
|
+
>
|
|
231
|
+
{queue.failed}
|
|
232
|
+
</span>
|
|
233
|
+
</td>
|
|
234
|
+
<td className="px-6 py-5 text-center">
|
|
235
|
+
<span
|
|
236
|
+
className={cn(
|
|
237
|
+
'px-3 py-1.5 rounded-full text-[9px] font-black uppercase tracking-widest border',
|
|
238
|
+
queue.paused
|
|
239
|
+
? 'bg-amber-500/20 text-amber-500 border-amber-500/30'
|
|
240
|
+
: status === 'critical'
|
|
241
|
+
? 'bg-red-500 text-white border-red-600'
|
|
242
|
+
: status === 'active'
|
|
243
|
+
? 'bg-green-500/10 text-green-500 border-green-500/20'
|
|
244
|
+
: 'bg-muted/40 text-muted-foreground border-transparent'
|
|
245
|
+
)}
|
|
246
|
+
>
|
|
247
|
+
{queue.paused ? 'paused' : status}
|
|
248
|
+
</span>
|
|
249
|
+
</td>
|
|
250
|
+
<td className="px-6 py-5">
|
|
251
|
+
<div className="flex justify-end gap-2 items-center">
|
|
252
|
+
{/* Pause/Resume button */}
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
onClick={async () => {
|
|
256
|
+
const action = queue.paused ? 'resume' : 'pause'
|
|
257
|
+
await fetch(`/api/queues/${queue.name}/${action}`, { method: 'POST' })
|
|
258
|
+
queryClient.invalidateQueries({ queryKey: ['queues'] })
|
|
259
|
+
}}
|
|
260
|
+
className={cn(
|
|
261
|
+
'p-2 rounded-lg transition-all',
|
|
262
|
+
queue.paused
|
|
263
|
+
? 'text-green-500 hover:bg-green-500/10'
|
|
264
|
+
: 'text-muted-foreground hover:bg-amber-500/10 hover:text-amber-500'
|
|
265
|
+
)}
|
|
266
|
+
title={queue.paused ? 'Resume Queue' : 'Pause Queue'}
|
|
267
|
+
>
|
|
268
|
+
{queue.paused ? <Play size={16} /> : <Pause size={16} />}
|
|
269
|
+
</button>
|
|
270
|
+
{queue.delayed > 0 && (
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
onClick={() =>
|
|
274
|
+
fetch(`/api/queues/${queue.name}/retry-all`, {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
}).then(() =>
|
|
277
|
+
queryClient.invalidateQueries({ queryKey: ['queues'] })
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
className="p-2 text-amber-500 hover:bg-amber-500/10 rounded-lg transition-all"
|
|
281
|
+
title="Retry All Delayed"
|
|
282
|
+
>
|
|
283
|
+
<RefreshCcw size={16} />
|
|
284
|
+
</button>
|
|
285
|
+
)}
|
|
286
|
+
{queue.failed > 0 && (
|
|
287
|
+
<>
|
|
288
|
+
<button
|
|
289
|
+
type="button"
|
|
290
|
+
onClick={() =>
|
|
291
|
+
fetch(`/api/queues/${queue.name}/retry-all-failed`, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
}).then(() =>
|
|
294
|
+
queryClient.invalidateQueries({ queryKey: ['queues'] })
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
className="p-2 text-blue-500 hover:bg-blue-500/10 rounded-lg transition-all"
|
|
298
|
+
title="Retry All Failed"
|
|
299
|
+
>
|
|
300
|
+
<RefreshCcw size={16} />
|
|
301
|
+
</button>
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
onClick={() => {
|
|
305
|
+
if (
|
|
306
|
+
confirm(
|
|
307
|
+
`Are you sure you want to clear all failed jobs in queue "${queue.name}"?`
|
|
308
|
+
)
|
|
309
|
+
) {
|
|
310
|
+
fetch(`/api/queues/${queue.name}/clear-failed`, {
|
|
311
|
+
method: 'POST',
|
|
312
|
+
}).then(() =>
|
|
313
|
+
queryClient.invalidateQueries({ queryKey: ['queues'] })
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
}}
|
|
317
|
+
className="p-2 text-red-500 hover:bg-red-500/10 rounded-lg transition-all"
|
|
318
|
+
title="Clear Failed Jobs"
|
|
319
|
+
>
|
|
320
|
+
<XCircle size={16} />
|
|
321
|
+
</button>
|
|
322
|
+
</>
|
|
323
|
+
)}
|
|
324
|
+
<button
|
|
325
|
+
type="button"
|
|
326
|
+
onClick={async () => {
|
|
327
|
+
if (
|
|
328
|
+
confirm(
|
|
329
|
+
`Are you sure you want to purge all jobs in queue "${queue.name}"?`
|
|
330
|
+
)
|
|
331
|
+
) {
|
|
332
|
+
await fetch(`/api/queues/${queue.name}/purge`, { method: 'POST' })
|
|
333
|
+
queryClient.invalidateQueries({ queryKey: ['queues'] })
|
|
334
|
+
}
|
|
335
|
+
}}
|
|
336
|
+
className="p-2 text-muted-foreground hover:bg-red-500/10 hover:text-red-500 rounded-lg transition-all"
|
|
337
|
+
title="Purge Queue"
|
|
338
|
+
>
|
|
339
|
+
<Trash2 size={16} />
|
|
340
|
+
</button>
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
onClick={() => setSelectedQueue(queue.name)}
|
|
344
|
+
className="px-4 py-1.5 bg-muted text-foreground rounded-lg transition-all flex items-center gap-2 text-[10px] font-black uppercase tracking-widest border border-border/50 hover:border-primary/50 hover:bg-background"
|
|
345
|
+
>
|
|
346
|
+
Inspect <ArrowRight size={12} />
|
|
347
|
+
</button>
|
|
348
|
+
</div>
|
|
349
|
+
</td>
|
|
350
|
+
</tr>
|
|
351
|
+
)
|
|
352
|
+
})}
|
|
353
|
+
{filteredQueues.length === 0 && (
|
|
354
|
+
<tr>
|
|
355
|
+
<td colSpan={7} className="px-6 py-20 text-center text-muted-foreground">
|
|
356
|
+
<Activity size={40} className="mx-auto mb-4 opacity-10 animate-pulse" />
|
|
357
|
+
<p className="text-sm font-bold opacity-30 italic uppercase tracking-widest">
|
|
358
|
+
{searchQuery || statusFilter !== 'all'
|
|
359
|
+
? 'No queues match your filters'
|
|
360
|
+
: 'No queues available'}
|
|
361
|
+
</p>
|
|
362
|
+
</td>
|
|
363
|
+
</tr>
|
|
364
|
+
)}
|
|
365
|
+
</tbody>
|
|
366
|
+
</table>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
</>
|
|
371
|
+
)
|
|
372
|
+
}
|