@gravito/zenith 1.0.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 (35) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/bin.js +436 -43
  3. package/dist/client/assets/index-C332gZ-J.css +1 -0
  4. package/dist/client/assets/{index-oXEse8ih.js → index-D4HibwTK.js} +88 -88
  5. package/dist/client/index.html +2 -2
  6. package/dist/server/index.js +436 -43
  7. package/docs/LARAVEL_ZENITH_ROADMAP.md +109 -0
  8. package/{QUASAR_MASTER_PLAN.md → docs/QUASAR_MASTER_PLAN.md} +6 -3
  9. package/package.json +1 -1
  10. package/scripts/debug_redis_keys.ts +24 -0
  11. package/src/client/App.tsx +1 -1
  12. package/src/client/Layout.tsx +11 -12
  13. package/src/client/WorkerStatus.tsx +97 -56
  14. package/src/client/components/BrandIcons.tsx +119 -44
  15. package/src/client/components/ConfirmDialog.tsx +0 -1
  16. package/src/client/components/JobInspector.tsx +18 -6
  17. package/src/client/components/PageHeader.tsx +32 -28
  18. package/src/client/pages/OverviewPage.tsx +0 -1
  19. package/src/client/pages/PulsePage.tsx +422 -340
  20. package/src/client/pages/SettingsPage.tsx +69 -15
  21. package/src/client/pages/WorkersPage.tsx +70 -2
  22. package/src/server/index.ts +171 -11
  23. package/src/server/services/QueueService.ts +6 -3
  24. package/src/shared/types.ts +2 -0
  25. package/ARCHITECTURE.md +0 -88
  26. package/BATCH_OPERATIONS_IMPLEMENTATION.md +0 -159
  27. package/EVOLUTION_BLUEPRINT.md +0 -112
  28. package/JOBINSPECTOR_SCROLL_FIX.md +0 -152
  29. package/TESTING_BATCH_OPERATIONS.md +0 -252
  30. package/dist/client/assets/index-BSTyMCFd.css +0 -1
  31. /package/{ALERTING_GUIDE.md → docs/ALERTING_GUIDE.md} +0 -0
  32. /package/{DEPLOYMENT.md → docs/DEPLOYMENT.md} +0 -0
  33. /package/{DOCS_INTERNAL.md → docs/DOCS_INTERNAL.md} +0 -0
  34. /package/{QUICK_TEST_GUIDE.md → docs/QUICK_TEST_GUIDE.md} +0 -0
  35. /package/{ROADMAP.md → docs/ROADMAP.md} +0 -0
@@ -1,396 +1,478 @@
1
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
2
  import { motion } from 'framer-motion'
5
- import { PulseNode } from '../../shared/types'
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'
6
7
  import { PageHeader } from '../components/PageHeader'
7
8
  import { cn } from '../utils'
8
- import { BunIcon, DenoIcon, GoIcon, NodeIcon, PhpIcon, PythonIcon } from '../components/BrandIcons'
9
9
 
10
10
  // Helper to format bytes
11
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]
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]}`
17
19
  }
18
20
 
19
21
  // Helper to send remote commands to Quasar agents
20
22
  const sendCommand = async (
21
- service: string,
22
- nodeId: string,
23
- type: 'RETRY_JOB' | 'DELETE_JOB' | 'LARAVEL_ACTION',
24
- queue: string,
25
- action?: string
23
+ service: string,
24
+ nodeId: string,
25
+ type: 'RETRY_JOB' | 'DELETE_JOB' | 'LARAVEL_ACTION',
26
+ queue: string,
27
+ action?: string
26
28
  ) => {
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
- })
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
+ })
43
45
 
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)
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)
52
51
  }
52
+ } catch (err) {
53
+ console.error('[Pulse] Failed to send command:', err)
54
+ }
53
55
  }
54
56
 
55
57
  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
+ const isHealthy = Date.now() - node.timestamp < 30000 // 30s threshold
59
+ const isWarning = !isHealthy && Date.now() - node.timestamp < 60000 // 60s warning
58
60
 
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
- }
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" />
69
77
  }
78
+ }
70
79
 
71
- const laravel = node.meta?.laravel
80
+ const laravel = node.meta?.laravel
72
81
 
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
- )} />
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
+ />
84
99
 
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
- )} />
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>
104
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
+ )}
105
178
 
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">
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">
118
206
  <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"
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"
125
226
  >
126
- <RotateCw size={10} /> Retry All
227
+ <RotateCw size={12} />
127
228
  </button>
128
229
  <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"
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"
135
236
  >
136
- <RotateCw size={10} /> Restart Workers
237
+ <Trash2 size={12} />
137
238
  </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>
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'
143
248
  )}
249
+ >
250
+ {q.size.waiting} wait
251
+ </span>
144
252
  </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>
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
+ />
239
275
  </div>
276
+ )}
240
277
  </div>
278
+ ))}
279
+ </div>
280
+ </div>
281
+ )}
241
282
 
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>
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
277
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>
278
316
 
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>
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
286
322
  </div>
287
- </motion.div>
288
- )
289
- }
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>
290
330
 
291
- // Compact Service Group Component
292
- function ServiceGroup({ service, nodes }: { service: string, nodes: PulseNode[] }) {
293
- const isSingle = nodes.length === 1
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>
294
348
 
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>
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>
303
353
  </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
- ))}
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>
312
357
  </div>
358
+ </div>
313
359
  </div>
314
- )
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
+ )
315
370
  }
316
371
 
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
- })
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>
326
387
 
327
- const [nodes, setNodes] = useState<Record<string, PulseNode[]>>({})
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
+ }
328
396
 
329
- // Hydrate initial data
330
- useEffect(() => {
331
- if (initialData?.nodes) {
332
- setNodes(initialData.nodes)
333
- }
334
- }, [initialData])
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
+ })
335
406
 
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
- }, [])
407
+ const [nodes, setNodes] = useState<Record<string, PulseNode[]>>({})
347
408
 
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
- )
409
+ // Hydrate initial data
410
+ useEffect(() => {
411
+ if (initialData?.nodes) {
412
+ setNodes(initialData.nodes)
358
413
  }
414
+ }, [initialData])
359
415
 
360
- const services = Object.entries(nodes).sort(([a], [b]) => a.localeCompare(b))
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
+ }, [])
361
427
 
428
+ // Loading Skeleton
429
+ if (isLoading && Object.keys(nodes).length === 0) {
362
430
  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>
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
+ ))}
394
437
  </div>
438
+ </div>
395
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
+ )
396
478
  }