@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,4 +1,4 @@
1
- import { useQuery } from '@tanstack/react-query'
1
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
2
2
  import {
3
3
  Bell,
4
4
  Clock,
@@ -18,6 +18,8 @@ import React from 'react'
18
18
  import { cn } from '../utils'
19
19
 
20
20
  export function SettingsPage() {
21
+ const queryClient = useQueryClient()
22
+ const [showAddRule, setShowAddRule] = React.useState(false)
21
23
  const [theme, setTheme] = React.useState<'light' | 'dark' | 'system'>(() => {
22
24
  if (typeof window !== 'undefined') {
23
25
  const stored = localStorage.getItem('theme')
@@ -147,14 +149,14 @@ export function SettingsPage() {
147
149
  <span className="font-medium">Redis URL</span>
148
150
  </div>
149
151
  <code className="text-sm bg-muted px-2 py-1 rounded font-mono">
150
- {process.env.REDIS_URL || 'redis://localhost:6379'}
152
+ {systemStatus?.redisUrl || 'redis://localhost:6379'}
151
153
  </code>
152
154
  </div>
153
155
 
154
156
  <div className="flex items-center justify-between py-3 border-b border-border/30">
155
157
  <div className="flex items-center gap-3">
156
158
  <Clock size={16} className="text-muted-foreground" />
157
- <span className="font-medium">Server Uptime</span>
159
+ <span className="font-medium">Service Uptime</span>
158
160
  </div>
159
161
  <span className="text-sm font-mono font-bold">
160
162
  {systemStatus?.uptime ? formatUptime(systemStatus.uptime) : 'Loading...'}
@@ -210,6 +212,12 @@ export function SettingsPage() {
210
212
  </p>
211
213
  <p className="text-lg font-mono font-bold">{systemStatus?.memory?.heapUsed || '...'}</p>
212
214
  </div>
215
+ <div className="bg-muted/20 rounded-xl p-4">
216
+ <p className="text-[10px] font-black text-muted-foreground/50 uppercase tracking-widest mb-1">
217
+ Total System RAM
218
+ </p>
219
+ <p className="text-lg font-mono font-bold">{systemStatus?.memory?.total || '...'}</p>
220
+ </div>
213
221
  </div>
214
222
  </section>
215
223
 
@@ -227,66 +235,572 @@ export function SettingsPage() {
227
235
  </div>
228
236
  </div>
229
237
 
230
- <div className="space-y-6">
231
- <div className="flex items-center justify-between py-3 border-b border-border/30">
232
- <div>
233
- <h3 className="text-sm font-bold">Slack Webhook</h3>
234
- <p className="text-xs text-muted-foreground">
235
- Current status of notification integration.
236
- </p>
237
- </div>
238
- <div className="flex items-center gap-2">
239
- <span
240
- className={cn(
241
- 'w-2 h-2 rounded-full',
242
- alertConfig?.webhookEnabled
243
- ? 'bg-green-500 animate-pulse'
244
- : 'bg-muted-foreground/30'
245
- )}
246
- ></span>
247
- <span
248
- className={cn(
249
- 'text-sm font-bold',
250
- alertConfig?.webhookEnabled ? 'text-green-500' : 'text-muted-foreground'
251
- )}
252
- >
253
- {alertConfig?.webhookEnabled ? 'Enabled' : 'Not Configured'}
254
- </span>
238
+ <div className="space-y-8">
239
+ {/* Notification Channels */}
240
+ <div className="space-y-4">
241
+ <h3 className="text-xs font-black uppercase tracking-widest text-muted-foreground/60 mb-2">
242
+ Notification Channels
243
+ </h3>
244
+
245
+ {/* Slack */}
246
+ <div className="p-4 bg-muted/20 rounded-xl border border-border/10">
247
+ <div className="flex items-center justify-between mb-4">
248
+ <div className="flex items-center gap-3">
249
+ <div className="w-8 h-8 rounded-lg bg-[#4A154B]/10 flex items-center justify-center text-[#4A154B] dark:text-[#E01E5A]">
250
+ <Bell size={16} />
251
+ </div>
252
+ <div>
253
+ <h4 className="text-sm font-bold">Slack</h4>
254
+ <p className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">
255
+ Standard notification webhook
256
+ </p>
257
+ </div>
258
+ </div>
259
+ <button
260
+ type="button"
261
+ onClick={async () => {
262
+ const enabled = !alertConfig?.config?.channels?.slack?.enabled
263
+ const current = alertConfig?.config?.channels?.slack || {}
264
+ await fetch('/api/alerts/config', {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({
268
+ ...alertConfig.config,
269
+ channels: {
270
+ ...alertConfig.config.channels,
271
+ slack: { ...current, enabled },
272
+ },
273
+ }),
274
+ })
275
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
276
+ }}
277
+ className={cn(
278
+ 'px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all',
279
+ alertConfig?.config?.channels?.slack?.enabled
280
+ ? 'bg-green-500 text-white shadow-lg shadow-green-500/20'
281
+ : 'bg-muted-foreground/20 text-muted-foreground'
282
+ )}
283
+ >
284
+ {alertConfig?.config?.channels?.slack?.enabled ? 'Enabled' : 'Disabled'}
285
+ </button>
286
+ </div>
287
+ <div className="flex gap-3">
288
+ <input
289
+ type="password"
290
+ placeholder="https://hooks.slack.com/services/..."
291
+ defaultValue={alertConfig?.config?.channels?.slack?.webhookUrl || ''}
292
+ onBlur={async (e) => {
293
+ const val = e.target.value
294
+ if (val === alertConfig?.config?.channels?.slack?.webhookUrl) {
295
+ return
296
+ }
297
+ await fetch('/api/alerts/config', {
298
+ method: 'POST',
299
+ headers: { 'Content-Type': 'application/json' },
300
+ body: JSON.stringify({
301
+ ...alertConfig.config,
302
+ channels: {
303
+ ...alertConfig.config.channels,
304
+ slack: {
305
+ ...alertConfig?.config?.channels?.slack,
306
+ webhookUrl: val,
307
+ },
308
+ },
309
+ }),
310
+ })
311
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
312
+ }}
313
+ className="flex-1 bg-background/50 border border-border/50 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:ring-1 focus:ring-primary/30"
314
+ />
315
+ </div>
255
316
  </div>
256
- </div>
257
317
 
258
- <div>
259
- <h3 className="text-xs font-black uppercase tracking-widest text-muted-foreground/60 mb-3">
260
- Active Rules
261
- </h3>
262
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
263
- {alertConfig?.rules?.map((rule: any) => (
264
- <div
265
- key={rule.id}
266
- className="p-3 bg-muted/20 border border-border/10 rounded-xl flex items-center justify-between"
318
+ {/* Discord */}
319
+ <div className="p-4 bg-muted/20 rounded-xl border border-border/10">
320
+ <div className="flex items-center justify-between mb-4">
321
+ <div className="flex items-center gap-3">
322
+ <div className="w-8 h-8 rounded-lg bg-[#5865F2]/10 flex items-center justify-center text-[#5865F2]">
323
+ <Monitor size={16} />
324
+ </div>
325
+ <div>
326
+ <h4 className="text-sm font-bold">Discord</h4>
327
+ <p className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">
328
+ Webhook integration for servers
329
+ </p>
330
+ </div>
331
+ </div>
332
+ <button
333
+ type="button"
334
+ onClick={async () => {
335
+ const enabled = !alertConfig?.config?.channels?.discord?.enabled
336
+ const current = alertConfig?.config?.channels?.discord || {}
337
+ await fetch('/api/alerts/config', {
338
+ method: 'POST',
339
+ headers: { 'Content-Type': 'application/json' },
340
+ body: JSON.stringify({
341
+ ...alertConfig.config,
342
+ channels: {
343
+ ...alertConfig.config.channels,
344
+ discord: { ...current, enabled },
345
+ },
346
+ }),
347
+ })
348
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
349
+ }}
350
+ className={cn(
351
+ 'px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all',
352
+ alertConfig?.config?.channels?.discord?.enabled
353
+ ? 'bg-green-500 text-white shadow-lg shadow-green-500/20'
354
+ : 'bg-muted-foreground/20 text-muted-foreground'
355
+ )}
267
356
  >
357
+ {alertConfig?.config?.channels?.discord?.enabled ? 'Enabled' : 'Disabled'}
358
+ </button>
359
+ </div>
360
+ <div className="flex gap-3">
361
+ <input
362
+ type="password"
363
+ placeholder="https://discord.com/api/webhooks/..."
364
+ defaultValue={alertConfig?.config?.channels?.discord?.webhookUrl || ''}
365
+ onBlur={async (e) => {
366
+ const val = e.target.value
367
+ if (val === alertConfig?.config?.channels?.discord?.webhookUrl) {
368
+ return
369
+ }
370
+ await fetch('/api/alerts/config', {
371
+ method: 'POST',
372
+ headers: { 'Content-Type': 'application/json' },
373
+ body: JSON.stringify({
374
+ ...alertConfig.config,
375
+ channels: {
376
+ ...alertConfig.config.channels,
377
+ discord: {
378
+ ...alertConfig?.config?.channels?.discord,
379
+ webhookUrl: val,
380
+ },
381
+ },
382
+ }),
383
+ })
384
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
385
+ }}
386
+ className="flex-1 bg-background/50 border border-border/50 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:ring-1 focus:ring-primary/30"
387
+ />
388
+ </div>
389
+ </div>
390
+
391
+ {/* Email (SMTP) */}
392
+ <div className="p-4 bg-muted/20 rounded-xl border border-border/10">
393
+ <div className="flex items-center justify-between mb-4">
394
+ <div className="flex items-center gap-3">
395
+ <div className="w-8 h-8 rounded-lg bg-red-500/10 flex items-center justify-center text-red-500">
396
+ <Info size={16} />
397
+ </div>
268
398
  <div>
269
- <p className="text-[11px] font-black uppercase tracking-tight">{rule.name}</p>
270
- <p className="text-[10px] text-muted-foreground opacity-70">
271
- {rule.type === 'backlog'
272
- ? `Waiting > ${rule.threshold}`
273
- : rule.type === 'failure'
274
- ? `Failed > ${rule.threshold}`
275
- : `Workers < ${rule.threshold}`}
399
+ <h4 className="text-sm font-bold">Email (SMTP)</h4>
400
+ <p className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">
401
+ Standard mail delivery
276
402
  </p>
277
403
  </div>
278
- <div className="px-2 py-0.5 bg-muted rounded text-[9px] font-bold text-muted-foreground">
279
- {rule.cooldownMinutes}m cooldown
404
+ </div>
405
+ <button
406
+ type="button"
407
+ onClick={async () => {
408
+ const enabled = !alertConfig?.config?.channels?.email?.enabled
409
+ const current = alertConfig?.config?.channels?.email || {}
410
+ await fetch('/api/alerts/config', {
411
+ method: 'POST',
412
+ headers: { 'Content-Type': 'application/json' },
413
+ body: JSON.stringify({
414
+ ...alertConfig.config,
415
+ channels: {
416
+ ...alertConfig.config.channels,
417
+ email: { ...current, enabled },
418
+ },
419
+ }),
420
+ })
421
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
422
+ }}
423
+ className={cn(
424
+ 'px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all',
425
+ alertConfig?.config?.channels?.email?.enabled
426
+ ? 'bg-green-500 text-white shadow-lg shadow-green-500/20'
427
+ : 'bg-muted-foreground/20 text-muted-foreground'
428
+ )}
429
+ >
430
+ {alertConfig?.config?.channels?.email?.enabled ? 'Enabled' : 'Disabled'}
431
+ </button>
432
+ </div>
433
+
434
+ {alertConfig?.config?.channels?.email?.enabled && (
435
+ <div className="grid grid-cols-2 gap-3 mt-4 animate-in fade-in slide-in-from-top-2">
436
+ <div className="col-span-2 space-y-1">
437
+ <label
438
+ htmlFor="email-to"
439
+ className="text-[9px] font-black uppercase text-muted-foreground/60"
440
+ >
441
+ Destination Address
442
+ </label>
443
+ <input
444
+ id="email-to"
445
+ placeholder="admin@example.com"
446
+ defaultValue={alertConfig?.config?.channels?.email?.to || ''}
447
+ onBlur={async (e) => {
448
+ const val = e.target.value
449
+ await fetch('/api/alerts/config', {
450
+ method: 'POST',
451
+ headers: { 'Content-Type': 'application/json' },
452
+ body: JSON.stringify({
453
+ ...alertConfig.config,
454
+ channels: {
455
+ ...alertConfig.config.channels,
456
+ email: {
457
+ ...alertConfig?.config?.channels?.email,
458
+ to: val,
459
+ },
460
+ },
461
+ }),
462
+ })
463
+ }}
464
+ className="w-full bg-background/50 border border-border/50 rounded-lg px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary/30"
465
+ />
466
+ </div>
467
+ <div className="space-y-1">
468
+ <label
469
+ htmlFor="smtp-host"
470
+ className="text-[9px] font-black uppercase text-muted-foreground/60"
471
+ >
472
+ SMTP Host
473
+ </label>
474
+ <input
475
+ id="smtp-host"
476
+ placeholder="smtp.gmail.com"
477
+ defaultValue={alertConfig?.config?.channels?.email?.smtpHost || ''}
478
+ onBlur={async (e) => {
479
+ await fetch('/api/alerts/config', {
480
+ method: 'POST',
481
+ headers: { 'Content-Type': 'application/json' },
482
+ body: JSON.stringify({
483
+ ...alertConfig.config,
484
+ channels: {
485
+ ...alertConfig.config.channels,
486
+ email: {
487
+ ...alertConfig?.config?.channels?.email,
488
+ smtpHost: e.target.value,
489
+ },
490
+ },
491
+ }),
492
+ })
493
+ }}
494
+ className="w-full bg-background/50 border border-border/50 rounded-lg px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary/30"
495
+ />
496
+ </div>
497
+ <div className="space-y-1">
498
+ <label
499
+ htmlFor="smtp-port"
500
+ className="text-[9px] font-black uppercase text-muted-foreground/60"
501
+ >
502
+ Port
503
+ </label>
504
+ <input
505
+ id="smtp-port"
506
+ type="number"
507
+ placeholder="465"
508
+ defaultValue={alertConfig?.config?.channels?.email?.smtpPort || 465}
509
+ onBlur={async (e) => {
510
+ await fetch('/api/alerts/config', {
511
+ method: 'POST',
512
+ headers: { 'Content-Type': 'application/json' },
513
+ body: JSON.stringify({
514
+ ...alertConfig.config,
515
+ channels: {
516
+ ...alertConfig.config.channels,
517
+ email: {
518
+ ...alertConfig?.config?.channels?.email,
519
+ smtpPort: parseInt(e.target.value, 10),
520
+ },
521
+ },
522
+ }),
523
+ })
524
+ }}
525
+ className="w-full bg-background/50 border border-border/50 rounded-lg px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary/30"
526
+ />
527
+ </div>
528
+ <div className="space-y-1">
529
+ <label
530
+ htmlFor="smtp-user"
531
+ className="text-[9px] font-black uppercase text-muted-foreground/60"
532
+ >
533
+ Username
534
+ </label>
535
+ <input
536
+ id="smtp-user"
537
+ placeholder="user@example.com"
538
+ defaultValue={alertConfig?.config?.channels?.email?.smtpUser || ''}
539
+ onBlur={async (e) => {
540
+ await fetch('/api/alerts/config', {
541
+ method: 'POST',
542
+ headers: { 'Content-Type': 'application/json' },
543
+ body: JSON.stringify({
544
+ ...alertConfig.config,
545
+ channels: {
546
+ ...alertConfig.config.channels,
547
+ email: {
548
+ ...alertConfig?.config?.channels?.email,
549
+ smtpUser: e.target.value,
550
+ },
551
+ },
552
+ }),
553
+ })
554
+ }}
555
+ className="w-full bg-background/50 border border-border/50 rounded-lg px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary/30"
556
+ />
557
+ </div>
558
+ <div className="space-y-1">
559
+ <label
560
+ htmlFor="smtp-pass"
561
+ className="text-[9px] font-black uppercase text-muted-foreground/60"
562
+ >
563
+ Password
564
+ </label>
565
+ <input
566
+ id="smtp-pass"
567
+ type="password"
568
+ placeholder="••••••••"
569
+ defaultValue={alertConfig?.config?.channels?.email?.smtpPass || ''}
570
+ onBlur={async (e) => {
571
+ await fetch('/api/alerts/config', {
572
+ method: 'POST',
573
+ headers: { 'Content-Type': 'application/json' },
574
+ body: JSON.stringify({
575
+ ...alertConfig.config,
576
+ channels: {
577
+ ...alertConfig.config.channels,
578
+ email: {
579
+ ...alertConfig?.config?.channels?.email,
580
+ smtpPass: e.target.value,
581
+ },
582
+ },
583
+ }),
584
+ })
585
+ }}
586
+ className="w-full bg-background/50 border border-border/50 rounded-lg px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary/30"
587
+ />
588
+ </div>
589
+ <div className="col-span-2 space-y-1">
590
+ <label
591
+ htmlFor="email-from"
592
+ className="text-[9px] font-black uppercase text-muted-foreground/60"
593
+ >
594
+ From Address
595
+ </label>
596
+ <input
597
+ id="email-from"
598
+ placeholder="Zenith Monitor <noreply@example.com>"
599
+ defaultValue={alertConfig?.config?.channels?.email?.from || ''}
600
+ onBlur={async (e) => {
601
+ await fetch('/api/alerts/config', {
602
+ method: 'POST',
603
+ headers: { 'Content-Type': 'application/json' },
604
+ body: JSON.stringify({
605
+ ...alertConfig.config,
606
+ channels: {
607
+ ...alertConfig.config.channels,
608
+ email: {
609
+ ...alertConfig?.config?.channels?.email,
610
+ from: e.target.value,
611
+ },
612
+ },
613
+ }),
614
+ })
615
+ }}
616
+ className="w-full bg-background/50 border border-border/50 rounded-lg px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary/30"
617
+ />
280
618
  </div>
281
619
  </div>
282
- ))}
620
+ )}
283
621
  </div>
284
622
  </div>
285
623
 
624
+ <div className="flex items-center justify-between mb-3 mt-8">
625
+ <h3 className="text-xs font-black uppercase tracking-widest text-muted-foreground/60">
626
+ Active Rules
627
+ </h3>
628
+ <button
629
+ type="button"
630
+ onClick={() => setShowAddRule(!showAddRule)}
631
+ className="text-[10px] font-black uppercase tracking-widest text-primary hover:underline border-none bg-transparent cursor-pointer"
632
+ >
633
+ {showAddRule ? 'Cancel' : '+ Add Rule'}
634
+ </button>
635
+ </div>
636
+
637
+ {showAddRule && (
638
+ <form
639
+ onSubmit={async (e) => {
640
+ e.preventDefault()
641
+ const formData = new FormData(e.currentTarget)
642
+ const rule = {
643
+ id: Math.random().toString(36).substring(7),
644
+ name: formData.get('name'),
645
+ type: formData.get('type'),
646
+ threshold: parseInt(formData.get('threshold') as string, 10),
647
+ cooldownMinutes: parseInt(formData.get('cooldown') as string, 10),
648
+ queue: formData.get('queue') || undefined,
649
+ }
650
+ await fetch('/api/alerts/rules', {
651
+ method: 'POST',
652
+ headers: { 'Content-Type': 'application/json' },
653
+ body: JSON.stringify(rule),
654
+ })
655
+ setShowAddRule(false)
656
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
657
+ }}
658
+ className="p-4 bg-muted/40 rounded-xl border border-primary/20 space-y-4 mb-6"
659
+ >
660
+ <div className="grid grid-cols-2 gap-4">
661
+ <div className="space-y-1">
662
+ <label
663
+ htmlFor="rule-name"
664
+ className="text-[10px] font-black uppercase text-muted-foreground"
665
+ >
666
+ Rule Name
667
+ </label>
668
+ <input
669
+ id="rule-name"
670
+ name="name"
671
+ required
672
+ placeholder="High CPU"
673
+ className="w-full bg-background border border-border/50 rounded-lg px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/30"
674
+ />
675
+ </div>
676
+ <div className="space-y-1">
677
+ <label
678
+ htmlFor="rule-type"
679
+ className="text-[10px] font-black uppercase text-muted-foreground"
680
+ >
681
+ Type
682
+ </label>
683
+ <select
684
+ id="rule-type"
685
+ name="type"
686
+ className="w-full bg-background border border-border/50 rounded-lg px-3 py-2 text-sm outline-none cursor-pointer"
687
+ >
688
+ <option value="backlog">Queue Backlog</option>
689
+ <option value="failure">High Failure Count</option>
690
+ <option value="worker_lost">Worker Loss</option>
691
+ <option value="node_cpu">Node CPU (%)</option>
692
+ <option value="node_ram">Node RAM (%)</option>
693
+ </select>
694
+ </div>
695
+ </div>
696
+ <div className="grid grid-cols-3 gap-4">
697
+ <div className="space-y-1">
698
+ <label
699
+ htmlFor="rule-threshold"
700
+ className="text-[10px] font-black uppercase text-muted-foreground"
701
+ >
702
+ Threshold
703
+ </label>
704
+ <input
705
+ id="rule-threshold"
706
+ name="threshold"
707
+ type="number"
708
+ required
709
+ defaultValue="80"
710
+ className="w-full bg-background border border-border/50 rounded-lg px-3 py-2 text-sm outline-none"
711
+ />
712
+ </div>
713
+ <div className="space-y-1">
714
+ <label
715
+ htmlFor="rule-cooldown"
716
+ className="text-[10px] font-black uppercase text-muted-foreground"
717
+ >
718
+ Cooldown (Min)
719
+ </label>
720
+ <input
721
+ id="rule-cooldown"
722
+ name="cooldown"
723
+ type="number"
724
+ required
725
+ defaultValue="30"
726
+ className="w-full bg-background border border-border/50 rounded-lg px-3 py-2 text-sm outline-none"
727
+ />
728
+ </div>
729
+ <div className="space-y-1">
730
+ <label
731
+ htmlFor="rule-queue"
732
+ className="text-[10px] font-black uppercase text-muted-foreground"
733
+ >
734
+ Queue (Optional)
735
+ </label>
736
+ <input
737
+ id="rule-queue"
738
+ name="queue"
739
+ placeholder="default"
740
+ className="w-full bg-background border border-border/50 rounded-lg px-3 py-2 text-sm outline-none"
741
+ />
742
+ </div>
743
+ </div>
744
+ <button
745
+ type="submit"
746
+ className="w-full py-2 bg-primary text-primary-foreground rounded-lg text-xs font-black uppercase tracking-widest shadow-lg shadow-primary/20 cursor-pointer"
747
+ >
748
+ Save Alert Rule
749
+ </button>
750
+ </form>
751
+ )}
752
+
753
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
754
+ {alertConfig?.rules?.map((rule: any) => (
755
+ <div
756
+ key={rule.id}
757
+ className="p-3 bg-muted/20 border border-border/10 rounded-xl flex items-center justify-between group"
758
+ >
759
+ <div className="flex-1">
760
+ <p className="text-[11px] font-black uppercase tracking-tight flex items-center gap-2">
761
+ {rule.name}
762
+ {rule.queue && (
763
+ <span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">
764
+ {rule.queue}
765
+ </span>
766
+ )}
767
+ </p>
768
+ <p className="text-[10px] text-muted-foreground opacity-70">
769
+ {rule.type === 'backlog'
770
+ ? `Waiting > ${rule.threshold}`
771
+ : rule.type === 'failure'
772
+ ? `Failed > ${rule.threshold}`
773
+ : rule.type === 'worker_lost'
774
+ ? `Workers < ${rule.threshold}`
775
+ : rule.type === 'node_cpu'
776
+ ? `CPU > ${rule.threshold}%`
777
+ : `RAM > ${rule.threshold}%`}
778
+ </p>
779
+ </div>
780
+ <div className="flex items-center gap-2">
781
+ <div className="px-2 py-0.5 bg-muted rounded text-[9px] font-bold text-muted-foreground">
782
+ {rule.cooldownMinutes}m
783
+ </div>
784
+ <button
785
+ type="button"
786
+ onClick={async () => {
787
+ if (confirm('Delete this alert rule?')) {
788
+ await fetch(`/api/alerts/rules/${rule.id}`, { method: 'DELETE' })
789
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
790
+ }
791
+ }}
792
+ className="p-1 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
793
+ >
794
+ <Trash2 size={12} />
795
+ </button>
796
+ </div>
797
+ </div>
798
+ ))}
799
+ </div>
800
+
286
801
  <div className="pt-4 flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-border/30">
287
802
  <p className="text-xs text-muted-foreground max-w-md">
288
- Configure <code>SLACK_WEBHOOK_URL</code> in your environment variables to receive
289
- notifications.
803
+ Configure notification channels above to receive real-time alerts.
290
804
  </p>
291
805
  <button
292
806
  type="button"
@@ -295,13 +809,12 @@ export function SettingsPage() {
295
809
  r.json()
296
810
  )
297
811
  if (res.success) {
298
- alert('Test alert dispatched to server processing loop.')
812
+ alert('Test alert dispatched to all enabled channels.')
299
813
  }
300
814
  }}
301
- disabled={!alertConfig?.webhookEnabled}
302
- className="w-full sm:w-auto px-4 py-2 border border-primary/20 hover:bg-primary/5 text-primary rounded-lg text-xs font-black uppercase tracking-widest transition-all active:scale-95 disabled:opacity-50"
815
+ className="w-full sm:w-auto px-4 py-2 border border-primary/20 hover:bg-primary/5 text-primary rounded-lg text-xs font-black uppercase tracking-widest transition-all active:scale-95 shadow-lg shadow-primary/10 cursor-pointer"
303
816
  >
304
- Test Notification
817
+ Test Dispatch Now
305
818
  </button>
306
819
  </div>
307
820
  </div>
@@ -323,48 +836,97 @@ export function SettingsPage() {
323
836
 
324
837
  <div className="space-y-6">
325
838
  <div>
326
- <div className="flex justify-between items-center mb-4">
839
+ <div className="flex items-center justify-between py-3 border-b border-border/30">
327
840
  <div>
328
841
  <h3 className="text-sm font-bold">SQL Job Archive Preservation</h3>
329
842
  <p className="text-xs text-muted-foreground">
330
843
  Keep archived jobs for a specific number of days before permanent removal.
331
844
  </p>
332
845
  </div>
333
- <div className="flex items-center gap-3">
334
- <span className="text-[10px] font-black uppercase text-muted-foreground/40 mr-2">
335
- Retention Period
336
- </span>
337
- <select
338
- className="bg-muted border border-border/50 rounded-lg px-3 py-1.5 text-sm font-bold outline-none focus:ring-1 focus:ring-primary/30 transition-all cursor-pointer"
339
- defaultValue="30"
340
- id="retention-days"
341
- >
342
- <option value="7">7 Days</option>
343
- <option value="15">15 Days</option>
344
- <option value="30">30 Days</option>
345
- <option value="90">90 Days</option>
346
- <option value="365">1 Year</option>
347
- </select>
846
+ <div className="flex items-center gap-6">
847
+ <div className="flex items-center gap-3">
848
+ <span className="text-[10px] font-black uppercase text-muted-foreground/40">
849
+ Auto-Cleanup
850
+ </span>
851
+ <button
852
+ type="button"
853
+ onClick={async () => {
854
+ const enabled = !alertConfig?.maintenance?.autoCleanup
855
+ await fetch('/api/maintenance/config', {
856
+ method: 'POST',
857
+ headers: { 'Content-Type': 'application/json' },
858
+ body: JSON.stringify({
859
+ ...alertConfig.maintenance,
860
+ autoCleanup: enabled,
861
+ }),
862
+ })
863
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
864
+ }}
865
+ className={cn(
866
+ 'w-10 h-5 rounded-full p-1 transition-all flex items-center',
867
+ alertConfig?.maintenance?.autoCleanup
868
+ ? 'bg-green-500 justify-end'
869
+ : 'bg-muted justify-start'
870
+ )}
871
+ >
872
+ <div className="w-3 h-3 bg-white rounded-full shadow-sm" />
873
+ </button>
874
+ </div>
875
+
876
+ <div className="flex items-center gap-3">
877
+ <span className="text-[10px] font-black uppercase text-muted-foreground/40">
878
+ Retention Days
879
+ </span>
880
+ <select
881
+ className="bg-muted border border-border/50 rounded-lg px-3 py-1.5 text-sm font-bold outline-none focus:ring-1 focus:ring-primary/30 transition-all cursor-pointer"
882
+ value={alertConfig?.maintenance?.retentionDays || 30}
883
+ onChange={async (e) => {
884
+ const days = parseInt(e.target.value, 10)
885
+ await fetch('/api/maintenance/config', {
886
+ method: 'POST',
887
+ headers: { 'Content-Type': 'application/json' },
888
+ body: JSON.stringify({
889
+ ...alertConfig.maintenance,
890
+ retentionDays: days,
891
+ }),
892
+ })
893
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
894
+ }}
895
+ >
896
+ <option value="7">7 Days</option>
897
+ <option value="15">15 Days</option>
898
+ <option value="30">30 Days</option>
899
+ <option value="90">90 Days</option>
900
+ <option value="365">1 Year</option>
901
+ </select>
902
+ </div>
348
903
  </div>
349
904
  </div>
350
905
 
351
906
  <div className="bg-red-500/5 border border-red-500/10 rounded-xl p-4 flex items-center justify-between">
352
907
  <div className="flex items-center gap-3">
353
908
  <Info size={16} className="text-red-500/60" />
354
- <span className="text-xs font-medium text-red-900/60 dark:text-red-400/60">
355
- Manual prune will remove all jobs older than the selected period.
356
- </span>
909
+ <div className="flex flex-col">
910
+ <span className="text-xs font-medium text-red-900/60 dark:text-red-400/60">
911
+ Manual prune will remove all jobs older than the selected period.
912
+ </span>
913
+ {alertConfig?.maintenance?.lastRun && (
914
+ <span className="text-[10px] text-muted-foreground/60">
915
+ Last auto-cleanup run:{' '}
916
+ {new Date(alertConfig.maintenance.lastRun).toLocaleString()}
917
+ </span>
918
+ )}
919
+ </div>
357
920
  </div>
358
921
  <button
359
922
  type="button"
360
923
  onClick={async () => {
361
- const days = (document.getElementById('retention-days') as HTMLSelectElement)
362
- .value
924
+ const days = alertConfig?.maintenance?.retentionDays || 30
363
925
  if (confirm(`Are you sure you want to prune logs older than ${days} days?`)) {
364
926
  const res = await fetch('/api/maintenance/cleanup-archive', {
365
927
  method: 'POST',
366
928
  headers: { 'Content-Type': 'application/json' },
367
- body: JSON.stringify({ days: parseInt(days, 10) }),
929
+ body: JSON.stringify({ days }),
368
930
  }).then((r) => r.json())
369
931
  alert(`Cleanup complete. Removed ${res.deleted || 0} archived jobs.`)
370
932
  }
@@ -409,12 +971,12 @@ export function SettingsPage() {
409
971
  <div className="space-y-4">
410
972
  <div className="flex items-center justify-between py-3 border-b border-border/30">
411
973
  <span className="font-medium">Version</span>
412
- <span className="text-sm font-bold">0.1.0-alpha.1</span>
974
+ <span className="text-sm font-bold">{systemStatus?.version || '0.1.0-alpha.1'}</span>
413
975
  </div>
414
976
  <div className="flex items-center justify-between py-3 border-b border-border/30">
415
977
  <span className="font-medium">Package</span>
416
978
  <code className="text-sm bg-muted px-2 py-1 rounded font-mono">
417
- @gravito/flux-console
979
+ {systemStatus?.package || '@gravito/flux-console'}
418
980
  </code>
419
981
  </div>
420
982
  <div className="flex items-center justify-between py-3">