mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-30 20:52:40 +00:00
Add uptime tracking
This commit is contained in:
@@ -1159,3 +1159,19 @@ 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>;
|
||||||
|
|||||||
@@ -1181,6 +1181,20 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const statusHistory = sqliteTable("statusHistory", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||||
|
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||||
|
timestamp: integer("timestamp").notNull(), // unix epoch seconds
|
||||||
|
}, (table) => [
|
||||||
|
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||||
|
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||||
|
]);
|
||||||
|
|
||||||
export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
||||||
messageId: integer("messageId").primaryKey({ autoIncrement: true }),
|
messageId: integer("messageId").primaryKey({ autoIncrement: true }),
|
||||||
wsClientId: text("clientId"),
|
wsClientId: text("clientId"),
|
||||||
@@ -1258,3 +1272,4 @@ export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
|||||||
export type RoundTripMessageTracker = InferSelectModel<
|
export type RoundTripMessageTracker = InferSelectModel<
|
||||||
typeof roundTripMessageTracker
|
typeof roundTripMessageTracker
|
||||||
>;
|
>;
|
||||||
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
|
|||||||
@@ -285,6 +285,20 @@ authenticated.get(
|
|||||||
site.listContainers
|
site.listContainers
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/site/:siteId/status-history",
|
||||||
|
verifySiteAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getSite),
|
||||||
|
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",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db, newts, sites, targetHealthCheck, targets } from "@server/db";
|
import { db, newts, sites, targetHealthCheck, targets, statusHistory } from "@server/db";
|
||||||
import {
|
import {
|
||||||
hasActiveConnections,
|
hasActiveConnections,
|
||||||
} from "#dynamic/routers/ws";
|
} from "#dynamic/routers/ws";
|
||||||
@@ -77,6 +77,14 @@ export const startNewtOfflineChecker = (): void => {
|
|||||||
.set({ online: false })
|
.set({ online: false })
|
||||||
.where(eq(sites.siteId, staleSite.siteId));
|
.where(eq(sites.siteId, staleSite.siteId));
|
||||||
|
|
||||||
|
await db.insert(statusHistory).values({
|
||||||
|
entityType: "site",
|
||||||
|
entityId: staleSite.siteId,
|
||||||
|
orgId: staleSite.orgId,
|
||||||
|
status: "offline",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
}).execute();
|
||||||
|
|
||||||
const healthChecksOnSite = await db
|
const healthChecksOnSite = await db
|
||||||
.select()
|
.select()
|
||||||
.from(targetHealthCheck)
|
.from(targetHealthCheck)
|
||||||
@@ -147,6 +155,14 @@ export const startNewtOfflineChecker = (): void => {
|
|||||||
.set({ online: false })
|
.set({ online: false })
|
||||||
.where(eq(sites.siteId, site.siteId));
|
.where(eq(sites.siteId, site.siteId));
|
||||||
|
|
||||||
|
await db.insert(statusHistory).values({
|
||||||
|
entityType: "site",
|
||||||
|
entityId: site.siteId,
|
||||||
|
orgId: site.orgId,
|
||||||
|
status: "offline",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
}).execute();
|
||||||
|
|
||||||
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name);
|
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name);
|
||||||
} else if (
|
} else if (
|
||||||
lastBandwidthUpdate >= wireguardOfflineThreshold &&
|
lastBandwidthUpdate >= wireguardOfflineThreshold &&
|
||||||
@@ -161,6 +177,14 @@ export const startNewtOfflineChecker = (): void => {
|
|||||||
.set({ online: true })
|
.set({ online: true })
|
||||||
.where(eq(sites.siteId, site.siteId));
|
.where(eq(sites.siteId, site.siteId));
|
||||||
|
|
||||||
|
await db.insert(statusHistory).values({
|
||||||
|
entityType: "site",
|
||||||
|
entityId: site.siteId,
|
||||||
|
orgId: site.orgId,
|
||||||
|
status: "online",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
}).execute();
|
||||||
|
|
||||||
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
|
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { sites, clients, olms } from "@server/db";
|
import { sites, clients, olms, statusHistory } from "@server/db";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fireSiteOnlineAlert } from "#dynamic/lib/alerts";
|
import { fireSiteOnlineAlert } from "#dynamic/lib/alerts";
|
||||||
@@ -147,6 +147,13 @@ async function flushSitePingsToDb(): Promise<void> {
|
|||||||
}, "flushSitePingsToDb");
|
}, "flushSitePingsToDb");
|
||||||
|
|
||||||
for (const site of newlyOnlineSites) {
|
for (const site of newlyOnlineSites) {
|
||||||
|
await db.insert(statusHistory).values({
|
||||||
|
entityType: "site",
|
||||||
|
entityId: site.siteId,
|
||||||
|
orgId: site.orgId,
|
||||||
|
status: "online",
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
}).execute();
|
||||||
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
|
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
305
server/routers/site/getStatusHistory.ts
Normal file
305
server/routers/site/getStatusHistory.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const siteParamsSchema = z.object({
|
||||||
|
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,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = siteParamsSchema.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 = "site";
|
||||||
|
const entityId = parsedParams.data.siteId;
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./getSite";
|
export * from "./getSite";
|
||||||
|
export * from "./getStatusHistory";
|
||||||
export * from "./createSite";
|
export * from "./createSite";
|
||||||
export * from "./deleteSite";
|
export * from "./deleteSite";
|
||||||
export * from "./updateSite";
|
export * from "./updateSite";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db, targets, resources, sites, targetHealthCheck } from "@server/db";
|
import { db, targets, resources, sites, targetHealthCheck, statusHistory } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { Newt } from "@server/db";
|
import { Newt } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
@@ -137,6 +137,15 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
|||||||
.where(eq(targetHealthCheck.targetId, targetIdNum))
|
.where(eq(targetHealthCheck.targetId, targetIdNum))
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
// Log the state change to status history
|
||||||
|
await db.insert(statusHistory).values({
|
||||||
|
entityType: "healthCheck",
|
||||||
|
entityId: targetCheck.targetHealthCheckId,
|
||||||
|
orgId: targetCheck.orgId || targetCheck.resourceOrgId,
|
||||||
|
status: healthStatus.status,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
}).execute();
|
||||||
|
|
||||||
// because we are checking above if there was a change we can fire the alert here because it changed
|
// because we are checking above if there was a change we can fire the alert here because it changed
|
||||||
if (healthStatus.status === "unhealthy") {
|
if (healthStatus.status === "unhealthy") {
|
||||||
await fireHealthCheckHealthyAlert(
|
await fireHealthCheckHealthyAlert(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import UptimeBar from "@app/components/UptimeBar";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -223,6 +225,19 @@ export default function GeneralPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Site availability over the last 90 days.
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
{site?.siteId && (
|
||||||
|
<UptimeBar siteId={site.siteId} days={90} />
|
||||||
|
)}
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import UptimeMiniBar from "@app/components/UptimeMiniBar";
|
||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import HealthCheckCredenza, {
|
import HealthCheckCredenza, {
|
||||||
HealthCheckRow
|
HealthCheckRow
|
||||||
@@ -219,6 +221,16 @@ export default function HealthChecksTable({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "uptime",
|
||||||
|
friendlyName: "Uptime",
|
||||||
|
header: () => <span className="p-3">Uptime (30d)</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<UptimeMiniBar targetId={row.original.targetHealthCheckId} days={30} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "hcEnabled",
|
accessorKey: "hcEnabled",
|
||||||
friendlyName: t("alertingColumnEnabled"),
|
friendlyName: t("alertingColumnEnabled"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
|
import UptimeMiniBar from "@app/components/UptimeMiniBar";
|
||||||
|
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
@@ -222,6 +223,17 @@ export default function SitesTable({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "uptime",
|
||||||
|
friendlyName: "Uptime",
|
||||||
|
header: () => <span className="p-3">Uptime (30d)</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const originalRow = row.original;
|
||||||
|
return (
|
||||||
|
<UptimeMiniBar siteId={originalRow.id} days={30} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "mbIn",
|
accessorKey: "mbIn",
|
||||||
friendlyName: t("dataIn"),
|
friendlyName: t("dataIn"),
|
||||||
|
|||||||
208
src/components/UptimeBar.tsx
Normal file
208
src/components/UptimeBar.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds === 0) return "0s";
|
||||||
|
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.round(seconds % 60);
|
||||||
|
if (h > 0) return s > 0 ? `${h}h ${m}m ${s}s` : `${h}h ${m}m`;
|
||||||
|
if (m > 0 && s > 0) return `${m}m ${s}s`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr + "T00:00:00").toLocaleDateString([], {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const barColorClass: Record<string, string> = {
|
||||||
|
good: "bg-green-500",
|
||||||
|
degraded: "bg-yellow-500",
|
||||||
|
bad: "bg-red-500",
|
||||||
|
no_data: "bg-zinc-700"
|
||||||
|
};
|
||||||
|
|
||||||
|
type UptimeBarProps = {
|
||||||
|
siteId?: number;
|
||||||
|
targetId?: number;
|
||||||
|
days?: number;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UptimeBar({
|
||||||
|
siteId,
|
||||||
|
targetId,
|
||||||
|
days = 90,
|
||||||
|
title,
|
||||||
|
className
|
||||||
|
}: UptimeBarProps) {
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const siteQuery = useQuery({
|
||||||
|
...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }),
|
||||||
|
enabled: siteId != null,
|
||||||
|
meta: { api }
|
||||||
|
});
|
||||||
|
|
||||||
|
const hcQuery = useQuery({
|
||||||
|
...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }),
|
||||||
|
enabled: targetId != null && siteId == null,
|
||||||
|
meta: { api }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = siteId != null ? siteQuery : hcQuery;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-2", className)}>
|
||||||
|
{title && (
|
||||||
|
<div className="text-sm font-medium">{title}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-0.5 h-8">
|
||||||
|
{Array.from({ length: days }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-sm bg-zinc-800 animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-3", className)}>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{title && (
|
||||||
|
<span className="text-sm font-medium">{title}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-sm ml-auto">
|
||||||
|
{!allNoData && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{data.overallUptimePercent.toFixed(2)}%
|
||||||
|
</span>{" "}
|
||||||
|
uptime
|
||||||
|
</span>
|
||||||
|
{data.totalDowntimeSeconds > 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{formatDuration(
|
||||||
|
data.totalDowntimeSeconds
|
||||||
|
)}
|
||||||
|
</span>{" "}
|
||||||
|
downtime
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{allNoData && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
No data available
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bar row */}
|
||||||
|
<div className="flex gap-0.5 h-8">
|
||||||
|
{data.days.map((day, i) => (
|
||||||
|
<Tooltip key={i}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-sm cursor-default transition-opacity hover:opacity-80",
|
||||||
|
barColorClass[day.status]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
className="max-w-[220px] p-3 space-y-1"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-xs">
|
||||||
|
{formatDate(day.date)}
|
||||||
|
</div>
|
||||||
|
{day.status !== "no_data" && (
|
||||||
|
<div className="text-xs text-primary-foreground/80">
|
||||||
|
Uptime:{" "}
|
||||||
|
<span className="font-medium text-primary-foreground">
|
||||||
|
{day.uptimePercent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{day.totalDowntimeSeconds > 0 && (
|
||||||
|
<div className="text-xs text-primary-foreground/80">
|
||||||
|
Downtime:{" "}
|
||||||
|
<span className="font-medium text-primary-foreground">
|
||||||
|
{formatDuration(
|
||||||
|
day.totalDowntimeSeconds
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{day.downtimeWindows.length > 0 && (
|
||||||
|
<div className="pt-1 space-y-0.5 border-t border-primary-foreground/20">
|
||||||
|
{day.downtimeWindows.map((w, wi) => (
|
||||||
|
<div
|
||||||
|
key={wi}
|
||||||
|
className="text-xs text-primary-foreground/70"
|
||||||
|
>
|
||||||
|
{formatTime(w.start)}
|
||||||
|
{w.end
|
||||||
|
? ` – ${formatTime(w.end)}`
|
||||||
|
: " – ongoing"}{" "}
|
||||||
|
<span className="capitalize">
|
||||||
|
({w.status})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{day.status === "no_data" && (
|
||||||
|
<div className="text-xs text-primary-foreground/60">
|
||||||
|
No monitoring data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date labels */}
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{days} days ago</span>
|
||||||
|
<span>Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/UptimeMiniBar.tsx
Normal file
128
src/components/UptimeMiniBar.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds === 0) return "0s";
|
||||||
|
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.round(seconds % 60);
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
if (m > 0 && s > 0) return `${m}m ${s}s`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Date(dateStr + "T00:00:00").toLocaleDateString([], {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const barColorClass: Record<string, string> = {
|
||||||
|
good: "bg-green-500",
|
||||||
|
degraded: "bg-yellow-500",
|
||||||
|
bad: "bg-red-500",
|
||||||
|
no_data: "bg-zinc-700"
|
||||||
|
};
|
||||||
|
|
||||||
|
type UptimeMiniBarProps = {
|
||||||
|
siteId?: number;
|
||||||
|
targetId?: number;
|
||||||
|
days?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UptimeMiniBar({
|
||||||
|
siteId,
|
||||||
|
targetId,
|
||||||
|
days = 30
|
||||||
|
}: UptimeMiniBarProps) {
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
const siteQuery = useQuery({
|
||||||
|
...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }),
|
||||||
|
enabled: siteId != null,
|
||||||
|
meta: { api }
|
||||||
|
});
|
||||||
|
|
||||||
|
const hcQuery = useQuery({
|
||||||
|
...orgQueries.healthCheckStatusHistory({ targetId: targetId ?? 0, days }),
|
||||||
|
enabled: targetId != null && siteId == null,
|
||||||
|
meta: { api }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = siteId != null ? siteQuery : hcQuery;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex gap-px h-5 w-24">
|
||||||
|
{Array.from({ length: days }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-[2px] bg-zinc-800 animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground w-12">—</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex gap-px h-5"
|
||||||
|
style={{ width: `${days * 5}px` }}
|
||||||
|
>
|
||||||
|
{data.days.map((day, i) => (
|
||||||
|
<Tooltip key={i}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-[2px] cursor-default transition-opacity hover:opacity-75",
|
||||||
|
barColorClass[day.status]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="p-2 space-y-0.5">
|
||||||
|
<div className="font-semibold text-xs">
|
||||||
|
{formatDate(day.date)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-primary-foreground/80">
|
||||||
|
{day.status === "no_data"
|
||||||
|
? "No data"
|
||||||
|
: `${day.uptimePercent.toFixed(1)}% uptime`}
|
||||||
|
</div>
|
||||||
|
{day.totalDowntimeSeconds > 0 && (
|
||||||
|
<div className="text-xs text-primary-foreground/70">
|
||||||
|
Down:{" "}
|
||||||
|
{formatDuration(day.totalDowntimeSeconds)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{allNoData
|
||||||
|
? "No data"
|
||||||
|
: `${data.overallUptimePercent.toFixed(1)}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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";
|
||||||
@@ -304,7 +305,30 @@ export const orgQueries = {
|
|||||||
>(`/org/${orgId}/health-checks`, { signal });
|
>(`/org/${orgId}/health-checks`, { signal });
|
||||||
return res.data.data.healthChecks;
|
return res.data.data.healthChecks;
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
siteStatusHistory: ({ siteId, days = 90 }: { siteId: number; days?: number }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<StatusHistoryResponse>
|
||||||
|
>(`/site/${siteId}/status-history?days=${days}`, { signal });
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
}),
|
||||||
|
|
||||||
|
healthCheckStatusHistory: ({ targetId, days = 90 }: { targetId: number; days?: number }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["HC_STATUS_HISTORY", targetId, days] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<StatusHistoryResponse>
|
||||||
|
>(`/target/${targetId}/health-check/status-history?days=${days}`, { signal });
|
||||||
|
return res.data.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const logAnalyticsFiltersSchema = z.object({
|
export const logAnalyticsFiltersSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user