Files
pangolin/server/lib/statusHistory.ts

234 lines
7.5 KiB
TypeScript

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({
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" | "unknown";
}
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 dayDegradedTime = 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";
const isDegraded = windowStatus === "degraded";
if (isDown) {
dayDowntime += windowEnd - windowStart;
windows.push({
start: windowStart,
end: windowEnd,
status: windowStatus,
});
} else if (isDegraded) {
dayDegradedTime += 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";
const isDegraded = windowStatus === "degraded";
if (isDown && finalEnd > windowStart) {
dayDowntime += finalEnd - windowStart;
windows.push({
start: windowStart,
end: finalEnd,
status: windowStatus,
});
} else if (isDegraded && finalEnd > windowStart) {
dayDegradedTime += 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 - dayDegradedTime) /
effectiveDayLength) *
100
)
: 100;
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10);
const hasAnyData = currentStatus !== null || dayEvents.length > 0;
// The whole observable window is "unknown" if every status we have seen is unknown
const allStatuses = [
...(currentStatus !== null ? [currentStatus] : []),
...dayEvents.map((e) => e.status)
];
const onlyUnknownData =
hasAnyData && allStatuses.every((s) => s === "unknown");
let status: StatusHistoryDayBucket["status"] = "no_data";
if (hasAnyData) {
if (onlyUnknownData) {
status = "unknown";
} else if (dayDowntime > 0 && uptimePct < 50) {
status = "bad";
} else if (dayDowntime > 0 || dayDegradedTime > 0) {
status = "degraded";
} else {
status = "good";
}
}
buckets.push({
date: dateStr,
uptimePercent: Math.round(uptimePct * 100) / 100,
totalDowntimeSeconds: dayDowntime,
downtimeWindows: windows,
status,
});
}
return { buckets, totalDowntime };
}