Merge pull request #2918 from fosrl/dev

Use logsDb for the status history
This commit is contained in:
Owen Schwartz
2026-04-28 16:38:43 -07:00
committed by GitHub
6 changed files with 67 additions and 38 deletions

View File

@@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { db, statusHistory } from "@server/db"; import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm"; import { and, eq, gte, asc } from "drizzle-orm";
import cache from "@server/lib/cache"; import cache from "@server/lib/cache";
@@ -27,7 +27,7 @@ export async function getCachedStatusHistory(
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const startSec = nowSec - days * 86400; const startSec = nowSec - days * 86400;
const events = await db const events = await logsDb
.select() .select()
.from(statusHistory) .from(statusHistory)
.where( .where(
@@ -74,11 +74,11 @@ export const statusHistoryQuerySchema = z
days: z days: z
.string() .string()
.optional() .optional()
.transform((v) => (v ? parseInt(v, 10) : 90)), .transform((v) => (v ? parseInt(v, 10) : 90))
}) })
.pipe( .pipe(
z.object({ z.object({
days: z.number().int().min(1).max(365), days: z.number().int().min(1).max(365)
}) })
); );
@@ -99,7 +99,14 @@ export interface StatusHistoryResponse {
} }
export function computeBuckets( export function computeBuckets(
events: { entityType: string; entityId: number; orgId: string; status: string; timestamp: number; id: number }[], events: {
entityType: string;
entityId: number;
orgId: string;
status: string;
timestamp: number;
id: number;
}[],
days: number days: number
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
@@ -121,7 +128,8 @@ export function computeBuckets(
const currentStatus = lastBeforeDay?.status ?? null; const currentStatus = lastBeforeDay?.status ?? null;
const windows: { start: number; end: number | null; status: string }[] = []; const windows: { start: number; end: number | null; status: string }[] =
[];
let dayDowntime = 0; let dayDowntime = 0;
let dayDegradedTime = 0; let dayDegradedTime = 0;
@@ -132,22 +140,21 @@ export function computeBuckets(
if (windowStatus !== null && windowStatus !== evt.status) { if (windowStatus !== null && windowStatus !== evt.status) {
const windowEnd = evt.timestamp; const windowEnd = evt.timestamp;
const isDown = const isDown =
windowStatus === "offline" || windowStatus === "offline" || windowStatus === "unhealthy";
windowStatus === "unhealthy";
const isDegraded = windowStatus === "degraded"; const isDegraded = windowStatus === "degraded";
if (isDown) { if (isDown) {
dayDowntime += windowEnd - windowStart; dayDowntime += windowEnd - windowStart;
windows.push({ windows.push({
start: windowStart, start: windowStart,
end: windowEnd, end: windowEnd,
status: windowStatus, status: windowStatus
}); });
} else if (isDegraded) { } else if (isDegraded) {
dayDegradedTime += windowEnd - windowStart; dayDegradedTime += windowEnd - windowStart;
windows.push({ windows.push({
start: windowStart, start: windowStart,
end: windowEnd, end: windowEnd,
status: windowStatus, status: windowStatus
}); });
} }
} }
@@ -159,22 +166,21 @@ export function computeBuckets(
if (windowStatus !== null) { if (windowStatus !== null) {
const finalEnd = Math.min(dayEndSec, nowSec); const finalEnd = Math.min(dayEndSec, nowSec);
const isDown = const isDown =
windowStatus === "offline" || windowStatus === "offline" || windowStatus === "unhealthy";
windowStatus === "unhealthy";
const isDegraded = windowStatus === "degraded"; const isDegraded = windowStatus === "degraded";
if (isDown && finalEnd > windowStart) { if (isDown && finalEnd > windowStart) {
dayDowntime += finalEnd - windowStart; dayDowntime += finalEnd - windowStart;
windows.push({ windows.push({
start: windowStart, start: windowStart,
end: finalEnd, end: finalEnd,
status: windowStatus, status: windowStatus
}); });
} else if (isDegraded && finalEnd > windowStart) { } else if (isDegraded && finalEnd > windowStart) {
dayDegradedTime += finalEnd - windowStart; dayDegradedTime += finalEnd - windowStart;
windows.push({ windows.push({
start: windowStart, start: windowStart,
end: finalEnd, end: finalEnd,
status: windowStatus, status: windowStatus
}); });
} }
} }
@@ -225,7 +231,7 @@ export function computeBuckets(
uptimePercent: Math.round(uptimePct * 100) / 100, uptimePercent: Math.round(uptimePct * 100) / 100,
totalDowntimeSeconds: dayDowntime, totalDowntimeSeconds: dayDowntime,
downtimeWindows: windows, downtimeWindows: windows,
status, status
}); });
} }

