Page MenuHomeDevCentral

D3965.id10275.diff
No OneTemporary

D3965.id10275.diff

diff --git a/backend/app.js b/backend/app.js
--- a/backend/app.js
+++ b/backend/app.js
@@ -22,12 +22,18 @@
const incidentRoutes = require('./routes/incidentRoutes.js');
const configRoutes = require('./routes/configRoutes.js');
const maintenanceRoutes = require('./routes/maintenanceRoutes.js');
+const webhookRoutes = require('./routes/webhookRoutes.js');
+const metricRoutes = require('./routes/metricRoutes.js');
+const subscriberRoutes = require('./routes/subscriberRoutes.js');
// Use the routes
app.use('/api', serviceRoutes);
app.use('/api', incidentRoutes);
app.use('/api', configRoutes);
app.use('/api', maintenanceRoutes);
+app.use('/api', webhookRoutes);
+app.use('/api', metricRoutes);
+app.use('/api', subscriberRoutes);
// Start the server
const PORT = process.env.EXPRESS_PORT || 3000;
diff --git a/backend/controllers/incidentController.js b/backend/controllers/incidentController.js
--- a/backend/controllers/incidentController.js
+++ b/backend/controllers/incidentController.js
@@ -1,10 +1,17 @@
const incidentModel = require('../models/incidentModel.js');
const incidentUpdateModel = require('../models/incidentUpdateModel.js');
+const { notifyAll } = require('../services/notificationService.js');
+const incidentServiceModel = require('../models/incidentServiceModel.js');
const createIncident = async (req, res) => {
try {
const result = await incidentModel.createIncident(req.body);
- res.status(201).json(result.rows[0]);
+ const incident = result.rows[0];
+ if (req.body.service_ids && req.body.service_ids.length > 0) {
+ await incidentServiceModel.setServices(incident.id, req.body.service_ids);
+ }
+ res.status(201).json(incident);
+ notifyAll('Incident Created', { title: req.body.title, status: req.body.status || 'investigating', impact: req.body.impact || 'none' });
} catch (error) {
res.status(500).json({ message: 'Error creating incident', error: error.message });
}
@@ -26,7 +33,8 @@
return res.status(404).json({ message: 'Incident not found' });
}
const updates = await incidentUpdateModel.getUpdatesByIncidentId(req.params.id);
- res.status(200).json({ ...incident.rows[0], updates: updates.rows });
+ const services = await incidentServiceModel.getServicesByIncident(req.params.id);
+ res.status(200).json({ ...incident.rows[0], updates: updates.rows, affected_services: services.rows });
} catch (error) {
res.status(500).json({ message: 'Error fetching incident', error: error.message });
}
@@ -45,7 +53,11 @@
message: req.body.message
});
}
+ if (req.body.service_ids) {
+ await incidentServiceModel.setServices(req.params.id, req.body.service_ids);
+ }
res.status(200).json(result.rows[0]);
+ notifyAll('Incident Updated', { title: req.body.title, status: req.body.status, impact: req.body.impact, message: req.body.message });
} catch (error) {
res.status(500).json({ message: 'Error updating incident', error: error.message });
}
@@ -63,6 +75,7 @@
message: req.body.message || 'Incident resolved'
});
res.status(200).json(result.rows[0]);
+ notifyAll('Incident Resolved', { id: req.params.id, message: req.body.message || 'Incident resolved' });
} catch (error) {
res.status(500).json({ message: 'Error resolving incident', error: error.message });
}
diff --git a/backend/controllers/maintenanceController.js b/backend/controllers/maintenanceController.js
--- a/backend/controllers/maintenanceController.js
+++ b/backend/controllers/maintenanceController.js
@@ -1,9 +1,14 @@
const maintenanceModel = require('../models/maintenanceModel.js');
+const maintenanceServiceModel = require('../models/maintenanceServiceModel.js');
const createMaintenance = async (req, res) => {
try {
const result = await maintenanceModel.createMaintenance(req.body);
- res.status(201).json(result.rows[0]);
+ const maintenance = result.rows[0];
+ if (req.body.service_ids && req.body.service_ids.length > 0) {
+ await maintenanceServiceModel.setServices(maintenance.id, req.body.service_ids);
+ }
+ res.status(201).json(maintenance);
} catch (error) {
res.status(500).json({ message: 'Error creating maintenance', error: error.message });
}
@@ -24,7 +29,8 @@
if (result.rows.length === 0) {
return res.status(404).json({ message: 'Maintenance not found' });
}
- res.status(200).json(result.rows[0]);
+ const services = await maintenanceServiceModel.getServicesByMaintenance(req.params.id);
+ res.status(200).json({ ...result.rows[0], affected_services: services.rows });
} catch (error) {
res.status(500).json({ message: 'Error fetching maintenance', error: error.message });
}
@@ -36,6 +42,9 @@
if (result.rows.length === 0) {
return res.status(404).json({ message: 'Maintenance not found' });
}
+ if (req.body.service_ids) {
+ await maintenanceServiceModel.setServices(req.params.id, req.body.service_ids);
+ }
res.status(200).json(result.rows[0]);
} catch (error) {
res.status(500).json({ message: 'Error updating maintenance', error: error.message });
diff --git a/backend/controllers/metricController.js b/backend/controllers/metricController.js
new file mode 100644
--- /dev/null
+++ b/backend/controllers/metricController.js
@@ -0,0 +1,41 @@
+const metricModel = require('../models/metricModel.js');
+
+const recordMetric = async (req, res) => {
+ try {
+ const result = await metricModel.recordMetric(req.body);
+ res.status(201).json(result.rows[0]);
+ } catch (error) {
+ res.status(500).json({ message: 'Error recording metric', error: error.message });
+ }
+};
+
+const getMetricsByService = async (req, res) => {
+ try {
+ const days = parseInt(req.query.days) || 30;
+ const result = await metricModel.getMetricsByService(req.params.serviceId, days);
+ res.status(200).json(result.rows);
+ } catch (error) {
+ res.status(500).json({ message: 'Error fetching metrics', error: error.message });
+ }
+};
+
+const getLatestMetrics = async (req, res) => {
+ try {
+ const result = await metricModel.getLatestMetrics();
+ res.status(200).json(result.rows);
+ } catch (error) {
+ res.status(500).json({ message: 'Error fetching latest metrics', error: error.message });
+ }
+};
+
+const getDailySummary = async (req, res) => {
+ try {
+ const days = parseInt(req.query.days) || 30;
+ const result = await metricModel.getDailySummary(req.params.serviceId, days);
+ res.status(200).json(result.rows);
+ } catch (error) {
+ res.status(500).json({ message: 'Error fetching daily summary', error: error.message });
+ }
+};
+
+module.exports = { recordMetric, getMetricsByService, getLatestMetrics, getDailySummary };
diff --git a/backend/controllers/subscriberController.js b/backend/controllers/subscriberController.js
new file mode 100644
--- /dev/null
+++ b/backend/controllers/subscriberController.js
@@ -0,0 +1,48 @@
+const subscriberModel = require('../models/subscriberModel.js');
+
+const subscribe = async (req, res) => {
+ try {
+ const result = await subscriberModel.createSubscriber(req.body);
+ res.status(201).json(result.rows[0]);
+ } catch (error) {
+ if (error.code === '23505') {
+ return res.status(409).json({ message: 'Already subscribed' });
+ }
+ res.status(500).json({ message: 'Error subscribing', error: error.message });
+ }
+};
+
+const confirm = async (req, res) => {
+ try {
+ const result = await subscriberModel.confirmSubscriber(req.params.token);
+ if (result.rows.length === 0) {
+ return res.status(404).json({ message: 'Invalid or expired token' });
+ }
+ res.status(200).json({ message: 'Subscription confirmed', subscriber: result.rows[0] });
+ } catch (error) {
+ res.status(500).json({ message: 'Error confirming subscription', error: error.message });
+ }
+};
+
+const getAll = async (req, res) => {
+ try {
+ const result = await subscriberModel.getAllSubscribers();
+ res.status(200).json(result.rows);
+ } catch (error) {
+ res.status(500).json({ message: 'Error fetching subscribers', error: error.message });
+ }
+};
+
+const remove = async (req, res) => {
+ try {
+ const result = await subscriberModel.deleteSubscriber(req.params.id);
+ if (result.rows.length === 0) {
+ return res.status(404).json({ message: 'Subscriber not found' });
+ }
+ res.status(200).json({ message: 'Unsubscribed' });
+ } catch (error) {
+ res.status(500).json({ message: 'Error unsubscribing', error: error.message });
+ }
+};
+
+module.exports = { subscribe, confirm, getAll, remove };
diff --git a/backend/controllers/webhookController.js b/backend/controllers/webhookController.js
new file mode 100644
--- /dev/null
+++ b/backend/controllers/webhookController.js
@@ -0,0 +1,57 @@
+const serviceModel = require('../models/serviceModel.js');
+const incidentModel = require('../models/incidentModel.js');
+const incidentServiceModel = require('../models/incidentServiceModel.js');
+const incidentUpdateModel = require('../models/incidentUpdateModel.js');
+
+const ingest = async (req, res) => {
+ try {
+ const { service_name, status, message, impact } = req.body;
+
+ if (!service_name || !status) {
+ return res.status(400).json({ message: 'service_name and status are required' });
+ }
+
+ const services = await serviceModel.getServices();
+ const service = services.rows.find(
+ (s) => s.name.toLowerCase() === service_name.toLowerCase()
+ );
+
+ if (!service) {
+ return res.status(404).json({ message: `Service '${service_name}' not found` });
+ }
+
+ await serviceModel.updateService(service.id, {
+ name: service.name,
+ group: service.group,
+ description: service.description,
+ status: status,
+ order: service.order,
+ });
+
+ let incident = null;
+ if (status !== 'operational') {
+ const incidentResult = await incidentModel.createIncident({
+ title: message || `${service_name}: ${status}`,
+ status: 'investigating',
+ impact: impact || 'minor',
+ });
+ incident = incidentResult.rows[0];
+ await incidentServiceModel.setServices(incident.id, [service.id]);
+ await incidentUpdateModel.addUpdate({
+ incident_id: incident.id,
+ status: 'investigating',
+ message: message || `Automated alert: ${service_name} is ${status}`,
+ });
+ }
+
+ res.status(200).json({
+ message: 'Status updated',
+ service: { id: service.id, name: service.name, status },
+ incident: incident,
+ });
+ } catch (error) {
+ res.status(500).json({ message: 'Error processing webhook', error: error.message });
+ }
+};
+
+module.exports = { ingest };
diff --git a/backend/models/incidentServiceModel.js b/backend/models/incidentServiceModel.js
new file mode 100644
--- /dev/null
+++ b/backend/models/incidentServiceModel.js
@@ -0,0 +1,39 @@
+const pool = require('../config/database.js');
+
+const linkService = async (incidentId, serviceId) => {
+ return await pool.query(`
+ INSERT INTO incident_service (incident_id, service_id)
+ VALUES ($1, $2)
+ ON CONFLICT (incident_id, service_id) DO NOTHING
+ RETURNING *
+ `, [incidentId, serviceId]);
+};
+
+const unlinkService = async (incidentId, serviceId) => {
+ return await pool.query(`
+ DELETE FROM incident_service
+ WHERE incident_id = $1 AND service_id = $2
+ RETURNING *
+ `, [incidentId, serviceId]);
+};
+
+const getServicesByIncident = async (incidentId) => {
+ return await pool.query(`
+ SELECT s.* FROM service s
+ JOIN incident_service isv ON isv.service_id = s.id
+ WHERE isv.incident_id = $1
+ ORDER BY s.name
+ `, [incidentId]);
+};
+
+const setServices = async (incidentId, serviceIds) => {
+ await pool.query(`DELETE FROM incident_service WHERE incident_id = $1`, [incidentId]);
+ if (!serviceIds || serviceIds.length === 0) return { rows: [] };
+ const values = serviceIds.map((id, i) => `($1, $${i + 2})`).join(', ');
+ return await pool.query(
+ `INSERT INTO incident_service (incident_id, service_id) VALUES ${values} RETURNING *`,
+ [incidentId, ...serviceIds]
+ );
+};
+
+module.exports = { linkService, unlinkService, getServicesByIncident, setServices };
diff --git a/backend/models/maintenanceServiceModel.js b/backend/models/maintenanceServiceModel.js
new file mode 100644
--- /dev/null
+++ b/backend/models/maintenanceServiceModel.js
@@ -0,0 +1,22 @@
+const pool = require('../config/database.js');
+
+const setServices = async (maintenanceId, serviceIds) => {
+ await pool.query(`DELETE FROM maintenance_service WHERE maintenance_id = $1`, [maintenanceId]);
+ if (!serviceIds || serviceIds.length === 0) return { rows: [] };
+ const values = serviceIds.map((id, i) => `($1, $${i + 2})`).join(', ');
+ return await pool.query(
+ `INSERT INTO maintenance_service (maintenance_id, service_id) VALUES ${values} RETURNING *`,
+ [maintenanceId, ...serviceIds]
+ );
+};
+
+const getServicesByMaintenance = async (maintenanceId) => {
+ return await pool.query(`
+ SELECT s.* FROM service s
+ JOIN maintenance_service ms ON ms.service_id = s.id
+ WHERE ms.maintenance_id = $1
+ ORDER BY s.name
+ `, [maintenanceId]);
+};
+
+module.exports = { setServices, getServicesByMaintenance };
diff --git a/backend/models/metricModel.js b/backend/models/metricModel.js
new file mode 100644
--- /dev/null
+++ b/backend/models/metricModel.js
@@ -0,0 +1,44 @@
+const pool = require('../config/database.js');
+
+const recordMetric = async (data) => {
+ return await pool.query(`
+ INSERT INTO metric (service_id, uptime, response_time, error_rate)
+ VALUES ($1, $2, $3, $4)
+ RETURNING *
+ `, [data.service_id, data.uptime || 100, data.response_time, data.error_rate || 0]);
+};
+
+const getMetricsByService = async (serviceId, days = 30) => {
+ return await pool.query(`
+ SELECT * FROM metric
+ WHERE service_id = $1 AND recorded_at >= NOW() - INTERVAL '1 day' * $2
+ ORDER BY recorded_at ASC
+ `, [serviceId, days]);
+};
+
+const getLatestMetrics = async () => {
+ return await pool.query(`
+ SELECT DISTINCT ON (service_id)
+ m.*, s.name as service_name
+ FROM metric m
+ JOIN service s ON s.id = m.service_id
+ ORDER BY service_id, recorded_at DESC
+ `);
+};
+
+const getDailySummary = async (serviceId, days = 30) => {
+ return await pool.query(`
+ SELECT
+ DATE(recorded_at) as date,
+ AVG(uptime)::DECIMAL(5,2) as avg_uptime,
+ AVG(response_time)::INTEGER as avg_response_time,
+ AVG(error_rate)::DECIMAL(5,2) as avg_error_rate,
+ COUNT(*) as data_points
+ FROM metric
+ WHERE service_id = $1 AND recorded_at >= NOW() - INTERVAL '1 day' * $2
+ GROUP BY DATE(recorded_at)
+ ORDER BY date ASC
+ `, [serviceId, days]);
+};
+
+module.exports = { recordMetric, getMetricsByService, getLatestMetrics, getDailySummary };
diff --git a/backend/models/subscriberModel.js b/backend/models/subscriberModel.js
new file mode 100644
--- /dev/null
+++ b/backend/models/subscriberModel.js
@@ -0,0 +1,41 @@
+const pool = require('../config/database.js');
+
+const createSubscriber = async (data) => {
+ const token = require('crypto').randomBytes(32).toString('hex');
+ return await pool.query(`
+ INSERT INTO subscriber (email, webhook_url, type, confirm_token)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id, email, webhook_url, type, confirmed, created_at
+ `, [data.email || null, data.webhook_url || null, data.type || 'email', token]);
+};
+
+const confirmSubscriber = async (token) => {
+ return await pool.query(`
+ UPDATE subscriber SET confirmed = true, confirm_token = NULL
+ WHERE confirm_token = $1
+ RETURNING id, email, webhook_url, type, confirmed
+ `, [token]);
+};
+
+const getConfirmedSubscribers = async () => {
+ return await pool.query(`
+ SELECT id, email, webhook_url, type FROM subscriber
+ WHERE confirmed = true
+ ORDER BY created_at ASC
+ `);
+};
+
+const getAllSubscribers = async () => {
+ return await pool.query(`
+ SELECT id, email, webhook_url, type, confirmed, created_at FROM subscriber
+ ORDER BY created_at DESC
+ `);
+};
+
+const deleteSubscriber = async (id) => {
+ return await pool.query(`
+ DELETE FROM subscriber WHERE id = $1 RETURNING *
+ `, [id]);
+};
+
+module.exports = { createSubscriber, confirmSubscriber, getConfirmedSubscribers, getAllSubscribers, deleteSubscriber };
diff --git a/backend/package-lock.json b/backend/package-lock.json
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -9,11 +9,13 @@
"version": "1.0.0",
"license": "BSD-2-Clause",
"dependencies": {
+ "axios": "^1.13.5",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
+ "nodemailer": "^8.0.1",
"pg": "^8.11.0"
}
},
@@ -36,6 +38,23 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.13.5",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
+ "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.11",
+ "form-data": "^4.0.5",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
@@ -110,6 +129,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -172,6 +203,15 @@
"ms": "2.0.0"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -271,6 +311,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -350,6 +405,42 @@
"node": ">= 0.8"
}
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -438,6 +529,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -672,6 +778,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/nodemailer": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
+ "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -861,6 +976,12 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
diff --git a/backend/package.json b/backend/package.json
--- a/backend/package.json
+++ b/backend/package.json
@@ -11,11 +11,13 @@
"author": "",
"license": "BSD-2-Clause",
"dependencies": {
+ "axios": "^1.13.5",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
+ "nodemailer": "^8.0.1",
"pg": "^8.11.0"
}
}
diff --git a/backend/routes/metricRoutes.js b/backend/routes/metricRoutes.js
new file mode 100644
--- /dev/null
+++ b/backend/routes/metricRoutes.js
@@ -0,0 +1,11 @@
+const express = require('express');
+const router = express.Router();
+const metricController = require('../controllers/metricController.js');
+const { authenticate } = require('../middleware/auth.js');
+
+router.get('/metrics', metricController.getLatestMetrics);
+router.get('/metrics/service/:serviceId', metricController.getMetricsByService);
+router.get('/metrics/service/:serviceId/daily', metricController.getDailySummary);
+router.post('/metrics', authenticate, metricController.recordMetric);
+
+module.exports = router;
diff --git a/backend/routes/subscriberRoutes.js b/backend/routes/subscriberRoutes.js
new file mode 100644
--- /dev/null
+++ b/backend/routes/subscriberRoutes.js
@@ -0,0 +1,11 @@
+const express = require('express');
+const router = express.Router();
+const subscriberController = require('../controllers/subscriberController.js');
+const { authenticate } = require('../middleware/auth.js');
+
+router.post('/subscribers', subscriberController.subscribe);
+router.get('/subscribers/confirm/:token', subscriberController.confirm);
+router.get('/subscribers', authenticate, subscriberController.getAll);
+router.delete('/subscribers/:id', authenticate, subscriberController.remove);
+
+module.exports = router;
diff --git a/backend/routes/webhookRoutes.js b/backend/routes/webhookRoutes.js
new file mode 100644
--- /dev/null
+++ b/backend/routes/webhookRoutes.js
@@ -0,0 +1,8 @@
+const express = require('express');
+const router = express.Router();
+const webhookController = require('../controllers/webhookController.js');
+const { authenticate } = require('../middleware/auth.js');
+
+router.post('/webhooks/ingest', authenticate, webhookController.ingest);
+
+module.exports = router;
diff --git a/backend/services/notificationService.js b/backend/services/notificationService.js
new file mode 100644
--- /dev/null
+++ b/backend/services/notificationService.js
@@ -0,0 +1,71 @@
+const nodemailer = require('nodemailer');
+const axios = require('axios');
+const subscriberModel = require('../models/subscriberModel.js');
+
+const transporter = nodemailer.createTransport({
+ host: process.env.SMTP_HOST || 'localhost',
+ port: parseInt(process.env.SMTP_PORT) || 587,
+ secure: process.env.SMTP_SECURE === 'true',
+ auth: process.env.SMTP_USER ? {
+ user: process.env.SMTP_USER,
+ pass: process.env.SMTP_PASS,
+ } : undefined,
+});
+
+const sendEmail = async (to, subject, text) => {
+ try {
+ await transporter.sendMail({
+ from: process.env.SMTP_FROM || 'ServPulse <noreply@servpulse.local>',
+ to,
+ subject,
+ text,
+ });
+ } catch (error) {
+ console.error(`Failed to send email to ${to}:`, error.message);
+ }
+};
+
+const sendWebhook = async (url, payload) => {
+ try {
+ await axios.post(url, payload, {
+ headers: { 'Content-Type': 'application/json' },
+ timeout: 10000,
+ });
+ } catch (error) {
+ console.error(`Failed to send webhook to ${url}:`, error.message);
+ }
+};
+
+const notifyAll = async (event, data) => {
+ const result = await subscriberModel.getConfirmedSubscribers();
+ const subscribers = result.rows;
+
+ const subject = `[ServPulse] ${event}`;
+ const text = formatNotification(event, data);
+ const payload = { event, timestamp: new Date().toISOString(), ...data };
+
+ const promises = subscribers.map((sub) => {
+ if (sub.type === 'email' && sub.email) {
+ return sendEmail(sub.email, subject, text);
+ } else if (sub.type === 'webhook' && sub.webhook_url) {
+ return sendWebhook(sub.webhook_url, payload);
+ }
+ });
+
+ await Promise.allSettled(promises);
+};
+
+const formatNotification = (event, data) => {
+ let text = `ServPulse Notification\n${'='.repeat(40)}\n\n`;
+ text += `Event: ${event}\n`;
+ text += `Time: ${new Date().toISOString()}\n\n`;
+
+ if (data.title) text += `Title: ${data.title}\n`;
+ if (data.status) text += `Status: ${data.status}\n`;
+ if (data.impact) text += `Impact: ${data.impact}\n`;
+ if (data.message) text += `Message: ${data.message}\n`;
+
+ return text;
+};
+
+module.exports = { notifyAll, sendEmail, sendWebhook };
diff --git a/database/init.sql b/database/init.sql
--- a/database/init.sql
+++ b/database/init.sql
@@ -72,3 +72,36 @@
service_id INTEGER NOT NULL REFERENCES service(id) ON DELETE CASCADE,
UNIQUE(maintenance_id, service_id)
);
+
+--
+-- Subscribers
+--
+
+CREATE TABLE IF NOT EXISTS subscriber (
+ id SERIAL PRIMARY KEY,
+ email VARCHAR(255),
+ webhook_url TEXT,
+ type VARCHAR(20) NOT NULL DEFAULT 'email',
+ confirmed BOOLEAN NOT NULL DEFAULT false,
+ confirm_token VARCHAR(255),
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ CONSTRAINT subscriber_contact CHECK (email IS NOT NULL OR webhook_url IS NOT NULL)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriber_email ON subscriber(email) WHERE email IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriber_webhook ON subscriber(webhook_url) WHERE webhook_url IS NOT NULL;
+
+--
+-- Metrics
+--
+
+CREATE TABLE IF NOT EXISTS metric (
+ id SERIAL PRIMARY KEY,
+ service_id INTEGER NOT NULL REFERENCES service(id) ON DELETE CASCADE,
+ uptime DECIMAL(5,2) NOT NULL DEFAULT 100.00,
+ response_time INTEGER,
+ error_rate DECIMAL(5,2) DEFAULT 0.00,
+ recorded_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_metric_service_date ON metric(service_id, recorded_at DESC);
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,7 +9,9 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.4.0",
+ "chart.js": "^4.5.1",
"vue": "^3.3.2",
+ "vue-chartjs": "^5.3.3",
"vue-router": "^4.2.0"
},
"devDependencies": {
@@ -647,6 +649,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@kurkle/color": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+ "license": "MIT"
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1763,6 +1771,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chart.js": {
+ "version": "4.5.1",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
+ "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
"node_modules/check-error": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
@@ -5177,6 +5197,16 @@
}
}
},
+ "node_modules/vue-chartjs": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
+ "integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "vue": "^3.0.0-0 || ^2.7.0"
+ }
+ },
"node_modules/vue-component-type-helpers": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz",
diff --git a/frontend/package.json b/frontend/package.json
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,7 +12,9 @@
},
"dependencies": {
"axios": "^1.4.0",
+ "chart.js": "^4.5.1",
"vue": "^3.3.2",
+ "vue-chartjs": "^5.3.3",
"vue-router": "^4.2.0"
},
"devDependencies": {
diff --git a/frontend/src/components/SubscribeForm.vue b/frontend/src/components/SubscribeForm.vue
new file mode 100644
--- /dev/null
+++ b/frontend/src/components/SubscribeForm.vue
@@ -0,0 +1,71 @@
+<script setup>
+import { ref } from 'vue'
+import { subscribersApi } from '@/plugins/api'
+
+const type = ref('email')
+const email = ref('')
+const webhookUrl = ref('')
+const submitted = ref(false)
+const error = ref('')
+
+const handleSubmit = async () => {
+ error.value = ''
+ try {
+ const data = type.value === 'email'
+ ? { type: 'email', email: email.value }
+ : { type: 'webhook', webhook_url: webhookUrl.value }
+ await subscribersApi.subscribe(data)
+ submitted.value = true
+ } catch (err) {
+ if (err.response?.status === 409) {
+ error.value = 'Already subscribed'
+ } else {
+ error.value = 'Failed to subscribe. Please try again.'
+ }
+ }
+}
+</script>
+
+<template>
+ <div class="card p-5">
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Subscribe to Updates</h3>
+
+ <div v-if="submitted" class="text-sm text-green-600 dark:text-green-400">
+ ✓ Subscribed! Check your email for confirmation.
+ </div>
+
+ <form v-else @submit.prevent="handleSubmit" class="space-y-3">
+ <div class="flex gap-4">
+ <label class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
+ <input type="radio" v-model="type" value="email" class="text-brand-500" />
+ Email
+ </label>
+ <label class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
+ <input type="radio" v-model="type" value="webhook" class="text-brand-500" />
+ Webhook
+ </label>
+ </div>
+
+ <input
+ v-if="type === 'email'"
+ v-model="email"
+ type="email"
+ placeholder="you@example.com"
+ class="input-field"
+ required
+ />
+ <input
+ v-else
+ v-model="webhookUrl"
+ type="url"
+ placeholder="https://your-webhook-url.com/hook"
+ class="input-field"
+ required
+ />
+
+ <p v-if="error" class="text-sm text-red-500">{{ error }}</p>
+
+ <button type="submit" class="btn-primary">Subscribe</button>
+ </form>
+ </div>
+</template>
diff --git a/frontend/src/components/UptimeChart.vue b/frontend/src/components/UptimeChart.vue
new file mode 100644
--- /dev/null
+++ b/frontend/src/components/UptimeChart.vue
@@ -0,0 +1,82 @@
+<script setup>
+import { ref, onMounted, watch } from 'vue'
+import { Bar } from 'vue-chartjs'
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Tooltip,
+} from 'chart.js'
+import { metricsApi } from '@/plugins/api'
+
+ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip)
+
+const props = defineProps({
+ serviceId: { type: Number, required: true },
+ days: { type: Number, default: 30 },
+})
+
+const chartData = ref(null)
+const loading = ref(true)
+
+const chartOptions = {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ tooltip: {
+ callbacks: {
+ label: (ctx) => `${ctx.raw}% uptime`,
+ },
+ },
+ },
+ scales: {
+ x: { display: false },
+ y: {
+ display: false,
+ min: 0,
+ max: 100,
+ },
+ },
+}
+
+const fetchData = async () => {
+ loading.value = true
+ try {
+ const data = await metricsApi.getDailySummary(props.serviceId, props.days)
+ chartData.value = {
+ labels: data.map((d) => d.date),
+ datasets: [
+ {
+ data: data.map((d) => parseFloat(d.avg_uptime)),
+ backgroundColor: data.map((d) => {
+ const uptime = parseFloat(d.avg_uptime)
+ if (uptime >= 99) return '#10b981'
+ if (uptime >= 95) return '#f59e0b'
+ return '#ef4444'
+ }),
+ borderRadius: 2,
+ barPercentage: 0.8,
+ },
+ ],
+ }
+ } catch {
+ chartData.value = null
+ } finally {
+ loading.value = false
+ }
+}
+
+onMounted(fetchData)
+watch(() => props.serviceId, fetchData)
+</script>
+
+<template>
+ <div class="h-8">
+ <div v-if="loading" class="flex items-center justify-center h-full">
+ <div class="h-3 w-3 animate-spin rounded-full border-2 border-gray-300 border-t-brand-500"></div>
+ </div>
+ <Bar v-else-if="chartData" :data="chartData" :options="chartOptions" />
+ <p v-else class="text-xs text-gray-400 text-center leading-8">No data</p>
+ </div>
+</template>
diff --git a/frontend/src/composables/useMetrics.js b/frontend/src/composables/useMetrics.js
new file mode 100644
--- /dev/null
+++ b/frontend/src/composables/useMetrics.js
@@ -0,0 +1,35 @@
+import { ref } from 'vue'
+import { metricsApi } from '@/plugins/api'
+
+export function useMetrics() {
+ const metrics = ref([])
+ const dailySummary = ref([])
+ const loading = ref(false)
+ const error = ref(null)
+
+ const fetchDailySummary = async (serviceId, days = 30) => {
+ loading.value = true
+ error.value = null
+ try {
+ dailySummary.value = await metricsApi.getDailySummary(serviceId, days)
+ } catch (err) {
+ error.value = err.message
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const fetchLatestMetrics = async () => {
+ loading.value = true
+ error.value = null
+ try {
+ metrics.value = await metricsApi.getLatest()
+ } catch (err) {
+ error.value = err.message
+ } finally {
+ loading.value = false
+ }
+ }
+
+ return { metrics, dailySummary, loading, error, fetchDailySummary, fetchLatestMetrics }
+}
diff --git a/frontend/src/plugins/api.js b/frontend/src/plugins/api.js
--- a/frontend/src/plugins/api.js
+++ b/frontend/src/plugins/api.js
@@ -42,4 +42,20 @@
delete: (id) => apiClient.delete(`/maintenances/${id}`).then((r) => r.data),
}
+export const metricsApi = {
+ getLatest: () => apiClient.get('/metrics').then((r) => r.data),
+ getByService: (serviceId, days = 30) =>
+ apiClient.get(`/metrics/service/${serviceId}?days=${days}`).then((r) => r.data),
+ getDailySummary: (serviceId, days = 30) =>
+ apiClient.get(`/metrics/service/${serviceId}/daily?days=${days}`).then((r) => r.data),
+ record: (data) => apiClient.post('/metrics', data).then((r) => r.data),
+}
+
+export const subscribersApi = {
+ subscribe: (data) => apiClient.post('/subscribers', data).then((r) => r.data),
+ confirm: (token) => apiClient.get(`/subscribers/confirm/${token}`).then((r) => r.data),
+ getAll: () => apiClient.get('/subscribers').then((r) => r.data),
+ delete: (id) => apiClient.delete(`/subscribers/${id}`).then((r) => r.data),
+}
+
export default apiClient
diff --git a/frontend/src/views/StatusPage.vue b/frontend/src/views/StatusPage.vue
--- a/frontend/src/views/StatusPage.vue
+++ b/frontend/src/views/StatusPage.vue
@@ -8,6 +8,7 @@
import IncidentTimeline from '@/components/IncidentTimeline.vue'
import MaintenanceCard from '@/components/MaintenanceCard.vue'
import { incidentsApi } from '@/plugins/api'
+import SubscribeForm from '@/components/SubscribeForm.vue'
const { services, loading: servicesLoading, fetchServices, groupedServices } = useServices()
const { incidents, loading: incidentsLoading, fetchIncidents, activeIncidents, resolvedIncidents } = useIncidents()
@@ -88,6 +89,11 @@
<IncidentTimeline v-for="i in recentResolved" :key="i.id" :incident="i" />
</div>
</section>
+
+ <!-- Subscribe -->
+ <section>
+ <SubscribeForm />
+ </section>
</template>
</div>
</template>

File Metadata

Mime Type
text/plain
Expires
Tue, Feb 17, 12:00 (21 h, 17 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3454232
Default Alt Text
D3965.id10275.diff (37 KB)

Event Timeline