Skip to content

Commit 14855b9

Browse files
committed
feat: enhance health monitoring and reporting features
- Updated the health controller to utilize new health history and snapshot services for improved health data management. - Introduced server-sent events (SSE) for real-time health status updates. - Added new dependencies for ECharts and Vue ECharts to visualize health metrics. - Enhanced the settings page to display detailed health indicators and trends. - Refactored health check logic to streamline system resource monitoring and reporting.
1 parent 5f392da commit 14855b9

File tree

10 files changed

+1273
-236
lines changed

10 files changed

+1273
-236
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'
2+
import { Interval } from '@nestjs/schedule'
3+
import { HealthHistoryService } from './health-history.service'
4+
import { HealthSnapshotService } from './health-snapshot.service'
5+
6+
const HEALTH_COLLECTION_INTERVAL_MS = 5_000
7+
8+
@Injectable()
9+
export class HealthCollectorService implements OnModuleInit {
10+
private readonly logger = new Logger(HealthCollectorService.name)
11+
12+
public constructor(
13+
private readonly healthSnapshotService: HealthSnapshotService,
14+
private readonly healthHistoryService: HealthHistoryService,
15+
) { }
16+
17+
public async onModuleInit(): Promise<void> {
18+
await this.collectAndStoreSnapshot()
19+
}
20+
21+
@Interval(HEALTH_COLLECTION_INTERVAL_MS)
22+
public async collectAndStoreSnapshot(): Promise<void> {
23+
try {
24+
const snapshot = await this.healthSnapshotService.collectSnapshot()
25+
await this.healthHistoryService.appendSnapshot({
26+
status: snapshot.status || 'unknown',
27+
details: snapshot.details || {},
28+
system: snapshot.system || {},
29+
futureChecks: snapshot.futureChecks || {},
30+
})
31+
} catch (error) {
32+
const message = error instanceof Error ? error.message : String(error)
33+
this.logger.warn(`Health scheduled collection failed: ${message}`)
34+
}
35+
}
36+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { InjectRedis } from '@nestjs-modules/ioredis'
2+
import { Injectable, Logger } from '@nestjs/common'
3+
import Redis from 'ioredis'
4+
5+
const HEALTH_HISTORY_RAW_KEY = 'core:health:history:raw'
6+
const HEALTH_HISTORY_5M_INDEX_KEY = 'core:health:history:5m:index'
7+
const HEALTH_HISTORY_5M_DATA_KEY = 'core:health:history:5m:data'
8+
const HEALTH_HISTORY_1H_INDEX_KEY = 'core:health:history:1h:index'
9+
const HEALTH_HISTORY_1H_DATA_KEY = 'core:health:history:1h:data'
10+
const HEALTH_HISTORY_1D_INDEX_KEY = 'core:health:history:1d:index'
11+
const HEALTH_HISTORY_1D_DATA_KEY = 'core:health:history:1d:data'
12+
13+
const FIVE_MINUTES_MS = 5 * 60 * 1_000
14+
const ONE_HOUR_MS = 60 * 60 * 1_000
15+
const ONE_DAY_MS = 24 * 60 * 60 * 1_000
16+
const ONE_MONTH_MS = 30 * ONE_DAY_MS
17+
18+
const RAW_RETENTION_MS = ONE_HOUR_MS
19+
const FIVE_MIN_RETENTION_MS = ONE_HOUR_MS
20+
const HOURLY_RETENTION_MS = ONE_DAY_MS
21+
const DAILY_RETENTION_MS = ONE_MONTH_MS
22+
23+
const RAW_TTL_SECONDS = Math.ceil(RAW_RETENTION_MS / 1_000) + 60
24+
const FIVE_MIN_TTL_SECONDS = Math.ceil(FIVE_MIN_RETENTION_MS / 1_000) + 60
25+
const HOURLY_TTL_SECONDS = Math.ceil(HOURLY_RETENTION_MS / 1_000) + 60
26+
const DAILY_TTL_SECONDS = Math.ceil(DAILY_RETENTION_MS / 1_000) + 60
27+
28+
export type HealthHistorySnapshot = {
29+
timestamp: number
30+
status: string
31+
details: Record<string, unknown>
32+
system: Record<string, unknown>
33+
futureChecks?: Record<string, unknown>
34+
}
35+
36+
@Injectable()
37+
export class HealthHistoryService {
38+
private readonly logger = new Logger(HealthHistoryService.name)
39+
40+
public constructor(
41+
@InjectRedis() private readonly redis: Redis,
42+
) { }
43+
44+
public async appendSnapshot(snapshot: Omit<HealthHistorySnapshot, 'timestamp'>): Promise<void> {
45+
const timestamp = Date.now()
46+
const payload: HealthHistorySnapshot = {
47+
...snapshot,
48+
timestamp,
49+
}
50+
51+
try {
52+
await this.redis
53+
.multi()
54+
.zadd(HEALTH_HISTORY_RAW_KEY, timestamp, JSON.stringify(payload))
55+
.zremrangebyscore(HEALTH_HISTORY_RAW_KEY, 0, timestamp - RAW_RETENTION_MS)
56+
.expire(HEALTH_HISTORY_RAW_KEY, RAW_TTL_SECONDS)
57+
.exec()
58+
59+
await this.writeBucketSnapshot({
60+
indexKey: HEALTH_HISTORY_5M_INDEX_KEY,
61+
dataKey: HEALTH_HISTORY_5M_DATA_KEY,
62+
bucketSizeMs: FIVE_MINUTES_MS,
63+
retentionMs: FIVE_MIN_RETENTION_MS,
64+
ttlSeconds: FIVE_MIN_TTL_SECONDS,
65+
nowTimestamp: timestamp,
66+
payload,
67+
})
68+
await this.writeBucketSnapshot({
69+
indexKey: HEALTH_HISTORY_1H_INDEX_KEY,
70+
dataKey: HEALTH_HISTORY_1H_DATA_KEY,
71+
bucketSizeMs: ONE_HOUR_MS,
72+
retentionMs: HOURLY_RETENTION_MS,
73+
ttlSeconds: HOURLY_TTL_SECONDS,
74+
nowTimestamp: timestamp,
75+
payload,
76+
})
77+
await this.writeBucketSnapshot({
78+
indexKey: HEALTH_HISTORY_1D_INDEX_KEY,
79+
dataKey: HEALTH_HISTORY_1D_DATA_KEY,
80+
bucketSizeMs: ONE_DAY_MS,
81+
retentionMs: DAILY_RETENTION_MS,
82+
ttlSeconds: DAILY_TTL_SECONDS,
83+
nowTimestamp: timestamp,
84+
payload,
85+
})
86+
} catch (error) {
87+
const message = error instanceof Error ? error.message : String(error)
88+
this.logger.warn(`Cannot persist health history in Redis: ${message}`)
89+
}
90+
}
91+
92+
public async appendAndGet(snapshot: Omit<HealthHistorySnapshot, 'timestamp'>): Promise<HealthHistorySnapshot[]> {
93+
await this.appendSnapshot(snapshot)
94+
return this.getAdaptiveHistory()
95+
}
96+
97+
public async getLatestRawSnapshot(): Promise<HealthHistorySnapshot | null> {
98+
try {
99+
const item = await this.redis.zrevrange(HEALTH_HISTORY_RAW_KEY, 0, 0)
100+
if (!item[0]) {
101+
return null
102+
}
103+
return JSON.parse(item[0]) as HealthHistorySnapshot
104+
} catch (error) {
105+
const message = error instanceof Error ? error.message : String(error)
106+
this.logger.warn(`Cannot read latest raw health snapshot from Redis: ${message}`)
107+
return null
108+
}
109+
}
110+
111+
public async getLiveHistory(): Promise<HealthHistorySnapshot[]> {
112+
const oldestAcceptedTimestamp = Date.now() - FIVE_MINUTES_MS
113+
try {
114+
const items = await this.redis.zrangebyscore(HEALTH_HISTORY_RAW_KEY, oldestAcceptedTimestamp, '+inf')
115+
return items
116+
.map((item) => {
117+
try {
118+
return JSON.parse(item) as HealthHistorySnapshot
119+
} catch {
120+
return null
121+
}
122+
})
123+
.filter((item): item is HealthHistorySnapshot => item !== null)
124+
.sort((a, b) => a.timestamp - b.timestamp)
125+
} catch (error) {
126+
const message = error instanceof Error ? error.message : String(error)
127+
this.logger.warn(`Cannot read live health history from Redis: ${message}`)
128+
return []
129+
}
130+
}
131+
132+
public async getAdaptiveHistory(): Promise<HealthHistorySnapshot[]> {
133+
const nowTimestamp = Date.now()
134+
const fiveMinutesStart = nowTimestamp - ONE_HOUR_MS
135+
const oneHourStart = nowTimestamp - ONE_DAY_MS
136+
const oneDayStart = nowTimestamp - ONE_MONTH_MS
137+
138+
try {
139+
const [fiveMinutesSnapshots, hourlySnapshots, dailySnapshots] = await Promise.all([
140+
this.readBucketSnapshots(HEALTH_HISTORY_5M_INDEX_KEY, HEALTH_HISTORY_5M_DATA_KEY, fiveMinutesStart, '+inf'),
141+
this.readBucketSnapshots(HEALTH_HISTORY_1H_INDEX_KEY, HEALTH_HISTORY_1H_DATA_KEY, oneHourStart, fiveMinutesStart - 1),
142+
this.readBucketSnapshots(HEALTH_HISTORY_1D_INDEX_KEY, HEALTH_HISTORY_1D_DATA_KEY, oneDayStart, oneHourStart - 1),
143+
])
144+
145+
return [...dailySnapshots, ...hourlySnapshots, ...fiveMinutesSnapshots].sort((a, b) => a.timestamp - b.timestamp)
146+
} catch (error) {
147+
const message = error instanceof Error ? error.message : String(error)
148+
this.logger.warn(`Cannot read health history from Redis: ${message}`)
149+
return []
150+
}
151+
}
152+
153+
private async writeBucketSnapshot(params: {
154+
indexKey: string
155+
dataKey: string
156+
bucketSizeMs: number
157+
retentionMs: number
158+
ttlSeconds: number
159+
nowTimestamp: number
160+
payload: HealthHistorySnapshot
161+
}): Promise<void> {
162+
const {
163+
indexKey,
164+
dataKey,
165+
bucketSizeMs,
166+
retentionMs,
167+
ttlSeconds,
168+
nowTimestamp,
169+
payload,
170+
} = params
171+
172+
const bucketTimestamp = Math.floor(nowTimestamp / bucketSizeMs) * bucketSizeMs
173+
const oldestAcceptedTimestamp = nowTimestamp - retentionMs
174+
const staleMembers = await this.redis.zrangebyscore(indexKey, 0, oldestAcceptedTimestamp)
175+
const bucketMember = `${bucketTimestamp}`
176+
const bucketPayload: HealthHistorySnapshot = {
177+
...payload,
178+
timestamp: bucketTimestamp,
179+
}
180+
181+
const pipeline = this.redis
182+
.multi()
183+
.hset(dataKey, bucketMember, JSON.stringify(bucketPayload))
184+
.zadd(indexKey, bucketTimestamp, bucketMember)
185+
.zremrangebyscore(indexKey, 0, oldestAcceptedTimestamp)
186+
.expire(indexKey, ttlSeconds)
187+
.expire(dataKey, ttlSeconds)
188+
189+
if (staleMembers.length > 0) {
190+
pipeline.hdel(dataKey, ...staleMembers)
191+
}
192+
193+
await pipeline.exec()
194+
}
195+
196+
private async readBucketSnapshots(indexKey: string, dataKey: string, minScore: number, maxScore: number | '+inf'): Promise<HealthHistorySnapshot[]> {
197+
const memberIds = await this.redis.zrangebyscore(indexKey, minScore, maxScore)
198+
if (memberIds.length === 0) {
199+
return []
200+
}
201+
202+
const serialized = await this.redis.hmget(dataKey, ...memberIds)
203+
return serialized
204+
.map((item) => {
205+
if (!item) {
206+
return null
207+
}
208+
try {
209+
return JSON.parse(item) as HealthHistorySnapshot
210+
} catch {
211+
return null
212+
}
213+
})
214+
.filter((item): item is HealthHistorySnapshot => item !== null)
215+
}
216+
}

0 commit comments

Comments
 (0)