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