mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-22 05:24:09 +02:00
🚧 WIP: batched site status histories
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
export * from "./getSite";
|
||||
export * from "./getStatusHistory";
|
||||
export * from "./getBatchedStatusHistory";
|
||||
export * from "./createSite";
|
||||
export * from "./deleteSite";
|
||||
export * from "./updateSite";
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user