diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 75a5b8d48..d2dc7bec1 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -853,6 +853,14 @@ authenticated.get( healthChecks.getHealthCheckStatusHistory ); +authenticated.get( + "/org/:orgId/health-check-status-histories", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getTarget), + healthChecks.getBatchedHealthCheckStatusHistory +); + authenticated.get( "/client/:clientId/verify-associations-cache", verifyClientAccess, diff --git a/server/private/routers/healthChecks/getBatchedStatusHistory.ts b/server/private/routers/healthChecks/getBatchedStatusHistory.ts new file mode 100644 index 000000000..38c4903ec --- /dev/null +++ b/server/private/routers/healthChecks/getBatchedStatusHistory.ts @@ -0,0 +1,96 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import response from "@server/lib/response"; +import { + getBatchedStatusHistory, + type BatchedStatusHistoryResponse +} from "@server/lib/statusHistory"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const healthCheckIdParamsSchema = z.object({ + days: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 90)), + // Minutes to add to UTC to get the requesting client's local time + // (e.g. Australia/Sydney standard time is 600). Optional and + // defaults to 0 (UTC) so older clients keep the prior behavior. + tzOffsetMinutes: z + .string() + .optional() + .transform((v) => (v ? parseInt(v, 10) : 0)), + healthCheckIds: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) ? val : [val]; + const nums = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + const unique = [...new Set(nums)]; + return unique.length ? unique : undefined; + }, z.array(z.number().int().positive())) + .openapi({ + description: "Filter by healthCheckIds (repeat query param)" + }) +}); + +export async function getBatchedHealthCheckStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = healthCheckIdParamsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "health_check"; + const { days, healthCheckIds, tzOffsetMinutes } = parsedQuery.data; + + const data = await getBatchedStatusHistory( + entityType, + healthCheckIds, + days, + tzOffsetMinutes + ); + + return response(res, { + data, + success: true, + error: false, + message: "Status history retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/healthChecks/index.ts b/server/private/routers/healthChecks/index.ts index 665ae5cca..a2e01c2ae 100644 --- a/server/private/routers/healthChecks/index.ts +++ b/server/private/routers/healthChecks/index.ts @@ -16,3 +16,4 @@ export * from "./createHealthCheck"; export * from "./updateHealthCheck"; export * from "./deleteHealthCheck"; export * from "./getStatusHistory"; +export * from "./getBatchedStatusHistory"; diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 02e725fed..b4f273e5a 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -1,6 +1,6 @@ "use client"; -import UptimeMiniBar from "@app/components/UptimeMiniBar"; +import { UptimeMiniBar } from "@app/components/UptimeMiniBar"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import HealthCheckCredenza, { @@ -51,6 +51,8 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; type StandaloneHealthChecksTableProps = { orgId: string; @@ -81,6 +83,8 @@ function formatTarget(row: HealthCheckRow): string { return `${scheme}://${host}${port}${path}`; } +const HEALTH_CHECK_STATUS_HISTORY_DAYS = 30; + export default function HealthChecksTable({ orgId, healthChecks, @@ -157,6 +161,20 @@ export default function HealthChecksTable({ const rows = healthChecks; + const healthCheckIds = useMemo( + () => rows.map((r) => r.targetHealthCheckId), + [rows] + ); + + const statusHistoryQuery = useQuery({ + ...orgQueries.batchedHealthCheckStatusHistory({ + orgId, + healthCheckIds, + days: HEALTH_CHECK_STATUS_HISTORY_DAYS + }), + enabled: healthCheckIds.length > 0 + }); + function refreshList() { startRefresh(() => { router.refresh(); @@ -547,9 +565,13 @@ export default function HealthChecksTable({ cell: ({ row }) => { return ( ); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 18568f721..8007fd2f1 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -679,6 +679,46 @@ export const orgQueries = { }, staleTime: durationToMs(5, "seconds") }), + batchedHealthCheckStatusHistory: ({ + healthCheckIds, + orgId, + days = 90 + }: { + orgId: string; + healthCheckIds: number[]; + days?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "BATCHED_HEALTH_CHECK_STATUS_HISTORY", + healthCheckIds, + days + ] as const, + queryFn: async ({ signal, meta }) => { + // Negated because getTimezoneOffset() returns UTC - local, + // while the API expects minutes to add to UTC to get local + const tzOffsetMinutes = -new Date().getTimezoneOffset(); + const sp = new URLSearchParams([ + ["days", days.toString()], + ["tzOffsetMinutes", tzOffsetMinutes.toString()], + ...healthCheckIds.map((id) => [ + "healthCheckIds", + id.toString() + ]) + ]); + + const res = await meta!.api.get< + AxiosResponse + >( + `/org/${orgId}/health-check-status-histories?${sp.toString()}`, + { signal } + ); + return res.data.data; + }, + staleTime: durationToMs(5, "seconds") + }), batchedResourceStatusHistory: ({ resourceIds, orgId,