Page Menu
Home
DevCentral
Search
Configure Global Search
Log In
Files
F24373734
D3965.id10275.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
37 KB
Referenced Files
None
Subscribers
None
D3965.id10275.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D3965: Phase 4: Add metrics, notifications, and monitoring integrations
Attached
Detach File
Event Timeline
Log In to Comment