Skip to content

Commit 50d29c6

Browse files
Merge branch 'master' into feat/google-sheets-notification
2 parents ed28048 + 9169a64 commit 50d29c6

12 files changed

Lines changed: 1254 additions & 147 deletions

File tree

server/model/incident.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
const { BeanModel } = require("redbean-node/dist/bean-model");
2+
const { R } = require("redbean-node");
3+
const dayjs = require("dayjs");
24

35
class Incident extends BeanModel {
6+
/**
7+
* Resolve the incident and mark it as inactive
8+
* @returns {Promise<void>}
9+
*/
10+
async resolve() {
11+
this.active = false;
12+
this.pin = false;
13+
this.last_updated_date = R.isoDateTime(dayjs.utc());
14+
await R.store(this);
15+
}
16+
417
/**
518
* Return an object that ready to parse to JSON for public
6-
* Only show necessary data to public
719
* @returns {object} Object ready to parse
820
*/
921
toPublicJSON() {
@@ -12,9 +24,11 @@ class Incident extends BeanModel {
1224
style: this.style,
1325
title: this.title,
1426
content: this.content,
15-
pin: this.pin,
16-
createdDate: this.createdDate,
17-
lastUpdatedDate: this.lastUpdatedDate,
27+
pin: !!this.pin,
28+
active: !!this.active,
29+
createdDate: this.created_date,
30+
lastUpdatedDate: this.last_updated_date,
31+
status_page_id: this.status_page_id,
1832
};
1933
}
2034
}

server/model/status_page.js

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ const analytics = require("../analytics/analytics");
77
const { marked } = require("marked");
88
const { Feed } = require("feed");
99
const config = require("../config");
10-
const { setting } = require("../util-server");
1110

