@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.
Files changed (63) hide show
  1. package/ARCHITECTURE.md +88 -0
  2. package/BATCH_OPERATIONS_IMPLEMENTATION.md +159 -0
  3. package/DEMO.md +156 -0
  4. package/DEPLOYMENT.md +157 -0
  5. package/DOCS_INTERNAL.md +73 -0
  6. package/Dockerfile +46 -0
  7. package/Dockerfile.demo-worker +29 -0
  8. package/EVOLUTION_BLUEPRINT.md +112 -0
  9. package/JOBINSPECTOR_SCROLL_FIX.md +152 -0
  10. package/PULSE_IMPLEMENTATION_PLAN.md +111 -0
  11. package/QUICK_TEST_GUIDE.md +72 -0
  12. package/README.md +33 -0
  13. package/ROADMAP.md +85 -0
  14. package/TESTING_BATCH_OPERATIONS.md +252 -0
  15. package/bin/flux-console.ts +2 -0
  16. package/dist/bin.js +108196 -0
  17. package/dist/client/assets/index-DGYEwTDL.css +1 -0
  18. package/dist/client/assets/index-oyTdySX0.js +421 -0
  19. package/dist/client/index.html +13 -0
  20. package/dist/server/index.js +108191 -0
  21. package/docker-compose.yml +40 -0
  22. package/docs/integrations/LARAVEL.md +207 -0
  23. package/package.json +50 -0
  24. package/postcss.config.js +6 -0
  25. package/scripts/flood-logs.ts +21 -0
  26. package/scripts/seed.ts +213 -0
  27. package/scripts/verify-throttle.ts +45 -0
  28. package/scripts/worker.ts +123 -0
  29. package/src/bin.ts +6 -0
  30. package/src/client/App.tsx +70 -0
  31. package/src/client/Layout.tsx +644 -0
  32. package/src/client/Sidebar.tsx +102 -0
  33. package/src/client/ThroughputChart.tsx +135 -0
  34. package/src/client/WorkerStatus.tsx +170 -0
  35. package/src/client/components/ConfirmDialog.tsx +103 -0
  36. package/src/client/components/JobInspector.tsx +524 -0
  37. package/src/client/components/LogArchiveModal.tsx +383 -0
  38. package/src/client/components/NotificationBell.tsx +203 -0
  39. package/src/client/components/Toaster.tsx +80 -0
  40. package/src/client/components/UserProfileDropdown.tsx +177 -0
  41. package/src/client/contexts/AuthContext.tsx +93 -0
  42. package/src/client/contexts/NotificationContext.tsx +103 -0
  43. package/src/client/index.css +174 -0
  44. package/src/client/index.html +12 -0
  45. package/src/client/main.tsx +15 -0
  46. package/src/client/pages/LoginPage.tsx +153 -0
  47. package/src/client/pages/MetricsPage.tsx +408 -0
  48. package/src/client/pages/OverviewPage.tsx +511 -0
  49. package/src/client/pages/QueuesPage.tsx +372 -0
  50. package/src/client/pages/SchedulesPage.tsx +531 -0
  51. package/src/client/pages/SettingsPage.tsx +449 -0
  52. package/src/client/pages/WorkersPage.tsx +316 -0
  53. package/src/client/pages/index.ts +7 -0
  54. package/src/client/utils.ts +6 -0
  55. package/src/server/index.ts +556 -0
  56. package/src/server/middleware/auth.ts +127 -0
  57. package/src/server/services/AlertService.ts +160 -0
  58. package/src/server/services/QueueService.ts +828 -0
  59. package/tailwind.config.js +73 -0
  60. package/tests/placeholder.test.ts +7 -0
  61. package/tsconfig.json +38 -0
  62. package/tsconfig.node.json +12 -0
  63. 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
+ }