@gravito/zenith 0.1.0-beta.1 → 1.0.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.
@@ -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,522 @@ 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) return
295
+ await fetch('/api/alerts/config', {
296
+ method: 'POST',
297
+ headers: { 'Content-Type': 'application/json' },
298
+ body: JSON.stringify({
299
+ ...alertConfig.config,
300
+ channels: {
301
+ ...alertConfig.config.channels,
302
+ slack: {
303
+ ...alertConfig?.config?.channels?.slack,
304
+ webhookUrl: val,
305
+ },
306
+ },
307
+ }),
308
+ })
309
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
310
+ }}
311
+ 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"
312
+ />
313
+ </div>
255
314
  </div>
256
- </div>
257
315
 
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"
316
+ {/* Discord */}
317
+ <div className="p-4 bg-muted/20 rounded-xl border border-border/10">
318
+ <div className="flex items-center justify-between mb-4">
319
+ <div className="flex items-center gap-3">
320
+ <div className="w-8 h-8 rounded-lg bg-[#5865F2]/10 flex items-center justify-center text-[#5865F2]">
321
+ <Monitor size={16} />
322
+ </div>
323
+ <div>
324
+ <h4 className="text-sm font-bold">Discord</h4>
325
+ <p className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">
326
+ Webhook integration for servers
327
+ </p>
328
+ </div>
329
+ </div>
330
+ <button
331
+ type="button"
332
+ onClick={async () => {
333
+ const enabled = !alertConfig?.config?.channels?.discord?.enabled
334
+ const current = alertConfig?.config?.channels?.discord || {}
335
+ await fetch('/api/alerts/config', {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({
339
+ ...alertConfig.config,
340
+ channels: {
341
+ ...alertConfig.config.channels,
342
+ discord: { ...current, enabled },
343
+ },
344
+ }),
345
+ })
346
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
347
+ }}
348
+ className={cn(
349
+ 'px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all',
350
+ alertConfig?.config?.channels?.discord?.enabled
351
+ ? 'bg-green-500 text-white shadow-lg shadow-green-500/20'
352
+ : 'bg-muted-foreground/20 text-muted-foreground'
353
+ )}
267
354
  >
