@gravito/zenith 0.1.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.
Files changed (63) hide show
  1. package/ARCHITECTURE.md +88 -0
  2. package/BATCH_OPERATIONS_IMPLEMENTATION.md +159 -0
  3. package/DEMO.md +156 -0
  4. package/DEPLOYMENT.md +157 -0
  5. package/DOCS_INTERNAL.md +73 -0
  6. package/Dockerfile +46 -0
  7. package/Dockerfile.demo-worker +29 -0
  8. package/EVOLUTION_BLUEPRINT.md +112 -0
  9. package/JOBINSPECTOR_SCROLL_FIX.md +152 -0
  10. package/PULSE_IMPLEMENTATION_PLAN.md +111 -0
  11. package/QUICK_TEST_GUIDE.md +72 -0
  12. package/README.md +33 -0
  13. package/ROADMAP.md +85 -0
  14. package/TESTING_BATCH_OPERATIONS.md +252 -0
  15. package/bin/flux-console.ts +2 -0
  16. package/dist/bin.js +108196 -0
  17. package/dist/client/assets/index-DGYEwTDL.css +1 -0
  18. package/dist/client/assets/index-oyTdySX0.js +421 -0
  19. package/dist/client/index.html +13 -0
  20. package/dist/server/index.js +108191 -0
  21. package/docker-compose.yml +40 -0
  22. package/docs/integrations/LARAVEL.md +207 -0
  23. package/package.json +50 -0
  24. package/postcss.config.js +6 -0
  25. package/scripts/flood-logs.ts +21 -0
  26. package/scripts/seed.ts +213 -0
  27. package/scripts/verify-throttle.ts +45 -0
  28. package/scripts/worker.ts +123 -0
  29. package/src/bin.ts +6 -0
  30. package/src/client/App.tsx +70 -0
  31. package/src/client/Layout.tsx +644 -0
  32. package/src/client/Sidebar.tsx +102 -0
  33. package/src/client/ThroughputChart.tsx +135 -0
  34. package/src/client/WorkerStatus.tsx +170 -0
  35. package/src/client/components/ConfirmDialog.tsx +103 -0
  36. package/src/client/components/JobInspector.tsx +524 -0
  37. package/src/client/components/LogArchiveModal.tsx +383 -0
  38. package/src/client/components/NotificationBell.tsx +203 -0
  39. package/src/client/components/Toaster.tsx +80 -0
  40. package/src/client/components/UserProfileDropdown.tsx +177 -0
  41. package/src/client/contexts/AuthContext.tsx +93 -0
  42. package/src/client/contexts/NotificationContext.tsx +103 -0
  43. package/src/client/index.css +174 -0
  44. package/src/client/index.html +12 -0
  45. package/src/client/main.tsx +15 -0
  46. package/src/client/pages/LoginPage.tsx +153 -0
  47. package/src/client/pages/MetricsPage.tsx +408 -0
  48. package/src/client/pages/OverviewPage.tsx +511 -0
  49. package/src/client/pages/QueuesPage.tsx +372 -0
  50. package/src/client/pages/SchedulesPage.tsx +531 -0
  51. package/src/client/pages/SettingsPage.tsx +449 -0
  52. package/src/client/pages/WorkersPage.tsx +316 -0
  53. package/src/client/pages/index.ts +7 -0
  54. package/src/client/utils.ts +6 -0
  55. package/src/server/index.ts +556 -0
  56. package/src/server/middleware/auth.ts +127 -0
  57. package/src/server/services/AlertService.ts +160 -0
  58. package/src/server/services/QueueService.ts +828 -0
  59. package/tailwind.config.js +73 -0
  60. package/tests/placeholder.test.ts +7 -0
  61. package/tsconfig.json +38 -0
  62. package/tsconfig.node.json +12 -0
  63. package/vite.config.ts +27 -0
