@gravito/zenith 1.0.0-beta.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/bin.js +436 -43
- package/dist/client/assets/index-C332gZ-J.css +1 -0
- package/dist/client/assets/{index-oXEse8ih.js → index-D4HibwTK.js} +88 -88
- package/dist/client/index.html +2 -2
- package/dist/server/index.js +436 -43
- package/docs/LARAVEL_ZENITH_ROADMAP.md +109 -0
- package/{QUASAR_MASTER_PLAN.md → docs/QUASAR_MASTER_PLAN.md} +6 -3
- package/package.json +1 -1
- package/scripts/debug_redis_keys.ts +24 -0
- package/src/client/App.tsx +1 -1
- package/src/client/Layout.tsx +11 -12
- package/src/client/WorkerStatus.tsx +97 -56
- package/src/client/components/BrandIcons.tsx +119 -44
- package/src/client/components/ConfirmDialog.tsx +0 -1
- package/src/client/components/JobInspector.tsx +18 -6
- package/src/client/components/PageHeader.tsx +32 -28
- package/src/client/pages/OverviewPage.tsx +0 -1
- package/src/client/pages/PulsePage.tsx +422 -340
- package/src/client/pages/SettingsPage.tsx +69 -15
- package/src/client/pages/WorkersPage.tsx +70 -2
- package/src/server/index.ts +171 -11
- package/src/server/services/QueueService.ts +6 -3
- package/src/shared/types.ts +2 -0
- package/ARCHITECTURE.md +0 -88
- package/BATCH_OPERATIONS_IMPLEMENTATION.md +0 -159
- package/EVOLUTION_BLUEPRINT.md +0 -112
- package/JOBINSPECTOR_SCROLL_FIX.md +0 -152
- package/TESTING_BATCH_OPERATIONS.md +0 -252
- package/dist/client/assets/index-BSTyMCFd.css +0 -1
- /package/{ALERTING_GUIDE.md → docs/ALERTING_GUIDE.md} +0 -0
- /package/{DEPLOYMENT.md → docs/DEPLOYMENT.md} +0 -0
- /package/{DOCS_INTERNAL.md → docs/DOCS_INTERNAL.md} +0 -0
- /package/{QUICK_TEST_GUIDE.md → docs/QUICK_TEST_GUIDE.md} +0 -0
- /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 {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
service: string,
|
|
24
|
+
nodeId: string,
|
|
25
|
+
type: 'RETRY_JOB' | 'DELETE_JOB' | 'LARAVEL_ACTION',
|
|
26
|
+
queue: string,
|
|
27
|
+
action?: string
|
|
26
28
|
) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
+
const isHealthy = Date.now() - node.timestamp < 30000 // 30s threshold
|
|
59
|
+
const isWarning = !isHealthy && Date.now() - node.timestamp < 60000 // 60s warning
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
80
|
+
const laravel = node.meta?.laravel
|
|
72
81
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
227
|
+
<RotateCw size={12} />
|
|
127
228
|
</button>
|
|
128
229
|
<button
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
237
|
+
<Trash2 size={12} />
|
|
137
238
|
</button>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
}
|