Fix status history and show on the health check

This commit is contained in:
Owen
2026-04-16 20:55:21 -07:00
parent c1782a2650
commit f932cc7aca
13 changed files with 319 additions and 276 deletions

View File

@@ -1092,6 +1092,20 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
complete: boolean("complete").notNull().default(false)
});
export const statusHistory = pgTable("statusHistory", {
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull(),
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -1159,19 +1173,4 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker
>;
export type Network = InferSelectModel<typeof networks>;
export const statusHistory = pgTable("statusHistory", {
id: serial("id").primaryKey(),
entityType: varchar("entityType").notNull(),
entityId: integer("entityId").notNull(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: varchar("status").notNull(),
timestamp: integer("timestamp").notNull(),
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export type StatusHistory = InferSelectModel<typeof statusHistory>;

View File

@@ -1181,6 +1181,16 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", {
})
});
export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
messageId: integer("messageId").primaryKey({ autoIncrement: true }),
wsClientId: text("clientId"),
messageType: text("messageType"),
sentAt: integer("sentAt").notNull(),
receivedAt: integer("receivedAt"),
error: text("error"),
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
});
export const statusHistory = sqliteTable("statusHistory", {
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
@@ -1195,16 +1205,6 @@ export const statusHistory = sqliteTable("statusHistory", {
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
messageId: integer("messageId").primaryKey({ autoIncrement: true }),
wsClientId: text("clientId"),
messageType: text("messageType"),
sentAt: integer("sentAt").notNull(),
receivedAt: integer("receivedAt"),
error: text("error"),
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
});
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;

133
server/lib/statusHistory.ts Normal file
View File

@@ -0,0 +1,133 @@
import { z } from "zod";
export const statusHistoryQuerySchema = z
.object({
days: z
.string()
.optional()
.transform((v) => (v ? parseInt(v, 10) : 90)),
})
.pipe(
z.object({
days: z.number().int().min(1).max(365),
})
);
export interface StatusHistoryDayBucket {
date: string; // ISO date "YYYY-MM-DD"
uptimePercent: number; // 0-100
totalDowntimeSeconds: number;
downtimeWindows: { start: number; end: number | null; status: string }[];
status: "good" | "degraded" | "bad" | "no_data";
}
export interface StatusHistoryResponse {
entityType: string;
entityId: number;
days: StatusHistoryDayBucket[];
overallUptimePercent: number;
totalDowntimeSeconds: number;
}
export function computeBuckets(
events: { entityType: string; entityId: number; orgId: string; status: string; timestamp: number; id: number }[],
days: number
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
const nowSec = Math.floor(Date.now() / 1000);
const buckets: StatusHistoryDayBucket[] = [];
let totalDowntime = 0;
for (let d = 0; d < days; d++) {
const dayStartSec = nowSec - (days - d) * 86400;
const dayEndSec = dayStartSec + 86400;
const dayEvents = events.filter(
(e) => e.timestamp >= dayStartSec && e.timestamp < dayEndSec
);
// Determine the status at the start of this day (last event before dayStart)
const lastBeforeDay = [...events]
.filter((e) => e.timestamp < dayStartSec)
.at(-1);
const currentStatus = lastBeforeDay?.status ?? null;
const windows: { start: number; end: number | null; status: string }[] = [];
let dayDowntime = 0;
let windowStart = dayStartSec;
let windowStatus = currentStatus;
for (const evt of dayEvents) {
if (windowStatus !== null && windowStatus !== evt.status) {
const windowEnd = evt.timestamp;
const isDown =
windowStatus === "offline" ||
windowStatus === "unhealthy" ||
windowStatus === "unknown";
if (isDown) {
dayDowntime += windowEnd - windowStart;
windows.push({
start: windowStart,
end: windowEnd,
status: windowStatus,
});
}
}
windowStart = evt.timestamp;
windowStatus = evt.status;
}
// Close the final window at the end of the day (or now if day hasn't ended)
if (windowStatus !== null) {
const finalEnd = Math.min(dayEndSec, nowSec);
const isDown =
windowStatus === "offline" ||
windowStatus === "unhealthy" ||
windowStatus === "unknown";
if (isDown && finalEnd > windowStart) {
dayDowntime += finalEnd - windowStart;
windows.push({
start: windowStart,
end: finalEnd,
status: windowStatus,
});
}
}
totalDowntime += dayDowntime;
const effectiveDayLength = Math.max(
0,
Math.min(dayEndSec, nowSec) - dayStartSec
);
const uptimePct =
effectiveDayLength > 0
? Math.max(
0,
((effectiveDayLength - dayDowntime) /
effectiveDayLength) *
100
)
: 100;
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10);
let status: StatusHistoryDayBucket["status"] = "no_data";
if (currentStatus !== null || dayEvents.length > 0) {
if (uptimePct >= 99) status = "good";
else if (uptimePct >= 50) status = "degraded";
else status = "bad";
}
buckets.push({
date: dateStr,
uptimePercent: Math.round(uptimePct * 100) / 100,
totalDowntimeSeconds: dayDowntime,
downtimeWindows: windows,
status,
});
}
return { buckets, totalDowntime };
}

View File

@@ -15,7 +15,7 @@ export async function verifyDomainAccess(
try {
const userId = req.user!.userId;
const domainId =
req.params.domainId || req.body.apiKeyId || req.query.apiKeyId;
req.params.domainId;
const orgId = req.params.orgId;
if (!userId) {

View File

@@ -657,6 +657,7 @@ authenticated.delete(
authenticated.get(
"/org/:orgId/event-streaming-destinations",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listEventStreamingDestinations),
eventStreamingDestination.listEventStreamingDestinations
@@ -692,6 +693,7 @@ authenticated.delete(
authenticated.get(
"/org/:orgId/alert-rules",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listAlertRules),
alertRule.listAlertRules
@@ -699,6 +701,7 @@ authenticated.get(
authenticated.get(
"/org/:orgId/alert-rule/:alertRuleId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getAlertRule),
alertRule.getAlertRule
@@ -706,6 +709,7 @@ authenticated.get(
authenticated.get(
"/org/:orgId/health-checks",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listHealthChecks),
healthChecks.listHealthChecks
@@ -738,3 +742,11 @@ authenticated.delete(
logActionAudit(ActionsEnum.deleteHealthCheck),
healthChecks.deleteHealthCheck
);
authenticated.get(
"/org/:orgId/health-check/:healthCheckId/status-history",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getTarget),
healthChecks.getHealthCheckStatusHistory
);

View File

@@ -0,0 +1,93 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
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 {
computeBuckets,
statusHistoryQuerySchema,
StatusHistoryResponse
} from "@server/lib/statusHistory";
const healthCheckParamsSchema = z.object({
healthCheckId: z.string().transform((v) => parseInt(v, 10))
});
export async function getHealthCheckStatusHistory(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = healthCheckParamsSchema.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 = "healthCheck";
const entityId = parsedParams.data.healthCheckId
const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400;
const events = await db
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
eq(statusHistory.entityId, entityId),
gte(statusHistory.timestamp, startSec)
)
)
.orderBy(asc(statusHistory.timestamp));
const { buckets, totalDowntime } = computeBuckets(events, days);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
? Math.max(
0,
((totalWindow - totalDowntime) / totalWindow) * 100
)
: 100;
return response<StatusHistoryResponse>(res, {
data: {
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime
},
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")
);
}
}

View File

@@ -15,3 +15,4 @@ export * from "./listHealthChecks";
export * from "./createHealthCheck";
export * from "./updateHealthCheck";
export * from "./deleteHealthCheck";
export * from "./getStatusHistory";

View File

@@ -292,13 +292,6 @@ authenticated.get(
site.getSiteStatusHistory
);
authenticated.get(
"/target/:targetId/health-check/status-history",
verifyTargetAccess,
verifyUserHasAction(ActionsEnum.getTarget),
site.getHealthCheckStatusHistory
);
// Site Resource endpoints
authenticated.put(
"/org/:orgId/site-resource",

View File

@@ -7,147 +7,16 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
computeBuckets,
statusHistoryQuerySchema,
StatusHistoryResponse
} from "@server/lib/statusHistory";
const siteParamsSchema = z.object({
siteId: z.string().transform((v) => parseInt(v, 10)),
siteId: z.string().transform((v) => parseInt(v, 10))
});
const healthCheckParamsSchema = z.object({
targetHealthCheckId: z.string().transform((v) => parseInt(v, 10)),
});
const querySchema = z
.object({
days: z
.string()
.optional()
.transform((v) => (v ? parseInt(v, 10) : 90)),
})
.pipe(
z.object({
days: z.number().int().min(1).max(365),
})
);
export interface DayBucket {
date: string; // ISO date "YYYY-MM-DD"
uptimePercent: number; // 0-100
totalDowntimeSeconds: number;
downtimeWindows: { start: number; end: number | null; status: string }[];
status: "good" | "degraded" | "bad" | "no_data";
}
export interface StatusHistoryResponse {
entityType: string;
entityId: number;
days: DayBucket[];
overallUptimePercent: number;
totalDowntimeSeconds: number;
}
function computeBuckets(
events: { entityType: string; entityId: number; orgId: string; status: string; timestamp: number; id: number }[],
days: number
): { buckets: DayBucket[]; totalDowntime: number } {
const nowSec = Math.floor(Date.now() / 1000);
const buckets: DayBucket[] = [];
let totalDowntime = 0;
for (let d = 0; d < days; d++) {
const dayStartSec = nowSec - (days - d) * 86400;
const dayEndSec = dayStartSec + 86400;
const dayEvents = events.filter(
(e) => e.timestamp >= dayStartSec && e.timestamp < dayEndSec
);
// Determine the status at the start of this day (last event before dayStart)
const lastBeforeDay = [...events]
.filter((e) => e.timestamp < dayStartSec)
.at(-1);
let currentStatus = lastBeforeDay?.status ?? null;
const windows: { start: number; end: number | null; status: string }[] = [];
let dayDowntime = 0;
let windowStart = dayStartSec;
let windowStatus = currentStatus;
for (const evt of dayEvents) {
if (windowStatus !== null && windowStatus !== evt.status) {
const windowEnd = evt.timestamp;
const isDown =
windowStatus === "offline" ||
windowStatus === "unhealthy" ||
windowStatus === "unknown";
if (isDown) {
dayDowntime += windowEnd - windowStart;
windows.push({
start: windowStart,
end: windowEnd,
status: windowStatus,
});
}
}
windowStart = evt.timestamp;
windowStatus = evt.status;
}
// Close the final window at the end of the day (or now if day hasn't ended)
if (windowStatus !== null) {
const finalEnd = Math.min(dayEndSec, nowSec);
const isDown =
windowStatus === "offline" ||
windowStatus === "unhealthy" ||
windowStatus === "unknown";
if (isDown && finalEnd > windowStart) {
dayDowntime += finalEnd - windowStart;
windows.push({
start: windowStart,
end: finalEnd,
status: windowStatus,
});
}
}
totalDowntime += dayDowntime;
const effectiveDayLength = Math.max(
0,
Math.min(dayEndSec, nowSec) - dayStartSec
);
const uptimePct =
effectiveDayLength > 0
? Math.max(
0,
((effectiveDayLength - dayDowntime) /
effectiveDayLength) *
100
)
: 100;
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10);
let status: DayBucket["status"] = "no_data";
if (currentStatus !== null || dayEvents.length > 0) {
if (uptimePct >= 99) status = "good";
else if (uptimePct >= 50) status = "degraded";
else status = "bad";
}
buckets.push({
date: dateStr,
uptimePercent: Math.round(uptimePct * 100) / 100,
totalDowntimeSeconds: dayDowntime,
downtimeWindows: windows,
status,
});
}
return { buckets, totalDowntime };
}
export async function getSiteStatusHistory(
req: Request,
res: Response,
@@ -163,7 +32,7 @@ export async function getSiteStatusHistory(
)
);
}
const parsedQuery = querySchema.safeParse(req.query);
const parsedQuery = statusHistoryQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
@@ -208,98 +77,17 @@ export async function getSiteStatusHistory(
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime,
totalDowntimeSeconds: totalDowntime
},
success: true,
error: false,
message: "Status history retrieved successfully",
status: HttpCode.OK,
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred"
)
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
export async function getHealthCheckStatusHistory(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = healthCheckParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const entityType = "healthCheck";
const entityId = parsedParams.data.targetHealthCheckId;
const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400;
const events = await db
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
eq(statusHistory.entityId, entityId),
gte(statusHistory.timestamp, startSec)
)
)
.orderBy(asc(statusHistory.timestamp));
const { buckets, totalDowntime } = computeBuckets(events, days);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
? Math.max(
0,
((totalWindow - totalDowntime) / totalWindow) * 100
)
: 100;
return response<StatusHistoryResponse>(res, {
data: {
entityType,
entityId,
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime,
},
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"
)
);
}
}

