diff --git a/messages/en-US.json b/messages/en-US.json index d6a39168d..99e4d09e5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1974,10 +1974,9 @@ "resourcesTableAliasAddressInfo": "This address is part of the organization's utility subnet. It's used to resolve alias records using internal DNS resolution.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.", - "resourcesTableNoTargets": "No targets", "resourcesTableHealthy": "Healthy", "resourcesTableDegraded": "Degraded", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Unhealthy", "resourcesTableUnknown": "Unknown", "resourcesTableNotMonitored": "Not monitored", "editInternalResourceDialogEditClientResource": "Edit Private Resource", diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 4851f08c4..9b5c3104b 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -173,15 +173,25 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null const otherTargets = await trx .select({ hcHealth: targetHealthCheck.hcHealth }) .from(targets) + .innerJoin(targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId)) .where(eq(targets.resourceId, resource.resourceId)); let health = "healthy"; const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); - if (!allHealthy) { + const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy"); + + if (allHealthy) { + health = "healthy"; + } else if (allUnhealthy) { logger.debug( - `Not marking resource ${resource.resourceId} as healthy because not all targets are healthy` + `Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy` ); health = "unhealthy"; + } else { + logger.debug( + `Marking resource ${resource.resourceId} as degraded because some targets are unhealthy` + ); + health = "degraded"; } if (health != resource.health) { diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index 374ec4ba4..ada583a70 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -141,7 +141,8 @@ export async function createHealthCheck( hcStatus: hcStatus ?? null, hcTlsServerName: hcTlsServerName ?? null, hcHealthyThreshold, - hcUnhealthyThreshold + hcUnhealthyThreshold, + hcHealth: "unhealthy" }) .returning(); diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index 713bf1e03..47a9518a9 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -166,6 +166,17 @@ export async function updateHealthCheck( const updateData: Record = {}; + const [existingHealthCheck] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId) + ) + ) + .limit(1); + if (name !== undefined) updateData.name = name; if (siteId !== undefined) updateData.siteId = siteId; if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; @@ -190,6 +201,26 @@ export async function updateHealthCheck( if (hcUnhealthyThreshold !== undefined) updateData.hcUnhealthyThreshold = hcUnhealthyThreshold; + const hcEnabledTurnedOn = + parsedBody.data.hcEnabled === true && + existingHealthCheck.hcEnabled === false; + + let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; + if ( + parsedBody.data.hcEnabled === false || + parsedBody.data.hcEnabled === null + ) { + hcHealthValue = "unknown"; + } else if (hcEnabledTurnedOn) { + hcHealthValue = "unhealthy"; + } else { + hcHealthValue = undefined; + } + + if (hcHealthValue) { + updateData.hcHealth = hcHealthValue; + } + const [updated] = await db .update(targetHealthCheck) .set(updateData) diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 85e607211..f8b7551e9 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -327,7 +327,8 @@ async function createHttpResource( ssl: true, stickySession: stickySession, postAuthPath: postAuthPath, - wildcard + wildcard, + health: "unknown" }) .returning(); diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index d1accfc9d..e6889d285 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -105,7 +105,7 @@ const listResourcesSchema = z.object({ "Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)." }), healthStatus: z - .enum(["no_targets", "healthy", "degraded", "offline", "unknown"]) + .enum(["healthy", "degraded", "unhealthy", "unknown"]) .optional() .catch(undefined) .openapi({ @@ -143,27 +143,6 @@ export type ResourceWithTargets = { }>; }; -// Aggregate filters -const total_targets = count(targets.targetId); -const healthy_targets = sql`SUM( - CASE - WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1 - ELSE 0 - END - ) `; -const unknown_targets = sql`SUM( - CASE - WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1 - ELSE 0 - END - ) `; -const unhealthy_targets = sql`SUM( - CASE - WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1 - ELSE 0 - END - ) `; - function queryResourcesBase() { return db .select({ @@ -183,7 +162,8 @@ function queryResourcesBase() { niceId: resources.niceId, headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthExtendedCompatibilityId: - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, + health: resources.health }) .from(resources) .leftJoin( @@ -378,46 +358,12 @@ export async function listResources( ); break; } - } - - let aggregateFilters: SQL | undefined = sql`1 = 1`; - - if (typeof healthStatus !== "undefined") { - switch (healthStatus) { - case "healthy": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${healthy_targets} = ${total_targets}` - ); - break; - case "degraded": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${unhealthy_targets} > 0` - ); - break; - case "no_targets": - aggregateFilters = sql`${total_targets} = 0`; - break; - case "offline": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${healthy_targets} = 0`, - sql`${unhealthy_targets} = ${total_targets}` - ); - break; - case "unknown": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${unknown_targets} = ${total_targets}` - ); - break; + if (typeof healthStatus !== "undefined") { + conditions.push(eq(resources.health, healthStatus)); } } - const baseQuery = queryResourcesBase() - .where(and(...conditions)) - .having(aggregateFilters); + const baseQuery = queryResourcesBase().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count(baseQuery.as("filtered_resources")); diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index e5c1f246e..e37f12490 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -245,7 +245,7 @@ export async function createTarget( hcFollowRedirects: targetData.hcFollowRedirects ?? null, hcMethod: targetData.hcMethod ?? null, hcStatus: targetData.hcStatus ?? null, - hcHealth: "unknown", + hcHealth: targetData.hcEnabled ? "unhealthy" : "unknown", hcTlsServerName: targetData.hcTlsServerName ?? null, hcHealthyThreshold: targetData.hcHealthyThreshold ?? null, hcUnhealthyThreshold: targetData.hcUnhealthyThreshold ?? null diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index a633deb4d..dad302de8 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -13,7 +13,7 @@ import { addTargets } from "../newt/targets"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; -import { vs } from "@react-email/components"; + const updateTargetParamsSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) @@ -153,32 +153,6 @@ export async function updateTarget( ); } - const targetData = { - ...target, - ...parsedBody.data - }; - - const existingTargets = await db - .select() - .from(targets) - .where(eq(targets.resourceId, target.resourceId)); - - const foundTarget = existingTargets.find( - (target) => - target.targetId !== targetId && // Exclude the current target being updated - target.ip === targetData.ip && - target.port === targetData.port && - target.method === targetData.method && - target.siteId === targetData.siteId - ); - - if (foundTarget) { - // log a warning - logger.warn( - `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${target.resourceId}` - ); - } - const { internalPort, targetIps } = await pickPort(site.siteId!, db); if (!internalPort) { @@ -210,20 +184,46 @@ export async function updateTarget( .where(eq(targets.targetId, targetId)) .returning(); + const [existingHc] = await db + .select() + .from(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .limit(1); + + if (!existingHc) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check for target with ID ${targetId} not found` + ) + ); + } + let hcHeaders = null; if (parsedBody.data.hcHeaders) { hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); } // When health check is disabled, reset hcHealth to "unknown" - // to prevent previously unhealthy targets from being excluded - // Also when the site is not a newt, set hcHealth to "unknown" - const hcHealthValue = + // to prevent previously unhealthy targets from being excluded. + // Also when the site is not a newt, set hcHealth to "unknown". + // If hcEnabled is being turned on (was false, now true), set to "unhealthy" + // so the target must pass a health check before being considered healthy. + const hcEnabledTurnedOn = + parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false; + + let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; + if ( parsedBody.data.hcEnabled === false || parsedBody.data.hcEnabled === null || site.type !== "newt" - ? "unknown" - : undefined; + ) { + hcHealthValue = "unknown"; + } else if (hcEnabledTurnedOn) { + hcHealthValue = "unhealthy"; + } else { + hcHealthValue = undefined; + } const [updatedHc] = await db .update(targetHealthCheck) @@ -245,7 +245,7 @@ export async function updateTarget( hcTlsServerName: parsedBody.data.hcTlsServerName, hcHealthyThreshold: parsedBody.data.hcHealthyThreshold, hcUnhealthyThreshold: parsedBody.data.hcUnhealthyThreshold, - ...(hcHealthValue !== undefined && { hcHealth: hcHealthValue }) + hcHealth: hcHealthValue }) .where(eq(targetHealthCheck.targetId, targetId)) .returning(); diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index dddf1312c..ff0a6ad5b 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -83,54 +83,24 @@ export type ResourceRow = { targetHost?: string; targetPort?: number; targets?: TargetHealth[]; + health?: "online" | "degraded" | "unhealthy" | "unknown"; }; -function getOverallHealthStatus( - targets?: TargetHealth[] -): "online" | "degraded" | "offline" | "unknown" { - if (!targets || targets.length === 0) { - return "unknown"; - } - - const monitoredTargets = targets.filter( - (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" - ); - - if (monitoredTargets.length === 0) { - return "unknown"; - } - - const healthyCount = monitoredTargets.filter( - (t) => t.healthStatus === "healthy" - ).length; - const unhealthyCount = monitoredTargets.filter( - (t) => t.healthStatus === "unhealthy" - ).length; - - if (healthyCount === monitoredTargets.length) { - return "online"; - } else if (unhealthyCount === monitoredTargets.length) { - return "offline"; - } else { - return "degraded"; - } -} - function StatusIcon({ status, className = "" }: { - status: "online" | "degraded" | "offline" | "unknown"; + status: string | undefined | null; className?: string; }) { const iconClass = `h-4 w-4 ${className}`; switch (status) { - case "online": + case "healthy": return ; case "degraded": return ; - case "offline": + case "unhealthy": return ; case "unknown": return ; @@ -231,12 +201,18 @@ export default function ProxyResourcesTable({ } } - function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) { - const overallStatus = getOverallHealthStatus(targets); + function TargetStatusCell({ + targets, + healthStatus + }: { + targets?: TargetHealth[]; + healthStatus?: string; + }) { + const overallStatus = healthStatus; if (!targets || targets.length === 0) { return ( -
+
{t("resourcesTableNoTargets")} @@ -266,8 +242,8 @@ export default function ProxyResourcesTable({ t("resourcesTableHealthy")} {overallStatus === "degraded" && t("resourcesTableDegraded")} - {overallStatus === "offline" && - t("resourcesTableOffline")} + {overallStatus === "unhealthy" && + t("resourcesTableUnhealthy")} {overallStatus === "unknown" && t("resourcesTableUnknown")} @@ -405,10 +381,9 @@ export default function ProxyResourcesTable({ value: "degraded", label: t("resourcesTableDegraded") }, - { value: "offline", label: t("resourcesTableOffline") }, { - value: "no_targets", - label: t("resourcesTableNoTargets") + value: "unhealty", + label: t("resourcesTableUnhealthy") }, { value: "unknown", label: t("resourcesTableUnknown") } ]} @@ -429,12 +404,15 @@ export default function ProxyResourcesTable({ return ; }, sortingFn: (rowA, rowB) => { - const statusA = getOverallHealthStatus(rowA.original.targets); - const statusB = getOverallHealthStatus(rowB.original.targets); + const statusA = rowA.original.health; + const statusB = rowB.original.health; + if (!statusA && !statusB) return 0; + if (!statusA) return 1; + if (!statusB) return -1; const statusOrder = { online: 3, degraded: 2, - offline: 1, + unhealthy: 1, unknown: 0 }; return statusOrder[statusA] - statusOrder[statusB]; @@ -446,9 +424,7 @@ export default function ProxyResourcesTable({ header: () => {t("uptime30d")}, cell: ({ row }) => { const resourceRow = row.original; - return ( - - ); + return ; } }, {