11+
const { setting } = require("../util-server");
1212
const {
1313
STATUS_PAGE_ALL_DOWN,
1414
STATUS_PAGE_ALL_UP,
@@ -17,6 +17,7 @@ const {
1717
UP,
1818
MAINTENANCE,
1919
DOWN,
20+
INCIDENT_PAGE_SIZE,
2021
} = require("../../src/util");
2122

2223
class StatusPage extends BeanModel {
@@ -307,12 +308,13 @@ class StatusPage extends BeanModel {
307308
static async getStatusPageData(statusPage) {
308309
const config = await statusPage.toPublicJSON();
309310

310-
// Incident
311-
let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [statusPage.id]);
312-
313-
if (incident) {
314-
incident = incident.toPublicJSON();
315-
}
311+
// All active incidents
312+
let incidents = await R.find(
313+
"incident",
314+
" pin = 1 AND active = 1 AND status_page_id = ? ORDER BY created_date DESC",
315+
[statusPage.id]
316+
);
317+
incidents = incidents.map((i) => i.toPublicJSON());
316318

317319
let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
318320

@@ -330,7 +332,7 @@ class StatusPage extends BeanModel {
330332
// Response
331333
return {
332334
config,
333-
incident,
335+
incidents,
334336
publicGroupList,
335337
maintenanceList,
336338
};
@@ -499,6 +501,54 @@ class StatusPage extends BeanModel {
499501
}
500502
}
501503

504+
/**
505+
* Get paginated incident history for a status page using cursor-based pagination
506+
* @param {number} statusPageId ID of the status page
507+
* @param {string|null} cursor ISO date string cursor (created_date of last item from previous page)
508+
* @param {boolean} isPublic Whether to return public or admin data
509+
* @returns {Promise<object>} Paginated incident data with cursor
510+
*/
511+
static async getIncidentHistory(statusPageId, cursor = null, isPublic = true) {
512+
let incidents;
513+
514+
if (cursor) {
515+
incidents = await R.find(
516+
"incident",
517+
" status_page_id = ? AND created_date < ? ORDER BY created_date DESC LIMIT ? ",
518+
[statusPageId, cursor, INCIDENT_PAGE_SIZE]
519+
);
520+
} else {
521+
incidents = await R.find("incident", " status_page_id = ? ORDER BY created_date DESC LIMIT ? ", [
522+
statusPageId,
523+
INCIDENT_PAGE_SIZE,
524+
]);
525+
}
526+
527+
const total = await R.count("incident", " status_page_id = ? ", [statusPageId]);
528+
529+
const lastIncident = incidents[incidents.length - 1];
530+
let nextCursor = null;
531+
let hasMore = false;
532+
533+
if (lastIncident) {
534+
const moreCount = await R.count("incident", " status_page_id = ? AND created_date < ? ", [
535+
statusPageId,
536+
lastIncident.created_date,
537+
]);
538+
hasMore = moreCount > 0;
539+
if (hasMore) {
540+
nextCursor = lastIncident.created_date;
541+
}
542+
}
543+
544+
return {
545+
incidents: incidents.map((i) => i.toPublicJSON()),
546+
total,
547+
nextCursor,
548+
hasMore,
549+
};
550+
}
551+
502552
/**
503553
* Get list of maintenances
504554
* @param {number} statusPageId ID of status page to get maintenance for

server/routers/status-page-router.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,30 @@ router.get("/api/status-page/:slug/manifest.json", cache("1440 minutes"), async
142142
}
143143
});
144144

145+
router.get("/api/status-page/:slug/incident-history", cache("5 minutes"), async (request, response) => {
146+
allowDevAllOrigin(response);
147+
148+
try {
149+
let slug = request.params.slug;
150+
slug = slug.toLowerCase();
151+
let statusPageID = await StatusPage.slugToID(slug);
152+
153+
if (!statusPageID) {
154+
sendHttpError(response, "Status Page Not Found");
155+
return;
156+
}
157+
158+
const cursor = request.query.cursor || null;
159+
const result = await StatusPage.getIncidentHistory(statusPageID, cursor, true);
160+
response.json({
161+
ok: true,
162+
...result,
163+
});
164+
} catch (error) {
165+
sendHttpError(response, error.message);
166+
}
167+
});
168+
145169
// overall status-page status badge
146170
router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, response) => {
147171
allowDevAllOrigin(response);

server/socket-handlers/status-page-socket-handler.js

Lines changed: 183 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ const apicache = require("../modules/apicache");
88
const StatusPage = require("../model/status_page");
99
const { UptimeKumaServer } = require("../uptime-kuma-server");
1010

11+
/**
12+
* Validates incident data
13+
* @param {object} incident - The incident object
14+
* @returns {void}
15+
* @throws {Error} If validation fails
16+
*/
17+
function validateIncident(incident) {
18+
if (!incident.title || incident.title.trim() === "") {
19+
throw new Error("Please input title");
20+
}
21+
if (!incident.content || incident.content.trim() === "") {
22+
throw new Error("Please input content");
23+
}
24+
}
25+
1126
/**
1227
* Socket handlers for status page
1328
* @param {Socket} socket Socket.io instance to add listeners on
@@ -25,8 +40,6 @@ module.exports.statusPageSocketHandler = (socket) => {
2540
throw new Error("slug is not found");
2641
}
2742

28-
await R.exec("UPDATE incident SET pin = 0 WHERE status_page_id = ? ", [statusPageID]);
29-
3043
let incidentBean;
3144

3245
if (incident.id) {
@@ -44,12 +57,13 @@ module.exports.statusPageSocketHandler = (socket) => {
4457
incidentBean.content = incident.content;
4558
incidentBean.style = incident.style;
4659
incidentBean.pin = true;
60+
incidentBean.active = true;
4761
incidentBean.status_page_id = statusPageID;
4862

4963
if (incident.id) {
50-
incidentBean.lastUpdatedDate = R.isoDateTime(dayjs.utc());
64+
incidentBean.last_updated_date = R.isoDateTime(dayjs.utc());
5165
} else {
52-
incidentBean.createdDate = R.isoDateTime(dayjs.utc());
66+
incidentBean.created_date = R.isoDateTime(dayjs.utc());
5367
}
5468

5569
await R.store(incidentBean);
@@ -85,6 +99,171 @@ module.exports.statusPageSocketHandler = (socket) => {
8599
}
86100
});
87101

102+
socket.on("getIncidentHistory", async (slug, cursor, callback) => {
103+
try {
104+
let statusPageID = await StatusPage.slugToID(slug);
105+
if (!statusPageID) {
106+
throw new Error("slug is not found");
107+
}
108+
109+
const isPublic = !socket.userID;
110+
const result = await StatusPage.getIncidentHistory(statusPageID, cursor, isPublic);
111+
callback({
112+
ok: true,
113+
...result,
114+
});
115+
} catch (error) {
116+
callback({
117+
ok: false,
118+
msg: error.message,
119+
});
120+
}
121+
});
122+
123+
socket.on("editIncident", async (slug, incidentID, incident, callback) => {
124+
try {
125+
checkLogin(socket);
126+
127+
let statusPageID = await StatusPage.slugToID(slug);
128+
if (!statusPageID) {
129+
callback({
130+
ok: false,
131+
msg: "slug is not found",
132+
msgi18n: true,
133+
});
134+
return;
135+
}
136+
137+
let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [incidentID, statusPageID]);
138+
if (!bean) {
139+
callback({
140+
ok: false,
141+
msg: "Incident not found or access denied",
142+
msgi18n: true,
143+
});
144+
return;
145+
}
146+
147+
try {
148+
validateIncident(incident);
149+
} catch (e) {
150+
callback({
151+
ok: false,
152+
msg: e.message,
153+
msgi18n: true,
154+
});
155+
return;
156+
}
157+
158+
const validStyles = ["info", "warning", "danger", "primary", "light", "dark"];
159+
if (!validStyles.includes(incident.style)) {
160+
incident.style = "warning";
161+
}
162+
163+
bean.title = incident.title;
164+
bean.content = incident.content;
165+
bean.style = incident.style;
166+
bean.pin = incident.pin !== false;
167+
bean.lastUpdatedDate = R.isoDateTime(dayjs.utc());
168+
169+
await R.store(bean);
170+
171+
callback({
172+
ok: true,
173+
msg: "Saved.",
174+
msgi18n: true,
175+
incident: bean.toPublicJSON(),
176+
});
177+
} catch (error) {
178+
callback({
179+
ok: false,
180+
msg: error.message,
181+
msgi18n: true,
182+
});
183+
}
184+
});
185+
186+
socket.on("deleteIncident", async (slug, incidentID, callback) => {
187+
try {
188+
checkLogin(socket);
189+
190+
let statusPageID = await StatusPage.slugToID(slug);
191+
if (!statusPageID) {
192+
callback({
193+
ok: false,
194+
msg: "slug is not found",
195+
msgi18n: true,
196+
});
197+
return;
198+
}
199+
200+
let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [incidentID, statusPageID]);
201+
if (!bean) {
202+
callback({
203+
ok: false,
204+
msg: "Incident not found or access denied",
205+
msgi18n: true,
206+
});
207+
return;
208+
}
209+
210+
await R.trash(bean);
211+
212+
callback({
213+
ok: true,
214+
msg: "successDeleted",
215+
msgi18n: true,
216+
});
217+
} catch (error) {
218+
callback({
219+
ok: false,
220+
msg: error.message,
221+
msgi18n: true,
222+
});
223+
}
224+
});
225+
226+
socket.on("resolveIncident", async (slug, incidentID, callback) => {
227+
try {
228+
checkLogin(socket);
229+
230+
let statusPageID = await StatusPage.slugToID(slug);
231+
if (!statusPageID) {
232+
callback({
233+
ok: false,
234+
msg: "slug is not found",
235+
msgi18n: true,
236+
});
237+
return;
238+
}
239+
240+
let bean = await R.findOne("incident", " id = ? AND status_page_id = ? ", [incidentID, statusPageID]);
241+
if (!bean) {
242+
callback({
243+
ok: false,
244+
msg: "Incident not found or access denied",
245+
msgi18n: true,
246+
});
247+
return;
248+
}
249+
250+
await bean.resolve();
251+
252+
callback({
253+
ok: true,
254+
msg: "Resolved",
255+
msgi18n: true,
256+
incident: bean.toPublicJSON(),
257+
});
258+
} catch (error) {
259+
callback({
260+
ok: false,
261+
msg: error.message,
262+
msgi18n: true,
263+
});
264+
}
265+
});
266+
88267
socket.on("getStatusPage", async (slug, callback) => {
89268
try {
90269
checkLogin(socket);

0 commit comments

Comments
 (0)