diff --git a/client/src/Components/monitors/charts/ChartUtils.ts b/client/src/Components/monitors/charts/ChartUtils.ts new file mode 100644 index 000000000..27c36d38b --- /dev/null +++ b/client/src/Components/monitors/charts/ChartUtils.ts @@ -0,0 +1,6 @@ +export const computeYAxisCap = (values: number[]): number | undefined => { + const sorted = values.slice().sort((a, b) => a - b); + if (sorted.length < 2) return undefined; + const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]; + return p95 > 0 ? Math.ceil(p95 * 2) : undefined; +}; diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index 17849fadb..29bf63f09 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -4,6 +4,7 @@ import { AreaChart, Area, XAxis, + YAxis, Tooltip, CartesianGrid, ResponsiveContainer, @@ -24,6 +25,8 @@ import { useTheme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; import type { GroupedCheck } from "@/Types/Check"; import type { RootState } from "@/Types/state"; +import { useMemo } from "react"; +import { computeYAxisCap } from "@/Components/monitors/charts/ChartUtils"; type XTickProps = { x: number; @@ -69,7 +72,7 @@ const ResponseTimeToolTip = ({ if (!active) return null; const format = tooltipDateFormatLookup(range); - const responseTime = Math.floor(payload[0]?.payload?.originalAvgResponseTime || 0); + const responseTime = Math.floor(payload[0]?.payload?.avgResponseTime || 0); return ( {formatDateWithTz(String(label), format, uiTimezone)} @@ -90,6 +93,10 @@ export const HistogramDetails = ({ }) => { const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const yMax = useMemo(() => { + const totals = checks.map((check) => check.avgResponseTime || 0); + return computeYAxisCap(totals); + }, [checks]); return ( + { - const totals = checks - .map((check) => PHASE_KEYS.reduce((sum, key) => sum + check[key], 0)) - .sort((a, b) => a - b); - if (totals.length < 2) return undefined; - const p95 = totals[Math.min(totals.length - 1, Math.floor(totals.length * 0.95))]; - return p95 > 0 ? Math.ceil(p95 * 2) : undefined; + const totals = checks.map((check) => + PHASE_KEYS.reduce((sum, key) => sum + check[key], 0) + ); + return computeYAxisCap(totals); }, [checks]); return ( diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index 04d207d06..c4128e5e4 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -61,7 +61,6 @@ const UptimeDetailsPage = () => { } const params = new URLSearchParams(); params.append("dateRange", dateRange); - params.append("normalize", "true"); return `/monitors/uptime/details/${monitorId}?${params.toString()}`; }, [monitorId, dateRange]); diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index f629bd122..cfd072944 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -261,7 +261,3 @@ export interface HasResponseTime { export type NormalizedCheck = T & { originalResponseTime: number; }; - -export type NormalizedUptimeCheck = T & { - originalAvgResponseTime: number; -}; diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 37a31fc6a..0e8b7b0bd 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -197,7 +197,6 @@ export const getUptimeDetailsByIdParamValidation = z.object({ export const getUptimeDetailsByIdQueryValidation = z.object({ dateRange: z.enum(DateRanges), - normalize: booleanCoercion.optional(), }); const importedMonitorSchema = z diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index 911629e92..d4c47cfb9 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -172,10 +172,6 @@ export type NormalizedCheck = T & { originalResponseTime: number; }; -export type NormalizedUptimeCheck = T & { - originalAvgResponseTime: 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 178da5225..29046f83d 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -1,4 +1,4 @@ -import { NormalizeData, NormalizeDataUptimeDetails } from "@/utils/dataUtils.js"; +import { NormalizeData } from "@/utils/dataUtils.js"; import { type Monitor } from "@/domain/monitors/monitor.type.js"; import type { MonitorType, @@ -193,9 +193,9 @@ export class MonitorService implements IMonitorService { return { monitorData: { monitor, - groupedChecks: NormalizeDataUptimeDetails(checksData.groupedChecks, 10, 100), - groupedUpChecks: NormalizeDataUptimeDetails(checksData.groupedUpChecks, 10, 100), - groupedDownChecks: NormalizeDataUptimeDetails(checksData.groupedDownChecks, 10, 100), + groupedChecks: checksData.groupedChecks, + groupedUpChecks: checksData.groupedUpChecks, + groupedDownChecks: checksData.groupedDownChecks, groupedAvgResponseTime: checksData.avgResponseTime, groupedUptimePercentage: checksData.uptimePercentage, }, diff --git a/server/src/utils/dataUtils.ts b/server/src/utils/dataUtils.ts index 0382abc57..2c15b943e 100755 --- a/server/src/utils/dataUtils.ts +++ b/server/src/utils/dataUtils.ts @@ -1,4 +1,4 @@ -import type { GroupedCheck, HasResponseTime, NormalizedCheck, NormalizedUptimeCheck } from "@/domain/checks/check.type.js"; +import type { HasResponseTime, NormalizedCheck } from "@/domain/checks/check.type.js"; import { type DateRange } from "@/types/query.js"; export const getDateForRange = (dateRange: DateRange): Date => { @@ -61,17 +61,3 @@ export const NormalizeData = (checks: T[], rangeMin: originalResponseTime: original(check), })); }; - -export const NormalizeDataUptimeDetails = (checks: T[], rangeMin: number, rangeMax: number): NormalizedUptimeCheck[] => { - const original = (check: T) => (typeof check.avgResponseTime === "number" ? check.avgResponseTime : 0); - if (checks.length <= 1) { - return checks.map((check) => ({ ...check, originalAvgResponseTime: original(check) })); - } - const min = percentileBy(checks, 0, (c) => c.avgResponseTime); - const max = percentileBy(checks, 95, (c) => c.avgResponseTime); - return checks.map((check) => ({ - ...check, - avgResponseTime: rescale(check.avgResponseTime, min, max, rangeMin, rangeMax), - originalAvgResponseTime: original(check), - })); -}; diff --git a/server/test/unit/utils/dataUtils.test.ts b/server/test/unit/utils/dataUtils.test.ts index 01f102cca..af9434bab 100644 --- a/server/test/unit/utils/dataUtils.test.ts +++ b/server/test/unit/utils/dataUtils.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "@jest/globals"; -import { getDateForRange, getDateFormat, NormalizeData, NormalizeDataUptimeDetails } from "../../../src/utils/dataUtils.ts"; -import type { GroupedCheck } from "../../../src/domain/checks/check.type.ts"; +import { getDateForRange, getDateFormat, NormalizeData } from "../../../src/utils/dataUtils.ts"; import { DateRanges, type DateRange } from "../../../src/types/query.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -12,13 +11,6 @@ const makeCheck = (responseTime: number, overrides?: Record) => ...overrides, }); -const makeGrouped = (avgResponseTime: number, overrides?: Partial): GroupedCheck => ({ - bucketDate: `bucket-${avgResponseTime}`, - avgResponseTime, - totalChecks: 5, - ...overrides, -}); - // ── Tests ──────────────────────────────────────────────────────────────────── describe("getDateForRange", () => { @@ -126,50 +118,3 @@ describe("NormalizeData", () => { }); }); }); - -describe("NormalizeDataUptimeDetails", () => { - it("rescales avgResponseTime into [rangeMin, rangeMax] anchored to the p0–p95 window", () => { - const checks = [100, 200, 300, 400, 500].map((avg) => makeGrouped(avg)); - - const result = NormalizeDataUptimeDetails(checks, 10, 100); - - expect(result[0]!.avgResponseTime).toBe(10); - expect(result[1]!.avgResponseTime).toBeCloseTo(33.6842, 4); - expect(result[4]!.avgResponseTime).toBe(100); - }); - - it("preserves the raw value in originalAvgResponseTime and spreads other fields", () => { - const checks = [makeGrouped(100), makeGrouped(500)]; - - const result = NormalizeDataUptimeDetails(checks, 10, 100); - - expect(result[0]!.originalAvgResponseTime).toBe(100); - expect(result[1]!.originalAvgResponseTime).toBe(500); - expect(result[0]).toMatchObject({ bucketDate: "bucket-100", totalChecks: 5 }); - }); - - it("passes a single bucket through, only adding originalAvgResponseTime", () => { - const result = NormalizeDataUptimeDetails([makeGrouped(250)], 10, 100); - - expect(result).toEqual([{ bucketDate: "bucket-250", avgResponseTime: 250, totalChecks: 5, originalAvgResponseTime: 250 }]); - }); - - it("returns an empty array for empty input", () => { - expect(NormalizeDataUptimeDetails([], 10, 100)).toEqual([]); - }); - - it("records originalAvgResponseTime as 0 for a null avgResponseTime ($avg over an empty bucket)", () => { - const result = NormalizeDataUptimeDetails([makeGrouped(null as unknown as number)], 10, 100); - - expect(result[0]!.originalAvgResponseTime).toBe(0); - }); - - it("produces NaN when all values are equal (division by zero — known edge case)", () => { - const result = NormalizeDataUptimeDetails([makeGrouped(50), makeGrouped(50)], 10, 100); - - result.forEach((check) => { - expect(check.avgResponseTime).toBeNaN(); - expect(check.originalAvgResponseTime).toBe(50); - }); - }); -});