@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.
- package/CHANGELOG.md +8 -0
- package/dist/bin.js +12048 -4366
- package/dist/client/assets/index-BSMp8oq_.js +436 -0
- package/dist/client/assets/index-BwxlHx-_.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/index.js +12048 -4366
- package/package.json +1 -1
- package/scripts/verify-throttle.ts +6 -2
- package/scripts/worker.ts +2 -1
- package/src/client/Layout.tsx +10 -0
- package/src/client/Sidebar.tsx +9 -0
- package/src/client/ThroughputChart.tsx +9 -0
- package/src/client/WorkerStatus.tsx +14 -3
- package/src/client/components/BrandIcons.tsx +30 -0
- package/src/client/components/ConfirmDialog.tsx +24 -0
- package/src/client/components/JobInspector.tsx +18 -0
- package/src/client/components/LogArchiveModal.tsx +51 -2
- package/src/client/components/NotificationBell.tsx +9 -0
- package/src/client/components/PageHeader.tsx +9 -0
- package/src/client/components/Toaster.tsx +10 -0
- package/src/client/components/UserProfileDropdown.tsx +9 -0
- package/src/client/contexts/AuthContext.tsx +12 -0
- package/src/client/contexts/NotificationContext.tsx +25 -0
- package/src/client/pages/LoginPage.tsx +9 -0
- package/src/client/pages/MetricsPage.tsx +9 -0
- package/src/client/pages/OverviewPage.tsx +9 -0
- package/src/client/pages/PulsePage.tsx +10 -0
- package/src/client/pages/QueuesPage.tsx +9 -0
- package/src/client/pages/SchedulesPage.tsx +9 -0
- package/src/client/pages/SettingsPage.tsx +9 -0
- package/src/client/pages/WorkersPage.tsx +10 -0
- package/src/client/utils.ts +9 -0
- package/src/server/config/ServerConfigManager.ts +87 -0
- package/src/server/index.ts +19 -18
- package/src/server/services/AlertService.ts +16 -3
- package/src/server/services/LogStreamProcessor.ts +93 -0
- package/src/server/services/MaintenanceScheduler.ts +78 -0
- package/src/server/services/PulseService.ts +12 -1
- package/src/server/services/QueueMetricsCollector.ts +138 -0
- package/src/server/services/QueueService.ts +29 -283
- package/src/shared/types.ts +126 -27
- package/vite.config.ts +1 -1
- package/dist/client/assets/index-C332gZ-J.css +0 -1
- 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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
+
}
|