@gravito/zenith 1.1.0 → 1.1.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 (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/bin.js +12048 -4366
  3. package/dist/client/assets/index-BSMp8oq_.js +436 -0
  4. package/dist/client/assets/index-BwxlHx-_.css +1 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/server/index.js +12048 -4366
  7. package/package.json +1 -1
  8. package/scripts/verify-throttle.ts +6 -2
  9. package/scripts/worker.ts +2 -1
  10. package/src/client/Layout.tsx +10 -0
  11. package/src/client/Sidebar.tsx +9 -0
  12. package/src/client/ThroughputChart.tsx +9 -0
  13. package/src/client/WorkerStatus.tsx +14 -3
  14. package/src/client/components/BrandIcons.tsx +30 -0
  15. package/src/client/components/ConfirmDialog.tsx +24 -0
  16. package/src/client/components/JobInspector.tsx +18 -0
  17. package/src/client/components/LogArchiveModal.tsx +51 -2
  18. package/src/client/components/NotificationBell.tsx +9 -0
  19. package/src/client/components/PageHeader.tsx +9 -0
  20. package/src/client/components/Toaster.tsx +10 -0
  21. package/src/client/components/UserProfileDropdown.tsx +9 -0
  22. package/src/client/contexts/AuthContext.tsx +12 -0
  23. package/src/client/contexts/NotificationContext.tsx +25 -0
  24. package/src/client/pages/LoginPage.tsx +9 -0
  25. package/src/client/pages/MetricsPage.tsx +9 -0
  26. package/src/client/pages/OverviewPage.tsx +9 -0
  27. package/src/client/pages/PulsePage.tsx +10 -0
  28. package/src/client/pages/QueuesPage.tsx +9 -0
  29. package/src/client/pages/SchedulesPage.tsx +9 -0
  30. package/src/client/pages/SettingsPage.tsx +9 -0
  31. package/src/client/pages/WorkersPage.tsx +10 -0
  32. package/src/client/utils.ts +9 -0
  33. package/src/server/config/ServerConfigManager.ts +87 -0
  34. package/src/server/index.ts +19 -18
  35. package/src/server/services/AlertService.ts +16 -3
  36. package/src/server/services/LogStreamProcessor.ts +93 -0
  37. package/src/server/services/MaintenanceScheduler.ts +78 -0
  38. package/src/server/services/PulseService.ts +12 -1
  39. package/src/server/services/QueueMetricsCollector.ts +138 -0
  40. package/src/server/services/QueueService.ts +29 -283
  41. package/src/shared/types.ts +126 -27
  42. package/vite.config.ts +1 -1
  43. package/dist/client/assets/index-C332gZ-J.css +0 -1
  44. package/dist/client/assets/index-D4HibwTK.js +0 -436
@@ -0,0 +1,87 @@
1
+ import { DB } from '@gravito/atlas'
2
+ import { MySQLPersistence, SQLitePersistence } from '@gravito/stream'
3
+
4
+ export interface ServerConfig {
5
+ port: number
6
+ redisUrl: string
7
+ queuePrefix: string
8
+ dbDriver: 'sqlite' | 'mysql'
9
+ dbConfig: {
10
+ name?: string
11
+ host?: string
12
+ port?: number
13
+ username?: string
14
+ password?: string
15
+ }
16
+ persistence?: {
17
+ adapter: any
18
+ archiveCompleted: boolean
19
+ archiveFailed: boolean
20
+ archiveEnqueued: boolean
21
+ }
22
+ }
23
+
24
+ export class ServerConfigManager {
25
+ static load(): ServerConfig {
26
+ const dbDriver = (process.env.DB_DRIVER || (process.env.DB_HOST ? 'mysql' : 'sqlite')) as
27
+ | 'sqlite'
28
+ | 'mysql'
29
+
30
+ const dbConfig: ServerConfig['dbConfig'] = {}
31
+ if (dbDriver === 'mysql') {
32
+ dbConfig.host = process.env.DB_HOST
33
+ dbConfig.port = parseInt(process.env.DB_PORT || '3306', 10)
34
+ dbConfig.name = process.env.DB_NAME
35
+ dbConfig.username = process.env.DB_USER
36
+ dbConfig.password = process.env.DB_PASSWORD
37
+ } else {
38
+ dbConfig.name = process.env.DB_NAME || 'flux.sqlite'
39
+ }
40
+
41
+ let persistence: ServerConfig['persistence']
42
+
43
+ if (dbDriver === 'sqlite' || process.env.DB_HOST) {
44
+ this.setupDatabase(dbDriver, dbConfig)
45
+
46
+ const adapter = dbDriver === 'sqlite' ? new SQLitePersistence(DB) : new MySQLPersistence(DB)
47
+ adapter
48
+ .setupTable()
49
+ .catch((err) => console.error('[FluxConsole] SQL Archive Setup Error:', err))
50
+
51
+ persistence = {
52
+ adapter,
53
+ archiveCompleted: process.env.PERSIST_ARCHIVE_COMPLETED === 'true',
54
+ archiveFailed: process.env.PERSIST_ARCHIVE_FAILED !== 'false',
55
+ archiveEnqueued: process.env.PERSIST_ARCHIVE_ENQUEUED === 'true',
56
+ }
57
+ console.log(`[FluxConsole] SQL Archive enabled via ${dbDriver}`)
58
+ }
59
+
60
+ return {
61
+ port: parseInt(process.env.PORT || '3000', 10),
62
+ redisUrl: process.env.REDIS_URL || 'redis://localhost:6379',
63
+ queuePrefix: process.env.QUEUE_PREFIX || 'queue:',
64
+ dbDriver,
65
+ dbConfig,
66
+ persistence,
67
+ }
68
+ }
69
+
70
+ private static setupDatabase(driver: 'sqlite' | 'mysql', config: ServerConfig['dbConfig']): void {
71
+ if (driver === 'sqlite') {
72
+ DB.addConnection('default', {
73
+ driver: 'sqlite',
74
+ database: config.name,
75
+ })
76
+ } else {
77
+ DB.addConnection('default', {
78
+ driver,
79
+ host: config.host,
80
+ port: config.port,
81
+ database: config.name,
82
+ username: config.username,
83
+ password: config.password,
84
+ })
85
+ }
86
+ }
87
+ }
@@ -1,14 +1,14 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
1
5
  import { DB } from '@gravito/atlas'
2
6
  import { Photon } from '@gravito/photon'
3
7
  import { QuasarAgent } from '@gravito/quasar'
4
8
  import { MySQLPersistence, SQLitePersistence } from '@gravito/stream'
5
- import fs from 'fs'
6
9
  import { serveStatic } from 'hono/bun'
7
10
  import { getCookie } from 'hono/cookie'
8
11
  import { streamSSE } from 'hono/streaming'
9
- import os from 'os'
10
- import path from 'path'
11
- import { fileURLToPath } from 'url'
12
12
  import {
13
13
  authMiddleware,
14
14
  createSession,
@@ -705,7 +705,7 @@ api.get('/logs/stream', async (c) => {
705
705
  data: JSON.stringify({ nodes }),
706
706
  event: 'pulse',
707
707
  })
708
- } catch (err) {
708
+ } catch (_err) {
709
709
  // ignore errors
710
710
  }
711
711
  }, 2000)
