Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion server/model/incident.js
Original file line number Diff line number Diff line change
@@ -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<void>}
*/
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
Expand All @@ -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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't consider this quite internal, or am I missing something? πŸ€”
Can't this just be added to toPublicJSON?

Copy link
Contributor Author

@aluminyoom aluminyoom Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

definitely trivial yea, reasoning behind this is to expose the only needed data for public endpoints. since status_page_id doesn't really get consumed on the public facing side.

we can add it to toPublicJSON but it would be unused for public endpoints.

edit: formatting

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets simplify this then to just include it up in toPublicJSON.

};
}
}

module.exports = Incident;
38 changes: 30 additions & 8 deletions server/model/status_page.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const { marked } = require("marked");
const { Feed } = require("feed");
const config = require("../config");

const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE, DOWN } = require("../../src/util");
const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE, DOWN, INCIDENT_PAGE_SIZE } = require("../../src/util");

class StatusPage extends BeanModel {

Expand Down Expand Up @@ -267,14 +267,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);

Expand All @@ -294,7 +291,7 @@ class StatusPage extends BeanModel {
// Response
return {
config,
incident,
incidents,
publicGroupList,
maintenanceList,
};
Expand Down Expand Up @@ -468,6 +465,31 @@ class StatusPage extends BeanModel {
}
}

/**
* Get paginated incident history for a status page
* @param {number} statusPageId ID of the status page
* @param {number} page Page number (1-based)
* @param {boolean} isPublic Whether to return public or admin data
* @returns {Promise<object>} Paginated incident data
*/
static async getIncidentHistory(statusPageId, page, isPublic = true) {
const offset = (page - 1) * INCIDENT_PAGE_SIZE;

const incidents = await R.find("incident",
" status_page_id = ? ORDER BY created_date DESC LIMIT ? OFFSET ? ",
[ statusPageId, INCIDENT_PAGE_SIZE, offset ]
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of offset based, can we make it created_date based?

Here is a blogpost explaining the rationale and why limit/offset is usually buggy.
https://cedardb.com/blog/pagination/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thats a very interesting and great read. i'll go ahead and do that, thanks!


const total = await R.count("incident", " status_page_id = ? ", [ statusPageId ]);

return {
incidents: incidents.map(i => isPublic ? i.toPublicJSON() : i.toJSON()),
total,
page,
totalPages: Math.ceil(total / INCIDENT_PAGE_SIZE)
};
}

/**
* Get list of maintenances
* @param {number} statusPageId ID of status page to get maintenance for
Expand Down
24 changes: 24 additions & 0 deletions server/routers/status-page-router.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,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 page = parseInt(request.query.page) || 1;
const result = await StatusPage.getIncidentHistory(statusPageID, page, 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);
Expand Down
206 changes: 202 additions & 4 deletions server/socket-handlers/status-page-socket-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,10 +41,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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -90,6 +102,192 @@ module.exports.statusPageSocketHandler = (socket) => {
}
});

socket.on("getIncidentHistory", async (slug, page, callback) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getIncidentHistory and getPublicIncidentHistory only differ in the one checking for login and one true/false.
Can we merge them into one where true/false is set based on the login status?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure!

try {
checkLogin(socket);

let statusPageID = await StatusPage.slugToID(slug);
if (!statusPageID) {
throw new Error("slug is not found");
}

const result = await StatusPage.getIncidentHistory(statusPageID, page, false);
callback({
ok: true,
...result
});
} 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 is not found");
}

const result = await StatusPage.getIncidentHistory(statusPageID, page, true);
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.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 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.toJSON(),
});
} catch (error) {
callback({
ok: false,
msg: error.message,
msgi18n: true,
});
}
});

socket.on("getStatusPage", async (slug, callback) => {
try {
checkLogin(socket);
Expand Down
Loading
Loading