mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-22 08:45:24 +00:00
Compare commits
5 Commits
b59262b7af
...
6969671fc4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6969671fc4 | ||
|
|
e765f661a7 | ||
|
|
7da3719a00 | ||
|
|
206b3a7d22 | ||
|
|
ed327626bb |
@@ -1408,7 +1408,14 @@
|
|||||||
"alertingSectionActions": "Actions",
|
"alertingSectionActions": "Actions",
|
||||||
"alertingAddAction": "Add action",
|
"alertingAddAction": "Add action",
|
||||||
"alertingActionNotify": "Email",
|
"alertingActionNotify": "Email",
|
||||||
|
"alertingActionNotifyDescription": "Send email notifications to users or roles",
|
||||||
"alertingActionWebhook": "Webhook",
|
"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",
|
"alertingActionType": "Action type",
|
||||||
"alertingNotifyUsers": "Users",
|
"alertingNotifyUsers": "Users",
|
||||||
"alertingNotifyRoles": "Roles",
|
"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",
|
createHealthCheck = "createHealthCheck",
|
||||||
updateHealthCheck = "updateHealthCheck",
|
updateHealthCheck = "updateHealthCheck",
|
||||||
deleteHealthCheck = "deleteHealthCheck",
|
deleteHealthCheck = "deleteHealthCheck",
|
||||||
listHealthChecks = "listHealthChecks"
|
listHealthChecks = "listHealthChecks",
|
||||||
|
triggerSiteAlert = "triggerSiteAlert",
|
||||||
|
triggerResourceAlert = "triggerResourceAlert",
|
||||||
|
triggerHealthCheckAlert = "triggerHealthCheckAlert"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
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 orgIdp from "#private/routers/orgIdp";
|
||||||
import * as org from "#private/routers/org";
|
import * as org from "#private/routers/org";
|
||||||
import * as logs from "#private/routers/auditLogs";
|
import * as logs from "#private/routers/auditLogs";
|
||||||
|
import * as alertEvents from "#private/routers/alertEvents";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyApiKeyHasAction,
|
verifyApiKeyHasAction,
|
||||||
@@ -40,6 +41,27 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|||||||
export const unauthenticated = ua;
|
export const unauthenticated = ua;
|
||||||
export const authenticated = a;
|
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(
|
authenticated.post(
|
||||||
`/org/:orgId/send-usage-notification`,
|
`/org/:orgId/send-usage-notification`,
|
||||||
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
|
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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,
|
||||||
@@ -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
|
// update the peer info on the olm
|
||||||
// if the peer has not been added yet this will be a no-op
|
// if the peer has not been added yet this will be a no-op
|
||||||
await updatePeer(client.clients.clientId, {
|
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
|
// Get all enabled targets with their resource protocol information
|
||||||
const allTargets = await db
|
const allTargets = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -201,7 +206,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
internalPort: targets.internalPort,
|
internalPort: targets.internalPort,
|
||||||
enabled: targets.enabled,
|
enabled: targets.enabled,
|
||||||
protocol: resources.protocol,
|
protocol: resources.protocol,
|
||||||
hcId: targetHealthCheck.targetHealthCheckId,
|
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
|
||||||
hcEnabled: targetHealthCheck.hcEnabled,
|
hcEnabled: targetHealthCheck.hcEnabled,
|
||||||
hcPath: targetHealthCheck.hcPath,
|
hcPath: targetHealthCheck.hcPath,
|
||||||
hcScheme: targetHealthCheck.hcScheme,
|
hcScheme: targetHealthCheck.hcScheme,
|
||||||
@@ -273,8 +278,9 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: target.targetId,
|
id: supportsTargetHealthChecksV2(version)
|
||||||
hcId: target.hcId,
|
? target.targetId
|
||||||
|
: target.targetHealthCheckId,
|
||||||
hcEnabled: target.hcEnabled,
|
hcEnabled: target.hcEnabled,
|
||||||
hcPath: target.hcPath,
|
hcPath: target.hcPath,
|
||||||
hcScheme: target.hcScheme,
|
hcScheme: target.hcScheme,
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||||
await buildTargetConfigurationForNewtClient(siteId);
|
await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
`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 { 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,
|
||||||
@@ -83,8 +90,7 @@ export async function addTargets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: target.targetId,
|
id: supportsTargetHealthChecksV2(version) ? target.targetId : hc.targetHealthCheckId,
|
||||||
hcId: hc.targetHealthCheckId,
|
|
||||||
hcEnabled: hc.hcEnabled,
|
hcEnabled: hc.hcEnabled,
|
||||||
hcPath: hc.hcPath,
|
hcPath: hc.hcPath,
|
||||||
hcScheme: hc.hcScheme,
|
hcScheme: hc.hcScheme,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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;
|
||||||
@@ -73,6 +74,8 @@ 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(
|
||||||
@@ -88,7 +91,20 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [targetCheck] = await db
|
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({
|
.select({
|
||||||
targetId: targets.targetId,
|
targetId: targets.targetId,
|
||||||
siteId: targets.siteId,
|
siteId: targets.siteId,
|
||||||
@@ -116,6 +132,37 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.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(
|
||||||
@@ -142,13 +189,21 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
|||||||
| "healthy"
|
| "healthy"
|
||||||
| "unhealthy"
|
| "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
|
// Log the state change to status history
|
||||||
await db.insert(statusHistory).values({
|
await db.insert(statusHistory).values({
|
||||||
entityType: "healthCheck",
|
entityType: "healthCheck",
|
||||||
entityId: targetCheck.targetHealthCheckId,
|
entityId: targetCheck.targetHealthCheckId,
|
||||||
orgId: targetCheck.orgId || targetCheck.resourceOrgId,
|
orgId: orgId,
|
||||||
status: healthStatus.status,
|
status: healthStatus.status,
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
});
|
});
|
||||||
@@ -170,7 +225,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(targets.resourceId, targetCheck.resourceId),
|
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({
|
await db.insert(statusHistory).values({
|
||||||
entityType: "resource",
|
entityType: "resource",
|
||||||
entityId: targetCheck.resourceId,
|
entityId: targetCheck.resourceId,
|
||||||
orgId: targetCheck.orgId || targetCheck.resourceOrgId,
|
orgId: orgId,
|
||||||
status: status,
|
status: status,
|
||||||
timestamp: Math.floor(Date.now() / 1000)
|
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
|
// because we are checking above if there was a change we can fire the alert here because it changed
|
||||||
if (healthStatus.status === "unhealthy") {
|
if (healthStatus.status === "unhealthy") {
|
||||||
await fireHealthCheckHealthyAlert(
|
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.targetHealthCheckId,
|
||||||
targetCheck.name
|
targetCheck.name
|
||||||
);
|
);
|
||||||
} else if (healthStatus.status === "healthy") {
|
} else if (healthStatus.status === "healthy") {
|
||||||
await fireHealthCheckNotHealthyAlert(
|
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.targetHealthCheckId,
|
||||||
targetCheck.name
|
targetCheck.name
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,54 +44,115 @@ import {
|
|||||||
} from "@app/lib/alertRuleForm";
|
} from "@app/lib/alertRuleForm";
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { 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 type { Control, UseFormReturn } from "react-hook-form";
|
||||||
import { useFormContext, useWatch } from "react-hook-form";
|
import { useFormContext, useWatch } from "react-hook-form";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
export function DropdownAddAction({
|
export function AddActionPanel({
|
||||||
onAdd
|
onAdd
|
||||||
}: {
|
}: {
|
||||||
onAdd: (type: AlertRuleFormAction["type"]) => void;
|
onAdd: (type: AlertRuleFormAction["type"]) => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
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 (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<div className="space-y-3">
|
||||||
<PopoverTrigger asChild>
|
<StrategySelect
|
||||||
<Button type="button" variant="outline" size="sm">
|
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" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
{t("alertingAddAction")}
|
{t("alertingAddAction")}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
)}
|
||||||
<PopoverContent className="p-0 w-48" align="start">
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +443,20 @@ export function ActionBlock({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const type = useWatch({ control, name: `actions.${index}.type` });
|
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 (
|
return (
|
||||||
<div className="rounded-lg border p-4 space-y-3 relative">
|
<div className="rounded-lg border p-4 space-y-3 relative">
|
||||||
{canRemove && (
|
{canRemove && (
|
||||||
@@ -395,55 +470,7 @@ export function ActionBlock({
|
|||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<FormField
|
{typeHeader}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{type === "notify" && (
|
{type === "notify" && (
|
||||||
<NotifyActionFields
|
<NotifyActionFields
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
@@ -484,8 +511,8 @@ function NotifyActionFields({
|
|||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId }));
|
const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(orgQueries.users({ orgId }));
|
||||||
const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId }));
|
const { data: orgRoles = [], isLoading: isLoadingRoles } = useQuery(orgQueries.roles({ orgId }));
|
||||||
|
|
||||||
const allUsers = useMemo(
|
const allUsers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -508,6 +535,50 @@ function NotifyActionFields({
|
|||||||
[orgRoles]
|
[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 userTags = (useWatch({ control, name: `actions.${index}.userTags` }) ?? []) as Tag[];
|
||||||
const roleTags = (useWatch({ control, name: `actions.${index}.roleTags` }) ?? []) as Tag[];
|
const roleTags = (useWatch({ control, name: `actions.${index}.roleTags` }) ?? []) as Tag[];
|
||||||
const emailTags = (useWatch({ control, name: `actions.${index}.emailTags` }) ?? []) as Tag[];
|
const emailTags = (useWatch({ control, name: `actions.${index}.emailTags` }) ?? []) as Tag[];
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ActionBlock,
|
ActionBlock,
|
||||||
|
AddActionPanel,
|
||||||
AlertRuleSourceFields,
|
AlertRuleSourceFields,
|
||||||
AlertRuleTriggerFields,
|
AlertRuleTriggerFields
|
||||||
DropdownAddAction
|
|
||||||
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
||||||
import { SettingsContainer } from "@app/components/Settings";
|
import { SettingsContainer } from "@app/components/Settings";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
@@ -346,10 +346,16 @@ export default function AlertRuleGraphEditor({
|
|||||||
useWatch({ control: form.control, name: "enabled" }) ?? true;
|
useWatch({ control: form.control, name: "enabled" }) ?? true;
|
||||||
const wSourceType =
|
const wSourceType =
|
||||||
useWatch({ control: form.control, name: "sourceType" }) ?? "site";
|
useWatch({ control: form.control, name: "sourceType" }) ?? "site";
|
||||||
|
const wAllSites =
|
||||||
|
useWatch({ control: form.control, name: "allSites" }) ?? true;
|
||||||
const wSiteIds =
|
const wSiteIds =
|
||||||
useWatch({ control: form.control, name: "siteIds" }) ?? [];
|
useWatch({ control: form.control, name: "siteIds" }) ?? [];
|
||||||
|
const wAllHealthChecks =
|
||||||
|
useWatch({ control: form.control, name: "allHealthChecks" }) ?? true;
|
||||||
const wHealthCheckIds =
|
const wHealthCheckIds =
|
||||||
useWatch({ control: form.control, name: "healthCheckIds" }) ?? [];
|
useWatch({ control: form.control, name: "healthCheckIds" }) ?? [];
|
||||||
|
const wAllResources =
|
||||||
|
useWatch({ control: form.control, name: "allResources" }) ?? true;
|
||||||
const wResourceIds =
|
const wResourceIds =
|
||||||
useWatch({ control: form.control, name: "resourceIds" }) ?? [];
|
useWatch({ control: form.control, name: "resourceIds" }) ?? [];
|
||||||
const wTrigger =
|
const wTrigger =
|
||||||
@@ -363,8 +369,11 @@ export default function AlertRuleGraphEditor({
|
|||||||
name: wName,
|
name: wName,
|
||||||
enabled: wEnabled,
|
enabled: wEnabled,
|
||||||
sourceType: wSourceType,
|
sourceType: wSourceType,
|
||||||
|
allSites: wAllSites,
|
||||||
siteIds: wSiteIds,
|
siteIds: wSiteIds,
|
||||||
|
allHealthChecks: wAllHealthChecks,
|
||||||
healthCheckIds: wHealthCheckIds,
|
healthCheckIds: wHealthCheckIds,
|
||||||
|
allResources: wAllResources,
|
||||||
resourceIds: wResourceIds,
|
resourceIds: wResourceIds,
|
||||||
trigger: wTrigger,
|
trigger: wTrigger,
|
||||||
actions: wActions
|
actions: wActions
|
||||||
@@ -373,8 +382,11 @@ export default function AlertRuleGraphEditor({
|
|||||||
wName,
|
wName,
|
||||||
wEnabled,
|
wEnabled,
|
||||||
wSourceType,
|
wSourceType,
|
||||||
|
wAllSites,
|
||||||
wSiteIds,
|
wSiteIds,
|
||||||
|
wAllHealthChecks,
|
||||||
wHealthCheckIds,
|
wHealthCheckIds,
|
||||||
|
wAllResources,
|
||||||
wResourceIds,
|
wResourceIds,
|
||||||
wTrigger,
|
wTrigger,
|
||||||
wActions
|
wActions
|
||||||
@@ -693,13 +705,10 @@ export default function AlertRuleGraphEditor({
|
|||||||
)}
|
)}
|
||||||
{isActionsSidebar && (
|
{isActionsSidebar && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t(
|
{t("alertingSectionActions")}
|
||||||
"alertingSectionActions"
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
<DropdownAddAction
|
<AddActionPanel
|
||||||
onAdd={(type) => {
|
onAdd={(type) => {
|
||||||
const newIndex =
|
const newIndex =
|
||||||
fields.length;
|
fields.length;
|
||||||
@@ -733,7 +742,6 @@ export default function AlertRuleGraphEditor({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{fields.map((f, index) => (
|
{fields.map((f, index) => (
|
||||||
<ActionBlock
|
<ActionBlock
|
||||||
key={f.id}
|
key={f.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user