@@ -0,0 +1,644 @@
1
+ // ... imports ...
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
3
+ import { AnimatePresence, motion } from 'framer-motion'
4
+ import {
5
+ Activity,
6
+ BarChart3,
7
+ Briefcase,
8
+ Command,
9
+ HardDrive,
10
+ LayoutDashboard,
11
+ ListTree,
12
+ LogOut,
13
+ Moon,
14
+ RefreshCcw,
15
+ Search,
16
+ Settings,
17
+ ShieldCheck,
18
+ Sun,
19
+ Trash2,
20
+ Zap,
21
+ } from 'lucide-react'
22
+ import type * as React from 'react'
23
+ import { useEffect, useMemo, useState } from 'react'
24
+ import { useNavigate } from 'react-router-dom'
25
+ import { NotificationBell } from './components/NotificationBell'
26
+ import { Toaster } from './components/Toaster'
27
+ import { UserProfileDropdown } from './components/UserProfileDropdown'
28
+ import { useAuth } from './contexts/AuthContext'
29
+ import { Sidebar } from './Sidebar'
30
+ import { cn } from './utils'
31
+
32
+ interface LayoutProps {
33
+ children: React.ReactNode
34
+ }
35
+
36
+ interface CommandItem {
37
+ id: string
38
+ title: string
39
+ description: string
40
+ icon: React.ReactNode
41
+ action: () => void
42
+ category: 'Navigation' | 'System' | 'Action'
43
+ }
44
+
45
+ export function Layout({ children }: LayoutProps) {
46
+ const navigate = useNavigate()
47
+ const queryClient = useQueryClient()
48
+ const { isAuthEnabled, logout } = useAuth()
49
+ const [theme, setTheme] = useState<'light' | 'dark'>(() => {
50
+ if (typeof window !== 'undefined') {
51
+ return (localStorage.getItem('theme') as 'light' | 'dark') || 'light'
52
+ }
53
+ return 'light'
54
+ })
55
+ const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false)
56
+ const [searchQuery, setSearchQuery] = useState('')
57
+ const [selectedIndex, setSelectedIndex] = useState(0)
58
+ const [health, setHealth] = useState(99.9)
59
+ const [systemStatus, setSystemStatus] = useState<Record<string, any>>({})
60
+ const [isSidebarOpen, setIsSidebarOpen] = useState(true)
61
+
62
+ // Initial System Status Fetch
63
+ useEffect(() => {
64
+ fetch('/api/system/status')
65
+ .then((res) => res.json())
66
+ .then(setSystemStatus)
67
+ .catch(() => {})
68
+ }, [])
69
+
70
+ // Global SSE Stream Manager
71
+ useEffect(() => {
72
+ console.log('[Zenith] Establishing Global Event Stream...')
73
+ const ev = new EventSource('/api/logs/stream')
74
+
75
+ ev.addEventListener('log', (e) => {
76
+ try {
77
+ const data = JSON.parse(e.data)
78
+ window.dispatchEvent(new CustomEvent('flux-log-update', { detail: data }))
79
+ } catch (err) {
80
+ console.error('SSE Log Error', err)
81
+ }
82
+ })
83
+
84
+ ev.addEventListener('stats', (e) => {
85
+ try {
86
+ const data = JSON.parse(e.data)
87
+ window.dispatchEvent(new CustomEvent('flux-stats-update', { detail: data }))
88
+ } catch (err) {
89
+ console.error('SSE Stats Error', err)
90
+ }
91
+ })
92
+
93
+ ev.onerror = (err) => {
94
+ console.error('[Zenith] SSE Connection Error', err)
95
+ ev.close()
96
+ }
97
+
98
+ return () => {
99
+ console.log('[Zenith] Closing Global Event Stream')
100
+ ev.close()
101
+ }
102
+ }, [])
103
+
104
+ // Fetch Queues for search (once)
105
+ const [queueData, setQueueData] = useState<{ queues: any[] }>({ queues: [] })
106
+ useEffect(() => {
107
+ fetch('/api/queues')
108
+ .then((res) => res.json())
109
+ .then(setQueueData)
110
+ .catch(() => {})
111
+
112
+ // Optional: Listen to global stats if available (from OverviewPage) to keep queue stats fresh in command palette
113
+ const handler = (e: Event) => {
114
+ const customEvent = e as CustomEvent
115
+ if (customEvent.detail?.queues) {
116
+ setQueueData({ queues: customEvent.detail.queues })
117
+ }
118
+ }
119
+ window.addEventListener('flux-stats-update', handler)
120
+ return () => window.removeEventListener('flux-stats-update', handler)
121
+ }, [])
122
+
123
+ // Debounced job search
124
+ const [debouncedQuery, setDebouncedQuery] = useState('')
125
+
126
+ useEffect(() => {
127
+ const timer = setTimeout(() => {
128
+ if (searchQuery.length >= 2) {
129
+ setDebouncedQuery(searchQuery)
130
+ } else {
131
+ setDebouncedQuery('')
132
+ }
133
+ }, 300)
134
+ return () => clearTimeout(timer)
135
+ }, [searchQuery])
136
+
137
+ // Search jobs (Real-time and Archive)
138
+ const { data: searchResults } = useQuery<{ results: any[]; archiveResults?: any[] }>({
139
+ queryKey: ['job-search', debouncedQuery],
140
+ queryFn: async () => {
141
+ const [realtime, archive] = await Promise.all([
142
+ fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}&limit=10`).then((res) =>
143
+ res.json()
144
+ ),
145
+ fetch(`/api/archive/search?q=${encodeURIComponent(debouncedQuery)}&limit=10`).then((res) =>
146
+ res.json()
147
+ ),
148
+ ])
149
+ return {
150
+ results: realtime.results || [],
151
+ archiveResults: archive.results || [],
152
+ }
153
+ },
154
+ enabled: debouncedQuery.length >= 2,
155
+ staleTime: 5000,
156
+ })
157
+
158
+ useEffect(() => {
159
+ const root = window.document.documentElement
160
+ if (theme === 'dark') {
161
+ root.classList.add('dark')
162
+ } else {
163
+ root.classList.remove('dark')
164
+ }
165
+ localStorage.setItem('theme', theme)
166
+ }, [theme])
167
+
168
+ useEffect(() => {
169
+ const interval = setInterval(() => {
170
+ setHealth((prev) => {
171
+ const jitter = (Math.random() - 0.5) * 0.1
172
+ return Math.min(100, Math.max(98.5, prev + jitter))
173
+ })
174
+ }, 3000)
175
+ return () => clearInterval(interval)
176
+ }, [])
177
+
178
+ const toggleTheme = () => setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
179
+
180
+ const retryAllFailed = async () => {
181
+ const queues = queueData?.queues || []
182
+ for (const q of queues) {
183
+ if (q.failed > 0) {
184
+ await fetch(`/api/queues/${q.name}/retry-all-failed`, { method: 'POST' })
185
+ }
186
+ }
187
+ queryClient.invalidateQueries({ queryKey: ['queues'] })
188
+ }
189
+
190
+ const baseCommands: CommandItem[] = [
191
+ {
192
+ id: 'nav-overview',
193
+ title: 'Go to Overview',
194
+ description: 'Navigate to system dashboard',
195
+ icon: <LayoutDashboard size={18} />,
196
+ category: 'Navigation',
197
+ action: () => navigate('/'),
198
+ },
199
+ {
200
+ id: 'nav-queues',
201
+ title: 'Go to Queues',
202
+ description: 'Manage processing queues',
203
+ icon: <ListTree size={18} />,
204
+ category: 'Navigation',
205
+ action: () => navigate('/queues'),
206
+ },
207
+ {
208
+ id: 'nav-workers',
209
+ title: 'Go to Workers',
210
+ description: 'Monitor worker nodes',
211
+ icon: <HardDrive size={18} />,
212
+ category: 'Navigation',
213
+ action: () => navigate('/workers'),
214
+ },
215
+ {
216
+ id: 'nav-metrics',
217
+ title: 'Go to Metrics',
218
+ description: 'View system analytics',
219
+ icon: <BarChart3 size={18} />,
220
+ category: 'Navigation',
221
+ action: () => navigate('/metrics'),
222
+ },
223
+ {
224
+ id: 'nav-settings',
225
+ title: 'Go to Settings',
226
+ description: 'Configure console preferences',
227
+ icon: <Settings size={18} />,
228
+ category: 'Navigation',
229
+ action: () => navigate('/settings'),
230
+ },
231
+ {
232
+ id: 'act-retry-all',
233
+ title: 'Retry All Failed Jobs',
234
+ description: 'Recover all critical failures across all queues',
235
+ icon: <RefreshCcw size={18} />,
236
+ category: 'Action',
237
+ action: retryAllFailed,
238
+ },
239
+ {
240
+ id: 'sys-theme',
241
+ title: `Switch to ${theme === 'dark' ? 'Light' : 'Dark'} Mode`,
242
+ description: 'Toggle system visual appearance',
243
+ icon: theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />,
244
+ category: 'System',
245
+ action: toggleTheme,
246
+ },
247
+ ...(isAuthEnabled
248
+ ? [
249
+ {
250
+ id: 'sys-logout',
251
+ title: 'Logout',
252
+ description: 'Sign out from the console',
253
+ icon: <LogOut size={18} />,
254
+ category: 'System' as const,
255
+ action: logout,
256
+ },
257
+ ]
258
+ : []),
259
+ ]
260
+
261
+ const queueCommands: CommandItem[] = (queueData?.queues || []).map((q: any) => ({
262
+ id: `queue-${q.name}`,
263
+ title: `Queue: ${q.name}`,
264
+ description: `${q.waiting} waiting, ${q.failed} failed`,
265
+ icon: <ListTree size={18} />,
266
+ category: 'Navigation',
267
+ action: () => {
268
+ navigate('/queues')
269
+ setTimeout(() => {
270
+ window.dispatchEvent(new CustomEvent('select-queue', { detail: q.name }))
271
+ }, 100)
272
+ },
273
+ }))
274
+
275
+ const actionCommands: CommandItem[] = [
276
+ {
277
+ id: 'act-clear-logs',
278
+ title: 'Clear All Logs',
279
+ description: 'Flush temporary log buffer in UI',
280
+ icon: <Trash2 size={18} />,
281
+ category: 'Action',
282
+ action: () => {
283
+ window.dispatchEvent(new CustomEvent('clear-logs'))
284
+ },
285
+ },
286
+ ]
287
+
288
+ // Dynamic job search results
289
+ const jobCommands: CommandItem[] = useMemo(() => {
290
+ const combined = [
291
+ ...(searchResults?.results || []),
292
+ ...(searchResults?.archiveResults || []).map((j: any) => ({ ...j, _archived: true })),
293
+ ]
294
+
295
+ if (!combined.length) {
296
+ return []
297
+ }
298
+
299
+ return combined.slice(0, 15).map((job: any) => ({
300
+ id: `job-${job._queue}-${job.id}-${job._archived ? 'arch' : 'live'}`,
301
+ title: `Job: ${job.id || 'Unknown'}`,
302
+ description: `${job._queue} • ${job.status || job._type}${job._archived ? ' • ARCHIVED' : ''} • ${job.name || 'No name'}`,
303
+ icon: job._archived ? <HardDrive size={18} /> : <Briefcase size={18} />,
304
+ category: 'Action' as const,
305
+ action: () => {
306
+ navigate('/queues')
307
+ setTimeout(() => {
308
+ window.dispatchEvent(new CustomEvent('select-queue', { detail: job._queue }))
309
+ if (job._archived) {
310
+ // Future-proof: trigger archive view opening
311
+ window.dispatchEvent(
312
+ new CustomEvent('inspect-job', { detail: { queue: job._queue, job } })
313
+ )
314
+ }
315
+ }, 100)
316
+ },
317
+ }))
318
+ }, [searchResults, navigate])
319
+
320
+ const commands = [...baseCommands, ...actionCommands, ...queueCommands, ...jobCommands]
321
+
322
+ const filteredCommands = commands.filter(
323
+ (cmd) =>
324
+ cmd.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
325
+ cmd.description.toLowerCase().includes(searchQuery.toLowerCase())
326
+ )
327
+
328
+ useEffect(() => {
329
+ const handleKeyDown = (e: KeyboardEvent) => {
330
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
331
+ e.preventDefault()
332
+ setIsCommandPaletteOpen((prev) => !prev)
333
+ }
334
+ if (e.key === 'Escape') {
335
+ setIsCommandPaletteOpen(false)
336
+ }
337
+ }
338
+ window.addEventListener('keydown', handleKeyDown)
339
+ return () => window.removeEventListener('keydown', handleKeyDown)
340
+ }, [])
341
+
342
+ const handleSelect = (cmd: CommandItem) => {
343
+ cmd.action()
344
+ setIsCommandPaletteOpen(false)
345
+ setSearchQuery('')
346
+ }
347
+
348
+ return (
349
+ <div className="flex h-screen bg-background text-foreground overflow-hidden transition-colors duration-300">
350
+ <motion.aside
351
+ initial={false}
352
+ animate={{ width: isSidebarOpen ? 260 : 80 }}
353
+ className="border-r border-border/40 bg-card/50 backdrop-blur-xl flex flex-col z-50 transition-all duration-300 ease-[0.22, 1, 0.36, 1]"
354
+ >
355
+ <div className="h-16 flex items-center px-6 border-b border-border/40 bg-card/80">
356
+ <div className="flex items-center gap-3 overflow-hidden">
357
+ <div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center shrink-0 shadow-lg shadow-primary/20">
358
+ <Zap className="text-primary-foreground fill-current" size={18} />
359
+ </div>
360
+ <motion.div
361
+ animate={{ opacity: isSidebarOpen ? 1 : 0 }}
362
+ className="flex flex-col min-w-[140px]"
363
+ >
364
+ <span className="font-extrabold text-lg tracking-tight leading-none bg-clip-text text-transparent bg-gradient-to-r from-primary to-primary/60">
365
+ Zenith
366
+ </span>
367
+ <span className="text-[9px] font-bold text-muted-foreground uppercase tracking-[0.2em]">
368
+ Control Plane
369
+ </span>
370
+ </motion.div>
371
+ </div>
372
+ </div>
373
+ <Sidebar
374
+ collapsed={!isSidebarOpen}
375
+ toggleCollapse={() => setIsSidebarOpen(!isSidebarOpen)}
376
+ />
377
+ </motion.aside>
378
+
379
+ <main className="flex-1 flex flex-col relative overflow-hidden scanline">
380
+ {/* Top Header */}
381
+ <header className="h-16 border-b bg-card/50 backdrop-blur-md flex items-center justify-between px-8 sticky top-0 z-10 transition-colors">
382
+ <div className="flex items-center gap-6 flex-1">
383
+ <button
384
+ type="button"
385
+ className="relative max-w-md w-full group cursor-pointer outline-none block text-left"
386
+ onClick={() => setIsCommandPaletteOpen(true)}
387
+ >
388
+ <Search
389
+ className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-hover:text-primary transition-colors"
390
+ size={18}
391
+ />
392
+ <div className="w-full bg-muted/40 border border-border/50 rounded-xl py-2 pl-10 pr-4 text-sm text-muted-foreground/60 font-medium flex justify-between items-center transition-all hover:bg-muted/60 hover:border-primary/20">
393
+ <span>Search or command...</span>
394
+ <div className="flex gap-1 group-hover:scale-105 transition-transform">
395
+ <kbd className="bg-muted px-1.5 py-0.5 rounded border text-[10px] font-black opacity-60">
396
+
397
+ </kbd>
398
+ <kbd className="bg-muted px-1.5 py-0.5 rounded border text-[10px] font-black opacity-60">
399
+ K
400
+ </kbd>
401
+ </div>
402
+ </div>
403
+ </button>
404
+
405
+ {/* System Integrity Indicator */}
406
+ <div className="hidden lg:flex items-center gap-3 px-4 py-1.5 rounded-full bg-primary/5 border border-primary/10 transition-all hover:bg-primary/10 hover:border-primary/20 cursor-default group">
407
+ <div className="relative flex items-center justify-center">
408
+ <ShieldCheck size={14} className="text-primary z-10" />
409
+ <div className="absolute w-3 h-3 bg-primary rounded-full glow-pulse"></div>
410
+ </div>
411
+ <div className="flex flex-col">
412
+ <span className="text-[9px] font-black uppercase tracking-[0.1em] text-primary/60 leading-none">
413
+ System Integrity
414
+ </span>
415
+ <span className="text-[11px] font-black tracking-tight leading-none">
416
+ {health.toFixed(1)}% Nominal
417
+ </span>
418
+ </div>
419
+ </div>
420
+ </div>
421
+
422
+ <div className="flex items-center gap-6">
423
+ <button
424
+ type="button"
425
+ onClick={toggleTheme}
426
+ className="p-2.5 hover:bg-muted rounded-xl text-muted-foreground hover:text-primary transition-all duration-300 active:scale-95 group relative"
427
+ title={theme === 'light' ? 'Switch to Dark Mode' : 'Switch to Light Mode'}
428
+ >
429
+ {theme === 'light' ? (
430
+ <Moon size={20} className="group-hover:rotate-[15deg] transition-transform" />
431
+ ) : (
432
+ <Sun
433
+ size={20}
434
+ className="group-hover:rotate-90 transition-transform text-yellow-500"
435
+ />
436
+ )}
437
+ </button>
438
+
439
+ <NotificationBell />
440
+
441
+ <div className="h-8 w-[1px] bg-border/50"></div>
442
+
443
+ <UserProfileDropdown />
444
+ </div>
445
+ </header>
446
+
447
+ {/* Content Area */}
448
+ <div className="flex-1 overflow-y-auto p-8 scrollbar-thin">
449
+ <motion.div
450
+ initial={{ opacity: 0, scale: 0.98, filter: 'blur(10px)' }}
451
+ animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
452
+ transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
453
+ >
454
+ {children}
455
+ </motion.div>
456
+ </div>
457
+
458
+ {/* Dynamic Status Bar (Ambient) */}
459
+ <footer className="h-7 border-t bg-card/80 backdrop-blur-md flex items-center justify-between px-6 z-10 transition-colors">
460
+ <div className="flex items-center gap-6 overflow-hidden">
461
+ <div className="flex items-center gap-2 border-r border-border/50 pr-4">
462
+ <span className="w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]"></span>
463
+ <span className="text-[10px] font-black text-muted-foreground/60 uppercase tracking-widest whitespace-nowrap">
464
+ Node: {systemStatus?.env || 'production-east-1'}
465
+ </span>
466
+ </div>
467
+ <div className="flex items-center gap-4 text-[9px] font-black text-muted-foreground/40 uppercase tracking-[0.2em] animate-in fade-in slide-in-from-left duration-1000">
468
+ <span className="flex items-center gap-1.5">
469
+ <Activity size={10} className="text-primary/40" /> Latency: 4ms
470
+ </span>
471
+ <span className="hidden sm:inline border-l border-border/30 pl-4 text-primary">
472
+ RAM: {systemStatus?.memory?.rss || '...'} /{' '}
473
+ {systemStatus?.memory?.total || '4.00 GB'}
474
+ </span>
475
+ <span className="hidden md:inline border-l border-border/30 pl-4 uppercase">
476
+ Engine: {systemStatus?.engine || 'v2.4.1-beta'}
477
+ </span>
478
+ <span className="hidden lg:inline border-l border-border/30 pl-4 lowercase">
479
+ v: {systemStatus?.node || '...'}
480
+ </span>
481
+ </div>
482
+ </div>
483
+ <div className="flex items-center gap-4 pl-4 bg-gradient-to-l from-card via-card to-transparent text-right">
484
+ <div className="flex items-center gap-2">
485
+ <div className="w-8 h-1 bg-muted rounded-full overflow-hidden">
486
+ <motion.div
487
+ animate={{ x: [-20, 20] }}
488
+ transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
489
+ className="w-4 h-full bg-primary/40"
490
+ />
491
+ </div>
492
+ <span className="text-[9px] font-black text-primary/60 uppercase tracking-widest">
493
+ Bus Traffic
494
+ </span>
495
+ </div>
496
+ <span className="font-mono text-[10px] text-muted-foreground/60 tabular-nums lowercase">
497
+ {new Date().toISOString().split('T')[1]?.split('.')[0] || ''} utc
498
+ </span>
499
+ </div>
500
+ </footer>
501
+ </main>
502
+
503
+ {/* Command Palette Modal */}
504
+ <AnimatePresence>
505
+ {isCommandPaletteOpen && (
506
+ <div className="fixed inset-0 z-[100] flex items-start justify-center pt-24 px-4">
507
+ <motion.div
508
+ initial={{ opacity: 0 }}
509
+ animate={{ opacity: 1 }}
510
+ exit={{ opacity: 0 }}
511
+ className="absolute inset-0 bg-black/60 backdrop-blur-md"
512
+ onClick={() => setIsCommandPaletteOpen(false)}
513
+ />
514
+ <motion.div
515
+ initial={{ opacity: 0, scale: 0.95, y: -20 }}
516
+ animate={{ opacity: 1, scale: 1, y: 0 }}
517
+ exit={{ opacity: 0, scale: 0.95, y: -20 }}
518
+ className="relative w-full max-w-2xl bg-card border-border/50 border rounded-3xl shadow-2xl overflow-hidden scanline"
519
+ >
520
+ <div className="p-6 border-b flex items-center gap-4 bg-muted/5">
521
+ <Command className="text-primary animate-pulse" size={24} />
522
+ <input
523
+ type="text"
524
+ placeholder="Execute command or navigate..."
525
+ className="flex-1 bg-transparent border-none outline-none text-lg font-bold placeholder:text-muted-foreground/30"
526
+ value={searchQuery}
527
+ onChange={(e) => {
528
+ setSearchQuery(e.target.value)
529
+ setSelectedIndex(0)
530
+ }}
531
+ onKeyDown={(e) => {
532
+ if (e.key === 'ArrowDown') {
533
+ e.preventDefault()
534
+ setSelectedIndex((prev) => (prev + 1) % filteredCommands.length)
535
+ } else if (e.key === 'ArrowUp') {
536
+ e.preventDefault()
537
+ setSelectedIndex(
538
+ (prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length
539
+ )
540
+ } else if (e.key === 'Enter' && filteredCommands[selectedIndex]) {
541
+ handleSelect(filteredCommands[selectedIndex]!)
542
+ }
543
+ }}
544
+ />
545
+ <div className="flex items-center gap-2 px-2 py-1 rounded-lg bg-muted border text-[9px] font-black text-muted-foreground/60 uppercase">
546
+ ESC to close
547
+ </div>
548
+ </div>
549
+ <div className="max-h-[400px] overflow-y-auto p-2 custom-scrollbar">
550
+ {filteredCommands.length === 0 ? (
551
+ <div className="py-12 text-center text-muted-foreground/40 space-y-2">
552
+ <Activity size={32} className="mx-auto opacity-20" />
553
+ <p className="text-xs font-black uppercase tracking-widest">
554
+ No matching commands found
555
+ </p>
556
+ </div>
557
+ ) : (
558
+ <div className="space-y-1">
559
+ {filteredCommands.map((cmd, i) => (
560
+ <button
561
+ type="button"
562
+ key={cmd.id}
563
+ className={cn(
564
+ 'w-full flex items-center justify-between p-4 rounded-2xl transition-all cursor-pointer group/cmd outline-none',
565
+ i === selectedIndex
566
+ ? 'bg-primary shadow-lg shadow-primary/20 -translate-x-1'
567
+ : 'hover:bg-muted'
568
+ )}
569
+ onClick={() => handleSelect(cmd)}
570
+ onMouseEnter={() => setSelectedIndex(i)}
571
+ >
572
+ <div className="flex items-center gap-4">
573
+ <div
574
+ className={cn(
575
+ 'w-10 h-10 rounded-xl flex items-center justify-center transition-colors',
576
+ i === selectedIndex
577
+ ? 'bg-white/20 text-white'
578
+ : 'bg-muted text-primary'
579
+ )}
580
+ >
581
+ {cmd.icon}
582
+ </div>
583
+ <div>
584
+ <p
585
+ className={cn(
586
+ 'text-sm font-black tracking-tight',
587
+ i === selectedIndex ? 'text-white' : 'text-foreground'
588
+ )}
589
+ >
590
+ {cmd.title}
591
+ </p>
592
+ <p
593
+ className={cn(
594
+ 'text-[10px] font-bold uppercase tracking-widest opacity-60',
595
+ i === selectedIndex ? 'text-white/80' : 'text-muted-foreground'
596
+ )}
597
+ >
598
+ {cmd.description}
599
+ </p>
600
+ </div>
601
+ </div>
602
+ <div className="flex items-center gap-2">
603
+ <span
604
+ className={cn(
605
+ 'text-[9px] font-black uppercase tracking-widest px-2 py-1 rounded-md',
606
+ i === selectedIndex
607
+ ? 'bg-white/20 text-white'
608
+ : 'bg-muted text-muted-foreground'
609
+ )}
610
+ >
611
+ {cmd.category}
612
+ </span>
613
+ {i === selectedIndex && (
614
+ <kbd className="bg-white/20 px-1.5 py-0.5 rounded text-[10px] text-white">
615
+
616
+ </kbd>
617
+ )}
618
+ </div>
619
+ </button>
620
+ ))}
621
+ </div>
622
+ )}
623
+ </div>
624
+ <div className="p-4 border-t bg-muted/5 flex justify-between items-center px-6">
625
+ <div className="flex gap-4">
626
+ <div className="flex items-center gap-1.5 text-[9px] font-black text-muted-foreground/40 uppercase">
627
+ <kbd className="bg-muted px-1.5 py-0.5 rounded border">↑↓</kbd> to navigate
628
+ </div>
629
+ <div className="flex items-center gap-1.5 text-[9px] font-black text-muted-foreground/40 uppercase">
630
+ <kbd className="bg-muted px-1.5 py-0.5 rounded border">↵</kbd> to select
631
+ </div>
632
+ </div>
633
+ <span className="text-[9px] font-black text-primary/40 uppercase tracking-[0.2em]">
634
+ Gravito Zenith v1.0
635
+ </span>
636
+ </div>
637
+ </motion.div>
638
+ </div>
639
+ )}
640
+ </AnimatePresence>
641
+ <Toaster />
642
+ </div>
643
+ )
644
+ }