@@ -769,26 +769,27 @@ api.get('/alerts/config', async (c) => {
769
769
  return c.json({
770
770
  rules: queueService.alerts.getRules(),
771
771
  config: queueService.alerts.getConfig(),
772
- maintenance: await queueService.getMaintenanceConfig(),
772
+ // maintenance: await queueService.getMaintenanceConfig(),
773
773
  })
774
774
  })
775
775
 
776
- api.post('/maintenance/config', async (c) => {
777
- const config = await c.req.json()
778
- try {
779
- await queueService.saveMaintenanceConfig(config)
780
- return c.json({ success: true })
781
- } catch (err) {
782
- return c.json({ error: 'Failed to save maintenance config' }, 500)
783
- }
784
- })
776
+ // Maintenance API temporarily disabled - requires ServerConfigManager enhancement
777
+ // api.post('/maintenance/config', async (c) => {
778
+ // const config = await c.req.json()
779
+ // try {
780
+ // // await queueService.saveMaintenanceConfig(config)
781
+ // return c.json({ success: true })
782
+ // } catch (_err) {
783
+ // return c.json({ error: 'Failed to save maintenance config' }, 500)
784
+ // }
785
+ // })
785
786
 
786
787
  api.post('/alerts/config', async (c) => {
787
788
  const config = await c.req.json()
788
789
  try {
789
790
  await queueService.alerts.saveConfig(config)
790
791
  return c.json({ success: true })
791
- } catch (err) {
792
+ } catch (_err) {
792
793
  return c.json({ error: 'Failed to save alert config' }, 500)
793
794
  }
794
795
  })
