🚧 WIP: batched status

This commit is contained in:
Fred KISSIE
2026-07-20 20:37:52 +01:00
parent 4c873e7c48
commit 9cc3190e3a
2 changed files with 85 additions and 44 deletions
+55 -32
View File
@@ -264,9 +264,7 @@ export function computeBuckets(
// Shift by the client's offset before formatting so the label reflects // Shift by the client's offset before formatting so the label reflects
// their local calendar date rather than the UTC date of dayStartSec // their local calendar date rather than the UTC date of dayStartSec
const dateStr = new Date( const dateStr = new Date((dayStartSec + tzOffsetMinutes * 60) * 1000)
(dayStartSec + tzOffsetMinutes * 60) * 1000
)
.toISOString() .toISOString()
.slice(0, 10); .slice(0, 10);
@@ -305,13 +303,10 @@ export function computeBuckets(
return { buckets, totalDowntime }; return { buckets, totalDowntime };
} }
export interface BatchedStatusHistoryResponse { export type BatchedStatusHistoryResponse = Record<
entityType: string; string,
entityIds: number[]; StatusHistoryResponse
days: StatusHistoryDayBucket[]; >;
overallUptimePercent: number;
totalDowntimeSeconds: number;
}
export async function getBatchedStatusHistory( export async function getBatchedStatusHistory(
entityType: string, entityType: string,
@@ -345,8 +340,8 @@ export async function getBatchedStatusHistory(
// Fetch the last known state before the window so that entities that // Fetch the last known state before the window so that entities that
// haven't changed status recently still show the correct status rather // haven't changed status recently still show the correct status rather
// than appearing as "no_data". // than appearing as "no_data".
const [lastKnownEvent] = await logsDb const lastKnownEvents = await logsDb
.select() .selectDistinctOn([statusHistory.entityId, statusHistory.timestamp])
.from(statusHistory) .from(statusHistory)
.where( .where(
and( and(
@@ -355,29 +350,57 @@ export async function getBatchedStatusHistory(
lt(statusHistory.timestamp, startSec) lt(statusHistory.timestamp, startSec)
) )
) )
.orderBy(desc(statusHistory.timestamp)) .orderBy(desc(statusHistory.timestamp));
.limit(1);
const priorStatus = lastKnownEvent?.status ?? null; const eventStatusMap: Record<
number,
{
events: typeof events;
lastKnownEvent: (typeof lastKnownEvents)[number] | null;
}
> = {};
const { buckets, totalDowntime } = computeBuckets( for (const event of events) {
events, if (!eventStatusMap[event.entityId]) {
days, eventStatusMap[event.entityId] = {
priorStatus events: [],
); lastKnownEvent:
const totalWindow = days * 86400; lastKnownEvents.find(
const overallUptime = (ev) => ev.entityId === event.entityId
totalWindow > 0 ) ?? null
? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100) };
: 100; }
eventStatusMap[event.entityId].events.push(event);
}
const result: BatchedStatusHistoryResponse = { const result: BatchedStatusHistoryResponse = {};
entityType,
entityIds, for (const entityId in eventStatusMap) {
days: buckets, const event = eventStatusMap[Number(entityId)];
overallUptimePercent: Math.round(overallUptime * 100) / 100, const priorStatus = event.lastKnownEvent?.status ?? null;
totalDowntimeSeconds: totalDowntime
}; 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); // await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL);
return result; return result;
+30 -12
View File
@@ -11,8 +11,35 @@ import {
StatusHistoryResponse StatusHistoryResponse
} from "@server/lib/statusHistory"; } from "@server/lib/statusHistory";
const siteParamsSchema = z.object({ const siteIdParamsSchema = z.object({
siteId: z.string().transform((v) => parseInt(v, 10)) 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( export async function getBatchedSiteStatusHistory(
@@ -21,16 +48,7 @@ export async function getBatchedSiteStatusHistory(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const parsedParams = siteParamsSchema.safeParse(req.params); const parsedQuery = siteIdParamsSchema.safeParse(req.query);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedQuery = statusHistoryQuerySchema.safeParse(req.query);
if (!parsedQuery.success) { if (!parsedQuery.success) {
return next( return next(
createHttpError( createHttpError(