mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-20 15:55:31 +00:00
Compare commits
5 Commits
b59262b7af
...
6969671fc4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6969671fc4 | ||
|
|
e765f661a7 | ||
|
|
7da3719a00 | ||
|
|
206b3a7d22 | ||
|
|
ed327626bb |
@@ -1408,7 +1408,14 @@
|
||||
"alertingSectionActions": "Actions",
|
||||
"alertingAddAction": "Add action",
|
||||
"alertingActionNotify": "Email",
|
||||
"alertingActionNotifyDescription": "Send email notifications to users or roles",
|
||||
"alertingActionWebhook": "Webhook",
|
||||
"alertingActionWebhookDescription": "Send an HTTP request to a custom endpoint",
|
||||
"alertingExternalIntegration": "External Integration",
|
||||
"alertingExternalPagerDutyDescription": "Send alerts to PagerDuty for incident management",
|
||||
"alertingExternalOpsgenieDescription": "Route alerts to Opsgenie for on-call management",
|
||||
"alertingExternalServiceNowDescription": "Create ServiceNow incidents from alert events",
|
||||
"alertingExternalIncidentIoDescription": "Trigger Incident.io workflows from alert events",
|
||||
"alertingActionType": "Action type",
|
||||
"alertingNotifyUsers": "Users",
|
||||
"alertingNotifyRoles": "Roles",
|
||||
|
||||
BIN
public/third-party/incidentio.png
vendored
Normal file
BIN
public/third-party/incidentio.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/third-party/opsgenie.png
vendored
Normal file
BIN
public/third-party/opsgenie.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
BIN
public/third-party/pgd.png
vendored
Normal file
BIN
public/third-party/pgd.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
BIN
public/third-party/servicenow.png
vendored
Normal file
BIN
public/third-party/servicenow.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
@@ -153,7 +153,10 @@ export enum ActionsEnum {
|
||||
createHealthCheck = "createHealthCheck",
|
||||
updateHealthCheck = "updateHealthCheck",
|
||||
deleteHealthCheck = "deleteHealthCheck",
|
||||
listHealthChecks = "listHealthChecks"
|
||||
listHealthChecks = "listHealthChecks",
|
||||
triggerSiteAlert = "triggerSiteAlert",
|
||||
triggerResourceAlert = "triggerResourceAlert",
|
||||
triggerHealthCheckAlert = "triggerHealthCheckAlert"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
16
server/private/routers/alertEvents/index.ts
Normal file
16
server/private/routers/alertEvents/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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 "./triggerSiteAlert";
|
||||
export * from "./triggerResourceAlert";
|
||||
export * from "./triggerHealthCheckAlert";
|
||||
129
server/private/routers/alertEvents/triggerHealthCheckAlert.ts
Normal file
129
server/private/routers/alertEvents/triggerHealthCheckAlert.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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 } from "@server/db";
|
||||
import { targetHealthCheck, statusHistory } 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 { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
fireHealthCheckHealthyAlert,
|
||||
fireHealthCheckNotHealthyAlert
|
||||
} from "#private/lib/alerts/events/healthCheckEvents";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
healthCheckId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
eventType: z.enum(["health_check_healthy", "health_check_unhealthy"])
|
||||
});
|
||||
|
||||
export type TriggerHealthCheckAlertResponse = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export async function triggerHealthCheckAlert(
|
||||
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 { eventType } = parsedBody.data;
|
||||
|
||||
// Verify the health check exists and belongs to the org
|
||||
const [healthCheck] = await db
|
||||
.select()
|
||||
.from(targetHealthCheck)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
targetHealthCheck.targetHealthCheckId,
|
||||
healthCheckId
|
||||
),
|
||||
eq(targetHealthCheck.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!healthCheck) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Health check ${healthCheckId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "healthCheck",
|
||||
entityId: healthCheckId,
|
||||
orgId,
|
||||
status: eventType === "health_check_healthy" ? "healthy" : "unhealthy",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
if (eventType === "health_check_healthy") {
|
||||
await fireHealthCheckHealthyAlert(
|
||||
orgId,
|
||||
healthCheckId,
|
||||
healthCheck.name ?? undefined
|
||||
);
|
||||
} else {
|
||||
await fireHealthCheckNotHealthyAlert(
|
||||
orgId,
|
||||
healthCheckId,
|
||||
healthCheck.name ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
return response<TriggerHealthCheckAlertResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert triggered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
135
server/private/routers/alertEvents/triggerResourceAlert.ts
Normal file
135
server/private/routers/alertEvents/triggerResourceAlert.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 } from "@server/db";
|
||||
import { resources, statusHistory } 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 { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
fireResourceHealthyAlert,
|
||||
fireResourceUnhealthyAlert,
|
||||
fireResourceToggleAlert
|
||||
} from "#private/lib/alerts/events/resourceEvents";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
eventType: z.enum(["resource_healthy", "resource_unhealthy", "resource_toggle"])
|
||||
});
|
||||
|
||||
export type TriggerResourceAlertResponse = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export async function triggerResourceAlert(
|
||||
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, resourceId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { eventType } = parsedBody.data;
|
||||
|
||||
// Verify the resource exists and belongs to the org
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource ${resourceId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === "resource_healthy" || eventType === "resource_unhealthy") {
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "resource",
|
||||
entityId: resourceId,
|
||||
orgId,
|
||||
status: eventType === "resource_healthy" ? "healthy" : "unhealthy",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
|
||||
if (eventType === "resource_healthy") {
|
||||
await fireResourceHealthyAlert(
|
||||
orgId,
|
||||
resourceId,
|
||||
resource.name ?? undefined
|
||||
);
|
||||
} else if (eventType === "resource_unhealthy") {
|
||||
await fireResourceUnhealthyAlert(
|
||||
orgId,
|
||||
resourceId,
|
||||
resource.name ?? undefined
|
||||
);
|
||||
} else {
|
||||
await fireResourceToggleAlert(
|
||||
orgId,
|
||||
resourceId,
|
||||
resource.name ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
return response<TriggerResourceAlertResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert triggered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
113
server/private/routers/alertEvents/triggerSiteAlert.ts
Normal file
113
server/private/routers/alertEvents/triggerSiteAlert.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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 } from "@server/db";
|
||||
import { sites, statusHistory } 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 { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
fireSiteOnlineAlert,
|
||||
fireSiteOfflineAlert
|
||||
} from "#private/lib/alerts/events/siteEvents";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
siteId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
eventType: z.enum(["site_online", "site_offline"])
|
||||
});
|
||||
|
||||
export type TriggerSiteAlertResponse = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export async function triggerSiteAlert(
|
||||
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, siteId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { eventType } = parsedBody.data;
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site ${siteId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "site",
|
||||
entityId: siteId,
|
||||
orgId,
|
||||
status: eventType === "site_online" ? "online" : "offline",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
if (eventType === "site_online") {
|
||||
await fireSiteOnlineAlert(orgId, siteId, site.name ?? undefined);
|
||||
} else {
|
||||
await fireSiteOfflineAlert(orgId, siteId, site.name ?? undefined);
|
||||
}
|
||||
|
||||
return response<TriggerSiteAlertResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert triggered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
import * as orgIdp from "#private/routers/orgIdp";
|
||||
import * as org from "#private/routers/org";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
import * as alertEvents from "#private/routers/alertEvents";
|
||||
|
||||
import {
|
||||
verifyApiKeyHasAction,
|
||||
@@ -40,6 +41,27 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
export const unauthenticated = ua;
|
||||
export const authenticated = a;
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/site/:siteId/trigger-alert",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.triggerSiteAlert),
|
||||
alertEvents.triggerSiteAlert
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/resource/:resourceId/trigger-alert",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.triggerResourceAlert),
|
||||
alertEvents.triggerResourceAlert
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/health-check/:healthCheckId/trigger-alert",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.triggerHealthCheckAlert),
|
||||
alertEvents.triggerHealthCheckAlert
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/org/:orgId/send-usage-notification`,
|
||||
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
generateSubnetProxyTargetV2,
|
||||
SubnetProxyTargetV2
|
||||
} from "@server/lib/ip";
|
||||
import { supportsTargetHealthChecksV2 } from "./targets";
|
||||
|
||||
export async function buildClientConfigurationForNewtClient(
|
||||
site: Site,
|
||||
@@ -86,7 +87,8 @@ export async function buildClientConfigurationForNewtClient(
|
||||
// )
|
||||
// );
|
||||
|
||||
if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm
|
||||
if (!client.clientSitesAssociationsCache.isJitMode) {
|
||||
// if we are adding sites through jit then dont add the site to the olm
|
||||
// update the peer info on the olm
|
||||
// if the peer has not been added yet this will be a no-op
|
||||
await updatePeer(client.clients.clientId, {
|
||||
@@ -189,7 +191,10 @@ export async function buildClientConfigurationForNewtClient(
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
export async function buildTargetConfigurationForNewtClient(
|
||||
siteId: number,
|
||||
version?: string | null
|
||||
) {
|
||||
// Get all enabled targets with their resource protocol information
|
||||
const allTargets = await db
|
||||
.select({
|
||||
@@ -201,7 +206,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
internalPort: targets.internalPort,
|
||||
enabled: targets.enabled,
|
||||
protocol: resources.protocol,
|
||||
hcId: targetHealthCheck.targetHealthCheckId,
|
||||
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
|
||||
hcEnabled: targetHealthCheck.hcEnabled,
|
||||
hcPath: targetHealthCheck.hcPath,
|
||||
hcScheme: targetHealthCheck.hcScheme,
|
||||
@@ -273,8 +278,9 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
}
|
||||
|
||||
return {
|
||||
id: target.targetId,
|
||||
hcId: target.hcId,
|
||||
id: supportsTargetHealthChecksV2(version)
|
||||
? target.targetId
|
||||
: target.targetHealthCheckId,
|
||||
hcEnabled: target.hcEnabled,
|
||||
hcPath: target.hcPath,
|
||||
hcScheme: target.hcScheme,
|
||||
|
||||
@@ -192,7 +192,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
}
|
||||
|
||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||
await buildTargetConfigurationForNewtClient(siteId);
|
||||
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
||||
|
||||
logger.debug(
|
||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||
|
||||
@@ -2,6 +2,13 @@ import { Target, TargetHealthCheck } from "@server/db";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import logger from "@server/logger";
|
||||
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(
|
||||
newtId: string,
|
||||
@@ -83,8 +90,7 @@ export async function addTargets(
|
||||
}
|
||||
|
||||
return {
|
||||
id: target.targetId,
|
||||
hcId: hc.targetHealthCheckId,
|
||||
id: supportsTargetHealthChecksV2(version) ? target.targetId : hc.targetHealthCheckId,
|
||||
hcEnabled: hc.hcEnabled,
|
||||
hcPath: hc.hcPath,
|
||||
hcScheme: hc.hcScheme,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
fireHealthCheckHealthyAlert,
|
||||
fireHealthCheckNotHealthyAlert
|
||||
} from "#dynamic/lib/alerts";
|
||||
import { supportsTargetHealthChecksV2 } from "@server/routers/newt/targets";
|
||||
|
||||
interface TargetHealthStatus {
|
||||
status: string;
|
||||
@@ -73,6 +74,8 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
const isV2 = supportsTargetHealthChecksV2(newt.version);
|
||||
|
||||
// Process each target status update
|
||||
for (const [targetId, healthStatus] of Object.entries(data.targets)) {
|
||||
logger.debug(
|
||||
@@ -88,34 +91,78 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
const [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(targets)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(targets.resourceId, resources.resourceId)
|
||||
)
|
||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.innerJoin(
|
||||
targetHealthCheck,
|
||||
eq(targets.targetId, targetHealthCheck.targetId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(targets.targetId, targetIdNum),
|
||||
eq(sites.siteId, newt.siteId)
|
||||
let targetCheck: {
|
||||
targetId: number;
|
||||
siteId: number | null;
|
||||
orgId: string | null;
|
||||
targetHealthCheckId: number;
|
||||
resourceOrgId: string | null;
|
||||
resourceId: number | null;
|
||||
name: string | null;
|
||||
hcStatus: string | null;
|
||||
} | undefined;
|
||||
|
||||
if (isV2) {
|
||||
// New newt (>= 1.12.0): the key is the targetId
|
||||
[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(targets)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(targets.resourceId, resources.resourceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.innerJoin(
|
||||
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) {
|
||||
logger.warn(
|
||||
@@ -142,13 +189,21 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
| "healthy"
|
||||
| "unhealthy"
|
||||
})
|
||||
.where(eq(targetHealthCheck.targetId, targetIdNum));
|
||||
.where(eq(targetHealthCheck.targetId, targetCheck.targetId));
|
||||
|
||||
const orgId = targetCheck.orgId || targetCheck.resourceOrgId; // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId
|
||||
if (!orgId) {
|
||||
logger.warn(
|
||||
`No org ID found for target ${targetId}, skipping status history logging`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Log the state change to status history
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "healthCheck",
|
||||
entityId: targetCheck.targetHealthCheckId,
|
||||
orgId: targetCheck.orgId || targetCheck.resourceOrgId,
|
||||
orgId: orgId,
|
||||
status: healthStatus.status,
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
@@ -170,7 +225,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
.where(
|
||||
and(
|
||||
eq(targets.resourceId, targetCheck.resourceId),
|
||||
eq(targets.targetId, targetIdNum) // only check the other targets, not the one we just updated
|
||||
eq(targets.targetId, targetCheck.targetId) // only check the other targets, not the one we just updated
|
||||
)
|
||||
);
|
||||
|
||||
@@ -188,7 +243,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "resource",
|
||||
entityId: targetCheck.resourceId,
|
||||
orgId: targetCheck.orgId || targetCheck.resourceOrgId,
|
||||
orgId: orgId,
|
||||
status: status,
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
@@ -197,13 +252,13 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
// because we are checking above if there was a change we can fire the alert here because it changed
|
||||
if (healthStatus.status === "unhealthy") {
|
||||
await fireHealthCheckHealthyAlert(
|
||||
targetCheck.orgId || targetCheck.resourceOrgId, // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId
|
||||
orgId,
|
||||
targetCheck.targetHealthCheckId,
|
||||
targetCheck.name
|
||||
);
|
||||
} else if (healthStatus.status === "healthy") {
|
||||
await fireHealthCheckNotHealthyAlert(
|
||||
targetCheck.orgId || targetCheck.resourceOrgId, // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId
|
||||
orgId,
|
||||
targetCheck.targetHealthCheckId,
|
||||
targetCheck.name
|
||||
);
|
||||
|
||||
@@ -44,54 +44,115 @@ import {
|
||||
} from "@app/lib/alertRuleForm";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronsUpDown, Plus, Trash2 } from "lucide-react";
|
||||
import { ContactSalesBanner } from "@app/components/ContactSalesBanner";
|
||||
import { Bell, Globe, ChevronsUpDown, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Control, UseFormReturn } from "react-hook-form";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
export function DropdownAddAction({
|
||||
export function AddActionPanel({
|
||||
onAdd
|
||||
}: {
|
||||
onAdd: (type: AlertRuleFormAction["type"]) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
|
||||
const EXTERNAL_INTEGRATIONS = [
|
||||
{
|
||||
id: "pagerduty",
|
||||
name: "PagerDuty",
|
||||
logo: "/third-party/pgd.png",
|
||||
description: "Send alerts to PagerDuty for incident management",
|
||||
descriptionKey: t("alertingExternalPagerDutyDescription")
|
||||
},
|
||||
{
|
||||
id: "opsgenie",
|
||||
name: "Opsgenie",
|
||||
logo: "/third-party/opsgenie.png",
|
||||
description: "Route alerts to Opsgenie for on-call management",
|
||||
descriptionKey: t("alertingExternalOpsgenieDescription")
|
||||
},
|
||||
{
|
||||
id: "servicenow",
|
||||
name: "ServiceNow",
|
||||
logo: "/third-party/servicenow.png",
|
||||
description: "Create ServiceNow incidents from alert events",
|
||||
descriptionKey: t("alertingExternalServiceNowDescription")
|
||||
},
|
||||
{
|
||||
id: "incidentio",
|
||||
name: "Incident.io",
|
||||
logo: "/third-party/incidentio.png",
|
||||
description: "Trigger Incident.io workflows from alert events",
|
||||
descriptionKey: t("alertingExternalIncidentIoDescription")
|
||||
}
|
||||
] as const;
|
||||
|
||||
const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id);
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
const isPremiumSelected =
|
||||
selected !== null && EXTERNAL_IDS.includes(selected as any);
|
||||
const isBuiltInSelected = selected !== null && !isPremiumSelected;
|
||||
|
||||
const actionTypeOptions = [
|
||||
{
|
||||
id: "notify",
|
||||
title: t("alertingActionNotify"),
|
||||
description: t("alertingActionNotifyDescription"),
|
||||
icon: <Bell className="h-5 w-5" />
|
||||
},
|
||||
{
|
||||
id: "webhook",
|
||||
title: t("alertingActionWebhook"),
|
||||
description: t("alertingActionWebhookDescription"),
|
||||
icon: <Globe className="h-5 w-5" />
|
||||
},
|
||||
...EXTERNAL_INTEGRATIONS.map((integration) => ({
|
||||
id: integration.id,
|
||||
title: integration.name,
|
||||
description: integration.description,
|
||||
icon: (
|
||||
<img
|
||||
src={integration.logo}
|
||||
alt={integration.name}
|
||||
className="h-5 w-5 object-contain"
|
||||
/>
|
||||
)
|
||||
}))
|
||||
];
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!isBuiltInSelected) return;
|
||||
onAdd(selected as AlertRuleFormAction["type"]);
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<div className="space-y-3">
|
||||
<StrategySelect
|
||||
options={actionTypeOptions}
|
||||
value={selected}
|
||||
cols={2}
|
||||
onChange={(v) => setSelected(v)}
|
||||
/>
|
||||
{isPremiumSelected && <ContactSalesBanner />}
|
||||
{!isPremiumSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!isBuiltInSelected}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("alertingAddAction")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-48" align="start">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
onAdd("notify");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("alertingActionNotify")}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
onAdd("webhook");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("alertingActionWebhook")}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,6 +443,20 @@ export function ActionBlock({
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const type = useWatch({ control, name: `actions.${index}.type` });
|
||||
|
||||
const typeHeader =
|
||||
type === "notify" ? (
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||
{t("alertingActionNotify")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
{t("alertingActionWebhook")}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4 space-y-3 relative">
|
||||
{canRemove && (
|
||||
@@ -395,55 +470,7 @@ export function ActionBlock({
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
<FormField
|
||||
control={control}
|
||||
name={`actions.${index}.type`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("alertingActionType")}</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
const nt = v as AlertRuleFormAction["type"];
|
||||
if (nt === "notify") {
|
||||
onUpdate({
|
||||
type: "notify",
|
||||
userTags: [],
|
||||
roleTags: [],
|
||||
emailTags: []
|
||||
});
|
||||
} else {
|
||||
onUpdate({
|
||||
type: "webhook",
|
||||
url: "",
|
||||
method: "POST",
|
||||
headers: [],
|
||||
authType: "none",
|
||||
bearerToken: "",
|
||||
basicCredentials: "",
|
||||
customHeaderName: "",
|
||||
customHeaderValue: ""
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="notify">
|
||||
{t("alertingActionNotify")}
|
||||
</SelectItem>
|
||||
<SelectItem value="webhook">
|
||||
{t("alertingActionWebhook")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{typeHeader}
|
||||
{type === "notify" && (
|
||||
<NotifyActionFields
|
||||
orgId={orgId}
|
||||
@@ -484,8 +511,8 @@ function NotifyActionFields({
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
|
||||
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
|
||||
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(orgQueries.users({ orgId }));
|
||||
const { data: orgRoles = [], isLoading: isLoadingRoles } = useQuery(orgQueries.roles({ orgId }));
|
||||
|
||||
const allUsers = useMemo(
|
||||
() =>
|
||||
@@ -508,6 +535,50 @@ function NotifyActionFields({
|
||||
[orgRoles]
|
||||
);
|
||||
|
||||
const hasResolvedTagsRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingUsers || isLoadingRoles) return;
|
||||
if (hasResolvedTagsRef.current) return;
|
||||
|
||||
const currentUserTags = form.getValues(
|
||||
`actions.${index}.userTags`
|
||||
) as Tag[];
|
||||
const currentRoleTags = form.getValues(
|
||||
`actions.${index}.roleTags`
|
||||
) as Tag[];
|
||||
|
||||
const resolvedUserTags = currentUserTags.map((tag) => {
|
||||
const match = allUsers.find((u) => u.id === tag.id);
|
||||
return match ? { id: tag.id, text: match.text } : tag;
|
||||
});
|
||||
|
||||
const resolvedRoleTags = currentRoleTags.map((tag) => {
|
||||
const match = allRoles.find((r) => r.id === tag.id);
|
||||
return match ? { id: tag.id, text: match.text } : tag;
|
||||
});
|
||||
|
||||
const userTagsNeedUpdate = resolvedUserTags.some(
|
||||
(t, i) => t.text !== currentUserTags[i]?.text
|
||||
);
|
||||
const roleTagsNeedUpdate = resolvedRoleTags.some(
|
||||
(t, i) => t.text !== currentRoleTags[i]?.text
|
||||
);
|
||||
|
||||
if (userTagsNeedUpdate) {
|
||||
form.setValue(`actions.${index}.userTags`, resolvedUserTags, {
|
||||
shouldDirty: false
|
||||
});
|
||||
}
|
||||
if (roleTagsNeedUpdate) {
|
||||
form.setValue(`actions.${index}.roleTags`, resolvedRoleTags, {
|
||||
shouldDirty: false
|
||||
});
|
||||
}
|
||||
|
||||
hasResolvedTagsRef.current = true;
|
||||
}, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]);
|
||||
|
||||
const userTags = (useWatch({ control, name: `actions.${index}.userTags` }) ?? []) as Tag[];
|
||||
const roleTags = (useWatch({ control, name: `actions.${index}.roleTags` }) ?? []) as Tag[];
|
||||
const emailTags = (useWatch({ control, name: `actions.${index}.emailTags` }) ?? []) as Tag[];
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import {
|
||||
ActionBlock,
|
||||
AddActionPanel,
|
||||
AlertRuleSourceFields,
|
||||
AlertRuleTriggerFields,
|
||||
DropdownAddAction
|
||||
AlertRuleTriggerFields
|
||||
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
||||
import { SettingsContainer } from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
@@ -346,10 +346,16 @@ export default function AlertRuleGraphEditor({
|
||||
useWatch({ control: form.control, name: "enabled" }) ?? true;
|
||||
const wSourceType =
|
||||
useWatch({ control: form.control, name: "sourceType" }) ?? "site";
|
||||
const wAllSites =
|
||||
useWatch({ control: form.control, name: "allSites" }) ?? true;
|
||||
const wSiteIds =
|
||||
useWatch({ control: form.control, name: "siteIds" }) ?? [];
|
||||
const wAllHealthChecks =
|
||||
useWatch({ control: form.control, name: "allHealthChecks" }) ?? true;
|
||||
const wHealthCheckIds =
|
||||
useWatch({ control: form.control, name: "healthCheckIds" }) ?? [];
|
||||
const wAllResources =
|
||||
useWatch({ control: form.control, name: "allResources" }) ?? true;
|
||||
const wResourceIds =
|
||||
useWatch({ control: form.control, name: "resourceIds" }) ?? [];
|
||||
const wTrigger =
|
||||
@@ -363,8 +369,11 @@ export default function AlertRuleGraphEditor({
|
||||
name: wName,
|
||||
enabled: wEnabled,
|
||||
sourceType: wSourceType,
|
||||
allSites: wAllSites,
|
||||
siteIds: wSiteIds,
|
||||
allHealthChecks: wAllHealthChecks,
|
||||
healthCheckIds: wHealthCheckIds,
|
||||
allResources: wAllResources,
|
||||
resourceIds: wResourceIds,
|
||||
trigger: wTrigger,
|
||||
actions: wActions
|
||||
@@ -373,8 +382,11 @@ export default function AlertRuleGraphEditor({
|
||||
wName,
|
||||
wEnabled,
|
||||
wSourceType,
|
||||
wAllSites,
|
||||
wSiteIds,
|
||||
wAllHealthChecks,
|
||||
wHealthCheckIds,
|
||||
wAllResources,
|
||||
wResourceIds,
|
||||
wTrigger,
|
||||
wActions
|
||||
@@ -693,47 +705,43 @@ export default function AlertRuleGraphEditor({
|
||||
)}
|
||||
{isActionsSidebar && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
"alertingSectionActions"
|
||||
)}
|
||||
</span>
|
||||
<DropdownAddAction
|
||||
onAdd={(type) => {
|
||||
const newIndex =
|
||||
fields.length;
|
||||
if (type === "notify") {
|
||||
append({
|
||||
type: "notify",
|
||||
userTags: [],
|
||||
roleTags: [],
|
||||
emailTags: []
|
||||
});
|
||||
} else {
|
||||
append({
|
||||
type: "webhook",
|
||||
url: "",
|
||||
method: "POST",
|
||||
headers: [
|
||||
{
|
||||
key: "",
|
||||
value: ""
|
||||
}
|
||||
],
|
||||
authType: "none",
|
||||
bearerToken: "",
|
||||
basicCredentials: "",
|
||||
customHeaderName: "",
|
||||
customHeaderValue: ""
|
||||
});
|
||||
}
|
||||
setSelectedStep(
|
||||
`action-${newIndex}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{t("alertingSectionActions")}
|
||||
</span>
|
||||
<AddActionPanel
|
||||
onAdd={(type) => {
|
||||
const newIndex =
|
||||
fields.length;
|
||||
if (type === "notify") {
|
||||
append({
|
||||
type: "notify",
|
||||
userTags: [],
|
||||
roleTags: [],
|
||||
emailTags: []
|
||||
});
|
||||
} else {
|
||||
append({
|
||||
type: "webhook",
|
||||
url: "",
|
||||
method: "POST",
|
||||
headers: [
|
||||
{
|
||||
key: "",
|
||||
value: ""
|
||||
}
|
||||
],
|
||||
authType: "none",
|
||||
bearerToken: "",
|
||||
basicCredentials: "",
|
||||
customHeaderName: "",
|
||||
customHeaderValue: ""
|
||||
});
|
||||
}
|
||||
setSelectedStep(
|
||||
`action-${newIndex}`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{fields.map((f, index) => (
|
||||
<ActionBlock
|
||||
key={f.id}
|
||||
|
||||
Reference in New Issue
Block a user