@gravito/zenith 0.1.0-beta.1 → 1.0.0

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 (45) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/bin.js +38846 -27303
  3. package/dist/client/assets/index-C332gZ-J.css +1 -0
  4. package/dist/client/assets/index-D4HibwTK.js +436 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/server/index.js +38846 -27303
  7. package/docs/ALERTING_GUIDE.md +71 -0
  8. package/docs/LARAVEL_ZENITH_ROADMAP.md +109 -0
  9. package/docs/QUASAR_MASTER_PLAN.md +140 -0
  10. package/package.json +52 -48
  11. package/scripts/debug_redis_keys.ts +24 -0
  12. package/specs/PULSE_SPEC.md +86 -0
  13. package/src/client/App.tsx +2 -0
  14. package/src/client/Layout.tsx +18 -0
  15. package/src/client/Sidebar.tsx +2 -1
  16. package/src/client/WorkerStatus.tsx +121 -76
  17. package/src/client/components/BrandIcons.tsx +138 -0
  18. package/src/client/components/ConfirmDialog.tsx +0 -1
  19. package/src/client/components/JobInspector.tsx +18 -6
  20. package/src/client/components/PageHeader.tsx +38 -0
  21. package/src/client/pages/OverviewPage.tsx +17 -20
  22. package/src/client/pages/PulsePage.tsx +478 -0
  23. package/src/client/pages/QueuesPage.tsx +1 -3
  24. package/src/client/pages/SettingsPage.tsx +640 -78
  25. package/src/client/pages/WorkersPage.tsx +71 -3
  26. package/src/client/pages/index.ts +1 -0
  27. package/src/server/index.ts +311 -11
  28. package/src/server/services/AlertService.ts +189 -41
  29. package/src/server/services/CommandService.ts +137 -0
  30. package/src/server/services/PulseService.ts +80 -0
  31. package/src/server/services/QueueService.ts +63 -6
  32. package/src/shared/types.ts +99 -0
  33. package/tsconfig.json +2 -2
  34. package/ARCHITECTURE.md +0 -88
  35. package/BATCH_OPERATIONS_IMPLEMENTATION.md +0 -159
  36. package/EVOLUTION_BLUEPRINT.md +0 -112
  37. package/JOBINSPECTOR_SCROLL_FIX.md +0 -152
  38. package/PULSE_IMPLEMENTATION_PLAN.md +0 -111
  39. package/TESTING_BATCH_OPERATIONS.md +0 -252
  40. package/dist/client/assets/index-DGYEwTDL.css +0 -1
  41. package/dist/client/assets/index-oyTdySX0.js +0 -421
  42. /package/{DEPLOYMENT.md → docs/DEPLOYMENT.md} +0 -0
  43. /package/{DOCS_INTERNAL.md → docs/DOCS_INTERNAL.md} +0 -0
  44. /package/{QUICK_TEST_GUIDE.md → docs/QUICK_TEST_GUIDE.md} +0 -0
  45. /package/{ROADMAP.md → docs/ROADMAP.md} +0 -0
@@ -0,0 +1,478 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { motion } from 'framer-motion'
3
+ import { Activity, Cpu, Database, HelpCircle, Laptop, RotateCw, Server, Trash2 } from 'lucide-react'
4
+ import { useEffect, useState } from 'react'
5
+ import type { PulseNode } from '../../shared/types'
6
+ import { BunIcon, DenoIcon, GoIcon, NodeIcon, PhpIcon, PythonIcon } from '../components/BrandIcons'
7
+ import { PageHeader } from '../components/PageHeader'
8
+ import { cn } from '../utils'
9
+
10
+ // Helper to format bytes
11
+ const formatBytes = (bytes: number) => {
12
+ if (bytes === 0) {
13
+ return '0 B'
14
+ }
15
+ const k = 1024
16
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
17
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
18
+ return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
19
+ }
20
+
21
+ // Helper to send remote commands to Quasar agents
22
+ const sendCommand = async (
23
+ service: string,
24
+ nodeId: string,
25
+ type: 'RETRY_JOB' | 'DELETE_JOB' | 'LARAVEL_ACTION',
26
+ queue: string,
27
+ action?: string
28
+ ) => {
29
+ try {
30
+ const response = await fetch('/api/pulse/command', {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/json' },
33
+ body: JSON.stringify({
34
+ service,
35
+ nodeId,
36
+ type,
37
+ queue,
38
+ action,
39
+ // For now, we send a wildcard jobKey to indicate "all failed jobs"
40
+ // The agent will interpret this appropriately
41
+ jobKey: '*',
42
+ driver: 'redis', // Default to redis, could be detected from queue config
43
+ }),
44
+ })
45
+
46
+ const result = await response.json()
47
+ if (result.success) {
48
+ console.log(`[Pulse] ${type} command sent:`, result.message)
49
+ } else {
50
+ console.error(`[Pulse] Command failed:`, result.error)
51
+ }
52
+ } catch (err) {
53
+ console.error('[Pulse] Failed to send command:', err)
54
+ }
55
+ }
56
+
57
+ function NodeCard({ node }: { node: PulseNode }) {
58
+ const isHealthy = Date.now() - node.timestamp < 30000 // 30s threshold
59
+ const isWarning = !isHealthy && Date.now() - node.timestamp < 60000 // 60s warning
60
+
61
+ const renderIcon = () => {
62
+ switch (node.language) {
63
+ case 'node':
64
+ return <NodeIcon className="w-6 h-6" />
65
+ case 'bun':
66
+ return <BunIcon className="w-6 h-6 text-black" />
67
+ case 'deno':
68
+ return <DenoIcon className="w-6 h-6" />
69
+ case 'php':
70
+ return <PhpIcon className="w-6 h-6" />
71
+ case 'go':
72
+ return <GoIcon className="w-6 h-6" />
73
+ case 'python':
74
+ return <PythonIcon className="w-6 h-6" />
75
+ default:
76
+ return <HelpCircle className="w-6 h-6 text-white" />
77
+ }
78
+ }
79
+
80
+ const laravel = node.meta?.laravel
81
+
82
+ return (
83
+ <motion.div
84
+ initial={{ opacity: 0, scale: 0.95 }}
85
+ animate={{ opacity: 1, scale: 1 }}
86
+ className="bg-card border border-border/50 rounded-xl p-4 shadow-sm relative overflow-hidden group"
87
+ >
88
+ {/* Background Pulse Effect */}
89
+ <div
90
+ className={cn(
91
+ 'absolute top-0 right-0 w-24 h-24 bg-gradient-to-bl opacity-10 rounded-bl-full transition-all duration-500',
92
+ isHealthy
93
+ ? 'from-emerald-500 to-transparent'
94
+ : isWarning
95
+ ? 'from-yellow-500 to-transparent'
96
+ : 'from-red-500 to-transparent'
97
+ )}
98
+ />
99
+
100
+ <div className="flex items-start justify-between mb-4 relative z-10">
101
+ <div className="flex items-center gap-3">
102
+ <div className="w-10 h-10 rounded-lg flex items-center justify-center bg-white dark:bg-card border border-border/20 shadow-sm shrink-0">
103
+ {renderIcon()}
104
+ </div>
105
+ <div>
106
+ <h3 className="font-bold text-foreground text-sm flex items-center gap-2">
107
+ {node.id}
108
+ <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground uppercase tracking-wider">
109
+ {node.platform}
110
+ </span>
111
+ </h3>
112
+ <div className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
113
+ <Laptop size={10} /> {node.hostname} <span className="opacity-30">|</span> PID:{' '}
114
+ {node.pid}
115
+ </div>
116
+ </div>
117
+ </div>
118
+ <div
119
+ className={cn(
120
+ 'w-2.5 h-2.5 rounded-full shadow-[0_0_10px_currentColor]',
121
+ isHealthy
122
+ ? 'bg-emerald-500 text-emerald-500'
123
+ : isWarning
124
+ ? 'bg-yellow-500 text-yellow-500'
125
+ : 'bg-red-500 text-red-500'
126
+ )}
127
+ />
128
+ </div>
129
+
130
+ {/* Metrics Grid - Vertical Stack */}
131
+ <div className="space-y-3">
132
+ {/* Laravel Specific Tools (if detected) */}
133
+ {laravel && laravel.workerCount > 0 && (
134
+ <div className="bg-amber-500/5 rounded-lg p-2.5 border border-amber-500/20">
135
+ <div className="flex items-center justify-between text-xs mb-2">
136
+ <div className="flex items-center gap-2 font-bold text-amber-600 dark:text-amber-400">
137
+ <PhpIcon className="w-3 h-3" />
138
+ Laravel Workers ({laravel.workerCount})
139
+ </div>
140
+ </div>
141
+ <div className="flex flex-wrap gap-2">
142
+ <button
143
+ type="button"
144
+ onClick={() => {
145
+ if (
146
+ confirm('Are you sure you want to retry ALL failed Laravel jobs on this host?')
147
+ ) {
148
+ sendCommand(node.service, node.id, 'LARAVEL_ACTION', 'default', 'retry-all')
149
+ }
150
+ }}
151
+ className="bg-amber-500/10 hover:bg-amber-500/20 text-amber-600 dark:text-amber-400 px-2 py-1 rounded text-[10px] font-black uppercase flex items-center gap-1 transition-all border border-amber-500/10"
152
+ >
153
+ <RotateCw size={10} /> Retry All
154
+ </button>
155
+ <button
156
+ type="button"
157
+ onClick={() => {
158
+ if (
159
+ confirm(
160
+ 'Artisan queue:restart will signal all workers to quit. Supervisor will restart them. Proceed?'
161
+ )
162
+ ) {
163
+ sendCommand(node.service, node.id, 'LARAVEL_ACTION', 'default', 'restart')
164
+ }
165
+ }}
166
+ className="bg-amber-500/10 hover:bg-amber-500/20 text-amber-600 dark:text-amber-400 px-2 py-1 rounded text-[10px] font-black uppercase flex items-center gap-1 transition-all border border-amber-500/10"
167
+ >
168
+ <RotateCw size={10} /> Restart Workers
169
+ </button>
170
+ </div>
171
+ {laravel.roots?.length > 0 && (
172
+ <div className="mt-2 text-[9px] text-muted-foreground font-mono opacity-60 truncate">
173
+ Root: {laravel.roots[0]}
174
+ </div>
175
+ )}
176
+ </div>
177
+ )}
178
+
179
+ {/* Queues Section (if present) */}
180
+ {node.queues && node.queues.length > 0 && (
181
+ <div className="bg-muted/30 rounded-lg p-2.5 border border-border/50">
182
+ <div className="flex items-center justify-between text-xs text-muted-foreground mb-2">
183
+ <div className="flex items-center gap-2 font-bold text-foreground">
184
+ <span
185
+ className={cn(
186
+ 'w-1.5 h-1.5 rounded-full',
187
+ node.queues.some((q) => q.size.failed > 0)
188
+ ? 'bg-red-500 animate-pulse'
189
+ : 'bg-emerald-500'
190
+ )}
191
+ />
192
+ Queues ({node.queues.length})
193
+ </div>
194
+ </div>
195
+ <div className="space-y-2">
196
+ {node.queues.map((q) => (
197
+ <div key={q.name} className="flex flex-col gap-1 text-xs">
198
+ <div className="flex justify-between items-center">
199
+ <span className="font-mono text-muted-foreground">{q.name}</span>
200
+ <div className="flex gap-2 font-mono items-center">
201
+ {q.size.failed > 0 && (
202
+ <>
203
+ <span className="text-red-500 font-bold">{q.size.failed} fail</span>
204
+ {/* Action Buttons for Failed Jobs */}
205
+ <div className="flex gap-1 ml-1">
206
+ <button
207
+ type="button"
208
+ onClick={async () => {
209
+ if (q.driver === 'redis' && node.language === 'php') {
210
+ // For PHP + Redis, provide option to use artisan
211
+ if (confirm('Use php artisan queue:retry for precision?')) {
212
+ await sendCommand(
213
+ node.service,
214
+ node.id,
215
+ 'LARAVEL_ACTION',
216
+ q.name,
217
+ 'retry-all'
218
+ )
219
+ return
220
+ }
221
+ }
222
+ sendCommand(node.service, node.id, 'RETRY_JOB', q.name)
223
+ }}
224
+ className="p-1 rounded hover:bg-emerald-500/20 text-emerald-500 transition-colors"
225
+ title="Retry all failed jobs"
226
+ >
227
+ <RotateCw size={12} />
228
+ </button>
229
+ <button
230
+ type="button"
231
+ onClick={() =>
232
+ sendCommand(node.service, node.id, 'DELETE_JOB', q.name)
233
+ }
234
+ className="p-1 rounded hover:bg-red-500/20 text-red-400 transition-colors"
235
+ title="Delete all failed jobs"
236
+ >
237
+ <Trash2 size={12} />
238
+ </button>
239
+ </div>
240
+ </>
241
+ )}
242
+ {q.size.active > 0 && (
243
+ <span className="text-emerald-500">{q.size.active} act</span>
244
+ )}
245
+ <span
246
+ className={cn(
247
+ q.size.waiting > 100 ? 'text-yellow-500' : 'text-muted-foreground'
248
+ )}
249
+ >
250
+ {q.size.waiting} wait
251
+ </span>
252
+ </div>
253
+ </div>
254
+ {/* Mini Progress bar for Queue Health (Failed vs Total) */}
255
+ {q.size.waiting + q.size.active + q.size.failed > 0 && (
256
+ <div className="h-1 w-full bg-muted rounded-full overflow-hidden flex">
257
+ <div
258
+ className="bg-red-500 h-full transition-all"
259
+ style={{
260
+ width: `${(q.size.failed / (q.size.waiting + q.size.active + q.size.failed)) * 100}%`,
261
+ }}
262
+ />
263
+ <div
264
+ className="bg-yellow-500 h-full transition-all"
265
+ style={{
266
+ width: `${(q.size.waiting / (q.size.waiting + q.size.active + q.size.failed)) * 100}%`,
267
+ }}
268
+ />
269
+ <div
270
+ className="bg-emerald-500 h-full transition-all"
271
+ style={{
272
+ width: `${(q.size.active / (q.size.waiting + q.size.active + q.size.failed)) * 100}%`,
273
+ }}
274
+ />
275
+ </div>
276
+ )}
277
+ </div>
278
+ ))}
279
+ </div>
280
+ </div>
281
+ )}
282
+
283
+ {/* CPU */}
284
+ <div className="bg-muted/30 rounded-lg p-2.5 border border-border/50">
285
+ <div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
286
+ <div className="flex items-center gap-2">
287
+ <Cpu size={12} /> CPU Usage
288
+ </div>
289
+ <span className="text-[10px]">{node.cpu.cores} cores</span>
290
+ </div>
291
+ <div className="flex items-baseline gap-1">
292
+ <span className="text-xl font-bold text-foreground">{node.cpu.process}%</span>
293
+ <span className="text-xs text-muted-foreground ml-1">proc</span>
294
+ </div>
295
+ {/* Mini Bar */}
296
+ <div className="h-2 w-full bg-muted rounded-full mt-2 overflow-hidden relative">
297
+ {/* Process Usage */}
298
+ <div
299
+ className={cn(
300
+ 'h-full rounded-full transition-all duration-500 absolute top-0 left-0 z-20 shadow-sm',
301
+ node.cpu.process > 80 ? 'bg-red-500' : 'bg-primary'
302
+ )}
303
+ style={{ width: `${Math.min(node.cpu.process, 100)}%` }}
304
+ />
305
+ {/* System Load Background (Darker) */}
306
+ <div
307
+ className="h-full rounded-full transition-all duration-500 absolute top-0 left-0 bg-muted-foreground/30 z-10"
308
+ style={{ width: `${Math.min(node.cpu.system, 100)}%` }}
309
+ />
310
+ </div>
311
+ <div className="flex justify-between text-[9px] text-muted-foreground mt-1 font-mono">
312
+ <span className="text-primary font-bold">Proc: {node.cpu.process}%</span>
313
+ <span>Sys: {node.cpu.system}%</span>
314
+ </div>
315
+ </div>
316
+
317
+ {/* Memory */}
318
+ <div className="bg-muted/30 rounded-lg p-2.5 border border-border/50">
319
+ <div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
320
+ <div className="flex items-center gap-2">
321
+ <Database size={12} /> RAM Usage
322
+ </div>
323
+ </div>
324
+ <div className="flex items-baseline gap-1">
325
+ <span className="text-xl font-bold text-foreground">
326
+ {formatBytes(node.memory.process.rss)}
327
+ </span>
328
+ <span className="text-xs text-muted-foreground ml-1">RSS</span>
329
+ </div>
330
+
331
+ {/* RAM Bar */}
332
+ <div className="h-2 w-full bg-muted rounded-full mt-2 overflow-hidden relative">
333
+ {/* Process Usage */}
334
+ <div
335
+ className="h-full rounded-full transition-all duration-500 absolute top-0 left-0 z-20 shadow-sm bg-indigo-500"
336
+ style={{
337
+ width: `${Math.min((node.memory.process.rss / node.memory.system.total) * 100, 100)}%`,
338
+ }}
339
+ />
340
+ {/* System Usage */}
341
+ <div
342
+ className="h-full rounded-full transition-all duration-500 absolute top-0 left-0 bg-muted-foreground/30 z-10"
343
+ style={{
344
+ width: `${Math.min((node.memory.system.used / node.memory.system.total) * 100, 100)}%`,
345
+ }}
346
+ />
347
+ </div>
348
+
349
+ <div className="grid grid-cols-2 gap-2 text-[10px] text-muted-foreground mt-3 border-t border-border/30 pt-2 font-mono">
350
+ <div className="flex flex-col">
351
+ <span className="opacity-70">Heap</span>
352
+ <span className="font-bold">{formatBytes(node.memory.process.heapUsed)}</span>
353
+ </div>
354
+ <div className="flex flex-col text-right">
355
+ <span className="opacity-70">Sys Free</span>
356
+ <span className="">{formatBytes(node.memory.system.free)}</span>
357
+ </div>
358
+ </div>
359
+ </div>
360
+ </div>
361
+
362
+ <div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-between text-[10px] text-muted-foreground">
363
+ <span className="flex items-center gap-1.5">
364
+ <Server size={10} /> {node.runtime.framework}
365
+ </span>
366
+ <span>Ups: {Math.floor(node.runtime.uptime / 60)}m</span>
367
+ </div>
368
+ </motion.div>
369
+ )
370
+ }
371
+
372
+ // Compact Service Group Component
373
+ function ServiceGroup({ service, nodes }: { service: string; nodes: PulseNode[] }) {
374
+ const isSingle = nodes.length === 1
375
+
376
+ return (
377
+ <div className="bg-card/50 border border-border/40 rounded-xl p-4 flex flex-col h-full">
378
+ <div className="flex items-center gap-2 mb-4 pb-3 border-b border-border/40">
379
+ <div className="w-2 h-2 rounded-full bg-primary" />
380
+ <h2 className="text-sm font-bold uppercase tracking-widest text-muted-foreground flex-1">
381
+ {service}
382
+ </h2>
383
+ <span className="bg-muted text-foreground px-2 py-0.5 rounded-md text-xs font-mono">
384
+ {nodes.length}
385
+ </span>
386
+ </div>
387
+
388
+ <div className={cn('grid gap-3', isSingle ? 'grid-cols-1' : 'grid-cols-1 xl:grid-cols-2')}>
389
+ {nodes.map((node) => (
390
+ <NodeCard key={node.id} node={node} />
391
+ ))}
392
+ </div>
393
+ </div>
394
+ )
395
+ }
396
+
397
+ export function PulsePage() {
398
+ const { data: initialData, isLoading } = useQuery<{ nodes: Record<string, PulseNode[]> }>({
399
+ queryKey: ['pulse-nodes'],
400
+ queryFn: async () => {
401
+ const res = await fetch('/api/pulse/nodes')
402
+ return res.json()
403
+ },
404
+ // Remove polling
405
+ })
406
+
407
+ const [nodes, setNodes] = useState<Record<string, PulseNode[]>>({})
408
+
409
+ // Hydrate initial data
410
+ useEffect(() => {
411
+ if (initialData?.nodes) {
412
+ setNodes(initialData.nodes)
413
+ }
414
+ }, [initialData])
415
+
416
+ // Listen for SSE updates
417
+ useEffect(() => {
418
+ const handler = (e: Event) => {
419
+ const customEvent = e as CustomEvent
420
+ if (customEvent.detail?.nodes) {
421
+ setNodes(customEvent.detail.nodes)
422
+ }
423
+ }
424
+ window.addEventListener('flux-pulse-update', handler)
425
+ return () => window.removeEventListener('flux-pulse-update', handler)
426
+ }, [])
427
+
428
+ // Loading Skeleton
429
+ if (isLoading && Object.keys(nodes).length === 0) {
430
+ return (
431
+ <div className="p-6 md:p-10 max-w-7xl mx-auto space-y-8 animate-pulse">
432
+ <div className="h-8 w-48 bg-muted rounded-lg" />
433
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
434
+ {[1, 2, 3].map((i) => (
435
+ <div key={i} className="h-48 bg-muted rounded-xl" />
436
+ ))}
437
+ </div>
438
+ </div>
439
+ )
440
+ }
441
+
442
+ const services = Object.entries(nodes).sort(([a], [b]) => a.localeCompare(b))
443
+
444
+ return (
445
+ <div className="min-h-screen bg-background text-foreground pb-20">
446
+ <div className="p-6 md:p-10 max-w-7xl mx-auto space-y-8">
447
+ <PageHeader
448
+ icon={Activity}
449
+ title="System Pulse"
450
+ description="Real-time infrastructure monitoring across your entire stack."
451
+ >
452
+ <div className="flex items-center gap-2 text-xs font-mono text-muted-foreground bg-muted px-3 py-1.5 rounded-lg border border-border/50">
453
+ <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
454
+ LIVE CONNECTION
455
+ </div>
456
+ </PageHeader>
457
+
458
+ {services.length === 0 ? (
459
+ <div className="flex flex-col items-center justify-center py-20 text-center border-2 border-dashed border-border rounded-xl">
460
+ <div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
461
+ <Activity className="text-muted-foreground" size={32} />
462
+ </div>
463
+ <h3 className="text-lg font-bold">No Pulse Signals Detected</h3>
464
+ <p className="text-muted-foreground max-w-sm mt-2">
465
+ Start a worker with the pulse agent enabled or check your Redis connection.
466
+ </p>
467
+ </div>
468
+ ) : (
469
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
470
+ {services.map(([service, nodes]) => (
471
+ <ServiceGroup key={service} service={service} nodes={nodes} />
472
+ ))}
473
+ </div>
474
+ )}
475
+ </div>
476
+ </div>
477
+ )
478
+ }
@@ -1,11 +1,9 @@
1
1
  import { useQuery, useQueryClient } from '@tanstack/react-query'
2
- import { AnimatePresence, motion } from 'framer-motion'
2
+ import { AnimatePresence } from 'framer-motion'
3
3
  import {
4
4
  Activity,
5
5
  AlertCircle,
6
6
  ArrowRight,
7
- CheckCircle2,
8
- Clock,
9
7
  Filter,
10
8
  ListTree,
11
9
  Pause,