diff --git a/src/beat-state.ts b/src/beat-state.ts index a03a16129..dfa2b9816 100644 --- a/src/beat-state.ts +++ b/src/beat-state.ts @@ -72,3 +72,25 @@ export function applyLatestBeats( } heartbeatList[monitorID] = beats; } + +/** + * Store the newest beat of each monitor from a REST heartbeat payload + * @param state Mixin state + * @param heartbeatList Beats per monitor, oldest first + * @param translate Translation function + * @returns void + */ +export function applyHeartbeatSnapshot( + state: BeatState, + heartbeatList: Record, + translate: Translate +): void { + for (const monitorID in heartbeatList) { + const beats = heartbeatList[monitorID]; + if (!beats || beats.length === 0) { + continue; + } + const beat = beats[beats.length - 1]; + applyBeat(state, Number(monitorID), beat, beatStatusEntry(translate, beat)); + } +} diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index 06aa8e4b7..901dfc936 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -635,6 +635,7 @@ import { } from "../util.ts"; import Tag from "../components/Tag.vue"; import VueMultiselect from "vue-multiselect"; +import { applyHeartbeatSnapshot } from "../beat-state"; const toast = useToast(); dayjs.extend(duration); @@ -1075,6 +1076,7 @@ export default { this.$root.heartbeatList = heartbeatList; this.$root.uptimeList = uptimeList; + applyHeartbeatSnapshot(this.$root, heartbeatList, this.$t.bind(this)); const heartbeatIds = Object.keys(heartbeatList); const downMonitors = heartbeatIds.reduce((downMonitorsAmount, currentId) => { diff --git a/test/e2e/specs/status-page-public.spec.js b/test/e2e/specs/status-page-public.spec.js index f96e54cec..d21055966 100644 --- a/test/e2e/specs/status-page-public.spec.js +++ b/test/e2e/specs/status-page-public.spec.js @@ -84,6 +84,22 @@ test.describe("Public status page", () => { expect(await badge.text()).toContain(" { + const pushUrl = await addPushMonitor(page, "Uptime Badge Monitor"); + + // One up beat gives a 100% uptime badge; push it before the status page is fetched + await page.request.get(pushUrl); + + await addStatusPageWithMonitor(page, "Uptime Badge Demo", "uptime-badge-demo", "Uptime Badge Monitor"); + + await page.context().clearCookies(); + await page.goto("./status/uptime-badge-demo"); + + const badge = page.getByTestId("monitor").locator(".badge"); + await expect(badge).toHaveText(/%$/); + await expect(badge).toHaveClass(/bg-primary/); + }); + test("shows an active maintenance window to logged-out visitors", async ({ page }) => { const pushUrl = await addPushMonitor(page, "Maintenance Monitor"); await addStatusPageWithMonitor(page, "Maintenance Demo", "maintenance-demo", "Maintenance Monitor"); @@ -107,7 +123,7 @@ test.describe("Public status page", () => { await page.context().clearCookies(); await page.goto("./status/maintenance-demo"); - await expect(page.locator(".bg-maintenance")).toContainText("Planned Window"); + await expect(page.locator(".alert.bg-maintenance")).toContainText("Planned Window"); await expect(page.getByTestId("monitor-name")).toContainText("Maintenance Monitor"); }); }); diff --git a/test/frontend-test/test-beat-state.ts b/test/frontend-test/test-beat-state.ts index 498266f4a..3069e75d0 100644 --- a/test/frontend-test/test-beat-state.ts +++ b/test/frontend-test/test-beat-state.ts @@ -1,6 +1,6 @@ import { describe, test } from "node:test"; import assert from "node:assert"; -import { applyBeat, applyLatestBeats, beatStatusEntry } from "../../src/beat-state"; +import { applyBeat, applyHeartbeatSnapshot, applyLatestBeats, beatStatusEntry } from "../../src/beat-state"; import { DOWN, MAINTENANCE, PENDING, UP } from "../../src/util"; /** @@ -78,6 +78,48 @@ describe("applyLatestBeats()", () => { }); }); +describe("applyHeartbeatSnapshot()", () => { + /** + * Build empty mixin state + * @returns {object} State + */ + function emptyState() { + return { lastHeartbeatList: {}, statusList: {}, lastBeatRaw: {} }; + } + + test("fills the badge state from a REST payload with string monitor ids", () => { + const state = emptyState(); + const beats = [{ status: UP }]; + + applyHeartbeatSnapshot(state, { 7: beats }, translate); + + assert.strictEqual(state.lastHeartbeatList[7], beats[0]); + assert.deepStrictEqual(state.statusList[7], { text: "Up", color: "primary" }); + }); + + test("uses the newest beat of every monitor", () => { + const state = emptyState(); + const heartbeatList = { + 1: [{ status: DOWN }, { status: UP }], + 2: [{ status: DOWN }], + }; + + applyHeartbeatSnapshot(state, heartbeatList, translate); + + assert.deepStrictEqual(state.statusList[1], { text: "Up", color: "primary" }); + assert.deepStrictEqual(state.statusList[2], { text: "Down", color: "danger" }); + }); + + test("leaves monitors without beats untouched", () => { + const state = { lastHeartbeatList: { 1: { status: UP } }, statusList: {}, lastBeatRaw: {} }; + + applyHeartbeatSnapshot(state, { 2: [] }, translate); + + assert.strictEqual(Object.keys(state.lastHeartbeatList).length, 1); + assert.deepStrictEqual(state.statusList, {}); + }); +}); + describe("applyBeat()", () => { test("updates the three maps in place", () => { const state = { lastHeartbeatList: {}, statusList: {}, lastBeatRaw: {} };