@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,511 @@
|
|
|
1
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { AnimatePresence, animate, motion } from 'framer-motion'
|
|
3
|
+
import {
|
|
4
|
+
Activity,
|
|
5
|
+
AlertCircle,
|
|
6
|
+
ArrowRight,
|
|
7
|
+
ChevronRight,
|
|
8
|
+
Clock,
|
|
9
|
+
Cpu,
|
|
10
|
+
Hourglass,
|
|
11
|
+
ListTree,
|
|
12
|
+
RefreshCcw,
|
|
13
|
+
Search,
|
|
14
|
+
Terminal,
|
|
15
|
+
Trash2,
|
|
16
|
+
} from 'lucide-react'
|
|
17
|
+
import React from 'react'
|
|
18
|
+
import { useNavigate } from 'react-router-dom'
|
|
19
|
+
import { JobInspector } from '../components/JobInspector'
|
|
20
|
+
import { LogArchiveModal } from '../components/LogArchiveModal'
|
|
21
|
+
import { ThroughputChart } from '../ThroughputChart'
|
|
22
|
+
import { cn } from '../utils'
|
|
23
|
+
import { WorkerStatus } from '../WorkerStatus'
|
|
24
|
+
|
|
25
|
+
interface QueueStats {
|
|
26
|
+
name: string
|
|
27
|
+
waiting: number
|
|
28
|
+
delayed: number
|
|
29
|
+
active: number
|
|
30
|
+
failed: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface SystemLog {
|
|
34
|
+
timestamp: string
|
|
35
|
+
level: 'error' | 'warn' | 'success' | 'info'
|
|
36
|
+
workerId: string
|
|
37
|
+
queue?: string
|
|
38
|
+
message: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface FluxStats {
|
|
42
|
+
queues: QueueStats[]
|
|
43
|
+
workers: any[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_STATS: FluxStats = {
|
|
47
|
+
queues: [],
|
|
48
|
+
workers: [],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function LiveLogs({
|
|
52
|
+
logs,
|
|
53
|
+
onSearchArchive,
|
|
54
|
+
onWorkerHover,
|
|
55
|
+
}: {
|
|
56
|
+
logs: SystemLog[]
|
|
57
|
+
onSearchArchive: () => void
|
|
58
|
+
onWorkerHover?: (id: string | null) => void
|
|
59
|
+
}) {
|
|
60
|
+
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
61
|
+
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
// Access logs to satisfy dependency check (and trigger on update)
|
|
64
|
+
if (scrollRef.current && logs) {
|
|
65
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
66
|
+
}
|
|
67
|
+
}, [logs])
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="card-premium h-full flex flex-col overflow-hidden group">
|
|
71
|
+
<div className="p-4 border-b bg-muted/5 flex justify-between items-center">
|
|
72
|
+
<div className="flex items-center gap-2">
|
|
73
|
+
<Terminal size={14} className="text-primary" />
|
|
74
|
+
<h2 className="text-xs font-black uppercase tracking-widest opacity-70">
|
|
75
|
+
Operational Logs
|
|
76
|
+
</h2>
|
|
77
|
+
</div>
|
|
78
|
+
<div className="flex items-center gap-3">
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={onSearchArchive}
|
|
82
|
+
className="flex items-center gap-1.5 px-2 py-1 hover:bg-muted rounded-md text-[10px] font-black uppercase tracking-tighter text-muted-foreground transition-all"
|
|
83
|
+
>
|
|
84
|
+
<Search size={12} />
|
|
85
|
+
Search Archive
|
|
86
|
+
</button>
|
|
87
|
+
<div className="flex gap-1">
|
|
88
|
+
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></div>
|
|
89
|
+
<div className="w-1.5 h-1.5 rounded-full bg-green-500/40"></div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
<ul
|
|
94
|
+
ref={scrollRef}
|
|
95
|
+
className="flex-1 overflow-y-auto p-4 font-mono text-[11px] space-y-2.5 scrollbar-thin scroll-smooth"
|
|
96
|
+
>
|
|
97
|
+
{logs.length === 0 ? (
|
|
98
|
+
<div className="h-full flex flex-col items-center justify-center text-muted-foreground/30 gap-2 opacity-50">
|
|
99
|
+
<Activity size={24} className="animate-pulse" />
|
|
100
|
+
<p className="font-bold uppercase tracking-widest text-[9px]">Awaiting signals...</p>
|
|
101
|
+
</div>
|
|
102
|
+
) : (
|
|
103
|
+
logs.map((log, i) => (
|
|
104
|
+
<li
|
|
105
|
+
key={i}
|
|
106
|
+
onMouseEnter={() => onWorkerHover?.(log.workerId)}
|
|
107
|
+
onMouseLeave={() => onWorkerHover?.(null)}
|
|
108
|
+
className="group flex gap-3 hover:bg-primary/[0.02] -mx-2 px-2 py-0.5 rounded transition-all animate-in fade-in slide-in-from-left-2 duration-300 cursor-default"
|
|
109
|
+
>
|
|
110
|
+
<span className="text-muted-foreground/40 shrink-0 tabular-nums select-none opacity-0 group-hover:opacity-100 transition-opacity">
|
|
111
|
+
{new Date(log.timestamp).toLocaleTimeString([], {
|
|
112
|
+
hour12: false,
|
|
113
|
+
hour: '2-digit',
|
|
114
|
+
minute: '2-digit',
|
|
115
|
+
second: '2-digit',
|
|
116
|
+
})}
|
|
117
|
+
</span>
|
|
118
|
+
<div className="flex-1">
|
|
119
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
120
|
+
<span
|
|
121
|
+
className={cn(
|
|
122
|
+
'text-[9px] font-black uppercase tracking-tighter',
|
|
123
|
+
log.level === 'error'
|
|
124
|
+
? 'text-red-500'
|
|
125
|
+
: log.level === 'warn'
|
|
126
|
+
? 'text-amber-500'
|
|
127
|
+
: log.level === 'success'
|
|
128
|
+
? 'text-green-500'
|
|
129
|
+
: 'text-blue-500'
|
|
130
|
+
)}
|
|
131
|
+
>
|
|
132
|
+
[{log.level}]
|
|
133
|
+
</span>
|
|
134
|
+
<span className="text-[9px] font-black text-muted-foreground/40 uppercase opacity-0 group-hover:opacity-100 transition-all">
|
|
135
|
+
{log.workerId}
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
<p className="text-foreground/80 leading-relaxed whitespace-pre-wrap break-all">
|
|
139
|
+
{log.message}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
</li>
|
|
143
|
+
))
|
|
144
|
+
)}
|
|
145
|
+
</ul>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function QueueHeatmap({ queues }: { queues: any[] }) {
|
|
151
|
+
return (
|
|
152
|
+
<div className="card-premium p-6 mb-8 overflow-hidden relative group">
|
|
153
|
+
<div className="absolute inset-0 opacity-10 group-hover:opacity-20 transition-opacity scanline pointer-events-none"></div>
|
|
154
|
+
<div className="flex items-center justify-between mb-4">
|
|
155
|
+
<div className="flex items-center gap-2">
|
|
156
|
+
<Activity size={14} className="text-primary animate-pulse" />
|
|
157
|
+
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-muted-foreground/60">
|
|
158
|
+
Pipeline Load Distribution
|
|
159
|
+
</h3>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="flex gap-1">
|
|
162
|
+
{[0.2, 0.4, 0.6, 0.8, 1].map((o) => (
|
|
163
|
+
<div key={o} className="w-2 h-2 rounded-sm bg-primary" style={{ opacity: o }} />
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
<div className="grid grid-cols-10 sm:grid-cols-20 gap-1.5">
|
|
168
|
+
{queues.map((q, i) => {
|
|
169
|
+
const load = Math.min(1, q.waiting / 200)
|
|
170
|
+
return (
|
|
171
|
+
<div
|
|
172
|
+
key={i}
|
|
173
|
+
className="aspect-square rounded-sm transition-all duration-500 hover:scale-125 cursor-help group/tile relative"
|
|
174
|
+
style={{
|
|
175
|
+
backgroundColor: `hsl(var(--primary) / ${0.1 + load * 0.9})`,
|
|
176
|
+
boxShadow: load > 0.7 ? `0 0 10px hsl(var(--primary) / ${load})` : 'none',
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2 bg-popover text-popover-foreground rounded-lg text-[10px] whitespace-nowrap opacity-0 group-hover/tile:opacity-100 transition-opacity pointer-events-none border border-border z-50">
|
|
180
|
+
<span className="font-black">{q.name}</span>: {q.waiting} items
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
})}
|
|
185
|
+
{Array.from({ length: Math.max(0, 40 - queues.length) }).map((_, i) => (
|
|
186
|
+
<div
|
|
187
|
+
key={`empty-${i}`}
|
|
188
|
+
className="aspect-square rounded-sm bg-muted/20 border border-border/5 group-hover:border-border/10 transition-colors"
|
|
189
|
+
/>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function AnimatedNumber({ value }: { value: number }) {
|
|
197
|
+
const [displayValue, setDisplayValue] = React.useState(value)
|
|
198
|
+
|
|
199
|
+
React.useEffect(() => {
|
|
200
|
+
const controls = animate(displayValue, value, {
|
|
201
|
+
duration: 1.5,
|
|
202
|
+
ease: 'easeOut',
|
|
203
|
+
onUpdate: (latest: number) => setDisplayValue(Math.round(latest)),
|
|
204
|
+
})
|
|
205
|
+
return () => controls.stop()
|
|
206
|
+
}, [value, displayValue])
|
|
207
|
+
|
|
208
|
+
return <span>{displayValue.toLocaleString()}</span>
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface MetricCardProps {
|
|
212
|
+
title: string
|
|
213
|
+
value: number
|
|
214
|
+
icon: React.ReactNode
|
|
215
|
+
color: string
|
|
216
|
+
trend?: string
|
|
217
|
+
data?: number[]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function MetricCard({ title, value, icon, color, trend, data }: MetricCardProps) {
|
|
221
|
+
const displayData = data && data.length > 0 ? data : [20, 30, 25, 40, 35, 50, 45, 60, 55, 70]
|
|
222
|
+
const max = Math.max(...displayData, 10)
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div className="card-premium p-8 hover:shadow-2xl transform hover:-translate-y-2 group relative overflow-hidden">
|
|
226
|
+
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none scanline z-0"></div>
|
|
227
|
+
|
|
228
|
+
<div className="flex justify-between items-start mb-6 z-10 relative">
|
|
229
|
+
<div
|
|
230
|
+
className={cn(
|
|
231
|
+
'p-4 rounded-2xl bg-muted/30 transition-all group-hover:bg-primary/20 group-hover:text-primary group-hover:rotate-12 duration-500 border border-transparent group-hover:border-primary/20 shadow-inner',
|
|
232
|
+
color
|
|
233
|
+
)}
|
|
234
|
+
>
|
|
235
|
+
{icon}
|
|
236
|
+
</div>
|
|
237
|
+
{trend && (
|
|
238
|
+
<div className="flex flex-col items-end">
|
|
239
|
+
<span className="text-[10px] font-black text-muted-foreground/40 uppercase tracking-widest">
|
|
240
|
+
{trend}
|
|
241
|
+
</span>
|
|
242
|
+
<div className="w-8 h-1 bg-muted/50 rounded-full mt-1 overflow-hidden">
|
|
243
|
+
<motion.div
|
|
244
|
+
initial={{ width: 0 }}
|
|
245
|
+
animate={{ width: '100%' }}
|
|
246
|
+
transition={{ duration: 1, repeat: Infinity, repeatType: 'reverse' }}
|
|
247
|
+
className={cn('h-full', color.replace('text-', 'bg-'))}
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<div className="z-10 relative">
|
|
255
|
+
<p className="text-[11px] font-black text-muted-foreground/50 uppercase tracking-[0.2em] mb-2">
|
|
256
|
+
{title}
|
|
257
|
+
</p>
|
|
258
|
+
<div className="text-4xl font-black tracking-tighter flex items-center gap-1">
|
|
259
|
+
<AnimatedNumber value={value} />
|
|
260
|
+
{title === 'Waiting Jobs' && value > 100 && (
|
|
261
|
+
<span className="text-red-500 animate-pulse text-xs">!</span>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<div className="mt-8 flex items-end gap-1.5 h-16 opacity-5 group-hover:opacity-20 transition-all duration-700 absolute bottom-0 left-0 right-0 p-1.5 pointer-events-none">
|
|
267
|
+
{displayData.map((v, i) => (
|
|
268
|
+
<div
|
|
269
|
+
key={i}
|
|
270
|
+
className={cn(
|
|
271
|
+
'flex-1 rounded-t-lg transition-all duration-1000',
|
|
272
|
+
color.replace('text-', 'bg-')
|
|
273
|
+
)}
|
|
274
|
+
style={{
|
|
275
|
+
height: `${(v / max) * 100}%`,
|
|
276
|
+
opacity: 0.1 + (i / displayData.length) * 0.9,
|
|
277
|
+
transitionDelay: `${i * 30}ms`,
|
|
278
|
+
}}
|
|
279
|
+
></div>
|
|
280
|
+
))}
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function QueueList({
|
|
287
|
+
queues,
|
|
288
|
+
setSelectedQueue,
|
|
289
|
+
}: {
|
|
290
|
+
queues: QueueStats[]
|
|
291
|
+
setSelectedQueue: (name: string | null) => void
|
|
292
|
+
}) {
|
|
293
|
+
const queryClient = useQueryClient()
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div className="card-premium h-full flex flex-col overflow-hidden">
|
|
297
|
+
<div className="p-4 border-b bg-muted/5 flex justify-between items-center">
|
|
298
|
+
<div className="flex items-center gap-2">
|
|
299
|
+
<ListTree size={14} className="text-primary" />
|
|
300
|
+
<h2 className="text-xs font-black uppercase tracking-widest opacity-70">
|
|
301
|
+
Processing Pipelines
|
|
302
|
+
</h2>
|
|
303
|
+
</div>
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
className="text-[10px] font-black text-primary hover:underline flex items-center gap-2 uppercase tracking-widest transition-opacity"
|
|
307
|
+
>
|
|
308
|
+
Stats <ChevronRight size={12} />
|
|
309
|
+
</button>
|
|
310
|
+
</div>
|
|
311
|
+
<div className="flex-1 overflow-auto scrollbar-thin">
|
|
312
|
+
<table className="w-full text-left">
|
|
313
|
+
<thead className="bg-muted/10 text-muted-foreground uppercase text-[9px] font-black tracking-widest sticky top-0">
|
|
314
|
+
<tr>
|
|
315
|
+
<th className="px-4 py-3">Queue</th>
|
|
316
|
+
<th className="px-4 py-3">Waiting</th>
|
|
317
|
+
<th className="px-4 py-3 text-right">Ops</th>
|
|
318
|
+
</tr>
|
|
319
|
+
</thead>
|
|
320
|
+
<tbody className="divide-y divide-border/30 text-xs">
|
|
321
|
+
{queues.map((queue) => (
|
|
322
|
+
<tr key={queue.name} className="hover:bg-muted/5 transition-colors group">
|
|
323
|
+
<td className="px-4 py-4">
|
|
324
|
+
<div className="flex flex-col">
|
|
325
|
+
<span className="font-black text-foreground">{queue.name}</span>
|
|
326
|
+
{queue.failed > 0 && (
|
|
327
|
+
<span className="text-[9px] text-red-500 font-bold uppercase">
|
|
328
|
+
{queue.failed} FAILED
|
|
329
|
+
</span>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
</td>
|
|
333
|
+
<td className="px-4 py-4 font-mono font-black">{queue.waiting.toLocaleString()}</td>
|
|
334
|
+
<td className="px-4 py-4 text-right">
|
|
335
|
+
<div className="flex justify-end gap-2">
|
|
336
|
+
<button
|
|
337
|
+
type="button"
|
|
338
|
+
onClick={() => setSelectedQueue(queue.name)}
|
|
339
|
+
className="p-1.5 bg-muted hover:bg-primary/20 hover:text-primary rounded text-muted-foreground transition-all active:scale-90"
|
|
340
|
+
title="Inspect"
|
|
341
|
+
>
|
|
342
|
+
<ArrowRight size={14} />
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
</td>
|
|
346
|
+
</tr>
|
|
347
|
+
))}
|
|
348
|
+
</tbody>
|
|
349
|
+
</table>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function OverviewPage() {
|
|
356
|
+
const navigate = useNavigate()
|
|
357
|
+
const [selectedQueue, setSelectedQueue] = React.useState<string | null>(null)
|
|
358
|
+
const [hoveredWorkerId, setHoveredWorkerId] = React.useState<string | null>(null)
|
|
359
|
+
const queryClient = useQueryClient()
|
|
360
|
+
|
|
361
|
+
const [logs, setLogs] = React.useState<SystemLog[]>([])
|
|
362
|
+
const [stats, setStats] = React.useState<FluxStats>(DEFAULT_STATS)
|
|
363
|
+
const [isLogArchiveOpen, setIsLogArchiveOpen] = React.useState(false)
|
|
364
|
+
|
|
365
|
+
React.useEffect(() => {
|
|
366
|
+
const handler = (e: any) => setSelectedQueue(e.detail)
|
|
367
|
+
window.addEventListener('select-queue', handler)
|
|
368
|
+
return () => window.removeEventListener('select-queue', handler)
|
|
369
|
+
}, [])
|
|
370
|
+
|
|
371
|
+
// Initial fetch
|
|
372
|
+
React.useEffect(() => {
|
|
373
|
+
fetch('/api/queues')
|
|
374
|
+
.then((res) => res.json())
|
|
375
|
+
.then((data) => {
|
|
376
|
+
if (data.queues) {
|
|
377
|
+
setStats((prev) => ({ ...prev, queues: data.queues }))
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
fetch('/api/workers')
|
|
381
|
+
.then((res) => res.json())
|
|
382
|
+
.then((data) => {
|
|
383
|
+
if (data.workers) {
|
|
384
|
+
setStats((prev) => ({ ...prev, workers: data.workers }))
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
}, [])
|
|
388
|
+
|
|
389
|
+
// Stats update listener
|
|
390
|
+
React.useEffect(() => {
|
|
391
|
+
const handler = (e: any) => {
|
|
392
|
+
const newStats = e.detail
|
|
393
|
+
if (newStats) {
|
|
394
|
+
setStats((prev) => ({
|
|
395
|
+
queues: newStats.queues || prev.queues,
|
|
396
|
+
workers: newStats.workers || prev.workers,
|
|
397
|
+
}))
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
window.addEventListener('flux-stats-update', handler)
|
|
401
|
+
return () => window.removeEventListener('flux-stats-update', handler)
|
|
402
|
+
}, [])
|
|
403
|
+
|
|
404
|
+
// Live log listener
|
|
405
|
+
React.useEffect(() => {
|
|
406
|
+
const handler = (e: CustomEvent) => {
|
|
407
|
+
const data = e.detail
|
|
408
|
+
setLogs((prev) => [...prev.slice(-99), data])
|
|
409
|
+
}
|
|
410
|
+
window.addEventListener('flux-log-update', handler as EventListener)
|
|
411
|
+
return () => window.removeEventListener('flux-log-update', handler as EventListener)
|
|
412
|
+
}, [])
|
|
413
|
+
|
|
414
|
+
// Clear logs listener
|
|
415
|
+
React.useEffect(() => {
|
|
416
|
+
const handler = () => setLogs([])
|
|
417
|
+
window.addEventListener('clear-logs', handler)
|
|
418
|
+
return () => window.removeEventListener('clear-logs', handler)
|
|
419
|
+
}, [])
|
|
420
|
+
|
|
421
|
+
const { data: historyData } = useQuery<{ history: Record<string, number[]> }>({
|
|
422
|
+
queryKey: ['metrics-history'],
|
|
423
|
+
queryFn: () => fetch('/api/metrics/history').then((res) => res.json()),
|
|
424
|
+
refetchInterval: 30000,
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
const history = historyData?.history || {}
|
|
428
|
+
const { queues, workers } = stats
|
|
429
|
+
|
|
430
|
+
const totalWaiting = queues.reduce((acc, q) => acc + q.waiting, 0)
|
|
431
|
+
const totalDelayed = queues.reduce((acc, q) => acc + q.delayed, 0)
|
|
432
|
+
const totalFailed = queues.reduce((acc, q) => acc + q.failed, 0)
|
|
433
|
+
const activeWorkers = workers.length
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
<div className="space-y-12">
|
|
437
|
+
<LogArchiveModal isOpen={isLogArchiveOpen} onClose={() => setIsLogArchiveOpen(false)} />
|
|
438
|
+
<AnimatePresence>
|
|
439
|
+
{selectedQueue && (
|
|
440
|
+
<JobInspector queueName={selectedQueue} onClose={() => setSelectedQueue(null)} />
|
|
441
|
+
)}
|
|
442
|
+
</AnimatePresence>
|
|
443
|
+
|
|
444
|
+
<div className="flex justify-between items-end">
|
|
445
|
+
<div>
|
|
446
|
+
<h1 className="text-4xl font-black tracking-tighter">System Overview</h1>
|
|
447
|
+
<p className="text-muted-foreground mt-2 text-sm font-bold opacity-60 uppercase tracking-widest">
|
|
448
|
+
Real-time status of your processing pipelines.
|
|
449
|
+
</p>
|
|
450
|
+
</div>
|
|
451
|
+
<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">
|
|
452
|
+
<span className="w-2 h-2 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.6)]"></span>
|
|
453
|
+
Live Syncing
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
458
|
+
<MetricCard
|
|
459
|
+
title="Waiting Jobs"
|
|
460
|
+
value={totalWaiting}
|
|
461
|
+
icon={<Hourglass size={20} />}
|
|
462
|
+
color="text-amber-500"
|
|
463
|
+
trend="+12% / hr"
|
|
464
|
+
data={history.waiting}
|
|
465
|
+
/>
|
|
466
|
+
<MetricCard
|
|
467
|
+
title="Delayed Jobs"
|
|
468
|
+
value={totalDelayed}
|
|
469
|
+
icon={<Clock size={20} />}
|
|
470
|
+
color="text-blue-500"
|
|
471
|
+
trend="Stable"
|
|
472
|
+
data={history.delayed}
|
|
473
|
+
/>
|
|
474
|
+
<MetricCard
|
|
475
|
+
title="Failed Jobs"
|
|
476
|
+
value={totalFailed}
|
|
477
|
+
icon={<AlertCircle size={20} />}
|
|
478
|
+
color="text-red-500"
|
|
479
|
+
trend={totalFailed > 0 ? 'CRITICAL' : 'CLEAN'}
|
|
480
|
+
data={history.failed}
|
|
481
|
+
/>
|
|
482
|
+
<MetricCard
|
|
483
|
+
title="Active Workers"
|
|
484
|
+
value={activeWorkers}
|
|
485
|
+
icon={<Cpu size={20} />}
|
|
486
|
+
color="text-indigo-500"
|
|
487
|
+
trend={activeWorkers > 0 ? 'ONLINE' : 'IDLE'}
|
|
488
|
+
data={history.workers}
|
|
489
|
+
/>
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
<ThroughputChart />
|
|
493
|
+
|
|
494
|
+
<QueueHeatmap queues={queues} />
|
|
495
|
+
|
|
496
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 h-[600px]">
|
|
497
|
+
<div className="lg:col-span-1 h-full">
|
|
498
|
+
<WorkerStatus highlightedWorkerId={hoveredWorkerId} workers={workers} />
|
|
499
|
+
</div>
|
|
500
|
+
<div className="lg:col-span-2 grid grid-rows-2 gap-6 h-full">
|
|
501
|
+
<LiveLogs
|
|
502
|
+
logs={logs}
|
|
503
|
+
onSearchArchive={() => setIsLogArchiveOpen(true)}
|
|
504
|
+
onWorkerHover={setHoveredWorkerId}
|
|
505
|
+
/>
|
|
506
|
+
<QueueList queues={queues} setSelectedQueue={setSelectedQueue} />
|
|
507
|
+
</div>
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
)
|
|
511
|
+
}
|