diff --git a/server/model/incident.js b/server/model/incident.js index c47dabb41..405bc5a52 100644 --- a/server/model/incident.js +++ b/server/model/incident.js @@ -1,7 +1,20 @@ const { BeanModel } = require("redbean-node/dist/bean-model"); +const { R } = require("redbean-node"); +const dayjs = require("dayjs"); class Incident extends BeanModel { + /** + * Resolve the incident and mark it as inactive + * @returns {Promise} + */ + async resolve() { + this.active = false; + this.pin = false; + this.lastUpdatedDate = R.isoDateTime(dayjs.utc()); + await R.store(this); + } + /** * Return an object that ready to parse to JSON for public * Only show necessary data to public @@ -13,11 +26,23 @@ class Incident extends BeanModel { style: this.style, title: this.title, content: this.content, - pin: this.pin, + pin: !!this.pin, + active: !!this.active, createdDate: this.createdDate, lastUpdatedDate: this.lastUpdatedDate, }; } + + /** + * Return full object for admin use + * @returns {object} Object ready to parse + */ + toJSON() { + return { + ...this.toPublicJSON(), + status_page_id: this.status_page_id, + }; + } } module.exports = Incident; diff --git a/server/model/status_page.js b/server/model/status_page.js index 224441127..a204ab75e 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -263,14 +263,11 @@ class StatusPage extends BeanModel { static async getStatusPageData(statusPage) { const config = await statusPage.toPublicJSON(); - // Incident - let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [ + // All active incidents + let incidents = await R.find("incident", " pin = 1 AND active = 1 AND status_page_id = ? ORDER BY created_date DESC", [ statusPage.id, ]); - - if (incident) { - incident = incident.toPublicJSON(); - } + incidents = incidents.map(i => i.toPublicJSON()); let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id); @@ -290,7 +287,7 @@ class StatusPage extends BeanModel { // Response return { config, - incident, + incidents, publicGroupList, maintenanceList, }; diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js index 6e57451f1..94ce7fc98 100644 --- a/server/routers/status-page-router.js +++ b/server/routers/status-page-router.js @@ -147,6 +147,43 @@ router.get("/api/status-page/:slug/manifest.json", cache("1440 minutes"), async } }); +router.get("/api/status-page/:slug/incident-history", cache("5 minutes"), async (request, response) => { + allowDevAllOrigin(response); + + try { + let slug = request.params.slug; + slug = slug.toLowerCase(); + let statusPageID = await StatusPage.slugToID(slug); + + if (!statusPageID) { + sendHttpError(response, "Status Page Not Found"); + return; + } + + const page = parseInt(request.query.page) || 1; + const limit = 10; + const offset = (page - 1) * limit; + + const incidents = await R.find("incident", + " status_page_id = ? ORDER BY created_date DESC LIMIT ? OFFSET ? ", + [ statusPageID, limit, offset ] + ); + + const total = await R.count("incident", " status_page_id = ? ", [ statusPageID ]); + + response.json({ + ok: true, + incidents: incidents.map(i => i.toPublicJSON()), + total: total, + page: page, + totalPages: Math.ceil(total / limit) + }); + + } catch (error) { + sendHttpError(response, error.message); + } +}); + // overall status-page status badge router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, response) => { allowDevAllOrigin(response); diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js index 7fb93cfaf..b978d7f48 100644 --- a/server/socket-handlers/status-page-socket-handler.js +++ b/server/socket-handlers/status-page-socket-handler.js @@ -8,6 +8,21 @@ const apicache = require("../modules/apicache"); const StatusPage = require("../model/status_page"); const { UptimeKumaServer } = require("../uptime-kuma-server"); +/** + * Validates incident data + * @param {object} incident - The incident object + * @returns {void} + * @throws {Error} If validation fails + */ +function validateIncident(incident) { + if (!incident.title || incident.title.trim() === "") { + throw new Error("Please input title"); + } + if (!incident.content || incident.content.trim() === "") { + throw new Error("Please input content"); + } +} + /** * Socket handlers for status page * @param {Socket} socket Socket.io instance to add listeners on @@ -23,13 +38,9 @@ module.exports.statusPageSocketHandler = (socket) => { let statusPageID = await StatusPage.slugToID(slug); if (!statusPageID) { - throw new Error("slug is not found"); + throw new Error("slug not found"); } - await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [ - statusPageID - ]); - let incidentBean; if (incident.id) { @@ -47,6 +58,7 @@ module.exports.statusPageSocketHandler = (socket) => { incidentBean.content = incident.content; incidentBean.style = incident.style; incidentBean.pin = true; + incidentBean.active = true; incidentBean.status_page_id = statusPageID; if (incident.id) { @@ -90,6 +102,216 @@ module.exports.statusPageSocketHandler = (socket) => { } }); + socket.on("getIncidentHistory", async (slug, page, callback) => { + try { + checkLogin(socket); + + let statusPageID = await StatusPage.slugToID(slug); + if (!statusPageID) { + throw new Error("slug not found"); + } + + const limit = 10; + const offset = (page - 1) * limit; + + const incidents = await R.find("incident", + " status_page_id = ? ORDER BY created_date DESC LIMIT ? OFFSET ? ", + [ statusPageID, limit, offset ] + ); + + const total = await R.count("incident", " status_page_id = ? ", [ statusPageID ]); + + callback({ + ok: true, + incidents: incidents.map(i => i.toJSON()), + total: total, + page: page, + totalPages: Math.ceil(total / limit) + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("getPublicIncidentHistory", async (slug, page, callback) => { + try { + let statusPageID = await StatusPage.slugToID(slug); + if (!statusPageID) { + throw new Error("slug not found"); + } + + const limit = 10; + const offset = (page - 1) * limit; + + const incidents = await R.find("incident", + " status_page_id = ? ORDER BY created_date DESC LIMIT ? OFFSET ? ", + [ statusPageID, limit, offset ] + ); + + const total = await R.count("incident", " status_page_id = ? ", [ statusPageID ]); + + callback({ + ok: true, + incidents: incidents.map(i => i.toPublicJSON()), + total: total, + page: page, + totalPages: Math.ceil(total / limit) + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + }); + } + }); + + socket.on("editIncident", async (slug, incidentID, incident, callback) => { + try { + checkLogin(socket); + + let statusPageID = await StatusPage.slugToID(slug); + if (!statusPageID) { + callback({ + ok: false, + msg: "slug not found", + msgi18n: true + }); + return; + } + + let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [ incidentID, statusPageID ]); + if (!bean) { + callback({ + ok: false, + msg: "Incident not found or access denied", + msgi18n: true + }); + return; + } + + try { + validateIncident(incident); + } catch (e) { + callback({ + ok: false, + msg: e.message, + msgi18n: true + }); + return; + } + + const validStyles = [ "info", "warning", "danger", "primary", "light", "dark" ]; + if (!validStyles.includes(incident.style)) { + incident.style = "warning"; + } + + bean.title = incident.title; + bean.content = incident.content; + bean.style = incident.style; + bean.pin = incident.pin !== false; + bean.lastUpdatedDate = R.isoDateTime(dayjs.utc()); + + await R.store(bean); + + callback({ + ok: true, + msg: "Saved.", + msgi18n: true, + incident: bean.toJSON(), + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + msgi18n: true, + }); + } + }); + + socket.on("deleteIncident", async (slug, incidentID, callback) => { + try { + checkLogin(socket); + + let statusPageID = await StatusPage.slugToID(slug); + if (!statusPageID) { + callback({ + ok: false, + msg: "slug not found", + msgi18n: true + }); + return; + } + + let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [ incidentID, statusPageID ]); + if (!bean) { + callback({ + ok: false, + msg: "Incident not found or access denied", + msgi18n: true + }); + return; + } + + await R.trash(bean); + + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + msgi18n: true, + }); + } + }); + + socket.on("resolveIncident", async (slug, incidentID, callback) => { + try { + checkLogin(socket); + + let statusPageID = await StatusPage.slugToID(slug); + if (!statusPageID) { + callback({ + ok: false, + msg: "slug not found", + msgi18n: true + }); + return; + } + + let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [ incidentID, statusPageID ]); + if (!bean) { + callback({ + ok: false, + msg: "Incident not found or access denied", + msgi18n: true + }); + return; + } + + await bean.resolve(); + + callback({ + ok: true, + msg: "Resolved", + msgi18n: true, + incident: bean.toJSON(), + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + msgi18n: true, + }); + } + }); + socket.on("getStatusPage", async (slug, callback) => { try { checkLogin(socket); diff --git a/src/components/IncidentHistory.vue b/src/components/IncidentHistory.vue new file mode 100644 index 000000000..1fcb5c3ac --- /dev/null +++ b/src/components/IncidentHistory.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/src/components/IncidentManageModal.vue b/src/components/IncidentManageModal.vue new file mode 100644 index 000000000..6138d0466 --- /dev/null +++ b/src/components/IncidentManageModal.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/src/lang/en.json b/src/lang/en.json index 0d076db00..32525b837 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -41,6 +41,9 @@ "Status": "Status", "DateTime": "DateTime", "Message": "Message", + "No incidents recorded": "No incidents recorded", + "Load More": "Load More", + "Loading...": "Loading...", "No important events": "No important events", "Resume": "Resume", "Edit": "Edit", @@ -57,6 +60,7 @@ "-hour": "-hour", "-year": "-year", "Response": "Response", + "Pin this incident": "Pin this incident", "Ping": "Ping", "Monitor Type": "Monitor Type", "Keyword": "Keyword", @@ -183,6 +187,10 @@ "Last Result": "Last Result", "Create your admin account": "Create your admin account", "Repeat Password": "Repeat Password", + "Incident description": "Incident description", + "Incident not found or access denied": "Incident not found or access denied", + "Past Incidents": "Past Incidents", + "Incident title": "Incident title", "Import Backup": "Import Backup", "Export Backup": "Export Backup", "Export": "Export", @@ -239,6 +247,7 @@ "Blue": "Blue", "Indigo": "Indigo", "Purple": "Purple", + "Pinned incidents are shown prominently on the status page": "Pinned incidents are shown prominently on the status page", "Pink": "Pink", "Custom": "Custom", "Search...": "Search…", @@ -254,6 +263,7 @@ "Degraded Service": "Degraded Service", "Add Group": "Add Group", "Add a monitor": "Add a monitor", + "Edit Incident": "Edit Incident", "Edit Status Page": "Edit Status Page", "Go to Dashboard": "Go to Dashboard", "Status Page": "Status Page", @@ -308,6 +318,7 @@ "successKeyword": "Success Keyword", "successKeywordExplanation": "MQTT Keyword that will be considered as success", "recent": "Recent", + "Resolve": "Resolve", "Reset Token": "Reset Token", "Done": "Done", "Info": "Info", @@ -356,6 +367,7 @@ "Customize": "Customize", "Custom Footer": "Custom Footer", "Custom CSS": "Custom CSS", + "deleteIncidentMsg": "Are you sure you want to delete this incident?", "deleteStatusPageMsg": "Are you sure want to delete this status page?", "Proxies": "Proxies", "default": "Default", @@ -378,6 +390,7 @@ "Stop": "Stop", "Add New Status Page": "Add New Status Page", "Slug": "Slug", + "slug is not found": "Slug not found", "Accept characters:": "Accept characters:", "startOrEndWithOnly": "Start or end with {0} only", "No consecutive dashes": "No consecutive dashes", @@ -401,6 +414,8 @@ "Trust Proxy": "Trust Proxy", "Other Software": "Other Software", "For example: nginx, Apache and Traefik.": "For example: nginx, Apache and Traefik.", + "Please input content": "Please input content", + "Please input title": "Please input title", "Please read": "Please read", "Subject:": "Subject:", "Valid To:": "Valid To:", @@ -1233,3 +1248,4 @@ "Unable to get permission to notify": "Unable to get permission to notify (request either denied or ignored).", "Webpush Helptext": "Web push only works with SSL (HTTPS) connections. For iOS devices, webpage must be added to homescreen beforehand." } + diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index 74a5c3f66..6c2aec02c 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -183,43 +183,28 @@ -