mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-08 05:39:49 -05:00
Add caching to the hc and fix resource stuff
This commit is contained in:
@@ -1,4 +1,73 @@
|
||||
import { z } from "zod";
|
||||
import { db, statusHistory } from "@server/db";
|
||||
import { and, eq, gte, asc } from "drizzle-orm";
|
||||
import cache from "@server/lib/cache";
|
||||
|
||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||
|
||||
function statusHistoryCacheKey(
|
||||
entityType: string,
|
||||
entityId: number,
|
||||
days: number
|
||||
): string {
|
||||
return `statusHistory:${entityType}:${entityId}:${days}`;
|
||||
}
|
||||
|
||||
export async function getCachedStatusHistory(
|
||||
entityType: string,
|
||||
entityId: number,
|
||||
days: number
|
||||
): Promise<StatusHistoryResponse> {
|
||||
const cacheKey = statusHistoryCacheKey(entityType, entityId, days);
|
||||
const cached = await cache.get<StatusHistoryResponse>(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const result: StatusHistoryResponse = {
|
||||
entityType,
|
||||
entityId,
|
||||
days: buckets,
|
||||
overallUptimePercent: Math.round(overallUptime * 100) / 100,
|
||||
totalDowntimeSeconds: totalDowntime
|
||||
};
|
||||
|
||||
await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function invalidateStatusHistoryCache(
|
||||
entityType: string,
|
||||
entityId: number
|
||||
): Promise<void> {
|
||||
const prefix = `statusHistory:${entityType}:${entityId}:`;
|
||||
const keys = cache.keys().filter((k) => k.startsWith(prefix));
|
||||
if (keys.length > 0) {
|
||||
await cache.del(keys);
|
||||
}
|
||||
}
|
||||
|
||||
export const statusHistoryQuerySchema = z
|
||||
.object({
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Transaction
|
||||
} from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
|
||||
import {
|
||||
fireResourceDegradedAlert,
|
||||
fireResourceHealthyAlert,
|
||||
@@ -61,8 +62,9 @@ export async function fireHealthCheckHealthyAlert(
|
||||
status: "healthy",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await invalidateStatusHistoryCache("health_check", healthCheckId);
|
||||
|
||||
await handleResource(orgId, healthCheckTargetId, trx);
|
||||
await handleResource(orgId, healthCheckTargetId, send, trx);
|
||||
|
||||
if (!send) {
|
||||
return;
|
||||
@@ -124,8 +126,9 @@ export async function fireHealthCheckUnhealthyAlert(
|
||||
status: "unhealthy",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await invalidateStatusHistoryCache("health_check", healthCheckId);
|
||||
|
||||
await handleResource(orgId, healthCheckTargetId, trx);
|
||||
await handleResource(orgId, healthCheckTargetId, send, trx);
|
||||
|
||||
if (!send) {
|
||||
return;
|
||||
@@ -176,8 +179,9 @@ export async function fireHealthCheckUnknownAlert(
|
||||
status: "unknown",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await invalidateStatusHistoryCache("health_check", healthCheckId);
|
||||
|
||||
await handleResource(orgId, healthCheckTargetId, trx);
|
||||
await handleResource(orgId, healthCheckTargetId, send, trx);
|
||||
|
||||
if (!send) {
|
||||
return;
|
||||
@@ -190,11 +194,11 @@ export async function fireHealthCheckUnknownAlert(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) {
|
||||
async function handleResource(orgId: string, healthCheckTargetId?: number | null, send: boolean = true, trx: Transaction | typeof db = db) {
|
||||
if (!healthCheckTargetId) {
|
||||
return;
|
||||
}
|
||||
// we have resources lets get them
|
||||
// we have targets lets get them
|
||||
const [target] = await trx
|
||||
.select()
|
||||
.from(targets)
|
||||
@@ -204,6 +208,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [resource] = await trx
|
||||
.select()
|
||||
.from(resources)
|
||||
@@ -213,6 +218,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const otherTargets = await trx
|
||||
.select({ hcHealth: targetHealthCheck.hcHealth })
|
||||
.from(targets)
|
||||
@@ -256,6 +262,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
|
||||
resource.resourceId,
|
||||
resource.name,
|
||||
undefined,
|
||||
send,
|
||||
trx
|
||||
);
|
||||
} else if (health === "unhealthy") {
|
||||
@@ -264,6 +271,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
|
||||
resource.resourceId,
|
||||
resource.name,
|
||||
undefined,
|
||||
send,
|
||||
trx
|
||||
);
|
||||
} else if (health === "healthy") {
|
||||
@@ -272,6 +280,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
|
||||
resource.resourceId,
|
||||
resource.name,
|
||||
undefined,
|
||||
send,
|
||||
trx
|
||||
);
|
||||
} else if (health === "degraded") {
|
||||
@@ -280,6 +289,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
|
||||
resource.resourceId,
|
||||
resource.name,
|
||||
undefined,
|
||||
send,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import logger from "@server/logger";
|
||||
import { processAlerts } from "../processAlerts";
|
||||
import { db, statusHistory, Transaction } from "@server/db";
|
||||
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
@@ -35,6 +36,7 @@ export async function fireResourceHealthyAlert(
|
||||
resourceId: number,
|
||||
resourceName?: string | null,
|
||||
extra?: Record<string, unknown>,
|
||||
send: boolean = true,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -45,6 +47,11 @@ export async function fireResourceHealthyAlert(
|
||||
status: "healthy",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await invalidateStatusHistoryCache("resource", resourceId);
|
||||
|
||||
if (!send) {
|
||||
return;
|
||||
}
|
||||
|
||||
await processAlerts({
|
||||
eventType: "resource_healthy",
|
||||
@@ -90,6 +97,7 @@ export async function fireResourceUnhealthyAlert(
|
||||
resourceId: number,
|
||||
resourceName?: string | null,
|
||||
extra?: Record<string, unknown>,
|
||||
send: boolean = true,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -100,6 +108,11 @@ export async function fireResourceUnhealthyAlert(
|
||||
status: "unhealthy",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await invalidateStatusHistoryCache("resource", resourceId);
|
||||
|
||||
if (!send) {
|
||||
return;
|
||||
}
|
||||
|
||||
await processAlerts({
|
||||
eventType: "resource_unhealthy",
|
||||
@@ -145,6 +158,7 @@ export async function fireResourceDegradedAlert(
|
||||
resourceId: number,
|
||||
resourceName?: string | null,
|
||||
extra?: Record<string, unknown>,
|
||||
send: boolean = true,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -155,6 +169,11 @@ export async function fireResourceDegradedAlert(
|
||||
status: "degraded",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await invalidateStatusHistoryCache("resource", resourceId);
|
||||
|
||||
if (!send) {
|
||||
return;
|
||||
}
|
||||
|
||||
await processAlerts({
|
||||
eventType: "resource_degraded",
|
||||
@@ -200,6 +219,7 @@ export async function fireResourceUnknownAlert(
|
||||
resourceId: number,
|
||||
resourceName?: string | null,
|
||||
extra?: Record<string, unknown>,
|
||||
send: boolean = true,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -210,6 +230,11 @@ export async function fireResourceUnknownAlert(
|
||||
status: "unknown",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await invalidateStatusHistoryCache("resource", resourceId);
|
||||
|
||||
if (!send) {
|
||||
return;
|
||||
}
|
||||
|
||||
await processAlerts({
|
||||
eventType: "resource_toggle",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import logger from "@server/logger";
|
||||
import { processAlerts } from "../processAlerts";
|
||||
import { db, sites, statusHistory, targetHealthCheck, Transaction } from "@server/db";
|
||||
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
|
||||
|
||||
@@ -47,6 +48,7 @@ export async function fireSiteOnlineAlert(
|
||||
status: "online",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await invalidateStatusHistoryCache("site", siteId);
|
||||
|
||||
await processAlerts({
|
||||
eventType: "site_online",
|
||||
@@ -102,6 +104,7 @@ export async function fireSiteOfflineAlert(
|
||||
status: "offline",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
await invalidateStatusHistoryCache("site", siteId);
|
||||
|
||||
const unhealthyHealthChecks = await trx
|
||||
.update(targetHealthCheck)
|
||||
|
||||
@@ -13,15 +13,13 @@
|
||||
|
||||
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,
|
||||
getCachedStatusHistory,
|
||||
statusHistoryQuerySchema,
|
||||
StatusHistoryResponse
|
||||
} from "@server/lib/statusHistory";
|
||||
@@ -59,39 +57,10 @@ export async function getHealthCheckStatusHistory(
|
||||
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;
|
||||
const data = await getCachedStatusHistory(entityType, entityId, days);
|
||||
|
||||
return response<StatusHistoryResponse>(res, {
|
||||
data: {
|
||||
entityType,
|
||||
entityId,
|
||||
days: buckets,
|
||||
overallUptimePercent: Math.round(overallUptime * 100) / 100,
|
||||
totalDowntimeSeconds: totalDowntime
|
||||
},
|
||||
data,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Status history retrieved successfully",
|
||||
@@ -103,4 +72,4 @@ export async function getHealthCheckStatusHistory(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
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,
|
||||
getCachedStatusHistory,
|
||||
statusHistoryQuerySchema,
|
||||
StatusHistoryResponse
|
||||
} from "@server/lib/statusHistory";
|
||||
@@ -46,39 +44,10 @@ export async function getResourceStatusHistory(
|
||||
const entityId = parsedParams.data.resourceId;
|
||||
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;
|
||||
const data = await getCachedStatusHistory(entityType, entityId, days);
|
||||
|
||||
return response<StatusHistoryResponse>(res, {
|
||||
data: {
|
||||
entityType,
|
||||
entityId,
|
||||
days: buckets,
|
||||
overallUptimePercent: Math.round(overallUptime * 100) / 100,
|
||||
totalDowntimeSeconds: totalDowntime
|
||||
},
|
||||
data,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Status history retrieved successfully",
|
||||
@@ -90,4 +59,4 @@ export async function getResourceStatusHistory(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
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,
|
||||
getCachedStatusHistory,
|
||||
statusHistoryQuerySchema,
|
||||
StatusHistoryResponse
|
||||
} from "@server/lib/statusHistory";
|
||||
@@ -46,39 +44,10 @@ export async function getSiteStatusHistory(
|
||||
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;
|
||||
const data = await getCachedStatusHistory(entityType, entityId, days);
|
||||
|
||||
return response<StatusHistoryResponse>(res, {
|
||||
data: {
|
||||
entityType,
|
||||
entityId,
|
||||
days: buckets,
|
||||
overallUptimePercent: Math.round(overallUptime * 100) / 100,
|
||||
totalDowntimeSeconds: totalDowntime
|
||||
},
|
||||
data,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Status history retrieved successfully",
|
||||
@@ -90,4 +59,4 @@ export async function getSiteStatusHistory(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,7 +267,7 @@ export async function createTarget(
|
||||
healthCheck[0].orgId,
|
||||
healthCheck[0].targetHealthCheckId,
|
||||
healthCheck[0].name,
|
||||
undefined,
|
||||
healthCheck[0].targetId,
|
||||
undefined,
|
||||
false, // dont send the alert because we just want to create the alert, not notify users yet
|
||||
trx
|
||||
@@ -278,7 +278,7 @@ export async function createTarget(
|
||||
healthCheck[0].orgId,
|
||||
healthCheck[0].targetHealthCheckId,
|
||||
healthCheck[0].name,
|
||||
undefined,
|
||||
healthCheck[0].targetId,
|
||||
undefined,
|
||||
false, // dont send the alert because we just want to create the alert, not notify users yet
|
||||
trx
|
||||
@@ -288,7 +288,7 @@ export async function createTarget(
|
||||
healthCheck[0].orgId,
|
||||
healthCheck[0].targetHealthCheckId,
|
||||
healthCheck[0].name,
|
||||
undefined,
|
||||
healthCheck[0].targetId,
|
||||
undefined,
|
||||
false, // dont send the alert because we just want to create the alert, not notify users yet
|
||||
trx
|
||||
|
||||
@@ -228,12 +228,7 @@ export async function updateTarget(
|
||||
hcHealthValue = undefined;
|
||||
}
|
||||
|
||||
const isDisablingHc =
|
||||
(parsedBody.data.hcEnabled === false ||
|
||||
parsedBody.data.hcEnabled === null) &&
|
||||
existingHc.hcEnabled === true;
|
||||
|
||||
const [updatedHc] = await trx
|
||||
[updatedHc] = await trx
|
||||
.update(targetHealthCheck)
|
||||
.set({
|
||||
siteId: parsedBody.data.siteId,
|
||||
@@ -259,32 +254,41 @@ export async function updateTarget(
|
||||
.returning();
|
||||
|
||||
if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") {
|
||||
logger.debug(
|
||||
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unhealthy, firing alert`
|
||||
);
|
||||
await fireHealthCheckUnhealthyAlert(
|
||||
updatedHc.orgId,
|
||||
updatedHc.targetHealthCheckId,
|
||||
updatedHc.name || "",
|
||||
undefined,
|
||||
updatedHc.targetId,
|
||||
undefined,
|
||||
false, // dont send the alert because we just want to create the alert, not notify users yet
|
||||
trx
|
||||
);
|
||||
} else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") {
|
||||
logger.debug(
|
||||
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unknown, firing alert`
|
||||
);
|
||||
// if the health is unknown, we want to fire an alert to notify users to enable health checks
|
||||
await fireHealthCheckUnknownAlert(
|
||||
updatedHc.orgId,
|
||||
updatedHc.targetHealthCheckId,
|
||||
updatedHc.name,
|
||||
undefined,
|
||||
updatedHc.targetId,
|
||||
undefined,
|
||||
false, // dont send the alert because we just want to create the alert, not notify users yet
|
||||
trx
|
||||
);
|
||||
} else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") {
|
||||
logger.debug(
|
||||
`Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert`
|
||||
);
|
||||
await fireHealthCheckHealthyAlert(
|
||||
updatedHc.orgId,
|
||||
updatedHc.targetHealthCheckId,
|
||||
updatedHc.name,
|
||||
undefined,
|
||||
updatedHc.targetId,
|
||||
undefined,
|
||||
false, // dont send the alert because we just want to create the alert, not notify users yet
|
||||
trx
|
||||
|
||||
Reference in New Issue
Block a user