Merge branch 'alerting-rules' of https://github.com/fosrl/pangolin into alerting-rules

This commit is contained in:
miloschwartz
2026-04-21 15:03:10 -07:00
17 changed files with 295 additions and 108 deletions

View File

@@ -194,6 +194,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
name: varchar("name"), name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false), hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"), hcPath: varchar("hcPath"),

View File

@@ -217,6 +217,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
name: text("name"), name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" }) hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull() .notNull()

View File

@@ -141,6 +141,7 @@ export async function updateProxyResources(
.insert(targetHealthCheck) .insert(targetHealthCheck)
.values({ .values({
name: `${targetData.hostname}:${targetData.port}`, name: `${targetData.hostname}:${targetData.port}`,
siteId: site.siteId,
targetId: newTarget.targetId, targetId: newTarget.targetId,
orgId: orgId, orgId: orgId,
hcEnabled: healthcheckData?.enabled || false, hcEnabled: healthcheckData?.enabled || false,

View File

@@ -13,13 +13,15 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, targetHealthCheck } from "@server/db"; import { db, targetHealthCheck, newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -27,6 +29,7 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({ const bodySchema = z.strictObject({
name: z.string().nonempty(), name: z.string().nonempty(),
siteId: z.number().int().positive(),
hcEnabled: z.boolean().default(false), hcEnabled: z.boolean().default(false),
hcMode: z.string().default("http"), hcMode: z.string().default("http"),
hcHostname: z.string().optional(), hcHostname: z.string().optional(),
@@ -97,6 +100,7 @@ export async function createHealthCheck(
const { const {
name, name,
siteId,
hcEnabled, hcEnabled,
hcMode, hcMode,
hcHostname, hcHostname,
@@ -120,6 +124,7 @@ export async function createHealthCheck(
.values({ .values({
targetId: null, targetId: null,
orgId, orgId,
siteId,
name, name,
hcEnabled, hcEnabled,
hcMode, hcMode,
@@ -140,6 +145,31 @@ export async function createHealthCheck(
}) })
.returning(); .returning();
// Push health check to newt if the site is a newt site
if (siteId) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (site && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (newt) {
await addStandaloneHealthCheck(
newt.newtId,
record,
newt.version
);
}
}
}
return response<CreateHealthCheckResponse>(res, { return response<CreateHealthCheckResponse>(res, {
data: { data: {
targetHealthCheckId: record.targetHealthCheckId targetHealthCheckId: record.targetHealthCheckId

View File

@@ -13,7 +13,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, targetHealthCheck } from "@server/db"; import { db, targetHealthCheck, newts, sites } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -21,6 +21,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { removeStandaloneHealthCheck } from "@server/routers/newt/targets";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -91,6 +92,21 @@ export async function deleteHealthCheck(
) )
); );
// Remove health check from newt if the site is a newt site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, existing.siteId))
.limit(1);
if (newt) {
await removeStandaloneHealthCheck(
newt.newtId,
healthCheckId,
newt.version
);
}
return response<null>(res, { return response<null>(res, {
data: null, data: null,
success: true, success: true,

View File

@@ -43,7 +43,7 @@ export async function getHealthCheckStatusHistory(
} }
const entityType = "healthCheck"; const entityType = "healthCheck";
const entityId = parsedParams.data.healthCheckId const entityId = parsedParams.data.healthCheckId;
const { days } = parsedQuery.data; const { days } = parsedQuery.data;
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);

View File

@@ -11,7 +11,7 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { db, targetHealthCheck, targets, resources } from "@server/db"; import { db, targetHealthCheck, targets, resources, sites } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -97,6 +97,9 @@ export async function listHealthChecks(
.select({ .select({
targetHealthCheckId: targetHealthCheck.targetHealthCheckId, targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
name: targetHealthCheck.name, name: targetHealthCheck.name,
siteId: targetHealthCheck.siteId,
siteName: sites.name,
siteNiceId: sites.niceId,
hcEnabled: targetHealthCheck.hcEnabled, hcEnabled: targetHealthCheck.hcEnabled,
hcHealth: targetHealthCheck.hcHealth, hcHealth: targetHealthCheck.hcHealth,
hcMode: targetHealthCheck.hcMode, hcMode: targetHealthCheck.hcMode,
@@ -121,6 +124,7 @@ export async function listHealthChecks(
.from(targetHealthCheck) .from(targetHealthCheck)
.leftJoin(targets, eq(targetHealthCheck.targetId, targets.targetId)) .leftJoin(targets, eq(targetHealthCheck.targetId, targets.targetId))
.leftJoin(resources, eq(targets.resourceId, resources.resourceId)) .leftJoin(resources, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targetHealthCheck.siteId, sites.siteId))
.where(whereClause) .where(whereClause)
.orderBy(sql`${targetHealthCheck.targetHealthCheckId} DESC`) .orderBy(sql`${targetHealthCheck.targetHealthCheckId} DESC`)
.limit(limit) .limit(limit)
@@ -136,6 +140,9 @@ export async function listHealthChecks(
healthChecks: list.map((row) => ({ healthChecks: list.map((row) => ({
targetHealthCheckId: row.targetHealthCheckId, targetHealthCheckId: row.targetHealthCheckId,
name: row.name ?? "", name: row.name ?? "",
siteId: row.siteId ?? null,
siteName: row.siteName ?? null,
siteNiceId: row.siteNiceId ?? null,
hcEnabled: row.hcEnabled, hcEnabled: row.hcEnabled,
hcHealth: (row.hcHealth ?? "unknown") as hcHealth: (row.hcHealth ?? "unknown") as
| "unknown" | "unknown"

View File

@@ -13,7 +13,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, targetHealthCheck } from "@server/db"; import { db, targetHealthCheck, newts, sites } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -21,6 +21,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -34,6 +35,7 @@ const paramsSchema = z
const bodySchema = z.strictObject({ const bodySchema = z.strictObject({
name: z.string().nonempty().optional(), name: z.string().nonempty().optional(),
siteId: z.number().int().positive().optional(),
hcEnabled: z.boolean().optional(), hcEnabled: z.boolean().optional(),
hcMode: z.string().optional(), hcMode: z.string().optional(),
hcHostname: z.string().optional(), hcHostname: z.string().optional(),
@@ -55,6 +57,7 @@ const bodySchema = z.strictObject({
export type UpdateHealthCheckResponse = { export type UpdateHealthCheckResponse = {
targetHealthCheckId: number; targetHealthCheckId: number;
name: string | null; name: string | null;
siteId: number | null;
hcEnabled: boolean; hcEnabled: boolean;
hcHealth: string | null; hcHealth: string | null;
hcMode: string | null; hcMode: string | null;
@@ -125,10 +128,7 @@ export async function updateHealthCheck(
.from(targetHealthCheck) .from(targetHealthCheck)
.where( .where(
and( and(
eq( eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
targetHealthCheck.targetHealthCheckId,
healthCheckId
),
eq(targetHealthCheck.orgId, orgId), eq(targetHealthCheck.orgId, orgId),
isNull(targetHealthCheck.targetId) isNull(targetHealthCheck.targetId)
) )
@@ -145,6 +145,7 @@ export async function updateHealthCheck(
const { const {
name, name,
siteId,
hcEnabled, hcEnabled,
hcMode, hcMode,
hcHostname, hcHostname,
@@ -166,6 +167,7 @@ export async function updateHealthCheck(
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (name !== undefined) updateData.name = name; if (name !== undefined) updateData.name = name;
if (siteId !== undefined) updateData.siteId = siteId;
if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled;
if (hcMode !== undefined) updateData.hcMode = hcMode; if (hcMode !== undefined) updateData.hcMode = hcMode;
if (hcHostname !== undefined) updateData.hcHostname = hcHostname; if (hcHostname !== undefined) updateData.hcHostname = hcHostname;
@@ -193,19 +195,28 @@ export async function updateHealthCheck(
.set(updateData) .set(updateData)
.where( .where(
and( and(
eq( eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
targetHealthCheck.targetHealthCheckId,
healthCheckId
),
eq(targetHealthCheck.orgId, orgId), eq(targetHealthCheck.orgId, orgId),
isNull(targetHealthCheck.targetId) isNull(targetHealthCheck.targetId)
) )
) )
.returning(); .returning();
// Push updated health check to newt if the site is a newt site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, updated.siteId))
.limit(1);
if (newt) {
await addStandaloneHealthCheck(newt.newtId, updated, newt.version);
}
return response<UpdateHealthCheckResponse>(res, { return response<UpdateHealthCheckResponse>(res, {
data: { data: {
targetHealthCheckId: updated.targetHealthCheckId, targetHealthCheckId: updated.targetHealthCheckId,
siteId: updated.siteId ?? null,
name: updated.name ?? null, name: updated.name ?? null,
hcEnabled: updated.hcEnabled, hcEnabled: updated.hcEnabled,
hcHealth: updated.hcHealth ?? null, hcHealth: updated.hcHealth ?? null,

View File

@@ -2,6 +2,9 @@ export type ListHealthChecksResponse = {
healthChecks: { healthChecks: {
targetHealthCheckId: number; targetHealthCheckId: number;
name: string; name: string;
siteId: number | null;
siteName: string | null;
siteNiceId: string | null;
hcEnabled: boolean; hcEnabled: boolean;
hcHealth: "unknown" | "healthy" | "unhealthy"; hcHealth: "unknown" | "healthy" | "unhealthy";
hcMode: string | null; hcMode: string | null;

View File

@@ -21,7 +21,6 @@ import {
generateSubnetProxyTargetV2, generateSubnetProxyTargetV2,
SubnetProxyTargetV2 SubnetProxyTargetV2
} from "@server/lib/ip"; } from "@server/lib/ip";
import { supportsTargetHealthChecksV2 } from "./targets";
export async function buildClientConfigurationForNewtClient( export async function buildClientConfigurationForNewtClient(
site: Site, site: Site,
@@ -205,7 +204,14 @@ export async function buildTargetConfigurationForNewtClient(
port: targets.port, port: targets.port,
internalPort: targets.internalPort, internalPort: targets.internalPort,
enabled: targets.enabled, enabled: targets.enabled,
protocol: resources.protocol, protocol: resources.protocol
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
const allHealthChecks = await db
.select({
targetHealthCheckId: targetHealthCheck.targetHealthCheckId, targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
hcEnabled: targetHealthCheck.hcEnabled, hcEnabled: targetHealthCheck.hcEnabled,
hcPath: targetHealthCheck.hcPath, hcPath: targetHealthCheck.hcPath,
@@ -224,13 +230,8 @@ export async function buildTargetConfigurationForNewtClient(
hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold, hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold,
hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold
}) })
.from(targets) .from(targetHealthCheck)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId)) .where(eq(targetHealthCheck.siteId, siteId));
.leftJoin(
targetHealthCheck,
eq(targets.targetId, targetHealthCheck.targetId)
)
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
const { tcpTargets, udpTargets } = allTargets.reduce( const { tcpTargets, udpTargets } = allTargets.reduce(
(acc, target) => { (acc, target) => {
@@ -254,7 +255,7 @@ export async function buildTargetConfigurationForNewtClient(
{ tcpTargets: [] as string[], udpTargets: [] as string[] } { tcpTargets: [] as string[], udpTargets: [] as string[] }
); );
const healthCheckTargets = allTargets.map((target) => { const healthCheckTargets = allHealthChecks.map((target) => {
// make sure the stuff is defined // make sure the stuff is defined
const isTCP = target.hcMode?.toLowerCase() === "tcp"; const isTCP = target.hcMode?.toLowerCase() === "tcp";
if (!target.hcHostname || !target.hcPort || !target.hcInterval) { if (!target.hcHostname || !target.hcPort || !target.hcInterval) {
@@ -278,9 +279,7 @@ export async function buildTargetConfigurationForNewtClient(
} }
return { return {
id: supportsTargetHealthChecksV2(version) id: target.targetHealthCheckId,
? target.targetId
: target.targetHealthCheckId,
hcEnabled: target.hcEnabled, hcEnabled: target.hcEnabled,
hcPath: target.hcPath, hcPath: target.hcPath,
hcScheme: target.hcScheme, hcScheme: target.hcScheme,

View File

@@ -2,13 +2,6 @@ import { Target, TargetHealthCheck } from "@server/db";
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger"; import logger from "@server/logger";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import semver from "semver";
const NEWT_V2_TARGET_HEALTH_CHECK_VERSION = ">=1.12.0";
export function supportsTargetHealthChecksV2(version?: string | null) {
return version ? semver.satisfies(version, NEWT_V2_TARGET_HEALTH_CHECK_VERSION) : false;
}
export async function addTargets( export async function addTargets(
newtId: string, newtId: string,
@@ -90,7 +83,7 @@ export async function addTargets(
} }
return { return {
id: supportsTargetHealthChecksV2(version) ? target.targetId : hc.targetHealthCheckId, id: hc.targetHealthCheckId,
hcEnabled: hc.hcEnabled, hcEnabled: hc.hcEnabled,
hcPath: hc.hcPath, hcPath: hc.hcPath,
hcScheme: hc.hcScheme, hcScheme: hc.hcScheme,
@@ -127,6 +120,96 @@ export async function addTargets(
); );
} }
export async function addStandaloneHealthCheck(
newtId: string,
healthCheck: TargetHealthCheck,
version?: string | null
) {
const isTCP = healthCheck.hcMode?.toLowerCase() === "tcp";
if (
!healthCheck.hcHostname ||
!healthCheck.hcPort ||
!healthCheck.hcInterval
) {
logger.debug(
`Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing fields`
);
return;
}
if (!isTCP && (!healthCheck.hcPath || !healthCheck.hcMethod)) {
logger.debug(
`Skipping standalone health check ${healthCheck.targetHealthCheckId} due to missing HTTP health check fields`
);
return;
}
const hcHeadersParse = healthCheck.hcHeaders
? JSON.parse(healthCheck.hcHeaders)
: null;
const hcHeadersSend: { [key: string]: string } = {};
if (hcHeadersParse) {
hcHeadersParse.forEach((header: { name: string; value: string }) => {
hcHeadersSend[header.name] = header.value;
});
}
let hcStatus: number | undefined = undefined;
if (healthCheck.hcStatus) {
const parsedStatus = parseInt(healthCheck.hcStatus.toString());
if (!isNaN(parsedStatus)) {
hcStatus = parsedStatus;
}
}
await sendToClient(
newtId,
{
type: `newt/healthcheck/add`,
data: {
targets: [
{
id: healthCheck.targetHealthCheckId,
hcEnabled: healthCheck.hcEnabled,
hcPath: healthCheck.hcPath,
hcScheme: healthCheck.hcScheme,
hcMode: healthCheck.hcMode,
hcHostname: healthCheck.hcHostname,
hcPort: healthCheck.hcPort,
hcInterval: healthCheck.hcInterval,
hcUnhealthyInterval: healthCheck.hcUnhealthyInterval,
hcTimeout: healthCheck.hcTimeout,
hcHeaders: hcHeadersSend,
hcFollowRedirects: healthCheck.hcFollowRedirects,
hcMethod: healthCheck.hcMethod,
hcStatus: hcStatus,
hcTlsServerName: healthCheck.hcTlsServerName,
hcHealthyThreshold: healthCheck.hcHealthyThreshold,
hcUnhealthyThreshold: healthCheck.hcUnhealthyThreshold
}
]
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function removeStandaloneHealthCheck(
newtId: string,
healthCheckId: number,
version?: string | null
) {
await sendToClient(
newtId,
{
type: `newt/healthcheck/remove`,
data: {
ids: [healthCheckId]
}
},
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
}
export async function removeTargets( export async function removeTargets(
newtId: string, newtId: string,
targets: Target[], targets: Target[],

View File

@@ -230,6 +230,7 @@ export async function createTarget(
.values({ .values({
orgId: resource.orgId, orgId: resource.orgId,
targetId: newTarget[0].targetId, targetId: newTarget[0].targetId,
siteId: targetData.siteId,
name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`,
hcEnabled: targetData.hcEnabled ?? false, hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null, hcPath: targetData.hcPath ?? null,

View File

@@ -14,7 +14,6 @@ import {
fireHealthCheckHealthyAlert, fireHealthCheckHealthyAlert,
fireHealthCheckNotHealthyAlert fireHealthCheckNotHealthyAlert
} from "#dynamic/lib/alerts"; } from "#dynamic/lib/alerts";
import { supportsTargetHealthChecksV2 } from "@server/routers/newt/targets";
interface TargetHealthStatus { interface TargetHealthStatus {
status: string; status: string;
@@ -74,8 +73,6 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
const isV2 = supportsTargetHealthChecksV2(newt.version);
// Process each target status update // Process each target status update
for (const [targetId, healthStatus] of Object.entries(data.targets)) { for (const [targetId, healthStatus] of Object.entries(data.targets)) {
logger.debug( logger.debug(
@@ -91,78 +88,34 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
continue; continue;
} }
let targetCheck: { const [targetCheck] = await db
targetId: number; .select({
siteId: number | null; targetId: targets.targetId,
orgId: string | null; siteId: targets.siteId,
targetHealthCheckId: number; orgId: targetHealthCheck.orgId,
resourceOrgId: string | null; targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
resourceId: number | null; resourceOrgId: resources.orgId,
name: string | null; resourceId: resources.resourceId,
hcStatus: string | null; name: targetHealthCheck.name,
} | undefined; hcStatus: targetHealthCheck.hcHealth
})
if (isV2) { .from(targetHealthCheck)
// New newt (>= 1.12.0): the key is the targetId .innerJoin(
[targetCheck] = await db targets,
.select({ eq(targetHealthCheck.targetId, targets.targetId)
targetId: targets.targetId, )
siteId: targets.siteId, .innerJoin(
orgId: targetHealthCheck.orgId, resources,
targetHealthCheckId: targetHealthCheck.targetHealthCheckId, eq(targets.resourceId, resources.resourceId)
resourceOrgId: resources.orgId, )
resourceId: resources.resourceId, .innerJoin(sites, eq(targets.siteId, sites.siteId))
name: targetHealthCheck.name, .where(
hcStatus: targetHealthCheck.hcHealth and(
}) eq(targetHealthCheck.targetHealthCheckId, targetIdNum),
.from(targets) eq(sites.siteId, newt.siteId)
.innerJoin(
resources,
eq(targets.resourceId, resources.resourceId)
) )
.innerJoin(sites, eq(targets.siteId, sites.siteId)) )
.innerJoin( .limit(1);
targetHealthCheck,
eq(targets.targetId, targetHealthCheck.targetId)
)
.where(
and(
eq(targets.targetId, targetIdNum),
eq(sites.siteId, newt.siteId)
)
)
.limit(1);
} else {
// Old newt (< 1.12.0): the key is the targetHealthCheckId
[targetCheck] = await db
.select({
targetId: targets.targetId,
siteId: targets.siteId,
orgId: targetHealthCheck.orgId,
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
resourceOrgId: resources.orgId,
resourceId: resources.resourceId,
name: targetHealthCheck.name,
hcStatus: targetHealthCheck.hcHealth
})
.from(targetHealthCheck)
.innerJoin(
targets,
eq(targetHealthCheck.targetId, targets.targetId)
)
.innerJoin(
resources,
eq(targets.resourceId, resources.resourceId)
)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(
and(
eq(targetHealthCheck.targetHealthCheckId, targetIdNum),
eq(sites.siteId, newt.siteId)
)
)
.limit(1);
}
if (!targetCheck) { if (!targetCheck) {
logger.warn( logger.warn(

View File

@@ -228,6 +228,7 @@ export async function updateTarget(
const [updatedHc] = await db const [updatedHc] = await db
.update(targetHealthCheck) .update(targetHealthCheck)
.set({ .set({
siteId: parsedBody.data.siteId,
hcEnabled: parsedBody.data.hcEnabled || false, hcEnabled: parsedBody.data.hcEnabled || false,
hcPath: parsedBody.data.hcPath, hcPath: parsedBody.data.hcPath,
hcScheme: parsedBody.data.hcScheme, hcScheme: parsedBody.data.hcScheme,

View File

@@ -41,6 +41,11 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ContactSalesBanner } from "@app/components/ContactSalesBanner"; import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { SitesSelector } from "@app/components/site-selector";
import type { Selectedsite } from "@app/components/site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { cn } from "@app/lib/cn";
export type HealthCheckConfig = { export type HealthCheckConfig = {
hcEnabled: boolean; hcEnabled: boolean;
@@ -84,6 +89,9 @@ export type HealthCheckRow = {
resourceId: number | null; resourceId: number | null;
resourceName: string | null; resourceName: string | null;
resourceNiceId: string | null; resourceNiceId: string | null;
siteId: number | null;
siteName: string | null;
siteNiceId: string | null;
}; };
export type HealthCheckCredenzaProps = export type HealthCheckCredenzaProps =
@@ -132,6 +140,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedSite, setSelectedSite] = useState<Selectedsite | null>(null);
const healthCheckSchema = z const healthCheckSchema = z
.object({ .object({
@@ -280,8 +289,14 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
hcStatus: initialValues.hcStatus ?? null, hcStatus: initialValues.hcStatus ?? null,
hcHeaders: parsedHeaders hcHeaders: parsedHeaders
}); });
if (initialValues.siteId && initialValues.siteName) {
setSelectedSite({ siteId: initialValues.siteId, name: initialValues.siteName, type: "" });
} else {
setSelectedSite(null);
}
} else { } else {
form.reset(DEFAULT_VALUES); form.reset(DEFAULT_VALUES);
setSelectedSite(null);
} }
} }
}, [open]); }, [open]);
@@ -331,6 +346,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
try { try {
const payload = { const payload = {
name: (values as any).name, name: (values as any).name,
siteId: selectedSite?.siteId,
hcEnabled: values.hcEnabled, hcEnabled: values.hcEnabled,
hcMode: values.hcMode, hcMode: values.hcMode,
hcScheme: values.hcScheme, hcScheme: values.hcScheme,
@@ -439,6 +455,42 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
/> />
)} )}
{/* Site picker (submit mode only) */}
{mode === "submit" && (
<div className="mt-4">
<FormItem>
<FormLabel>{t("site")}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!selectedSite && "text-muted-foreground"
)}
>
<span className="truncate">
{selectedSite ? selectedSite.name : t("siteSelect")}
</span>
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-full min-w-64">
<SitesSelector
orgId={orgId!}
selectedSite={selectedSite}
onSelectSite={(site) => {
setSelectedSite(site);
}}
/>
</PopoverContent>
</Popover>
</FormItem>
</div>
)}
<div className="mt-5"> <div className="mt-5">
<HorizontalTabs <HorizontalTabs
clientSide clientSide

View File

@@ -240,6 +240,27 @@ export default function HealthChecksTable({
); );
} }
}, },
{
id: "site",
friendlyName: "Site",
header: () => (
<span className="p-3">Site</span>
),
cell: ({ row }) => {
const r = row.original;
if (!r.siteId || !r.siteName || !r.siteNiceId) {
return <span className="text-neutral-400">-</span>;
}
return (
<Link href={`/${orgId}/settings/sites/${r.siteNiceId}/general`}>
<Button variant="outline" size="sm">
{r.siteName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
},
{ {
id: "health", id: "health",
friendlyName: t("standaloneHcColumnHealth"), friendlyName: t("standaloneHcColumnHealth"),

View File

@@ -335,6 +335,9 @@ export const orgQueries = {
healthChecks: { healthChecks: {
targetHealthCheckId: number; targetHealthCheckId: number;
name: string; name: string;
siteId: number | null;
siteName: string | null;
siteNiceId: string | null;
hcEnabled: boolean; hcEnabled: boolean;
hcHealth: "unknown" | "healthy" | "unhealthy"; hcHealth: "unknown" | "healthy" | "unhealthy";
hcMode: string | null; hcMode: string | null;