mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-17 16:19:48 +02: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)
|
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 Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
@@ -1159,19 +1173,4 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
typeof roundTripMessageTracker
|
typeof roundTripMessageTracker
|
||||||
>;
|
>;
|
||||||
export type Network = InferSelectModel<typeof networks>;
|
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>;
|
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", {
|
export const statusHistory = sqliteTable("statusHistory", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
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),
|
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 Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const domainId =
|
const domainId =
|
||||||
req.params.domainId || req.body.apiKeyId || req.query.apiKeyId;
|
req.params.domainId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = req.params.orgId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
|
|||||||
@@ -657,6 +657,7 @@ authenticated.delete(
|
|||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/event-streaming-destinations",
|
"/org/:orgId/event-streaming-destinations",
|
||||||
|
verifyValidLicense,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.listEventStreamingDestinations),
|
verifyUserHasAction(ActionsEnum.listEventStreamingDestinations),
|
||||||
eventStreamingDestination.listEventStreamingDestinations
|
eventStreamingDestination.listEventStreamingDestinations
|
||||||
@@ -692,6 +693,7 @@ authenticated.delete(
|
|||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/alert-rules",
|
"/org/:orgId/alert-rules",
|
||||||
|
verifyValidLicense,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.listAlertRules),
|
verifyUserHasAction(ActionsEnum.listAlertRules),
|
||||||
alertRule.listAlertRules
|
alertRule.listAlertRules
|
||||||
@@ -699,6 +701,7 @@ authenticated.get(
|
|||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/alert-rule/:alertRuleId",
|
"/org/:orgId/alert-rule/:alertRuleId",
|
||||||
|
verifyValidLicense,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.getAlertRule),
|
verifyUserHasAction(ActionsEnum.getAlertRule),
|
||||||
alertRule.getAlertRule
|
alertRule.getAlertRule
|
||||||
@@ -706,6 +709,7 @@ authenticated.get(
|
|||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/health-checks",
|
"/org/:orgId/health-checks",
|
||||||
|
verifyValidLicense,
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserHasAction(ActionsEnum.listHealthChecks),
|
verifyUserHasAction(ActionsEnum.listHealthChecks),
|
||||||
healthChecks.listHealthChecks
|
healthChecks.listHealthChecks
|
||||||
@@ -738,3 +742,11 @@ authenticated.delete(
|
|||||||
logActionAudit(ActionsEnum.deleteHealthCheck),
|
logActionAudit(ActionsEnum.deleteHealthCheck),
|
||||||
healthChecks.deleteHealthCheck
|
healthChecks.deleteHealthCheck
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/health-check/:healthCheckId/status-history",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getTarget),
|
||||||
|
healthChecks.getHealthCheckStatusHistory
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 "./createHealthCheck";
|
||||||
export * from "./updateHealthCheck";
|
export * from "./updateHealthCheck";
|
||||||
export * from "./deleteHealthCheck";
|
export * from "./deleteHealthCheck";
|
||||||
|
export * from "./getStatusHistory";
|
||||||
|
|||||||
@@ -292,13 +292,6 @@ authenticated.get(
|
|||||||
site.getSiteStatusHistory
|
site.getSiteStatusHistory
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/target/:targetId/health-check/status-history",
|
|
||||||
verifyTargetAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.getTarget),
|
|
||||||
site.getHealthCheckStatusHistory
|
|
||||||
);
|
|
||||||
|
|
||||||
// Site Resource endpoints
|
// Site Resource endpoints
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/site-resource",
|
"/org/:orgId/site-resource",
|
||||||
|
|||||||
@@ -7,147 +7,16 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import {
|
||||||
|
computeBuckets,
|
||||||
|
statusHistoryQuerySchema,
|
||||||
|
StatusHistoryResponse
|
||||||
|
} from "@server/lib/statusHistory";
|
||||||
|
|
||||||
const siteParamsSchema = z.object({
|
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(
|
export async function getSiteStatusHistory(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
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) {
|
if (!parsedQuery.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -208,98 +77,17 @@ export async function getSiteStatusHistory(
|
|||||||
entityId,
|
entityId,
|
||||||
days: buckets,
|
days: buckets,
|
||||||
overallUptimePercent: Math.round(overallUptime * 100) / 100,
|
overallUptimePercent: Math.round(overallUptime * 100) / 100,
|
||||||
totalDowntimeSeconds: totalDowntime,
|
totalDowntimeSeconds: totalDowntime
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Status history retrieved successfully",
|
message: "Status history retrieved successfully",
|
||||||
status: HttpCode.OK,
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
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>,
|
header: () => <span className="p-3">Uptime (30d)</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
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 = {
|
type UptimeBarProps = {
|
||||||
|
orgId?: string;
|
||||||
siteId?: number;
|
siteId?: number;
|
||||||
targetId?: number;
|
healthCheckId?: number;
|
||||||
days?: number;
|
days?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UptimeBar({
|
export default function UptimeBar({
|
||||||
|
orgId,
|
||||||
siteId,
|
siteId,
|
||||||
targetId,
|
healthCheckId,
|
||||||
days = 90,
|
days = 90,
|
||||||
title,
|
title,
|
||||||
className
|
className
|
||||||
@@ -68,8 +70,8 @@ export default function UptimeBar({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hcQuery = useQuery({
|
const hcQuery = useQuery({
|
||||||
...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }),
|
...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }),
|
||||||
enabled: targetId != null && siteId == null,
|
enabled: healthCheckId != null && siteId == null,
|
||||||
meta: { api }
|
meta: { api }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -205,4 +207,4 @@ export default function UptimeBar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,14 +37,16 @@ const barColorClass: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type UptimeMiniBarProps = {
|
type UptimeMiniBarProps = {
|
||||||
|
orgId?: string;
|
||||||
siteId?: number;
|
siteId?: number;
|
||||||
targetId?: number;
|
healthCheckId?: number;
|
||||||
days?: number;
|
days?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UptimeMiniBar({
|
export default function UptimeMiniBar({
|
||||||
|
orgId,
|
||||||
siteId,
|
siteId,
|
||||||
targetId,
|
healthCheckId,
|
||||||
days = 30
|
days = 30
|
||||||
}: UptimeMiniBarProps) {
|
}: UptimeMiniBarProps) {
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
@@ -56,8 +58,8 @@ export default function UptimeMiniBar({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hcQuery = useQuery({
|
const hcQuery = useQuery({
|
||||||
...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }),
|
...orgQueries.healthCheckStatusHistory({ orgId: orgId ?? "", healthCheckId: healthCheckId ?? 0, days }),
|
||||||
enabled: targetId != null && siteId == null,
|
enabled: healthCheckId != null && siteId == null,
|
||||||
meta: { api }
|
meta: { api }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,4 +127,4 @@ export default function UptimeMiniBar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-10
@@ -1,5 +1,4 @@
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import type { StatusHistoryResponse } from "@server/routers/site/getStatusHistory";
|
|
||||||
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||||
import type { ListClientsResponse } from "@server/routers/client";
|
import type { ListClientsResponse } from "@server/routers/client";
|
||||||
import type { ListDomainsResponse } from "@server/routers/domain";
|
import type { ListDomainsResponse } from "@server/routers/domain";
|
||||||
@@ -29,6 +28,7 @@ import z from "zod";
|
|||||||
import { remote } from "./api";
|
import { remote } from "./api";
|
||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||||
|
import { StatusHistoryResponse } from "@server/middlewares/statusHistory";
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
link: string | null;
|
link: string | null;
|
||||||
@@ -306,7 +306,13 @@ export const orgQueries = {
|
|||||||
return res.data.data.healthChecks;
|
return res.data.data.healthChecks;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
siteStatusHistory: ({ siteId, days = 90 }: { siteId: number; days?: number }) =>
|
siteStatusHistory: ({
|
||||||
|
siteId,
|
||||||
|
days = 90
|
||||||
|
}: {
|
||||||
|
siteId: number;
|
||||||
|
days?: number;
|
||||||
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const,
|
queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
@@ -314,21 +320,35 @@ export const orgQueries = {
|
|||||||
AxiosResponse<StatusHistoryResponse>
|
AxiosResponse<StatusHistoryResponse>
|
||||||
>(`/site/${siteId}/status-history?days=${days}`, { signal });
|
>(`/site/${siteId}/status-history?days=${days}`, { signal });
|
||||||
return res.data.data;
|
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({
|
queryOptions({
|
||||||
queryKey: ["HC_STATUS_HISTORY", targetId, days] as const,
|
queryKey: [
|
||||||
|
"HC_STATUS_HISTORY",
|
||||||
|
orgId,
|
||||||
|
healthCheckId,
|
||||||
|
days
|
||||||
|
] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<StatusHistoryResponse>
|
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;
|
return res.data.data;
|
||||||
},
|
}
|
||||||
refetchInterval: 60_000,
|
})
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logAnalyticsFiltersSchema = z.object({
|
export const logAnalyticsFiltersSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user