From 043ddebc6ca2a3dbc195dee59fae8a2858aff1bc Mon Sep 17 00:00:00 2001 From: Nawaz Dhandala Date: Tue, 31 Mar 2026 12:22:17 +0100 Subject: [PATCH] feat: add webhook secret key functionality to workflows and update related components --- .../src/Pages/Monitor/View/Metrics.tsx | 238 +++++++++++++++++- .../src/Pages/Workflow/View/Builder.tsx | 7 + .../src/Pages/Workflow/View/Settings.tsx | 98 +++++++- Common/Models/DatabaseModels/Workflow.ts | 29 +++ .../1774559064920-MigrationName.ts | 22 ++ .../Postgres/SchemaMigrations/Index.ts | 2 + Common/Server/Services/WorkflowService.ts | 19 +- .../Types/Workflow/Components/Webhook.ts | 33 ++- .../Utils/Monitor/MonitorCriteriaEvaluator.ts | 21 +- .../Workflow/ComponentSettingsModal.tsx | 2 + .../Workflow/DocumentationViewer.tsx | 5 + Common/UI/Components/Workflow/Workflow.tsx | 2 + .../Docs/ComponentDocumentation/Webhook.md | 8 +- 13 files changed, 466 insertions(+), 20 deletions(-) create mode 100644 Common/Server/Infrastructure/Postgres/SchemaMigrations/1774559064920-MigrationName.ts diff --git a/App/FeatureSet/Dashboard/src/Pages/Monitor/View/Metrics.tsx b/App/FeatureSet/Dashboard/src/Pages/Monitor/View/Metrics.tsx index e131b72b94..24a3e65885 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Monitor/View/Metrics.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Monitor/View/Metrics.tsx @@ -1,21 +1,247 @@ import DisabledWarning from "../../../Components/Monitor/DisabledWarning"; +import IncidentsTable from "../../../Components/Incident/IncidentsTable"; +import AlertsTable from "../../../Components/Alert/AlertsTable"; +import MonitorMetricsElement from "../../../Components/Monitor/MonitorMetrics"; import PageComponentProps from "../../PageComponentProps"; import ObjectID from "Common/Types/ObjectID"; import Navigation from "Common/UI/Utils/Navigation"; -import React, { Fragment, FunctionComponent, ReactElement } from "react"; -import MonitorMetricsElement from "../../../Components/Monitor/MonitorMetrics"; +import React, { Fragment, FunctionComponent, ReactElement, useState } from "react"; +import Tabs from "Common/UI/Components/Tabs/Tabs"; +import { Tab } from "Common/UI/Components/Tabs/Tab"; +import Incident from "Common/Models/DatabaseModels/Incident"; +import Alert from "Common/Models/DatabaseModels/Alert"; +import Query from "Common/Types/BaseDatabase/Query"; +import ProjectUtil from "Common/UI/Utils/Project"; +import Includes from "Common/Types/BaseDatabase/Includes"; +import MonitorStatusTimeline from "Common/Models/DatabaseModels/MonitorStatusTimeline"; +import MonitorStatus from "Common/Models/DatabaseModels/MonitorStatus"; +import ModelTable from "Common/UI/Components/ModelTable/ModelTable"; +import FieldType from "Common/UI/Components/Types/FieldType"; +import SortOrder from "Common/Types/BaseDatabase/SortOrder"; +import BadDataException from "Common/Types/Exception/BadDataException"; +import Statusbubble from "Common/UI/Components/StatusBubble/StatusBubble"; +import { Black } from "Common/Types/BrandColors"; +import OneUptimeDate from "Common/Types/Date"; +import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType"; -const MonitorDelete: FunctionComponent< +const MonitorMetrics: FunctionComponent< PageComponentProps -> = (): ReactElement => { +> = (props: PageComponentProps): ReactElement => { const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + const [_currentTab, setCurrentTab] = useState(null); + + const incidentQuery: Query = { + projectId: ProjectUtil.getCurrentProjectId()!, + monitors: new Includes([modelId]), + }; + + const alertQuery: Query = { + projectId: ProjectUtil.getCurrentProjectId()!, + monitor: modelId, + }; + + const tabs: Array = [ + { + name: "Monitor Metrics", + children: , + }, + { + name: "Incidents", + children: ( + + ), + }, + { + name: "Alerts", + children: ( + + ), + }, + { + name: "Status Timeline", + children: ( + + modelType={MonitorStatusTimeline} + id="table-monitor-status-timeline" + name="Monitor > Status Timeline" + userPreferencesKey="monitor-status-timeline-table" + isDeleteable={true} + showViewIdButton={true} + isCreateable={true} + isViewable={false} + query={{ + monitorId: modelId, + projectId: ProjectUtil.getCurrentProjectId()!, + }} + sortBy="startsAt" + sortOrder={SortOrder.Descending} + onBeforeCreate={( + item: MonitorStatusTimeline, + ): Promise => { + if (!props.currentProject || !props.currentProject._id) { + throw new BadDataException("Project ID cannot be null"); + } + item.monitorId = modelId; + item.projectId = new ObjectID(props.currentProject._id); + return Promise.resolve(item); + }} + cardProps={{ + title: "Status Timeline", + description: "Here is the status timeline for this monitor", + }} + noItemsMessage={ + "No status timeline created for this monitor so far." + } + formFields={[ + { + field: { + monitorStatus: true, + }, + title: "Monitor Status", + fieldType: FormFieldSchemaType.Dropdown, + required: true, + placeholder: "Monitor Status", + dropdownModal: { + type: MonitorStatus, + labelField: "name", + valueField: "_id", + }, + }, + { + field: { + startsAt: true, + }, + title: "Starts At", + fieldType: FormFieldSchemaType.DateTime, + required: true, + placeholder: "Starts At", + getDefaultValue: () => { + return OneUptimeDate.getCurrentDate(); + }, + }, + ]} + showRefreshButton={true} + viewPageRoute={Navigation.getCurrentRoute()} + filters={[ + { + field: { + monitorStatus: { + name: true, + }, + }, + title: "Monitor Status", + type: FieldType.Entity, + filterEntityType: MonitorStatus, + filterQuery: { + projectId: ProjectUtil.getCurrentProjectId()!, + }, + filterDropdownField: { + label: "name", + value: "_id", + }, + }, + { + field: { + startsAt: true, + }, + title: "Starts At", + type: FieldType.Date, + }, + { + field: { + endsAt: true, + }, + title: "Ends At", + type: FieldType.Date, + }, + ]} + columns={[ + { + field: { + monitorStatus: { + name: true, + color: true, + }, + }, + title: "Monitor Status", + type: FieldType.Text, + getElement: (item: MonitorStatusTimeline): ReactElement => { + if (!item["monitorStatus"]) { + throw new BadDataException("Monitor Status not found"); + } + + return ( + + ); + }, + }, + { + field: { + startsAt: true, + }, + title: "Starts At", + type: FieldType.DateTime, + }, + { + field: { + endsAt: true, + }, + title: "Ends At", + type: FieldType.DateTime, + noValueMessage: "Currently Active", + }, + { + field: { + endsAt: true, + }, + title: "Duration", + type: FieldType.Text, + getElement: (item: MonitorStatusTimeline): ReactElement => { + return ( +

