mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-11 23:18:24 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4463f0e1a | |||
| b7c0669c38 |
@@ -1695,8 +1695,6 @@
|
|||||||
"alertingRuleSaved": "Alert rule saved",
|
"alertingRuleSaved": "Alert rule saved",
|
||||||
"alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.",
|
"alertingRuleSavedCreatedDescription": "Your new alert rule was created. You can keep editing it on this page.",
|
||||||
"alertingRuleSavedUpdatedDescription": "Your changes to this alert rule were saved.",
|
"alertingRuleSavedUpdatedDescription": "Your changes to this alert rule were saved.",
|
||||||
"alertingTestAlertSent": "Test alert sent",
|
|
||||||
"alertingTestAlertSentDescription": "A test alert was sent to the actions configured on this rule.",
|
|
||||||
"alertingEditRule": "Edit Alert Rule",
|
"alertingEditRule": "Edit Alert Rule",
|
||||||
"alertingCreateRule": "Create Alert Rule",
|
"alertingCreateRule": "Create Alert Rule",
|
||||||
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify",
|
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify",
|
||||||
@@ -1806,10 +1804,6 @@
|
|||||||
"alertingRulesBannerDescription": "Each rule ties together what to watch (a site, health check, or resource), when to fire (for example offline or unhealthy), and how to notify your team via email, webhooks, or integrations. Use this list to create, enable, and manage those rules.",
|
"alertingRulesBannerDescription": "Each rule ties together what to watch (a site, health check, or resource), when to fire (for example offline or unhealthy), and how to notify your team via email, webhooks, or integrations. Use this list to create, enable, and manage those rules.",
|
||||||
"alertingHealthChecksBannerTitle": "Monitor Health & Resources",
|
"alertingHealthChecksBannerTitle": "Monitor Health & Resources",
|
||||||
"alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.",
|
"alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.",
|
||||||
"alertingTestRule": "Test Alert Rule",
|
|
||||||
"alertingNoActionsTitle": "No actions configured",
|
|
||||||
"alertingNoActionsSaveDescription": "Add at least one action so this rule can notify someone when it fires.",
|
|
||||||
"alertingNoActionsTestDescription": "Add at least one action before you can test this rule.",
|
|
||||||
"standaloneHcTableTitle": "Health Checks",
|
"standaloneHcTableTitle": "Health Checks",
|
||||||
"standaloneHcSearchPlaceholder": "Search health checks…",
|
"standaloneHcSearchPlaceholder": "Search health checks…",
|
||||||
"standaloneHcAddButton": "Create Health Check",
|
"standaloneHcAddButton": "Create Health Check",
|
||||||
|
|||||||
Generated
+3
-3
@@ -17072,9 +17072,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-parser": {
|
"node_modules/socket.io-parser": {
|
||||||
"version": "4.2.6",
|
"version": "4.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
||||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ export enum ActionsEnum {
|
|||||||
createAlertRule = "createAlertRule",
|
createAlertRule = "createAlertRule",
|
||||||
updateAlertRule = "updateAlertRule",
|
updateAlertRule = "updateAlertRule",
|
||||||
deleteAlertRule = "deleteAlertRule",
|
deleteAlertRule = "deleteAlertRule",
|
||||||
testAlertRule = "testAlertRule",
|
|
||||||
listAlertRules = "listAlertRules",
|
listAlertRules = "listAlertRules",
|
||||||
listOrgLabels = "listOrgLabels",
|
listOrgLabels = "listOrgLabels",
|
||||||
createOrgLabel = "createOrgLabel",
|
createOrgLabel = "createOrgLabel",
|
||||||
|
|||||||
@@ -31,24 +31,9 @@ export type AlertNotificationProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
dashboardLink: string;
|
dashboardLink: string;
|
||||||
isTestAlert?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getEventMeta(
|
function getEventMeta(eventType: AlertEventType): {
|
||||||
eventType: AlertEventType,
|
|
||||||
isTestAlert: boolean = false
|
|
||||||
): {
|
|
||||||
heading: string;
|
|
||||||
previewText: string;
|
|
||||||
summary: string;
|
|
||||||
statusLabel: string | null;
|
|
||||||
statusColor: string | null;
|
|
||||||
} {
|
|
||||||
const meta = getBaseEventMeta(eventType);
|
|
||||||
return isTestAlert ? { ...meta, heading: `[TEST] ${meta.heading}` } : meta;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBaseEventMeta(eventType: AlertEventType): {
|
|
||||||
heading: string;
|
heading: string;
|
||||||
previewText: string;
|
previewText: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -195,14 +180,8 @@ function formatDataItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AlertNotification = (props: AlertNotificationProps) => {
|
export const AlertNotification = (props: AlertNotificationProps) => {
|
||||||
const {
|
const { eventType, orgId, data, dashboardLink } = props;
|
||||||
eventType,
|
const meta = getEventMeta(eventType);
|
||||||
orgId,
|
|
||||||
data,
|
|
||||||
dashboardLink,
|
|
||||||
isTestAlert = false
|
|
||||||
} = props;
|
|
||||||
const meta = getEventMeta(eventType, isTestAlert);
|
|
||||||
const dataItems = formatDataItems(data);
|
const dataItems = formatDataItems(data);
|
||||||
|
|
||||||
const isToggle =
|
const isToggle =
|
||||||
@@ -263,12 +242,6 @@ export const AlertNotification = (props: AlertNotificationProps) => {
|
|||||||
Open your dashboard to view more details and manage
|
Open your dashboard to view more details and manage
|
||||||
your alert rules.
|
your alert rules.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
{isTestAlert && (
|
|
||||||
<EmailText>
|
|
||||||
This is a test alert. No action is required,
|
|
||||||
and no real event has occurred.
|
|
||||||
</EmailText>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EmailSection>
|
<EmailSection>
|
||||||
<ButtonLink href={dashboardLink}>
|
<ButtonLink href={dashboardLink}>
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import { db, userOrgRoles, users } from "@server/db";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import type {
|
|
||||||
EmailAlertAction,
|
|
||||||
TestAlertContext,
|
|
||||||
WebhookAlertConfig
|
|
||||||
} from "@server/routers/alertRule/types";
|
|
||||||
import { eq, inArray } from "drizzle-orm";
|
|
||||||
import { sendAlertEmail } from "./sendAlertEmail";
|
|
||||||
import { decrypt } from "@server/lib/crypto";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import { sendAlertWebhook } from "./sendAlertWebhook";
|
|
||||||
|
|
||||||
export async function processTestAlerts(context: TestAlertContext) {
|
|
||||||
const emailActions = context.actions.filter(
|
|
||||||
(action) => action.type === "email"
|
|
||||||
);
|
|
||||||
// Process email actions
|
|
||||||
for (const action of emailActions) {
|
|
||||||
try {
|
|
||||||
const recipients = await resolveEmailRecipients(action);
|
|
||||||
if (recipients.length > 0) {
|
|
||||||
await sendAlertEmail(recipients, {
|
|
||||||
...context,
|
|
||||||
isTest: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`processTestAlerts: failed to send alert email`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const webhookActions = context.actions.filter(
|
|
||||||
(action) => action.type === "webhook"
|
|
||||||
);
|
|
||||||
const serverSecret = config.getRawConfig().server.secret!;
|
|
||||||
|
|
||||||
for (const action of webhookActions) {
|
|
||||||
try {
|
|
||||||
let webhookConfig: WebhookAlertConfig = { authType: "none" };
|
|
||||||
|
|
||||||
if (action.config) {
|
|
||||||
try {
|
|
||||||
const decrypted = decrypt(action.config, serverSecret);
|
|
||||||
webhookConfig = JSON.parse(decrypted) as WebhookAlertConfig;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`processTestAlerts: failed to decrypt webhook`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendAlertWebhook(action.webhookUrl, webhookConfig, context);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`processTestAlerts: failed to send alert webhook `,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves all email addresses for a given `emailActionId`.
|
|
||||||
*
|
|
||||||
* Recipients may be:
|
|
||||||
* - Direct users (by `userId`)
|
|
||||||
* - All users in a role (by `roleId`, resolved via `userOrgRoles`)
|
|
||||||
* - Direct external email addresses
|
|
||||||
*/
|
|
||||||
async function resolveEmailRecipients(
|
|
||||||
action: EmailAlertAction
|
|
||||||
): Promise<string[]> {
|
|
||||||
const emailList: string[] = [];
|
|
||||||
|
|
||||||
emailList.push(...(action.emails ?? []));
|
|
||||||
|
|
||||||
if (action.userIds && action.userIds?.length > 0) {
|
|
||||||
const userList = await db
|
|
||||||
.select({ email: users.email })
|
|
||||||
.from(users)
|
|
||||||
.where(inArray(users.userId, action.userIds));
|
|
||||||
|
|
||||||
emailList.push(
|
|
||||||
...userList.filter((u) => u.email !== null).map((u) => u.email!)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (action.roleIds && action.roleIds?.length > 0) {
|
|
||||||
const userList = await db
|
|
||||||
.select({ email: users.email })
|
|
||||||
.from(userOrgRoles)
|
|
||||||
.innerJoin(users, eq(userOrgRoles.userId, users.userId))
|
|
||||||
.where(inArray(userOrgRoles.roleId, action.roleIds.map(Number)));
|
|
||||||
|
|
||||||
emailList.push(
|
|
||||||
...userList.filter((u) => u.email !== null).map((u) => u.email!)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...new Set(emailList)];
|
|
||||||
}
|
|
||||||
@@ -15,24 +15,7 @@ import { sendEmail } from "@server/emails";
|
|||||||
import AlertNotification from "@server/emails/templates/AlertNotification";
|
import AlertNotification from "@server/emails/templates/AlertNotification";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import {
|
import { AlertContext } from "@server/routers/alertRule/types";
|
||||||
AlertContext,
|
|
||||||
type AlertEventType
|
|
||||||
} from "@server/routers/alertRule/types";
|
|
||||||
|
|
||||||
type EmailAlertContext = {
|
|
||||||
eventType: AlertEventType;
|
|
||||||
orgId: string;
|
|
||||||
/** Set for site_online / site_offline events */
|
|
||||||
siteId?: number;
|
|
||||||
/** Set for health_check_* events */
|
|
||||||
healthCheckId?: number;
|
|
||||||
/** Set for resource_* events */
|
|
||||||
resourceId?: number;
|
|
||||||
/** Human-readable context data included in emails and webhook payloads */
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
isTest?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends an alert notification email to every address in `recipients`.
|
* Sends an alert notification email to every address in `recipients`.
|
||||||
@@ -44,7 +27,7 @@ type EmailAlertContext = {
|
|||||||
*/
|
*/
|
||||||
export async function sendAlertEmail(
|
export async function sendAlertEmail(
|
||||||
recipients: string[],
|
recipients: string[],
|
||||||
context: EmailAlertContext
|
context: AlertContext
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (recipients.length === 0) {
|
if (recipients.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -63,8 +46,7 @@ export async function sendAlertEmail(
|
|||||||
eventType: context.eventType,
|
eventType: context.eventType,
|
||||||
orgId: context.orgId,
|
orgId: context.orgId,
|
||||||
data: context.data,
|
data: context.data,
|
||||||
dashboardLink,
|
dashboardLink
|
||||||
isTestAlert: context.isTest
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
from,
|
from,
|
||||||
@@ -88,35 +70,34 @@ export async function sendAlertEmail(
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function buildSubject(context: EmailAlertContext): string {
|
function buildSubject(context: AlertContext): string {
|
||||||
const prefix = context.isTest ? "[Test Alert]" : "[Alert]";
|
|
||||||
switch (context.eventType) {
|
switch (context.eventType) {
|
||||||
case "site_online":
|
case "site_online":
|
||||||
return `${prefix} Site Back Online`;
|
return "[Alert] Site Back Online";
|
||||||
case "site_offline":
|
case "site_offline":
|
||||||
return `${prefix} Site Offline`;
|
return "[Alert] Site Offline";
|
||||||
case "site_toggle":
|
case "site_toggle":
|
||||||
return `${prefix} Site Status Changed`;
|
return "[Alert] Site Status Changed";
|
||||||
case "health_check_healthy":
|
case "health_check_healthy":
|
||||||
return `${prefix} Health Check Recovered`;
|
return "[Alert] Health Check Recovered";
|
||||||
case "health_check_unhealthy":
|
case "health_check_unhealthy":
|
||||||
return `${prefix} Health Check Failing`;
|
return "[Alert] Health Check Failing";
|
||||||
case "health_check_toggle":
|
case "health_check_toggle":
|
||||||
return `${prefix} Health Check Status Changed`;
|
return "[Alert] Health Check Status Changed";
|
||||||
case "resource_healthy":
|
case "resource_healthy":
|
||||||
return `${prefix} Resource Healthy`;
|
return "[Alert] Resource Healthy";
|
||||||
case "resource_unhealthy":
|
case "resource_unhealthy":
|
||||||
return `${prefix} Resource Unhealthy`;
|
return "[Alert] Resource Unhealthy";
|
||||||
case "resource_degraded":
|
case "resource_degraded":
|
||||||
return `${prefix} Resource Degraded`;
|
return "[Alert] Resource Degraded";
|
||||||
case "resource_toggle":
|
case "resource_toggle":
|
||||||
return `${prefix} Resource Status Changed`;
|
return "[Alert] Resource Status Changed";
|
||||||
default: {
|
default: {
|
||||||
// Exhaustiveness fallback – should never be reached with a
|
// Exhaustiveness fallback – should never be reached with a
|
||||||
// well-typed caller, but keeps runtime behaviour predictable.
|
// well-typed caller, but keeps runtime behaviour predictable.
|
||||||
const _exhaustive: never = context.eventType;
|
const _exhaustive: never = context.eventType;
|
||||||
void _exhaustive;
|
void _exhaustive;
|
||||||
return `${prefix} Event Notification`;
|
return "[Alert] Event Notification";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,3 @@ export * from "./updateAlertRule";
|
|||||||
export * from "./deleteAlertRule";
|
export * from "./deleteAlertRule";
|
||||||
export * from "./listAlertRules";
|
export * from "./listAlertRules";
|
||||||
export * from "./getAlertRule";
|
export * from "./getAlertRule";
|
||||||
export * from "./testAlertRule";
|
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 {
|
|
||||||
alertRules,
|
|
||||||
alertSites,
|
|
||||||
alertHealthChecks,
|
|
||||||
alertResources
|
|
||||||
} 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, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
ListAlertRulesResponse,
|
|
||||||
type AlertAction,
|
|
||||||
type EmailAlertAction
|
|
||||||
} from "@server/routers/alertRule/types";
|
|
||||||
import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts";
|
|
||||||
import { getRandomItemInArray } from "@app/lib/getRandomItemInArray";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SITE_EVENT_TYPES = [
|
|
||||||
"site_online",
|
|
||||||
"site_offline",
|
|
||||||
"site_toggle"
|
|
||||||
] as const;
|
|
||||||
export const HC_EVENT_TYPES = [
|
|
||||||
"health_check_healthy",
|
|
||||||
"health_check_unhealthy",
|
|
||||||
"health_check_toggle"
|
|
||||||
] as const;
|
|
||||||
export const RESOURCE_EVENT_TYPES = [
|
|
||||||
"resource_healthy",
|
|
||||||
"resource_unhealthy",
|
|
||||||
"resource_degraded",
|
|
||||||
"resource_toggle"
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const webhookActionSchema = z.strictObject({
|
|
||||||
webhookUrl: z.url(),
|
|
||||||
config: z.string().optional(),
|
|
||||||
enabled: z.boolean().optional().default(true)
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodySchema = z.object({
|
|
||||||
eventType: z.enum([
|
|
||||||
...HC_EVENT_TYPES,
|
|
||||||
...SITE_EVENT_TYPES,
|
|
||||||
...RESOURCE_EVENT_TYPES
|
|
||||||
]),
|
|
||||||
// Email recipients (flat)
|
|
||||||
userIds: z.array(z.string().nonempty()).optional().default([]),
|
|
||||||
roleIds: z.array(z.number()).optional().default([]),
|
|
||||||
emails: z.array(z.email()).optional().default([]),
|
|
||||||
// Webhook actions
|
|
||||||
webhookActions: z.array(webhookActionSchema).optional().default([])
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function testAlertRule(
|
|
||||||
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 body = parsedBody.data;
|
|
||||||
const collectedActions: AlertAction[] = [];
|
|
||||||
if (
|
|
||||||
body.emails.length > 0 ||
|
|
||||||
body.roleIds.length > 0 ||
|
|
||||||
body.userIds.length > 0
|
|
||||||
) {
|
|
||||||
collectedActions.push({
|
|
||||||
type: "email",
|
|
||||||
emails: body.emails,
|
|
||||||
roleIds: body.roleIds,
|
|
||||||
userIds: body.userIds
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const action of body.webhookActions) {
|
|
||||||
collectedActions.push({
|
|
||||||
type: "webhook",
|
|
||||||
...action
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: Record<string, any> = {};
|
|
||||||
switch (body.eventType) {
|
|
||||||
case "site_toggle":
|
|
||||||
data = {
|
|
||||||
status: getRandomItemInArray(["online", "offline"]),
|
|
||||||
siteName: "Test Site Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "site_offline":
|
|
||||||
data = {
|
|
||||||
status: "offline",
|
|
||||||
siteName: "Test Site Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "site_online":
|
|
||||||
data = {
|
|
||||||
status: "online",
|
|
||||||
siteName: "Test Site Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "resource_toggle":
|
|
||||||
data = {
|
|
||||||
status: getRandomItemInArray([
|
|
||||||
"healthy",
|
|
||||||
"unhealthy",
|
|
||||||
"degraded"
|
|
||||||
]),
|
|
||||||
siteName: "Test Resource Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "resource_healthy":
|
|
||||||
data = {
|
|
||||||
status: "healthy",
|
|
||||||
siteName: "Test Resource Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "resource_unhealthy":
|
|
||||||
data = {
|
|
||||||
status: "unhealthy",
|
|
||||||
siteName: "Test Resource Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "resource_degraded":
|
|
||||||
data = {
|
|
||||||
status: "degraded",
|
|
||||||
siteName: "Test Resource Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "health_check_toggle":
|
|
||||||
data = {
|
|
||||||
status: getRandomItemInArray(["healthy", "unhealthy"]),
|
|
||||||
healthCheckName: "Test Health Check Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "health_check_healthy":
|
|
||||||
data = {
|
|
||||||
status: "healthy",
|
|
||||||
healthCheckName: "Test Health Check Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "health_check_unhealthy":
|
|
||||||
data = {
|
|
||||||
status: "unhealthy",
|
|
||||||
healthCheckName: "Test Health Check Alert"
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: process alert rule
|
|
||||||
await processTestAlerts({
|
|
||||||
eventType: body.eventType,
|
|
||||||
orgId,
|
|
||||||
actions: collectedActions,
|
|
||||||
data
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -808,14 +808,6 @@ authenticated.get(
|
|||||||
alertRule.listAlertRules
|
alertRule.listAlertRules
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/test-alert-rule",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.testAlertRule),
|
|
||||||
alertRule.testAlertRule
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/alert-rule/:alertRuleId",
|
"/org/:orgId/alert-rule/:alertRuleId",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -124,26 +124,3 @@ export interface AlertContext {
|
|||||||
/** Human-readable context data included in emails and webhook payloads */
|
/** Human-readable context data included in emails and webhook payloads */
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmailAlertAction = {
|
|
||||||
type: "email";
|
|
||||||
userIds?: string[];
|
|
||||||
roleIds?: number[];
|
|
||||||
emails?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebhookAlertAction = {
|
|
||||||
type: "webhook";
|
|
||||||
webhookUrl: string;
|
|
||||||
enabled: boolean;
|
|
||||||
config?: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AlertAction = EmailAlertAction | WebhookAlertAction;
|
|
||||||
export interface TestAlertContext {
|
|
||||||
eventType: AlertEventType;
|
|
||||||
actions: AlertAction[];
|
|
||||||
orgId: string;
|
|
||||||
/** Human-readable context data included in emails and webhook payloads */
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ const createRoleSchema = z.strictObject({
|
|||||||
export const defaultRoleAllowedActions: ActionsEnum[] = [
|
export const defaultRoleAllowedActions: ActionsEnum[] = [
|
||||||
ActionsEnum.getOrg,
|
ActionsEnum.getOrg,
|
||||||
ActionsEnum.getResource,
|
ActionsEnum.getResource,
|
||||||
ActionsEnum.listResources,
|
ActionsEnum.listResources
|
||||||
ActionsEnum.getSiteResource,
|
|
||||||
ActionsEnum.listSiteResources
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export type CreateRoleBody = z.infer<typeof createRoleSchema>;
|
export type CreateRoleBody = z.infer<typeof createRoleSchema>;
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ import {
|
|||||||
DB_TYPE,
|
DB_TYPE,
|
||||||
Label,
|
Label,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
roleSiteResources,
|
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
siteResourceLabels,
|
siteResourceLabels,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
sites,
|
||||||
labels,
|
labels
|
||||||
userSiteResources
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -325,48 +323,7 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
labels: labelFilter
|
labels: labelFilter
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
let accessibleSiteResourceIds: number[];
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
if (req.user) {
|
|
||||||
const accessibleSiteResources = await db
|
|
||||||
.select({
|
|
||||||
siteResourceId: sql<number>`COALESCE(${userSiteResources.siteResourceId}, ${roleSiteResources.siteResourceId})`
|
|
||||||
})
|
|
||||||
.from(userSiteResources)
|
|
||||||
.fullJoin(
|
|
||||||
roleSiteResources,
|
|
||||||
eq(
|
|
||||||
userSiteResources.siteResourceId,
|
|
||||||
roleSiteResources.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
or(
|
|
||||||
eq(userSiteResources.userId, req.user.userId),
|
|
||||||
inArray(
|
|
||||||
roleSiteResources.roleId,
|
|
||||||
req.userOrgRoleIds ?? []
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
accessibleSiteResourceIds = accessibleSiteResources.map(
|
|
||||||
(row) => row.siteResourceId
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const allOrgSiteResources = await db
|
|
||||||
.select({ siteResourceId: siteResources.siteResourceId })
|
|
||||||
.from(siteResources)
|
|
||||||
.where(eq(siteResources.orgId, orgId));
|
|
||||||
accessibleSiteResourceIds = allOrgSiteResources.map(
|
|
||||||
(row) => row.siteResourceId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const conditions = [
|
|
||||||
and(
|
|
||||||
eq(siteResources.orgId, orgId),
|
|
||||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
|
||||||
)
|
|
||||||
];
|
|
||||||
|
|
||||||
if (siteId != null) {
|
if (siteId != null) {
|
||||||
// Keep inner joins here: filtering by a specific site implies the
|
// Keep inner joins here: filtering by a specific site implies the
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { db, networks, siteNetworks } from "@server/db";
|
||||||
db,
|
|
||||||
networks,
|
|
||||||
roleSiteResources,
|
|
||||||
siteNetworks,
|
|
||||||
userSiteResources
|
|
||||||
} from "@server/db";
|
|
||||||
import { siteResources, sites, SiteResource } from "@server/db";
|
import { siteResources, sites, SiteResource } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq } from "drizzle-orm";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -165,47 +159,10 @@ export async function listSiteResources(
|
|||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let accessibleSiteResourceIds: number[];
|
|
||||||
if (req.user) {
|
|
||||||
const accessibleSiteResources = await db
|
|
||||||
.select({
|
|
||||||
siteResourceId: sql<number>`COALESCE(${userSiteResources.siteResourceId}, ${roleSiteResources.siteResourceId})`
|
|
||||||
})
|
|
||||||
.from(userSiteResources)
|
|
||||||
.fullJoin(
|
|
||||||
roleSiteResources,
|
|
||||||
eq(
|
|
||||||
userSiteResources.siteResourceId,
|
|
||||||
roleSiteResources.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
or(
|
|
||||||
eq(userSiteResources.userId, req.user.userId),
|
|
||||||
inArray(
|
|
||||||
roleSiteResources.roleId,
|
|
||||||
req.userOrgRoleIds ?? []
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
accessibleSiteResourceIds = accessibleSiteResources.map(
|
|
||||||
(row) => row.siteResourceId
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const allOrgSiteResources = await db
|
|
||||||
.select({ siteResourceId: siteResources.siteResourceId })
|
|
||||||
.from(siteResources)
|
|
||||||
.where(eq(siteResources.orgId, orgId));
|
|
||||||
accessibleSiteResourceIds = allOrgSiteResources.map(
|
|
||||||
(row) => row.siteResourceId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get site resources by joining networks to siteResources via siteNetworks
|
// Get site resources by joining networks to siteResources via siteNetworks
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(siteNetworks.siteId, siteId),
|
eq(siteNetworks.siteId, siteId),
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId)
|
||||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (typeof status !== "undefined") {
|
if (typeof status !== "undefined") {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import m19 from "./scriptsPg/1.18.4";
|
|||||||
import m20 from "./scriptsPg/1.19.0";
|
import m20 from "./scriptsPg/1.19.0";
|
||||||
import m21 from "./scriptsPg/1.20.0";
|
import m21 from "./scriptsPg/1.20.0";
|
||||||
import m22 from "./scriptsPg/1.21.0";
|
import m22 from "./scriptsPg/1.21.0";
|
||||||
import m23 from "./scriptsPg/1.21.1";
|
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -56,8 +55,7 @@ const migrations = [
|
|||||||
{ version: "1.18.4", run: m19 },
|
{ version: "1.18.4", run: m19 },
|
||||||
{ version: "1.19.0", run: m20 },
|
{ version: "1.19.0", run: m20 },
|
||||||
{ version: "1.20.0", run: m21 },
|
{ version: "1.20.0", run: m21 },
|
||||||
{ version: "1.21.0", run: m22 },
|
{ version: "1.21.0", run: m22 }
|
||||||
{ version: "1.21.1", run: m23 }
|
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as {
|
] as {
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import m41 from "./scriptsSqlite/1.19.0";
|
|||||||
import m42 from "./scriptsSqlite/1.19.1";
|
import m42 from "./scriptsSqlite/1.19.1";
|
||||||
import m43 from "./scriptsSqlite/1.20.0";
|
import m43 from "./scriptsSqlite/1.20.0";
|
||||||
import m44 from "./scriptsSqlite/1.21.0";
|
import m44 from "./scriptsSqlite/1.21.0";
|
||||||
import m45 from "./scriptsSqlite/1.21.1";
|
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -92,8 +91,7 @@ const migrations = [
|
|||||||
{ version: "1.19.0", run: m41 },
|
{ version: "1.19.0", run: m41 },
|
||||||
{ version: "1.19.1", run: m42 },
|
{ version: "1.19.1", run: m42 },
|
||||||
{ version: "1.20.0", run: m43 },
|
{ version: "1.20.0", run: m43 },
|
||||||
{ version: "1.21.0", run: m44 },
|
{ version: "1.21.0", run: m44 }
|
||||||
{ version: "1.21.1", run: m45 }
|
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { db } from "@server/db/pg/driver";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
|
|
||||||
const version = "1.21.1";
|
|
||||||
|
|
||||||
const actionsToGrant = ["getSiteResource", "listSiteResources"] as const;
|
|
||||||
|
|
||||||
export default async function migration() {
|
|
||||||
console.log(`Running setup script ${version}...`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.execute(sql`BEGIN`);
|
|
||||||
|
|
||||||
for (const actionId of actionsToGrant) {
|
|
||||||
await db.execute(sql`
|
|
||||||
INSERT INTO "roleActions" ("roleId", "actionId", "orgId")
|
|
||||||
SELECT r."roleId", ${actionId}, r."orgId"
|
|
||||||
FROM "roles" r
|
|
||||||
WHERE COALESCE(r."isAdmin", false) = false
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM "roleActions" ra
|
|
||||||
WHERE ra."roleId" = r."roleId"
|
|
||||||
AND ra."actionId" = ${actionId}
|
|
||||||
AND ra."orgId" = r."orgId"
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.execute(sql`COMMIT`);
|
|
||||||
console.log(`Finished setup script ${version}`);
|
|
||||||
} catch (e) {
|
|
||||||
await db.execute(sql`ROLLBACK`);
|
|
||||||
console.log("Unable to migrate database");
|
|
||||||
console.log(e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { APP_PATH } from "@server/lib/consts";
|
|
||||||
import Database from "better-sqlite3";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
const version = "1.21.1";
|
|
||||||
|
|
||||||
const actionsToGrant = ["getSiteResource", "listSiteResources"] as const;
|
|
||||||
|
|
||||||
export default async function migration() {
|
|
||||||
console.log(`Running setup script ${version}...`);
|
|
||||||
|
|
||||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
|
||||||
const db = new Database(location);
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.transaction(() => {
|
|
||||||
const insertRoleAction = db.prepare(`
|
|
||||||
INSERT INTO 'roleActions' ("roleId", "actionId", "orgId")
|
|
||||||
SELECT r."roleId", ?, r."orgId"
|
|
||||||
FROM 'roles' r
|
|
||||||
WHERE COALESCE(r."isAdmin", 0) = 0
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM 'roleActions' ra
|
|
||||||
WHERE ra."roleId" = r."roleId"
|
|
||||||
AND ra."actionId" = ?
|
|
||||||
AND ra."orgId" = r."orgId"
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
for (const actionId of actionsToGrant) {
|
|
||||||
insertRoleAction.run(actionId, actionId);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
console.log(`Finished setup script ${version}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.log("Unable to migrate database");
|
|
||||||
console.log(e);
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -181,7 +181,7 @@ export default function NetworkingPage() {
|
|||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t("remoteExitNodeNetworkingDescription")}
|
{t("remoteExitNodeNetworkingDescription")}
|
||||||
<a
|
<a
|
||||||
href="https://docs.pangolin.net/manage/remote-node/backhaul"
|
href="https://docs.pangolin.net/placeholder"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export function ContactSalesBanner() {
|
|||||||
<ExternalLink className="size-3.5 shrink-0" />
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
</Link>
|
</Link>
|
||||||
{" " + t("contactSalesOr") + " "}
|
{" " + t("contactSalesOr") + " "}
|
||||||
<span className="whitespace-nowrap">
|
|
||||||
<Link
|
<Link
|
||||||
href="https://pangolin.net/contact"
|
href="https://pangolin.net/contact"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -36,7 +35,6 @@ export function ContactSalesBanner() {
|
|||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||||
|
|
||||||
const MFA_OTP_INPUT_ID = "mfa-otp-code";
|
const MFA_OTP_INPUT_ID = "mfa-otp-code";
|
||||||
|
|
||||||
@@ -82,11 +82,9 @@ export default function MfaInputForm({
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
{...field}
|
{...field}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
inputMode="text"
|
inputMode="numeric"
|
||||||
autoFocus
|
autoFocus
|
||||||
pattern={
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
|
||||||
}
|
|
||||||
onChange={(value: string) => {
|
onChange={(value: string) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
if (value.length === 6) {
|
if (value.length === 6) {
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import {
|
|||||||
AlertRuleSourceFields,
|
AlertRuleSourceFields,
|
||||||
AlertRuleTriggerFields
|
AlertRuleTriggerFields
|
||||||
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
|
||||||
import { SettingsContainer } from "@app/components/Settings";
|
import { SettingsContainer } from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Card, CardContent } from "@app/components/ui/card";
|
import { Card, CardContent } from "@app/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +19,6 @@ import {
|
|||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import {
|
import {
|
||||||
buildFormSchema,
|
buildFormSchema,
|
||||||
@@ -30,15 +27,19 @@ import {
|
|||||||
type AlertRuleFormValues
|
type AlertRuleFormValues
|
||||||
} from "@app/lib/alertRuleForm";
|
} from "@app/lib/alertRuleForm";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import type { CreateAlertRuleResponse } from "@server/routers/alertRule/types";
|
import type { CreateAlertRuleResponse } from "@server/routers/alertRule/types";
|
||||||
import type { AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import { Cog, Flag, Zap, ZapIcon } from "lucide-react";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useTranslations } from "next-intl";
|
import { ChevronLeft, Cog, Flag, Zap } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useActionState, useMemo, useTransition, type ReactNode } from "react";
|
import { useMemo, useState, type ReactNode } from "react";
|
||||||
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
|
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { Badge } from "../ui/badge";
|
import { Badge } from "../ui/badge";
|
||||||
|
|
||||||
const FORM_ID = "alert-rule-form";
|
const FORM_ID = "alert-rule-form";
|
||||||
@@ -114,6 +115,7 @@ export default function AlertRuleGraphEditor({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const schema = useMemo(() => buildFormSchema(t), [t]);
|
const schema = useMemo(() => buildFormSchema(t), [t]);
|
||||||
const form = useForm<AlertRuleFormValues>({
|
const form = useForm<AlertRuleFormValues>({
|
||||||
resolver: zodResolver(schema) as Resolver<AlertRuleFormValues>,
|
resolver: zodResolver(schema) as Resolver<AlertRuleFormValues>,
|
||||||
@@ -125,22 +127,8 @@ export default function AlertRuleGraphEditor({
|
|||||||
name: "actions"
|
name: "actions"
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveAlert = async () => {
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
const isValid = await form.trigger();
|
setIsSaving(true);
|
||||||
if (!isValid) {
|
|
||||||
const values = form.getValues();
|
|
||||||
if (values.actions.length === 0) {
|
|
||||||
toast({
|
|
||||||
variant: "warning",
|
|
||||||
title: t("alertingNoActionsTitle"),
|
|
||||||
description: t("alertingNoActionsSaveDescription")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = form.getValues();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = formValuesToApiPayload(values);
|
const payload = formValuesToApiPayload(values);
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@@ -170,65 +158,14 @@ export default function AlertRuleGraphEditor({
|
|||||||
description: formatAxiosError(e),
|
description: formatAxiosError(e),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
// const submit = form.handleSubmit(async (values) => {
|
|
||||||
|
|
||||||
// });
|
|
||||||
|
|
||||||
// await submit();
|
|
||||||
};
|
|
||||||
|
|
||||||
const testAlert = async () => {
|
|
||||||
const isValid = await form.trigger();
|
|
||||||
if (!isValid) {
|
|
||||||
const values = form.getValues();
|
|
||||||
if (values.actions.length === 0) {
|
|
||||||
toast({
|
|
||||||
variant: "warning",
|
|
||||||
title: t("alertingNoActionsTitle"),
|
|
||||||
description: t("alertingNoActionsTestDescription")
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = form.getValues();
|
|
||||||
try {
|
|
||||||
const payload = formValuesToApiPayload(values);
|
|
||||||
if (isNew) {
|
|
||||||
const res = await api.post<
|
|
||||||
AxiosResponse<CreateAlertRuleResponse>
|
|
||||||
>(`/org/${orgId}/test-alert-rule`, payload);
|
|
||||||
toast({
|
|
||||||
title: t("alertingTestAlertSent"),
|
|
||||||
description: t("alertingTestAlertSentDescription")
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await api.post(
|
|
||||||
`/org/${orgId}/alert-rule/${alertRuleId}`,
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
toast({
|
|
||||||
title: t("alertingTestAlertSent"),
|
|
||||||
description: t("alertingTestAlertSentDescription")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [, formAction, isSaving] = useActionState(saveAlert, null);
|
|
||||||
const [isTestingAlert, startTransition] = useTransition();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form id={FORM_ID} action={formAction}>
|
<form id={FORM_ID} onSubmit={onSubmit}>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
||||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
||||||
@@ -326,7 +263,6 @@ export default function AlertRuleGraphEditor({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center w-full gap-3">
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -335,20 +271,6 @@ export default function AlertRuleGraphEditor({
|
|||||||
>
|
>
|
||||||
{t("save")}
|
{t("save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full gap-1.5"
|
|
||||||
onClick={() =>
|
|
||||||
startTransition(testAlert)
|
|
||||||
}
|
|
||||||
loading={isTestingAlert}
|
|
||||||
>
|
|
||||||
{t("alertingTestRule")}
|
|
||||||
<ZapIcon className="size-3.5 flex-none" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export function getRandomItemInArray<T>(array: T[]) {
|
|
||||||
// Source - https://stackoverflow.com/a/4550514
|
|
||||||
const randomElement = array[Math.floor(Math.random() * array.length)];
|
|
||||||
return randomElement;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user