Handeling the different health status

This commit is contained in:
Owen
2026-04-24 17:30:54 -07:00
parent 34296e5f40
commit cca7cea2f1
9 changed files with 112 additions and 148 deletions

View File

@@ -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",

View File

@@ -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) {

View File

@@ -141,7 +141,8 @@ export async function createHealthCheck(
hcStatus: hcStatus ?? null,
hcTlsServerName: hcTlsServerName ?? null,
hcHealthyThreshold,
hcUnhealthyThreshold
hcUnhealthyThreshold,
hcHealth: "unhealthy"
})
.returning();

View File

@@ -166,6 +166,17 @@ export async function updateHealthCheck(
const updateData: Record<string, unknown> = {};
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)

View File

@@ -327,7 +327,8 @@ async function createHttpResource(
ssl: true,
stickySession: stickySession,
postAuthPath: postAuthPath,
wildcard
wildcard,
health: "unknown"
})
.returning();

View File

@@ -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<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1
ELSE 0
END
) `;
const unknown_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1
ELSE 0
END
) `;
const unhealthy_targets = sql<number>`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<any> | 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"));

View File

@@ -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

View File

@@ -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();

View File

@@ -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 <CheckCircle2 className={`${iconClass} text-green-500`} />;
case "degraded":
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
case "offline":
case "unhealthy":
return <XCircle className={`${iconClass} text-destructive`} />;
case "unknown":
return <Clock className={`${iconClass} text-muted-foreground`} />;
@@ -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 (
<div id="LOOK_FOR_ME" className="flex items-center gap-2">
<div className="flex items-center gap-2">
<StatusIcon status="unknown" />
<span className="text-sm">
{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")}
</span>
@@ -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 <TargetStatusCell targets={resourceRow.targets} />;
},
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: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
);
return <UptimeMiniBar resourceId={resourceRow.id} days={30} />;
}
},
{