@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
@@ -1,13 +1,38 @@
1
1
  import { type ClassValue, clsx } from 'clsx'
2
- import { Activity, Cpu } from 'lucide-react'
2
+ import { Activity, Cpu, Terminal } from 'lucide-react'
3
3
  import { twMerge } from 'tailwind-merge'
4
4
 
5
5
  function cn(...inputs: ClassValue[]) {
6
6
  return twMerge(clsx(inputs))
7
7
  }
8
8
 
9
+ function formatBytes(bytes: number) {
10
+ if (bytes === 0) return '0 B'
11
+ const k = 1024
12
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
13
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
14
+ return parseFloat((bytes / k ** i).toFixed(1)) + ' ' + sizes[i]
15
+ }
16
+
17
+ function getWorkerName(id: string, pid: number) {
18
+ // If ID contains Hostname+PID, try to simplify it
19
+ // Example: CarldeMacBook-Air.local-99401
20
+ const complexIdMatch = id.match(/^(.*)-(\d+)$/)
21
+ if (complexIdMatch && parseInt(complexIdMatch[2]) === pid) {
22
+ // Return just the hostname part, and maybe truncate if too long
23
+ let hostname = complexIdMatch[1]
24
+ if (hostname.endsWith('.local')) {
25
+ hostname = hostname.replace('.local', '')
26
+ }
27
+ return hostname
28
+ }
29
+ // Fallback
30
+ return id.replace('.local', '')
31
+ }
32
+
9
33
  interface WorkerInfo {
10
34
  id: string
35
+ service?: string
11
36
  status: 'online' | 'offline'
12
37
  pid: number
13
38
  uptime: number
@@ -18,6 +43,12 @@ interface WorkerInfo {
18
43
  rss: number
19
44
  }
20
45
  }
46
+ meta?: {
47
+ laravel?: {
48
+ workerCount: number
49
+ roots: string[]
50
+ }
51
+ }
21
52
  }
22
53
 
