feat: clickthrough historical data view for status page downtime (#3972)

This adds the ability for users to click on any day block in the historical uptime chart to see the specific incidents that occurred on that day.

Closes #3972
This commit is contained in:
nikhil2004 committed 2026-09-22 03:31:14 +05:30
1 parent da8b230cec
commit 7f0e577dd2
15 files changed
+234 -33

No files matched your search

+2 -22
View File
@@ -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",
@@ -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<string | null>(null);
const [selectedDate, setSelectedDate] = useState<string | null>(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);
}}
/>
) : (
<ThemedHistogram
@@ -313,6 +320,10 @@ export const BaseStatusPage = ({
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
onCellClick={(date) => {
setSelectedMonitorId(monitor.id);
setSelectedDate(date);
}}
/>
))}
</Box>
@@ -333,6 +344,15 @@ export const BaseStatusPage = ({
Checkmate
</a>
</Box>
<IncidentModal
url={statusPage.url}
monitorId={selectedMonitorId}
date={selectedDate}
onClose={() => {
setSelectedMonitorId(null);
setSelectedDate(null);
}}
/>
</Box>
);
};
@@ -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: <ThemedChartTooltip check={check} />,
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,
};
});
};
@@ -0,0 +1,97 @@
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";
interface Incident {
id: string;
message: string | null;
startTime: string;
endTime: string | null;
statusCode: number | null;
}
interface IncidentModalProps {
url: string;
monitorId: string | null;
date: string | null;
onClose: () => void;
}
export const IncidentModal = ({ url, monitorId, date, onClose }: IncidentModalProps) => {
const { t } = useTranslation();
const open = Boolean(monitorId && date);
const { data, isLoading } = useGet<{ incidents: Incident[] }>(
open ? `/status-page/${url}/incidents/${monitorId}?date=${date}` : null,
{},
{ revalidateOnFocus: false }
);
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
>
<DialogTitle
sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
{t("pages.statusPages.monitorsList.incidents.modalTitle", { date })}
<IconButton
onClick={onClose}
size="small"
aria-label="close"
>
<XIcon size={20} />
</IconButton>
</DialogTitle>
<DialogContent>
{isLoading ? (
<Typography sx={{ mt: 1 }}>
{t("pages.statusPages.monitorsList.incidents.loading")}
</Typography>
) : data?.incidents && data.incidents.length > 0 ? (
<Stack
gap={2}
sx={{ mt: 1 }}
>
{data.incidents.map((incident) => (
<Stack
key={incident.id}
sx={{ p: 2, border: 1, borderColor: "divider", borderRadius: 1 }}
>
<Typography
variant="subtitle2"
fontWeight="bold"
>
{incident.message ||
(incident.statusCode ? `Error ${incident.statusCode}` : "Incident")}
</Typography>
<Typography
variant="body2"
color="text.secondary"
>
{new Date(incident.startTime).toLocaleTimeString()} -{" "}
{incident.endTime
? new Date(incident.endTime).toLocaleTimeString()
: t("pages.statusPages.monitorsList.incidents.ongoing")}
</Typography>
</Stack>
))}
</Stack>
) : (
<Typography sx={{ mt: 1 }}>
{t("pages.statusPages.monitorsList.incidents.noIncidents")}
</Typography>
)}
</DialogContent>
</Dialog>
);
};
@@ -11,9 +11,10 @@ interface Props {
cells: ChartCell[];
containerSx: SxProps<Theme>;
cellSx: (kind: HeatCellKind, severity?: number) => SxProps<Theme>;
onCellClick?: (date: string) => void;
}
export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
export const ThemedHeatmap = ({ cells, containerSx, cellSx, onCellClick }: Props) => {
const { t } = useTranslation();
return (
@@ -42,8 +43,16 @@ export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => {
placement="top"
>
<Box
sx={cellSx(cell.heatKind, cell.severity)}
sx={[
cellSx(cell.heatKind, cell.severity),
!!cell.date && !!onCellClick && { cursor: "pointer" },
]}
aria-label={cell.ariaLabel}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
/>
</Tooltip>
);
@@ -16,6 +16,7 @@ interface Props {
barSx: (kind: BarKind, heightPct: number, severity?: number) => SxProps<Theme>;
statsSx: SxProps<Theme>;
statsGap?: number;
onCellClick?: (date: string) => void;
}
export const ThemedHistogram = ({
@@ -24,6 +25,7 @@ export const ThemedHistogram = ({
barSx,
statsSx,
statsGap = 1,
onCellClick,
}: Props) => {
const { t } = useTranslation();
@@ -61,8 +63,16 @@ export const ThemedHistogram = ({
placement="top"
>
<Box
sx={barSx(cell.barKind, cell.heightPct, cell.severity)}
sx={[
barSx(cell.barKind, cell.heightPct, cell.severity),
!!cell.date && !!onCellClick && { cursor: "pointer" },
]}
aria-label={cell.ariaLabel}
onClick={() => {
if (cell.date && onCellClick) {
onCellClick(cell.date);
}
}}
/>
</Tooltip>
);
+6
View File
@@ -1712,6 +1712,12 @@
"uptime": {
"title": "Uptime",
"responseTime": "Response time"
},
"incidents": {
"modalTitle": "Incidents for {{date}}",
"loading": "Loading...",
"noIncidents": "No incidents reported for this day.",
"ongoing": "Ongoing"
}
},
"statusBar": {
@@ -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);
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);
@@ -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;
};
@@ -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().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be 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"),
+1 -1
View File
@@ -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);
@@ -17,6 +17,7 @@ export interface IIncidentsRepository {
monitorId?: string,
resolutionType?: string
): Promise<Incident[]>;
findByMonitorIdAndDate(monitorId: string, teamId: string, dateStart: Date, dateEnd: Date): Promise<Incident[]>;
findSummaryByTeamId(teamId: string, limit?: number): Promise<IncidentSummary>;
countByTeamId(teamId: string, startDate: Date | undefined, status?: boolean, monitorId?: string, resolutionType?: string): Promise<number>;
@@ -120,6 +120,17 @@ class MongoIncidentsRepository implements IIncidentsRepository {
return this.mapDocuments(incidents);
};
findByMonitorIdAndDate = async (monitorId: string, teamId: string, dateStart: Date, dateEnd: Date): Promise<Incident[]> => {
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<Incident>) => {
const updatedIncident = await IncidentModel.findOneAndUpdate(
{ _id: new mongoose.Types.ObjectId(incidentId), teamId: new mongoose.Types.ObjectId(teamId) },
@@ -1,6 +1,13 @@
import { type IIncidentsRepository } from "@/domain/incidents/incident.repository.interface.js";
import type { Incident } 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<StatusPage>): Promise<StatusPage>;
@@ -21,6 +29,7 @@ export interface IStatusPageService {
getStatusPageByCustomDomain(customDomain: string): Promise<StatusPage>;
getStatusPagesByTeamId(teamId: string): Promise<StatusPage[]>;
getPublicStatusPagePayload(statusPage: StatusPage, requesterTeamId: string | undefined, range: StatusPageRange): Promise<PublicStatusPagePayload>;
getPublicMonitorIncidents(url: string, monitorId: string, date: string): Promise<Incident[]>;
updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
deleteStatusPage(statusPageId: string, teamId: string): Promise<StatusPage>;
@@ -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) => {
@@ -167,6 +177,26 @@ export class StatusPageService implements IStatusPageService {
};
};
getPublicMonitorIncidents = async (url: string, monitorId: string, date: string): Promise<Incident[]> => {
const statusPage = await this.getStatusPageByUrl(url);
// Ensure the status page is public
if (!statusPage.isPublished) {
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();
return this.incidentsRepository.findByMonitorIdAndDate(monitorId, statusPage.teamId, dateStart, dateEnd);
};
updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data));
const updated = await this.statusPagesRepository.updateById(id, teamId, image, normalizedData);
@@ -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<IChecksRepository>;
const createIncidentsRepo = () =>
({
findByMonitorIdAndDate: jest.fn().mockResolvedValue([]),
}) as unknown as jest.Mocked<IIncidentsRepository>;
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 ────────────────────────────────────────────────────────────────────