mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-30 09:41:51 +00:00
234 lines
7.5 KiB
TypeScript
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 };
|
|
}
|