From 1580b7abff4d434baf550e1057a23bad76cdd1c2 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 10 Jul 2026 07:02:22 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20batch=20status=20historie?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 80 ++++++++++++++++++- .../routers/site/getBatchedStatusHistory.ts | 62 ++++++++++++++ src/lib/queries.ts | 22 +++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 server/routers/site/getBatchedStatusHistory.ts diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 7c5b5c370..e3b13270b 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { db, logsDb, statusHistory } from "@server/db"; -import { and, eq, gte, lt, asc, desc } from "drizzle-orm"; +import { and, eq, gte, lt, asc, desc, inArray } from "drizzle-orm"; import { regionalCache as cache } from "#dynamic/lib/cache"; const STATUS_HISTORY_CACHE_TTL = 60; // seconds @@ -273,3 +273,81 @@ export function computeBuckets( return { buckets, totalDowntime }; } + +export interface BatchedStatusHistoryResponse { + entityType: string; + entityIds: number[]; + days: StatusHistoryDayBucket[]; + overallUptimePercent: number; + totalDowntimeSeconds: number; +} + +export async function getBatchedStatusHistory( + entityType: string, + entityIds: number[], + days: number +): Promise { + // const cacheKey = statusHistoryCacheKey(entityType, entityId, days); + // const cached = await cache.get(cacheKey); + // if (cached !== undefined) { + // return cached; + // } + + // Anchor to UTC midnight so the query window aligns with stable calendar days + const utcToday = new Date(); + utcToday.setUTCHours(0, 0, 0, 0); + const todayMidnightSec = Math.floor(utcToday.getTime() / 1000); + const startSec = todayMidnightSec - days * 86400; + + const events = await logsDb + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + inArray(statusHistory.entityId, entityIds), + gte(statusHistory.timestamp, startSec) + ) + ) + .orderBy(asc(statusHistory.timestamp)); + + // Fetch the last known state before the window so that entities that + // haven't changed status recently still show the correct status rather + // than appearing as "no_data". + const [lastKnownEvent] = await logsDb + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + inArray(statusHistory.entityId, entityIds), + lt(statusHistory.timestamp, startSec) + ) + ) + .orderBy(desc(statusHistory.timestamp)) + .limit(1); + + const priorStatus = lastKnownEvent?.status ?? null; + + const { buckets, totalDowntime } = computeBuckets( + events, + days, + priorStatus + ); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100) + : 100; + + const result: BatchedStatusHistoryResponse = { + entityType, + entityIds, + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime + }; + + // await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); + return result; +} diff --git a/server/routers/site/getBatchedStatusHistory.ts b/server/routers/site/getBatchedStatusHistory.ts new file mode 100644 index 000000000..97ed8a9ac --- /dev/null +++ b/server/routers/site/getBatchedStatusHistory.ts @@ -0,0 +1,62 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { + getCachedStatusHistory, + statusHistoryQuerySchema, + StatusHistoryResponse +} from "@server/lib/statusHistory"; + +const siteParamsSchema = z.object({ + siteId: z.string().transform((v) => parseInt(v, 10)) +}); + +export async function getBatchedSiteStatusHistory( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = siteParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const parsedQuery = statusHistoryQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const entityType = "site"; + const entityId = parsedParams.data.siteId; + const { days } = parsedQuery.data; + + const data = await getCachedStatusHistory(entityType, entityId, days); + + 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/src/lib/queries.ts b/src/lib/queries.ts index 8fdb1c531..19b28e2d9 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -640,6 +640,28 @@ export const orgQueries = { }; } }), + batchedSiteStatusHistory: ({ + siteIds, + days = 90 + }: { + siteIds: number[]; + days?: number; + }) => + queryOptions({ + queryKey: [ + "SITE_STATUS_HISTORY", + "BATCHED", + siteIds, + days + ] as const, + queryFn: async ({ signal, meta }) => { + // TODO + // const res = await meta!.api.get< + // AxiosResponse + // >(`/site/${siteId}/status-history?days=${days}`, { signal }); + // return res.data.data; + } + }), siteStatusHistory: ({ siteId, days = 90