View File

@@ -19,7 +19,8 @@ import {
targetHealthCheck, targetHealthCheck,
targets, targets,
resources, resources,
Transaction Transaction,
logsDb
} from "@server/db"; } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
@@ -52,10 +53,10 @@ export async function fireHealthCheckHealthyAlert(
healthCheckTargetId?: number | null, healthCheckTargetId?: number | null,
extra?: Record<string, unknown>, extra?: Record<string, unknown>,
send: boolean = true, send: boolean = true,
trx: Transaction | typeof db = db, trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { try {
await trx.insert(statusHistory).values({ await logsDb.insert(statusHistory).values({
entityType: "health_check", entityType: "health_check",
entityId: healthCheckId, entityId: healthCheckId,
orgId: orgId, orgId: orgId,
@@ -119,7 +120,7 @@ export async function fireHealthCheckUnhealthyAlert(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { try {
await trx.insert(statusHistory).values({ await logsDb.insert(statusHistory).values({
entityType: "health_check", entityType: "health_check",
entityId: healthCheckId, entityId: healthCheckId,
orgId: orgId, orgId: orgId,
@@ -172,7 +173,7 @@ export async function fireHealthCheckUnknownAlert(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { try {
await trx.insert(statusHistory).values({ await logsDb.insert(statusHistory).values({
entityType: "health_check", entityType: "health_check",
entityId: healthCheckId, entityId: healthCheckId,
orgId: orgId, orgId: orgId,
@@ -194,7 +195,12 @@ export async function fireHealthCheckUnknownAlert(
} }
} }
async function handleResource(orgId: string, healthCheckTargetId?: number | null, send: boolean = true, trx: Transaction | typeof db = db) { async function handleResource(
orgId: string,
healthCheckTargetId?: number | null,
send: boolean = true,
trx: Transaction | typeof db = db
) {
if (!healthCheckTargetId) { if (!healthCheckTargetId) {
return; return;
} }
@@ -222,7 +228,10 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null
const otherTargets = await trx const otherTargets = await trx
.select({ hcHealth: targetHealthCheck.hcHealth }) .select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets) .from(targets)
.innerJoin(targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId)) .innerJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
)
.where(eq(targets.resourceId, resource.resourceId)); .where(eq(targets.resourceId, resource.resourceId));
let health = "healthy"; let health = "healthy";

View File

@@ -13,7 +13,7 @@
import logger from "@server/logger"; import logger from "@server/logger";
import { processAlerts } from "../processAlerts"; import { processAlerts } from "../processAlerts";
import { db, statusHistory, Transaction } from "@server/db"; import { db, logsDb, statusHistory, Transaction } from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -40,7 +40,7 @@ export async function fireResourceHealthyAlert(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { try {
await trx.insert(statusHistory).values({ await logsDb.insert(statusHistory).values({
entityType: "resource", entityType: "resource",
entityId: resourceId, entityId: resourceId,
orgId: orgId, orgId: orgId,
@@ -101,7 +101,7 @@ export async function fireResourceUnhealthyAlert(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { try {
await trx.insert(statusHistory).values({ await logsDb.insert(statusHistory).values({
entityType: "resource", entityType: "resource",
entityId: resourceId, entityId: resourceId,
orgId: orgId, orgId: orgId,
@@ -162,7 +162,7 @@ export async function fireResourceDegradedAlert(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { try {
await trx.insert(statusHistory).values({ await logsDb.insert(statusHistory).values({
entityType: "resource", entityType: "resource",
entityId: resourceId, entityId: resourceId,
orgId: orgId, orgId: orgId,
@@ -223,7 +223,7 @@ export async function fireResourceUnknownAlert(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { try {
await trx.insert(statusHistory).values({ await logsDb.insert(statusHistory).values({
entityType: "resource", entityType: "resource",
entityId: resourceId, entityId: resourceId,
orgId: orgId, orgId: orgId,

View File

@@ -13,7 +13,13 @@
import logger from "@server/logger"; import logger from "@server/logger";
import { processAlerts } from "../processAlerts"; import { processAlerts } from "../processAlerts";
import { db, sites, statusHistory, targetHealthCheck, Transaction } from "@server/db"; import {
db,
logsDb,
statusHistory,
targetHealthCheck,
Transaction
} from "@server/db";
import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; import { invalidateStatusHistoryCache } from "@server/lib/statusHistory";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents"; import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
@@ -41,7 +47,7 @@ export async function fireSiteOnlineAlert(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { try {
await trx.insert(statusHistory).values({ await logsDb.insert(statusHistory).values({
entityType: "site", entityType: "site",
entityId: siteId, entityId: siteId,
orgId: orgId, orgId: orgId,
@@ -97,7 +103,7 @@ export async function fireSiteOfflineAlert(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
try { try {
await trx.insert(statusHistory).values({ await logsDb.insert(statusHistory).values({
entityType: "site", entityType: "site",
entityId: siteId, entityId: siteId,
orgId: orgId, orgId: orgId,

View File

@@ -84,7 +84,7 @@ export async function registerNewt(
maxBatchSize: siteProvisioningKeys.maxBatchSize, maxBatchSize: siteProvisioningKeys.maxBatchSize,
numUsed: siteProvisioningKeys.numUsed, numUsed: siteProvisioningKeys.numUsed,
validUntil: siteProvisioningKeys.validUntil, validUntil: siteProvisioningKeys.validUntil,
approveNewSites: siteProvisioningKeys.approveNewSites, approveNewSites: siteProvisioningKeys.approveNewSites
}) })
.from(siteProvisioningKeys) .from(siteProvisioningKeys)
.innerJoin( .innerJoin(
@@ -125,7 +125,10 @@ export async function registerNewt(
); );
} }
if (keyRecord.maxBatchSize && keyRecord.numUsed >= keyRecord.maxBatchSize) { if (
keyRecord.maxBatchSize &&
keyRecord.numUsed >= keyRecord.maxBatchSize
) {
return next( return next(
createHttpError( createHttpError(
HttpCode.UNAUTHORIZED, HttpCode.UNAUTHORIZED,
@@ -134,7 +137,10 @@ export async function registerNewt(
); );
} }
if (keyRecord.validUntil && new Date(keyRecord.validUntil) < new Date()) { if (
keyRecord.validUntil &&
new Date(keyRecord.validUntil) < new Date()
) {
return next( return next(
createHttpError( createHttpError(
HttpCode.UNAUTHORIZED, HttpCode.UNAUTHORIZED,
@@ -154,7 +160,10 @@ export async function registerNewt(
} }
if (!org.subnet) { if (!org.subnet) {
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Organization subnet not found") createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Organization subnet not found"
)
); );
} }
@@ -195,7 +204,6 @@ export async function registerNewt(
let newSiteId: number | undefined; let newSiteId: number | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const newClientAddress = await getNextAvailableClientSubnet(orgId); const newClientAddress = await getNextAvailableClientSubnet(orgId);
if (!newClientAddress) { if (!newClientAddress) {
return next( return next(
@@ -219,11 +227,11 @@ export async function registerNewt(
address: clientAddress, address: clientAddress,
type: "newt", type: "newt",
dockerSocketEnabled: true, dockerSocketEnabled: true,
status: keyRecord.approveNewSites ? "approved" : "pending", status: keyRecord.approveNewSites ? "approved" : "pending"
}) })
.returning(); .returning();
await trx.insert(statusHistory).values({ await logsDb.insert(statusHistory).values({
entityType: "site", entityType: "site",
entityId: newSite.siteId, entityId: newSite.siteId,
orgId: orgId, orgId: orgId,

View File

@@ -351,7 +351,7 @@ export async function createSite(
}) })
.returning(); .returning();
await trx.insert(statusHistory).values({ await logsDb.insert(statusHistory).values({
entityType: "site", entityType: "site",
entityId: newSite.siteId, entityId: newSite.siteId,
orgId: orgId, orgId: orgId,