diff --git a/public/svg/webhook.svg b/public/svg/webhook.svg
new file mode 100644
index 00000000..959bc307
--- /dev/null
+++ b/public/svg/webhook.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/server/src/api/record.ts b/server/src/api/record.ts
index 9eec3edb..e05aa8ce 100644
--- a/server/src/api/record.ts
+++ b/server/src/api/record.ts
@@ -7,7 +7,7 @@ import Robot from "../models/Robot";
import Run from "../models/Run";
const router = Router();
import { getDecryptedProxyConfig } from "../routes/proxy";
-import { uuid } from "uuidv4";
+import { v4 as uuid } from "uuid";
import { createRemoteBrowserForRun, destroyRemoteBrowser } from "../browser-management/controller";
import logger from "../logger";
import { browserPool } from "../server";
@@ -19,6 +19,7 @@ import { Page } from "playwright";
import { WorkflowFile } from "maxun-core";
import { googleSheetUpdateTasks, processGoogleSheetUpdates } from "../workflow-management/integrations/gsheet";
import { airtableUpdateTasks, processAirtableUpdates } from "../workflow-management/integrations/airtable";
+import { sendWebhook } from "../routes/webhook";
chromium.use(stealthPlugin());
const formatRecording = (recordingData: any) => {
@@ -667,6 +668,35 @@ async function executeRun(id: string, userId: string) {
}
)
+ // Trigger webhooks for run completion
+ const webhookPayload = {
+ robot_id: plainRun.robotMetaId,
+ run_id: plainRun.runId,
+ robot_name: recording.recording_meta.name,
+ status: 'success',
+ started_at: plainRun.startedAt,
+ finished_at: new Date().toLocaleString(),
+ extracted_data: {
+ captured_texts: Object.values(categorizedOutput.scrapeSchema).flat() || [],
+ captured_lists: categorizedOutput.scrapeList,
+ total_rows: totalRowsExtracted,
+ captured_texts_count: totalSchemaItemsExtracted,
+ captured_lists_count: totalListItemsExtracted,
+ screenshots_count: extractedScreenshotsCount
+ },
+ metadata: {
+ browser_id: plainRun.browserId,
+ user_id: userId,
+ }
+ };
+
+ try {
+ await sendWebhook(plainRun.robotMetaId, 'run_completed', webhookPayload);
+ logger.log('info', `Webhooks sent successfully for completed run ${plainRun.runId}`);
+ } catch (webhookError: any) {
+ logger.log('error', `Failed to send webhooks for run ${plainRun.runId}: ${webhookError.message}`);
+ }
+
try {
googleSheetUpdateTasks[id] = {
robotId: plainRun.robotMetaId,
@@ -701,6 +731,34 @@ async function executeRun(id: string, userId: string) {
status: 'failed',
finishedAt: new Date().toLocaleString(),
});
+
+ const recording = await Robot.findOne({ where: { 'recording_meta.id': run.robotMetaId }, raw: true });
+
+ // Trigger webhooks for run failure
+ const failedWebhookPayload = {
+ robot_id: run.robotMetaId,
+ run_id: run.runId,
+ robot_name: recording ? recording.recording_meta.name : 'Unknown Robot',
+ status: 'failed',
+ started_at: run.startedAt,
+ finished_at: new Date().toLocaleString(),
+ error: {
+ message: error.message,
+ stack: error.stack,
+ type: error.name || 'ExecutionError'
+ },
+ metadata: {
+ browser_id: run.browserId,
+ user_id: userId,
+ }
+ };
+
+ try {
+ await sendWebhook(run.robotMetaId, 'run_failed', failedWebhookPayload);
+ logger.log('info', `Failure webhooks sent successfully for run ${run.runId}`);
+ } catch (webhookError: any) {
+ logger.log('error', `Failed to send failure webhooks for run ${run.runId}: ${webhookError.message}`);
+ }
}
capture(
'maxun-oss-run-created-api',
diff --git a/server/src/browser-management/controller.ts b/server/src/browser-management/controller.ts
index 7c5271bf..3da388a0 100644
--- a/server/src/browser-management/controller.ts
+++ b/server/src/browser-management/controller.ts
@@ -3,7 +3,7 @@
* Holds the singleton instances of browser pool and socket.io server.
*/
import { Socket } from "socket.io";
-import { uuid } from 'uuidv4';
+import { v4 as uuid } from "uuid";
import { createSocketConnection, createSocketConnectionForRun } from "../socket-connection/connection";
import { io, browserPool } from "../server";
diff --git a/server/src/db/migrations/20250527105655-add-webhooks.js b/server/src/db/migrations/20250527105655-add-webhooks.js
new file mode 100644
index 00000000..60eefd19
--- /dev/null
+++ b/server/src/db/migrations/20250527105655-add-webhooks.js
@@ -0,0 +1,27 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.addColumn('robot', 'webhooks', {
+ type: Sequelize.JSONB,
+ allowNull: true,
+ defaultValue: null,
+ comment: 'Webhook configurations for the robot'
+ });
+
+ // Optional: Add an index for better query performance if you plan to search within webhook data
+ await queryInterface.addIndex('robot', {
+ fields: ['webhooks'],
+ using: 'gin', // GIN index for JSONB columns
+ name: 'robot_webhooks_gin_idx'
+ });
+ },
+
+ async down(queryInterface, Sequelize) {
+ // Remove the index first
+ await queryInterface.removeIndex('robot', 'robot_webhooks_gin_idx');
+
+ // Then remove the column
+ await queryInterface.removeColumn('robot', 'webhooks');
+ }
+};
\ No newline at end of file
diff --git a/server/src/models/Robot.ts b/server/src/models/Robot.ts
index 1681eaac..eae9438e 100644
--- a/server/src/models/Robot.ts
+++ b/server/src/models/Robot.ts
@@ -15,6 +15,19 @@ interface RobotWorkflow {
workflow: WhereWhatPair[];
}
+interface WebhookConfig {
+ id: string;
+ url: string;
+ events: string[];
+ active: boolean;
+ createdAt: string;
+ updatedAt: string;
+ lastCalledAt?: string | null;
+ retryAttempts?: number;
+ retryDelay?: number;
+ timeout?: number;
+}
+
interface RobotAttributes {
id: string;
userId?: number;
@@ -32,6 +45,7 @@ interface RobotAttributes {
airtable_refresh_token?: string | null;
schedule?: ScheduleConfig | null;
airtable_table_id?: string | null;
+ webhooks?: WebhookConfig[] | null;
}
interface ScheduleConfig {
@@ -66,6 +80,7 @@ class Robot extends Model implements R
public airtable_refresh_token!: string | null;
public airtable_table_id!: string | null;
public schedule!: ScheduleConfig | null;
+ public webhooks!: WebhookConfig[] | null;
}
Robot.init(
@@ -135,6 +150,11 @@ Robot.init(
type: DataTypes.JSONB,
allowNull: true,
},
+ webhooks: {
+ type: DataTypes.JSONB,
+ allowNull: true,
+ defaultValue: null,
+ },
},
{
sequelize,
diff --git a/server/src/pgboss-worker.ts b/server/src/pgboss-worker.ts
index 54a70697..1a32f79b 100644
--- a/server/src/pgboss-worker.ts
+++ b/server/src/pgboss-worker.ts
@@ -20,6 +20,7 @@ import { googleSheetUpdateTasks, processGoogleSheetUpdates } from './workflow-ma
import { airtableUpdateTasks, processAirtableUpdates } from './workflow-management/integrations/airtable';
import { RemoteBrowser } from './browser-management/classes/RemoteBrowser';
import { io as serverIo } from "./server";
+import { sendWebhook } from './routes/webhook';
if (!process.env.DB_USER || !process.env.DB_PASSWORD || !process.env.DB_HOST || !process.env.DB_PORT || !process.env.DB_NAME) {
throw new Error('Failed to start pgboss worker: one or more required environment variables are missing.');
@@ -203,6 +204,31 @@ async function processRunExecution(job: Job) {
finishedAt: new Date().toLocaleString(),
log: 'Failed: Recording not found',
});
+
+ // Trigger webhooks for run failure
+ const failedWebhookPayload = {
+ robot_id: plainRun.robotMetaId,
+ run_id: data.runId,
+ robot_name: 'Unknown Robot',
+ status: 'failed',
+ started_at: plainRun.startedAt,
+ finished_at: new Date().toLocaleString(),
+ error: {
+ message: "Failed: Recording not found",
+ type: 'RecordingNotFoundError'
+ },
+ metadata: {
+ browser_id: plainRun.browserId,
+ user_id: data.userId,
+ }
+ };
+
+ try {
+ await sendWebhook(plainRun.robotMetaId, 'run_failed', failedWebhookPayload);
+ logger.log('info', `Failure webhooks sent successfully for run ${data.runId}`);
+ } catch (webhookError: any) {
+ logger.log('error', `Failed to send failure webhooks for run ${data.runId}: ${webhookError.message}`);
+ }
}
// Check for queued runs even if this one failed
@@ -314,7 +340,7 @@ async function processRunExecution(job: Job) {
console.log(`Extracted List Items Count: ${totalListItemsExtracted}`);
console.log(`Extracted Screenshots Count: ${extractedScreenshotsCount}`);
console.log(`Total Rows Extracted: ${totalRowsExtracted}`);
-
+
// Capture metrics
capture(
'maxun-oss-run-created-manual',
@@ -330,6 +356,35 @@ async function processRunExecution(job: Job) {
}
);
+ // Trigger webhooks for run completion
+ const webhookPayload = {
+ robot_id: plainRun.robotMetaId,
+ run_id: data.runId,
+ robot_name: recording.recording_meta.name,
+ status: 'success',
+ started_at: plainRun.startedAt,
+ finished_at: new Date().toLocaleString(),
+ extracted_data: {
+ captured_texts: Object.values(categorizedOutput.scrapeSchema).flat() || [],
+ captured_lists: categorizedOutput.scrapeList,
+ total_rows: totalRowsExtracted,
+ captured_texts_count: totalSchemaItemsExtracted,
+ captured_lists_count: totalListItemsExtracted,
+ screenshots_count: extractedScreenshotsCount
+ },
+ metadata: {
+ browser_id: plainRun.browserId,
+ user_id: data.userId,
+ }
+ };
+
+ try {
+ await sendWebhook(plainRun.robotMetaId, 'run_completed', webhookPayload);
+ logger.log('info', `Webhooks sent successfully for completed run ${data.runId}`);
+ } catch (webhookError: any) {
+ logger.log('error', `Failed to send webhooks for run ${data.runId}: ${webhookError.message}`);
+ }
+
// Schedule updates for Google Sheets and Airtable
try {
googleSheetUpdateTasks[plainRun.runId] = {
@@ -392,6 +447,32 @@ async function processRunExecution(job: Job) {
error_message: executionError.message,
}
);
+
+ // Trigger webhooks for run failure
+ const failedWebhookPayload = {
+ robot_id: plainRun.robotMetaId,
+ run_id: data.runId,
+ robot_name: recording.recording_meta.name,
+ status: 'failed',
+ started_at: plainRun.startedAt,
+ finished_at: new Date().toLocaleString(),
+ error: {
+ message: executionError.message,
+ stack: executionError.stack,
+ type: executionError.name || 'ExecutionError'
+ },
+ metadata: {
+ browser_id: plainRun.browserId,
+ user_id: data.userId,
+ }
+ };
+
+ try {
+ await sendWebhook(plainRun.robotMetaId, 'run_failed', failedWebhookPayload);
+ logger.log('info', `Failure webhooks sent successfully for run ${data.runId}`);
+ } catch (webhookError: any) {
+ logger.log('error', `Failed to send failure webhooks for run ${data.runId}: ${webhookError.message}`);
+ }
} else {
logger.log('info', `Run ${data.runId} was aborted, not updating status to failed`);
}
diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts
index bc616273..3d8a3644 100644
--- a/server/src/routes/index.ts
+++ b/server/src/routes/index.ts
@@ -4,6 +4,7 @@ import { router as storage } from './storage';
import { router as auth } from './auth';
import { router as integration } from './integration';
import { router as proxy } from './proxy';
+import { router as webhook } from './webhook';
export {
record,
@@ -11,5 +12,6 @@ export {
storage,
auth,
integration,
- proxy
+ proxy,
+ webhook
};
diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts
index 0942600c..b4e8cdfd 100644
--- a/server/src/routes/storage.ts
+++ b/server/src/routes/storage.ts
@@ -4,7 +4,7 @@ import { createRemoteBrowserForRun, getActiveBrowserIdByState } from "../browser
import { chromium } from 'playwright-extra';
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
import { browserPool } from "../server";
-import { uuid } from "uuidv4";
+import { v4 as uuid } from "uuid";
import moment from 'moment-timezone';
import cron from 'node-cron';
import { getDecryptedProxyConfig } from './proxy';
diff --git a/server/src/routes/webhook.ts b/server/src/routes/webhook.ts
new file mode 100644
index 00000000..bba8ec8f
--- /dev/null
+++ b/server/src/routes/webhook.ts
@@ -0,0 +1,493 @@
+import { Router, Request, Response } from 'express';
+import Robot from '../models/Robot';
+import { requireSignIn } from '../middlewares/auth';
+import axios from 'axios';
+import { v4 as uuid } from "uuid";
+
+export const router = Router();
+
+interface AuthenticatedRequest extends Request {
+ user?: { id: string };
+}
+
+interface WebhookConfig {
+ id: string;
+ url: string;
+ events: string[];
+ active: boolean;
+ createdAt: string;
+ updatedAt: string;
+ lastCalledAt?: string | null;
+ retryAttempts?: number;
+ retryDelay?: number;
+ timeout?: number;
+}
+
+const updateWebhookLastCalled = async (robotId: string, webhookId: string): Promise => {
+ try {
+ const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } });
+ if (!robot || !robot.webhooks) {
+ return;
+ }
+
+ const updatedWebhooks = robot.webhooks.map((w: WebhookConfig) => {
+ if (w.id === webhookId) {
+ return {
+ ...w,
+ lastCalledAt: new Date().toISOString()
+ };
+ }
+ return w;
+ });
+
+ await robot.update({ webhooks: updatedWebhooks });
+ } catch (error) {
+ console.error('Error updating webhook lastCalledAt:', error);
+ }
+};
+
+// Add new webhook
+router.post('/add', requireSignIn, async (req: Request, res: Response) => {
+ const { webhook, robotId } = req.body;
+ const authenticatedReq = req as AuthenticatedRequest;
+
+ try {
+ if (!authenticatedReq.user) {
+ return res.status(401).json({ ok: false, error: 'Unauthorized' });
+ }
+
+ if (!webhook || !robotId) {
+ return res.status(400).json({ ok: false, error: 'Webhook configuration and robot ID are required' });
+ }
+
+ if (!webhook.url) {
+ return res.status(400).json({ ok: false, error: 'Webhook URL is required' });
+ }
+
+ // Validate URL format
+ try {
+ new URL(webhook.url);
+ } catch (error) {
+ return res.status(400).json({ ok: false, error: 'Invalid webhook URL format' });
+ }
+
+ const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } });
+
+ if (!robot) {
+ return res.status(404).json({ ok: false, error: 'Robot not found' });
+ }
+
+ const currentWebhooks = robot.webhooks || [];
+
+ const existingWebhook = currentWebhooks.find((w: WebhookConfig) => w.url === webhook.url);
+ if (existingWebhook) {
+ return res.status(400).json({ ok: false, error: 'Webhook with this url already exists' });
+ }
+
+ const newWebhook: WebhookConfig = {
+ ...webhook,
+ id: webhook.id || uuid(),
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ lastCalledAt: null,
+ retryAttempts: webhook.retryAttempts || 3,
+ retryDelay: webhook.retryDelay || 5,
+ timeout: webhook.timeout || 30,
+ };
+
+ const updatedWebhooks = [...currentWebhooks, newWebhook];
+
+ await robot.update({ webhooks: updatedWebhooks });
+
+ res.status(200).json({
+ ok: true,
+ message: 'Webhook added successfully',
+ webhook: newWebhook
+ });
+ } catch (error: any) {
+ console.log(`Could not add webhook - ${error}`);
+ res.status(500).json({ ok: false, error: 'Could not add webhook configuration' });
+ }
+});
+
+// Update existing webhook
+router.post('/update', requireSignIn, async (req: Request, res: Response) => {
+ const { webhook, robotId } = req.body;
+ const authenticatedReq = req as AuthenticatedRequest;
+
+ try {
+ if (!authenticatedReq.user) {
+ return res.status(401).json({ ok: false, error: 'Unauthorized' });
+ }
+
+ if (!webhook || !robotId || !webhook.id) {
+ return res.status(400).json({ ok: false, error: 'Webhook configuration, webhook ID, and robot ID are required' });
+ }
+
+ // Validate URL format if provided
+ if (webhook.url) {
+ try {
+ new URL(webhook.url);
+ } catch (error) {
+ return res.status(400).json({ ok: false, error: 'Invalid webhook URL format' });
+ }
+ }
+
+ const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } });
+
+ if (!robot) {
+ return res.status(404).json({ ok: false, error: 'Robot not found' });
+ }
+
+ const currentWebhooks = robot.webhooks || [];
+ const webhookIndex = currentWebhooks.findIndex((w: WebhookConfig) => w.id === webhook.id);
+
+ if (webhookIndex === -1) {
+ return res.status(404).json({ ok: false, error: 'Webhook not found' });
+ }
+
+ // Check for duplicate URLs (excluding current webhook)
+ const duplicateUrl = currentWebhooks.find((w: WebhookConfig, index: number) =>
+ w.url === webhook.url && index !== webhookIndex
+ );
+ if (duplicateUrl) {
+ return res.status(400).json({ ok: false, error: 'Webhook with this URL already exists' });
+ }
+
+ const updatedWebhook: WebhookConfig = {
+ ...currentWebhooks[webhookIndex],
+ ...webhook,
+ updatedAt: new Date().toISOString(),
+ lastCalledAt: currentWebhooks[webhookIndex].lastCalledAt
+ };
+
+ const updatedWebhooks = [...currentWebhooks];
+ updatedWebhooks[webhookIndex] = updatedWebhook;
+
+ await robot.update({ webhooks: updatedWebhooks });
+
+ res.status(200).json({
+ ok: true,
+ message: 'Webhook updated successfully',
+ webhook: updatedWebhook
+ });
+ } catch (error: any) {
+ console.log(`Could not update webhook - ${error}`);
+ res.status(500).json({ ok: false, error: 'Could not update webhook configuration' });
+ }
+});
+
+// Remove webhook
+router.post('/remove', requireSignIn, async (req: Request, res: Response) => {
+ const { webhookId, robotId } = req.body;
+ const authenticatedReq = req as AuthenticatedRequest;
+
+ try {
+ if (!authenticatedReq.user) {
+ return res.status(401).json({ ok: false, error: 'Unauthorized' });
+ }
+
+ if (!webhookId || !robotId) {
+ return res.status(400).json({ ok: false, error: 'Webhook ID and robot ID are required' });
+ }
+
+ const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } });
+
+ if (!robot) {
+ return res.status(404).json({ ok: false, error: 'Robot not found' });
+ }
+
+ const currentWebhooks = robot.webhooks || [];
+ const webhookExists = currentWebhooks.find((w: WebhookConfig) => w.id === webhookId);
+
+ if (!webhookExists) {
+ return res.status(404).json({ ok: false, error: 'Webhook not found' });
+ }
+
+ const updatedWebhooks = currentWebhooks.filter((w: WebhookConfig) => w.id !== webhookId);
+
+ await robot.update({ webhooks: updatedWebhooks });
+
+ res.status(200).json({
+ ok: true,
+ message: 'Webhook removed successfully'
+ });
+ } catch (error: any) {
+ console.log(`Could not remove webhook - ${error}`);
+ res.status(500).json({ ok: false, error: 'Could not remove webhook configuration' });
+ }
+});
+
+// Get all webhooks for a robot
+router.get('/list/:robotId', requireSignIn, async (req: Request, res: Response) => {
+ const { robotId } = req.params;
+ const authenticatedReq = req as AuthenticatedRequest;
+
+ try {
+ if (!authenticatedReq.user) {
+ return res.status(401).json({ ok: false, error: 'Unauthorized' });
+ }
+
+ const robot = await Robot.findOne({
+ where: { 'recording_meta.id': robotId },
+ attributes: ['webhooks']
+ });
+
+ if (!robot) {
+ return res.status(404).json({ ok: false, error: 'Robot not found' });
+ }
+
+ const webhooks = robot.webhooks || [];
+
+ res.status(200).json({
+ ok: true,
+ webhooks: webhooks
+ });
+ } catch (error: any) {
+ console.log(`Could not retrieve webhooks - ${error}`);
+ res.status(500).json({ ok: false, error: 'Could not retrieve webhook configurations' });
+ }
+});
+
+// Test webhook endpoint
+router.post('/test', requireSignIn, async (req: Request, res: Response) => {
+ const { webhook, robotId } = req.body;
+ const authenticatedReq = req as AuthenticatedRequest;
+
+ try {
+ if (!authenticatedReq.user) {
+ return res.status(401).json({ ok: false, error: 'Unauthorized' });
+ }
+
+ if (!webhook || !robotId) {
+ return res.status(400).json({ ok: false, error: 'Webhook configuration and robot ID are required' });
+ }
+
+ const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } });
+
+ if (!robot) {
+ return res.status(404).json({ ok: false, error: 'Robot not found' });
+ }
+
+ // Create test payload
+ const testPayload = {
+ event_type: "webhook_test",
+ timestamp: new Date().toISOString(),
+ webhook_id: webhook.id,
+ data: {
+ robot_id: robotId,
+ run_id: "110c4dae-c39b-4b30-a932-eff1022e4bb0",
+ robot_name: robot.recording_meta?.name || "E-commerce Product Scraper",
+ status: "test",
+ started_at: new Date(Date.now() - 45000).toISOString(),
+ finished_at: new Date().toISOString(),
+ extracted_data: {
+ captured_texts: [
+ {
+ "Product Name": "MacBook Pro 16-inch M3 Max",
+ "Price": "$3,999.00",
+ "Rating": "4.8/5 stars",
+ "Availability": "In Stock - Ships within 2-3 business days",
+ "SKU": "MBPM3-16-1TB-SLV",
+ "Description": "The most powerful MacBook Pro ever is here. With the blazing-fast M3 Max chip, pro-level performance has never been more portable."
+ }
+ ],
+ captured_lists: {
+ "list_1": [
+ {
+ "Rank": "1",
+ "Product": "MacBook Air M2",
+ "Category": "Laptops",
+ "Units Sold": "2,847",
+ "Revenue": "$2,847,000"
+ },
+ {
+ "Rank": "2",
+ "Product": "iPhone 15",
+ "Category": "Smartphones",
+ "Units Sold": "1,923",
+ "Revenue": "$1,923,000"
+ },
+ {
+ "Rank": "3",
+ "Product": "iPad Pro 12.9",
+ "Category": "Tablets",
+ "Units Sold": "1,456",
+ "Revenue": "$1,456,000"
+ }
+ ],
+ "list_0": [
+ {
+ "Customer": "Sarah M.",
+ "Rating": "5 stars",
+ "Review": "Absolutely love my new MacBook! The battery life is incredible and the performance is outstanding.",
+ "Date": "2024-12-15",
+ "Verified Purchase": "Yes"
+ },
+ {
+ "Customer": "John D.",
+ "Rating": "4 stars",
+ "Review": "Great phone overall, but wish the battery lasted a bit longer with heavy usage.",
+ "Date": "2024-12-14",
+ "Verified Purchase": "Yes"
+ },
+ {
+ "Customer": "Emily R.",
+ "Rating": "5 stars",
+ "Review": "The camera quality is phenomenal! Perfect for my photography business.",
+ "Date": "2024-12-13",
+ "Verified Purchase": "Yes"
+ }
+ ],
+ },
+ total_rows: 11,
+ captured_texts_count: 5,
+ captured_lists_count: 6,
+ screenshots_count: 5
+ },
+ metadata: {
+ test_mode: true,
+ browser_id: "d27ace57-75cb-441c-8589-8ba34e52f7d1",
+ user_id: 108,
+ }
+ }
+ };
+
+ await updateWebhookLastCalled(robotId, webhook.id);
+
+ const response = await axios.post(webhook.url, testPayload, {
+ timeout: (webhook.timeout || 30) * 1000,
+ validateStatus: (status) => status < 500
+ });
+
+ const success = response.status >= 200 && response.status < 300;
+
+ res.status(200).json({
+ ok: true,
+ message: success ? 'Test webhook sent successfully' : 'Webhook endpoint responded with non-success status',
+ details: {
+ status: response.status,
+ statusText: response.statusText,
+ success: success
+ }
+ });
+ } catch (error: any) {
+ console.log(`Could not test webhook - ${error}`);
+
+ try {
+ await updateWebhookLastCalled(robotId, webhook.id);
+ } catch (updateError) {
+ console.error('Failed to update lastCalledAt after webhook error:', updateError);
+ }
+
+ let errorMessage = 'Could not send test webhook';
+ if (error.code === 'ECONNREFUSED') {
+ errorMessage = 'Connection refused - webhook URL is not accessible';
+ } else if (error.code === 'ETIMEDOUT') {
+ errorMessage = 'Request timeout - webhook endpoint did not respond in time';
+ } else if (error.response) {
+ errorMessage = `Webhook endpoint responded with error: ${error.response.status} ${error.response.statusText}`;
+ }
+
+ res.status(500).json({
+ ok: false,
+ error: errorMessage,
+ details: {
+ code: error.code,
+ message: error.message
+ }
+ });
+ }
+});
+
+// Send webhook
+export const sendWebhook = async (robotId: string, eventType: string, data: any): Promise => {
+ try {
+ const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } });
+ if (!robot || !robot.webhooks) {
+ return;
+ }
+
+ const activeWebhooks = robot.webhooks.filter((w: WebhookConfig) =>
+ w.active && w.events.includes(eventType)
+ );
+
+ if (activeWebhooks.length === 0) {
+ return;
+ }
+
+ const webhookPromises = activeWebhooks.map(async (webhook: WebhookConfig) => {
+ const payload = {
+ event_type: eventType,
+ timestamp: new Date().toISOString(),
+ webhook_id: webhook.id,
+ data: data
+ };
+
+ return sendWebhookWithRetry(robotId, webhook, payload);
+ });
+
+ await Promise.allSettled(webhookPromises);
+ } catch (error) {
+ console.error('Error sending webhooks:', error);
+ }
+};
+
+// Helper function to send webhook with retry logic
+const sendWebhookWithRetry = async (robotId: string, webhook: WebhookConfig, payload: any, attempt: number = 1): Promise => {
+ const maxRetries = webhook.retryAttempts || 3;
+ const retryDelay = webhook.retryDelay || 5;
+ const timeout = webhook.timeout || 30;
+
+ try {
+ await updateWebhookLastCalled(robotId, webhook.id);
+
+ const response = await axios.post(webhook.url, payload, {
+ timeout: timeout * 1000,
+ validateStatus: (status) => status >= 200 && status < 300
+ });
+
+ console.log(`Webhook sent successfully to ${webhook.url}: ${response.status}`);
+ } catch (error: any) {
+ console.error(`Webhook failed for ${webhook.url} (attempt ${attempt}):`, error.message);
+
+ if (attempt < maxRetries) {
+ const delay = retryDelay * Math.pow(2, attempt - 1);
+ console.log(`Retrying webhook ${webhook.url} in ${delay} seconds...`);
+
+ setTimeout(async () => {
+ await sendWebhookWithRetry(robotId, webhook, payload, attempt + 1);
+ }, delay * 1000);
+ } else {
+ console.error(`Webhook ${webhook.url} failed after ${maxRetries} attempts`);
+ }
+ }
+};
+
+// Clear all webhooks for a robot
+router.delete('/clear/:robotId', requireSignIn, async (req: Request, res: Response) => {
+ const { robotId } = req.params;
+ const authenticatedReq = req as AuthenticatedRequest;
+
+ try {
+ if (!authenticatedReq.user) {
+ return res.status(401).json({ ok: false, error: 'Unauthorized' });
+ }
+
+ const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } });
+
+ if (!robot) {
+ return res.status(404).json({ ok: false, error: 'Robot not found' });
+ }
+
+ await robot.update({ webhooks: [] });
+
+ res.status(200).json({
+ ok: true,
+ message: 'All webhooks cleared successfully'
+ });
+ } catch (error: any) {
+ console.log(`Could not clear webhooks - ${error}`);
+ res.status(500).json({ ok: false, error: 'Could not clear webhook configurations' });
+ }
+});
\ No newline at end of file
diff --git a/server/src/server.ts b/server/src/server.ts
index 7f2d04d3..bd4a1697 100644
--- a/server/src/server.ts
+++ b/server/src/server.ts
@@ -4,7 +4,7 @@ import http from 'http';
import cors from 'cors';
import dotenv from 'dotenv';
dotenv.config();
-import { record, workflow, storage, auth, integration, proxy } from './routes';
+import { record, workflow, storage, auth, integration, proxy, webhook } from './routes';
import { BrowserPool } from "./browser-management/classes/BrowserPool";
import logger from './logger';
import { connectDB, syncDB } from './storage/db'
@@ -88,6 +88,7 @@ export const browserPool = new BrowserPool();
// parse cookies - "cookie" is true in csrfProtection
app.use(cookieParser())
+app.use('/webhook', webhook);
app.use('/record', record);
app.use('/workflow', workflow);
app.use('/storage', storage);
diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts
index 1be328aa..27123e22 100644
--- a/server/src/workflow-management/classes/Generator.ts
+++ b/server/src/workflow-management/classes/Generator.ts
@@ -15,7 +15,7 @@ import {
import { CustomActions } from "../../../../src/shared/types";
import Robot from "../../models/Robot";
import { getBestSelectorForAction } from "../utils";
-import { uuid } from "uuidv4";
+import { v4 as uuid } from "uuid";
import { capture } from "../../utils/analytics"
import { decrypt, encrypt } from "../../utils/auth";
diff --git a/server/src/workflow-management/classes/Interpreter.ts b/server/src/workflow-management/classes/Interpreter.ts
index 14877600..ca853489 100644
--- a/server/src/workflow-management/classes/Interpreter.ts
+++ b/server/src/workflow-management/classes/Interpreter.ts
@@ -372,25 +372,19 @@ export class WorkflowInterpreter {
log: this.debugMessages,
result: status,
scrapeSchemaOutput: Object.keys(mergedScrapeSchema).length > 0
- ? { "schema-merged": [mergedScrapeSchema] }
+ ? { "schema_merged": [mergedScrapeSchema] }
: this.serializableDataByType.scrapeSchema.reduce((reducedObject, item, index) => {
- return {
- [`schema-${index}`]: item,
- ...reducedObject,
- }
- }, {}),
+ reducedObject[`schema_${index}`] = item;
+ return reducedObject;
+ }, {} as Record),
scrapeListOutput: this.serializableDataByType.scrapeList.reduce((reducedObject, item, index) => {
- return {
- [`list-${index}`]: item,
- ...reducedObject,
- }
- }, {}),
+ reducedObject[`list_${index}`] = item;
+ return reducedObject;
+ }, {} as Record),
binaryOutput: this.binaryData.reduce((reducedObject, item, index) => {
- return {
- [`item-${index}`]: item,
- ...reducedObject,
- }
- }, {})
+ reducedObject[`item_${index}`] = item;
+ return reducedObject;
+ }, {} as Record)
}
logger.log('debug', `Interpretation finished`);
diff --git a/server/src/workflow-management/scheduler/index.ts b/server/src/workflow-management/scheduler/index.ts
index 1559e63c..b40e55f2 100644
--- a/server/src/workflow-management/scheduler/index.ts
+++ b/server/src/workflow-management/scheduler/index.ts
@@ -1,4 +1,4 @@
-import { uuid } from "uuidv4";
+import { v4 as uuid } from "uuid";
import { chromium } from 'playwright-extra';
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
import { io, Socket } from "socket.io-client";
@@ -13,6 +13,8 @@ import { BinaryOutputService } from "../../storage/mino";
import { capture } from "../../utils/analytics";
import { WorkflowFile } from "maxun-core";
import { Page } from "playwright";
+import { sendWebhook } from "../../routes/webhook";
+import { airtableUpdateTasks, processAirtableUpdates } from "../integrations/airtable";
chromium.use(stealthPlugin());
async function createWorkflowAndStoreMetadata(id: string, userId: string) {
@@ -152,26 +154,34 @@ async function executeRun(id: string, userId: string) {
binaryOutput: uploadedBinaryOutput,
});
- let totalRowsExtracted = 0;
+ // Track extraction metrics
+ let totalSchemaItemsExtracted = 0;
+ let totalListItemsExtracted = 0;
let extractedScreenshotsCount = 0;
- let extractedItemsCount = 0;
-
- if (run.dataValues.binaryOutput && run.dataValues.binaryOutput["item-0"]) {
- extractedScreenshotsCount = 1;
+
+ if (categorizedOutput.scrapeSchema) {
+ Object.values(categorizedOutput.scrapeSchema).forEach((schemaResult: any) => {
+ if (Array.isArray(schemaResult)) {
+ totalSchemaItemsExtracted += schemaResult.length;
+ } else if (schemaResult && typeof schemaResult === 'object') {
+ totalSchemaItemsExtracted += 1;
+ }
+ });
}
-
- if (run.dataValues.serializableOutput && run.dataValues.serializableOutput["item-0"]) {
- const itemsArray = run.dataValues.serializableOutput["item-0"];
- extractedItemsCount = itemsArray.length;
-
- totalRowsExtracted = itemsArray.reduce((total, item) => {
- return total + Object.keys(item).length;
- }, 0);
+
+ if (categorizedOutput.scrapeList) {
+ Object.values(categorizedOutput.scrapeList).forEach((listResult: any) => {
+ if (Array.isArray(listResult)) {
+ totalListItemsExtracted += listResult.length;
+ }
+ });
}
-
- console.log(`Extracted Items Count: ${extractedItemsCount}`);
- console.log(`Extracted Screenshots Count: ${extractedScreenshotsCount}`);
- console.log(`Total Rows Extracted: ${totalRowsExtracted}`);
+
+ if (uploadedBinaryOutput) {
+ extractedScreenshotsCount = Object.keys(uploadedBinaryOutput).length;
+ }
+
+ const totalRowsExtracted = totalSchemaItemsExtracted + totalListItemsExtracted;
capture(
'maxun-oss-run-created-scheduled',
@@ -180,18 +190,60 @@ async function executeRun(id: string, userId: string) {
created_at: new Date().toISOString(),
status: 'success',
totalRowsExtracted,
- extractedItemsCount,
+ schemaItemsExtracted: totalSchemaItemsExtracted,
+ listItemsExtracted: totalListItemsExtracted,
extractedScreenshotsCount,
}
);
- googleSheetUpdateTasks[id] = {
- robotId: plainRun.robotMetaId,
- runId: id,
- status: 'pending',
- retries: 5,
+ const webhookPayload = {
+ robot_id: plainRun.robotMetaId,
+ run_id: plainRun.runId,
+ robot_name: recording.recording_meta.name,
+ status: 'success',
+ started_at: plainRun.startedAt,
+ finished_at: new Date().toLocaleString(),
+ extracted_data: {
+ captured_texts: Object.values(categorizedOutput.scrapeSchema).flat() || [],
+ captured_lists: categorizedOutput.scrapeList,
+ total_rows: totalRowsExtracted,
+ captured_texts_count: totalSchemaItemsExtracted,
+ captured_lists_count: totalListItemsExtracted,
+ screenshots_count: extractedScreenshotsCount
+ },
+ metadata: {
+ browser_id: plainRun.browserId,
+ user_id: userId
+ }
};
- processGoogleSheetUpdates();
+
+ try {
+ await sendWebhook(plainRun.robotMetaId, 'run_completed', webhookPayload);
+ logger.log('info', `Webhooks sent successfully for completed run ${plainRun.runId}`);
+ } catch (webhookError: any) {
+ logger.log('error', `Failed to send webhooks for run ${plainRun.runId}: ${webhookError.message}`);
+ }
+
+ try {
+ googleSheetUpdateTasks[plainRun.runId] = {
+ robotId: plainRun.robotMetaId,
+ runId: plainRun.runId,
+ status: 'pending',
+ retries: 5,
+ };
+
+ airtableUpdateTasks[plainRun.runId] = {
+ robotId: plainRun.robotMetaId,
+ runId: plainRun.runId,
+ status: 'pending',
+ retries: 5,
+ };
+
+ processAirtableUpdates();
+ processGoogleSheetUpdates();
+ } catch (err: any) {
+ logger.log('error', `Failed to update Google Sheet for run: ${plainRun.runId}: ${err.message}`);
+ }
return true;
} catch (error: any) {
logger.log('info', `Error while running a robot with id: ${id} - ${error.message}`);
@@ -202,6 +254,34 @@ async function executeRun(id: string, userId: string) {
status: 'failed',
finishedAt: new Date().toLocaleString(),
});
+
+ const recording = await Robot.findOne({ where: { 'recording_meta.id': run.robotMetaId }, raw: true });
+
+ // Trigger webhooks for run failure
+ const failedWebhookPayload = {
+ robot_id: run.robotMetaId,
+ run_id: run.runId,
+ robot_name: recording ? recording.recording_meta.name : 'Unknown Robot',
+ status: 'failed',
+ started_at: run.startedAt,
+ finished_at: new Date().toLocaleString(),
+ error: {
+ message: error.message,
+ stack: error.stack,
+ type: error.name || 'ExecutionError'
+ },
+ metadata: {
+ browser_id: run.browserId,
+ user_id: userId,
+ }
+ };
+
+ try {
+ await sendWebhook(run.robotMetaId, 'run_failed', failedWebhookPayload);
+ logger.log('info', `Failure webhooks sent successfully for run ${run.runId}`);
+ } catch (webhookError: any) {
+ logger.log('error', `Failed to send failure webhooks for run ${run.runId}: ${webhookError.message}`);
+ }
}
capture(
'maxun-oss-run-created-scheduled',
diff --git a/src/api/webhook.ts b/src/api/webhook.ts
new file mode 100644
index 00000000..ad5c75dc
--- /dev/null
+++ b/src/api/webhook.ts
@@ -0,0 +1,149 @@
+import { default as axios } from "axios";
+import { apiUrl } from "../apiConfig";
+
+export interface WebhookConfig {
+ id: string;
+ url: string;
+ events: string[];
+ active: boolean;
+ createdAt?: string;
+ updatedAt?: string;
+ lastCalledAt?: string | null;
+ retryAttempts?: number;
+ retryDelay?: number;
+ timeout?: number;
+}
+
+export interface WebhookResponse {
+ ok: boolean;
+ message?: string;
+ webhook?: WebhookConfig;
+ webhooks?: WebhookConfig[];
+ error?: string;
+ details?: any;
+}
+
+export const addWebhook = async (webhook: WebhookConfig, robotId: string): Promise => {
+ try {
+ const response = await axios.post(`${apiUrl}/webhook/add`, {
+ webhook,
+ robotId
+ }, { withCredentials: true });
+
+ if (response.status === 200) {
+ return response.data;
+ } else {
+ throw new Error(`Failed to add webhook. Status code: ${response.status}`);
+ }
+ } catch (error: any) {
+ console.error('Error adding webhook:', error.message || error);
+ return {
+ ok: false,
+ error: error.response?.data?.message || error.message || 'Failed to add webhook'
+ };
+ }
+};
+
+export const updateWebhook = async (webhook: WebhookConfig, robotId: string): Promise => {
+ try {
+ const response = await axios.post(`${apiUrl}/webhook/update`, {
+ webhook,
+ robotId
+ }, { withCredentials: true });
+
+ if (response.status === 200) {
+ return response.data;
+ } else {
+ throw new Error(`Failed to update webhook. Status code: ${response.status}`);
+ }
+ } catch (error: any) {
+ console.error('Error updating webhook:', error.message || error);
+ return {
+ ok: false,
+ error: error.response?.data?.message || error.message || 'Failed to update webhook'
+ };
+ }
+};
+
+export const removeWebhook = async (webhookId: string, robotId: string): Promise => {
+ try {
+ const response = await axios.post(`${apiUrl}/webhook/remove`, {
+ webhookId,
+ robotId
+ }, { withCredentials: true });
+
+ if (response.status === 200) {
+ return response.data;
+ } else {
+ throw new Error(`Failed to remove webhook. Status code: ${response.status}`);
+ }
+ } catch (error: any) {
+ console.error('Error removing webhook:', error.message || error);
+ return {
+ ok: false,
+ error: error.response?.data?.message || error.message || 'Failed to remove webhook'
+ };
+ }
+};
+
+export const getWebhooks = async (robotId: string): Promise => {
+ try {
+ const response = await axios.get(`${apiUrl}/webhook/list/${robotId}`, {
+ withCredentials: true
+ });
+
+ if (response.status === 200) {
+ return response.data;
+ } else {
+ throw new Error(`Failed to fetch webhooks. Status code: ${response.status}`);
+ }
+ } catch (error: any) {
+ console.error('Error fetching webhooks:', error.message || error);
+ return {
+ ok: false,
+ error: error.response?.data?.message || error.message || 'Failed to fetch webhooks',
+ webhooks: []
+ };
+ }
+};
+
+export const testWebhook = async (webhook: WebhookConfig, robotId: string): Promise => {
+ try {
+ const response = await axios.post(`${apiUrl}/webhook/test`, {
+ webhook,
+ robotId
+ }, { withCredentials: true });
+
+ if (response.status === 200) {
+ return response.data;
+ } else {
+ throw new Error(`Failed to test webhook. Status code: ${response.status}`);
+ }
+ } catch (error: any) {
+ console.error('Error testing webhook:', error.message || error);
+ return {
+ ok: false,
+ error: error.response?.data?.message || error.message || 'Failed to test webhook'
+ };
+ }
+};
+
+export const clearAllWebhooks = async (robotId: string): Promise => {
+ try {
+ const response = await axios.delete(`${apiUrl}/webhook/clear/${robotId}`, {
+ withCredentials: true
+ });
+
+ if (response.status === 200) {
+ return response.data;
+ } else {
+ throw new Error(`Failed to clear webhooks. Status code: ${response.status}`);
+ }
+ } catch (error: any) {
+ console.error('Error clearing webhooks:', error.message || error);
+ return {
+ ok: false,
+ error: error.response?.data?.message || error.message || 'Failed to clear webhooks'
+ };
+ }
+};
\ No newline at end of file
diff --git a/src/components/integration/IntegrationSettings.tsx b/src/components/integration/IntegrationSettings.tsx
index 5ca71840..cf907106 100644
--- a/src/components/integration/IntegrationSettings.tsx
+++ b/src/components/integration/IntegrationSettings.tsx
@@ -8,22 +8,41 @@ import {
AlertTitle,
Button,
TextField,
+ IconButton,
+ Box,
+ Chip,
+ Card,
+ CardContent,
+ CardActions,
+ Switch,
+ FormControlLabel,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Paper,
} from "@mui/material";
+import { Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, Science as ScienceIcon } from "@mui/icons-material";
import axios from "axios";
import { useGlobalInfoStore } from "../../context/globalInfo";
import { getStoredRecording } from "../../api/storage";
import { apiUrl } from "../../apiConfig.js";
+import { v4 as uuid } from "uuid";
import Cookies from "js-cookie";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
+import { addWebhook, updateWebhook, removeWebhook, getWebhooks, testWebhook,WebhookConfig } from "../../api/webhook";
+
interface IntegrationProps {
isOpen: boolean;
handleStart: (data: IntegrationSettings) => void;
handleClose: () => void;
- preSelectedIntegrationType?: "googleSheets" | "airtable" | null;
+ preSelectedIntegrationType?: "googleSheets" | "airtable" | "webhook" | null;
}
export interface IntegrationSettings {
@@ -33,8 +52,9 @@ export interface IntegrationSettings {
airtableBaseName?: string;
airtableTableName?: string,
airtableTableId?: string,
+ webhooks?: WebhookConfig[];
data: string;
- integrationType: "googleSheets" | "airtable";
+ integrationType: "googleSheets" | "airtable" | "webhook";
}
const getCookie = (name: string): string | null => {
@@ -64,6 +84,7 @@ export const IntegrationSettingsModal = ({
airtableBaseName: "",
airtableTableName: "",
airtableTableId: "",
+ webhooks: [],
data: "",
integrationType: preSelectedIntegrationType || "googleSheets",
});
@@ -74,6 +95,16 @@ export const IntegrationSettingsModal = ({
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ const [showWebhookForm, setShowWebhookForm] = useState(false);
+ const [editingWebhook, setEditingWebhook] = useState(null);
+ const [newWebhook, setNewWebhook] = useState({
+ id: "",
+ url: "",
+ events: ["run_completed"],
+ active: true,
+ });
+ const [urlError, setUrlError] = useState(null);
+
const {
recordingId,
notify,
@@ -84,7 +115,7 @@ export const IntegrationSettingsModal = ({
const navigate = useNavigate();
const [selectedIntegrationType, setSelectedIntegrationType] = useState<
- "googleSheets" | "airtable" | null
+ "googleSheets" | "airtable" | "webhook" | null
>(preSelectedIntegrationType);
const authenticateWithGoogle = () => {
@@ -96,6 +127,230 @@ export const IntegrationSettingsModal = ({
window.location.href = `${apiUrl}/auth/airtable?robotId=${recordingId}`;
};
+ const validateWebhookData = (url: string, events: string[], excludeId?: string) => {
+ if (!url) {
+ setUrlError("Please provide webhook URL");
+ return false;
+ }
+
+ try {
+ new URL(url);
+ } catch {
+ setUrlError("Please provide a valid URL");
+ return false;
+ }
+
+ const existingWebhook = settings.webhooks?.find(
+ (webhook) => webhook.url === url && webhook.id !== excludeId
+ );
+
+ if (existingWebhook) {
+ setUrlError("This webhook URL is already in use");
+ return false;
+ }
+
+ if (!events || events.length === 0) {
+ setUrlError("Please select at least one event");
+ return false;
+ }
+
+ setUrlError(null);
+ return true;
+ };
+
+ const fetchWebhooks = async () => {
+ try {
+ setLoading(true);
+ if (!recordingId) return;
+
+ const response = await getWebhooks(recordingId);
+
+ if (response.ok && response.webhooks) {
+ setSettings(prev => ({
+ ...prev,
+ webhooks: response.webhooks
+ }));
+ } else {
+ notify("error", response.error || "Failed to fetch webhooks");
+ }
+ setLoading(false);
+ } catch (error: any) {
+ setLoading(false);
+ console.error("Error fetching webhooks:", error);
+ notify("error", "Failed to fetch webhooks");
+ }
+ };
+
+ const addWebhookSetting = async () => {
+ if (!validateWebhookData(newWebhook.url, newWebhook.events)) {
+ if (!newWebhook.url) {
+ notify("error", "Please provide webhook URL");
+ } else if (!newWebhook.events || newWebhook.events.length === 0) {
+ notify("error", "Please select at least one event");
+ }
+ return;
+ }
+
+ if (!recordingId) return;
+
+ try {
+ setLoading(true);
+ const webhookWithId = {
+ ...newWebhook,
+ id: uuid(),
+ };
+
+ const response = await addWebhook(webhookWithId, recordingId);
+
+ if (response.ok) {
+ const updatedWebhooks = [...(settings.webhooks || []), webhookWithId];
+ setSettings({ ...settings, webhooks: updatedWebhooks });
+
+ resetWebhookForm();
+ await refreshRecordingData();
+ notify("success", "Webhook added successfully");
+ } else {
+ notify("error", response.error || "Failed to add webhook");
+ }
+ setLoading(false);
+ } catch (error: any) {
+ setLoading(false);
+ console.log("Error adding webhook:", error);
+ notify("error", "Failed to add webhook");
+ }
+ };
+
+ const updateWebhookSetting = async () => {
+ if (!editingWebhook || !recordingId) return;
+
+ if (!validateWebhookData(newWebhook.url, newWebhook.events, editingWebhook)) {
+ if (!newWebhook.url) {
+ notify("error", "Please provide webhook URL");
+ } else if (!newWebhook.events || newWebhook.events.length === 0) {
+ notify("error", "Please select at least one event");
+ }
+ return;
+ }
+
+ try {
+ setLoading(true);
+ const response = await updateWebhook(newWebhook, recordingId);
+
+ if (response.ok) {
+ const updatedWebhooks = (settings.webhooks || []).map(w =>
+ w.id === editingWebhook ? newWebhook : w
+ );
+ setSettings({ ...settings, webhooks: updatedWebhooks });
+
+ resetWebhookForm();
+ await refreshRecordingData();
+ notify("success", "Webhook updated successfully");
+ } else {
+ notify("error", response.error || "Failed to update webhook");
+ }
+ setLoading(false);
+ } catch (error: any) {
+ setLoading(false);
+ console.error("Error updating webhook:", error);
+ notify("error", "Failed to update webhook");
+ }
+ };
+
+ const removeWebhookSetting = async (webhookId: string) => {
+ if (!recordingId) return;
+
+ try {
+ setLoading(true);
+ const response = await removeWebhook(webhookId, recordingId);
+
+ if (response.ok) {
+ const updatedWebhooks = (settings.webhooks || []).filter(w => w.id !== webhookId);
+ setSettings({ ...settings, webhooks: updatedWebhooks });
+
+ await refreshRecordingData();
+ notify("success", "Webhook removed successfully");
+ } else {
+ notify("error", response.error || "Failed to remove webhook");
+ }
+ setLoading(false);
+ } catch (error: any) {
+ setLoading(false);
+ console.error("Error removing webhook:", error);
+ notify("error", "Failed to remove webhook");
+ }
+ };
+
+ const toggleWebhookStatusSetting = async (webhookId: string) => {
+ if (!recordingId) return;
+
+ try {
+ const webhook = settings.webhooks?.find(w => w.id === webhookId);
+ if (!webhook) return;
+
+ const updatedWebhook = { ...webhook, active: !webhook.active };
+
+ const response = await updateWebhook(updatedWebhook, recordingId);
+
+ if (response.ok) {
+ const updatedWebhooks = (settings.webhooks || []).map(w =>
+ w.id === webhookId ? updatedWebhook : w
+ );
+ setSettings({ ...settings, webhooks: updatedWebhooks });
+
+ await refreshRecordingData();
+ notify("success", `Webhook ${updatedWebhook.active ? "enabled" : "disabled"}`);
+ } else {
+ notify("error", response.error || "Failed to update webhook");
+ }
+ } catch (error: any) {
+ console.error("Error toggling webhook status:", error);
+ notify("error", "Failed to update webhook");
+ }
+ };
+
+ const testWebhookSetting = async (webhook: WebhookConfig) => {
+ if (!recordingId) return;
+
+ try {
+ setLoading(true);
+ const response = await testWebhook(webhook, recordingId);
+
+ if (response.ok) {
+ const updatedWebhooks = (settings.webhooks || []).map(w =>
+ w.id === webhook.id ? { ...w, lastCalledAt: new Date().toISOString() } : w
+ );
+ setSettings({ ...settings, webhooks: updatedWebhooks });
+
+ notify("success", "Test webhook sent successfully");
+ } else {
+ notify("error", response.error || "Failed to test webhook");
+ }
+ setLoading(false);
+ } catch (error: any) {
+ setLoading(false);
+ console.error("Error testing webhook:", error);
+ notify("error", "Failed to test webhook");
+ }
+ };
+
+ const editWebhookSetting = (webhook: WebhookConfig) => {
+ setNewWebhook(webhook);
+ setEditingWebhook(webhook.id);
+ setShowWebhookForm(true);
+ };
+
+ const resetWebhookForm = () => {
+ setNewWebhook({
+ id: "",
+ url: "",
+ events: ["run_completed"],
+ active: true,
+ });
+ setShowWebhookForm(false);
+ setEditingWebhook(null);
+ setUrlError(null);
+ };
+
// Fetch Google Sheets files
const fetchSpreadsheetFiles = async () => {
try {
@@ -193,6 +448,9 @@ export const IntegrationSettingsModal = ({
if (!recordingId) return null;
const updatedRecording = await getStoredRecording(recordingId);
setRecording(updatedRecording);
+
+ await fetchWebhooks();
+
setRerenderRobots(true);
return updatedRecording;
};
@@ -331,8 +589,7 @@ export const IntegrationSettingsModal = ({
if (preSelectedIntegrationType) {
setSettings(prev => ({ ...prev, integrationType: preSelectedIntegrationType }));
- }
- else if (recording.google_sheet_id) {
+ } else if (recording.google_sheet_id) {
setSettings(prev => ({ ...prev, integrationType: "googleSheets" }));
} else if (recording.airtable_base_id) {
setSettings(prev => ({
@@ -341,9 +598,18 @@ export const IntegrationSettingsModal = ({
airtableBaseName: recording.airtable_base_name || "",
airtableTableName: recording.airtable_table_name || "",
airtableTableId: recording.airtable_table_id || "",
- integrationType: recording.airtable_base_id ? "airtable" : "googleSheets"
+ integrationType: "airtable"
}));
}
+
+ await fetchWebhooks();
+
+ if (!preSelectedIntegrationType && !recording.google_sheet_id && !recording.airtable_base_id) {
+ const webhookResponse = await getWebhooks(recordingId);
+ if (webhookResponse.ok && webhookResponse.webhooks && webhookResponse.webhooks.length > 0) {
+ setSettings(prev => ({ ...prev, integrationType: "webhook" }));
+ }
+ }
}
setLoading(false);
@@ -370,7 +636,48 @@ export const IntegrationSettingsModal = ({
}
}, []);
- // Add this UI at the top of the modal return statement
+ const formatEventName = (event: string) => {
+ switch (event) {
+ case "run_completed":
+ return "Run finished";
+ case "run_failed":
+ return "Run failed";
+ default:
+ return event;
+ }
+ };
+
+ const formatLastCalled = (lastCalledAt?: string | null) => {
+ if (!lastCalledAt) {
+ return "Not called yet";
+ }
+
+ const date = new Date(lastCalledAt);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffMinutes = Math.floor(diffMs / (1000 * 60));
+
+ if (diffMinutes < 1) {
+ return "Just now";
+ } else if (diffMinutes < 60) {
+ return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
+ } else if (diffHours < 24) {
+ return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
+ } else if (diffDays < 7) {
+ return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
+ } else {
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ }
+ };
+
if (!selectedIntegrationType) {
return (
-
+
Google Sheets
@@ -407,9 +714,22 @@ export const IntegrationSettingsModal = ({
}}
style={{ display: "flex", flexDirection: "column", alignItems: "center", background: 'white', color: '#ff00c3' }}
>
-
+
Airtable
+
+
@@ -423,6 +743,7 @@ export const IntegrationSettingsModal = ({
flexDirection: "column",
alignItems: "flex-start",
marginLeft: "65px",
+ maxWidth: "1000px",
}}>
{settings.integrationType === "googleSheets" && (
@@ -640,6 +961,218 @@ export const IntegrationSettingsModal = ({
)}
>
)}
+
+ {settings.integrationType === "webhook" && (
+ <>
+
+ Integrate using Webhooks
+
+
+ {settings.webhooks && settings.webhooks.length > 0 && (
+
+
+
+
+ Webhook URL
+ Call when
+ Last called
+ Status
+ Actions
+
+
+
+ {settings.webhooks.map((webhook) => (
+
+ {webhook.url}
+
+
+ {webhook.events.map((event) => (
+
+ ))}
+
+
+ {formatLastCalled(webhook.lastCalledAt)}
+
+ toggleWebhookStatusSetting(webhook.id)}
+ size="small"
+ />
+
+
+
+ testWebhookSetting(webhook)}
+ disabled={loading || !webhook.active}
+ title="Test"
+ >
+
+
+ editWebhookSetting(webhook)}
+ disabled={loading}
+ title="Edit"
+ >
+
+
+ removeWebhookSetting(webhook.id)}
+ disabled={loading}
+ title="Delete"
+ >
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {!showWebhookForm && (
+
+
+ {
+ setNewWebhook({ ...newWebhook, url: e.target.value });
+ if (urlError) setUrlError(null);
+ }}
+ error={!!urlError}
+ helperText={urlError}
+ required
+ aria-describedby="webhook-url-help"
+ />
+ setNewWebhook({
+ ...newWebhook,
+ events: [e.target.value]
+ })}
+ sx={{ minWidth: "200px" }}
+ required
+ >
+
+
+
+
+
+
+
+ Refer to the API documentation for examples and details.
+
+ }
+ onClick={addWebhookSetting}
+ disabled={!newWebhook.url || !newWebhook.events || newWebhook.events.length === 0 || loading || !!urlError}
+ >
+ Add New Webhook
+
+
+
+ )}
+
+ {showWebhookForm && (
+
+
+
+ {editingWebhook ? "Edit Webhook" : "Add New Webhook"}
+
+
+ {
+ setNewWebhook({ ...newWebhook, url: e.target.value });
+ if (urlError) setUrlError(null);
+ }}
+ sx={{ marginBottom: "15px" }}
+ placeholder="https://your-api.com/webhook/endpoint"
+ required
+ error={!!urlError}
+ helperText={urlError}
+ />
+
+ setNewWebhook({
+ ...newWebhook,
+ events: typeof e.target.value === 'string' ? [e.target.value] : e.target.value
+ })}
+ SelectProps={{
+ multiple: true,
+ renderValue: (selected) => (
+
+ {(selected as string[]).map((value) => (
+
+ ))}
+
+ ),
+ }}
+ sx={{ marginBottom: "20px" }}
+ required
+ >
+
+
+
+
+ setNewWebhook({ ...newWebhook, active: e.target.checked })}
+ />
+ }
+ label="Active"
+ sx={{ marginBottom: "10px" }}
+ />
+
+
+
+
+
+
+
+ )}
+ >
+ )}
);
@@ -649,10 +1182,12 @@ export const modalStyle = {
top: "40%",
left: "50%",
transform: "translate(-50%, -50%)",
- width: "50%",
+ width: "60%",
backgroundColor: "background.paper",
p: 4,
height: "fit-content",
display: "block",
padding: "20px",
+ maxHeight: "90vh",
+ overflow: "auto",
};
\ No newline at end of file
diff --git a/src/components/robot/Recordings.tsx b/src/components/robot/Recordings.tsx
index 780d01cf..1b3fc7b2 100644
--- a/src/components/robot/Recordings.tsx
+++ b/src/components/robot/Recordings.tsx
@@ -114,6 +114,15 @@ export const Recordings = ({
preSelectedIntegrationType="airtable"
/>
);
+ } else if (currentPath.endsWith("/integrate/webhook")) {
+ return (
+ {}}
+ preSelectedIntegrationType="webhook"
+ />
+ );
} else if (currentPath.endsWith("/integrate")) {
return (