From 0988a61c1d490aa797f8b6d6eff4cbed7148767e Mon Sep 17 00:00:00 2001 From: Iven Schlenther Date: Wed, 22 Jul 2026 12:51:30 +0200 Subject: [PATCH 001/143] Resolve legacy monitor status issues: handle unknown statuses, fix stuck states from old boolean values, and add regression tests. Adjust BaseChart text color for theme compatibility. --- server/src/service/statusService.ts | 7 +++- .../test/unit/services/statusService.test.ts | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/server/src/service/statusService.ts b/server/src/service/statusService.ts index 748960a37..db83de08f 100755 --- a/server/src/service/statusService.ts +++ b/server/src/service/statusService.ts @@ -1,7 +1,7 @@ import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import type { Check, CheckDiskInfo, CheckSnapshot } from "@/domain/checks/check.type.js"; -import type { Monitor, MonitorStatus } from "@/domain/monitors/monitor.type.js"; +import { MonitorStatuses, type Monitor, type MonitorStatus } from "@/domain/monitors/monitor.type.js"; import type { DockerStatusPayload, GameStatusPayload, @@ -218,9 +218,12 @@ export class StatusService implements IStatusService { const patch: Partial = {}; // Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down + // Unknown values (e.g. legacy boolean statuses from old versions) are resolved the same way, + // otherwise computeReachability can never transition them and they stay stuck forever. + const isKnownStatus = (MonitorStatuses as readonly string[]).includes(monitor.status); let newStatus: MonitorStatus = monitor.status; let statusChanged = false; - if (monitor.status === "initializing" || monitor.status === "maintenance") { + if (monitor.status === "initializing" || monitor.status === "maintenance" || !isKnownStatus) { newStatus = status === true ? "up" : "down"; patch.status = newStatus; statusChanged = newStatus === "down"; diff --git a/server/test/unit/services/statusService.test.ts b/server/test/unit/services/statusService.test.ts index 9e66ddbec..071465d48 100644 --- a/server/test/unit/services/statusService.test.ts +++ b/server/test/unit/services/statusService.test.ts @@ -498,6 +498,45 @@ describe("StatusService", () => { expect(result.monitor.status).toBe("down"); }); + it("resolves a legacy boolean status ('true') to 'up' on a passing check (regression: pre-upgrade monitors stuck forever)", async () => { + // Old Checkmate versions stored monitor.status as a boolean. Pre-fix, a monitor + // carrying status='true' never matched the initializing override and + // computeReachability only transitions to 'up' from 'down', so the legacy value + // was written back unchanged on every evaluation — stuck forever while the UI + // rendered it as 'initializing'. + const monitor = makeMonitor({ + statusWindow: [true, true, true, true, true], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "true" as MonitorStatus, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true }), monitor); + + expect(result.monitor.status).toBe("up"); + expect(result.statusChanged).toBe(false); // was effectively up already — no notification storm + }); + + it("resolves a legacy boolean status ('false') to 'down' on a failing check (regression mirror)", async () => { + const monitor = makeMonitor({ + statusWindow: [false, false, false, false, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "false" as MonitorStatus, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false }), monitor); + + expect(result.monitor.status).toBe("down"); + expect(result.statusChanged).toBe(true); // down must surface so an incident is opened + }); + it("during warmup, sub-threshold raw checks no longer flip stored status (regression: down-during-init incident not resolving)", async () => { // Once the monitor has left 'initializing', the warmup branch must NOT silently // flip status from raw checks. Otherwise a down check during warmup writes From 3201653f199aaf27ac036e4bf47fafa011ec3569 Mon Sep 17 00:00:00 2001 From: Iven Schlenther Date: Mon, 27 Jul 2026 15:02:37 +0200 Subject: [PATCH 002/143] Fix monitor status handling: replace `isKnownStatus` with `isUnknownStatus` for clarity and simplify initialization logic. --- server/src/service/statusService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/service/statusService.ts b/server/src/service/statusService.ts index db83de08f..773a49e27 100755 --- a/server/src/service/statusService.ts +++ b/server/src/service/statusService.ts @@ -220,11 +220,11 @@ export class StatusService implements IStatusService { // Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down // Unknown values (e.g. legacy boolean statuses from old versions) are resolved the same way, // otherwise computeReachability can never transition them and they stay stuck forever. - const isKnownStatus = (MonitorStatuses as readonly string[]).includes(monitor.status); + const isUnknownStatus = !MonitorStatuses.includes(monitor.status); let newStatus: MonitorStatus = monitor.status; let statusChanged = false; - if (monitor.status === "initializing" || monitor.status === "maintenance" || !isKnownStatus) { - newStatus = status === true ? "up" : "down"; + if (monitor.status === "initializing" || monitor.status === "maintenance" || isUnknownStatus) { + newStatus = status ? "up" : "down"; patch.status = newStatus; statusChanged = newStatus === "down"; } From 04f7ef252a08c9dc90155a6feef342384720a852 Mon Sep 17 00:00:00 2001 From: Iven Schlenther Date: Mon, 27 Jul 2026 15:07:00 +0200 Subject: [PATCH 003/143] Fix indentation in `statusService.ts` for `isUnknownStatus` assignment. --- server/src/service/statusService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/service/statusService.ts b/server/src/service/statusService.ts index 773a49e27..9be31e5f1 100755 --- a/server/src/service/statusService.ts +++ b/server/src/service/statusService.ts @@ -220,7 +220,7 @@ export class StatusService implements IStatusService { // Resolve "initializing" and "maintenance" up front, incidents should be created on initialization && down // Unknown values (e.g. legacy boolean statuses from old versions) are resolved the same way, // otherwise computeReachability can never transition them and they stay stuck forever. - const isUnknownStatus = !MonitorStatuses.includes(monitor.status); + const isUnknownStatus = !MonitorStatuses.includes(monitor.status); let newStatus: MonitorStatus = monitor.status; let statusChanged = false; if (monitor.status === "initializing" || monitor.status === "maintenance" || isUnknownStatus) { From a29b7037cf5ae6bf5bada2f7544591e191be3ee3 Mon Sep 17 00:00:00 2001 From: Anju <168076172+anjupathak03@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:40:11 +0530 Subject: [PATCH 004/143] test: cover monitor tag filtering --- .../integration/monitorRepository.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 server/test/integration/monitorRepository.test.ts diff --git a/server/test/integration/monitorRepository.test.ts b/server/test/integration/monitorRepository.test.ts new file mode 100644 index 000000000..7144f7f3e --- /dev/null +++ b/server/test/integration/monitorRepository.test.ts @@ -0,0 +1,54 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import { MonitorModel } from "../../src/domain/monitors/monitor.model.ts"; +import MongoMonitorsRepository from "../../src/domain/monitors/monitor.repository.mongo.ts"; + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + await MonitorModel.init(); +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await MonitorModel.deleteMany({}); +}); + +const seedMonitor = (teamId: mongoose.Types.ObjectId, name: string, tags: mongoose.Types.ObjectId[]) => + MonitorModel.create({ + userId: new mongoose.Types.ObjectId(), + teamId, + name, + type: "http", + url: "https://example.com", + tags, + }); + +describe("MongoMonitorsRepository", () => { + describe("tag filtering", () => { + it("matches ObjectId tag references when filters arrive as query strings", async () => { + const repo = new MongoMonitorsRepository(); + const teamId = new mongoose.Types.ObjectId(); + const selectedTagId = new mongoose.Types.ObjectId(); + const otherTagId = new mongoose.Types.ObjectId(); + + await seedMonitor(teamId, "selected", [selectedTagId]); + await seedMonitor(teamId, "not-selected", [otherTagId]); + await seedMonitor(new mongoose.Types.ObjectId(), "other-team", [selectedTagId]); + + const tags = selectedTagId.toString(); + const monitors = await repo.findByTeamIdWithStats(teamId.toString(), { tags }); + const summary = await repo.findMonitorsSummaryByTeamId(teamId.toString(), { tags }); + + expect(monitors.map((monitor) => monitor.name)).toEqual(["selected"]); + expect(summary.totalMonitors).toBe(1); + }); + }); +}); From c102cd8d26e610581c0bb0a03a8e2cd7a0484915 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 30 Jul 2026 10:35:01 -0700 Subject: [PATCH 005/143] openapi schemas --- server/openapi.json | 356 +++++++++++++++++- server/openapi/routes/monitor.ts | 5 +- .../src/api/validation/monitorValidation.ts | 49 +++ 3 files changed, 396 insertions(+), 14 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index 4b710b639..28a22f394 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -536,6 +536,341 @@ "updatedAt": "2026-04-15T14:30:00.000Z" } }, + "UptimeDetails": { + "type": "object", + "properties": { + "monitorData": { + "type": "object", + "properties": { + "monitor": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "url": { + "type": "string" + }, + "port": { + "type": "number" + }, + "isActive": { + "type": "boolean" + }, + "interval": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"] + }, + "statusWindowSize": { + "type": "number" + }, + "statusWindowThreshold": { + "type": "number" + }, + "ignoreTlsErrors": { + "type": "boolean" + }, + "useAdvancedMatching": { + "type": "boolean" + }, + "jsonPath": { + "type": "string" + }, + "expectedValue": { + "type": "string" + }, + "matchMethod": { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + "method": { + "type": "string", + "enum": ["GET", "HEAD"] + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "customUpCodes": { + "type": "array", + "items": { + "type": "number" + } + }, + "secret": { + "type": "string" + }, + "cpuAlertThreshold": { + "type": "number" + }, + "memoryAlertThreshold": { + "type": "number" + }, + "diskAlertThreshold": { + "type": "number" + }, + "tempAlertThreshold": { + "type": "number" + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + } + }, + "gameId": { + "type": "string" + }, + "grpcServiceName": { + "type": "string" + }, + "group": { + "type": "string", + "nullable": true + }, + "geoCheckEnabled": { + "type": "boolean" + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + }, + "geoCheckInterval": { + "type": "number" + }, + "dnsServer": { + "type": "string" + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] + }, + "teamId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "lastEvaluatedAt": { + "type": "number" + }, + "id": { + "type": "string" + } + }, + "required": [ + "name", + "type", + "url", + "isActive", + "interval", + "status", + "statusWindowSize", + "statusWindowThreshold", + "ignoreTlsErrors", + "useAdvancedMatching", + "method", + "notifications", + "tags", + "cpuAlertThreshold", + "memoryAlertThreshold", + "diskAlertThreshold", + "tempAlertThreshold", + "selectedDisks", + "geoCheckEnabled", + "geoCheckLocations", + "geoCheckInterval", + "teamId", + "userId", + "createdAt", + "updatedAt", + "lastEvaluatedAt", + "id" + ], + "additionalProperties": { + "nullable": true + } + }, + "groupedChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bucketDate": { + "type": "string" + }, + "avgResponseTime": { + "type": "number" + }, + "totalChecks": { + "type": "number" + }, + "avgDns": { + "type": "number" + }, + "avgTcp": { + "type": "number" + }, + "avgTls": { + "type": "number" + }, + "avgRequest": { + "type": "number" + }, + "avgFirstByte": { + "type": "number" + }, + "avgDownload": { + "type": "number" + } + }, + "required": [ + "bucketDate", + "avgResponseTime", + "totalChecks", + "avgDns", + "avgTcp", + "avgTls", + "avgRequest", + "avgFirstByte", + "avgDownload" + ] + } + }, + "groupedUpChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bucketDate": { + "type": "string" + }, + "avgResponseTime": { + "type": "number" + }, + "totalChecks": { + "type": "number" + } + }, + "required": ["bucketDate", "avgResponseTime", "totalChecks"] + } + }, + "groupedDownChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bucketDate": { + "type": "string" + }, + "avgResponseTime": { + "type": "number" + }, + "totalChecks": { + "type": "number" + } + }, + "required": ["bucketDate", "avgResponseTime", "totalChecks"] + } + }, + "groupedAvgResponseTime": { + "type": "number" + }, + "groupedUptimePercentage": { + "type": "number" + } + }, + "required": ["monitor", "groupedChecks", "groupedUpChecks", "groupedDownChecks", "groupedAvgResponseTime", "groupedUptimePercentage"] + }, + "monitorStats": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "string" + }, + "monitorId": { + "type": "string" + }, + "avgResponseTime": { + "type": "number" + }, + "maxResponseTime": { + "type": "number" + }, + "totalChecks": { + "type": "number" + }, + "totalUpChecks": { + "type": "number" + }, + "totalDownChecks": { + "type": "number" + }, + "uptimePercentage": { + "type": "number" + }, + "lastCheckTimestamp": { + "type": "number" + }, + "lastResponseTime": { + "type": "number" + }, + "timeOfLastFailure": { + "type": "number" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "monitorId", + "avgResponseTime", + "maxResponseTime", + "totalChecks", + "totalUpChecks", + "totalDownChecks", + "uptimePercentage", + "lastCheckTimestamp", + "lastResponseTime", + "createdAt", + "updatedAt" + ] + } + }, + "required": ["monitorData", "monitorStats"] + }, "NotificationChannelBody": { "oneOf": [ { @@ -2426,6 +2761,9 @@ "minLength": 8, "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" }, + "deleteProfileImage": { + "type": "boolean" + }, "profileImage": { "type": "string", "format": "binary" @@ -3747,7 +4085,7 @@ { "schema": { "type": "string", - "enum": ["manual", "automatic"] + "enum": ["automatic", "manual"] }, "required": false, "name": "resolutionType", @@ -6013,14 +6351,6 @@ "required": true, "name": "dateRange", "in": "query" - }, - { - "schema": { - "type": "boolean" - }, - "required": false, - "name": "normalize", - "in": "query" } ], "responses": { @@ -6039,10 +6369,10 @@ "type": "string" }, "data": { - "nullable": true + "$ref": "#/components/schemas/UptimeDetails" } }, - "required": ["success", "msg"] + "required": ["success", "msg", "data"] } } } @@ -10538,7 +10868,7 @@ }, "theme": { "type": "string", - "enum": ["refined", "modern", "bold", "editorial"] + "enum": ["refined", "modern", "bold", "editorial", "minimal"] }, "themeMode": { "type": "string", @@ -10756,7 +11086,7 @@ }, "theme": { "type": "string", - "enum": ["refined", "modern", "bold", "editorial"] + "enum": ["refined", "modern", "bold", "editorial", "minimal"] }, "themeMode": { "type": "string", diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index a941e54bf..01b687678 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -16,6 +16,7 @@ import { getHardwareDetailsByIdParamValidation, getHardwareDetailsByIdQueryValidation, monitorResponseSchema, + uptimeDetailsResponseSchema, } from "@/api/validation/monitorValidation.js"; import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js"; @@ -81,6 +82,8 @@ registry.registerPath({ responses: { "200": okJson(z.array(z.string())), ...standardErrors }, }); +const uptimeDetailsObject = uptimeDetailsResponseSchema.openapi("UptimeDetails"); + registry.registerPath({ method: "get", path: "/monitors/uptime/details/{monitorId}", @@ -88,7 +91,7 @@ registry.registerPath({ summary: "Get uptime details for a monitor", security: bearer, request: { params: getUptimeDetailsByIdParamValidation, query: getUptimeDetailsByIdQueryValidation }, - responses: { "200": okUnknown, ...standardErrors }, + responses: { "200": okJson(uptimeDetailsObject), ...standardErrors }, }); registry.registerPath({ diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 0e8b7b0bd..86db942a5 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -311,3 +311,52 @@ export const monitorResponseSchema = z lastEvaluatedAt: z.number(), }) .passthrough(); + +// Grouped-check buckets returned by GET /monitors/uptime/details/{monitorId}. Keep +// aligned with GroupedCheck / GroupedUptimeCheck in domain/checks/check.type.ts. +export const groupedCheckResponseSchema = z.object({ + bucketDate: z.string(), + avgResponseTime: z.number(), + totalChecks: z.number(), +}); + +export const groupedUptimeCheckResponseSchema = groupedCheckResponseSchema.extend({ + avgDns: z.number(), + avgTcp: z.number(), + avgTls: z.number(), + avgRequest: z.number(), + avgFirstByte: z.number(), + avgDownload: z.number(), +}); + +// Keep aligned with MonitorStats in domain/monitor-stats/monitor-stats.type.ts. +export const monitorStatsResponseSchema = z.object({ + id: z.string(), + monitorId: z.string(), + avgResponseTime: z.number(), + maxResponseTime: z.number(), + totalChecks: z.number(), + totalUpChecks: z.number(), + totalDownChecks: z.number(), + uptimePercentage: z.number(), + lastCheckTimestamp: z.number(), + lastResponseTime: z.number(), + timeOfLastFailure: z.number().optional(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +// Response of GET /monitors/uptime/details/{monitorId}. Keep aligned with +// UptimeDetailsResult in domain/monitors/monitor.type.ts; the monitor here is the +// repository's domain entity, which serializes `id` rather than `_id`. +export const uptimeDetailsResponseSchema = z.object({ + monitorData: z.object({ + monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }), + groupedChecks: z.array(groupedUptimeCheckResponseSchema), + groupedUpChecks: z.array(groupedCheckResponseSchema), + groupedDownChecks: z.array(groupedCheckResponseSchema), + groupedAvgResponseTime: z.number(), + groupedUptimePercentage: z.number(), + }), + monitorStats: monitorStatsResponseSchema.nullable(), +}); From 6856914d411f908fd340b6b1d7172013599246e7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 30 Jul 2026 10:56:24 -0700 Subject: [PATCH 006/143] extract hardcoded strings --- .../Components/monitors/charts/ChartUtils.ts | 15 +- .../monitors/charts/HistogramDetails.tsx | 26 ++- .../charts/HistogramDetailsStacked.tsx | 207 ------------------ client/src/locales/en.json | 14 +- 4 files changed, 41 insertions(+), 221 deletions(-) delete mode 100644 client/src/Components/monitors/charts/HistogramDetailsStacked.tsx diff --git a/client/src/Components/monitors/charts/ChartUtils.ts b/client/src/Components/monitors/charts/ChartUtils.ts index fb9d1779c..0516eaac3 100644 --- a/client/src/Components/monitors/charts/ChartUtils.ts +++ b/client/src/Components/monitors/charts/ChartUtils.ts @@ -23,11 +23,12 @@ export const PHASE_COLOR_KEYS = { avgDownload: "download", } as const; -export const PHASE_LABELS: Record<(typeof PHASE_KEYS)[number], string> = { - avgDns: "DNS", - avgTcp: "TCP", - avgTls: "TLS", - avgRequest: "Request", - avgFirstByte: "First byte", - avgDownload: "Download", +// i18n keys, resolved with t() at render +export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = { + avgDns: "common.charts.phases.dns", + avgTcp: "common.charts.phases.tcp", + avgTls: "common.charts.phases.tls", + avgRequest: "common.charts.phases.request", + avgFirstByte: "common.charts.phases.firstByte", + avgDownload: "common.charts.phases.download", }; diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index 0736abd6c..1f78c692e 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -17,6 +17,7 @@ import Typography from "@mui/material/Typography"; import prettyMilliseconds from "pretty-ms"; import { XTick } from "@/Components/monitors/charts/XTick"; import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; import { useTheme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; @@ -29,7 +30,7 @@ import { SPACING } from "@/Utils/Theme/constants"; import { PHASE_COLOR_KEYS, PHASE_KEYS, - PHASE_LABELS, + PHASE_LABEL_KEYS, } from "@/Components/monitors/charts/ChartUtils"; type DetailsCheck = GroupedCheck & @@ -49,6 +50,7 @@ const ResponseTimeToolTip = ({ theme, uiTimezone, }: ResponseTimeToolTipProps) => { + const { t } = useTranslation(); if (!label) return null; if (!payload) return null; if (!active) return null; @@ -59,8 +61,12 @@ const ResponseTimeToolTip = ({ {formatDateWithTz(String(label), format, uiTimezone)} - Response time:{" "} - {prettyMilliseconds(responseTime, { formatSubMilliseconds: true, compact: true })} + {t("common.charts.histogram.responseTime", { + value: prettyMilliseconds(responseTime, { + formatSubMilliseconds: true, + compact: true, + }), + })} ); @@ -79,6 +85,7 @@ const TimingPhasesToolTip = ({ theme, uiTimezone, }: TimingPhaseToolTipProps) => { + const { t } = useTranslation(); if (!active || !payload?.length) return null; const bucket = payload[0].payload as GroupedUptimeCheck; const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0); @@ -86,7 +93,9 @@ const TimingPhasesToolTip = ({ return ( {formatDateWithTz(bucket.bucketDate, format, uiTimezone)} - Total: {prettyMilliseconds(total)} + + {t("common.charts.histogram.total", { value: prettyMilliseconds(total) })} + {PHASE_KEYS.map((key) => ( - {PHASE_LABELS[key]}:{" "} + {t(PHASE_LABEL_KEYS[key])}:{" "} {prettyMilliseconds(bucket[key], { formatSubMilliseconds: true, compact: true, @@ -119,6 +128,7 @@ export const HistogramDetails = ({ range: string; }) => { const [showTimingPhases, setShowTimingPhases] = useState(false); + const { t } = useTranslation(); const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const yMax = useMemo(() => { @@ -142,7 +152,11 @@ export const HistogramDetails = ({ strokeWidth={1.5} /> } - title={showTimingPhases ? "Timing Phases" : "Response Time"} + title={ + showTimingPhases + ? t("common.charts.labels.timingPhases") + : t("common.charts.labels.responseTime") + } onClick={() => { if (!hasTimingPhases) return; setShowTimingPhases((prev) => !prev); diff --git a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx b/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx deleted file mode 100644 index 2ef74f56b..000000000 --- a/client/src/Components/monitors/charts/HistogramDetailsStacked.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import type { GroupedUptimeCheck } from "@/Types/Check"; -import type { TooltipProps } from "recharts"; -import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; -import type { Theme } from "@mui/material/styles"; -import type { RootState } from "@/Types/state"; -import { Clock } from "lucide-react"; -import prettyMilliseconds from "pretty-ms"; - -import Stack from "@mui/material/Stack"; -import { - Area, - AreaChart, - CartesianGrid, - ResponsiveContainer, - YAxis, - XAxis, - Tooltip, -} from "recharts"; -import { BaseBox, BaseChart, Dot } from "@/Components/design-elements"; -import Typography from "@mui/material/Typography"; -import { XTick } from "@/Components/monitors/charts/XTick"; - -import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; -import { useSelector } from "react-redux"; -import { useMemo } from "react"; -import { useTheme } from "@mui/material"; -import { SPACING } from "@/Utils/Theme/constants"; -import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils"; - -export const PHASE_KEYS = [ - "avgDns", - "avgTcp", - "avgTls", - "avgRequest", - "avgFirstByte", - "avgDownload", -] as const; - -const PHASE_COLOR_KEYS = { - avgDns: "dns", - avgTcp: "tcp", - avgTls: "tls", - avgRequest: "request", - avgFirstByte: "firstByte", - avgDownload: "download", -} as const; - -const PHASE_LABELS: Record<(typeof PHASE_KEYS)[number], string> = { - avgDns: "DNS", - avgTcp: "TCP", - avgTls: "TLS", - avgRequest: "Request", - avgFirstByte: "First byte", - avgDownload: "Download", -}; - -type TimingPhaseToolTipProps = TooltipProps & { - range: string; - theme: Theme; - uiTimezone: string; -}; - -const TimingPhasesToolTip = ({ - active, - payload, - range, - theme, - uiTimezone, -}: TimingPhaseToolTipProps) => { - if (!active || !payload?.length) return null; - const bucket = payload[0].payload as GroupedUptimeCheck; - const total = PHASE_KEYS.reduce((sum, key) => sum + bucket[key], 0); - const format = tooltipDateFormatLookup(range); - return ( - - {formatDateWithTz(bucket.bucketDate, format, uiTimezone)} - Total: {prettyMilliseconds(total)} - {PHASE_KEYS.map((key) => ( - - - - {PHASE_LABELS[key]}:{" "} - {prettyMilliseconds(bucket[key], { - formatSubMilliseconds: true, - compact: true, - })} - - - ))} - - ); -}; - -export const HistogramDetailsStacked = ({ - checks = [], - range, -}: { - checks: GroupedUptimeCheck[]; - range: string; -}) => { - const theme = useTheme(); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); - - // Cap the y domain at 2x the p95 of bucket totals, so typical stacks sit - // around mid-height with headroom above - const yMax = useMemo(() => { - const totals = checks.map((check) => - PHASE_KEYS.reduce((sum, key) => sum + check[key], 0) - ); - return computeYAxisCap(totals); - }, [checks]); - - return ( - - } - title="Timing phases" - > - - - - - {PHASE_KEYS.map((key) => ( - - - - - ))} - - - ( - - )} - /> - ( - - )} - /> - {PHASE_KEYS.map((key) => ( - - ))} - - - - ); -}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d940f95fc..82d144b1c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -68,11 +68,23 @@ "downtime": "Downtime", "high": "High", "low": "Low", + "responseTime": "Response time", + "timingPhases": "Timing phases", "uptime": "Uptime" }, "histogram": { "avg": "Avg: {{value}} ms", - "max": "Max: {{value}} ms" + "max": "Max: {{value}} ms", + "responseTime": "Response time: {{value}}", + "total": "Total: {{value}}" + }, + "phases": { + "dns": "DNS", + "tcp": "TCP", + "tls": "TLS", + "request": "Request", + "firstByte": "First byte", + "download": "Download" } }, "dialogs": { From 89464e9be4460c25b98f4f528b53541ccc8c9fb8 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 30 Jul 2026 11:54:12 -0700 Subject: [PATCH 007/143] add legend --- client/src/Components/design-elements/Dot.tsx | 18 +++++-- .../monitors/charts/HistogramDetails.tsx | 52 ++++++++++++++++++- client/src/locales/en.json | 1 + 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/client/src/Components/design-elements/Dot.tsx b/client/src/Components/design-elements/Dot.tsx index 541e90812..ff97de9b4 100644 --- a/client/src/Components/design-elements/Dot.tsx +++ b/client/src/Components/design-elements/Dot.tsx @@ -1,18 +1,28 @@ import type { CSSProperties } from "react"; +export const DotSizes = ["xs", "sm", "md", "lg"] as const; +export type DotSize = (typeof DotSizes)[number]; + +const DOT_SIZE_PX: Record = { + xs: "2px", + sm: "4px", + md: "8px", + lg: "12px", +}; + interface DotProps { color?: string; - size?: string; + size?: DotSize; style?: CSSProperties; } -export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => { +export const Dot = ({ color = "gray", size = "sm", style }: DotProps) => { return ( @@ -128,6 +128,8 @@ export const HistogramDetails = ({ range: string; }) => { const [showTimingPhases, setShowTimingPhases] = useState(false); + const [hoveredPhase, setHoveredPhase] = useState(null); + const { t } = useTranslation(); const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); @@ -267,6 +269,7 @@ export const HistogramDetails = ({ {hasTimingPhases && showTimingPhases && PHASE_KEYS.map((key) => { + const dimmed = hoveredPhase !== null && hoveredPhase !== key; return ( ); })} + + {showTimingPhases ? ( + PHASE_KEYS.map((key) => { + return ( + setHoveredPhase(key)} + onMouseLeave={() => setHoveredPhase(null)} + > + + {t(PHASE_LABEL_KEYS[key])} + + ); + }) + ) : ( + setHoveredPhase("avgResponseTime")} + onMouseLeave={() => setHoveredPhase(null)} + > + + + {t("common.charts.labels.totalResponseTime")} + + + )} + ); }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 82d144b1c..ce7f74dfb 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -70,6 +70,7 @@ "low": "Low", "responseTime": "Response time", "timingPhases": "Timing phases", + "totalResponseTime": "Total response time", "uptime": "Uptime" }, "histogram": { From 6b4444038c2f6977d397692e25715c6093b4b07f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 30 Jul 2026 11:54:19 -0700 Subject: [PATCH 008/143] add tests --- .../checksRepositoryGroupedChecks.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 server/test/integration/checksRepositoryGroupedChecks.test.ts diff --git a/server/test/integration/checksRepositoryGroupedChecks.test.ts b/server/test/integration/checksRepositoryGroupedChecks.test.ts new file mode 100644 index 000000000..cf5384d0c --- /dev/null +++ b/server/test/integration/checksRepositoryGroupedChecks.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import MongoChecksRepository from "../../src/domain/checks/check.repository.mongo.ts"; +import { CheckModel } from "../../src/domain/checks/check.model.ts"; +import type { UptimeChecksResult } from "../../src/domain/checks/check.type.ts"; +import type { ILogger } from "../../src/utils/logger.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; + +// ── Real-Mongo harness ───────────────────────────────────────────────────────── +// The guarantee under test — every phase average uses the full bucket as its +// denominator — IS the $ifNull-inside-$avg behavior of the groupedChecks facet, so it +// can only be exercised against a live aggregation engine. Reused keep-alive sockets +// produce checks with no timings subdocument at all; without the coercion, $avg would +// skip them and average dns/tcp/tls over fresh-connection checks only. + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + await CheckModel.createCollection(); // timeseries collections must exist before insert +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await CheckModel.deleteMany({}); +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const MONITOR_ID = new mongoose.Types.ObjectId(); +const TEAM_ID = new mongoose.Types.ObjectId(); + +// One shared createdAt puts every seeded check in the same hourly bucket of the "day" +// range, keeping the facet's group stage deterministic. +const BUCKET_TIME = new Date(Date.now() - 10 * 60 * 1000); + +const seedCheck = (overrides: Record = {}) => + CheckModel.create({ + metadata: { monitorId: MONITOR_ID, teamId: TEAM_ID, type: "http" }, + status: true, + responseTime: 100, + createdAt: BUCKET_TIME, + ...overrides, + }); + +const findGrouped = async (repo: MongoChecksRepository) => + (await repo.findByDateRangeAndMonitorId(MONITOR_ID.toString(), "day", { type: "http" })) as UptimeChecksResult; + +describe("MongoChecksRepository groupedChecks facet", () => { + let repo: MongoChecksRepository; + + beforeEach(() => { + repo = new MongoChecksRepository(createMockLogger() as unknown as ILogger); + }); + + it("averages each phase over the full bucket, counting checks without timings as 0", async () => { + await seedCheck({ + responseTime: 100, + timings: { phases: { dns: 10, tcp: 20, tls: 30, request: 8, firstByte: 40, download: 6 } }, + }); + await seedCheck({ responseTime: 200 }); // reused socket: no timings subdocument at all + + const result = await findGrouped(repo); + + expect(result.groupedChecks).toHaveLength(1); + // Plain $avg would skip the timings-less check and report 10/20/30/8/40/6 here. + expect(result.groupedChecks[0]).toMatchObject({ + totalChecks: 2, + avgDns: 5, + avgTcp: 10, + avgTls: 15, + avgRequest: 4, + avgFirstByte: 20, + avgDownload: 3, + avgResponseTime: 150, + }); + }); + + it("yields 0 (not null) for every phase in a bucket of only timings-less checks", async () => { + await seedCheck(); + await seedCheck(); + + const result = await findGrouped(repo); + + expect(result.groupedChecks).toHaveLength(1); + // The client sums these fields for the stacked chart; null would poison the math. + expect(result.groupedChecks[0]).toMatchObject({ + totalChecks: 2, + avgDns: 0, + avgTcp: 0, + avgTls: 0, + avgRequest: 0, + avgFirstByte: 0, + avgDownload: 0, + }); + }); +}); From e5282b90997e6e0284163805a60ce910b622d462 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 30 Jul 2026 12:04:25 -0700 Subject: [PATCH 009/143] opacity -> 0.2 --- client/src/Components/monitors/charts/HistogramDetails.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index a9dea6e0c..f0d501327 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -278,8 +278,8 @@ export const HistogramDetails = ({ dataKey={key} stroke={theme.palette.chart.phases[PHASE_COLOR_KEYS[key]]} fill={`url(#phaseGradient-${key})`} - fillOpacity={dimmed ? 0.05 : undefined} - strokeOpacity={dimmed ? 0.05 : undefined} + fillOpacity={dimmed ? 0.2 : undefined} + strokeOpacity={dimmed ? 0.2 : undefined} /> ); })} From 8a608961e6b2c521bf1c8dedc6f1a98cc85f3a9f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 10:31:39 -0700 Subject: [PATCH 010/143] remove server side normalization --- .../src/api/validation/monitorValidation.ts | 1 - server/src/domain/checks/check.type.ts | 4 - server/src/domain/monitors/monitor.service.ts | 3 +- .../status-pages/status-page.service.ts | 2 - .../domain/status-pages/status-page.type.ts | 3 +- server/src/utils/dataUtils.ts | 30 ------- server/test/unit/utils/dataUtils.test.ts | 78 +------------------ 7 files changed, 3 insertions(+), 118 deletions(-) diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 86db942a5..441809c42 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -25,7 +25,6 @@ export const getMonitorByIdQueryValidation = z.object({ limit: z.coerce.number().optional(), dateRange: z.enum(DateRanges).optional(), numToDisplay: z.coerce.number().optional(), - normalize: booleanCoercion.optional(), continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(), }); diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index d4c47cfb9..d6e60c3e3 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -168,10 +168,6 @@ export interface HasResponseTime { responseTime: number; } -export type NormalizedCheck = T & { - originalResponseTime: number; -}; - export type CheckSnapshot = Omit; export interface HardwareDiskStats { diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 29046f83d..7a8478c5a 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -1,4 +1,3 @@ -import { NormalizeData } from "@/utils/dataUtils.js"; import { type Monitor } from "@/domain/monitors/monitor.type.js"; import type { MonitorType, @@ -364,7 +363,7 @@ export class MonitorService implements IMonitorService { const monitorsWithChecks = monitors.map((monitor: Monitor) => { const rawChecks = monitor.recentChecks ?? []; const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type); - const checks = isSnapshotType ? rawChecks.slice(0, 1) : NormalizeData(rawChecks, 10, 100); + const checks = isSnapshotType ? rawChecks.slice(0, 1) : rawChecks; monitor.recentChecks = checks; return monitor; }); diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 834f87384..7a2a0724b 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -10,7 +10,6 @@ import { import { AppError } from "@/utils/AppError.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { Monitor } from "@/domain/monitors/monitor.type.js"; -import { NormalizeData } from "@/utils/dataUtils.js"; export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; @@ -79,7 +78,6 @@ export class StatusPageService implements IStatusPageService { status: monitor.status, uptimePercentage: monitor.uptimePercentage, recentChecks: monitor.recentChecks, - checks: NormalizeData(monitor.recentChecks, 10, 100), }; if (showURL) { diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index 65f331ad9..e80087b39 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -1,5 +1,4 @@ import type { Monitor } from "@/domain/monitors/monitor.type.js"; -import type { NormalizedCheck, CheckSnapshot } from "@/domain/checks/check.type.js"; export const StatusPageTypes = ["uptime", "infrastructure"] as const; export type StatusPageType = (typeof StatusPageTypes)[number]; @@ -48,7 +47,7 @@ export interface StatusPage { } export type PublicStatusPageMonitor = Pick & - Partial> & { checks: NormalizedCheck[] }; + Partial>; export interface PublicStatusPagePayload { statusPage: StatusPage; diff --git a/server/src/utils/dataUtils.ts b/server/src/utils/dataUtils.ts index 2c15b943e..08e8de740 100755 --- a/server/src/utils/dataUtils.ts +++ b/server/src/utils/dataUtils.ts @@ -1,4 +1,3 @@ -import type { HasResponseTime, NormalizedCheck } from "@/domain/checks/check.type.js"; import { type DateRange } from "@/types/query.js"; export const getDateForRange = (dateRange: DateRange): Date => { @@ -32,32 +31,3 @@ export const getDateFormat = (dateRange: DateRange): string => { }; return formatLookup[dateRange]; }; - -const percentileBy = (arr: T[], percentile: number, value: (item: T) => number): number => { - const sorted = arr.slice().sort((a, b) => value(a) - value(b)); - const index = (percentile / 100) * (sorted.length - 1); - const lower = Math.floor(index); - const upper = lower + 1; - const weight = index % 1; - if (upper >= sorted.length) return value(sorted[lower]!); - return value(sorted[lower]!) * (1 - weight) + value(sorted[upper]!) * weight; -}; - -const rescale = (raw: number, min: number, max: number, rangeMin: number, rangeMax: number): number => { - const scaled = rangeMin + ((raw - min) * (rangeMax - rangeMin)) / (max - min); - return Math.max(rangeMin, Math.min(rangeMax, scaled)); -}; - -export const NormalizeData = (checks: T[], rangeMin: number, rangeMax: number): NormalizedCheck[] => { - const original = (check: T) => (typeof check.responseTime === "number" ? check.responseTime : 0); - if (checks.length <= 1) { - return checks.map((check) => ({ ...check, originalResponseTime: original(check) })); - } - const min = percentileBy(checks, 0, (c) => c.responseTime); - const max = percentileBy(checks, 95, (c) => c.responseTime); - return checks.map((check) => ({ - ...check, - responseTime: rescale(check.responseTime, min, max, rangeMin, rangeMax), - originalResponseTime: original(check), - })); -}; diff --git a/server/test/unit/utils/dataUtils.test.ts b/server/test/unit/utils/dataUtils.test.ts index af9434bab..d89cc0762 100644 --- a/server/test/unit/utils/dataUtils.test.ts +++ b/server/test/unit/utils/dataUtils.test.ts @@ -1,18 +1,7 @@ import { describe, expect, it } from "@jest/globals"; -import { getDateForRange, getDateFormat, NormalizeData } from "../../../src/utils/dataUtils.ts"; +import { getDateForRange, getDateFormat } from "../../../src/utils/dataUtils.ts"; import { DateRanges, type DateRange } from "../../../src/types/query.ts"; -// ── Helpers ────────────────────────────────────────────────────────────────── - -const makeCheck = (responseTime: number, overrides?: Record) => ({ - id: `check-${responseTime}`, - status: true, - responseTime, - ...overrides, -}); - -// ── Tests ──────────────────────────────────────────────────────────────────── - describe("getDateForRange", () => { it.each([ ["recent", 2 * 60 * 60 * 1000], @@ -53,68 +42,3 @@ describe("getDateFormat", () => { } }); }); - -describe("NormalizeData", () => { - // Fixture math: p0 of [100..500] is 100; p95 interpolates between the 4th and 5th - // sorted values at weight 0.8 → 400*0.2 + 500*0.8 = 480. Values rescale into - // [10, 100] via 10 + (rt - 100) * 90 / 380, clamped. - it("rescales responseTime into [rangeMin, rangeMax] anchored to the p0–p95 window", () => { - const checks = [100, 200, 300, 400, 500].map((rt) => makeCheck(rt)); - - const result = NormalizeData(checks, 10, 100); - - expect(result[0]!.responseTime).toBe(10); - expect(result[1]!.responseTime).toBeCloseTo(33.6842, 4); - expect(result[2]!.responseTime).toBeCloseTo(57.3684, 4); - expect(result[3]!.responseTime).toBeCloseTo(81.0526, 4); - }); - - it("clamps values above the p95 anchor to rangeMax", () => { - const checks = [100, 200, 300, 400, 500].map((rt) => makeCheck(rt)); - - const result = NormalizeData(checks, 10, 100); - - expect(result[4]!.responseTime).toBe(100); - }); - - it("preserves the raw value in originalResponseTime and spreads other fields", () => { - const checks = [makeCheck(100, { message: "OK" }), makeCheck(500)]; - - const result = NormalizeData(checks, 10, 100); - - expect(result[0]!.originalResponseTime).toBe(100); - expect(result[1]!.originalResponseTime).toBe(500); - expect(result[0]).toMatchObject({ id: "check-100", status: true, message: "OK" }); - }); - - it("passes a single check through, only adding originalResponseTime", () => { - const result = NormalizeData([makeCheck(250)], 10, 100); - - expect(result).toEqual([{ id: "check-250", status: true, responseTime: 250, originalResponseTime: 250 }]); - }); - - it("returns an empty array for empty input", () => { - expect(NormalizeData([], 10, 100)).toEqual([]); - }); - - it("records originalResponseTime as 0 for a non-numeric responseTime (failed checks)", () => { - const checks = [makeCheck(undefined as unknown as number), makeCheck(100), makeCheck(200)]; - - const result = NormalizeData(checks, 10, 100); - - expect(result[0]!.originalResponseTime).toBe(0); - }); - - // Characterization, not endorsement: when every value is equal the p0–p95 window - // has zero width and the rescale divides by zero. Fixing this is a planned - // follow-up (docs/planning/dataUtils-generic-normalization-refactor.md); this - // test pins the current behavior so the fix is a deliberate, visible change. - it("produces NaN when all values are equal (division by zero — known edge case)", () => { - const result = NormalizeData([makeCheck(50), makeCheck(50), makeCheck(50)], 10, 100); - - result.forEach((check) => { - expect(check.responseTime).toBeNaN(); - expect(check.originalResponseTime).toBe(50); - }); - }); -}); From 08f167c18e9d435063d0a35c5cf0a2aec004d5b4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 11:36:10 -0700 Subject: [PATCH 011/143] cap values at p95 on client --- .../charts/HeatmapResponseTimeTooltip.tsx | 5 +- .../common/charts/HistogramResponseTime.tsx | 9 ++- client/src/Pages/Settings/DummyChart.tsx | 3 +- .../Status/themes/shared/BaseStatusPage.tsx | 2 +- .../Status/themes/shared/ThemedHeatmap.tsx | 3 +- .../Status/themes/shared/ThemedHistogram.tsx | 14 ++-- .../themes/shared/ThemedInfrastructure.tsx | 4 +- client/src/Types/Check.ts | 9 +-- client/src/Utils/DataUtils.ts | 73 ++++++++----------- 9 files changed, 52 insertions(+), 70 deletions(-) diff --git a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx index 8bc651c8c..38ad25b9f 100644 --- a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx +++ b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx @@ -7,6 +7,7 @@ import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { useTranslation } from "react-i18next"; +import prettyMilliseconds from "pretty-ms"; type HeatmapCheck = | CheckSnapshot @@ -58,7 +59,9 @@ export const HeatmapResponseTimeTooltip = ({ {t("common.labels.responseTime")}:{" "} - {check?.originalResponseTime?.toFixed() ?? "N/A"} ms + {check?.responseTime + ? prettyMilliseconds(check.responseTime, { compact: true }) + : "N/A"} Status:{" "} diff --git a/client/src/Components/common/charts/HistogramResponseTime.tsx b/client/src/Components/common/charts/HistogramResponseTime.tsx index d9485318a..eca798011 100644 --- a/client/src/Components/common/charts/HistogramResponseTime.tsx +++ b/client/src/Components/common/charts/HistogramResponseTime.tsx @@ -8,6 +8,7 @@ import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +import { computeBarHeights } from "@/Utils/DataUtils"; interface HistogramResponseTimeProps { checks: CheckSnapshot[]; @@ -29,12 +30,12 @@ const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats return { max: "-", avg: "-" }; } - const validChecks = checks.filter((check) => check.originalResponseTime != null); + const validChecks = checks.filter((check) => check.responseTime != null); if (validChecks.length === 0) { return { max: "-", avg: "-" }; } - const responseTimes = validChecks.map((check) => check.originalResponseTime); + const responseTimes = validChecks.map((check) => check.responseTime); const max = Math.round(Math.max(...responseTimes)); const avg = Math.round( responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length @@ -54,7 +55,7 @@ export const HistogramResponseTime = ({ const { t } = useTranslation(); const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]); - + const barHeights = useMemo(() => computeBarHeights(checks), [checks]); if (!Array.isArray(checks) || checks.length === 0) return null; const data = @@ -111,7 +112,7 @@ export const HistogramResponseTime = ({ > {data.map((check, index) => { const isPlaceholder = (check as any).status === "placeholder"; - const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`; + const heightPct = `${barHeights[index] ?? 0}%`; const barColor = check.status === true ? theme.palette.success.light diff --git a/client/src/Pages/Settings/DummyChart.tsx b/client/src/Pages/Settings/DummyChart.tsx index 47b3e45ea..78a05ed5f 100644 --- a/client/src/Pages/Settings/DummyChart.tsx +++ b/client/src/Pages/Settings/DummyChart.tsx @@ -13,13 +13,12 @@ const generateDummyChecks = (): CheckSnapshot[] => { const checks: CheckSnapshot[] = []; for (let i = 0; i < MAX_RECENT_CHECKS; i++) { const isUp = Math.random() > 0.1; - const responseTime = Math.floor(Math.random() * 80) + 20; + const responseTime = Math.floor(Math.random() * 800) + 20; checks.push({ id: `dummy-${i}`, status: isUp, statusCode: isUp ? 200 : 500, responseTime: responseTime, - originalResponseTime: responseTime, message: "", createdAt: new Date(Date.now() - i * 60000).toISOString(), }); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index 1dfc0bd1b..96beeb173 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -29,7 +29,7 @@ import { import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { formatPercentage } from "@/Utils/FormatUtils"; -type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] }; +type StatusPageMonitor = Monitor; export interface BaseStyles { page: SxProps; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx index fe56155c3..2a7d92b1e 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -8,6 +8,7 @@ import type { CheckSnapshot } from "@/Types/Check"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { useStatusPageTheme } from "../StatusPageThemeProvider"; +import prettyMilliseconds from "pretty-ms"; const CELLS = MAX_RECENT_CHECKS; @@ -60,7 +61,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { fontWeight={600} > {check.status - ? `${check.responseTime} ms` + ? `${prettyMilliseconds(check.responseTime, { compact: true })}` : t("pages.statusPages.monitorsList.chart.downTooltip")} { + const { padded, heights, avg, peak } = useMemo(() => { const source = checks.slice(-CELLS); const out: (CheckSnapshot | null)[] = [ ...source, ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), ]; + const heights = computeBarHeights(source); const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0); const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1; const avgRt = valid.length ? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length) : 0; - return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 }; + return { padded: out, heights, avg: avgRt, peak: maxRt }; }, [checks]); return ( @@ -61,10 +64,7 @@ export const ThemedHistogram = ({ /> ); } - const height = Math.max( - MIN_HEIGHT_PCT, - Math.round((check.responseTime / max) * 100) - ); + const height = heights[i] ?? MIN_HEIGHT_PCT; const tooltipContent = ( {check.status - ? `${check.responseTime} ms` + ? `${prettyMilliseconds(check.responseTime, { compact: true })}` : t("pages.statusPages.monitorsList.chart.downTooltip")} export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => { const { t } = useTranslation(); - const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0]; + const latest = monitor.recentChecks?.[0]; const renderEmpty = () => ( {t("pages.statusPages.monitorsList.noData")} diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index cfd072944..c930eacad 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -250,14 +250,7 @@ export interface ChecksSummary { downChecks: number; } -export type CheckSnapshot = Omit & { - originalResponseTime: number; -}; - +export type CheckSnapshot = Omit; export interface HasResponseTime { responseTime: number; } - -export type NormalizedCheck = T & { - originalResponseTime: number; -}; diff --git a/client/src/Utils/DataUtils.ts b/client/src/Utils/DataUtils.ts index 40bbd1b8b..28fdfb7b5 100644 --- a/client/src/Utils/DataUtils.ts +++ b/client/src/Utils/DataUtils.ts @@ -1,47 +1,3 @@ -const MIN_OUT = 10; -const MAX_OUT = 100; - -export const normalizeResponseTimes = ( - checks: T[], - key: K -): (T & { normalResponseTime: number })[] => { - if (!Array.isArray(checks) || checks.length === 0) - return checks as (T & { - normalResponseTime: number; - })[]; - - if (checks.length === 1) { - return [ - { - ...checks[0], - normalResponseTime: 50, - }, - ]; - } - - const getVal = (check: T): number => { - const v = check[key] as unknown; - return typeof v === "number" && Number.isFinite(v) ? v : 0; - }; - - const { min, max } = checks.reduce( - (acc, check) => { - const v = getVal(check); - if (v > acc.max) acc.max = v; - if (v < acc.min) acc.min = v; - return acc; - }, - { max: -Infinity, min: Infinity } - ); - - const range = max - min || 1; - - return checks.map((check) => ({ - ...check, - normalResponseTime: MIN_OUT + ((getVal(check) - min) * (MAX_OUT - MIN_OUT)) / range, - })); -}; - // Interpolate color between three theme colors over 0-100 range. // 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light) export const getResponseColor = ( @@ -129,3 +85,32 @@ export const getResponseColor = ( } return normalizeToHex(safe.end); }; + +const MIN_HEIGHT_PCT = 8; +const CAP_PERCENTILE = 95; + +const percentile = (sortedValues: number[], p: number): number => { + const index = (p / 100) * (sortedValues.length - 1); + const lower = Math.floor(index); + const upper = lower + 1; + const weight = index % 1; + if (upper >= sortedValues.length) return sortedValues[lower]; + return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight; +}; + +export const computeBarHeights = ( + checks: { responseTime?: number | null }[] +): number[] => { + const values = checks.map((check) => { + return typeof check.responseTime === "number" && Number.isFinite(check.responseTime) + ? Math.max(0, check.responseTime) + : 0; + }); + + const sorted = values.slice().sort((a, b) => a - b); + const cap = percentile(sorted, CAP_PERCENTILE); + if (cap <= 0) return values.map(() => MIN_HEIGHT_PCT); + return values.map((value) => { + return Math.max(MIN_HEIGHT_PCT, Math.min(100, (value / cap) * 100)); + }); +}; From ac251c4d3f3b7d9c619f6027f3c590685832d2ce Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 11:36:20 -0700 Subject: [PATCH 012/143] update tests --- server/test/unit/services/statusPageService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index e0115ab99..149179bd7 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -315,8 +315,8 @@ describe("StatusPageService", () => { status: "up", uptimePercentage: 99.9, recentChecks: [], - checks: [], }); + expect(monitors[0]).not.toHaveProperty("checks"); }); it("orders monitors to match the status page's monitor list", async () => { From 98e0ffc983d84d14c4232a964419c6e065dad14c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 11:41:18 -0700 Subject: [PATCH 013/143] update tooltips --- .../themes/shared/ThemedChartTooltip.tsx | 28 +++++++++++++++++++ .../Status/themes/shared/ThemedHeatmap.tsx | 20 ++----------- .../Status/themes/shared/ThemedHistogram.tsx | 24 ++-------------- 3 files changed, 32 insertions(+), 40 deletions(-) create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx new file mode 100644 index 000000000..c4a528731 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx @@ -0,0 +1,28 @@ +import type { CheckSnapshot } from "@/Types/Check"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import prettyMilliseconds from "pretty-ms"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; + +export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => { + const { timezone } = useStatusPageTheme(); + return ( + + + {check.status + ? `${prettyMilliseconds(check.responseTime, { compact: true })}` + : t("pages.statusPages.monitorsList.chart.downTooltip")} + + + {formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)} + + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx index 2a7d92b1e..5340f4637 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -9,6 +9,7 @@ import { formatDateWithTz } from "@/Utils/TimeUtils"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { useStatusPageTheme } from "../StatusPageThemeProvider"; import prettyMilliseconds from "pretty-ms"; +import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip"; const CELLS = MAX_RECENT_CHECKS; @@ -54,24 +55,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { ); } const kind = classify(check); - const tooltipContent = ( - - - {check.status - ? `${prettyMilliseconds(check.responseTime, { compact: true })}` - : t("pages.statusPages.monitorsList.chart.downTooltip")} - - - {formatDateWithTz(check.createdAt, "ddd, MMMM D, YYYY, HH:mm A", timezone)} - - - ); + const tooltipContent = ; return ( - - {check.status - ? `${prettyMilliseconds(check.responseTime, { compact: true })}` - : t("pages.statusPages.monitorsList.chart.downTooltip")} - - - {formatDateWithTz( - check.createdAt, - "ddd, MMMM D, YYYY, HH:mm A", - timezone - )} - - - ); + const tooltipContent = ; return ( Date: Fri, 31 Jul 2026 11:45:41 -0700 Subject: [PATCH 014/143] remove unused imports --- .../StatusPage/Status/themes/shared/ThemedChartTooltip.tsx | 2 ++ .../Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx | 6 ------ .../StatusPage/Status/themes/shared/ThemedHistogram.tsx | 4 ---- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx index c4a528731..597000b17 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx @@ -4,8 +4,10 @@ import Typography from "@mui/material/Typography"; import prettyMilliseconds from "pretty-ms"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; +import { useTranslation } from "react-i18next"; export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => { + const { t } = useTranslation(); const { timezone } = useStatusPageTheme(); return ( diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx index 5340f4637..375fd1b0e 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -1,14 +1,9 @@ import Tooltip from "@mui/material/Tooltip"; import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import type { CheckSnapshot } from "@/Types/Check"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; -import prettyMilliseconds from "pretty-ms"; import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip"; const CELLS = MAX_RECENT_CHECKS; @@ -31,7 +26,6 @@ const classify = (check: CheckSnapshot): Exclude => { export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { const { t } = useTranslation(); - const { timezone } = useStatusPageTheme(); const source = checks.slice(-CELLS); const padded: (CheckSnapshot | null)[] = [ diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index 81d928db9..3dd7a1f66 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -2,15 +2,12 @@ import { useMemo } from "react"; import Tooltip from "@mui/material/Tooltip"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import type { CheckSnapshot } from "@/Types/Check"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { useStatusPageTheme } from "../StatusPageThemeProvider"; import { computeBarHeights } from "@/Utils/DataUtils"; -import prettyMilliseconds from "pretty-ms"; import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip"; const CELLS = MAX_RECENT_CHECKS; const MIN_HEIGHT_PCT = 6; @@ -36,7 +33,6 @@ export const ThemedHistogram = ({ statsGap = 1, }: Props) => { const { t } = useTranslation(); - const { timezone } = useStatusPageTheme(); const { padded, heights, avg, peak } = useMemo(() => { const source = checks.slice(-CELLS); From e81fdb981050cf7962ef4ab515e9ce2cac3fae61 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 11:50:02 -0700 Subject: [PATCH 015/143] remove unused hook --- .../Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index 3dd7a1f66..c83010e7d 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -6,7 +6,6 @@ import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import type { CheckSnapshot } from "@/Types/Check"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -import { useStatusPageTheme } from "../StatusPageThemeProvider"; import { computeBarHeights } from "@/Utils/DataUtils"; import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip"; const CELLS = MAX_RECENT_CHECKS; From 65c6f567b51187006441badb141c19ab9d83a061 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 11:55:05 -0700 Subject: [PATCH 016/143] add ms formatting util --- client/src/Utils/TimeUtils.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index 5b63de87e..85890ca4f 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -3,6 +3,7 @@ import duration from "dayjs/plugin/duration"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import customParseFormat from "dayjs/plugin/customParseFormat"; +import prettyMilliseconds from "pretty-ms"; dayjs.extend(utc); dayjs.extend(timezone); @@ -77,3 +78,11 @@ export const formatTimestamp = (timestamp: string | number | null): string => { const date = new Date(timestamp); return date.toLocaleString(); }; + +export const formatMs = (ms: number): string => { + return prettyMilliseconds(ms, { + compact: true, + formatSubMilliseconds: true, + secondsDecimalDigits: 0, + }); +}; From 336dbe4de2c9b93536124c69c3cf1ebaaaabbfac Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 12:40:18 -0700 Subject: [PATCH 017/143] use formatMs function to consistently format point times --- .../charts/HeatmapResponseTimeTooltip.tsx | 7 +-- .../common/charts/HistogramResponseTime.tsx | 56 +++++++++---------- .../src/Components/monitors/GeoChecksMap.tsx | 7 +-- .../Components/monitors/MonitorStatBoxes.tsx | 3 +- .../monitors/charts/HistogramDetails.tsx | 18 ++---- .../monitors/charts/HistogramStatus.tsx | 4 +- .../monitors/charts/RadialAvgResponse.tsx | 5 +- client/src/Pages/Logs/components/Stats.tsx | 5 +- .../themes/shared/ThemedChartTooltip.tsx | 5 +- .../Status/themes/shared/ThemedHeatmap.tsx | 3 +- .../Status/themes/shared/ThemedHistogram.tsx | 9 +-- .../Details/Components/GeoChecksTable.tsx | 5 +- client/src/Utils/TimeUtils.ts | 21 ++++++- client/src/locales/ar.json | 8 +-- client/src/locales/ca.json | 8 +-- client/src/locales/cs.json | 8 +-- client/src/locales/de.json | 8 +-- client/src/locales/en.json | 8 +-- client/src/locales/es.json | 8 +-- client/src/locales/fi.json | 8 +-- client/src/locales/fr.json | 8 +-- client/src/locales/it.json | 8 +-- client/src/locales/ja.json | 8 +-- client/src/locales/pl.json | 8 +-- client/src/locales/pt-BR.json | 8 +-- client/src/locales/ru.json | 8 +-- client/src/locales/th.json | 8 +-- client/src/locales/tr.json | 8 +-- client/src/locales/uk.json | 8 +-- client/src/locales/vi.json | 8 +-- client/src/locales/zh-CN.json | 8 +-- client/src/locales/zh-TW.json | 8 +-- 32 files changed, 153 insertions(+), 147 deletions(-) diff --git a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx index 38ad25b9f..66c502f72 100644 --- a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx +++ b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx @@ -1,13 +1,12 @@ import Stack from "@mui/material/Stack"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import type { CheckSnapshot } from "@/Types/Check"; import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { useTranslation } from "react-i18next"; -import prettyMilliseconds from "pretty-ms"; type HeatmapCheck = | CheckSnapshot @@ -59,9 +58,7 @@ export const HeatmapResponseTimeTooltip = ({ {t("common.labels.responseTime")}:{" "} - {check?.responseTime - ? prettyMilliseconds(check.responseTime, { compact: true }) - : "N/A"} + {check?.responseTime ? formatMs(check.responseTime) : "N/A"} Status:{" "} diff --git a/client/src/Components/common/charts/HistogramResponseTime.tsx b/client/src/Components/common/charts/HistogramResponseTime.tsx index eca798011..e2924d29f 100644 --- a/client/src/Components/common/charts/HistogramResponseTime.tsx +++ b/client/src/Components/common/charts/HistogramResponseTime.tsx @@ -9,6 +9,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { computeBarHeights } from "@/Utils/DataUtils"; +import { formatMs } from "@/Utils/TimeUtils"; interface HistogramResponseTimeProps { checks: CheckSnapshot[]; @@ -19,25 +20,25 @@ interface HistogramResponseTimeProps { } interface ResponseTimeStats { - max: number | string; - avg: number | string; + max: string | null; + avg: string | null; } const DEFAULT_HEIGHT = 50; const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => { if (!Array.isArray(checks) || checks.length === 0) { - return { max: "-", avg: "-" }; + return { max: null, avg: null }; } const validChecks = checks.filter((check) => check.responseTime != null); if (validChecks.length === 0) { - return { max: "-", avg: "-" }; + return { max: null, avg: null }; } const responseTimes = validChecks.map((check) => check.responseTime); - const max = Math.round(Math.max(...responseTimes)); - const avg = Math.round( + const max = formatMs(Math.max(...responseTimes)); + const avg = formatMs( responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length ); @@ -69,29 +70,28 @@ export const HistogramResponseTime = ({ const chartHeight = typeof height === "number" ? `${height}px` : height; const gridGap = gap ?? theme.spacing(0.5); - const statsContent = showStats && - (typeof stats.max === "number" || typeof stats.avg === "number") && ( - + - - {t("common.charts.histogram.avg", { value: stats.avg })} - - - {t("common.charts.histogram.max", { value: stats.max })} - - - ); + {t("common.charts.histogram.avg", { value: stats.avg })} + + + {t("common.charts.histogram.max", { value: stats.max })} + + + ); return ( { return status ? theme.palette.success.main : theme.palette.error.main; }; - const formatResponseTime = (timing: number): string => { - return `${timing.toFixed(0)}ms`; - }; - return ( <> {mapPopupStyles} @@ -186,7 +183,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { > Response Time: {" "} - {formatResponseTime(selectedCheck.timings.total)} + {formatMs(selectedCheck.timings.total)} {monitor?.type === "http" && ( diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index f0d501327..a9af6d117 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -14,11 +14,10 @@ import { import type { TooltipProps } from "recharts"; import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; import Typography from "@mui/material/Typography"; -import prettyMilliseconds from "pretty-ms"; import { XTick } from "@/Components/monitors/charts/XTick"; import { useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; -import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; import { useTheme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check"; @@ -56,16 +55,13 @@ const ResponseTimeToolTip = ({ if (!active) return null; const format = tooltipDateFormatLookup(range); - const responseTime = Math.floor(payload[0]?.payload?.avgResponseTime || 0); + const responseTime = payload[0]?.payload?.avgResponseTime ?? 0; return ( {formatDateWithTz(String(label), format, uiTimezone)} {t("common.charts.histogram.responseTime", { - value: prettyMilliseconds(responseTime, { - formatSubMilliseconds: true, - compact: true, - }), + value: formatMs(responseTime), })} @@ -94,7 +90,7 @@ const TimingPhasesToolTip = ({ {formatDateWithTz(bucket.bucketDate, format, uiTimezone)} - {t("common.charts.histogram.total", { value: prettyMilliseconds(total) })} + {t("common.charts.histogram.total", { value: formatMs(total) })} {PHASE_KEYS.map((key) => ( - {t(PHASE_LABEL_KEYS[key])}:{" "} - {prettyMilliseconds(bucket[key], { - formatSubMilliseconds: true, - compact: true, - })} + {t(PHASE_LABEL_KEYS[key])}: {formatMs(bucket[key])} ))} diff --git a/client/src/Components/monitors/charts/HistogramStatus.tsx b/client/src/Components/monitors/charts/HistogramStatus.tsx index 44f6bea97..846510e09 100644 --- a/client/src/Components/monitors/charts/HistogramStatus.tsx +++ b/client/src/Components/monitors/charts/HistogramStatus.tsx @@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography"; import { BaseChart } from "@/Components/design-elements"; import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import { useTranslation } from "react-i18next"; import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts"; import { getResponseTimeColor } from "@/Utils/MonitorUtils"; @@ -100,7 +100,7 @@ export const HistogramStatus = ({ ) : null} {titleText} - {Math.floor(avg)} ms + {formatMs(avg)} ); }; diff --git a/client/src/Components/monitors/charts/RadialAvgResponse.tsx b/client/src/Components/monitors/charts/RadialAvgResponse.tsx index dccb18984..98864b48e 100644 --- a/client/src/Components/monitors/charts/RadialAvgResponse.tsx +++ b/client/src/Components/monitors/charts/RadialAvgResponse.tsx @@ -7,6 +7,7 @@ import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts"; import { useTranslation } from "react-i18next"; import { getResponseTimeColor } from "@/Utils/MonitorUtils"; import { useTheme } from "@mui/material/styles"; +import { formatMs } from "@/Utils/TimeUtils"; export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => { const { t } = useTranslation(); @@ -84,7 +85,9 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {`${avg?.toFixed()}ms`} + > + {formatMs(avg)} + diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx index 9fe04fa9d..5cee4fe87 100644 --- a/client/src/Pages/Logs/components/Stats.tsx +++ b/client/src/Pages/Logs/components/Stats.tsx @@ -6,6 +6,7 @@ import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import type { Diagnostics } from "@/Types/Diagnostics"; +import { formatMs } from "@/Utils/TimeUtils"; interface StatsProps { diagnostics: Diagnostics | null; @@ -18,9 +19,7 @@ export const Stats = ({ diagnostics }: StatsProps) => { const theme = useTheme(); const eventLoopDelay = diagnostics - ? prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, { - millisecondsDecimalDigits: 2, - }) + ? formatMs(diagnostics.eventLoopDelayMs ?? 0) : PLACEHOLDER; const uptime = diagnostics ? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true }) diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx index 597000b17..bc3def0d4 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx @@ -1,8 +1,7 @@ import type { CheckSnapshot } from "@/Types/Check"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import prettyMilliseconds from "pretty-ms"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { useTranslation } from "react-i18next"; @@ -16,7 +15,7 @@ export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => { fontWeight={600} > {check.status - ? `${prettyMilliseconds(check.responseTime, { compact: true })}` + ? formatMs(check.responseTime) : t("pages.statusPages.monitorsList.chart.downTooltip")} { > ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index c83010e7d..a5c0b71f1 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import type { CheckSnapshot } from "@/Types/Check"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { computeBarHeights } from "@/Utils/DataUtils"; +import { formatMs } from "@/Utils/TimeUtils"; import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip"; const CELLS = MAX_RECENT_CHECKS; const MIN_HEIGHT_PCT = 6; @@ -41,11 +42,11 @@ export const ThemedHistogram = ({ ]; const heights = computeBarHeights(source); const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0); - const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1; + const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 0; const avgRt = valid.length - ? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length) + ? valid.reduce((s, c) => s + c.responseTime, 0) / valid.length : 0; - return { padded: out, heights, avg: avgRt, peak: maxRt }; + return { padded: out, heights, avg: formatMs(avgRt), peak: formatMs(maxRt) }; }, [checks]); return ( @@ -71,7 +72,7 @@ export const ThemedHistogram = ({ > ); diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx index 373e09da2..2144c8769 100644 --- a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx @@ -8,10 +8,9 @@ import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; import { useTranslation } from "react-i18next"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; -import prettyMilliseconds from "pretty-ms"; export const GeoChecksTable = ({ geoChecks, @@ -69,7 +68,7 @@ export const GeoChecksTable = ({ content: t("common.table.headers.responseTime"), render: (row) => { if (!row.timings?.total) return "N/A"; - return prettyMilliseconds(row.timings.total, { compact: true }); + return formatMs(row.timings.total); }, }, ]; diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index 85890ca4f..78bdc42a4 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -79,10 +79,27 @@ export const formatTimestamp = (timestamp: string | number | null): string => { return date.toLocaleString(); }; -export const formatMs = (ms: number): string => { - return prettyMilliseconds(ms, { +const DISPLAY_UNITS = [MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_SECOND]; + +// pretty-ms floors seconds-and-above and truncates fractional ms, so values +// must be rounded to their display unit first to land on the nearest whole +// unit (53.6 → "54 ms", 1937 → "2 s", 59500 → "1 m"). +const roundToDisplayUnit = (ms: number): number => { + for (const unit of DISPLAY_UNITS) { + if (ms >= unit) return Math.round(ms / unit) * unit; + } + return ms >= 1 ? Math.round(ms) : ms; +}; + +export const formatMs = (ms: number, hasSpace: boolean = true): string => { + const formatted = prettyMilliseconds(roundToDisplayUnit(ms), { compact: true, formatSubMilliseconds: true, secondsDecimalDigits: 0, }); + + if (hasSpace) { + return formatted.replace(/^(\d+)(\D+)$/, "$1 $2"); + } + return formatted; }; diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 6e315bd9a..55f34650d 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -58,8 +58,8 @@ "uptime": "وقت التشغيل" }, "histogram": { - "avg": "المتوسط: {{value}} مللي ثانية", - "max": "الحد الأقصى: {{value}} مللي ثانية" + "avg": "المتوسط: {{value}}", + "max": "الحد الأقصى: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "وقت الاستجابة" }, "chart": { - "avg": "متوسط {{value}} مللي ثانية", + "avg": "متوسط {{value}}", "downTooltip": "متوقف", "heatmapAria": "خريطة حرارية لزمن الاستجابة", - "max": "أقصى {{value}} مللي ثانية" + "max": "أقصى {{value}}" }, "status": { "breached": "تم تجاوز الحد", diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json index 3c1a29613..7e39eca86 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -58,8 +58,8 @@ "uptime": "Temps en línia" }, "histogram": { - "avg": "Mitjana: {{value}} ms", - "max": "Màx: {{value}} ms" + "avg": "Mitjana: {{value}}", + "max": "Màx: {{value}}" } }, "dialogs": { @@ -1346,8 +1346,8 @@ "initializing": "Inicialitzant" }, "chart": { - "avg": "mitj {{value}}ms", - "max": "màx {{value}}ms", + "avg": "mitj {{value}}", + "max": "màx {{value}}", "downTooltip": "Caigut", "heatmapAria": "Mapa de calor del temps de resposta" }, diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index ae683aefe..d04b9b23b 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -58,8 +58,8 @@ "uptime": "Dostupnost" }, "histogram": { - "avg": "Prům.: {{value}} ms", - "max": "Max: {{value}} ms" + "avg": "Prům.: {{value}}", + "max": "Max: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Doba odezvy" }, "chart": { - "avg": "průměr {{value}}ms", + "avg": "průměr {{value}}", "downTooltip": "Mimo provoz", "heatmapAria": "Tepelná mapa doby odezvy", - "max": "max {{value}}ms" + "max": "max {{value}}" }, "status": { "breached": "Překročen limit", diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 7b2e64945..d43d5d52a 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -58,8 +58,8 @@ "uptime": "Verfügbarkeit" }, "histogram": { - "avg": "Durchschn.: {{value}} ms", - "max": "Max.: {{value}} ms" + "avg": "Durchschn.: {{value}}", + "max": "Max.: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Antwortzeit" }, "chart": { - "avg": "Ø {{value}}ms", + "avg": "Ø {{value}}", "downTooltip": "Ausgefallen", "heatmapAria": "Heatmap der Antwortzeit", - "max": "max. {{value}}ms" + "max": "max. {{value}}" }, "status": { "breached": "Schwellenwert überschritten", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index ce7f74dfb..955e9727f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -74,8 +74,8 @@ "uptime": "Uptime" }, "histogram": { - "avg": "Avg: {{value}} ms", - "max": "Max: {{value}} ms", + "avg": "Avg: {{value}}", + "max": "Max: {{value}}", "responseTime": "Response time: {{value}}", "total": "Total: {{value}}" }, @@ -1455,8 +1455,8 @@ "initializing": "Initializing" }, "chart": { - "avg": "avg {{value}}ms", - "max": "max {{value}}ms", + "avg": "avg {{value}}", + "max": "max {{value}}", "downTooltip": "Down", "heatmapAria": "Response time heatmap" }, diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 1c45ecad4..589399a83 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -58,8 +58,8 @@ "uptime": "Tiempo de actividad" }, "histogram": { - "avg": "Prom: {{value}} ms", - "max": "Máx: {{value}} ms" + "avg": "Prom: {{value}}", + "max": "Máx: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Tiempo de respuesta" }, "chart": { - "avg": "prom {{value}}ms", + "avg": "prom {{value}}", "downTooltip": "Caído", "heatmapAria": "Mapa de calor del tiempo de respuesta", - "max": "máx {{value}}ms" + "max": "máx {{value}}" }, "status": { "breached": "Umbral superado", diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 3ca1de82a..030698d44 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -58,8 +58,8 @@ "uptime": "Käytettävyys" }, "histogram": { - "avg": "Keskim.: {{value}} ms", - "max": "Maks.: {{value}} ms" + "avg": "Keskim.: {{value}}", + "max": "Maks.: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Vasteaika" }, "chart": { - "avg": "ka {{value}}ms", + "avg": "ka {{value}}", "downTooltip": "Alhaalla", "heatmapAria": "Vasteajan lämpökartta", - "max": "max {{value}}ms" + "max": "max {{value}}" }, "status": { "breached": "Kynnys ylittyi", diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 9b3e983a6..1e0a6b096 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -58,8 +58,8 @@ "uptime": "Disponibilité" }, "histogram": { - "avg": "Moy : {{value}} ms", - "max": "Max : {{value}} ms" + "avg": "Moy : {{value}}", + "max": "Max : {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Temps de réponse" }, "chart": { - "avg": "moy {{value}}ms", + "avg": "moy {{value}}", "downTooltip": "Hors service", "heatmapAria": "Carte thermique des temps de réponse", - "max": "max {{value}}ms" + "max": "max {{value}}" }, "status": { "breached": "Seuil dépassé", diff --git a/client/src/locales/it.json b/client/src/locales/it.json index cabeb94ef..3d6d1ea16 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -71,8 +71,8 @@ "uptime": "Uptime" }, "histogram": { - "avg": "Media: {{value}} ms", - "max": "Max: {{value}} ms" + "avg": "Media: {{value}}", + "max": "Max: {{value}}" } }, "dialogs": { @@ -1442,8 +1442,8 @@ "initializing": "Inizializzazione" }, "chart": { - "avg": "media {{value}}ms", - "max": "max {{value}}ms", + "avg": "media {{value}}", + "max": "max {{value}}", "downTooltip": "Non attivo", "heatmapAria": "Heatmap del tempo di risposta" }, diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 031350b20..16e2ec641 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -58,8 +58,8 @@ "uptime": "稼働時間" }, "histogram": { - "avg": "平均: {{value}} ms", - "max": "最大: {{value}} ms" + "avg": "平均: {{value}}", + "max": "最大: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "応答時間" }, "chart": { - "avg": "平均 {{value}}ms", + "avg": "平均 {{value}}", "downTooltip": "ダウン", "heatmapAria": "応答時間のヒートマップ", - "max": "最大 {{value}}ms" + "max": "最大 {{value}}" }, "status": { "breached": "しきい値超過", diff --git a/client/src/locales/pl.json b/client/src/locales/pl.json index 9a74b6cdb..81509ee6f 100644 --- a/client/src/locales/pl.json +++ b/client/src/locales/pl.json @@ -53,8 +53,8 @@ }, "charts": { "histogram": { - "avg": "Średnia: {{value}} ms", - "max": "Maks: {{value}} ms" + "avg": "Średnia: {{value}}", + "max": "Maks: {{value}}" }, "labels": { "averageResponseTime": "Średni czas odpowiedzi", @@ -1542,10 +1542,10 @@ }, "monitorsList": { "chart": { - "avg": "śr. {{value}}ms", + "avg": "śr. {{value}}", "downTooltip": "Niedostępny", "heatmapAria": "Heatmapa czasu odpowiedzi", - "max": "maks. {{value}}ms" + "max": "maks. {{value}}" }, "chartTypeHeatmap": "Heatmapa", "chartTypeHistogram": "Histogram", diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index 0286f2857..c75ead3ef 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -58,8 +58,8 @@ "uptime": "Uptime" }, "histogram": { - "avg": "Média: {{value}} ms", - "max": "Máx: {{value}} ms" + "avg": "Média: {{value}}", + "max": "Máx: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Tempo de resposta" }, "chart": { - "avg": "méd {{value}}ms", + "avg": "méd {{value}}", "downTooltip": "Fora do ar", "heatmapAria": "Mapa de calor do tempo de resposta", - "max": "máx {{value}}ms" + "max": "máx {{value}}" }, "status": { "breached": "Limite excedido", diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 50226037a..cf5e486f7 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -58,8 +58,8 @@ "uptime": "Аптайм" }, "histogram": { - "avg": "Сред: {{value}} мс", - "max": "Макс: {{value}} мс" + "avg": "Сред: {{value}}", + "max": "Макс: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Время ответа" }, "chart": { - "avg": "ср {{value}}мс", + "avg": "ср {{value}}", "downTooltip": "Не работает", "heatmapAria": "Тепловая карта времени отклика", - "max": "макс {{value}}мс" + "max": "макс {{value}}" }, "status": { "breached": "Превышен порог", diff --git a/client/src/locales/th.json b/client/src/locales/th.json index c0889a72f..66d354adc 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -58,8 +58,8 @@ "uptime": "อัปไทม์" }, "histogram": { - "avg": "เฉลี่ย: {{value}} ms", - "max": "สูงสุด: {{value}} ms" + "avg": "เฉลี่ย: {{value}}", + "max": "สูงสุด: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "เวลาตอบสนอง" }, "chart": { - "avg": "เฉลี่ย {{value}}ms", + "avg": "เฉลี่ย {{value}}", "downTooltip": "ไม่พร้อมใช้งาน", "heatmapAria": "แผนที่ความร้อนของเวลาตอบสนอง", - "max": "สูงสุด {{value}}ms" + "max": "สูงสุด {{value}}" }, "status": { "breached": "เกินขีดจำกัด", diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index c82091a83..26f8aace0 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -58,8 +58,8 @@ "uptime": "Çalışma süresi" }, "histogram": { - "avg": "Ort: {{value}} ms", - "max": "Maks: {{value}} ms" + "avg": "Ort: {{value}}", + "max": "Maks: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Yanıt süresi" }, "chart": { - "avg": "ort {{value}}ms", + "avg": "ort {{value}}", "downTooltip": "Çalışmıyor", "heatmapAria": "Yanıt süresi ısı haritası", - "max": "maks {{value}}ms" + "max": "maks {{value}}" }, "status": { "breached": "Eşik aşıldı", diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 6c3b47b66..d1cb983d1 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -58,8 +58,8 @@ "uptime": "Час роботи" }, "histogram": { - "avg": "Сер: {{value}} мс", - "max": "Макс: {{value}} мс" + "avg": "Сер: {{value}}", + "max": "Макс: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Час відповіді" }, "chart": { - "avg": "сер {{value}}мс", + "avg": "сер {{value}}", "downTooltip": "Не працює", "heatmapAria": "Теплова карта часу відгуку", - "max": "макс {{value}}мс" + "max": "макс {{value}}" }, "status": { "breached": "Перевищено поріг", diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 72c933601..81da1c6fc 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -58,8 +58,8 @@ "uptime": "Thời gian hoạt động" }, "histogram": { - "avg": "TB: {{value}} ms", - "max": "Tối đa: {{value}} ms" + "avg": "TB: {{value}}", + "max": "Tối đa: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Thời gian phản hồi" }, "chart": { - "avg": "TB {{value}}ms", + "avg": "TB {{value}}", "downTooltip": "Ngừng hoạt động", "heatmapAria": "Bản đồ nhiệt thời gian phản hồi", - "max": "tối đa {{value}}ms" + "max": "tối đa {{value}}" }, "status": { "breached": "Vượt ngưỡng", diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 4499dd5fb..bef6bebde 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -58,8 +58,8 @@ "uptime": "正常运行时间" }, "histogram": { - "avg": "平均: {{value}} ms", - "max": "最大: {{value}} ms" + "avg": "平均: {{value}}", + "max": "最大: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "响应时间" }, "chart": { - "avg": "平均 {{value}}ms", + "avg": "平均 {{value}}", "downTooltip": "宕机", "heatmapAria": "响应时间热图", - "max": "最大 {{value}}ms" + "max": "最大 {{value}}" }, "status": { "breached": "超出阈值", diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index 7b560ae7d..a15a2a2bb 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -58,8 +58,8 @@ "uptime": "正常運行時間" }, "histogram": { - "avg": "平均:{{value}} ms", - "max": "最大:{{value}} ms" + "avg": "平均:{{value}}", + "max": "最大:{{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "回應時間" }, "chart": { - "avg": "平均 {{value}}ms", + "avg": "平均 {{value}}", "downTooltip": "停機", "heatmapAria": "回應時間熱圖", - "max": "最大 {{value}}ms" + "max": "最大 {{value}}" }, "status": { "breached": "超過閾值", From 19b5976f856f4898948fc5a25ec2033deb69b00e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 12:58:58 -0700 Subject: [PATCH 018/143] extract formatting durations --- .../Components/monitors/MonitorStatBoxes.tsx | 12 +++--------- client/src/Pages/Incidents/utils.ts | 7 ++----- client/src/Pages/Logs/components/Stats.tsx | 6 ++---- client/src/Pages/Logs/components/TableJobs.tsx | 5 ++--- .../Maintenance/MaintenanceWindowTable.tsx | 8 +++----- client/src/Utils/TimeUtils.ts | 17 +++++++++++++++++ 6 files changed, 29 insertions(+), 26 deletions(-) diff --git a/client/src/Components/monitors/MonitorStatBoxes.tsx b/client/src/Components/monitors/MonitorStatBoxes.tsx index 914e76ffb..58176dc04 100644 --- a/client/src/Components/monitors/MonitorStatBoxes.tsx +++ b/client/src/Components/monitors/MonitorStatBoxes.tsx @@ -1,12 +1,11 @@ import Stack from "@mui/material/Stack"; import { StatBox } from "@/Components/design-elements"; -import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material/styles"; import type { MonitorStats, Monitor } from "@/Types/Monitor"; import { getStatusPalette } from "@/Utils/MonitorUtils"; import { useTranslation } from "react-i18next"; -import { formatMs } from "@/Utils/TimeUtils"; +import { formatMs, formatDuration } from "@/Utils/TimeUtils"; interface MonitorStatBoxesProps { monitor?: Monitor; @@ -32,14 +31,9 @@ export const MonitorStatBoxes = ({ const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0; const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0; - const options = { - secondsDecimalDigits: 0, - millisecondsDecimalDigits: 0, - }; + const streakTime = formatDuration(timeSinceLastFailure); - const streakTime = prettyMilliseconds(timeSinceLastFailure, options); - - const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options); + const lastCheckTime = formatDuration(timeSinceLastCheck); const isActive = monitor?.status === "up" || monitor?.status === "paused" || diff --git a/client/src/Pages/Incidents/utils.ts b/client/src/Pages/Incidents/utils.ts index 6ff518a18..3407c40b4 100644 --- a/client/src/Pages/Incidents/utils.ts +++ b/client/src/Pages/Incidents/utils.ts @@ -1,5 +1,5 @@ import type { Incident, IncidentSummaryItem } from "@/Types/Incident"; -import prettyMilliseconds from "pretty-ms"; +import { formatDuration } from "@/Utils/TimeUtils"; type IncidentLike = Pick; @@ -24,8 +24,5 @@ export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryIte return "-"; } - return prettyMilliseconds(durationMs, { - secondsDecimalDigits: 0, - millisecondsDecimalDigits: 0, - }); + return formatDuration(durationMs); }; diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx index 5cee4fe87..884fa9c18 100644 --- a/client/src/Pages/Logs/components/Stats.tsx +++ b/client/src/Pages/Logs/components/Stats.tsx @@ -6,7 +6,7 @@ import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import type { Diagnostics } from "@/Types/Diagnostics"; -import { formatMs } from "@/Utils/TimeUtils"; +import { formatDuration, formatMs } from "@/Utils/TimeUtils"; interface StatsProps { diagnostics: Diagnostics | null; @@ -21,9 +21,7 @@ export const Stats = ({ diagnostics }: StatsProps) => { const eventLoopDelay = diagnostics ? formatMs(diagnostics.eventLoopDelayMs ?? 0) : PLACEHOLDER; - const uptime = diagnostics - ? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true }) - : PLACEHOLDER; + const uptime = diagnostics ? formatDuration(diagnostics.uptimeMs ?? 0) : PLACEHOLDER; const usedHeap = diagnostics ? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0) : PLACEHOLDER; diff --git a/client/src/Pages/Logs/components/TableJobs.tsx b/client/src/Pages/Logs/components/TableJobs.tsx index 26fe62bb3..c8c287a9e 100644 --- a/client/src/Pages/Logs/components/TableJobs.tsx +++ b/client/src/Pages/Logs/components/TableJobs.tsx @@ -1,7 +1,6 @@ import { Table, Pagination } from "@/Components/design-elements"; import { Typography, useTheme } from "@mui/material"; -import prettyMilliseconds from "pretty-ms"; -import { formatTimestamp } from "@/Utils/TimeUtils"; +import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils"; import { useTranslation } from "react-i18next"; import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue"; @@ -84,7 +83,7 @@ export const TableJobs = ({ id: "interval", content: t("common.table.headers.interval"), render: (row) => ( - {prettyMilliseconds(row.monitorInterval ?? 0)} + {formatDuration(row.monitorInterval ?? 0)} ), }, { diff --git a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx index 6dadb5a9c..7cd543007 100644 --- a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx +++ b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx @@ -4,7 +4,6 @@ import { Pagination } from "@/Components/design-elements/Table"; import { ActionsMenu } from "@/Components/actions-menu"; import { DialogInput } from "@/Components/inputs/Dialog"; -import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; import type { Header } from "@/Components/design-elements/Table"; import type { ActionMenuItem } from "@/Components/actions-menu"; @@ -18,6 +17,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice"; import dayjs from "dayjs"; import { useState } from "react"; import { useDelete, usePatch } from "@/Hooks/UseApi"; +import { formatDuration } from "@/Utils/TimeUtils"; interface MaintenanceWindowTableProps { maintenanceWindows: MaintenanceWindow[]; @@ -51,7 +51,7 @@ const getTimeToNextWindow = ( // Window is in the future if (start.isAfter(now)) { - return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true }); + return formatDuration(start.diff(now)); } return "N/A"; @@ -152,9 +152,7 @@ export const MaintenanceWindowTable = ({ id: "repeat", content: t("pages.maintenanceWindow.table.headers.repeat"), render: (row) => - row.repeat === 0 - ? t("common.labels.na") - : prettyMilliseconds(row.repeat, { verbose: true }), + row.repeat === 0 ? t("common.labels.na") : formatDuration(row.repeat), }, { id: "actions", diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index 78bdc42a4..4112967ab 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -103,3 +103,20 @@ export const formatMs = (ms: number, hasSpace: boolean = true): string => { } return formatted; }; + +export const formatDuration = (ms: number, verbose: boolean = false, hasSpace = true) => { + if (verbose) { + return prettyMilliseconds(ms, { verbose: true }); + } + + const formatted = prettyMilliseconds(ms, { + unitCount: 2, + secondsDecimalDigits: 0, + millisecondsDecimalDigits: 0, + }); + + if (hasSpace) { + return formatted.replace(/^(\d+)(\D+)$/, "$1 $2"); + } + return formatted; +}; From e3b0b33b15365b5d1a06fd16a269a5ea5b8cd660 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 13:00:58 -0700 Subject: [PATCH 019/143] replace duration in monitorstatus, remove dead import --- client/src/Components/design-elements/MonitorStatus.tsx | 6 ++---- client/src/Pages/Logs/components/Stats.tsx | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/client/src/Components/design-elements/MonitorStatus.tsx b/client/src/Components/design-elements/MonitorStatus.tsx index 2d53470f0..f353328c1 100644 --- a/client/src/Components/design-elements/MonitorStatus.tsx +++ b/client/src/Components/design-elements/MonitorStatus.tsx @@ -4,10 +4,10 @@ import Typography from "@mui/material/Typography"; import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements"; import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils"; import { useTheme } from "@mui/material/styles"; -import prettyMilliseconds from "pretty-ms"; import { typographyLevels } from "@/Utils/Theme/Palette"; import useMediaQuery from "@mui/material/useMediaQuery"; import { LAYOUT } from "@/Utils/Theme/constants"; +import { formatDuration } from "@/Utils/TimeUtils"; export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); @@ -51,9 +51,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { {!isSmall && ( <> - - Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })} - + Checking every {formatDuration(monitor?.interval)} )} diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx index 884fa9c18..be67e72d4 100644 --- a/client/src/Pages/Logs/components/Stats.tsx +++ b/client/src/Pages/Logs/components/Stats.tsx @@ -2,7 +2,6 @@ import Box from "@mui/material/Box"; import { StatBox } from "@/Components/design-elements"; import prettyBytes from "pretty-bytes"; -import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import type { Diagnostics } from "@/Types/Diagnostics"; From e0122d376146146b46b0131a1145faec80dc6081 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 13:04:10 -0700 Subject: [PATCH 020/143] hoist props --- client/src/Pages/Logs/components/Stats.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx index be67e72d4..d76aa7443 100644 --- a/client/src/Pages/Logs/components/Stats.tsx +++ b/client/src/Pages/Logs/components/Stats.tsx @@ -33,10 +33,10 @@ export const Stats = ({ diagnostics }: StatsProps) => { return ( *": { width: "100% !important" }, }} > From d4782a97a30ab52d41f9086a2e2bf54a7e9d05a2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 13:05:10 -0700 Subject: [PATCH 021/143] add missing verbose flag --- client/src/Components/design-elements/MonitorStatus.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/Components/design-elements/MonitorStatus.tsx b/client/src/Components/design-elements/MonitorStatus.tsx index f353328c1..cc0f8df5b 100644 --- a/client/src/Components/design-elements/MonitorStatus.tsx +++ b/client/src/Components/design-elements/MonitorStatus.tsx @@ -51,7 +51,9 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { {!isSmall && ( <> - Checking every {formatDuration(monitor?.interval)} + + Checking every {formatDuration(monitor?.interval, true)} + )} From 750fc9e238e5ec38d49930a70e8b8b2ee8465aeb Mon Sep 17 00:00:00 2001 From: Marcos Paulo Diniz Date: Sun, 2 Aug 2026 01:59:11 -0300 Subject: [PATCH 022/143] feat(notifications): add Rocket.Chat provider Refs #3821 --- .../api/validation/notificationValidation.ts | 8 + server/src/config/services.shared.ts | 3 + .../notifications/notification.model.ts | 1 + .../domain/notifications/notification.type.ts | 1 + .../notifications/providers/rocketChat.ts | 88 +++++++++++ .../notifications/notificationType.test.ts | 15 ++ .../notifications/rocketChatProvider.test.ts | 143 ++++++++++++++++++ .../validation/notificationValidation.test.ts | 47 ++++++ 8 files changed, 306 insertions(+) create mode 100644 server/src/domain/notifications/providers/rocketChat.ts create mode 100644 server/test/unit/notifications/notificationType.test.ts create mode 100644 server/test/unit/providers/notifications/rocketChatProvider.test.ts create mode 100644 server/test/unit/validation/notificationValidation.test.ts diff --git a/server/src/api/validation/notificationValidation.ts b/server/src/api/validation/notificationValidation.ts index bd3cdd6c2..fea35e89d 100644 --- a/server/src/api/validation/notificationValidation.ts +++ b/server/src/api/validation/notificationValidation.ts @@ -23,6 +23,14 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), }), + // Rocket.Chat notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("rocket_chat"), + address: z + .url({ message: "Please enter a valid Rocket.Chat webhook URL" }) + .refine((url) => /^https?:\/\//i.test(url), { message: "Rocket.Chat webhook URL must use HTTP or HTTPS" }), + }), // Slack notification z.object({ notificationName: z.string().min(1, "Notification name is required"), diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts index 0f7813d8a..b278f6eab 100644 --- a/server/src/config/services.shared.ts +++ b/server/src/config/services.shared.ts @@ -23,6 +23,7 @@ import { ILogger } from "@/utils/logger.js"; // Notification providers import type { NotificationProviderRegistry } from "@/domain/notifications/notification.service.js"; import { WebhookProvider } from "@/domain/notifications/providers/webhook.js"; +import { RocketChatProvider } from "@/domain/notifications/providers/rocketChat.js"; import { SlackProvider } from "@/domain/notifications/providers/slack.js"; import { EmailProvider } from "@/domain/notifications/providers/email.js"; import { DiscordProvider } from "@/domain/notifications/providers/discord.js"; @@ -157,6 +158,7 @@ export const buildShared = async ({ }); const webhookProvider = new WebhookProvider(logger); + const rocketChatProvider = new RocketChatProvider(logger); const slackProvider = new SlackProvider(logger); const emailProvider = new EmailProvider(emailService, logger); const discordProvider = new DiscordProvider(logger); @@ -170,6 +172,7 @@ export const buildShared = async ({ const notificationProviders: NotificationProviderRegistry = { webhook: webhookProvider, + rocket_chat: rocketChatProvider, email: emailProvider, slack: slackProvider, discord: discordProvider, diff --git a/server/src/domain/notifications/notification.model.ts b/server/src/domain/notifications/notification.model.ts index 797b3e768..518a4fc3f 100755 --- a/server/src/domain/notifications/notification.model.ts +++ b/server/src/domain/notifications/notification.model.ts @@ -30,6 +30,7 @@ const NotificationSchema = new Schema( "slack", "discord", "webhook", + "rocket_chat", "pager_duty", "matrix", "teams", diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts index 8ae617a88..7c4c9f75d 100644 --- a/server/src/domain/notifications/notification.type.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -3,6 +3,7 @@ export const NotificationChannels = [ "slack", "discord", "webhook", + "rocket_chat", "pager_duty", "matrix", "teams", diff --git a/server/src/domain/notifications/providers/rocketChat.ts b/server/src/domain/notifications/providers/rocketChat.ts new file mode 100644 index 000000000..aa6852d74 --- /dev/null +++ b/server/src/domain/notifications/providers/rocketChat.ts @@ -0,0 +1,88 @@ +const SERVICE_NAME = "RocketChatProvider"; +import type { Notification, NotificationMessage } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; +import got from "got"; + +export class RocketChatProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: { text: getTestMessage() }, + headers: { + "Content-Type": "application/json", + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Rocket.Chat test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address) { + return false; + } + + try { + await got.post(notification.address, { + json: { text: this.buildText(message) }, + headers: { + "Content-Type": "application/json", + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "Rocket.Chat notification failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + private buildText(message: NotificationMessage): string { + const lines = [ + message.content.title, + message.content.summary, + "", + `Monitor: ${message.monitor.name}`, + `Type: ${message.monitor.type}`, + `Status: ${message.monitor.status}`, + `URL: ${message.monitor.url}`, + ]; + + if (message.content.details?.length) { + lines.push("", "Details:", ...message.content.details.map((detail) => `- ${detail}`)); + } + if (message.content.thresholds?.length) { + lines.push( + "", + "Thresholds:", + ...message.content.thresholds.map( + (threshold) => `- ${threshold.metric.toUpperCase()}: ${threshold.formattedValue} (threshold: ${threshold.threshold}${threshold.unit})` + ) + ); + } + + lines.push("", `Incident: ${message.clientHost}/infrastructure/${message.monitor.id}`); + + return lines.join("\n"); + } +} diff --git a/server/test/unit/notifications/notificationType.test.ts b/server/test/unit/notifications/notificationType.test.ts new file mode 100644 index 000000000..0029fbd46 --- /dev/null +++ b/server/test/unit/notifications/notificationType.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "@jest/globals"; +import { NotificationModel } from "@/domain/notifications/notification.model.js"; +import { NotificationChannels } from "@/domain/notifications/notification.type.js"; + +describe("NotificationChannels", () => { + it("includes Rocket.Chat", () => { + expect(NotificationChannels).toContain("rocket_chat"); + }); + + it("allows Rocket.Chat to be persisted", () => { + const typePath = NotificationModel.schema.path("type") as unknown as { enumValues: string[] }; + + expect(typePath.enumValues).toContain("rocket_chat"); + }); +}); diff --git a/server/test/unit/providers/notifications/rocketChatProvider.test.ts b/server/test/unit/providers/notifications/rocketChatProvider.test.ts new file mode 100644 index 000000000..8ceb60bee --- /dev/null +++ b/server/test/unit/providers/notifications/rocketChatProvider.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeMessage, makeMessageWithThresholds, makeNotification } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; + +const mockGotPost = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); + +const { RocketChatProvider } = await import("../../../../src/domain/notifications/providers/rocketChat.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + const provider = new RocketChatProvider(logger as any); + return { provider, logger }; +}; + +testNotificationProviderContract("RocketChatProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification({ type: "rocket_chat" }), +}); + +describe("RocketChatProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + it("sends a text-only test alert", async () => { + const { provider } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" })); + + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ + json: { text: "This is a test notification from Checkmate" }, + timeout: { request: 30000 }, + retry: { limit: 0 }, + }) + ); + }); + + it("returns false when the test webhook URL is missing", async () => { + const { provider } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat", address: "" })); + + expect(result).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + }); + + it("returns false and logs when test delivery fails", async () => { + mockGotPost.mockRejectedValue(new Error("network failure")); + const { provider, logger } = createProvider(); + + const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" })); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Rocket.Chat test alert failed", + service: "RocketChatProvider", + method: "sendTestAlert", + }) + ); + }); + + it("sends monitor details as a text-only message", async () => { + const { provider } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); + + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ + timeout: { request: 30000 }, + retry: { limit: 0 }, + json: { + text: [ + "Monitor Down: Test Monitor", + 'Monitor "Test Monitor" is currently down.', + "", + "Monitor: Test Monitor", + "Type: http", + "Status: down", + "URL: https://example.com", + "", + "Details:", + "- URL: https://example.com", + "- Status: Down", + "", + "Incident: https://app.example.com/infrastructure/mon-1", + ].join("\n"), + }, + }) + ); + }); + + it("returns false when the message webhook URL is missing", async () => { + const { provider } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat", address: "" }), makeMessage()); + + expect(result).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + }); + + it("includes threshold breaches in the text", async () => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithThresholds()); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.text).toContain("Thresholds:\n- CPU: 90.0% (threshold: 80%)\n- MEMORY: 85.0% (threshold: 70%)"); + }); + + it("includes a monitor-scoped incident link without synthetic incident data", async () => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.text).toContain("Incident: https://app.example.com/infrastructure/mon-1"); + }); + + it("returns false and logs when message delivery fails", async () => { + mockGotPost.mockRejectedValue(new Error("network failure")); + const { provider, logger } = createProvider(); + + const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Rocket.Chat notification failed", + service: "RocketChatProvider", + method: "sendMessage", + }) + ); + }); +}); diff --git a/server/test/unit/validation/notificationValidation.test.ts b/server/test/unit/validation/notificationValidation.test.ts new file mode 100644 index 000000000..5cb2c1d59 --- /dev/null +++ b/server/test/unit/validation/notificationValidation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "@jest/globals"; +import { createNotificationBodyValidation } from "@/api/validation/notificationValidation.js"; + +describe("notification validation", () => { + it("accepts a Rocket.Chat incoming webhook", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address: "https://chat.example.com/hooks/integration-id/token", + }); + + expect(result.success).toBe(true); + }); + + it("accepts a self-hosted Rocket.Chat webhook over HTTP", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Local Rocket.Chat alerts", + type: "rocket_chat", + address: "http://localhost:3000/hooks/integration-id/token", + }); + + expect(result.success).toBe(true); + }); + + it("rejects an invalid Rocket.Chat incoming webhook URL", () => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address: "not-a-url", + }); + + expect(result.success).toBe(false); + }); + + it.each(["ftp://chat.example.com/hooks/integration-id/token", "mailto:admin@example.com", "data:text/plain,hello", "javascript:alert(1)"])( + "rejects a Rocket.Chat webhook using an unsupported protocol: %s", + (address) => { + const result = createNotificationBodyValidation.safeParse({ + notificationName: "Rocket.Chat alerts", + type: "rocket_chat", + address, + }); + + expect(result.success).toBe(false); + } + ); +}); From 5774cb0e06dd69f52d47086041a589e9a8a89401 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Diniz Date: Sun, 2 Aug 2026 01:59:34 -0300 Subject: [PATCH 023/143] feat(client): expose Rocket.Chat channel Refs #3821 --- client/src/Hooks/useNotificationForm.ts | 7 +++++++ .../Notifications/components/NotificationsTable.tsx | 8 +++++++- client/src/Pages/Notifications/create/index.tsx | 6 +++++- client/src/Types/Notification.ts | 1 + client/src/Validation/notifications.ts | 13 +++++++++++++ client/src/locales/en.json | 1 + 6 files changed, 34 insertions(+), 2 deletions(-) diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 0051ddafd..31ab30150 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -46,6 +46,13 @@ function buildDefaults(data: Notification | null): NotificationFormData { address: data.address || "", }; } + if (data?.type === "rocket_chat") { + return { + type: "rocket_chat", + notificationName: data.notificationName || "", + address: data.address || "", + }; + } if (data?.type === "pager_duty") { return { type: "pager_duty", diff --git a/client/src/Pages/Notifications/components/NotificationsTable.tsx b/client/src/Pages/Notifications/components/NotificationsTable.tsx index a7a8170f6..0b41c3be0 100644 --- a/client/src/Pages/Notifications/components/NotificationsTable.tsx +++ b/client/src/Pages/Notifications/components/NotificationsTable.tsx @@ -65,7 +65,13 @@ export const NotificationsTable = ({ id: "type", content: t("common.table.headers.type"), render: (row) => { - return {row?.type}; + return ( + + {row?.type === "rocket_chat" + ? t("pages.notifications.form.type.rocketChat") + : row?.type} + + ); }, }, { diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 84d264616..955add20f 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -139,7 +139,11 @@ const NotificationsCreatePage = () => { key={type} value={type} > - {type} + + {type === "rocket_chat" + ? t("pages.notifications.form.type.rocketChat") + : type} + ))} diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts index 0e389f9e2..a118c4a3d 100644 --- a/client/src/Types/Notification.ts +++ b/client/src/Types/Notification.ts @@ -3,6 +3,7 @@ export const NotificationChannels = [ "slack", "discord", "webhook", + "rocket_chat", "pager_duty", "matrix", "teams", diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index 6526fa0dd..3058d3f66 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -30,6 +30,18 @@ const webhookSchema = baseSchema.extend({ address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"), }); +const rocketChatSchema = baseSchema.extend({ + type: z.literal("rocket_chat"), + address: z + .string() + .min(1, "Webhook URL is required") + .url("Please enter a valid URL") + .refine( + (url) => /^https?:\/\//i.test(url), + "Rocket.Chat webhook URL must use HTTP or HTTPS" + ), +}); + const pagerDutySchema = baseSchema.extend({ type: z.literal("pager_duty"), address: z.string().min(1, "Integration key is required"), @@ -81,6 +93,7 @@ export const notificationSchema = z.discriminatedUnion("type", [ slackSchema, discordSchema, webhookSchema, + rocketChatSchema, pagerDutySchema, matrixSchema, teamsSchema, diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 955e9727f..87984495f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1164,6 +1164,7 @@ "type": { "description": "Select the type of notification channel to create.", "optionType": "Type", + "rocketChat": "Rocket.Chat", "title": "Channel type" }, "telegram": { From 2e19b85a2d57d84fc39e0df33bac59726452f9d9 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Diniz Date: Sun, 2 Aug 2026 01:59:58 -0300 Subject: [PATCH 024/143] docs(notifications): document Rocket.Chat Refs #3821 --- README.md | 2 +- server/openapi.json | 29 ++++++++++++++++++++++++++- server/openapi/index.ts | 3 ++- server/openapi/routes/notification.ts | 8 ++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bcbd557d4..139df2372 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! - Selective disk monitoring with mountpoint selection - Incidents at a glance - Status pages with 4 beautiful themes -- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications +- E-mail, Webhooks, Discord, Slack, PagerDuty, Matrix, Rocket.Chat, Microsoft Teams, Telegram, Pushover, Twilio (SMS) notifications - Scheduled maintenance - JSON query monitoring - Multi-language support for Arabic, Chinese (Simplified), Chinese (Traditional, Taiwan), Czech, English, Finnish, French, German, Japanese, Portuguese (Brazil), Russian, Spanish, Thai, Turkish, Ukrainian, and Vietnamese diff --git a/server/openapi.json b/server/openapi.json index 28a22f394..627354c55 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -34,7 +34,7 @@ }, { "name": "notifications", - "description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts." + "description": "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Rocket.Chat, Teams, Telegram, Pushover, Twilio) and test alerts." }, { "name": "maintenance-window", @@ -879,6 +879,9 @@ { "$ref": "#/components/schemas/WebhookNotification" }, + { + "$ref": "#/components/schemas/RocketChatNotification" + }, { "$ref": "#/components/schemas/SlackNotification" }, @@ -912,6 +915,7 @@ "mapping": { "email": "#/components/schemas/EmailNotification", "webhook": "#/components/schemas/WebhookNotification", + "rocket_chat": "#/components/schemas/RocketChatNotification", "slack": "#/components/schemas/SlackNotification", "discord": "#/components/schemas/DiscordNotification", "pager_duty": "#/components/schemas/PagerDutyNotification", @@ -1036,6 +1040,29 @@ "address": "https://example.com/hooks/checkmate" } }, + "RocketChatNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["rocket_chat"] + }, + "address": { + "type": "string", + "format": "uri" + } + }, + "required": ["notificationName", "type", "address"], + "example": { + "notificationName": "Rocket.Chat alerts", + "type": "rocket_chat", + "address": "https://chat.example.com/hooks/integration-id/token" + } + }, "SlackNotification": { "type": "object", "properties": { diff --git a/server/openapi/index.ts b/server/openapi/index.ts index ef3e0b1d0..c96cec233 100644 --- a/server/openapi/index.ts +++ b/server/openapi/index.ts @@ -39,7 +39,8 @@ export function getOpenApiSpec(): JsonObject { { name: "incidents", description: "Downtime incidents derived from monitor state changes, including filtering and CSV export." }, { name: "notifications", - description: "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Teams, Telegram, Pushover, Twilio) and test alerts.", + description: + "Notification channels (email, webhook, Slack, Discord, PagerDuty, Matrix, Rocket.Chat, Teams, Telegram, Pushover, Twilio) and test alerts.", }, { name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." }, { name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." }, diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts index f1c70ca6d..7da45adfb 100644 --- a/server/openapi/routes/notification.ts +++ b/server/openapi/routes/notification.ts @@ -23,6 +23,14 @@ const notificationVariantMeta: Record Date: Tue, 4 Aug 2026 11:27:32 -0700 Subject: [PATCH 025/143] slim check snapshot model/types --- client/src/Types/Check.ts | 39 +++++- .../src/db/migration/0010_slimRecentChecks.ts | 128 ++++++++++++++++++ server/src/db/migration/index.ts | 2 + .../checks/check.repository.interface.ts | 2 - .../domain/checks/check.repository.mongo.ts | 28 ---- server/src/domain/checks/check.snapshot.ts | 85 ++++++++++++ server/src/domain/checks/check.type.ts | 30 +++- server/src/domain/monitors/monitor.model.ts | 99 ++------------ .../monitors/monitor.repository.interface.ts | 1 - .../monitors/monitor.repository.mongo.ts | 91 +------------ server/src/service/statusService.ts | 29 +--- .../helpers/InMemoryMonitorsRepository.ts | 4 - 12 files changed, 295 insertions(+), 243 deletions(-) create mode 100644 server/src/db/migration/0010_slimRecentChecks.ts create mode 100644 server/src/domain/checks/check.snapshot.ts diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index c930eacad..e32762d95 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -250,7 +250,44 @@ export interface ChecksSummary { downChecks: number; } -export type CheckSnapshot = Omit; +export type SnapshotCpuInfo = Pick< + CheckCpuInfo, + | "physical_core" + | "logical_core" + | "frequency" + | "current_frequency" + | "temperature" + | "usage_percent" +>; +export type SnapshotMemoryInfo = Pick< + CheckMemoryInfo, + "total_bytes" | "used_bytes" | "usage_percent" +>; +export type SnapshotDiskInfo = Pick< + CheckDiskInfo, + "device" | "total_bytes" | "used_bytes" | "usage_percent" +>; +export type SnapshotHostInfo = Pick; + +export type CheckSnapshot = Pick< + Check, + | "id" + | "status" + | "responseTime" + | "statusCode" + | "message" + | "createdAt" + | "accessibility" + | "bestPractices" + | "seo" + | "performance" + | "audits" +> & { + cpu?: SnapshotCpuInfo; + memory?: SnapshotMemoryInfo; + disk?: SnapshotDiskInfo[]; + host?: SnapshotHostInfo; +}; export interface HasResponseTime { responseTime: number; } diff --git a/server/src/db/migration/0010_slimRecentChecks.ts b/server/src/db/migration/0010_slimRecentChecks.ts new file mode 100644 index 000000000..2b8f8e661 --- /dev/null +++ b/server/src/db/migration/0010_slimRecentChecks.ts @@ -0,0 +1,128 @@ +import { MonitorModel } from "../../domain/monitors/monitor.model.js"; +import type { ILogger } from "@/utils/logger.js"; + +const KEEP_KEYS = [ + "id", + "status", + "responseTime", + "statusCode", + "message", + "createdAt", + "cpu", + "memory", + "disk", + "host", + "accessibility", + "bestPractices", + "seo", + "performance", + "audits", +]; + +/** + * Slim stored recentChecks snapshots down to the fields the slim CheckSnapshot type keeps. + * The schema change only affects new writes; paused monitors never rotate their snapshots out. + * Idempotent: re-running on already-slim snapshots is a no-op re-pick. + */ +export async function slimRecentChecks(logger: ILogger): Promise { + const SERVICE_NAME = "Migration:SlimRecentChecks"; + try { + logger.info({ service: SERVICE_NAME, message: "Slimming recentChecks snapshots" }); + + const result = await MonitorModel.updateMany({ "recentChecks.0": { $exists: true } }, [ + { + $set: { + recentChecks: { + $map: { + input: "$recentChecks", + as: "snap", + in: { + $mergeObjects: [ + // 1) keep whitelisted top-level keys only (drops timings/errors/capture/net) + { + $arrayToObject: { + $filter: { + input: { $objectToArray: "$$snap" }, + as: "kv", + cond: { $in: ["$$kv.k", KEEP_KEYS] }, + }, + }, + }, + // 2) where a nested group exists, overwrite it with its slim pick + { + $cond: [ + { $eq: [{ $type: "$$snap.cpu" }, "object"] }, + { + cpu: { + physical_core: "$$snap.cpu.physical_core", + logical_core: "$$snap.cpu.logical_core", + frequency: "$$snap.cpu.frequency", + current_frequency: "$$snap.cpu.current_frequency", + temperature: "$$snap.cpu.temperature", + usage_percent: "$$snap.cpu.usage_percent", + }, + }, + {}, + ], + }, + { + $cond: [ + { $eq: [{ $type: "$$snap.memory" }, "object"] }, + { + memory: { + total_bytes: "$$snap.memory.total_bytes", + used_bytes: "$$snap.memory.used_bytes", + usage_percent: "$$snap.memory.usage_percent", + }, + }, + {}, + ], + }, + { + $cond: [ + { $eq: [{ $type: "$$snap.disk" }, "array"] }, + { + disk: { + $map: { + input: "$$snap.disk", + as: "d", + in: { + device: "$$d.device", + total_bytes: "$$d.total_bytes", + used_bytes: "$$d.used_bytes", + usage_percent: "$$d.usage_percent", + }, + }, + }, + }, + {}, + ], + }, + { + $cond: [ + { $eq: [{ $type: "$$snap.host" }, "object"] }, + { + host: { + os: "$$snap.host.os", + platform: "$$snap.host.platform", + pretty_name: "$$snap.host.pretty_name", + }, + }, + {}, + ], + }, + ], + }, + }, + }, + }, + }, + ]); + + logger.info({ service: SERVICE_NAME, message: `Slimmed recentChecks on ${result.modifiedCount} monitors` }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error slimming recentChecks: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index f59af32b7..24c330ef5 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -8,6 +8,7 @@ import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplic import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js"; import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js"; import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js"; +import { slimRecentChecks } from "./0010_slimRecentChecks.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -25,6 +26,7 @@ const migrations: MigrationEntry[] = [ { name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray }, { name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt }, { name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings }, + { name: "0010_slimRecentChecks", execute: slimRecentChecks }, ]; const runMigrations = async (logger: ILogger) => { diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index e6b7c6368..6889d8d15 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -7,7 +7,6 @@ import type { UptimeChecksResult, } from "@/domain/checks/check.type.js"; import type { MonitorType } from "@/domain/monitors/monitor.type.js"; -import type { LatestChecksMap } from "@/domain/checks/check.repository.mongo.js"; import { CheckFilter, DateRange } from "@/types/query.js"; export interface IChecksRepository { @@ -34,7 +33,6 @@ export interface IChecksRepository { teamId: string, filter?: CheckFilter ): Promise; - findLatestByMonitorIds(monitorIds: string[], options?: { limitPerMonitor?: number }): Promise; findByDateRangeAndMonitorId( monitorId: string, dateRange: DateRange, diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index fcc967828..78f0b9344 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -27,8 +27,6 @@ import { NETWORK_ERROR } from "@/types/network.js"; const SERVICE_NAME = "StatusService"; -export type LatestChecksMap = Record; - class MongoChecksRepository implements IChecksRepository { static SERVICE_NAME = SERVICE_NAME; @@ -288,32 +286,6 @@ class MongoChecksRepository implements IChecksRepository { return { checksCount, checks: this.mapDocuments(checks) }; }; - findLatestByMonitorIds = async (monitorIds: string[], options?: { limitPerMonitor?: number }): Promise => { - if (monitorIds.length === 0) { - return {}; - } - const limitPerMonitor = options?.limitPerMonitor ?? 25; - const dateFilter = new Date(Date.now() - 24 * 60 * 60 * 1000); - const results = await Promise.all( - monitorIds.map(async (monitorId) => { - const docs = await CheckModel.find({ - "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), - createdAt: { $gte: dateFilter }, - }) - .sort({ createdAt: -1 }) - .limit(limitPerMonitor) - .lean(); - return { monitorId, docs }; - }) - ); - - const mapped = results.reduce((acc, { monitorId, docs }) => { - acc[monitorId] = docs.map((doc: CheckDocument) => this.toEntity(doc)); - return acc; - }, {}); - return mapped; - }; - findByDateRangeAndMonitorId = async (monitorId: string, dateRange: DateRange, options?: { type?: MonitorType }) => { const monitorObjectId = new mongoose.Types.ObjectId(monitorId); const start = getDateForRange(dateRange); diff --git a/server/src/domain/checks/check.snapshot.ts b/server/src/domain/checks/check.snapshot.ts new file mode 100644 index 000000000..3ee208d04 --- /dev/null +++ b/server/src/domain/checks/check.snapshot.ts @@ -0,0 +1,85 @@ +import type { + Check, + CheckAudits, + CheckCpuInfo, + CheckDiskInfo, + CheckHostInfo, + CheckMemoryInfo, + CheckSnapshot, + SnapshotCpuInfo, + SnapshotDiskInfo, + SnapshotHostInfo, + SnapshotMemoryInfo, +} from "@/domain/checks/check.type.js"; +import { toDateString } from "@/utils/mongoMappers.js"; + +export type CheckSnapshotSource = Pick< + Check, + "id" | "status" | "responseTime" | "statusCode" | "message" | "accessibility" | "bestPractices" | "seo" | "performance" +> & { + createdAt: string | Date; + cpu?: CheckCpuInfo; + memory?: CheckMemoryInfo; + disk?: CheckDiskInfo[]; + host?: CheckHostInfo; + audits?: CheckAudits; +}; + +const mapCpu = (cpu?: CheckCpuInfo): SnapshotCpuInfo | undefined => + cpu && { + physical_core: cpu.physical_core, + logical_core: cpu.logical_core, + frequency: cpu.frequency, + current_frequency: cpu.current_frequency, + temperature: cpu.temperature, + usage_percent: cpu.usage_percent, + }; + +const mapMemory = (memory?: CheckMemoryInfo): SnapshotMemoryInfo | undefined => + memory && { + total_bytes: memory.total_bytes, + used_bytes: memory.used_bytes, + usage_percent: memory.usage_percent, + }; + +const mapDisk = (disk?: CheckDiskInfo[]): SnapshotDiskInfo[] | undefined => + disk?.map((entry) => ({ + device: entry.device, + total_bytes: entry.total_bytes, + used_bytes: entry.used_bytes, + usage_percent: entry.usage_percent, + })); + +const mapHost = (host?: CheckHostInfo): SnapshotHostInfo | undefined => + host && { + os: host.os, + platform: host.platform, + pretty_name: host.pretty_name, + }; + +const mapAudits = (audits?: CheckAudits): CheckAudits | undefined => + audits && { + cls: audits.cls, + si: audits.si, + fcp: audits.fcp, + lcp: audits.lcp, + tbt: audits.tbt, + }; + +export const toCheckSnapshot = (source: CheckSnapshotSource): CheckSnapshot => ({ + id: source.id, + status: source.status, + responseTime: source.responseTime, + statusCode: source.statusCode, + message: source.message, + createdAt: toDateString(source.createdAt), + cpu: mapCpu(source.cpu), + memory: mapMemory(source.memory), + disk: mapDisk(source.disk), + host: mapHost(source.host), + accessibility: source.accessibility, + bestPractices: source.bestPractices, + seo: source.seo, + performance: source.performance, + audits: mapAudits(source.audits), +}); diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index d6e60c3e3..4e69a5241 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -168,8 +168,36 @@ export interface HasResponseTime { responseTime: number; } -export type CheckSnapshot = Omit; +export type SnapshotCpuInfo = Pick< + CheckCpuInfo, + "physical_core" | "logical_core" | "frequency" | "current_frequency" | "temperature" | "usage_percent" +>; +export type SnapshotMemoryInfo = Pick; +export type SnapshotDiskInfo = Pick; +export type SnapshotHostInfo = Pick; +export type CheckSnapshot = Pick< + Check, + // uptime charts and down check tooltips + | "id" + | "status" + | "responseTime" + | "statusCode" + | "message" + | "createdAt" + // pagespeed + | "accessibility" + | "bestPractices" + | "seo" + | "performance" + | "audits" +> & { + // hardware monitors only + cpu?: SnapshotCpuInfo; + memory?: SnapshotMemoryInfo; + disk?: SnapshotDiskInfo[]; + host?: SnapshotHostInfo; +}; export interface HardwareDiskStats { name: string; readSpeed: number; diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 446631f45..ffd4238d0 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -3,15 +3,11 @@ import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monito import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js"; import type { CheckAudits, - CheckCaptureInfo, - CheckCpuInfo, - CheckDiskInfo, - CheckErrorInfo, - CheckHostInfo, - CheckMemoryInfo, - CheckNetworkInterfaceInfo, - GotTimings, ILighthouseAudit, + SnapshotCpuInfo, + SnapshotDiskInfo, + SnapshotHostInfo, + SnapshotMemoryInfo, } from "@/domain/checks/check.type.js"; type CheckSnapshotDocument = Omit & { createdAt: Date }; @@ -37,121 +33,46 @@ interface MonitorDocument extends MonitorDocumentBase { lastEvaluatedAt: number; // epoch ms } -const snapshotTimingPhasesSchema = new Schema( - { - wait: { type: Number }, - dns: { type: Number }, - tcp: { type: Number }, - tls: { type: Number }, - request: { type: Number }, - firstByte: { type: Number }, - download: { type: Number }, - total: { type: Number }, - }, - { _id: false } -); - -const snapshotTimingsSchema = new Schema( - { - start: { type: Number }, - socket: { type: Number }, - lookup: { type: Number }, - connect: { type: Number }, - secureConnect: { type: Number }, - upload: { type: Number }, - response: { type: Number }, - end: { type: Number }, - phases: { type: snapshotTimingPhasesSchema }, - }, - { _id: false } -); - -const snapshotCpuSchema = new Schema( +const snapshotCpuSchema = new Schema( { physical_core: { type: Number }, logical_core: { type: Number }, frequency: { type: Number }, current_frequency: { type: Number }, temperature: { type: [Number] }, - free_percent: { type: Number }, usage_percent: { type: Number }, }, { _id: false } ); -const snapshotMemorySchema = new Schema( +const snapshotMemorySchema = new Schema( { total_bytes: { type: Number }, - available_bytes: { type: Number }, used_bytes: { type: Number }, usage_percent: { type: Number }, }, { _id: false } ); -const snapshotDiskSchema = new Schema( +const snapshotDiskSchema = new Schema( { device: { type: String }, - mountpoint: { type: String }, total_bytes: { type: Number }, - free_bytes: { type: Number }, used_bytes: { type: Number }, usage_percent: { type: Number }, - total_inodes: { type: Number }, - free_inodes: { type: Number }, - used_inodes: { type: Number }, - inodes_usage_percent: { type: Number }, - read_bytes: { type: Number }, - write_bytes: { type: Number }, - read_time: { type: Number }, - write_time: { type: Number }, }, { _id: false } ); -const snapshotHostSchema = new Schema( +const snapshotHostSchema = new Schema( { os: { type: String }, platform: { type: String }, - kernel_version: { type: String }, pretty_name: { type: String }, }, { _id: false } ); -const snapshotErrorSchema = new Schema( - { - metric: { type: [String] }, - err: { type: String }, - }, - { _id: false } -); - -const snapshotCaptureSchema = new Schema( - { - version: { type: String }, - mode: { type: String }, - }, - { _id: false } -); - -const snapshotNetworkInterfaceSchema = new Schema( - { - name: { type: String }, - bytes_sent: { type: Number }, - bytes_recv: { type: Number }, - packets_sent: { type: Number }, - packets_recv: { type: Number }, - err_in: { type: Number }, - err_out: { type: Number }, - drop_in: { type: Number }, - drop_out: { type: Number }, - fifo_in: { type: Number }, - fifo_out: { type: Number }, - }, - { _id: false } -); - const snapshotLighthouseAuditSchema = new Schema( { id: { type: String }, @@ -180,16 +101,12 @@ const checkSnapshotSchema = new Schema( id: { type: String, required: true }, status: { type: Boolean, required: true }, responseTime: { type: Number }, - timings: { type: snapshotTimingsSchema }, statusCode: { type: Number }, message: { type: String }, cpu: { type: snapshotCpuSchema }, memory: { type: snapshotMemorySchema }, disk: { type: [snapshotDiskSchema] }, host: { type: snapshotHostSchema }, - errors: { type: [snapshotErrorSchema] }, - capture: { type: snapshotCaptureSchema }, - net: { type: [snapshotNetworkInterfaceSchema] }, accessibility: { type: Number }, bestPractices: { type: Number }, seo: { type: Number }, diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index d3dc0597e..e743e423f 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -30,7 +30,6 @@ export interface IMonitorsRepository { findByTeamId(teamId: string, config: TeamQueryConfig): Promise; findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise; findByIds(monitorIds: string[]): Promise; - findByIdsWithChecks(monitorIds: string[], checksCount?: number): Promise; // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index b96ec3907..68065f332 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -7,6 +7,7 @@ import { MongoBulkWriteError } from "mongodb"; import { AppError } from "@/utils/AppError.js"; import { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "@/domain/monitors/monitor.repository.interface.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js"; +import { toCheckSnapshot } from "@/domain/checks/check.snapshot.js"; class MongoMonitorsRepository implements IMonitorsRepository { create = async (monitor: Monitor, teamId: string, userId: string) => { const monitorModel = new MonitorModel({ ...monitor, teamId, userId }); @@ -131,70 +132,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { return documents.map((doc) => this.toEntity(doc)); }; - findByIdsWithChecks = async (monitorIds: string[], checksCount: number = 25): Promise => { - if (!monitorIds.length) { - return []; - } - - const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - - const pipeline: PipelineStage[] = [ - { $match: { _id: { $in: objectIds } } }, - { - $lookup: { - from: "checks", - let: { monitorId: "$_id" }, - pipeline: [{ $match: { $expr: { $eq: ["$metadata.monitorId", "$$monitorId"] } } }, { $sort: { createdAt: -1 } }, { $limit: checksCount }], - as: "checks", - }, - }, - { - $lookup: { - from: "maintenancewindows", - let: { monitorId: "$_id" }, - pipeline: [{ $match: { $expr: { $in: ["$$monitorId", { $ifNull: ["$monitorIds", []] }] } } }], - as: "maintenanceWindows", - }, - }, - { - $lookup: { - from: "monitorstats", - localField: "_id", - foreignField: "monitorId", - as: "stats", - }, - }, - { - $addFields: { - isMaintenance: { - $reduce: { - input: "$maintenanceWindows", - initialValue: false, - in: { - $or: [ - "$$value", - { - $and: [{ $eq: ["$$this.active", true] }, { $lte: ["$$this.start", "$$NOW"] }, { $gte: ["$$this.end", "$$NOW"] }], - }, - ], - }, - }, - }, - uptimePercentage: { $arrayElemAt: ["$stats.uptimePercentage", 0] }, - }, - }, - { - $project: { - maintenanceWindows: 0, - stats: 0, - }, - }, - ]; - - const documents = await MonitorModel.aggregate(pipeline); - return documents.map((doc) => this.toEntity(doc)); - }; - findMonitorCountByTeamIdAndType = async (teamId: string, config: TeamQueryConfig): Promise => { const query = this.queryBuilder(config, teamId); const count = await MonitorModel.countDocuments(query); @@ -473,7 +410,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { grpcServiceName: doc.grpcServiceName ?? undefined, strategy: doc.strategy ?? undefined, group: doc.group ?? null, - recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)), + recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => toCheckSnapshot(check)), geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, @@ -485,30 +422,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; }; - private toCheckSnapshot = (doc: CheckSnapshotDocument): CheckSnapshot => { - return { - id: doc.id, - status: doc.status, - responseTime: doc.responseTime, - timings: doc.timings, - statusCode: doc.statusCode, - message: doc.message, - cpu: doc.cpu, - memory: doc.memory, - disk: doc.disk, - host: doc.host, - errors: doc.errors, - capture: doc.capture, - net: doc.net, - accessibility: doc.accessibility, - bestPractices: doc.bestPractices, - seo: doc.seo, - performance: doc.performance, - audits: doc.audits, - createdAt: toDateString(doc.createdAt), - }; - }; - deleteByTeamIdsNotIn = async (teamIds: string[]): Promise => { const objectIds = teamIds.map((id) => new mongoose.Types.ObjectId(id)); const result = await MonitorModel.deleteMany({ teamId: { $nin: objectIds } }); diff --git a/server/src/service/statusService.ts b/server/src/service/statusService.ts index 9be31e5f1..07ddee2a0 100755 --- a/server/src/service/statusService.ts +++ b/server/src/service/statusService.ts @@ -1,6 +1,6 @@ import { IMonitorStatsRepository } from "@/domain/monitor-stats/monitor-stats.repository.interface.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; -import type { Check, CheckDiskInfo, CheckSnapshot } from "@/domain/checks/check.type.js"; +import type { Check, CheckDiskInfo } from "@/domain/checks/check.type.js"; import { MonitorStatuses, type Monitor, type MonitorStatus } from "@/domain/monitors/monitor.type.js"; import type { DockerStatusPayload, @@ -18,6 +18,7 @@ import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; import type { HardwareStatusMetrics } from "@/types/network.js"; import { MAX_RECENT_CHECKS } from "@/domain/monitors/monitor.type.js"; +import { toCheckSnapshot } from "@/domain/checks/check.snapshot.js"; const SERVICE_NAME = "StatusService"; const HARDWARE_ALERT_COUNTER_START = 5; @@ -87,30 +88,6 @@ export class StatusService implements IStatusService { } }; - private toCheckSnapshot = (check: Check): CheckSnapshot => { - return { - id: check.id, - status: check.status, - responseTime: check.responseTime, - timings: check.timings, - statusCode: check.statusCode, - message: check.message, - cpu: check.cpu, - memory: check.memory, - disk: check.disk, - host: check.host, - errors: check.errors, - capture: check.capture, - net: check.net, - accessibility: check.accessibility, - bestPractices: check.bestPractices, - seo: check.seo, - performance: check.performance, - audits: check.audits, - createdAt: check.createdAt, - }; - }; - private computeReachability = ( currentStatus: MonitorStatus, window: Array, @@ -207,7 +184,7 @@ export class StatusService implements IStatusService { await this.tryUpdateRunningStats(monitor, statusResponse); const prevStatus = monitor.status; - const checkSnapshot = this.toCheckSnapshot(check); + const checkSnapshot = toCheckSnapshot(check); // Project the window as it will look after updating DB // This is done because we need the updated status window to compute new status, but we don't diff --git a/server/test/helpers/InMemoryMonitorsRepository.ts b/server/test/helpers/InMemoryMonitorsRepository.ts index 1ff0b0383..fa811ad62 100644 --- a/server/test/helpers/InMemoryMonitorsRepository.ts +++ b/server/test/helpers/InMemoryMonitorsRepository.ts @@ -45,10 +45,6 @@ export class InMemoryMonitorsRepository implements IMonitorsRepository { return monitor ? { ...monitor } : null; } - async findByIdsWithChecks(monitorIds: string[], _checksCount?: number): Promise { - return this.findByIds(monitorIds); - } - async updateById(monitorId: string, teamId: string, updates: Partial): Promise { const index = this.monitors.findIndex((m) => m.id === monitorId && m.teamId === teamId); if (index === -1) { From 4c8342f3791ace3e18ab830d9e82e6ed1c502fc3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 4 Aug 2026 14:46:52 -0700 Subject: [PATCH 026/143] fix pretty name bug, ordering bug --- .../Infrastructure/Details/Components/StatusBoxes.tsx | 11 +++-------- .../Infrastructure/Details/Components/TabOverview.tsx | 3 ++- .../Monitors/Components/MonitorsTable.tsx | 9 ++++++--- client/src/Pages/PageSpeed/Details/index.tsx | 6 ++++-- .../Status/themes/shared/ThemedInfrastructure.tsx | 3 ++- server/src/domain/checks/check.repository.mongo.ts | 1 + server/src/domain/monitors/monitor.service.ts | 2 +- server/test/unit/services/monitorService.test.ts | 4 +++- 8 files changed, 22 insertions(+), 17 deletions(-) diff --git a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx index ebfdad569..70f4325c6 100644 --- a/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes.tsx @@ -11,7 +11,8 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => { const { t } = useTranslation(); const theme = useTheme(); - const latestCheck = monitor?.recentChecks?.[0]; + const recentChecks = monitor?.recentChecks ?? []; + const latestCheck = recentChecks[recentChecks.length - 1]; // Get data from latest check const physicalCores = getCores(latestCheck?.cpu?.physical_core); const logicalCores = getCores(latestCheck?.cpu?.logical_core); @@ -21,13 +22,7 @@ export const StatusBoxes = ({ monitor }: { monitor: Monitor }) => { const memoryTotalBytes = latestCheck?.memory?.total_bytes ?? 0; const diskTotalBytes = latestCheck?.disk?.reduce((acc, disk) => acc + (disk.total_bytes || 0), 0) || 0; - const os = getOsAndPlatform(latestCheck?.host); - - const platform = latestCheck?.host?.platform ?? undefined; - const osPlatform = - typeof os === "undefined" && typeof platform === "undefined" - ? undefined - : `${os} ${platform}`; + const osPlatform = getOsAndPlatform(latestCheck?.host); return ( - + { - const check = row.recentChecks?.[0]; + const recentChecks = row.recentChecks ?? []; + const check = recentChecks[recentChecks.length - 1]; const cpuUsage = (check?.cpu?.usage_percent || 0) * 100; return ; }, @@ -257,7 +258,8 @@ export const InfraMonitorsTable = ({ id: "memory", content: t("pages.infrastructure.table.headers.memory"), render: (row) => { - const check = row.recentChecks?.[0]; + const recentChecks = row.recentChecks ?? []; + const check = recentChecks[recentChecks.length - 1]; const memoryUsage = (check?.memory?.usage_percent || 0) * 100; return ; }, @@ -266,7 +268,8 @@ export const InfraMonitorsTable = ({ id: "disk", content: t("pages.infrastructure.table.headers.disk"), render: (row) => { - const check = row.recentChecks?.[0]; + const recentChecks = row.recentChecks ?? []; + const check = recentChecks[recentChecks.length - 1]; const totalDiskUsage = check?.disk?.reduce( (acc, disk) => acc + (disk?.usage_percent || 0), diff --git a/client/src/Pages/PageSpeed/Details/index.tsx b/client/src/Pages/PageSpeed/Details/index.tsx index 48a9ba99c..ea9da4a7f 100644 --- a/client/src/Pages/PageSpeed/Details/index.tsx +++ b/client/src/Pages/PageSpeed/Details/index.tsx @@ -45,6 +45,8 @@ const PageSpeedDetails = () => { const monitor = monitorData?.monitorData?.monitor; const groupedChecks = monitorData?.monitorData?.groupedChecks || []; const monitorStats = monitorData?.monitorStats || null; + const recentChecks = monitor?.recentChecks ?? []; + const latestCheck = recentChecks[recentChecks.length - 1]; return ( { direction={{ xs: "column", md: "row" }} gap={theme.spacing(10)} > - - + + ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx index ab7d0dc35..3c92e6b0a 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx @@ -36,7 +36,8 @@ const heatLevel = (value: number): GaugeFillLevel => export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => { const { t } = useTranslation(); - const latest = monitor.recentChecks?.[0]; + const recentChecks = monitor.recentChecks ?? []; + const latest = recentChecks[recentChecks.length - 1]; const renderEmpty = () => ( {t("pages.statusPages.monitorsList.noData")} diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index 78f0b9344..cb1c5bfba 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -81,6 +81,7 @@ class MongoChecksRepository implements IChecksRepository { os: host?.os ?? "", platform: host?.platform ?? "", kernel_version: host?.kernel_version ?? "", + pretty_name: host?.pretty_name ?? "", }); const mapCapture = (capture?: CheckCaptureInfo): CheckCaptureInfo => ({ diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 7a8478c5a..78a452044 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -363,7 +363,7 @@ export class MonitorService implements IMonitorService { const monitorsWithChecks = monitors.map((monitor: Monitor) => { const rawChecks = monitor.recentChecks ?? []; const isSnapshotType = snapshotOnlyRequest || snapshotTypes.includes(monitor.type); - const checks = isSnapshotType ? rawChecks.slice(0, 1) : rawChecks; + const checks = isSnapshotType ? rawChecks.slice(-1) : rawChecks; monitor.recentChecks = checks; return monitor; }); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 69edbba9c..0f56e5d2b 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -583,7 +583,7 @@ describe("MonitorService", () => { expect(result.monitors).toEqual([]); }); - it("uses snapshot (first check only) for hardware type monitors", async () => { + it("uses snapshot (latest check only) for hardware type monitors", async () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1 }); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); @@ -602,6 +602,7 @@ describe("MonitorService", () => { const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID, type: "hardware" }); expect(result.monitors[0].recentChecks).toHaveLength(1); + expect(result.monitors[0].recentChecks[0].responseTime).toBe(30); }); it("uses snapshot for hardware type when type is array with hardware", async () => { @@ -622,6 +623,7 @@ describe("MonitorService", () => { const result = await service.getMonitorsWithChecksByTeamId({ teamId: TEAM_ID, type: ["hardware"] }); expect(result.monitors[0].recentChecks).toHaveLength(1); + expect(result.monitors[0].recentChecks[0].responseTime).toBe(20); }); it("normalizes checks for non-snapshot types when type is an array", async () => { From 5fa74bd88312e1f0a2f7b2234bede4901cd7a7f9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 4 Aug 2026 15:16:03 -0700 Subject: [PATCH 027/143] replace findAll with findAllForScheduling --- .../monitors/monitor.repository.interface.ts | 4 ++-- .../monitors/monitor.repository.mongo.ts | 19 +++++++++++++------ server/src/domain/monitors/monitor.type.ts | 2 ++ server/src/worker/worker.job-scheduler.ts | 8 ++++---- .../helpers/InMemoryMonitorsRepository.ts | 13 ++++++++++--- .../test/unit/services/dbQueueWorker.test.ts | 14 +++++++++++--- .../test/unit/services/jobScheduler.test.ts | 10 +++++----- 7 files changed, 47 insertions(+), 23 deletions(-) diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index e743e423f..e6e68863b 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -1,4 +1,4 @@ -import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/domain/monitors/monitor.type.js"; +import type { MonitorType, Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot, MonitorScheduleFields } from "@/domain/monitors/monitor.type.js"; export interface TeamQueryConfig { limit?: number; @@ -26,7 +26,7 @@ export interface IMonitorsRepository { findByIdLean(monitorId: string): Promise; // collection fetch - findAll(): Promise; + findAllForScheduling(): Promise; findByTeamId(teamId: string, config: TeamQueryConfig): Promise; findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise; findByIds(monitorIds: string[]): Promise; diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 68065f332..cdfbf4ca9 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -1,7 +1,7 @@ import { MonitorModel } from "@/domain/monitors/monitor.model.js"; import type { MonitorDocument, CheckSnapshotDocument } from "@/domain/monitors/monitor.model.js"; import type { CheckSnapshot } from "@/domain/checks/check.type.js"; -import type { Monitor, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.type.js"; +import type { Monitor, MonitorScheduleFields, MonitorStatus, MonitorsSummary } from "@/domain/monitors/monitor.type.js"; import mongoose, { type FilterQuery, type PipelineStage } from "mongoose"; import { MongoBulkWriteError } from "mongodb"; import { AppError } from "@/utils/AppError.js"; @@ -41,13 +41,20 @@ class MongoMonitorsRepository implements IMonitorsRepository { }; findByIdLean = async (monitorId: string): Promise => { - const monitor = await MonitorModel.findOne({ _id: monitorId }); + const monitor = await MonitorModel.findOne({ _id: monitorId }).select({ recentChecks: 0 }); return monitor ? this.toEntity(monitor) : null; }; - findAll = async (): Promise => { - const monitors = await MonitorModel.find(); - return this.mapDocuments(monitors); + findAllForScheduling = async (): Promise => { + const docs = await MonitorModel.find({}, { type: 1, isActive: 1, interval: 1, geoCheckEnabled: 1, geoCheckInterval: 1 }).lean(); + return docs.map((doc) => ({ + id: doc._id.toString(), + type: doc.type, + isActive: doc.isActive, + interval: doc.interval, + geoCheckEnabled: doc.geoCheckEnabled ?? false, + geoCheckInterval: doc.geoCheckInterval ?? 300000, + })); }; private queryBuilder = (config: TeamQueryConfig, teamId: string): FilterQuery => { @@ -182,7 +189,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { }, ...(statusPatch && { $set: statusPatch }), }, - { returnDocument: "after" } + { returnDocument: "after", projection: { recentChecks: 0 } } ); if (!updatedMonitor) { diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 865bafd76..0fb1ed397 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -178,3 +178,5 @@ export interface Game { } export type GamesMap = Record; + +export type MonitorScheduleFields = Pick; diff --git a/server/src/worker/worker.job-scheduler.ts b/server/src/worker/worker.job-scheduler.ts index 8d103e87f..aae8a489b 100644 --- a/server/src/worker/worker.job-scheduler.ts +++ b/server/src/worker/worker.job-scheduler.ts @@ -1,7 +1,7 @@ import { IJobScheduler } from "@/worker/worker.interface.js"; import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; -import { Monitor, supportsGeoCheck } from "@/domain/monitors/monitor.type.js"; +import { Monitor, MonitorScheduleFields, supportsGeoCheck } from "@/domain/monitors/monitor.type.js"; import { type Job, type JobSeed, type JobType, jobId } from "@/domain/jobs/job.type.js"; import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js"; import { WorkerJobsPagination, WorkerJobSummary, WorkerMetrics } from "@/worker/worker.interface.js"; @@ -43,7 +43,7 @@ export class JobScheduler implements IJobScheduler { "cleanup-retention": POLL_MS, }; // Helpers, builds job seeds. immediate=true runs on the next tick; otherwise jitter spreads the herd. - protected toCheckJob = (monitor: Monitor, now: number, immediate = false): JobSeed => ({ + protected toCheckJob = (monitor: MonitorScheduleFields, now: number, immediate = false): JobSeed => ({ id: jobId("check", monitor.id), type: "check", refId: monitor.id, @@ -52,7 +52,7 @@ export class JobScheduler implements IJobScheduler { intervalMs: monitor.interval, }); - protected toGeoCheckJob = (monitor: Monitor, now: number, immediate = false): JobSeed => ({ + protected toGeoCheckJob = (monitor: MonitorScheduleFields, now: number, immediate = false): JobSeed => ({ ...this.toCheckJob(monitor, now, immediate), id: jobId("geo-check", monitor.id), type: "geo-check", @@ -187,7 +187,7 @@ export class JobScheduler implements IJobScheduler { // Seed queue protected reconcile = async () => { const now = Date.now(); - const monitors = await this.monitorsRepository.findAll(); + const monitors = await this.monitorsRepository.findAllForScheduling(); for (const monitor of monitors) { await this.jobsRepository.upsertJob(this.toCheckJob(monitor, now)); if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) { diff --git a/server/test/helpers/InMemoryMonitorsRepository.ts b/server/test/helpers/InMemoryMonitorsRepository.ts index fa811ad62..2ec36e8e2 100644 --- a/server/test/helpers/InMemoryMonitorsRepository.ts +++ b/server/test/helpers/InMemoryMonitorsRepository.ts @@ -1,6 +1,6 @@ import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "../../src/domain/monitors/monitor.repository.interface.ts"; import type { CheckSnapshot } from "../../src/domain/checks/check.type.ts"; -import type { Monitor, MonitorsSummary } from "../../src/domain/monitors/monitor.type.ts"; +import type { Monitor, MonitorScheduleFields, MonitorsSummary } from "../../src/domain/monitors/monitor.type.ts"; export class InMemoryMonitorsRepository implements IMonitorsRepository { private monitors: Monitor[] = []; @@ -24,8 +24,15 @@ export class InMemoryMonitorsRepository implements IMonitorsRepository { return { ...monitor }; } - async findAll(): Promise { - return this.monitors.map((m) => ({ ...m })); + async findAllForScheduling(): Promise { + return this.monitors.map((m) => ({ + id: m.id, + type: m.type, + isActive: m.isActive, + interval: m.interval, + geoCheckEnabled: m.geoCheckEnabled ?? false, + geoCheckInterval: m.geoCheckInterval ?? 300000, + })); } async findByTeamId(_teamId: string, _config: TeamQueryConfig): Promise { diff --git a/server/test/unit/services/dbQueueWorker.test.ts b/server/test/unit/services/dbQueueWorker.test.ts index d903ae887..8b13287d4 100644 --- a/server/test/unit/services/dbQueueWorker.test.ts +++ b/server/test/unit/services/dbQueueWorker.test.ts @@ -84,7 +84,7 @@ const createWorker = (overrides?: { queueMode?: QueueMode; queuePrimaryProcesses const monitorsRepository = { findByIds: jest.fn().mockResolvedValue([makeMonitor()]), findByIdLean: jest.fn().mockResolvedValue(makeMonitor()), - findAll: jest.fn().mockResolvedValue([]), + findAllForScheduling: jest.fn().mockResolvedValue([]), updateById: jest.fn().mockResolvedValue({}), }; const checksRepository = { @@ -183,7 +183,11 @@ describe("DBQueueWorker", () => { queueMode: "primary", queuePrimaryProcesses: false, // isolate reconcile from the polling loops mocks: { - monitorsRepository: { findByIds: jest.fn(), findAll: jest.fn().mockResolvedValue([makeMonitor()]), updateById: jest.fn() }, + monitorsRepository: { + findByIds: jest.fn(), + findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor()]), + updateById: jest.fn(), + }, }, }); @@ -200,7 +204,11 @@ describe("DBQueueWorker", () => { queueMode: "primary", queuePrimaryProcesses: false, mocks: { - monitorsRepository: { findByIds: jest.fn(), findAll: jest.fn().mockResolvedValue([geoMonitor]), updateById: jest.fn() }, + monitorsRepository: { + findByIds: jest.fn(), + findAllForScheduling: jest.fn().mockResolvedValue([geoMonitor]), + updateById: jest.fn(), + }, }, }); diff --git a/server/test/unit/services/jobScheduler.test.ts b/server/test/unit/services/jobScheduler.test.ts index 098cef245..90c8c2850 100644 --- a/server/test/unit/services/jobScheduler.test.ts +++ b/server/test/unit/services/jobScheduler.test.ts @@ -38,7 +38,7 @@ const createScheduler = (overrides?: { queueMode?: QueueMode; mocks?: Record().mockResolvedValue(undefined), }; const monitorsRepository = { - findAll: jest.fn().mockResolvedValue([]), + findAllForScheduling: jest.fn().mockResolvedValue([]), }; const logger = createMockLogger(); const mocks = { jobsRepository, queueWorkersRepository, monitorsRepository, logger, ...overrides?.mocks }; @@ -73,7 +73,7 @@ describe("JobScheduler", () => { it("primary mode reconciles: seeds a check row per monitor plus the two cleanup rows", async () => { const { scheduler, mocks } = createScheduler({ queueMode: "primary", - mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor()]) } }, + mocks: { monitorsRepository: { findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor()]) } }, }); await scheduler.init(); @@ -87,7 +87,7 @@ describe("JobScheduler", () => { it("primary mode also seeds a geo-check row for geo-enabled monitors", async () => { const { scheduler, mocks } = createScheduler({ queueMode: "primary", - mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor({ geoCheckEnabled: true })]) } }, + mocks: { monitorsRepository: { findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor({ geoCheckEnabled: true })]) } }, }); await scheduler.init(); @@ -98,12 +98,12 @@ describe("JobScheduler", () => { it("worker mode heartbeats but does not reconcile (no monitor reads, no seeds)", async () => { const { scheduler, mocks } = createScheduler({ queueMode: "worker", - mocks: { monitorsRepository: { findAll: jest.fn().mockResolvedValue([makeMonitor()]) } }, + mocks: { monitorsRepository: { findAllForScheduling: jest.fn().mockResolvedValue([makeMonitor()]) } }, }); await scheduler.init(); expect(mocks.queueWorkersRepository.upsert).toHaveBeenCalledWith("worker-1", "worker", false); - expect(mocks.monitorsRepository.findAll).not.toHaveBeenCalled(); + expect(mocks.monitorsRepository.findAllForScheduling).not.toHaveBeenCalled(); expect(mocks.jobsRepository.upsertJob).not.toHaveBeenCalled(); expect(mocks.jobsRepository.upsertCleanupJob).not.toHaveBeenCalled(); }); From 303f47a11733635ef56f9bebfb9e149109ec782b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 4 Aug 2026 15:30:34 -0700 Subject: [PATCH 028/143] add opt out flag for recent checks --- .../maintenance-window.service.ts | 4 ++-- .../monitors/monitor.repository.interface.ts | 4 ++-- .../monitors/monitor.repository.mongo.ts | 18 +++++++++++++----- server/src/domain/monitors/monitor.service.ts | 2 +- server/src/domain/users/user.service.ts | 2 +- .../services/maintenanceWindowService.test.ts | 4 ++-- server/test/unit/services/userService.test.ts | 2 +- 7 files changed, 22 insertions(+), 14 deletions(-) diff --git a/server/src/domain/maintenance-windows/maintenance-window.service.ts b/server/src/domain/maintenance-windows/maintenance-window.service.ts index 350e755aa..dd27ba0b3 100644 --- a/server/src/domain/maintenance-windows/maintenance-window.service.ts +++ b/server/src/domain/maintenance-windows/maintenance-window.service.ts @@ -106,7 +106,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { start: string; end: string; }) => { - const monitors = await this.monitorsRepository.findByIds(monitorIDs); + const monitors = await this.monitorsRepository.findByIds(monitorIDs, { includeRecentChecks: false }); const unauthorizedMonitors = monitors.filter((monitor) => monitor.teamId !== teamId); @@ -193,7 +193,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { const update: Partial = rest; if (monitors !== undefined) { - const monitorDocs = await this.monitorsRepository.findByIds(monitors); + const monitorDocs = await this.monitorsRepository.findByIds(monitors, { includeRecentChecks: false }); const unauthorizedMonitors = monitorDocs.filter((monitor) => monitor.teamId !== teamId); if (unauthorizedMonitors.length > 0) { throw new AppError({ diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index e6e68863b..53c178a1d 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -27,9 +27,9 @@ export interface IMonitorsRepository { // collection fetch findAllForScheduling(): Promise; - findByTeamId(teamId: string, config: TeamQueryConfig): Promise; + findByTeamId(teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise; findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise; - findByIds(monitorIds: string[]): Promise; + findByIds(monitorIds: string[], options?: { includeRecentChecks?: boolean }): Promise; // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index cdfbf4ca9..2411f3840 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -104,10 +104,15 @@ class MongoMonitorsRepository implements IMonitorsRepository { return { sort: { [field]: order === "asc" ? 1 : -1 } as const, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage }; }; - findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise => { + findByTeamId = async (teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise => { const query = this.queryBuilder(config, teamId); const { sort, skip, limit } = this.pageOptions(config); - const documents = await MonitorModel.find(query).sort(sort).skip(skip).limit(limit); + + const cursor = MonitorModel.find(query).sort(sort).skip(skip).limit(limit); + if (options?.includeRecentChecks === false) { + cursor.select({ recentChecks: 0 }); + } + const documents = await cursor; return this.mapDocuments(documents); }; @@ -126,15 +131,18 @@ class MongoMonitorsRepository implements IMonitorsRepository { return documents.map((doc) => this.toEntity(doc)); }; - findByIds = async (monitorIds: string[]): Promise => { + findByIds = async (monitorIds: string[], options?: { includeRecentChecks?: boolean }): Promise => { if (!monitorIds.length) { return []; } const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - const pipeline: PipelineStage[] = [{ $match: { _id: { $in: objectIds } } }, ...this.uptimeStatsLookupStages]; - + const pipeline: PipelineStage[] = [ + { $match: { _id: { $in: objectIds } } }, + ...(options?.includeRecentChecks === false ? [{ $project: { recentChecks: 0 } } as PipelineStage] : []), + ...this.uptimeStatsLookupStages, + ]; const documents = await MonitorModel.aggregate(pipeline); return documents.map((doc) => this.toEntity(doc)); }; diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 78a452044..31488c378 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -519,7 +519,7 @@ export class MonitorService implements IMonitorService { }; exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise => { - const monitors = await this.monitorsRepository.findByTeamId(teamId, {}); + const monitors = await this.monitorsRepository.findByTeamId(teamId, {}, { includeRecentChecks: false }); if (monitors.length === 0) { throw new AppError({ message: "No monitors found to export.", service: SERVICE_NAME, method: "exportMonitorsToJSON", status: 400 }); diff --git a/server/src/domain/users/user.service.ts b/server/src/domain/users/user.service.ts index f3db2f3b5..f042863aa 100644 --- a/server/src/domain/users/user.service.ts +++ b/server/src/domain/users/user.service.ts @@ -324,7 +324,7 @@ export class UserService implements IUserService { } if (roles.includes("superadmin")) { - const monitors = await this.monitorsRepository.findByTeamId(teamId, {}); + const monitors = await this.monitorsRepository.findByTeamId(teamId, {}, { includeRecentChecks: false }); if (monitors) { await Promise.all(monitors.map((monitor) => this.scheduler.deleteJob(monitor))); } diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index e15f20d46..481dc5983 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -123,7 +123,7 @@ describe("MaintenanceWindowService", () => { await service.createMaintenanceWindow(defaultCreateParams); - expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]); + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { includeRecentChecks: false }); }); it("throws 403 when a monitor belongs to a different team", async () => { @@ -404,7 +404,7 @@ describe("MaintenanceWindowService", () => { body: { monitors: ["mon-1", "mon-2"] }, }); - expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]); + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { includeRecentChecks: false }); }); it("throws 403 when a new monitor belongs to a different team", async () => { diff --git a/server/test/unit/services/userService.test.ts b/server/test/unit/services/userService.test.ts index bce8769cf..5187a3c60 100644 --- a/server/test/unit/services/userService.test.ts +++ b/server/test/unit/services/userService.test.ts @@ -472,7 +472,7 @@ describe("UserService", () => { await service.deleteUser({ userId: "user-1", teamId: "team-1", roles: ["superadmin"] }); - expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {}); + expect(monitorsRepository.findByTeamId).toHaveBeenCalledWith("team-1", {}, { includeRecentChecks: false }); expect(scheduler.deleteJob).toHaveBeenCalledTimes(2); expect(usersRepository.deleteById).toHaveBeenCalledWith("user-1"); }); From 0c184e6d0c614b239ae86dd2c2fcb28bdde73ba6 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Diniz Date: Wed, 5 Aug 2026 08:33:29 -0300 Subject: [PATCH 029/143] fix(notifications): guard incident link Refs #3821 --- .../notifications/providers/rocketChat.ts | 4 +++- .../notifications/rocketChatProvider.test.ts | 24 +++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/server/src/domain/notifications/providers/rocketChat.ts b/server/src/domain/notifications/providers/rocketChat.ts index aa6852d74..345eec8c3 100644 --- a/server/src/domain/notifications/providers/rocketChat.ts +++ b/server/src/domain/notifications/providers/rocketChat.ts @@ -81,7 +81,9 @@ export class RocketChatProvider extends NotificationProvider { ); } - lines.push("", `Incident: ${message.clientHost}/infrastructure/${message.monitor.id}`); + if (message.content.incident) { + lines.push("", `Incident: ${message.clientHost}/infrastructure/${message.monitor.id}`); + } return lines.join("\n"); } diff --git a/server/test/unit/providers/notifications/rocketChatProvider.test.ts b/server/test/unit/providers/notifications/rocketChatProvider.test.ts index 8ceb60bee..ac9ffc4c1 100644 --- a/server/test/unit/providers/notifications/rocketChatProvider.test.ts +++ b/server/test/unit/providers/notifications/rocketChatProvider.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from "@jest/globals"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; -import { makeMessage, makeMessageWithThresholds, makeNotification } from "../../../helpers/notificationMessage.ts"; +import { makeMessage, makeMessageWithIncident, makeMessageWithThresholds, makeNotification } from "../../../helpers/notificationMessage.ts"; import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; const mockGotPost = jest.fn().mockResolvedValue({}); @@ -90,8 +90,6 @@ describe("RocketChatProvider", () => { "Details:", "- URL: https://example.com", "- Status: Down", - "", - "Incident: https://app.example.com/infrastructure/mon-1", ].join("\n"), }, }) @@ -116,15 +114,31 @@ describe("RocketChatProvider", () => { expect(payload.text).toContain("Thresholds:\n- CPU: 90.0% (threshold: 80%)\n- MEMORY: 85.0% (threshold: 70%)"); }); - it("includes a monitor-scoped incident link without synthetic incident data", async () => { + it("includes a monitor-scoped link when incident data is present", async () => { const { provider } = createProvider(); - await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithIncident()); const payload = mockGotPost.mock.calls[0][1].json; expect(payload.text).toContain("Incident: https://app.example.com/infrastructure/mon-1"); }); + it("omits the incident link when incident data is absent", async () => { + const { provider } = createProvider(); + + await provider.sendMessage( + makeNotification({ type: "rocket_chat" }), + makeMessage({ + type: "monitor_up", + severity: "success", + monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: "http", status: "up" }, + }) + ); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.text).not.toContain("Incident:"); + }); + it("returns false and logs when message delivery fails", async () => { mockGotPost.mockRejectedValue(new Error("network failure")); const { provider, logger } = createProvider(); From 466d356ad1cffbb3a08662c360db7cb4b5c0b67e Mon Sep 17 00:00:00 2001 From: Marcos Paulo Diniz Date: Wed, 5 Aug 2026 08:34:12 -0300 Subject: [PATCH 030/143] refactor(validation): use Zod URL protocols Refs #3821 --- client/src/Validation/notifications.ts | 6 +----- server/src/api/validation/notificationValidation.ts | 7 ++++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index 3058d3f66..f7490d526 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -35,11 +35,7 @@ const rocketChatSchema = baseSchema.extend({ address: z .string() .min(1, "Webhook URL is required") - .url("Please enter a valid URL") - .refine( - (url) => /^https?:\/\//i.test(url), - "Rocket.Chat webhook URL must use HTTP or HTTPS" - ), + .url({ protocol: /^https?$/, message: "Please enter a valid URL" }), }); const pagerDutySchema = baseSchema.extend({ diff --git a/server/src/api/validation/notificationValidation.ts b/server/src/api/validation/notificationValidation.ts index fea35e89d..e883b9006 100644 --- a/server/src/api/validation/notificationValidation.ts +++ b/server/src/api/validation/notificationValidation.ts @@ -27,9 +27,10 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ z.object({ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("rocket_chat"), - address: z - .url({ message: "Please enter a valid Rocket.Chat webhook URL" }) - .refine((url) => /^https?:\/\//i.test(url), { message: "Rocket.Chat webhook URL must use HTTP or HTTPS" }), + address: z.url({ + protocol: /^https?$/, + message: "Please enter a valid Rocket.Chat webhook URL", + }), }), // Slack notification z.object({ From 1c291bb89338eba28e05c2e8f1bdad43d530b556 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Diniz Date: Wed, 5 Aug 2026 08:35:56 -0300 Subject: [PATCH 031/143] refactor(client): use raw channel types Refs #3821 --- .../Pages/Notifications/components/NotificationsTable.tsx | 8 +------- client/src/Pages/Notifications/create/index.tsx | 6 +----- client/src/locales/en.json | 1 - 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/client/src/Pages/Notifications/components/NotificationsTable.tsx b/client/src/Pages/Notifications/components/NotificationsTable.tsx index 0b41c3be0..a7a8170f6 100644 --- a/client/src/Pages/Notifications/components/NotificationsTable.tsx +++ b/client/src/Pages/Notifications/components/NotificationsTable.tsx @@ -65,13 +65,7 @@ export const NotificationsTable = ({ id: "type", content: t("common.table.headers.type"), render: (row) => { - return ( - - {row?.type === "rocket_chat" - ? t("pages.notifications.form.type.rocketChat") - : row?.type} - - ); + return {row?.type}; }, }, { diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 955add20f..84d264616 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -139,11 +139,7 @@ const NotificationsCreatePage = () => { key={type} value={type} > - - {type === "rocket_chat" - ? t("pages.notifications.form.type.rocketChat") - : type} - + {type} ))} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 87984495f..955e9727f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1164,7 +1164,6 @@ "type": { "description": "Select the type of notification channel to create.", "optionType": "Type", - "rocketChat": "Rocket.Chat", "title": "Channel type" }, "telegram": { From 6e1e5dd1a532790476a4703a135f707c8904ba1c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 5 Aug 2026 10:51:48 -0700 Subject: [PATCH 032/143] create bound components for text input and radio input --- .../inputs/forms/FormNumberField.tsx | 46 + .../inputs/forms/FormRadioGroup.tsx | 50 + .../Components/inputs/forms/FormTextField.tsx | 39 + client/src/Pages/CreateMonitor/index.tsx | 1984 ++++++++--------- client/src/Types/Monitor.ts | 28 + client/src/locales/en.json | 2 +- 6 files changed, 1088 insertions(+), 1061 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormNumberField.tsx create mode 100644 client/src/Components/inputs/forms/FormRadioGroup.tsx create mode 100644 client/src/Components/inputs/forms/FormTextField.tsx diff --git a/client/src/Components/inputs/forms/FormNumberField.tsx b/client/src/Components/inputs/forms/FormNumberField.tsx new file mode 100644 index 000000000..a78a92e46 --- /dev/null +++ b/client/src/Components/inputs/forms/FormNumberField.tsx @@ -0,0 +1,46 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { TextField } from "@/Components/inputs"; + +interface FormNumberFieldProps extends Omit< + React.ComponentProps, + "name" | "error" | "helperText" | "type" +> { + name: FieldPath; + emptyValue?: number; // what an empty input writes to the form; 0 keeps today's sentinel +} + +export const FormNumberField = ({ + name, + emptyValue = 0, + ...rest +}: FormNumberFieldProps) => { + const { control } = useFormContext(); + return ( + ( + { + const val = e.target.value; + field.onChange(val === "" ? emptyValue : Number(val)); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + fullWidth + {...rest} + /> + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormRadioGroup.tsx b/client/src/Components/inputs/forms/FormRadioGroup.tsx new file mode 100644 index 000000000..a751ff0ed --- /dev/null +++ b/client/src/Components/inputs/forms/FormRadioGroup.tsx @@ -0,0 +1,50 @@ +import { RadioGroup, useTheme } from "@mui/material"; +import FormControl from "@mui/material/FormControl"; +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { RadioWithDescription } from "@/Components/inputs/Radio"; +export interface FormRadioOption { + value: string; + label: string; + description: string; +} + +interface FormRadioGroupProps { + name: FieldPath; + options: FormRadioOption[]; +} +export const FormRadioGroup = ({ + name, + options, +}: FormRadioGroupProps) => { + const { control } = useFormContext(); + const theme = useTheme(); + return ( + ( + + + {options.map((option) => ( + + ))} + + + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormTextField.tsx b/client/src/Components/inputs/forms/FormTextField.tsx new file mode 100644 index 000000000..f923f6df4 --- /dev/null +++ b/client/src/Components/inputs/forms/FormTextField.tsx @@ -0,0 +1,39 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; + +import { TextField } from "@/Components/inputs"; + +interface FormTextFieldProps extends Omit< + React.ComponentProps, + "name" | "error" | "helperText" +> { + name: FieldPath; +} + +export const FormTextField = ({ + name, + ...rest +}: FormTextFieldProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 96b05be71..e3ef10cab 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -3,12 +3,10 @@ import { useEffect } from "react"; import { logger } from "@/Utils/logger"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; import { useParams, useLocation, useNavigate } from "react-router"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, Controller, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; -import RadioGroup from "@mui/material/RadioGroup"; -import FormControl from "@mui/material/FormControl"; import { Trans, useTranslation } from "react-i18next"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; @@ -28,7 +26,6 @@ import { import { RadioWithDescription, Button, - TextField, Select, Autocomplete, SwitchComponent as Switch, @@ -47,6 +44,8 @@ import { DefaultPageSpeedStrategy, DefaultHttpMethod, HttpMethods, + SelectableMonitorTypes, + monitorTypeLabelKey, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; @@ -55,6 +54,9 @@ import { monitorStepCount, type MonitorFormData, } from "@/Validation/monitor"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroup"; interface GeneralSettingsConfig { urlLabel: string; @@ -357,851 +359,494 @@ const CreateMonitorPage = () => { }; return ( - - - {!isEditMode && ( - + + - )} - {/* Monitor Type Selection - only shown for uptime monitors */} - {showTypeSelector && showStep(0) && ( - ( - - - - - - - - - - - - - )} - /> - } - /> - )} - - {showStep(0) && ( - - ), - }} - /> - } - rightContent={ - - {/* URL/Host/Container field - not shown for hardware */} - {generalSettingsConfig.showUrl && ( - ( - - )} - /> - )} - - {/* Strategy field - only for pagespeed type */} - {generalSettingsConfig.showStrategy && ( - ( - - )} - /> - )} - - {/* Port field - only for port and game types */} - {generalSettingsConfig.showPort && ( - ( - { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - type="number" - fieldLabel={t("pages.createMonitor.form.general.option.port.label")} - placeholder={t( - "pages.createMonitor.form.general.option.port.placeholder" - )} - fullWidth - error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} - /> - )} - /> - )} - - {/* Game select - only for game type */} - {generalSettingsConfig.showGameSelect && ( - ( - - )} - /> - )} - - {/* gRPC Service Name field - only for grpc type */} - {generalSettingsConfig.showGrpcServiceName && ( - ( - - )} - /> - )} - - {/* Secret field - only for hardware type */} - {generalSettingsConfig.showSecret && ( - ( - - )} - /> - )} - {generalSettingsConfig.showDnsServer && ( - ( - - )} - /> - )} - {generalSettingsConfig.showDnsRecordType && ( - ( - - )} - /> - )} - - ( - - )} - /> - - } - /> - )} - - {showStep(1) && ( - ( - - )} - /> - } - /> - )} - - {/* Alert Thresholds - only for hardware type */} - {generalSettingsConfig.showSecret && showStep(1) && ( - - ( - `${value}%`} - /> - )} - /> - ( - `${value}%`} - /> - )} - /> - ( - `${value}%`} - /> - )} - /> - ( - `${value}°C`} - /> - )} - /> - - } - /> - )} - - {showStep(1) && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - - {showStep(1) && ( - { - // Map notifications to have 'name' property for Autocomplete - const notificationOptions = (notifications ?? []).map((n) => ({ - ...n, - name: n.notificationName, - })); - const selectedNotifications = notificationOptions.filter((n) => - (field.value ?? []).includes(n.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof notificationOptions) => { - field.onChange(newValue.map((n) => n.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - /> - {selectedNotifications.length > 0 && ( - - {selectedNotifications.map((notification, index) => ( - - - {notification.notificationName} - - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== notification.id - ) - ); - }} - aria-label="Remove notification" - > - - - {index < selectedNotifications.length - 1 && } - - ))} - - )} - - ); - }} - /> - } - /> - )} - - {showStep(1) && ( - { - const tagOptions = tags ?? []; - const selectedTags = tagOptions.filter((tag) => - (field.value ?? []).includes(tag.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof tagOptions) => { - field.onChange(newValue.map((tag) => tag.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - renderOptionContent={(option) => ( - - )} - /> - {selectedTags.length > 0 && ( - - {selectedTags.map((tag, index) => ( - - -
- { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== tag.id - ) - ); - }} - aria-label="Remove tag" - > - - - {index < selectedTags.length - 1 && } - - ))} - - )} - - ); - }} - /> - } - /> - )} - - {showStep(2) && - (watchedType === "http" || - watchedType === "grpc" || - watchedType === "websocket") && ( + {!isEditMode && ( + + )} + {/* Monitor Type Selection - only shown for uptime monitors */} + {showTypeSelector && showStep(0) && ( ({ + value: type, + label: t( + `pages.common.monitors.monitorTypes.${monitorTypeLabelKey[type]}` + ), + description: t( + `pages.createMonitor.form.type.${monitorTypeLabelKey[type]}Description` + ), + }))} + /> + } + /> + )} + + {showStep(0) && ( + + ), + }} + /> + } + rightContent={ + + {/* URL/Host/Container field - not shown for hardware */} + {generalSettingsConfig.showUrl && ( + + )} + + {/* Strategy field - only for pagespeed type */} + {generalSettingsConfig.showStrategy && ( + ( + + )} + /> + )} + + {/* Port field - only for port and game types */} + {generalSettingsConfig.showPort && ( + + )} + + {/* Game select - only for game type */} + {generalSettingsConfig.showGameSelect && ( + ( + + )} + /> + )} + + {/* gRPC Service Name field - only for grpc type */} + {generalSettingsConfig.showGrpcServiceName && ( + + )} + + {/* Secret field - only for hardware type */} + {generalSettingsConfig.showSecret && ( + + )} + {generalSettingsConfig.showDnsServer && ( + + )} + {generalSettingsConfig.showDnsRecordType && ( + ( + + )} + /> + )} + + + } + /> + )} + + {showStep(1) && ( + ( - ( + )} /> } /> )} - {showStep(2) && watchedType === "http" && ( - - ( - - - - {t( - `pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}` + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} + /> + )} + /> + ( + - - )} - /> + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} + /> + )} + /> + ( + `${value}%`} + /> + )} + /> + ( + `${value}°C`} + /> + )} + /> + + } + /> + )} + + {showStep(1) && ( + + ( + + )} + /> + ( + + )} + /> + + } + /> + )} + + {showStep(1) && ( + { - const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) => - field.value?.includes(option.id) + // Map notifications to have 'name' property for Autocomplete + const notificationOptions = (notifications ?? []).map((n) => ({ + ...n, + name: n.notificationName, + })); + const selectedNotifications = notificationOptions.filter((n) => + (field.value ?? []).includes(n.id) ); return ( option.name} - isOptionEqualToValue={(option, value) => option.id === value.id} - onChange={( - _: unknown, - newValue: typeof ALL_HTTP_STATUS_CODES - ) => { - field.onChange(newValue?.map((v) => v.id) || []); + onChange={(_: unknown, newValue: typeof notificationOptions) => { + field.onChange(newValue.map((n) => n.id)); }} - fieldLabel={t( - "pages.createMonitor.form.advanced.option.customUpCodes.label" - )} + isOptionEqualToValue={(option, value) => option.id === value.id} /> - - {t( - "pages.createMonitor.form.advanced.option.customUpCodes.description" - )} - - {selectedOptions.length > 0 && ( + {selectedNotifications.length > 0 && ( - {selectedOptions.map((option, index) => ( + {selectedNotifications.map((notification, index) => ( - {option.name} + + {notification.notificationName} + { field.onChange( - field.value?.filter((id) => id !== option.id) + (field.value ?? []).filter( + (id: string) => id !== notification.id + ) ); }} - aria-label={t( - "pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel" - )} + aria-label="Remove notification" > - {index < selectedOptions.length - 1 && } + {index < selectedNotifications.length - 1 && } ))} @@ -1210,9 +855,96 @@ const CreateMonitorPage = () => { ); }} /> - {watchedMethod !== "HEAD" && ( + } + /> + )} + + {showStep(1) && ( + { + const tagOptions = tags ?? []; + const selectedTags = tagOptions.filter((tag) => + (field.value ?? []).includes(tag.id) + ); + return ( + + option.name} + onChange={(_: unknown, newValue: typeof tagOptions) => { + field.onChange(newValue.map((tag) => tag.id)); + }} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderOptionContent={(option) => ( + + )} + /> + {selectedTags.length > 0 && ( + + {selectedTags.map((tag, index) => ( + + +
+ { + field.onChange( + (field.value ?? []).filter( + (id: string) => id !== tag.id + ) + ); + }} + aria-label="Remove tag" + > + + + {index < selectedTags.length - 1 && } + + ))} + + )} + + ); + }} + /> + } + /> + )} + + {showStep(2) && + (watchedType === "http" || + watchedType === "grpc" || + watchedType === "websocket") && ( + ( { onChange={(e) => field.onChange(e.target.checked)} /> + {t("pages.createMonitor.form.ignoreTls.option.tls.label")} + + + )} + /> + } + /> + )} + + {showStep(2) && watchedType === "http" && ( + + ( + + + {t( - "pages.createMonitor.form.advanced.option.advancedMatching.label" + `pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}` )} )} /> - )} - {watchedUseAdvancedMatching && watchedMethod !== "HEAD" && ( - + { + const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) => + field.value?.includes(option.id) + ); + return ( + + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={( + _: unknown, + newValue: typeof ALL_HTTP_STATUS_CODES + ) => { + field.onChange(newValue?.map((v) => v.id) || []); + }} + fieldLabel={t( + "pages.createMonitor.form.advanced.option.customUpCodes.label" + )} + /> + + {t( + "pages.createMonitor.form.advanced.option.customUpCodes.description" + )} + + {selectedOptions.length > 0 && ( + + {selectedOptions.map((option, index) => ( + + {option.name} + { + field.onChange( + field.value?.filter((id) => id !== option.id) + ); + }} + aria-label={t( + "pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel" + )} + > + + + {index < selectedOptions.length - 1 && } + + ))} + + )} + + ); + }} + /> + {watchedMethod !== "HEAD" && ( ( - + + )} /> - ( - - )} - /> - ( - - )} - /> - - - ), - }} + )} + {watchedUseAdvancedMatching && watchedMethod !== "HEAD" && ( + + ( + + )} /> - - - )} - - } - /> - )} - {showStep(2) && supportsGeoCheck(watchedType) && ( - - ( - - field.onChange(e.target.checked)} + - - {t("pages.createMonitor.form.geoChecks.option.enabled.label")} + + + + + + ), + }} + /> )} - /> - {watchGeoCheckEnabled && ( - - { - // Map continents to have 'name' property for Autocomplete - const locationOptions = GeoContinents.map((continent) => ({ - id: continent, - name: t( - `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` - ), - })); - const selectedLocations = locationOptions.filter((loc) => - (field.value ?? []).includes(loc.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof locationOptions) => { - field.onChange(newValue.map((loc) => loc.id)); - }} - isOptionEqualToValue={(option, value) => - option.id === value.id - } - fieldLabel={t( - "pages.createMonitor.form.geoChecks.option.locations.label" - )} - /> - {selectedLocations.length > 0 && ( - - {selectedLocations.map((location, index) => ( - - {location.name} - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== location.id - ) - ); - }} - aria-label="Remove location" - > - - - {index < selectedLocations.length - 1 && } - - ))} - - )} - - ); - }} - /> - ( - - )} - /> - - )} - - } - /> - )} + + } + /> + )} - - {!isEditMode && ( - + {showStep(2) && supportsGeoCheck(watchedType) && ( + + ( + + field.onChange(e.target.checked)} + /> + + {t("pages.createMonitor.form.geoChecks.option.enabled.label")} + + + )} + /> + {watchGeoCheckEnabled && ( + + { + // Map continents to have 'name' property for Autocomplete + const locationOptions = GeoContinents.map((continent) => ({ + id: continent, + name: t( + `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` + ), + })); + const selectedLocations = locationOptions.filter((loc) => + (field.value ?? []).includes(loc.id) + ); + return ( + + option.name} + onChange={( + _: unknown, + newValue: typeof locationOptions + ) => { + field.onChange(newValue.map((loc) => loc.id)); + }} + isOptionEqualToValue={(option, value) => + option.id === value.id + } + fieldLabel={t( + "pages.createMonitor.form.geoChecks.option.locations.label" + )} + /> + {selectedLocations.length > 0 && ( + + {selectedLocations.map((location, index) => ( + + {location.name} + { + field.onChange( + (field.value ?? []).filter( + (id: string) => id !== location.id + ) + ); + }} + aria-label="Remove location" + > + + + {index < selectedLocations.length - 1 && } + + ))} + + )} + + ); + }} + /> + ( + + )} + /> + + )} + + } + /> )} - {!isEditMode && currentStep < totalSteps - 1 ? ( - - ) : ( - - )} - - - + + + {!isEditMode && ( + + )} + {!isEditMode && currentStep < totalSteps - 1 ? ( + + ) : ( + + )} + + + + ); }; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index b9aaf417a..1f3a59b1f 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -20,6 +20,34 @@ export const MonitorTypes = [ ] as const; export type MonitorType = (typeof MonitorTypes)[number]; +// The subset offered by the CreateMonitor type selector, in display order. +// pagespeed/hardware are set by their own pages; unknown is never selectable. +export const SelectableMonitorTypes = [ + "http", + "ping", + "docker", + "port", + "game", + "grpc", + "websocket", + "dns", +] as const; +export type SelectableMonitorType = (typeof SelectableMonitorTypes)[number]; + +// Irregular i18n key fragments shared by the type selector's label +// (pages.common.monitors.monitorTypes.) and description +// (pages.createMonitor.form.type.Description) keys. +export const monitorTypeLabelKey: Record = { + http: "optionHttp", + ping: "optionPing", + docker: "optionDocker", + port: "optionPort", + game: "optionGame", + grpc: "optionGrpc", + websocket: "optionWebSocket", + dns: "optionDns", +}; + export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; export type DnsRecordType = (typeof DnsRecordTypes)[number]; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 955e9727f..fa204fdd1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -583,7 +583,7 @@ }, "port": { "label": "Port to monitor", - "placeholder": 80 + "placeholder": "80" }, "grpcServiceName": { "label": "Service name", From c5ef61e64c81f1e8c8cb111bf3baa7ff2e11f804 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 5 Aug 2026 11:44:47 -0700 Subject: [PATCH 033/143] refactor radio and multi select autocomplete --- client/src/Components/inputs/AutoComplete.tsx | 10 +- .../inputs/forms/FormAutoCompleteField.tsx | 49 +++ .../inputs/forms/FormMultiSelectField.tsx | 93 ++++++ ...RadioGroup.tsx => FormRadioGroupField.tsx} | 0 client/src/Pages/CreateMonitor/index.tsx | 314 +++--------------- 5 files changed, 199 insertions(+), 267 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormAutoCompleteField.tsx create mode 100644 client/src/Components/inputs/forms/FormMultiSelectField.tsx rename client/src/Components/inputs/forms/{FormRadioGroup.tsx => FormRadioGroupField.tsx} (100%) diff --git a/client/src/Components/inputs/AutoComplete.tsx b/client/src/Components/inputs/AutoComplete.tsx index 4822a805f..9d3a8c7ef 100644 --- a/client/src/Components/inputs/AutoComplete.tsx +++ b/client/src/Components/inputs/AutoComplete.tsx @@ -15,6 +15,9 @@ type AutoCompleteInputProps = Omit< fieldLabel?: string; required?: boolean; renderOptionContent?: (option: any) => React.ReactNode; + placeholder?: string; + error?: boolean; + helperText?: string; }; export const AutoCompleteInput = ({ @@ -22,6 +25,9 @@ export const AutoCompleteInput = ({ required, renderInput, renderOptionContent, + placeholder, + error, + helperText, ...props }: AutoCompleteInputProps) => { const theme = useTheme(); @@ -30,7 +36,9 @@ export const AutoCompleteInput = ({ const defaultRenderInput = (params: any) => ( ); diff --git a/client/src/Components/inputs/forms/FormAutoCompleteField.tsx b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx new file mode 100644 index 000000000..104141b87 --- /dev/null +++ b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx @@ -0,0 +1,49 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { Autocomplete } from "@/Components/inputs"; + +export interface AutocompleteOption { + id: string; + name: string; +} + +interface FormAutocompleteProps { + name: FieldPath; + options: O[]; + fieldLabel?: string; + placeholder?: string; +} + +export const FormAutocomplete = ({ + name, + options, + fieldLabel, + placeholder, +}: FormAutocompleteProps) => { + const { control } = useFormContext(); + return ( + ( + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={options.find((o) => o.id === field.value) ?? null} + onChange={(_: unknown, newValue: O | null) => { + field.onChange(newValue?.id ?? ""); + }} + fieldLabel={fieldLabel} + placeholder={placeholder} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx new file mode 100644 index 000000000..02aec4c95 --- /dev/null +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -0,0 +1,93 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import Divider from "@mui/material/Divider"; +import { useTheme } from "@mui/material/styles"; +import { Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Autocomplete } from "@/Components/inputs"; +import { LAYOUT } from "@/Utils/Theme/constants"; + +export interface MultiSelectOption { + id: string | number; + name: string; +} + +interface FormMultiSelectFieldProps { + name: FieldPath; + options: O[]; + fieldLabel?: string; + renderRow?: (option: O) => React.ReactNode; + renderOptionContent?: (option: O) => React.ReactNode; +} + +export const FormMultiSelectField = ({ + name, + options, + fieldLabel, + renderRow, + renderOptionContent, +}: FormMultiSelectFieldProps) => { + const { control } = useFormContext(); + const theme = useTheme(); + const { t } = useTranslation(); + return ( + { + const selected = options.filter((o) => (field.value ?? []).includes(o.id)); + return ( + + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_: unknown, newValue: O[]) => { + field.onChange(newValue.map((o) => o.id)); + }} + fieldLabel={fieldLabel} + renderOptionContent={renderOptionContent} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + /> + {selected.map((option, index) => ( + + {renderRow ? ( + renderRow(option) + ) : ( + {option.name} + )} + { + field.onChange( + (field.value ?? []).filter((id: O["id"]) => id !== option.id) + ); + }} + aria-label={t("common.buttons.remove")} + > + + + {index < selected.length - 1 && } + + ))} + + ); + }} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormRadioGroup.tsx b/client/src/Components/inputs/forms/FormRadioGroupField.tsx similarity index 100% rename from client/src/Components/inputs/forms/FormRadioGroup.tsx rename to client/src/Components/inputs/forms/FormRadioGroupField.tsx diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index e3ef10cab..6989cd7a6 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -11,12 +11,10 @@ import { Trans, useTranslation } from "react-i18next"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import Link from "@mui/material/Link"; -import Divider from "@mui/material/Divider"; -import IconButton from "@mui/material/IconButton"; -import { Trash2 } from "lucide-react"; import { HeaderDeleteControls } from "@/Components/monitors"; import { GeoContinents } from "@/Types/GeoCheck"; +import Box from "@mui/material/Box"; import { BasePage, ColoredLabel, @@ -24,10 +22,8 @@ import { StepProgress, } from "@/Components/design-elements"; import { - RadioWithDescription, Button, Select, - Autocomplete, SwitchComponent as Switch, SliderWithLabel, Dialog, @@ -56,7 +52,8 @@ import { } from "@/Validation/monitor"; import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; import { FormTextField } from "@/Components/inputs/forms/FormTextField"; -import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroup"; +import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; interface GeneralSettingsConfig { urlLabel: string; @@ -358,6 +355,24 @@ const CreateMonitorPage = () => { logger.debug("Monitor creation validation errors", errors); }; + const notificationOptions = useMemo(() => { + return (notifications ?? []).map((n) => ({ + ...n, + name: n.notificationName, + })); + }, [notifications]); + + const locationOptions = useMemo( + () => + GeoContinents.map((continent) => ({ + id: continent, + name: t( + `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` + ), + })), + [t] + ); + return ( { title={t("pages.createMonitor.form.notifications.title")} subtitle={t("pages.createMonitor.form.notifications.description")} rightContent={ - { - // Map notifications to have 'name' property for Autocomplete - const notificationOptions = (notifications ?? []).map((n) => ({ - ...n, - name: n.notificationName, - })); - const selectedNotifications = notificationOptions.filter((n) => - (field.value ?? []).includes(n.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof notificationOptions) => { - field.onChange(newValue.map((n) => n.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - /> - {selectedNotifications.length > 0 && ( - - {selectedNotifications.map((notification, index) => ( - - - {notification.notificationName} - - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== notification.id - ) - ); - }} - aria-label="Remove notification" - > - - - {index < selectedNotifications.length - 1 && } - - ))} - - )} - - ); - }} + options={notificationOptions} /> } /> @@ -864,72 +822,23 @@ const CreateMonitorPage = () => { title={t("pages.createMonitor.form.tags.title")} subtitle={t("pages.createMonitor.form.tags.description")} rightContent={ - { - const tagOptions = tags ?? []; - const selectedTags = tagOptions.filter((tag) => - (field.value ?? []).includes(tag.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof tagOptions) => { - field.onChange(newValue.map((tag) => tag.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - renderOptionContent={(option) => ( - - )} - /> - {selectedTags.length > 0 && ( - - {selectedTags.map((tag, index) => ( - - -
- { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== tag.id - ) - ); - }} - aria-label="Remove tag" - > - - - {index < selectedTags.length - 1 && } - - ))} - - )} - - ); - }} + options={tags ?? []} + renderRow={(tag) => ( + + + + )} + renderOptionContent={(option) => ( + + )} /> } /> @@ -1016,77 +925,12 @@ const CreateMonitorPage = () => { )} /> - { - const selectedOptions = ALL_HTTP_STATUS_CODES.filter((option) => - field.value?.includes(option.id) - ); - return ( - - option.name} - isOptionEqualToValue={(option, value) => option.id === value.id} - onChange={( - _: unknown, - newValue: typeof ALL_HTTP_STATUS_CODES - ) => { - field.onChange(newValue?.map((v) => v.id) || []); - }} - fieldLabel={t( - "pages.createMonitor.form.advanced.option.customUpCodes.label" - )} - /> - - {t( - "pages.createMonitor.form.advanced.option.customUpCodes.description" - )} - - {selectedOptions.length > 0 && ( - - {selectedOptions.map((option, index) => ( - - {option.name} - { - field.onChange( - field.value?.filter((id) => id !== option.id) - ); - }} - aria-label={t( - "pages.createMonitor.form.advanced.option.customUpCodes.removeAriaLabel" - )} - > - - - {index < selectedOptions.length - 1 && } - - ))} - - )} - - ); - }} + options={ALL_HTTP_STATUS_CODES} /> + {watchedMethod !== "HEAD" && ( { /> {watchGeoCheckEnabled && ( - { - // Map continents to have 'name' property for Autocomplete - const locationOptions = GeoContinents.map((continent) => ({ - id: continent, - name: t( - `pages.createMonitor.form.geoChecks.option.locations.options.${continent}` - ), - })); - const selectedLocations = locationOptions.filter((loc) => - (field.value ?? []).includes(loc.id) - ); - return ( - - option.name} - onChange={( - _: unknown, - newValue: typeof locationOptions - ) => { - field.onChange(newValue.map((loc) => loc.id)); - }} - isOptionEqualToValue={(option, value) => - option.id === value.id - } - fieldLabel={t( - "pages.createMonitor.form.geoChecks.option.locations.label" - )} - /> - {selectedLocations.length > 0 && ( - - {selectedLocations.map((location, index) => ( - - {location.name} - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== location.id - ) - ); - }} - aria-label="Remove location" - > - - - {index < selectedLocations.length - 1 && } - - ))} - - )} - - ); - }} + options={locationOptions} + fieldLabel={t( + "pages.createMonitor.form.geoChecks.option.locations.label" + )} /> Date: Wed, 5 Aug 2026 11:51:20 -0700 Subject: [PATCH 034/143] fix label and description regression --- .../Components/inputs/forms/FormMultiSelectField.tsx | 12 ++++++++++++ client/src/Pages/CreateMonitor/index.tsx | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index 02aec4c95..e23981d87 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -23,6 +23,7 @@ interface FormMultiSelectFieldProps; options: O[]; fieldLabel?: string; + description?: string; renderRow?: (option: O) => React.ReactNode; renderOptionContent?: (option: O) => React.ReactNode; } @@ -31,6 +32,7 @@ export const FormMultiSelectField = ) => { @@ -47,6 +49,7 @@ export const FormMultiSelectField = option.name} @@ -59,6 +62,15 @@ export const FormMultiSelectField = + {description && ( + + {description} + + )} {selected.map((option, index) => ( { {watchedMethod !== "HEAD" && ( From da7e8a1ae8fe0a1f260f84ecbda4c9452155feb6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 5 Aug 2026 11:52:47 -0700 Subject: [PATCH 035/143] add missing remove button label --- client/src/locales/en.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index fa204fdd1..b708f0204 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -60,7 +60,8 @@ "exportToJSON": "Export to JSON", "importFromJSON": "Import from JSON", "clearFilters": "Clear filters", - "removeUser": "Remove user" + "removeUser": "Remove user", + "remove": "Remove" }, "charts": { "labels": { From 20d1d1407ba52deb7add3fa9e71b7229659769b8 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 5 Aug 2026 11:53:49 -0700 Subject: [PATCH 036/143] remove dead key --- client/src/locales/en.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b708f0204..dcbaf00f5 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -529,8 +529,7 @@ }, "customUpCodes": { "label": "Accepted status codes", - "description": "Add any non-2xx codes that should also be considered successful.", - "removeAriaLabel": "Remove status code" + "description": "Add any non-2xx codes that should also be considered successful." } }, "title": "Advanced settings" From 94f1f7e576313533ec001d91ca728e561974287c Mon Sep 17 00:00:00 2001 From: Glazzze <2050408913@qq.com> Date: Thu, 6 Aug 2026 11:41:01 +0800 Subject: [PATCH 037/143] docs(readme): clarify MongoDB requirement for single-image deployment --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 139df2372..f055f6f39 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,9 @@ Usage instructions can be found [here](https://checkmate.so/docs). ## Installation -The quickest way to run Checkmate is the reference Docker Compose file, which runs the all-in-one image (`ghcr.io/bluewave-labs/checkmate`) plus MongoDB: +The quickest way to run Checkmate is the reference Docker Compose file. It starts two services: the all-in-one Checkmate application image (`ghcr.io/bluewave-labs/checkmate`) and a separate MongoDB service. + +> **What “all-in-one” means:** the Checkmate application is packaged in a single image; MongoDB is not embedded in that image and remains required. The reference Compose file starts MongoDB for you. For custom deployments, configure `DB_CONNECTION_STRING` to use an external MongoDB instance. ```bash curl -O https://raw.githubusercontent.com/bluewave-labs/checkmate/master/docker/docker-compose.yaml From 7cab326ad379ddf7941994a3fdff942ba6efea20 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 10:17:32 -0700 Subject: [PATCH 038/143] refactor selects --- .../inputs/forms/FormSelectField.tsx | 60 +++ client/src/Hooks/useMonitorForm.ts | 10 +- client/src/Pages/CreateMonitor/index.tsx | 344 +++++------------- client/src/Types/Monitor.ts | 21 +- 4 files changed, 180 insertions(+), 255 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormSelectField.tsx diff --git a/client/src/Components/inputs/forms/FormSelectField.tsx b/client/src/Components/inputs/forms/FormSelectField.tsx new file mode 100644 index 000000000..78e42300a --- /dev/null +++ b/client/src/Components/inputs/forms/FormSelectField.tsx @@ -0,0 +1,60 @@ +import { + Controller, + useFormContext, + type FieldValues, + type FieldPath, +} from "react-hook-form"; +import MenuItem from "@mui/material/MenuItem"; +import { Select } from "@/Components/inputs"; + +export interface FormSelectOption { + value: string | number; + label: string; +} + +interface FormSelectProps extends Omit< + React.ComponentProps, + "name" | "error" | "value" | "onChange" +> { + name: FieldPath; + options: FormSelectOption[]; + fallbackValue?: string | number; // display value while the field is undefined + onValueChange?: (value: string | number) => void; // side effects, see Notes +} + +export const FormSelectField = ({ + name, + options, + fallbackValue, + onValueChange, + ...rest +}: FormSelectProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 9c6200d22..16314e8ca 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -1,6 +1,10 @@ import { useMemo } from "react"; import { monitorSchema, type MonitorFormData } from "@/Validation/monitor"; -import { DefaultHttpMethod, DefaultPageSpeedStrategy } from "@/Types/Monitor"; +import { + DefaultHttpMethod, + DefaultMonitorMatchMethod, + DefaultPageSpeedStrategy, +} from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor"; interface UseMonitorFormOptions { @@ -43,7 +47,7 @@ export const getMonitorDefaults = ( method: data?.method ?? DefaultHttpMethod, ignoreTlsErrors: data?.ignoreTlsErrors || false, useAdvancedMatching: data?.useAdvancedMatching || false, - matchMethod: data?.matchMethod || "", + matchMethod: data?.matchMethod || DefaultMonitorMatchMethod, expectedValue: data?.expectedValue || "", jsonPath: data?.jsonPath || "", customUpCodes: data?.customUpCodes || [], @@ -138,7 +142,7 @@ export const getMonitorDefaults = ( method: DefaultHttpMethod, ignoreTlsErrors: false, useAdvancedMatching: false, - matchMethod: "", + matchMethod: DefaultMonitorMatchMethod, expectedValue: "", jsonPath: "", customUpCodes: [], diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index e0180095c..153e793f2 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -8,7 +8,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; import { Trans, useTranslation } from "react-i18next"; -import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import Link from "@mui/material/Link"; import { HeaderDeleteControls } from "@/Components/monitors"; @@ -23,7 +22,6 @@ import { } from "@/Components/design-elements"; import { Button, - Select, SwitchComponent as Switch, SliderWithLabel, Dialog, @@ -42,6 +40,12 @@ import { HttpMethods, SelectableMonitorTypes, monitorTypeLabelKey, + PageSpeedStrategies, + DnsRecordTypes, + MonitorIntervalOptions, + DefaultMonitorMatchMethod, + MonitorMatchMethods, + GeoCheckIntervalOptions, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; @@ -54,6 +58,7 @@ import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; import { FormTextField } from "@/Components/inputs/forms/FormTextField"; import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField"; import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; interface GeneralSettingsConfig { urlLabel: string; @@ -444,26 +449,18 @@ const CreateMonitorPage = () => { {/* Strategy field - only for pagespeed type */} {generalSettingsConfig.showStrategy && ( - ( - + fieldLabel={t( + "pages.createMonitor.form.general.option.strategy.label" )} + options={PageSpeedStrategies.map((strategy) => ({ + value: strategy, + label: t( + `pages.createMonitor.form.general.option.strategy.${strategy}` + ), + }))} + fallbackValue={DefaultPageSpeedStrategy} /> )} @@ -480,34 +477,21 @@ const CreateMonitorPage = () => { {/* Game select - only for game type */} {generalSettingsConfig.showGameSelect && ( - ( - - )} + fieldLabel={t("pages.createMonitor.form.general.option.game.label")} + options={[ + { + value: "", + label: t( + "pages.createMonitor.form.general.option.game.placeholder" + ), + }, + ...Object.entries(games ?? {}).map(([key, game]) => ({ + value: key, + label: game.name, + })), + ]} /> )} @@ -546,51 +530,17 @@ const CreateMonitorPage = () => { /> )} {generalSettingsConfig.showDnsRecordType && ( - ( - + fieldLabel={t( + "pages.createMonitor.form.general.option.dnsRecordType.label" )} + options={DnsRecordTypes.map((recordType) => ({ + value: recordType, + label: t( + `pages.createMonitor.form.general.option.dnsRecordType.value.${recordType}` + ), + }))} /> )} { title={t("pages.createMonitor.form.frequency.title")} subtitle={t("pages.createMonitor.form.frequency.description")} rightContent={ - ( - + fieldLabel={t( + "pages.createMonitor.form.frequency.option.frequency.label" )} + options={MonitorIntervalOptions.map((option) => ({ + value: option.value, + label: t( + `pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}` + ), + }))} /> } /> @@ -881,50 +778,33 @@ const CreateMonitorPage = () => { subtitle={t("pages.createMonitor.form.advanced.description")} rightContent={ - ( - - - - {t( - `pages.createMonitor.form.advanced.option.method.description.${field.value ?? DefaultHttpMethod}` - )} - - - )} + fieldLabel={t("pages.createMonitor.form.advanced.option.method.label")} + fallbackValue={DefaultHttpMethod} + onValueChange={(value) => { + // HEAD has no response body, reset advanced matching fields if selected + if (value === "HEAD") { + setValue("useAdvancedMatching", false); + setValue("matchMethod", DefaultMonitorMatchMethod); + setValue("expectedValue", ""); + setValue("jsonPath", ""); + } + }} + options={HttpMethods.map((method) => ({ + value: method, + label: method, + }))} /> + + {t( + `pages.createMonitor.form.advanced.option.method.description.${watchedMethod}` + )} + { )} {watchedUseAdvancedMatching && watchedMethod !== "HEAD" && ( - ( - + fieldLabel={t( + "pages.createMonitor.form.advanced.option.matchMethod.label" )} + fallbackValue={DefaultMonitorMatchMethod} + options={MonitorMatchMethods.map((method) => ({ + value: method, + label: t( + `pages.createMonitor.form.advanced.option.matchMethod.${method}` + ), + }))} /> { "pages.createMonitor.form.geoChecks.option.locations.label" )} /> - ( - + fieldLabel={t( + "pages.createMonitor.form.geoChecks.option.interval.label" )} + options={GeoCheckIntervalOptions.map((option) => ({ + value: option.value, + label: t( + `pages.createMonitor.form.geoChecks.option.interval.value.${option.labelKey}` + ), + }))} /> )} diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 1f3a59b1f..bf5e4f170 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -48,6 +48,23 @@ export const monitorTypeLabelKey: Record = { dns: "optionDns", }; +export const MonitorIntervalOptions = [ + { value: 15000, labelKey: "fifteenSeconds" }, + { value: 30000, labelKey: "thirtySeconds" }, + { value: 60000, labelKey: "oneMinute" }, + { value: 120000, labelKey: "twoMinutes" }, + { value: 180000, labelKey: "threeMinutes" }, + { value: 240000, labelKey: "fourMinutes" }, + { value: 300000, labelKey: "fiveMinutes" }, + { value: 600000, labelKey: "tenMinutes" }, + { value: 900000, labelKey: "fifteenMinutes" }, + { value: 1800000, labelKey: "thirtyMinutes" }, +] as const; + +export const GeoCheckIntervalOptions = MonitorIntervalOptions.filter( + ({ value }) => value >= 300000 +); + export const DnsRecordTypes = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] as const; export type DnsRecordType = (typeof DnsRecordTypes)[number]; @@ -70,7 +87,9 @@ export const MonitorStatuses = [ ] as const; export type MonitorStatus = (typeof MonitorStatuses)[number]; -export type MonitorMatchMethod = "equal" | "include" | "regex" | ""; +export const MonitorMatchMethods = ["equal", "include", "regex"] as const; +export type MonitorMatchMethod = (typeof MonitorMatchMethods)[number] | ""; +export const DefaultMonitorMatchMethod: MonitorMatchMethod = "equal"; export const PageSpeedStrategies = ["desktop", "mobile"] as const; export type PageSpeedStrategy = (typeof PageSpeedStrategies)[number]; From 226864079c88ee39776a8320f135759cfd8b94df Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 10:50:37 -0700 Subject: [PATCH 039/143] refactor sliders --- .../inputs/forms/FormSliderField.tsx | 34 +++++ client/src/Pages/CreateMonitor/index.tsx | 138 ++++++------------ 2 files changed, 79 insertions(+), 93 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormSliderField.tsx diff --git a/client/src/Components/inputs/forms/FormSliderField.tsx b/client/src/Components/inputs/forms/FormSliderField.tsx new file mode 100644 index 000000000..1d39d17ad --- /dev/null +++ b/client/src/Components/inputs/forms/FormSliderField.tsx @@ -0,0 +1,34 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { SliderWithLabel } from "@/Components/inputs"; + +interface FormSliderProps extends Omit< + React.ComponentProps, + "name" | "value" | "onChange" +> { + name: FieldPath; +} + +export const FormSliderField = ({ + name, + ...rest +}: FormSliderProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 153e793f2..715871aa5 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -20,12 +20,7 @@ import { ConfigBox, StepProgress, } from "@/Components/design-elements"; -import { - Button, - SwitchComponent as Switch, - SliderWithLabel, - Dialog, -} from "@/Components/inputs"; +import { Button, SwitchComponent as Switch, Dialog } from "@/Components/inputs"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; import { useMonitorForm, getMonitorDefaults } from "@/Hooks/useMonitorForm"; @@ -59,6 +54,7 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField"; import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField"; import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; interface GeneralSettingsConfig { urlLabel: string; @@ -581,77 +577,49 @@ const CreateMonitorPage = () => { subtitle={t("pages.createMonitor.form.thresholds.description")} rightContent={ - ( - `${value}%`} - /> + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.cpuThreshold.label" )} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} /> - ( - `${value}%`} - /> + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.memoryThreshold.label" )} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} /> - ( - `${value}%`} - /> + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.diskThreshold.label" )} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}%`} /> - ( - `${value}°C`} - /> + fieldLabel={t( + "pages.createMonitor.form.thresholds.option.tempThreshold.label" )} + min={0} + max={100} + step={1} + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}°C`} /> } @@ -664,37 +632,21 @@ const CreateMonitorPage = () => { subtitle={t("pages.createMonitor.form.incidents.description")} rightContent={ - ( - - )} + fieldLabel={t("pages.createMonitor.form.incidents.option.checks.label")} + min={1} + max={25} + valueLabelDisplay="auto" /> - ( - + fieldLabel={t( + "pages.createMonitor.form.incidents.option.percentage.label" )} + min={1} + max={100} + valueLabelDisplay="auto" /> } From c1e9fd6079cf14bf91211ec385fee2c64e9ac585 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 11:00:37 -0700 Subject: [PATCH 040/143] refactor sliders --- .../inputs/forms/FormSwitchField.tsx | 44 ++++++++++++ client/src/Pages/CreateMonitor/index.tsx | 68 ++++--------------- 2 files changed, 56 insertions(+), 56 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormSwitchField.tsx diff --git a/client/src/Components/inputs/forms/FormSwitchField.tsx b/client/src/Components/inputs/forms/FormSwitchField.tsx new file mode 100644 index 000000000..8d9357ce1 --- /dev/null +++ b/client/src/Components/inputs/forms/FormSwitchField.tsx @@ -0,0 +1,44 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { useTheme } from "@mui/material/styles"; +import { SwitchComponent } from "@/Components/inputs"; +import { SPACING } from "@/Utils/Theme/constants"; + +interface FormSwitchProps { + name: FieldPath; + label: string; +} + +export const FormSwitchField = ({ + name, + label, +}: FormSwitchProps) => { + const { control } = useFormContext(); + const theme = useTheme(); + + return ( + ( + + field.onChange(e.target.checked)} + /> + {label} + + )} + /> + ); +}; diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 715871aa5..b962565a4 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { logger } from "@/Utils/logger"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; import { useParams, useLocation, useNavigate } from "react-router"; -import { useForm, Controller, FormProvider } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; @@ -20,8 +20,8 @@ import { ConfigBox, StepProgress, } from "@/Components/design-elements"; -import { Button, SwitchComponent as Switch, Dialog } from "@/Components/inputs"; -import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; +import { Button, Dialog } from "@/Components/inputs"; +import { LAYOUT } from "@/Utils/Theme/constants"; import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; import { useMonitorForm, getMonitorDefaults } from "@/Hooks/useMonitorForm"; import { @@ -55,6 +55,7 @@ import { FormRadioGroup } from "@/Components/inputs/forms/FormRadioGroupField"; import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; +import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; interface GeneralSettingsConfig { urlLabel: string; @@ -262,7 +263,7 @@ const CreateMonitorPage = () => { resolver: zodResolver(schema), defaultValues: defaults, }); - const { control, watch, handleSubmit, clearErrors, trigger, reset, setValue } = form; + const { watch, handleSubmit, clearErrors, trigger, reset, setValue } = form; useEffect(() => { reset(defaults); @@ -701,24 +702,9 @@ const CreateMonitorPage = () => { title={t("pages.createMonitor.form.ignoreTls.title")} subtitle={t("pages.createMonitor.form.ignoreTls.description")} rightContent={ - ( - - field.onChange(e.target.checked)} - /> - - {t("pages.createMonitor.form.ignoreTls.option.tls.label")} - - - )} + label={t("pages.createMonitor.form.ignoreTls.option.tls.label")} /> } /> @@ -770,25 +756,10 @@ const CreateMonitorPage = () => { /> {watchedMethod !== "HEAD" && ( - ( - - field.onChange(e.target.checked)} - /> - - {t( - "pages.createMonitor.form.advanced.option.advancedMatching.label" - )} - - + label={t( + "pages.createMonitor.form.advanced.option.advancedMatching.label" )} /> )} @@ -853,24 +824,9 @@ const CreateMonitorPage = () => { subtitle={t("pages.createMonitor.form.geoChecks.description")} rightContent={ - ( - - field.onChange(e.target.checked)} - /> - - {t("pages.createMonitor.form.geoChecks.option.enabled.label")} - - - )} + label={t("pages.createMonitor.form.geoChecks.option.enabled.label")} /> {watchGeoCheckEnabled && ( From bc2115f57852a7c385a47c0d6e4d89c3f85588df Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 11:54:46 -0700 Subject: [PATCH 041/143] refacotr notification create page --- .../inputs/forms/FormDatePickerField.tsx | 43 ++ client/src/Hooks/useNotificationForm.ts | 35 +- .../src/Pages/Notifications/create/index.tsx | 563 ++++++------------ client/src/locales/en.json | 15 +- 4 files changed, 268 insertions(+), 388 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormDatePickerField.tsx diff --git a/client/src/Components/inputs/forms/FormDatePickerField.tsx b/client/src/Components/inputs/forms/FormDatePickerField.tsx new file mode 100644 index 000000000..c9b012c14 --- /dev/null +++ b/client/src/Components/inputs/forms/FormDatePickerField.tsx @@ -0,0 +1,43 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import dayjs from "dayjs"; +import { DatePicker } from "@/Components/inputs"; + +interface FormDatePickerFieldProps { + name: FieldPath; + fieldLabel?: string; + format?: string; + onValueChange?: (value: string) => void; // e.g. () => trigger("startDate") +} + +export const FormDatePickerField = ({ + name, + fieldLabel, + format = "YYYY-MM-DD", + onValueChange, +}: FormDatePickerFieldProps) => { + const { control } = useFormContext(); + return ( + ( + { + const next = date ? date.format(format) : ""; + field.onChange(next); + onValueChange?.(next); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message} + /> + )} + /> + ); +}; diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 0051ddafd..b4b3fad7d 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -8,10 +8,23 @@ interface UseNotificationFormOptions { } function buildDefaults(data: Notification | null): NotificationFormData { + // Every channel field defaults to "" (not undefined) so untouched required + // fields fail zod with their custom messages instead of a type error. + const base = { + notificationName: data?.notificationName || "", + address: "", + accessToken: "", + accountSid: "", + phone: "", + twilioPhoneNumber: "", + homeserverUrl: "", + roomId: "", + topic: "", + }; if (data?.type === "matrix") { return { + ...base, type: "matrix", - notificationName: data.notificationName || "", homeserverUrl: data.homeserverUrl || "", roomId: data.roomId || "", accessToken: data.accessToken || "", @@ -19,51 +32,51 @@ function buildDefaults(data: Notification | null): NotificationFormData { } if (data?.type === "telegram") { return { + ...base, type: "telegram", - notificationName: data.notificationName || "", address: data.address || "", accessToken: data.accessToken || "", }; } if (data?.type === "slack") { return { + ...base, type: "slack", - notificationName: data.notificationName || "", address: data.address || "", }; } if (data?.type === "discord") { return { + ...base, type: "discord", - notificationName: data.notificationName || "", address: data.address || "", }; } if (data?.type === "webhook") { return { + ...base, type: "webhook", - notificationName: data.notificationName || "", address: data.address || "", }; } if (data?.type === "pager_duty") { return { + ...base, type: "pager_duty", - notificationName: data.notificationName || "", address: data.address || "", }; } if (data?.type === "teams") { return { + ...base, type: "teams", - notificationName: data.notificationName || "", address: data.address || "", }; } if (data?.type === "twilio") { return { + ...base, type: "twilio", - notificationName: data.notificationName || "", accountSid: data.accountSid || "", accessToken: data.accessToken || "", phone: data.phone || "", @@ -72,24 +85,24 @@ function buildDefaults(data: Notification | null): NotificationFormData { } if (data?.type === "pushover") { return { + ...base, type: "pushover", - notificationName: data.notificationName || "", address: data.address || "", accessToken: data.accessToken || "", }; } if (data?.type === "ntfy") { return { + ...base, type: "ntfy", - notificationName: data.notificationName || "", address: data.address || "", topic: data.topic || "", }; } // Default: email (covers both data === null and data.type === "email") return { + ...base, type: "email", - notificationName: data?.notificationName || "", address: data?.address || "", }; } diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 84d264616..639391060 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -1,14 +1,12 @@ import { BasePage, ConfigBox } from "@/Components/design-elements"; -import { TextField, Select, Button } from "@/Components/inputs"; -import MenuItem from "@mui/material/MenuItem"; -import Typography from "@mui/material/Typography"; +import { Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import { useEffect, useMemo } from "react"; import { useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom"; -import { Controller, useForm } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { useNotificationForm } from "@/Hooks/useNotificationForm"; @@ -16,6 +14,8 @@ import type { NotificationFormData } from "@/Validation/notifications"; import type { Notification } from "@/Types/Notification"; import { useTranslation } from "react-i18next"; import { NotificationChannels } from "@/Types/Notification"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; const NotificationsCreatePage = () => { const { t } = useTranslation(); @@ -93,384 +93,195 @@ const NotificationsCreatePage = () => { }; return ( - - ( - - )} - /> - } - /> - ( - - )} - /> - } - /> - {watchedType !== "matrix" && - watchedType !== "telegram" && - watchedType !== "pushover" && - watchedType !== "twilio" && - watchedType !== "ntfy" && ( + + + + } + /> + ({ + value: channel, + label: t(`pages.notifications.form.type.value.${channel}`), + }))} + /> + } + /> + {watchedType !== "matrix" && + watchedType !== "telegram" && + watchedType !== "pushover" && + watchedType !== "twilio" && + watchedType !== "ntfy" && ( + + } + /> + )} + {watchedType === "ntfy" && ( ( - - )} - /> + + + + } /> )} - {watchedType === "ntfy" && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "telegram" && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "pushover" && ( - - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "twilio" && ( - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {watchedType === "matrix" && ( - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - - - - - + + + + + ); }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index dcbaf00f5..142ffc8c7 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1164,7 +1164,20 @@ "type": { "description": "Select the type of notification channel to create.", "optionType": "Type", - "title": "Channel type" + "title": "Channel type", + "value": { + "email": "Email", + "slack": "Slack", + "discord": "Discord", + "webhook": "Webhook", + "pager_duty": "PagerDuty", + "matrix": "Matrix", + "teams": "Teams", + "telegram": "Telegram", + "pushover": "Pushover", + "twilio": "Twilio", + "ntfy": "ntfy" + } }, "telegram": { "title": "Telegram configuration", From 05f265743bb2d26364dda5fc64e09c15983f3458 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 11:55:19 -0700 Subject: [PATCH 042/143] remove unsued --- client/src/Pages/Notifications/create/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 639391060..8063f16b6 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -39,7 +39,7 @@ const NotificationsCreatePage = () => { defaultValues: defaults, }); - const { control, watch, reset, handleSubmit, clearErrors, trigger, getValues } = form; + const { watch, reset, handleSubmit, clearErrors, trigger, getValues } = form; useEffect(() => { reset(defaults); From 08bd2d56f3e6c2aa68cc939173f0f934c7a68e6a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 12:31:20 -0700 Subject: [PATCH 043/143] refactor settings page --- .../inputs/forms/FormAutoCompleteField.tsx | 5 +- .../inputs/forms/FormSwitchField.tsx | 29 +- client/src/Pages/Settings/index.tsx | 1339 +++++++---------- 3 files changed, 606 insertions(+), 767 deletions(-) diff --git a/client/src/Components/inputs/forms/FormAutoCompleteField.tsx b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx index 104141b87..1dbc65118 100644 --- a/client/src/Components/inputs/forms/FormAutoCompleteField.tsx +++ b/client/src/Components/inputs/forms/FormAutoCompleteField.tsx @@ -18,7 +18,10 @@ interface FormAutocompleteProps({ +export const FormAutocompleteField = < + T extends FieldValues, + O extends AutocompleteOption, +>({ name, options, fieldLabel, diff --git a/client/src/Components/inputs/forms/FormSwitchField.tsx b/client/src/Components/inputs/forms/FormSwitchField.tsx index 8d9357ce1..ed65d99f2 100644 --- a/client/src/Components/inputs/forms/FormSwitchField.tsx +++ b/client/src/Components/inputs/forms/FormSwitchField.tsx @@ -13,11 +13,13 @@ import { SPACING } from "@/Utils/Theme/constants"; interface FormSwitchProps { name: FieldPath; label: string; + labelPlacement?: "start" | "end"; // left | right } export const FormSwitchField = ({ name, label, + labelPlacement = "end", }: FormSwitchProps) => { const { control } = useFormContext(); const theme = useTheme(); @@ -29,14 +31,27 @@ export const FormSwitchField = ({ render={({ field }) => ( - field.onChange(e.target.checked)} - /> - {label} + {labelPlacement === "start" ? ( + <> + {label} + field.onChange(e.target.checked)} + /> + + ) : ( + <> + field.onChange(e.target.checked)} + /> + {label} + + )} )} /> diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index e14366e0f..2ec402759 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -1,5 +1,5 @@ import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements"; -import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/inputs"; +import { Autocomplete, Select, Dialog } from "@/Components/inputs"; import { logger } from "@/Utils/logger"; import { LAYOUT } from "@/Utils/Theme/constants"; import { @@ -11,7 +11,7 @@ import { type SelectChangeEvent, } from "@mui/material"; import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { FormProvider, useForm, type FieldPath } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "react-i18next"; import { useSelector, useDispatch } from "react-redux"; @@ -22,8 +22,7 @@ import { useSettingsForm } from "@/Hooks/useSettingsForm"; import { useIsAdmin } from "@/Hooks/useIsAdmin.js"; import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings"; import { useState } from "react"; -import { Controller } from "react-hook-form"; -import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs"; +import { Button, FieldLabel } from "@/Components/inputs"; import { languageNames } from "@/Components/inputs/LanguageSelector"; import { Box, Typography } from "@mui/material"; import { useDelete } from "@/Hooks/UseApi"; @@ -39,6 +38,9 @@ import { import timezones from "@/Utils/timezones.json"; import type { RootState } from "@/Types/state"; import { CHECK_TTL_SENTINEL } from "@/Types/Check"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; +import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; interface Timezone { id: string; @@ -90,6 +92,26 @@ export const SettingsPage = () => { // Initialize form with schema and defaults const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings }); + const emailSwitches: { name: FieldPath; label: string }[] = [ + { + name: "systemEmailSecure", + label: t("pages.settings.form.email.option.secure.label"), + }, + { name: "systemEmailPool", label: t("pages.settings.form.email.option.pool.label") }, + { + name: "systemEmailIgnoreTLS", + label: t("pages.settings.form.email.option.ignoreTLS.label"), + }, + { + name: "systemEmailRequireTLS", + label: t("pages.settings.form.email.option.requireTLS.label"), + }, + { + name: "systemEmailRejectUnauthorized", + label: t("pages.settings.form.email.option.rejectUnauthorized.label"), + }, + ]; + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, @@ -280,347 +302,233 @@ export const SettingsPage = () => { const languages = Object.keys(i18n.options.resources || {}); return ( - - - option.name} - isOptionEqualToValue={(option: Timezone, value: Timezone) => - option.id === value.id - } - onChange={(_, newValue) => { - handleTimezoneChange(newValue as Timezone | null); - }} - fieldLabel={t("pages.settings.form.timezone.option.timezone.label")} - /> - } - /> - - - - - - - } - /> - {isAdmin && ( + + + - {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( - ( - - )} - /> - )} - - {isApiKeySet === true && apiKeyHasBeenReset === false && ( - - - {t("pages.settings.form.pagespeed.option.apiKey.labelSet")} - - - - )} - - } - /> - )} - - {/* URL Settings */} - ( - - )} - /> - } - /> - - {/* Clear All Stats */} - {isAdmin && ( - setIsStatsDialogOpen(true)} - > - {t("common.buttons.clear")} - - } - /> - )} - - {/* Check Retention */} - {isAdmin && ( - ( - field.onChange(value)} - valueLabelDisplay="auto" - valueLabelFormat={(value: number) => - value >= CHECK_TTL_SENTINEL - ? t("pages.settings.form.retention.option.days.unlimited") - : `${value}` - } - formatDisplayValue={(value: number) => - value >= CHECK_TTL_SENTINEL - ? t("pages.settings.form.retention.option.days.unlimited") - : `${value}` - } - /> - )} + option.name} + isOptionEqualToValue={(option: Timezone, value: Timezone) => + option.id === value.id + } + onChange={(_, newValue) => { + handleTimezoneChange(newValue as Timezone | null); + }} + fieldLabel={t("pages.settings.form.timezone.option.timezone.label")} /> } /> - )} - - {/* Global Thresholds */} - {isAdmin && ( - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> - ( - field.onChange(value)} - valueLabelDisplay="auto" - /> - )} - /> + + + + + } /> - )} - + {isAdmin && ( + + {(isApiKeySet === false || apiKeyHasBeenReset === true) && ( + + )} - {/* Email Settings - Admin Only */} - {isAdmin && ( - - - {(() => { - const address = form.watch("systemEmailAddress") || ""; - const displayName = form.watch("systemEmailDisplayName")?.trim(); - return ( - <> - - - {JSON.stringify( - { - host: form.watch("systemEmailHost") || "", - port: form.watch("systemEmailPort") || "", - secure: form.watch("systemEmailSecure") ?? false, - auth: { - user: form.watch("systemEmailUser") || address, - pass: "", - }, - name: form.watch("systemEmailConnectionHost") || "localhost", - pool: form.watch("systemEmailPool") ?? false, - tls: { - rejectUnauthorized: - form.watch("systemEmailRejectUnauthorized") ?? true, - ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, - requireTLS: form.watch("systemEmailRequireTLS") ?? false, - servername: form.watch("systemEmailTLSServername") || "", - }, - }, - null, - 2 - )} - + {isApiKeySet === true && apiKeyHasBeenReset === false && ( + + + {t("pages.settings.form.pagespeed.option.apiKey.labelSet")} + + - {address && ( + )} + + } + /> + )} + + {/* URL Settings */} + + } + /> + + {/* Clear All Stats */} + {isAdmin && ( + setIsStatsDialogOpen(true)} + > + {t("common.buttons.clear")} + + } + /> + )} + + {/* Check Retention */} + {isAdmin && ( + + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + formatDisplayValue={(value: number) => + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + /> + } + /> + )} + + {/* Global Thresholds */} + {isAdmin && ( + + + + + + + + } + /> + )} + + + {/* Email Settings - Admin Only */} + {isAdmin && ( + + + {(() => { + const address = form.watch("systemEmailAddress") || ""; + const displayName = form.watch("systemEmailDisplayName")?.trim(); + return ( + <> { }} > - {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} + {JSON.stringify( + { + host: form.watch("systemEmailHost") || "", + port: form.watch("systemEmailPort") || "", + secure: form.watch("systemEmailSecure") ?? false, + auth: { + user: form.watch("systemEmailUser") || address, + pass: "", + }, + name: + form.watch("systemEmailConnectionHost") || "localhost", + pool: form.watch("systemEmailPool") ?? false, + tls: { + rejectUnauthorized: + form.watch("systemEmailRejectUnauthorized") ?? true, + ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, + requireTLS: form.watch("systemEmailRequireTLS") ?? false, + servername: form.watch("systemEmailTLSServername") || "", + }, + }, + null, + 2 + )} - )} - - ); - })()} - - } - rightContent={ - - {/* Email Host */} - ( - - )} - /> - - {/* Email Port */} - ( - { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - fieldLabel={t("pages.settings.form.email.option.port.label")} - type="number" - inputProps={{ min: 0 }} - placeholder={t("pages.settings.form.email.option.port.placeholder")} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - - {/* Email Address */} - ( - - )} - /> - - {/* Email Display Name (Optional) */} - ( - - )} - /> - - {/* Email User (Optional) */} - ( - - )} - /> - - {/* Email Password with Reset Pattern */} - {isEmailPasswordSet && !emailPasswordHasBeenReset ? ( - - - {t("pages.settings.form.email.option.password.labelSet")} - - - - - - ) : ( - ( - + + {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} + + )} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> + + ); + })()} + + } + rightContent={ + + {/* Email Host */} + + + {/* Email Port */} + + + {/* Email Address */} + + + {/* Email Display Name (Optional) */} + - )} - {/* TLS Servername (Optional) */} - ( - - )} - /> + {/* Email User (Optional) */} + - {/* Connection Host (Optional) */} - ( - - )} - /> - - {/* Boolean Switches */} - - {[ - { - name: "systemEmailSecure", - label: t("pages.settings.form.email.option.secure.label"), - }, - { - name: "systemEmailPool", - label: t("pages.settings.form.email.option.pool.label"), - }, - { - name: "systemEmailIgnoreTLS", - label: t("pages.settings.form.email.option.ignoreTLS.label"), - }, - { - name: "systemEmailRequireTLS", - label: t("pages.settings.form.email.option.requireTLS.label"), - }, - { - name: "systemEmailRejectUnauthorized", - label: t("pages.settings.form.email.option.rejectUnauthorized.label"), - }, - ].map(({ name, label }) => ( - ( - + + {t("pages.settings.form.email.option.password.labelSet")} + + + + + + ) : ( + - ))} - + )} - {/* Test Email Button */} + {/* TLS Servername (Optional) */} + + + {/* Connection Host (Optional) */} + + + {/* Boolean Switches */} + + {emailSwitches.map(({ name, label }) => ( + + ))} + + + {/* Test Email Button */} + + + + + } + /> + )} + + {/* Demo Monitors - Admin Only */} + {isAdmin && ( + - - } - /> - )} - - {/* Demo Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> - )} - - {/* Remove All Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> - )} - - {/* Export Monitors - Admin Only */} - {isAdmin && ( - - - - - - } - /> - )} - - {/* About */} - - - {t("common.appName")} {__APP_VERSION__} - - - {t("pages.settings.form.about.developedBy")} - - - https://github.com/bluewave-labs/checkmate - - - } - /> - - {/* Clear Stats Confirmation Dialog */} - setIsStatsDialogOpen(false)} - onConfirm={handleClearStats} - loading={isDeletingStats} - /> - - {/* Delete All Monitors Confirmation Dialog */} - setIsDemoMonitorsDialogOpen(false)} - onConfirm={async () => { - await deleteAllMonitors("/monitors/"); - setIsDemoMonitorsDialogOpen(false); - }} - loading={isDeletingAllMonitors} - confirmColor="error" - confirmText={t("common.buttons.removeMonitors")} - > - - {t("pages.settings.form.removeMonitors.dialog.paragraph")} - - - - - {/* Validation Error Display */} - {Object.keys(form.formState.errors).length > 0 && ( - - - {t("pages.settings.form.validation.errorMessage")} - - - {Object.entries(form.formState.errors).map(([field, error]) => { - const message = - typeof error === "object" && error?.message - ? error.message - : "Invalid value"; - return ( -
  • - - {field}: {message} - -
  • - ); - })} -
    -
    + } + /> )} - + + } + /> + )} + + {/* Export Monitors - Admin Only */} + {isAdmin && ( + + + + +
    + } + /> + )} + + {/* About */} + + + {t("common.appName")} {__APP_VERSION__} + + + {t("pages.settings.form.about.developedBy")} + + + https://github.com/bluewave-labs/checkmate + + + } + /> + + {/* Clear Stats Confirmation Dialog */} + setIsStatsDialogOpen(false)} + onConfirm={handleClearStats} + loading={isDeletingStats} + /> + + {/* Delete All Monitors Confirmation Dialog */} + setIsDemoMonitorsDialogOpen(false)} + onConfirm={async () => { + await deleteAllMonitors("/monitors/"); + setIsDemoMonitorsDialogOpen(false); + }} + loading={isDeletingAllMonitors} + confirmColor="error" + confirmText={t("common.buttons.removeMonitors")} > - {t("common.buttons.save")} - - -
    + + {t("pages.settings.form.removeMonitors.dialog.paragraph")} + +
    + + + {/* Validation Error Display */} + {Object.keys(form.formState.errors).length > 0 && ( + + + {t("pages.settings.form.validation.errorMessage")} + + + {Object.entries(form.formState.errors).map(([field, error]) => { + const message = + typeof error === "object" && error?.message + ? error.message + : "Invalid value"; + return ( +
  • + + {field}: {message} + +
  • + ); + })} +
    +
    + )} + + +
    + + ); }; From 6ceb28d3a11826db7b4a5fa5ebe4312be361dd5d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 12:33:35 -0700 Subject: [PATCH 044/143] remove dead keys --- client/src/locales/en.json | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 142ffc8c7..6a96090e4 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -721,17 +721,6 @@ } } }, - "url": { - "title": "Monitor IP/URL on Status Page", - "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", - "option": { - "showURL": { - "label": "Display IP/URL on status page", - "enabled": "Enabled", - "disabled": "Disabled" - } - } - }, "stats": { "title": "Monitor history", "description": "Clear all monitoring history and statistics for your team. This action is irreversible.", @@ -1308,9 +1297,7 @@ "description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.", "option": { "showURL": { - "label": "Display IP/URL on status page", - "enabled": "Enabled", - "disabled": "Disabled" + "label": "Display IP/URL on status page" } } }, From 52c80b9a397f4fca686758dccdc4b5405ee675be Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 14:40:21 -0700 Subject: [PATCH 045/143] refactor status page create page --- .../inputs/forms/FormCheckboxField.tsx | 38 + .../inputs/forms/FormColorField.tsx | 33 + .../inputs/forms/FormMultiSelectField.tsx | 124 ++- .../Components/inputs/forms/FormTextField.tsx | 4 +- client/src/Pages/StatusPage/Create/index.tsx | 711 ++++++------------ 5 files changed, 413 insertions(+), 497 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormCheckboxField.tsx create mode 100644 client/src/Components/inputs/forms/FormColorField.tsx diff --git a/client/src/Components/inputs/forms/FormCheckboxField.tsx b/client/src/Components/inputs/forms/FormCheckboxField.tsx new file mode 100644 index 000000000..237191b68 --- /dev/null +++ b/client/src/Components/inputs/forms/FormCheckboxField.tsx @@ -0,0 +1,38 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import { Checkbox } from "@/Components/inputs"; + +interface FormCheckboxFieldProps { + name: FieldPath; + label: string; +} + +export const FormCheckboxField = ({ + name, + label, +}: FormCheckboxFieldProps) => { + const { control } = useFormContext(); + + return ( + ( + field.onChange(e.target.checked)} + /> + } + label={label} + /> + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormColorField.tsx b/client/src/Components/inputs/forms/FormColorField.tsx new file mode 100644 index 000000000..a806ace51 --- /dev/null +++ b/client/src/Components/inputs/forms/FormColorField.tsx @@ -0,0 +1,33 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { ColorInput } from "@/Components/inputs"; + +interface FormColorFieldProps { + name: FieldPath; + fieldLabel?: string; +} + +export const FormColorField = ({ + name, + fieldLabel, +}: FormColorFieldProps) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index e23981d87..8a44a0121 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -9,10 +9,12 @@ import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import Divider from "@mui/material/Divider"; import { useTheme } from "@mui/material/styles"; -import { Trash2 } from "lucide-react"; +import { Trash2, GripVertical } from "lucide-react"; +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; +import type { DropResult } from "@hello-pangea/dnd"; import { useTranslation } from "react-i18next"; import { Autocomplete } from "@/Components/inputs"; -import { LAYOUT } from "@/Utils/Theme/constants"; +import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; export interface MultiSelectOption { id: string | number; @@ -26,6 +28,7 @@ interface FormMultiSelectFieldProps React.ReactNode; renderOptionContent?: (option: O) => React.ReactNode; + sortable?: boolean; } export const FormMultiSelectField = ({ @@ -35,6 +38,7 @@ export const FormMultiSelectField = ) => { const { control } = useFormContext(); const theme = useTheme(); @@ -44,7 +48,38 @@ export const FormMultiSelectField = { - const selected = options.filter((o) => (field.value ?? []).includes(o.id)); + const selectedIds: O["id"][] = field.value ?? []; + const selected = selectedIds + .map((id) => options.find((o) => o.id === id)) + .filter((o): o is O => o !== undefined); + + const handleDragEnd = (result: DropResult) => { + if (!result.destination) return; + const reordered = Array.from(selectedIds); + const [removed] = reordered.splice(result.source.index, 1); + reordered.splice(result.destination.index, 0, removed); + field.onChange(reordered); + }; + + const rowContent = (option: O) => + renderRow ? ( + renderRow(option) + ) : ( + {option.name} + ); + + const removeButton = (option: O) => ( + { + field.onChange(selectedIds.filter((id) => id !== option.id)); + }} + aria-label={t("common.buttons.remove")} + > + + + ); + return ( )} - {selected.map((option, index) => ( - - {renderRow ? ( - renderRow(option) - ) : ( - {option.name} - )} - { - field.onChange( - (field.value ?? []).filter((id: O["id"]) => id !== option.id) - ); - }} - aria-label={t("common.buttons.remove")} - > - - - {index < selected.length - 1 && } - - ))} + {sortable + ? selected.length > 0 && ( + + + {(provided) => ( + + {selected.map((option, index) => ( + + {(provided) => ( + + + {rowContent(option)} + {removeButton(option)} + + )} + + ))} + {provided.placeholder} + + )} + + + ) + : selected.map((option, index) => ( + + {rowContent(option)} + {removeButton(option)} + {index < selected.length - 1 && } + + ))} ); }} diff --git a/client/src/Components/inputs/forms/FormTextField.tsx b/client/src/Components/inputs/forms/FormTextField.tsx index f923f6df4..984821ed7 100644 --- a/client/src/Components/inputs/forms/FormTextField.tsx +++ b/client/src/Components/inputs/forms/FormTextField.tsx @@ -9,7 +9,7 @@ import { TextField } from "@/Components/inputs"; interface FormTextFieldProps extends Omit< React.ComponentProps, - "name" | "error" | "helperText" + "name" | "error" > { name: FieldPath; } @@ -29,7 +29,7 @@ export const FormTextField = ({ type="text" value={field.value ?? ""} error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} + helperText={fieldState.error?.message ?? rest.helperText ?? ""} fullWidth {...rest} /> diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 8dd119b72..05e5143e5 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -1,28 +1,14 @@ import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; import { logger } from "@/Utils/logger"; -import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; +import { LAYOUT } from "@/Utils/Theme/constants"; import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import { Trash2, GripVertical } from "lucide-react"; -import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; -import type { DropResult } from "@hello-pangea/dnd"; -import { - ImageUpload, - SwitchComponent, - Button, - TextField, - Autocomplete, - Checkbox, - Dialog, - ColorInput, -} from "@/Components/inputs"; +import { ImageUpload, Button, Dialog } from "@/Components/inputs"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import { useEffect, useMemo, useState } from "react"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider, useController } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import type { StatusPageFormData } from "@/Validation/statusPage"; @@ -37,6 +23,40 @@ import { mutate } from "swr"; import axios from "axios"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; import { ThemePicker } from "./Components/ThemePicker"; +import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; +import { FormAutocompleteField } from "@/Components/inputs/forms/FormAutoCompleteField"; +import { FormCheckboxField } from "@/Components/inputs/forms/FormCheckboxField"; +import { FormColorField } from "@/Components/inputs/forms/FormColorField"; + +const ThemePickerField = () => { + const { field: themeField } = useController({ + name: "theme", + }); + const { field: modeField } = useController({ + name: "themeMode", + }); + return ( + + ); +}; +const LogoUploadField = () => { + const { field } = useController({ name: "logo" }); + return ( + { + field.onChange(file ? { data: file.src, contentType: file.file.type } : null); + }} + /> + ); +}; const monitorsUrl = (() => { const params = new URLSearchParams(); @@ -51,6 +71,11 @@ interface TimezoneOption { name: string; } +const timezoneOptions = timezones.map((tz: TimezoneOption) => ({ + id: tz._id, + name: tz.name, +})); + const buildStatusPageKey = (slug: string | null | undefined) => slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null; @@ -67,7 +92,7 @@ const CreateStatusPage = () => { useGet(isCreate ? null : buildStatusPageKey(url)); const { data: monitorsResponse } = useGet(monitorsUrl); - const monitors = monitorsResponse ?? []; + const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]); const { post, loading: isSubmittingPost } = usePost(); const { put, loading: isSubmittingPut } = usePut(); @@ -87,7 +112,7 @@ const CreateStatusPage = () => { defaultValues: defaults, }); - const { control, reset, handleSubmit, trigger } = form; + const { reset, handleSubmit, trigger } = form; // Reset form when defaults change (from fetched data) useEffect(() => { @@ -108,7 +133,7 @@ const CreateStatusPage = () => { }; const handleBack = () => setCurrentStep((step) => step - 1); - const watchedMonitorIds: string[] = form.watch("monitors") ?? []; + const watchedMonitorIds: string[] = form.watch("monitors"); const computedTypes: MonitorDisplayType[] = useMemo(() => { const selectedMonitors = (watchedMonitorIds ?? []) .map((id) => monitors.find((m) => m.id === id)) @@ -124,7 +149,7 @@ const CreateStatusPage = () => { useEffect(() => { form.setValue("type", computedTypes); - }, [computedTypes]); + }, [computedTypes, form]); const onError = (errors: any) => { logger.debug("Status page validation errors", errors); @@ -216,470 +241,224 @@ const CreateStatusPage = () => { return Loading...; } return ( - - {!isCreate && } - {isCreate && ( - - )} - {showStep(0) && ( - - + + {!isCreate && } + {isCreate && ( + + )} + {showStep(0) && ( + ( - field.onChange(e.target.checked)} - /> - )} + label={t("pages.statusPages.form.access.option.published.name")} /> - - {t("pages.statusPages.form.access.option.published.name")} - - - } - /> - )} - {showStep(0) && ( - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - } - /> - )} - {showStep(0) && ( - { - const selectedMonitors = field.value - .map((id: string) => monitors.find((m) => m.id === id)) - .filter((m): m is Monitor => m !== undefined); - - const handleDragEnd = (result: DropResult) => { - if (!result.destination) return; - const reordered = Array.from(field.value); - const [removed] = reordered.splice(result.source.index, 1); - reordered.splice(result.destination.index, 0, removed); - field.onChange(reordered); - }; - - return ( - - option.name} - value={selectedMonitors} - onChange={(_, newValue) => { - field.onChange(newValue.map((m: Monitor) => m.id)); - }} - fieldLabel={t( - "pages.statusPages.form.monitors.option.monitors.label" - )} - renderInput={(params) => ( - - )} - /> - {selectedMonitors.length > 0 && ( - - - {(provided) => ( - - {selectedMonitors.map((monitor, index) => ( - - {(provided) => ( - - - {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} - { - field.onChange( - field.value.filter( - (id: string) => id !== monitor.id - ) - ); - }} - aria-label="Remove monitor" - > - - - - )} - - ))} - {provided.placeholder} - - )} - - - )} - - ); - }} - /> - } - /> - )} - {showStep(0) && ( - ( - option.name} - value={ - timezones.find((tz: TimezoneOption) => tz._id === field.value) ?? null - } - onChange={(_, newValue: TimezoneOption | null) => { - field.onChange(newValue?._id ?? ""); - }} - fieldLabel={t("pages.statusPages.form.timezone.option.timezone.label")} - renderInput={(params) => ( - + } + /> + )} + {showStep(0) && ( + + - )} - /> - } - /> - )} - {showStep(1) && ( - - - ( - { - if (file) { - field.onChange({ - data: file.src, - contentType: file.file.type, - }); - } else { - field.onChange(null); - } - }} - /> + + + - ( - + } + /> + )} + {showStep(0) && ( + ( + + {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} + )} /> - - } - /> - )} - {showStep(1) && ( - ( - ( - + } + /> + )} + {showStep(0) && ( + + } + /> + )} + {showStep(1) && ( + + + + + + + } + /> + )} + {showStep(1) && ( + } + /> + )} + {showStep(1) && ( + + } + /> + )} + {showStep(1) && ( + + + - )} - /> - } - /> - )} - {showStep(1) && ( - ( - - )} - /> - } - /> - )} - {showStep(1) && ( - - ( - - } - label={t("pages.statusPages.form.features.option.showCharts.label")} - /> - )} - /> - ( - - } - label={t( - "pages.statusPages.form.features.option.showInfrastructure.label" - )} - /> - )} - /> - {/* ( - - } + {/* + - )} - /> - ( - - } + + - )} - /> */} - - } + /> */} + + } + /> + )} + + {isCreate && ( + + )} + {isCreate && currentStep < totalSteps - 1 ? ( + + ) : ( + + )} + + - )} - - {isCreate && ( - - )} - {isCreate && currentStep < totalSteps - 1 ? ( - - ) : ( - - )} - - - + + ); }; From 0eff0522582f19f885c4efa4255af4c20323e6eb Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 6 Aug 2026 15:05:16 -0700 Subject: [PATCH 046/143] refactor maintenance create page --- client/src/Components/inputs/TimePicker.tsx | 33 +- .../inputs/forms/FormTimePickerField.tsx | 43 ++ client/src/Pages/Maintenance/create/index.tsx | 370 ++++++------------ 3 files changed, 177 insertions(+), 269 deletions(-) create mode 100644 client/src/Components/inputs/forms/FormTimePickerField.tsx diff --git a/client/src/Components/inputs/TimePicker.tsx b/client/src/Components/inputs/TimePicker.tsx index 251332fe1..b82df1f15 100644 --- a/client/src/Components/inputs/TimePicker.tsx +++ b/client/src/Components/inputs/TimePicker.tsx @@ -3,6 +3,7 @@ import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker import type { Dayjs } from "dayjs"; import { useTheme } from "@mui/material/styles"; import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; import { FieldLabel } from "./FieldLabel"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { LocalizationProvider } from "@mui/x-date-pickers"; @@ -10,11 +11,15 @@ import { LocalizationProvider } from "@mui/x-date-pickers"; interface TimePickerComponentProps extends Omit, "label"> { fieldLabel?: string; required?: boolean; + error?: boolean; + helperText?: string; } export const TimePickerComponent = ({ fieldLabel, required, + error, + helperText, ...props }: TimePickerComponentProps) => { const theme = useTheme(); @@ -33,12 +38,12 @@ export const TimePickerComponent = ({ px: theme.spacing(5), }, "& fieldset": { - borderColor: theme.palette.divider, + borderColor: error ? theme.palette.error.main : theme.palette.divider, borderRadius: theme.shape.borderRadius, }, "&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset": { - borderColor: theme.palette.divider, + borderColor: error ? theme.palette.error.main : theme.palette.divider, }, }, }, @@ -48,14 +53,18 @@ export const TimePickerComponent = ({ ); - if (fieldLabel) { - return ( - - {fieldLabel} - {picker} - - ); - } - - return picker; + return ( + + {fieldLabel && {fieldLabel}} + {picker} + {helperText && ( + + {helperText} + + )} + + ); }; diff --git a/client/src/Components/inputs/forms/FormTimePickerField.tsx b/client/src/Components/inputs/forms/FormTimePickerField.tsx new file mode 100644 index 000000000..2db830a50 --- /dev/null +++ b/client/src/Components/inputs/forms/FormTimePickerField.tsx @@ -0,0 +1,43 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import dayjs from "dayjs"; +import { TimePicker } from "@/Components/inputs"; + +interface FormTimePickerFieldProps { + name: FieldPath; + fieldLabel?: string; + format?: string; + onValueChange?: (value: string) => void; // e.g. () => trigger("startDate") +} + +export const FormTimePickerField = ({ + name, + fieldLabel, + format = "HH:mm", + onValueChange, +}: FormTimePickerFieldProps) => { + const { control } = useFormContext(); + return ( + ( + { + const next = time ? time.format(format) : ""; + field.onChange(next); + onValueChange?.(next); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message} + /> + )} + /> + ); +}; diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 20a0b461e..d01249e02 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -1,18 +1,8 @@ import Stack from "@mui/material/Stack"; -import MenuItem from "@mui/material/MenuItem"; import { logger } from "@/Utils/logger"; -import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; import { BasePage, ConfigBox } from "@/Components/design-elements"; -import { - TextField, - Select, - DatePicker, - TimePicker, - Button, - Autocomplete, -} from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; @@ -25,10 +15,15 @@ import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; import { mutate } from "swr"; import { useParams, useNavigate } from "react-router-dom"; import type { Monitor } from "@/Types/Monitor"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { useEffect } from "react"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; -import { Trash2 } from "lucide-react"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import { FormDatePickerField } from "@/Components/inputs/forms/FormDatePickerField"; +import { FormTimePickerField } from "@/Components/inputs/forms/FormTimePickerField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; const CreateMaintenanceWindowPage = () => { const theme = useTheme(); @@ -57,7 +52,7 @@ const CreateMaintenanceWindowPage = () => { defaultValues: defaults, }); - const { control, handleSubmit, trigger } = form; + const { handleSubmit, trigger } = form; useEffect(() => { if (existingMaintenanceWindow) { form.reset(defaults); @@ -114,255 +109,116 @@ const CreateMaintenanceWindowPage = () => { }; return ( - - - ( - - )} - /> - ( - - )} - /> - - } - /> - - ( - { - field.onChange(date ? date.format("YYYY-MM-DD") : ""); - trigger("startDate"); - }} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - - } - /> - - ( - { - field.onChange(time ? time.format("HH:mm") : ""); - trigger("startDate"); - }} - /> - )} - /> - - ( - { - const val = e.target.value; - field.onChange(val === "" ? 0 : Number(val)); - }} - error={!!fieldState.error} - helperText={fieldState.error?.message ?? ""} - sx={{ width: 120 }} - /> + + + + - ( - - )} + ({ + value: option.id, + label: option.name, + }))} + defaultValue={defaults.repeat} /> - - } - /> - { - const monitorsList = monitors ?? []; - const selectedMonitors = field.value - .map((id: string) => monitorsList.find((m) => m.id === id)) - .filter((m): m is Monitor => m !== undefined); - - return ( - - option.name} - value={selectedMonitors} - onChange={(_, newValue) => { - field.onChange(newValue.map((m: Monitor) => m.id)); - }} - fieldLabel={t( - "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" - )} - renderInput={(params) => ( - - )} - /> - {selectedMonitors.length > 0 && ( - - {selectedMonitors.map((monitor) => ( - - {monitor.name} - { - field.onChange( - field.value.filter((id: string) => id !== monitor.id) - ); - }} - aria-label="Remove monitor" - > - - - - ))} - + } + /> + + trigger("startDate")} + /> + + } + /> + + trigger("startDate")} + /> + + - ); - }} - /> - } - /> - - - - + + + + ); }; From 012e09d30cbc36e13489f1675067e860cecf2b43 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 09:47:11 -0700 Subject: [PATCH 047/143] refactor tags create page --- client/src/Pages/Tags/create/index.tsx | 104 +++++++++++-------------- 1 file changed, 44 insertions(+), 60 deletions(-) diff --git a/client/src/Pages/Tags/create/index.tsx b/client/src/Pages/Tags/create/index.tsx index 5e29096d0..dd50bf558 100644 --- a/client/src/Pages/Tags/create/index.tsx +++ b/client/src/Pages/Tags/create/index.tsx @@ -6,7 +6,7 @@ import { useTheme } from "@mui/material/styles"; import { useEffect } from "react"; import { useParams } from "react-router-dom"; import { useNavigate } from "react-router-dom"; -import { Controller, useForm } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { mutate } from "swr"; import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; @@ -14,6 +14,8 @@ import type { TagFormData } from "@/Validation/tag"; import type { Tag } from "@/Types/Tag"; import { useTranslation } from "react-i18next"; import { useTagsForm } from "@/Hooks/useTagsForm"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormColorField } from "@/Components/inputs/forms/FormColorField"; const TagsCreatePage = () => { const { t } = useTranslation(); @@ -51,67 +53,49 @@ const TagsCreatePage = () => { }; return ( - - ( - - )} - /> - } - /> - ( - - )} - /> - } - /> - - + - - - + + + + ); }; From 3df85b752265db04294505a6398781eaa89484a3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 09:54:08 -0700 Subject: [PATCH 048/143] refacotr add team member dialog --- .../components/AddTeamMemberDialog.tsx | 215 ++++++++---------- 1 file changed, 91 insertions(+), 124 deletions(-) diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx index aff0c3cf2..04a5655c9 100644 --- a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -2,14 +2,15 @@ import { Stack } from "@mui/material"; import MenuItem from "@mui/material/MenuItem"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider, useController } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; -import { Dialog, TextField, Select } from "@/Components/inputs"; +import { Dialog, Select } from "@/Components/inputs"; import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm"; import type { AddTeamMemberFormData } from "@/Validation/addTeamMember"; import type { UserRole, User } from "@/Types/User"; import { usePost } from "@/Hooks/UseApi"; import { LAYOUT } from "@/Utils/Theme/constants"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; interface AddTeamMemberDialogProps { open: boolean; @@ -25,6 +26,36 @@ interface RegisterPayload { role: string[]; } +const RoleSelectField = () => { + const { t } = useTranslation(); + const { field } = useController({ name: "role" }); + + const roleOptions: { value: UserRole; label: string }[] = [ + { value: "admin", label: t("common.auth.roles.admin") }, + { value: "user", label: t("common.auth.roles.user") }, + { value: "demo", label: t("common.auth.roles.demo") }, + ]; + + return ( + + ); +}; + export const AddTeamMemberDialog = ({ open, onClose, @@ -35,17 +66,13 @@ export const AddTeamMemberDialog = ({ const { schema, defaults } = useAddTeamMemberForm(); const { post, loading } = usePost(); - const { control, handleSubmit, reset, setError } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, values: defaults, }); - const roleOptions: { value: UserRole; label: string }[] = [ - { value: "admin", label: t("common.auth.roles.admin") }, - { value: "user", label: t("common.auth.roles.user") }, - { value: "demo", label: t("common.auth.roles.demo") }, - ]; + const { handleSubmit, reset, setError } = form; const handleClose = () => { reset(); @@ -72,124 +99,64 @@ export const AddTeamMemberDialog = ({ }; return ( - - - - ( - - )} - /> - ( - - )} - /> - - ( - + + + + - )} - /> - ( - - )} - /> - - ( - - )} - /> - ( - - )} + + + + + + + + + + + - - + + ); }; From 35f93815735f1eecbbf5c2cdf7c193e4da3a255d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:06:09 -0700 Subject: [PATCH 049/143] refactor register page --- client/src/Pages/Auth/Register/index.tsx | 174 +++++++++-------------- 1 file changed, 71 insertions(+), 103 deletions(-) diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 92c712bda..387d091f3 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -1,7 +1,7 @@ import { BaseAuthPage } from "@/Components/design-elements"; -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import Alert from "@mui/material/Alert"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useRegisterForm } from "@/Hooks/useRegisterForm"; import type { RegisterFormData } from "@/Validation/register"; @@ -12,6 +12,7 @@ import { useDispatch } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; import type { AuthResponse } from "@/Types/User"; import { useEffect, useRef, useState } from "react"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; interface RegisterPayload { user: Omit; @@ -45,10 +46,11 @@ const RegisterPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const { control, handleSubmit, setError, reset } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); + const { handleSubmit, setError, reset } = form; useEffect(() => { if (!token || hasVerified.current) return; @@ -64,14 +66,14 @@ const RegisterPage = () => { navigate("/register", { replace: true }); } }); - }, [token]); + }, [token, defaults, navigate, reset, verifyToken]); if (isCheckingAdmin) return null; const onSubmit = async (data: RegisterFormData) => { if (loading) return; - const { confirm, ...userData } = data; + const { confirm: _confirm, ...userData } = data; const payload: RegisterPayload = { user: userData }; if (token) { payload.token = token; @@ -89,105 +91,71 @@ const RegisterPage = () => { }; return ( - - {!token && ( - ({ - fontSize: 13, - lineHeight: 1.55, - color: theme.palette.text.secondary, - backgroundColor: theme.palette.action.hover, - border: `1px solid ${theme.palette.divider}`, - borderRadius: 1, - "& .MuiAlert-message": { padding: 0 }, - })} - > - {t("pages.auth.register.setupNotice")} - - )} - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - + {!token && ( + ({ + fontSize: 13, + lineHeight: 1.55, + color: theme.palette.text.secondary, + backgroundColor: theme.palette.action.hover, + border: `1px solid ${theme.palette.divider}`, + borderRadius: 1, + "& .MuiAlert-message": { padding: 0 }, + })} + > + {t("pages.auth.register.setupNotice")} + + )} + + + + + + + + + + + ); }; From 59267ef451830736b1d1f5f77ee0903464a91ce0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:08:27 -0700 Subject: [PATCH 050/143] refactor edit user --- client/src/Pages/Account/EditUser/index.tsx | 142 ++++++-------------- 1 file changed, 39 insertions(+), 103 deletions(-) diff --git a/client/src/Pages/Account/EditUser/index.tsx b/client/src/Pages/Account/EditUser/index.tsx index 86a52bbbb..a36d18028 100644 --- a/client/src/Pages/Account/EditUser/index.tsx +++ b/client/src/Pages/Account/EditUser/index.tsx @@ -1,11 +1,6 @@ -import { Trash2 } from "lucide-react"; -import { TextField, Button, Autocomplete } from "@/Components/inputs"; +import { TextField, Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import IconButton from "@mui/material/IconButton"; -import Divider from "@mui/material/Divider"; -import FormHelperText from "@mui/material/FormHelperText"; -import { useForm, Controller } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; import { ConfigBox, BasePage } from "@/Components/design-elements"; import { UserRoles } from "@/Types/User"; @@ -22,6 +17,8 @@ import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { LAYOUT } from "@/Utils/Theme/constants"; import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectField"; interface RoleOption { id: UserRole; @@ -43,10 +40,11 @@ const EditUserPage = () => { const { data: user, isLoading } = useGet(`/auth/users/${userId}`); - const { control, handleSubmit, reset, watch, setValue } = useForm({ + const form = useForm({ resolver, defaultValues: defaults, }); + const { handleSubmit, reset } = form; useEffect(() => { if (user) { @@ -64,13 +62,6 @@ const EditUserPage = () => { name: t(`common.auth.roles.${role}`), })); - const watchedRoles = watch("role"); - const selectedRoles = roleOptions.filter((r) => watchedRoles?.includes(r.id)); - const handleRemoveRole = (roleToRemove: UserRole) => { - const newRoles = watchedRoles.filter((role) => role !== roleToRemove); - setValue("role", newRoles, { shouldValidate: true }); - }; - const canDeleteUser = isAdmin && userId !== currentUser?.id && @@ -90,42 +81,31 @@ const EditUserPage = () => { }; return ( - -
    + + - ( - - )} + autoComplete="given-name" + fieldLabel={t("common.form.name.option.firstName.label")} + placeholder={t("common.form.name.option.firstName.placeholder")} /> - ( - - )} + autoComplete="family-name" + fieldLabel={t("common.form.name.option.lastName.label")} + placeholder={t("common.form.name.option.lastName.placeholder")} /> + { title={t("pages.editUser.form.roles.title")} subtitle={t("pages.editUser.form.roles.description")} rightContent={ - - ( - <> - option.name} - onChange={(_: unknown, newValue: RoleOption[]) => { - field.onChange(newValue.map((r) => r.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - /> - {fieldState.error && ( - {fieldState.error.message} - )} - - )} - /> - {selectedRoles.length > 0 && ( - - {selectedRoles.map((role, index) => ( - - {role.name} - handleRemoveRole(role.id)} - aria-label="Remove role" - > - - - {index < selectedRoles.length - 1 && } - - ))} - - )} - + } /> { - - setShowDeleteDialog(false)} - onConfirm={handleDeleteUser} - confirmText={t("common.buttons.removeUser")} - loading={isDeleting} - /> - + setShowDeleteDialog(false)} + onConfirm={handleDeleteUser} + confirmText={t("common.buttons.removeUser")} + loading={isDeleting} + /> +
    + ); }; From eaf93668f509eaf6a1d5ff1b0ca23a9df562c39e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:13:56 -0700 Subject: [PATCH 051/143] refactor password tab --- client/src/Pages/Account/TabPassword.tsx | 143 ++++++++++------------- 1 file changed, 61 insertions(+), 82 deletions(-) diff --git a/client/src/Pages/Account/TabPassword.tsx b/client/src/Pages/Account/TabPassword.tsx index d09a9eeea..ff9598aac 100644 --- a/client/src/Pages/Account/TabPassword.tsx +++ b/client/src/Pages/Account/TabPassword.tsx @@ -1,13 +1,15 @@ import { Stack } from "@mui/material"; import { ConfigBox } from "@/Components/design-elements"; -import { TextField, Button } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { usePasswordForm } from "@/Hooks/usePasswordForm"; import { usePatch } from "@/Hooks/UseApi"; import type { PasswordFormData } from "@/Validation/password"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { LAYOUT } from "@/Utils/Theme/constants"; export const TabPassword = () => { const { t } = useTranslation(); @@ -15,10 +17,11 @@ export const TabPassword = () => { const { resolver, defaults } = usePasswordForm(); const { patch, loading } = usePatch(); - const { control, handleSubmit, reset } = useForm({ + const form = useForm({ resolver, defaultValues: defaults, }); + const { handleSubmit, reset } = form; const onSubmit = async (data: PasswordFormData) => { const fd = new FormData(); @@ -32,85 +35,61 @@ export const TabPassword = () => { }; return ( - - ( - - )} - /> - } - /> - - ( - - )} - /> - ( - - )} - /> - - } - /> - - + + } + /> + + + + + } + /> + + + ); }; From 11a6ee71e9e46f76d2d9b27941932b9a2cebbe13 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:22:43 -0700 Subject: [PATCH 052/143] refacotr add team/invite team member --- .../components/AddTeamMemberDialog.tsx | 40 +---- .../components/InviteTeamMemberDialog.tsx | 144 +++++++----------- .../Account/components/RoleSelectField.tsx | 35 +++++ 3 files changed, 93 insertions(+), 126 deletions(-) create mode 100644 client/src/Pages/Account/components/RoleSelectField.tsx diff --git a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx index 04a5655c9..0e99af20f 100644 --- a/client/src/Pages/Account/components/AddTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/AddTeamMemberDialog.tsx @@ -1,16 +1,16 @@ import { Stack } from "@mui/material"; -import MenuItem from "@mui/material/MenuItem"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { useForm, FormProvider, useController } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; -import { Dialog, Select } from "@/Components/inputs"; +import { Dialog } from "@/Components/inputs"; import { useAddTeamMemberForm } from "@/Hooks/useAddTeamMemberForm"; import type { AddTeamMemberFormData } from "@/Validation/addTeamMember"; -import type { UserRole, User } from "@/Types/User"; +import type { User } from "@/Types/User"; import { usePost } from "@/Hooks/UseApi"; import { LAYOUT } from "@/Utils/Theme/constants"; import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { RoleSelectField } from "./RoleSelectField"; interface AddTeamMemberDialogProps { open: boolean; @@ -26,36 +26,6 @@ interface RegisterPayload { role: string[]; } -const RoleSelectField = () => { - const { t } = useTranslation(); - const { field } = useController({ name: "role" }); - - const roleOptions: { value: UserRole; label: string }[] = [ - { value: "admin", label: t("common.auth.roles.admin") }, - { value: "user", label: t("common.auth.roles.user") }, - { value: "demo", label: t("common.auth.roles.demo") }, - ]; - - return ( - - ); -}; - export const AddTeamMemberDialog = ({ open, onClose, @@ -82,7 +52,7 @@ export const AddTeamMemberDialog = ({ const onSubmit = async (data: AddTeamMemberFormData) => { if (loading) return; - const { confirm, ...userData } = data; + const { confirm: _confirm, ...userData } = data; const payload: RegisterPayload = userData; const result = await post("/auth/users", payload); diff --git a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx index d41bf3ca3..498456f03 100644 --- a/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx +++ b/client/src/Pages/Account/components/InviteTeamMemberDialog.tsx @@ -1,16 +1,16 @@ import { Stack } from "@mui/material"; -import MenuItem from "@mui/material/MenuItem"; -import { useTheme, FormHelperText, Typography } from "@mui/material"; +import { useTheme, Typography } from "@mui/material"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; -import { Dialog, TextField, Select, Button } from "@/Components/inputs"; -import type { UserRole } from "@/Types/User"; +import { useForm, FormProvider } from "react-hook-form"; +import { Dialog, TextField, Button } from "@/Components/inputs"; import { useInviteForm } from "@/Hooks/useInviteForm"; import type { InviteFormData } from "@/Validation/invite"; import { usePost } from "@/Hooks/UseApi"; import { LAYOUT } from "@/Utils/Theme/constants"; import { runtimeConfig } from "@/Utils/runtimeConfig"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { RoleSelectField } from "./RoleSelectField"; const CLIENT_HOST = runtimeConfig.clientHost || import.meta.env.VITE_APP_CLIENT_HOST; @@ -31,11 +31,12 @@ export const InviteTeamMemberDialog = ({ const { t } = useTranslation(); const { resolver, defaults } = useInviteForm(); - const { control, handleSubmit, reset } = useForm({ + const form = useForm({ resolver, defaultValues: defaults, values: defaults, }); + const { handleSubmit, reset } = form; const { post: generateToken, loading: generateLoading } = usePost< InviteFormData, @@ -47,12 +48,6 @@ export const InviteTeamMemberDialog = ({ >(); const [inviteLink, setInviteLink] = useState(null); - const roleOptions: { value: UserRole; label: string }[] = [ - { value: "admin", label: t("common.auth.roles.admin") }, - { value: "user", label: t("common.auth.roles.user") }, - { value: "demo", label: t("common.auth.roles.demo") }, - ]; - const handleGenerateToken = async (data: InviteFormData) => { const result = await generateToken("/invite", data); if (result?.data?.token) { @@ -76,88 +71,55 @@ export const InviteTeamMemberDialog = ({ }; return ( - - {t("common.buttons.generateToken")} - - } - > - - ( - - )} - /> - ( + + + {t("common.buttons.generateToken")} + + } + > + + + + + {inviteLink && ( <> - - {fieldState.error && ( - {fieldState.error.message} - )} + slotProps={{ + input: { + readOnly: true, + }, + }} + /> )} - /> - {inviteLink && ( - <> - - {t("pages.account.team.invite.linkLabel")} - - - - )} - - + + + ); }; diff --git a/client/src/Pages/Account/components/RoleSelectField.tsx b/client/src/Pages/Account/components/RoleSelectField.tsx new file mode 100644 index 000000000..1f117b0e4 --- /dev/null +++ b/client/src/Pages/Account/components/RoleSelectField.tsx @@ -0,0 +1,35 @@ +import MenuItem from "@mui/material/MenuItem"; +import { useController } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { Select } from "@/Components/inputs"; +import type { UserRole } from "@/Types/User"; + +export const RoleSelectField = () => { + const { t } = useTranslation(); + const { field } = useController<{ role: UserRole[] }, "role">({ name: "role" }); + + const roleOptions: { value: UserRole; label: string }[] = [ + { value: "admin", label: t("common.auth.roles.admin") }, + { value: "user", label: t("common.auth.roles.user") }, + { value: "demo", label: t("common.auth.roles.demo") }, + ]; + + return ( + + ); +}; From 350083d179e2e13c9606817af12873d69cb68011 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:25:04 -0700 Subject: [PATCH 053/143] refactor Profile tab --- client/src/Pages/Account/TabProfile.tsx | 116 +++++++++++------------- client/src/Pages/Tags/create/index.tsx | 4 +- 2 files changed, 54 insertions(+), 66 deletions(-) diff --git a/client/src/Pages/Account/TabProfile.tsx b/client/src/Pages/Account/TabProfile.tsx index 01e4f14b1..e799855c8 100644 --- a/client/src/Pages/Account/TabProfile.tsx +++ b/client/src/Pages/Account/TabProfile.tsx @@ -1,11 +1,11 @@ import { Stack } from "@mui/material"; import { ConfigBox } from "@/Components/design-elements"; -import { TextField, Button } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { ImageUpload } from "@/Components/inputs"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { useSelector, useDispatch } from "react-redux"; import { useProfileForm } from "@/Hooks/useProfileForm"; import { usePatch } from "@/Hooks/UseApi"; @@ -13,6 +13,8 @@ import { setUser } from "@/Features/Auth/authSlice"; import type { ProfileFormData } from "@/Validation/profile"; import type { RootState } from "@/Types/state"; import type { User } from "@/Types/User"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { LAYOUT } from "@/Utils/Theme/constants"; export const TabProfile = () => { const theme = useTheme(); @@ -22,7 +24,7 @@ export const TabProfile = () => { const { resolver, defaults } = useProfileForm(); const { patch, loading: patchLoading } = usePatch(); - const { control, handleSubmit, setValue, watch } = useForm({ + const form = useForm({ resolver, defaultValues: { firstName: user?.firstName ?? defaults.firstName, @@ -31,7 +33,7 @@ export const TabProfile = () => { deleteProfileImage: defaults.deleteProfileImage, }, }); - + const { handleSubmit, setValue, watch } = form; const currentImage = watch("profileImage"); const deleteImage = watch("deleteProfileImage"); @@ -74,66 +76,52 @@ export const TabProfile = () => { }; return ( - - - ( - - )} - /> - ( - - )} - /> - - } - /> - - } - /> - - + + + + + } + /> + + } + /> + + + ); }; diff --git a/client/src/Pages/Tags/create/index.tsx b/client/src/Pages/Tags/create/index.tsx index dd50bf558..484630c17 100644 --- a/client/src/Pages/Tags/create/index.tsx +++ b/client/src/Pages/Tags/create/index.tsx @@ -1,5 +1,5 @@ import { BasePage, ConfigBox } from "@/Components/design-elements"; -import { TextField, Button, ColorInput } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; @@ -36,7 +36,7 @@ const TagsCreatePage = () => { defaultValues: defaults, }); - const { control, reset, handleSubmit } = form; + const { reset, handleSubmit } = form; useEffect(() => { reset(defaults); From 69db716f0bdaa7ff9c0d07db900c13b946b347bd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:29:46 -0700 Subject: [PATCH 054/143] refactor login page --- client/src/Pages/Auth/Login/index.tsx | 88 ++++++++++++--------------- 1 file changed, 39 insertions(+), 49 deletions(-) diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index 9de5f1df5..f380b5b90 100644 --- a/client/src/Pages/Auth/Login/index.tsx +++ b/client/src/Pages/Auth/Login/index.tsx @@ -1,9 +1,9 @@ import { BaseAuthPage, TextLink } from "@/Components/design-elements"; -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useLoginForm } from "@/Hooks/useLoginForm"; import type { LoginFormData } from "@/Validation/login"; @@ -11,6 +11,7 @@ import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; import { setAuthState } from "@/Features/Auth/authSlice"; import { useLazyGet, usePost } from "@/Hooks/UseApi"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; const LoginPage = () => { const { t } = useTranslation(); @@ -31,11 +32,13 @@ const LoginPage = () => { const { schema, defaults } = useLoginForm(); - const { control, handleSubmit } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); + const { handleSubmit } = form; + if (isCheckingAdmin) return null; const onSubmit = async (data: LoginFormData) => { @@ -50,53 +53,40 @@ const LoginPage = () => { }; return ( - - ( - - )} - /> - ( - - )} - /> - - - + + + + + + ); }; From 5916d0ca33653f6b25051e914b2861fe8a8432c0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:32:01 -0700 Subject: [PATCH 055/143] refactor recovery and set new password --- client/src/Pages/Auth/Recovery/index.tsx | 67 ++++---- .../src/Pages/Auth/SetNewPassword/index.tsx | 144 ++++++++---------- 2 files changed, 98 insertions(+), 113 deletions(-) diff --git a/client/src/Pages/Auth/Recovery/index.tsx b/client/src/Pages/Auth/Recovery/index.tsx index d0eeaef06..05ada5821 100644 --- a/client/src/Pages/Auth/Recovery/index.tsx +++ b/client/src/Pages/Auth/Recovery/index.tsx @@ -1,12 +1,13 @@ -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import { BaseAuthPage, TextLink } from "@/Components/design-elements"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useRecoveryForm } from "@/Hooks/useRecoveryForm"; import type { RecoveryFormData } from "@/Validation/recovery"; import { usePost } from "@/Hooks/UseApi"; import { useTranslation } from "react-i18next"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; const ForgotPasswordPage = () => { const { t } = useTranslation(); @@ -14,10 +15,11 @@ const ForgotPasswordPage = () => { const { schema, defaults } = useRecoveryForm(); - const { control, handleSubmit } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); + const { handleSubmit } = form; const onSubmit = async (data: RecoveryFormData) => { if (loading) return; @@ -30,39 +32,34 @@ const ForgotPasswordPage = () => { }; return ( - - ( - - )} - /> - - - + + + + + ); }; diff --git a/client/src/Pages/Auth/SetNewPassword/index.tsx b/client/src/Pages/Auth/SetNewPassword/index.tsx index 5d70b4811..9ee2dee31 100644 --- a/client/src/Pages/Auth/SetNewPassword/index.tsx +++ b/client/src/Pages/Auth/SetNewPassword/index.tsx @@ -1,9 +1,9 @@ import { BaseAuthPage, BulletPointCheck, TextLink } from "@/Components/design-elements"; -import { Button, TextField } from "@/Components/inputs"; +import { Button } from "@/Components/inputs"; import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import { useForm, Controller } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { useSetNewPasswordForm } from "@/Hooks/useSetNewPasswordForm"; import type { SetNewPasswordFormData } from "@/Validation/setNewPassword"; @@ -12,6 +12,7 @@ import { useParams, useNavigate } from "react-router-dom"; import { usePost } from "@/Hooks/UseApi"; import { useDispatch } from "react-redux"; import { setAuthState } from "@/Features/Auth/authSlice"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; const SetNewPasswordPage = () => { const theme = useTheme(); @@ -23,11 +24,11 @@ const SetNewPasswordPage = () => { const { schema, defaults } = useSetNewPasswordForm(); - const { control, handleSubmit, watch } = useForm({ + const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); - + const { handleSubmit, watch } = form; const password = watch("password"); const confirm = watch("confirm"); @@ -57,81 +58,68 @@ const SetNewPasswordPage = () => { }; return ( - - ( - - )} - /> - ( - - )} - /> - - - - - - - - - - - + + + + + + + + + + + + + + + ); }; From d0c8da1653fa4185140a613cd6c265c2377d6112 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 10:41:16 -0700 Subject: [PATCH 056/143] remove dead default values --- client/src/Pages/Maintenance/create/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index d01249e02..21822b111 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -133,7 +133,6 @@ const CreateMaintenanceWindowPage = () => { value: option.id, label: option.name, }))} - defaultValue={defaults.repeat} /> } @@ -180,7 +179,6 @@ const CreateMaintenanceWindowPage = () => { ({ value: option.id, label: option.name, From ee25c6b47937a9ddc6a4f2e5d0d0904e08cac5c5 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Diniz Date: Thu, 6 Aug 2026 10:55:11 -0300 Subject: [PATCH 057/143] feat(notifications): enrich Rocket.Chat tests --- .../notifications/providers/rocketChat.ts | 28 ++++++++++++++++++- .../notifications/rocketChatProvider.test.ts | 22 +++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/server/src/domain/notifications/providers/rocketChat.ts b/server/src/domain/notifications/providers/rocketChat.ts index 345eec8c3..fb2d05003 100644 --- a/server/src/domain/notifications/providers/rocketChat.ts +++ b/server/src/domain/notifications/providers/rocketChat.ts @@ -4,6 +4,16 @@ import { NotificationProvider } from "@/domain/notifications/providers/INotifica import { getTestMessage } from "@/domain/notifications/providers/utils.js"; import got from "got"; +type RocketChatField = { title: string; value: string; short: boolean }; +type RocketChatAttachment = { + color: string; + title: string; + title_link?: string; + fields?: RocketChatField[]; + ts?: string; +}; +type RocketChatPayload = { text: string; attachments: [RocketChatAttachment] }; + export class RocketChatProvider extends NotificationProvider { async sendTestAlert(notification: Partial): Promise { if (!notification.address) { @@ -12,7 +22,7 @@ export class RocketChatProvider extends NotificationProvider { try { await got.post(notification.address, { - json: { text: getTestMessage() }, + json: this.buildTestPayload(), headers: { "Content-Type": "application/json", }, @@ -31,6 +41,22 @@ export class RocketChatProvider extends NotificationProvider { } } + private buildTestPayload(): RocketChatPayload { + return { + text: getTestMessage(), + attachments: [ + { + color: "#0000FF", + title: "Checkmate test notification", + fields: [ + { title: "Channel", value: "Rocket.Chat", short: true }, + { title: "Status", value: "Test notification", short: true }, + ], + }, + ], + }; + } + async sendMessage(notification: Notification, message: NotificationMessage): Promise { if (!notification.address) { return false; diff --git a/server/test/unit/providers/notifications/rocketChatProvider.test.ts b/server/test/unit/providers/notifications/rocketChatProvider.test.ts index ac9ffc4c1..077892353 100644 --- a/server/test/unit/providers/notifications/rocketChatProvider.test.ts +++ b/server/test/unit/providers/notifications/rocketChatProvider.test.ts @@ -25,7 +25,7 @@ testNotificationProviderContract("RocketChatProvider", { describe("RocketChatProvider", () => { beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); - it("sends a text-only test alert", async () => { + it("sends a rich test alert without webhook overrides", async () => { const { provider } = createProvider(); const result = await provider.sendTestAlert(makeNotification({ type: "rocket_chat" })); @@ -34,11 +34,29 @@ describe("RocketChatProvider", () => { expect(mockGotPost).toHaveBeenCalledWith( "https://hooks.example.com/webhook", expect.objectContaining({ - json: { text: "This is a test notification from Checkmate" }, + json: { + text: "This is a test notification from Checkmate", + attachments: [ + { + color: "#0000FF", + title: "Checkmate test notification", + fields: [ + { title: "Channel", value: "Rocket.Chat", short: true }, + { title: "Status", value: "Test notification", short: true }, + ], + }, + ], + }, timeout: { request: 30000 }, retry: { limit: 0 }, }) ); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload).not.toHaveProperty("alias"); + expect(payload).not.toHaveProperty("avatar"); + expect(payload).not.toHaveProperty("emoji"); + expect(payload).not.toHaveProperty("channel"); }); it("returns false when the test webhook URL is missing", async () => { From 2c780b8101e53aa8af05892ee9cfc74f5908abb8 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Diniz Date: Fri, 7 Aug 2026 08:34:01 -0300 Subject: [PATCH 058/143] feat(notifications): add Rocket.Chat attachments --- .../notifications/providers/rocketChat.ts | 60 +++++++------ .../notifications/rocketChatProvider.test.ts | 88 ++++++++----------- 2 files changed, 71 insertions(+), 77 deletions(-) diff --git a/server/src/domain/notifications/providers/rocketChat.ts b/server/src/domain/notifications/providers/rocketChat.ts index fb2d05003..821515b25 100644 --- a/server/src/domain/notifications/providers/rocketChat.ts +++ b/server/src/domain/notifications/providers/rocketChat.ts @@ -64,7 +64,7 @@ export class RocketChatProvider extends NotificationProvider { try { await got.post(notification.address, { - json: { text: this.buildText(message) }, + json: this.buildPayload(message), headers: { "Content-Type": "application/json", }, @@ -83,34 +83,38 @@ export class RocketChatProvider extends NotificationProvider { } } - private buildText(message: NotificationMessage): string { - const lines = [ - message.content.title, - message.content.summary, - "", - `Monitor: ${message.monitor.name}`, - `Type: ${message.monitor.type}`, - `Status: ${message.monitor.status}`, - `URL: ${message.monitor.url}`, - ]; + private buildPayload(message: NotificationMessage): RocketChatPayload { + return { + text: message.content.summary, + attachments: [ + { + color: this.severityColor(message.severity), + title: message.content.title, + title_link: `${message.clientHost}/infrastructure/${message.monitor.id}`, + fields: [ + { title: "Monitor", value: message.monitor.name, short: true }, + { title: "Type", value: message.monitor.type, short: true }, + { title: "Status", value: message.monitor.status, short: true }, + { title: "URL", value: message.monitor.url, short: false }, + ], + ts: message.content.timestamp.toISOString(), + }, + ], + }; + } - if (message.content.details?.length) { - lines.push("", "Details:", ...message.content.details.map((detail) => `- ${detail}`)); + private severityColor(severity: NotificationMessage["severity"]): string { + switch (severity) { + case "critical": + return "#FF0000"; + case "warning": + return "#FFA500"; + case "success": + return "#00FF00"; + case "info": + return "#0000FF"; + default: + return "#808080"; } - if (message.content.thresholds?.length) { - lines.push( - "", - "Thresholds:", - ...message.content.thresholds.map( - (threshold) => `- ${threshold.metric.toUpperCase()}: ${threshold.formattedValue} (threshold: ${threshold.threshold}${threshold.unit})` - ) - ); - } - - if (message.content.incident) { - lines.push("", `Incident: ${message.clientHost}/infrastructure/${message.monitor.id}`); - } - - return lines.join("\n"); } } diff --git a/server/test/unit/providers/notifications/rocketChatProvider.test.ts b/server/test/unit/providers/notifications/rocketChatProvider.test.ts index 077892353..401cac58b 100644 --- a/server/test/unit/providers/notifications/rocketChatProvider.test.ts +++ b/server/test/unit/providers/notifications/rocketChatProvider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; +import type { NotificationSeverity } from "../../../../src/domain/notifications/notification.type.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; -import { makeMessage, makeMessageWithIncident, makeMessageWithThresholds, makeNotification } from "../../../helpers/notificationMessage.ts"; +import { makeMessage, makeNotification } from "../../../helpers/notificationMessage.ts"; import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; const mockGotPost = jest.fn().mockResolvedValue({}); @@ -84,7 +85,7 @@ describe("RocketChatProvider", () => { ); }); - it("sends monitor details as a text-only message", async () => { + it("sends a rich monitor attachment without webhook overrides", async () => { const { provider } = createProvider(); const result = await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage()); @@ -96,22 +97,45 @@ describe("RocketChatProvider", () => { timeout: { request: 30000 }, retry: { limit: 0 }, json: { - text: [ - "Monitor Down: Test Monitor", - 'Monitor "Test Monitor" is currently down.', - "", - "Monitor: Test Monitor", - "Type: http", - "Status: down", - "URL: https://example.com", - "", - "Details:", - "- URL: https://example.com", - "- Status: Down", - ].join("\n"), + text: 'Monitor "Test Monitor" is currently down.', + attachments: [ + { + color: "#FF0000", + title: "Monitor Down: Test Monitor", + title_link: "https://app.example.com/infrastructure/mon-1", + fields: [ + { title: "Monitor", value: "Test Monitor", short: true }, + { title: "Type", value: "http", short: true }, + { title: "Status", value: "down", short: true }, + { title: "URL", value: "https://example.com", short: false }, + ], + ts: "2025-01-01T00:00:00.000Z", + }, + ], }, }) ); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload).not.toHaveProperty("alias"); + expect(payload).not.toHaveProperty("avatar"); + expect(payload).not.toHaveProperty("emoji"); + expect(payload).not.toHaveProperty("channel"); + }); + + it.each([ + ["critical", "#FF0000"], + ["warning", "#FFA500"], + ["success", "#00FF00"], + ["info", "#0000FF"], + ["unknown", "#808080"], + ])("maps %s severity to %s", async (severity, color) => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage({ severity: severity as NotificationSeverity })); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.attachments[0].color).toBe(color); }); it("returns false when the message webhook URL is missing", async () => { @@ -123,40 +147,6 @@ describe("RocketChatProvider", () => { expect(mockGotPost).not.toHaveBeenCalled(); }); - it("includes threshold breaches in the text", async () => { - const { provider } = createProvider(); - - await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithThresholds()); - - const payload = mockGotPost.mock.calls[0][1].json; - expect(payload.text).toContain("Thresholds:\n- CPU: 90.0% (threshold: 80%)\n- MEMORY: 85.0% (threshold: 70%)"); - }); - - it("includes a monitor-scoped link when incident data is present", async () => { - const { provider } = createProvider(); - - await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithIncident()); - - const payload = mockGotPost.mock.calls[0][1].json; - expect(payload.text).toContain("Incident: https://app.example.com/infrastructure/mon-1"); - }); - - it("omits the incident link when incident data is absent", async () => { - const { provider } = createProvider(); - - await provider.sendMessage( - makeNotification({ type: "rocket_chat" }), - makeMessage({ - type: "monitor_up", - severity: "success", - monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: "http", status: "up" }, - }) - ); - - const payload = mockGotPost.mock.calls[0][1].json; - expect(payload.text).not.toContain("Incident:"); - }); - it("returns false and logs when message delivery fails", async () => { mockGotPost.mockRejectedValue(new Error("network failure")); const { provider, logger } = createProvider(); From 865fa7cce6ef0026de30f4a98965af911aff2b16 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Diniz Date: Fri, 7 Aug 2026 11:48:27 -0300 Subject: [PATCH 059/143] feat(notifications): add rich alert fields --- .../notifications/providers/rocketChat.ts | 40 ++++++++++-- .../notifications/rocketChatProvider.test.ts | 65 ++++++++++++++++++- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/server/src/domain/notifications/providers/rocketChat.ts b/server/src/domain/notifications/providers/rocketChat.ts index 821515b25..217dfa795 100644 --- a/server/src/domain/notifications/providers/rocketChat.ts +++ b/server/src/domain/notifications/providers/rocketChat.ts @@ -84,6 +84,39 @@ export class RocketChatProvider extends NotificationProvider { } private buildPayload(message: NotificationMessage): RocketChatPayload { + const fields: RocketChatField[] = [ + { title: "Monitor", value: message.monitor.name, short: true }, + { title: "Type", value: message.monitor.type, short: true }, + { title: "Status", value: message.monitor.status, short: true }, + { title: "URL", value: message.monitor.url, short: false }, + ]; + + if (message.content.thresholds?.length) { + fields.push( + ...message.content.thresholds.map((breach) => ({ + title: breach.metric.toUpperCase(), + value: `${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`, + short: true, + })) + ); + } + + if (message.content.details?.length) { + fields.push({ + title: "Details", + value: message.content.details.map((detail) => `- ${detail}`).join("\n"), + short: false, + }); + } + + if (message.content.incident) { + fields.push({ + title: "Incident", + value: message.content.incident.url, + short: false, + }); + } + return { text: message.content.summary, attachments: [ @@ -91,12 +124,7 @@ export class RocketChatProvider extends NotificationProvider { color: this.severityColor(message.severity), title: message.content.title, title_link: `${message.clientHost}/infrastructure/${message.monitor.id}`, - fields: [ - { title: "Monitor", value: message.monitor.name, short: true }, - { title: "Type", value: message.monitor.type, short: true }, - { title: "Status", value: message.monitor.status, short: true }, - { title: "URL", value: message.monitor.url, short: false }, - ], + fields, ts: message.content.timestamp.toISOString(), }, ], diff --git a/server/test/unit/providers/notifications/rocketChatProvider.test.ts b/server/test/unit/providers/notifications/rocketChatProvider.test.ts index 401cac58b..7a5b1f2a5 100644 --- a/server/test/unit/providers/notifications/rocketChatProvider.test.ts +++ b/server/test/unit/providers/notifications/rocketChatProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; import type { NotificationSeverity } from "../../../../src/domain/notifications/notification.type.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; -import { makeMessage, makeNotification } from "../../../helpers/notificationMessage.ts"; +import { makeMessage, makeMessageWithIncident, makeMessageWithThresholds, makeNotification } from "../../../helpers/notificationMessage.ts"; import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; const mockGotPost = jest.fn().mockResolvedValue({}); @@ -108,6 +108,11 @@ describe("RocketChatProvider", () => { { title: "Type", value: "http", short: true }, { title: "Status", value: "down", short: true }, { title: "URL", value: "https://example.com", short: false }, + { + title: "Details", + value: "- URL: https://example.com\n- Status: Down", + short: false, + }, ], ts: "2025-01-01T00:00:00.000Z", }, @@ -138,6 +143,64 @@ describe("RocketChatProvider", () => { expect(payload.attachments[0].color).toBe(color); }); + it("appends threshold fields before details", async () => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithThresholds()); + + const fields = mockGotPost.mock.calls[0][1].json.attachments[0].fields; + expect(fields).toEqual([ + { title: "Monitor", value: "Infra Server", short: true }, + { title: "Type", value: "hardware", short: true }, + { title: "Status", value: "up", short: true }, + { title: "URL", value: "https://infra.example.com", short: false }, + { title: "CPU", value: "90.0% (threshold: 80%)", short: true }, + { title: "MEMORY", value: "85.0% (threshold: 70%)", short: true }, + { title: "Details", value: "- URL: https://infra.example.com", short: false }, + ]); + }); + + it("appends the incident URL after details", async () => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessageWithIncident()); + + const fields = mockGotPost.mock.calls[0][1].json.attachments[0].fields; + expect(fields).toEqual([ + { title: "Monitor", value: "Test Monitor", short: true }, + { title: "Type", value: "http", short: true }, + { title: "Status", value: "down", short: true }, + { title: "URL", value: "https://example.com", short: false }, + { title: "Details", value: "- URL: https://example.com\n- Status: Down", short: false }, + { title: "Incident", value: "https://app.example.com/incidents/inc-1", short: false }, + ]); + }); + + it("omits empty optional fields", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + + await provider.sendMessage( + makeNotification({ type: "rocket_chat" }), + makeMessage({ + content: { + ...message.content, + details: [], + thresholds: [], + incident: undefined, + }, + }) + ); + + const fields = mockGotPost.mock.calls[0][1].json.attachments[0].fields; + expect(fields).toEqual([ + { title: "Monitor", value: "Test Monitor", short: true }, + { title: "Type", value: "http", short: true }, + { title: "Status", value: "down", short: true }, + { title: "URL", value: "https://example.com", short: false }, + ]); + }); + it("returns false when the message webhook URL is missing", async () => { const { provider } = createProvider(); From 8d1fd61bb4446d7ed6bcd826e25f86691e9ef5ec Mon Sep 17 00:00:00 2001 From: Marcos Paulo Diniz Date: Fri, 7 Aug 2026 14:02:53 -0300 Subject: [PATCH 060/143] fix(notifications): omit empty incident links --- .../notifications/providers/rocketChat.ts | 2 +- .../notifications/rocketChatProvider.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/server/src/domain/notifications/providers/rocketChat.ts b/server/src/domain/notifications/providers/rocketChat.ts index 217dfa795..f5d224439 100644 --- a/server/src/domain/notifications/providers/rocketChat.ts +++ b/server/src/domain/notifications/providers/rocketChat.ts @@ -109,7 +109,7 @@ export class RocketChatProvider extends NotificationProvider { }); } - if (message.content.incident) { + if (message.content.incident?.url.trim()) { fields.push({ title: "Incident", value: message.content.incident.url, diff --git a/server/test/unit/providers/notifications/rocketChatProvider.test.ts b/server/test/unit/providers/notifications/rocketChatProvider.test.ts index 7a5b1f2a5..dcf1001d0 100644 --- a/server/test/unit/providers/notifications/rocketChatProvider.test.ts +++ b/server/test/unit/providers/notifications/rocketChatProvider.test.ts @@ -201,6 +201,24 @@ describe("RocketChatProvider", () => { ]); }); + it("omits the incident field when its URL is empty", async () => { + const { provider } = createProvider(); + const message = makeMessageWithIncident(); + + await provider.sendMessage( + makeNotification({ type: "rocket_chat" }), + makeMessage({ + content: { + ...message.content, + incident: { ...message.content.incident!, url: "" }, + }, + }) + ); + + const fields = mockGotPost.mock.calls[0][1].json.attachments[0].fields; + expect(fields).not.toContainEqual(expect.objectContaining({ title: "Incident" })); + }); + it("returns false when the message webhook URL is missing", async () => { const { provider } = createProvider(); From 2765ebfc3bdcd2c1d1bb9602208303dca1fb959d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 7 Aug 2026 11:50:47 -0700 Subject: [PATCH 061/143] bug fixes --- client/src/Components/inputs/AutoComplete.tsx | 4 +- .../inputs/forms/FormColorField.tsx | 4 +- .../inputs/forms/FormDatePickerField.tsx | 43 ---------- .../inputs/forms/FormDateTimePickerField.tsx | 81 +++++++++++++++++++ .../inputs/forms/FormMultiSelectField.tsx | 24 ++++-- .../Components/inputs/forms/FormTextField.tsx | 5 +- .../inputs/forms/FormTimePickerField.tsx | 43 ---------- client/src/Pages/Auth/Login/index.tsx | 1 - client/src/Pages/Auth/Recovery/index.tsx | 1 - client/src/Pages/Auth/Register/index.tsx | 1 - client/src/Pages/CreateMonitor/index.tsx | 2 +- client/src/Pages/Maintenance/create/index.tsx | 6 +- client/src/Pages/Settings/index.tsx | 3 +- client/src/Pages/StatusPage/Create/index.tsx | 3 + client/src/locales/en.json | 5 +- 15 files changed, 122 insertions(+), 104 deletions(-) delete mode 100644 client/src/Components/inputs/forms/FormDatePickerField.tsx create mode 100644 client/src/Components/inputs/forms/FormDateTimePickerField.tsx delete mode 100644 client/src/Components/inputs/forms/FormTimePickerField.tsx diff --git a/client/src/Components/inputs/AutoComplete.tsx b/client/src/Components/inputs/AutoComplete.tsx index 9d3a8c7ef..75e8daf47 100644 --- a/client/src/Components/inputs/AutoComplete.tsx +++ b/client/src/Components/inputs/AutoComplete.tsx @@ -6,6 +6,7 @@ import Stack from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; import { FieldLabel } from "./FieldLabel"; import { ChevronDown } from "lucide-react"; +import { useTranslation } from "react-i18next"; type AutoCompleteInputProps = Omit< AutocompleteProps, @@ -31,12 +32,13 @@ export const AutoCompleteInput = ({ ...props }: AutoCompleteInputProps) => { const theme = useTheme(); + const { t } = useTranslation(); const multiple = props.multiple; const { renderOption: customRenderOption, ...autocompleteProps } = props; const defaultRenderInput = (params: any) => ( diff --git a/client/src/Components/inputs/forms/FormColorField.tsx b/client/src/Components/inputs/forms/FormColorField.tsx index a806ace51..4d8578318 100644 --- a/client/src/Components/inputs/forms/FormColorField.tsx +++ b/client/src/Components/inputs/forms/FormColorField.tsx @@ -20,12 +20,14 @@ export const FormColorField = ({ ( + render={({ field, fieldState }) => ( )} /> diff --git a/client/src/Components/inputs/forms/FormDatePickerField.tsx b/client/src/Components/inputs/forms/FormDatePickerField.tsx deleted file mode 100644 index c9b012c14..000000000 --- a/client/src/Components/inputs/forms/FormDatePickerField.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - Controller, - useFormContext, - type FieldPath, - type FieldValues, -} from "react-hook-form"; -import dayjs from "dayjs"; -import { DatePicker } from "@/Components/inputs"; - -interface FormDatePickerFieldProps { - name: FieldPath; - fieldLabel?: string; - format?: string; - onValueChange?: (value: string) => void; // e.g. () => trigger("startDate") -} - -export const FormDatePickerField = ({ - name, - fieldLabel, - format = "YYYY-MM-DD", - onValueChange, -}: FormDatePickerFieldProps) => { - const { control } = useFormContext(); - return ( - ( - { - const next = date ? date.format(format) : ""; - field.onChange(next); - onValueChange?.(next); - }} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - ); -}; diff --git a/client/src/Components/inputs/forms/FormDateTimePickerField.tsx b/client/src/Components/inputs/forms/FormDateTimePickerField.tsx new file mode 100644 index 000000000..71a17afd0 --- /dev/null +++ b/client/src/Components/inputs/forms/FormDateTimePickerField.tsx @@ -0,0 +1,81 @@ +import { + Controller, + useFormContext, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import dayjs, { type Dayjs } from "dayjs"; +import { DatePicker, TimePicker } from "@/Components/inputs"; + +export interface FormPickerFieldProps { + name: FieldPath; + fieldLabel?: string; + format?: string; + onValueChange?: (value: string) => void; // e.g. () => trigger("startDate") +} + +interface DayjsPickerProps { + fieldLabel?: string; + value: Dayjs | null; + onChange: (value: Dayjs | null) => void; + error?: boolean; + helperText?: string; +} + +interface FormDayjsPickerFieldProps< + T extends FieldValues, +> extends FormPickerFieldProps { + picker: React.ComponentType; + format: string; +} + +const FormDayjsPickerField = ({ + name, + fieldLabel, + format, + onValueChange, + picker: Picker, +}: FormDayjsPickerFieldProps) => { + const { control } = useFormContext(); + return ( + ( + { + const next = value ? value.format(format) : ""; + field.onChange(next); + onValueChange?.(next); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message} + /> + )} + /> + ); +}; + +export const FormDatePickerField = ({ + format = "YYYY-MM-DD", + ...rest +}: FormPickerFieldProps) => ( + +); + +export const FormTimePickerField = ({ + format = "HH:mm", + ...rest +}: FormPickerFieldProps) => ( + +); diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index 8a44a0121..d38f75239 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { Controller, useFormContext, @@ -25,6 +26,7 @@ interface FormMultiSelectFieldProps; options: O[]; fieldLabel?: string; + placeholder?: string; description?: string; renderRow?: (option: O) => React.ReactNode; renderOptionContent?: (option: O) => React.ReactNode; @@ -35,6 +37,7 @@ export const FormMultiSelectField = (); const theme = useTheme(); const { t } = useTranslation(); + const optionsById = useMemo( + () => new Map(options.map((o) => [o.id, o])), + [options] + ); return ( { const selectedIds: O["id"][] = field.value ?? []; const selected = selectedIds - .map((id) => options.find((o) => o.id === id)) + .map((id) => optionsById.get(id)) .filter((o): o is O => o !== undefined); + // Drag indexes refer to the rendered (resolved) list, not the raw id + // array — ids missing from options are filtered out above, so reorder + // the rendered ids and re-append unresolved ids at the end. const handleDragEnd = (result: DropResult) => { if (!result.destination) return; - const reordered = Array.from(selectedIds); - const [removed] = reordered.splice(result.source.index, 1); - reordered.splice(result.destination.index, 0, removed); - field.onChange(reordered); + const reordered = selected.map((o) => o.id); + const [moved] = reordered.splice(result.source.index, 1); + reordered.splice(result.destination.index, 0, moved); + const unresolved = selectedIds.filter((id) => !optionsById.has(id)); + field.onChange([...reordered, ...unresolved]); }; const rowContent = (option: O) => @@ -74,7 +85,7 @@ export const FormMultiSelectField = { field.onChange(selectedIds.filter((id) => id !== option.id)); }} - aria-label={t("common.buttons.remove")} + aria-label={t("common.buttons.removeItem", { item: option.name })} > @@ -93,6 +104,7 @@ export const FormMultiSelectField = o.id)); }} fieldLabel={fieldLabel} + placeholder={placeholder} renderOptionContent={renderOptionContent} error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} diff --git a/client/src/Components/inputs/forms/FormTextField.tsx b/client/src/Components/inputs/forms/FormTextField.tsx index 984821ed7..3cda2a978 100644 --- a/client/src/Components/inputs/forms/FormTextField.tsx +++ b/client/src/Components/inputs/forms/FormTextField.tsx @@ -9,9 +9,10 @@ import { TextField } from "@/Components/inputs"; interface FormTextFieldProps extends Omit< React.ComponentProps, - "name" | "error" + "name" | "error" | "helperText" > { name: FieldPath; + helperText?: string; } export const FormTextField = ({ @@ -27,11 +28,11 @@ export const FormTextField = ({ )} /> diff --git a/client/src/Components/inputs/forms/FormTimePickerField.tsx b/client/src/Components/inputs/forms/FormTimePickerField.tsx deleted file mode 100644 index 2db830a50..000000000 --- a/client/src/Components/inputs/forms/FormTimePickerField.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - Controller, - useFormContext, - type FieldPath, - type FieldValues, -} from "react-hook-form"; -import dayjs from "dayjs"; -import { TimePicker } from "@/Components/inputs"; - -interface FormTimePickerFieldProps { - name: FieldPath; - fieldLabel?: string; - format?: string; - onValueChange?: (value: string) => void; // e.g. () => trigger("startDate") -} - -export const FormTimePickerField = ({ - name, - fieldLabel, - format = "HH:mm", - onValueChange, -}: FormTimePickerFieldProps) => { - const { control } = useFormContext(); - return ( - ( - { - const next = time ? time.format(format) : ""; - field.onChange(next); - onValueChange?.(next); - }} - error={!!fieldState.error} - helperText={fieldState.error?.message} - /> - )} - /> - ); -}; diff --git a/client/src/Pages/Auth/Login/index.tsx b/client/src/Pages/Auth/Login/index.tsx index f380b5b90..0aeb17dd8 100644 --- a/client/src/Pages/Auth/Login/index.tsx +++ b/client/src/Pages/Auth/Login/index.tsx @@ -62,7 +62,6 @@ const LoginPage = () => { > diff --git a/client/src/Pages/Auth/Recovery/index.tsx b/client/src/Pages/Auth/Recovery/index.tsx index 05ada5821..8680115af 100644 --- a/client/src/Pages/Auth/Recovery/index.tsx +++ b/client/src/Pages/Auth/Recovery/index.tsx @@ -41,7 +41,6 @@ const ForgotPasswordPage = () => { > diff --git a/client/src/Pages/Auth/Register/index.tsx b/client/src/Pages/Auth/Register/index.tsx index 387d091f3..af396e6c7 100644 --- a/client/src/Pages/Auth/Register/index.tsx +++ b/client/src/Pages/Auth/Register/index.tsx @@ -127,7 +127,6 @@ const RegisterPage = () => { /> { )} {/* Alert Thresholds - only for hardware type */} - {generalSettingsConfig.showSecret && showStep(1) && ( + {watchedType === "hardware" && showStep(1) && ( { /> {/* Email Port */} - { sortable name="monitors" fieldLabel={t("pages.statusPages.form.monitors.option.monitors.label")} + placeholder={t( + "pages.statusPages.form.monitors.option.monitors.placeholder" + )} options={monitors} renderRow={(monitor) => ( diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 6a96090e4..104a27b8f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -61,7 +61,7 @@ "importFromJSON": "Import from JSON", "clearFilters": "Clear filters", "removeUser": "Remove user", - "remove": "Remove" + "removeItem": "Remove {{item}}" }, "charts": { "labels": { @@ -105,6 +105,9 @@ "responseTime": "Response time" }, "form": { + "autocomplete": { + "placeholder": "Type to search" + }, "role": { "option": { "role": { From e5959ca91daf38da9461417a88d1ca7079aca499 Mon Sep 17 00:00:00 2001 From: colinjtsen2010 Date: Tue, 11 Aug 2026 00:34:21 +0800 Subject: [PATCH 062/143] feat: add domain registration expiry monitoring Adds a GET /monitors/domain/:monitorId endpoint that looks up the registrable domain's WHOIS expiry date (walking up from subdomains), and displays it next to the certificate expiry on the uptime details page. Fixes #2094 --- .../Components/monitors/MonitorStatBoxes.tsx | 16 ++- client/src/Pages/Uptime/Details/index.tsx | 29 ++++ client/src/locales/en.json | 1 + client/src/locales/zh-CN.json | 1 + server/openapi.json | 125 ++++++++++++++++++ server/openapi/routes/monitor.ts | 11 ++ server/package-lock.json | 13 ++ server/package.json | 1 + server/src/api/controllers/controllerUtils.ts | 61 +++++++++ .../src/api/controllers/monitorController.ts | 21 ++- server/src/api/routes/monitorRoutes.ts | 3 + .../src/api/validation/monitorValidation.ts | 4 + server/test/unit/utils/domainExpiry.test.ts | 52 ++++++++ 13 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 server/test/unit/utils/domainExpiry.test.ts diff --git a/client/src/Components/monitors/MonitorStatBoxes.tsx b/client/src/Components/monitors/MonitorStatBoxes.tsx index 58176dc04..8b201b474 100644 --- a/client/src/Components/monitors/MonitorStatBoxes.tsx +++ b/client/src/Components/monitors/MonitorStatBoxes.tsx @@ -11,12 +11,14 @@ interface MonitorStatBoxesProps { monitor?: Monitor; monitorStats: MonitorStats | null; certificateExpiry?: string; + domainExpiry?: string; } export const MonitorStatBoxes = ({ monitor, monitorStats, certificateExpiry, + domainExpiry, }: MonitorStatBoxesProps) => { const theme = useTheme(); const { t } = useTranslation(); @@ -66,10 +68,16 @@ export const MonitorStatBoxes = ({ /> {monitor?.type === "http" && ( - + <> + + + )} ); diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 56724259a..bcf7177ac 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -38,6 +38,11 @@ interface CertificateResponse { certificateDate: string; } +interface DomainResponse { + domain: string; + expiryDate: string; +} + const UptimeDetailsPage = () => { const theme = useTheme(); const isAdmin = useIsAdmin(); @@ -102,6 +107,29 @@ const UptimeDetailsPage = () => { ); }, [certificateData, uiTimezone]); + // Domain expiry fetch - only for HTTP monitors + const domainUrl = useMemo(() => { + if (!monitorId || monitor?.type !== "http") { + return null; + } + return `/monitors/domain/${monitorId}`; + }, [monitorId, monitor?.type]); + + const { data: domainData } = useGet( + domainUrl, + {}, + { revalidateOnFocus: false } + ); + + const domainExpiry = useMemo(() => { + if (!domainData?.expiryDate) { + return undefined; + } + return ( + formatDateWithTz(domainData.expiryDate, certificateDateFormat, uiTimezone) ?? "N/A" + ); + }, [domainData, uiTimezone]); + const checksUrl = useMemo(() => { if (!monitorId || !monitor?.type) { return null; @@ -193,6 +221,7 @@ const UptimeDetailsPage = () => { monitor={monitor} monitorStats={monitorStats} certificateExpiry={certificateExpiry} + domainExpiry={domainExpiry} /> = 8" } }, + "node_modules/whoiser": { + "version": "1.18.0", + "resolved": "https://registry.npmmirror.com/whoiser/-/whoiser-1.18.0.tgz", + "integrity": "sha512-QRIGreBuouc8d9i+UVMFqYJSiG7gaoaGX8nKugYDGqnuNLLgjDBwmlKODOIGHveBawza3Kfkk/OuM9VsTUYwaA==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=15.0.0" + } + }, "node_modules/winston": { "version": "3.18.3", "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", diff --git a/server/package.json b/server/package.json index e67bcdb78..f30495c5c 100755 --- a/server/package.json +++ b/server/package.json @@ -63,6 +63,7 @@ "sharp": "0.33.5", "ssl-checker": "2.0.10", "swagger-ui-express": "5.0.1", + "whoiser": "^1.18.0", "winston": "^3.13.0", "ws": "^8.19.0", "zod": "^4.3.6" diff --git a/server/src/api/controllers/controllerUtils.ts b/server/src/api/controllers/controllerUtils.ts index 1980ec0b4..7b014e656 100755 --- a/server/src/api/controllers/controllerUtils.ts +++ b/server/src/api/controllers/controllerUtils.ts @@ -2,7 +2,9 @@ import { AppError } from "@/utils/AppError.js"; import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js"; import { UserRole } from "@/domain/users/user.type.js"; import sslChecker, { SSLDetails } from "ssl-checker"; +import * as whoiser from "whoiser"; type SSLCheckerType = typeof sslChecker; +type WhoisModule = typeof whoiser; export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise => { const monitorUrl = new URL(monitor.url); @@ -13,6 +15,65 @@ export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: } return cert; }; + +const WHOIS_TIMEOUT_MS = 10_000; +const EXPIRY_DATE_KEY_PATTERN = /(expiry date|expiration|paid-?till|registration expiration)/i; + +export const extractDomainExpiryDate = (whoisData: object): string | null => { + const matchingKey = Object.keys(whoisData).find((key) => EXPIRY_DATE_KEY_PATTERN.test(key)); + if (!matchingKey) { + return null; + } + const raw = extractString((whoisData as Record)[matchingKey] ?? null); + if (!raw) { + return null; + } + + const parsed = parseDomainExpiryValue(raw); + return parsed === null ? null : parsed.toISOString(); +}; + +const parseDomainExpiryValue = (raw: string): Date | null => { + // Some registries (e.g. Nominet for .uk) return "14-Feb-2027" style values. + const monthMatch = /^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/.exec(raw.trim()); + if (monthMatch) { + const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + const monthIndex = months.indexOf((monthMatch[2] ?? "").toLowerCase()); + if (monthIndex !== -1) { + const parsed = new Date(Date.UTC(Number(monthMatch[3]), monthIndex, Number(monthMatch[1]))); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + } + + const date = new Date(raw); + if (!Number.isNaN(date.getTime())) { + return date; + } + return null; +}; + +export const fetchMonitorDomain = async (client: WhoisModule, monitor: Monitor): Promise<{ domain: string; expiryDate: string }> => { + const monitorUrl = new URL(monitor.url); + const labels = monitorUrl.hostname.split("."); + + // WHOIS registries only answer for the registrable domain, so try the full + // hostname first and walk up to the parent domain (www.example.com -> example.com). + for (let index = 0; index < labels.length - 1; index++) { + const candidate = labels.slice(index).join("."); + const result = await client.domain(candidate, { timeout: WHOIS_TIMEOUT_MS }); + const firstServerData = Object.values(result)[0]; + const expiryDate = extractDomainExpiryDate( + typeof firstServerData === "object" && firstServerData !== null && !Array.isArray(firstServerData) ? firstServerData : {} + ); + if (expiryDate) { + return { domain: candidate, expiryDate }; + } + } + throw new Error("Domain expiry not found"); +}; + export const requireString = (value: unknown, fieldName: string): string => { if (typeof value === "string" && value.trim().length > 0) { return value; diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index 664a78f45..4bd1a4fcb 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -10,6 +10,7 @@ import { editMonitorBodyValidation, pauseMonitorParamValidation, getCertificateParamValidation, + getDomainParamValidation, getHardwareDetailsByIdParamValidation, getHardwareDetailsByIdQueryValidation, getUptimeDetailsByIdParamValidation, @@ -18,13 +19,15 @@ import { bulkPauseMonitorBodyValidation, } from "@/api/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; -import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js"; +import * as whoiser from "whoiser"; +import { fetchMonitorCertificate, fetchMonitorDomain, requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js"; import { AppError } from "@/utils/AppError.js"; import { IMonitorService } from "@/domain/monitors/monitor.service.js"; import { INotificationsService } from "@/domain/notifications/notification.service.js"; export interface IMonitorController { getMonitorCertificate: RequestHandler; + getMonitorDomain: RequestHandler; getUptimeDetailsById: RequestHandler; getHardwareDetailsById: RequestHandler; getPageSpeedDetailsById: RequestHandler; @@ -68,6 +71,22 @@ class MonitorController implements IMonitorController { }); }); + getMonitorDomain = catchAsync(async (req: Request, res: Response) => { + const validatedParams = getDomainParamValidation.parse(req.params); + const teamId = requireTeamId(req.user?.teamId); + const monitor = await this.monitorService.getMonitorById({ teamId, monitorId: validatedParams.monitorId }); + const domainExpiry = await fetchMonitorDomain(whoiser, monitor); + + return res.status(200).json({ + success: true, + msg: "Domain expiry retrieved successfully", + data: { + domain: domainExpiry.domain, + expiryDate: new Date(domainExpiry.expiryDate), + }, + }); + }); + getUptimeDetailsById = catchAsync(async (req: Request, res: Response) => { const validatedParams = getUptimeDetailsByIdParamValidation.parse(req.params); const validatedQuery = getUptimeDetailsByIdQueryValidation.parse(req.query); diff --git a/server/src/api/routes/monitorRoutes.ts b/server/src/api/routes/monitorRoutes.ts index 43a94b5c4..4cd90a9a0 100644 --- a/server/src/api/routes/monitorRoutes.ts +++ b/server/src/api/routes/monitorRoutes.ts @@ -29,6 +29,9 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout router.get("/certificate/:monitorId", (req, res, next) => { monitorController.getMonitorCertificate(req, res, next); }); + router.get("/domain/:monitorId", (req, res, next) => { + monitorController.getMonitorDomain(req, res, next); + }); // General monitor CRUD routes router.patch("/notifications", isAllowed(["admin", "superadmin"]), monitorController.updateNotifications); diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 441809c42..d8b23a8e3 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -52,6 +52,10 @@ export const getCertificateParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); +export const getDomainParamValidation = z.object({ + monitorId: z.string().min(1, "Monitor ID is required"), +}); + const refineDnsHostname = (body: { type?: string; url?: string }, ctx: z.RefinementCtx) => { if (body.type === "dns" && body.url && !dnsHostnameRegex.test(body.url)) { ctx.addIssue({ diff --git a/server/test/unit/utils/domainExpiry.test.ts b/server/test/unit/utils/domainExpiry.test.ts new file mode 100644 index 000000000..7d6e02e49 --- /dev/null +++ b/server/test/unit/utils/domainExpiry.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "@jest/globals"; +import { extractDomainExpiryDate, fetchMonitorDomain } from "../../../src/api/controllers/controllerUtils.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; + +const makeMonitor = (url: string): Monitor => + ({ + url, + type: "http", + }) as unknown as Monitor; + +describe("extractDomainExpiryDate", () => { + it("returns an ISO date for ISO registry expiry values", () => { + expect(extractDomainExpiryDate({ "Registry Expiry Date": "2027-08-13T04:00:00Z" })).toBe("2027-08-13T04:00:00.000Z"); + }); + + it("returns an ISO date for dd-MMM-yyyy expiry values", () => { + expect(extractDomainExpiryDate({ "Expiry Date": "14-Feb-2027" })).toBe("2027-02-14T00:00:00.000Z"); + }); + + it("returns null when no expiry key exists", () => { + expect(extractDomainExpiryDate({ "Domain Name": "example.com" })).toBeNull(); + }); + + it("returns null when the expiry value cannot be parsed", () => { + expect(extractDomainExpiryDate({ "Expiry Date": "not-a-date" })).toBeNull(); + }); +}); + +describe("fetchMonitorDomain", () => { + it("walks up from the hostname to the registrable domain", async () => { + const queried: string[] = []; + const client = { + domain: async (domain: string) => { + queried.push(domain); + return domain === "example.com" ? { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } } : {}; + }, + }; + + const result = await fetchMonitorDomain(client as never, makeMonitor("https://www.example.com")); + + expect(queried).toEqual(["www.example.com", "example.com"]); + expect(result).toEqual({ domain: "example.com", expiryDate: "2027-08-13T04:00:00.000Z" }); + }); + + it("throws when no candidate has an expiry date", async () => { + const client = { + domain: async () => ({ "whois.verisign-grs.com": { "Domain Name": "example.com" } }), + }; + + await expect(fetchMonitorDomain(client as never, makeMonitor("https://example.com"))).rejects.toThrow("Domain expiry not found"); + }); +}); From 441ecddf9296f0f09b942fde2ab13f48a386cb4a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:08:18 -0700 Subject: [PATCH 063/143] fix unresolved IDs --- .../src/Components/inputs/forms/FormMultiSelectField.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index d38f75239..6727abcb5 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -60,9 +60,6 @@ export const FormMultiSelectField = optionsById.get(id)) .filter((o): o is O => o !== undefined); - // Drag indexes refer to the rendered (resolved) list, not the raw id - // array — ids missing from options are filtered out above, so reorder - // the rendered ids and re-append unresolved ids at the end. const handleDragEnd = (result: DropResult) => { if (!result.destination) return; const reordered = selected.map((o) => o.id); @@ -101,7 +98,11 @@ export const FormMultiSelectField = option.name} isOptionEqualToValue={(option, value) => option.id === value.id} onChange={(_: unknown, newValue: O[]) => { - field.onChange(newValue.map((o) => o.id)); + // The Autocomplete only sees resolved options, so ids missing + // from options must be re-appended or they'd be silently + // dropped (e.g. a role excluded from the selectable list). + const unresolved = selectedIds.filter((id) => !optionsById.has(id)); + field.onChange([...newValue.map((o) => o.id), ...unresolved]); }} fieldLabel={fieldLabel} placeholder={placeholder} From 564006890704f40ae5edbb65e92d552ce6ec61b5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:10:48 -0700 Subject: [PATCH 064/143] fix placeholder --- client/src/Components/inputs/forms/FormMultiSelectField.tsx | 2 +- client/src/Pages/Maintenance/create/index.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index 6727abcb5..808809318 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -105,7 +105,7 @@ export const FormMultiSelectField = o.id), ...unresolved]); }} fieldLabel={fieldLabel} - placeholder={placeholder} + placeholder={selected.length > 0 ? "" : placeholder} renderOptionContent={renderOptionContent} error={!!fieldState.error} helperText={fieldState.error?.message ?? ""} diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 49f0f3dc0..0372fe749 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -199,6 +199,9 @@ const CreateMaintenanceWindowPage = () => { fieldLabel={t( "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" )} + placeholder={t( + "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" + )} options={monitors ?? []} /> } From caebb937488eb7fd7d16c743f8a1eeb33cb5aa59 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:15:15 -0700 Subject: [PATCH 065/143] derive options from roles, wire error state --- .../Account/components/RoleSelectField.tsx | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/client/src/Pages/Account/components/RoleSelectField.tsx b/client/src/Pages/Account/components/RoleSelectField.tsx index 1f117b0e4..a6bfcf15a 100644 --- a/client/src/Pages/Account/components/RoleSelectField.tsx +++ b/client/src/Pages/Account/components/RoleSelectField.tsx @@ -1,35 +1,45 @@ import MenuItem from "@mui/material/MenuItem"; +import FormHelperText from "@mui/material/FormHelperText"; import { useController } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { Select } from "@/Components/inputs"; +import { UserRoles } from "@/Types/User"; import type { UserRole } from "@/Types/User"; export const RoleSelectField = () => { const { t } = useTranslation(); - const { field } = useController<{ role: UserRole[] }, "role">({ name: "role" }); + const { field, fieldState } = useController<{ role: UserRole[] }, "role">({ + name: "role", + }); - const roleOptions: { value: UserRole; label: string }[] = [ - { value: "admin", label: t("common.auth.roles.admin") }, - { value: "user", label: t("common.auth.roles.user") }, - { value: "demo", label: t("common.auth.roles.demo") }, - ]; + const editableRoles = UserRoles.filter((role) => role !== "superadmin"); + const roleOptions: { value: UserRole; label: string }[] = editableRoles.map((role) => ({ + value: role, + label: t(`common.auth.roles.${role}`), + })); return ( - + <> + + {fieldState.error && ( + {fieldState.error.message} + )} + ); }; From 7f68d235664bd4e2c3d1b65caa80f8f06808c88d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:21:51 -0700 Subject: [PATCH 066/143] fix non sortable styling --- .../Components/inputs/forms/FormMultiSelectField.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/src/Components/inputs/forms/FormMultiSelectField.tsx b/client/src/Components/inputs/forms/FormMultiSelectField.tsx index 808809318..60552c6f2 100644 --- a/client/src/Components/inputs/forms/FormMultiSelectField.tsx +++ b/client/src/Components/inputs/forms/FormMultiSelectField.tsx @@ -8,7 +8,6 @@ import { import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; -import Divider from "@mui/material/Divider"; import { useTheme } from "@mui/material/styles"; import { Trash2, GripVertical } from "lucide-react"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; @@ -164,16 +163,20 @@ export const FormMultiSelectField = ) - : selected.map((option, index) => ( + : selected.map((option) => ( {rowContent(option)} {removeButton(option)} - {index < selected.length - 1 && } ))} From a7fb235c56a39f523bf62176668f6cf798dae302 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:24:03 -0700 Subject: [PATCH 067/143] fix monitor types --- client/src/Types/Monitor.ts | 11 ++++++++--- client/src/Types/StatusPage.ts | 17 +++-------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index bf5e4f170..ef328ebdb 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -35,9 +35,12 @@ export const SelectableMonitorTypes = [ export type SelectableMonitorType = (typeof SelectableMonitorTypes)[number]; // Irregular i18n key fragments shared by the type selector's label -// (pages.common.monitors.monitorTypes.) and description -// (pages.createMonitor.form.type.Description) keys. -export const monitorTypeLabelKey: Record = { +// (pages.common.monitors.monitorTypes.), its description +// (pages.createMonitor.form.type.Description), and status page +// type labels (via getMonitorTypeLabel). Covers every type with a label; +// only selectable types have description keys. +export const monitorTypeLabelKey: Record & + Partial> = { http: "optionHttp", ping: "optionPing", docker: "optionDocker", @@ -46,6 +49,8 @@ export const monitorTypeLabelKey: Record = { grpc: "optionGrpc", websocket: "optionWebSocket", dns: "optionDns", + hardware: "optionHardware", + pagespeed: "optionPagespeed", }; export const MonitorIntervalOptions = [ diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index bbe1f41f2..ea0db5292 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -1,24 +1,13 @@ +import { monitorTypeLabelKey } from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor"; export type MonitorDisplayType = "uptime" | "infrastructure"; -export const MONITOR_TYPE_KEYS: Partial> = { - http: "pages.common.monitors.monitorTypes.optionHttp", - ping: "pages.common.monitors.monitorTypes.optionPing", - docker: "pages.common.monitors.monitorTypes.optionDocker", - port: "pages.common.monitors.monitorTypes.optionPort", - game: "pages.common.monitors.monitorTypes.optionGame", - grpc: "pages.common.monitors.monitorTypes.optionGrpc", - websocket: "pages.common.monitors.monitorTypes.optionWebSocket", - hardware: "pages.common.monitors.monitorTypes.optionHardware", - pagespeed: "pages.common.monitors.monitorTypes.optionPagespeed", -}; - export const getMonitorTypeLabel = ( type: MonitorType, t: (key: string) => string ): string => { - const key = MONITOR_TYPE_KEYS[type]; - return key ? t(key) : type; + const fragment = monitorTypeLabelKey[type]; + return fragment ? t(`pages.common.monitors.monitorTypes.${fragment}`) : type; }; export const STATUS_PAGE_THEMES = [ From 7dd378f0854d885db4dc7614ce02bcf24796ba9a Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:26:15 -0700 Subject: [PATCH 068/143] remove duplicates --- client/src/Hooks/useNotificationForm.ts | 101 +++--------------------- 1 file changed, 9 insertions(+), 92 deletions(-) diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index b4b3fad7d..fdd8204d3 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -10,100 +10,17 @@ interface UseNotificationFormOptions { function buildDefaults(data: Notification | null): NotificationFormData { // Every channel field defaults to "" (not undefined) so untouched required // fields fail zod with their custom messages instead of a type error. - const base = { - notificationName: data?.notificationName || "", - address: "", - accessToken: "", - accountSid: "", - phone: "", - twilioPhoneNumber: "", - homeserverUrl: "", - roomId: "", - topic: "", - }; - if (data?.type === "matrix") { - return { - ...base, - type: "matrix", - homeserverUrl: data.homeserverUrl || "", - roomId: data.roomId || "", - accessToken: data.accessToken || "", - }; - } - if (data?.type === "telegram") { - return { - ...base, - type: "telegram", - address: data.address || "", - accessToken: data.accessToken || "", - }; - } - if (data?.type === "slack") { - return { - ...base, - type: "slack", - address: data.address || "", - }; - } - if (data?.type === "discord") { - return { - ...base, - type: "discord", - address: data.address || "", - }; - } - if (data?.type === "webhook") { - return { - ...base, - type: "webhook", - address: data.address || "", - }; - } - if (data?.type === "pager_duty") { - return { - ...base, - type: "pager_duty", - address: data.address || "", - }; - } - if (data?.type === "teams") { - return { - ...base, - type: "teams", - address: data.address || "", - }; - } - if (data?.type === "twilio") { - return { - ...base, - type: "twilio", - accountSid: data.accountSid || "", - accessToken: data.accessToken || "", - phone: data.phone || "", - twilioPhoneNumber: data.twilioPhoneNumber || "", - }; - } - if (data?.type === "pushover") { - return { - ...base, - type: "pushover", - address: data.address || "", - accessToken: data.accessToken || "", - }; - } - if (data?.type === "ntfy") { - return { - ...base, - type: "ntfy", - address: data.address || "", - topic: data.topic || "", - }; - } - // Default: email (covers both data === null and data.type === "email") return { - ...base, - type: "email", + type: data?.type ?? "email", + notificationName: data?.notificationName || "", address: data?.address || "", + accessToken: data?.accessToken || "", + accountSid: data?.accountSid || "", + phone: data?.phone || "", + twilioPhoneNumber: data?.twilioPhoneNumber || "", + homeserverUrl: data?.homeserverUrl || "", + roomId: data?.roomId || "", + topic: data?.topic || "", }; } From 521827e1d0d84385c0b991db5f2367bb99a0ed96 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 11 Aug 2026 17:50:47 -0700 Subject: [PATCH 069/143] memoize options --- client/src/Pages/CreateMonitor/index.tsx | 145 ++++++++++++------- client/src/Pages/Settings/index.tsx | 24 +-- client/src/Pages/StatusPage/Create/index.tsx | 12 +- client/src/Utils/timezoneOptions.ts | 13 ++ 4 files changed, 111 insertions(+), 83 deletions(-) create mode 100644 client/src/Utils/timezoneOptions.ts diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 39543d072..95e6d9a29 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -57,6 +57,11 @@ import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +const httpMethodOptions = HttpMethods.map((method) => ({ + value: method, + label: method, +})); + interface GeneralSettingsConfig { urlLabel: string; urlPlaceholder: string; @@ -375,6 +380,83 @@ const CreateMonitorPage = () => { [t] ); + const typeOptions = useMemo( + () => + SelectableMonitorTypes.map((type) => ({ + value: type, + label: t(`pages.common.monitors.monitorTypes.${monitorTypeLabelKey[type]}`), + description: t( + `pages.createMonitor.form.type.${monitorTypeLabelKey[type]}Description` + ), + })), + [t] + ); + + const strategyOptions = useMemo( + () => + PageSpeedStrategies.map((strategy) => ({ + value: strategy, + label: t(`pages.createMonitor.form.general.option.strategy.${strategy}`), + })), + [t] + ); + + const gameOptions = useMemo( + () => [ + { + value: "", + label: t("pages.createMonitor.form.general.option.game.placeholder"), + }, + ...Object.entries(games ?? {}).map(([key, game]) => ({ + value: key, + label: game.name, + })), + ], + [games, t] + ); + + const dnsRecordTypeOptions = useMemo( + () => + DnsRecordTypes.map((recordType) => ({ + value: recordType, + label: t( + `pages.createMonitor.form.general.option.dnsRecordType.value.${recordType}` + ), + })), + [t] + ); + + const intervalOptions = useMemo( + () => + MonitorIntervalOptions.map((option) => ({ + value: option.value, + label: t( + `pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}` + ), + })), + [t] + ); + + const matchMethodOptions = useMemo( + () => + MonitorMatchMethods.map((method) => ({ + value: method, + label: t(`pages.createMonitor.form.advanced.option.matchMethod.${method}`), + })), + [t] + ); + + const geoCheckIntervalOptions = useMemo( + () => + GeoCheckIntervalOptions.map((option) => ({ + value: option.value, + label: t( + `pages.createMonitor.form.geoChecks.option.interval.value.${option.labelKey}` + ), + })), + [t] + ); + return ( { rightContent={ ({ - value: type, - label: t( - `pages.common.monitors.monitorTypes.${monitorTypeLabelKey[type]}` - ), - description: t( - `pages.createMonitor.form.type.${monitorTypeLabelKey[type]}Description` - ), - }))} + options={typeOptions} /> } /> @@ -451,12 +525,7 @@ const CreateMonitorPage = () => { fieldLabel={t( "pages.createMonitor.form.general.option.strategy.label" )} - options={PageSpeedStrategies.map((strategy) => ({ - value: strategy, - label: t( - `pages.createMonitor.form.general.option.strategy.${strategy}` - ), - }))} + options={strategyOptions} fallbackValue={DefaultPageSpeedStrategy} /> )} @@ -477,18 +546,7 @@ const CreateMonitorPage = () => { ({ - value: key, - label: game.name, - })), - ]} + options={gameOptions} /> )} @@ -532,12 +590,7 @@ const CreateMonitorPage = () => { fieldLabel={t( "pages.createMonitor.form.general.option.dnsRecordType.label" )} - options={DnsRecordTypes.map((recordType) => ({ - value: recordType, - label: t( - `pages.createMonitor.form.general.option.dnsRecordType.value.${recordType}` - ), - }))} + options={dnsRecordTypeOptions} /> )} { fieldLabel={t( "pages.createMonitor.form.frequency.option.frequency.label" )} - options={MonitorIntervalOptions.map((option) => ({ - value: option.value, - label: t( - `pages.createMonitor.form.frequency.option.frequency.value.${option.labelKey}` - ), - }))} + options={intervalOptions} /> } /> @@ -729,10 +777,7 @@ const CreateMonitorPage = () => { setValue("jsonPath", ""); } }} - options={HttpMethods.map((method) => ({ - value: method, - label: method, - }))} + options={httpMethodOptions} /> { "pages.createMonitor.form.advanced.option.matchMethod.label" )} fallbackValue={DefaultMonitorMatchMethod} - options={MonitorMatchMethods.map((method) => ({ - value: method, - label: t( - `pages.createMonitor.form.advanced.option.matchMethod.${method}` - ), - }))} + options={matchMethodOptions} /> { fieldLabel={t( "pages.createMonitor.form.geoChecks.option.interval.label" )} - options={GeoCheckIntervalOptions.map((option) => ({ - value: option.value, - label: t( - `pages.createMonitor.form.geoChecks.option.interval.value.${option.labelKey}` - ), - }))} + options={geoCheckIntervalOptions} /> )} diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 8564952ae..cc1472de2 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -35,7 +35,8 @@ import { type ThemeMode, type ChartType, } from "@/Features/UI/uiSlice.js"; -import timezones from "@/Utils/timezones.json"; +import { timezoneOptions } from "@/Utils/timezoneOptions"; +import type { TimezoneOption } from "@/Utils/timezoneOptions"; import type { RootState } from "@/Types/state"; import { CHECK_TTL_SENTINEL } from "@/Types/Check"; import { FormTextField } from "@/Components/inputs/forms/FormTextField"; @@ -43,11 +44,6 @@ import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; -interface Timezone { - id: string; - name: string; -} - interface SettingsResponse { settings: any; pagespeedKeySet: boolean; @@ -141,16 +137,10 @@ export const SettingsPage = () => { const user = useSelector((state: RootState) => state.auth.user); - // Convert timezones to match AutoComplete format (id instead of _id) - const timezoneOptions: Timezone[] = timezones.map((tz) => ({ - id: tz._id, - name: tz.name, - })); - const selectedTimezone = timezoneOptions.find((tz) => tz.id === selectedTimezoneId) ?? null; - const handleTimezoneChange = (newValue: Timezone | null) => { + const handleTimezoneChange = (newValue: TimezoneOption | null) => { if (!newValue?.id) return; dispatch(setTimezone({ timezone: newValue.id })); }; @@ -317,12 +307,12 @@ export const SettingsPage = () => { option.name} - isOptionEqualToValue={(option: Timezone, value: Timezone) => + getOptionLabel={(option: TimezoneOption) => option.name} + isOptionEqualToValue={(option: TimezoneOption, value: TimezoneOption) => option.id === value.id } - onChange={(_, newValue) => { - handleTimezoneChange(newValue as Timezone | null); + onChange={(_, newValue: TimezoneOption | null) => { + handleTimezoneChange(newValue); }} fieldLabel={t("pages.settings.form.timezone.option.timezone.label")} /> diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 1d9614a5d..1a3a5c344 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -17,7 +17,7 @@ import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"; import { getMonitorTypeLabel } from "@/Types/StatusPage"; -import timezones from "@/Utils/timezones.json"; +import { timezoneOptions } from "@/Utils/timezoneOptions"; import { useNavigate, useParams } from "react-router-dom"; import { mutate } from "swr"; import axios from "axios"; @@ -66,16 +66,6 @@ const monitorsUrl = (() => { return `/monitors/team?${params.toString()}`; })(); -interface TimezoneOption { - _id: string; - name: string; -} - -const timezoneOptions = timezones.map((tz: TimezoneOption) => ({ - id: tz._id, - name: tz.name, -})); - const buildStatusPageKey = (slug: string | null | undefined) => slug ? `/status-page/${slug}?type=uptime&type=infrastructure` : null; diff --git a/client/src/Utils/timezoneOptions.ts b/client/src/Utils/timezoneOptions.ts new file mode 100644 index 000000000..c937bdc14 --- /dev/null +++ b/client/src/Utils/timezoneOptions.ts @@ -0,0 +1,13 @@ +import timezones from "@/Utils/timezones.json"; + +export interface TimezoneOption { + id: string; + name: string; +} + +// timezones.json uses Mongo-style _id; remap once at module load to the +// { id, name } shape AutoComplete expects. +export const timezoneOptions: TimezoneOption[] = timezones.map((tz) => ({ + id: tz._id, + name: tz.name, +})); From b5f8faf1e8aadc3d28fb861185c1b9391487b12e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 12 Aug 2026 10:22:56 -0700 Subject: [PATCH 070/143] add missing rocket chat label --- client/src/locales/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 104a27b8f..a92577840 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1162,6 +1162,7 @@ "slack": "Slack", "discord": "Discord", "webhook": "Webhook", + "rocket_chat": "Rocket.Chat", "pager_duty": "PagerDuty", "matrix": "Matrix", "teams": "Teams", From 75516a220d308a5a2d3a419f3d2c0900f14bb49a Mon Sep 17 00:00:00 2001 From: colinjtsen2010 Date: Thu, 13 Aug 2026 12:02:49 +0800 Subject: [PATCH 071/143] fix: handle IPs, avoid wrong parent-domain lookups, cache whois results - Bare IP addresses and non-domain hosts (localhost, IPv6) now return a null expiry instead of throwing, so they no longer spam error logs. - Extract the registrable domain with tldts instead of walking up hostname labels, so a subdomain can never accidentally return its parent domain's expiry date. - Cache WHOIS lookups per registrable domain (1h positive, 10min negative) so visiting details pages does not re-query the registry every time. Addresses review feedback on #3834. --- server/openapi.json | 6 +- server/openapi/routes/monitor.ts | 5 +- server/package-lock.json | 51 ++++++------ server/package.json | 1 + server/src/api/controllers/controllerUtils.ts | 82 +++++++++++++++---- .../src/api/controllers/monitorController.ts | 2 +- server/test/unit/utils/domainExpiry.test.ts | 66 +++++++++++++-- 7 files changed, 160 insertions(+), 53 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index 0a57093ea..e95617426 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -7191,10 +7191,12 @@ "type": "object", "properties": { "domain": { - "type": "string" + "type": "string", + "nullable": true }, "expiryDate": { - "type": "string" + "type": "string", + "nullable": true } }, "required": ["domain", "expiryDate"] diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index 02626d8ab..ddb207eb8 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -152,7 +152,10 @@ registry.registerPath({ summary: "Get domain registration expiry for a monitor", security: bearer, request: { params: getDomainParamValidation }, - responses: { "200": okJson(z.object({ domain: z.string(), expiryDate: z.string() })), ...standardErrors }, + responses: { + "200": okJson(z.object({ domain: z.string().nullable(), expiryDate: z.string().nullable() })), + ...standardErrors, + }, }); registry.registerPath({ diff --git a/server/package-lock.json b/server/package-lock.json index 548f99575..a581bd18b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -40,6 +40,7 @@ "sharp": "0.33.5", "ssl-checker": "2.0.10", "swagger-ui-express": "5.0.1", + "tldts": "^7.4.10", "whoiser": "^1.18.0", "winston": "^3.13.0", "ws": "^8.19.0", @@ -9670,24 +9671,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/isomorphic-dompurify/node_modules/tldts": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", - "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.17" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/isomorphic-dompurify/node_modules/tldts-core": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", - "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", - "license": "MIT" - }, "node_modules/isomorphic-dompurify/node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -15258,21 +15241,21 @@ } }, "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "version": "7.4.10", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", "license": "MIT", "dependencies": { - "tldts-core": "^6.1.86" + "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "version": "7.4.10", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", "license": "MIT" }, "node_modules/tmpl": { @@ -15326,6 +15309,24 @@ "node": ">=16" } }, + "node_modules/tough-cookie/node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tough-cookie/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, "node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", diff --git a/server/package.json b/server/package.json index f30495c5c..a43871d78 100755 --- a/server/package.json +++ b/server/package.json @@ -63,6 +63,7 @@ "sharp": "0.33.5", "ssl-checker": "2.0.10", "swagger-ui-express": "5.0.1", + "tldts": "^7.4.10", "whoiser": "^1.18.0", "winston": "^3.13.0", "ws": "^8.19.0", diff --git a/server/src/api/controllers/controllerUtils.ts b/server/src/api/controllers/controllerUtils.ts index 7b014e656..608c39d57 100755 --- a/server/src/api/controllers/controllerUtils.ts +++ b/server/src/api/controllers/controllerUtils.ts @@ -3,6 +3,7 @@ import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monit import { UserRole } from "@/domain/users/user.type.js"; import sslChecker, { SSLDetails } from "ssl-checker"; import * as whoiser from "whoiser"; +import { parse as parseDomain } from "tldts"; type SSLCheckerType = typeof sslChecker; type WhoisModule = typeof whoiser; @@ -17,8 +18,50 @@ export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: }; const WHOIS_TIMEOUT_MS = 10_000; +const DOMAIN_EXPIRY_CACHE_POSITIVE_TTL_MS = 60 * 60 * 1000; // 1 hour +const DOMAIN_EXPIRY_CACHE_NEGATIVE_TTL_MS = 10 * 60 * 1000; // 10 minutes const EXPIRY_DATE_KEY_PATTERN = /(expiry date|expiration|paid-?till|registration expiration)/i; +export interface DomainExpiryResult { + domain: string | null; + expiryDate: string | null; +} + +interface DomainExpiryCacheEntry extends DomainExpiryResult { + expiresAt: number; +} + +interface DomainExpiryCache { + get(domain: string): DomainExpiryResult | undefined; + set(domain: string, result: DomainExpiryResult): void; +} + +export const createDomainExpiryCache = (positiveTtlMs: number, negativeTtlMs: number): DomainExpiryCache => { + const entries = new Map(); + + return { + get(domain: string): DomainExpiryResult | undefined { + const entry = entries.get(domain); + if (entry === undefined) { + return undefined; + } + if (entry.expiresAt <= Date.now()) { + entries.delete(domain); + return undefined; + } + return { domain: entry.domain, expiryDate: entry.expiryDate }; + }, + set(domain: string, result: DomainExpiryResult): void { + entries.set(domain, { + ...result, + expiresAt: Date.now() + (result.expiryDate === null ? negativeTtlMs : positiveTtlMs), + }); + }, + }; +}; + +const domainExpiryCache = createDomainExpiryCache(DOMAIN_EXPIRY_CACHE_POSITIVE_TTL_MS, DOMAIN_EXPIRY_CACHE_NEGATIVE_TTL_MS); + export const extractDomainExpiryDate = (whoisData: object): string | null => { const matchingKey = Object.keys(whoisData).find((key) => EXPIRY_DATE_KEY_PATTERN.test(key)); if (!matchingKey) { @@ -54,24 +97,31 @@ const parseDomainExpiryValue = (raw: string): Date | null => { return null; }; -export const fetchMonitorDomain = async (client: WhoisModule, monitor: Monitor): Promise<{ domain: string; expiryDate: string }> => { +export const fetchMonitorDomain = async ( + client: WhoisModule, + monitor: Monitor, + cache: DomainExpiryCache = domainExpiryCache +): Promise => { const monitorUrl = new URL(monitor.url); - const labels = monitorUrl.hostname.split("."); - - // WHOIS registries only answer for the registrable domain, so try the full - // hostname first and walk up to the parent domain (www.example.com -> example.com). - for (let index = 0; index < labels.length - 1; index++) { - const candidate = labels.slice(index).join("."); - const result = await client.domain(candidate, { timeout: WHOIS_TIMEOUT_MS }); - const firstServerData = Object.values(result)[0]; - const expiryDate = extractDomainExpiryDate( - typeof firstServerData === "object" && firstServerData !== null && !Array.isArray(firstServerData) ? firstServerData : {} - ); - if (expiryDate) { - return { domain: candidate, expiryDate }; - } + const registrableDomain = parseDomain(monitorUrl.hostname).domain; + if (registrableDomain === null) { + // Bare IP addresses, localhost, and other non-domain hosts have no WHOIS expiry. + return { domain: null, expiryDate: null }; } - throw new Error("Domain expiry not found"); + + const cached = cache.get(registrableDomain); + if (cached !== undefined) { + return cached; + } + + const result = await client.domain(registrableDomain, { timeout: WHOIS_TIMEOUT_MS }); + const firstServerData = Object.values(result)[0]; + const expiryDate = extractDomainExpiryDate( + typeof firstServerData === "object" && firstServerData !== null && !Array.isArray(firstServerData) ? firstServerData : {} + ); + const domainExpiry: DomainExpiryResult = { domain: registrableDomain, expiryDate }; + cache.set(registrableDomain, domainExpiry); + return domainExpiry; }; export const requireString = (value: unknown, fieldName: string): string => { diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index 4bd1a4fcb..f05e0df5c 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -82,7 +82,7 @@ class MonitorController implements IMonitorController { msg: "Domain expiry retrieved successfully", data: { domain: domainExpiry.domain, - expiryDate: new Date(domainExpiry.expiryDate), + expiryDate: domainExpiry.expiryDate === null ? null : new Date(domainExpiry.expiryDate), }, }); }); diff --git a/server/test/unit/utils/domainExpiry.test.ts b/server/test/unit/utils/domainExpiry.test.ts index 7d6e02e49..185a759ee 100644 --- a/server/test/unit/utils/domainExpiry.test.ts +++ b/server/test/unit/utils/domainExpiry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@jest/globals"; -import { extractDomainExpiryDate, fetchMonitorDomain } from "../../../src/api/controllers/controllerUtils.ts"; +import { createDomainExpiryCache, extractDomainExpiryDate, fetchMonitorDomain } from "../../../src/api/controllers/controllerUtils.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; const makeMonitor = (url: string): Monitor => @@ -27,26 +27,76 @@ describe("extractDomainExpiryDate", () => { }); describe("fetchMonitorDomain", () => { - it("walks up from the hostname to the registrable domain", async () => { + it("queries the registrable domain for subdomains", async () => { const queried: string[] = []; const client = { domain: async (domain: string) => { queried.push(domain); - return domain === "example.com" ? { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } } : {}; + return { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } }; + }, + }; + const cache = createDomainExpiryCache(60_000, 10_000); + + const result = await fetchMonitorDomain(client as never, makeMonitor("https://www.example.co.uk"), cache); + + expect(queried).toEqual(["example.co.uk"]); + expect(result).toEqual({ domain: "example.co.uk", expiryDate: "2027-08-13T04:00:00.000Z" }); + }); + + it("returns null expiry for bare IP addresses without querying whois", async () => { + const client = { + domain: async () => { + throw new Error("should not be called"); }, }; - const result = await fetchMonitorDomain(client as never, makeMonitor("https://www.example.com")); + const result = await fetchMonitorDomain(client as never, makeMonitor("https://1.2.3.4")); - expect(queried).toEqual(["www.example.com", "example.com"]); - expect(result).toEqual({ domain: "example.com", expiryDate: "2027-08-13T04:00:00.000Z" }); + expect(result).toEqual({ domain: null, expiryDate: null }); }); - it("throws when no candidate has an expiry date", async () => { + it("returns null expiry when the registry has no expiry record", async () => { const client = { domain: async () => ({ "whois.verisign-grs.com": { "Domain Name": "example.com" } }), }; - await expect(fetchMonitorDomain(client as never, makeMonitor("https://example.com"))).rejects.toThrow("Domain expiry not found"); + const result = await fetchMonitorDomain(client as never, makeMonitor("https://example.com")); + + expect(result).toEqual({ domain: "example.com", expiryDate: null }); + }); + + it("serves repeat lookups from cache", async () => { + let calls = 0; + const client = { + domain: async () => { + calls++; + return { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } }; + }, + }; + const cache = createDomainExpiryCache(60_000, 10_000); + const monitor = makeMonitor("https://example.com"); + + await fetchMonitorDomain(client as never, monitor, cache); + const second = await fetchMonitorDomain(client as never, monitor, cache); + + expect(calls).toBe(1); + expect(second).toEqual({ domain: "example.com", expiryDate: "2027-08-13T04:00:00.000Z" }); + }); + + it("re-queries after the cache expires", async () => { + let calls = 0; + const client = { + domain: async () => { + calls++; + return { "whois.verisign-grs.com": { "Expiry Date": "2027-08-13T04:00:00Z" } }; + }, + }; + const cache = createDomainExpiryCache(-1, -1); + const monitor = makeMonitor("https://example.com"); + + await fetchMonitorDomain(client as never, monitor, cache); + await fetchMonitorDomain(client as never, monitor, cache); + + expect(calls).toBe(2); }); }); From d4eaad617090a8a3689cb243314e3f902cac6eb3 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Diniz Date: Thu, 13 Aug 2026 11:56:28 -0300 Subject: [PATCH 072/143] fix(notifications): route Rocket.Chat links by monitor type Build title_link from the monitor type (hardware -> infrastructure, pagespeed -> pagespeed, uptime types -> uptime) and omit it when clientHost is not configured, instead of always linking to the infrastructure page. --- .../notifications/providers/rocketChat.ts | 25 +++++++++++++++- .../notifications/rocketChatProvider.test.ts | 29 ++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/server/src/domain/notifications/providers/rocketChat.ts b/server/src/domain/notifications/providers/rocketChat.ts index f5d224439..2ef8d7efb 100644 --- a/server/src/domain/notifications/providers/rocketChat.ts +++ b/server/src/domain/notifications/providers/rocketChat.ts @@ -1,4 +1,5 @@ const SERVICE_NAME = "RocketChatProvider"; +import { supportsUptimeDetails, type MonitorType } from "@/domain/monitors/monitor.type.js"; import type { Notification, NotificationMessage } from "@/domain/notifications/notification.type.js"; import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; import { getTestMessage } from "@/domain/notifications/providers/utils.js"; @@ -84,6 +85,7 @@ export class RocketChatProvider extends NotificationProvider { } private buildPayload(message: NotificationMessage): RocketChatPayload { + const monitorUrl = this.monitorUrl(message); const fields: RocketChatField[] = [ { title: "Monitor", value: message.monitor.name, short: true }, { title: "Type", value: message.monitor.type, short: true }, @@ -123,7 +125,7 @@ export class RocketChatProvider extends NotificationProvider { { color: this.severityColor(message.severity), title: message.content.title, - title_link: `${message.clientHost}/infrastructure/${message.monitor.id}`, + ...(monitorUrl ? { title_link: monitorUrl } : {}), fields, ts: message.content.timestamp.toISOString(), }, @@ -131,6 +133,27 @@ export class RocketChatProvider extends NotificationProvider { }; } + private monitorUrl(message: NotificationMessage): string | undefined { + const clientHost = message.clientHost?.trim(); + if (!clientHost || clientHost === "Host not defined") { + return undefined; + } + + if (message.monitor.type === "hardware") { + return `${clientHost}/infrastructure/${message.monitor.id}`; + } + + if (message.monitor.type === "pagespeed") { + return `${clientHost}/pagespeed/${message.monitor.id}`; + } + + if (supportsUptimeDetails(message.monitor.type as MonitorType)) { + return `${clientHost}/uptime/${message.monitor.id}`; + } + + return undefined; + } + private severityColor(severity: NotificationMessage["severity"]): string { switch (severity) { case "critical": diff --git a/server/test/unit/providers/notifications/rocketChatProvider.test.ts b/server/test/unit/providers/notifications/rocketChatProvider.test.ts index dcf1001d0..816e19801 100644 --- a/server/test/unit/providers/notifications/rocketChatProvider.test.ts +++ b/server/test/unit/providers/notifications/rocketChatProvider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, jest } from "@jest/globals"; +import type { MonitorType } from "../../../../src/domain/monitors/monitor.type.ts"; import type { NotificationSeverity } from "../../../../src/domain/notifications/notification.type.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; import { makeMessage, makeMessageWithIncident, makeMessageWithThresholds, makeNotification } from "../../../helpers/notificationMessage.ts"; @@ -102,7 +103,7 @@ describe("RocketChatProvider", () => { { color: "#FF0000", title: "Monitor Down: Test Monitor", - title_link: "https://app.example.com/infrastructure/mon-1", + title_link: "https://app.example.com/uptime/mon-1", fields: [ { title: "Monitor", value: "Test Monitor", short: true }, { title: "Type", value: "http", short: true }, @@ -128,6 +129,32 @@ describe("RocketChatProvider", () => { expect(payload).not.toHaveProperty("channel"); }); + it.each([ + ["http", "https://app.example.com/uptime/mon-1"], + ["ping", "https://app.example.com/uptime/mon-1"], + ["pagespeed", "https://app.example.com/pagespeed/mon-1"], + ["hardware", "https://app.example.com/infrastructure/mon-1"], + ])("links %s monitors to %s", async (type, expectedLink) => { + const { provider } = createProvider(); + + await provider.sendMessage( + makeNotification({ type: "rocket_chat" }), + makeMessage({ monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: type as MonitorType, status: "down" } }) + ); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.attachments[0].title_link).toBe(expectedLink); + }); + + it.each([["Host not defined"], [""], [" "]])("omits title_link when clientHost is %j", async (clientHost) => { + const { provider } = createProvider(); + + await provider.sendMessage(makeNotification({ type: "rocket_chat" }), makeMessage({ clientHost })); + + const payload = mockGotPost.mock.calls[0][1].json; + expect(payload.attachments[0]).not.toHaveProperty("title_link"); + }); + it.each([ ["critical", "#FF0000"], ["warning", "#FFA500"], From 1c9739d1da1e7a19d38b9101fb7e95853c5040af Mon Sep 17 00:00:00 2001 From: egeoztass Date: Fri, 14 Aug 2026 14:01:06 +0300 Subject: [PATCH 073/143] feat: add authentication support to ntfy notifications ntfy servers that require auth rejected every Checkmate alert because the provider posted without an Authorization header, so alerts to protected topics failed silently. Adds ntfyAuthType ("none" | "token" | "basic"): - token: Authorization: Bearer - basic: Authorization: Basic base64(ntfyUsername:accessToken) Both types store their secret in the existing accessToken field rather than introducing a new secret, so credential handling stays in one place. Incomplete credentials fail the send instead of falling back to an anonymous post, and validation rejects partial auth config up front so a channel can't be saved looking authenticated while posting anonymously. Backend only; frontend follows once this lands. Closes #3717 --- server/openapi.json | 30 ++++++- server/openapi/routes/notification.ts | 9 +- .../api/validation/notificationValidation.ts | 52 ++++++++++-- .../notifications/notification.model.ts | 3 + .../domain/notifications/notification.type.ts | 7 ++ .../domain/notifications/providers/ntfy.ts | 48 +++++++++++ .../notifications/ntfyProvider.test.ts | 83 +++++++++++++++++++ .../validation/notificationValidation.test.ts | 65 +++++++++++++++ 8 files changed, 289 insertions(+), 8 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index 627354c55..22498d691 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1410,6 +1410,32 @@ "topic": { "type": "string", "minLength": 1 + }, + "ntfyAuthType": { + "type": "string", + "enum": ["none", "token", "basic"] + }, + "ntfyUsername": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] } }, "required": ["notificationName", "type", "address", "topic"], @@ -1417,7 +1443,9 @@ "notificationName": "ntfy topic", "type": "ntfy", "address": "https://ntfy.sh", - "topic": "checkmate-alerts" + "topic": "checkmate-alerts", + "ntfyAuthType": "token", + "accessToken": "tk_your-ntfy-access-token" } } }, diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts index 7da45adfb..5950ab4a3 100644 --- a/server/openapi/routes/notification.ts +++ b/server/openapi/routes/notification.ts @@ -78,7 +78,14 @@ const notificationVariantMeta: Record { + const authType = body.ntfyAuthType ?? "none"; + + if (authType === "none") { + if (body.accessToken) { + ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Select an authentication type to use an access token" }); + } + if (body.ntfyUsername) { + ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Select an authentication type to use a username" }); + } + return; + } + + if (authType === "token") { + if (!body.accessToken) { + ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Access token is required for token authentication" }); + } + if (body.ntfyUsername) { + ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Username is only used with basic authentication" }); + } + return; + } + + if (!body.ntfyUsername) { + ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Username is required for basic authentication" }); + } + if (!body.accessToken) { + ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Password is required for basic authentication" }); + } +}; + export const createNotificationBodyValidation = z.discriminatedUnion("type", [ // Email notification z.object({ @@ -98,12 +133,17 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), }), // ntfy notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("ntfy"), - address: z.url({ message: "Please enter a valid ntfy server URL" }), - topic: z.string().min(1, "Topic is required"), - }), + z + .object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("ntfy"), + address: z.url({ message: "Please enter a valid ntfy server URL" }), + topic: z.string().min(1, "Topic is required"), + ntfyAuthType: z.enum(NtfyAuthTypes).optional(), + ntfyUsername: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }) + .superRefine(refineNtfyAuth), ]); export const testNotificationBodyValidation = createNotificationBodyValidation; diff --git a/server/src/domain/notifications/notification.model.ts b/server/src/domain/notifications/notification.model.ts index 518a4fc3f..b2dce9ebe 100755 --- a/server/src/domain/notifications/notification.model.ts +++ b/server/src/domain/notifications/notification.model.ts @@ -1,5 +1,6 @@ import { Schema, model, type Types } from "mongoose"; import type { Notification, NotificationChannel } from "@/domain/notifications/notification.type.js"; +import { NtfyAuthTypes } from "@/domain/notifications/notification.type.js"; interface NotificationDocument extends Omit { _id: Types.ObjectId; @@ -53,6 +54,8 @@ const NotificationSchema = new Schema( accountSid: { type: String }, twilioPhoneNumber: { type: String }, topic: { type: String }, + ntfyAuthType: { type: String, enum: NtfyAuthTypes }, + ntfyUsername: { type: String }, }, { timestamps: true, diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts index 7c4c9f75d..a45d4c4f8 100644 --- a/server/src/domain/notifications/notification.type.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -14,6 +14,11 @@ export const NotificationChannels = [ ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; +// ntfy servers accept either a bearer access token or HTTP basic credentials. +// Both store their secret in `accessToken`; basic auth pairs it with `ntfyUsername`. +export const NtfyAuthTypes = ["none", "token", "basic"] as const; +export type NtfyAuthType = (typeof NtfyAuthTypes)[number]; + export interface Notification { id: string; userId: string; @@ -28,6 +33,8 @@ export interface Notification { accountSid?: string; twilioPhoneNumber?: string; topic?: string; + ntfyAuthType?: NtfyAuthType; + ntfyUsername?: string; createdAt: string; updatedAt: string; } diff --git a/server/src/domain/notifications/providers/ntfy.ts b/server/src/domain/notifications/providers/ntfy.ts index 7ca351d87..b415f648b 100644 --- a/server/src/domain/notifications/providers/ntfy.ts +++ b/server/src/domain/notifications/providers/ntfy.ts @@ -11,12 +11,18 @@ export class NtfyProvider extends NotificationProvider { return false; } + const authHeaders = this.buildAuthHeaders(notification, "sendTestAlert"); + if (authHeaders === null) { + return false; + } + try { await got.post(this.buildTopicUrl(notification.address, notification.topic), { body: getTestMessage(), headers: { Title: "Checkmate test notification", Priority: "default", + ...authHeaders, }, ...this.gotRequestOptions(), }); @@ -38,6 +44,11 @@ export class NtfyProvider extends NotificationProvider { return false; } + const authHeaders = this.buildAuthHeaders(notification, "sendMessage"); + if (authHeaders === null) { + return false; + } + try { await got.post(this.buildTopicUrl(notification.address, notification.topic), { body: this.buildNtfyText(message), @@ -45,6 +56,7 @@ export class NtfyProvider extends NotificationProvider { Title: message.content.title, Priority: this.mapPriority(message.severity), Tags: this.mapTags(message.severity), + ...authHeaders, }, ...this.gotRequestOptions(), }); @@ -70,6 +82,42 @@ export class NtfyProvider extends NotificationProvider { return `${address.replace(/\/+$/, "")}/${encodeURIComponent(topic)}`; } + /** + * Returns the Authorization header for the configured auth type, or null when the + * credentials are incomplete. Null is treated as a send failure rather than falling + * back to an anonymous post, so a misconfigured channel surfaces instead of quietly + * publishing to a topic the server may leave world-readable. + */ + private buildAuthHeaders(notification: Partial, method: string): Record | null { + const { ntfyAuthType, accessToken, ntfyUsername } = notification; + + if (!ntfyAuthType || ntfyAuthType === "none") { + return {}; + } + + if (ntfyAuthType === "token") { + if (!accessToken) { + this.logMissingCredentials("access token", method); + return null; + } + return { Authorization: `Bearer ${accessToken}` }; + } + + if (!ntfyUsername || !accessToken) { + this.logMissingCredentials("username and password", method); + return null; + } + return { Authorization: `Basic ${Buffer.from(`${ntfyUsername}:${accessToken}`).toString("base64")}` }; + } + + private logMissingCredentials(missing: string, method: string): void { + this.logger.warn({ + message: `ntfy notification is configured for authentication but is missing its ${missing}`, + service: SERVICE_NAME, + method, + }); + } + private buildNtfyText(message: NotificationMessage): string { const lines = [ message.content.summary, diff --git a/server/test/unit/providers/notifications/ntfyProvider.test.ts b/server/test/unit/providers/notifications/ntfyProvider.test.ts index ffd95a0d7..d365584a1 100644 --- a/server/test/unit/providers/notifications/ntfyProvider.test.ts +++ b/server/test/unit/providers/notifications/ntfyProvider.test.ts @@ -109,4 +109,87 @@ describe("NtfyProvider", () => { expect(mockGotPost.mock.calls[0][1].body).toContain("/infrastructure/mon-1"); }); }); + + describe("authentication", () => { + const authHeaderOf = () => mockGotPost.mock.calls[0][1].headers.Authorization; + + it("sends no Authorization header when no auth type is configured", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification() as any, makeMessage()); + expect(authHeaderOf()).toBeUndefined(); + }); + + it("sends no Authorization header when auth type is none", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification({ ntfyAuthType: "none" }) as any, makeMessage()); + expect(authHeaderOf()).toBeUndefined(); + }); + + it("sends a bearer token for token auth", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(true); + expect(authHeaderOf()).toBe("Bearer tk_secret"); + }); + + it("sends base64 encoded credentials for basic auth", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "s3cret" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(true); + expect(authHeaderOf()).toBe(`Basic ${Buffer.from("alice:s3cret").toString("base64")}`); + }); + + it("authenticates test alerts as well as alerts", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" }); + expect(await provider.sendTestAlert(notification)).toBe(true); + expect(authHeaderOf()).toBe("Bearer tk_secret"); + }); + + it("preserves the notification headers alongside the Authorization header", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" }); + await provider.sendMessage(notification as any, makeMessage()); + expect(mockGotPost.mock.calls[0][1].headers).toEqual( + expect.objectContaining({ + Title: "Monitor Down: Test Monitor", + Priority: "high", + Tags: "rotating_light", + Authorization: "Bearer tk_secret", + }) + ); + }); + + // A misconfigured channel must fail loudly rather than post anonymously to a topic + // the operator believes is protected. + it("refuses to send unauthenticated when token auth has no access token", async () => { + const { provider, logger } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("refuses to send unauthenticated when basic auth has no username", async () => { + const { provider, logger } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "basic", accessToken: "s3cret" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("refuses to send unauthenticated when basic auth has no password", async () => { + const { provider, logger } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("refuses to send an unauthenticated test alert when credentials are incomplete", async () => { + const { provider } = createProvider(); + expect(await provider.sendTestAlert(makeNtfyNotification({ ntfyAuthType: "token", accessToken: "" }))).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/test/unit/validation/notificationValidation.test.ts b/server/test/unit/validation/notificationValidation.test.ts index 5cb2c1d59..2dd7ffc95 100644 --- a/server/test/unit/validation/notificationValidation.test.ts +++ b/server/test/unit/validation/notificationValidation.test.ts @@ -44,4 +44,69 @@ describe("notification validation", () => { expect(result.success).toBe(false); } ); + + describe("ntfy authentication", () => { + const parseNtfy = (overrides: Record) => + createNotificationBodyValidation.safeParse({ + notificationName: "ntfy alerts", + type: "ntfy", + address: "https://ntfy.example.com", + topic: "checkmate-alerts", + ...overrides, + }); + + const pathsOf = (result: ReturnType) => (result.success ? [] : result.error.issues.map((issue) => issue.path.join("."))); + + it("accepts an unauthenticated channel", () => { + expect(parseNtfy({}).success).toBe(true); + }); + + it("accepts an explicit auth type of none", () => { + expect(parseNtfy({ ntfyAuthType: "none" }).success).toBe(true); + }); + + it("accepts token auth with an access token", () => { + expect(parseNtfy({ ntfyAuthType: "token", accessToken: "tk_secret" }).success).toBe(true); + }); + + it("accepts basic auth with a username and password", () => { + expect(parseNtfy({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "s3cret" }).success).toBe(true); + }); + + it("rejects an unknown auth type", () => { + expect(parseNtfy({ ntfyAuthType: "oauth" }).success).toBe(false); + }); + + it("rejects token auth without an access token", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "token" }))).toContain("accessToken"); + }); + + it("rejects token auth carrying a username", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "token", accessToken: "tk_secret", ntfyUsername: "alice" }))).toContain("ntfyUsername"); + }); + + it("rejects basic auth without a username", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "basic", accessToken: "s3cret" }))).toContain("ntfyUsername"); + }); + + it("rejects basic auth without a password", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "basic", ntfyUsername: "alice" }))).toContain("accessToken"); + }); + + it("reports both fields when basic auth is entirely empty", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "basic" }))).toEqual(expect.arrayContaining(["ntfyUsername", "accessToken"])); + }); + + // Credentials with auth off would be stored but never sent, which reads as protected + // while posting anonymously. + it("rejects credentials supplied without an auth type", () => { + expect(pathsOf(parseNtfy({ accessToken: "tk_secret" }))).toContain("accessToken"); + expect(pathsOf(parseNtfy({ ntfyUsername: "alice" }))).toContain("ntfyUsername"); + }); + + it("still requires a topic and a valid server URL", () => { + expect(parseNtfy({ topic: "" }).success).toBe(false); + expect(parseNtfy({ address: "not-a-url" }).success).toBe(false); + }); + }); }); From c35b22e1acf2a539bc90913dd35a0d543972f421 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Fri, 14 Aug 2026 14:11:16 +0300 Subject: [PATCH 074/143] fix: propagate SMTP transport errors from EmailService.sendEmail sendEmail caught transport failures internally and resolved to false or undefined, so every caller's .catch() was dead code and send failures were silently suppressed. Password recovery in particular reported "email sent successfully" while the user received nothing. sendEmail now throws an AppError carrying the underlying cause, and returns Promise instead of Promise. Call sites updated for the new contract: - UserService.registerUser: the existing .catch() now actually fires - UserService.requestRecovery: the failure reaches the client instead of a misleading success response - InviteService and the test-email endpoint: keep their user-facing messages and attach the SMTP cause - EmailProvider: catches and returns false, because NotificationsService fans providers out through Promise.all and a rejection there would abort the other channels' alerts Closes #3757 --- .../src/api/controllers/settingsController.ts | 42 +++++++++++-------- server/src/domain/invites/invite.service.ts | 6 ++- .../domain/notifications/providers/email.ts | 18 +++++--- server/src/domain/users/user.service.ts | 2 +- server/src/service/emailService.ts | 16 ++++++- .../notifications/emailProvider.test.ts | 10 +++-- .../test/unit/services/emailService.test.ts | 41 +++++++++++------- .../test/unit/services/inviteService.test.ts | 4 +- server/test/unit/services/userService.test.ts | 29 +++++++++++++ 9 files changed, 119 insertions(+), 49 deletions(-) diff --git a/server/src/api/controllers/settingsController.ts b/server/src/api/controllers/settingsController.ts index 3cb032e93..a8113ed2b 100644 --- a/server/src/api/controllers/settingsController.ts +++ b/server/src/api/controllers/settingsController.ts @@ -93,24 +93,30 @@ class SettingsController implements ISettingsController { if (!html) { throw new AppError({ message: "Failed to build email template.", status: 500 }); } - const messageId = await this.emailService.sendEmail(to, subject, html, { - systemEmailHost, - systemEmailPort, - systemEmailUser, - systemEmailAddress, - systemEmailDisplayName, - systemEmailPassword, - systemEmailConnectionHost, - systemEmailSecure, - systemEmailPool, - systemEmailIgnoreTLS, - systemEmailRequireTLS, - systemEmailRejectUnauthorized, - systemEmailTLSServername, - }); - - if (!messageId) { - throw new AppError({ message: "Failed to send test email.", status: 500 }); + let messageId: string; + try { + messageId = await this.emailService.sendEmail(to, subject, html, { + systemEmailHost, + systemEmailPort, + systemEmailUser, + systemEmailAddress, + systemEmailDisplayName, + systemEmailPassword, + systemEmailConnectionHost, + systemEmailSecure, + systemEmailPool, + systemEmailIgnoreTLS, + systemEmailRequireTLS, + systemEmailRejectUnauthorized, + systemEmailTLSServername, + }); + } catch (error: unknown) { + // Surface the underlying SMTP failure: diagnosing the settings is the whole + // point of the test email endpoint. + throw new AppError({ + message: error instanceof Error ? `Failed to send test email. ${error.message}` : "Failed to send test email.", + status: 500, + }); } return res.status(200).json({ diff --git a/server/src/domain/invites/invite.service.ts b/server/src/domain/invites/invite.service.ts index 3dcffd9e0..8182f4026 100644 --- a/server/src/domain/invites/invite.service.ts +++ b/server/src/domain/invites/invite.service.ts @@ -96,13 +96,15 @@ export class InviteService implements IInviteService { }); } - const result = await this.emailService.sendEmail(invite.email, "Welcome to Uptime Monitor", html); - if (!result) { + try { + await this.emailService.sendEmail(invite.email, "Welcome to Uptime Monitor", html); + } catch (error: unknown) { throw new AppError({ message: "Failed to send invite e-mail... Please verify your settings.", service: SERVICE_NAME, method: "sendInviteEmail", status: 500, + details: { cause: error instanceof Error ? error.message : "Unknown error" }, }); } }; diff --git a/server/src/domain/notifications/providers/email.ts b/server/src/domain/notifications/providers/email.ts index fa84be66d..2b5cde245 100644 --- a/server/src/domain/notifications/providers/email.ts +++ b/server/src/domain/notifications/providers/email.ts @@ -35,16 +35,18 @@ export class EmailProvider extends NotificationProvider { return false; } - const messageId = await this.emailService.sendEmail(notification.address, subject, html); - if (!messageId) { + try { + await this.emailService.sendEmail(notification.address, subject, html); + return true; + } catch (error: unknown) { this.logger.warn({ message: "Email test alert failed", service: SERVICE_NAME, method: "sendTestAlert", + stack: error instanceof Error ? error.stack : undefined, }); return false; } - return true; } async sendMessage(notification: Notification, message: NotificationMessage): Promise { @@ -64,16 +66,20 @@ export class EmailProvider extends NotificationProvider { return false; } - const messageId = await this.emailService.sendEmail(notification.address, subject, html); - if (!messageId) { + // Providers must resolve to a boolean: NotificationsService fans these out through + // Promise.all, so a rejection here would abort the other channels' alerts too. + try { + await this.emailService.sendEmail(notification.address, subject, html); + return true; + } catch (error: unknown) { this.logger.warn({ message: "Email notification failed", service: SERVICE_NAME, method: "sendMessage", + stack: error instanceof Error ? error.stack : undefined, }); return false; } - return true; } private buildSubject(message: NotificationMessage): string { diff --git a/server/src/domain/users/user.service.ts b/server/src/domain/users/user.service.ts index f042863aa..58a3ec1a3 100644 --- a/server/src/domain/users/user.service.ts +++ b/server/src/domain/users/user.service.ts @@ -30,7 +30,7 @@ export interface IUserService { currentUserEmail: string ): Promise; checkSuperadminExists(): Promise; - requestRecovery(email: string): Promise; + requestRecovery(email: string): Promise; validateRecovery(recoveryToken: string): Promise; resetPassword(password: string, recoveryToken: string): Promise<{ user: User; token: string }>; deleteUser(params: { userId: string; teamId: string; roles: UserRole[] }): Promise; diff --git a/server/src/service/emailService.ts b/server/src/service/emailService.ts index 26b89f84b..3aca5362c 100755 --- a/server/src/service/emailService.ts +++ b/server/src/service/emailService.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from "url"; import { EmailTransportConfig } from "@/domain/app-settings/app-settings.type.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { ILogger } from "@/utils/logger.js"; +import { AppError } from "@/utils/AppError.js"; import fs from "node:fs"; import path from "node:path"; import nodemailer from "nodemailer"; @@ -20,7 +21,7 @@ type TemplateCompiler = (template: string) => (context: Record) export interface IEmailService { init(): void; buildEmail(template: string, context: Record): Promise; - sendEmail(to: string, subject: string, html: string, transportConfig?: EmailTransportConfig): Promise; + sendEmail(to: string, subject: string, html: string, transportConfig?: EmailTransportConfig): Promise; } export class EmailService implements IEmailService { @@ -151,7 +152,12 @@ export class EmailService implements IEmailService { method: "verifyTransporter", stack: error instanceof Error ? error.stack : undefined, }); - return false; + throw new AppError({ + message: "Email transporter verification failed", + service: SERVICE_NAME, + method: "sendEmail", + details: { cause: error instanceof Error ? error.message : "Unknown error" }, + }); } const trimmedDisplayName = systemEmailDisplayName?.trim(); @@ -172,6 +178,12 @@ export class EmailService implements IEmailService { method: "sendEmail", stack: error instanceof Error ? error.stack : undefined, }); + throw new AppError({ + message: "Failed to send email", + service: SERVICE_NAME, + method: "sendEmail", + details: { cause: error instanceof Error ? error.message : "Unknown error" }, + }); } }; } diff --git a/server/test/unit/providers/notifications/emailProvider.test.ts b/server/test/unit/providers/notifications/emailProvider.test.ts index fecce8ef6..7f3b34d4e 100644 --- a/server/test/unit/providers/notifications/emailProvider.test.ts +++ b/server/test/unit/providers/notifications/emailProvider.test.ts @@ -50,9 +50,9 @@ describe("EmailProvider", () => { expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build test email content" })); }); - it("returns false when sendEmail returns falsy", async () => { + it("returns false when sendEmail rejects", async () => { const { provider, emailService, logger } = createProvider(); - (emailService.sendEmail as jest.Mock).mockResolvedValue(false); + (emailService.sendEmail as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); expect(await provider.sendTestAlert(makeNotification())).toBe(false); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email test alert failed" })); }); @@ -82,9 +82,11 @@ describe("EmailProvider", () => { expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build email content" })); }); - it("returns false when sendEmail returns falsy", async () => { + // NotificationsService fans providers out through Promise.all, so a failed email must + // resolve to false rather than reject and take the other channels down with it. + it("returns false when sendEmail rejects", async () => { const { provider, emailService, logger } = createProvider(); - (emailService.sendEmail as jest.Mock).mockResolvedValue(undefined); + (emailService.sendEmail as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email notification failed" })); }); diff --git a/server/test/unit/services/emailService.test.ts b/server/test/unit/services/emailService.test.ts index 971c712da..d18b83917 100644 --- a/server/test/unit/services/emailService.test.ts +++ b/server/test/unit/services/emailService.test.ts @@ -326,15 +326,17 @@ describe("EmailService", () => { expect(nodemailer.createTransport).toHaveBeenCalledWith(expect.objectContaining({ name: "localhost" })); }); - it("returns false when transporter verification fails", async () => { + // Callers wrap sendEmail in .catch()/try-catch to react to delivery failures, so a + // transport error has to reject rather than resolve to a falsy value. + it("throws when transporter verification fails", async () => { const transporter = createMockTransporter(); (transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); - const result = await service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig()); - - expect(result).toBe(false); + await expect(service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig())).rejects.toThrow( + "Email transporter verification failed" + ); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ message: "Email transporter verification failed", @@ -344,27 +346,38 @@ describe("EmailService", () => { ); }); + it("reports the underlying cause when verification fails", async () => { + const transporter = createMockTransporter(); + (transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + + await expect(service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig())).rejects.toMatchObject({ + status: 500, + details: { cause: "SMTP auth failed" }, + }); + }); + it("logs stack as undefined when verification fails with non-Error", async () => { const transporter = createMockTransporter(); (transporter.verify as jest.Mock).mockRejectedValue("connection refused"); const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); - const result = await service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig()); - - expect(result).toBe(false); + await expect(service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig())).rejects.toThrow(); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); }); - it("returns undefined and logs error when sendMail throws", async () => { + it("throws and logs error when sendMail throws", async () => { const transporter = createMockTransporter(); (transporter.sendMail as jest.Mock).mockRejectedValue(new Error("Recipient rejected")); const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); - const result = await service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig()); - - expect(result).toBeUndefined(); + await expect(service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig())).rejects.toMatchObject({ + message: "Failed to send email", + details: { cause: "Recipient rejected" }, + }); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "Recipient rejected", @@ -380,9 +393,9 @@ describe("EmailService", () => { const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); - const result = await service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig()); - - expect(result).toBeUndefined(); + await expect(service.sendEmail("to@example.com", "Subject", "

    body

    ", makeTransportConfig())).rejects.toMatchObject({ + details: { cause: "Unknown error" }, + }); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "Unknown error", diff --git a/server/test/unit/services/inviteService.test.ts b/server/test/unit/services/inviteService.test.ts index 59d472539..d60b0830e 100644 --- a/server/test/unit/services/inviteService.test.ts +++ b/server/test/unit/services/inviteService.test.ts @@ -127,11 +127,11 @@ describe("InviteService", () => { ).rejects.toThrow("Failed to build invite e-mail"); }); - it("throws 500 when sendEmail returns falsy", async () => { + it("throws 500 when sendEmail rejects", async () => { const { service } = createService({ emailService: { buildEmail: jest.fn().mockResolvedValue("ok"), - sendEmail: jest.fn().mockResolvedValue(false), + sendEmail: jest.fn().mockRejectedValue(new Error("SMTP auth failed")), }, }); diff --git a/server/test/unit/services/userService.test.ts b/server/test/unit/services/userService.test.ts index 5187a3c60..ee47ffded 100644 --- a/server/test/unit/services/userService.test.ts +++ b/server/test/unit/services/userService.test.ts @@ -196,6 +196,22 @@ describe("UserService", () => { expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "registerUser" })); }); + // The welcome email is fire-and-forget, but its .catch() only runs if sendEmail + // actually rejects; swallowing the transport error made that handler dead code. + it("logs a warning when the welcome email fails to send", async () => { + const { service, logger } = createService({ + emailService: { + buildEmail: jest.fn().mockResolvedValue("Welcome"), + sendEmail: jest.fn().mockRejectedValue(new Error("SMTP auth failed")), + }, + }); + + const result = await service.registerUser({ password: "pass" }, "invite-token", null); + + expect(result.user).toBeDefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "registerUser", message: "SMTP auth failed" })); + }); + it("uses default role when invite has no role", async () => { const { service, usersRepository } = createService({ invitesRepository: { @@ -423,6 +439,19 @@ describe("UserService", () => { await expect(service.requestRecovery("test@example.com")).rejects.toThrow("Failed to build password reset email HTML"); }); + + // Previously the send failure was swallowed and the endpoint still reported + // "Password recovery email sent successfully" while the user got nothing. + it("propagates the failure when the recovery email cannot be sent", async () => { + const { service } = createService({ + emailService: { + buildEmail: jest.fn().mockResolvedValue("Reset"), + sendEmail: jest.fn().mockRejectedValue(new Error("SMTP auth failed")), + }, + }); + + await expect(service.requestRecovery("test@example.com")).rejects.toThrow("SMTP auth failed"); + }); }); // ── validateRecovery ──────────────────────────────────────────────────── From 099eaa38c3aa5cec2caeef5a8472a1018cea16ce Mon Sep 17 00:00:00 2001 From: egeoztass Date: Fri, 14 Aug 2026 17:55:36 +0300 Subject: [PATCH 075/143] fix: resolve Matrix room aliases and encode room IDs in the send path Matrix notifications failed for anyone who entered a room alias. The send endpoint only accepts a room ID, and an unresolved alias broke the request outright: the leading "#" opens a URI fragment, so the PUT landed on /_matrix/client/v3/rooms/ instead of the send endpoint. Element shows users the alias, so this is the value most people paste in. - Resolve "#alias:server" through GET /_matrix/client/v3/directory/room/{alias} before sending, per the client-server spec - Percent-encode the room ID in the send path; ":" was previously sent raw - Strip trailing slashes from the homeserver URL to avoid "//_matrix/..." Alias resolution failures return false and log, matching how the provider already handles send failures. Closes #3478 --- .../domain/notifications/providers/matrix.ts | 36 ++++++++- .../notifications/matrixProvider.test.ts | 75 ++++++++++++++++++- 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/server/src/domain/notifications/providers/matrix.ts b/server/src/domain/notifications/providers/matrix.ts index a1f35b0ca..1fafc44b1 100644 --- a/server/src/domain/notifications/providers/matrix.ts +++ b/server/src/domain/notifications/providers/matrix.ts @@ -38,10 +38,13 @@ export class MatrixProvider extends NotificationProvider { private sendMessageWithBody = async (notification: Partial, body: unknown): Promise => { const { homeserverUrl, accessToken, roomId } = notification; - const txnId = randomUUID(); - const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message/${txnId}`; + const baseUrl = this.normalizeHomeserverUrl(homeserverUrl as string); try { + const resolvedRoomId = await this.resolveRoomId(baseUrl, accessToken as string, roomId as string); + const txnId = randomUUID(); + const url = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoomId)}/send/m.room.message/${txnId}`; + await got.put(url, { json: body, headers: { @@ -63,6 +66,35 @@ export class MatrixProvider extends NotificationProvider { } }; + private normalizeHomeserverUrl(homeserverUrl: string): string { + return homeserverUrl.replace(/\/+$/, ""); + } + + /** + * The send endpoint only accepts a room ID (`!id:server`). Element shows users the room + * alias (`#name:server`) instead, and an unresolved alias breaks the request outright: + * the `#` opens a URI fragment, so the PUT lands on /rooms/ rather than the send + * endpoint. Resolve aliases through the directory first, per the client-server spec. + */ + private resolveRoomId = async (baseUrl: string, accessToken: string, roomIdOrAlias: string): Promise => { + if (!roomIdOrAlias.startsWith("#")) { + return roomIdOrAlias; + } + + const url = `${baseUrl}/_matrix/client/v3/directory/room/${encodeURIComponent(roomIdOrAlias)}`; + const response = await got.get(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + responseType: "json", + ...this.gotRequestOptions(), + }); + + const roomId = (response.body as { room_id?: string })?.room_id; + if (!roomId) { + throw new Error(`Matrix directory returned no room_id for alias ${roomIdOrAlias}`); + } + return roomId; + }; + /** * Build Matrix message from NotificationMessage * Returns both plain text and HTML formatted versions diff --git a/server/test/unit/providers/notifications/matrixProvider.test.ts b/server/test/unit/providers/notifications/matrixProvider.test.ts index 65305e731..1294a677b 100644 --- a/server/test/unit/providers/notifications/matrixProvider.test.ts +++ b/server/test/unit/providers/notifications/matrixProvider.test.ts @@ -4,7 +4,8 @@ import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWi import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; const mockGotPut = jest.fn().mockResolvedValue({}); -jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut } })); +const mockGotGet = jest.fn().mockResolvedValue({ body: { room_id: "!resolved:example.com" } }); +jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut, get: mockGotGet } })); jest.unstable_mockModule("crypto", () => ({ randomUUID: () => "test-uuid-1234" })); const { MatrixProvider } = await import("../../../../src/domain/notifications/providers/matrix.ts"); @@ -23,13 +24,16 @@ testNotificationProviderContract("MatrixProvider", { }); describe("MatrixProvider", () => { - beforeEach(() => mockGotPut.mockReset().mockResolvedValue({})); + beforeEach(() => { + mockGotPut.mockReset().mockResolvedValue({}); + mockGotGet.mockReset().mockResolvedValue({ body: { room_id: "!resolved:example.com" } }); + }); describe("sendTestAlert", () => { it("sends to Matrix API and returns true", async () => { expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true); expect(mockGotPut).toHaveBeenCalledWith( - expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/test-uuid-1234"), + expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room%3Aexample.com/send/m.room.message/test-uuid-1234"), expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer token-abc" }), }) @@ -141,4 +145,69 @@ describe("MatrixProvider", () => { expect(html).toContain("#808080"); }); }); + + describe("room identifiers", () => { + const putUrl = () => mockGotPut.mock.calls[0][0] as string; + + it("percent-encodes the room ID in the send path", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ roomId: "!abc123:example.com" })); + expect(putUrl()).toContain("/rooms/!abc123%3Aexample.com/send/"); + }); + + it("does not resolve a room ID through the directory", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ roomId: "!abc123:example.com" })); + expect(mockGotGet).not.toHaveBeenCalled(); + }); + + // An unresolved alias breaks the request outright: the "#" opens a URI fragment, so + // the PUT lands on /rooms/ instead of the send endpoint. + it("resolves a room alias through the directory before sending", async () => { + const { provider } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ roomId: "#alerts:example.com" }))).toBe(true); + expect(mockGotGet).toHaveBeenCalledWith( + "https://matrix.example.com/_matrix/client/v3/directory/room/%23alerts%3Aexample.com", + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer token-abc" }) }) + ); + expect(putUrl()).toContain("/rooms/!resolved%3Aexample.com/send/"); + }); + + it("never leaves an unencoded # in the request URL", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ roomId: "#alerts:example.com" })); + expect(putUrl()).not.toContain("#"); + expect(new URL(putUrl()).hash).toBe(""); + }); + + it("returns false and logs when the alias cannot be resolved", async () => { + mockGotGet.mockRejectedValue(new Error("M_NOT_FOUND")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ roomId: "#missing:example.com" }))).toBe(false); + expect(mockGotPut).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("returns false when the directory response carries no room_id", async () => { + mockGotGet.mockResolvedValue({ body: {} }); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ roomId: "#alerts:example.com" }))).toBe(false); + expect(mockGotPut).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("strips a trailing slash from the homeserver URL", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ homeserverUrl: "https://matrix.example.com/" })); + expect(putUrl()).toContain("https://matrix.example.com/_matrix/"); + expect(putUrl()).not.toContain("com//_matrix"); + }); + + it("resolves aliases for real alerts, not just test alerts", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification({ roomId: "#alerts:example.com" }) as any, makeMessage()); + expect(mockGotGet).toHaveBeenCalled(); + expect(putUrl()).toContain("/rooms/!resolved%3Aexample.com/send/"); + }); + }); }); From 741ee7a92ae222ee7877bbc73ffda3a3d9d38fb5 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Mon, 17 Aug 2026 12:27:02 -0400 Subject: [PATCH 076/143] fix(security): redact sensitive fields from client-side logs Failed requests logged the full request body to the browser console, so a failing POST to /auth/login wrote the user's plaintext password where it could be captured by error-reporting tools, screen shares, or support sessions. Redaction is applied centrally in the logger rather than at the call sites, so every current and future caller is covered: - createLogData() redacts context before it is serialized, covering the JSON branches of error() and warn(). - The debug-level branches of error() and warn() printed the raw context object directly, bypassing createLogData(); they now print the redacted copy. - debug() and info() redact their free-form data argument. Keys are matched case-insensitively on a substring after stripping dashes and underscores, so password, newPassword, X-Api-Key and Authorization are all caught. The walk copies rather than mutates the caller's object, cuts genuine cycles without mislabelling repeated siblings, and renders FormData/Blob as a type marker instead of walking them. Affected paths were POST (/auth/login, /auth/register) and PATCH/PUT, which carry monitor secrets. Error-level logging is on by default, so this was not limited to debug builds. Fixes #3843 --- client/src/Utils/logger.ts | 78 +++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/client/src/Utils/logger.ts b/client/src/Utils/logger.ts index 74be87918..c70769db4 100644 --- a/client/src/Utils/logger.ts +++ b/client/src/Utils/logger.ts @@ -44,6 +44,70 @@ const formatError = (error?: Error) => { }; }; +/** + * Keys whose values must never reach the console. Matched case-insensitively + * against a substring of the key, so `newPassword` and `X-Api-Key` are covered + * by `password` and `apikey` respectively. + */ +const SENSITIVE_KEY_PATTERNS = [ + "password", + "secret", + "token", + "authorization", + "apikey", + "credential", + "passphrase", +] as const; +type SensitiveKeyPattern = (typeof SENSITIVE_KEY_PATTERNS)[number]; + +const REDACTED = "[REDACTED]"; + +const isSensitiveKey = (key: string): boolean => { + const normalized = key.toLowerCase().replace(/[-_]/g, ""); + return SENSITIVE_KEY_PATTERNS.some((pattern: SensitiveKeyPattern) => + normalized.includes(pattern) + ); +}; + +/** + * Deep-copies a value with sensitive fields replaced by a placeholder. Guards + * against cycles so a self-referencing payload cannot hang the logger, and + * leaves non-plain objects (FormData, File, Blob) as a type marker rather than + * walking them. + * + * `ancestors` tracks only the current path, so the same object appearing twice + * as a sibling is still rendered in full — only a genuine cycle is cut. + */ +const redactSensitive = (value: unknown, ancestors: readonly object[] = []): unknown => { + if (value === null || typeof value !== "object") return value; + + if (ancestors.includes(value)) return "[CIRCULAR]"; + + if ( + typeof FormData !== "undefined" && + (value instanceof FormData || value instanceof Blob) + ) { + return `[${value.constructor.name}]`; + } + + const path = [...ancestors, value]; + + if (Array.isArray(value)) { + return value.map((item) => redactSensitive(item, path)); + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return value; + } + + const result: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + result[key] = isSensitiveKey(key) ? REDACTED : redactSensitive(entry, path); + } + return result; +}; + const createLogData = ( level: LogLevel, message: string, @@ -55,7 +119,7 @@ const createLogData = ( level, message, error: formatError(error), - context, + context: context === undefined ? undefined : (redactSensitive(context) as LogContext), url: window.location.href, userAgent: navigator.userAgent, }; @@ -72,7 +136,7 @@ const error = (message: string, error?: Error, context?: LogContext): void => { console.error("Error:", error); } if (context && Object.keys(context).length > 0) { - console.log("Context:", context); + console.log("Context:", logData.context); } console.log("URL:", logData.url); console.log("Timestamp:", logData.timestamp); @@ -90,7 +154,7 @@ const warn = (message: string, context?: LogContext): void => { if (configuredLevel === "debug") { console.group(`WARN: ${message}`); if (context && Object.keys(context).length > 0) { - console.log("Context:", context); + console.log("Context:", logData.context); } console.groupEnd(); } else { @@ -103,7 +167,7 @@ const debug = (message: string, data?: any): void => { console.group(`DEBUG: ${message}`); if (data !== undefined) { - console.log(data); + console.log(redactSensitive(data)); } console.groupEnd(); }; @@ -111,14 +175,16 @@ const debug = (message: string, data?: any): void => { const info = (message: string, data?: any): void => { if (!shouldLog("info")) return; + const safeData = data === undefined ? undefined : redactSensitive(data); + if (configuredLevel === "debug") { - console.log(`INFO: ${message}`, data); + console.log(`INFO: ${message}`, safeData); } else { const logData = { timestamp: new Date().toISOString(), level: "info" as LogLevel, message, - data, + data: safeData, }; console.log(JSON.stringify(logData)); } From e26825ef9921beb56af4aad8b0e67714756ad567 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Mon, 17 Aug 2026 12:31:40 -0400 Subject: [PATCH 077/143] fix(security): close redaction gaps found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the initial redaction pass, addressing four defects: Class instances bypassed redaction entirely. The prototype guard was meant to skip FormData/Blob but caught every non-literal object, returning it by reference — so a credential on a DTO or form-state instance was logged verbatim, and the deep-copy contract was violated. The check is now an explicit deny-list of opaque types; everything else is walked. The raw Error was still logged by the debug branch. An AxiosError carries config.data (the serialized request body) and config.headers.Authorization (set for every request in ApiClient), so the password redacted from the context was printed straight back out via the error object. Errors now go through toSafeError(). Because Axios serializes the body to a string, which key-based redaction cannot see into, it is parsed before redaction; a body that is not JSON is replaced wholesale rather than logged raw. The Blob guard was gated on `typeof FormData !== "undefined"`, so in an environment with FormData but no Blob the instanceof check threw a ReferenceError — from inside a catch block, turning a handled API error into an unhandled crash. Each constructor is now resolved defensively. Also widened the key list (cookie, jwt, privateKey, sessionId, csrf, pwd) and dropped a redundant type annotation. Verified with 18 assertions covering the reported payload, class instances, cycles, shared siblings, axios error shapes, and a missing Blob global. --- client/src/Utils/logger.ts | 117 +++++++++++++++++++++++++------------ 1 file changed, 81 insertions(+), 36 deletions(-) diff --git a/client/src/Utils/logger.ts b/client/src/Utils/logger.ts index c70769db4..1b7d7a7a5 100644 --- a/client/src/Utils/logger.ts +++ b/client/src/Utils/logger.ts @@ -16,11 +16,7 @@ interface ErrorLogData { timestamp: string; level: LogLevel; message: string; - error?: { - message: string; - name: string; - stack?: string; - }; + error?: Record; context?: LogContext; url: string; userAgent: string; @@ -34,16 +30,6 @@ const shouldLog = (level: LogLevel): boolean => { return LOG_LEVEL_PRIORITY[level] >= configuredPriority; }; -const formatError = (error?: Error) => { - if (!error) return undefined; - - return { - message: error.message, - name: error.name, - stack: error.stack, - }; -}; - /** * Keys whose values must never reach the console. Matched case-insensitively * against a substring of the key, so `newPassword` and `X-Api-Key` are covered @@ -51,29 +37,45 @@ const formatError = (error?: Error) => { */ const SENSITIVE_KEY_PATTERNS = [ "password", + "passwd", + "pwd", "secret", "token", "authorization", "apikey", "credential", "passphrase", + "cookie", + "jwt", + "privatekey", + "sessionid", + "csrf", ] as const; -type SensitiveKeyPattern = (typeof SENSITIVE_KEY_PATTERNS)[number]; const REDACTED = "[REDACTED]"; const isSensitiveKey = (key: string): boolean => { const normalized = key.toLowerCase().replace(/[-_]/g, ""); - return SENSITIVE_KEY_PATTERNS.some((pattern: SensitiveKeyPattern) => - normalized.includes(pattern) - ); + return SENSITIVE_KEY_PATTERNS.some((pattern) => normalized.includes(pattern)); }; /** - * Deep-copies a value with sensitive fields replaced by a placeholder. Guards - * against cycles so a self-referencing payload cannot hang the logger, and - * leaves non-plain objects (FormData, File, Blob) as a type marker rather than - * walking them. + * Opaque browser types that must not be walked: they carry no useful log data + * and may hold large buffers. Resolved defensively because these globals are + * absent in SSR and some test environments. + */ +const isOpaqueObject = (value: object): boolean => { + const opaqueTypes = ["FormData", "Blob", "File", "ArrayBuffer"] as const; + return opaqueTypes.some((name) => { + const ctor = (globalThis as Record)[name]; + return typeof ctor === "function" && value instanceof (ctor as new () => object); + }); +}; + +/** + * Deep-copies a value with sensitive fields replaced by a placeholder. Walks + * class instances as well as object literals, so a credential on a DTO is + * redacted too. Opaque types (FormData, Blob) render as a type marker. * * `ancestors` tracks only the current path, so the same object appearing twice * as a sibling is still rendered in full — only a genuine cycle is cut. @@ -83,12 +85,10 @@ const redactSensitive = (value: unknown, ancestors: readonly object[] = []): unk if (ancestors.includes(value)) return "[CIRCULAR]"; - if ( - typeof FormData !== "undefined" && - (value instanceof FormData || value instanceof Blob) - ) { - return `[${value.constructor.name}]`; - } + if (isOpaqueObject(value)) return `[${value.constructor?.name ?? "Object"}]`; + + // Dates and RegExps stringify usefully on their own; walking them yields nothing. + if (value instanceof Date || value instanceof RegExp) return value; const path = [...ancestors, value]; @@ -96,11 +96,8 @@ const redactSensitive = (value: unknown, ancestors: readonly object[] = []): unk return value.map((item) => redactSensitive(item, path)); } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - return value; - } - + // Walk own enumerable properties of ANY object, including class instances — + // a credential on a DTO must be redacted just as it is on an object literal. const result: Record = {}; for (const [key, entry] of Object.entries(value as Record)) { result[key] = isSensitiveKey(key) ? REDACTED : redactSensitive(entry, path); @@ -108,6 +105,54 @@ const redactSensitive = (value: unknown, ancestors: readonly object[] = []): unk return result; }; +/** + * Parses a serialized request body so its fields can be redacted by key. A body + * that is not JSON is replaced wholesale rather than logged raw, since an + * unparseable payload (e.g. form-encoded) may still carry credentials. + */ +const safeJsonParse = (raw: string): unknown => { + try { + const parsed = JSON.parse(raw); + return typeof parsed === "object" && parsed !== null ? parsed : "[UNPARSED BODY]"; + } catch { + return "[UNPARSED BODY]"; + } +}; + +/** + * Errors are logged as-is by the debug branches, but an AxiosError carries the + * outgoing request on `config` — including the serialized body and the + * Authorization header — so the raw object cannot be handed to the console. + * Returns a redacted plain object safe to print. + */ +const toSafeError = (error: Error): Record => { + const candidate = error as unknown as { + config?: { url?: string; method?: string; data?: unknown; headers?: unknown }; + response?: { status?: number }; + }; + + const safe: Record = { + name: error.name, + message: error.message, + stack: error.stack, + }; + + if (candidate.config) { + const { url, method, data, headers } = candidate.config; + safe.config = { + url, + method, + // Axios serializes the request body to a string, so key-based + // redaction cannot reach into it — parse it back first. + data: redactSensitive(typeof data === "string" ? safeJsonParse(data) : data), + headers: redactSensitive(headers), + }; + safe.status = candidate.response?.status; + } + + return redactSensitive(safe) as Record; +}; + const createLogData = ( level: LogLevel, message: string, @@ -118,7 +163,7 @@ const createLogData = ( timestamp: new Date().toISOString(), level, message, - error: formatError(error), + error: error === undefined ? undefined : toSafeError(error), context: context === undefined ? undefined : (redactSensitive(context) as LogContext), url: window.location.href, userAgent: navigator.userAgent, @@ -133,7 +178,7 @@ const error = (message: string, error?: Error, context?: LogContext): void => { if (configuredLevel === "debug") { console.group(`ERROR: ${message}`); if (error) { - console.error("Error:", error); + console.error("Error:", toSafeError(error)); } if (context && Object.keys(context).length > 0) { console.log("Context:", logData.context); From ed268b30998f3e8665279a6a219675e5c9f643ba Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Mon, 17 Aug 2026 12:35:59 -0400 Subject: [PATCH 078/143] test(client): cover credential redaction in the logger Adds a regression suite for #3843 so the leak cannot return silently. The client had no test runner, so this introduces Vitest, which reuses the existing Vite resolution and the `@/` alias rather than needing a separate transform pipeline. jsdom is required because the logger reads window.location and navigator.userAgent. The tests drive the exported `logger` rather than the private redaction helpers, so they cover the wiring as well as the logic. That distinction matters here: the first attempt at this fix redacted the JSON branch but left the debug branch printing raw objects, a gap that testing the helper in isolation would not have caught. Each case runs at both error and debug level for the same reason. Covered: the reported login payload, credentials carried on an AxiosError (config.data and the Authorization header), non-JSON request bodies, passwords on class instances, key-matching variants, and circular payloads. One asserts non-sensitive context survives, so redaction cannot regress into blanket suppression. Verified by reverting the fix: 16 of 23 tests fail against the original behaviour, including a pre-existing crash on circular payloads. --- client/package-lock.json | 1013 ++++++++++++++++++++++++++++++- client/package.json | 8 +- client/src/Utils/logger.test.ts | 201 ++++++ client/vitest.config.ts | 18 + 4 files changed, 1237 insertions(+), 3 deletions(-) create mode 100644 client/src/Utils/logger.test.ts create mode 100644 client/vitest.config.ts diff --git a/client/package-lock.json b/client/package-lock.json index bca8463c0..7bea223fb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -57,13 +57,161 @@ "eslint-plugin-react": "^7.34.1", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.6", + "jsdom": "^29.1.1", "lint-staged": "^16.2.7", "mongoose": "9.6.2", "prettier": "^3.3.3", "typescript": "5.9.3", - "vite": "^6.4.2" + "vite": "^6.4.2", + "vitest": "^3.2.7" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -335,6 +483,64 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", @@ -950,6 +1156,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "license": "BSD-3-Clause" @@ -2297,6 +2521,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "license": "MIT" @@ -2346,6 +2581,13 @@ "version": "3.0.2", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2752,6 +2994,131 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "dev": true, @@ -2985,6 +3352,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", @@ -3069,6 +3446,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", @@ -3155,6 +3542,16 @@ "typewise-core": "^1.2" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "dev": true, @@ -3235,6 +3632,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -3250,6 +3664,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -3392,6 +3816,20 @@ "utrie": "^1.0.2" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "license": "MIT" @@ -3505,6 +3943,58 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "dev": true, @@ -3572,10 +4062,27 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -3842,6 +4349,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "license": "MIT", @@ -4169,6 +4683,16 @@ "version": "4.0.7", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -4711,6 +5235,19 @@ "version": "16.13.1", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "license": "MIT", @@ -5115,6 +5652,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "dev": true, @@ -5316,6 +5860,95 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "license": "MIT", @@ -5549,6 +6182,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lower-case": { "version": "2.0.2", "license": "MIT", @@ -5572,6 +6212,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/maplibre-gl": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz", @@ -5616,6 +6266,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", @@ -6115,6 +6772,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -6150,6 +6833,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pbf": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", @@ -6628,6 +7328,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "license": "MIT" @@ -6838,6 +7548,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "license": "MIT", @@ -7004,6 +7727,13 @@ "dev": true, "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7161,6 +7891,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "dev": true, @@ -7339,6 +8083,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stylis": { "version": "4.2.0", "license": "MIT" @@ -7388,6 +8152,13 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/text-segmentation": { "version": "1.0.3", "license": "MIT", @@ -7404,6 +8175,20 @@ "version": "1.3.3", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "license": "MIT", @@ -7418,12 +8203,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7437,6 +8272,19 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -7603,6 +8451,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.12.0", "devOptional": true, @@ -7769,6 +8627,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-svgr": { "version": "4.5.0", "license": "MIT", @@ -7781,6 +8662,79 @@ "vite": ">=2.6.0" } }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "license": "MIT", @@ -7788,6 +8742,19 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -7798,6 +8765,16 @@ "node": ">=12" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", @@ -7907,6 +8884,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, @@ -7998,6 +8992,23 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/client/package.json b/client/package.json index ee290cae8..ac70f6834 100644 --- a/client/package.json +++ b/client/package.json @@ -11,7 +11,9 @@ "lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "format": "prettier --write .", - "format-check": "prettier --check ." + "format-check": "prettier --check .", + "test": "vitest run", + "test:watch": "vitest" }, "lint-staged": { "**/*": "prettier --write --ignore-unknown" @@ -66,10 +68,12 @@ "eslint-plugin-react": "^7.34.1", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.6", + "jsdom": "^29.1.1", "lint-staged": "^16.2.7", "mongoose": "9.6.2", "prettier": "^3.3.3", "typescript": "5.9.3", - "vite": "^6.4.2" + "vite": "^6.4.2", + "vitest": "^3.2.7" } } diff --git a/client/src/Utils/logger.test.ts b/client/src/Utils/logger.test.ts new file mode 100644 index 000000000..da7c0d7fa --- /dev/null +++ b/client/src/Utils/logger.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Regression tests for the credential leak reported in #3843: a failed request + * wrote the full body — including the plaintext password — to the console. + * + * These drive the exported `logger` rather than the private redaction helpers, + * so they cover the wiring too. The original fix redacted the JSON branch but + * left the debug branch printing the raw objects, which these tests catch. + * + * `configuredLevel` is captured at module load, so each level needs a fresh + * import with `runtimeConfig` mocked beforehand. + */ + +const PASSWORD = "correct-horse-battery-staple"; +const BEARER = "eyJhbGciOiJIUzI1NiJ9.super-secret-jwt"; + +type ConsoleMethod = "log" | "warn" | "error"; +const spies: Partial>> = {}; + +/** Everything written to the console during a test, as one searchable string. */ +const consoleOutput = (): string => + (["log", "warn", "error"] as const) + .flatMap((method) => spies[method]?.mock.calls ?? []) + .map((args) => args.map((arg) => JSON.stringify(arg) ?? String(arg)).join(" ")) + .join("\n"); + +const loadLogger = async (level: "debug" | "error") => { + vi.resetModules(); + vi.doMock("@/Utils/runtimeConfig", () => ({ runtimeConfig: { logLevel: level } })); + return (await import("@/Utils/logger")).logger; +}; + +beforeEach(() => { + for (const method of ["log", "warn", "error"] as const) { + spies[method] = vi.spyOn(console, method).mockImplementation(() => {}); + } + vi.spyOn(console, "group").mockImplementation(() => {}); + vi.spyOn(console, "groupEnd").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock("@/Utils/runtimeConfig"); +}); + +describe.each(["error", "debug"] as const)("logger at level %s", (level) => { + it("does not log a password from a failed request body", async () => { + const logger = await loadLogger(level); + + logger.error("POST request failed", new Error("Request failed"), { + endpoint: "/auth/login", + body: { email: "user@example.com", password: PASSWORD }, + }); + + expect(consoleOutput()).not.toContain(PASSWORD); + expect(consoleOutput()).toContain("[REDACTED]"); + }); + + it("keeps non-sensitive context so logs stay useful", async () => { + const logger = await loadLogger(level); + + logger.error("POST request failed", new Error("boom"), { + endpoint: "/auth/login", + body: { email: "user@example.com", password: PASSWORD }, + }); + + const output = consoleOutput(); + expect(output).toContain("/auth/login"); + expect(output).toContain("user@example.com"); + }); + + it("does not leak credentials carried on an AxiosError", async () => { + const logger = await loadLogger(level); + + // Axios attaches the outgoing request to the error: `config.data` holds the + // serialized body and `config.headers` the Authorization set in ApiClient. + const axiosError = Object.assign(new Error("Request failed with status code 502"), { + name: "AxiosError", + config: { + url: "/auth/login", + method: "post", + data: JSON.stringify({ email: "user@example.com", password: PASSWORD }), + headers: { Authorization: `Bearer ${BEARER}`, Accept: "application/json" }, + }, + response: { status: 502 }, + }); + + logger.error("POST request failed", axiosError, { endpoint: "/auth/login" }); + + const output = consoleOutput(); + expect(output).not.toContain(PASSWORD); + expect(output).not.toContain(BEARER); + }); + + it("redacts a password held on a class instance, not just an object literal", async () => { + const logger = await loadLogger(level); + + // Declared without parameter properties: the repo enables + // `erasableSyntaxOnly`, which disallows that shorthand. + class LoginPayload { + email: string; + password: string; + + constructor(email: string, password: string) { + this.email = email; + this.password = password; + } + } + + logger.error("POST request failed", new Error("boom"), { + endpoint: "/auth/login", + body: new LoginPayload("user@example.com", PASSWORD), + }); + + expect(consoleOutput()).not.toContain(PASSWORD); + }); + + it("redacts a request body that is not JSON rather than logging it raw", async () => { + const logger = await loadLogger(level); + + const formEncoded = Object.assign(new Error("boom"), { + config: { + url: "/auth/login", + method: "post", + data: `email=user@example.com&password=${PASSWORD}`, + headers: {}, + }, + }); + + logger.error("POST request failed", formEncoded, {}); + + expect(consoleOutput()).not.toContain(PASSWORD); + }); + + it("redacts secrets passed through warn", async () => { + const logger = await loadLogger(level); + + logger.warn("monitor update failed", { + endpoint: "/monitors/1", + body: { url: "https://example.com", secret: BEARER }, + }); + + expect(consoleOutput()).not.toContain(BEARER); + }); +}); + +describe("logger at level debug", () => { + // debug() and info() are suppressed at error level, so they only apply here. + it("redacts secrets passed through debug and info", async () => { + const logger = await loadLogger("debug"); + + logger.debug("payload", { password: PASSWORD }); + logger.info("payload", { apiKey: BEARER }); + + const output = consoleOutput(); + expect(output).not.toContain(PASSWORD); + expect(output).not.toContain(BEARER); + }); +}); + +describe("redaction key matching", () => { + it.each([ + ["password", { password: PASSWORD }], + ["newPassword", { newPassword: PASSWORD }], + ["confirmPassword", { confirmPassword: PASSWORD }], + ["secret", { secret: PASSWORD }], + ["accessToken", { accessToken: PASSWORD }], + ["X-Api-Key", { "X-Api-Key": PASSWORD }], + ["Authorization", { Authorization: PASSWORD }], + ["private_key", { private_key: PASSWORD }], + ])("redacts %s", async (_name, body) => { + const logger = await loadLogger("error"); + + logger.error("POST request failed", undefined, { body }); + + expect(consoleOutput()).not.toContain(PASSWORD); + }); + + it("survives a circular payload without hanging", async () => { + const logger = await loadLogger("error"); + + const circular: Record = { password: PASSWORD }; + circular.self = circular; + + expect(() => + logger.error("POST request failed", undefined, { body: circular }) + ).not.toThrow(); + expect(consoleOutput()).not.toContain(PASSWORD); + }); + + it("renders a repeated sibling in full rather than marking it circular", async () => { + const logger = await loadLogger("error"); + + const shared = { monitorId: "abc123" }; + + logger.error("POST request failed", undefined, { first: shared, second: shared }); + + expect(consoleOutput()).not.toContain("[CIRCULAR]"); + }); +}); diff --git a/client/vitest.config.ts b/client/vitest.config.ts new file mode 100644 index 000000000..75ca75aa3 --- /dev/null +++ b/client/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + // The logger reads window.location and navigator.userAgent. + environment: "jsdom", + include: ["src/**/*.{test,spec}.{ts,tsx}"], + }, +}); From 0cbd9ab8a23f345c53071c3343bb433cc27b0ec9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 10:49:39 -0700 Subject: [PATCH 079/143] point lockfile at official npm registry --- server/package-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index a581bd18b..b9a733664 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -15242,7 +15242,7 @@ }, "node_modules/tldts": { "version": "7.4.10", - "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.10.tgz", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", "license": "MIT", "dependencies": { @@ -15254,7 +15254,7 @@ }, "node_modules/tldts-core": { "version": "7.4.10", - "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.4.10.tgz", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", "license": "MIT" }, @@ -15311,7 +15311,7 @@ }, "node_modules/tough-cookie/node_modules/tldts": { "version": "6.1.86", - "resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "license": "MIT", "dependencies": { @@ -15323,7 +15323,7 @@ }, "node_modules/tough-cookie/node_modules/tldts-core": { "version": "6.1.86", - "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "license": "MIT" }, @@ -15970,7 +15970,7 @@ }, "node_modules/whoiser": { "version": "1.18.0", - "resolved": "https://registry.npmmirror.com/whoiser/-/whoiser-1.18.0.tgz", + "resolved": "https://registry.npmjs.org/whoiser/-/whoiser-1.18.0.tgz", "integrity": "sha512-QRIGreBuouc8d9i+UVMFqYJSiG7gaoaGX8nKugYDGqnuNLLgjDBwmlKODOIGHveBawza3Kfkk/OuM9VsTUYwaA==", "license": "MIT", "dependencies": { From 3a22d3e819848b6caed530b6e9f70fa737bdec44 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 11:48:44 -0700 Subject: [PATCH 080/143] add range buckets for status pages --- .../api/validation/statusPageValidation.ts | 4 +- server/src/domain/checks/check.type.ts | 9 +++++ .../status-page-repository.interface.ts | 2 + .../status-page-repository.mongo.ts | 38 +++++++++++++++++++ .../domain/status-pages/status-page.type.ts | 15 +++++++- 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index ee2b7376c..1b07dc1dc 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; -import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js"; +import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes, StatusPageRanges } from "@/domain/status-pages/status-page.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { cssReferencesExternalResource } from "@/utils/customCss.js"; import { ImageMimeTypes } from "@/types/upload.js"; @@ -15,7 +15,7 @@ export const getStatusPageParamValidation = z.object({ export const getStatusPageQueryValidation = z.object({ type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])), - timeFrame: z.coerce.number().optional(), + range: z.enum(StatusPageRanges).optional().default("latest"), }); export const resolveStatusPageQueryValidation = getStatusPageQueryValidation.extend({ diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index 4e69a5241..2f933dd0b 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -243,3 +243,12 @@ export interface HardwareStats { export interface HardwareChecksResult extends HardwareStats { monitorType: "hardware"; } + +export type DailyCheckBucket = { + monitorId: string; + date: string; + totalChecks: number; + upChecks: number; + downChecks: number; + avgResponseTime: number; +}; diff --git a/server/src/domain/status-pages/status-page-repository.interface.ts b/server/src/domain/status-pages/status-page-repository.interface.ts index 4b3eccd0a..ed58b18a2 100644 --- a/server/src/domain/status-pages/status-page-repository.interface.ts +++ b/server/src/domain/status-pages/status-page-repository.interface.ts @@ -1,3 +1,4 @@ +import { DailyCheckBucket } from "@/domain/checks/check.type.js"; import type { StatusPage } from "@/domain/status-pages/status-page.type.js"; export interface IStatusPagesRepository { @@ -8,6 +9,7 @@ export interface IStatusPagesRepository { findByCustomDomain(customDomain: string): Promise; findByTeamId(teamId: string): Promise; // collection fetch + getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string | undefined): Promise; // update updateById(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; // delete diff --git a/server/src/domain/status-pages/status-page-repository.mongo.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts index 467ce10f5..931efa9d0 100644 --- a/server/src/domain/status-pages/status-page-repository.mongo.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -5,6 +5,7 @@ import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js"; +import CheckModel from "@/domain/checks/check.model.js"; // Type for update data that can include document-level fields (Buffer for logo) type StatusPageUpdateData = Partial> & { logo?: StatusPageLogoDocument | null; @@ -113,6 +114,43 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { return this.mapDocuments(statusPages); }; + getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string | undefined) => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const windowStart = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000); + const results = await CheckModel.aggregate([ + { + $match: { + "metadata.monitorId": { $in: objectIds }, + createdAt: { $gte: windowStart }, + }, + }, + { + $group: { + _id: { + monitorId: "$metadata.monitorId", + day: { $dateTrunc: { date: "$createdAt", unit: "day", timezone } }, + }, + totalChecks: { $sum: 1 }, + upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, + avgResponseTime: { $avg: { $ifNull: ["$responseTime", 0] } }, + }, + }, + { $sort: { "_id.day": 1 } }, + { + $project: { + _id: 0, + monitorId: { $toString: "$_id.monitorId" }, + date: { $dateToString: { date: "$_id.day", format: "%Y-%m-%d", timezone } }, + totalChecks: 1, + upChecks: 1, + downChecks: { $subtract: ["$totalChecks", "$upChecks"] }, + avgResponseTime: { $round: ["$avgResponseTime", 0] }, + }, + }, + ]); + return results; + }; + updateById = async ( id: string, teamId: string, diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index e80087b39..dc5977273 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -1,3 +1,4 @@ +import { DailyCheckBucket } from "@/domain/checks/check.type.js"; import type { Monitor } from "@/domain/monitors/monitor.type.js"; export const StatusPageTypes = ["uptime", "infrastructure"] as const; export type StatusPageType = (typeof StatusPageTypes)[number]; @@ -10,6 +11,13 @@ export const StatusPageThemeModes = ["auto", "light", "dark"] as const; export type StatusPageThemeMode = (typeof StatusPageThemeModes)[number]; export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; +export const StatusPageRanges = ["latest", "30d", "60d", "90d"] as const; +export type StatusPageRange = (typeof StatusPageRanges)[number]; +export const STATUS_PAGE_RANGE_DAYS: Record, number> = { + "30d": 30, + "60d": 60, + "90d": 90, +}; export interface StatusPageLogo { data: string; contentType: string; @@ -46,8 +54,11 @@ export interface StatusPage { updatedAt: string; } -export type PublicStatusPageMonitor = Pick & - Partial>; +export type PublicStatusPageMonitor = Pick & { + url?: string; + port?: number; + dailyChecks?: DailyCheckBucket[]; // Only present when range !== "latest" +}; export interface PublicStatusPagePayload { statusPage: StatusPage; From 6f9642c298dc3b2afab6cd6805b9cb2bff73ae94 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 11:48:56 -0700 Subject: [PATCH 081/143] tighten timezone validation --- server/src/api/validation/shared.ts | 15 +++++++++++++++ server/src/api/validation/statusPageValidation.ts | 4 ++-- .../status-pages/status-page-repository.mongo.ts | 5 +++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/server/src/api/validation/shared.ts b/server/src/api/validation/shared.ts index 18b8ba463..ef3ec24ef 100644 --- a/server/src/api/validation/shared.ts +++ b/server/src/api/validation/shared.ts @@ -25,6 +25,21 @@ export const dnsServerValidation = z .min(1, "DNS server is required") .refine((v) => z.ipv4().safeParse(v).success || z.ipv6().safeParse(v).success, "Enter a valid IPv4 or IPv6 address (e.g. 8.8.8.8)"); +// Canonical casing matters: MongoDB's date operators reject non-canonical zone names (e.g. "america/toronto") that Intl accepts, +// so the resolved canonical form is what gets stored. +export const resolveTimezone = (value: string): string | undefined => { + try { + return new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone; + } catch { + return undefined; + } +}; + +export const timezoneValidation = z + .string() + .refine((v) => resolveTimezone(v) !== undefined, "Enter a valid IANA timezone (e.g. America/Toronto)") + .transform((v) => resolveTimezone(v) as string); + // Hostname (FQDN) — labels of 1-63 alphanumerics/hyphens separated by dots, // optionally prefixed with `_` for service labels (e.g. _dmarc, _imaps._tcp). // No scheme, port, path, or whitespace. Total length ≤ 253. Kept in sync with diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index 1b07dc1dc..6b8011044 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; +import { booleanCoercion, dnsHostnameRegex, timezoneValidation } from "./shared.js"; import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes, StatusPageRanges } from "@/domain/status-pages/status-page.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { cssReferencesExternalResource } from "@/utils/customCss.js"; @@ -40,7 +40,7 @@ export const createStatusPageBodyValidation = z message: "URL can only contain letters, numbers, underscores, and hyphens", }), customDomain: customDomainValidation, - timezone: z.string().optional(), + timezone: timezoneValidation.optional(), color: z.string().optional(), monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"), subMonitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/)).optional(), diff --git a/server/src/domain/status-pages/status-page-repository.mongo.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts index 931efa9d0..326db1493 100644 --- a/server/src/domain/status-pages/status-page-repository.mongo.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -116,7 +116,8 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string | undefined) => { const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - const windowStart = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000); + // One extra day so the oldest rendered day is complete; the client enumerates exactly `days` days and ignores the partial extra bucket + const windowStart = new Date(Date.now() - days * 24 * 60 * 60 * 1000); const results = await CheckModel.aggregate([ { $match: { @@ -132,7 +133,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { }, totalChecks: { $sum: 1 }, upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, - avgResponseTime: { $avg: { $ifNull: ["$responseTime", 0] } }, + avgResponseTime: { $avg: "$responseTime" }, }, }, { $sort: { "_id.day": 1 } }, From 3fc7020a903528eb7927f69883e91a58aa3542a3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 12:26:49 -0700 Subject: [PATCH 082/143] move methods to checks, wire up service --- .../checks/check.repository.interface.ts | 3 ++ .../domain/checks/check.repository.mongo.ts | 38 +++++++++++++++++++ .../status-page-repository.interface.ts | 2 - .../status-page-repository.mongo.ts | 38 ------------------- .../status-pages/status-page.service.ts | 33 ++++++++++++++-- .../domain/status-pages/status-page.type.ts | 3 ++ 6 files changed, 74 insertions(+), 43 deletions(-) diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index 6889d8d15..91ea5bb5b 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -2,6 +2,7 @@ import type { Check, ChecksQueryResult, ChecksSummary, + DailyCheckBucket, HardwareChecksResult, PageSpeedChecksResult, UptimeChecksResult, @@ -40,6 +41,8 @@ export interface IChecksRepository { ): Promise; findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise; findUnevaluatedByMonitorId(monitorId: string, since: number): Promise; + getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string | undefined): Promise; + // update //delete deleteByMonitorId(monitorId: string): Promise; diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index cb1c5bfba..2840907c9 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -329,6 +329,44 @@ class MongoChecksRepository implements IChecksRepository { return docs.map(this.toEntity); }; + getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string | undefined) => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + // One extra day so the oldest rendered day is complete; the client enumerates exactly `days` days and ignores the partial extra bucket + const windowStart = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const results = await CheckModel.aggregate([ + { + $match: { + "metadata.monitorId": { $in: objectIds }, + createdAt: { $gte: windowStart }, + }, + }, + { + $group: { + _id: { + monitorId: "$metadata.monitorId", + day: { $dateTrunc: { date: "$createdAt", unit: "day", timezone } }, + }, + totalChecks: { $sum: 1 }, + upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, + avgResponseTime: { $avg: "$responseTime" }, + }, + }, + { $sort: { "_id.day": 1 } }, + { + $project: { + _id: 0, + monitorId: { $toString: "$_id.monitorId" }, + date: { $dateToString: { date: "$_id.day", format: "%Y-%m-%d", timezone } }, + totalChecks: 1, + upChecks: 1, + downChecks: { $subtract: ["$totalChecks", "$upChecks"] }, + avgResponseTime: { $round: ["$avgResponseTime", 0] }, + }, + }, + ]); + return results; + }; + deleteByMonitorId = async (monitorId: string): Promise => { const result = await CheckModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) }); return result.deletedCount; diff --git a/server/src/domain/status-pages/status-page-repository.interface.ts b/server/src/domain/status-pages/status-page-repository.interface.ts index ed58b18a2..4b3eccd0a 100644 --- a/server/src/domain/status-pages/status-page-repository.interface.ts +++ b/server/src/domain/status-pages/status-page-repository.interface.ts @@ -1,4 +1,3 @@ -import { DailyCheckBucket } from "@/domain/checks/check.type.js"; import type { StatusPage } from "@/domain/status-pages/status-page.type.js"; export interface IStatusPagesRepository { @@ -9,7 +8,6 @@ export interface IStatusPagesRepository { findByCustomDomain(customDomain: string): Promise; findByTeamId(teamId: string): Promise; // collection fetch - getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string | undefined): Promise; // update updateById(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; // delete diff --git a/server/src/domain/status-pages/status-page-repository.mongo.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts index 326db1493..0b74a9374 100644 --- a/server/src/domain/status-pages/status-page-repository.mongo.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -114,44 +114,6 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { return this.mapDocuments(statusPages); }; - getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string | undefined) => { - const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - // One extra day so the oldest rendered day is complete; the client enumerates exactly `days` days and ignores the partial extra bucket - const windowStart = new Date(Date.now() - days * 24 * 60 * 60 * 1000); - const results = await CheckModel.aggregate([ - { - $match: { - "metadata.monitorId": { $in: objectIds }, - createdAt: { $gte: windowStart }, - }, - }, - { - $group: { - _id: { - monitorId: "$metadata.monitorId", - day: { $dateTrunc: { date: "$createdAt", unit: "day", timezone } }, - }, - totalChecks: { $sum: 1 }, - upChecks: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, - avgResponseTime: { $avg: "$responseTime" }, - }, - }, - { $sort: { "_id.day": 1 } }, - { - $project: { - _id: 0, - monitorId: { $toString: "$_id.monitorId" }, - date: { $dateToString: { date: "$_id.day", format: "%Y-%m-%d", timezone } }, - totalChecks: 1, - upChecks: 1, - downChecks: { $subtract: ["$totalChecks", "$upChecks"] }, - avgResponseTime: { $round: ["$avgResponseTime", 0] }, - }, - }, - ]); - return results; - }; - updateById = async ( id: string, teamId: string, diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 7a2a0724b..7d953f8f3 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -5,11 +5,14 @@ import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, PublicStatusPagePayload, + STATUS_PAGE_RANGE_DAYS, StatusPage, + StatusPageRange, } from "@/domain/status-pages/status-page.type.js"; 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"; export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; @@ -26,7 +29,8 @@ export class StatusPageService implements IStatusPageService { constructor( private statusPagesRepository: IStatusPagesRepository, private settingsService: ISettingsService, - private monitorsRepository: IMonitorsRepository + private monitorsRepository: IMonitorsRepository, + private checksRepository: IChecksRepository ) {} private assertCustomDomainAllowed = (customDomain: string | null | undefined) => { @@ -116,7 +120,11 @@ export class StatusPageService implements IStatusPageService { return statusPages.map((sp) => this.normalizeTheme(sp)); }; - getPublicStatusPagePayload = async (statusPage: StatusPage, requesterTeamId: string | undefined): Promise => { + getPublicStatusPagePayload = async ( + statusPage: StatusPage, + requesterTeamId: string | undefined, + range: StatusPageRange = "latest" + ): Promise => { if (!statusPage.isPublished) { if (!requesterTeamId || statusPage.teamId !== requesterTeamId) { throw new AppError({ message: "Forbidden", status: 403 }); @@ -128,7 +136,26 @@ export class StatusPageService implements IStatusPageService { const order = new Map(statusPage.monitors.map((id, i) => [id, i])); const sorted = [...monitors].sort((a, b) => (order.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.id) ?? Number.MAX_SAFE_INTEGER)); - return { statusPage, monitors: sorted.map((monitor) => this.toPublicMonitor(monitor, showURL)) }; + if (range === "latest") { + return { statusPage, monitors: sorted.map((monitor) => this.toPublicMonitor(monitor, showURL)) }; + } + + const days = STATUS_PAGE_RANGE_DAYS[range]; + const bucketTimezone = statusPage.timezone ?? "Etc/UTC"; + const buckets = await this.checksRepository.getDailyStatusBuckets(statusPage.monitors, days, bucketTimezone); + const bucketsByMonitor = Map.groupBy(buckets, (bucket) => bucket.monitorId); + + return { + statusPage, + range, + bucketTimezone, + checkTTLDays: (await this.settingsService.getDBSettings()).checkTTL, + monitors: sorted.map((monitor) => ({ + ...this.toPublicMonitor(monitor, showURL), + recentChecks: [], + dailyChecks: bucketsByMonitor.get(monitor.id) ?? [], + })), + }; }; updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => { diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index dc5977273..a8bb8fbc7 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -63,4 +63,7 @@ export type PublicStatusPageMonitor = Pick Date: Mon, 17 Aug 2026 12:31:01 -0700 Subject: [PATCH 083/143] wire controller --- server/src/api/controllers/statusPageController.ts | 7 ++++--- server/src/domain/status-pages/status-page.service.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/server/src/api/controllers/statusPageController.ts b/server/src/api/controllers/statusPageController.ts index 038ddb423..322f74c8d 100644 --- a/server/src/api/controllers/statusPageController.ts +++ b/server/src/api/controllers/statusPageController.ts @@ -70,13 +70,14 @@ class StatusPageController implements IStatusPageController { getStatusPageByUrl = catchAsync(async (req: Request, res: Response) => { getStatusPageParamValidation.parse(req.params); getStatusPageQueryValidation.parse(req.query); + const { range } = getStatusPageQueryValidation.parse(req.query); if (!req.params.url) { throw new AppError({ message: "Status page URL is required", status: 400 }); } const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url as string); - const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId); + const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId, range); return res.status(200).json({ success: true, @@ -87,7 +88,7 @@ class StatusPageController implements IStatusPageController { resolveStatusPageByDomain = catchAsync(async (req: Request, res: Response) => { resolveStatusPageQueryValidation.parse(req.query); - + const { range } = resolveStatusPageQueryValidation.parse(req.query); const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined); if (!domain) { throw new AppError({ message: "Domain is required", status: 400 }); @@ -98,7 +99,7 @@ class StatusPageController implements IStatusPageController { throw new AppError({ message: "Status page not found", status: 404 }); } - const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId); + const data = await this.statusPageService.getPublicStatusPagePayload(statusPage, req.user?.teamId, range); return res.status(200).json({ success: true, diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 7d953f8f3..cfacc3c4f 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -19,7 +19,7 @@ export interface IStatusPageService { getStatusPageByUrl(url: string): Promise; getStatusPageByCustomDomain(customDomain: string): Promise; getStatusPagesByTeamId(teamId: string): Promise; - getPublicStatusPagePayload(statusPage: StatusPage, requesterTeamId: string | undefined): Promise; + getPublicStatusPagePayload(statusPage: StatusPage, requesterTeamId: string | undefined, range: StatusPageRange): Promise; updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; deleteStatusPage(statusPageId: string, teamId: string): Promise; From ab7f2cd8f4edf24b13551843843a85045ab14f50 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 12:36:39 -0700 Subject: [PATCH 084/143] update openapi docs --- server/openapi.json | 317 +++++++++++++++++- server/openapi/routes/statusPage.ts | 51 ++- .../api/validation/statusPageValidation.ts | 80 +++++ server/src/config/services.api.ts | 2 +- server/src/domain/checks/check.type.ts | 2 +- .../status-page-repository.mongo.ts | 1 - 6 files changed, 429 insertions(+), 24 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index e95617426..3fc6568b6 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1419,6 +1419,293 @@ "address": "https://ntfy.sh", "topic": "checkmate-alerts" } + }, + "PublicStatusPagePayload": { + "type": "object", + "properties": { + "statusPage": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "type": { + "type": "array", + "items": { + "type": "string", + "enum": ["uptime", "infrastructure"] + } + }, + "companyName": { + "type": "string" + }, + "url": { + "type": "string" + }, + "customDomain": { + "type": "string", + "nullable": true + }, + "timezone": { + "type": "string" + }, + "color": { + "type": "string" + }, + "monitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "subMonitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "originalMonitors": { + "type": "array", + "items": { + "type": "string" + } + }, + "logo": { + "type": "object", + "nullable": true, + "properties": { + "data": { + "type": "string" + }, + "contentType": { + "type": "string" + } + }, + "required": ["data", "contentType"] + }, + "isPublished": { + "type": "boolean" + }, + "showCharts": { + "type": "boolean" + }, + "showUptimePercentage": { + "type": "boolean" + }, + "showAdminLoginLink": { + "type": "boolean" + }, + "showInfrastructure": { + "type": "boolean" + }, + "customCSS": { + "type": "string" + }, + "theme": { + "type": "string", + "enum": ["refined", "modern", "bold", "editorial", "minimal"] + }, + "themeMode": { + "type": "string", + "enum": ["auto", "light", "dark"] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "userId", + "teamId", + "type", + "companyName", + "url", + "color", + "monitors", + "subMonitors", + "isPublished", + "showCharts", + "showUptimePercentage", + "showAdminLoginLink", + "showInfrastructure", + "customCSS", + "theme", + "themeMode", + "createdAt", + "updatedAt" + ] + }, + "monitors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"] + }, + "uptimePercentage": { + "type": "number" + }, + "recentChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "boolean" + }, + "responseTime": { + "type": "number" + }, + "statusCode": { + "type": "number" + }, + "message": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "accessibility": { + "type": "number" + }, + "bestPractices": { + "type": "number" + }, + "seo": { + "type": "number" + }, + "performance": { + "type": "number" + } + }, + "required": ["id", "status", "responseTime", "statusCode", "message", "createdAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "url": { + "type": "string" + }, + "port": { + "type": "number" + }, + "dailyChecks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "monitorId": { + "type": "string" + }, + "date": { + "type": "string" + }, + "totalChecks": { + "type": "number" + }, + "upChecks": { + "type": "number" + }, + "downChecks": { + "type": "number" + }, + "avgResponseTime": { + "type": "number", + "nullable": true + } + }, + "required": ["monitorId", "date", "totalChecks", "upChecks", "downChecks", "avgResponseTime"] + } + } + }, + "required": ["id", "name", "type", "status", "recentChecks"] + } + }, + "range": { + "type": "string", + "enum": ["latest", "30d", "60d", "90d"] + }, + "bucketTimezone": { + "type": "string" + }, + "checkTTLDays": { + "type": "number" + } + }, + "required": ["statusPage", "monitors"], + "example": { + "statusPage": { + "id": "65f1c2a4d8b9e0123456789c", + "userId": "65f1c2a4d8b9e01234567891", + "teamId": "65f1c2a4d8b9e01234567890", + "type": ["uptime"], + "companyName": "Acme", + "url": "acme-status", + "customDomain": null, + "timezone": "America/Toronto", + "color": "#4169E1", + "monitors": ["65f1c2a4d8b9e0123456789a"], + "subMonitors": [], + "isPublished": true, + "showCharts": true, + "showUptimePercentage": true, + "showAdminLoginLink": false, + "showInfrastructure": false, + "customCSS": "", + "theme": "refined", + "themeMode": "auto", + "createdAt": "2026-04-01T10:00:00.000Z", + "updatedAt": "2026-04-15T14:30:00.000Z" + }, + "range": "90d", + "bucketTimezone": "America/Toronto", + "checkTTLDays": 30, + "monitors": [ + { + "id": "65f1c2a4d8b9e0123456789a", + "name": "API", + "type": "http", + "status": "up", + "uptimePercentage": 0.9987, + "recentChecks": [], + "dailyChecks": [ + { + "monitorId": "65f1c2a4d8b9e0123456789a", + "date": "2026-07-19", + "totalChecks": 2880, + "upChecks": 2877, + "downChecks": 3, + "avgResponseTime": 142 + } + ] + } + ] + } } }, "parameters": {} @@ -6789,14 +7076,6 @@ "name": "numToDisplay", "in": "query" }, - { - "schema": { - "type": "boolean" - }, - "required": false, - "name": "normalize", - "in": "query" - }, { "schema": { "anyOf": [ @@ -11483,11 +11762,12 @@ }, { "schema": { - "type": "number", - "nullable": true + "type": "string", + "enum": ["latest", "30d", "60d", "90d"], + "default": "latest" }, "required": false, - "name": "timeFrame", + "name": "range", "in": "query" }, { @@ -11515,10 +11795,10 @@ "type": "string" }, "data": { - "nullable": true + "$ref": "#/components/schemas/PublicStatusPagePayload" } }, - "required": ["success", "msg"] + "required": ["success", "msg", "data"] } } } @@ -11588,11 +11868,12 @@ }, { "schema": { - "type": "number", - "nullable": true + "type": "string", + "enum": ["latest", "30d", "60d", "90d"], + "default": "latest" }, "required": false, - "name": "timeFrame", + "name": "range", "in": "query" } ], @@ -11612,10 +11893,10 @@ "type": "string" }, "data": { - "nullable": true + "$ref": "#/components/schemas/PublicStatusPagePayload" } }, - "required": ["success", "msg"] + "required": ["success", "msg", "data"] } } } diff --git a/server/openapi/routes/statusPage.ts b/server/openapi/routes/statusPage.ts index 7ecc6e6d7..b8bfddc76 100644 --- a/server/openapi/routes/statusPage.ts +++ b/server/openapi/routes/statusPage.ts @@ -1,14 +1,59 @@ import { registry } from "../registry.js"; -import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; +import { bearer, multipart, okJson, okJsonNoData, okUnknown, standardErrors } from "../helpers.js"; import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation, + publicStatusPagePayloadResponseSchema, resolveStatusPageQueryValidation, } from "@/api/validation/statusPageValidation.js"; const tags = ["status-page"]; +const publicStatusPageObject = publicStatusPagePayloadResponseSchema.openapi("PublicStatusPagePayload", { + example: { + statusPage: { + id: "65f1c2a4d8b9e0123456789c", + userId: "65f1c2a4d8b9e01234567891", + teamId: "65f1c2a4d8b9e01234567890", + type: ["uptime"], + companyName: "Acme", + url: "acme-status", + customDomain: null, + timezone: "America/Toronto", + color: "#4169E1", + monitors: ["65f1c2a4d8b9e0123456789a"], + subMonitors: [], + isPublished: true, + showCharts: true, + showUptimePercentage: true, + showAdminLoginLink: false, + showInfrastructure: false, + customCSS: "", + theme: "refined", + themeMode: "auto", + createdAt: "2026-04-01T10:00:00.000Z", + updatedAt: "2026-04-15T14:30:00.000Z", + }, + range: "90d", + bucketTimezone: "America/Toronto", + checkTTLDays: 30, + monitors: [ + { + id: "65f1c2a4d8b9e0123456789a", + name: "API", + type: "http", + status: "up", + uptimePercentage: 0.9987, + recentChecks: [], + dailyChecks: [ + { monitorId: "65f1c2a4d8b9e0123456789a", date: "2026-07-19", totalChecks: 2880, upChecks: 2877, downChecks: 3, avgResponseTime: 142 }, + ], + }, + ], + }, +}); + registry.registerPath({ method: "get", path: "/status-page/team", @@ -44,7 +89,7 @@ registry.registerPath({ tags, summary: "Resolve a published status page by custom domain", request: { query: resolveStatusPageQueryValidation }, - responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] }, + responses: { "200": okJson(publicStatusPageObject), "404": { description: "Status page not found" }, "500": standardErrors["500"] }, }); registry.registerPath({ @@ -53,7 +98,7 @@ registry.registerPath({ tags, summary: "Get a public status page by its URL slug", request: { params: getStatusPageParamValidation, query: getStatusPageQueryValidation }, - responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] }, + responses: { "200": okJson(publicStatusPageObject), "404": { description: "Status page not found" }, "500": standardErrors["500"] }, }); registry.registerPath({ diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index 6b8011044..b363efd15 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex, timezoneValidation } from "./shared.js"; import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes, StatusPageRanges } from "@/domain/status-pages/status-page.type.js"; +import { MonitorTypes, MonitorStatuses } from "@/domain/monitors/monitor.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { cssReferencesExternalResource } from "@/utils/customCss.js"; import { ImageMimeTypes } from "@/types/upload.js"; @@ -80,3 +81,82 @@ export const imageValidation = z .refine((data) => data.buffer, { message: "Image file is required", }); + +// Keep aligned with DailyCheckBucket in domain/checks/check.type.ts. avgResponseTime is null +// when no check that day recorded a response time ($avg skips missing, $round passes null through). +export const dailyCheckBucketResponseSchema = z.object({ + monitorId: z.string(), + date: z.string(), + totalChecks: z.number(), + upChecks: z.number(), + downChecks: z.number(), + avgResponseTime: z.number().nullable(), +}); + +// Keep aligned with CheckSnapshot in domain/checks/check.type.ts. +export const checkSnapshotResponseSchema = z + .object({ + id: z.string(), + status: z.boolean(), + responseTime: z.number(), + statusCode: z.number(), + message: z.string(), + createdAt: z.string(), + accessibility: z.number().optional(), + bestPractices: z.number().optional(), + seo: z.number().optional(), + performance: z.number().optional(), + }) + .passthrough(); + +// Status page entity as serialized by the repository. Keep aligned with StatusPage in +// domain/status-pages/status-page.type.ts. +export const statusPageResponseSchema = z.object({ + id: z.string(), + userId: z.string(), + teamId: z.string(), + type: z.array(z.enum(StatusPageTypes)), + companyName: z.string(), + url: z.string(), + customDomain: z.string().nullable().optional(), + timezone: z.string().optional(), + color: z.string(), + monitors: z.array(z.string()), + subMonitors: z.array(z.string()), + originalMonitors: z.array(z.string()).optional(), + logo: z.object({ data: z.string(), contentType: z.string() }).nullable().optional(), + isPublished: z.boolean(), + showCharts: z.boolean(), + showUptimePercentage: z.boolean(), + showAdminLoginLink: z.boolean(), + showInfrastructure: z.boolean(), + customCSS: z.string(), + theme: z.enum(StatusPageThemes), + themeMode: z.enum(StatusPageThemeModes), + createdAt: z.string(), + updatedAt: z.string(), +}); + +// Keep aligned with PublicStatusPageMonitor in domain/status-pages/status-page.type.ts. +// url/port are present only when the showURL setting is enabled; dailyChecks only when range !== "latest". +export const publicStatusPageMonitorResponseSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.enum(MonitorTypes), + status: z.enum(MonitorStatuses), + uptimePercentage: z.number().optional(), + recentChecks: z.array(checkSnapshotResponseSchema), + url: z.string().optional(), + port: z.number().optional(), + dailyChecks: z.array(dailyCheckBucketResponseSchema).optional(), +}); + +// Response of the public status page endpoints. Keep aligned with PublicStatusPagePayload in +// domain/status-pages/status-page.type.ts; range, bucketTimezone, and checkTTLDays are present only when range !== "latest". +export const publicStatusPagePayloadResponseSchema = z.object({ + statusPage: statusPageResponseSchema, + monitors: z.array(publicStatusPageMonitorResponseSchema), + range: z.enum(StatusPageRanges).optional(), + bucketTimezone: z.string().optional(), + checkTTLDays: z.number().optional(), +}); diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts index 2768770c3..ae16814fd 100644 --- a/server/src/config/services.api.ts +++ b/server/src/config/services.api.ts @@ -91,7 +91,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A emailService, }); - const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository); + const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository); const tagsService = new TagsService(tagsRepository, monitorsRepository); const diagnosticService = new DiagnosticService(db); diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index 2f933dd0b..aa1eb3782 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -250,5 +250,5 @@ export type DailyCheckBucket = { totalChecks: number; upChecks: number; downChecks: number; - avgResponseTime: number; + avgResponseTime: number | null; }; diff --git a/server/src/domain/status-pages/status-page-repository.mongo.ts b/server/src/domain/status-pages/status-page-repository.mongo.ts index 0b74a9374..467ce10f5 100644 --- a/server/src/domain/status-pages/status-page-repository.mongo.ts +++ b/server/src/domain/status-pages/status-page-repository.mongo.ts @@ -5,7 +5,6 @@ import mongoose from "mongoose"; import { AppError } from "@/utils/AppError.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js"; -import CheckModel from "@/domain/checks/check.model.js"; // Type for update data that can include document-level fields (Buffer for logo) type StatusPageUpdateData = Partial> & { logo?: StatusPageLogoDocument | null; From 9001a8d29494392e11e241ddc52fef0fb7696dc7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 12:58:56 -0700 Subject: [PATCH 085/143] add tests --- ...checksRepositoryDailyStatusBuckets.test.ts | 124 ++++++++++++++++++ .../unit/services/statusPageService.test.ts | 67 +++++++++- .../validation/statusPageValidation.test.ts | 31 ++++- 3 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 server/test/integration/checksRepositoryDailyStatusBuckets.test.ts diff --git a/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts b/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts new file mode 100644 index 000000000..5517c3a98 --- /dev/null +++ b/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import MongoChecksRepository from "../../src/domain/checks/check.repository.mongo.ts"; +import { CheckModel } from "../../src/domain/checks/check.model.ts"; +import type { ILogger } from "../../src/utils/logger.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; + +// ── Real-Mongo harness ───────────────────────────────────────────────────────── +// The guarantees under test — timezone-correct $dateTrunc day boundaries, $avg +// skipping missing responseTime (null bucket, not 0), and zero-check days yielding +// no rows — are all aggregation-engine behavior, so they can only be exercised +// against a live mongod. + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + await CheckModel.createCollection(); // timeseries collections must exist before insert +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await CheckModel.deleteMany({}); +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const MONITOR_A = new mongoose.Types.ObjectId(); +const MONITOR_B = new mongoose.Types.ObjectId(); +const TEAM_ID = new mongoose.Types.ObjectId(); + +const DAY_MS = 24 * 60 * 60 * 1000; + +// One shared instant per seeded "day" keeps every check of that day in the same +// bucket no matter when the test runs; instants exactly 24h apart always land on +// distinct calendar days. +const TODAY = new Date(Date.now() - 10 * 60 * 1000); +const ONE_DAY_AGO = new Date(TODAY.getTime() - DAY_MS); + +const utcDate = (date: Date) => date.toISOString().slice(0, 10); + +// Yesterday at 03:30 UTC: in America/Toronto that is 23:30 (EDT) or 22:30 (EST) +// of the *prior* day, so the local calendar date is always one behind UTC. +const yesterdayAtUtc = (hours: number, minutes: number) => { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - 1); + date.setUTCHours(hours, minutes, 0, 0); + return date; +}; + +const seedCheck = (monitorId: mongoose.Types.ObjectId, createdAt: Date, overrides: Record = {}) => + CheckModel.create({ + metadata: { monitorId, teamId: TEAM_ID, type: "http" }, + status: true, + responseTime: 100, + createdAt, + ...overrides, + }); + +describe("MongoChecksRepository getDailyStatusBuckets", () => { + let repo: MongoChecksRepository; + + beforeEach(() => { + repo = new MongoChecksRepository(createMockLogger() as unknown as ILogger); + }); + + it("groups by monitor and calendar day with up/down math; zero-check days produce no rows", async () => { + await seedCheck(MONITOR_A, TODAY, { responseTime: 100 }); + await seedCheck(MONITOR_A, TODAY, { responseTime: 300 }); + await seedCheck(MONITOR_A, TODAY, { status: false, responseTime: 200 }); + await seedCheck(MONITOR_A, ONE_DAY_AGO, { responseTime: 100 }); + await seedCheck(MONITOR_A, ONE_DAY_AGO, { status: false, responseTime: undefined }); + await seedCheck(MONITOR_B, TODAY, { status: false, responseTime: undefined }); + // Two days ago is deliberately unseeded: the gap day must be absent, not zero-filled. + + const buckets = await repo.getDailyStatusBuckets([MONITOR_A.toString(), MONITOR_B.toString()], 7, "UTC"); + + const monitorABuckets = buckets.filter((bucket) => bucket.monitorId === MONITOR_A.toString()); + const monitorBBuckets = buckets.filter((bucket) => bucket.monitorId === MONITOR_B.toString()); + + // Ascending by day; the day with a responseTime-less check averages over the + // checks that have one ($avg skips missing) rather than coercing them to 0. + expect(monitorABuckets).toEqual([ + { monitorId: MONITOR_A.toString(), date: utcDate(ONE_DAY_AGO), totalChecks: 2, upChecks: 1, downChecks: 1, avgResponseTime: 100 }, + { monitorId: MONITOR_A.toString(), date: utcDate(TODAY), totalChecks: 3, upChecks: 2, downChecks: 1, avgResponseTime: 200 }, + ]); + // A day where no check recorded a responseTime yields null, not 0. + expect(monitorBBuckets).toEqual([ + { monitorId: MONITOR_B.toString(), date: utcDate(TODAY), totalChecks: 1, upChecks: 0, downChecks: 1, avgResponseTime: null }, + ]); + }); + + it("cuts day boundaries in the requested timezone", async () => { + const lateEveningLocal = yesterdayAtUtc(3, 30); + await seedCheck(MONITOR_A, lateEveningLocal); + + const utcBuckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "UTC"); + const torontoBuckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "America/Toronto"); + + const previousUtcDay = new Date(lateEveningLocal); + previousUtcDay.setUTCDate(previousUtcDay.getUTCDate() - 1); + + expect(utcBuckets).toHaveLength(1); + expect(utcBuckets[0].date).toBe(utcDate(lateEveningLocal)); + expect(torontoBuckets).toHaveLength(1); + expect(torontoBuckets[0].date).toBe(utcDate(previousUtcDay)); + }); + + it("excludes checks older than the requested window", async () => { + await seedCheck(MONITOR_A, new Date(Date.now() - 10 * DAY_MS)); + await seedCheck(MONITOR_A, TODAY); + + const buckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "UTC"); + + expect(buckets).toHaveLength(1); + expect(buckets[0]).toMatchObject({ date: utcDate(TODAY), totalChecks: 1 }); + }); +}); diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index 149179bd7..9c8a4b27f 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -3,6 +3,8 @@ import { StatusPageService } from "../../../src/domain/status-pages/status-page. import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts"; 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 { 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"; import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/domain/status-pages/status-page.type.ts"; @@ -69,7 +71,7 @@ const createSettingsService = (themesEnabled: boolean, clientHost = "http://loca ({ areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled), getSettings: jest.fn().mockReturnValue({ clientHost }), - getDBSettings: jest.fn().mockResolvedValue({ showURL }), + getDBSettings: jest.fn().mockResolvedValue({ showURL, checkTTL: 30 }), }) as unknown as jest.Mocked; const createMonitorsRepo = () => @@ -77,12 +79,18 @@ const createMonitorsRepo = () => findByIds: jest.fn().mockResolvedValue([makeMonitor()]), }) as unknown as jest.Mocked; +const createChecksRepo = () => + ({ + getDailyStatusBuckets: 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 service = new StatusPageService(repo, settingsService, monitorsRepo); - return { service, repo, settingsService, monitorsRepo }; + const checksRepo = createChecksRepo(); + const service = new StatusPageService(repo, settingsService, monitorsRepo, checksRepo); + return { service, repo, settingsService, monitorsRepo, checksRepo }; }; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -353,6 +361,59 @@ describe("StatusPageService", () => { statusPage: expect.objectContaining({ id: "sp-1" }), }); }); + + describe("range", () => { + it("omits range-mode fields and never queries buckets for the default (latest) range", async () => { + const { service, checksRepo } = createService(); + + const implicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined); + const explicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined, "latest"); + + for (const payload of [implicitLatest, explicitLatest]) { + expect(payload).not.toHaveProperty("range"); + expect(payload).not.toHaveProperty("bucketTimezone"); + expect(payload).not.toHaveProperty("checkTTLDays"); + expect(payload.monitors[0]).not.toHaveProperty("dailyChecks"); + } + expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); + }); + + it("attaches dailyChecks, empties recentChecks, and echoes range metadata for a day range", async () => { + const { service, checksRepo, monitorsRepo } = createService(); + const bucket = { monitorId: "mon-1", date: "2026-08-01", totalChecks: 10, upChecks: 9, downChecks: 1, avgResponseTime: 120 }; + (checksRepo.getDailyStatusBuckets as jest.Mock).mockResolvedValue([bucket]); + (monitorsRepo.findByIds as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: [{ id: "chk-1" } as CheckSnapshot] })]); + const page = makeStatusPage({ isPublished: true, timezone: "America/Toronto" }); + + const payload = await service.getPublicStatusPagePayload(page, undefined, "30d"); + + expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1"], 30, "America/Toronto"); + expect(payload).toMatchObject({ range: "30d", bucketTimezone: "America/Toronto", checkTTLDays: 30 }); + expect(payload.monitors[0].recentChecks).toEqual([]); + expect(payload.monitors[0].dailyChecks).toEqual([bucket]); + for (const field of SENSITIVE_FIELDS) { + expect(payload.monitors[0]).not.toHaveProperty(field); + } + }); + + it("falls back to Etc/UTC when the page has no timezone and defaults dailyChecks to empty", async () => { + const { service, checksRepo } = createService(); + + const payload = await service.getPublicStatusPagePayload(publishedPage(), undefined, "60d"); + + expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1"], 60, "Etc/UTC"); + expect(payload.bucketTimezone).toBe("Etc/UTC"); + expect(payload.monitors[0].dailyChecks).toEqual([]); + }); + + it("does not bypass the unpublished-page 403", async () => { + const { service, checksRepo } = createService(); + const unpublished = makeStatusPage({ isPublished: false, teamId: "team-A" }); + + await expect(service.getPublicStatusPagePayload(unpublished, undefined, "90d")).rejects.toMatchObject({ status: 403 }); + expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); + }); + }); }); describe("deleteStatusPage", () => { diff --git a/server/test/unit/validation/statusPageValidation.test.ts b/server/test/unit/validation/statusPageValidation.test.ts index a2d659c34..ce4e0191b 100644 --- a/server/test/unit/validation/statusPageValidation.test.ts +++ b/server/test/unit/validation/statusPageValidation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@jest/globals"; -import { createStatusPageBodyValidation } from "../../../src/api/validation/statusPageValidation.ts"; +import { createStatusPageBodyValidation, getStatusPageQueryValidation } from "../../../src/api/validation/statusPageValidation.ts"; +import { StatusPageRanges } from "../../../src/domain/status-pages/status-page.type.ts"; const baseCreateBody = (overrides: Record = {}) => ({ type: "uptime", @@ -61,3 +62,31 @@ describe("createStatusPageBodyValidation", () => { } }); }); + +describe("getStatusPageQueryValidation", () => { + it("defaults range to latest when absent", () => { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.range).toBe("latest"); + } + }); + + it("accepts every supported range", () => { + for (const range of StatusPageRanges) { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime", range }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.range).toBe(range); + } + } + }); + + it("rejects an unsupported range", () => { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime", range: "45d" }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ path: ["range"] })])); + } + }); +}); From ff2e1fd648eea840557b377a0e786c9e11d56233 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 12:59:23 -0700 Subject: [PATCH 086/143] use reduce --- server/src/domain/status-pages/status-page.service.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index cfacc3c4f..6aab311ee 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -13,6 +13,7 @@ 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"; export interface IStatusPageService { createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise; @@ -143,7 +144,15 @@ export class StatusPageService implements IStatusPageService { const days = STATUS_PAGE_RANGE_DAYS[range]; const bucketTimezone = statusPage.timezone ?? "Etc/UTC"; const buckets = await this.checksRepository.getDailyStatusBuckets(statusPage.monitors, days, bucketTimezone); - const bucketsByMonitor = Map.groupBy(buckets, (bucket) => bucket.monitorId); + const bucketsByMonitor = buckets.reduce((grouped, bucket) => { + const monitorBuckets = grouped.get(bucket.monitorId); + if (monitorBuckets) { + monitorBuckets.push(bucket); + } else { + grouped.set(bucket.monitorId, [bucket]); + } + return grouped; + }, new Map()); return { statusPage, From 7b1d6c763fb133168628082e738025f68860183b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 13:05:03 -0700 Subject: [PATCH 087/143] tighten types, remove duplicate calls --- server/openapi.json | 2 +- server/src/api/controllers/statusPageController.ts | 1 - server/src/api/validation/statusPageValidation.ts | 10 ++++++++-- server/src/domain/checks/check.repository.interface.ts | 2 +- server/src/domain/checks/check.repository.mongo.ts | 2 +- server/src/domain/status-pages/status-page.service.ts | 7 ++++--- server/src/domain/status-pages/status-page.type.ts | 8 +++++--- server/test/unit/services/statusPageService.test.ts | 4 +++- 8 files changed, 23 insertions(+), 13 deletions(-) diff --git a/server/openapi.json b/server/openapi.json index 3fc6568b6..0ea728a61 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1648,7 +1648,7 @@ }, "range": { "type": "string", - "enum": ["latest", "30d", "60d", "90d"] + "enum": ["30d", "60d", "90d"] }, "bucketTimezone": { "type": "string" diff --git a/server/src/api/controllers/statusPageController.ts b/server/src/api/controllers/statusPageController.ts index 322f74c8d..9153c152c 100644 --- a/server/src/api/controllers/statusPageController.ts +++ b/server/src/api/controllers/statusPageController.ts @@ -69,7 +69,6 @@ class StatusPageController implements IStatusPageController { getStatusPageByUrl = catchAsync(async (req: Request, res: Response) => { getStatusPageParamValidation.parse(req.params); - getStatusPageQueryValidation.parse(req.query); const { range } = getStatusPageQueryValidation.parse(req.query); if (!req.params.url) { diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index b363efd15..ad2277797 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex, timezoneValidation } from "./shared.js"; -import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes, StatusPageRanges } from "@/domain/status-pages/status-page.type.js"; +import { + StatusPageTypes, + StatusPageThemes, + StatusPageThemeModes, + StatusPageRanges, + StatusPageDayRanges, +} from "@/domain/status-pages/status-page.type.js"; import { MonitorTypes, MonitorStatuses } from "@/domain/monitors/monitor.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; import { cssReferencesExternalResource } from "@/utils/customCss.js"; @@ -156,7 +162,7 @@ export const publicStatusPageMonitorResponseSchema = z.object({ export const publicStatusPagePayloadResponseSchema = z.object({ statusPage: statusPageResponseSchema, monitors: z.array(publicStatusPageMonitorResponseSchema), - range: z.enum(StatusPageRanges).optional(), + range: z.enum(StatusPageDayRanges).optional(), bucketTimezone: z.string().optional(), checkTTLDays: z.number().optional(), }); diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index 91ea5bb5b..1767b2ea1 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -41,7 +41,7 @@ export interface IChecksRepository { ): Promise; findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise; findUnevaluatedByMonitorId(monitorId: string, since: number): Promise; - getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string | undefined): Promise; + getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise; // update //delete diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index 2840907c9..de144ea80 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -329,7 +329,7 @@ class MongoChecksRepository implements IChecksRepository { return docs.map(this.toEntity); }; - getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string | undefined) => { + getDailyStatusBuckets = async (monitorIds: string[], days: number, timezone: string) => { const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); // One extra day so the oldest rendered day is complete; the client enumerates exactly `days` days and ignores the partial extra bucket const windowStart = new Date(Date.now() - days * 24 * 60 * 60 * 1000); diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 6aab311ee..25e6d3e08 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -132,8 +132,9 @@ export class StatusPageService implements IStatusPageService { } } - const showURL = (await this.settingsService.getDBSettings()).showURL; - const monitors = await this.monitorsRepository.findByIds(statusPage.monitors); + const dbSettings = await this.settingsService.getDBSettings(); + const showURL = dbSettings.showURL; + const monitors = await this.monitorsRepository.findByIds(statusPage.monitors, { includeRecentChecks: range === "latest" }); const order = new Map(statusPage.monitors.map((id, i) => [id, i])); const sorted = [...monitors].sort((a, b) => (order.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.id) ?? Number.MAX_SAFE_INTEGER)); @@ -158,7 +159,7 @@ export class StatusPageService implements IStatusPageService { statusPage, range, bucketTimezone, - checkTTLDays: (await this.settingsService.getDBSettings()).checkTTL, + checkTTLDays: dbSettings.checkTTL, monitors: sorted.map((monitor) => ({ ...this.toPublicMonitor(monitor, showURL), recentChecks: [], diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index a8bb8fbc7..630a22ce2 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -11,9 +11,11 @@ export const StatusPageThemeModes = ["auto", "light", "dark"] as const; export type StatusPageThemeMode = (typeof StatusPageThemeModes)[number]; export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; -export const StatusPageRanges = ["latest", "30d", "60d", "90d"] as const; +export const StatusPageDayRanges = ["30d", "60d", "90d"] as const; +export type StatusPageDayRange = (typeof StatusPageDayRanges)[number]; +export const StatusPageRanges = ["latest", ...StatusPageDayRanges] as const; export type StatusPageRange = (typeof StatusPageRanges)[number]; -export const STATUS_PAGE_RANGE_DAYS: Record, number> = { +export const STATUS_PAGE_RANGE_DAYS: Record = { "30d": 30, "60d": 60, "90d": 90, @@ -63,7 +65,7 @@ export type PublicStatusPageMonitor = Pick { describe("range", () => { it("omits range-mode fields and never queries buckets for the default (latest) range", async () => { - const { service, checksRepo } = createService(); + const { service, checksRepo, monitorsRepo } = createService(); const implicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined); const explicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined, "latest"); @@ -376,6 +376,7 @@ describe("StatusPageService", () => { expect(payload.monitors[0]).not.toHaveProperty("dailyChecks"); } expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); + expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1"], { includeRecentChecks: true }); }); it("attaches dailyChecks, empties recentChecks, and echoes range metadata for a day range", async () => { @@ -388,6 +389,7 @@ describe("StatusPageService", () => { const payload = await service.getPublicStatusPagePayload(page, undefined, "30d"); expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1"], 30, "America/Toronto"); + expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1"], { includeRecentChecks: false }); expect(payload).toMatchObject({ range: "30d", bucketTimezone: "America/Toronto", checkTTLDays: 30 }); expect(payload.monitors[0].recentChecks).toEqual([]); expect(payload.monitors[0].dailyChecks).toEqual([bucket]); From 7478bd7d17b3b7bb93d596d5dc41b470975a105d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 13:08:43 -0700 Subject: [PATCH 088/143] remove duplicate validation --- server/src/api/controllers/statusPageController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/api/controllers/statusPageController.ts b/server/src/api/controllers/statusPageController.ts index 9153c152c..94783b5a9 100644 --- a/server/src/api/controllers/statusPageController.ts +++ b/server/src/api/controllers/statusPageController.ts @@ -86,7 +86,6 @@ class StatusPageController implements IStatusPageController { }); resolveStatusPageByDomain = catchAsync(async (req: Request, res: Response) => { - resolveStatusPageQueryValidation.parse(req.query); const { range } = resolveStatusPageQueryValidation.parse(req.query); const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined); if (!domain) { From cee7db64ee471470f3980e4cf0702435a4c35565 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 13:16:55 -0700 Subject: [PATCH 089/143] add range types --- client/src/Types/StatusPage.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index ea0db5292..a69a3c737 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -24,6 +24,14 @@ export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const; export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number]; export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; +export const STATUS_PAGE_RANGES = ["latest", "30d", "60d", "90d"] as const; +export type StatusPageRange = (typeof STATUS_PAGE_RANGES)[number]; + +export const STATUS_PAGE_RANGE_DAYS: Record< + Exclude, + number +> = { "30d": 30, "60d": 60, "90d": 90 }; + export const resolveStatusPageTheme = ( value: string | null | undefined ): StatusPageTheme => From c71c833b77b2ee606ba26258b16f6e3b77eaf6fb Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 13:20:36 -0700 Subject: [PATCH 090/143] add range to query --- client/src/Utils/statusPageUrl.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/client/src/Utils/statusPageUrl.ts b/client/src/Utils/statusPageUrl.ts index 65109c198..d015edf8b 100644 --- a/client/src/Utils/statusPageUrl.ts +++ b/client/src/Utils/statusPageUrl.ts @@ -1,4 +1,8 @@ -import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage"; +import { + PUBLIC_STATUS_PAGE_PREFIX, + type StatusPage, + type StatusPageRange, +} from "@/Types/StatusPage"; import { runtimeConfig } from "@/Utils/runtimeConfig"; const CLIENT_HOST = @@ -86,8 +90,12 @@ export const getStatusPagePreviewUrl = (statusPage: Pick): st export const buildStatusPageApiPath = (options: { url?: string; useCustomDomain?: boolean; + range?: StatusPageRange; }): string | null => { - const query = "type=uptime&type=infrastructure"; + let query = "type=uptime&type=infrastructure"; + if (options.range && options.range !== "latest") { + query += `&range=${options.range}`; + } if (options.useCustomDomain && typeof window !== "undefined") { const domain = encodeURIComponent(window.location.hostname); From 42aae20fa3472bb61726430bafb048e7ff72da7c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 13:30:03 -0700 Subject: [PATCH 091/143] add param to URL --- client/src/Pages/StatusPage/Status/index.tsx | 16 +++++++++++++--- client/src/Types/StatusPage.ts | 3 +++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 3c0408628..ae5b3d8ed 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -1,6 +1,6 @@ import { BasePage, BaseFallback } from "@/Components/design-elements"; import Typography from "@mui/material/Typography"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; @@ -9,7 +9,12 @@ import { useTranslation } from "react-i18next"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useLocation, useParams } from "react-router-dom"; import { useGet } from "@/Hooks/UseApi"; -import { resolveStatusPageTheme } from "@/Types/StatusPage"; +import { + isStatusPageRange, + resolveStatusPageTheme, + STATUS_PAGE_RANGES, + type StatusPageRange, +} from "@/Types/StatusPage"; import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPageResponse, @@ -80,21 +85,26 @@ const StatusPageView = () => { const { url } = useParams(); const isAdmin = useIsAdmin(); const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); const onCustomDomainHost = isCustomDomainHost(); const isPublic = onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); + const rawRange = searchParams.get("range"); + const range = isStatusPageRange(rawRange) ? rawRange : "latest"; + const apiUrl = buildStatusPageApiPath({ url, useCustomDomain: onCustomDomainHost, + range, }); const { data, isLoading, error } = useGet( apiUrl, {}, { - refreshInterval: 10000, + refreshInterval: range === "latest" ? 10000 : 60000, } ); diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index a69a3c737..89dd44782 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -32,6 +32,9 @@ export const STATUS_PAGE_RANGE_DAYS: Record< number > = { "30d": 30, "60d": 60, "90d": 90 }; +export const isStatusPageRange = (value: string | null): value is StatusPageRange => + STATUS_PAGE_RANGES.some((range) => range === value); + export const resolveStatusPageTheme = ( value: string | null | undefined ): StatusPageTheme => From 4b02bbec37359bf929eedd71848b3c226783466e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 13:54:14 -0700 Subject: [PATCH 092/143] add helper methods --- client/src/Utils/DataUtils.ts | 23 ++++++++++++++++++++++- client/src/Utils/TimeUtils.ts | 15 +++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/client/src/Utils/DataUtils.ts b/client/src/Utils/DataUtils.ts index 28fdfb7b5..3562d86ed 100644 --- a/client/src/Utils/DataUtils.ts +++ b/client/src/Utils/DataUtils.ts @@ -86,7 +86,7 @@ export const getResponseColor = ( return normalizeToHex(safe.end); }; -const MIN_HEIGHT_PCT = 8; +export const MIN_HEIGHT_PCT = 8; const CAP_PERCENTILE = 95; const percentile = (sortedValues: number[], p: number): number => { @@ -114,3 +114,24 @@ export const computeBarHeights = ( return Math.max(MIN_HEIGHT_PCT, Math.min(100, (value / cap) * 100)); }); }; + +export const computeDayBarHeights = ( + buckets: { date: string; avgResponseTime: number | null }[] +): Map => { + const values = buckets.map((bucket) => + typeof bucket.avgResponseTime === "number" && Number.isFinite(bucket.avgResponseTime) + ? Math.max(0, bucket.avgResponseTime) + : 0 + ); + + const sorted = values.slice().sort((a, b) => a - b); + const cap = percentile(sorted, CAP_PERCENTILE); + return new Map( + buckets.map((bucket, i) => [ + bucket.date, + cap <= 0 + ? MIN_HEIGHT_PCT + : Math.max(MIN_HEIGHT_PCT, Math.min(100, (values[i] / cap) * 100)), + ]) + ); +}; diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index 4112967ab..9b809e1da 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -120,3 +120,18 @@ export const formatDuration = (ms: number, verbose: boolean = false, hasSpace = } return formatted; }; + +export const enumerateDays = (days: number, timeZone: string): string[] => { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const [year, month, day] = formatter.format(new Date()).split("-").map(Number); + const todayUtc = Date.UTC(year, month - 1, day); + const DAY_MS = 24 * 60 * 60 * 1000; + return Array.from({ length: days }, (_, i) => + new Date(todayUtc - (days - 1 - i) * DAY_MS).toISOString().slice(0, 10) + ); +}; From 03f168fa848859d56c843e7dfc7881d76c41f5b6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 13:54:38 -0700 Subject: [PATCH 093/143] add daily bucket type --- client/src/Types/Check.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index e32762d95..78e970f04 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -291,3 +291,12 @@ export type CheckSnapshot = Pick< export interface HasResponseTime { responseTime: number; } + +export interface DailyCheckBucket { + monitorId: string; + date: string; + totalChecks: number; + upChecks: number; + downChecks: number; + avgResponseTime: number | null; // null when no check that day recorded a response time +} From 506133af292513ac5d0285f21ddc158fb95c9416 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 13:55:01 -0700 Subject: [PATCH 094/143] add daily tooltip --- .../Status/themes/shared/ChartCells.tsx | 113 ++++++++++++++++++ .../themes/shared/ThemedChartTooltip.tsx | 37 +++++- client/src/locales/en.json | 4 +- 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx new file mode 100644 index 000000000..8e86c25c4 --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx @@ -0,0 +1,113 @@ +import { + ThemedChartTooltip, + ThemedDailyTooltip, +} from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip"; +import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check"; +import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; +import { + computeBarHeights, + computeDayBarHeights, + MIN_HEIGHT_PCT, +} from "@/Utils/DataUtils"; +import { formatPercentage } from "@/Utils/FormatUtils"; +import { enumerateDays, formatMs } from "@/Utils/TimeUtils"; +import type { TFunction } from "i18next"; + +export type BarKind = "up" | "degraded" | "down" | "empty"; +export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empty"; + +export interface ChartCell { + key: string; + barKind: BarKind; + heatKind: HeatCellKind; + heightPct: number; // histogram bar height + responseTime: number; // 0 for empty cells + tooltip: React.ReactNode | null; + ariaLabel: string; +} + +const DAY_DOWN_THRESHOLD = 0.5; + +const classifyDay = (bucket: DailyCheckBucket): BarKind => { + if (bucket.totalChecks === 0) return "empty"; + const upRatio = bucket.upChecks / bucket.totalChecks; + if (upRatio >= 1) return "up"; + if (upRatio < DAY_DOWN_THRESHOLD) return "down"; + return "degraded"; +}; + +export const classifyResponseTime = ( + status: boolean, + responseTime: number | undefined +): Exclude => { + if (!status) return "down"; + const rt = responseTime ?? 0; + if (rt > 500) return "slow"; + if (rt > 250) return "med"; + return "fast"; +}; + +const emptyCell = (key: string): ChartCell => ({ + key, + barKind: "empty", + heatKind: "empty", + heightPct: MIN_HEIGHT_PCT, + responseTime: 0, + tooltip: null, + ariaLabel: "", +}); + +const padCells = (cells: ChartCell[], length: number): ChartCell[] => [ + ...cells, + ...Array.from({ length: Math.max(0, length - cells.length) }, (_, i) => + emptyCell(`empty-${cells.length + i}`) + ), +]; + +export const checksToCells = (checks: CheckSnapshot[], t: TFunction): ChartCell[] => { + const source = checks.slice(-MAX_RECENT_CHECKS); + const heights = computeBarHeights(source); + const cells = source.map( + (check, i): ChartCell => ({ + key: check.id ?? String(i), + barKind: check.status ? "up" : "down", + heatKind: classifyResponseTime(check.status, check.responseTime), + heightPct: heights[i] ?? MIN_HEIGHT_PCT, + responseTime: check.responseTime ?? 0, + tooltip: , + ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`, + }) + ); + return padCells(cells, MAX_RECENT_CHECKS); +}; + +export const dailyBucketsToCells = ( + buckets: DailyCheckBucket[], + days: number, + bucketTimezone: string, + t: TFunction +): ChartCell[] => { + const byDate = new Map(buckets.map((b) => [b.date, b])); + const heights = computeDayBarHeights(buckets); + const enumerated = enumerateDays(days, bucketTimezone); + return enumerated.map((date) => { + const bucket = byDate.get(date); + if (!bucket) return emptyCell(`day-${date}`); + const barKind = classifyDay(bucket); + return { + key: `day-${date}`, + barKind, + heatKind: + barKind === "up" + ? classifyResponseTime(true, bucket.avgResponseTime ?? 0) + : barKind, // degraded/down days keep their outcome color in the heatmap too + heightPct: heights.get(date) ?? MIN_HEIGHT_PCT, + responseTime: bucket.avgResponseTime ?? 0, // null (no RT data that day) → 0, excluded from footer stats like empty cells + tooltip: , + ariaLabel: t("pages.statusPages.monitorsList.chart.dayAria", { + date, + uptime: formatPercentage(bucket.upChecks / bucket.totalChecks), + }), + }; + }); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx index bc3def0d4..8f6550460 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx @@ -1,7 +1,8 @@ -import type { CheckSnapshot } from "@/Types/Check"; +import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; +import { formatPercentage } from "@/Utils/FormatUtils"; import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { useTranslation } from "react-i18next"; @@ -27,3 +28,37 @@ export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => { ); }; + +export const ThemedDailyTooltip = ({ bucket }: { bucket: DailyCheckBucket }) => { + const { t } = useTranslation(); + return ( + + + {bucket.date} + + + {t("pages.statusPages.monitorsList.chart.dayUptime", { + uptime: formatPercentage(bucket.upChecks / bucket.totalChecks), + upChecks: bucket.upChecks.toLocaleString(), + totalChecks: bucket.totalChecks.toLocaleString(), + })} + + {bucket.avgResponseTime !== null && ( + + {t("pages.statusPages.monitorsList.chart.dayAvgResponse", { + value: formatMs(bucket.avgResponseTime), + })} + + )} + + ); +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index f6d405d86..c20fdc82f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1463,7 +1463,9 @@ "avg": "avg {{value}}", "max": "max {{value}}", "downTooltip": "Down", - "heatmapAria": "Response time heatmap" + "heatmapAria": "Response time heatmap", + "dayUptime": "Uptime: {{uptime}} ({{upChecks}}/{{totalChecks}} checks)", + "dayAvgResponse": "Avg response: {{value}}" }, "infrastructure": { "title": "Infrastructure", From c15d6e0851cde022c236821b3944207af06029dd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 17 Aug 2026 14:56:53 -0700 Subject: [PATCH 095/143] wire client --- client/src/Pages/StatusPage/Status/index.tsx | 13 ++ .../StatusPage/Status/themes/bold/styles.ts | 9 +- .../Status/themes/editorial/styles.ts | 8 +- .../StatusPage/Status/themes/modern/styles.ts | 8 +- .../Status/themes/refined/styles.ts | 8 +- .../Status/themes/shared/BaseStatusPage.tsx | 119 ++++++++++++------ .../Status/themes/shared/ThemedHeatmap.tsx | 51 +++----- .../Status/themes/shared/ThemedHistogram.tsx | 58 ++++----- client/src/Types/Monitor.ts | 8 +- client/src/Types/StatusPage.ts | 16 ++- client/src/locales/en.json | 7 ++ 11 files changed, 189 insertions(+), 116 deletions(-) diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index ae5b3d8ed..b38b25323 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -87,6 +87,16 @@ const StatusPageView = () => { const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); + const onRangeChange = (next: StatusPageRange) => { + const updated = new URLSearchParams(searchParams); + if (next === "latest") { + updated.delete("range"); + } else { + updated.set("range", next); + } + setSearchParams(updated, { replace: true }); + }; + const onCustomDomainHost = isCustomDomainHost(); const isPublic = onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); @@ -160,6 +170,9 @@ const StatusPageView = () => { statusPage={statusPage} monitors={monitors} config={themeConfig} + range={range} + onRangeChange={onRangeChange} + bucketTimezone={data.bucketTimezone ?? "Etc/UTC"} /> ); diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts index c4df4e207..86208d41b 100644 --- a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts @@ -4,8 +4,10 @@ import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type BoldBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type BoldHeatCell = HeatCellKind; +export type BoldBarKind = BarKind; export type BoldGaugeFill = "ok" | "warm" | "hot"; export interface BoldStyles { @@ -55,6 +57,7 @@ export const boldStyles = ( fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`, slow: tokens.warn, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; @@ -62,12 +65,14 @@ export const boldStyles = ( fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`, + degraded: `0 0 6px color-mix(in srgb, ${tokens.degraded} 20%, transparent)`, down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`, empty: "none", }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts index 868ebf186..9de2c96fb 100644 --- a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts @@ -8,8 +8,10 @@ import { } from "../shared/fontStacks"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type EditorialBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type EditorialHeatCell = HeatCellKind; +export type EditorialBarKind = BarKind; export type EditorialGaugeFill = "ok" | "warm" | "hot"; export interface EditorialStyles { @@ -59,12 +61,14 @@ export const editorialStyles = ( fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`, slow: tokens.warn, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts index 2f730ee63..7bb108f51 100644 --- a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts @@ -5,8 +5,10 @@ import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type ModernBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type ModernHeatCell = HeatCellKind; +export type ModernBarKind = BarKind; export type ModernGaugeFill = "ok" | "warm" | "hot"; export interface ModernStyles { @@ -86,12 +88,14 @@ export const modernStyles = ( fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`, med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`, slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`, + degraded: `linear-gradient(180deg, ${tokens.degraded}, color-mix(in srgb, ${tokens.degraded} 70%, #000000 30%))`, down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`, empty: tokens.border, }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts index 457b86ede..38f972c95 100644 --- a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts @@ -3,8 +3,10 @@ import type { StatusPageThemeTokens } from "../tokens"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; -export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type RefinedBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type RefinedHeatCell = HeatCellKind; +export type RefinedBarKind = BarKind; export type RefinedGaugeFill = "ok" | "warm" | "hot"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; @@ -71,12 +73,14 @@ export const refinedStyles = ( fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`, slow: tokens.warn, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index 96beeb173..a522b302f 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -4,17 +4,15 @@ import Stack from "@mui/material/Stack"; import { useTranslation } from "react-i18next"; import type { SxProps, Theme } from "@mui/material/styles"; import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; -import { getMonitorTypeLabel } from "@/Types/StatusPage"; +import type { StatusPage, StatusPageRange } from "@/Types/StatusPage"; +import { + getMonitorTypeLabel, + STATUS_PAGE_RANGE_DAYS, + STATUS_PAGE_RANGES, +} from "@/Types/StatusPage"; import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens"; -import { - ThemedHeatmap, - type HeatCellKind, -} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap"; -import { - ThemedHistogram, - type BarKind, -} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram"; +import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap"; +import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram"; import { ThemedInfrastructure, type GaugeFillLevel, @@ -28,6 +26,12 @@ import { } from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { formatPercentage } from "@/Utils/FormatUtils"; +import { + checksToCells, + dailyBucketsToCells, + type BarKind, + type HeatCellKind, +} from "@/Pages/StatusPage/Status/themes/shared/ChartCells"; type StatusPageMonitor = Monitor; @@ -82,9 +86,19 @@ interface Props { statusPage: StatusPage; monitors: StatusPageMonitor[]; config: ThemeConfig; + range: StatusPageRange; + onRangeChange: (range: StatusPageRange) => void; + bucketTimezone: string; } -export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { +export const BaseStatusPage = ({ + statusPage, + monitors, + config, + range, + onRangeChange, + bucketTimezone, +}: Props) => { const { t } = useTranslation(); const { tokens, mode } = useStatusPageTheme(); const styles = useMemo( @@ -126,33 +140,56 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { /> {statusPage.showCharts && ( - - + + setChartMode("heatmap")} - sx={styles.chartSwitchButton(chartMode === "heatmap")} + sx={styles.chartSwitch} + role="radiogroup" > - {t("pages.statusPages.monitorsList.chartTypeHeatmap")} - - setChartMode("histogram")} - sx={styles.chartSwitchButton(chartMode === "histogram")} - > - {t("pages.statusPages.monitorsList.chartTypeHistogram")} + setChartMode("heatmap")} + sx={styles.chartSwitchButton(chartMode === "heatmap")} + > + {t("pages.statusPages.monitorsList.chartTypeHeatmap")} + + setChartMode("histogram")} + sx={styles.chartSwitchButton(chartMode === "histogram")} + > + {t("pages.statusPages.monitorsList.chartTypeHistogram")} + - + + + {STATUS_PAGE_RANGES.map((value) => ( + onRangeChange(value)} + sx={styles.chartSwitchButton(range === value)} + > + {t(`pages.statusPages.range.${value}`)} + + ))} + + + )} { const badgeTone = monitorBadgeTone(monitor.status); const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0); + const cells = + range === "latest" + ? checksToCells(monitor.recentChecks ?? [], t) + : dailyBucketsToCells( + monitor.dailyChecks ?? [], + STATUS_PAGE_RANGE_DAYS[range], + bucketTimezone, + t + ); + return ( { {showChart && (chartMode === "heatmap" ? ( ) : ( ; cellSx: (kind: HeatCellKind) => SxProps; } -const classify = (check: CheckSnapshot): Exclude => { - if (!check.status) return "down"; - const rt = check.responseTime ?? 0; - if (rt > 500) return "slow"; - if (rt > 250) return "med"; - return "fast"; -}; - -export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { +export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => { const { t } = useTranslation(); - const source = checks.slice(-CELLS); - const padded: (CheckSnapshot | null)[] = [ - ...source, - ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), - ]; - return ( - {padded.map((check, i) => { - if (!check) { + {cells.map((cell) => { + if (cell.heatKind === "empty") { return ( ); } - const kind = classify(check); - const tooltipContent = ; return ( ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index a5c0b71f1..5fc1dcf33 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -5,17 +5,14 @@ import Stack from "@mui/material/Stack"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; import type { CheckSnapshot } from "@/Types/Check"; -import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -import { computeBarHeights } from "@/Utils/DataUtils"; import { formatMs } from "@/Utils/TimeUtils"; -import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip"; -const CELLS = MAX_RECENT_CHECKS; -const MIN_HEIGHT_PCT = 6; - -export type BarKind = "up" | "down" | "empty"; +import type { + BarKind, + ChartCell, +} from "@/Pages/StatusPage/Status/themes/shared/ChartCells"; interface Props { - checks: CheckSnapshot[]; + cells: ChartCell[]; containerSx: SxProps; barSx: (kind: BarKind, heightPct: number) => SxProps; statsSx: SxProps; @@ -26,7 +23,7 @@ const tone = (check: CheckSnapshot): Exclude => check.status ? "up" : "down"; export const ThemedHistogram = ({ - checks, + cells, containerSx, barSx, statsSx, @@ -34,45 +31,42 @@ export const ThemedHistogram = ({ }: Props) => { const { t } = useTranslation(); - const { padded, heights, avg, peak } = useMemo(() => { - const source = checks.slice(-CELLS); - const out: (CheckSnapshot | null)[] = [ - ...source, - ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), - ]; - const heights = computeBarHeights(source); - const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0); - const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 0; + const { avg, peak } = useMemo(() => { + const valid = cells.filter((cell) => cell.responseTime > 0); + const maxRt = valid.length ? Math.max(...valid.map((cell) => cell.responseTime)) : 0; const avgRt = valid.length - ? valid.reduce((s, c) => s + c.responseTime, 0) / valid.length + ? valid.reduce((sum, cell) => sum + cell.responseTime, 0) / valid.length : 0; - return { padded: out, heights, avg: formatMs(avgRt), peak: formatMs(maxRt) }; - }, [checks]); + return { avg: formatMs(avgRt), peak: formatMs(maxRt) }; + }, [cells]); return ( - - {padded.map((check, i) => { - if (!check) { + + {cells.map((cell) => { + if (cell.barKind === "empty") { return ( ); } - const height = heights[i] ?? MIN_HEIGHT_PCT; - const tooltipContent = ; return ( ); diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index ef328ebdb..6eaa280d7 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -1,4 +1,9 @@ -import type { GroupedCheck, CheckSnapshot, GroupedUptimeCheck } from "@/Types/Check"; +import type { + GroupedCheck, + CheckSnapshot, + GroupedUptimeCheck, + DailyCheckBucket, +} from "@/Types/Check"; import type { PageSpeedGroupedCheck } from "@/Types/Check"; import type { GeoContinent } from "@/Types/GeoCheck"; export type { GeoContinent } from "@/Types/GeoCheck"; @@ -149,6 +154,7 @@ export interface Monitor { dnsServer?: string; dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; + dailyChecks?: DailyCheckBucket[]; createdAt: string; updatedAt: string; } diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index 89dd44782..a9adb9271 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -24,13 +24,16 @@ export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const; export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number]; export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; -export const STATUS_PAGE_RANGES = ["latest", "30d", "60d", "90d"] as const; +export const STATUS_PAGE_DAY_RANGES = ["30d", "60d", "90d"] as const; +export type StatusPageDayRange = (typeof STATUS_PAGE_DAY_RANGES)[number]; +export const STATUS_PAGE_RANGES = ["latest", ...STATUS_PAGE_DAY_RANGES] as const; export type StatusPageRange = (typeof STATUS_PAGE_RANGES)[number]; -export const STATUS_PAGE_RANGE_DAYS: Record< - Exclude, - number -> = { "30d": 30, "60d": 60, "90d": 90 }; +export const STATUS_PAGE_RANGE_DAYS: Record = { + "30d": 30, + "60d": 60, + "90d": 90, +}; export const isStatusPageRange = (value: string | null): value is StatusPageRange => STATUS_PAGE_RANGES.some((range) => range === value); @@ -78,4 +81,7 @@ export interface StatusPage { export interface StatusPageResponse { statusPage: StatusPage; monitors: Monitor[]; + range?: StatusPageDayRange; // present only when range !== "latest" + bucketTimezone?: string; + checkTTLDays?: number; } diff --git a/client/src/locales/en.json b/client/src/locales/en.json index c20fdc82f..669e1b479 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1442,6 +1442,13 @@ }, "statusPages": { "deleteSuccess": "Status page deleted successfully", + "rangeSelectorAria": "History range", + "range": { + "latest": "Latest", + "30d": "30 days", + "60d": "60 days", + "90d": "90 days" + }, "fallback": { "title": "No status pages yet", "description": "Publish a public page that shows real-time uptime and incident history to your customers and stakeholders.", From 7096afcdf2955f510e01551955f76975728a000f Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Tue, 18 Aug 2026 02:37:54 -0400 Subject: [PATCH 096/143] fix(status-page): allow DNS monitors to be added to status pages The monitor picker on the status page create/configure form fetched candidates using a hardcoded list of monitor types that omitted "dns", so DNS monitors were filtered out server-side and never appeared in the dialog. Everything else was already in place: the DNS provider stores checks like any other monitor type, and the status page renders them once attached. Rather than appending "dns" to the hardcoded list, derive the query from the existing SelectableMonitorTypes tuple in @/Types/Monitor. The two lists had silently drifted apart, and deriving prevents the next new monitor type from hitting the same bug. "hardware" is appended explicitly because infrastructure monitors are created on their own page and so are absent from the selectable set. Net effect on the requested types: gains "dns", loses none. Fixes #3721 --- client/src/Pages/StatusPage/Create/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 1a3a5c344..c04bcb186 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -15,6 +15,7 @@ import type { StatusPageFormData } from "@/Validation/statusPage"; import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statusPage"; import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; +import { SelectableMonitorTypes } from "@/Types/Monitor"; import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"; import { getMonitorTypeLabel } from "@/Types/StatusPage"; import { timezoneOptions } from "@/Utils/timezoneOptions"; @@ -58,11 +59,13 @@ const LogoUploadField = () => { ); }; +// Derived from SelectableMonitorTypes so a newly supported monitor type is +// offered here automatically; the previous hardcoded list silently omitted DNS. +// "hardware" is appended because infrastructure monitors are created on their +// own page and so are absent from the selectable set. const monitorsUrl = (() => { const params = new URLSearchParams(); - ["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach( - (type) => params.append("type", type) - ); + [...SelectableMonitorTypes, "hardware"].forEach((type) => params.append("type", type)); return `/monitors/team?${params.toString()}`; })(); From 840c9365018c821be7ca3092bdbe7bbd859909ce Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 18 Aug 2026 09:38:34 -0700 Subject: [PATCH 097/143] remove unused vars --- client/src/Pages/StatusPage/Status/index.tsx | 1 - .../StatusPage/Status/themes/shared/BaseStatusPage.tsx | 2 +- .../Pages/StatusPage/Status/themes/shared/ChartCells.tsx | 6 +++--- .../StatusPage/Status/themes/shared/ThemedHistogram.tsx | 4 ---- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index b38b25323..66fcece97 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -12,7 +12,6 @@ import { useGet } from "@/Hooks/UseApi"; import { isStatusPageRange, resolveStatusPageTheme, - STATUS_PAGE_RANGES, type StatusPageRange, } from "@/Types/StatusPage"; import { diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index a522b302f..daca33d00 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -205,7 +205,7 @@ export const BaseStatusPage = ({ const cells = range === "latest" - ? checksToCells(monitor.recentChecks ?? [], t) + ? checksToCells(monitor.recentChecks ?? []) : dailyBucketsToCells( monitor.dailyChecks ?? [], STATUS_PAGE_RANGE_DAYS[range], diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx index 8e86c25c4..e45d2e6b6 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ChartCells.tsx @@ -64,7 +64,7 @@ const padCells = (cells: ChartCell[], length: number): ChartCell[] => [ ), ]; -export const checksToCells = (checks: CheckSnapshot[], t: TFunction): ChartCell[] => { +export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => { const source = checks.slice(-MAX_RECENT_CHECKS); const heights = computeBarHeights(source); const cells = source.map( @@ -100,9 +100,9 @@ export const dailyBucketsToCells = ( heatKind: barKind === "up" ? classifyResponseTime(true, bucket.avgResponseTime ?? 0) - : barKind, // degraded/down days keep their outcome color in the heatmap too + : barKind, heightPct: heights.get(date) ?? MIN_HEIGHT_PCT, - responseTime: bucket.avgResponseTime ?? 0, // null (no RT data that day) → 0, excluded from footer stats like empty cells + responseTime: bucket.avgResponseTime ?? 0, tooltip: , ariaLabel: t("pages.statusPages.monitorsList.chart.dayAria", { date, diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index 5fc1dcf33..3643d901c 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -4,7 +4,6 @@ import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import type { CheckSnapshot } from "@/Types/Check"; import { formatMs } from "@/Utils/TimeUtils"; import type { BarKind, @@ -19,9 +18,6 @@ interface Props { statsGap?: number; } -const tone = (check: CheckSnapshot): Exclude => - check.status ? "up" : "down"; - export const ThemedHistogram = ({ cells, containerSx, From 248dad01d47678ce7eee589cbfaae91f3b7ae763 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 18 Aug 2026 10:01:05 -0700 Subject: [PATCH 098/143] add retention hint --- .../src/Components/design-elements/Icon.tsx | 4 ++- client/src/Pages/StatusPage/Status/index.tsx | 1 + .../Status/themes/shared/BaseStatusPage.tsx | 35 +++++++++++++++++-- client/src/locales/en.json | 2 ++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/client/src/Components/design-elements/Icon.tsx b/client/src/Components/design-elements/Icon.tsx index 22f939750..95e68826b 100644 --- a/client/src/Components/design-elements/Icon.tsx +++ b/client/src/Components/design-elements/Icon.tsx @@ -5,13 +5,15 @@ interface IconProps { size?: number; strokeWidth?: number; stroke?: string; + color?: string; } -const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => { +const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => { return ( ); }; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 66fcece97..87a2de7a1 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -172,6 +172,7 @@ const StatusPageView = () => { range={range} onRangeChange={onRangeChange} bucketTimezone={data.bucketTimezone ?? "Etc/UTC"} + checkTTLDays={data.checkTTLDays} /> ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index daca33d00..cf08ca62f 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -1,7 +1,8 @@ -import { useMemo, useState } from "react"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; -import { useTranslation } from "react-i18next"; +import Typography from "@mui/material/Typography"; +import { TriangleAlert } from "lucide-react"; + import type { SxProps, Theme } from "@mui/material/styles"; import type { Monitor } from "@/Types/Monitor"; import type { StatusPage, StatusPageRange } from "@/Types/StatusPage"; @@ -24,7 +25,6 @@ import { resolveOverallStatus, statusBadgeKey, } from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; -import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { formatPercentage } from "@/Utils/FormatUtils"; import { checksToCells, @@ -32,6 +32,13 @@ import { type BarKind, type HeatCellKind, } from "@/Pages/StatusPage/Status/themes/shared/ChartCells"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { Icon } from "@/Components/design-elements"; + +import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; type StatusPageMonitor = Monitor; @@ -89,6 +96,7 @@ interface Props { range: StatusPageRange; onRangeChange: (range: StatusPageRange) => void; bucketTimezone: string; + checkTTLDays?: number; } export const BaseStatusPage = ({ @@ -98,7 +106,9 @@ export const BaseStatusPage = ({ range, onRangeChange, bucketTimezone, + checkTTLDays, }: Props) => { + const theme = useTheme(); const { t } = useTranslation(); const { tokens, mode } = useStatusPageTheme(); const styles = useMemo( @@ -189,6 +199,25 @@ export const BaseStatusPage = ({ ))} + {range !== "latest" && + checkTTLDays && + STATUS_PAGE_RANGE_DAYS[range] > checkTTLDays && ( + + + + {t("pages.statusPages.rangeExceedsRetention", { days: checkTTLDays })} + + + )} )} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 669e1b479..fe8f0319e 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1443,6 +1443,7 @@ "statusPages": { "deleteSuccess": "Status page deleted successfully", "rangeSelectorAria": "History range", + "rangeExceedsRetention": "Check history retention is set to {{days}} days. Increase retention for more data.", "range": { "latest": "Latest", "30d": "30 days", @@ -1471,6 +1472,7 @@ "max": "max {{value}}", "downTooltip": "Down", "heatmapAria": "Response time heatmap", + "dayAria": "{{date}}, {{uptime}} uptime", "dayUptime": "Uptime: {{uptime}} ({{upChecks}}/{{totalChecks}} checks)", "dayAvgResponse": "Avg response: {{value}}" }, From 1b6218ea569a250e040cf5655d9e21db10d960c7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 18 Aug 2026 10:46:57 -0700 Subject: [PATCH 099/143] remove tests and harness --- client/package-lock.json | 1013 +------------------------------ client/package.json | 8 +- client/src/Utils/logger.test.ts | 201 ------ client/vitest.config.ts | 18 - 4 files changed, 3 insertions(+), 1237 deletions(-) delete mode 100644 client/src/Utils/logger.test.ts delete mode 100644 client/vitest.config.ts diff --git a/client/package-lock.json b/client/package-lock.json index 7bea223fb..bca8463c0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -57,161 +57,13 @@ "eslint-plugin-react": "^7.34.1", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.6", - "jsdom": "^29.1.1", "lint-staged": "^16.2.7", "mongoose": "9.6.2", "prettier": "^3.3.3", "typescript": "5.9.3", - "vite": "^6.4.2", - "vitest": "^3.2.7" + "vite": "^6.4.2" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-calc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", - "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-color-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", - "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.1.1", - "@csstools/css-calc": "^3.3.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -483,64 +335,6 @@ "node": ">=6.9.0" } }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", - "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", - "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", @@ -1156,24 +950,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, "node_modules/@hapi/hoek": { "version": "9.3.0", "license": "BSD-3-Clause" @@ -2521,17 +2297,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, "node_modules/@types/d3-array": { "version": "3.2.2", "license": "MIT" @@ -2581,13 +2346,6 @@ "version": "3.0.2", "license": "MIT" }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2994,131 +2752,6 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/acorn": { "version": "8.15.0", "dev": true, @@ -3352,16 +2985,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", @@ -3446,16 +3069,6 @@ "node": ">=6.0.0" } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", @@ -3542,16 +3155,6 @@ "typewise-core": "^1.2" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind": { "version": "1.0.8", "dev": true, @@ -3632,23 +3235,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -3664,16 +3250,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -3816,20 +3392,6 @@ "utrie": "^1.0.2" } }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/csstype": { "version": "3.2.3", "license": "MIT" @@ -3943,58 +3505,6 @@ "node": ">=12" } }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/data-urls/node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/data-urls/node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "dev": true, @@ -4062,27 +3572,10 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, "node_modules/decimal.js-light": { "version": "2.5.1", "license": "MIT" }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -4349,13 +3842,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, "node_modules/es-object-atoms": { "version": "1.1.1", "license": "MIT", @@ -4683,16 +4169,6 @@ "version": "4.0.7", "license": "MIT" }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -5235,19 +4711,6 @@ "version": "16.13.1", "license": "MIT" }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/html-parse-stringify": { "version": "3.0.1", "license": "MIT", @@ -5652,13 +5115,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-regex": { "version": "1.2.1", "dev": true, @@ -5860,95 +5316,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", - "parse5": "^8.0.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/jsdom/node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/jsdom/node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/jsdom/node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/jsesc": { "version": "3.1.0", "license": "MIT", @@ -6182,13 +5549,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lower-case": { "version": "2.0.2", "license": "MIT", @@ -6212,16 +5572,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/maplibre-gl": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz", @@ -6266,13 +5616,6 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", @@ -6772,32 +6115,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -6833,23 +6150,6 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pbf": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", @@ -7328,16 +6628,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/reselect": { "version": "5.1.1", "license": "MIT" @@ -7548,19 +6838,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/scheduler": { "version": "0.23.2", "license": "MIT", @@ -7727,13 +7004,6 @@ "dev": true, "license": "MIT" }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7891,20 +7161,6 @@ "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "dev": true, @@ -8083,26 +7339,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/stylis": { "version": "4.2.0", "license": "MIT" @@ -8152,13 +7388,6 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, "node_modules/text-segmentation": { "version": "1.0.3", "license": "MIT", @@ -8175,20 +7404,6 @@ "version": "1.3.3", "license": "MIT" }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.15", "license": "MIT", @@ -8203,62 +7418,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", - "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.10" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", - "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", - "dev": true, - "license": "MIT" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8272,19 +7437,6 @@ "node": ">=8.0" } }, - "node_modules/tough-cookie": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", - "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -8451,16 +7603,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/undici-types": { "version": "7.12.0", "devOptional": true, @@ -8627,29 +7769,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vite-plugin-svgr": { "version": "4.5.0", "license": "MIT", @@ -8662,79 +7781,6 @@ "vite": ">=2.6.0" } }, - "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, "node_modules/void-elements": { "version": "3.1.0", "license": "MIT", @@ -8742,19 +7788,6 @@ "node": ">=0.10.0" } }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -8765,16 +7798,6 @@ "node": ">=12" } }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/whatwg-url": { "version": "14.2.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", @@ -8884,23 +7907,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, @@ -8992,23 +7998,6 @@ "dev": true, "license": "ISC" }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/client/package.json b/client/package.json index ac70f6834..ee290cae8 100644 --- a/client/package.json +++ b/client/package.json @@ -11,9 +11,7 @@ "lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "format": "prettier --write .", - "format-check": "prettier --check .", - "test": "vitest run", - "test:watch": "vitest" + "format-check": "prettier --check ." }, "lint-staged": { "**/*": "prettier --write --ignore-unknown" @@ -68,12 +66,10 @@ "eslint-plugin-react": "^7.34.1", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.6", - "jsdom": "^29.1.1", "lint-staged": "^16.2.7", "mongoose": "9.6.2", "prettier": "^3.3.3", "typescript": "5.9.3", - "vite": "^6.4.2", - "vitest": "^3.2.7" + "vite": "^6.4.2" } } diff --git a/client/src/Utils/logger.test.ts b/client/src/Utils/logger.test.ts deleted file mode 100644 index da7c0d7fa..000000000 --- a/client/src/Utils/logger.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -/** - * Regression tests for the credential leak reported in #3843: a failed request - * wrote the full body — including the plaintext password — to the console. - * - * These drive the exported `logger` rather than the private redaction helpers, - * so they cover the wiring too. The original fix redacted the JSON branch but - * left the debug branch printing the raw objects, which these tests catch. - * - * `configuredLevel` is captured at module load, so each level needs a fresh - * import with `runtimeConfig` mocked beforehand. - */ - -const PASSWORD = "correct-horse-battery-staple"; -const BEARER = "eyJhbGciOiJIUzI1NiJ9.super-secret-jwt"; - -type ConsoleMethod = "log" | "warn" | "error"; -const spies: Partial>> = {}; - -/** Everything written to the console during a test, as one searchable string. */ -const consoleOutput = (): string => - (["log", "warn", "error"] as const) - .flatMap((method) => spies[method]?.mock.calls ?? []) - .map((args) => args.map((arg) => JSON.stringify(arg) ?? String(arg)).join(" ")) - .join("\n"); - -const loadLogger = async (level: "debug" | "error") => { - vi.resetModules(); - vi.doMock("@/Utils/runtimeConfig", () => ({ runtimeConfig: { logLevel: level } })); - return (await import("@/Utils/logger")).logger; -}; - -beforeEach(() => { - for (const method of ["log", "warn", "error"] as const) { - spies[method] = vi.spyOn(console, method).mockImplementation(() => {}); - } - vi.spyOn(console, "group").mockImplementation(() => {}); - vi.spyOn(console, "groupEnd").mockImplementation(() => {}); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.doUnmock("@/Utils/runtimeConfig"); -}); - -describe.each(["error", "debug"] as const)("logger at level %s", (level) => { - it("does not log a password from a failed request body", async () => { - const logger = await loadLogger(level); - - logger.error("POST request failed", new Error("Request failed"), { - endpoint: "/auth/login", - body: { email: "user@example.com", password: PASSWORD }, - }); - - expect(consoleOutput()).not.toContain(PASSWORD); - expect(consoleOutput()).toContain("[REDACTED]"); - }); - - it("keeps non-sensitive context so logs stay useful", async () => { - const logger = await loadLogger(level); - - logger.error("POST request failed", new Error("boom"), { - endpoint: "/auth/login", - body: { email: "user@example.com", password: PASSWORD }, - }); - - const output = consoleOutput(); - expect(output).toContain("/auth/login"); - expect(output).toContain("user@example.com"); - }); - - it("does not leak credentials carried on an AxiosError", async () => { - const logger = await loadLogger(level); - - // Axios attaches the outgoing request to the error: `config.data` holds the - // serialized body and `config.headers` the Authorization set in ApiClient. - const axiosError = Object.assign(new Error("Request failed with status code 502"), { - name: "AxiosError", - config: { - url: "/auth/login", - method: "post", - data: JSON.stringify({ email: "user@example.com", password: PASSWORD }), - headers: { Authorization: `Bearer ${BEARER}`, Accept: "application/json" }, - }, - response: { status: 502 }, - }); - - logger.error("POST request failed", axiosError, { endpoint: "/auth/login" }); - - const output = consoleOutput(); - expect(output).not.toContain(PASSWORD); - expect(output).not.toContain(BEARER); - }); - - it("redacts a password held on a class instance, not just an object literal", async () => { - const logger = await loadLogger(level); - - // Declared without parameter properties: the repo enables - // `erasableSyntaxOnly`, which disallows that shorthand. - class LoginPayload { - email: string; - password: string; - - constructor(email: string, password: string) { - this.email = email; - this.password = password; - } - } - - logger.error("POST request failed", new Error("boom"), { - endpoint: "/auth/login", - body: new LoginPayload("user@example.com", PASSWORD), - }); - - expect(consoleOutput()).not.toContain(PASSWORD); - }); - - it("redacts a request body that is not JSON rather than logging it raw", async () => { - const logger = await loadLogger(level); - - const formEncoded = Object.assign(new Error("boom"), { - config: { - url: "/auth/login", - method: "post", - data: `email=user@example.com&password=${PASSWORD}`, - headers: {}, - }, - }); - - logger.error("POST request failed", formEncoded, {}); - - expect(consoleOutput()).not.toContain(PASSWORD); - }); - - it("redacts secrets passed through warn", async () => { - const logger = await loadLogger(level); - - logger.warn("monitor update failed", { - endpoint: "/monitors/1", - body: { url: "https://example.com", secret: BEARER }, - }); - - expect(consoleOutput()).not.toContain(BEARER); - }); -}); - -describe("logger at level debug", () => { - // debug() and info() are suppressed at error level, so they only apply here. - it("redacts secrets passed through debug and info", async () => { - const logger = await loadLogger("debug"); - - logger.debug("payload", { password: PASSWORD }); - logger.info("payload", { apiKey: BEARER }); - - const output = consoleOutput(); - expect(output).not.toContain(PASSWORD); - expect(output).not.toContain(BEARER); - }); -}); - -describe("redaction key matching", () => { - it.each([ - ["password", { password: PASSWORD }], - ["newPassword", { newPassword: PASSWORD }], - ["confirmPassword", { confirmPassword: PASSWORD }], - ["secret", { secret: PASSWORD }], - ["accessToken", { accessToken: PASSWORD }], - ["X-Api-Key", { "X-Api-Key": PASSWORD }], - ["Authorization", { Authorization: PASSWORD }], - ["private_key", { private_key: PASSWORD }], - ])("redacts %s", async (_name, body) => { - const logger = await loadLogger("error"); - - logger.error("POST request failed", undefined, { body }); - - expect(consoleOutput()).not.toContain(PASSWORD); - }); - - it("survives a circular payload without hanging", async () => { - const logger = await loadLogger("error"); - - const circular: Record = { password: PASSWORD }; - circular.self = circular; - - expect(() => - logger.error("POST request failed", undefined, { body: circular }) - ).not.toThrow(); - expect(consoleOutput()).not.toContain(PASSWORD); - }); - - it("renders a repeated sibling in full rather than marking it circular", async () => { - const logger = await loadLogger("error"); - - const shared = { monitorId: "abc123" }; - - logger.error("POST request failed", undefined, { first: shared, second: shared }); - - expect(consoleOutput()).not.toContain("[CIRCULAR]"); - }); -}); diff --git a/client/vitest.config.ts b/client/vitest.config.ts deleted file mode 100644 index 75ca75aa3..000000000 --- a/client/vitest.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "vitest/config"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -export default defineConfig({ - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, - }, - test: { - // The logger reads window.location and navigator.userAgent. - environment: "jsdom", - include: ["src/**/*.{test,spec}.{ts,tsx}"], - }, -}); From 2468d3e287d5f8d66fe5b9eb7285f42ca874a13e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 18 Aug 2026 11:21:20 -0700 Subject: [PATCH 100/143] bump dependencies --- server/package-lock.json | 2311 +++++++------------------------------- server/package.json | 7 +- 2 files changed, 402 insertions(+), 1916 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index b9a733664..dac79faa8 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -30,14 +30,13 @@ "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", - "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", "multer": "^1.4.5-lts.2", - "nodemailer": "8.0.11", + "nodemailer": "9.0.5", "ping": "0.4.4", "re2": "^1.24.1", - "sharp": "0.33.5", + "sharp": "0.35.3", "ssl-checker": "2.0.10", "swagger-ui-express": "5.0.1", "tldts": "^7.4.10", @@ -61,7 +60,7 @@ "@types/jsonwebtoken": "9.0.10", "@types/mjml": "^4.7.4", "@types/multer": "^2.0.0", - "@types/nodemailer": "7.0.1", + "@types/nodemailer": "8.0.1", "@types/papaparse": "^5.5.2", "@types/ping": "0.4.4", "@types/swagger-ui-express": "4.1.8", @@ -143,702 +142,6 @@ "zod": "^4.0.0" } }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-sdk/client-sesv2": { - "version": "3.1003.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.1003.0.tgz", - "integrity": "sha512-NR1rfYBcT4QWMG+VN2CTgbtWpf8I6aYsrU81hslucQd+z9O0qHMzZ3KHEdIcT9+PGYtqDwqhxUArboF7G09Oiw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/credential-provider-node": "^3.972.17", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.18", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.6", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.3", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.8", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.22", - "@smithy/middleware-retry": "^4.4.39", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.38", - "@smithy/util-defaults-mode-node": "^4.2.41", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.973.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.18.tgz", - "integrity": "sha512-GUIlegfcK2LO1J2Y98sCJy63rQSiLiDOgVw7HiHPRqfI2vb3XozTVqemwO0VSGXp54ngCnAQz0Lf0YPCBINNxA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/xml-builder": "^3.972.10", - "@smithy/core": "^3.23.8", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.16.tgz", - "integrity": "sha512-HrdtnadvTGAQUr18sPzGlE5El3ICphnH6SU7UQOMOWFgRKbTRNN8msTxM4emzguUso9CzaHU2xy5ctSrmK5YNA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.18.tgz", - "integrity": "sha512-NyB6smuZAixND5jZumkpkunQ0voc4Mwgkd+SZ6cvAzIB7gK8HV8Zd4rS8Kn5MmoGgusyNfVGG+RLoYc4yFiw+A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/types": "^3.973.5", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.17", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.16.tgz", - "integrity": "sha512-hzAnzNXKV0A4knFRWGu2NCt72P4WWxpEGnOc6H3DptUjC4oX3hGw846oN76M1rTHAOwDdbhjU0GAOWR4OUfTZg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/credential-provider-env": "^3.972.16", - "@aws-sdk/credential-provider-http": "^3.972.18", - "@aws-sdk/credential-provider-login": "^3.972.16", - "@aws-sdk/credential-provider-process": "^3.972.16", - "@aws-sdk/credential-provider-sso": "^3.972.16", - "@aws-sdk/credential-provider-web-identity": "^3.972.16", - "@aws-sdk/nested-clients": "^3.996.6", - "@aws-sdk/types": "^3.973.5", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.16.tgz", - "integrity": "sha512-VI0kXTlr0o1FTay+Jvx6AKqx5ECBgp7X4VevGBEbuXdCXnNp7SPU0KvjsOLVhIz3OoPK4/lTXphk43t0IVk65w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/nested-clients": "^3.996.6", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.17.tgz", - "integrity": "sha512-98MAcQ2Dk7zkvgwZ5f6fLX2lTyptC3gTSDx4EpvTdJWET8qs9lBPYggoYx7GmKp/5uk0OwVl0hxIDZsDNS/Y9g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.16", - "@aws-sdk/credential-provider-http": "^3.972.18", - "@aws-sdk/credential-provider-ini": "^3.972.16", - "@aws-sdk/credential-provider-process": "^3.972.16", - "@aws-sdk/credential-provider-sso": "^3.972.16", - "@aws-sdk/credential-provider-web-identity": "^3.972.16", - "@aws-sdk/types": "^3.973.5", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.16.tgz", - "integrity": "sha512-n89ibATwnLEg0ZdZmUds5bq8AfBAdoYEDpqP3uzPLaRuGelsKlIvCYSNNvfgGLi8NaHPNNhs1HjJZYbqkW9b+g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.16.tgz", - "integrity": "sha512-b9of7tQgERxgcEcwAFWvRe84ivw+Kw6b3jVuz/6LQzonkomiY5UoWfprkbjc8FSCQ2VjDqKTvIRA9F0KSQ025w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/nested-clients": "^3.996.6", - "@aws-sdk/token-providers": "3.1003.0", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.16.tgz", - "integrity": "sha512-PaOH5jFoPQX4WkqpKzKh9cM7rieKtbgEGqrZ+ybGmotJhcvhI/xl69yCwMbHGnpQJJmHZIX9q2zaPB7HTBn/4w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/nested-clients": "^3.996.6", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz", - "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz", - "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz", - "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.18.tgz", - "integrity": "sha512-5E3XxaElrdyk6ZJ0TjH7Qm6ios4b/qQCiLr6oQ8NK7e4Kn6JBTJCaYioQCQ65BpZ1+l1mK5wTAac2+pEz0Smpw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.8", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-stream": "^4.5.17", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.18.tgz", - "integrity": "sha512-KcqQDs/7WtoEnp52+879f8/i1XAJkgka5i4arOtOCPR10o4wWo3VRecDI9Gxoh6oghmLCnIiOSKyRcXI/50E+w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@smithy/core": "^3.23.8", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.6.tgz", - "integrity": "sha512-blNJ3ugn4gCQ9ZSZi/firzKCvVl5LvPFVxv24LprENeWI4R8UApG006UQkF4SkmLygKq2BQXRad2/anQ13Te4Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/middleware-host-header": "^3.972.7", - "@aws-sdk/middleware-logger": "^3.972.7", - "@aws-sdk/middleware-recursion-detection": "^3.972.7", - "@aws-sdk/middleware-user-agent": "^3.972.18", - "@aws-sdk/region-config-resolver": "^3.972.7", - "@aws-sdk/types": "^3.973.5", - "@aws-sdk/util-endpoints": "^3.996.4", - "@aws-sdk/util-user-agent-browser": "^3.972.7", - "@aws-sdk/util-user-agent-node": "^3.973.3", - "@smithy/config-resolver": "^4.4.10", - "@smithy/core": "^3.23.8", - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/hash-node": "^4.2.11", - "@smithy/invalid-dependency": "^4.2.11", - "@smithy/middleware-content-length": "^4.2.11", - "@smithy/middleware-endpoint": "^4.4.22", - "@smithy/middleware-retry": "^4.4.39", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/protocol-http": "^5.3.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.38", - "@smithy/util-defaults-mode-node": "^4.2.41", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz", - "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/config-resolver": "^4.4.10", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.6.tgz", - "integrity": "sha512-NnsOQsVmJXy4+IdPFUjRCWPn9qNH1TzS/f7MiWgXeoHs903tJpAWQWQtoFvLccyPoBgomKP9L89RRr2YsT/L0g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.18", - "@aws-sdk/types": "^3.973.5", - "@smithy/protocol-http": "^5.3.11", - "@smithy/signature-v4": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1003.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1003.0.tgz", - "integrity": "sha512-SOyyWNdT7njKRwtZ1JhwHlH1csv6Pkgf305X96/OIfnhq1pU/EjmT6W6por57rVrjrKuHBuEIXgpWv8OgoMHpg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.18", - "@aws-sdk/nested-clients": "^3.996.6", - "@aws-sdk/types": "^3.973.5", - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", - "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", - "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", - "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-endpoints": "^3.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz", - "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.5", - "@smithy/types": "^4.13.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.3.tgz", - "integrity": "sha512-8s2cQmTUOwcBlIJyI9PAZNnnnF+cGtdhHc1yzMMsSD/GR/Hxj7m0IGUE92CslXXb8/p5Q76iqOCjN1GFwyf+1A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.18", - "@aws-sdk/types": "^3.973.5", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", - "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", - "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -869,6 +172,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1501,6 +805,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1545,6 +850,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1560,37 +866,13 @@ "kuler": "^2.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2277,10 +1559,19 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -2290,19 +1581,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -2312,19 +1603,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -2338,9 +1648,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -2354,9 +1664,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -2370,9 +1680,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -2385,10 +1695,42 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -2402,9 +1744,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -2418,9 +1760,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -2434,9 +1776,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -2450,9 +1792,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -2462,19 +1804,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -2484,19 +1826,63 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -2506,19 +1892,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -2528,19 +1914,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -2550,19 +1936,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -2572,38 +1958,73 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "cpu": [ - "wasm32" - ], + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.11.1" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -2613,16 +2034,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -2632,7 +2053,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -2709,9 +2130,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -3213,19 +2634,6 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3402,626 +2810,6 @@ "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz", - "integrity": "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.10.tgz", - "integrity": "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.2", - "@smithy/util-middleware": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.8.tgz", - "integrity": "sha512-f7uPeBi7ehmLT4YF2u9j3qx6lSnurG1DLXOsTtJrIRNDF7VXio4BGHQ+SQteN/BrUVudbkuL4v7oOsRCzq4BqA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/middleware-serde": "^4.2.12", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-stream": "^4.5.17", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.11.tgz", - "integrity": "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.13.tgz", - "integrity": "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.11.tgz", - "integrity": "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.11.tgz", - "integrity": "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.11.tgz", - "integrity": "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.22", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.22.tgz", - "integrity": "sha512-sc81w1o4Jy+/MAQlY3sQ8C7CmSpcvIi3TAzXblUv2hjG11BBSJi/Cw8vDx5BxMxapuH2I+Gc+45vWsgU07WZRQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.8", - "@smithy/middleware-serde": "^4.2.12", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.11", - "@smithy/util-middleware": "^4.2.11", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.4.39", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.39.tgz", - "integrity": "sha512-MCVCxaCzuZgiHtHGV2Ke44nh6t4+8/tO+rTYOzrr2+G4nMLU/qbzNCWKBX54lyEaVcGQrfOJiG2f8imtiw+nIQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/service-error-classification": "^4.2.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-retry": "^4.2.11", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.12.tgz", - "integrity": "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.11.tgz", - "integrity": "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.11", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.11.tgz", - "integrity": "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.11", - "@smithy/shared-ini-file-loader": "^4.4.6", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.14.tgz", - "integrity": "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/querystring-builder": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.11.tgz", - "integrity": "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.11.tgz", - "integrity": "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.11.tgz", - "integrity": "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-uri-escape": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.11.tgz", - "integrity": "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.11.tgz", - "integrity": "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.6.tgz", - "integrity": "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.11.tgz", - "integrity": "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.11", - "@smithy/util-uri-escape": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.2.tgz", - "integrity": "sha512-HezY3UuG0k4T+4xhFKctLXCA5N2oN+Rtv+mmL8Gt7YmsUY2yhmcLyW75qrSzldfj75IsCW/4UhY3s20KcFnZqA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.23.8", - "@smithy/middleware-endpoint": "^4.4.22", - "@smithy/middleware-stack": "^4.2.11", - "@smithy/protocol-http": "^5.3.11", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.17", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", - "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.11.tgz", - "integrity": "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", - "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", - "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", - "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", - "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.38", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.38.tgz", - "integrity": "sha512-c8P1mFLNxcsdAMabB8/VUQUbWzFmgujWi4bAXSggcqLYPc8V4U5abqFqOyn+dK4YT+q8UyCVkTO8807t4t2syA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.41", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.41.tgz", - "integrity": "sha512-/UG+9MT3UZAR0fLzOtMJMfWGcjjHvgggq924x/CRy8vRbL+yFf3Z6vETlvq8vDH92+31P/1gSOFoo7303wN8WQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.4.10", - "@smithy/credential-provider-imds": "^4.2.11", - "@smithy/node-config-provider": "^4.3.11", - "@smithy/property-provider": "^4.2.11", - "@smithy/smithy-client": "^4.12.2", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.2.tgz", - "integrity": "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", - "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.11.tgz", - "integrity": "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.11.tgz", - "integrity": "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/service-error-classification": "^4.2.11", - "@smithy/types": "^4.13.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.5.17", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.17.tgz", - "integrity": "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.3.13", - "@smithy/node-http-handler": "^4.4.14", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-buffer-from": "^4.2.2", - "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-utf8": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", - "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/uuid": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", - "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -4262,6 +3050,7 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4422,18 +3211,18 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } }, "node_modules/@types/nodemailer": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.1.tgz", - "integrity": "sha512-UfHAghPmGZVzaL8x9y+mKZMWyHC399+iq0MOmya5tIyenWX3lcdSb60vOmp0DocR6gCDTYTozv/ULQnREyyjkg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", "dev": true, "license": "MIT", "dependencies": { - "@aws-sdk/client-sesv2": "^3.839.0", "@types/node": "*" } }, @@ -4659,6 +3448,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -4867,16 +3657,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { @@ -5323,12 +4113,12 @@ ] }, "node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/accepts": { @@ -5359,6 +4149,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5567,13 +4358,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -5766,6 +4557,7 @@ "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "dev": true, "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -5968,9 +4760,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -6041,17 +4833,10 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "dev": true, - "license": "MIT" - }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -6091,6 +4876,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", @@ -6581,19 +5367,6 @@ "dev": true, "license": "MIT" }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6612,16 +5385,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -6976,6 +5739,7 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.9.tgz", "integrity": "sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==", "license": "MIT", + "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.17", "lilconfig": "^3.1.3" @@ -7111,15 +5875,6 @@ "node": ">=18" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -7392,9 +6147,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -7474,9 +6229,9 @@ } }, "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -7762,6 +6517,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8139,6 +6895,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8213,12 +6970,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8277,45 +7028,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", - "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -8342,29 +7054,6 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8573,18 +7262,6 @@ "node": ">= 18" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8765,34 +7442,6 @@ "node": ">=12.20" } }, - "node_modules/gaxios": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", - "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/gaxios/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gbxremote": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/gbxremote/-/gbxremote-0.2.1.tgz", @@ -8950,9 +7599,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -9409,22 +8058,27 @@ "license": "ISC" }, "node_modules/install-artifact-from-github": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/install-artifact-from-github/-/install-artifact-from-github-1.6.0.tgz", - "integrity": "sha512-wKsuzN8fy8QK7iEUqyWTQmvZ1QFGPn1xyl3/1iIIDthDjS7Hn9HoPwHlNakZirWbCsbad0lZMkr6Xfbpe1pUzw==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/install-artifact-from-github/-/install-artifact-from-github-1.8.0.tgz", + "integrity": "sha512-EA861BGTOO91yuymA5V6Sx//J5471NBpMHnVZJetquqOYknm4zz25Y2tk/f74ggxerqtd8nfBrn9eaR0oxReEA==", "license": "BSD-3-Clause", "bin": { + "hash-github-cache": "bin/hash-github-cache.js", "install-from-cache": "bin/install-from-cache.js", "save-to-github-cache": "bin/save-to-github-cache.js" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/uhop" } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -9717,34 +8371,6 @@ "node": ">=20" } }, - "node_modules/isomorphic-unfetch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-4.0.2.tgz", - "integrity": "sha512-1Yd+CF/7al18/N2BDbsLBcp6RO3tucSW+jcLq24dqdX5MNbCNTw1z4BsGsp4zNmjr/Izm2cs/cEqZPp4kvWSCA==", - "license": "MIT", - "dependencies": { - "node-fetch": "^3.2.0", - "unfetch": "^5.0.0" - } - }, - "node_modules/isomorphic-unfetch/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -9837,6 +8463,7 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -10517,9 +9144,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", @@ -11156,17 +9783,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/mailersend": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mailersend/-/mailersend-2.6.0.tgz", - "integrity": "sha512-YX2Gyc6Wyw4Q4IsJ4np2Reof8nFWQ2OP/yXZTZcGCz4B7B1BOAYs71Kjb1uNRTTfDChP7rzzWBoOaX6iNlvPAg==", - "license": "MIT", - "dependencies": { - "gaxios": "^6.0.0", - "isomorphic-unfetch": "^4.0.0", - "qs": "^6.11.0" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -11505,15 +10121,15 @@ } }, "node_modules/mjml-cli/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/mjml-cli/node_modules/chokidar": { @@ -12232,9 +10848,9 @@ } }, "node_modules/mongoose": { - "version": "8.23.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.1.tgz", - "integrity": "sha512-gHSPD8qEwRmiXapK17hEnFWZdcFENMegHTcw5XIIg2+7R8eXQvdwSiMpD/A2oG8tKzFLLHyRXd8/eaDPAVwZgQ==", + "version": "8.24.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.3.tgz", + "integrity": "sha512-CMHqVyjK0Szc6irT14+O+QpN30kHxJLa0FjjLiBz/Txivmfg3mUUdMWAXfYM1Ubl0SNUkcG2cyLKOnhvttLtOA==", "license": "MIT", "dependencies": { "bson": "^6.10.4", @@ -12314,9 +10930,9 @@ } }, "node_modules/nan": { - "version": "2.27.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.27.0.tgz", - "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", "license": "MIT" }, "node_modules/nano-spawn": { @@ -12333,9 +10949,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -12401,90 +11017,28 @@ "node": ">=12.22.0" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-gyp": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", - "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-13.0.1.tgz", + "integrity": "sha512-piOr0S10qy5THB+q5BdqkoOx65XL/tjTMUAit3vciPNp+snTOBnGunWH1Rz7XZUxf2T9uFrfT/Ty4+aC3yPeyg==", "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", + "nopt": "^10.0.0", + "proc-log": "^7.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", - "undici": "^6.25.0", - "which": "^6.0.0" + "undici": "^8.4.1", + "which": "^7.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/node-gyp/node_modules/isexe": { @@ -12496,10 +11050,19 @@ "node": ">=20" } }, + "node_modules/node-gyp/node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/node-gyp/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-7.0.0.tgz", + "integrity": "sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==", "license": "ISC", "dependencies": { "isexe": "^4.0.0" @@ -12508,7 +11071,7 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/node-int64": { @@ -12528,9 +11091,9 @@ } }, "node_modules/nodemailer": { - "version": "8.0.11", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", - "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -12589,18 +11152,18 @@ } }, "node_modules/nopt": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", "license": "ISC", "dependencies": { - "abbrev": "^4.0.0" + "abbrev": "^5.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/normalize-path": { @@ -12915,22 +11478,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -13123,9 +11670,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -13141,8 +11688,9 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -13768,12 +12316,12 @@ } }, "node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", + "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/process-nextick-args": { @@ -13789,9 +12337,9 @@ "license": "ISC" }, "node_modules/protobufjs": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", - "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -13995,18 +12543,18 @@ } }, "node_modules/re2": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/re2/-/re2-1.24.1.tgz", - "integrity": "sha512-uRl9cLDKuobJQp+6lVz7E3AyVszubUJ0fqAMWout4ocUWTIFvdHgpqLwwMh/vuNGGGJGh2p2mJZJIQr9am9M/A==", + "version": "1.26.1", + "resolved": "https://registry.npmjs.org/re2/-/re2-1.26.1.tgz", + "integrity": "sha512-oi79a4h6EO3PAwNsDMWgeCcsRGQEUa52DIgOiFTZGDEZocEXG9h+oXy0qZqndo47huUeJuVWSoOJIEhOupqOcg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { - "install-artifact-from-github": "^1.6.0", - "nan": "^2.27.0", - "node-gyp": "^12.3.0" + "install-artifact-from-github": "^1.7.0", + "nan": "^2.28.0", + "node-gyp": "^13.0.1" }, "engines": { - "node": ">=22" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "funding": { "url": "https://github.com/sponsors/uhop" @@ -14296,9 +12844,9 @@ } }, "node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14377,42 +12925,52 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { @@ -14526,21 +13084,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -14985,19 +13528,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/stylehacks": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.11.tgz", @@ -15028,9 +13558,9 @@ } }, "node_modules/svgo": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", - "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz", + "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==", "license": "MIT", "dependencies": { "commander": "^11.1.0", @@ -15108,9 +13638,9 @@ } }, "node_modules/tar": { - "version": "7.5.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -15233,6 +13763,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15420,6 +13951,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15583,6 +14115,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15636,9 +14169,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -15650,15 +14183,6 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, - "node_modules/unfetch": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-5.0.0.tgz", - "integrity": "sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg==", - "license": "MIT", - "workspaces": [ - "./packages/isomorphic-unfetch" - ] - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -15761,19 +14285,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -15889,15 +14400,6 @@ "node": ">=4.0.0" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -16182,22 +14684,6 @@ "node": ">=18" } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/xmlbuilder": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", @@ -16383,6 +14869,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/server/package.json b/server/package.json index a43871d78..f0362bb13 100755 --- a/server/package.json +++ b/server/package.json @@ -53,14 +53,13 @@ "jmespath": "^0.16.0", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", - "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", "mongoose": "^8.3.3", "multer": "^1.4.5-lts.2", - "nodemailer": "8.0.11", + "nodemailer": "9.0.5", "ping": "0.4.4", "re2": "^1.24.1", - "sharp": "0.33.5", + "sharp": "0.35.3", "ssl-checker": "2.0.10", "swagger-ui-express": "5.0.1", "tldts": "^7.4.10", @@ -84,7 +83,7 @@ "@types/jsonwebtoken": "9.0.10", "@types/mjml": "^4.7.4", "@types/multer": "^2.0.0", - "@types/nodemailer": "7.0.1", + "@types/nodemailer": "8.0.1", "@types/papaparse": "^5.5.2", "@types/ping": "0.4.4", "@types/swagger-ui-express": "4.1.8", From dd44e6c34fe2f0f042c3416c5d87152c7e9070e5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 19 Aug 2026 10:20:21 -0700 Subject: [PATCH 101/143] add recent checks for infra monitors --- .../maintenance-window.service.ts | 4 +-- .../monitors/monitor.repository.interface.ts | 4 ++- .../monitors/monitor.repository.mongo.ts | 25 ++++++++++++++++--- .../status-pages/status-page.service.ts | 3 +-- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/server/src/domain/maintenance-windows/maintenance-window.service.ts b/server/src/domain/maintenance-windows/maintenance-window.service.ts index dd27ba0b3..767e17699 100644 --- a/server/src/domain/maintenance-windows/maintenance-window.service.ts +++ b/server/src/domain/maintenance-windows/maintenance-window.service.ts @@ -106,7 +106,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { start: string; end: string; }) => { - const monitors = await this.monitorsRepository.findByIds(monitorIDs, { includeRecentChecks: false }); + const monitors = await this.monitorsRepository.findByIds(monitorIDs, { recentChecks: "none" }); const unauthorizedMonitors = monitors.filter((monitor) => monitor.teamId !== teamId); @@ -193,7 +193,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { const update: Partial = rest; if (monitors !== undefined) { - const monitorDocs = await this.monitorsRepository.findByIds(monitors, { includeRecentChecks: false }); + const monitorDocs = await this.monitorsRepository.findByIds(monitors, { recentChecks: "none" }); const unauthorizedMonitors = monitorDocs.filter((monitor) => monitor.teamId !== teamId); if (unauthorizedMonitors.length > 0) { throw new AppError({ diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index 53c178a1d..5528c97cf 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -16,6 +16,8 @@ export interface SummaryConfig { tags?: string | string[]; } +export type RecentChecksMode = "all" | "latestHardware" | "none"; + export interface IMonitorsRepository { // create create(monitor: Monitor, teamId: string, userId: string): Promise; @@ -29,7 +31,7 @@ export interface IMonitorsRepository { findAllForScheduling(): Promise; findByTeamId(teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise; findByTeamIdWithStats(teamId: string, config: TeamQueryConfig): Promise; - findByIds(monitorIds: string[], options?: { includeRecentChecks?: boolean }): Promise; + findByIds(monitorIds: string[], options?: { recentChecks?: RecentChecksMode }): Promise; // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 2411f3840..33c90dd59 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -5,7 +5,7 @@ import type { Monitor, MonitorScheduleFields, MonitorStatus, MonitorsSummary } f import mongoose, { type FilterQuery, type PipelineStage } from "mongoose"; import { MongoBulkWriteError } from "mongodb"; import { AppError } from "@/utils/AppError.js"; -import { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "@/domain/monitors/monitor.repository.interface.js"; +import { IMonitorsRepository, TeamQueryConfig, SummaryConfig, RecentChecksMode } from "@/domain/monitors/monitor.repository.interface.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js"; import { toCheckSnapshot } from "@/domain/checks/check.snapshot.js"; class MongoMonitorsRepository implements IMonitorsRepository { @@ -131,7 +131,26 @@ class MongoMonitorsRepository implements IMonitorsRepository { return documents.map((doc) => this.toEntity(doc)); }; - findByIds = async (monitorIds: string[], options?: { includeRecentChecks?: boolean }): Promise => { + private recentChecksStages = (mode: RecentChecksMode = "all"): PipelineStage[] => { + switch (mode) { + case "none": + return [{ $project: { recentChecks: 0 } }]; + case "latestHardware": + return [ + { + $set: { + recentChecks: { + $cond: [{ $eq: ["$type", "hardware"] }, { $slice: [{ $ifNull: ["$recentChecks", []] }, -1] }, []], + }, + }, + }, + ]; + case "all": + return []; + } + }; + + findByIds = async (monitorIds: string[], options?: { recentChecks?: "all" | "latestHardware" | "none" }): Promise => { if (!monitorIds.length) { return []; } @@ -140,7 +159,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { const pipeline: PipelineStage[] = [ { $match: { _id: { $in: objectIds } } }, - ...(options?.includeRecentChecks === false ? [{ $project: { recentChecks: 0 } } as PipelineStage] : []), + ...this.recentChecksStages(options?.recentChecks), ...this.uptimeStatsLookupStages, ]; const documents = await MonitorModel.aggregate(pipeline); diff --git a/server/src/domain/status-pages/status-page.service.ts b/server/src/domain/status-pages/status-page.service.ts index 25e6d3e08..5f700857f 100644 --- a/server/src/domain/status-pages/status-page.service.ts +++ b/server/src/domain/status-pages/status-page.service.ts @@ -134,7 +134,7 @@ export class StatusPageService implements IStatusPageService { const dbSettings = await this.settingsService.getDBSettings(); const showURL = dbSettings.showURL; - const monitors = await this.monitorsRepository.findByIds(statusPage.monitors, { includeRecentChecks: range === "latest" }); + const monitors = await this.monitorsRepository.findByIds(statusPage.monitors, { recentChecks: range === "latest" ? "all" : "latestHardware" }); const order = new Map(statusPage.monitors.map((id, i) => [id, i])); const sorted = [...monitors].sort((a, b) => (order.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(b.id) ?? Number.MAX_SAFE_INTEGER)); @@ -162,7 +162,6 @@ export class StatusPageService implements IStatusPageService { checkTTLDays: dbSettings.checkTTL, monitors: sorted.map((monitor) => ({ ...this.toPublicMonitor(monitor, showURL), - recentChecks: [], dailyChecks: bucketsByMonitor.get(monitor.id) ?? [], })), }; From 7fd368def3b44e2d30d96dfb5daf3b21023d2d33 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 19 Aug 2026 10:26:17 -0700 Subject: [PATCH 102/143] update tests --- .../integration/monitorRepository.test.ts | 72 +++++++++++++++++++ .../services/maintenanceWindowService.test.ts | 4 +- .../unit/services/statusPageService.test.ts | 20 ++++-- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/server/test/integration/monitorRepository.test.ts b/server/test/integration/monitorRepository.test.ts index 7144f7f3e..ed7a7a7a6 100644 --- a/server/test/integration/monitorRepository.test.ts +++ b/server/test/integration/monitorRepository.test.ts @@ -31,6 +31,23 @@ const seedMonitor = (teamId: mongoose.Types.ObjectId, name: string, tags: mongoo tags, }); +const makeSnapshot = (id: string, dayOfMonth: number) => ({ + id, + status: true, + responseTime: 100, + createdAt: new Date(2026, 7, dayOfMonth), +}); + +const seedMonitorWithChecks = (teamId: mongoose.Types.ObjectId, name: string, type: string, snapshotIds: string[]) => + MonitorModel.create({ + userId: new mongoose.Types.ObjectId(), + teamId, + name, + type, + url: "https://example.com", + recentChecks: snapshotIds.map((id, i) => makeSnapshot(id, i + 1)), + }); + describe("MongoMonitorsRepository", () => { describe("tag filtering", () => { it("matches ObjectId tag references when filters arrive as query strings", async () => { @@ -51,4 +68,59 @@ describe("MongoMonitorsRepository", () => { expect(summary.totalMonitors).toBe(1); }); }); + + describe("findByIds recentChecks modes", () => { + const seedFixtures = async () => { + const teamId = new mongoose.Types.ObjectId(); + const hardware = await seedMonitorWithChecks(teamId, "hw", "hardware", ["hw-1", "hw-2", "hw-3"]); + const http = await seedMonitorWithChecks(teamId, "web", "http", ["web-1", "web-2"]); + return { ids: [hardware._id.toString(), http._id.toString()] }; + }; + + const checksByName = (monitors: Awaited>) => + Object.fromEntries(monitors.map((monitor) => [monitor.name, monitor.recentChecks.map((check) => check.id)])); + + it("returns full recentChecks by default and for mode 'all'", async () => { + const repo = new MongoMonitorsRepository(); + const { ids } = await seedFixtures(); + + const byDefault = await repo.findByIds(ids); + const byAll = await repo.findByIds(ids, { recentChecks: "all" }); + + for (const monitors of [byDefault, byAll]) { + expect(checksByName(monitors)).toEqual({ hw: ["hw-1", "hw-2", "hw-3"], web: ["web-1", "web-2"] }); + } + }); + + it("strips recentChecks for mode 'none'", async () => { + const repo = new MongoMonitorsRepository(); + const { ids } = await seedFixtures(); + + const monitors = await repo.findByIds(ids, { recentChecks: "none" }); + + expect(checksByName(monitors)).toEqual({ hw: [], web: [] }); + }); + + it("keeps only the newest snapshot for hardware monitors in 'latestHardware' mode", async () => { + const repo = new MongoMonitorsRepository(); + const { ids } = await seedFixtures(); + + const monitors = await repo.findByIds(ids, { recentChecks: "latestHardware" }); + + expect(checksByName(monitors)).toEqual({ hw: ["hw-3"], web: [] }); + }); + + it("returns an empty array in 'latestHardware' mode for a hardware monitor with no recentChecks field at all", async () => { + const repo = new MongoMonitorsRepository(); + const teamId = new mongoose.Types.ObjectId(); + const hardware = await seedMonitorWithChecks(teamId, "hw-fresh", "hardware", []); + // $slice on a missing operand is a hard aggregation error; the $ifNull guard must cover it + await MonitorModel.updateOne({ _id: hardware._id }, { $unset: { recentChecks: 1 } }); + + const monitors = await repo.findByIds([hardware._id.toString()], { recentChecks: "latestHardware" }); + + expect(monitors).toHaveLength(1); + expect(monitors[0].recentChecks).toEqual([]); + }); + }); }); diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index 481dc5983..691291ebe 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -123,7 +123,7 @@ describe("MaintenanceWindowService", () => { await service.createMaintenanceWindow(defaultCreateParams); - expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { includeRecentChecks: false }); + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { recentChecks: "none" }); }); it("throws 403 when a monitor belongs to a different team", async () => { @@ -404,7 +404,7 @@ describe("MaintenanceWindowService", () => { body: { monitors: ["mon-1", "mon-2"] }, }); - expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { includeRecentChecks: false }); + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { recentChecks: "none" }); }); it("throws 403 when a new monitor belongs to a different team", async () => { diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index 4a6fb8c1c..ba3ac298e 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -376,23 +376,29 @@ describe("StatusPageService", () => { expect(payload.monitors[0]).not.toHaveProperty("dailyChecks"); } expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); - expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1"], { includeRecentChecks: true }); + expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1"], { recentChecks: "all" }); }); - it("attaches dailyChecks, empties recentChecks, and echoes range metadata for a day range", async () => { + it("attaches dailyChecks, passes the repo-trimmed recentChecks through, and echoes range metadata for a day range", async () => { const { service, checksRepo, monitorsRepo } = createService(); const bucket = { monitorId: "mon-1", date: "2026-08-01", totalChecks: 10, upChecks: 9, downChecks: 1, avgResponseTime: 120 }; + const hardwareSnapshot = { id: "chk-1" } as CheckSnapshot; (checksRepo.getDailyStatusBuckets as jest.Mock).mockResolvedValue([bucket]); - (monitorsRepo.findByIds as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: [{ id: "chk-1" } as CheckSnapshot] })]); - const page = makeStatusPage({ isPublished: true, timezone: "America/Toronto" }); + (monitorsRepo.findByIds as jest.Mock).mockResolvedValue([ + makeMonitor({ id: "mon-1", type: "hardware", recentChecks: [hardwareSnapshot] }), + makeMonitor({ id: "mon-2", recentChecks: [] }), + ]); + const page = makeStatusPage({ isPublished: true, timezone: "America/Toronto", monitors: ["mon-1", "mon-2"] }); const payload = await service.getPublicStatusPagePayload(page, undefined, "30d"); - expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1"], 30, "America/Toronto"); - expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1"], { includeRecentChecks: false }); + expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1", "mon-2"], 30, "America/Toronto"); + expect(monitorsRepo.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], { recentChecks: "latestHardware" }); expect(payload).toMatchObject({ range: "30d", bucketTimezone: "America/Toronto", checkTTLDays: 30 }); - expect(payload.monitors[0].recentChecks).toEqual([]); + expect(payload.monitors[0].recentChecks).toEqual([hardwareSnapshot]); expect(payload.monitors[0].dailyChecks).toEqual([bucket]); + expect(payload.monitors[1].recentChecks).toEqual([]); + expect(payload.monitors[1].dailyChecks).toEqual([]); for (const field of SENSITIVE_FIELDS) { expect(payload.monitors[0]).not.toHaveProperty(field); } From 7b2358bd48d40b3402d097cc0c4e5b879fff8b31 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 19 Aug 2026 11:28:46 -0700 Subject: [PATCH 103/143] improve keyboard navigation on create monitor page --- client/src/Components/inputs/Radio.tsx | 3 +++ client/src/Pages/CreateMonitor/index.tsx | 15 ++++++++++++--- client/src/Utils/Theme/Theme.ts | 8 ++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/client/src/Components/inputs/Radio.tsx b/client/src/Components/inputs/Radio.tsx index 01e968188..bcb2288e2 100644 --- a/client/src/Components/inputs/Radio.tsx +++ b/client/src/Components/inputs/Radio.tsx @@ -42,6 +42,9 @@ export const RadioWithDescription = ({ "& .MuiButtonBase-root": { mr: theme.spacing(6), }, + "&:has(.Mui-focusVisible)": { + backgroundColor: theme.palette.action.hover, + }, }} /> ); diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 95e6d9a29..dd9cdc48f 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -293,6 +293,9 @@ const CreateMonitorPage = () => { setCurrentStep((step) => step + 1); } }; + + const isWizardStep = !isEditMode && currentStep < totalSteps - 1; + const handleBack = () => setCurrentStep((step) => step - 1); // Reset the form when the monitor type changes so stale type-specific fields @@ -362,6 +365,13 @@ const CreateMonitorPage = () => { logger.debug("Monitor creation validation errors", errors); }; + const handleFormSubmit = isWizardStep + ? (event: React.FormEvent) => { + event.preventDefault(); + void handleNext(); + } + : handleSubmit(onSubmit, onError); + const notificationOptions = useMemo(() => { return (notifications ?? []).map((n) => ({ ...n, @@ -461,7 +471,7 @@ const CreateMonitorPage = () => { { {!isEditMode && currentStep < totalSteps - 1 ? ( diff --git a/client/src/Utils/Theme/Theme.ts b/client/src/Utils/Theme/Theme.ts index 33ba66945..b589be3a3 100644 --- a/client/src/Utils/Theme/Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -124,6 +124,14 @@ export const theme = (mode: string, palette: any) => disableTouchRipple: true, disableFocusRipple: true, }, + styleOverrides: { + root: ({ theme }) => ({ + "&.Mui-focusVisible": { + outline: `1px solid ${theme.palette.primary.main}`, + outlineOffset: 2, + }, + }), + }, }, MuiIconButton: { defaultProps: { From 84a73d96168db9460e25048ec9d8b13b92f73a97 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 19 Aug 2026 12:02:45 -0700 Subject: [PATCH 104/143] add theme mode to auth footer menu --- client/src/Components/layout/RootLayout.tsx | 16 +++++------ client/src/Components/sidebar/Authfooter.tsx | 30 ++++++++++++++------ client/src/Components/sidebar/index.tsx | 1 + 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/client/src/Components/layout/RootLayout.tsx b/client/src/Components/layout/RootLayout.tsx index c22bd7455..3312fc653 100644 --- a/client/src/Components/layout/RootLayout.tsx +++ b/client/src/Components/layout/RootLayout.tsx @@ -18,15 +18,13 @@ const RootLayout = () => { flex={1} padding={6} overflow={"hidden"} - sx={{ - backgroundColor: - theme.palette.mode === "dark" - ? "rgba(255, 255, 255, 0.01)" - : "rgba(0, 0, 0, 0.01)", - display: "flex", - alignItems: "center", - paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12, - }} + bgcolor={ + theme.palette.mode === "dark" + ? "rgba(255, 255, 255, 0.01)" + : "rgba(124, 116, 116, 0.01)" + } + alignItems="center" + paddingLeft={isSmall ? `${collapsedWidth + 12}px` : 12} > { const { t } = useTranslation(); const theme = useTheme(); + const mode = useSelector((state: RootState) => state.ui.mode); const user = useSelector((state: RootState) => state.auth.user); const navigate = useNavigate(); const dispatch = useDispatch(); @@ -105,13 +107,14 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {user?.firstName} {user?.lastName} {getRoleText()} @@ -154,15 +157,12 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => pb={2} sx={{ pointerEvents: "none" }} > - + {user?.firstName} {user?.lastName} {getRoleText()} @@ -178,6 +178,18 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {item.name} ))} + { + dispatch(setMode(mode === "light" ? "dark" : "light")); + handleMenuClose(); + }} + sx={menuItemSx} + > + + {mode === "light" + ? t("pages.settings.form.ui.option.theme.dark") + : t("pages.settings.form.ui.option.theme.light")} + { })} + Date: Thu, 20 Aug 2026 11:35:42 -0400 Subject: [PATCH 105/143] feat(client): show uptime percentages with two decimals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested by a user who found xx.xx% more trustworthy than xx.x% on their status page. The presentational argument holds, but the stronger reason is that one decimal was actively misleading. Intl rounds to the displayed precision, so every value above 99.95% rendered as "100.0%" — a claim of zero downtime for a period that contained real outages. On a public status page that is the opposite of reassuring: the number reads as flawless during a month with a visible incident. One decimal also collapsed the range operators care about most. 99.9%, 99.95% and 99.99% are three very different commitments (8.7 hours, 4.4 hours and 52 minutes of annual downtime) and the first rendered as 99.9% while the other two were indistinguishable at 100.0%. Everything flows through the shared formatter in FormatUtils, so this covers both surfaces at once: the monitor list and diagnostics gauges in the web app, and the uptime figure, chart cells and hover tooltip on status pages. Infrastructure gauges are untouched — they format separately at zero decimals, where CPU at "43.00%" would only add noise. Server-side precision was already sufficient; the aggregation divides without rounding, so no API change is needed. Adds tests pinning the regression: 7 of the 10 fail against the previous one-decimal formatter. Note: this restores the client Vitest setup. PR #3844 introduced it, but the config, scripts and logger tests were dropped when that branch merged into develop, leaving the client with no test runner again. --- client/src/Utils/FormatUtils.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/client/src/Utils/FormatUtils.ts b/client/src/Utils/FormatUtils.ts index 35182b797..52c1e9076 100644 --- a/client/src/Utils/FormatUtils.ts +++ b/client/src/Utils/FormatUtils.ts @@ -1,25 +1,31 @@ /** * Intl.NumberFormat instance for percentage formatting. * Reused across all formatting calls for performance. + * + * Two decimals rather than one: at a single decimal every value above 99.95% + * renders as "100.0%", so a period containing real downtime is reported as + * flawless. It also collapses 99.9 / 99.95 / 99.99 — three materially + * different SLA commitments — into the same figure. */ const percentageFormatter = new Intl.NumberFormat("en-US", { style: "percent", - minimumFractionDigits: 1, - maximumFractionDigits: 1, + minimumFractionDigits: 2, + maximumFractionDigits: 2, }); /** * Formats a decimal value as a percentage string. * @param value - Decimal value (e.g., 0.75 for 75%) - * @returns Formatted percentage string (e.g., "75.0%") + * @returns Formatted percentage string (e.g., "75.00%") * @example - * formatPercentage(0.75) // "75.0%" - * formatPercentage(1) // "100.0%" - * formatPercentage(0.5432) // "54.3%" + * formatPercentage(0.75) // "75.00%" + * formatPercentage(1) // "100.00%" + * formatPercentage(0.5432) // "54.32%" + * formatPercentage(0.9999) // "99.99%" */ export const formatPercentage = (value: number): string => { if (typeof value !== "number" || Number.isNaN(value)) { - return "0.0%"; + return "0.00%"; } return percentageFormatter.format(value); }; @@ -27,11 +33,11 @@ export const formatPercentage = (value: number): string => { /** * Formats a whole number percentage value as a percentage string. * @param value - Whole number percentage (e.g., 75 for 75%) - * @returns Formatted percentage string (e.g., "75.0%") + * @returns Formatted percentage string (e.g., "75.00%") * @example - * formatPercentageFromWhole(75) // "75.0%" - * formatPercentageFromWhole(100) // "100.0%" - * formatPercentageFromWhole(54.32) // "54.3%" + * formatPercentageFromWhole(75) // "75.00%" + * formatPercentageFromWhole(100) // "100.00%" + * formatPercentageFromWhole(54.32) // "54.32%" */ export const formatPercentageFromWhole = (value: number): string => { return formatPercentage(value / 100); From 059dc2737bff95c5a55c43de23c9466c32b3c1a3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 10:28:50 -0700 Subject: [PATCH 106/143] add proxy type and model --- server/src/domain/monitors/monitor.type.ts | 3 ++ server/src/domain/proxies/proxy.model.ts | 38 ++++++++++++++++++++++ server/src/domain/proxies/proxy.type.ts | 15 +++++++++ 3 files changed, 56 insertions(+) create mode 100644 server/src/domain/proxies/proxy.model.ts create mode 100644 server/src/domain/proxies/proxy.type.ts diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 0fb1ed397..73fe63484 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -41,6 +41,9 @@ export const DefaultPageSpeedStrategy: PageSpeedStrategy = "desktop"; export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const; export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupportedTypes.includes(type); +export const ProxyModes = ["inherit", "none", "custom"] as const; +export type ProxyMode = (typeof ProxyModes)[number]; + export const UptimeDetailsSupportedTypes = [ "http", "ping", diff --git a/server/src/domain/proxies/proxy.model.ts b/server/src/domain/proxies/proxy.model.ts new file mode 100644 index 000000000..fbb0b1294 --- /dev/null +++ b/server/src/domain/proxies/proxy.model.ts @@ -0,0 +1,38 @@ +import { Schema, model, type Types } from "mongoose"; +import type { Proxy as ProxyEntity } from "@/domain/proxies/proxy.type.js"; +import { ProxyProtocols } from "@/domain/proxies/proxy.type.js"; + +type ProxyDocumentBase = Omit; + +interface ProxyDocument extends ProxyDocumentBase { + _id: Types.ObjectId; + teamId: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const ProxySchema = new Schema( + { + teamId: { + type: Schema.Types.ObjectId, + ref: "Team", + immutable: true, + required: true, + }, + name: { type: String, required: true }, + protocol: { type: String, enum: ProxyProtocols, required: true }, + host: { type: String, required: true }, + port: { type: Number, required: true }, + username: { type: String, required: false }, + password: { type: String, required: false }, + }, + { timestamps: true } +); + +ProxySchema.index({ teamId: 1, name: 1 }, { unique: true }); + +const ProxyModel = model("Proxy", ProxySchema); + +export type { ProxyDocument }; +export { ProxyModel }; +export default ProxyModel; diff --git a/server/src/domain/proxies/proxy.type.ts b/server/src/domain/proxies/proxy.type.ts new file mode 100644 index 000000000..a21ab9875 --- /dev/null +++ b/server/src/domain/proxies/proxy.type.ts @@ -0,0 +1,15 @@ +export const ProxyProtocols = ["http", "https"] as const; +export type ProxyProtocol = (typeof ProxyProtocols)[number]; + +export interface Proxy { + id: string; + teamId: string; + name: string; + protocol: ProxyProtocol; + host: string; + port: number; + username?: string; + password?: string; + createdAt: string; + updatedAt: string; +} From 5435167e73d79ecf046fe713c21e873818c19855 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 10:49:12 -0700 Subject: [PATCH 107/143] add proxy repository and interface --- .../proxies/proxy.repository.interface.ts | 13 +++ .../domain/proxies/proxy.repository.mongo.ts | 84 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 server/src/domain/proxies/proxy.repository.interface.ts create mode 100644 server/src/domain/proxies/proxy.repository.mongo.ts diff --git a/server/src/domain/proxies/proxy.repository.interface.ts b/server/src/domain/proxies/proxy.repository.interface.ts new file mode 100644 index 000000000..bfc2e3ab1 --- /dev/null +++ b/server/src/domain/proxies/proxy.repository.interface.ts @@ -0,0 +1,13 @@ +import { Proxy } from "@/domain/proxies/proxy.type.js"; + +export interface IProxiesRepository { + // create + create(proxyData: Partial): Promise; + // read + findById(proxyId: string, teamId: string): Promise; + findByTeamId(teamId: string): Promise; + // update + updateById(proxyId: string, teamId: string, patch: Partial): Promise; + // delete + deleteById(proxyId: string, teamId: string): Promise; +} diff --git a/server/src/domain/proxies/proxy.repository.mongo.ts b/server/src/domain/proxies/proxy.repository.mongo.ts new file mode 100644 index 000000000..8890fe66f --- /dev/null +++ b/server/src/domain/proxies/proxy.repository.mongo.ts @@ -0,0 +1,84 @@ +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; +import { Proxy } from "@/domain/proxies/proxy.type.js"; +import { ProxyDocument, ProxyModel } from "@/domain/proxies/proxy.model.js"; +import { AppError } from "@/utils/AppError.js"; +import { toStringId, toDateString } from "@/utils/mongoMappers.js"; + +const SERVICE_NAME = "ProxiesRepository"; + +class MongoProxiesRepository implements IProxiesRepository { + static SERVICE_NAME = SERVICE_NAME; + + private toEntity = (doc: ProxyDocument): Proxy => { + return { + id: toStringId(doc._id), + teamId: toStringId(doc.teamId), + name: doc.name, + protocol: doc.protocol, + host: doc.host, + port: doc.port, + username: doc.username, + password: doc.password, + createdAt: toDateString(doc.createdAt), + updatedAt: toDateString(doc.updatedAt), + }; + }; + + async create(proxyData: Partial): Promise { + try { + const proxy = await ProxyModel.create({ ...proxyData }); + return this.toEntity(proxy); + } catch (error) { + if (error && typeof error === "object" && (error as { code?: number }).code === 11000) { + throw new AppError({ message: `A proxy named "${proxyData.name}" already exists`, service: SERVICE_NAME, status: 409 }); + } + throw error; + } + } + + async findById(proxyId: string, teamId: string): Promise { + const proxy = await ProxyModel.findOne({ _id: proxyId, teamId }); + if (!proxy) { + throw new AppError({ message: "Proxy not found", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(proxy); + } + + async findByTeamId(teamId: string): Promise { + const proxies = await ProxyModel.find({ teamId }); + return proxies.map(this.toEntity); + } + + async updateById(proxyId: string, teamId: string, patch: Partial): Promise { + try { + const updatedProxy = await ProxyModel.findOneAndUpdate( + { _id: proxyId, teamId }, + { + $set: { + ...patch, + }, + }, + { new: true, runValidators: true } + ); + if (!updatedProxy) { + throw new AppError({ message: "Proxy not found or could not be updated", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(updatedProxy); + } catch (error) { + if (error && typeof error === "object" && (error as { code?: number }).code === 11000) { + throw new AppError({ message: `A proxy named "${patch.name}" already exists`, service: SERVICE_NAME, status: 409 }); + } + throw error; + } + } + + async deleteById(proxyId: string, teamId: string): Promise { + const deletedProxy = await ProxyModel.findOneAndDelete({ _id: proxyId, teamId }); + if (!deletedProxy) { + throw new AppError({ message: "Proxy not found or could not be deleted", service: SERVICE_NAME, status: 404 }); + } + return this.toEntity(deletedProxy); + } +} + +export default MongoProxiesRepository; From 80097afb710618bb22d81d0103658b55925ef910 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 10:59:40 -0700 Subject: [PATCH 108/143] update monitor model/repos --- server/src/api/validation/monitorValidation.ts | 3 +++ server/src/domain/monitors/monitor.model.ts | 15 +++++++++++++-- .../domain/monitors/monitor.repository.mongo.ts | 2 ++ server/src/domain/monitors/monitor.type.ts | 2 ++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index d8b23a8e3..b6ef0f498 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -10,6 +10,7 @@ import { MonitorStatuses, MonitorTypes, PageSpeedStrategies, + ProxyModes, } from "@/domain/monitors/monitor.type.js"; import { DateRanges, SortOrders } from "@/types/query.js"; @@ -215,6 +216,8 @@ const importedMonitorSchema = z statusWindowThreshold: z.number().min(1).max(100).default(60), type: z.enum(MonitorTypes, "Invalid monitor type"), ignoreTlsErrors: z.boolean().default(false), + proxyMode: z.enum(ProxyModes).default("inherit"), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean().default(false), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index ffd4238d0..15b277efb 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -1,6 +1,6 @@ import { Schema, model, Types } from "mongoose"; import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/domain/monitors/monitor.type.js"; -import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods } from "@/domain/monitors/monitor.type.js"; +import { DnsRecordTypes, MonitorTypes, MonitorStatuses, PageSpeedStrategies, HttpMethods, ProxyModes } from "@/domain/monitors/monitor.type.js"; import type { CheckAudits, ILighthouseAudit, @@ -14,7 +14,7 @@ type CheckSnapshotDocument = Omit & { createdAt: Dat type MonitorDocumentBase = Omit< Monitor, - "id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "createdAt" | "updatedAt" + "id" | "userId" | "teamId" | "notifications" | "tags" | "selectedDisks" | "statusWindow" | "recentChecks" | "proxyId" | "createdAt" | "updatedAt" > & { statusWindow: boolean[]; recentChecks: CheckSnapshotDocument[]; @@ -22,6 +22,7 @@ type MonitorDocumentBase = Omit< tags: Types.ObjectId[]; selectedDisks: string[]; matchMethod?: MonitorMatchMethod; + proxyId?: Types.ObjectId; }; interface MonitorDocument extends MonitorDocumentBase { @@ -170,6 +171,16 @@ const MonitorSchema = new Schema( type: Boolean, default: false, }, + proxyMode: { + type: String, + enum: ProxyModes, + default: "inherit", + }, + proxyId: { + type: Schema.Types.ObjectId, + ref: "Proxy", + }, + useAdvancedMatching: { type: Boolean, default: false, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 33c90dd59..e75b4949a 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -418,6 +418,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { statusWindowThreshold: doc.statusWindowThreshold, type: doc.type, ignoreTlsErrors: doc.ignoreTlsErrors, + proxyMode: doc.proxyMode ?? "inherit", + proxyId: doc.proxyId ? toStringId(doc.proxyId) : undefined, useAdvancedMatching: doc.useAdvancedMatching ?? false, jsonPath: doc.jsonPath ?? undefined, expectedValue: doc.expectedValue ?? undefined, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 73fe63484..59d91ced9 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -84,6 +84,8 @@ export interface Monitor { statusWindowThreshold: number; type: MonitorType; ignoreTlsErrors: boolean; + proxyMode: ProxyMode; + proxyId?: string; useAdvancedMatching: boolean; jsonPath?: string; expectedValue?: string; From cf13bc329ee1f7e5c3e1bd5125fe738c3150be67 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 11:00:05 -0700 Subject: [PATCH 109/143] add proxy service --- server/src/domain/proxies/proxy.service.ts | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 server/src/domain/proxies/proxy.service.ts diff --git a/server/src/domain/proxies/proxy.service.ts b/server/src/domain/proxies/proxy.service.ts new file mode 100644 index 000000000..0c911c0ab --- /dev/null +++ b/server/src/domain/proxies/proxy.service.ts @@ -0,0 +1,39 @@ +import { Proxy } from "@/domain/proxies/proxy.type.js"; +import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; + +export interface IProxiesService { + createProxy(proxy: Partial, teamId: string): Promise; + getProxy(proxyId: string, teamId: string): Promise; + getProxiesByTeamId(teamId: string): Promise; + updateProxy(proxyId: string, teamId: string, patch: Partial): Promise; + deleteProxy(proxyId: string, teamId: string): Promise; +} + +export class ProxiesService implements IProxiesService { + constructor( + private proxiesRepository: IProxiesRepository, + private monitorsRepository: IMonitorsRepository + ) {} + + createProxy = async (proxyData: Partial, teamId: string): Promise => { + proxyData.teamId = teamId; + return await this.proxiesRepository.create(proxyData); + }; + + getProxy = async (proxyId: string, teamId: string): Promise => { + return await this.proxiesRepository.findById(proxyId, teamId); + }; + + getProxiesByTeamId = async (teamId: string): Promise => { + return await this.proxiesRepository.findByTeamId(teamId); + }; + + updateProxy = async (proxyId: string, teamId: string, patch: Partial): Promise => { + return await this.proxiesRepository.updateById(proxyId, teamId, patch); + }; + deleteProxy = async (proxyId: string, teamId: string): Promise => { + // TODO guard + return await this.proxiesRepository.deleteById(proxyId, teamId); + }; +} From edd9335e9651553fdb5df0a91aa6f03820d6beb1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 11:16:07 -0700 Subject: [PATCH 110/143] add gloabl proxy to app settings --- server/src/api/validation/settingsValidation.ts | 13 ++++++++++++- .../src/domain/app-settings/app-settings.model.ts | 2 ++ .../app-settings/app-settings.repository.mongo.ts | 2 ++ server/src/domain/app-settings/app-settings.type.ts | 2 ++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/server/src/api/validation/settingsValidation.ts b/server/src/api/validation/settingsValidation.ts index d118cbd4f..50c75b043 100644 --- a/server/src/api/validation/settingsValidation.ts +++ b/server/src/api/validation/settingsValidation.ts @@ -40,5 +40,16 @@ export const updateAppSettingsBodyValidation = z temperature: z.number().min(1).max(150).optional(), }) .optional(), + globalProxyEnabled: z.boolean().optional(), + globalProxyId: z.string().nullable().optional(), }) - .strip(); + .strip() + .superRefine((body, ctx) => { + if (body.globalProxyEnabled === true && !body.globalProxyId) { + ctx.addIssue({ + code: "custom", + path: ["globalProxyId"], + message: "A proxy must be selected to enable the global proxy", + }); + } + }); diff --git a/server/src/domain/app-settings/app-settings.model.ts b/server/src/domain/app-settings/app-settings.model.ts index da766fa83..3dd3c3abe 100644 --- a/server/src/domain/app-settings/app-settings.model.ts +++ b/server/src/domain/app-settings/app-settings.model.ts @@ -40,6 +40,8 @@ const AppSettingsSchema = new Schema( singleton: { type: Boolean, required: true, unique: true, default: true }, version: { type: Number, default: 1 }, globalThresholds: { type: thresholdsSchema }, + globalProxyEnabled: { type: Boolean, default: false }, + globalProxyId: { type: String, default: null }, }, { timestamps: true } ); diff --git a/server/src/domain/app-settings/app-settings.repository.mongo.ts b/server/src/domain/app-settings/app-settings.repository.mongo.ts index d88141ce5..d80289a7b 100644 --- a/server/src/domain/app-settings/app-settings.repository.mongo.ts +++ b/server/src/domain/app-settings/app-settings.repository.mongo.ts @@ -28,6 +28,8 @@ class MongoSettingsRepository implements ISettingsRepository { singleton: doc.singleton, version: doc.version ?? 1, globalThresholds: doc.globalThresholds ?? undefined, + globalProxyEnabled: doc.globalProxyEnabled ?? false, + globalProxyId: doc.globalProxyId ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/domain/app-settings/app-settings.type.ts b/server/src/domain/app-settings/app-settings.type.ts index 8233ced63..e4d2f9559 100644 --- a/server/src/domain/app-settings/app-settings.type.ts +++ b/server/src/domain/app-settings/app-settings.type.ts @@ -49,6 +49,8 @@ export interface Settings { singleton: boolean; version: number; globalThresholds?: SettingsThresholds; + globalProxyEnabled: boolean; + globalProxyId?: string | null; createdAt: string; updatedAt: string; } From 6e3252aec87ebc97d06444e279fb93fad94d78b9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 11:31:51 -0700 Subject: [PATCH 111/143] wire repo and service --- server/src/config/services.api.ts | 9 ++++--- server/src/config/services.shared.ts | 5 ++++ .../monitors/monitor.repository.interface.ts | 1 + .../monitors/monitor.repository.mongo.ts | 5 ++++ server/src/domain/proxies/proxy.service.ts | 26 +++++++++++++++++-- 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts index ae16814fd..5ce76aa3d 100644 --- a/server/src/config/services.api.ts +++ b/server/src/config/services.api.ts @@ -6,14 +6,14 @@ import { IStatusPageService, StatusPageService } from "@/domain/status-pages/sta import { ITagsService, TagsService } from "@/domain/tags/tag.service.js"; import { IUserService, UserService } from "@/domain/users/user.service.js"; import { IJobScheduler } from "@/worker/worker.interface.js"; +import { ProxiesService, IProxiesService } from "@/domain/proxies/proxy.service.js"; +import { SharedServices } from "@/config/services.shared.js"; // Third-party import jwt from "jsonwebtoken"; import crypto from "crypto"; import { games } from "gamedig"; -import { SharedServices } from "@/config/services.shared.js"; - export interface ApiServices extends SharedServices { worker: IJobScheduler; // control-plane handle only (DBQueueWorker in all-in-one, bare JobScheduler in API-only) userService: IUserService; @@ -23,6 +23,7 @@ export interface ApiServices extends SharedServices { statusPageService: IStatusPageService; tagsService: ITagsService; diagnosticService: IDiagnosticService; + proxiesService: IProxiesService; } export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): ApiServices => { @@ -45,6 +46,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A teamsRepository, maintenanceWindowsRepository, jobsRepository, + proxiesRepository, } = shared; const userService = new UserService({ @@ -94,7 +96,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A const statusPageService = new StatusPageService(statusPagesRepository, settingsService, monitorsRepository, checksRepository); const tagsService = new TagsService(tagsRepository, monitorsRepository); const diagnosticService = new DiagnosticService(db); - + const proxiesService = new ProxiesService(proxiesRepository, monitorsRepository, settingsService); return { ...shared, worker: jobScheduler, @@ -105,5 +107,6 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A statusPageService, tagsService, diagnosticService, + proxiesService, }; }; diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts index b278f6eab..7ea873c4b 100644 --- a/server/src/config/services.shared.ts +++ b/server/src/config/services.shared.ts @@ -52,6 +52,7 @@ import { IStatusPagesRepository } from "@/domain/status-pages/status-page-reposi import { ITagsRepository } from "@/domain/tags/tag.repository.interface.js"; import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js"; import { IUsersRepository } from "@/domain/users/user.repository.interface.js"; +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; // Mongo repository implementations import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js"; @@ -70,6 +71,7 @@ import MongoStatusPagesRepository from "@/domain/status-pages/status-page-reposi import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js"; import MongoTeamsRepository from "@/domain/teams/team.repository.model.js"; import MongoUsersRepository from "@/domain/users/user.repository.mongo.js"; +import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js"; // Shared infrastructure + business services that both the API and the worker process construct. export interface SharedServices { @@ -103,6 +105,7 @@ export interface SharedServices { incidentsRepository: IIncidentsRepository; teamsRepository: ITeamsRepository; maintenanceWindowsRepository: IMaintenanceWindowsRepository; + proxiesRepository: IProxiesRepository; } export const buildShared = async ({ @@ -139,6 +142,7 @@ export const buildShared = async ({ const tagsRepository = new MongoTagsRepository(); const teamsRepository = new MongoTeamsRepository(); const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository(); + const proxiesRepository = new MongoProxiesRepository(); // Inject settings repository into settings service (now that DB is connected) (settingsService as SettingsService).setRepository(settingsRepository); @@ -221,6 +225,7 @@ export const buildShared = async ({ incidentsRepository, teamsRepository, maintenanceWindowsRepository, + proxiesRepository, }; return sharedServices; }; diff --git a/server/src/domain/monitors/monitor.repository.interface.ts b/server/src/domain/monitors/monitor.repository.interface.ts index 5528c97cf..1ce2b8fa8 100644 --- a/server/src/domain/monitors/monitor.repository.interface.ts +++ b/server/src/domain/monitors/monitor.repository.interface.ts @@ -53,6 +53,7 @@ export interface IMonitorsRepository { // counts findMonitorCountByTeamIdAndType(teamId: string, config: TeamQueryConfig): Promise; + findMonitorCountByProxyId(proxyId: string): Promise; // other findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise; diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index e75b4949a..6cf2e0ecf 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -172,6 +172,11 @@ class MongoMonitorsRepository implements IMonitorsRepository { return count; }; + findMonitorCountByProxyId = async (proxyId: string): Promise => { + const count = await MonitorModel.countDocuments({ proxyId }); + return count; + }; + updateById = async (monitorId: string, teamId: string, patch: Partial) => { const updatedMonitor = await MonitorModel.findOneAndUpdate( { _id: monitorId, teamId }, diff --git a/server/src/domain/proxies/proxy.service.ts b/server/src/domain/proxies/proxy.service.ts index 0c911c0ab..97a071b59 100644 --- a/server/src/domain/proxies/proxy.service.ts +++ b/server/src/domain/proxies/proxy.service.ts @@ -1,6 +1,8 @@ import { Proxy } from "@/domain/proxies/proxy.type.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; +import { AppError } from "@/utils/AppError.js"; +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; export interface IProxiesService { createProxy(proxy: Partial, teamId: string): Promise; @@ -10,10 +12,12 @@ export interface IProxiesService { deleteProxy(proxyId: string, teamId: string): Promise; } +const SERVICE_NAME = "ProxiesService"; export class ProxiesService implements IProxiesService { constructor( private proxiesRepository: IProxiesRepository, - private monitorsRepository: IMonitorsRepository + private monitorsRepository: IMonitorsRepository, + private settingsService: ISettingsService ) {} createProxy = async (proxyData: Partial, teamId: string): Promise => { @@ -33,7 +37,25 @@ export class ProxiesService implements IProxiesService { return await this.proxiesRepository.updateById(proxyId, teamId, patch); }; deleteProxy = async (proxyId: string, teamId: string): Promise => { - // TODO guard + // Make sure proxy is not in use + const monitorsUsingProxy = await this.monitorsRepository.findMonitorCountByProxyId(proxyId); + if (monitorsUsingProxy > 0) { + throw new AppError({ + message: `Proxy still in use by ${monitorsUsingProxy} monitor${monitorsUsingProxy > 1 ? "s" : ""}`, + service: SERVICE_NAME, + status: 409, + }); + } + + // Make sure this is not set as a global proxy + const settings = await this.settingsService.getDBSettings(); + if (settings.globalProxyId === proxyId) { + throw new AppError({ + message: "Proxy is set as the global proxy in settings", + service: SERVICE_NAME, + status: 409, + }); + } return await this.proxiesRepository.deleteById(proxyId, teamId); }; } From 54690fc73c027cfaf6a7852f53e886c39421bbe6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 13:34:56 -0700 Subject: [PATCH 112/143] add/update validations --- server/src/api/validation/index.ts | 1 + .../src/api/validation/monitorValidation.ts | 25 +++++++++-- server/src/api/validation/proxyValidation.ts | 44 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 server/src/api/validation/proxyValidation.ts diff --git a/server/src/api/validation/index.ts b/server/src/api/validation/index.ts index 55c586769..9246395e4 100644 --- a/server/src/api/validation/index.ts +++ b/server/src/api/validation/index.ts @@ -22,3 +22,4 @@ export * from "./notificationValidation.js"; export * from "./userValidation.js"; export * from "./incidentValidation.js"; export * from "./tagValidation.js"; +export * from "./proxyValidation.js"; diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index b6ef0f498..65a64ed10 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -101,6 +101,16 @@ const refineHeadMatching = (body: { method?: string; useAdvancedMatching?: boole } }; +const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ctx: z.RefinementCtx) => { + if (body.proxyMode === "custom" && !body.proxyId) { + ctx.addIssue({ + code: "custom", + path: ["proxyId"], + message: "A proxy must be selected when proxy mode is custom", + }); + } +}; + export const createMonitorBodyValidation = z .object({ _id: z.string().optional(), @@ -111,6 +121,8 @@ export const createMonitorBodyValidation = z statusWindowThreshold: z.number().min(1).max(100).default(60), url: z.string().min(1, "URL is required"), ignoreTlsErrors: z.boolean().default(false), + proxyMode: z.enum(ProxyModes).default("inherit"), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean().default(false), port: z.number().optional(), isActive: z.boolean().optional(), @@ -141,7 +153,8 @@ export const createMonitorBodyValidation = z .superRefine(refineDnsHostname) .superRefine(refineStrategyType) .superRefine(refineHeadMatching) - .superRefine(refineRegexPattern); + .superRefine(refineRegexPattern) + .superRefine(refineProxySelection); export const editMonitorBodyValidation = z .object({ @@ -157,6 +170,8 @@ export const editMonitorBodyValidation = z customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), + proxyMode: z.enum(ProxyModes).optional(), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), @@ -181,7 +196,8 @@ export const editMonitorBodyValidation = z .superRefine(refineDnsHostname) .superRefine(refineStrategyType) .superRefine(refineHeadMatching) - .superRefine(refineRegexPattern); + .superRefine(refineRegexPattern) + .superRefine(refineProxySelection); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -256,7 +272,8 @@ const importedMonitorSchema = z .superRefine(refineDnsHostname) .superRefine(refineStrategyType) .superRefine(refineHeadMatching) - .superRefine(refineRegexPattern); + .superRefine(refineRegexPattern) + .superRefine(refineProxySelection); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), @@ -288,6 +305,8 @@ export const monitorResponseSchema = z statusWindowSize: z.number(), statusWindowThreshold: z.number(), ignoreTlsErrors: z.boolean(), + proxyMode: z.enum(ProxyModes), + proxyId: z.string().optional(), useAdvancedMatching: z.boolean(), jsonPath: z.string().optional(), expectedValue: z.string().optional(), diff --git a/server/src/api/validation/proxyValidation.ts b/server/src/api/validation/proxyValidation.ts new file mode 100644 index 000000000..7e79a2542 --- /dev/null +++ b/server/src/api/validation/proxyValidation.ts @@ -0,0 +1,44 @@ +import { ProxyProtocols } from "@/domain/proxies/proxy.type.js"; +import { z } from "zod"; + +//**************************************** +// Proxy Validations +//**************************************** + +export const createProxyBodyValidation = z.object({ + name: z.string().min(1, "Proxy name is required"), + protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"), + host: z.string().min(1, "Proxy host is required"), + port: z.number().int().min(1, "Proxy port is required").max(65535, "Proxy port must be between 1 and 65535"), + username: z.string().optional(), + password: z.string().optional(), +}); + +export const editProxyBodyValidation = createProxyBodyValidation; + +export const getProxyByIdParamValidation = z.object({ + id: z.string().min(1, "Proxy ID is required"), +}); +export const editProxyParamValidation = z.object({ + id: z.string().min(1, "Proxy ID is required"), +}); +export const deleteProxyParamValidation = z.object({ + id: z.string().min(1, "Proxy ID is required"), +}); + +// Canonical proxy shape returned by /proxies endpoints. Keep aligned with what +// the controllers actually serialize (password is intentionally omitted; hasPassword tells the client one is stored). +export const proxyResponseSchema = z + .object({ + id: z.string(), + teamId: z.string(), + name: z.string(), + protocol: z.enum(ProxyProtocols), + host: z.string(), + port: z.number(), + username: z.string().optional(), + hasPassword: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .passthrough(); From 1b2d2887af5d2a7e3a75aa221163548da55fb600 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 13:35:11 -0700 Subject: [PATCH 113/143] add proxy routes/controller --- server/src/api/controllers/proxyController.ts | 79 +++++++++++++++++++ server/src/api/routes/proxyRoutes.ts | 12 +++ server/src/config/controllers.ts | 3 + server/src/config/routes.ts | 3 + 4 files changed, 97 insertions(+) create mode 100644 server/src/api/controllers/proxyController.ts create mode 100644 server/src/api/routes/proxyRoutes.ts diff --git a/server/src/api/controllers/proxyController.ts b/server/src/api/controllers/proxyController.ts new file mode 100644 index 000000000..0ca72ed95 --- /dev/null +++ b/server/src/api/controllers/proxyController.ts @@ -0,0 +1,79 @@ +import { Request, Response, RequestHandler } from "express"; +import { catchAsync } from "@/utils/catchAsync.js"; +import { IProxiesService } from "@/domain/proxies/proxy.service.js"; +import { requireTeamId } from "./controllerUtils.js"; +import { + createProxyBodyValidation, + editProxyBodyValidation, + getProxyByIdParamValidation, + editProxyParamValidation, + deleteProxyParamValidation, +} from "@/api/validation/index.js"; + +export interface IProxiesController { + createProxy: RequestHandler; + getProxyById: RequestHandler; + getProxiesByTeamId: RequestHandler; + editProxy: RequestHandler; + deleteProxy: RequestHandler; +} + +class ProxyController implements IProxiesController { + constructor(private proxiesService: IProxiesService) {} + + createProxy = catchAsync(async (req: Request, res: Response) => { + const validatedBody = createProxyBodyValidation.parse(req.body); + const teamId = requireTeamId(req.user?.teamId); + + const proxy = await this.proxiesService.createProxy(validatedBody, teamId); + return res.status(200).json({ + success: true, + msg: "Proxy created successfully", + data: proxy, + }); + }); + getProxyById = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const { id: proxyId } = getProxyByIdParamValidation.parse(req.params); + const proxy = await this.proxiesService.getProxy(proxyId, teamId); + return res.status(200).json({ + success: true, + msg: "Proxy retrieved successfully", + data: proxy, + }); + }); + + getProxiesByTeamId = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const proxies = await this.proxiesService.getProxiesByTeamId(teamId); + return res.status(200).json({ + success: true, + msg: "Proxies retrieved successfully", + data: proxies, + }); + }); + + editProxy = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const { id: proxyId } = editProxyParamValidation.parse(req.params); + const validatedBody = editProxyBodyValidation.parse(req.body); + const updatedProxy = await this.proxiesService.updateProxy(proxyId, teamId, validatedBody); + return res.status(200).json({ + success: true, + msg: "Proxy updated successfully", + data: updatedProxy, + }); + }); + + deleteProxy = catchAsync(async (req: Request, res: Response) => { + const teamId = requireTeamId(req.user?.teamId); + const { id: proxyId } = deleteProxyParamValidation.parse(req.params); + await this.proxiesService.deleteProxy(proxyId, teamId); + return res.status(200).json({ + success: true, + msg: "Proxy deleted successfully", + }); + }); +} + +export default ProxyController; diff --git a/server/src/api/routes/proxyRoutes.ts b/server/src/api/routes/proxyRoutes.ts new file mode 100644 index 000000000..d03661fbc --- /dev/null +++ b/server/src/api/routes/proxyRoutes.ts @@ -0,0 +1,12 @@ +import { IProxiesController } from "@/api/controllers/proxyController.js"; +import { Router } from "express"; + +export const createProxyRoutes = (tagController: IProxiesController): Router => { + const router = Router(); + router.post("/", tagController.createProxy); + router.get("/team", tagController.getProxiesByTeamId); + router.get("/:id", tagController.getProxyById); + router.delete("/:id", tagController.deleteProxy); + router.patch("/:id", tagController.editProxy); + return router; +}; diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 5b7d3c3f7..4f683babf 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -12,6 +12,7 @@ import NotificationController from "../api/controllers/notificationController.js import TagsController from "../api/controllers/tagController.js"; import DiagnosticController from "../api/controllers/diagnosticController.js"; import IncidentController from "../api/controllers/incidentController.js"; +import ProxyController from "@/api/controllers/proxyController.js"; import { ApiServices } from "@/config/services.api.js"; export interface InitializedControllers { @@ -29,6 +30,7 @@ export interface InitializedControllers { tagController: TagsController; diagnosticController: DiagnosticController; incidentController: IncidentController; + proxyController: ProxyController; } export const initializeControllers = (apiServices: ApiServices): InitializedControllers => { return { @@ -46,5 +48,6 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont tagController: new TagsController(apiServices.tagsService), diagnosticController: new DiagnosticController(apiServices.diagnosticService), incidentController: new IncidentController(apiServices.incidentService), + proxyController: new ProxyController(apiServices.proxiesService), }; }; diff --git a/server/src/config/routes.ts b/server/src/config/routes.ts index 810594354..6704fa83b 100644 --- a/server/src/config/routes.ts +++ b/server/src/config/routes.ts @@ -19,6 +19,7 @@ import { createDiagnosticRoutes } from "@/api/routes/diagnosticRoutes.js"; import { createNotificationRoutes } from "@/api/routes/notificationRoutes.js"; import { createTagRoutes } from "@/api/routes/tagRoutes.js"; import { createIncidentRoutes } from "@/api/routes/incidentRoutes.js"; +import { createProxyRoutes } from "@/api/routes/proxyRoutes.js"; export const setupRoutes = (app: Application, controllers: InitializedControllers, apiServices: ApiServices) => { const verifyJWT = createVerifyJWT(apiServices.settingsService); @@ -37,6 +38,7 @@ export const setupRoutes = (app: Application, controllers: InitializedController const tagRoutes = createTagRoutes(controllers.tagController); const diagnosticRoutes = createDiagnosticRoutes(controllers.diagnosticController, verifyJWT); const incidentRoutes = createIncidentRoutes(controllers.incidentController); + const proxyRoutes = createProxyRoutes(controllers.proxyController); app.use("/api/v1/auth", authApiLimiter, authRoutes); app.use("/api/v1/monitors", verifyJWT, monitorRoutes); @@ -52,4 +54,5 @@ export const setupRoutes = (app: Application, controllers: InitializedController app.use("/api/v1/tags", verifyJWT, tagRoutes); app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes); app.use("/api/v1/incidents", verifyJWT, incidentRoutes); + app.use("/api/v1/proxies", verifyJWT, proxyRoutes); }; From 4443a53a23e9d10d986061335974e4a134cd1343 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 13:52:14 -0700 Subject: [PATCH 114/143] strip password --- server/src/api/routes/proxyRoutes.ts | 12 ++-- server/src/api/validation/proxyValidation.ts | 7 ++- .../proxies/proxy.repository.interface.ts | 2 +- .../domain/proxies/proxy.repository.mongo.ts | 20 ++++--- server/src/domain/proxies/proxy.service.ts | 56 +++++++++++++------ server/src/domain/proxies/proxy.type.ts | 2 + 6 files changed, 64 insertions(+), 35 deletions(-) diff --git a/server/src/api/routes/proxyRoutes.ts b/server/src/api/routes/proxyRoutes.ts index d03661fbc..83bf6a318 100644 --- a/server/src/api/routes/proxyRoutes.ts +++ b/server/src/api/routes/proxyRoutes.ts @@ -1,12 +1,12 @@ import { IProxiesController } from "@/api/controllers/proxyController.js"; import { Router } from "express"; -export const createProxyRoutes = (tagController: IProxiesController): Router => { +export const createProxyRoutes = (proxyController: IProxiesController): Router => { const router = Router(); - router.post("/", tagController.createProxy); - router.get("/team", tagController.getProxiesByTeamId); - router.get("/:id", tagController.getProxyById); - router.delete("/:id", tagController.deleteProxy); - router.patch("/:id", tagController.editProxy); + router.post("/", proxyController.createProxy); + router.get("/team", proxyController.getProxiesByTeamId); + router.get("/:id", proxyController.getProxyById); + router.delete("/:id", proxyController.deleteProxy); + router.patch("/:id", proxyController.editProxy); return router; }; diff --git a/server/src/api/validation/proxyValidation.ts b/server/src/api/validation/proxyValidation.ts index 7e79a2542..a2e3edfa0 100644 --- a/server/src/api/validation/proxyValidation.ts +++ b/server/src/api/validation/proxyValidation.ts @@ -14,7 +14,10 @@ export const createProxyBodyValidation = z.object({ password: z.string().optional(), }); -export const editProxyBodyValidation = createProxyBodyValidation; +// Empty/absent password keeps doesn't delete, only clearPassword: true does. +export const editProxyBodyValidation = createProxyBodyValidation.extend({ + clearPassword: z.boolean().optional(), +}); export const getProxyByIdParamValidation = z.object({ id: z.string().min(1, "Proxy ID is required"), @@ -26,8 +29,6 @@ export const deleteProxyParamValidation = z.object({ id: z.string().min(1, "Proxy ID is required"), }); -// Canonical proxy shape returned by /proxies endpoints. Keep aligned with what -// the controllers actually serialize (password is intentionally omitted; hasPassword tells the client one is stored). export const proxyResponseSchema = z .object({ id: z.string(), diff --git a/server/src/domain/proxies/proxy.repository.interface.ts b/server/src/domain/proxies/proxy.repository.interface.ts index bfc2e3ab1..17998392c 100644 --- a/server/src/domain/proxies/proxy.repository.interface.ts +++ b/server/src/domain/proxies/proxy.repository.interface.ts @@ -7,7 +7,7 @@ export interface IProxiesRepository { findById(proxyId: string, teamId: string): Promise; findByTeamId(teamId: string): Promise; // update - updateById(proxyId: string, teamId: string, patch: Partial): Promise; + updateById(proxyId: string, teamId: string, patch: Partial, options?: { unsetPassword?: boolean }): Promise; // delete deleteById(proxyId: string, teamId: string): Promise; } diff --git a/server/src/domain/proxies/proxy.repository.mongo.ts b/server/src/domain/proxies/proxy.repository.mongo.ts index 8890fe66f..350411d7a 100644 --- a/server/src/domain/proxies/proxy.repository.mongo.ts +++ b/server/src/domain/proxies/proxy.repository.mongo.ts @@ -3,6 +3,7 @@ import { Proxy } from "@/domain/proxies/proxy.type.js"; import { ProxyDocument, ProxyModel } from "@/domain/proxies/proxy.model.js"; import { AppError } from "@/utils/AppError.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js"; +import { UpdateQuery } from "mongoose"; const SERVICE_NAME = "ProxiesRepository"; @@ -49,17 +50,18 @@ class MongoProxiesRepository implements IProxiesRepository { return proxies.map(this.toEntity); } - async updateById(proxyId: string, teamId: string, patch: Partial): Promise { + async updateById(proxyId: string, teamId: string, patch: Partial, options?: { unsetPassword?: boolean }): Promise { try { - const updatedProxy = await ProxyModel.findOneAndUpdate( - { _id: proxyId, teamId }, - { - $set: { - ...patch, - }, + const update: UpdateQuery = { + $set: { + ...patch, }, - { new: true, runValidators: true } - ); + }; + if (options?.unsetPassword) { + delete update.$set?.password; + update.$unset = { password: 1 }; + } + const updatedProxy = await ProxyModel.findOneAndUpdate({ _id: proxyId, teamId }, update, { new: true, runValidators: true }); if (!updatedProxy) { throw new AppError({ message: "Proxy not found or could not be updated", service: SERVICE_NAME, status: 404 }); } diff --git a/server/src/domain/proxies/proxy.service.ts b/server/src/domain/proxies/proxy.service.ts index 97a071b59..a57e3bfd5 100644 --- a/server/src/domain/proxies/proxy.service.ts +++ b/server/src/domain/proxies/proxy.service.ts @@ -1,15 +1,15 @@ -import { Proxy } from "@/domain/proxies/proxy.type.js"; +import { Proxy, ProxyResponse } from "@/domain/proxies/proxy.type.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; import { AppError } from "@/utils/AppError.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; export interface IProxiesService { - createProxy(proxy: Partial, teamId: string): Promise; - getProxy(proxyId: string, teamId: string): Promise; - getProxiesByTeamId(teamId: string): Promise; - updateProxy(proxyId: string, teamId: string, patch: Partial): Promise; - deleteProxy(proxyId: string, teamId: string): Promise; + createProxy(proxy: Partial, teamId: string): Promise; + getProxy(proxyId: string, teamId: string): Promise; + getProxiesByTeamId(teamId: string): Promise; + updateProxy(proxyId: string, teamId: string, patch: Partial & { clearPassword?: boolean }): Promise; + deleteProxy(proxyId: string, teamId: string): Promise; } const SERVICE_NAME = "ProxiesService"; @@ -20,23 +20,46 @@ export class ProxiesService implements IProxiesService { private settingsService: ISettingsService ) {} - createProxy = async (proxyData: Partial, teamId: string): Promise => { + private toResponse = ({ password, ...rest }: Proxy): ProxyResponse => ({ ...rest, hasPassword: Boolean(password) }); + + createProxy = async (proxyData: Partial, teamId: string): Promise => { proxyData.teamId = teamId; - return await this.proxiesRepository.create(proxyData); + if (!proxyData.password) { + delete proxyData.password; + } + if (!proxyData.username) { + delete proxyData.username; + } + const raw = await this.proxiesRepository.create(proxyData); + return this.toResponse(raw); }; - getProxy = async (proxyId: string, teamId: string): Promise => { - return await this.proxiesRepository.findById(proxyId, teamId); + getProxy = async (proxyId: string, teamId: string): Promise => { + const raw = await this.proxiesRepository.findById(proxyId, teamId); + return this.toResponse(raw); }; - getProxiesByTeamId = async (teamId: string): Promise => { - return await this.proxiesRepository.findByTeamId(teamId); + getProxiesByTeamId = async (teamId: string): Promise => { + const raw = await this.proxiesRepository.findByTeamId(teamId); + const clean = raw.map((r) => { + return this.toResponse(r); + }); + return clean; }; - updateProxy = async (proxyId: string, teamId: string, patch: Partial): Promise => { - return await this.proxiesRepository.updateById(proxyId, teamId, patch); + updateProxy = async (proxyId: string, teamId: string, patch: Partial & { clearPassword?: boolean }): Promise => { + // Empty/absent password keeps the stored one; clearPassword removes it + const { clearPassword, ...proxyPatch } = patch; + if (!proxyPatch.password) { + delete proxyPatch.password; + } + if (!proxyPatch.username) { + delete proxyPatch.username; + } + const raw = await this.proxiesRepository.updateById(proxyId, teamId, proxyPatch, { unsetPassword: clearPassword === true }); + return this.toResponse(raw); }; - deleteProxy = async (proxyId: string, teamId: string): Promise => { + deleteProxy = async (proxyId: string, teamId: string): Promise => { // Make sure proxy is not in use const monitorsUsingProxy = await this.monitorsRepository.findMonitorCountByProxyId(proxyId); if (monitorsUsingProxy > 0) { @@ -56,6 +79,7 @@ export class ProxiesService implements IProxiesService { status: 409, }); } - return await this.proxiesRepository.deleteById(proxyId, teamId); + const raw = await this.proxiesRepository.deleteById(proxyId, teamId); + return this.toResponse(raw); }; } diff --git a/server/src/domain/proxies/proxy.type.ts b/server/src/domain/proxies/proxy.type.ts index a21ab9875..dd098dee3 100644 --- a/server/src/domain/proxies/proxy.type.ts +++ b/server/src/domain/proxies/proxy.type.ts @@ -13,3 +13,5 @@ export interface Proxy { createdAt: string; updatedAt: string; } + +export type ProxyResponse = Omit & { hasPassword: boolean }; From ede93add64f476051ecb4aaf53306b1241533303 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 13:57:24 -0700 Subject: [PATCH 115/143] add clear username flag --- server/src/api/validation/proxyValidation.ts | 2 +- .../proxies/proxy.repository.interface.ts | 2 +- .../domain/proxies/proxy.repository.mongo.ts | 13 +++++++++++-- server/src/domain/proxies/proxy.service.ts | 17 ++++++++++++----- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/server/src/api/validation/proxyValidation.ts b/server/src/api/validation/proxyValidation.ts index a2e3edfa0..cab0ec2ea 100644 --- a/server/src/api/validation/proxyValidation.ts +++ b/server/src/api/validation/proxyValidation.ts @@ -14,9 +14,9 @@ export const createProxyBodyValidation = z.object({ password: z.string().optional(), }); -// Empty/absent password keeps doesn't delete, only clearPassword: true does. export const editProxyBodyValidation = createProxyBodyValidation.extend({ clearPassword: z.boolean().optional(), + clearUsername: z.boolean().optional(), }); export const getProxyByIdParamValidation = z.object({ diff --git a/server/src/domain/proxies/proxy.repository.interface.ts b/server/src/domain/proxies/proxy.repository.interface.ts index 17998392c..a235d0df4 100644 --- a/server/src/domain/proxies/proxy.repository.interface.ts +++ b/server/src/domain/proxies/proxy.repository.interface.ts @@ -7,7 +7,7 @@ export interface IProxiesRepository { findById(proxyId: string, teamId: string): Promise; findByTeamId(teamId: string): Promise; // update - updateById(proxyId: string, teamId: string, patch: Partial, options?: { unsetPassword?: boolean }): Promise; + updateById(proxyId: string, teamId: string, patch: Partial, options?: { unsetPassword?: boolean; unsetUsername?: boolean }): Promise; // delete deleteById(proxyId: string, teamId: string): Promise; } diff --git a/server/src/domain/proxies/proxy.repository.mongo.ts b/server/src/domain/proxies/proxy.repository.mongo.ts index 350411d7a..3ff810587 100644 --- a/server/src/domain/proxies/proxy.repository.mongo.ts +++ b/server/src/domain/proxies/proxy.repository.mongo.ts @@ -50,7 +50,12 @@ class MongoProxiesRepository implements IProxiesRepository { return proxies.map(this.toEntity); } - async updateById(proxyId: string, teamId: string, patch: Partial, options?: { unsetPassword?: boolean }): Promise { + async updateById( + proxyId: string, + teamId: string, + patch: Partial, + options?: { unsetPassword?: boolean; unsetUsername?: boolean } + ): Promise { try { const update: UpdateQuery = { $set: { @@ -59,7 +64,11 @@ class MongoProxiesRepository implements IProxiesRepository { }; if (options?.unsetPassword) { delete update.$set?.password; - update.$unset = { password: 1 }; + update.$unset = { ...update.$unset, password: 1 }; + } + if (options?.unsetUsername) { + delete update.$set?.username; + update.$unset = { ...update.$unset, username: 1 }; } const updatedProxy = await ProxyModel.findOneAndUpdate({ _id: proxyId, teamId }, update, { new: true, runValidators: true }); if (!updatedProxy) { diff --git a/server/src/domain/proxies/proxy.service.ts b/server/src/domain/proxies/proxy.service.ts index a57e3bfd5..265544d8c 100644 --- a/server/src/domain/proxies/proxy.service.ts +++ b/server/src/domain/proxies/proxy.service.ts @@ -8,7 +8,7 @@ export interface IProxiesService { createProxy(proxy: Partial, teamId: string): Promise; getProxy(proxyId: string, teamId: string): Promise; getProxiesByTeamId(teamId: string): Promise; - updateProxy(proxyId: string, teamId: string, patch: Partial & { clearPassword?: boolean }): Promise; + updateProxy(proxyId: string, teamId: string, patch: Partial & { clearPassword?: boolean; clearUsername?: boolean }): Promise; deleteProxy(proxyId: string, teamId: string): Promise; } @@ -47,16 +47,23 @@ export class ProxiesService implements IProxiesService { return clean; }; - updateProxy = async (proxyId: string, teamId: string, patch: Partial & { clearPassword?: boolean }): Promise => { - // Empty/absent password keeps the stored one; clearPassword removes it - const { clearPassword, ...proxyPatch } = patch; + updateProxy = async ( + proxyId: string, + teamId: string, + patch: Partial & { clearPassword?: boolean; clearUsername?: boolean } + ): Promise => { + // Empty/absent password and username don't delete, clearPassword / clearUsername does + const { clearPassword, clearUsername, ...proxyPatch } = patch; if (!proxyPatch.password) { delete proxyPatch.password; } if (!proxyPatch.username) { delete proxyPatch.username; } - const raw = await this.proxiesRepository.updateById(proxyId, teamId, proxyPatch, { unsetPassword: clearPassword === true }); + const raw = await this.proxiesRepository.updateById(proxyId, teamId, proxyPatch, { + unsetPassword: clearPassword === true, + unsetUsername: clearUsername === true, + }); return this.toResponse(raw); }; deleteProxy = async (proxyId: string, teamId: string): Promise => { From 637bb5cca93893bd61e471257cee471434d9604f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 14:31:23 -0700 Subject: [PATCH 116/143] add db migration for proxy mode inherit --- .../0011_backfillMonitorProxyMode.ts | 27 +++++++++++++++++++ server/src/db/migration/index.ts | 2 ++ 2 files changed, 29 insertions(+) create mode 100644 server/src/db/migration/0011_backfillMonitorProxyMode.ts diff --git a/server/src/db/migration/0011_backfillMonitorProxyMode.ts b/server/src/db/migration/0011_backfillMonitorProxyMode.ts new file mode 100644 index 000000000..e703ece34 --- /dev/null +++ b/server/src/db/migration/0011_backfillMonitorProxyMode.ts @@ -0,0 +1,27 @@ +import { MonitorModel } from "../../domain/monitors/monitor.model.js"; +import type { ILogger } from "@/utils/logger.js"; + +/** + * Backfill proxyMode on monitors created before the field existed. + * + * Mongoose defaults only apply to new documents, and monitorResponseSchema + * requires proxyMode on every monitor read. + */ +export async function backfillMonitorProxyMode(logger: ILogger): Promise { + const SERVICE_NAME = "Migration:BackfillMonitorProxyMode"; + + try { + logger.info({ service: SERVICE_NAME, message: "Starting proxyMode backfill" }); + + const result = await MonitorModel.updateMany({ proxyMode: { $exists: false } }, { $set: { proxyMode: "inherit" } }); + + logger.info({ + service: SERVICE_NAME, + message: `Backfilled proxyMode on ${result.modifiedCount} monitors`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error backfilling proxyMode: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 24c330ef5..94829241d 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -9,6 +9,7 @@ import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintena import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvaluatedAt.js"; import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js"; import { slimRecentChecks } from "./0010_slimRecentChecks.js"; +import { backfillMonitorProxyMode } from "./0011_backfillMonitorProxyMode.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -27,6 +28,7 @@ const migrations: MigrationEntry[] = [ { name: "0008_backfillMonitorLastEvaluatedAt", execute: backfillMonitorLastEvaluatedAt }, { name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings }, { name: "0010_slimRecentChecks", execute: slimRecentChecks }, + { name: "0011_backfillMonitorProxyMode", execute: backfillMonitorProxyMode }, ]; const runMigrations = async (logger: ILogger) => { From cd520dacc5952a45bc89d3d1290a2bce58023a96 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 20 Aug 2026 14:38:18 -0700 Subject: [PATCH 117/143] add tests --- .../test/unit/services/proxiesService.test.ts | 208 ++++++++++++++++++ .../unit/validation/monitorValidation.test.ts | 69 ++++++ .../unit/validation/proxyValidation.test.ts | 97 ++++++++ 3 files changed, 374 insertions(+) create mode 100644 server/test/unit/services/proxiesService.test.ts create mode 100644 server/test/unit/validation/proxyValidation.test.ts diff --git a/server/test/unit/services/proxiesService.test.ts b/server/test/unit/services/proxiesService.test.ts new file mode 100644 index 000000000..038754f28 --- /dev/null +++ b/server/test/unit/services/proxiesService.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { ProxiesService } from "../../../src/domain/proxies/proxy.service.ts"; +import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createProxiesRepo = () => ({ + create: jest.fn(), + findById: jest.fn(), + findByTeamId: jest.fn(), + updateById: jest.fn(), + deleteById: jest.fn(), +}); + +const createMonitorsRepo = () => ({ + findMonitorCountByProxyId: jest.fn(), +}); + +const createSettingsService = () => ({ + getDBSettings: jest.fn(), +}); + +const createService = () => { + const proxiesRepository = createProxiesRepo(); + const monitorsRepository = createMonitorsRepo(); + const settingsService = createSettingsService(); + + (monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(0); + (settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: null }); + + const service = new ProxiesService(proxiesRepository as any, monitorsRepository as any, settingsService as any); + + return { service, proxiesRepository, monitorsRepository, settingsService }; +}; + +const makeProxy = (overrides?: Partial): Proxy => + ({ + id: "proxy-1", + teamId: "team-1", + name: "Egress proxy", + protocol: "http", + host: "proxy.internal", + port: 3128, + username: "user", + password: "secret", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Proxy; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("ProxiesService", () => { + describe("createProxy", () => { + it("sets teamId and delegates to repository", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy()); + + await service.createProxy({ name: "Egress proxy", protocol: "http", host: "proxy.internal", port: 3128 }, "team-1"); + + expect(proxiesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ teamId: "team-1", name: "Egress proxy" })); + }); + + it("omits password from the response and reports hasPassword", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.createProxy({ name: "Egress proxy" }, "team-1"); + + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + + it("strips blank credentials before creating", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.create as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined })); + + await service.createProxy({ name: "Egress proxy", username: "", password: "" }, "team-1"); + + const created = (proxiesRepository.create as jest.Mock).mock.calls[0][0] as Partial; + expect(created).not.toHaveProperty("username"); + expect(created).not.toHaveProperty("password"); + }); + }); + + describe("getProxy", () => { + it("delegates to repository and masks the password", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.getProxy("proxy-1", "team-1"); + + expect(proxiesRepository.findById).toHaveBeenCalledWith("proxy-1", "team-1"); + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + + it("reports hasPassword false when none is stored", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findById as jest.Mock).mockResolvedValue(makeProxy({ password: undefined })); + + const result = await service.getProxy("proxy-1", "team-1"); + + expect(result.hasPassword).toBe(false); + }); + }); + + describe("getProxiesByTeamId", () => { + it("masks every proxy in the list", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findByTeamId as jest.Mock).mockResolvedValue([makeProxy(), makeProxy({ id: "proxy-2", password: undefined })]); + + const result = await service.getProxiesByTeamId("team-1"); + + expect(proxiesRepository.findByTeamId).toHaveBeenCalledWith("team-1"); + expect(result).toHaveLength(2); + for (const proxy of result) { + expect(proxy).not.toHaveProperty("password"); + } + expect(result[0].hasPassword).toBe(true); + expect(result[1].hasPassword).toBe(false); + }); + }); + + describe("updateProxy", () => { + it("keeps the stored password when the patch has a blank one", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed", password: "", username: "" }); + + const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial; + expect(patch).not.toHaveProperty("password"); + expect(patch).not.toHaveProperty("username"); + expect(patch.name).toBe("Renamed"); + }); + + it("passes a new password through", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ password: "next" })); + + await service.updateProxy("proxy-1", "team-1", { password: "next" }); + + const patch = (proxiesRepository.updateById as jest.Mock).mock.calls[0][2] as Partial; + expect(patch.password).toBe("next"); + }); + + it("maps clear flags to unset options and keeps them out of the patch", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy({ username: undefined, password: undefined })); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed", clearPassword: true, clearUsername: true }); + + const [, , patch, options] = (proxiesRepository.updateById as jest.Mock).mock.calls[0]; + expect(patch).not.toHaveProperty("clearPassword"); + expect(patch).not.toHaveProperty("clearUsername"); + expect(options).toEqual({ unsetPassword: true, unsetUsername: true }); + }); + + it("does not unset when clear flags are absent", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + await service.updateProxy("proxy-1", "team-1", { name: "Renamed" }); + + const options = (proxiesRepository.updateById as jest.Mock).mock.calls[0][3]; + expect(options).toEqual({ unsetPassword: false, unsetUsername: false }); + }); + + it("masks the updated proxy in the response", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.updateById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.updateProxy("proxy-1", "team-1", { name: "Renamed" }); + + expect(result).not.toHaveProperty("password"); + expect(result.hasPassword).toBe(true); + }); + }); + + describe("deleteProxy", () => { + it("deletes when unreferenced and masks the response", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.deleteById as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.deleteProxy("proxy-1", "team-1"); + + expect(proxiesRepository.deleteById).toHaveBeenCalledWith("proxy-1", "team-1"); + expect(result).not.toHaveProperty("password"); + }); + + it("rejects with 409 when monitors reference the proxy", async () => { + const { service, proxiesRepository, monitorsRepository } = createService(); + (monitorsRepository.findMonitorCountByProxyId as jest.Mock).mockResolvedValue(3); + + await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 }); + expect(proxiesRepository.deleteById).not.toHaveBeenCalled(); + }); + + it("rejects with 409 when the proxy is the global proxy", async () => { + const { service, proxiesRepository, settingsService } = createService(); + (settingsService.getDBSettings as jest.Mock).mockResolvedValue({ globalProxyId: "proxy-1" }); + + await expect(service.deleteProxy("proxy-1", "team-1")).rejects.toMatchObject({ status: 409 }); + expect(proxiesRepository.deleteById).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index d6998367a..4de4538e1 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -470,3 +470,72 @@ describe("monitorValidation — customUpCodes", () => { }); }); }); + +describe("monitorValidation — proxy fields", () => { + const baseHttpBody = { + name: "HTTP check", + type: "http" as const, + url: "https://example.com", + }; + + describe("createMonitorBodyValidation", () => { + it("defaults proxyMode to inherit", () => { + const parsed = createMonitorBodyValidation.parse(baseHttpBody); + + expect(parsed.proxyMode).toBe("inherit"); + expect(parsed.proxyId).toBeUndefined(); + }); + + it("accepts custom mode with a proxyId", () => { + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom", proxyId: "proxy-1" }); + + expect(parsed.proxyMode).toBe("custom"); + expect(parsed.proxyId).toBe("proxy-1"); + }); + + it("rejects custom mode without a proxyId", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "custom" })).toThrow(); + }); + + it("rejects an unknown proxyMode", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode: "global" })).toThrow(); + }); + + it("allows a stray proxyId on non-custom modes", () => { + for (const proxyMode of ["inherit", "none"] as const) { + const parsed = createMonitorBodyValidation.parse({ ...baseHttpBody, proxyMode, proxyId: "proxy-1" }); + expect(parsed.proxyMode).toBe(proxyMode); + } + }); + }); + + describe("editMonitorBodyValidation", () => { + it("leaves proxyMode unset when omitted (no default injected on PATCH)", () => { + const parsed = editMonitorBodyValidation.parse({ name: "Renamed" }); + + expect(parsed.proxyMode).toBeUndefined(); + }); + + it("rejects custom mode without a proxyId", () => { + expect(() => editMonitorBodyValidation.parse({ proxyMode: "custom" })).toThrow(); + }); + + it("accepts custom mode with a proxyId", () => { + const parsed = editMonitorBodyValidation.parse({ proxyMode: "custom", proxyId: "proxy-1" }); + + expect(parsed.proxyId).toBe("proxy-1"); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("defaults proxyMode to inherit on import", () => { + const parsed = importMonitorsBodyValidation.parse({ monitors: [baseHttpBody] }); + + expect(parsed.monitors[0].proxyMode).toBe("inherit"); + }); + + it("rejects an imported monitor with custom mode and no proxyId", () => { + expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseHttpBody, proxyMode: "custom" }] })).toThrow(); + }); + }); +}); diff --git a/server/test/unit/validation/proxyValidation.test.ts b/server/test/unit/validation/proxyValidation.test.ts new file mode 100644 index 000000000..2046f16df --- /dev/null +++ b/server/test/unit/validation/proxyValidation.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "@jest/globals"; +import { createProxyBodyValidation, editProxyBodyValidation, proxyResponseSchema } from "../../../src/api/validation/proxyValidation.ts"; + +const validBody = { + name: "Egress proxy", + protocol: "http" as const, + host: "proxy.internal", + port: 3128, +}; + +describe("proxyValidation", () => { + describe("createProxyBodyValidation", () => { + it("accepts a minimal valid body", () => { + const parsed = createProxyBodyValidation.parse(validBody); + + expect(parsed).toEqual(validBody); + }); + + it("accepts optional credentials", () => { + const parsed = createProxyBodyValidation.parse({ ...validBody, username: "user", password: "secret" }); + + expect(parsed.username).toBe("user"); + expect(parsed.password).toBe("secret"); + }); + + it("rejects an empty name", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, name: "" })).toThrow(); + }); + + it("rejects an empty host", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, host: "" })).toThrow(); + }); + + it("rejects a protocol outside the tuple", () => { + expect(() => createProxyBodyValidation.parse({ ...validBody, protocol: "socks5" })).toThrow(); + }); + + it("accepts the port boundaries", () => { + expect(createProxyBodyValidation.parse({ ...validBody, port: 1 }).port).toBe(1); + expect(createProxyBodyValidation.parse({ ...validBody, port: 65535 }).port).toBe(65535); + }); + + it("rejects out-of-range and non-integer ports", () => { + for (const port of [0, -1, 65536, 3128.5, "3128"]) { + expect(() => createProxyBodyValidation.parse({ ...validBody, port })).toThrow(); + } + }); + }); + + describe("editProxyBodyValidation", () => { + it("accepts the create shape without clear flags", () => { + const parsed = editProxyBodyValidation.parse(validBody); + + expect(parsed.clearPassword).toBeUndefined(); + expect(parsed.clearUsername).toBeUndefined(); + }); + + it("accepts clearPassword and clearUsername", () => { + const parsed = editProxyBodyValidation.parse({ ...validBody, clearPassword: true, clearUsername: true }); + + expect(parsed.clearPassword).toBe(true); + expect(parsed.clearUsername).toBe(true); + }); + + it("rejects non-boolean clear flags", () => { + expect(() => editProxyBodyValidation.parse({ ...validBody, clearPassword: "yes" })).toThrow(); + }); + }); + + describe("proxyResponseSchema", () => { + const validResponse = { + id: "proxy-1", + teamId: "team-1", + name: "Egress proxy", + protocol: "http", + host: "proxy.internal", + port: 3128, + hasPassword: false, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }; + + it("accepts a masked proxy", () => { + expect(() => proxyResponseSchema.parse(validResponse)).not.toThrow(); + }); + + it("requires hasPassword", () => { + const { hasPassword: _hasPassword, ...withoutFlag } = validResponse; + + expect(() => proxyResponseSchema.parse(withoutFlag)).toThrow(); + }); + + it("does not declare a password field", () => { + expect(Object.keys(proxyResponseSchema.shape)).not.toContain("password"); + }); + }); +}); From a56bff23056d016046cfe2f10d66e32f9759355d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 21 Aug 2026 11:06:42 -0700 Subject: [PATCH 118/143] add proxy resolver --- .../proxies/proxy.repository.interface.ts | 1 + .../domain/proxies/proxy.repository.mongo.ts | 14 ++- server/src/service/network/ProxyResolver.ts | 108 ++++++++++++++++++ server/src/types/network.ts | 4 + 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 server/src/service/network/ProxyResolver.ts diff --git a/server/src/domain/proxies/proxy.repository.interface.ts b/server/src/domain/proxies/proxy.repository.interface.ts index a235d0df4..5d39ddb80 100644 --- a/server/src/domain/proxies/proxy.repository.interface.ts +++ b/server/src/domain/proxies/proxy.repository.interface.ts @@ -5,6 +5,7 @@ export interface IProxiesRepository { create(proxyData: Partial): Promise; // read findById(proxyId: string, teamId: string): Promise; + findByIdOrNull(proxyId: string, teamId?: string): Promise; findByTeamId(teamId: string): Promise; // update updateById(proxyId: string, teamId: string, patch: Partial, options?: { unsetPassword?: boolean; unsetUsername?: boolean }): Promise; diff --git a/server/src/domain/proxies/proxy.repository.mongo.ts b/server/src/domain/proxies/proxy.repository.mongo.ts index 3ff810587..454ef68ab 100644 --- a/server/src/domain/proxies/proxy.repository.mongo.ts +++ b/server/src/domain/proxies/proxy.repository.mongo.ts @@ -3,7 +3,7 @@ import { Proxy } from "@/domain/proxies/proxy.type.js"; import { ProxyDocument, ProxyModel } from "@/domain/proxies/proxy.model.js"; import { AppError } from "@/utils/AppError.js"; import { toStringId, toDateString } from "@/utils/mongoMappers.js"; -import { UpdateQuery } from "mongoose"; +import { Error as MongooseError, UpdateQuery } from "mongoose"; const SERVICE_NAME = "ProxiesRepository"; @@ -45,6 +45,18 @@ class MongoProxiesRepository implements IProxiesRepository { return this.toEntity(proxy); } + async findByIdOrNull(proxyId: string, teamId?: string): Promise { + try { + const proxy = await ProxyModel.findOne(teamId ? { _id: proxyId, teamId } : { _id: proxyId }); + return proxy ? this.toEntity(proxy) : null; + } catch (error) { + if (error instanceof MongooseError.CastError) { + return null; + } + throw error; + } + } + async findByTeamId(teamId: string): Promise { const proxies = await ProxyModel.find({ teamId }); return proxies.map(this.toEntity); diff --git a/server/src/service/network/ProxyResolver.ts b/server/src/service/network/ProxyResolver.ts new file mode 100644 index 000000000..8f9551acc --- /dev/null +++ b/server/src/service/network/ProxyResolver.ts @@ -0,0 +1,108 @@ +import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; +import { Monitor } from "@/domain/monitors/monitor.type.js"; +import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; +import { ILogger } from "@/utils/logger.js"; +import { Proxy } from "@/domain/proxies/proxy.type.js"; + +export interface IProxyResolver { + resolve(monitor: Monitor): Promise; +} + +const SERVICE_NAME = "ProxyResolver"; + +export const buildProxyUrl = (proxy: Proxy): string => { + const { protocol, host, port, username, password } = proxy; + let credentials = ""; + if (username) { + credentials = password ? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@` : `${encodeURIComponent(username)}@`; + } + return `${protocol}://${credentials}${host}:${port}`; +}; + +export class ProxyResolver implements IProxyResolver { + static SERVICE_NAME = SERVICE_NAME; + constructor( + private proxiesRepository: IProxiesRepository, + private settingsService: ISettingsService, + private logger: ILogger, + private ttlMs = 60_000 + ) {} + + private cache = new Map(); + + private async getCached(key: string, fetch: () => Promise): Promise { + const hit = this.cache.get(key); + if (hit && hit.expiresAt > Date.now()) { + return hit.value as T; + } + const value = await fetch(); + this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + return value; + } + + private warn(message: string, monitor: Monitor, error?: unknown) { + this.logger.warn({ + message, + service: SERVICE_NAME, + method: "resolve", + details: { + monitorId: monitor.id, + proxyMode: monitor.proxyMode, + proxyId: monitor.proxyId, + ...(error instanceof Error ? { error: error.message } : {}), + }, + }); + } + + private resolveCustom = async (monitor: Monitor): Promise => { + const { proxyId, teamId } = monitor; + if (!proxyId) { + this.warn("Proxy mode is custom, but proxyId is missing, connecting direct", monitor); + return undefined; + } + + const proxy = await this.getCached(`proxy:${proxyId}:${teamId}`, () => this.proxiesRepository.findByIdOrNull(proxyId, teamId)); + if (!proxy) { + this.warn("Proxy not found, connecting direct", monitor); + return undefined; + } + return buildProxyUrl(proxy); + }; + + private resolveInherit = async (monitor: Monitor): Promise => { + const settings = await this.getCached("settings", () => this.settingsService.getDBSettings()); + if (!settings.globalProxyEnabled) { + return undefined; + } + const globalProxyId = settings.globalProxyId; + if (!globalProxyId) { + return undefined; + } + const proxy = await this.getCached(`proxy:${globalProxyId}`, () => this.proxiesRepository.findByIdOrNull(globalProxyId)); + if (!proxy) { + this.warn("Global proxy references a proxy that no longer exists, connecting direct", monitor); + return undefined; + } + return buildProxyUrl(proxy); + }; + + async resolve(monitor: Monitor): Promise { + if (monitor.type !== "http") { + return undefined; + } + try { + switch (monitor.proxyMode) { + case "none": + return undefined; + case "custom": + return await this.resolveCustom(monitor); + default: + // "inherit" is the default mode + return await this.resolveInherit(monitor); + } + } catch (error: unknown) { + this.warn("Proxy resolution failed, connecting direct", monitor, error); + return undefined; + } + } +} diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 2839ed95f..0c8dd7cae 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -152,3 +152,7 @@ export type StatusChangeResult = { }; export type MonitorStatusResponseOverrides = Partial, "monitorId" | "teamId" | "type">>; + +export type CheckContext = { + proxyUrl?: string; +}; From ffd7ea7517932b908bded221cb1c191c1158370d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 21 Aug 2026 11:13:22 -0700 Subject: [PATCH 119/143] add tests --- server/src/service/network/ProxyResolver.ts | 11 +- .../test/unit/services/proxyResolver.test.ts | 267 ++++++++++++++++++ 2 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 server/test/unit/services/proxyResolver.test.ts diff --git a/server/src/service/network/ProxyResolver.ts b/server/src/service/network/ProxyResolver.ts index 8f9551acc..573905f4b 100644 --- a/server/src/service/network/ProxyResolver.ts +++ b/server/src/service/network/ProxyResolver.ts @@ -28,15 +28,18 @@ export class ProxyResolver implements IProxyResolver { private ttlMs = 60_000 ) {} - private cache = new Map(); + private cache = new Map; expiresAt: number }>(); - private async getCached(key: string, fetch: () => Promise): Promise { + private getCached(key: string, fetch: () => Promise): Promise { const hit = this.cache.get(key); if (hit && hit.expiresAt > Date.now()) { - return hit.value as T; + return hit.value as Promise; } - const value = await fetch(); + // Cache the in flight promise so concurrent misses share one fetch + const value = fetch(); this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + // Don't negative cache rejections, evict so the next resolve retries + value.catch(() => this.cache.delete(key)); return value; } diff --git a/server/test/unit/services/proxyResolver.test.ts b/server/test/unit/services/proxyResolver.test.ts new file mode 100644 index 000000000..d2c5eefa2 --- /dev/null +++ b/server/test/unit/services/proxyResolver.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { ProxyResolver, buildProxyUrl } from "../../../src/service/network/ProxyResolver.ts"; +import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; +import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + proxyMode: "inherit", + ...overrides, + }) as Monitor; + +const makeProxy = (overrides?: Partial): Proxy => + ({ + id: "p1", + teamId: "team", + name: "corp", + protocol: "http", + host: "proxy.example.com", + port: 8080, + ...overrides, + }) as Proxy; + +const createResolver = (overrides?: Record) => { + const defaults = { + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(makeProxy()) }, + settingsService: { getDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: "p1" }) }, + logger: createMockLogger(), + ttlMs: 60_000, + ...overrides, + }; + const resolver = new ProxyResolver(defaults.proxiesRepository as any, defaults.settingsService as any, defaults.logger as any, defaults.ttlMs); + return { resolver, defaults }; +}; + +describe("ProxyResolver", () => { + // ── gates ───────────────────────────────────────────────────────────────── + + it("returns undefined for non-http monitors without touching repo or settings", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ type: "port", proxyMode: "custom", proxyId: "p1" } as Partial)); + + expect(result).toBeUndefined(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled(); + }); + + it("returns undefined for proxyMode 'none' without calling settings", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "none" })); + + expect(result).toBeUndefined(); + expect(defaults.settingsService.getDBSettings).not.toHaveBeenCalled(); + }); + + // ── custom mode ─────────────────────────────────────────────────────────── + + it("resolves a custom proxy with a team-scoped lookup", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + + expect(result).toBe("http://proxy.example.com:8080"); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1", "team"); + }); + + it("returns undefined and warns when the custom proxy no longer exists", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + it("returns undefined and warns when proxyMode is custom but proxyId is missing, without a repo call", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: undefined })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + }); + + // ── inherit mode ────────────────────────────────────────────────────────── + + it("returns undefined when the global proxy is disabled, without a repo call", async () => { + const { resolver, defaults } = createResolver({ + settingsService: { getDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: false, globalProxyId: "p1" }) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + }); + + it("resolves the global proxy with an unscoped lookup when enabled", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBe("http://proxy.example.com:8080"); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("p1"); + }); + + it("returns undefined when the global proxy is enabled but globalProxyId is null, without a repo call", async () => { + const { resolver, defaults } = createResolver({ + settingsService: { getDBSettings: jest.fn<() => Promise>().mockResolvedValue({ globalProxyEnabled: true, globalProxyId: null }) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.proxiesRepository.findByIdOrNull).not.toHaveBeenCalled(); + }); + + it("returns undefined and warns when the global proxy references a proxy that no longer exists", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + it("treats an unknown proxyMode as inherit", async () => { + const { resolver, defaults } = createResolver(); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "garbage" as Monitor["proxyMode"] })); + + expect(result).toBe("http://proxy.example.com:8080"); + expect(defaults.settingsService.getDBSettings).toHaveBeenCalled(); + }); + + // ── never throws ────────────────────────────────────────────────────────── + + it("returns undefined and warns when the repository throws", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockRejectedValue(new Error("db down")) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + it("returns undefined and warns when the settings service throws", async () => { + const { resolver, defaults } = createResolver({ + settingsService: { getDBSettings: jest.fn<() => Promise>().mockRejectedValue(new Error("db down")) }, + }); + + const result = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(result).toBeUndefined(); + expect(defaults.logger.warn).toHaveBeenCalled(); + }); + + // ── caching ─────────────────────────────────────────────────────────────── + + it("serves a second resolve within the TTL from cache", async () => { + const { resolver, defaults } = createResolver(); + + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); + }); + + it("refetches after the TTL expires", async () => { + const { resolver, defaults } = createResolver({ ttlMs: 0 }); + + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(2); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(2); + }); + + it("negative-caches a dangling ref, costing one repo call per TTL window", async () => { + const { resolver, defaults } = createResolver({ + proxiesRepository: { findByIdOrNull: jest.fn<() => Promise>().mockResolvedValue(null) }, + }); + + await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); + await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "gone" })); + + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); + }); + + it("shares one in-flight fetch between concurrent resolves", async () => { + const { resolver, defaults } = createResolver(); + + await Promise.all([ + resolver.resolve(makeMonitor({ proxyMode: "inherit" })), + resolver.resolve(makeMonitor({ proxyMode: "inherit" })), + resolver.resolve(makeMonitor({ proxyMode: "inherit" })), + ]); + + expect(defaults.settingsService.getDBSettings).toHaveBeenCalledTimes(1); + expect(defaults.proxiesRepository.findByIdOrNull).toHaveBeenCalledTimes(1); + }); + + it("does not cache a rejected fetch — the next resolve retries", async () => { + const findByIdOrNull = jest.fn<() => Promise>().mockRejectedValueOnce(new Error("db down")).mockResolvedValueOnce(makeProxy()); + const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } }); + + const first = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + const second = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + + expect(first).toBeUndefined(); + expect(second).toBe("http://proxy.example.com:8080"); + expect(findByIdOrNull).toHaveBeenCalledTimes(2); + }); + + it("does not let a scoped miss poison the unscoped global lookup for the same proxy id", async () => { + const findByIdOrNull = jest.fn<(proxyId: string, teamId?: string) => Promise>(async (_proxyId, teamId) => + teamId ? null : makeProxy() + ); + const { resolver } = createResolver({ proxiesRepository: { findByIdOrNull } }); + + const customResult = await resolver.resolve(makeMonitor({ proxyMode: "custom", proxyId: "p1" })); + const inheritResult = await resolver.resolve(makeMonitor({ proxyMode: "inherit" })); + + expect(customResult).toBeUndefined(); + expect(inheritResult).toBe("http://proxy.example.com:8080"); + expect(findByIdOrNull).toHaveBeenCalledTimes(2); + }); +}); + +describe("buildProxyUrl", () => { + it("builds a URL without credentials when there is no username", () => { + expect(buildProxyUrl(makeProxy())).toBe("http://proxy.example.com:8080"); + }); + + it("builds a URL with username and password", () => { + expect(buildProxyUrl(makeProxy({ username: "user", password: "pass" }))).toBe("http://user:pass@proxy.example.com:8080"); + }); + + it("percent-encodes credentials", () => { + expect(buildProxyUrl(makeProxy({ username: "user@x", password: "p@ss" }))).toBe("http://user%40x:p%40ss@proxy.example.com:8080"); + }); + + it("builds a URL with a username only, without a colon", () => { + expect(buildProxyUrl(makeProxy({ username: "user" }))).toBe("http://user@proxy.example.com:8080"); + }); + + it("emits no credentials for a password without a username", () => { + expect(buildProxyUrl(makeProxy({ password: "pass" }))).toBe("http://proxy.example.com:8080"); + }); + + it("uses the https scheme without a trailing slash", () => { + expect(buildProxyUrl(makeProxy({ protocol: "https" }))).toBe("https://proxy.example.com:8080"); + }); +}); From 1152a70afb32a6c9d7cdba23e7298e6ca78bd082 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 21 Aug 2026 11:41:26 -0700 Subject: [PATCH 120/143] wire up proxy resolver --- server/src/config/services.worker.ts | 14 +++++++++-- server/src/service/network/IStatusProvider.ts | 4 +-- server/src/service/networkService.ts | 11 +++++--- server/src/worker/worker.check-producer.ts | 5 +++- server/test/helpers/heartbeatTestHarness.ts | 2 ++ .../test/unit/services/checkProducer.test.ts | 25 +++++++++++++++++++ .../test/unit/services/networkService.test.ts | 15 +++++++++-- 7 files changed, 65 insertions(+), 11 deletions(-) diff --git a/server/src/config/services.worker.ts b/server/src/config/services.worker.ts index b0bdff2d6..9b4ea8609 100644 --- a/server/src/config/services.worker.ts +++ b/server/src/config/services.worker.ts @@ -26,7 +26,7 @@ import { NotificationReactor } from "@/worker/reactors/reactor.notification.js"; import { IncidentReactor } from "@/worker/reactors/reactor.incident.js"; import { ReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js"; import { DBQueueWorker } from "@/worker/worker.db-queue.js"; - +import { ProxyResolver } from "@/service/network/ProxyResolver.js"; // Network providers import { PingProvider } from "@/service/network/PingProvider.js"; import { HttpProvider } from "@/service/network/HttpProvider.js"; @@ -64,6 +64,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig incidentsRepository, teamsRepository, maintenanceWindowsRepository, + proxiesRepository, } = shared; // *********************** @@ -93,6 +94,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig dnsProvider, ]); + const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger); const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository); const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository); const monitorStatusPolicy = new MonitorStatusPolicy(); @@ -110,7 +112,15 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig // Check producer/evaluator // Handles creating and evaluatiog checks // *********************** - const checkProducer = new CheckProducer(monitorsRepository, maintenanceWindowsRepository, checkService, networkService, bufferService, logger); + const checkProducer = new CheckProducer( + monitorsRepository, + maintenanceWindowsRepository, + checkService, + networkService, + proxyResolver, + bufferService, + logger + ); const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy); const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepository, geoChecksService, bufferService, logger); diff --git a/server/src/service/network/IStatusProvider.ts b/server/src/service/network/IStatusProvider.ts index 6da91b30f..e8f63f671 100644 --- a/server/src/service/network/IStatusProvider.ts +++ b/server/src/service/network/IStatusProvider.ts @@ -1,8 +1,8 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { MonitorStatusResponse } from "@/types/network.js"; - +import { CheckContext } from "@/types/network.js"; export interface IStatusProvider { type: string; supports: (type: MonitorType) => boolean; - handle(monitor: Monitor): Promise>; + handle(monitor: Monitor, ctx?: CheckContext): Promise>; } diff --git a/server/src/service/networkService.ts b/server/src/service/networkService.ts index 75ff66bda..69e97b092 100644 --- a/server/src/service/networkService.ts +++ b/server/src/service/networkService.ts @@ -1,5 +1,5 @@ import type { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; -import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; +import type { CheckContext, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; import type { AxiosStatic } from "axios"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR } from "@/types/network.js"; @@ -8,7 +8,7 @@ import { IStatusProvider } from "./network/IStatusProvider.js"; const SERVICE_NAME = "NetworkService"; export interface INetworkService { - requestStatus(monitor: Monitor & { type: T }): Promise>; + requestStatus(monitor: Monitor & { type: T }, ctx?: CheckContext): Promise>; requestWebhook( type: string, url: string, @@ -39,12 +39,15 @@ export class NetworkService implements INetworkService { } // Main entry point - async requestStatus(monitor: Monitor & { type: T }): Promise> { + async requestStatus( + monitor: Monitor & { type: T }, + ctx?: CheckContext + ): Promise> { const provider = this.providers.find((p) => p.supports(monitor.type)); if (!provider) { return this.handleUnsupportedType(monitor.type) as Promise>; } - return provider.handle(monitor) as Promise>; + return provider.handle(monitor, ctx) as Promise>; } private async handleUnsupportedType(type: string): Promise { diff --git a/server/src/worker/worker.check-producer.ts b/server/src/worker/worker.check-producer.ts index b73e2c6de..f297d01be 100644 --- a/server/src/worker/worker.check-producer.ts +++ b/server/src/worker/worker.check-producer.ts @@ -9,6 +9,7 @@ import { ICheckService } from "@/domain/checks/check.service.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; import { isWindowActive } from "@/utils/maintenanceWindow.js"; +import { IProxyResolver } from "@/service/network/ProxyResolver.js"; export interface ICheckProducer { produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>; @@ -22,6 +23,7 @@ export class CheckProducer implements ICheckProducer { private maintenanceWindowsRepository: IMaintenanceWindowsRepository, private checkService: ICheckService, private networkService: INetworkService, + private proxyResolver: IProxyResolver, private bufferService: IBufferService, private logger: ILogger ) {} @@ -56,7 +58,8 @@ export class CheckProducer implements ICheckProducer { } // Step 1b: Acquire status - const status = await this.networkService.requestStatus(monitor); + const proxyUrl = await this.proxyResolver.resolve(monitor); + const status = await this.networkService.requestStatus(monitor, { proxyUrl }); if (!status) { throw new Error("No network response"); } diff --git a/server/test/helpers/heartbeatTestHarness.ts b/server/test/helpers/heartbeatTestHarness.ts index 898c3c91e..9c2161790 100644 --- a/server/test/helpers/heartbeatTestHarness.ts +++ b/server/test/helpers/heartbeatTestHarness.ts @@ -127,12 +127,14 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { }; const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) }; + const proxyResolver = { resolve: jest.fn().mockResolvedValue(undefined) }; const checkProducer = new CheckProducer( monitorsRepo as any, maintenanceWindowsRepo as any, checkService as any, networkService as any, + proxyResolver as any, bufferStub as any, logger ); diff --git a/server/test/unit/services/checkProducer.test.ts b/server/test/unit/services/checkProducer.test.ts index dacff3d9b..2a1aa29f1 100644 --- a/server/test/unit/services/checkProducer.test.ts +++ b/server/test/unit/services/checkProducer.test.ts @@ -26,6 +26,7 @@ const createProducer = (overrides?: Record) => { maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) }, checkService: { toCheck: jest.fn().mockReturnValue({ id: "check-1" }) }, networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) }, + proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) }, buffer: { addToBuffer: jest.fn() }, ...overrides, }; @@ -34,6 +35,7 @@ const createProducer = (overrides?: Record) => { defaults.maintenanceWindowsRepository as any, defaults.checkService as any, defaults.networkService as any, + defaults.proxyResolver as any, defaults.buffer as any, defaults.logger as any ); @@ -82,6 +84,29 @@ describe("CheckProducer", () => { expect(defaults.networkService.requestStatus).toHaveBeenCalled(); }); + // ── proxy resolution ────────────────────────────────────────────────────── + + it("passes the resolved proxy url into requestStatus", async () => { + const { producer, defaults } = createProducer({ + proxyResolver: { resolve: jest.fn().mockResolvedValue("http://proxy.example.com:8080") }, + }); + const monitor = makeMonitor(); + + await producer.produce(monitor); + + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: "http://proxy.example.com:8080" }); + }); + + it("still produces a check when the resolver returns undefined", async () => { + const { producer, defaults } = createProducer(); + const monitor = makeMonitor(); + + const result = await producer.produce(monitor); + + expect(defaults.networkService.requestStatus).toHaveBeenCalledWith(monitor, { proxyUrl: undefined }); + expect(result).toEqual({ status: expect.objectContaining({ monitorId: "m1" }), check: { id: "check-1" } }); + }); + // ── acquire / record ────────────────────────────────────────────────────── it("throws when the network response is null", async () => { diff --git a/server/test/unit/services/networkService.test.ts b/server/test/unit/services/networkService.test.ts index 2e6627452..b33983bf9 100644 --- a/server/test/unit/services/networkService.test.ts +++ b/server/test/unit/services/networkService.test.ts @@ -54,7 +54,7 @@ describe("NetworkService", () => { const result = await service.requestStatus(monitor); expect(provider.supports).toHaveBeenCalledWith("http"); - expect(provider.handle).toHaveBeenCalledWith(monitor); + expect(provider.handle).toHaveBeenCalledWith(monitor, undefined); expect(result).toEqual( expect.objectContaining({ monitorId: "mon-1", @@ -73,7 +73,18 @@ describe("NetworkService", () => { await service.requestStatus(monitor); expect(httpProvider.handle).not.toHaveBeenCalled(); - expect(pingProvider.handle).toHaveBeenCalledWith(monitor); + expect(pingProvider.handle).toHaveBeenCalledWith(monitor, undefined); + }); + + it("forwards the check context to the provider", async () => { + const provider = createMockProvider("http"); + const { service } = createService([provider]); + const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" }; + const ctx = { proxyUrl: "http://proxy.example.com:8080" }; + + await service.requestStatus(monitor, ctx); + + expect(provider.handle).toHaveBeenCalledWith(monitor, ctx); }); it("returns unsupported-type response when no provider matches", async () => { From 252067142ee7da74dd6e912c5b313b9961b020af Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 21 Aug 2026 12:19:23 -0700 Subject: [PATCH 121/143] apply proxy agents to http monitors --- server/package-lock.json | 10 ++ server/package.json | 1 + server/src/service/network/HttpProvider.ts | 64 +++++++- .../providers/network/httpProvider.test.ts | 149 ++++++++++++++++++ 4 files changed, 216 insertions(+), 8 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index dac79faa8..3cb17bea6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -26,6 +26,7 @@ "got": "14.4.7", "handlebars": "^4.7.8", "helmet": "^8.0.0", + "hpagent": "1.2.0", "isomorphic-dompurify": "^2.26.0", "jmespath": "^0.16.0", "jsdom": "^26.1.0", @@ -7778,6 +7779,15 @@ "node": ">=18.0.0" } }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", diff --git a/server/package.json b/server/package.json index f0362bb13..5e67192db 100755 --- a/server/package.json +++ b/server/package.json @@ -49,6 +49,7 @@ "got": "14.4.7", "handlebars": "^4.7.8", "helmet": "^8.0.0", + "hpagent": "1.2.0", "isomorphic-dompurify": "^2.26.0", "jmespath": "^0.16.0", "jsdom": "^26.1.0", diff --git a/server/src/service/network/HttpProvider.ts b/server/src/service/network/HttpProvider.ts index fb5f9fea5..1ef35f474 100644 --- a/server/src/service/network/HttpProvider.ts +++ b/server/src/service/network/HttpProvider.ts @@ -9,11 +9,17 @@ import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { isStatusUp } from "@/service/network/utils.js"; import { NETWORK_ERROR } from "@/types/network.js"; import CacheableLookup from "cacheable-lookup"; +import { HttpProxyAgent, HttpsProxyAgent } from "hpagent"; +import { CheckContext } from "@/types/network.js"; + +export const PROXY_CONNECT_ERROR_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN", "EPIPE"]); export class HttpProvider implements IStatusProvider { readonly type = "http"; // Shared, pooled agents reused across every check + private static readonly PROXY_AGENT_CACHE_MAX = 50; + private readonly proxyAgents = new Map(); private readonly httpAgent: HttpAgent; private readonly httpsAgent: HttpsAgent; private readonly httpsAgentInsecure: HttpsAgent; @@ -41,6 +47,44 @@ export class HttpProvider implements IStatusProvider { return type === "http"; } + private proxyFailureMessage = (error: RequestError, proxyUrl: string): string | undefined => { + if (error.response || !error.code || !PROXY_CONNECT_ERROR_CODES.has(error.code)) { + return undefined; + } + const { hostname, port } = new URL(proxyUrl); + return `Proxy connection failed (${hostname}:${port}): ${error.message}`; + }; + + private getProxyAgents(proxyUrl: string, ignoreTlsErrors: boolean) { + const key = `${proxyUrl}|${ignoreTlsErrors}`; + const hit = this.proxyAgents.get(key); + // Agent already exists, refresh + if (hit) { + this.proxyAgents.delete(key); // Refresh + this.proxyAgents.set(key, hit); + return hit; + } + + // Create new agents + const agentOptions = { keepAlive: true, maxSockets: 256, maxFreeSockets: 256, proxy: proxyUrl }; + const pair = { + http: new HttpProxyAgent(agentOptions), + https: new HttpsProxyAgent({ ...agentOptions, rejectUnauthorized: !ignoreTlsErrors }), + }; + + // Cache agents + this.proxyAgents.set(key, pair); + if (this.proxyAgents.size > HttpProvider.PROXY_AGENT_CACHE_MAX) { + const [oldestKey] = this.proxyAgents.keys(); // Destructuring gets the first inserted, ie oldest, item + if (oldestKey === undefined) return pair; + const oldest = this.proxyAgents.get(oldestKey); + oldest?.http.destroy(); + oldest?.https.destroy(); + this.proxyAgents.delete(oldestKey); + } + return pair; + } + private buildResponse( monitor: Monitor, opts: { @@ -113,20 +157,22 @@ export class HttpProvider implements IStatusProvider { }; } - private handleHttpError(error: unknown, monitor: Monitor): MonitorStatusResponse { + private handleHttpError(error: unknown, monitor: Monitor, ctx?: CheckContext): MonitorStatusResponse { if (error instanceof HTTPError || error instanceof RequestError) { const statusCode = error.response?.statusCode; const statusUp = isStatusUp(statusCode, monitor.customUpCodes); const responseTime = error.timings?.phases?.total ?? 0; if (!statusUp) { + const message = (ctx?.proxyUrl && error instanceof RequestError && this.proxyFailureMessage(error, ctx.proxyUrl)) || error.message; + return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, status: false, code: statusCode ?? NETWORK_ERROR, - message: error.message, + message: message, responseTime, timings: error.timings, payload: null as T, @@ -156,7 +202,7 @@ export class HttpProvider implements IStatusProvider { }; } - async handle(monitor: Monitor): Promise> { + async handle(monitor: Monitor, ctx?: CheckContext): Promise> { const { url, secret, ignoreTlsErrors } = monitor; if (!url) { @@ -167,10 +213,12 @@ export class HttpProvider implements IStatusProvider { headers: monitor.secret ? { Authorization: `Bearer ${secret}` } : undefined, }; - options.agent = { - http: this.httpAgent, - https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent, - }; + options.agent = ctx?.proxyUrl + ? this.getProxyAgents(ctx.proxyUrl, Boolean(ignoreTlsErrors)) + : { + http: this.httpAgent, + https: ignoreTlsErrors ? this.httpsAgentInsecure : this.httpsAgent, + }; options.method = monitor.method; @@ -188,7 +236,7 @@ export class HttpProvider implements IStatusProvider { timings: response.timings, }); } catch (error: unknown) { - return this.handleHttpError(error, monitor); + return this.handleHttpError(error, monitor, ctx); } } } diff --git a/server/test/unit/providers/network/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts index 9ab69f25d..4e559ab06 100644 --- a/server/test/unit/providers/network/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, jest } from "@jest/globals"; +import { HttpProxyAgent, HttpsProxyAgent } from "hpagent"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts"; @@ -494,4 +495,152 @@ describe("HttpProvider", () => { expect(advancedMatcher.validate).not.toHaveBeenCalled(); }); }); + + // ── Proxy support ──────────────────────────────────────────────────── + + describe("proxy support", () => { + const PROXY_URL = "http://proxy.example.com:8080"; + + const gotOptions = (callIndex = 0) => mockGot.mock.calls[callIndex]?.[1] as any; + + const makeConnectionError = (code: string, message = `connect ${code} 10.0.0.1:8080`) => { + const err = new RequestError(message); + (err as any).code = code; + return err; + }; + + it("uses hpagent proxy agents when ctx.proxyUrl is set", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(gotOptions().agent.http).toBeInstanceOf(HttpProxyAgent); + expect(gotOptions().agent.https).toBeInstanceOf(HttpsProxyAgent); + }); + + it("uses the shared agents when ctx has no proxyUrl", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor()); + await provider.handle(makeMonitor(), {}); + + expect(gotOptions(0).agent.http).not.toBeInstanceOf(HttpProxyAgent); + expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http); + expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https); + }); + + it("reuses the cached agent pair across checks with the same proxyUrl", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(gotOptions(1).agent.http).toBe(gotOptions(0).agent.http); + expect(gotOptions(1).agent.https).toBe(gotOptions(0).agent.https); + }); + + it("builds distinct agent pairs for distinct proxyUrls", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + await provider.handle(makeMonitor(), { proxyUrl: "http://other.example.com:3128" }); + + expect(gotOptions(1).agent.http).not.toBe(gotOptions(0).agent.http); + }); + + it("builds distinct pairs per ignoreTlsErrors and sets rejectUnauthorized accordingly", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ ignoreTlsErrors: false }), { proxyUrl: PROXY_URL }); + await provider.handle(makeMonitor({ ignoreTlsErrors: true }), { proxyUrl: PROXY_URL }); + + expect(gotOptions(1).agent.https).not.toBe(gotOptions(0).agent.https); + expect(gotOptions(0).agent.https.options.rejectUnauthorized).toBe(true); + expect(gotOptions(1).agent.https.options.rejectUnauthorized).toBe(false); + }); + + it("evicts and destroys the least-recently-used pair past the cache cap", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" }); + const first = gotOptions(0).agent; + const httpDestroy = jest.spyOn(first.http, "destroy"); + const httpsDestroy = jest.spyOn(first.https, "destroy"); + + for (let i = 1; i <= 49; i++) { + await provider.handle(makeMonitor(), { proxyUrl: `http://proxy${i}.example.com:8080` }); + } + expect(httpDestroy).not.toHaveBeenCalled(); + + await provider.handle(makeMonitor(), { proxyUrl: "http://proxy50.example.com:8080" }); + + expect(httpDestroy).toHaveBeenCalled(); + expect(httpsDestroy).toHaveBeenCalled(); + // The evicted pair is gone — the same proxyUrl now gets a fresh pair + await provider.handle(makeMonitor(), { proxyUrl: "http://proxy0.example.com:8080" }); + expect(gotOptions(51).agent.http).not.toBe(first.http); + }); + + it("names the proxy in the message when the proxy connection fails", async () => { + mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(result.status).toBe(false); + expect(result.code).toBe(NETWORK_ERROR); + expect(result.message).toBe("Proxy connection failed (proxy.example.com:8080): connect ECONNREFUSED 10.0.0.1:8080"); + }); + + it("does not attribute timeouts to the proxy", async () => { + mockGot.mockRejectedValue(makeConnectionError("ETIMEDOUT", "Timeout awaiting 'request' for 30000ms")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(result.status).toBe(false); + expect(result.message).toBe("Timeout awaiting 'request' for 30000ms"); + }); + + it("leaves connection-error messages unprefixed for direct checks", async () => { + mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("connect ECONNREFUSED 10.0.0.1:8080"); + }); + + it("never leaks proxy credentials into the message", async () => { + mockGot.mockRejectedValue(makeConnectionError("ECONNREFUSED")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: "http://bob:sekret@proxy.example.com:8080" }); + + expect(result.message).toContain("proxy.example.com:8080"); + expect(result.message).not.toContain("bob"); + expect(result.message).not.toContain("sekret"); + }); + + it("does not prefix errors that carry a response (target failure through a healthy proxy)", async () => { + const err = new RequestError("Response code 500 (Internal Server Error)"); + (err as any).code = "ECONNRESET"; + (err as any).response = { statusCode: 500, body: "", headers: {} }; + (err as any).timings = { phases: { total: 25 } }; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor(), { proxyUrl: PROXY_URL }); + + expect(result.status).toBe(false); + expect(result.code).toBe(500); + expect(result.message).toBe("Response code 500 (Internal Server Error)"); + }); + }); }); From cc137e1d06a532c24ba0094eaa09a5054e1328de Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 21 Aug 2026 15:13:17 -0700 Subject: [PATCH 122/143] add proxies CRUD pages --- client/src/Hooks/useProxiesForm.ts | 22 ++ .../Pages/Proxies/components/ProxiesTable.tsx | 103 +++++++++ client/src/Pages/Proxies/create/index.tsx | 213 ++++++++++++++++++ client/src/Pages/Proxies/index.tsx | 72 ++++++ client/src/Types/Proxy.ts | 17 ++ client/src/Validation/monitor.ts | 18 +- client/src/Validation/proxy.ts | 17 ++ 7 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 client/src/Hooks/useProxiesForm.ts create mode 100644 client/src/Pages/Proxies/components/ProxiesTable.tsx create mode 100644 client/src/Pages/Proxies/create/index.tsx create mode 100644 client/src/Pages/Proxies/index.tsx create mode 100644 client/src/Types/Proxy.ts create mode 100644 client/src/Validation/proxy.ts diff --git a/client/src/Hooks/useProxiesForm.ts b/client/src/Hooks/useProxiesForm.ts new file mode 100644 index 000000000..1b8de2045 --- /dev/null +++ b/client/src/Hooks/useProxiesForm.ts @@ -0,0 +1,22 @@ +import { useMemo } from "react"; +import { proxySchema, type ProxyFormData } from "@/Validation/proxy"; +import type { ProxyResponse } from "@/Types/Proxy"; + +interface UseProxiesFormOptions { + data?: ProxyResponse | null; +} + +export const useProxiesForm = ({ data }: UseProxiesFormOptions) => { + return useMemo(() => { + const defaults: ProxyFormData = { + name: data?.name ?? "", + protocol: data?.protocol ?? "http", + host: data?.host ?? "", + port: data?.port ?? 8080, + username: "", + password: "", + }; + + return { schema: proxySchema, defaults }; + }, [data]); +}; diff --git a/client/src/Pages/Proxies/components/ProxiesTable.tsx b/client/src/Pages/Proxies/components/ProxiesTable.tsx new file mode 100644 index 000000000..f2ae148e3 --- /dev/null +++ b/client/src/Pages/Proxies/components/ProxiesTable.tsx @@ -0,0 +1,103 @@ +import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; +import Typography from "@mui/material/Typography"; +import type { Header } from "@/Components/design-elements/Table"; +import { Table } from "@/Components/design-elements"; +import { Pagination } from "@/Components/design-elements/Table"; +import { useClientPagination } from "@/Hooks/useClientPagination"; + +import type { ProxyResponse } from "@/Types/Proxy"; +import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; + +interface ProxiesTableProps { + proxies: ProxyResponse[]; + setSelectedProxy: Function; +} + +export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const theme = useTheme(); + const { pagedRows, paginationProps } = useClientPagination(proxies); + + const getActions = (proxy: ProxyResponse): ActionMenuItem[] => { + return [ + { + id: 1, + label: t("pages.common.monitors.actions.configure"), + action: () => { + navigate(`/proxies/configure/${proxy.id}`); + }, + closeMenu: true, + }, + + { + id: 2, + label: ( + + {t("pages.common.monitors.actions.delete")} + + ), + action: async () => { + setSelectedProxy(proxy); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = () => { + const headers: Header[] = [ + { + id: "name", + content: t("common.table.headers.name"), + render: (row) => { + return {row?.name}; + }, + }, + { + id: "protocol", + content: t("pages.proxies.table.headers.protocol"), + render: (row) => { + return {row?.protocol}; + }, + }, + { + id: "host", + content: t("pages.proxies.table.headers.host"), + render: (row) => { + return {row?.host}; + }, + }, + { + id: "port", + content: t("pages.proxies.table.headers.port"), + render: (row) => { + return {row?.port}; + }, + }, + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + return ( + <> + { + navigate(`/proxies/configure/${row.id}`); + }} + /> + {proxies.length > 0 && } + + ); +}; diff --git a/client/src/Pages/Proxies/create/index.tsx b/client/src/Pages/Proxies/create/index.tsx new file mode 100644 index 000000000..2783c93a9 --- /dev/null +++ b/client/src/Pages/Proxies/create/index.tsx @@ -0,0 +1,213 @@ +import { BasePage, ConfigBox } from "@/Components/design-elements"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { Button, FieldLabel } from "@/Components/inputs"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; + +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { useForm, FormProvider } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { mutate } from "swr"; +import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; +import type { ProxyFormData } from "@/Validation/proxy"; +import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy"; +import { useTranslation } from "react-i18next"; +import { useProxiesForm } from "@/Hooks/useProxiesForm"; +import { FormTextField } from "@/Components/inputs/forms/FormTextField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import { useMemo } from "react"; +import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; +const ProxiesCreatePage = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const navigate = useNavigate(); + const { proxyId } = useParams<{ proxyId: string }>(); + const isEditMode = Boolean(proxyId); + + const { data: existingProxy } = useGet( + isEditMode ? `/proxies/${proxyId}` : null + ); + + const { post, loading: isSubmitting } = usePost(); + const { patch, loading: isPatching } = usePatch(); + + const { schema, defaults } = useProxiesForm({ data: existingProxy }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const { reset, handleSubmit } = form; + + const isPasswordSet = existingProxy?.hasPassword ?? false; + const [passwordHasBeenReset, setPasswordHasBeenReset] = useState(false); + + const handleResetPassword = () => { + form.setValue("password", ""); + setPasswordHasBeenReset(true); + }; + + const isUsernameSet = Boolean(existingProxy?.username); + const [usernameHasBeenReset, setUsernameHasBeenReset] = useState(false); + + const handleResetUsername = () => { + form.setValue("username", ""); + setUsernameHasBeenReset(true); + }; + + const protocolOptions = useMemo( + () => + ProxyProtocols.map((protocol) => ({ + value: protocol, + label: protocol, + })), + [] + ); + + useEffect(() => { + reset(defaults); + }, [defaults, reset]); + + const onSubmit = async (data: ProxyFormData) => { + const payload: ProxyFormData & { clearPassword?: boolean; clearUsername?: boolean } = + { + ...data, + }; + // Don't send password/username if one is set and the user hasn't clicked reset + if (isPasswordSet && !passwordHasBeenReset) { + delete payload.password; + } else if (passwordHasBeenReset && !payload.password) { + payload.clearPassword = true; + } + if (isUsernameSet && !usernameHasBeenReset) { + delete payload.username; + } else if (usernameHasBeenReset && !payload.username) { + payload.clearUsername = true; + } + const result = isEditMode + ? await patch(`/proxies/${proxyId}`, payload) + : await post("/proxies", payload); + if (result?.success) { + await mutate((key) => typeof key === "string" && key.startsWith("/proxies")); + navigate("/proxies"); + } + }; + + return ( + + + + } + /> + + + + + + } + /> + + + {(isUsernameSet === false || usernameHasBeenReset === true) && ( + + )} + + {isUsernameSet === true && usernameHasBeenReset === false && ( + + + {t("pages.proxies.form.auth.option.username.labelSet")} + + + + )} + + {(isPasswordSet === false || passwordHasBeenReset === true) && ( + + )} + + {isPasswordSet === true && passwordHasBeenReset === false && ( + + + {t("pages.proxies.form.auth.option.password.labelSet")} + + + + )} + + } + /> + + + + + + + ); +}; + +export default ProxiesCreatePage; diff --git a/client/src/Pages/Proxies/index.tsx b/client/src/Pages/Proxies/index.tsx new file mode 100644 index 000000000..5b40242b8 --- /dev/null +++ b/client/src/Pages/Proxies/index.tsx @@ -0,0 +1,72 @@ +import { BasePageWithStates } from "@/Components/design-elements"; +import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable"; +import { Dialog } from "@/Components/inputs"; +import { HeaderCreate } from "@/Components/common"; + +import { useState } from "react"; +import { useGet, useDelete } from "@/Hooks/UseApi"; +import { useTranslation } from "react-i18next"; +import type { ProxyResponse } from "@/Types/Proxy"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; + +const ProxiesPage = () => { + const { t } = useTranslation(); + const isAdmin = useIsAdmin(); + + const [selectedProxy, setSelectedProxy] = useState(null); + const isDialogOpen = Boolean(selectedProxy); + + const { + data: proxies, + isLoading, + isValidating, + error, + refetch, + } = useGet("/proxies/team", {}, { keepPreviousData: true }); + + const { deleteFn, loading: isDeleting } = useDelete(); + + const handleConfirm = async () => { + if (!selectedProxy) return; + await deleteFn(`/proxies/${selectedProxy.id}`); + setSelectedProxy(null); + refetch(); + }; + ``; + const handleCancel = () => { + setSelectedProxy(null); + }; + + return ( + + + + + + ); +}; + +export default ProxiesPage; diff --git a/client/src/Types/Proxy.ts b/client/src/Types/Proxy.ts new file mode 100644 index 000000000..dd098dee3 --- /dev/null +++ b/client/src/Types/Proxy.ts @@ -0,0 +1,17 @@ +export const ProxyProtocols = ["http", "https"] as const; +export type ProxyProtocol = (typeof ProxyProtocols)[number]; + +export interface Proxy { + id: string; + teamId: string; + name: string; + protocol: ProxyProtocol; + host: string; + port: number; + username?: string; + password?: string; + createdAt: string; + updatedAt: string; +} + +export type ProxyResponse = Omit & { hasPassword: boolean }; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index a31cdb919..5b53ff3a5 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -5,6 +5,7 @@ import { DnsRecordTypes, HttpMethods, PageSpeedStrategies, + ProxyModes, type MonitorType, } from "@/Types/Monitor"; import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode"; @@ -67,6 +68,8 @@ const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code), const httpSchema = baseSchema.extend({ type: z.literal("http"), url: urlSchema, + proxyMode: z.enum(ProxyModes), + proxyId: z.string().optional(), method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }), ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }), useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }), @@ -195,7 +198,7 @@ const dnsSchema = baseSchema.extend({ }); // Discriminated union of all monitor types -export const monitorSchema = z.discriminatedUnion("type", [ +const monitorSchemaUnion = z.discriminatedUnion("type", [ httpSchema, pingSchema, portSchema, @@ -208,10 +211,21 @@ export const monitorSchema = z.discriminatedUnion("type", [ dnsSchema, ]); +// Mirrors the server's refineProxySelection (monitorValidation.ts) +export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => { + if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) { + ctx.addIssue({ + code: "custom", + path: ["proxyId"], + message: "A proxy must be selected when proxy mode is custom", + }); + } +}); + export type MonitorFormData = z.infer; const optionForType = (type: MonitorType) => - monitorSchema.options.find((o) => o.shape.type.value === type); + monitorSchemaUnion.options.find((o) => o.shape.type.value === type); // The step each field of a type is validated on, from the per-field // `monitorStepRegistry` metadata (unannotated fields default to step 0). diff --git a/client/src/Validation/proxy.ts b/client/src/Validation/proxy.ts new file mode 100644 index 000000000..22582e92a --- /dev/null +++ b/client/src/Validation/proxy.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { ProxyProtocols } from "@/Types/Proxy"; + +export const proxySchema = z.object({ + name: z.string().min(1, "Proxy name is required"), + protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"), + host: z.string().min(1, "Proxy host is required"), + port: z + .number() + .int() + .min(1, "Proxy port is required") + .max(65535, "Proxy port must be between 1 and 65535"), + username: z.string().optional(), + password: z.string().optional(), +}); + +export type ProxyFormData = z.infer; From ccfda938d56bea87af260683f498afda65667eff Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 21 Aug 2026 15:13:34 -0700 Subject: [PATCH 123/143] Add proxies to create/edit monitors page --- client/src/Components/inputs/Select.tsx | 19 +++- .../inputs/forms/FormSelectField.tsx | 1 + client/src/Components/sidebar/Menu.tsx | 7 +- client/src/Hooks/useMonitorForm.ts | 5 + client/src/Pages/CreateMonitor/index.tsx | 93 ++++++++++++++++++- client/src/Pages/Proxies/index.tsx | 2 +- client/src/Routes/index.tsx | 21 ++++- client/src/Types/Monitor.ts | 6 ++ client/src/locales/en.json | 75 ++++++++++++++- 9 files changed, 213 insertions(+), 16 deletions(-) diff --git a/client/src/Components/inputs/Select.tsx b/client/src/Components/inputs/Select.tsx index 92a57860a..0077d2be6 100644 --- a/client/src/Components/inputs/Select.tsx +++ b/client/src/Components/inputs/Select.tsx @@ -11,10 +11,11 @@ interface SelectInputProps extends Omit, "label"> { fieldLabel?: string; required?: boolean; placeholder?: string; + helperText?: string; } const SelectInputInner = ( - { fieldLabel, required, placeholder, ...props }: SelectInputProps, + { fieldLabel, required, placeholder, helperText, ...props }: SelectInputProps, ref: React.ForwardedRef ) => { const theme = useTheme(); @@ -27,7 +28,7 @@ const SelectInputInner = ( : selected === undefined || selected === null || selected === ""; if (isEmpty && placeholder) { - return {placeholder}; + return {placeholder}; } if (isMultiple) { @@ -92,11 +93,21 @@ const SelectInputInner = ( /> ); - if (fieldLabel) { + const helper = helperText ? ( + + {helperText} + + ) : null; + + if (fieldLabel || helper) { return ( - {fieldLabel} + {fieldLabel && {fieldLabel}} {select} + {helper} ); } diff --git a/client/src/Components/inputs/forms/FormSelectField.tsx b/client/src/Components/inputs/forms/FormSelectField.tsx index 78e42300a..faead399f 100644 --- a/client/src/Components/inputs/forms/FormSelectField.tsx +++ b/client/src/Components/inputs/forms/FormSelectField.tsx @@ -44,6 +44,7 @@ export const FormSelectField = ({ }} error={!!fieldState.error} {...rest} + helperText={fieldState.error?.message ?? rest.helperText} > {options.map((option) => ( { return [ { @@ -47,6 +47,11 @@ export const getMenu = (t: Function) => { path: "tags", icon: , }, + { + name: t("components.sidebar.menu.proxies"), + path: "proxies", + icon: , + }, { name: t("components.sidebar.menu.checks"), path: "checks", diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 16314e8ca..5458a75d8 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -4,6 +4,7 @@ import { DefaultHttpMethod, DefaultMonitorMatchMethod, DefaultPageSpeedStrategy, + DefaultProxyMode, } from "@/Types/Monitor"; import type { Monitor, MonitorType } from "@/Types/Monitor"; @@ -46,6 +47,8 @@ export const getMonitorDefaults = ( url: data?.url || "", method: data?.method ?? DefaultHttpMethod, ignoreTlsErrors: data?.ignoreTlsErrors || false, + proxyMode: data?.proxyMode ?? DefaultProxyMode, + proxyId: data?.proxyId || "", useAdvancedMatching: data?.useAdvancedMatching || false, matchMethod: data?.matchMethod || DefaultMonitorMatchMethod, expectedValue: data?.expectedValue || "", @@ -141,6 +144,8 @@ export const getMonitorDefaults = ( url: "", method: DefaultHttpMethod, ignoreTlsErrors: false, + proxyMode: DefaultProxyMode, + proxyId: "", useAdvancedMatching: false, matchMethod: DefaultMonitorMatchMethod, expectedValue: "", diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index dd9cdc48f..2ff2a985c 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -41,6 +41,8 @@ import { DefaultMonitorMatchMethod, MonitorMatchMethods, GeoCheckIntervalOptions, + ProxyModes, + type ProxyMode, } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; @@ -56,6 +58,7 @@ import { FormMultiSelectField } from "@/Components/inputs/forms/FormMultiSelectF import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; +import type { ProxyResponse } from "@/Types/Proxy"; const httpMethodOptions = HttpMethods.map((method) => ({ value: method, @@ -67,6 +70,7 @@ interface GeneralSettingsConfig { urlPlaceholder: string; namePlaceholder: string; showUrl: boolean; + showProxy: boolean; showPort: boolean; showGameSelect: boolean; showSecret: boolean; @@ -87,6 +91,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: true, showPort: false, showGameSelect: false, showSecret: false, @@ -101,6 +106,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -115,6 +121,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -129,6 +136,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: false, showSecret: false, @@ -143,6 +151,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: true, showSecret: false, @@ -157,6 +166,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: true, showGameSelect: false, showSecret: false, @@ -171,6 +181,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -185,6 +196,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.captureUrl.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: true, @@ -199,6 +211,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -213,6 +226,7 @@ const getGeneralSettingsConfig = ( urlPlaceholder: t("pages.createMonitor.form.general.option.url.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, + showProxy: false, showPort: false, showGameSelect: false, showSecret: false, @@ -258,6 +272,11 @@ const CreateMonitorPage = () => { const { data: notifications } = useGet("/notifications/team"); const { data: games } = useGet("/monitors/games"); const { data: tags } = useGet("/tags/team"); + const { data: proxies } = useGet("/proxies/team"); + const { data: appSettings } = useGet<{ + globalProxyEnabled?: boolean; + globalProxyId?: string | null; + }>("/settings"); const { schema, defaults } = useMonitorForm({ data: existingMonitor ?? null, @@ -281,6 +300,7 @@ const CreateMonitorPage = () => { const watchedType = watch("type") as MonitorType; const watchedMethod = watch("method") as HttpMethod | undefined; + const watchedProxyMode = watch("proxyMode") as ProxyMode | undefined; const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; @@ -466,6 +486,28 @@ const CreateMonitorPage = () => { })), [t] ); + const proxyModeOptions = useMemo( + () => + ProxyModes.map((mode) => ({ + value: mode, + label: t(`pages.createMonitor.form.general.option.proxyMode.value.${mode}`), + })), + [t] + ); + + const proxyOptions = useMemo( + () => + (proxies ?? []).map((proxy) => ({ + value: proxy.id, + label: `${proxy.name} (${proxy.host}:${proxy.port})`, + })), + [proxies] + ); + + const globalProxyName = + appSettings?.globalProxyEnabled === true + ? proxies?.find((proxy) => proxy.id === appSettings?.globalProxyId)?.name + : undefined; return ( @@ -518,6 +560,11 @@ const CreateMonitorPage = () => { } rightContent={ + {/* URL/Host/Container field - not shown for hardware */} {generalSettingsConfig.showUrl && ( { disabled={isEditMode} /> )} + {/* Proxy fields - only shown for HTTP */} + {generalSettingsConfig.showProxy && ( + <> + + {watchedProxyMode === "inherit" && ( + + {globalProxyName + ? t( + "pages.createMonitor.form.general.option.proxyMode.effect.global", + { name: globalProxyName } + ) + : t( + "pages.createMonitor.form.general.option.proxyMode.effect.direct" + )} + + )} + {watchedProxyMode === "custom" && ( + + )} + + )} {/* Strategy field - only for pagespeed type */} {generalSettingsConfig.showStrategy && ( @@ -603,11 +691,6 @@ const CreateMonitorPage = () => { options={dnsRecordTypeOptions} /> )} - } /> diff --git a/client/src/Pages/Proxies/index.tsx b/client/src/Pages/Proxies/index.tsx index 5b40242b8..5370543dc 100644 --- a/client/src/Pages/Proxies/index.tsx +++ b/client/src/Pages/Proxies/index.tsx @@ -32,7 +32,7 @@ const ProxiesPage = () => { setSelectedProxy(null); refetch(); }; - ``; + const handleCancel = () => { setSelectedProxy(null); }; diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index e73b38255..968b468c2 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -39,11 +39,14 @@ import CreateNotifications from "@/Pages/Notifications/create"; import Tags from "@/Pages/Tags"; import CreateTags from "@/Pages/Tags/create"; +// Proxies +import Proxies from "@/Pages/Proxies"; +import CreateProxies from "@/Pages/Proxies/create"; + // Settings import Account from "@/Pages/Account"; import EditUser from "@/Pages/Account/EditUser"; import Settings from "@/Pages/Settings"; - import Maintenance from "@/Pages/Maintenance"; import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create"; @@ -52,10 +55,8 @@ import Logs from "@/Pages/Logs"; // Routing import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected"; - import CreateMonitor from "@/Pages/CreateMonitor"; import { isCustomDomainHost } from "@/Utils/statusPageUrl"; - const Routes = () => { if (isCustomDomainHost()) { return ( @@ -188,12 +189,24 @@ const Routes = () => { path="tags/create" element={} /> - } /> + } + /> + } + /> + } + /> + } diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 6eaa280d7..a5ed14c01 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -109,6 +109,10 @@ export const HttpMethods = ["GET", "HEAD"] as const; export type HttpMethod = (typeof HttpMethods)[number]; export const DefaultHttpMethod: HttpMethod = "GET"; +export const ProxyModes = ["inherit", "none", "custom"] as const; +export type ProxyMode = (typeof ProxyModes)[number]; +export const DefaultProxyMode: ProxyMode = "inherit"; + export interface Monitor { id: string; userId: string; @@ -121,6 +125,8 @@ export interface Monitor { statusWindowThreshold: number; type: MonitorType; ignoreTlsErrors: boolean; + proxyMode: ProxyMode; + proxyId?: string; useAdvancedMatching: boolean; jsonPath?: string; expectedValue?: string; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index fe8f0319e..b78e625ee 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -205,6 +205,7 @@ "infrastructure": "Infrastructure", "notifications": "Notifications", "tags": "Tags", + "proxies": "Proxies", "checks": "Checks", "incidents": "Incidents", "statusPages": "Status pages", @@ -244,6 +245,63 @@ } }, "pages": { + "proxies": { + "header": { + "title": "Proxies", + "description": "Manage your proxy servers for routing HTTP monitoring traffic." + }, + "fallback": { + "title": "No proxies yet", + "description": "Route HTTP monitor checks through a proxy server. Once you create a proxy, you can select it on individual monitors or set it as the global default in Settings.", + "actionButton": "Create a proxy" + }, + "form": { + "name": { + "title": "Proxy name", + "description": "A friendly name to identify this proxy", + "optionName": "Proxy name", + "placeholder": "My proxy" + }, + "server": { + "title": "Proxy server", + "description": "Where the proxy server is reachable. Set the protocol Checkmate uses to connect to it, its hostname or IP address, and the port it listens on.", + "option": { + "protocol": { + "label": "Protocol" + }, + "host": { + "label": "Hostname / IP Address", + "placeholder": "proxy.example.com" + }, + "port": { + "label": "Port" + } + } + }, + "auth": { + "title": "Authentication", + "description": "Optional. Only needed if the proxy server requires basic authentication.", + "option": { + "username": { + "label": "Username", + "placeholder": "Proxy username", + "labelSet": "Username is set. Click Reset to change it." + }, + "password": { + "label": "Password", + "labelSet": "Password is set. Click Reset to change it." + } + } + } + }, + "table": { + "headers": { + "protocol": "Protocol", + "host": "Host", + "port": "Port" + } + } + }, "notFound": { "title": "Oh no! You dropped your sushi!", "subtitle": "Either the URL doesn’t exist, or you don’t have access to it." @@ -581,6 +639,21 @@ "label": "URL", "placeholder": "https://www.google.com" }, + "proxyMode": { + "label": "Proxy mode", + "value": { + "inherit": "Use global proxy setting", + "none": "No proxy (direct connection)", + "custom": "Custom" + }, + "effect": { + "global": "Checks currently go through the global proxy: {{name}}", + "direct": "Global proxy not enabled, will use direct connection" + } + }, + "proxy": { + "label": "Proxy server" + }, "game": { "label": "Choose game", "placeholder": "Select a game" @@ -624,7 +697,7 @@ }, "title": "General settings", "description": { - "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard.", + "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.", "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", From 9bb95e1f0c00074a102d98428d649d80fd720aa9 Mon Sep 17 00:00:00 2001 From: eralpozcan Date: Sat, 22 Aug 2026 14:05:44 +0300 Subject: [PATCH 124/143] feat: show monitor tags in status page selector --- client/src/Pages/StatusPage/Create/index.tsx | 32 +++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index c04bcb186..f54b981ba 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -1,4 +1,9 @@ -import { BasePage, ConfigBox, StepProgress } from "@/Components/design-elements"; +import { + BasePage, + ColoredLabel, + ConfigBox, + StepProgress, +} from "@/Components/design-elements"; import Stack from "@mui/material/Stack"; import { logger } from "@/Utils/logger"; import { LAYOUT } from "@/Utils/Theme/constants"; @@ -16,6 +21,7 @@ import { statusPageStepCount, statusPageStepFieldsFor } from "@/Validation/statu import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; import { SelectableMonitorTypes } from "@/Types/Monitor"; +import type { Tag } from "@/Types/Tag"; import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"; import { getMonitorTypeLabel } from "@/Types/StatusPage"; import { timezoneOptions } from "@/Utils/timezoneOptions"; @@ -86,6 +92,8 @@ const CreateStatusPage = () => { const { data: monitorsResponse } = useGet(monitorsUrl); const monitors = useMemo(() => monitorsResponse ?? [], [monitorsResponse]); + const { data: tagsResponse } = useGet("/tags/team"); + const tags = useMemo(() => tagsResponse ?? [], [tagsResponse]); const { post, loading: isSubmittingPost } = usePost(); const { put, loading: isSubmittingPut } = usePut(); @@ -308,6 +316,28 @@ const CreateStatusPage = () => { "pages.statusPages.form.monitors.option.monitors.placeholder" )} options={monitors} + renderOptionContent={(monitor) => ( + + + {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} + + {monitor.tags.map((tagId) => { + const tag = tags.find(({ id }) => id === tagId); + return tag ? ( + + ) : null; + })} + + )} renderRow={(monitor) => ( {`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`} From 0c21dcdd2ad9e8e33560e48890bee6529531e208 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 09:39:56 -0700 Subject: [PATCH 125/143] add settings response type, destructure --- client/src/Pages/CreateMonitor/index.tsx | 12 ++++++------ client/src/Types/Settings.ts | 2 ++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 2ff2a985c..05ecfdded 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -46,6 +46,7 @@ import { } from "@/Types/Monitor"; import type { Notification } from "@/Types/Notification"; import type { Tag } from "@/Types/Tag"; +import type { AppSettingsResponse } from "@/Types/Settings"; import { stepFieldsFor, monitorStepCount, @@ -273,10 +274,7 @@ const CreateMonitorPage = () => { const { data: games } = useGet("/monitors/games"); const { data: tags } = useGet("/tags/team"); const { data: proxies } = useGet("/proxies/team"); - const { data: appSettings } = useGet<{ - globalProxyEnabled?: boolean; - globalProxyId?: string | null; - }>("/settings"); + const { data: appSettings } = useGet("/settings"); const { schema, defaults } = useMonitorForm({ data: existingMonitor ?? null, @@ -505,10 +503,12 @@ const CreateMonitorPage = () => { ); const globalProxyName = - appSettings?.globalProxyEnabled === true - ? proxies?.find((proxy) => proxy.id === appSettings?.globalProxyId)?.name + appSettings?.settings?.globalProxyEnabled === true + ? proxies?.find((proxy) => proxy.id === appSettings?.settings?.globalProxyId)?.name : undefined; + console.log({ appSettings }); + return ( Date: Tue, 25 Aug 2026 09:55:34 -0700 Subject: [PATCH 126/143] select -> radio, update descriptions --- .../inputs/forms/FormRadioGroupField.tsx | 4 ++ client/src/Pages/CreateMonitor/index.tsx | 59 ++++++++----------- client/src/locales/en.json | 17 +++--- 3 files changed, 39 insertions(+), 41 deletions(-) diff --git a/client/src/Components/inputs/forms/FormRadioGroupField.tsx b/client/src/Components/inputs/forms/FormRadioGroupField.tsx index a751ff0ed..ed97ae2ee 100644 --- a/client/src/Components/inputs/forms/FormRadioGroupField.tsx +++ b/client/src/Components/inputs/forms/FormRadioGroupField.tsx @@ -8,6 +8,7 @@ import { } from "react-hook-form"; import { LAYOUT } from "@/Utils/Theme/constants"; import { RadioWithDescription } from "@/Components/inputs/Radio"; +import { FieldLabel } from "@/Components/inputs/FieldLabel"; export interface FormRadioOption { value: string; label: string; @@ -17,10 +18,12 @@ export interface FormRadioOption { interface FormRadioGroupProps { name: FieldPath; options: FormRadioOption[]; + fieldLabel?: string; } export const FormRadioGroup = ({ name, options, + fieldLabel, }: FormRadioGroupProps) => { const { control } = useFormContext(); const theme = useTheme(); @@ -30,6 +33,7 @@ export const FormRadioGroup = ({ control={control} render={({ field, fieldState }) => ( + {fieldLabel && {fieldLabel}} { })), [t] ); + const globalProxyName = + appSettings?.settings?.globalProxyEnabled === true + ? proxies?.find((proxy) => proxy.id === appSettings?.settings?.globalProxyId)?.name + : undefined; + const proxyModeOptions = useMemo( () => - ProxyModes.map((mode) => ({ - value: mode, - label: t(`pages.createMonitor.form.general.option.proxyMode.value.${mode}`), - })), - [t] + ProxyModes.map((mode) => { + const key = + mode === "inherit" + ? globalProxyName + ? "inheritGlobal" + : "inheritDirect" + : mode; + return { + value: mode, + label: t(`pages.createMonitor.form.general.option.proxyMode.value.${key}`, { + name: globalProxyName, + }), + description: t( + `pages.createMonitor.form.general.option.proxyMode.description.${key}` + ), + }; + }), + [t, globalProxyName] ); const proxyOptions = useMemo( @@ -502,13 +520,6 @@ const CreateMonitorPage = () => { [proxies] ); - const globalProxyName = - appSettings?.settings?.globalProxyEnabled === true - ? proxies?.find((proxy) => proxy.id === appSettings?.settings?.globalProxyId)?.name - : undefined; - - console.log({ appSettings }); - return ( { {/* Proxy fields - only shown for HTTP */} {generalSettingsConfig.showProxy && ( <> - - {watchedProxyMode === "inherit" && ( - - {globalProxyName - ? t( - "pages.createMonitor.form.general.option.proxyMode.effect.global", - { name: globalProxyName } - ) - : t( - "pages.createMonitor.form.general.option.proxyMode.effect.direct" - )} - - )} {watchedProxyMode === "custom" && ( Date: Tue, 25 Aug 2026 10:29:19 -0700 Subject: [PATCH 127/143] add global proxy setting config and translation keys --- client/src/Hooks/useSettingsForm.ts | 2 + client/src/Pages/Settings/index.tsx | 64 ++++++++++++++- client/src/Validation/settings.ts | 122 +++++++++++++++------------- client/src/locales/en.json | 20 ++++- 4 files changed, 150 insertions(+), 58 deletions(-) diff --git a/client/src/Hooks/useSettingsForm.ts b/client/src/Hooks/useSettingsForm.ts index 643e9caca..95947928c 100644 --- a/client/src/Hooks/useSettingsForm.ts +++ b/client/src/Hooks/useSettingsForm.ts @@ -43,6 +43,8 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => checkTTL: data?.checkTTL ?? 30, pagespeedApiKey: "", systemEmailPassword: "", + globalProxyEnabled: data?.globalProxyEnabled ?? false, + globalProxyId: data?.globalProxyId ?? null, }; return { schema: settingsSchema, defaults }; diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index cc1472de2..3377182af 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -10,7 +10,7 @@ import { Alert, type SelectChangeEvent, } from "@mui/material"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { FormProvider, useForm, type FieldPath } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "react-i18next"; @@ -43,6 +43,8 @@ import { FormTextField } from "@/Components/inputs/forms/FormTextField"; import { FormSliderField } from "@/Components/inputs/forms/FormSliderField"; import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; +import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; +import type { ProxyResponse } from "@/Types/Proxy"; interface SettingsResponse { settings: any; @@ -65,6 +67,17 @@ export const SettingsPage = () => { // Fetch settings data from API const { data: fetchedSettings } = useGet("/settings"); + // Get proxies + const { data: proxies } = useGet("/proxies/team"); + const proxyOptions = useMemo( + () => + (proxies ?? []).map((proxy) => ({ + value: proxy.id, + label: `${proxy.name} (${proxy.host}:${proxy.port})`, + })), + [proxies] + ); + // Form submission const { patch, loading: isSaving } = usePatch(); @@ -115,6 +128,8 @@ export const SettingsPage = () => { mode: "onChange", }); + const { watch } = form; + // Reset form when defaults change useEffect(() => { form.reset(defaults); @@ -292,6 +307,21 @@ export const SettingsPage = () => { const languages = Object.keys(i18n.options.resources || {}); + const globalProxyEnabled = watch("globalProxyEnabled"); + + // Enabling the proxy with nothing selected preselects the first proxy + useEffect(() => { + if ( + globalProxyEnabled && + !form.getValues("globalProxyId") && + proxyOptions.length > 0 + ) { + form.setValue("globalProxyId", proxyOptions[0].value, { + shouldValidate: true, + }); + } + }, [globalProxyEnabled, proxyOptions, form]); + return ( { } /> + {isAdmin && ( + 0 ? ( + + + {globalProxyEnabled && ( + + )} + + ) : ( + + ) + } + /> + )} {isAdmin && ( (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailSecure: z.boolean().optional(), - systemEmailPool: z.boolean().optional(), - showURL: z.boolean().optional(), - checkTTL: z - .number() - .int() - .min(1, "Please enter a value") - .max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`), - pagespeedApiKey: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailHost: z - .string() - .regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address") - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailPort: z.number().int().min(1).max(65535).optional(), - systemEmailAddress: z - .email("Please enter a valid email address") - .or(z.literal("")) - .transform((val) => (val === "" ? null : val.toLowerCase().trim())) - .optional(), - systemEmailDisplayName: z - .string() - .max(100, "Display name must be 100 characters or fewer") - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailUser: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailPassword: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - systemEmailTLSServername: z - .string() - .transform((val) => (val.trim() === "" ? null : val.trim())) - .optional(), - globalThresholds: z.object({ - cpu: z.number().int().min(1).max(100), - memory: z.number().int().min(1).max(100), - disk: z.number().int().min(1).max(100), - temperature: z.number().int().min(1).max(150), - }), -}); +export const settingsSchema = z + .object({ + systemEmailIgnoreTLS: z.boolean(), + systemEmailRequireTLS: z.boolean(), + systemEmailRejectUnauthorized: z.boolean(), + systemEmailConnectionHost: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailSecure: z.boolean().optional(), + systemEmailPool: z.boolean().optional(), + showURL: z.boolean().optional(), + checkTTL: z + .number() + .int() + .min(1, "Please enter a value") + .max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`), + pagespeedApiKey: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailHost: z + .string() + .regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address") + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailPort: z.number().int().min(1).max(65535).optional(), + systemEmailAddress: z + .email("Please enter a valid email address") + .or(z.literal("")) + .transform((val) => (val === "" ? null : val.toLowerCase().trim())) + .optional(), + systemEmailDisplayName: z + .string() + .max(100, "Display name must be 100 characters or fewer") + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailUser: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailPassword: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + systemEmailTLSServername: z + .string() + .transform((val) => (val.trim() === "" ? null : val.trim())) + .optional(), + globalThresholds: z.object({ + cpu: z.number().int().min(1).max(100), + memory: z.number().int().min(1).max(100), + disk: z.number().int().min(1).max(100), + temperature: z.number().int().min(1).max(150), + }), + globalProxyEnabled: z.boolean(), + globalProxyId: z.string().nullable().optional(), + }) + .superRefine((body, ctx) => { + if (body.globalProxyEnabled === true && !body.globalProxyId) { + ctx.addIssue({ + code: "custom", + path: ["globalProxyId"], + message: "A proxy must be selected to enable the global proxy", + }); + } + }); export type SettingsFormInput = z.input; export type SettingsFormData = z.infer; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b233deba9..e82f84949 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -643,13 +643,13 @@ "label": "Proxy", "value": { "inheritDirect": "Default (direct connection)", - "inheritGlobal": "Default (global proxy: {{name}})", + "inheritGlobal": "Default ({{name}})", "none": "Never use a proxy", "custom": "Custom proxy" }, "description": { "inheritDirect": "No global proxy is configured, connect directly. If a global proxy is later configured, this monitor will use it.", - "inheritGlobal": "Follows the global proxy setting — changing it in Settings also changes this monitor's connection.", + "inheritGlobal": "Use the global proxy setting", "none": "Always connects directly even if a global proxy is enabled later.", "custom": "Route this monitor's checks through a specific proxy. Overrides global proxy" } @@ -1362,6 +1362,22 @@ } } }, + "globalProxy": { + "title": "Global proxy", + "description": "Route monitoring checks through a proxy by default. Applies to all HTTP monitors whose proxy setting is \"Default\"; monitors set to \"Never use a proxy\" or a custom proxy are unaffected.", + "option": { + "enabled": { + "label": "Use global proxy" + }, + "proxy": { + "label": "Choose a global proxy" + }, + "empty": { + "text": "No proxies configured yet.", + "link": "Create one first" + } + } + }, "pagespeed": { "title": "Google PageSpeed API key", "description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.", From b2dca7b62f32441de696c9998ed32b10ad427083 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 11:02:37 -0700 Subject: [PATCH 128/143] add getAllProxies route --- server/src/api/controllers/proxyController.ts | 10 ++++++++++ server/src/api/routes/proxyRoutes.ts | 2 ++ server/src/domain/checks/check.repository.mongo.ts | 5 ++++- .../src/domain/proxies/proxy.repository.interface.ts | 1 + server/src/domain/proxies/proxy.repository.mongo.ts | 5 +++++ server/src/domain/proxies/proxy.service.ts | 8 ++++++++ 6 files changed, 30 insertions(+), 1 deletion(-) diff --git a/server/src/api/controllers/proxyController.ts b/server/src/api/controllers/proxyController.ts index 0ca72ed95..d509a490a 100644 --- a/server/src/api/controllers/proxyController.ts +++ b/server/src/api/controllers/proxyController.ts @@ -13,6 +13,7 @@ import { export interface IProxiesController { createProxy: RequestHandler; getProxyById: RequestHandler; + getAllProxies: RequestHandler; getProxiesByTeamId: RequestHandler; editProxy: RequestHandler; deleteProxy: RequestHandler; @@ -43,6 +44,15 @@ class ProxyController implements IProxiesController { }); }); + getAllProxies = catchAsync(async (req: Request, res: Response) => { + const proxies = await this.proxiesService.getProxies(); + return res.status(200).json({ + success: true, + msg: "Proxies retrieved successfully", + data: proxies, + }); + }); + getProxiesByTeamId = catchAsync(async (req: Request, res: Response) => { const teamId = requireTeamId(req.user?.teamId); const proxies = await this.proxiesService.getProxiesByTeamId(teamId); diff --git a/server/src/api/routes/proxyRoutes.ts b/server/src/api/routes/proxyRoutes.ts index 83bf6a318..fca695099 100644 --- a/server/src/api/routes/proxyRoutes.ts +++ b/server/src/api/routes/proxyRoutes.ts @@ -1,9 +1,11 @@ import { IProxiesController } from "@/api/controllers/proxyController.js"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; import { Router } from "express"; export const createProxyRoutes = (proxyController: IProxiesController): Router => { const router = Router(); router.post("/", proxyController.createProxy); + router.get("/", isAllowed(["admin", "superadmin"]), proxyController.getAllProxies); router.get("/team", proxyController.getProxiesByTeamId); router.get("/:id", proxyController.getProxyById); router.delete("/:id", proxyController.deleteProxy); diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index de144ea80..93ba2c42b 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -25,7 +25,7 @@ import { CheckFilter, DateRange } from "@/types/query.js"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR } from "@/types/network.js"; -const SERVICE_NAME = "StatusService"; +const SERVICE_NAME = "ChecksRepository"; class MongoChecksRepository implements IChecksRepository { static SERVICE_NAME = SERVICE_NAME; @@ -213,6 +213,9 @@ class MongoChecksRepository implements IChecksRepository { }; private filterToMatch = (filter: CheckFilter | undefined): Record => { + if (filter === undefined) { + return {}; + } switch (filter) { case "up": return { status: true }; diff --git a/server/src/domain/proxies/proxy.repository.interface.ts b/server/src/domain/proxies/proxy.repository.interface.ts index 5d39ddb80..12d145ef3 100644 --- a/server/src/domain/proxies/proxy.repository.interface.ts +++ b/server/src/domain/proxies/proxy.repository.interface.ts @@ -4,6 +4,7 @@ export interface IProxiesRepository { // create create(proxyData: Partial): Promise; // read + findAll(): Promise; findById(proxyId: string, teamId: string): Promise; findByIdOrNull(proxyId: string, teamId?: string): Promise; findByTeamId(teamId: string): Promise; diff --git a/server/src/domain/proxies/proxy.repository.mongo.ts b/server/src/domain/proxies/proxy.repository.mongo.ts index 454ef68ab..cf11fd458 100644 --- a/server/src/domain/proxies/proxy.repository.mongo.ts +++ b/server/src/domain/proxies/proxy.repository.mongo.ts @@ -37,6 +37,11 @@ class MongoProxiesRepository implements IProxiesRepository { } } + async findAll(): Promise { + const proxies = await ProxyModel.find(); + return proxies.map(this.toEntity); + } + async findById(proxyId: string, teamId: string): Promise { const proxy = await ProxyModel.findOne({ _id: proxyId, teamId }); if (!proxy) { diff --git a/server/src/domain/proxies/proxy.service.ts b/server/src/domain/proxies/proxy.service.ts index 265544d8c..a1c726f1e 100644 --- a/server/src/domain/proxies/proxy.service.ts +++ b/server/src/domain/proxies/proxy.service.ts @@ -7,6 +7,7 @@ import { ISettingsService } from "@/domain/app-settings/app-settings.service.js" export interface IProxiesService { createProxy(proxy: Partial, teamId: string): Promise; getProxy(proxyId: string, teamId: string): Promise; + getProxies(): Promise; getProxiesByTeamId(teamId: string): Promise; updateProxy(proxyId: string, teamId: string, patch: Partial & { clearPassword?: boolean; clearUsername?: boolean }): Promise; deleteProxy(proxyId: string, teamId: string): Promise; @@ -39,6 +40,13 @@ export class ProxiesService implements IProxiesService { return this.toResponse(raw); }; + getProxies = async (): Promise => { + const raw = await this.proxiesRepository.findAll(); + const clean = raw.map((r) => { + return this.toResponse(r); + }); + return clean; + }; getProxiesByTeamId = async (teamId: string): Promise => { const raw = await this.proxiesRepository.findByTeamId(teamId); const clean = raw.map((r) => { From 3f62c8ec6a320f81ad0adead228ce1730dcc8ce4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 11:13:12 -0700 Subject: [PATCH 129/143] resolve scoping issue by fetching proxy summary --- client/src/Pages/CreateMonitor/index.tsx | 5 +---- client/src/Pages/Settings/index.tsx | 13 ++++-------- client/src/Types/Settings.ts | 6 ++++++ .../src/api/controllers/settingsController.ts | 21 +++++++++++++++---- server/src/config/controllers.ts | 2 +- server/src/domain/proxies/proxy.service.ts | 13 +++++++++++- server/src/domain/proxies/proxy.type.ts | 2 ++ 7 files changed, 43 insertions(+), 19 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 6d19ae25b..5c2b693e0 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -484,10 +484,7 @@ const CreateMonitorPage = () => { })), [t] ); - const globalProxyName = - appSettings?.settings?.globalProxyEnabled === true - ? proxies?.find((proxy) => proxy.id === appSettings?.settings?.globalProxyId)?.name - : undefined; + const globalProxyName = appSettings?.globalProxy?.name; const proxyModeOptions = useMemo( () => diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 3377182af..70a8e1bb6 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -45,12 +45,7 @@ import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import type { ProxyResponse } from "@/Types/Proxy"; - -interface SettingsResponse { - settings: any; - pagespeedKeySet: boolean; - emailPasswordSet: boolean; -} +import type { AppSettingsResponse } from "@/Types/Settings"; export const SettingsPage = () => { const theme = useTheme(); @@ -66,9 +61,9 @@ export const SettingsPage = () => { const { post: importMonitors, loading: isImportingMonitors } = usePost(); // Fetch settings data from API - const { data: fetchedSettings } = useGet("/settings"); + const { data: fetchedSettings } = useGet("/settings"); // Get proxies - const { data: proxies } = useGet("/proxies/team"); + const { data: proxies } = useGet("/proxies"); const proxyOptions = useMemo( () => (proxies ?? []).map((proxy) => ({ @@ -79,7 +74,7 @@ export const SettingsPage = () => { ); // Form submission - const { patch, loading: isSaving } = usePatch(); + const { patch, loading: isSaving } = usePatch(); // Local state for API key reset const [isApiKeySet, setIsApiKeySet] = useState( diff --git a/client/src/Types/Settings.ts b/client/src/Types/Settings.ts index 22cbb6a8d..01d34aead 100644 --- a/client/src/Types/Settings.ts +++ b/client/src/Types/Settings.ts @@ -33,5 +33,11 @@ export interface Settings { export interface AppSettingsResponse { pagespeedKeySet: boolean; emailPasswordSet: boolean; + globalProxy: { + id: string; + name: string; + host: string; + port: number; + } | null; settings: Settings; } diff --git a/server/src/api/controllers/settingsController.ts b/server/src/api/controllers/settingsController.ts index a8113ed2b..d02c3886a 100644 --- a/server/src/api/controllers/settingsController.ts +++ b/server/src/api/controllers/settingsController.ts @@ -5,6 +5,7 @@ import { sendTestEmailBodyValidation } from "@/api/validation/notificationValida import { AppError } from "@/utils/AppError.js"; import { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import { IEmailService } from "@/service/emailService.js"; +import { IProxiesService } from "@/domain/proxies/proxy.service.js"; import { Settings } from "@/domain/app-settings/app-settings.type.js"; export interface ISettingsController { @@ -16,18 +17,23 @@ export interface ISettingsController { class SettingsController implements ISettingsController { private settingsService: ISettingsService; private emailService: IEmailService; - constructor(settingsService: ISettingsService, emailService: IEmailService) { + private proxiesService: IProxiesService; + constructor(settingsService: ISettingsService, emailService: IEmailService, proxiesService: IProxiesService) { this.settingsService = settingsService; this.emailService = emailService; + this.proxiesService = proxiesService; } - buildAppSettings = (dbSettings: Settings) => { + buildAppSettings = async (dbSettings: Settings) => { const sanitizedSettings: Record = { ...dbSettings }; delete sanitizedSettings.version; delete sanitizedSettings.jwtSecret; + const globalProxy = + dbSettings.globalProxyEnabled && dbSettings.globalProxyId ? await this.proxiesService.getProxySummary(dbSettings.globalProxyId) : null; const returnSettings: Record = { pagespeedKeySet: false, emailPasswordSet: false, + globalProxy, settings: null, }; @@ -46,7 +52,7 @@ class SettingsController implements ISettingsController { getAppSettings = catchAsync(async (req: Request, res: Response) => { const dbSettings = await this.settingsService.getDBSettings(); - const returnSettings = this.buildAppSettings(dbSettings); + const returnSettings = await this.buildAppSettings(dbSettings); return res.status(200).json({ success: true, msg: "App settings fetched successfully", @@ -57,8 +63,15 @@ class SettingsController implements ISettingsController { updateAppSettings = catchAsync(async (req: Request, res: Response) => { const validatedBody = updateAppSettingsBodyValidation.parse(req.body); + if (validatedBody.globalProxyId) { + const proxy = await this.proxiesService.getProxySummary(validatedBody.globalProxyId); + if (!proxy) { + throw new AppError({ message: "Referenced proxy does not exist", status: 422 }); + } + } + const updatedSettings = await this.settingsService.updateDbSettings(validatedBody); - const returnSettings = this.buildAppSettings(updatedSettings); + const returnSettings = await this.buildAppSettings(updatedSettings); return res.status(200).json({ success: true, msg: "App settings updated successfully", diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 4f683babf..7787a389e 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -36,7 +36,7 @@ export const initializeControllers = (apiServices: ApiServices): InitializedCont return { authController: new AuthController(apiServices.userService), monitorController: new MonitorController(apiServices.monitorService, apiServices.notificationsService), - settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService), + settingsController: new SettingsController(apiServices.settingsService, apiServices.emailService, apiServices.proxiesService), checkController: new CheckController(apiServices.checkService), geoCheckController: new GeoCheckController(apiServices.geoChecksService), inviteController: new InviteController(apiServices.inviteService), diff --git a/server/src/domain/proxies/proxy.service.ts b/server/src/domain/proxies/proxy.service.ts index a1c726f1e..9758f2856 100644 --- a/server/src/domain/proxies/proxy.service.ts +++ b/server/src/domain/proxies/proxy.service.ts @@ -1,4 +1,4 @@ -import { Proxy, ProxyResponse } from "@/domain/proxies/proxy.type.js"; +import { Proxy, ProxyResponse, ProxySummary } from "@/domain/proxies/proxy.type.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface.js"; import { AppError } from "@/utils/AppError.js"; @@ -8,6 +8,7 @@ export interface IProxiesService { createProxy(proxy: Partial, teamId: string): Promise; getProxy(proxyId: string, teamId: string): Promise; getProxies(): Promise; + getProxySummary(proxyId: string): Promise; getProxiesByTeamId(teamId: string): Promise; updateProxy(proxyId: string, teamId: string, patch: Partial & { clearPassword?: boolean; clearUsername?: boolean }): Promise; deleteProxy(proxyId: string, teamId: string): Promise; @@ -47,6 +48,16 @@ export class ProxiesService implements IProxiesService { }); return clean; }; + + getProxySummary = async (proxyId: string): Promise => { + const proxy = await this.proxiesRepository.findByIdOrNull(proxyId); + if (!proxy) { + return null; + } + const { id, name, host, port } = proxy; + return { id, name, host, port }; + }; + getProxiesByTeamId = async (teamId: string): Promise => { const raw = await this.proxiesRepository.findByTeamId(teamId); const clean = raw.map((r) => { diff --git a/server/src/domain/proxies/proxy.type.ts b/server/src/domain/proxies/proxy.type.ts index dd098dee3..c16b0c28c 100644 --- a/server/src/domain/proxies/proxy.type.ts +++ b/server/src/domain/proxies/proxy.type.ts @@ -15,3 +15,5 @@ export interface Proxy { } export type ProxyResponse = Omit & { hasPassword: boolean }; + +export type ProxySummary = Pick; From b3e98ac95dafe5f11b2f3ffac0ea25463c97006f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 11:14:50 -0700 Subject: [PATCH 130/143] update openapi docs --- server/openapi.json | 1034 ++++++++++++++++++++++++++++++ server/openapi/index.ts | 2 + server/openapi/routes/proxies.ts | 75 +++ 3 files changed, 1111 insertions(+) create mode 100644 server/openapi/routes/proxies.ts diff --git a/server/openapi.json b/server/openapi.json index 759fca9d7..c2408b580 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -48,6 +48,10 @@ "name": "settings", "description": "Global application settings (admin/superadmin)." }, + { + "name": "proxies", + "description": "Outbound proxy servers for routing HTTP monitor checks, team-scoped with an instance-wide admin listing." + }, { "name": "invite", "description": "Team invitations and accepting them." @@ -373,6 +377,13 @@ "ignoreTlsErrors": { "type": "boolean" }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean" }, @@ -486,6 +497,7 @@ "statusWindowSize", "statusWindowThreshold", "ignoreTlsErrors", + "proxyMode", "useAdvancedMatching", "method", "notifications", @@ -580,6 +592,13 @@ "ignoreTlsErrors": { "type": "boolean" }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean" }, @@ -695,6 +714,7 @@ "statusWindowSize", "statusWindowThreshold", "ignoreTlsErrors", + "proxyMode", "useAdvancedMatching", "method", "notifications", @@ -7785,6 +7805,14 @@ "type": "boolean", "default": false }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"], + "default": "inherit" + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean", "default": false @@ -8432,6 +8460,14 @@ "type": "boolean", "default": false }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"], + "default": "inherit" + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean", "default": false @@ -9050,6 +9086,13 @@ "ignoreTlsErrors": { "type": "boolean" }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, "useAdvancedMatching": { "type": "boolean" }, @@ -10210,6 +10253,990 @@ } } }, + "/proxies": { + "post": { + "tags": ["proxies"], + "summary": "Create a proxy for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": ["name", "protocol", "host", "port"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "get": { + "tags": ["proxies"], + "summary": "List all proxies on the instance (admin/superadmin)", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/proxies/team": { + "get": { + "tags": ["proxies"], + "summary": "List proxies for the caller's team", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, + "/proxies/{id}": { + "get": { + "tags": ["proxies"], + "summary": "Get a proxy by id", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "patch": { + "tags": ["proxies"], + "summary": "Edit a proxy", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "clearPassword": { + "type": "boolean" + }, + "clearUsername": { + "type": "boolean" + } + }, + "required": ["name", "protocol", "host", "port"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + }, + "delete": { + "tags": ["proxies"], + "summary": "Delete a proxy", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": ["id", "teamId", "name", "protocol", "host", "port", "hasPassword", "createdAt", "updatedAt"], + "additionalProperties": { + "nullable": true + } + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "409": { + "description": "Proxy is in use by monitors or set as the global proxy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, "/queue/jobs": { "get": { "tags": ["queue"], @@ -10839,6 +11866,13 @@ "maximum": 150 } } + }, + "globalProxyEnabled": { + "type": "boolean" + }, + "globalProxyId": { + "type": "string", + "nullable": true } } } diff --git a/server/openapi/index.ts b/server/openapi/index.ts index c96cec233..a50008a75 100644 --- a/server/openapi/index.ts +++ b/server/openapi/index.ts @@ -9,6 +9,7 @@ import "./routes/log.js"; import "./routes/maintenanceWindow.js"; import "./routes/monitor.js"; import "./routes/notification.js"; +import "./routes/proxies.js"; import "./routes/queue.js"; import "./routes/settings.js"; import "./routes/statusPage.js"; @@ -45,6 +46,7 @@ export function getOpenApiSpec(): JsonObject { { name: "maintenance-window", description: "Scheduled maintenance windows during which monitors do not generate incidents or alerts." }, { name: "status-page", description: "Public status pages, their configuration, and the monitors they expose." }, { name: "settings", description: "Global application settings (admin/superadmin)." }, + { name: "proxies", description: "Outbound proxy servers for routing HTTP monitor checks, team-scoped with an instance-wide admin listing." }, { name: "invite", description: "Team invitations and accepting them." }, { name: "queue", description: "Background job queue introspection and admin actions (admin/superadmin)." }, { name: "diagnostic", description: "System diagnostics for the running server (admin/superadmin)." }, diff --git a/server/openapi/routes/proxies.ts b/server/openapi/routes/proxies.ts new file mode 100644 index 000000000..0a742fcf1 --- /dev/null +++ b/server/openapi/routes/proxies.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { registry } from "../registry.js"; +import { bearer, json, okJson, errorEnvelope, standardErrors } from "../helpers.js"; +import { + createProxyBodyValidation, + editProxyBodyValidation, + getProxyByIdParamValidation, + editProxyParamValidation, + deleteProxyParamValidation, + proxyResponseSchema, +} from "@/api/validation/proxyValidation.js"; + +const tags = ["proxies"]; + +registry.registerPath({ + method: "post", + path: "/proxies", + tags, + summary: "Create a proxy for the caller's team", + security: bearer, + request: { body: { content: json(createProxyBodyValidation) } }, + responses: { "200": okJson(proxyResponseSchema), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/proxies", + tags, + summary: "List all proxies on the instance (admin/superadmin)", + security: bearer, + responses: { "200": okJson(z.array(proxyResponseSchema)), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/proxies/team", + tags, + summary: "List proxies for the caller's team", + security: bearer, + responses: { "200": okJson(z.array(proxyResponseSchema)), ...standardErrors }, +}); + +registry.registerPath({ + method: "get", + path: "/proxies/{id}", + tags, + summary: "Get a proxy by id", + security: bearer, + request: { params: getProxyByIdParamValidation }, + responses: { "200": okJson(proxyResponseSchema), ...standardErrors }, +}); + +registry.registerPath({ + method: "patch", + path: "/proxies/{id}", + tags, + summary: "Edit a proxy", + security: bearer, + request: { params: editProxyParamValidation, body: { content: json(editProxyBodyValidation) } }, + responses: { "200": okJson(proxyResponseSchema), ...standardErrors }, +}); + +registry.registerPath({ + method: "delete", + path: "/proxies/{id}", + tags, + summary: "Delete a proxy", + security: bearer, + request: { params: deleteProxyParamValidation }, + responses: { + "200": okJson(proxyResponseSchema), + "409": { description: "Proxy is in use by monitors or set as the global proxy", content: json(errorEnvelope) }, + ...standardErrors, + }, +}); From af3c72b0550dea34ef447bcd909713e213b8e538 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 11:19:33 -0700 Subject: [PATCH 131/143] add tests --- .../test/unit/services/proxiesService.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/server/test/unit/services/proxiesService.test.ts b/server/test/unit/services/proxiesService.test.ts index 038754f28..b2c89ba7d 100644 --- a/server/test/unit/services/proxiesService.test.ts +++ b/server/test/unit/services/proxiesService.test.ts @@ -6,7 +6,9 @@ import type { Proxy } from "../../../src/domain/proxies/proxy.type.ts"; const createProxiesRepo = () => ({ create: jest.fn(), + findAll: jest.fn(), findById: jest.fn(), + findByIdOrNull: jest.fn(), findByTeamId: jest.fn(), updateById: jest.fn(), deleteById: jest.fn(), @@ -105,6 +107,44 @@ describe("ProxiesService", () => { }); }); + describe("getProxies", () => { + it("lists every proxy on the instance and masks passwords", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findAll as jest.Mock).mockResolvedValue([makeProxy(), makeProxy({ id: "proxy-2", teamId: "team-2", password: undefined })]); + + const result = await service.getProxies(); + + expect(proxiesRepository.findAll).toHaveBeenCalledWith(); + expect(result).toHaveLength(2); + for (const proxy of result) { + expect(proxy).not.toHaveProperty("password"); + } + expect(result[0].hasPassword).toBe(true); + expect(result[1].hasPassword).toBe(false); + }); + }); + + describe("getProxySummary", () => { + it("looks up without a team filter and returns only display fields", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findByIdOrNull as jest.Mock).mockResolvedValue(makeProxy()); + + const result = await service.getProxySummary("proxy-1"); + + expect(proxiesRepository.findByIdOrNull).toHaveBeenCalledWith("proxy-1"); + expect(result).toEqual({ id: "proxy-1", name: "Egress proxy", host: "proxy.internal", port: 3128 }); + }); + + it("returns null when the proxy does not exist", async () => { + const { service, proxiesRepository } = createService(); + (proxiesRepository.findByIdOrNull as jest.Mock).mockResolvedValue(null); + + const result = await service.getProxySummary("missing"); + + expect(result).toBeNull(); + }); + }); + describe("getProxiesByTeamId", () => { it("masks every proxy in the list", async () => { const { service, proxiesRepository } = createService(); From d7dce5d7a326d13c25943aac0cd3b35ab063c675 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 14:03:35 -0700 Subject: [PATCH 132/143] regen package lock --- server/package-lock.json | 3118 +++++++++++++++++++++----------------- 1 file changed, 1710 insertions(+), 1408 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 3cb17bea6..3bd7af904 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -89,6 +89,12 @@ "node": ">=20" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -102,28 +108,43 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.3", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.3.tgz", - "integrity": "sha512-kiGFeY+Hxf5KbPpjRLf+ffWbkos1aGo8MBfd91oxS3O57RgU3XhZrt/6UzoVF9VMpWbC3v87SRc9jxGrc9qHtQ==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.2" + "lru-cache": "^11.2.6" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", @@ -210,14 +231,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -243,16 +264,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -359,13 +370,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -430,13 +441,13 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -638,18 +649,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -657,9 +668,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -683,10 +694,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@colordx/core": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.5.0.tgz", - "integrity": "sha512-3PxTH8itZzltK0U9jTwVVnjLXvnDYuq3m+QXsHkENxWiPRh4WaoLcs1SQjqgZ55kS+QyirpH5BVwzP2gMVG6EQ==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.6.0.tgz", + "integrity": "sha512-EDlcg/Hmlj1WuFh/Os9YhA+A2aasB2XlgwFfGePUDuW7fVHC07HBi5AFIMx0Ct1eM8kHM9NRyOtZ0MuVYa8xvg==", "license": "MIT" }, "node_modules/@colors/colors": { @@ -815,9 +838,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", - "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.9.tgz", + "integrity": "sha512-iGGw4OsAYsS6pD29MdJ2bX/nJx65a04ZZiw6x+VwWlP2DdXf6f++Zmuv/OzALpdyfVhjbduIIF2cXM7HWBIe9A==", "funding": [ { "type": "github", @@ -829,11 +852,13 @@ } ], "license": "MIT-0", - "engines": { - "node": ">=18" - }, "peerDependencies": { - "postcss": "^8.4" + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } } }, "node_modules/@csstools/css-tokenizer": { @@ -868,9 +893,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", - "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -1321,9 +1346,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -1339,19 +1364,6 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", @@ -1363,37 +1375,68 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/config-helpers": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", - "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^0.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1404,20 +1447,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1427,6 +1470,24 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -1440,10 +1501,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/@eslint/js": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -1464,19 +1548,36 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@grpc/grpc-js": { "version": "1.14.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", @@ -1491,14 +1592,14 @@ } }, "node_modules/@grpc/proto-loader": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", - "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", "license": "Apache-2.0", "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", - "protobufjs": "^7.5.3", + "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { @@ -1509,29 +1610,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -2197,9 +2312,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -2608,31 +2723,34 @@ } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz", - "integrity": "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.0.tgz", + "integrity": "sha512-Hk1SKJCMcCos38+vqDnZzlIo4XRj9yCGzYkjB4LcqpeXRIYfia1UWTz+VrueLxoU+uSRJzgkufxoRZg8gi52YA==", "license": "MIT", "dependencies": { "sparse-bitfield": "^3.0.3" } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "node_modules/@nodelib/fs.scandir": { @@ -2754,9 +2872,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, "node_modules/@scarf/scarf": { @@ -2773,16 +2891,16 @@ "license": "MIT" }, "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, "node_modules/@sindresorhus/is": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.1.0.tgz", - "integrity": "sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", "license": "MIT", "engines": { "node": ">=18" @@ -2821,52 +2939,6 @@ "text-hex": "1.0.x" } }, - "node_modules/@so-ric/colorspace/node_modules/color": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.2.tgz", - "integrity": "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==", - "license": "MIT", - "dependencies": { - "color-convert": "^3.0.1", - "color-string": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@so-ric/colorspace/node_modules/color-convert": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.2.tgz", - "integrity": "sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=14.6" - } - }, - "node_modules/@so-ric/colorspace/node_modules/color-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.0.2.tgz", - "integrity": "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/@so-ric/colorspace/node_modules/color-string": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.2.tgz", - "integrity": "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@szmarczak/http-timer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", @@ -2880,9 +2952,9 @@ } }, "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz", + "integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==", "dev": true, "license": "MIT" }, @@ -2908,9 +2980,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -3027,9 +3099,9 @@ } }, "node_modules/@types/dockerode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.0.tgz", - "integrity": "sha512-NBm/UIL1KYWivh9ihJELK4Jv0dzOQCleS0OT5JklErra8mZ/t0UQpHfTkMmPrAqtra7OyFius1EFLVs0DhiHQA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.1.tgz", + "integrity": "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3039,9 +3111,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -3059,9 +3131,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", - "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", "dev": true, "license": "MIT", "dependencies": { @@ -3079,9 +3151,9 @@ "license": "MIT" }, "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", "license": "MIT" }, "node_modules/@types/http-errors": { @@ -3166,13 +3238,6 @@ "@types/node": "*" } }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mjml": { "version": "4.7.4", "resolved": "https://registry.npmjs.org/@types/mjml/-/mjml-4.7.4.tgz", @@ -3184,9 +3249,9 @@ } }, "node_modules/@types/mjml-core": { - "version": "4.15.2", - "resolved": "https://registry.npmjs.org/@types/mjml-core/-/mjml-core-4.15.2.tgz", - "integrity": "sha512-Q7SxFXgoX979HP57DEVsRI50TV8x1V4lfCA4Up9AvfINDM5oD/X9ARgfoyX1qS987JCnDLv85JjkqAjt3hZSiQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/mjml-core/-/mjml-core-5.1.0.tgz", + "integrity": "sha512-dYYmvGIHFi4wzsV+zX7UhP4C5QFSKeTjk53uQcadyIpTrrpJSs8JCZEKfSCrbKS7Hev3aR62b2dg58IeuwiR/w==", "dev": true, "license": "MIT" }, @@ -3198,9 +3263,9 @@ "license": "MIT" }, "node_modules/@types/multer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", - "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", "dev": true, "license": "MIT", "dependencies": { @@ -3208,13 +3273,13 @@ } }, "node_modules/@types/node": { - "version": "24.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", - "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz", + "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==", "license": "MIT", "peer": true, "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/nodemailer": { @@ -3245,9 +3310,9 @@ "license": "MIT" }, "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "dev": true, "license": "MIT" }, @@ -3275,25 +3340,13 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, @@ -3369,9 +3422,10 @@ "license": "MIT" }, "node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/webidl-conversions": "*" @@ -3405,20 +3459,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", - "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", + "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/type-utils": "8.56.1", - "@typescript-eslint/utils": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/type-utils": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3428,33 +3482,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.1", + "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", - "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "engines": { @@ -3466,36 +3510,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", - "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.1", - "@typescript-eslint/types": "^8.56.1", + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", "debug": "^4.4.3" }, "engines": { @@ -3506,36 +3532,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", - "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1" + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3546,9 +3554,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", - "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", "dev": true, "license": "MIT", "engines": { @@ -3559,21 +3567,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", - "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz", + "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3584,31 +3592,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", - "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", "dev": true, "license": "MIT", "engines": { @@ -3620,21 +3610,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", - "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.1", - "@typescript-eslint/tsconfig-utils": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3644,77 +3634,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", - "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz", + "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1" + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3725,17 +3658,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", - "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3760,9 +3693,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "dev": true, "license": "ISC" }, @@ -4037,18 +3970,6 @@ "node": ">=14.0.0" } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -4060,17 +3981,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -4114,12 +4024,12 @@ ] }, "node_modules/abbrev": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", - "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", "license": "ISC", "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/accepts": { @@ -4145,9 +4055,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "peer": true, @@ -4169,9 +4079,9 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, "license": "MIT", "dependencies": { @@ -4182,18 +4092,21 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", + "dependencies": { + "debug": "4" + }, "engines": { - "node": ">= 14" + "node": ">= 6.0.0" } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4246,9 +4159,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "license": "MIT", "engines": { "node": ">=12" @@ -4292,6 +4205,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -4370,31 +4296,6 @@ "proxy-from-env": "^2.1.0" } }, - "node_modules/axios/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/axios/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -4452,43 +4353,6 @@ "node": ">=12" } }, - "node_modules/babel-plugin-istanbul/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/babel-plugin-jest-hoist": { "version": "30.4.0", "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", @@ -4547,10 +4411,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bare-events": { "version": "2.9.1", @@ -4569,9 +4436,9 @@ } }, "node_modules/bare-fs": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", - "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4582,7 +4449,7 @@ "fast-fifo": "^1.3.2" }, "engines": { - "bare": ">=1.16.0" + "bare": ">=1.28.0" }, "peerDependencies": { "bare-buffer": "*" @@ -4593,33 +4460,21 @@ } } }, - "node_modules/bare-os": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", - "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "bare": ">=1.14.0" - } - }, "node_modules/bare-path": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", - "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bare-os": "^3.0.1" - } + "license": "Apache-2.0" }, "node_modules/bare-stream": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.2.tgz", - "integrity": "sha512-2V5j0dOfxv3iIngIWMnW1O6UPXpeoU70HJzUJGVth/+RURhDyq7SEWTD70Y2SkUB0MQonYYWpIX3f85P/I22+Q==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", "dev": true, "license": "Apache-2.0", "dependencies": { + "b4a": "^1.8.1", "streamx": "^2.25.0", "teex": "^1.0.1" }, @@ -4641,9 +4496,9 @@ } }, "node_modules/bare-url": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", - "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.2.tgz", + "integrity": "sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4698,9 +4553,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.42", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -4793,41 +4648,12 @@ "ms": "2.0.0" } }, - "node_modules/body-parser/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/body-parser/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4835,14 +4661,15 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -4859,9 +4686,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", - "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "funding": [ { "type": "opencollective", @@ -4879,11 +4706,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001800", - "electron-to-chromium": "^1.5.387", - "node-releases": "^2.0.50", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -4916,12 +4743,13 @@ } }, "node_modules/bson": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", - "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.2.tgz", + "integrity": "sha512-1w0ra+ho1cuE+w8jzwgzTFIimFtCfZeCoOsvIPQg6uyFCsp8M29U7bNNf5GrFh88TXbYg1g3TyKUGpd6O7q6zA==", + "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=16.20.1" + "node": ">=20.19.0" } }, "node_modules/buffer": { @@ -5077,9 +4905,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001803", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", - "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -5162,53 +4990,33 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" } }, "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } }, "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -5222,9 +5030,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", "dev": true, "license": "MIT" }, @@ -5245,14 +5053,14 @@ } }, "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { "node": ">=20" @@ -5262,14 +5070,14 @@ } }, "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", - "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -5307,6 +5115,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5368,6 +5185,19 @@ "dev": true, "license": "MIT" }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5386,6 +5216,48 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -5409,6 +5281,7 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -5597,9 +5470,9 @@ "license": "MIT" }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -5607,6 +5480,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cosmiconfig": { @@ -5699,13 +5576,13 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -5890,9 +5767,9 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6148,9 +6025,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -6229,6 +6106,12 @@ "node": ">=14" } }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/editorconfig/node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -6269,9 +6152,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.388", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.388.tgz", - "integrity": "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==", + "version": "1.5.414", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.414.tgz", + "integrity": "sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw==", "license": "ISC" }, "node_modules/emittery": { @@ -6404,9 +6287,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6513,26 +6396,27 @@ } }, "node_modules/eslint": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", - "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.1", - "@eslint/core": "^0.16.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.38.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -6551,7 +6435,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -6667,6 +6551,37 @@ } }, "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", @@ -6679,6 +6594,29 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/esm": { "version": "3.2.25", "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", @@ -6707,6 +6645,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -6722,9 +6673,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6939,11 +6890,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", - "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", "license": "MIT", "dependencies": { + "debug": "^4.4.3", "ip-address": "^10.2.0" }, "engines": { @@ -7049,6 +7001,23 @@ "bser": "2.1.1" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -7082,17 +7051,17 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "statuses": "2.0.1", + "statuses": "~2.0.2", "unpipe": "~1.0.0" }, "engines": { @@ -7190,9 +7159,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -7478,9 +7447,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -7554,9 +7523,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", "dev": true, "license": "MIT", "dependencies": { @@ -7570,6 +7539,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -7599,6 +7569,12 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -7657,6 +7633,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7771,12 +7757,15 @@ } }, "node_modules/helmet": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", - "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", "license": "MIT", "engines": { "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" } }, "node_modules/hpagent": { @@ -7808,15 +7797,16 @@ "license": "MIT" }, "node_modules/htmlnano": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-3.3.2.tgz", - "integrity": "sha512-VtiwPbplKD8Xp/6mCJxbiTnJaqQvwIp+IovfFmgNL42Ltksl94zxP4YbVdR5qQ5shtEBhYzx+vpGc8v4QnjoyQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-3.4.0.tgz", + "integrity": "sha512-5rgW9c/830dlDiWLlsT3ZLBs52UAupymGwkwr4rn2yyuzrNY0RZwgr6F4q2AkUqrRuuHb3kPelqYsUgtzQDtQw==", "license": "MIT", "dependencies": { "@types/relateurl": "^0.2.33", - "commander": "^14.0.0", + "commander": "^15.0.0", "cosmiconfig": "^9.0.0", - "posthtml": "^0.16.5" + "posthtml": "^0.16.5", + "tinyglobby": "^0.2.17" }, "bin": { "htmlnano": "dist/bin.js" @@ -7858,6 +7848,15 @@ } } }, + "node_modules/htmlnano/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -7884,19 +7883,23 @@ "license": "BSD-2-Clause" }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -7912,6 +7915,15 @@ "node": ">= 14" } }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/http2-wrapper": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", @@ -7926,16 +7938,16 @@ } }, "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", + "agent-base": "6", "debug": "4" }, "engines": { - "node": ">= 14" + "node": ">= 6" } }, "node_modules/human-signals": { @@ -7981,9 +7993,9 @@ "license": "BSD-3-Clause" }, "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -8133,12 +8145,19 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-generator-fn": { @@ -8211,99 +8230,250 @@ "license": "ISC" }, "node_modules/isomorphic-dompurify": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.30.1.tgz", - "integrity": "sha512-VJFbthRrns7BE+q3qSUJ5zxGNjuq4FqiaWXKCwnMoJbumnoQJoeOeOzP/oejKLPPtENckLWoDxGQiv5OkEFC+Q==", + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.36.0.tgz", + "integrity": "sha512-E8YkGyPY3a/U5s0WOoc8Ok+3SWL/33yn2IHCoxCFLBUUPVy9WGa++akJZFxQCcJIhI+UvYhbrbnTIFQkHKZbgA==", "license": "MIT", "dependencies": { - "dompurify": "^3.3.0", - "jsdom": "^27.0.1" + "dompurify": "^3.3.1", + "jsdom": "^28.0.0" }, "engines": { "node": ">=20.19.5" } }, "node_modules/isomorphic-dompurify/node_modules/@asamuzakjp/css-color": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", - "integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.1" + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-color-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.1.tgz", + "integrity": "sha512-YpAJZhaHplYQkG8ib+/Fx5Y0eF2lVWi3tIvMJA6i39TLyUNp2439cifzW8VMjhlqrBjHzK5hVGugRRm2zTKI/A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" } }, "node_modules/isomorphic-dompurify/node_modules/cssstyle": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", - "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", - "css-tree": "^3.1.0" + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" }, "engines": { "node": ">=20" } }, "node_modules/isomorphic-dompurify/node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/isomorphic-dompurify/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/isomorphic-dompurify/node_modules/jsdom": { - "version": "27.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", - "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", + "node_modules/isomorphic-dompurify/node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "license": "MIT", "dependencies": { - "@asamuzakjp/dom-selector": "^6.7.2", - "cssstyle": "^5.3.1", - "data-urls": "^6.0.0", + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/isomorphic-dompurify/node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^4.0.0", + "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", - "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", + "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -8315,30 +8485,30 @@ } }, "node_modules/isomorphic-dompurify/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/isomorphic-dompurify/node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, "node_modules/isomorphic-dompurify/node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -8359,28 +8529,47 @@ "node": ">=20" } }, + "node_modules/isomorphic-dompurify/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/isomorphic-dompurify/node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "license": "BSD-2-Clause", "engines": { "node": ">=20" } }, - "node_modules/isomorphic-dompurify/node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "node_modules/isomorphic-dompurify/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "license": "MIT", - "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" - }, "engines": { "node": ">=20" } }, + "node_modules/isomorphic-dompurify/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -8716,19 +8905,6 @@ "fsevents": "^2.3.3" } }, - "node_modules/jest-haste-map/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-leak-detector": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", @@ -8781,19 +8957,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-mock": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", @@ -8990,19 +9153,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-validate": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", @@ -9117,30 +9267,6 @@ "node": ">=14" } }, - "node_modules/js-beautify/node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/js-beautify/node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/js-cookie": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", @@ -9214,6 +9340,28 @@ } } }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9473,19 +9621,18 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "16.2.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", - "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", "dev": true, "license": "MIT", "dependencies": { - "commander": "^14.0.2", + "commander": "^14.0.3", "listr2": "^9.0.5", - "micromatch": "^4.0.8", - "nano-spawn": "^2.0.0", - "pidtree": "^0.6.0", + "picomatch": "^4.0.3", "string-argv": "^0.3.2", - "yaml": "^2.8.1" + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -9716,6 +9863,23 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/log-update/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -9788,10 +9952,14 @@ } }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } }, "node_modules/make-dir": { "version": "4.0.0", @@ -9836,9 +10004,9 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, "node_modules/media-typer": { @@ -9911,6 +10079,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -9989,16 +10170,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -10011,10 +10194,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -10121,42 +10304,6 @@ "node": ">=18" } }, - "node_modules/mjml-cli/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/mjml-cli/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/mjml-cli/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/mjml-cli/node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -10197,29 +10344,14 @@ } }, "node_modules/mjml-cli/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, - "node_modules/mjml-cli/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mjml-cli/node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -10236,19 +10368,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mjml-cli/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/mjml-column": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-5.4.0.tgz", @@ -10638,6 +10757,183 @@ "license": "MIT" }, "node_modules/mongodb": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.6.0.tgz", + "integrity": "sha512-WbZ6OCjYw2c53LOjfkQa+reXr7kIiOVpXXglnASFuiMtif0BvsMwHe3ClJHLm1/r7wJFwaasfFtD6iYIktB01g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.4.11", + "bson": "^7.2.0", + "mongodb-connection-string-url": "^7.0.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": "^7.2.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.2.tgz", + "integrity": "sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-11.2.0.tgz", + "integrity": "sha512-506AD8qvClVx8Raw/WhAUUWBgIXPyi856iC01aa5vAzHmn6WOXC6ulvudkTF7oTMzJxkyA0A84VpD4BpyfqJ9w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "mongodb-memory-server-core": "11.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-11.2.0.tgz", + "integrity": "sha512-vOoDtn0JiLrHvZY81Rp/UtKXXK0rtJHZGZFVnccvJwYitPLNspO0Ty0grqFQOe7iAET8+GI4zAQcphg+R3vxQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.4.3", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.16.0", + "https-proxy-agent": "^7.0.6", + "mongodb": "^7.2.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.7.3", + "tar-stream": "^3.1.8", + "tslib": "^2.8.1", + "yauzl": "^3.3.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/mongoose": { + "version": "8.24.4", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.4.tgz", + "integrity": "sha512-DoV8hYy5ssoh/j99rhYzeS2BLLcLJZ+EGQYfe/Ln+yMorBy0wgKdlwJBj59zJ1N0MnEKQypbeqUYFB0FVThmYg==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.20.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/mongoose/node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongoose/node_modules/mongodb": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", @@ -10683,7 +10979,7 @@ } } }, - "node_modules/mongodb-connection-string-url": { + "node_modules/mongoose/node_modules/mongodb-connection-string-url": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", @@ -10693,192 +10989,6 @@ "whatwg-url": "^14.1.0 || ^13.0.0" } }, - "node_modules/mongodb-memory-server": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-11.2.0.tgz", - "integrity": "sha512-506AD8qvClVx8Raw/WhAUUWBgIXPyi856iC01aa5vAzHmn6WOXC6ulvudkTF7oTMzJxkyA0A84VpD4BpyfqJ9w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "mongodb-memory-server-core": "11.2.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongodb-memory-server-core": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-11.2.0.tgz", - "integrity": "sha512-vOoDtn0JiLrHvZY81Rp/UtKXXK0rtJHZGZFVnccvJwYitPLNspO0Ty0grqFQOe7iAET8+GI4zAQcphg+R3vxQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-mutex": "^0.5.0", - "camelcase": "^6.3.0", - "debug": "^4.4.3", - "find-cache-dir": "^3.3.2", - "follow-redirects": "^1.16.0", - "https-proxy-agent": "^7.0.6", - "mongodb": "^7.2.0", - "new-find-package-json": "^2.0.0", - "semver": "^7.7.3", - "tar-stream": "^3.1.8", - "tslib": "^2.8.1", - "yauzl": "^3.3.1" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/@types/whatwg-url": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", - "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/bson": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", - "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/mongodb-memory-server-core/node_modules/mongodb": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.3.0.tgz", - "integrity": "sha512-WpCqSx7JAU9vcyjm/SU7ydnHls2YrfU3Y3sx4Ml9D7sPe4mXPlaapndiurDXrQ7/VvJkB4/i7b7WovHb8bd8sg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.2.0", - "mongodb-connection-string-url": "^7.0.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.806.0", - "@mongodb-js/zstd": "^7.0.0", - "gcp-metadata": "^7.0.1", - "kerberos": "^7.0.0", - "mongodb-client-encryption": ">=7.0.0 <7.1.0", - "snappy": "^7.3.2", - "socks": "^2.8.6" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-memory-server-core/node_modules/mongodb-connection-string-url": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", - "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^13.0.0", - "whatwg-url": "^14.1.0" - }, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/tar-stream": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", - "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "bare-fs": "^4.5.5", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/mongoose": { - "version": "8.24.3", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.24.3.tgz", - "integrity": "sha512-CMHqVyjK0Szc6irT14+O+QpN30kHxJLa0FjjLiBz/Txivmfg3mUUdMWAXfYM1Ubl0SNUkcG2cyLKOnhvttLtOA==", - "license": "MIT", - "dependencies": { - "bson": "^6.10.4", - "kareem": "2.6.3", - "mongodb": "~6.20.0", - "mpath": "0.9.0", - "mquery": "5.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - }, - "engines": { - "node": ">=16.20.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, "node_modules/mpath": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", @@ -10945,19 +11055,6 @@ "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", "license": "MIT" }, - "node_modules/nano-spawn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", - "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" - } - }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -11051,6 +11148,15 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, + "node_modules/node-gyp/node_modules/abbrev": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-5.0.0.tgz", + "integrity": "sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/node-gyp/node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -11060,6 +11166,21 @@ "node": ">=20" } }, + "node_modules/node-gyp/node_modules/nopt": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", + "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "license": "ISC", + "dependencies": { + "abbrev": "^5.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/node-gyp/node_modules/undici": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", @@ -11092,9 +11213,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "license": "MIT", "engines": { "node": ">=18" @@ -11110,16 +11231,16 @@ } }, "node_modules/nodemon": { - "version": "3.1.11", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", - "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", "dev": true, "license": "MIT", "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", @@ -11138,6 +11259,44 @@ "url": "https://opencollective.com/nodemon" } }, + "node_modules/nodemon/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/nodemon/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/nodemon/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -11148,6 +11307,32 @@ "node": ">=4" } }, + "node_modules/nodemon/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/nodemon/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/nodemon/node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -11162,18 +11347,18 @@ } }, "node_modules/nopt": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-10.0.1.tgz", - "integrity": "sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", "license": "ISC", "dependencies": { - "abbrev": "^5.0.0" + "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/normalize-path": { @@ -11187,9 +11372,9 @@ } }, "node_modules/normalize-url": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.0.tgz", - "integrity": "sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", "license": "MIT", "engines": { "node": ">=14.16" @@ -11224,9 +11409,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", - "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", "license": "MIT" }, "node_modules/object-assign": { @@ -11306,13 +11491,13 @@ } }, "node_modules/openapi3-ts": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", - "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.6.1.tgz", + "integrity": "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA==", "dev": true, "license": "MIT", "dependencies": { - "yaml": "^2.8.0" + "yaml": "^2.9.0" } }, "node_modules/optionator": { @@ -11523,6 +11708,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -11553,31 +11744,17 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "license": "MIT", - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/ping": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/ping/-/ping-0.4.4.tgz", @@ -12091,9 +12268,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", - "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz", + "integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -12271,9 +12448,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -12399,9 +12576,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -12435,12 +12612,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -12523,35 +12701,6 @@ "node": ">= 0.8" } }, - "node_modules/raw-body/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/re2": { "version": "1.26.1", "resolved": "https://registry.npmjs.org/re2/-/re2-1.26.1.tgz", @@ -12580,9 +12729,9 @@ }, "node_modules/react-is-19": { "name": "react-is", - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", "dev": true, "license": "MIT" }, @@ -12601,16 +12750,16 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/require-directory": { @@ -12811,9 +12960,9 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -12866,24 +13015,24 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "range-parser": "~1.2.1", - "statuses": "2.0.1" + "statuses": "~2.0.2" }, "engines": { "node": ">= 0.8.0" @@ -12904,25 +13053,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.19.0" + "send": "~0.19.1" }, "engines": { "node": ">= 0.8.0" @@ -13005,14 +13145,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -13024,13 +13164,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -13118,17 +13258,17 @@ } }, "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/slice-ansi?sponsor=1" @@ -13147,22 +13287,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/slick": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", @@ -13279,9 +13403,9 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -13296,9 +13420,9 @@ } }, "node_modules/streamx": { - "version": "2.27.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.27.0.tgz", - "integrity": "sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA==", + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", "dev": true, "license": "MIT", "dependencies": { @@ -13456,6 +13580,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -13469,12 +13602,12 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -13568,18 +13701,18 @@ } }, "node_modules/svgo": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz", - "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", + "integrity": "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==", "license": "MIT", "dependencies": { "commander": "^11.1.0", - "css-select": "^5.1.0", + "css-select": "^6.0.0", "css-tree": "^3.0.1", - "css-what": "^6.1.0", + "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", - "sax": "^1.5.0" + "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" @@ -13601,10 +13734,38 @@ "node": ">=16" } }, + "node_modules/svgo/node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/svgo/node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/swagger-ui-dist": { - "version": "5.30.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.0.tgz", - "integrity": "sha512-BoiDSeT9PCtHfDYMgX5UpB/qTQy44UoSFRmzHqvhGfgxzEVPHxIW78a+HMLUIHnSFu3z63wjtbq6L6+Rto20Rw==", + "version": "5.32.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.14.tgz", + "integrity": "sha512-nOA2pSQhcmODMUQZpJHYKNuwniDUqcOWGNaSCOoZv12FdOSJ9JxV95HtyRGNMqEBj6h6lCNTy20TgZDYTSuUIg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -13664,9 +13825,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -13675,7 +13836,13 @@ "tar-stream": "^2.1.4" } }, - "node_modules/tar-stream": { + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-fs/node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", @@ -13691,13 +13858,17 @@ "node": ">=6" } }, - "node_modules/tar/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" + "node_modules/tar-stream": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", + "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" } }, "node_modules/tar/node_modules/yallist": { @@ -13719,6 +13890,74 @@ "streamx": "^2.12.5" } }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -13735,14 +13974,24 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -13751,52 +14000,22 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tldts": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", - "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.11.tgz", + "integrity": "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==", "license": "MIT", "dependencies": { - "tldts-core": "^7.4.10" + "tldts-core": "^7.4.11" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.10", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", - "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.11.tgz", + "integrity": "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==", "license": "MIT" }, "node_modules/tmpl": { @@ -13890,9 +14109,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -13903,9 +14122,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.11", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", - "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -13915,7 +14134,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -14022,6 +14241,31 @@ "node": ">=16.20.2" } }, + "node_modules/tsc-alias/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/tsc-alias/node_modules/commander": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", @@ -14032,6 +14276,45 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/tsc-alias/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tsc-alias/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tsc-alias/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -14135,16 +14418,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", - "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz", + "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.1", - "@typescript-eslint/parser": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1" + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -14155,7 +14438,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/uglify-js": { @@ -14188,9 +14471,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unpipe": { @@ -14241,9 +14524,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", "funding": [ { "type": "opencollective", @@ -14423,6 +14706,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" @@ -14493,9 +14777,9 @@ } }, "node_modules/winston": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", - "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", @@ -14606,6 +14890,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -14665,9 +14958,9 @@ } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -14755,9 +15048,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "bin": { @@ -14771,9 +15064,9 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -14812,6 +15105,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -14875,9 +15177,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "peer": true, "funding": { From b922654cfcee870a7011541dc6fde5013a1920f4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 14:07:13 -0700 Subject: [PATCH 133/143] remove poeditor workflows --- .github/workflows/poeditor-sync.yml | 138 -------------------------- .github/workflows/upload-poeditor.yml | 113 --------------------- 2 files changed, 251 deletions(-) delete mode 100644 .github/workflows/poeditor-sync.yml delete mode 100644 .github/workflows/upload-poeditor.yml diff --git a/.github/workflows/poeditor-sync.yml b/.github/workflows/poeditor-sync.yml deleted file mode 100644 index 6b6731b2d..000000000 --- a/.github/workflows/poeditor-sync.yml +++ /dev/null @@ -1,138 +0,0 @@ -name: POEditor Translation Synchronization (disabled) - -# This workflow is intentionally disabled — kept for reference only. -# The full definition is preserved commented-out below. To re-enable, -# delete the stub job and uncomment the section starting at `on:`. - -on: - workflow_dispatch: - -jobs: - disabled: - if: false - runs-on: ubuntu-latest - steps: - - run: echo "This workflow is disabled. See comments below for the original definition." - -# --------------------------------------------------------------------------- -# Original (disabled) workflow below. Do not uncomment if you don't know what you're doing. -# --------------------------------------------------------------------------- - -# on: -# # For manual triggering -# workflow_dispatch: -# inputs: -# languages: -# description: "Languages to synchronize (comma separated, e.g.: tr,en,es)" -# required: false -# default: "ar,zh-tw,cs,en,fi,fr,de,pt-br,ru,es,tr,ja,zh-cn,th" -# format: -# description: "Export format (key_value_json or json)" -# required: false -# default: "key_value_json" - -# permissions: -# contents: write -# pull-requests: write - -# jobs: -# sync-translations: -# runs-on: ubuntu-latest -# permissions: -# contents: write -# issues: write -# pull-requests: write - -# steps: -# - name: Checkout code -# uses: actions/checkout@v3 -# with: -# token: ${{ secrets.GITHUB_TOKEN }} -# fetch-depth: 0 - -# - name: Setup Node.js -# uses: actions/setup-node@v3 -# with: -# node-version: "22" - -# - name: Create package.json for scripts -# run: | -# mkdir -p .github/scripts -# cat > .github/scripts/package.json << EOF -# { -# "name": "poeditor-scripts", -# "version": "1.0.0", -# "private": true, -# "type": "module", -# "dependencies": { -# "axios": "^1.6.0", -# "fs-extra": "^11.1.1" -# } -# } -# EOF - -# - name: Install dependencies -# run: | -# cd .github/scripts -# npm install - -# - name: Download translations from POEditor -# env: -# POEDITOR_API: ${{ secrets.POEDITOR_API }} -# POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }} -# LANGUAGES: ${{ github.event.inputs.languages || 'tr,en' }} -# EXPORT_FORMAT: ${{ github.event.inputs.format || 'key_value_json' }} -# run: | -# mkdir -p temp -# node .github/scripts/download-translations.js - -# - name: Verify translation files -# run: | -# echo "Verifying translation files..." -# for file in temp/*.json; do -# echo "Checking $file" -# if [ ! -s "$file" ]; then -# echo "Error: $file is empty or does not exist" -# exit 1 -# fi -# # Validate JSON format -# cat "$file" | jq . > /dev/null || { echo "Error: $file is not valid JSON"; exit 1; } -# done -# echo "All translation files are valid" - -# - name: Copy translations to project -# run: | -# mkdir -p client/src/locales -# cp -r temp/* client/src/locales/ -# echo "Translation files copied to client/src/locales/" - -# - name: Get current date -# id: date -# run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT - -# - name: Format client code -# run: | -# cd client -# npm ci -# npm run format - -# - name: Create Pull Request -# uses: peter-evans/create-pull-request@v7 -# with: -# token: ${{ secrets.GITHUB_TOKEN }} -# commit-message: "feat: translations updated from POEditor" -# title: "🌐 Updated Translations from POEditor" -# body: | -# This PR contains the latest translations from POEditor. - -# 📅 Update Date: ${{ steps.date.outputs.date }} -# 🔄 Updated Languages: ${{ github.event.inputs.languages || 'tr,en' }} - -# ⚠️ Please review the translations and approve the PR if everything looks correct. -# branch: translation-update-${{ github.run_number }} -# delete-branch: true -# base: develop -# add-paths: | -# client/src/locales/*.json -# committer: GitHub Action -# author: GitHub Action diff --git a/.github/workflows/upload-poeditor.yml b/.github/workflows/upload-poeditor.yml deleted file mode 100644 index f695c8bbc..000000000 --- a/.github/workflows/upload-poeditor.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Upload Translations to POEditor on PR Merge - -on: - pull_request: - types: [closed] - branches: - - develop - paths: - - "client/src/locales/**" - -jobs: - upload-translations: - # Only run if the PR was merged (not just closed) or manually triggered - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - fetch-depth: 0 # Fetch all history to get changed files - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: "22" - - - name: Create package.json for scripts - run: | - mkdir -p .github/scripts - cat > .github/scripts/package.json << EOF - { - "name": "poeditor-scripts", - "version": "1.0.0", - "private": true, - "type": "module", - "dependencies": { - "axios": "^1.6.0", - "fs-extra": "^11.1.1", - "form-data": "^4.0.0" - } - } - EOF - - - name: Install dependencies - run: | - cd .github/scripts - npm install - - - name: Get changed locale files - id: changed-files - if: github.event_name == 'pull_request' - run: | - # Get base and head commits - BASE_SHA=${{ github.event.pull_request.base.sha }} - HEAD_SHA=${{ github.event.pull_request.head.sha }} - - echo "Base SHA: $BASE_SHA" - echo "Head SHA: $HEAD_SHA" - - # Get list of changed files in client/src/locales directory - CHANGED_FILES=$(git diff --name-only $BASE_SHA..$HEAD_SHA -- 'client/src/locales/*.json' || git ls-files 'client/src/locales/*.json') - - if [ -z "$CHANGED_FILES" ]; then - echo "No changes detected in locale files" - echo "CHANGED_FILES=" >> $GITHUB_ENV - else - echo "Changed files:" - echo "$CHANGED_FILES" - echo "CHANGED_FILES<> $GITHUB_ENV - echo "$CHANGED_FILES" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - fi - - - name: Upload changed translations to POEditor - if: env.CHANGED_FILES != '' - env: - POEDITOR_API: ${{ secrets.POEDITOR_API }} - POEDITOR_PROJECT_ID: ${{ secrets.POEDITOR_PROJECT_ID }} - run: | - # Process each changed file - for FILE in $CHANGED_FILES; do - if [[ -f "$FILE" ]]; then - # Extract language code from filename (e.g., client/src/locales/en.json -> en) - FILENAME=$(basename "$FILE") - - # Special case: map gb.json to en language code - if [ "$FILENAME" == "gb.json" ]; then - LANG="en" - echo "Found gb.json, mapping to language code 'en'" - else - LANG=$(basename "$FILE" .json) - fi - - echo "Processing $FILE for language $LANG" - - # Upload to POEditor - LANGUAGE=$LANG FILE_PATH=$FILE node .github/scripts/upload-translations.js - fi - done - - - name: Notify on success - if: success() && env.CHANGED_FILES != '' - run: | - echo "Successfully uploaded translation files to POEditor." - if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - echo "Manual trigger comment: ${{ github.event.inputs.comment }}" - fi - - - name: Notify on no changes - if: env.CHANGED_FILES == '' - run: | - echo "No translation files were found to upload." From d0fc402683f8adc65ab6232d284dcc1b86687fb9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 14:08:11 -0700 Subject: [PATCH 134/143] format --- .../domain/checks/check.repository.mongo.ts | 56 +++++++++---------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index 93ba2c42b..e64113b6c 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -546,35 +546,33 @@ class MongoChecksRepository implements IChecksRepository { totalChecks: upChecksDoc?.totalChecks ?? 0, }; - const checks = (hardwareMetrics ?? []).map( - (metric): HardwareCheckStats => ({ - bucketDate: metric._id, - avgCpuUsage: metric.avgCpuUsage ?? 0, - avgMemoryUsage: metric.avgMemoryUsage ?? 0, - avgTemperature: metric.avgTemperature ?? [], - disks: (metric.disks ?? []).map((disk) => ({ - name: disk?.name ?? "", - readSpeed: disk?.readSpeed ?? 0, - writeSpeed: disk?.writeSpeed ?? 0, - totalBytes: disk?.totalBytes ?? 0, - freeBytes: disk?.freeBytes ?? 0, - usagePercent: disk?.usagePercent ?? 0, - })), - net: (metric.net ?? []).map((iface) => ({ - name: iface?.name ?? "", - bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0, - deltaBytesRecv: iface?.deltaBytesRecv ?? 0, - deltaPacketsSent: iface?.deltaPacketsSent ?? 0, - deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0, - deltaErrIn: iface?.deltaErrIn ?? 0, - deltaErrOut: iface?.deltaErrOut ?? 0, - deltaDropIn: iface?.deltaDropIn ?? 0, - deltaDropOut: iface?.deltaDropOut ?? 0, - deltaFifoIn: iface?.deltaFifoIn ?? 0, - deltaFifoOut: iface?.deltaFifoOut ?? 0, - })), - }) - ); + const checks = (hardwareMetrics ?? []).map((metric): HardwareCheckStats => ({ + bucketDate: metric._id, + avgCpuUsage: metric.avgCpuUsage ?? 0, + avgMemoryUsage: metric.avgMemoryUsage ?? 0, + avgTemperature: metric.avgTemperature ?? [], + disks: (metric.disks ?? []).map((disk) => ({ + name: disk?.name ?? "", + readSpeed: disk?.readSpeed ?? 0, + writeSpeed: disk?.writeSpeed ?? 0, + totalBytes: disk?.totalBytes ?? 0, + freeBytes: disk?.freeBytes ?? 0, + usagePercent: disk?.usagePercent ?? 0, + })), + net: (metric.net ?? []).map((iface) => ({ + name: iface?.name ?? "", + bytesSentPerSecond: iface?.bytesSentPerSecond ?? 0, + deltaBytesRecv: iface?.deltaBytesRecv ?? 0, + deltaPacketsSent: iface?.deltaPacketsSent ?? 0, + deltaPacketsRecv: iface?.deltaPacketsRecv ?? 0, + deltaErrIn: iface?.deltaErrIn ?? 0, + deltaErrOut: iface?.deltaErrOut ?? 0, + deltaDropIn: iface?.deltaDropIn ?? 0, + deltaDropOut: iface?.deltaDropOut ?? 0, + deltaFifoIn: iface?.deltaFifoIn ?? 0, + deltaFifoOut: iface?.deltaFifoOut ?? 0, + })), + })); return { monitorType: "hardware" as const, From 9839d48ca0f1affc794d5231853f370f04a0be88 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 14:44:01 -0700 Subject: [PATCH 135/143] add img-src blob: to CSP, stop refetching blob --- client/src/Pages/StatusPage/Create/index.tsx | 19 +++++-------------- client/src/Validation/statusPage.ts | 1 + .../api/middleware/statusPageDocumentCsp.ts | 2 +- server/src/app.ts | 2 +- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index f54b981ba..e3b82f554 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -27,7 +27,6 @@ import { getMonitorTypeLabel } from "@/Types/StatusPage"; import { timezoneOptions } from "@/Utils/timezoneOptions"; import { useNavigate, useParams } from "react-router-dom"; import { mutate } from "swr"; -import axios from "axios"; import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls"; import { ThemePicker } from "./Components/ThemePicker"; import { FormSwitchField } from "@/Components/inputs/forms/FormSwitchField"; @@ -59,7 +58,9 @@ const LogoUploadField = () => { { - field.onChange(file ? { data: file.src, contentType: file.file.type } : null); + field.onChange( + file ? { data: file.src, contentType: file.file.type, file: file.file } : null + ); }} /> ); @@ -204,18 +205,8 @@ const CreateStatusPage = () => { if (data.logo === null) { // Signal to remove the logo fd.append("removeLogo", "true"); - } else if (data.logo?.data && data.logo.data !== "") { - if (data.logo.data.startsWith("blob:")) { - try { - const imageResult = await axios.get(data.logo.data, { - responseType: "blob", - }); - fd.append("logo", imageResult.data); - URL.revokeObjectURL(data.logo.data); - } catch (e) { - logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined); - } - } + } else if (data.logo?.file) { + fd.append("logo", data.logo.file); } let result; diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index eddde8ab7..08d12cdde 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -79,6 +79,7 @@ export const statusPageSchema = z.object({ .object({ data: z.string(), contentType: z.string(), + file: z.instanceof(File).optional(), }) .nullable() .optional() diff --git a/server/src/api/middleware/statusPageDocumentCsp.ts b/server/src/api/middleware/statusPageDocumentCsp.ts index c5175b81d..a04820ebd 100644 --- a/server/src/api/middleware/statusPageDocumentCsp.ts +++ b/server/src/api/middleware/statusPageDocumentCsp.ts @@ -7,7 +7,7 @@ const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public"; // the status page on top of the global helmet policy, blocking external images, // fonts, and stylesheets from custom CSS while keeping the app's Google Fonts. const STATUS_PAGE_CSP = [ - "img-src 'self' data:", + "img-src 'self' data: blob:", "font-src 'self' data: https://fonts.gstatic.com", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", ].join("; "); diff --git a/server/src/app.ts b/server/src/app.ts index 12e552406..181fa6064 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -76,7 +76,7 @@ export const createApp = ({ directives: { upgradeInsecureRequests: null, "script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"], - "img-src": ["'self'", "data:", "https://img.shields.io"], + "img-src": ["'self'", "data:", "blob:", "https://img.shields.io"], "object-src": ["'none'"], "base-uri": ["'self'"], }, From 0b54b5c6cdd5f827626ac255a5109241f2f31615 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 14:49:52 -0700 Subject: [PATCH 136/143] minor version bump --- client/package-lock.json | 4 ++-- client/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index bca8463c0..6cee968e3 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "client", - "version": "3.10.0", + "version": "3.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "client", - "version": "3.10.0", + "version": "3.11.0", "dependencies": { "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", diff --git a/client/package.json b/client/package.json index ee290cae8..b2a464f61 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "client", "private": true, - "version": "3.10.0", + "version": "3.11.0", "type": "module", "scripts": { "dev": "vite", From 4298397e3eb9d34ae7b09351e82978acde79783d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 15:05:28 -0700 Subject: [PATCH 137/143] fix package lock sync issue --- server/package-lock.json | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 3bd7af904..fb6370f9f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -893,9 +893,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, @@ -3970,6 +3970,18 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -3981,6 +3993,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", From 68679bc0bf3a12910f67e0a545f54187ca2c97bf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 15:16:12 -0700 Subject: [PATCH 138/143] lockfile script --- client/package.json | 1 + server/package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/client/package.json b/client/package.json index b2a464f61..ce2e481ba 100644 --- a/client/package.json +++ b/client/package.json @@ -4,6 +4,7 @@ "version": "3.11.0", "type": "module", "scripts": { + "lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only", "dev": "vite", "build": "tsc -b && vite build", "build-dev": "tsc -b && vite build --mode development", diff --git a/server/package.json b/server/package.json index 5e67192db..36906c936 100755 --- a/server/package.json +++ b/server/package.json @@ -5,6 +5,7 @@ "main": "index.js", "type": "module", "scripts": { + "lockfile": "docker run --rm -v \"$PWD\":/w -w /w $(grep -m1 -o 'node:[^ ]*' ../docker/Dockerfile) npm install --package-lock-only", "test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage", "test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/**/*.ts' test/unit", "test:unit:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications", From b23dbfeae0891c488b8aa42119158b6693e7624c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 15:16:54 -0700 Subject: [PATCH 139/143] regen lockfile --- server/package-lock.json | 43 ++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index fb6370f9f..4e58c301f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -194,7 +194,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -829,7 +828,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -876,7 +874,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -892,6 +889,29 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", @@ -899,6 +919,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -3123,7 +3144,6 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -3277,7 +3297,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz", "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -3493,7 +3512,6 @@ "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", @@ -4083,7 +4101,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4448,7 +4465,6 @@ "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "dev": true, "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -4727,7 +4743,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -5640,7 +5655,6 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.9.tgz", "integrity": "sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==", "license": "MIT", - "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.17", "lilconfig": "^3.1.3" @@ -6425,7 +6439,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6870,7 +6883,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8365,7 +8377,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -8388,7 +8399,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -8685,7 +8695,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -11898,7 +11907,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", @@ -14203,7 +14211,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14431,7 +14438,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15204,7 +15210,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 97d6222297be51722960479ff03076f74e35316c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 25 Aug 2026 15:20:57 -0700 Subject: [PATCH 140/143] add a lockfile check --- .github/workflows/check-lockfile.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/check-lockfile.yml diff --git a/.github/workflows/check-lockfile.yml b/.github/workflows/check-lockfile.yml new file mode 100644 index 000000000..b98880545 --- /dev/null +++ b/.github/workflows/check-lockfile.yml @@ -0,0 +1,22 @@ +name: Lockfile Check (Client & Server) +on: + pull_request: + workflow_dispatch: + +jobs: + check-lockfiles: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Validate with the same node image the production build uses, so a lock + # that passes here cannot fail the image build's npm ci. + - name: Resolve node image from Dockerfile + id: node-image + run: echo "image=$(grep -m1 -o 'node:[^ ]*' docker/Dockerfile)" >> "$GITHUB_OUTPUT" + + - name: Check server lockfile + run: docker run --rm -v "$PWD/server":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run + + - name: Check client lockfile + run: docker run --rm -v "$PWD/client":/w -w /w ${{ steps.node-image.outputs.image }} npm ci --dry-run From 7dedcca41d8f653f4e46efa7ba051f8f5c048f5d Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:47:27 +0300 Subject: [PATCH 141/143] Revise README for contributor stats and project description Updated the README to reflect recent achievements and contributions. --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f055f6f39..e8a17c4a1 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,13 @@

    Checkmate

    -

    An open source uptime and infrastructure monitoring application

    +

    An open source uptime and infrastructure monitoring application [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) -image +image +


    @@ -207,9 +208,9 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! ## Contributing -We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers. +We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers. -We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe. +We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe. Our repo is starred by employees from **Google, Microsoft, Intel, Cisco, Tencent, Electronic Arts, ByteDance, JP Morgan Chase, Deloitte, Accenture, Foxconn, Broadcom, China Telecom, Barclays, Capgemini, Wipro, Cloudflare, Dassault Systèmes and NEC**, so don’t hold back — jump in, contribute and learn with us! From 2e258fa39a574d1bcdfe13920e969134c21c21cc Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:51:40 +0300 Subject: [PATCH 142/143] Update README with installation options Added 1-click installation options for Checkmate. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index e8a17c4a1..bb5185378 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,10 @@ JWT_SECRET="$(openssl rand -hex 32)" docker compose up -d Then open http://localhost:52345. If the app is reached at another origin (domain or LAN IP), set `CLIENT_HOST` accordingly. To build the image yourself, run `docker build -f docker/Dockerfile -t checkmate .` from a checkout. For TLS, put any reverse proxy (Caddy, Traefik, nginx) in front of port 52345. +There are also 1-click installation options like [Repocloud](https://repocloud.io/details/Checkmate), +[Pikapods](https://www.pikapods.com/), [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](../../charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) or [Cloudzy](https://cloudzy.com/marketplace/checkmate). + + ### Configuration The image is configured entirely through environment variables on the server container: From d8ce2dfdc8812d068e0a49b2acc2c2e12651a7da Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 26 Aug 2026 10:40:02 -0700 Subject: [PATCH 143/143] set keep previous data --- client/src/Pages/StatusPage/Status/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 87a2de7a1..935cfd2b3 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -113,6 +113,7 @@ const StatusPageView = () => { apiUrl, {}, { + keepPreviousData: true, refreshInterval: range === "latest" ? 10000 : 60000, } );