🚧 WIP: batched site status histories

This commit is contained in:
Fred KISSIE
2026-07-21 19:57:09 +01:00
parent 9cc3190e3a
commit 23181f4019
7 changed files with 122 additions and 59 deletions
+25 -9
View File
@@ -37,10 +37,11 @@ export async function getCachedStatusHistory(
tzOffsetMinutes
);
const cached = await cache.get<StatusHistoryResponse>(cacheKey);
if (cached !== undefined) {
return cached;
}
// if (cached !== undefined) {
// return cached;
// }
console.time(`[getCachedStatusHistory/${entityType}=${entityId}]`);
// Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query
// window aligns with stable calendar days for the requesting client
const todayMidnightSec = localMidnightSec(tzOffsetMinutes);
@@ -76,12 +77,14 @@ export async function getCachedStatusHistory(
const priorStatus = lastKnownEvent?.status ?? null;
console.time(`[computeBuckets/${entityType}=${entityId}]`);
const { buckets, totalDowntime } = computeBuckets(
events,
days,
priorStatus,
tzOffsetMinutes
);
console.timeEnd(`[computeBuckets/${entityType}=${entityId}]`);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
@@ -96,6 +99,7 @@ export async function getCachedStatusHistory(
totalDowntimeSeconds: totalDowntime
};
console.timeEnd(`[getCachedStatusHistory/${entityType}=${entityId}]`);
await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL);
return result;
}
@@ -299,7 +303,6 @@ export function computeBuckets(
status
});
}
return { buckets, totalDowntime };
}
@@ -311,7 +314,8 @@ export type BatchedStatusHistoryResponse = Record<
export async function getBatchedStatusHistory(
entityType: string,
entityIds: number[],
days: number
days: number,
tzOffsetMinutes: number = 0
): Promise<BatchedStatusHistoryResponse> {
// const cacheKey = statusHistoryCacheKey(entityType, entityId, days);
// const cached = await cache.get<StatusHistoryResponse>(cacheKey);
@@ -319,10 +323,13 @@ export async function getBatchedStatusHistory(
// 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);
console.time(
`[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]`
);
// Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query
// window aligns with stable calendar days for the requesting client
const todayMidnightSec = localMidnightSec(tzOffsetMinutes);
const startSec = todayMidnightSec - days * 86400;
const events = await logsDb
@@ -337,6 +344,10 @@ export async function getBatchedStatusHistory(
)
.orderBy(asc(statusHistory.timestamp));
console.log({
events
});
// 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".
@@ -375,6 +386,7 @@ export async function getBatchedStatusHistory(
const result: BatchedStatusHistoryResponse = {};
console.time(`[computeBuckets/${entityType}=(${entityIds.join(" ,")})]`);
for (const entityId in eventStatusMap) {
const event = eventStatusMap[Number(entityId)];
const priorStatus = event.lastKnownEvent?.status ?? null;
@@ -401,7 +413,11 @@ export async function getBatchedStatusHistory(
totalDowntimeSeconds: totalDowntime
};
}
console.timeEnd(`[computeBuckets/${entityType}=(${entityIds.join(" ,")})]`);
console.timeEnd(
`[getBatchedStatusHistory/${entityType}=(${entityIds.join(" ,")})]`
);
// await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL);
return result;
}
+7
View File
@@ -319,6 +319,13 @@ authenticated.get(
site.getSiteStatusHistory
);
authenticated.get(
"/org/:orgId/site-status-histories",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listSites),
site.getBatchedSiteStatusHistory
);
// Site Resource endpoints
authenticated.put(
"/org/:orgId/site-resource",
+16 -13
View File
@@ -1,15 +1,14 @@
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
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 siteIdParamsSchema = z.object({
days: z
@@ -59,12 +58,16 @@ export async function getBatchedSiteStatusHistory(
}
const entityType = "site";
const entityId = parsedParams.data.siteId;
const { days } = parsedQuery.data;
const { days, siteIds, tzOffsetMinutes } = parsedQuery.data;
const data = await getCachedStatusHistory(entityType, entityId, days);
const data = await getBatchedStatusHistory(
entityType,
siteIds,
days,
tzOffsetMinutes
);
return response<StatusHistoryResponse>(res, {
return response<BatchedStatusHistoryResponse>(res, {
data,
success: true,
error: false,
+1
View File
@@ -1,5 +1,6 @@
export * from "./getSite";
export * from "./getStatusHistory";
export * from "./getBatchedStatusHistory";
export * from "./createSite";
export * from "./deleteSite";
export * from "./updateSite";
+24 -10
View File
@@ -1,7 +1,7 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import UptimeMiniBar from "@app/components/UptimeMiniBar";
import { UptimeMiniBar } from "@app/components/UptimeMiniBar";
import {
Credenza,
@@ -56,8 +56,9 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { LabelsTableCell } from "./LabelsTableCell";
import { useQuery } from "@tanstack/react-query";
import { productUpdatesQueries } from "@app/lib/queries";
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
import semver from "semver";
import { durationToMs } from "@app/lib/durationToMs";
export type SiteRow = {
id: number;
@@ -89,6 +90,8 @@ type SitesTableProps = {
rowCount: number;
};
const SITE_STATUS_HISTORY_DAYS = 30;
export default function SitesTable({
sites,
orgId,
@@ -112,7 +115,14 @@ export default function SitesTable({
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const { isPaidUser } = usePaidStatus();
const statusHistoryQuery = useQuery({
...orgQueries.batchedSiteStatusHistory({
orgId,
siteIds: sites.map((s) => s.id),
days: SITE_STATUS_HISTORY_DAYS
}),
staleTime: durationToMs(5, "seconds")
});
const api = createApiClient(useEnvContext());
const t = useTranslations();
@@ -296,7 +306,14 @@ export default function SitesTable({
if (originalRow.type == "local") {
return <span>-</span>;
}
return <UptimeMiniBar siteId={originalRow.id} days={30} />;
const data = statusHistoryQuery.data?.[row.original.id];
return (
<UptimeMiniBar
isLoading={statusHistoryQuery.isLoading}
data={data}
days={SITE_STATUS_HISTORY_DAYS}
/>
);
}
},
{
@@ -361,12 +378,9 @@ export default function SitesTable({
let updateAvailable = Boolean(
latestNewtVersion &&
originalRow.newtVersion &&
semver.valid(originalRow.newtVersion) &&
semver.lt(
originalRow.newtVersion,
latestNewtVersion
)
originalRow.newtVersion &&
semver.valid(originalRow.newtVersion) &&
semver.lt(originalRow.newtVersion, latestNewtVersion)
);
if (originalRow.type === "newt") {
+26 -19
View File
@@ -1,15 +1,14 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { orgQueries } from "@app/lib/queries";
import {
Tooltip,
TooltipContent,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { orgQueries } from "@app/lib/queries";
import type { StatusHistoryResponse } from "@server/lib/statusHistory";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
function formatDuration(seconds: number): string {
@@ -46,21 +45,16 @@ type UptimeMiniBarProps = {
days?: number;
};
export default function UptimeMiniBar({
export default function UptimeMiniBarWrapper({
orgId,
siteId,
resourceId,
healthCheckId,
days = 30
}: UptimeMiniBarProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const siteQuery = useQuery({
...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }),
enabled: siteId != null,
meta: { api },
staleTime: 5 * 60 * 1000
enabled: siteId != null
});
const hcQuery = useQuery({
@@ -69,16 +63,12 @@ export default function UptimeMiniBar({
healthCheckId: healthCheckId ?? 0,
days
}),
enabled: healthCheckId != null && siteId == null && resourceId == null,
meta: { api },
staleTime: 5 * 60 * 1000
enabled: healthCheckId != null && siteId == null && resourceId == null
});
const resourceQuery = useQuery({
...orgQueries.resourceStatusHistory({ resourceId, days }),
enabled: resourceId != null && siteId == null && healthCheckId == null,
meta: { api },
staleTime: 5 * 60 * 1000
enabled: resourceId != null && siteId == null && healthCheckId == null
});
const { data, isLoading } =
@@ -88,6 +78,22 @@ export default function UptimeMiniBar({
? resourceQuery
: hcQuery;
return <UptimeMiniBar data={data} isLoading={isLoading} days={days} />;
}
type UptimeMiniBarUIProps = {
data?: StatusHistoryResponse;
isLoading?: boolean;
days?: number;
};
export function UptimeMiniBar({
data,
isLoading,
days = 30
}: UptimeMiniBarUIProps) {
const t = useTranslations();
if (isLoading) {
return (
<div className="flex items-center gap-2">
@@ -138,7 +144,8 @@ export default function UptimeMiniBar({
{formatDate(day.date)}
</div>
<div className="text-xs text-primary-foreground/80">
{day.status === "no_data" || day.status === "unknown"
{day.status === "no_data" ||
day.status === "unknown"
? t("uptimeNoData")
: `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
</div>
@@ -159,4 +166,4 @@ export default function UptimeMiniBar({
</span>
</div>
);
}
}
+23 -8
View File
@@ -47,7 +47,10 @@ import { remote } from "./api";
import { durationToMs } from "./durationToMs";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { StatusHistoryResponse } from "@server/lib/statusHistory";
import {
StatusHistoryResponse,
type BatchedStatusHistoryResponse
} from "@server/lib/statusHistory";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type {
@@ -642,24 +645,33 @@ export const orgQueries = {
}),
batchedSiteStatusHistory: ({
siteIds,
orgId,
days = 90
}: {
orgId: string;
siteIds: number[];
days?: number;
}) =>
queryOptions({
queryKey: [
"SITE_STATUS_HISTORY",
"BATCHED",
"ORG",
orgId,
"BATCHED_SITE_STATUS_HISTORY",
siteIds,
days
] as const,
queryFn: async ({ signal, meta }) => {
// TODO
// const res = await meta!.api.get<
// AxiosResponse<StatusHistoryResponse>
// >(`/site/${siteId}/status-history?days=${days}`, { signal });
// return res.data.data;
const sp = new URLSearchParams([
["days", days.toString()],
...siteIds.map((id) => ["siteIds", id.toString()])
]);
const res = await meta!.api.get<
AxiosResponse<BatchedStatusHistoryResponse>
>(`/org/${orgId}/site-status-histories?${sp.toString()}`, {
signal
});
return res.data.data;
}
}),
siteStatusHistory: ({
@@ -671,6 +683,7 @@ export const orgQueries = {
}) =>
queryOptions({
queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const,
staleTime: durationToMs(5, "seconds"),
queryFn: async ({ signal, meta }) => {
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const res = await meta!.api.get<
@@ -692,6 +705,7 @@ export const orgQueries = {
}) =>
queryOptions({
queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const,
staleTime: durationToMs(5, "seconds"),
queryFn: async ({ signal, meta }) => {
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const res = await meta!.api.get<
@@ -714,6 +728,7 @@ export const orgQueries = {
days?: number;
}) =>
queryOptions({
staleTime: durationToMs(5, "seconds"),
queryKey: [
"HC_STATUS_HISTORY",
orgId,