Feature: Multi-email alerts and move resource between groups

- notificationService: Parse comma-separated emails from resource.email_to or global config; validate and send to each recipient
- API: Add PATCH /api/resources/:id/group endpoint to move resource between groups with validation and cache invalidation
- UI: Add group selector to edit modal; wire group change to PATCH endpoint; add help text for multi-email format
This commit is contained in:
nmemmert committed 2025-12-16 13:01:37 -05:00
1 parent 9c1c972045
commit 923c36e494
12 files changed
+115 -12

No files matched your search

+2 -1
View File
@@ -113,8 +113,9 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_checks_resource_id ON checks(resource_id);
CREATE INDEX IF NOT EXISTS idx_checks_checked_at ON checks(checked_at);
CREATE INDEX IF NOT EXISTS idx_checks_resource_checked ON checks(resource_id, checked_at DESC);
CREATE INDEX IF NOT EXISTS idx_checks_resource_checked ON checks(resource_id, checked_at);
CREATE INDEX IF NOT EXISTS idx_incidents_resource_id ON incidents(resource_id);
CREATE INDEX IF NOT EXISTS idx_incidents_resource_started ON incidents(resource_id, started_at);
CREATE INDEX IF NOT EXISTS idx_incidents_resolved ON incidents(resource_id, resolved_at);
CREATE INDEX IF NOT EXISTS idx_resources_group_id ON resources(group_id);
CREATE INDEX IF NOT EXISTS idx_maintenance_windows_resource ON maintenance_windows(resource_id);
+36
View File
@@ -219,6 +219,42 @@ app.put('/api/resources/:id', (req, res) => {
res.json({ message: 'Resource updated' });
});
// Move a resource to a different group
app.patch('/api/resources/:id/group', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const { group_id } = req.body;
if (!Number.isInteger(id)) {
return res.status(400).json({ error: 'Invalid resource id' });
}
if (group_id === undefined || group_id === null || group_id === '') {
return res.status(400).json({ error: 'group_id is required' });
}
const gid = parseInt(group_id, 10);
if (!Number.isInteger(gid)) {
return res.status(400).json({ error: 'Invalid group_id' });
}
const stmt = db.prepare('UPDATE resources SET group_id = ? WHERE id = ?');
const info = stmt.run(gid, id);
if (info.changes === 0) {
return res.status(404).json({ error: 'Resource not found' });
}
// Invalidate caches and broadcast update
cache.invalidatePattern('history:');
cache.invalidatePattern('sla:');
if (global.broadcastDashboardUpdate) {
try { global.broadcastDashboardUpdate(); } catch {}
}
const updated = db.prepare('SELECT * FROM resources WHERE id = ?').get(id);
res.json(updated);
} catch (err) {
res.status(500).json({ error: 'Failed to update group', details: String(err) });
}
});
// Delete resource
app.delete('/api/resources/:id', (req, res) => {
db.prepare('DELETE FROM resources WHERE id = ?').run(req.params.id);
+10
View File
@@ -1,4 +1,6 @@
const axios = require('axios');
const http = require('http');
const https = require('https');
const net = require('net');
const tls = require('tls');
const dns = require('dns').promises;
@@ -26,6 +28,11 @@ function parseHostPort(urlString, fallbackPort) {
}
class MonitorService {
constructor() {
// Keep-alive agents for pooled HTTP/S connections
this.httpAgent = new http.Agent({ keepAlive: true, maxSockets: 50 });
this.httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 50 });
}
async checkResource(resource) {
const startTime = Date.now();
const base = {
@@ -81,11 +88,14 @@ class MonitorService {
}
}
const isHttps = resource.url.startsWith('https');
const response = await axios.get(resource.url, {
timeout: resource.timeout,
validateStatus: () => true,
maxRedirects: 5,
headers,
httpAgent: this.httpAgent,
httpsAgent: this.httpsAgent,
});
const responseTime = Date.now() - startTime;
const isUp = response.status >= 200 && response.status < 400;
+12 -3
View File
@@ -76,8 +76,17 @@ class NotificationService {
const promises = [];
// Use resource-specific email if provided, otherwise fall back to global config
if (this.emailEnabled && (resource.email_to || this.config.email_to)) {
promises.push(this.sendEmail(resource, message, incident.type, stats));
if (this.emailEnabled && (resource.email_to || this.config.email_to)) {
const targetsCsv = resource.email_to || this.config.email_to;
const targets = String(targetsCsv)
.split(',')
.map(t => t.trim())
.filter(t => t && /.+@.+\..+/.test(t));
if (targets.length > 0) {
for (const email of targets) {
promises.push(this.sendEmail(resource, message, incident.type, stats, email));
}
}
} else {
if (!this.emailEnabled) {
// Email globally disabled
@@ -158,7 +167,7 @@ class NotificationService {
htmlContent += `</div>`;
// Use resource-specific email if provided, otherwise use global config
const emailTo = resource.email_to || this.config.email_to;
const emailTo = emailOverride || resource.email_to || this.config.email_to;
await this.transporter.sendMail({
from: this.config.email_from,
+23
View File
@@ -92,6 +92,29 @@ class Scheduler {
const result = await monitorService.checkResource(resource);
monitorService.saveCheck(result);
// Rolling cap per resource (configurable via settings; default 10000)
try {
const capSetting = db.prepare(`SELECT value FROM settings WHERE key = 'checks_cap_per_resource'`).get();
const cap = capSetting ? parseInt(capSetting.value) : 10000;
if (Number.isFinite(cap) && cap > 0) {
// Delete oldest rows beyond cap, using id ordering per resource
db.prepare(`
DELETE FROM checks
WHERE id IN (
SELECT id FROM checks
WHERE resource_id = ?
ORDER BY id ASC
LIMIT (
SELECT MAX(COUNT(*) - ?, 0)
FROM checks WHERE resource_id = ?
)
)
`).run(resource.id, cap, resource.id);
}
} catch (e) {
// Non-fatal
}
const incident = monitorService.handleIncident(
resource.id,
result.status === 'down'