Create hcs freely

This commit is contained in:
Owen
2026-04-15 20:32:02 -07:00
parent a04e2a5e00
commit 1397e61643
18 changed files with 1881 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<any> {
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<CreateHealthCheckResponse>(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")
);
}
}

View File

@@ -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<any> {
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<null>(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")
);
}
}

View File

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

View File

@@ -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<any> {
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<number>`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<ListHealthChecksResponse>(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")
);
}
}
}

View File

@@ -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<any> {
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<string, unknown> = {};
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<UpdateHealthCheckResponse>(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")
);
}
}

View File

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

View File

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

View File

@@ -32,4 +32,3 @@ export * from "./addUserToResource";
export * from "./removeUserFromResource";
export * from "./listAllResourceNames";
export * from "./removeEmailFromResourceWhitelist";
export * from "./listHealthChecks";

View File

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

View File

@@ -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 (
<>
<SettingsSectionTitle
title={t("alertingTitle")}
description={t("alertingDescription")}
/>
<AlertingRulesTable orgId={params.orgId} />
<HorizontalTabs items={tabs} clientSide>
<AlertingRulesTable orgId={params.orgId} />
<StandaloneHealthChecksTable orgId={params.orgId} />
</HorizontalTabs>
</>
);
}
}

View File

@@ -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<typeof healthCheckSchema>;
const form = useForm<FormValues>({
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 (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>
{isEditing
? t("standaloneHcEditTitle")
: t("standaloneHcCreateTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("standaloneHcDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
id="standalone-hc-form"
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
{/* Name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("standaloneHcNameLabel")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"standaloneHcNamePlaceholder"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Enable Health Check */}
<FormField
control={form.control}
name="hcEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>
{t("enableHealthChecks")}
</FormLabel>
<FormDescription>
{t(
"enableHealthChecksDescription"
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{watchedEnabled && (
<div className="space-y-4">
{/* Mode */}
<FormField
control={form.control}
name="hcMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("healthCheckMode")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">
HTTP
</SelectItem>
<SelectItem value="tcp">
TCP
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"healthCheckModeDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Connection fields */}
{watchedMode === "tcp" ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="hcHostname"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("healthHostname")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("healthPort")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<FormField
control={form.control}
name="hcScheme"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("healthScheme")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"healthSelectScheme"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">
HTTP
</SelectItem>
<SelectItem value="https">
HTTPS
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcHostname"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("healthHostname")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("healthPort")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("healthCheckPath")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* HTTP Method */}
{watchedMode !== "tcp" && (
<FormField
control={form.control}
name="hcMethod"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("httpMethod")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectHttpMethod"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="GET">
GET
</SelectItem>
<SelectItem value="POST">
POST
</SelectItem>
<SelectItem value="HEAD">
HEAD
</SelectItem>
<SelectItem value="PUT">
PUT
</SelectItem>
<SelectItem value="DELETE">
DELETE
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Check Interval, Unhealthy Interval, and Timeout */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="hcInterval"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"healthyIntervalSeconds"
)}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) =>
field.onChange(
parseInt(
e.target
.value
)
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcUnhealthyInterval"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"unhealthyIntervalSeconds"
)}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) =>
field.onChange(
parseInt(
e.target
.value
)
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcTimeout"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("timeoutSeconds")}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) =>
field.onChange(
parseInt(
e.target
.value
)
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Healthy and Unhealthy Thresholds */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="hcHealthyThreshold"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("healthyThreshold")}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) =>
field.onChange(
parseInt(
e.target
.value
)
)
}
/>
</FormControl>
<FormDescription>
{t(
"healthyThresholdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcUnhealthyThreshold"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("unhealthyThreshold")}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) =>
field.onChange(
parseInt(
e.target
.value
)
)
}
/>
</FormControl>
<FormDescription>
{t(
"unhealthyThresholdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* HTTP-only fields */}
{watchedMode !== "tcp" && (
<>
{/* Expected Response Code */}
<FormField
control={form.control}
name="hcStatus"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"expectedResponseCodes"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) => {
const val =
e.target
.value;
field.onChange(
val
? parseInt(
val
)
: null
);
}}
/>
</FormControl>
<FormDescription>
{t(
"expectedResponseCodesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* TLS Server Name */}
<FormField
control={form.control}
name="hcTlsServerName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("tlsServerName")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"tlsServerNameDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Custom Headers */}
<FormField
control={form.control}
name="hcHeaders"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={
field.value
}
onChange={
field.onChange
}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"customHeadersDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Follow Redirects */}
<FormField
control={form.control}
name="hcFollowRedirects"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>
{t(
"followRedirects"
)}
</FormLabel>
<FormDescription>
{t(
"followRedirectsDescription"
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
</div>
)}
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline" type="button">
{t("cancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="standalone-hc-form"
disabled={loading}
>
{t("save")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -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<HealthCheckRow["hcHealth"], string> = {
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 (
<Badge variant={healthVariant[health]}>{healthLabel[health]}</Badge>
);
}
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<HealthCheckRow | null>(null);
const [togglingId, setTogglingId] = useState<number | null>(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<HealthCheckRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium">{row.original.name}</span>
)
},
{
id: "mode",
friendlyName: t("standaloneHcColumnMode"),
header: () => (
<span className="p-3">{t("standaloneHcColumnMode")}</span>
),
cell: ({ row }) => (
<span className="uppercase text-xs font-mono">
{row.original.hcMode?.toUpperCase() ?? "—"}
</span>
)
},
{
id: "target",
friendlyName: t("standaloneHcColumnTarget"),
header: () => (
<span className="p-3">{t("standaloneHcColumnTarget")}</span>
),
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground truncate max-w-64 block">
{formatTarget(row.original)}
</span>
)
},
{
id: "health",
friendlyName: t("standaloneHcColumnHealth"),
header: () => (
<span className="p-3">{t("standaloneHcColumnHealth")}</span>
),
cell: ({ row }) => (
<HealthBadge health={row.original.hcHealth} />
)
},
{
accessorKey: "hcEnabled",
friendlyName: t("alertingColumnEnabled"),
header: () => (
<span className="p-3">{t("alertingColumnEnabled")}</span>
),
cell: ({ row }) => {
const r = row.original;
return (
<Switch
checked={r.hcEnabled}
disabled={togglingId === r.targetHealthCheckId}
onCheckedChange={(v) => handleToggleEnabled(r, v)}
/>
);
}
},
{
id: "rowActions",
enableHiding: false,
header: () => <span className="p-3" />,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(r);
setDeleteOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
onClick={() => {
setSelected(r);
setCredenzaOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}
];
return (
<>
{selected && deleteOpen && (
<ConfirmDeleteDialog
open={deleteOpen}
setOpen={(val) => {
setDeleteOpen(val);
if (!val) setSelected(null);
}}
dialog={
<div className="space-y-2">
<p>{t("standaloneHcDeleteQuestion")}</p>
</div>
}
buttonText={t("delete")}
onConfirm={handleDelete}
string={selected.name}
title={t("standaloneHcDeleteTitle")}
/>
)}
<StandaloneHealthCheckCredenza
open={credenzaOpen}
setOpen={(val) => {
setCredenzaOpen(val);
if (!val) setSelected(null);
}}
orgId={orgId}
initialValues={selected}
onSaved={invalidate}
/>
<DataTable
columns={columns}
data={rows}
persistPageSize="Org-standalone-health-checks-table"
title={t("standaloneHcTableTitle")}
searchPlaceholder={t("standaloneHcSearchPlaceholder")}
searchColumn="name"
onAdd={() => {
setSelected(null);
setCredenzaOpen(true);
}}
onRefresh={() => refetch()}
isRefreshing={isRefetching || isLoading}
addButtonText={t("standaloneHcAddButton")}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="rowActions"
/>
</>
);
}

View File

@@ -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) => (
<CommandItem
key={hc.targetHealthCheckId}
value={`${hc.targetHealthCheckId}:${hc.resourceName}`}
value={`${hc.targetHealthCheckId}:${hc.name}`}
onSelect={() =>
toggle(hc.targetHealthCheckId)
}
@@ -258,7 +258,7 @@ function HealthCheckMultiSelect({
tabIndex={-1}
/>
<span className="truncate">
{hc.resourceName}
{hc.name}
</span>
</CommandItem>
))}

View File

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