mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-05 23:28:44 +00:00
Fix status history and show on the health check
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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
133
server/lib/statusHistory.ts
Normal 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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
93
server/private/routers/healthChecks/getStatusHistory.ts
Normal file
93
server/private/routers/healthChecks/getStatusHistory.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,4 @@ export * from "./listHealthChecks";
|
||||
export * from "./createHealthCheck";
|
||||
export * from "./updateHealthCheck";
|
||||
export * from "./deleteHealthCheck";
|
||||
export * from "./getStatusHistory";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user