@@ -798,7 +799,7 @@ api.post('/alerts/rules', async (c) => {
798
799
  try {
799
800
  await queueService.alerts.addRule(rule)
800
801
  return c.json({ success: true })
801
- } catch (err) {
802
+ } catch (_err) {
802
803
  return c.json({ error: 'Failed to add rule' }, 500)
803
804
  }
804
805
  })
@@ -808,7 +809,7 @@ api.delete('/alerts/rules/:id', async (c) => {
808
809
  try {
809
810
  await queueService.alerts.deleteRule(id)
810
811
  return c.json({ success: true })
811
- } catch (err) {
812
+ } catch (_err) {
812
813
  return c.json({ error: 'Failed to delete rule' }, 500)
813
814
  }
814
815
  })
@@ -1,9 +1,18 @@
1
- import { EventEmitter } from 'events'
1
+ import { EventEmitter } from 'node:events'
2
2
  import { Redis } from 'ioredis'
3
3
  import nodemailer from 'nodemailer'
4
4
  import type { AlertConfig, AlertEvent, AlertRule, PulseNode } from '../../shared/types'
5
5
  import type { WorkerReport } from './QueueService'
6
6
 
7
+ /**
8
+ * AlertService monitors system telemetry and triggers notifications.
9
+ *
10
+ * It evaluates rules against real-time data from Quasar agents and
11
+ * dispatches alerts via Slack, Discord, or Email when thresholds are exceeded.
12
+ *
13
+ * @public
14
+ * @since 3.0.0
15
+ */
7
16
  export class AlertService {
8
17
  private redis: Redis
9
18
  private rules: AlertRule[] = []
@@ -185,7 +194,9 @@ export class AlertService {
185
194
  break
186
195
  }
187
196
  }
188
- if (fired) break
197
+ if (fired) {
198
+ break
199
+ }
189
200
  }
190
201
  break
191
202
 
@@ -200,7 +211,9 @@ export class AlertService {
200
211
  break
201
212
  }
202
213
  }
203
- if (fired) break
214
+ if (fired) {
215
+ break
216
+ }
204
217
  }
205
218
  break
206
219
  }