355
+ {alertConfig?.config?.channels?.discord?.enabled ? 'Enabled' : 'Disabled'}
356
+ </button>
357
+ </div>
358
+ <div className="flex gap-3">
359
+ <input
360
+ type="password"
361
+ placeholder="https://discord.com/api/webhooks/..."
362
+ defaultValue={alertConfig?.config?.channels?.discord?.webhookUrl || ''}
363
+ onBlur={async (e) => {
364
+ const val = e.target.value
365
+ if (val === alertConfig?.config?.channels?.discord?.webhookUrl) return
366
+ await fetch('/api/alerts/config', {
367
+ method: 'POST',
368
+ headers: { 'Content-Type': 'application/json' },
369
+ body: JSON.stringify({
370
+ ...alertConfig.config,
371
+ channels: {
372
+ ...alertConfig.config.channels,
373
+ discord: {
374
+ ...alertConfig?.config?.channels?.discord,
375
+ webhookUrl: val,
376
+ },
377
+ },
378
+ }),
379
+ })
380
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
381
+ }}
382
+ 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"
383
+ />
384
+ </div>
385
+ </div>
386
+
387
+ {/* Email (SMTP) */}
388
+ <div className="p-4 bg-muted/20 rounded-xl border border-border/10">
389
+ <div className="flex items-center justify-between mb-4">
390
+ <div className="flex items-center gap-3">
391
+ <div className="w-8 h-8 rounded-lg bg-red-500/10 flex items-center justify-center text-red-500">
392
+ <Info size={16} />
393
+ </div>
268
394
  <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}`}
395
+ <h4 className="text-sm font-bold">Email (SMTP)</h4>
396
+ <p className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">
397
+ Standard mail delivery
276
398
  </p>
277
399
  </div>
278
- <div className="px-2 py-0.5 bg-muted rounded text-[9px] font-bold text-muted-foreground">
279
- {rule.cooldownMinutes}m cooldown
400
+ </div>
401
+ <button
402
+ type="button"
403
+ onClick={async () => {
404
+ const enabled = !alertConfig?.config?.channels?.email?.enabled
405
+ const current = alertConfig?.config?.channels?.email || {}
406
+ await fetch('/api/alerts/config', {
407
+ method: 'POST',
408
+ headers: { 'Content-Type': 'application/json' },
409
+ body: JSON.stringify({
410
+ ...alertConfig.config,
411
+ channels: {
412
+ ...alertConfig.config.channels,
413
+ email: { ...current, enabled },
414
+ },
415
+ }),
416
+ })
417
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
418
+ }}
419
+ className={cn(
420
+ 'px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest transition-all',
421
+ alertConfig?.config?.channels?.email?.enabled
422
+ ? 'bg-green-500 text-white shadow-lg shadow-green-500/20'
423
+ : 'bg-muted-foreground/20 text-muted-foreground'
424
+ )}
425
+ >
426
+ {alertConfig?.config?.channels?.email?.enabled ? 'Enabled' : 'Disabled'}
427
+ </button>
428
+ </div>
429
+
430
+ {alertConfig?.config?.channels?.email?.enabled && (
431
+ <div className="grid grid-cols-2 gap-3 mt-4 animate-in fade-in slide-in-from-top-2">
432
+ <div className="col-span-2 space-y-1">
433
+ <label className="text-[9px] font-black uppercase text-muted-foreground/60">
434
+ Destination Address
435
+ </label>
436
+ <input
437
+ placeholder="admin@example.com"
438
+ defaultValue={alertConfig?.config?.channels?.email?.to || ''}
439
+ onBlur={async (e) => {
440
+ const val = e.target.value
441
+ await fetch('/api/alerts/config', {
442
+ method: 'POST',
443
+ headers: { 'Content-Type': 'application/json' },
444
+ body: JSON.stringify({
445
+ ...alertConfig.config,
446
+ channels: {
447
+ ...alertConfig.config.channels,
448
+ email: {
449
+ ...alertConfig?.config?.channels?.email,
450
+ to: val,
451
+ },
452
+ },
453
+ }),
454
+ })
455
+ }}
456
+ 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"
457
+ />
458
+ </div>
459
+ <div className="space-y-1">
460
+ <label className="text-[9px] font-black uppercase text-muted-foreground/60">
461
+ SMTP Host
462
+ </label>
463
+ <input
464
+ placeholder="smtp.gmail.com"
465
+ defaultValue={alertConfig?.config?.channels?.email?.smtpHost || ''}
466
+ onBlur={async (e) => {
467
+ await fetch('/api/alerts/config', {
468
+ method: 'POST',
469
+ headers: { 'Content-Type': 'application/json' },
470
+ body: JSON.stringify({
471
+ ...alertConfig.config,
472
+ channels: {
473
+ ...alertConfig.config.channels,
474
+ email: {
475
+ ...alertConfig?.config?.channels?.email,
476
+ smtpHost: e.target.value,
477
+ },
478
+ },
479
+ }),
480
+ })
481
+ }}
482
+ 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"
483
+ />
484
+ </div>
485
+ <div className="space-y-1">
486
+ <label className="text-[9px] font-black uppercase text-muted-foreground/60">
487
+ Port
488
+ </label>
489
+ <input
490
+ type="number"
491
+ placeholder="465"
492
+ defaultValue={alertConfig?.config?.channels?.email?.smtpPort || 465}
493
+ onBlur={async (e) => {
494
+ await fetch('/api/alerts/config', {
495
+ method: 'POST',
496
+ headers: { 'Content-Type': 'application/json' },
497
+ body: JSON.stringify({
498
+ ...alertConfig.config,
499
+ channels: {
500
+ ...alertConfig.config.channels,
501
+ email: {
502
+ ...alertConfig?.config?.channels?.email,
503
+ smtpPort: parseInt(e.target.value, 10),
504
+ },
505
+ },
506
+ }),
507
+ })
508
+ }}
509
+ 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"
510
+ />
511
+ </div>
512
+ <div className="space-y-1">
513
+ <label className="text-[9px] font-black uppercase text-muted-foreground/60">
514
+ Username
515
+ </label>
516
+ <input
517
+ placeholder="user@example.com"
518
+ defaultValue={alertConfig?.config?.channels?.email?.smtpUser || ''}
519
+ onBlur={async (e) => {
520
+ await fetch('/api/alerts/config', {
521
+ method: 'POST',
522
+ headers: { 'Content-Type': 'application/json' },
523
+ body: JSON.stringify({
524
+ ...alertConfig.config,
525
+ channels: {
526
+ ...alertConfig.config.channels,
527
+ email: {
528
+ ...alertConfig?.config?.channels?.email,
529
+ smtpUser: e.target.value,
530
+ },
531
+ },
532
+ }),
533
+ })
534
+ }}
535
+ 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"
536
+ />
537
+ </div>
538
+ <div className="space-y-1">
539
+ <label className="text-[9px] font-black uppercase text-muted-foreground/60">
540
+ Password
541
+ </label>
542
+ <input
543
+ type="password"
544
+ placeholder="••••••••"
545
+ defaultValue={alertConfig?.config?.channels?.email?.smtpPass || ''}
546
+ onBlur={async (e) => {
547
+ await fetch('/api/alerts/config', {
548
+ method: 'POST',
549
+ headers: { 'Content-Type': 'application/json' },
550
+ body: JSON.stringify({
551
+ ...alertConfig.config,
552
+ channels: {
553
+ ...alertConfig.config.channels,
554
+ email: {
555
+ ...alertConfig?.config?.channels?.email,
556
+ smtpPass: e.target.value,
557
+ },
558
+ },
559
+ }),
560
+ })
561
+ }}
562
+ 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"
563
+ />
564
+ </div>
565
+ <div className="col-span-2 space-y-1">
566
+ <label className="text-[9px] font-black uppercase text-muted-foreground/60">
567
+ From Address
568
+ </label>
569
+ <input
570
+ placeholder="Zenith Monitor <noreply@example.com>"
571
+ defaultValue={alertConfig?.config?.channels?.email?.from || ''}
572
+ onBlur={async (e) => {
573
+ await fetch('/api/alerts/config', {
574
+ method: 'POST',
575
+ headers: { 'Content-Type': 'application/json' },
576
+ body: JSON.stringify({
577
+ ...alertConfig.config,
578
+ channels: {
579
+ ...alertConfig.config.channels,
580
+ email: {
581
+ ...alertConfig?.config?.channels?.email,
582
+ from: e.target.value,
583
+ },
584
+ },
585
+ }),
586
+ })
587
+ }}
588
+ 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"
589
+ />
280
590
  </div>
281
591
  </div>
282
- ))}
592
+ )}
283
593
  </div>
284
594
  </div>
285
595
 
596
+ <div className="flex items-center justify-between mb-3 mt-8">
597
+ <h3 className="text-xs font-black uppercase tracking-widest text-muted-foreground/60">
598
+ Active Rules
599
+ </h3>
600
+ <button
601
+ onClick={() => setShowAddRule(!showAddRule)}
602
+ className="text-[10px] font-black uppercase tracking-widest text-primary hover:underline border-none bg-transparent cursor-pointer"
603
+ >
604
+ {showAddRule ? 'Cancel' : '+ Add Rule'}
605
+ </button>
606
+ </div>
607
+
608
+ {showAddRule && (
609
+ <form
610
+ onSubmit={async (e) => {
611
+ e.preventDefault()
612
+ const formData = new FormData(e.currentTarget)
613
+ const rule = {
614
+ id: Math.random().toString(36).substring(7),
615
+ name: formData.get('name'),
616
+ type: formData.get('type'),
617
+ threshold: parseInt(formData.get('threshold') as string, 10),
618
+ cooldownMinutes: parseInt(formData.get('cooldown') as string, 10),
619
+ queue: formData.get('queue') || undefined,
620
+ }
621
+ await fetch('/api/alerts/rules', {
622
+ method: 'POST',
623
+ headers: { 'Content-Type': 'application/json' },
624
+ body: JSON.stringify(rule),
625
+ })
626
+ setShowAddRule(false)
627
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
628
+ }}
629
+ className="p-4 bg-muted/40 rounded-xl border border-primary/20 space-y-4 mb-6"
630
+ >
631
+ <div className="grid grid-cols-2 gap-4">
632
+ <div className="space-y-1">
633
+ <label className="text-[10px] font-black uppercase text-muted-foreground">
634
+ Rule Name
635
+ </label>
636
+ <input
637
+ name="name"
638
+ required
639
+ placeholder="High CPU"
640
+ 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"
641
+ />
642
+ </div>
643
+ <div className="space-y-1">
644
+ <label className="text-[10px] font-black uppercase text-muted-foreground">
645
+ Type
646
+ </label>
647
+ <select
648
+ name="type"
649
+ className="w-full bg-background border border-border/50 rounded-lg px-3 py-2 text-sm outline-none cursor-pointer"
650
+ >
651
+ <option value="backlog">Queue Backlog</option>
652
+ <option value="failure">High Failure Count</option>
653
+ <option value="worker_lost">Worker Loss</option>
654
+ <option value="node_cpu">Node CPU (%)</option>
655
+ <option value="node_ram">Node RAM (%)</option>
656
+ </select>
657
+ </div>
658
+ </div>
659
+ <div className="grid grid-cols-3 gap-4">
660
+ <div className="space-y-1">
661
+ <label className="text-[10px] font-black uppercase text-muted-foreground">
662
+ Threshold
663
+ </label>
664
+ <input
665
+ name="threshold"
666
+ type="number"
667
+ required
668
+ defaultValue="80"
669
+ className="w-full bg-background border border-border/50 rounded-lg px-3 py-2 text-sm outline-none"
670
+ />
671
+ </div>
672
+ <div className="space-y-1">
673
+ <label className="text-[10px] font-black uppercase text-muted-foreground">
674
+ Cooldown (Min)
675
+ </label>
676
+ <input
677
+ name="cooldown"
678
+ type="number"
679
+ required
680
+ defaultValue="30"
681
+ className="w-full bg-background border border-border/50 rounded-lg px-3 py-2 text-sm outline-none"
682
+ />
683
+ </div>
684
+ <div className="space-y-1">
685
+ <label className="text-[10px] font-black uppercase text-muted-foreground">
686
+ Queue (Optional)
687
+ </label>
688
+ <input
689
+ name="queue"
690
+ placeholder="default"
691
+ className="w-full bg-background border border-border/50 rounded-lg px-3 py-2 text-sm outline-none"
692
+ />
693
+ </div>
694
+ </div>
695
+ <button
696
+ type="submit"
697
+ 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"
698
+ >
699
+ Save Alert Rule
700
+ </button>
701
+ </form>
702
+ )}
703
+
704
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
705
+ {alertConfig?.rules?.map((rule: any) => (
706
+ <div
707
+ key={rule.id}
708
+ className="p-3 bg-muted/20 border border-border/10 rounded-xl flex items-center justify-between group"
709
+ >
710
+ <div className="flex-1">
711
+ <p className="text-[11px] font-black uppercase tracking-tight flex items-center gap-2">
712
+ {rule.name}
713
+ {rule.queue && (
714
+ <span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">
715
+ {rule.queue}
716
+ </span>
717
+ )}
718
+ </p>
719
+ <p className="text-[10px] text-muted-foreground opacity-70">
720
+ {rule.type === 'backlog'
721
+ ? `Waiting > ${rule.threshold}`
722
+ : rule.type === 'failure'
723
+ ? `Failed > ${rule.threshold}`
724
+ : rule.type === 'worker_lost'
725
+ ? `Workers < ${rule.threshold}`
726
+ : rule.type === 'node_cpu'
727
+ ? `CPU > ${rule.threshold}%`
728
+ : `RAM > ${rule.threshold}%`}
729
+ </p>
730
+ </div>
731
+ <div className="flex items-center gap-2">
732
+ <div className="px-2 py-0.5 bg-muted rounded text-[9px] font-bold text-muted-foreground">
733
+ {rule.cooldownMinutes}m
734
+ </div>
735
+ <button
736
+ onClick={async () => {
737
+ if (confirm('Delete this alert rule?')) {
738
+ await fetch(`/api/alerts/rules/${rule.id}`, { method: 'DELETE' })
739
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
740
+ }
741
+ }}
742
+ className="p-1 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
743
+ >
744
+ <Trash2 size={12} />
745
+ </button>
746
+ </div>
747
+ </div>
748
+ ))}
749
+ </div>
750
+
286
751
  <div className="pt-4 flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-border/30">
287
752
  <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.
753
+ Configure notification channels above to receive real-time alerts.
290
754
  </p>
291
755
  <button
292
756
  type="button"
@@ -295,13 +759,12 @@ export function SettingsPage() {
295
759
  r.json()
296
760
  )
297
761
  if (res.success) {
298
- alert('Test alert dispatched to server processing loop.')
762
+ alert('Test alert dispatched to all enabled channels.')
299
763
  }
300
764
  }}
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"
765
+ 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
766
  >
304
- Test Notification
767
+ Test Dispatch Now
305
768
  </button>
306
769
  </div>
307
770
  </div>
@@ -323,48 +786,93 @@ export function SettingsPage() {
323
786
 
324
787
  <div className="space-y-6">
325
788
  <div>
326
- <div className="flex justify-between items-center mb-4">
789
+ <div className="flex items-center justify-between py-3 border-b border-border/30">
327
790
  <div>
328
791
  <h3 className="text-sm font-bold">SQL Job Archive Preservation</h3>
329
792
  <p className="text-xs text-muted-foreground">
330
793
  Keep archived jobs for a specific number of days before permanent removal.
331
794
  </p>
332
795
  </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>
796
+ <div className="flex items-center gap-6">
797
+ <div className="flex items-center gap-3">
798
+ <span className="text-[10px] font-black uppercase text-muted-foreground/40">
799
+ Auto-Cleanup
800
+ </span>
801
+ <button
802
+ onClick={async () => {
803
+ const enabled = !alertConfig?.maintenance?.autoCleanup
804
+ await fetch('/api/maintenance/config', {
805
+ method: 'POST',
806
+ headers: { 'Content-Type': 'application/json' },
807
+ body: JSON.stringify({
808
+ ...alertConfig.maintenance,
809
+ autoCleanup: enabled,
810
+ }),
811
+ })
812
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
813
+ }}
814
+ className={cn(
815
+ 'w-10 h-5 rounded-full p-1 transition-all flex items-center',
816
+ alertConfig?.maintenance?.autoCleanup ? 'bg-green-500 justify-end' : 'bg-muted justify-start'
817
+ )}
818
+ >
819
+ <div className="w-3 h-3 bg-white rounded-full shadow-sm" />
820
+ </button>
821
+ </div>
822
+
823
+ <div className="flex items-center gap-3">
824
+ <span className="text-[10px] font-black uppercase text-muted-foreground/40">
825
+ Retention Days
826
+ </span>
827
+ <select
828
+ 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"
829
+ value={alertConfig?.maintenance?.retentionDays || 30}
830
+ onChange={async (e) => {
831
+ const days = parseInt(e.target.value, 10)
832
+ await fetch('/api/maintenance/config', {
833
+ method: 'POST',
834
+ headers: { 'Content-Type': 'application/json' },
835
+ body: JSON.stringify({
836
+ ...alertConfig.maintenance,
837
+ retentionDays: days,
838
+ }),
839
+ })
840
+ queryClient.invalidateQueries({ queryKey: ['alerts-config'] })
841
+ }}
842
+ >
843
+ <option value="7">7 Days</option>
844
+ <option value="15">15 Days</option>
845
+ <option value="30">30 Days</option>
846
+ <option value="90">90 Days</option>
847
+ <option value="365">1 Year</option>
848
+ </select>
849
+ </div>
348
850
  </div>
349
851
  </div>
350
852
 
351
853
  <div className="bg-red-500/5 border border-red-500/10 rounded-xl p-4 flex items-center justify-between">
352
854
  <div className="flex items-center gap-3">
353
855
  <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>
856
+ <div className="flex flex-col">
857
+ <span className="text-xs font-medium text-red-900/60 dark:text-red-400/60">
858
+ Manual prune will remove all jobs older than the selected period.
859
+ </span>
860
+ {alertConfig?.maintenance?.lastRun && (
861
+ <span className="text-[10px] text-muted-foreground/60">
862
+ Last auto-cleanup run: {new Date(alertConfig.maintenance.lastRun).toLocaleString()}
863
+ </span>
864
+ )}
865
+ </div>
357
866
  </div>
358
867
  <button
359
868
  type="button"
360
869
  onClick={async () => {
361
- const days = (document.getElementById('retention-days') as HTMLSelectElement)
362
- .value
870
+ const days = alertConfig?.maintenance?.retentionDays || 30
363
871
  if (confirm(`Are you sure you want to prune logs older than ${days} days?`)) {
364
872
  const res = await fetch('/api/maintenance/cleanup-archive', {
365
873
  method: 'POST',
366
874
  headers: { 'Content-Type': 'application/json' },
367
- body: JSON.stringify({ days: parseInt(days, 10) }),
875
+ body: JSON.stringify({ days }),
368
876
  }).then((r) => r.json())
369
877
  alert(`Cleanup complete. Removed ${res.deleted || 0} archived jobs.`)
370
878
  }
@@ -409,12 +917,12 @@ export function SettingsPage() {
409
917
  <div className="space-y-4">
410
918
  <div className="flex items-center justify-between py-3 border-b border-border/30">
411
919
  <span className="font-medium">Version</span>
412
- <span className="text-sm font-bold">0.1.0-alpha.1</span>
920
+ <span className="text-sm font-bold">{systemStatus?.version || '0.1.0-alpha.1'}</span>
413
921
  </div>
414
922
  <div className="flex items-center justify-between py-3 border-b border-border/30">
415
923
  <span className="font-medium">Package</span>
416
924
  <code className="text-sm bg-muted px-2 py-1 rounded font-mono">
417
- @gravito/flux-console
925
+ {systemStatus?.package || '@gravito/flux-console'}
418
926
  </code>
419
927
  </div>
420
928
  <div className="flex items-center justify-between py-3">