From 9cc3190e3a75ece1c3fd01bc895d33d1ecd9c7bb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 20 Jul 2026 20:37:52 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20batched=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/statusHistory.ts | 87 ++++++++++++------- .../routers/site/getBatchedStatusHistory.ts | 42 ++++++--- 2 files changed, 85 insertions(+), 44 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index a0ede99a6..315c776c2 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -264,9 +264,7 @@ export function computeBuckets( // Shift by the client's offset before formatting so the label reflects // their local calendar date rather than the UTC date of dayStartSec - const dateStr = new Date( - (dayStartSec + tzOffsetMinutes * 60) * 1000 - ) + const dateStr = new Date((dayStartSec + tzOffsetMinutes * 60) * 1000) .toISOString() .slice(0, 10); @@ -305,13 +303,10 @@ export function computeBuckets( return { buckets, totalDowntime }; } -export interface BatchedStatusHistoryResponse { - entityType: string; - entityIds: number[]; - days: StatusHistoryDayBucket[]; - overallUptimePercent: number; - totalDowntimeSeconds: number; -} +export type BatchedStatusHistoryResponse = Record< + string, + StatusHistoryResponse +>; export async function getBatchedStatusHistory( entityType: string, @@ -345,8 +340,8 @@ export async function getBatchedStatusHistory( // 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() + const lastKnownEvents = await logsDb + .selectDistinctOn([statusHistory.entityId, statusHistory.timestamp]) .from(statusHistory) .where( and( @@ -355,29 +350,57 @@ export async function getBatchedStatusHistory( lt(statusHistory.timestamp, startSec) ) ) - .orderBy(desc(statusHistory.timestamp)) - .limit(1); + .orderBy(desc(statusHistory.timestamp)); - const priorStatus = lastKnownEvent?.status ?? null; + const eventStatusMap: Record< + number, + { + events: typeof events; + lastKnownEvent: (typeof lastKnownEvents)[number] | null; + } + > = {}; - const { buckets, totalDowntime } = computeBuckets( - events, - days, - priorStatus - ); - const totalWindow = days * 86400; - const overallUptime = - totalWindow > 0 - ? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100) - : 100; + for (const event of events) { + if (!eventStatusMap[event.entityId]) { + eventStatusMap[event.entityId] = { + events: [], + lastKnownEvent: + lastKnownEvents.find( + (ev) => ev.entityId === event.entityId + ) ?? null + }; + } + eventStatusMap[event.entityId].events.push(event); + } - const result: BatchedStatusHistoryResponse = { - entityType, - entityIds, - days: buckets, - overallUptimePercent: Math.round(overallUptime * 100) / 100, - totalDowntimeSeconds: totalDowntime - }; + const result: BatchedStatusHistoryResponse = {}; + + for (const entityId in eventStatusMap) { + const event = eventStatusMap[Number(entityId)]; + const priorStatus = event.lastKnownEvent?.status ?? null; + + const { buckets, totalDowntime } = computeBuckets( + event.events, + days, + priorStatus + ); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max( + 0, + ((totalWindow - totalDowntime) / totalWindow) * 100 + ) + : 100; + + result[entityId] = { + entityType, + entityId: Number(entityId), + 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 index 97ed8a9ac..507a556d4 100644 --- a/server/routers/site/getBatchedStatusHistory.ts +++ b/server/routers/site/getBatchedStatusHistory.ts @@ -11,8 +11,35 @@ import { StatusHistoryResponse } from "@server/lib/statusHistory"; -const siteParamsSchema = z.object({ - siteId: z.string().transform((v) => parseInt(v, 10)) +const siteIdParamsSchema = 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)), + siteIds: 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 siteIds (repeat query param)" + }) }); export async function getBatchedSiteStatusHistory( @@ -21,16 +48,7 @@ export async function getBatchedSiteStatusHistory( 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); + const parsedQuery = siteIdParamsSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError(