Skip to content

Commit a306ccc

Browse files
committed
feat: improve alert manager functionality and testing
1 parent fc1929c commit a306ccc

File tree

2 files changed

+76
-65
lines changed

2 files changed

+76
-65
lines changed

server/alertManager.js

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ class AlertManager extends EventEmitter {
347347
startTime: timestamp,
348348
lastUpdate: timestamp,
349349
currentValue,
350+
effectiveThreshold: effectiveThreshold,
350351
state: 'pending',
351352
escalated: false,
352353
acknowledged: false
@@ -637,7 +638,7 @@ class AlertManager extends EventEmitter {
637638
endpointId: alert.guest.endpointId
638639
},
639640
metric: alert.rule.metric,
640-
threshold: alert.rule.threshold,
641+
threshold: alert.effectiveThreshold || alert.rule.threshold,
641642
currentValue: alert.currentValue,
642643
triggeredAt: alert.triggeredAt,
643644
duration: Date.now() - alert.triggeredAt,
@@ -1149,6 +1150,15 @@ class AlertManager extends EventEmitter {
11491150
'critical': '🚨'
11501151
};
11511152

1153+
// Get the current value and effective threshold for this alert
1154+
const currentValue = alert.currentValue;
1155+
const effectiveThreshold = alert.effectiveThreshold || alert.rule.threshold;
1156+
1157+
// Format values for display (only add % for percentage metrics)
1158+
const isPercentageMetric = ['cpu', 'memory', 'disk'].includes(alert.rule.metric);
1159+
const valueDisplay = isPercentageMetric ? `${Math.round(currentValue || 0)}%` : (currentValue || 'N/A');
1160+
const thresholdDisplay = isPercentageMetric ? `${effectiveThreshold || 0}%` : (effectiveThreshold || 'N/A');
1161+
11521162
const subject = `${severityEmoji[alert.rule.severity] || '📢'} Pulse Alert: ${alert.rule.name}`;
11531163

11541164
const html = `
@@ -1164,7 +1174,7 @@ class AlertManager extends EventEmitter {
11641174
<table style="width: 100%; border-collapse: collapse;">
11651175
<tr>
11661176
<td style="padding: 8px 0; font-weight: bold; color: #374151; width: 120px;">VM/LXC:</td>
1167-
<td style="padding: 8px 0; color: #6b7280;">${alert.guest.name} (${alert.guest.type} ${alert.guest.id})</td>
1177+
<td style="padding: 8px 0; color: #6b7280;">${alert.guest.name} (${alert.guest.type} ${alert.guest.vmid})</td>
11681178
</tr>
11691179
<tr>
11701180
<td style="padding: 8px 0; font-weight: bold; color: #374151;">Node:</td>
@@ -1176,11 +1186,11 @@ class AlertManager extends EventEmitter {
11761186
</tr>
11771187
<tr>
11781188
<td style="padding: 8px 0; font-weight: bold; color: #374151;">Current Value:</td>
1179-
<td style="padding: 8px 0; color: #6b7280;">${alert.value}%</td>
1189+
<td style="padding: 8px 0; color: #6b7280;">${valueDisplay}</td>
11801190
</tr>
11811191
<tr>
11821192
<td style="padding: 8px 0; font-weight: bold; color: #374151;">Threshold:</td>
1183-
<td style="padding: 8px 0; color: #6b7280;">${alert.threshold}%</td>
1193+
<td style="padding: 8px 0; color: #6b7280;">${thresholdDisplay}</td>
11841194
</tr>
11851195
<tr>
11861196
<td style="padding: 8px 0; font-weight: bold; color: #374151;">Status:</td>
@@ -1209,11 +1219,11 @@ class AlertManager extends EventEmitter {
12091219
PULSE ALERT: ${alert.rule.name}
12101220
12111221
Severity: ${alert.rule.severity.toUpperCase()}
1212-
VM/LXC: ${alert.guest.name} (${alert.guest.type} ${alert.guest.id})
1222+
VM/LXC: ${alert.guest.name} (${alert.guest.type} ${alert.guest.vmid})
12131223
Node: ${alert.guest.node}
12141224
Metric: ${alert.rule.metric.toUpperCase()}
1215-
Current Value: ${alert.value}%
1216-
Threshold: ${alert.threshold}%
1225+
Current Value: ${valueDisplay}
1226+
Threshold: ${thresholdDisplay}
12171227
Status: ${alert.guest.status}
12181228
Time: ${new Date(this.getValidTimestamp(alert)).toLocaleString()}
12191229
@@ -1250,6 +1260,20 @@ This alert was generated by Pulse monitoring system.
12501260

12511261
const validTimestamp = this.getValidTimestamp(alert);
12521262

1263+
// Get the current value and effective threshold for this alert
1264+
const currentValue = alert.currentValue;
1265+
const effectiveThreshold = alert.effectiveThreshold || alert.rule.threshold;
1266+
1267+
// Format values for display (only add % for percentage metrics)
1268+
const isPercentageMetric = ['cpu', 'memory', 'disk'].includes(alert.rule.metric);
1269+
const formattedValue = typeof currentValue === 'number' ?
1270+
(isPercentageMetric ? Math.round(currentValue) : currentValue) : (currentValue || 'N/A');
1271+
const formattedThreshold = typeof effectiveThreshold === 'number' ?
1272+
effectiveThreshold : (effectiveThreshold || 'N/A');
1273+
1274+
const valueDisplay = isPercentageMetric ? `${formattedValue}%` : formattedValue;
1275+
const thresholdDisplay = isPercentageMetric ? `${formattedThreshold}%` : formattedThreshold;
1276+
12531277
// Detect webhook type based on URL
12541278
const url = channel.config.url;
12551279
const isDiscord = url.includes('discord.com/api/webhooks') || url.includes('discordapp.com/api/webhooks');
@@ -1269,7 +1293,7 @@ This alert was generated by Pulse monitoring system.
12691293
fields: [
12701294
{
12711295
name: 'VM/LXC',
1272-
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.id})`,
1296+
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.vmid})`,
12731297
inline: true
12741298
},
12751299
{
@@ -1289,12 +1313,12 @@ This alert was generated by Pulse monitoring system.
12891313
},
12901314
{
12911315
name: 'Current Value',
1292-
value: `${alert.value}%`,
1316+
value: valueDisplay,
12931317
inline: true
12941318
},
12951319
{
12961320
name: 'Threshold',
1297-
value: `${alert.threshold}%`,
1321+
value: thresholdDisplay,
12981322
inline: true
12991323
}
13001324
],
@@ -1314,7 +1338,7 @@ This alert was generated by Pulse monitoring system.
13141338
fields: [
13151339
{
13161340
title: 'VM/LXC',
1317-
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.id})`,
1341+
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.vmid})`,
13181342
short: true
13191343
},
13201344
{
@@ -1324,7 +1348,7 @@ This alert was generated by Pulse monitoring system.
13241348
},
13251349
{
13261350
title: 'Metric',
1327-
value: `${alert.rule.metric.toUpperCase()}: ${alert.value}% (threshold: ${alert.threshold}%)`,
1351+
value: `${alert.rule.metric.toUpperCase()}: ${valueDisplay} (threshold: ${thresholdDisplay})`,
13281352
short: false
13291353
}
13301354
],
@@ -1346,13 +1370,13 @@ This alert was generated by Pulse monitoring system.
13461370
},
13471371
guest: {
13481372
name: alert.guest.name,
1349-
id: alert.guest.id,
1373+
id: alert.guest.vmid,
13501374
type: alert.guest.type,
13511375
node: alert.guest.node,
13521376
status: alert.guest.status
13531377
},
1354-
value: alert.value,
1355-
threshold: alert.threshold,
1378+
value: formattedValue,
1379+
threshold: formattedThreshold,
13561380
emoji: severityEmoji[alert.rule.severity] || '📢'
13571381
},
13581382
// Include both formats for generic webhooks
@@ -1365,7 +1389,7 @@ This alert was generated by Pulse monitoring system.
13651389
fields: [
13661390
{
13671391
name: 'VM/LXC',
1368-
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.id})`,
1392+
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.vmid})`,
13691393
inline: true
13701394
},
13711395
{
@@ -1375,7 +1399,7 @@ This alert was generated by Pulse monitoring system.
13751399
},
13761400
{
13771401
name: 'Metric',
1378-
value: `${alert.rule.metric.toUpperCase()}: ${alert.value}% (threshold: ${alert.threshold}%)`,
1402+
value: `${alert.rule.metric.toUpperCase()}: ${valueDisplay} (threshold: ${thresholdDisplay})`,
13791403
inline: true
13801404
}
13811405
],
@@ -1391,12 +1415,12 @@ This alert was generated by Pulse monitoring system.
13911415
fields: [
13921416
{
13931417
title: 'VM/LXC',
1394-
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.id})`,
1418+
value: `${alert.guest.name} (${alert.guest.type} ${alert.guest.vmid})`,
13951419
short: true
13961420
},
13971421
{
13981422
title: 'Metric',
1399-
value: `${alert.rule.metric.toUpperCase()}: ${alert.value}% (threshold: ${alert.threshold}%)`,
1423+
value: `${alert.rule.metric.toUpperCase()}: ${valueDisplay} (threshold: ${thresholdDisplay})`,
14001424
short: false
14011425
}
14021426
],

server/tests/alertManager.test.js

Lines changed: 33 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@ describe('AlertManager Webhook Functionality', () => {
4242
},
4343
guest: {
4444
name: 'test-vm',
45-
id: '100',
45+
vmid: '100',
4646
type: 'qemu',
4747
node: 'test-node',
4848
status: 'running'
4949
},
50-
value: 92,
51-
threshold: 85,
50+
currentValue: 92,
51+
effectiveThreshold: 85,
5252
triggeredAt: 1640995200000, // Valid timestamp
5353
lastUpdate: 1640995260000 // Valid timestamp
5454
};
@@ -72,14 +72,12 @@ describe('AlertManager Webhook Functionality', () => {
7272
expect(mockAxios.post).toHaveBeenCalledTimes(1);
7373
const payload = mockAxios.post.mock.calls[0][1];
7474

75-
// Check main timestamp field
76-
expect(payload.timestamp).toBe(new Date(mockAlert.triggeredAt).toISOString());
77-
78-
// Check embed timestamp
79-
expect(payload.embeds[0].timestamp).toBe(new Date(mockAlert.triggeredAt).toISOString());
80-
81-
// Check Slack timestamp (Unix timestamp)
75+
// For Slack webhooks, check the timestamp in attachments
8276
expect(payload.attachments[0].ts).toBe(Math.floor(mockAlert.triggeredAt / 1000));
77+
78+
// Slack webhooks don't have top-level timestamp or embeds
79+
expect(payload.timestamp).toBeUndefined();
80+
expect(payload.embeds).toBeUndefined();
8381
});
8482

