diff --git a/messages/en-US.json b/messages/en-US.json index 7146bac3a..33003e434 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1426,6 +1426,23 @@ "alertingNodeRoleSource": "Source", "alertingNodeRoleTrigger": "Trigger", "alertingNodeRoleAction": "Action", + "alertingTabRules": "Alert Rules", + "alertingTabHealthChecks": "Health Checks", + "standaloneHcTableTitle": "Health Checks", + "standaloneHcSearchPlaceholder": "Search health checks…", + "standaloneHcAddButton": "Create Health Check", + "standaloneHcCreateTitle": "Create Health Check", + "standaloneHcEditTitle": "Edit Health Check", + "standaloneHcDescription": "Configure a HTTP or TCP health check for use in alert rules.", + "standaloneHcNameLabel": "Name", + "standaloneHcNamePlaceholder": "My HTTP Monitor", + "standaloneHcDeleteTitle": "Delete health check", + "standaloneHcDeleteQuestion": "Delete this health check? This cannot be undone.", + "standaloneHcDeleted": "Health check deleted", + "standaloneHcSaved": "Health check saved", + "standaloneHcColumnHealth": "Health", + "standaloneHcColumnMode": "Mode", + "standaloneHcColumnTarget": "Target", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index f192459cc..fd9c02e93 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -145,12 +145,15 @@ export enum ActionsEnum { updateEventStreamingDestination = "updateEventStreamingDestination", deleteEventStreamingDestination = "deleteEventStreamingDestination", listEventStreamingDestinations = "listEventStreamingDestinations", - listHealthChecks = "listHealthChecks", createAlertRule = "createAlertRule", updateAlertRule = "updateAlertRule", deleteAlertRule = "deleteAlertRule", listAlertRules = "listAlertRules", - getAlertRule = "getAlertRule" + getAlertRule = "getAlertRule", + createHealthCheck = "createHealthCheck", + updateHealthCheck = "updateHealthCheck", + deleteHealthCheck = "deleteHealthCheck", + listHealthChecks = "listHealthChecks" } export async function checkUserActionPermission( diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index b75914021..72bcda76f 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -140,6 +140,7 @@ export async function updateProxyResources( const [newHealthcheck] = await trx .insert(targetHealthCheck) .values({ + name: `${targetData.hostname}:${targetData.port}`, targetId: newTarget.targetId, hcEnabled: healthcheckData?.enabled || false, hcPath: healthcheckData?.path, diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0e5c5e0ef..f10e526ed 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -30,6 +30,7 @@ import * as user from "#private/routers/user"; import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; +import * as healthChecks from "#private/routers/healthChecks"; import { verifyOrgAccess, @@ -695,3 +696,38 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getAlertRule), alertRule.getAlertRule ); + +authenticated.get( + "/org/:orgId/health-checks", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listHealthChecks), + healthChecks.listHealthChecks +); + +authenticated.put( + "/org/:orgId/health-check", + verifyValidLicense, + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createHealthCheck), + logActionAudit(ActionsEnum.createHealthCheck), + healthChecks.createHealthCheck +); + +authenticated.post( + "/org/:orgId/health-check/:healthCheckId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateHealthCheck), + logActionAudit(ActionsEnum.updateHealthCheck), + healthChecks.updateHealthCheck +); + +authenticated.delete( + "/org/:orgId/health-check/:healthCheckId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteHealthCheck), + logActionAudit(ActionsEnum.deleteHealthCheck), + healthChecks.deleteHealthCheck +); diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts new file mode 100644 index 000000000..2a6028ea8 --- /dev/null +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -0,0 +1,158 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, targetHealthCheck } from "@server/db"; +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 { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.strictObject({ + name: z.string().nonempty(), + hcEnabled: z.boolean().default(false), + hcMode: z.string().default("http"), + hcHostname: z.string().optional(), + hcPort: z.number().int().min(1).max(65535).optional(), + hcPath: z.string().optional(), + hcScheme: z.string().optional(), + hcMethod: z.string().default("GET"), + hcInterval: z.number().int().positive().default(30), + hcUnhealthyInterval: z.number().int().positive().default(30), + hcTimeout: z.number().int().positive().default(5), + hcHeaders: z.string().optional().nullable(), + hcFollowRedirects: z.boolean().default(true), + hcStatus: z.number().int().optional().nullable(), + hcTlsServerName: z.string().optional(), + hcHealthyThreshold: z.number().int().positive().default(1), + hcUnhealthyThreshold: z.number().int().positive().default(1) +}); + +export type CreateHealthCheckResponse = { + targetHealthCheckId: number; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/health-check", + description: "Create a health check for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createHealthCheck( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { + name, + hcEnabled, + hcMode, + hcHostname, + hcPort, + hcPath, + hcScheme, + hcMethod, + hcInterval, + hcUnhealthyInterval, + hcTimeout, + hcHeaders, + hcFollowRedirects, + hcStatus, + hcTlsServerName, + hcHealthyThreshold, + hcUnhealthyThreshold + } = parsedBody.data; + + const [record] = await db + .insert(targetHealthCheck) + .values({ + targetId: null, + orgId, + name, + hcEnabled, + hcMode, + hcHostname: hcHostname ?? null, + hcPort: hcPort ?? null, + hcPath: hcPath ?? null, + hcScheme: hcScheme ?? null, + hcMethod, + hcInterval, + hcUnhealthyInterval, + hcTimeout, + hcHeaders: hcHeaders ?? null, + hcFollowRedirects, + hcStatus: hcStatus ?? null, + hcTlsServerName: hcTlsServerName ?? null, + hcHealthyThreshold, + hcUnhealthyThreshold + }) + .returning(); + + return response(res, { + data: { + targetHealthCheckId: record.targetHealthCheckId + }, + success: true, + error: false, + message: "Standalone health check created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/healthChecks/deleteHealthCheck.ts b/server/private/routers/healthChecks/deleteHealthCheck.ts new file mode 100644 index 000000000..b65e4a701 --- /dev/null +++ b/server/private/routers/healthChecks/deleteHealthCheck.ts @@ -0,0 +1,107 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, targetHealthCheck } from "@server/db"; +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 { OpenAPITags, registry } from "@server/openApi"; +import { and, eq, isNull } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + healthCheckId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/health-check/{healthCheckId}", + description: "Delete a health check for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteHealthCheck( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, healthCheckId } = parsedParams.data; + + const [existing] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Standalone health check not found" + ) + ); + } + + await db + .delete(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Standalone health check deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/healthChecks/index.ts b/server/private/routers/healthChecks/index.ts new file mode 100644 index 000000000..5f5c796f3 --- /dev/null +++ b/server/private/routers/healthChecks/index.ts @@ -0,0 +1,17 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./listHealthChecks"; +export * from "./createHealthCheck"; +export * from "./updateHealthCheck"; +export * from "./deleteHealthCheck"; diff --git a/server/routers/resource/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts similarity index 50% rename from server/routers/resource/listHealthChecks.ts rename to server/private/routers/healthChecks/listHealthChecks.ts index 698f35052..d5b05ac24 100644 --- a/server/routers/resource/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -1,19 +1,33 @@ -import { db, targetHealthCheck, targets, resources } from "@server/db"; +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { db, targetHealthCheck } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq, sql, inArray } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { fromError } from "zod-validation-error"; +import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; -const listHealthChecksParamsSchema = z.strictObject({ +const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const listHealthChecksSchema = z.object({ +const querySchema = z.object({ limit: z .string() .optional() @@ -28,29 +42,14 @@ const listHealthChecksSchema = z.object({ .pipe(z.int().nonnegative()) }); -export type ListHealthChecksResponse = { - healthChecks: { - targetHealthCheckId: number; - resourceId: number; - resourceName: string; - hcEnabled: boolean; - hcHealth: "unknown" | "healthy" | "unhealthy"; - }[]; - pagination: { - total: number; - limit: number; - offset: number; - }; -}; - registry.registerPath({ method: "get", path: "/org/{orgId}/health-checks", - description: "List health checks for all resources in an organization.", - tags: [OpenAPITags.Org, OpenAPITags.PublicResource], + description: "List health checks for an organization.", + tags: [OpenAPITags.Org], request: { - params: listHealthChecksParamsSchema, - query: listHealthChecksSchema + params: paramsSchema, + query: querySchema }, responses: {} }); @@ -61,62 +60,71 @@ export async function listHealthChecks( next: NextFunction ): Promise { try { - const parsedQuery = listHealthChecksSchema.safeParse(req.query); - if (!parsedQuery.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedQuery.error) - ) - ); - } - const { limit, offset } = parsedQuery.data; - - const parsedParams = listHealthChecksParamsSchema.safeParse(req.params); + const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedParams.error) + fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const whereClause = and( + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ); + const list = await db - .select({ - targetHealthCheckId: targetHealthCheck.targetHealthCheckId, - resourceId: resources.resourceId, - resourceName: resources.name, - hcEnabled: targetHealthCheck.hcEnabled, - hcHealth: targetHealthCheck.hcHealth - }) + .select() .from(targetHealthCheck) - .innerJoin(targets, eq(targets.targetId, targetHealthCheck.targetId)) - .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) - .where(eq(resources.orgId, orgId)) - .orderBy(sql`${resources.name} ASC`) + .where(whereClause) + .orderBy(sql`${targetHealthCheck.targetHealthCheckId} DESC`) .limit(limit) .offset(offset); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(targetHealthCheck) - .innerJoin(targets, eq(targets.targetId, targetHealthCheck.targetId)) - .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) - .where(eq(resources.orgId, orgId)); + .where(whereClause); return response(res, { data: { healthChecks: list.map((row) => ({ targetHealthCheckId: row.targetHealthCheckId, - resourceId: row.resourceId, - resourceName: row.resourceName, + name: row.name ?? "", hcEnabled: row.hcEnabled, hcHealth: (row.hcHealth ?? "unknown") as | "unknown" | "healthy" - | "unhealthy" + | "unhealthy", + hcMode: row.hcMode ?? null, + hcHostname: row.hcHostname ?? null, + hcPort: row.hcPort ?? null, + hcPath: row.hcPath ?? null, + hcScheme: row.hcScheme ?? null, + hcMethod: row.hcMethod ?? null, + hcInterval: row.hcInterval ?? null, + hcUnhealthyInterval: row.hcUnhealthyInterval ?? null, + hcTimeout: row.hcTimeout ?? null, + hcHeaders: row.hcHeaders ?? null, + hcFollowRedirects: row.hcFollowRedirects ?? null, + hcStatus: row.hcStatus ?? null, + hcTlsServerName: row.hcTlsServerName ?? null, + hcHealthyThreshold: row.hcHealthyThreshold ?? null, + hcUnhealthyThreshold: row.hcUnhealthyThreshold ?? null })), pagination: { total: count, @@ -126,7 +134,7 @@ export async function listHealthChecks( }, success: true, error: false, - message: "Health checks retrieved successfully", + message: "Standalone health checks retrieved successfully", status: HttpCode.OK }); } catch (error) { @@ -135,4 +143,4 @@ export async function listHealthChecks( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts new file mode 100644 index 000000000..c5a0759b7 --- /dev/null +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -0,0 +1,239 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, targetHealthCheck } from "@server/db"; +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 { OpenAPITags, registry } from "@server/openApi"; +import { and, eq, isNull } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + healthCheckId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +const bodySchema = z.strictObject({ + name: z.string().nonempty().optional(), + hcEnabled: z.boolean().optional(), + hcMode: z.string().optional(), + hcHostname: z.string().optional(), + hcPort: z.number().int().min(1).max(65535).optional(), + hcPath: z.string().optional(), + hcScheme: z.string().optional(), + hcMethod: z.string().optional(), + hcInterval: z.number().int().positive().optional(), + hcUnhealthyInterval: z.number().int().positive().optional(), + hcTimeout: z.number().int().positive().optional(), + hcHeaders: z.string().optional().nullable(), + hcFollowRedirects: z.boolean().optional(), + hcStatus: z.number().int().optional().nullable(), + hcTlsServerName: z.string().optional(), + hcHealthyThreshold: z.number().int().positive().optional(), + hcUnhealthyThreshold: z.number().int().positive().optional() +}); + +export type UpdateHealthCheckResponse = { + targetHealthCheckId: number; + name: string | null; + hcEnabled: boolean; + hcHealth: string | null; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcPath: string | null; + hcScheme: string | null; + hcMethod: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcStatus: number | null; + hcTlsServerName: string | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/health-check/{healthCheckId}", + description: "Update a health check for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateHealthCheck( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, healthCheckId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const [existing] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq( + targetHealthCheck.targetHealthCheckId, + healthCheckId + ), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Standalone health check not found" + ) + ); + } + + const { + name, + hcEnabled, + hcMode, + hcHostname, + hcPort, + hcPath, + hcScheme, + hcMethod, + hcInterval, + hcUnhealthyInterval, + hcTimeout, + hcHeaders, + hcFollowRedirects, + hcStatus, + hcTlsServerName, + hcHealthyThreshold, + hcUnhealthyThreshold + } = parsedBody.data; + + const updateData: Record = {}; + + if (name !== undefined) updateData.name = name; + if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; + if (hcMode !== undefined) updateData.hcMode = hcMode; + if (hcHostname !== undefined) updateData.hcHostname = hcHostname; + if (hcPort !== undefined) updateData.hcPort = hcPort; + if (hcPath !== undefined) updateData.hcPath = hcPath; + if (hcScheme !== undefined) updateData.hcScheme = hcScheme; + if (hcMethod !== undefined) updateData.hcMethod = hcMethod; + if (hcInterval !== undefined) updateData.hcInterval = hcInterval; + if (hcUnhealthyInterval !== undefined) + updateData.hcUnhealthyInterval = hcUnhealthyInterval; + if (hcTimeout !== undefined) updateData.hcTimeout = hcTimeout; + if (hcHeaders !== undefined) updateData.hcHeaders = hcHeaders; + if (hcFollowRedirects !== undefined) + updateData.hcFollowRedirects = hcFollowRedirects; + if (hcStatus !== undefined) updateData.hcStatus = hcStatus; + if (hcTlsServerName !== undefined) + updateData.hcTlsServerName = hcTlsServerName; + if (hcHealthyThreshold !== undefined) + updateData.hcHealthyThreshold = hcHealthyThreshold; + if (hcUnhealthyThreshold !== undefined) + updateData.hcUnhealthyThreshold = hcUnhealthyThreshold; + + const [updated] = await db + .update(targetHealthCheck) + .set(updateData) + .where( + and( + eq( + targetHealthCheck.targetHealthCheckId, + healthCheckId + ), + eq(targetHealthCheck.orgId, orgId), + isNull(targetHealthCheck.targetId) + ) + ) + .returning(); + + return response(res, { + data: { + targetHealthCheckId: updated.targetHealthCheckId, + name: updated.name ?? null, + hcEnabled: updated.hcEnabled, + hcHealth: updated.hcHealth ?? null, + hcMode: updated.hcMode ?? null, + hcHostname: updated.hcHostname ?? null, + hcPort: updated.hcPort ?? null, + hcPath: updated.hcPath ?? null, + hcScheme: updated.hcScheme ?? null, + hcMethod: updated.hcMethod ?? null, + hcInterval: updated.hcInterval ?? null, + hcUnhealthyInterval: updated.hcUnhealthyInterval ?? null, + hcTimeout: updated.hcTimeout ?? null, + hcHeaders: updated.hcHeaders ?? null, + hcFollowRedirects: updated.hcFollowRedirects ?? null, + hcStatus: updated.hcStatus ?? null, + hcTlsServerName: updated.hcTlsServerName ?? null, + hcHealthyThreshold: updated.hcHealthyThreshold ?? null, + hcUnhealthyThreshold: updated.hcUnhealthyThreshold ?? null + }, + success: true, + error: false, + message: "Standalone health check updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 484db4344..d7729bca5 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -427,13 +427,6 @@ authenticated.get( resource.listResources ); -authenticated.get( - "/org/:orgId/health-checks", - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.listHealthChecks), - resource.listHealthChecks -); - authenticated.get( "/org/:orgId/resource-names", verifyOrgAccess, diff --git a/server/routers/healthChecks/types.ts b/server/routers/healthChecks/types.ts new file mode 100644 index 000000000..429da80c0 --- /dev/null +++ b/server/routers/healthChecks/types.ts @@ -0,0 +1,28 @@ +export type ListHealthChecksResponse = { + healthChecks: { + targetHealthCheckId: number; + name: string; + hcEnabled: boolean; + hcHealth: "unknown" | "healthy" | "unhealthy"; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcPath: string | null; + hcScheme: string | null; + hcMethod: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcStatus: number | null; + hcTlsServerName: string | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 2b379a7d5..12e98a70d 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -32,4 +32,3 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; -export * from "./listHealthChecks"; diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index a4d2e7e54..973155ccc 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -228,6 +228,7 @@ export async function createTarget( healthCheck = await db .insert(targetHealthCheck) .values({ + name: `${targetData.ip}:${targetData.port}`, targetId: newTarget[0].targetId, hcEnabled: targetData.hcEnabled ?? false, hcPath: targetData.hcPath ?? null, diff --git a/src/app/[orgId]/settings/alerting/page.tsx b/src/app/[orgId]/settings/alerting/page.tsx index 3d100bed2..aeba881a0 100644 --- a/src/app/[orgId]/settings/alerting/page.tsx +++ b/src/app/[orgId]/settings/alerting/page.tsx @@ -1,5 +1,7 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import AlertingRulesTable from "@app/components/AlertingRulesTable"; +import StandaloneHealthChecksTable from "@app/components/StandaloneHealthChecksTable"; +import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; import { getTranslations } from "next-intl/server"; type AlertingPageProps = { @@ -12,13 +14,21 @@ export default async function AlertingPage(props: AlertingPageProps) { const params = await props.params; const t = await getTranslations(); + const tabs: TabItem[] = [ + { title: t("alertingTabRules"), href: "" }, + { title: t("alertingTabHealthChecks"), href: "" } + ]; + return ( <> - + + + + ); -} +} \ No newline at end of file diff --git a/src/components/StandaloneHealthCheckCredenza.tsx b/src/components/StandaloneHealthCheckCredenza.tsx new file mode 100644 index 000000000..dd5a7ab17 --- /dev/null +++ b/src/components/StandaloneHealthCheckCredenza.tsx @@ -0,0 +1,856 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { HeadersInput } from "@app/components/HeadersInput"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@/components/Credenza"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; + +export type HealthCheckRow = { + targetHealthCheckId: number; + name: string; + hcEnabled: boolean; + hcHealth: "unknown" | "healthy" | "unhealthy"; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcPath: string | null; + hcScheme: string | null; + hcMethod: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcStatus: number | null; + hcTlsServerName: string | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; +}; + +type StandaloneHealthCheckCredenzaProps = { + open: boolean; + setOpen: (v: boolean) => void; + orgId: string; + initialValues?: HealthCheckRow | null; + onSaved: () => void; +}; + +const DEFAULT_VALUES = { + name: "", + hcEnabled: true, + hcMode: "http", + hcScheme: "https", + hcMethod: "GET", + hcHostname: "", + hcPort: "", + hcPath: "/", + hcInterval: 30, + hcUnhealthyInterval: 30, + hcTimeout: 5, + hcHealthyThreshold: 1, + hcUnhealthyThreshold: 1, + hcFollowRedirects: true, + hcTlsServerName: "", + hcStatus: null as number | null, + hcHeaders: [] as { name: string; value: string }[] +}; + +export default function StandaloneHealthCheckCredenza({ + open, + setOpen, + orgId, + initialValues, + onSaved +}: StandaloneHealthCheckCredenzaProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + + const healthCheckSchema = z + .object({ + name: z.string().min(1, { message: t("standaloneHcNameLabel") }), + hcEnabled: z.boolean(), + hcPath: z.string().optional(), + hcMethod: z.string().optional(), + hcInterval: z + .int() + .positive() + .min(5, { message: t("healthCheckIntervalMin") }), + hcTimeout: z + .int() + .positive() + .min(1, { message: t("healthCheckTimeoutMin") }), + hcStatus: z.int().positive().min(100).optional().nullable(), + hcHeaders: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable() + .optional(), + hcScheme: z.string().optional(), + hcHostname: z.string(), + hcPort: z + .string() + .min(1, { message: t("healthCheckPortInvalid") }) + .refine( + (val) => { + const port = parseInt(val); + return port > 0 && port <= 65535; + }, + { message: t("healthCheckPortInvalid") } + ), + hcFollowRedirects: z.boolean(), + hcMode: z.string(), + hcUnhealthyInterval: z.int().positive().min(5), + hcTlsServerName: z.string(), + hcHealthyThreshold: z + .int() + .positive() + .min(1, { message: t("healthCheckHealthyThresholdMin") }), + hcUnhealthyThreshold: z + .int() + .positive() + .min(1, { message: t("healthCheckUnhealthyThresholdMin") }) + }) + .superRefine((data, ctx) => { + if (data.hcMode !== "tcp") { + if (!data.hcPath || data.hcPath.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("healthCheckPathRequired"), + path: ["hcPath"] + }); + } + if (!data.hcMethod || data.hcMethod.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: t("healthCheckMethodRequired"), + path: ["hcMethod"] + }); + } + } + }); + + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(healthCheckSchema), + defaultValues: DEFAULT_VALUES + }); + + useEffect(() => { + if (!open) return; + + if (initialValues) { + let parsedHeaders: { name: string; value: string }[] = []; + if (initialValues.hcHeaders) { + try { + parsedHeaders = JSON.parse(initialValues.hcHeaders); + } catch { + parsedHeaders = []; + } + } + + form.reset({ + name: initialValues.name, + hcEnabled: initialValues.hcEnabled, + hcMode: initialValues.hcMode ?? "http", + hcScheme: initialValues.hcScheme ?? "https", + hcMethod: initialValues.hcMethod ?? "GET", + hcHostname: initialValues.hcHostname ?? "", + hcPort: initialValues.hcPort + ? initialValues.hcPort.toString() + : "", + hcPath: initialValues.hcPath ?? "/", + hcInterval: initialValues.hcInterval ?? 30, + hcUnhealthyInterval: initialValues.hcUnhealthyInterval ?? 30, + hcTimeout: initialValues.hcTimeout ?? 5, + hcHealthyThreshold: initialValues.hcHealthyThreshold ?? 1, + hcUnhealthyThreshold: initialValues.hcUnhealthyThreshold ?? 1, + hcFollowRedirects: initialValues.hcFollowRedirects ?? true, + hcTlsServerName: initialValues.hcTlsServerName ?? "", + hcStatus: initialValues.hcStatus ?? null, + hcHeaders: parsedHeaders + }); + } else { + form.reset(DEFAULT_VALUES); + } + }, [open]); + + const watchedEnabled = form.watch("hcEnabled"); + const watchedMode = form.watch("hcMode"); + + const onSubmit = async (values: FormValues) => { + setLoading(true); + try { + const payload = { + name: values.name, + hcEnabled: values.hcEnabled, + hcMode: values.hcMode, + hcScheme: values.hcScheme, + hcMethod: values.hcMethod, + hcHostname: values.hcHostname, + hcPort: parseInt(values.hcPort), + hcPath: values.hcPath ?? "", + hcInterval: values.hcInterval, + hcUnhealthyInterval: values.hcUnhealthyInterval, + hcTimeout: values.hcTimeout, + hcHealthyThreshold: values.hcHealthyThreshold, + hcUnhealthyThreshold: values.hcUnhealthyThreshold, + hcFollowRedirects: values.hcFollowRedirects, + hcTlsServerName: values.hcTlsServerName, + hcStatus: values.hcStatus || null, + hcHeaders: + values.hcHeaders && values.hcHeaders.length > 0 + ? JSON.stringify(values.hcHeaders) + : null + }; + + if (initialValues) { + await api.post( + `/org/${orgId}/health-check/${initialValues.targetHealthCheckId}`, + payload + ); + } else { + await api.put( + `/org/${orgId}/health-check`, + payload + ); + } + + toast({ title: t("standaloneHcSaved") }); + onSaved(); + setOpen(false); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setLoading(false); + } + }; + + const isEditing = !!initialValues; + + return ( + + + + + {isEditing + ? t("standaloneHcEditTitle") + : t("standaloneHcCreateTitle")} + + + {t("standaloneHcDescription")} + + + +
+ + {/* Name */} + ( + + + {t("standaloneHcNameLabel")} + + + + + + + )} + /> + + {/* Enable Health Check */} + ( + +
+ + {t("enableHealthChecks")} + + + {t( + "enableHealthChecksDescription" + )} + +
+ + + +
+ )} + /> + + {watchedEnabled && ( +
+ {/* Mode */} + ( + + + {t("healthCheckMode")} + + + + {t( + "healthCheckModeDescription" + )} + + + + )} + /> + + {/* Connection fields */} + {watchedMode === "tcp" ? ( +
+ ( + + + {t("healthHostname")} + + + + + + + )} + /> + ( + + + {t("healthPort")} + + + + + + + )} + /> +
+ ) : ( +
+ ( + + + {t("healthScheme")} + + + + + )} + /> + ( + + + {t("healthHostname")} + + + + + + + )} + /> + ( + + + {t("healthPort")} + + + + + + + )} + /> + ( + + + {t("healthCheckPath")} + + + + + + + )} + /> +
+ )} + + {/* HTTP Method */} + {watchedMode !== "tcp" && ( + ( + + + {t("httpMethod")} + + + + + )} + /> + )} + + {/* Check Interval, Unhealthy Interval, and Timeout */} +
+ ( + + + {t( + "healthyIntervalSeconds" + )} + + + + field.onChange( + parseInt( + e.target + .value + ) + ) + } + /> + + + + )} + /> + + ( + + + {t( + "unhealthyIntervalSeconds" + )} + + + + field.onChange( + parseInt( + e.target + .value + ) + ) + } + /> + + + + )} + /> + + ( + + + {t("timeoutSeconds")} + + + + field.onChange( + parseInt( + e.target + .value + ) + ) + } + /> + + + + )} + /> +
+ + {/* Healthy and Unhealthy Thresholds */} +
+ ( + + + {t("healthyThreshold")} + + + + field.onChange( + parseInt( + e.target + .value + ) + ) + } + /> + + + {t( + "healthyThresholdDescription" + )} + + + + )} + /> + + ( + + + {t("unhealthyThreshold")} + + + + field.onChange( + parseInt( + e.target + .value + ) + ) + } + /> + + + {t( + "unhealthyThresholdDescription" + )} + + + + )} + /> +
+ + {/* HTTP-only fields */} + {watchedMode !== "tcp" && ( + <> + {/* Expected Response Code */} + ( + + + {t( + "expectedResponseCodes" + )} + + + { + const val = + e.target + .value; + field.onChange( + val + ? parseInt( + val + ) + : null + ); + }} + /> + + + {t( + "expectedResponseCodesDescription" + )} + + + + )} + /> + + {/* TLS Server Name */} + ( + + + {t("tlsServerName")} + + + + + + {t( + "tlsServerNameDescription" + )} + + + + )} + /> + + {/* Custom Headers */} + ( + + + {t("customHeaders")} + + + + + + {t( + "customHeadersDescription" + )} + + + + )} + /> + + {/* Follow Redirects */} + ( + +
+ + {t( + "followRedirects" + )} + + + {t( + "followRedirectsDescription" + )} + +
+ + + +
+ )} + /> + + )} +
+ )} + + +
+ + + + + + +
+
+ ); +} diff --git a/src/components/StandaloneHealthChecksTable.tsx b/src/components/StandaloneHealthChecksTable.tsx new file mode 100644 index 000000000..c839b705a --- /dev/null +++ b/src/components/StandaloneHealthChecksTable.tsx @@ -0,0 +1,299 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import StandaloneHealthCheckCredenza, { + HealthCheckRow +} from "@app/components/StandaloneHealthCheckCredenza"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Switch } from "@app/components/ui/switch"; +import { toast } from "@app/hooks/useToast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +type StandaloneHealthChecksTableProps = { + orgId: string; +}; + +function formatTarget(row: HealthCheckRow): string { + if (!row.hcHostname) return "—"; + if (row.hcMode === "tcp") { + if (!row.hcPort) return row.hcHostname; + return `${row.hcHostname}:${row.hcPort}`; + } + // HTTP / default + const scheme = row.hcScheme ?? "http"; + const host = row.hcHostname; + const port = row.hcPort ? `:${row.hcPort}` : ""; + const path = row.hcPath ?? "/"; + return `${scheme}://${host}${port}${path}`; +} + +const healthLabel: Record = { + healthy: "Healthy", + unhealthy: "Unhealthy", + unknown: "Unknown" +}; + +const healthVariant: Record< + HealthCheckRow["hcHealth"], + "green" | "red" | "secondary" +> = { + healthy: "green", + unhealthy: "red", + unknown: "secondary" +}; + +function HealthBadge({ health }: { health: HealthCheckRow["hcHealth"] }) { + return ( + {healthLabel[health]} + ); +} + +export default function StandaloneHealthChecksTable({ + orgId +}: StandaloneHealthChecksTableProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const queryClient = useQueryClient(); + + const [credenzaOpen, setCredenzaOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [togglingId, setTogglingId] = useState(null); + + const { + data: rows = [], + isLoading, + refetch, + isRefetching + } = useQuery(orgQueries.standaloneHealthChecks({ orgId })); + + const invalidate = () => + queryClient.invalidateQueries( + orgQueries.standaloneHealthChecks({ orgId }) + ); + + const handleToggleEnabled = async ( + row: HealthCheckRow, + enabled: boolean + ) => { + setTogglingId(row.targetHealthCheckId); + try { + await api.post( + `/org/${orgId}/health-check/${row.targetHealthCheckId}`, + { hcEnabled: enabled } + ); + await invalidate(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setTogglingId(null); + } + }; + + const handleDelete = async () => { + if (!selected) return; + try { + await api.delete( + `/org/${orgId}/health-check/${selected.targetHealthCheckId}` + ); + await invalidate(); + toast({ title: t("standaloneHcDeleted") }); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setDeleteOpen(false); + setSelected(null); + } + }; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.name} + ) + }, + { + id: "mode", + friendlyName: t("standaloneHcColumnMode"), + header: () => ( + {t("standaloneHcColumnMode")} + ), + cell: ({ row }) => ( + + {row.original.hcMode?.toUpperCase() ?? "—"} + + ) + }, + { + id: "target", + friendlyName: t("standaloneHcColumnTarget"), + header: () => ( + {t("standaloneHcColumnTarget")} + ), + cell: ({ row }) => ( + + {formatTarget(row.original)} + + ) + }, + { + id: "health", + friendlyName: t("standaloneHcColumnHealth"), + header: () => ( + {t("standaloneHcColumnHealth")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "hcEnabled", + friendlyName: t("alertingColumnEnabled"), + header: () => ( + {t("alertingColumnEnabled")} + ), + cell: ({ row }) => { + const r = row.original; + return ( + handleToggleEnabled(r, v)} + /> + ); + } + }, + { + id: "rowActions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + + + + { + setSelected(r); + setDeleteOpen(true); + }} + > + + {t("delete")} + + + + + +
+ ); + } + } + ]; + + return ( + <> + {selected && deleteOpen && ( + { + setDeleteOpen(val); + if (!val) setSelected(null); + }} + dialog={ +
+

{t("standaloneHcDeleteQuestion")}

+
+ } + buttonText={t("delete")} + onConfirm={handleDelete} + string={selected.name} + title={t("standaloneHcDeleteTitle")} + /> + )} + + { + setCredenzaOpen(val); + if (!val) setSelected(null); + }} + orgId={orgId} + initialValues={selected} + onSaved={invalidate} + /> + + { + setSelected(null); + setCredenzaOpen(true); + }} + onRefresh={() => refetch()} + isRefreshing={isRefetching || isLoading} + addButtonText={t("standaloneHcAddButton")} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="rowActions" + /> + + ); +} diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 73b2302e0..824fc1b10 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -182,7 +182,7 @@ function HealthCheckMultiSelect({ const query = debounced.trim().toLowerCase(); const base = query ? healthChecks.filter((hc) => - hc.resourceName.toLowerCase().includes(query) + hc.name.toLowerCase().includes(query) ) : healthChecks; // Always keep already-selected items visible even if they fall outside the search @@ -243,7 +243,7 @@ function HealthCheckMultiSelect({ {shown.map((hc) => ( toggle(hc.targetHealthCheckId) } @@ -258,7 +258,7 @@ function HealthCheckMultiSelect({ tabIndex={-1} /> - {hc.resourceName} + {hc.name} ))} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 17948d63a..17cc10f11 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -4,7 +4,6 @@ import type { ListClientsResponse } from "@server/routers/client"; import type { ListDomainsResponse } from "@server/routers/domain"; import type { GetResourceWhitelistResponse, - ListHealthChecksResponse, ListResourceNamesResponse, ListResourcesResponse } from "@server/routers/resource"; @@ -28,7 +27,7 @@ import type { AxiosResponse } from "axios"; import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; -import { wait } from "./wait"; +import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; export type ProductUpdate = { link: string | null; @@ -264,6 +263,44 @@ export const orgQueries = { >(`/org/${orgId}/alert-rules`, { signal }); return res.data.data.alertRules; } + }), + + standaloneHealthChecks: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse<{ + healthChecks: { + targetHealthCheckId: number; + name: string; + hcEnabled: boolean; + hcHealth: "unknown" | "healthy" | "unhealthy"; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcPath: string | null; + hcScheme: string | null; + hcMethod: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcStatus: number | null; + hcTlsServerName: string | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + }> + >(`/org/${orgId}/health-checks`, { signal }); + return res.data.data.healthChecks; + } }) };