From f9061ebb93b949cc13e04bd19ac83557f3f2cb8a Mon Sep 17 00:00:00 2001 From: ryana Date: Tue, 20 Jan 2026 02:42:54 +0800 Subject: [PATCH] address review comments --- server/model/incident.js | 11 -- server/model/status_page.js | 48 ++++-- server/routers/status-page-router.js | 4 +- .../status-page-socket-handler.js | 31 +--- src/pages/StatusPage.vue | 99 +++++++------ src/util.js | 4 +- test/e2e/specs/incident-history.spec.js | 138 ++++++++++++++++++ 7 files changed, 234 insertions(+), 101 deletions(-) create mode 100644 test/e2e/specs/incident-history.spec.js diff --git a/server/model/incident.js b/server/model/incident.js index f667c746a..48c979d48 100644 --- a/server/model/incident.js +++ b/server/model/incident.js @@ -16,7 +16,6 @@ class Incident extends BeanModel { /** * Return an object that ready to parse to JSON for public - * Only show necessary data to public * @returns {object} Object ready to parse */ toPublicJSON() { @@ -29,16 +28,6 @@ class Incident extends BeanModel { 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, }; } diff --git a/server/model/status_page.js b/server/model/status_page.js index 202d085de..8043012bb 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -502,28 +502,50 @@ class StatusPage extends BeanModel { } /** - * Get paginated incident history for a status page + * Get paginated incident history for a status page using cursor-based pagination * @param {number} statusPageId ID of the status page - * @param {number} page Page number (1-based) + * @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 + * @returns {Promise} Paginated incident data with cursor */ - static async getIncidentHistory(statusPageId, page, isPublic = true) { - const offset = (page - 1) * INCIDENT_PAGE_SIZE; + static async getIncidentHistory(statusPageId, cursor = null, isPublic = true) { + let incidents; - const incidents = await R.find("incident", " status_page_id = ? ORDER BY created_date DESC LIMIT ? OFFSET ? ", [ - statusPageId, - INCIDENT_PAGE_SIZE, - offset, - ]); + 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.createdDate, + ]); + hasMore = moreCount > 0; + if (hasMore) { + nextCursor = lastIncident.createdDate; + } + } + return { - incidents: incidents.map((i) => (isPublic ? i.toPublicJSON() : i.toJSON())), + incidents: incidents.map((i) => i.toPublicJSON()), total, - page, - totalPages: Math.ceil(total / INCIDENT_PAGE_SIZE), + nextCursor, + hasMore, }; } diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js index 7d80175c8..fda296268 100644 --- a/server/routers/status-page-router.js +++ b/server/routers/status-page-router.js @@ -155,8 +155,8 @@ router.get("/api/status-page/:slug/incident-history", cache("5 minutes"), async return; } - const page = parseInt(request.query.page) || 1; - const result = await StatusPage.getIncidentHistory(statusPageID, page, true); + const cursor = request.query.cursor || null; + const result = await StatusPage.getIncidentHistory(statusPageID, cursor, true); response.json({ ok: true, ...result, diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js index 848441d9a..d651b2fbb 100644 --- a/server/socket-handlers/status-page-socket-handler.js +++ b/server/socket-handlers/status-page-socket-handler.js @@ -99,36 +99,15 @@ 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 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) => { + socket.on("getIncidentHistory", async (slug, cursor, 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); + const isPublic = !socket.userID; + const result = await StatusPage.getIncidentHistory(statusPageID, cursor, isPublic); callback({ ok: true, ...result, @@ -193,7 +172,7 @@ module.exports.statusPageSocketHandler = (socket) => { ok: true, msg: "Saved.", msgi18n: true, - incident: bean.toJSON(), + incident: bean.toPublicJSON(), }); } catch (error) { callback({ @@ -274,7 +253,7 @@ module.exports.statusPageSocketHandler = (socket) => { ok: true, msg: "Resolved", msgi18n: true, - incident: bean.toJSON(), + incident: bean.toPublicJSON(), }); } catch (error) { callback({ diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index 479db1b0a..509013f6c 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -496,20 +496,12 @@ -
-

{{ $t("Past Incidents") }}

+
+

+ {{ $t("Past Incidents") }} +

-
-
- {{ $t("Loading...") }} -
-
- -
- {{ $t("No incidents recorded") }} -
- - +
@@ -715,8 +704,8 @@ export default { loading: true, incidentHistory: [], incidentHistoryLoading: false, - incidentHistoryPage: 1, - incidentHistoryTotalPages: 1, + incidentHistoryNextCursor: null, + incidentHistoryHasMore: false, }; }, computed: { @@ -878,12 +867,22 @@ export default { }, /** - * Group incidents by date for display + * Count of past incidents (non-active or unpinned) + * @returns {number} Number of past incidents + */ + pastIncidentCount() { + return this.incidentHistory.filter((i) => !(i.active && i.pin)).length; + }, + + /** + * Group past incidents (non-active or unpinned) by date for display + * Active+pinned incidents are shown separately at the top, not in this section * @returns {object} Incidents grouped by date string */ groupedIncidentHistory() { const groups = {}; - for (const incident of this.incidentHistory) { + const pastIncidents = this.incidentHistory.filter((i) => !(i.active && i.pin)); + for (const incident of pastIncidents) { const dateKey = this.formatDateKey(incident.createdDate); if (!groups[dateKey]) { groups[dateKey] = []; @@ -998,7 +997,6 @@ export default { this.$root.publicGroupList = res.data.publicGroupList; this.loading = false; - this.loadIncidentHistory(); feedInterval = setInterval( () => { @@ -1031,6 +1029,7 @@ export default { }); this.updateHeartbeatList(); + this.loadIncidentHistory(); // Go to edit page if ?edit present // null means ?edit present, but no value @@ -1393,20 +1392,20 @@ export default { * @returns {void} */ loadIncidentHistory() { - this.loadIncidentHistoryPage(1); + this.loadIncidentHistoryWithCursor(null); }, /** - * Load a specific page of incident history - * @param {number} page - Page number to load + * Load incident history using cursor-based pagination + * @param {string|null} cursor - Cursor for pagination (created_date of last item) * @param {boolean} append - Whether to append to existing list * @returns {void} */ - loadIncidentHistoryPage(page, append = false) { + loadIncidentHistoryWithCursor(cursor, append = false) { this.incidentHistoryLoading = true; if (this.enableEditMode) { - this.$root.getSocket().emit("getIncidentHistory", this.slug, page, (res) => { + this.$root.getSocket().emit("getIncidentHistory", this.slug, cursor, (res) => { this.incidentHistoryLoading = false; if (res.ok) { if (append) { @@ -1414,16 +1413,19 @@ export default { } else { this.incidentHistory = res.incidents; } - this.incidentHistoryPage = res.page; - this.incidentHistoryTotalPages = res.totalPages; + this.incidentHistoryNextCursor = res.nextCursor; + this.incidentHistoryHasMore = res.hasMore; } else { console.error("Failed to load incident history:", res.msg); this.$root.toastError(res.msg); } }); } else { + const url = cursor + ? `/api/status-page/${this.slug}/incident-history?cursor=${encodeURIComponent(cursor)}` + : `/api/status-page/${this.slug}/incident-history`; axios - .get(`/api/status-page/${this.slug}/incident-history?page=${page}`) + .get(url) .then((res) => { this.incidentHistoryLoading = false; if (res.data.ok) { @@ -1432,8 +1434,8 @@ export default { } else { this.incidentHistory = res.data.incidents; } - this.incidentHistoryPage = res.data.page; - this.incidentHistoryTotalPages = res.data.totalPages; + this.incidentHistoryNextCursor = res.data.nextCursor; + this.incidentHistoryHasMore = res.data.hasMore; } }) .catch((error) => { @@ -1444,12 +1446,12 @@ export default { }, /** - * Load more incident history (next page, appended) + * Load more incident history using cursor-based pagination * @returns {void} */ loadMoreIncidentHistory() { - if (this.incidentHistoryPage < this.incidentHistoryTotalPages) { - this.loadIncidentHistoryPage(this.incidentHistoryPage + 1, true); + if (this.incidentHistoryHasMore && this.incidentHistoryNextCursor) { + this.loadIncidentHistoryWithCursor(this.incidentHistoryNextCursor, true); } }, @@ -1601,12 +1603,14 @@ footer { /* Reset button placed at top-left of the logo */ .reset-top-left { - position: absolute; - top: 0; - left: -15px; - z-index: 2; - width: 20px; - height: 20px; + transition: + transform $easing-in 0.18s, + box-shadow $easing-in 0.18s, + background-color $easing-in 0.18s; + font-size: 18px; + width: 18px; + height: 18px; + padding: 0; display: inline-flex; align-items: center; justify-content: center; @@ -1615,11 +1619,6 @@ footer { border: none; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); cursor: pointer; - padding: 0; - transition: - transform $easing-in 0.18s, - box-shadow $easing-in 0.18s, - background-color $easing-in 0.18s; transform-origin: center; &:hover { @@ -1762,6 +1761,12 @@ footer { font-weight: normal; } +.past-incidents-section { + .past-incidents-content { + padding: 0; + } +} + .incident-date-group { .incident-date-header { font-size: 1rem; diff --git a/src/util.js b/src/util.js index 642af61b1..169b8a162 100644 --- a/src/util.js +++ b/src/util.js @@ -10,8 +10,8 @@ */ var _a; Object.defineProperty(exports, "__esModule", { value: true }); -exports.CONSOLE_STYLE_FgViolet = exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.RESPONSE_BODY_LENGTH_MAX = exports.RESPONSE_BODY_LENGTH_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_MAX = exports.PING_PER_REQUEST_TIMEOUT_MIN = exports.PING_COUNT_DEFAULT = exports.PING_COUNT_MAX = exports.PING_COUNT_MIN = exports.PING_GLOBAL_TIMEOUT_DEFAULT = exports.PING_GLOBAL_TIMEOUT_MAX = exports.PING_GLOBAL_TIMEOUT_MIN = exports.PING_PACKET_SIZE_DEFAULT = exports.PING_PACKET_SIZE_MAX = exports.PING_PACKET_SIZE_MIN = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0; -exports.TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD = exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = void 0; +exports.CONSOLE_STYLE_FgLightBlue = exports.CONSOLE_STYLE_FgLightGreen = exports.CONSOLE_STYLE_FgOrange = exports.CONSOLE_STYLE_FgGray = exports.CONSOLE_STYLE_FgWhite = exports.CONSOLE_STYLE_FgCyan = exports.CONSOLE_STYLE_FgMagenta = exports.CONSOLE_STYLE_FgBlue = exports.CONSOLE_STYLE_FgYellow = exports.CONSOLE_STYLE_FgGreen = exports.CONSOLE_STYLE_FgRed = exports.CONSOLE_STYLE_FgBlack = exports.CONSOLE_STYLE_Hidden = exports.CONSOLE_STYLE_Reverse = exports.CONSOLE_STYLE_Blink = exports.CONSOLE_STYLE_Underscore = exports.CONSOLE_STYLE_Dim = exports.CONSOLE_STYLE_Bright = exports.CONSOLE_STYLE_Reset = exports.RESPONSE_BODY_LENGTH_MAX = exports.RESPONSE_BODY_LENGTH_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_DEFAULT = exports.PING_PER_REQUEST_TIMEOUT_MAX = exports.PING_PER_REQUEST_TIMEOUT_MIN = exports.PING_COUNT_DEFAULT = exports.PING_COUNT_MAX = exports.PING_COUNT_MIN = exports.PING_GLOBAL_TIMEOUT_DEFAULT = exports.PING_GLOBAL_TIMEOUT_MAX = exports.PING_GLOBAL_TIMEOUT_MIN = exports.PING_PACKET_SIZE_DEFAULT = exports.PING_PACKET_SIZE_MAX = exports.PING_PACKET_SIZE_MIN = exports.INCIDENT_PAGE_SIZE = exports.MIN_INTERVAL_SECOND = exports.MAX_INTERVAL_SECOND = exports.SQL_DATETIME_FORMAT_WITHOUT_SECOND = exports.SQL_DATETIME_FORMAT = exports.SQL_DATE_FORMAT = exports.STATUS_PAGE_MAINTENANCE = exports.STATUS_PAGE_PARTIAL_DOWN = exports.STATUS_PAGE_ALL_UP = exports.STATUS_PAGE_ALL_DOWN = exports.MAINTENANCE = exports.PENDING = exports.UP = exports.DOWN = exports.appName = exports.isNode = exports.isDev = void 0; +exports.TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD = exports.evaluateJsonQuery = exports.intHash = exports.localToUTC = exports.utcToLocal = exports.utcToISODateTime = exports.isoToUTCDateTime = exports.parseTimeFromTimeObject = exports.parseTimeObject = exports.getMonitorRelativeURL = exports.genSecret = exports.getCryptoRandomInt = exports.getRandomInt = exports.getRandomArbitrary = exports.TimeLogger = exports.polyfill = exports.log = exports.debug = exports.ucfirst = exports.sleep = exports.flipStatus = exports.badgeConstants = exports.CONSOLE_STYLE_BgGray = exports.CONSOLE_STYLE_BgWhite = exports.CONSOLE_STYLE_BgCyan = exports.CONSOLE_STYLE_BgMagenta = exports.CONSOLE_STYLE_BgBlue = exports.CONSOLE_STYLE_BgYellow = exports.CONSOLE_STYLE_BgGreen = exports.CONSOLE_STYLE_BgRed = exports.CONSOLE_STYLE_BgBlack = exports.CONSOLE_STYLE_FgPink = exports.CONSOLE_STYLE_FgBrown = exports.CONSOLE_STYLE_FgViolet = void 0; const dayjs_1 = require("dayjs"); const jsonata = require("jsonata"); exports.isDev = process.env.NODE_ENV === "development"; diff --git a/test/e2e/specs/incident-history.spec.js b/test/e2e/specs/incident-history.spec.js new file mode 100644 index 000000000..3552f7e53 --- /dev/null +++ b/test/e2e/specs/incident-history.spec.js @@ -0,0 +1,138 @@ +import { expect, test } from "@playwright/test"; +import { login, restoreSqliteSnapshot, screenshot } from "../util-test"; + +test.describe("Incident History", () => { + test.beforeEach(async ({ page }) => { + await restoreSqliteSnapshot(page); + }); + + test("past incidents section is hidden when no incidents exist", async ({ page }, testInfo) => { + test.setTimeout(60000); + + await page.goto("./add"); + await login(page); + + await page.goto("./add-status-page"); + await page.getByTestId("name-input").fill("Empty Test"); + await page.getByTestId("slug-input").fill("empty-test"); + await page.getByTestId("submit-button").click(); + await page.waitForURL("/status/empty-test?edit"); + + await page.getByTestId("save-button").click(); + await expect(page.getByTestId("edit-sidebar")).toHaveCount(0); + + const pastIncidentsSection = page.locator(".past-incidents-section"); + await expect(pastIncidentsSection).toHaveCount(0); + + await screenshot(testInfo, page); + }); + + test("active pinned incidents are shown at top and not in past incidents", async ({ page }, testInfo) => { + test.setTimeout(60000); + + await page.goto("./add"); + await login(page); + + await page.goto("./add-status-page"); + await page.getByTestId("name-input").fill("Dedup Test"); + await page.getByTestId("slug-input").fill("dedup-test"); + await page.getByTestId("submit-button").click(); + await page.waitForURL("/status/dedup-test?edit"); + + await page.getByTestId("create-incident-button").click(); + await page.getByTestId("incident-title").fill("Active Incident"); + await page.getByTestId("incident-content-editable").fill("This is an active incident"); + await page.getByTestId("post-incident-button").click(); + + await page.getByTestId("save-button").click(); + await expect(page.getByTestId("edit-sidebar")).toHaveCount(0); + + const activeIncident = page.getByTestId("incident").filter({ hasText: "Active Incident" }); + await expect(activeIncident).toBeVisible(); + + const pastIncidentsSection = page.locator(".past-incidents-section"); + await expect(pastIncidentsSection).toHaveCount(0); + + await screenshot(testInfo, page); + }); + + test("resolved incidents appear in past incidents section", async ({ page }, testInfo) => { + test.setTimeout(90000); + + await page.goto("./add"); + await login(page); + + await page.goto("./add-status-page"); + await page.getByTestId("name-input").fill("Resolve Test"); + await page.getByTestId("slug-input").fill("resolve-test"); + await page.getByTestId("submit-button").click(); + await page.waitForURL("/status/resolve-test?edit"); + + await page.getByTestId("create-incident-button").click(); + await page.getByTestId("incident-title").fill("Resolved Incident"); + await page.getByTestId("incident-content-editable").fill("This incident will be resolved"); + await page.getByTestId("post-incident-button").click(); + + await page.waitForTimeout(500); + + const resolveButton = page.locator("button", { hasText: "Resolve" }).first(); + await expect(resolveButton).toBeVisible(); + await resolveButton.click(); + + await page.waitForTimeout(1000); + + const activeIncident = page.getByTestId("incident").filter({ hasText: "Resolved Incident" }); + await expect(activeIncident).toHaveCount(0); + + const pastIncidentsSection = page.locator(".past-incidents-section"); + await expect(pastIncidentsSection).toBeVisible(); + + const resolvedIncidentInHistory = pastIncidentsSection.locator("text=Resolved Incident"); + await expect(resolvedIncidentInHistory).toBeVisible(); + + await screenshot(testInfo, page); + }); + + test("incident history pagination loads more incidents", async ({ page }, testInfo) => { + test.setTimeout(120000); + + await page.goto("./add"); + await login(page); + + await page.goto("./add-status-page"); + await page.getByTestId("name-input").fill("Pagination Test"); + await page.getByTestId("slug-input").fill("pagination-test"); + await page.getByTestId("submit-button").click(); + await page.waitForURL("/status/pagination-test?edit"); + + for (let i = 1; i <= 12; i++) { + await page.getByTestId("create-incident-button").click(); + await page.getByTestId("incident-title").fill("Incident " + i); + await page.getByTestId("incident-content-editable").fill("Content for incident " + i); + await page.getByTestId("post-incident-button").click(); + await page.waitForTimeout(300); + + const resolveButton = page.locator("button", { hasText: "Resolve" }).first(); + if (await resolveButton.isVisible()) { + await resolveButton.click(); + await page.waitForTimeout(300); + } + } + + await page.getByTestId("save-button").click(); + await expect(page.getByTestId("edit-sidebar")).toHaveCount(0); + + await page.waitForTimeout(1000); + + const pastIncidentsSection = page.locator(".past-incidents-section"); + await expect(pastIncidentsSection).toBeVisible(); + + const loadMoreButton = page.locator("button", { hasText: "Load More" }); + + if (await loadMoreButton.isVisible()) { + await loadMoreButton.click(); + await page.waitForTimeout(1000); + await screenshot(testInfo, page); + } + }); +});