8583
test('should fallback to lastUpdate when triggeredAt is missing', async () => {
@@ -94,9 +92,7 @@ describe('AlertManager Webhook Functionality', () => {
9492
expect(mockAxios.post).toHaveBeenCalledTimes(1);
9593
const payload = mockAxios.post.mock.calls[0][1];
9694

97-
// Should use lastUpdate timestamp
98-
expect(payload.timestamp).toBe(new Date(mockAlert.lastUpdate).toISOString());
99-
expect(payload.embeds[0].timestamp).toBe(new Date(mockAlert.lastUpdate).toISOString());
95+
// Should use lastUpdate timestamp in Slack format
10096
expect(payload.attachments[0].ts).toBe(Math.floor(mockAlert.lastUpdate / 1000));
10197
});
10298

@@ -115,10 +111,11 @@ describe('AlertManager Webhook Functionality', () => {
115111
expect(mockAxios.post).toHaveBeenCalledTimes(1);
116112
const payload = mockAxios.post.mock.calls[0][1];
117113

118-
// Should use current time (within reasonable range)
119-
const timestamp = new Date(payload.timestamp).getTime();
120-
expect(timestamp).toBeGreaterThanOrEqual(beforeTime);
121-
expect(timestamp).toBeLessThanOrEqual(afterTime);
114+
// Should use current time (within reasonable range) for Slack format
115+
// Note: Unix timestamps lose millisecond precision, so allow for some tolerance
116+
const timestamp = payload.attachments[0].ts * 1000; // Convert Unix timestamp back to milliseconds
117+
expect(timestamp).toBeGreaterThanOrEqual(Math.floor(beforeTime / 1000) * 1000);
118+
expect(timestamp).toBeLessThanOrEqual(Math.ceil(afterTime / 1000) * 1000);
122119
});
123120

124121
test('should handle invalid timestamp values gracefully', async () => {
@@ -138,10 +135,11 @@ describe('AlertManager Webhook Functionality', () => {
138135
expect(mockAxios.post).toHaveBeenCalledTimes(1);
139136
const payload = mockAxios.post.mock.calls[0][1];
140137

141-
// Should fallback to current time when timestamps are invalid
142-
const timestamp = new Date(payload.timestamp).getTime();
143-
expect(timestamp).toBeGreaterThanOrEqual(beforeTime);
144-
expect(timestamp).toBeLessThanOrEqual(afterTime);
138+
// Should fallback to current time when timestamps are invalid (Slack format)
139+
// Note: Unix timestamps lose millisecond precision, so allow for some tolerance
140+
const timestamp = payload.attachments[0].ts * 1000;
141+
expect(timestamp).toBeGreaterThanOrEqual(Math.floor(beforeTime / 1000) * 1000);
142+
expect(timestamp).toBeLessThanOrEqual(Math.ceil(afterTime / 1000) * 1000);
145143
});
146144
});
147145

@@ -154,26 +152,19 @@ describe('AlertManager Webhook Functionality', () => {
154152
expect(mockAxios.post).toHaveBeenCalledTimes(1);
155153
const payload = mockAxios.post.mock.calls[0][1];
156154

157-
// Check main structure
158-
expect(payload).toHaveProperty('timestamp');
159-
expect(payload).toHaveProperty('alert');
160-
expect(payload).toHaveProperty('embeds');
155+
// Check Slack webhook structure (based on URL)
161156
expect(payload).toHaveProperty('text');
162157
expect(payload).toHaveProperty('attachments');
163-
164-
// Check Discord embed structure
165-
expect(payload.embeds).toHaveLength(1);
166-
expect(payload.embeds[0]).toHaveProperty('title');
167-
expect(payload.embeds[0]).toHaveProperty('description');
168-
expect(payload.embeds[0]).toHaveProperty('color');
169-
expect(payload.embeds[0]).toHaveProperty('fields');
170-
expect(payload.embeds[0]).toHaveProperty('footer');
171-
expect(payload.embeds[0]).toHaveProperty('timestamp');
158+
159+
// Slack webhooks don't have these properties
160+
expect(payload).not.toHaveProperty('timestamp');
161+
expect(payload).not.toHaveProperty('alert');
162+
expect(payload).not.toHaveProperty('embeds');
172163

173164
// Check Slack attachment structure
174165
expect(payload.attachments).toHaveLength(1);
175-
expect(payload.attachments[0]).toHaveProperty('color');
176166
expect(payload.attachments[0]).toHaveProperty('fields');
167+
expect(payload.attachments[0]).toHaveProperty('color');
177168
expect(payload.attachments[0]).toHaveProperty('footer');
178169
expect(payload.attachments[0]).toHaveProperty('ts');
179170
});
@@ -185,38 +176,34 @@ describe('AlertManager Webhook Functionality', () => {
185176

186177
const payload = mockAxios.post.mock.calls[0][1];
187178

188-
// Check alert fields
189-
expect(payload.alert.id).toBe(mockAlert.id);
190-
expect(payload.alert.rule.name).toBe(mockAlert.rule.name);
191-
expect(payload.alert.rule.severity).toBe(mockAlert.rule.severity);
192-
expect(payload.alert.guest.name).toBe(mockAlert.guest.name);
193-
expect(payload.alert.value).toBe(mockAlert.value);
194-
expect(payload.alert.threshold).toBe(mockAlert.threshold);
179+
// Check Slack format fields (data is in text and attachments)
180+
expect(payload.text).toContain(mockAlert.rule.name);
181+
expect(payload.attachments[0].fields[0].value).toContain(mockAlert.guest.name);
182+
expect(payload.attachments[0].fields[1].value).toBe(mockAlert.guest.node);
183+
expect(payload.attachments[0].fields[2].value).toContain('92%'); // formatted value
184+
expect(payload.attachments[0].fields[2].value).toContain('85%'); // formatted threshold
195185
});
196186

197187
test('should set correct colors based on severity', async () => {
198188
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
199189

200-
// Test warning severity
190+
// Test warning severity (Slack format only has attachments)
201191
await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert);
202192
let payload = mockAxios.post.mock.calls[0][1];
203-
expect(payload.embeds[0].color).toBe(15844367); // Orange
204193
expect(payload.attachments[0].color).toBe('warning');
205194

206195
// Test critical severity
207196
mockAxios.post.mockClear();
208197
const criticalAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'critical' } };
209198
await alertManager.sendWebhookNotification(mockWebhookChannel, criticalAlert);
210199
payload = mockAxios.post.mock.calls[0][1];
211-
expect(payload.embeds[0].color).toBe(15158332); // Red
212200
expect(payload.attachments[0].color).toBe('danger');
213201

214202
// Test info severity
215203
mockAxios.post.mockClear();
216204
const infoAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'info' } };
217205
await alertManager.sendWebhookNotification(mockWebhookChannel, infoAlert);
218206
payload = mockAxios.post.mock.calls[0][1];
219-
expect(payload.embeds[0].color).toBe(3447003); // Blue
220207
expect(payload.attachments[0].color).toBe('good');
221208
});
222209
});

0 commit comments

Comments
 (0)