View File

@@ -227,7 +227,7 @@ export default function HealthChecksTable({
header: () => <span className="p-3">Uptime (30d)</span>,
cell: ({ row }) => {
return (
<UptimeMiniBar targetId={row.original.targetHealthCheckId} days={30} />
<UptimeMiniBar orgId={orgId} healthCheckId={row.original.targetHealthCheckId} days={30} />
);
}
},

View File

@@ -45,16 +45,18 @@ const barColorClass: Record<string, string> = {
};
type UptimeBarProps = {
orgId?: string;
siteId?: number;
targetId?: number;
healthCheckId?: number;
days?: number;
title?: string;
className?: string;
};
export default function UptimeBar({
orgId,
siteId,
targetId,
healthCheckId,
days = 90,
title,
className
@@ -68,8 +70,8 @@ export default function UptimeBar({
});
const hcQuery = useQuery({
...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }),
enabled: targetId != null && siteId == null,
...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }),
enabled: healthCheckId != null && siteId == null,
meta: { api }
});
@@ -205,4 +207,4 @@ export default function UptimeBar({
</div>
</div>
);
}
}

View File

@@ -37,14 +37,16 @@ const barColorClass: Record<string, string> = {
};
type UptimeMiniBarProps = {
orgId?: string;
siteId?: number;
targetId?: number;
healthCheckId?: number;
days?: number;
};
export default function UptimeMiniBar({
orgId,
siteId,
targetId,
healthCheckId,
days = 30
}: UptimeMiniBarProps) {
const api = createApiClient(useEnvContext());
@@ -56,8 +58,8 @@ export default function UptimeMiniBar({
});
const hcQuery = useQuery({
...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }),
enabled: targetId != null && siteId == null,
...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }),
enabled: healthCheckId != null && siteId == null,
meta: { api }
});
@@ -125,4 +127,4 @@ export default function UptimeMiniBar({
</span>
</div>
);
}
}