+ {OneUptimeDate.differenceBetweenTwoDatesAsFromattedString( + item["startsAt"] as Date, + (item["endsAt"] as Date) || OneUptimeDate.getCurrentDate(), + )} +

+ ); + }, + }, + ]} + /> + ), + }, + ]; + return ( - + { + setCurrentTab(tab); + }} + /> ); }; -export default MonitorDelete; +export default MonitorMetrics; diff --git a/App/FeatureSet/Dashboard/src/Pages/Workflow/View/Builder.tsx b/App/FeatureSet/Dashboard/src/Pages/Workflow/View/Builder.tsx index 382f00d674..8577ff9f11 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Workflow/View/Builder.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Workflow/View/Builder.tsx @@ -46,6 +46,7 @@ const Delete: FunctionComponent = (): ReactElement => { const [nodes, setNodes] = useState>([]); const [edges, setEdges] = useState>([]); const [error, setError] = useState(""); + const [webhookSecretKey, setWebhookSecretKey] = useState(""); const [showRunSuccessConfirmation, setShowRunSuccessConfirmation] = useState(false); @@ -63,11 +64,16 @@ const Delete: FunctionComponent = (): ReactElement => { id: modelId, select: { graph: true, + webhookSecretKey: true, }, requestOptions: {}, }); if (workflow) { + if (workflow.webhookSecretKey) { + setWebhookSecretKey(workflow.webhookSecretKey); + } + const allComponents: { components: Array; categories: Array; @@ -349,6 +355,7 @@ const Delete: FunctionComponent = (): ReactElement => { ) : ( { setShowComponentPickerModal(value); diff --git a/App/FeatureSet/Dashboard/src/Pages/Workflow/View/Settings.tsx b/App/FeatureSet/Dashboard/src/Pages/Workflow/View/Settings.tsx index bb742a3923..173fa17feb 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Workflow/View/Settings.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Workflow/View/Settings.tsx @@ -5,15 +5,111 @@ import Route from "Common/Types/API/Route"; import ObjectID from "Common/Types/ObjectID"; import DuplicateModel from "Common/UI/Components/DuplicateModel/DuplicateModel"; import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType"; +import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail"; +import FieldType from "Common/UI/Components/Types/FieldType"; import Navigation from "Common/UI/Utils/Navigation"; import Workflow from "Common/Models/DatabaseModels/Workflow"; -import React, { Fragment, FunctionComponent, ReactElement } from "react"; +import React, { Fragment, FunctionComponent, ReactElement, useState } from "react"; +import { ButtonStyleType } from "Common/UI/Components/Button/Button"; +import IconProp from "Common/Types/Icon/IconProp"; +import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import UUID from "Common/Utils/UUID"; const Settings: FunctionComponent = (): ReactElement => { const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + const [showResetConfirmation, setShowResetConfirmation] = + useState(false); + const [refresher, setRefresher] = useState(false); + const [error, setError] = useState(""); + + const resetSecretKey: () => void = (): void => { + setShowResetConfirmation(false); + + ModelAPI.updateById({ + modelType: Workflow, + id: modelId, + data: { + webhookSecretKey: UUID.generate(), + }, + }) + .then(() => { + setRefresher(!refresher); + }) + .catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }; return ( + + name="Workflow > Webhook Secret Key" + cardProps={{ + title: "Webhook Secret Key", + description: + "This secret key is used to trigger this workflow via webhook. Use this key in the webhook URL instead of the workflow ID for security. You can reset this key if it is compromised.", + buttons: [ + { + title: "Reset Secret Key", + buttonStyle: ButtonStyleType.DANGER_OUTLINE, + onClick: () => { + setShowResetConfirmation(true); + }, + icon: IconProp.Refresh, + }, + ], + }} + isEditable={false} + refresher={refresher} + modelDetailProps={{ + showDetailsInNumberOfColumns: 1, + modelType: Workflow, + id: "model-detail-workflow-webhook-secret", + fields: [ + { + field: { + webhookSecretKey: true, + }, + fieldType: FieldType.HiddenText, + title: "Webhook Secret Key", + placeholder: + "No secret key generated yet. Save the workflow to generate one.", + opts: { + isCopyable: true, + }, + }, + ], + modelId: modelId, + }} + /> + + {showResetConfirmation && ( + { + setShowResetConfirmation(false); + }} + onSubmit={resetSecretKey} + /> + )} + + {error && ( + { + setError(""); + }} + /> + )} + { + await queryRunner.query( + `ALTER TABLE "Workflow" ADD "webhookSecretKey" text`, + ); + + // Set secret key to existing workflow ID so current webhook URLs keep working. + await queryRunner.query( + `UPDATE "Workflow" SET "webhookSecretKey" = "_id"::text WHERE "webhookSecretKey" IS NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "Workflow" DROP COLUMN "webhookSecretKey"`, + ); + } +} diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts index 0402dfb3a9..53ea01ed21 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts @@ -274,6 +274,7 @@ import { MigrationName1774524742177 } from "./1774524742177-MigrationName"; import { MigrationName1774524742178 } from "./1774524742178-MigrationName"; import { MigrationName1774524742179 } from "./1774524742179-MigrationName"; import { MigrationName1774559064919 } from "./1774559064919-MigrationName"; +import { MigrationName1774559064920 } from "./1774559064920-MigrationName"; export default [ InitialMigration, @@ -552,4 +553,5 @@ export default [ MigrationName1774524742178, MigrationName1774524742179, MigrationName1774559064919, + MigrationName1774559064920, ]; diff --git a/Common/Server/Services/WorkflowService.ts b/Common/Server/Services/WorkflowService.ts index a555cd54f9..30fc701145 100644 --- a/Common/Server/Services/WorkflowService.ts +++ b/Common/Server/Services/WorkflowService.ts @@ -1,6 +1,7 @@ import { WorkflowHostname } from "../EnvironmentConfig"; import ClusterKeyAuthorization from "../Middleware/ClusterKeyAuthorization"; -import { OnUpdate } from "../Types/Database/Hooks"; +import CreateBy from "../Types/Database/CreateBy"; +import { OnCreate, OnUpdate } from "../Types/Database/Hooks"; import DatabaseService from "./DatabaseService"; import EmptyResponseData from "../../Types/API/EmptyResponse"; import Protocol from "../../Types/API/Protocol"; @@ -17,12 +18,28 @@ import { import API from "../../Utils/API"; import Model from "../../Models/DatabaseModels/Workflow"; import logger from "../Utils/Logger"; +import UUID from "../../Utils/UUID"; export class Service extends DatabaseService { public constructor() { super(Model); } + @CaptureSpan() + protected override async onBeforeCreate( + createBy: CreateBy, + ): Promise> { + // Auto-generate webhook secret key for new workflows. + if (!createBy.data.webhookSecretKey) { + createBy.data.webhookSecretKey = UUID.generate(); + } + + return { + createBy, + carryForward: null, + }; + } + @CaptureSpan() protected override async onUpdateSuccess( onUpdate: OnUpdate, diff --git a/Common/Server/Types/Workflow/Components/Webhook.ts b/Common/Server/Types/Workflow/Components/Webhook.ts index ecd9eb2c39..365cf2f49d 100644 --- a/Common/Server/Types/Workflow/Components/Webhook.ts +++ b/Common/Server/Types/Workflow/Components/Webhook.ts @@ -13,6 +13,8 @@ import ComponentMetadata, { Port } from "../../../../Types/Workflow/Component"; import ComponentID from "../../../../Types/Workflow/ComponentID"; import WebhookComponents from "../../../../Types/Workflow/Components/Webhook"; import CaptureSpan from "../../../Utils/Telemetry/CaptureSpan"; +import WorkflowService from "../../../Services/WorkflowService"; +import Workflow from "../../../../Models/DatabaseModels/Workflow"; export default class WebhookTrigger extends TriggerCode { public constructor() { @@ -54,7 +56,7 @@ export default class WebhookTrigger extends TriggerCode { @CaptureSpan() public override async init(props: InitProps): Promise { props.router.get( - `/trigger/:workflowId`, + `/trigger/:secretkey`, async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { try { await this.initTrigger(req, res, props); @@ -65,7 +67,7 @@ export default class WebhookTrigger extends TriggerCode { ); props.router.post( - `/trigger/:workflowId`, + `/trigger/:secretkey`, async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { try { await this.initTrigger(req, res, props); @@ -82,12 +84,33 @@ export default class WebhookTrigger extends TriggerCode { res: ExpressResponse, props: InitProps, ): Promise { - /// Run Graph. + const secretKey: string = req.params["secretkey"] as string; - // check if this workflow has the trigger enabled. + if (!secretKey) { + throw new BadDataException("Secret key is required to trigger workflow."); + } + + // Look up the workflow by webhook secret key. + const workflow: Workflow | null = await WorkflowService.findOneBy({ + query: { + webhookSecretKey: secretKey, + }, + select: { + _id: true, + }, + props: { + isRoot: true, + }, + }); + + if (!workflow || !workflow._id) { + throw new BadDataException( + "Workflow not found for the provided secret key.", + ); + } const executeWorkflow: ExecuteWorkflowType = { - workflowId: new ObjectID(req.params["workflowId"] as string), + workflowId: new ObjectID(workflow._id), returnValues: { "request-headers": req.headers, "request-params": req.query, diff --git a/Common/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts b/Common/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts index 4b3082d8eb..29603cb95f 100644 --- a/Common/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +++ b/Common/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts @@ -713,12 +713,25 @@ ${contextBlock} if (breakdown.affectedResources && breakdown.affectedResources.length > 0) { const resourceLines: Array = []; - // Sort by metric value descending (worst first) + // Sort by metric value descending (worst first) and filter out zero-value resources const sortedResources: Array = [ ...breakdown.affectedResources, - ].sort((a: KubernetesAffectedResource, b: KubernetesAffectedResource) => { - return b.metricValue - a.metricValue; - }); + ] + .filter((r: KubernetesAffectedResource) => { + return r.metricValue > 0; + }) + .sort( + ( + a: KubernetesAffectedResource, + b: KubernetesAffectedResource, + ) => { + return b.metricValue - a.metricValue; + }, + ); + + if (sortedResources.length === 0) { + continue; + } // Show top 10 affected resources const resourcesToShow: Array = diff --git a/Common/UI/Components/Workflow/ComponentSettingsModal.tsx b/Common/UI/Components/Workflow/ComponentSettingsModal.tsx index ffdc90af87..d38c5accfa 100644 --- a/Common/UI/Components/Workflow/ComponentSettingsModal.tsx +++ b/Common/UI/Components/Workflow/ComponentSettingsModal.tsx @@ -25,6 +25,7 @@ export interface ComponentProps { component: NodeDataProp; graphComponents: Array; workflowId: ObjectID; + webhookSecretKey?: string | undefined; } const ComponentSettingsModal: FunctionComponent = ( @@ -179,6 +180,7 @@ const ComponentSettingsModal: FunctionComponent = ( )} diff --git a/Common/UI/Components/Workflow/DocumentationViewer.tsx b/Common/UI/Components/Workflow/DocumentationViewer.tsx index c70dc149d0..a2feb9e5cf 100644 --- a/Common/UI/Components/Workflow/DocumentationViewer.tsx +++ b/Common/UI/Components/Workflow/DocumentationViewer.tsx @@ -14,6 +14,7 @@ import useAsyncEffect from "use-async-effect"; export interface ComponentProps { documentationLink: Route; workflowId: ObjectID; + webhookSecretKey?: string | undefined; } const DocumentationViewer: FunctionComponent = ( @@ -30,6 +31,10 @@ const DocumentationViewer: FunctionComponent = ( ): string => { text = text.replace("{{serverUrl}}", HOME_URL.toString()); text = text.replace("{{workflowId}}", props.workflowId.toString()); + text = text.replace( + "{{webhookSecretKey}}", + props.webhookSecretKey || "Loading...", + ); return text; }; diff --git a/Common/UI/Components/Workflow/Workflow.tsx b/Common/UI/Components/Workflow/Workflow.tsx index e40187aed8..7ac2e482a3 100644 --- a/Common/UI/Components/Workflow/Workflow.tsx +++ b/Common/UI/Components/Workflow/Workflow.tsx @@ -111,6 +111,7 @@ export interface ComponentProps { workflowId: ObjectID; onRunModalUpdate: (isModalShown: boolean) => void; onRun: (trigger: NodeDataProp) => void; + webhookSecretKey?: string | undefined; } const Workflow: FunctionComponent = (props: ComponentProps) => { @@ -562,6 +563,7 @@ const Workflow: FunctionComponent = (props: ComponentProps) => { return node.data as NodeDataProp; })} workflowId={props.workflowId} + webhookSecretKey={props.webhookSecretKey} component={selectedNodeData} title={ selectedNodeData && selectedNodeData.metadata.title diff --git a/Worker/FeatureSet/Workflow/Docs/ComponentDocumentation/Webhook.md b/Worker/FeatureSet/Workflow/Docs/ComponentDocumentation/Webhook.md index 7e12bf28e7..a3b959b1e0 100644 --- a/Worker/FeatureSet/Workflow/Docs/ComponentDocumentation/Webhook.md +++ b/Worker/FeatureSet/Workflow/Docs/ComponentDocumentation/Webhook.md @@ -1,12 +1,14 @@ -This trigger lets you start the workflow with the incoming HTTP request. +This trigger lets you start the workflow with the incoming HTTP request. -**URL of this trigger:** +**URL of this trigger:** ```text -{{serverUrl}}workflow/trigger/{{workflowId}} +{{serverUrl}}workflow/trigger/{{webhookSecretKey}} ``` +This URL uses a secret key unique to this workflow. You can reset this secret key from the Workflow Settings page if it is compromised. + This can be a GET or POST request. You can send request headers, and body to the Webhook trigger and that can be accessed by any other components downstream. \ No newline at end of file