diff --git a/client/package-lock.json b/client/package-lock.json index ef226f507..659cde440 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -93,7 +93,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -409,7 +408,6 @@ "node_modules/@emotion/react": { "version": "11.14.0", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -447,7 +445,6 @@ "node_modules/@emotion/styled": { "version": "11.14.1", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1236,7 +1233,6 @@ "node_modules/@mui/material": { "version": "7.3.7", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.7", @@ -1341,7 +1337,6 @@ "node_modules/@mui/system": { "version": "7.3.7", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/private-theming": "^7.3.7", @@ -2386,7 +2381,6 @@ "version": "24.5.2", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.12.0" } @@ -2402,7 +2396,6 @@ "node_modules/@types/react": { "version": "18.3.27", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2493,7 +2486,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2774,7 +2766,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3131,7 +3122,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", @@ -3575,8 +3565,7 @@ }, "node_modules/dayjs": { "version": "1.11.13", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -3975,7 +3964,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4788,7 +4776,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -5600,7 +5587,6 @@ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz", "integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2", @@ -6382,7 +6368,6 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6394,7 +6379,6 @@ "node_modules/react-hook-form": { "version": "7.71.1", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6598,8 +6582,7 @@ }, "node_modules/redux": { "version": "5.0.1", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-persist": { "version": "6.0.0", @@ -6749,7 +6732,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -7591,7 +7573,6 @@ "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7729,7 +7710,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index a355e8774..148633b73 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -14,6 +14,7 @@ import { import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens"; import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap"; import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram"; +import { IncidentModal } from "@/Pages/StatusPage/Status/themes/shared/IncidentModal"; import { ThemedInfrastructure, type GaugeFillLevel, @@ -116,6 +117,8 @@ export const BaseStatusPage = ({ [config, tokens, mode] ); const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap"); + const [selectedMonitorId, setSelectedMonitorId] = useState(null); + const [selectedDate, setSelectedDate] = useState(null); const overall = resolveOverallStatus(monitors, t, config.overallStatusOptions); const logoSrc = statusPage.logo?.data @@ -306,6 +309,10 @@ export const BaseStatusPage = ({ cells={cells} containerSx={styles.heatmap} cellSx={styles.heatmapCell} + onCellClick={(date) => { + setSelectedMonitorId(monitor.id); + setSelectedDate(date); + }} /> ) : ( { + setSelectedMonitorId(monitor.id); + setSelectedDate(date); + }} /> ))} @@ -333,6 +344,16 @@ export const BaseStatusPage = ({ Checkmate + { + setSelectedMonitorId(null); + setSelectedDate(null); + }} + /> ); }; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx index 8d8a6a675..924e758b9 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx @@ -25,6 +25,7 @@ export interface ChartCell { responseTime: number; // 0 for empty cells tooltip: React.ReactNode | null; ariaLabel: string; + date: string | null; } const DAY_DOWN_THRESHOLD = 0.5; @@ -61,7 +62,7 @@ export const classifyResponseTime = ( return "fast"; }; -const emptyCell = (key: string): ChartCell => ({ +const emptyCell = (key: string, date: string | null = null): ChartCell => ({ key, barKind: "empty", heatKind: "empty", @@ -70,6 +71,7 @@ const emptyCell = (key: string): ChartCell => ({ tooltip: null, ariaLabel: "", severity: 0, + date, }); const padCells = (cells: ChartCell[], length: number): ChartCell[] => [ @@ -92,6 +94,7 @@ export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => { tooltip: , ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`, severity: 0, + date: null, }) ); return padCells(cells, MAX_RECENT_CHECKS); @@ -108,7 +111,7 @@ export const dailyBucketsToCells = ( const enumerated = enumerateDays(days, bucketTimezone); return enumerated.map((date) => { const bucket = byDate.get(date); - if (!bucket) return emptyCell(`day-${date}`); + if (!bucket) return emptyCell(`day-${date}`, date); const upRatio = bucket.upChecks / bucket.totalChecks; const barKind = classifyDay(bucket, upRatio); @@ -132,6 +135,7 @@ export const dailyBucketsToCells = ( date, uptime: formatPercentage(bucket.upChecks / bucket.totalChecks), }), + date, }; }); }; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/IncidentModal.tsx b/client/src/Pages/StatusPage/Status/themes/shared/IncidentModal.tsx new file mode 100644 index 000000000..7f5575ece --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/IncidentModal.tsx @@ -0,0 +1,111 @@ +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; +import IconButton from "@mui/material/IconButton"; +import { X as XIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useGet } from "@/Hooks/UseApi"; +import { useTheme } from "@mui/material"; +import { SPACING } from "@/Utils/Theme/constants"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; + +import type { Incident } from "@/Types/Incident"; +interface IncidentModalProps { + url: string; + monitorId: string | null; + date: string | null; + timezone: string; + onClose: () => void; +} + +export const IncidentModal = ({ + url, + monitorId, + date, + timezone, + onClose, +}: IncidentModalProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const open = Boolean(monitorId && date); + + const { data, isLoading, error } = useGet<{ incidents: Incident[] }>( + open ? `/status-page/${url}/incidents/${monitorId}?date=${date}` : null, + {}, + { revalidateOnFocus: false, keepPreviousData: true } + ); + + return ( + + + {t("pages.statusPages.monitorsList.incidents.modalTitle", { date })} ({timezone}) + + + + + + {isLoading ? ( + + {t("pages.statusPages.monitorsList.incidents.loading")} + + ) : error ? ( + + {t("pages.statusPages.monitorsList.incidents.errorLoading")} + + ) : data?.incidents && data.incidents.length > 0 ? ( + + {data.incidents.map((incident) => ( + + + {incident.message || + (incident.statusCode ? `Error ${incident.statusCode}` : "Incident")} + + + {formatDateWithTz(incident.startTime, "h:mm A", timezone)} -{" "} + {incident.endTime + ? formatDateWithTz(incident.endTime, "h:mm A", timezone) + : t("pages.statusPages.monitorsList.incidents.ongoing")} + + + ))} + + ) : ( + + {t("pages.statusPages.monitorsList.incidents.noIncidents")} + + )} + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx index 7ddc7424d..650fa135c 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -2,6 +2,8 @@ import Tooltip from "@mui/material/Tooltip"; import Box from "@mui/material/Box"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; +import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx"; + import type { ChartCell, HeatCellKind, @@ -11,17 +13,15 @@ interface Props { cells: ChartCell[]; containerSx: SxProps; cellSx: (kind: HeatCellKind, severity?: number) => SxProps; + onCellClick?: (date: string) => void; } -export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => { +export const ThemedHeatmap = ({ cells, containerSx, cellSx, onCellClick }: Props) => { const { t } = useTranslation(); return ( @@ -30,7 +30,15 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => { return ( { + if (cell.date && onCellClick) { + onCellClick(cell.date); + } + }} /> ); } @@ -42,8 +50,16 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => { placement="top" > { + if (cell.date && onCellClick) { + onCellClick(cell.date); + } + }} /> ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index ea5fa649a..82c4c97bb 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -5,6 +5,8 @@ import Stack from "@mui/material/Stack"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import { formatMs } from "@/Utils/TimeUtils"; +import { mergeSx } from "@/Pages/StatusPage/Status/themes/shared/sx"; + import type { BarKind, ChartCell, @@ -16,6 +18,7 @@ interface Props { barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps; statsSx: SxProps; statsGap?: number; + onCellClick?: (date: string) => void; } export const ThemedHistogram = ({ @@ -24,6 +27,7 @@ export const ThemedHistogram = ({ barSx, statsSx, statsGap = 1, + onCellClick, }: Props) => { const { t } = useTranslation(); @@ -39,17 +43,22 @@ export const ThemedHistogram = ({ return ( {cells.map((cell) => { if (cell.barKind === "empty") { return ( { + if (cell.date && onCellClick) { + onCellClick(cell.date); + } + }} /> ); } @@ -61,8 +70,16 @@ export const ThemedHistogram = ({ placement="top" > { + if (cell.date && onCellClick) { + onCellClick(cell.date); + } + }} /> ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/sx.ts b/client/src/Pages/StatusPage/Status/themes/shared/sx.ts new file mode 100644 index 000000000..aa2dd0c41 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/sx.ts @@ -0,0 +1,16 @@ +import type { SxProps, Theme } from "@mui/material/styles"; + +export const mergeSx = ( + ...styles: Array | false | null | undefined> +): SxProps => { + const result: SxProps[] = []; + for (const style of styles) { + if (!style) continue; + if (Array.isArray(style)) { + result.push(...(style.filter(Boolean) as SxProps[])); + } else { + result.push(style); + } + } + return result as SxProps; +}; diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 82015e849..1b07da727 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -1377,6 +1377,9 @@ "maintenance": "صيانة", "paused": "متوقف مؤقتاً", "up": "قيد التشغيل" + }, + "incidents": { + "errorLoading": "فشل تحميل الحوادث." } }, "statusBar": { diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json index 912c5b198..f36bbd295 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -1377,6 +1377,9 @@ "uptime": { "title": "Temps en línia", "responseTime": "Temps de resposta" + }, + "incidents": { + "errorLoading": "No s'han pogut carregar les incidències." } }, "statusBar": { diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index 1d98d565a..4a41b1f57 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -1377,6 +1377,9 @@ "maintenance": "Údržba", "paused": "Pozastaveno", "up": "V provozu" + }, + "incidents": { + "errorLoading": "Nepodařilo se načíst incidenty." } }, "statusBar": { diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 23db1be49..995b4488c 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -1378,6 +1378,9 @@ "maintenance": "Wartung", "paused": "Pausiert", "up": "Betriebsbereit" + }, + "incidents": { + "errorLoading": "Vorfälle konnten nicht geladen werden." } }, "statusBar": { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 562cdc2e4..1b1337dd2 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1712,6 +1712,13 @@ "uptime": { "title": "Uptime", "responseTime": "Response time" + }, + "incidents": { + "modalTitle": "Incidents for {{date}}", + "loading": "Loading...", + "noIncidents": "No incidents reported for this day.", + "ongoing": "Ongoing", + "errorLoading": "Failed to load incidents." } }, "statusBar": { diff --git a/client/src/locales/es.json b/client/src/locales/es.json index ac7e47c83..391eb05b4 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -1377,6 +1377,9 @@ "maintenance": "Mantenimiento", "paused": "Pausado", "up": "Operativo" + }, + "incidents": { + "errorLoading": "Error al cargar incidentes." } }, "statusBar": { diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 53bb68fbc..94f63cb9a 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -1377,6 +1377,9 @@ "maintenance": "Huolto", "paused": "Keskeytetty", "up": "Toiminnassa" + }, + "incidents": { + "errorLoading": "Häiriöiden lataaminen epäonnistui." } }, "statusBar": { diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index da3f965be..4d288c30d 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -1377,6 +1377,9 @@ "maintenance": "Maintenance", "paused": "En pause", "up": "Opérationnel" + }, + "incidents": { + "errorLoading": "Échec du chargement des incidents." } }, "statusBar": { diff --git a/client/src/locales/it.json b/client/src/locales/it.json index 4f4714675..ee9c10b1d 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -1473,6 +1473,9 @@ "uptime": { "title": "Uptime", "responseTime": "Tempo di risposta" + }, + "incidents": { + "errorLoading": "Impossibile caricare gli incidenti." } }, "statusBar": { diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index cf05c64b0..75f7aa5a3 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -1377,6 +1377,9 @@ "maintenance": "メンテナンス", "paused": "一時停止中", "up": "稼働中" + }, + "incidents": { + "errorLoading": "インシデントの読み込みに失敗しました。" } }, "statusBar": { diff --git a/client/src/locales/pl.json b/client/src/locales/pl.json index 5afb59748..4e6133875 100644 --- a/client/src/locales/pl.json +++ b/client/src/locales/pl.json @@ -1584,6 +1584,9 @@ "uptime": { "responseTime": "Czas odpowiedzi", "title": "Czas pracy" + }, + "incidents": { + "errorLoading": "Nie udało się załadować incydentów." } }, "preview": { diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index 225c212a2..5f6e9b324 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -1377,6 +1377,9 @@ "maintenance": "Manutenção", "paused": "Pausado", "up": "Operacional" + }, + "incidents": { + "errorLoading": "Falha ao carregar incidentes." } }, "statusBar": { diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index ff9960ebe..adaad852d 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -1377,6 +1377,9 @@ "maintenance": "Обслуживание", "paused": "Приостановлено", "up": "Работает" + }, + "incidents": { + "errorLoading": "Не удалось загрузить инциденты." } }, "statusBar": { diff --git a/client/src/locales/th.json b/client/src/locales/th.json index e6a0d8389..4bacb27c0 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -1377,6 +1377,9 @@ "maintenance": "การบำรุงรักษา", "paused": "หยุดชั่วคราว", "up": "ใช้งานได้" + }, + "incidents": { + "errorLoading": "ไม่สามารถโหลดเหตุการณ์ได้" } }, "statusBar": { diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index dc0f3444c..3f3b8778d 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -1377,6 +1377,9 @@ "maintenance": "Bakım", "paused": "Duraklatıldı", "up": "Çalışıyor" + }, + "incidents": { + "errorLoading": "Olaylar yüklenemedi." } }, "statusBar": { diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 807a9b2d0..1c6973044 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -1377,6 +1377,9 @@ "maintenance": "Обслуговування", "paused": "Призупинено", "up": "Працює" + }, + "incidents": { + "errorLoading": "Не вдалося завантажити інциденти." } }, "statusBar": { diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 899c8cb0c..60833c304 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -1377,6 +1377,9 @@ "maintenance": "Bảo trì", "paused": "Tạm dừng", "up": "Hoạt động" + }, + "incidents": { + "errorLoading": "Không thể tải sự cố." } }, "statusBar": { diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 959d09f38..91218119e 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -1378,6 +1378,9 @@ "maintenance": "维护中", "paused": "已暂停", "up": "运行中" + }, + "incidents": { + "errorLoading": "无法加载事件。" } }, "statusBar": { diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index 16849cc6e..ffa835184 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -1377,6 +1377,9 @@ "maintenance": "維護中", "paused": "已暫停", "up": "運行中" + }, + "incidents": { + "errorLoading": "無法載入事件。" } }, "statusBar": { diff --git a/server/package-lock.json b/server/package-lock.json index 4e58c301f..4cd5f7d31 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -18,6 +18,7 @@ "compression": "1.8.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "dayjs": "^1.11.23", "dockerode": "5.0.0", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -5803,6 +5804,12 @@ "node": ">=18" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/server/package.json b/server/package.json index b35ea1eae..b37b417b9 100755 --- a/server/package.json +++ b/server/package.json @@ -42,6 +42,7 @@ "compression": "1.8.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "dayjs": "^1.11.23", "dockerode": "5.0.0", "dotenv": "^16.4.5", "express": "^4.19.2", diff --git a/server/src/api/controllers/statusPageController.ts b/server/src/api/controllers/statusPageController.ts index 94783b5a9..1c18f00b3 100644 --- a/server/src/api/controllers/statusPageController.ts +++ b/server/src/api/controllers/statusPageController.ts @@ -7,6 +7,8 @@ import { getStatusPageQueryValidation, imageValidation, resolveStatusPageQueryValidation, + getPublicMonitorIncidentsParamValidation, + getPublicMonitorIncidentsQueryValidation, } from "@/api/validation/statusPageValidation.js"; import { AppError } from "@/utils/AppError.js"; import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js"; @@ -17,6 +19,7 @@ export interface IStatusPageController { createStatusPage: RequestHandler; updateStatusPage: RequestHandler; getStatusPageByUrl: RequestHandler; + getPublicMonitorIncidents: RequestHandler; resolveStatusPageByDomain: RequestHandler; getStatusPagesByTeamId: RequestHandler; deleteStatusPage: RequestHandler; @@ -85,6 +88,19 @@ class StatusPageController implements IStatusPageController { }); }); + getPublicMonitorIncidents = catchAsync(async (req: Request, res: Response) => { + const { url, monitorId } = getPublicMonitorIncidentsParamValidation.parse(req.params); + const { date } = getPublicMonitorIncidentsQueryValidation.parse(req.query); + + const incidents = await this.statusPageService.getPublicMonitorIncidents(url, monitorId, date, req.user?.teamId); + + return res.status(200).json({ + success: true, + msg: "Incidents retrieved successfully", + data: { incidents }, + }); + }); + resolveStatusPageByDomain = catchAsync(async (req: Request, res: Response) => { const { range } = resolveStatusPageQueryValidation.parse(req.query); const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined); diff --git a/server/src/api/middleware/statusPagePublicApiPath.ts b/server/src/api/middleware/statusPagePublicApiPath.ts index a4b5e42d1..b7e89f9af 100644 --- a/server/src/api/middleware/statusPagePublicApiPath.ts +++ b/server/src/api/middleware/statusPagePublicApiPath.ts @@ -16,7 +16,15 @@ export const isPublicStatusPageApiPath = (method: string, path: string): boolean return true; } - if (!slug || slug === "team" || slug.includes("/")) { + if (!slug || slug === "team") { + return false; + } + + if (slug.includes("/")) { + const parts = slug.split("/"); + if (parts.length === 3 && parts[1] === "incidents") { + return true; + } return false; } diff --git a/server/src/api/routes/statusPageRoutes.ts b/server/src/api/routes/statusPageRoutes.ts index b37ae5499..07848f41c 100644 --- a/server/src/api/routes/statusPageRoutes.ts +++ b/server/src/api/routes/statusPageRoutes.ts @@ -14,6 +14,7 @@ export const createStatusPageRoutes = ( router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), statusPageController.updateStatusPage); router.get("/resolve", statusPageController.resolveStatusPageByDomain); router.get("/:url", verifyStatusPageAccess, statusPageController.getStatusPageByUrl); + router.get("/:url/incidents/:monitorId", verifyStatusPageAccess, statusPageController.getPublicMonitorIncidents); router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), statusPageController.deleteStatusPage); return router; }; diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index e92f28a57..f21c48209 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -20,6 +20,15 @@ export const getStatusPageParamValidation = z.object({ url: z.string().min(1, "URL is required"), }); +export const getPublicMonitorIncidentsParamValidation = z.object({ + url: z.string().min(1, "URL is required"), + monitorId: z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID"), +}); + +export const getPublicMonitorIncidentsQueryValidation = z.object({ + date: z.string().date("Date must be a valid calendar date in YYYY-MM-DD format"), +}); + export const getStatusPageQueryValidation = z.object({ type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])), range: z.enum(StatusPageRanges).optional().default("latest"), diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts index 68b722f0a..a72fa50e5 100644 --- a/server/src/config/services.api.ts +++ b/server/src/config/services.api.ts @@ -99,7 +99,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A emailService, }); - const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository); + const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository, incidentsRepository); const tagsService = new TagsService(tagsRepository, monitorsRepository); const diagnosticService = new DiagnosticService(db); const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService); diff --git a/server/src/domain/incidents/incident.model.ts b/server/src/domain/incidents/incident.model.ts index 9e252bb1d..468c65eba 100644 --- a/server/src/domain/incidents/incident.model.ts +++ b/server/src/domain/incidents/incident.model.ts @@ -82,6 +82,7 @@ IncidentSchema.index({ teamId: 1, startTime: -1 }); IncidentSchema.index({ status: 1, startTime: -1 }); IncidentSchema.index({ resolutionType: 1, status: 1 }); IncidentSchema.index({ resolvedBy: 1, status: 1 }); +IncidentSchema.index({ monitorId: 1, startTime: -1 }); IncidentSchema.index({ createdAt: -1 }); const IncidentModel = model("Incident", IncidentSchema); diff --git a/server/src/domain/incidents/incident.repository.interface.ts b/server/src/domain/incidents/incident.repository.interface.ts index 549a33b85..561ab7a41 100644 --- a/server/src/domain/incidents/incident.repository.interface.ts +++ b/server/src/domain/incidents/incident.repository.interface.ts @@ -17,6 +17,7 @@ export interface IIncidentsRepository { monitorId?: string, resolutionType?: string ): Promise; + findByMonitorIdAndDate(monitorId: string, teamId: string, dateStart: Date, dateEnd: Date): Promise; findSummaryByTeamId(teamId: string, limit?: number): Promise; countByTeamId(teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string): Promise; diff --git a/server/src/domain/incidents/incident.repository.mongo.ts b/server/src/domain/incidents/incident.repository.mongo.ts index 61cea96b8..3f40b68cd 100644 --- a/server/src/domain/incidents/incident.repository.mongo.ts +++ b/server/src/domain/incidents/incident.repository.mongo.ts @@ -120,6 +120,17 @@ class MongoIncidentsRepository implements IIncidentsRepository { return this.mapDocuments(incidents); }; + findByMonitorIdAndDate = async (monitorId: string, teamId: string, dateStart: Date, dateEnd: Date): Promise => { + const matchStage = { + monitorId: new mongoose.Types.ObjectId(monitorId), + teamId: new mongoose.Types.ObjectId(teamId), + startTime: { $lt: dateEnd }, + $or: [{ endTime: null }, { endTime: { $gte: dateStart } }], + }; + const incidents = await IncidentModel.find(matchStage).sort({ startTime: -1 }); + return this.mapDocuments(incidents); + }; + updateById = async (incidentId: string, teamId: string, patch: Partial) => { const updatedIncident = await IncidentModel.findOneAndUpdate( { _id: new mongoose.Types.ObjectId(incidentId), teamId: new mongoose.Types.ObjectId(teamId) }, diff --git a/server/src/domain/incidents/incident.type.ts b/server/src/domain/incidents/incident.type.ts index 2b45e72c4..8b024969d 100644 --- a/server/src/domain/incidents/incident.type.ts +++ b/server/src/domain/incidents/incident.type.ts @@ -20,6 +20,11 @@ export interface Incident { updatedAt: string; } +export type PublicIncident = Pick< + Incident, + "id" | "monitorId" | "status" | "startTime" | "endTime" | "resolutionType" | "message" | "statusCode" | "createdAt" +>; + export interface IncidentSummaryTopMonitor { monitorId: string; monitorName: string | null; diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 5f700857f..5e16c8534 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -1,6 +1,13 @@ +import { type IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js"; +import type { Incident, PublicIncident } from "@/domain/incidents/incident.type.js"; import { type IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; +import type { DailyCheckBucket } from "@/domain/checks/check.type.js"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc.js"; +import timezone from "dayjs/plugin/timezone.js"; import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, @@ -12,8 +19,9 @@ import { import { AppError } from "@/utils/AppError.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { Monitor } from "@/domain/monitors/monitor.type.js"; -import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; -import type { DailyCheckBucket } from "@/domain/checks/check.type.js"; + +dayjs.extend(utc); +dayjs.extend(timezone); export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; @@ -21,6 +29,7 @@ export interface IStatusPageService { getStatusPageByCustomDomain(customDomain: string): Promise; getStatusPagesByTeamId(teamId: string): Promise; getPublicStatusPagePayload(statusPage: StatusPage, requesterTeamId: string | undefined, range: StatusPageRange): Promise; + getPublicMonitorIncidents(url: string, monitorId: string, date: string, requesterTeamId?: string): Promise; updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; deleteStatusPage(statusPageId: string, teamId: string): Promise; @@ -31,7 +40,8 @@ export class StatusPageService implements IStatusPageService { private statusPagesRepository: IStatusPagesRepository, private settingsService: ISettingsService, private monitorsRepository: IMonitorsRepository, - private checksRepository: IChecksRepository + private checksRepository: IChecksRepository, + private incidentsRepository: IIncidentsRepository ) {} private assertCustomDomainAllowed = (customDomain: string | null | undefined) => { @@ -95,6 +105,18 @@ export class StatusPageService implements IStatusPageService { return base; }; + private toPublicIncident = (incident: Incident): PublicIncident => ({ + id: incident.id, + monitorId: incident.monitorId, + status: incident.status, + startTime: incident.startTime, + endTime: incident.endTime, + resolutionType: incident.resolutionType, + message: incident.message ?? null, + statusCode: incident.statusCode ?? null, + createdAt: incident.createdAt, + }); + createStatusPage = async ( userId: string, teamId: string, @@ -167,6 +189,29 @@ export class StatusPageService implements IStatusPageService { }; }; + getPublicMonitorIncidents = async (url: string, monitorId: string, date: string, requesterTeamId?: string): Promise => { + const statusPage = await this.getStatusPageByUrl(url); + + // Ensure the status page is public or the requester is the owner + if (!statusPage.isPublished) { + if (!requesterTeamId || statusPage.teamId !== requesterTeamId) { + throw new AppError({ message: "Forbidden", status: 403 }); + } + } + + // Ensure the requested monitor actually belongs to this status page + if (!statusPage.monitors.includes(monitorId)) { + throw new AppError({ message: "Monitor not found on this status page", status: 404 }); + } + + const tz = statusPage.timezone ?? "Etc/UTC"; + const dateStart = dayjs.tz(date, tz).startOf("day").toDate(); + const dateEnd = dayjs.tz(date, tz).endOf("day").toDate(); + + const incidents = await this.incidentsRepository.findByMonitorIdAndDate(monitorId, statusPage.teamId, dateStart, dateEnd); + return incidents.map(this.toPublicIncident); + }; + updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data)); const updated = await this.statusPagesRepository.updateById(id, teamId, image, normalizedData); diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index ba3ac298e..6351e5c14 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -4,6 +4,7 @@ import type { IStatusPagesRepository } from "../../../src/domain/status-pages/st import type { ISettingsService } from "../../../src/domain/app-settings/app-settings.service.ts"; import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts"; +import type { IIncidentsRepository } from "../../../src/domain/incidents/incident.repository.interface.ts"; import type { CheckSnapshot } from "../../../src/domain/checks/check.type.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; import type { StatusPage } from "../../../src/domain/status-pages/status-page.type.ts"; @@ -84,13 +85,19 @@ const createChecksRepo = () => getDailyStatusBuckets: jest.fn().mockResolvedValue([]), }) as unknown as jest.Mocked; +const createIncidentsRepo = () => + ({ + findByMonitorIdAndDate: jest.fn().mockResolvedValue([]), + }) as unknown as jest.Mocked; + const createService = (themesEnabled = true, clientHost = "http://localhost:5173", showURL = false) => { const repo = createRepo(); const settingsService = createSettingsService(themesEnabled, clientHost, showURL); const monitorsRepo = createMonitorsRepo(); const checksRepo = createChecksRepo(); - const service = new StatusPageService(repo, settingsService, monitorsRepo, checksRepo); - return { service, repo, settingsService, monitorsRepo, checksRepo }; + const incidentsRepo = createIncidentsRepo(); + const service = new StatusPageService(repo, settingsService, monitorsRepo, checksRepo, incidentsRepo); + return { service, repo, settingsService, monitorsRepo, checksRepo, incidentsRepo }; }; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -424,6 +431,51 @@ describe("StatusPageService", () => { }); }); + describe("getPublicMonitorIncidents", () => { + const publishedPage = () => makeStatusPage({ isPublished: true, url: "my-status-page", monitors: ["mon-1"], teamId: "team-1" }); + + it("rejects an unpublished page (403)", async () => { + const { service, repo } = createService(); + (repo.findByUrl as jest.Mock).mockResolvedValue(makeStatusPage({ isPublished: false })); + await expect(service.getPublicMonitorIncidents("my-status-page", "mon-1", "2026-08-01")).rejects.toMatchObject({ status: 403 }); + }); + + it("rejects if monitor is not on the page (404)", async () => { + const { service, repo } = createService(); + (repo.findByUrl as jest.Mock).mockResolvedValue(publishedPage()); + await expect(service.getPublicMonitorIncidents("my-status-page", "mon-99", "2026-08-01")).rejects.toMatchObject({ status: 404 }); + }); + + it("fetches and sanitizes incidents successfully", async () => { + const { service, repo, incidentsRepo } = createService(); + (repo.findByUrl as jest.Mock).mockResolvedValue(publishedPage()); + + const mockIncident = { + id: "inc-1", + monitorId: "mon-1", + teamId: "team-1", + resolvedBy: "user-99", + status: false, + startTime: "2026-08-01T10:00:00Z", + endTime: "2026-08-01T11:00:00Z", + resolutionType: "manual" as const, + message: "Test incident", + createdAt: "2026-08-01T10:00:00Z", + updatedAt: "2026-08-01T10:00:00Z", + }; + (incidentsRepo.findByMonitorIdAndDate as jest.Mock).mockResolvedValue([mockIncident]); + + const results = await service.getPublicMonitorIncidents("my-status-page", "mon-1", "2026-08-01"); + + expect(incidentsRepo.findByMonitorIdAndDate).toHaveBeenCalled(); + expect(results).toHaveLength(1); + expect(results[0]).toHaveProperty("id", "inc-1"); + expect(results[0]).not.toHaveProperty("teamId"); + expect(results[0]).not.toHaveProperty("resolvedBy"); + expect(results[0]).not.toHaveProperty("updatedAt"); + }); + }); + describe("deleteStatusPage", () => { it("delegates to repository and returns deleted page", async () => { const { service, repo } = createService(true);