View File

@@ -1,5 +1,4 @@
import { build } from "@server/build";
import type { StatusHistoryResponse } from "@server/routers/site/getStatusHistory";
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
import type { ListClientsResponse } from "@server/routers/client";
import type { ListDomainsResponse } from "@server/routers/domain";
@@ -29,6 +28,7 @@ import z from "zod";
import { remote } from "./api";
import { durationToMs } from "./durationToMs";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import { StatusHistoryResponse } from "@server/middlewares/statusHistory";
export type ProductUpdate = {
link: string | null;
@@ -306,7 +306,13 @@ export const orgQueries = {
return res.data.data.healthChecks;
}
}),
siteStatusHistory: ({ siteId, days = 90 }: { siteId: number; days?: number }) =>
siteStatusHistory: ({
siteId,
days = 90
}: {
siteId: number;
days?: number;
}) =>
queryOptions({
queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const,
queryFn: async ({ signal, meta }) => {
@@ -314,21 +320,35 @@ export const orgQueries = {
AxiosResponse<StatusHistoryResponse>
>(`/site/${siteId}/status-history?days=${days}`, { signal });
return res.data.data;
},
refetchInterval: 60_000,
}
}),
healthCheckStatusHistory: ({ targetId, days = 90 }: { targetId: number; days?: number }) =>
healthCheckStatusHistory: ({
orgId,
healthCheckId,
days = 90
}: {
orgId: string;
healthCheckId: number;
days?: number;
}) =>
queryOptions({
queryKey: ["HC_STATUS_HISTORY", targetId, days] as const,
queryKey: [
"HC_STATUS_HISTORY",
orgId,
healthCheckId,
days
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse>
>(`/target/${targetId}/health-check/status-history?days=${days}`, { signal });
>(
`/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}`,
{ signal }
);
return res.data.data;
},
refetchInterval: 60_000,
}),
}
})
};
export const logAnalyticsFiltersSchema = z.object({