@@ -0,0 +1,93 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import type { Redis } from 'ioredis'
3
+ import type { SystemLog } from './QueueService'
4
+
5
+ export class LogStreamProcessor {
6
+ private static readonly MAX_LOGS_PER_SEC = 50
7
+ private logEmitter = new EventEmitter()
8
+ private logThrottleCount = 0
9
+ private logThrottleReset = Date.now()
10
+
11
+ constructor(
12
+ private redis: Redis,
13
+ private subRedis: Redis
14
+ ) {
15
+ this.setupLogSubscription()
16
+ }
17
+
18
+ /**
19
+ * Setup Redis log subscription
20
+ */
21
+ private setupLogSubscription(): void {
22
+ this.logEmitter.setMaxListeners(1000)
23
+
24
+ this.subRedis.on('message', (channel, message) => {
25
+ if (channel === 'flux_console:logs') {
26
+ this.processLogMessage(message)
27
+ }
28
+ })
29
+ }
30
+
31
+ /**
32
+ * Process incoming log message with throttling
33
+ */
34
+ private processLogMessage(message: string): void {
35
+ try {
36
+ const now = Date.now()
37
+
38
+ if (now - this.logThrottleReset > 1000) {
39
+ this.logThrottleReset = now
40
+ this.logThrottleCount = 0
41
+ }
42
+
43
+ if (this.logThrottleCount < LogStreamProcessor.MAX_LOGS_PER_SEC) {
44
+ this.logThrottleCount++
45
+ const log = JSON.parse(message) as SystemLog
46
+ this.logEmitter.emit('log', log)
47
+
48
+ if (log.level === 'success' || log.level === 'error') {
49
+ this.updateThroughput()
50
+ }
51
+ }
52
+ } catch (_e) {
53
+ // Ignore parse errors
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Update throughput counter for job completions
59
+ */
60
+ private updateThroughput(): void {
61
+ const minute = Math.floor(Date.now() / 60000)
62
+ this.redis
63
+ .incr(`flux_console:throughput:${minute}`)
64
+ .then(() => {
65
+ this.redis.expire(`flux_console:throughput:${minute}`, 3600)
66
+ })
67
+ .catch(() => {})
68
+ }
69
+
70
+ /**
71
+ * Subscribe to log stream
72
+ */
73
+ async subscribe(): Promise<void> {
74
+ await this.subRedis.subscribe('flux_console:logs')
75
+ }
76
+
77
+ /**
78
+ * Add listener for log events
79
+ */
80
+ onLog(callback: (msg: SystemLog) => void): () => void {
81
+ this.logEmitter.on('log', callback)
82
+ return () => {
83
+ this.logEmitter.off('log', callback)
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Get emitter for direct access
89
+ */
90
+ getEmitter(): EventEmitter {
91
+ return this.logEmitter
92
+ }
93
+ }
@@ -0,0 +1,78 @@
1
+ import type { Redis } from 'ioredis'
2
+
3
+ export interface MaintenanceConfig {
4
+ autoCleanup: boolean
5
+ retentionDays: number
6
+ lastRun?: number
7
+ }
8
+
9
+ export class MaintenanceScheduler {
10
+ private static readonly ONE_DAY = 24 * 60 * 60 * 1000
11
+ private static readonly CHECK_INTERVAL = 3600000 // 1 hour
12
+
13
+ constructor(
14
+ private redis: Redis,
15
+ private cleanupCallback: (retentionDays: number) => Promise<number>
16
+ ) {}
17
+
18
+ /**
19
+ * Start the maintenance loop
20
+ */
21
+ start(initialDelay = 30000): void {
22
+ setTimeout(() => {
23
+ const loop = async () => {
24
+ try {
25
+ await this.checkMaintenance()
26
+ } catch (err) {
27
+ console.error('[Maintenance] Task Error:', err)
28
+ }
29
+ setTimeout(loop, MaintenanceScheduler.CHECK_INTERVAL)
30
+ }
31
+ loop()
32
+ }, initialDelay)
33
+ }
34
+
35
+ /**
36
+ * Check and run maintenance if needed
37
+ */
38
+ private async checkMaintenance(): Promise<void> {
39
+ const config = await this.getConfig()
40
+ if (!config.autoCleanup) {
41
+ return
42
+ }
43
+
44
+ const now = Date.now()
45
+ const lastRun = config.lastRun || 0
46
+
47
+ if (now - lastRun >= MaintenanceScheduler.ONE_DAY) {
48
+ console.log(
49
+ `[Maintenance] Starting Auto-Cleanup (Retention: ${config.retentionDays} days)...`
50
+ )
51
+ const deleted = await this.cleanupCallback(config.retentionDays)
52
+ console.log(`[Maintenance] Cleanup Complete. Removed ${deleted} records.`)
53
+
54
+ await this.saveConfig({
55
+ ...config,
56
+ lastRun: now,
57
+ })
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Get maintenance configuration
63
+ */
64
+ async getConfig(): Promise<MaintenanceConfig> {
65
+ const data = await this.redis.get('gravito:zenith:maintenance:config')
66
+ if (data) {
67
+ return JSON.parse(data)
68
+ }
69
+ return { autoCleanup: false, retentionDays: 30 }
70
+ }
71
+
72
+ /**
73
+ * Save maintenance configuration
74
+ */
75
+ async saveConfig(config: MaintenanceConfig): Promise<void> {
76
+ await this.redis.set('gravito:zenith:maintenance:config', JSON.stringify(config))
77
+ }
78
+ }
@@ -1,6 +1,15 @@
1
1
  import { Redis } from 'ioredis'
2
2
  import type { PulseNode } from '../../shared/types'
3
3
 
4
+ /**
5
+ * PulseService manages the discovery and health monitoring of system nodes.
6
+ *
7
+ * It scans Redis for heartbeat keys emitted by Quasar agents and groups
8
+ * them by service name for the Zenith dashboard.
9
+ *
10
+ * @public
11
+ * @since 3.0.0
12
+ */
4
13
  export class PulseService {
5
14
  private redis: Redis
6
15
  private prefix = 'gravito:quasar:node:'
@@ -55,7 +64,9 @@ export class PulseService {
55
64
  // Sort nodes by service name, then by node id for stable UI positions
56
65
  nodes.sort((a, b) => {
57
66
  const sComp = a.service.localeCompare(b.service)
58
- if (sComp !== 0) return sComp
67
+ if (sComp !== 0) {
68
+ return sComp
69
+ }
59
70
  return a.id.localeCompare(b.id)
60
71
  })
61
72
 
@@ -0,0 +1,138 @@
1
+ import type { Redis } from 'ioredis'
2
+ import type { GlobalStats, QueueStats, WorkerReport } from './QueueService'
3
+
4
+ export class QueueMetricsCollector {
5
+ constructor(
6
+ private redis: Redis,
7
+ private prefix = 'queue:'
8
+ ) {}
9
+
10
+ /**
11
+ * Discover all queues using SCAN to avoid blocking Redis
12
+ */
13
+ async listQueues(): Promise<QueueStats[]> {
14
+ const queues = new Set<string>()
15
+ let cursor = '0'
16
+ let iterations = 0
17
+ const MAX_ITERATIONS = 1000
18
+
19
+ do {
20
+ const result = await this.redis.scan(cursor, 'MATCH', `${this.prefix}*`, 'COUNT', 100)
21
+ cursor = result[0]
22
+ const keys = result[1]
23
+
24
+ for (const key of keys) {
25
+ const relative = key.slice(this.prefix.length)
26
+ const parts = relative.split(':')
27
+ const candidateName = parts[0]
28
+
29
+ if (candidateName && !this.isSystemKey(candidateName)) {
30
+ queues.add(candidateName)
31
+ }
32
+ }
33
+
34
+ iterations++
35
+ } while (cursor !== '0' && iterations < MAX_ITERATIONS)
36
+
37
+ return this.collectQueueStats(Array.from(queues).sort())
38
+ }
39
+
40
+ /**
41
+ * Collect statistics for a list of queues
42
+ */
43
+ private async collectQueueStats(queueNames: string[]): Promise<QueueStats[]> {
44
+ const stats: QueueStats[] = []
45
+ const BATCH_SIZE = 10
46
+
47
+ for (let i = 0; i < queueNames.length; i += BATCH_SIZE) {
48
+ const batch = queueNames.slice(i, i + BATCH_SIZE)
49
+ const batchResults = await Promise.all(batch.map(async (name) => this.getQueueStats(name)))
50
+ stats.push(...batchResults)
51
+ }
52
+
53
+ return stats
54
+ }
55
+
56
+ /**
57
+ * Get statistics for a single queue
58
+ */
59
+ private async getQueueStats(name: string): Promise<QueueStats> {
60
+ const waiting = await this.redis.llen(`${this.prefix}${name}`)
61
+ const delayed = await this.redis.zcard(`${this.prefix}${name}:delayed`)
62
+ const failed = await this.redis.llen(`${this.prefix}${name}:failed`)
63
+ const active = await this.redis.scard(`${this.prefix}${name}:active`)
64
+ const paused = await this.redis.get(`${this.prefix}${name}:paused`)
65
+
66
+ return {
67
+ name,
68
+ waiting,
69
+ delayed,
70
+ failed,
71
+ active,
72
+ paused: paused === '1',
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Get global statistics including throughput
78
+ */
79
+ async getGlobalStats(): Promise<GlobalStats> {
80
+ const queues = await this.listQueues()
81
+ const throughput = await this.getThroughputData()
82
+
83
+ return {
84
+ queues,
85
+ throughput,
86
+ workers: [],
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Get throughput data for the last hour
92
+ */
93
+ private async getThroughputData(): Promise<{ timestamp: string; count: number }[]> {
94
+ const data: { timestamp: string; count: number }[] = []
95
+ const currentMinute = Math.floor(Date.now() / 60000)
96
+
97
+ for (let i = 0; i < 60; i++) {
98
+ const minute = currentMinute - i
99
+ const count = await this.redis.get(`flux_console:throughput:${minute}`)
100
+ data.push({
101
+ timestamp: new Date(minute * 60000).toISOString(),
102
+ count: Number(count) || 0,
103
+ })
104
+ }
105
+
106
+ return data.reverse()
107
+ }
108
+
109
+ /**
110
+ * Get reports from all active workers
111
+ */
112
+ async listWorkers(): Promise<WorkerReport[]> {
113
+ const workers = await this.redis.smembers('flux_console:workers')
114
+
115
+ return Promise.all(workers.map(async (workerId) => this.getWorkerReport(workerId)))
116
+ }
117
+
118
+ /**
119
+ * Get report from a specific worker
120
+ */
121
+ private async getWorkerReport(workerId: string): Promise<WorkerReport> {
122
+ const data = await this.redis.get(`flux_console:worker:${workerId}`)
123
+
124
+ if (!data) {
125
+ throw new Error(`Worker ${workerId} not found`)
126
+ }
127
+
128
+ return JSON.parse(data) as WorkerReport
129
+ }
130
+
131
+ /**
132
+ * Check if a key is a system key
133
+ */
134
+ private isSystemKey(key: string): boolean {
135
+ const systemKeys = ['active', 'schedules', 'schedule', 'lock']
136
+ return systemKeys.includes(key)
137
+ }
138
+ }