diff --git a/server/model/incident.js b/server/model/incident.js index a700172bb6..ecf400f793 100644 --- a/server/model/incident.js +++ b/server/model/incident.js @@ -1,9 +1,21 @@ 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.last_updated_date = 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 * @returns {object} Object ready to parse */ toPublicJSON() { @@ -12,9 +24,11 @@ class Incident extends BeanModel { style: this.style, title: this.title, content: this.content, - pin: this.pin, - createdDate: this.createdDate, - lastUpdatedDate: this.lastUpdatedDate, + pin: !!this.pin, + active: !!this.active, + createdDate: this.created_date, + lastUpdatedDate: this.last_updated_date, + status_page_id: this.status_page_id, }; } } diff --git a/server/model/status_page.js b/server/model/status_page.js index 9952d56a8a..24ec16dba4 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -7,8 +7,8 @@ const analytics = require("../analytics/analytics"); const { marked } = require("marked"); const { Feed } = require("feed"); const config = require("../config"); -const { setting } = require("../util-server"); +const { setting } = require("../util-server"); const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, @@ -17,6 +17,7 @@ const { UP, MAINTENANCE, DOWN, + INCIDENT_PAGE_SIZE, } = require("../../src/util"); class StatusPage extends BeanModel { @@ -307,12 +308,13 @@ 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 = ? ", [statusPage.id]); - - if (incident) { - incident = incident.toPublicJSON(); - } + // 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] + ); + incidents = incidents.map((i) => i.toPublicJSON()); let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id); @@ -330,7 +332,7 @@ class StatusPage extends BeanModel { // Response return { config, - incident, + incidents, publicGroupList, maintenanceList, }; @@ -499,6 +501,54 @@ class StatusPage extends BeanModel { } } + /** + * Get paginated incident history for a status page using cursor-based pagination + * @param {number} statusPageId ID of the status page + * @param {string|null} cursor ISO date string cursor (created_date of last item from previous page) + * @param {boolean} isPublic Whether to return public or admin data + * @returns {Promise} Paginated incident data with cursor + */ + static async getIncidentHistory(statusPageId, cursor = null, isPublic = true) { + let incidents; + + if (cursor) { + incidents = await R.find( + "incident", + " status_page_id = ? AND created_date < ? ORDER BY created_date DESC LIMIT ? ", + [statusPageId, cursor, INCIDENT_PAGE_SIZE] + ); + } else { + incidents = await R.find("incident", " status_page_id = ? ORDER BY created_date DESC LIMIT ? ", [ + statusPageId, + INCIDENT_PAGE_SIZE, + ]); + } + + const total = await R.count("incident", " status_page_id = ? ", [statusPageId]); + + const lastIncident = incidents[incidents.length - 1]; + let nextCursor = null; + let hasMore = false; + + if (lastIncident) { + const moreCount = await R.count("incident", " status_page_id = ? AND created_date < ? ", [ + statusPageId, + lastIncident.created_date, + ]); + hasMore = moreCount > 0; + if (hasMore) { + nextCursor = lastIncident.created_date; + } + } + + return { + incidents: incidents.map((i) => i.toPublicJSON()), + total, + nextCursor, + hasMore, + }; + } + /** * Get list of maintenances * @param {number} statusPageId ID of status page to get maintenance for diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js index 75e8fdea8f..fda2962685 100644 --- a/server/routers/status-page-router.js +++ b/server/routers/status-page-router.js @@ -142,6 +142,30 @@ 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 cursor = request.query.cursor || null; + const result = await StatusPage.getIncidentHistory(statusPageID, cursor, true); + response.json({ + ok: true, + ...result, + }); + } 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 a863076cca..5d1035d1da 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 @@ -25,8 +40,6 @@ module.exports.statusPageSocketHandler = (socket) => { throw new Error("slug is not found"); } - await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [statusPageID]); - let incidentBean; if (incident.id) { @@ -44,12 +57,13 @@ 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) { - incidentBean.lastUpdatedDate = R.isoDateTime(dayjs.utc()); + incidentBean.last_updated_date = R.isoDateTime(dayjs.utc()); } else { - incidentBean.createdDate = R.isoDateTime(dayjs.utc()); + incidentBean.created_date = R.isoDateTime(dayjs.utc()); } await R.store(incidentBean); @@ -85,6 +99,171 @@ module.exports.statusPageSocketHandler = (socket) => { } }); + socket.on("getIncidentHistory", async (slug, cursor, callback) => { + try { + let statusPageID = await StatusPage.slugToID(slug); + if (!statusPageID) { + throw new Error("slug is not found"); + } + + const isPublic = !socket.userID; + const result = await StatusPage.getIncidentHistory(statusPageID, cursor, isPublic); + callback({ + ok: true, + ...result, + }); + } 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 is 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.toPublicJSON(), + }); + } 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 is 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 is 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.toPublicJSON(), + }); + } catch (error) { + callback({ + ok: false, + msg: error.message, + msgi18n: true, + }); + } + }); + socket.on("getStatusPage", async (slug, callback) => { try { checkLogin(socket); diff --git a/src/components/IncidentEditForm.vue b/src/components/IncidentEditForm.vue new file mode 100644 index 0000000000..08597dfb38 --- /dev/null +++ b/src/components/IncidentEditForm.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/src/components/IncidentHistory.vue b/src/components/IncidentHistory.vue new file mode 100644 index 0000000000..d756b7f767 --- /dev/null +++ b/src/components/IncidentHistory.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/src/components/IncidentManageModal.vue b/src/components/IncidentManageModal.vue new file mode 100644 index 0000000000..ebe5d06c33 --- /dev/null +++ b/src/components/IncidentManageModal.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/src/lang/en.json b/src/lang/en.json index 808b1092e8..a2a87669d0 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -46,6 +46,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", @@ -62,6 +65,7 @@ "minuteShort": "{n} min | {n} min", "years": "{n} year | {n} years", "Response": "Response", + "Pin this incident": "Pin this incident", "Ping": "Ping", "Monitor Type": "Monitor Type", "Keyword": "Keyword", @@ -167,6 +171,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", @@ -223,6 +231,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…", @@ -238,6 +247,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", @@ -296,6 +306,8 @@ "successKeyword": "Success Keyword", "successKeywordExplanation": "MQTT Keyword that will be considered as success", "recent": "Recent", + "Resolve": "Resolve", + "Resolved": "Resolved", "Reset Token": "Reset Token", "Done": "Done", "Info": "Info", @@ -349,6 +361,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", @@ -371,6 +384,7 @@ "Stop": "Stop", "Add New Status Page": "Add New Status Page", "Slug": "Slug", + "slug is not found": "Slug is not found", "Accept characters:": "Accept characters:", "startOrEndWithOnly": "Start or end with {0} only", "No consecutive dashes": "No consecutive dashes", @@ -394,6 +408,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:", diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index db9fbfb89b..defc980bb6 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -297,131 +297,83 @@ - -