23
54
  export function WorkerStatus({
@@ -27,32 +58,28 @@ export function WorkerStatus({
27
58
  highlightedWorkerId?: string | null
28
59
  workers?: WorkerInfo[]
29
60
  }) {
30
- // Legacy polling removed, now using passed props
31
- // const { data: workerData } = useQuery<{ workers: any[] }>({ ... })
32
-
33
- // Fallback if not passed (though it should be)
34
- // const workers = workerData?.workers || []
35
-
36
61
  const onlineCount = workers.filter((w) => w.status === 'online').length
37
62
 
38
63
  return (
39
- <div className="card-premium p-6 h-full">
40
- <div className="flex justify-between items-center mb-8">
41
- <div>
42
- <h3 className="text-lg font-black flex items-center gap-2 tracking-tight">
43
- <Cpu size={20} className="text-primary" />
44
- Cluster Nodes
45
- </h3>
46
- <p className="text-[10px] text-muted-foreground uppercase font-black tracking-widest opacity-60">
47
- Real-time load
48
- </p>
64
+ <div className="card-premium h-full flex flex-col overflow-hidden">
65
+ <div className="p-6 pb-0 flex-none">
66
+ <div className="flex justify-between items-center mb-6">
67
+ <div>
68
+ <h3 className="text-lg font-black flex items-center gap-2 tracking-tight">
69
+ <Cpu size={20} className="text-primary" />
70
+ Cluster Nodes
71
+ </h3>
72
+ <p className="text-[10px] text-muted-foreground uppercase font-black tracking-widest opacity-60">
73
+ Real-time load
74
+ </p>
75
+ </div>
76
+ <span className="text-[10px] font-black text-green-500 bg-green-500/10 px-3 py-1 rounded-full uppercase tracking-widest border border-green-500/20">
77
+ {onlineCount} ACTIVE
78
+ </span>
49
79
  </div>
50
- <span className="text-[10px] font-black text-green-500 bg-green-500/10 px-3 py-1 rounded-full uppercase tracking-widest border border-green-500/20">
51
- {onlineCount} ACTIVE
52
- </span>
53
80
  </div>
54
81
 
55
- <div className="space-y-3">
82
+ <div className="flex-1 overflow-y-auto min-h-0 px-6 space-y-3 scrollbar-thin pb-6">
56
83
  {workers.length === 0 && (
57
84
  <div className="py-12 text-center text-muted-foreground/30 flex flex-col items-center gap-2">
58
85
  <Activity size={24} className="opacity-20 animate-pulse" />
@@ -64,10 +91,10 @@ export function WorkerStatus({
64
91
  <div
65
92
  key={worker.id}
66
93
  className={cn(
67
- 'flex items-center justify-between p-4 rounded-2xl bg-muted/10 border transition-all group overflow-hidden relative',
94
+ 'relative flex items-center gap-4 p-4 rounded-2xl border transition-all group overflow-hidden shrink-0',
68
95
  worker.id === highlightedWorkerId
69
- ? 'border-primary/50 bg-primary/5 shadow-[0_0_20px_rgba(var(--primary-rgb),0.1)] -translate-y-1 scale-[1.02] z-10'
70
- : 'border-transparent hover:border-primary/20 hover:bg-muted/20'
96
+ ? 'bg-primary/5 border-primary/50 shadow-[0_0_20px_rgba(var(--primary-rgb),0.1)] -translate-y-1 scale-[1.02] z-10'
97
+ : 'bg-card hover:bg-muted/10 border-border/50 hover:border-primary/20'
71
98
  )}
72
99
  >
73
100
  {/* Status bar */}
@@ -78,80 +105,96 @@ export function WorkerStatus({
78
105
  )}
79
106
  />
80
107
 
81
- <div className="flex items-center gap-4">
82
- <div className="relative">
83
- <div
84
- className={cn(
85
- 'w-3 h-3 rounded-full',
86
- worker.status === 'online'
87
- ? 'bg-green-500 animate-pulse shadow-[0_0_12px_rgba(34,197,94,0.6)]'
88
- : 'bg-muted-foreground/40'
89
- )}
90
- ></div>
91
- </div>
92
- <div>
93
- <p className="text-sm font-black tracking-tight group-hover:text-primary transition-colors">
94
- {worker.id}
95
- </p>
96
- <div className="flex items-center gap-2">
97
- <span className="text-[9px] font-black uppercase tracking-tighter opacity-50">
98
- {worker.status}
108
+ {/* Icon/Dot */}
109
+ <div className="relative shrink-0 ml-1">
110
+ <div
111
+ className={cn(
112
+ 'w-3 h-3 rounded-full',
113
+ worker.status === 'online'
114
+ ? 'bg-green-500 animate-pulse shadow-[0_0_12px_rgba(34,197,94,0.6)]'
115
+ : 'bg-muted-foreground/40'
116
+ )}
117
+ />
118
+ </div>
119
+
120
+ {/* Main Info */}
121
+ <div className="flex-1 min-w-0 flex flex-col justify-center mr-2">
122
+ {worker.service && (
123
+ <span className="text-[10px] font-black text-primary/80 uppercase tracking-widest mb-0.5 whitespace-nowrap">
124
+ {worker.service}
125
+ </span>
126
+ )}
127
+ <h4
128
+ className="text-sm font-black tracking-tight text-foreground truncate"
129
+ title={worker.id}
130
+ >
131
+ {getWorkerName(worker.id, worker.pid) || worker.id}
132
+ </h4>
133
+ <div className="flex items-center gap-2 mt-1">
134
+ <span className="text-[9px] font-bold text-muted-foreground uppercase tracking-wider whitespace-nowrap">
135
+ PID {worker.pid}
136
+ </span>
137
+ {worker.meta?.laravel && worker.meta.laravel.workerCount > 0 && (
138
+ <span className="inline-flex items-center gap-1 text-[9px] font-black text-white bg-red-500 px-1.5 py-0.5 rounded shadow-sm uppercase tracking-widest leading-none whitespace-nowrap">
139
+ <Terminal size={8} />
140
+ {worker.meta.laravel.workerCount} PHP
99
141
  </span>
100
- <span className="text-[9px] font-black text-primary/60">PID {worker.pid}</span>
101
- </div>
142
+ )}
102
143
  </div>
103
144
  </div>
104
145
 
105
- <div className="flex items-center gap-6">
146
+ {/* Metrics (Right Side) */}
147
+ <div className="flex items-center gap-3 text-right shrink-0">
106
148
  {worker.metrics && (
107
- <div className="flex gap-4">
108
- <div className="space-y-1.5 w-20">
149
+ <>
150
+ <div className="hidden sm:block space-y-1 w-12">
109
151
  <div className="flex justify-between text-[8px] font-black text-muted-foreground uppercase tracking-tighter">
110
- <span>LOAD ({worker.metrics.cores || '-'})</span>
152
+ <span>CPU</span>
111
153
  <span
112
154
  className={cn(
113
- worker.metrics.cpu > (worker.metrics.cores || 4) && 'text-red-500'
155
+ worker.metrics.cpu > (worker.metrics.cores || 1) * 100 && 'text-red-500'
114
156
  )}
115
157
  >
116
- {worker.metrics.cpu.toFixed(2)}
158
+ {worker.metrics.cpu.toFixed(0)}%
117
159
  </span>
118
160
  </div>
119
161
  <div className="h-1 w-full bg-muted rounded-full overflow-hidden">
120
162
  <div
121
- className={cn(
122
- 'h-full transition-all duration-700',
123
- worker.metrics.cpu > (worker.metrics.cores || 4)
124
- ? 'bg-red-500'
125
- : worker.metrics.cpu > (worker.metrics.cores || 4) * 0.7
126
- ? 'bg-amber-500'
127
- : 'bg-green-500'
128
- )}
129
- style={{
130
- width: `${Math.min(100, (worker.metrics.cpu / (worker.metrics.cores || 1)) * 100)}%`,
131
- }}
132
- />
163
+ className="h-full bg-foreground transition-all duration-700"
164
+ style={{ width: `${Math.min(100, worker.metrics.cpu)}%` }}
165
+ ></div>
133
166
  </div>
134
167
  </div>
135
- <div className="space-y-1.5 w-16">
168
+
169
+ <div className="hidden sm:block space-y-1 w-12">
136
170
  <div className="flex justify-between text-[8px] font-black text-muted-foreground uppercase tracking-tighter">
137
171
  <span>RAM</span>
138
- <span>{Math.round(worker.metrics.ram.rss / 1024)}G</span>
172
+ <span className="truncate ml-1">
173
+ {formatBytes(worker.metrics.ram.rss).split(' ')[0]}
174
+ </span>
139
175
  </div>
140
176
  <div className="h-1 w-full bg-muted rounded-full overflow-hidden">
141
177
  <div
142
178
  className="h-full bg-indigo-500 transition-all duration-700"
143
179
  style={{
144
- width: `${Math.min(100, (worker.metrics.ram.rss / 2048) * 100)}%`,
180
+ width: `${Math.min(100, (worker.metrics.ram.rss / 2000000000) * 100)}%`,
145
181
  }}
146
- />
182
+ ></div>
147
183
  </div>
148
184
  </div>
149
- </div>
185
+ </>
150
186
  )}
151
- <div className="text-right whitespace-nowrap hidden sm:block">
152
- <p className="text-sm font-black tracking-tighter">{worker.uptime}s</p>
187
+
188
+ <div className="w-12">
189
+ <p className="text-xs font-black tracking-tighter tabular-nums text-foreground">
190
+ {worker.uptime > 3600
191
+ ? `${(worker.uptime / 3600).toFixed(1)}h`
192
+ : worker.uptime > 60
193
+ ? `${(worker.uptime / 60).toFixed(0)}m`
194
+ : `${worker.uptime.toFixed(0)}s`}
195
+ </p>
153
196
  <p className="text-[8px] text-muted-foreground uppercase font-black tracking-widest opacity-50">
154
- UPTIME
197
+ UP
155
198
  </p>
156
199
  </div>
157
200
  </div>
@@ -159,12 +202,14 @@ export function WorkerStatus({
159
202
  ))}
160
203
  </div>
161
204
 
162
- <button
163
- type="button"
164
- className="w-full mt-8 py-3 bg-muted text-[10px] font-black rounded-xl hover:bg-primary hover:text-primary-foreground transition-all uppercase tracking-[0.2em] opacity-60 hover:opacity-100 active:scale-95 shadow-lg shadow-transparent hover:shadow-primary/20"
165
- >
166
- Manage Nodes
167
- </button>
205
+ <div className="p-6 pt-0 flex-none">
206
+ <button
207
+ type="button"
208
+ className="w-full py-3 bg-muted text-[10px] font-black rounded-xl hover:bg-primary hover:text-primary-foreground transition-all uppercase tracking-[0.2em] opacity-60 hover:opacity-100 active:scale-95 shadow-lg shadow-transparent hover:shadow-primary/20"
209
+ >
210
+ Manage Nodes
211
+ </button>
212
+ </div>
168
213
  </div>
169
214
  )
170
215
  }
@@ -0,0 +1,138 @@
1
+ import type { SVGProps } from 'react'
2
+
3
+ export function NodeIcon(props: SVGProps<SVGSVGElement>) {
4
+ return (
5
+ <svg
6
+ viewBox="0 0 32 32"
7
+ fill="none"
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ role="img"
10
+ aria-label="Node.js"
11
+ {...props}
12
+ >
13
+ <path
14
+ d="M16 2L2.1 9.9v12.2L16 30l13.9-7.9V9.9L16 2zm11.9 19.1L16 27.8l-11.9-6.7V11.1L16 4.2l11.9 6.9v10z"
15
+ fill="#339933"
16
+ />
17
+ <path d="M16 22.5l-6-3.4v-6.8l6-3.4 6 3.4v6.8l-6 3.4z" fill="#339933" />
18
+ </svg>
19
+ )
20
+ }
21
+
22
+ export function BunIcon(props: SVGProps<SVGSVGElement>) {
23
+ return (
24
+ <svg
25
+ viewBox="0 0 32 32"
26
+ fill="none"
27
+ xmlns="http://www.w3.org/2000/svg"
28
+ role="img"
29
+ aria-label="Bun"
30
+ {...props}
31
+ >
32
+ {/* Outer Outline/Shadow for contrast on light bg */}
33
+ <path
34
+ d="M30 17.045a9.8 9.8 0 0 0-.32-2.306l-.004.034a11.2 11.2 0 0 0-5.762-6.786c-3.495-1.89-5.243-3.326-6.8-3.811h.003c-1.95-.695-3.949.82-5.825 1.927-4.52 2.481-9.573 5.45-9.28 11.417.008-.029.017-.052.026-.08a9.97 9.97 0 0 0 3.934 7.257l-.01-.006C13.747 31.473 30.05 27.292 30 17.045"
35
+ fill="#fbf0df"
36
+ stroke="#4a4a4a"
37
+ strokeWidth="1.5"
38
+ />
39
+
40
+ <path
41
+ fill="#37474f"
42
+ d="M19.855 20.236A.8.8 0 0 0 19.26 20h-6.514a.8.8 0 0 0-.596.236.51.51 0 0 0-.137.463 4.37 4.37 0 0 0 1.641 2.339 4.2 4.2 0 0 0 2.349.926 4.2 4.2 0 0 0 2.343-.926 4.37 4.37 0 0 0 1.642-2.339.5.5 0 0 0-.132-.463Z"
43
+ />
44
+ <ellipse cx="22.5" cy="18.5" fill="#f8bbd0" rx="2.5" ry="1.5" />
45
+ <ellipse cx="9.5" cy="18.5" fill="#f8bbd0" rx="2.5" ry="1.5" />
46
+ <circle cx="10" cy="16" r="2" fill="#37474f" />
47
+ <circle cx="22" cy="16" r="2" fill="#37474f" />
48
+ </svg>
49
+ )
50
+ }
51
+
52
+ export function DenoIcon(props: SVGProps<SVGSVGElement>) {
53
+ return (
54
+ <svg
55
+ viewBox="0 0 32 32"
56
+ fill="none"
57
+ xmlns="http://www.w3.org/2000/svg"
58
+ role="img"
59
+ aria-label="Deno"
60
+ {...props}
61
+ >
62
+ <circle cx="16" cy="16" r="14" fill="currentColor" />
63
+ <path
64
+ d="M16 6C16 6 24 10 24 18C24 23.5228 19.5228 28 14 28C8.47715 28 4 23.5228 4 18C4 10 16 6 16 6Z"
65
+ fill="white"
66
+ />
67
+ <circle cx="12" cy="18" r="2" fill="black" />
68
+ </svg>
69
+ )
70
+ }
71
+
72
+ export function PhpIcon(props: SVGProps<SVGSVGElement>) {
73
+ return (
74
+ <svg
75
+ viewBox="0 0 32 32"
76
+ fill="none"
77
+ xmlns="http://www.w3.org/2000/svg"
78
+ role="img"
79
+ aria-label="PHP"
80
+ {...props}
81
+ >
82
+ <ellipse cx="16" cy="16" rx="14" ry="10" fill="#777BB4" />
83
+ <text
84
+ x="50%"
85
+ y="54%"
86
+ dominantBaseline="middle"
87
+ textAnchor="middle"
88
+ fill="white"
89
+ fontSize="9"
90
+ fontWeight="bold"
91
+ >
92
+ PHP
93
+ </text>
94
+ </svg>
95
+ )
96
+ }
97
+
98
+ export function GoIcon(props: SVGProps<SVGSVGElement>) {
99
+ return (
100
+ <svg
101
+ viewBox="0 0 32 32"
102
+ fill="none"
103
+ xmlns="http://www.w3.org/2000/svg"
104
+ role="img"
105
+ aria-label="Go"
106
+ {...props}
107
+ >
108
+ <path
109
+ d="M5 16C5 10 10 5 16 5H24V13H16C14.3431 13 13 14.3431 13 16C13 17.6569 14.3431 19 16 19H27V27H16C10 27 5 22 5 16Z"
110
+ fill="#00ADD8"
111
+ />
112
+ <circle cx="9" cy="16" r="2" fill="white" />
113
+ <circle cx="23" cy="9" r="2" fill="white" />
114
+ </svg>
115
+ )
116
+ }
117
+
118
+ export function PythonIcon(props: SVGProps<SVGSVGElement>) {
119
+ return (
120
+ <svg
121
+ viewBox="0 0 32 32"
122
+ fill="none"
123
+ xmlns="http://www.w3.org/2000/svg"
124
+ role="img"
125
+ aria-label="Python"
126
+ {...props}
127
+ >
128
+ <path
129
+ d="M16 2C10 2 10 5 10 5L10 9L18 9L18 11L8 11L8 20L12 20L12 14L22 14C22 14 22 12 16 2Z"
130
+ fill="#3776AB"
131
+ />
132
+ <path
133
+ d="M16 30C22 30 22 27 22 27L22 23L14 23L14 21L24 21L24 12L20 12L20 18L10 18C10 18 10 20 16 30Z"
134
+ fill="#FFD43B"
135
+ />
136
+ </svg>
137
+ )
138
+ }
@@ -35,7 +35,6 @@ export function ConfirmDialog({
35
35
  onClick={(e) => e.stopPropagation()}
36
36
  onKeyDown={(e) => e.stopPropagation()}
37
37
  >
38
- {/* biome-ignore lint/a11y/noStaticElementInteractions: Modal content needs to stop propagation */}
39
38
  <motion.div
40
39
  initial={{ scale: 0.9, opacity: 0 }}
41
40
  animate={{ scale: 1, opacity: 1 }}
@@ -83,14 +83,18 @@ export function JobInspector({ queueName, onClose }: JobInspectorProps) {
83
83
  }
84
84
 
85
85
  const toggleSelectAll = React.useCallback(() => {
86
- if (!data?.jobs) return
86
+ if (!data?.jobs) {
87
+ return
88
+ }
87
89
  const availableCount = data.jobs.filter((j) => j._raw && !j._archived).length
88
90
  if (selectedIndices.size === availableCount && availableCount > 0) {
89
91
  setSelectedIndices(new Set())
90
92
  } else {
91
93
  const indices = new Set<number>()
92
94
  data.jobs.forEach((j, i) => {
93
- if (j._raw && !j._archived) indices.add(i)
95
+ if (j._raw && !j._archived) {
96
+ indices.add(i)
97
+ }
94
98
  })
95
99
  setSelectedIndices(indices)
96
100
  }
@@ -134,10 +138,14 @@ export function JobInspector({ queueName, onClose }: JobInspectorProps) {
134
138
  }, [])
135
139
 
136
140
  const handleAction = async (action: 'delete' | 'retry', job: Job) => {
137
- if (!job._raw) return
141
+ if (!job._raw) {
142
+ return
143
+ }
138
144
  const endpoint = action === 'delete' ? 'delete' : 'retry'
139
145
  const body: any = { raw: job._raw }
140
- if (action === 'delete') body.type = view
146
+ if (action === 'delete') {
147
+ body.type = view
148
+ }
141
149
 
142
150
  await fetch(`/api/queues/${queueName}/jobs/${endpoint}`, {
143
151
  method: 'POST',
@@ -150,7 +158,9 @@ export function JobInspector({ queueName, onClose }: JobInspectorProps) {
150
158
  }
151
159
 
152
160
  const handleBulkAction = async (action: 'delete' | 'retry') => {
153
- if (selectedIndices.size === 0 || !data?.jobs) return
161
+ if (selectedIndices.size === 0 || !data?.jobs) {
162
+ return
163
+ }
154
164
 
155
165
  const count = selectedIndices.size
156
166
  setConfirmDialog({
@@ -186,7 +196,9 @@ export function JobInspector({ queueName, onClose }: JobInspectorProps) {
186
196
  }
187
197
 
188
198
  const handleBulkActionAll = async (action: 'delete' | 'retry') => {
189
- if (view === 'archive') return
199
+ if (view === 'archive') {
200
+ return
201
+ }
190
202
 
191
203
  setConfirmDialog({
192
204
  open: true,
@@ -0,0 +1,38 @@
1
+ import type { LucideIcon } from 'lucide-react'
2
+ import type { ReactNode } from 'react'
3
+ import { cn } from '../utils'
4
+
5
+ interface PageHeaderProps {
6
+ icon: LucideIcon
7
+ title: string
8
+ description?: string
9
+ children?: ReactNode
10
+ className?: string
11
+ }
12
+
13
+ export function PageHeader({
14
+ icon: Icon,
15
+ title,
16
+ description,
17
+ children,
18
+ className,
19
+ }: PageHeaderProps) {
20
+ return (
21
+ <div className={cn('flex justify-between items-end', className)}>
22
+ <div>
23
+ <h1 className="text-4xl font-black tracking-tighter flex items-center gap-3">
24
+ <div className="p-2 bg-primary/10 rounded-xl text-primary">
25
+ <Icon size={32} />
26
+ </div>
27
+ {title}
28
+ </h1>
29
+ {description && (
30
+ <p className="text-muted-foreground mt-2 text-sm font-bold opacity-60 uppercase tracking-widest pl-[3.75rem]">
31
+ {description}
32
+ </p>
33
+ )}
34
+ </div>
35
+ <div>{children}</div>
36
+ </div>
37
+ )
38
+ }
@@ -1,4 +1,4 @@
1
- import { useQuery, useQueryClient } from '@tanstack/react-query'
1
+ import { useQuery } from '@tanstack/react-query'
2
2
  import { AnimatePresence, animate, motion } from 'framer-motion'
3
3
  import {
4
4
  Activity,
@@ -9,13 +9,10 @@ import {
9
9
  Cpu,
10
10
  Hourglass,
11
11
  ListTree,
12
- RefreshCcw,
13
12
  Search,
14
13
  Terminal,
15
- Trash2,
16
14
  } from 'lucide-react'
17
15
  import React from 'react'
18
- import { useNavigate } from 'react-router-dom'
19
16
  import { JobInspector } from '../components/JobInspector'
20
17
  import { LogArchiveModal } from '../components/LogArchiveModal'
21
18
  import { ThroughputChart } from '../ThroughputChart'
@@ -57,7 +54,7 @@ function LiveLogs({
57
54
  onSearchArchive: () => void
58
55
  onWorkerHover?: (id: string | null) => void
59
56
  }) {
60
- const scrollRef = React.useRef<HTMLDivElement>(null)
57
+ const scrollRef = React.useRef<HTMLUListElement>(null)
61
58
 
62
59
  React.useEffect(() => {
63
60
  // Access logs to satisfy dependency check (and trigger on update)
@@ -67,7 +64,7 @@ function LiveLogs({
67
64
  }, [logs])
68
65
 
69
66
  return (
70
- <div className="card-premium h-full flex flex-col overflow-hidden group">
67
+ <div className="absolute inset-0 card-premium flex flex-col overflow-hidden group">
71
68
  <div className="p-4 border-b bg-muted/5 flex justify-between items-center">
72
69
  <div className="flex items-center gap-2">
73
70
  <Terminal size={14} className="text-primary" />
@@ -92,7 +89,7 @@ function LiveLogs({
92
89
  </div>
93
90
  <ul
94
91
  ref={scrollRef}
95
- className="flex-1 overflow-y-auto p-4 font-mono text-[11px] space-y-2.5 scrollbar-thin scroll-smooth"
92
+ className="flex-1 min-h-0 overflow-y-auto p-4 font-mono text-[11px] space-y-2.5 scrollbar-thin scroll-smooth"
96
93
  >
97
94
  {logs.length === 0 ? (
98
95
  <div className="h-full flex flex-col items-center justify-center text-muted-foreground/30 gap-2 opacity-50">
@@ -290,8 +287,6 @@ function QueueList({
290
287
  queues: QueueStats[]
291
288
  setSelectedQueue: (name: string | null) => void
292
289
  }) {
293
- const queryClient = useQueryClient()
294
-
295
290
  return (
296
291
  <div className="card-premium h-full flex flex-col overflow-hidden">
297
292
  <div className="p-4 border-b bg-muted/5 flex justify-between items-center">
@@ -353,10 +348,8 @@ function QueueList({
353
348
  }
354
349
 
355
350
  export function OverviewPage() {
356
- const navigate = useNavigate()
357
351
  const [selectedQueue, setSelectedQueue] = React.useState<string | null>(null)
358
352
  const [hoveredWorkerId, setHoveredWorkerId] = React.useState<string | null>(null)
359
- const queryClient = useQueryClient()
360
353
 
361
354
  const [logs, setLogs] = React.useState<SystemLog[]>([])
362
355
  const [stats, setStats] = React.useState<FluxStats>(DEFAULT_STATS)
@@ -493,17 +486,21 @@ export function OverviewPage() {
493
486
 
494
487
  <QueueHeatmap queues={queues} />
495
488
 
496
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 h-[600px]">
497
- <div className="lg:col-span-1 h-full">
489
+ <div className="flex flex-col lg:grid lg:grid-cols-3 gap-8 lg:h-[600px]">
490
+ <div className="lg:col-span-1 h-[400px] lg:h-full">
498
491
  <WorkerStatus highlightedWorkerId={hoveredWorkerId} workers={workers} />
499
492
  </div>
500
- <div className="lg:col-span-2 grid grid-rows-2 gap-6 h-full">
501
- <LiveLogs
502
- logs={logs}
503
- onSearchArchive={() => setIsLogArchiveOpen(true)}
504
- onWorkerHover={setHoveredWorkerId}
505
- />
506
- <QueueList queues={queues} setSelectedQueue={setSelectedQueue} />
493
+ <div className="lg:col-span-2 flex flex-col lg:grid lg:grid-rows-2 gap-6 lg:h-full">
494
+ <div className="relative h-[300px] lg:h-full min-h-0 overflow-hidden bg-card rounded-xl border border-border/50">
495
+ <LiveLogs
496
+ logs={logs}
497
+ onSearchArchive={() => setIsLogArchiveOpen(true)}
498
+ onWorkerHover={setHoveredWorkerId}
499
+ />
500
+ </div>
501
+ <div className="h-[300px] lg:h-full min-h-0 overflow-hidden bg-card rounded-xl border border-border/50">
502
+ <QueueList queues={queues} setSelectedQueue={setSelectedQueue} />
503
+ </div>
507
504
  </div>
508
505
  </div>
509
506
  </div>