diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index ecb3f369e..0ec338d93 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -317,12 +317,6 @@ export async function getBatchedStatusHistory( days: number, tzOffsetMinutes: number = 0 ): Promise { - // const cacheKey = statusHistoryCacheKey(entityType, entityId, days); - // const cached = await cache.get(cacheKey); - // if (cached !== undefined) { - // return cached; - // } - console.time( `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` ); @@ -412,7 +406,8 @@ export async function getBatchedStatusHistory( const { buckets, totalDowntime } = computeBuckets( event.events, days, - priorStatus + priorStatus, + tzOffsetMinutes ); const totalWindow = days * 86400; const overallUptime = @@ -436,6 +431,5 @@ export async function getBatchedStatusHistory( console.timeEnd( `[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]` ); - // await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); return result; } diff --git a/server/routers/external.ts b/server/routers/external.ts index e6d487f56..fc3170365 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -476,6 +476,13 @@ authenticated.get( resource.getResourceStatusHistory ); +authenticated.get( + "/org/:orgId/resource-status-histories", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listResources), + resource.getBatchedResourceStatusHistory +); + authenticated.get( "/org/:orgId/resources", verifyOrgAccess, diff --git a/server/routers/resource/getBatchedStatusHistory.ts b/server/routers/resource/getBatchedStatusHistory.ts new file mode 100644 index 000000000..870f20287 --- /dev/null +++ b/server/routers/resource/getBatchedStatusHistory.ts @@ -0,0 +1,83 @@ +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 resourceIdParamsSchema = 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)), + resourceIds: 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 resourceIds (repeat query param)" + }) +}); + +export async function getBatchedResourceStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = resourceIdParamsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "resource"; + const { days, resourceIds, tzOffsetMinutes } = parsedQuery.data; + + const data = await getBatchedStatusHistory( + entityType, + resourceIds, + 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/routers/resource/index.ts b/server/routers/resource/index.ts index 83dbdea2a..709bf7340 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -33,4 +33,5 @@ export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; export * from "./getStatusHistory"; +export * from "./getBatchedStatusHistory"; export * from "./getResourcePolicies"; diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 369fe0601..0b2e167fe 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -31,10 +31,13 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { durationToMs } from "@app/lib/durationToMs"; +import { orgQueries } from "@app/lib/queries"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UpdateResourceResponse } from "@server/routers/resource"; +import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; import { @@ -69,7 +72,7 @@ import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import UptimeMiniBar from "./UptimeMiniBar"; +import { UptimeMiniBar } from "./UptimeMiniBar"; import { type SelectedLabel } from "./labels-selector"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { useLocalLabels } from "@app/hooks/useLocalLabels"; @@ -127,6 +130,8 @@ const booleanSearchFilterSchema = z .optional() .catch(undefined); +const RESOURCE_STATUS_HISTORY_DAYS = 30; + export default function PublicResourcesTable({ resources, orgId, @@ -155,6 +160,21 @@ export default function PublicResourcesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); + // Only http resources show an uptime bar, so don't ask for the others + const statusHistoryResourceIds = useMemo( + () => resources.filter((r) => r.mode === "http").map((r) => r.id), + [resources] + ); + + const statusHistoryQuery = useQuery({ + ...orgQueries.batchedResourceStatusHistory({ + orgId, + resourceIds: statusHistoryResourceIds, + days: RESOURCE_STATUS_HISTORY_DAYS + }), + enabled: statusHistoryResourceIds.length > 0 + }); + const refreshData = () => { startTransition(() => { try { @@ -407,7 +427,11 @@ export default function PublicResourcesTable({ return -; } return ( - + ); } }, @@ -625,7 +649,13 @@ export default function PublicResourcesTable({ ]; return cols; - }, [orgId, t, searchParams]); + }, [ + orgId, + t, + searchParams, + statusHistoryQuery.data, + statusHistoryQuery.isLoading + ]); function handleFilterChange( column: string, diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 5fdaf05a0..199810eb3 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -120,7 +120,7 @@ export default function SitesTable({ siteIds: sites.map((s) => s.id), days: SITE_STATUS_HISTORY_DAYS }), - staleTime: durationToMs(5, "seconds") + enabled: sites.length > 0 }); const api = createApiClient(useEnvContext()); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index a56271991..18568f721 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -661,8 +661,12 @@ export const orgQueries = { 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()], ...siteIds.map((id) => ["siteIds", id.toString()]) ]); @@ -672,7 +676,44 @@ export const orgQueries = { signal }); return res.data.data; - } + }, + staleTime: durationToMs(5, "seconds") + }), + batchedResourceStatusHistory: ({ + resourceIds, + orgId, + days = 90 + }: { + orgId: string; + resourceIds: number[]; + days?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "BATCHED_RESOURCE_STATUS_HISTORY", + resourceIds, + 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()], + ...resourceIds.map((id) => ["resourceIds", id.toString()]) + ]); + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/resource-status-histories?${sp.toString()}`, { + signal + }); + return res.data.data; + }, + staleTime: durationToMs(5, "seconds") }), siteStatusHistory: ({ siteId,