Merge pull request #610 from RohitR311/webhooks

feat: webhook support
This commit is contained in:
Karishma Shukla
2025-05-29 00:02:30 +05:30
committed by GitHub
16 changed files with 1521 additions and 58 deletions

14
public/svg/webhook.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='150' height='150' viewBox='0 0 150 150'>
<defs>
<clipPath id='clip-Custom_Size_webhook'>
<rect width='150' height='150'/>
</clipPath>
</defs>
<g id='Custom_Size_webhook' data-name='Custom Size webhook' clip-path='url(#clip-Custom_Size_webhook)'>
<rect width='150' height='150' fill='#fff'/>
<g id='webhook_icon' transform='translate(25 25) scale(2.08)'>
<!-- Main webhook paths with consistent color scheme -->
<path fill="#37474f" d="M35,37c-2.2,0-4-1.8-4-4s1.8-4,4-4s4,1.8,4,4S37.2,37,35,37z"/><path fill="#37474f" d="M35,43c-3,0-5.9-1.4-7.8-3.7l3.1-2.5c1.1,1.4,2.9,2.3,4.7,2.3c3.3,0,6-2.7,6-6s-2.7-6-6-6 c-1,0-2,0.3-2.9,0.7l-1.7,1L23.3,16l3.5-1.9l5.3,9.4c1-0.3,2-0.5,3-0.5c5.5,0,10,4.5,10,10S40.5,43,35,43z"/><path fill="#37474f" d="M14,43C8.5,43,4,38.5,4,33c0-4.6,3.1-8.5,7.5-9.7l1,3.9C9.9,27.9,8,30.3,8,33c0,3.3,2.7,6,6,6 s6-2.7,6-6v-2h15v4H23.8C22.9,39.6,18.8,43,14,43z"/><path fill="#e91e63" d="M14,37c-2.2,0-4-1.8-4-4s1.8-4,4-4s4,1.8,4,4S16.2,37,14,37z"/><path fill="#37474f" d="M25,19c-2.2,0-4-1.8-4-4s1.8-4,4-4s4,1.8,4,4S27.2,19,25,19z"/><path fill="#e91e63" d="M15.7,34L12.3,32l5.9-9.7c-2-1.9-3.2-4.5-3.2-7.3c0-5.5,4.5-10,10-10c5.5,0,10,4.5,10,10 c0,0.9-0.1,1.7-0.3,2.5l-3.9-1c0.1-0.5,0.2-1,0.2-1.5c0-3.3-2.7-6-6-6s-6,2.7-6,6c0,2.1,1.1,4,2.9,5.1l1.7,1L15.7,34z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -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',

View File

@@ -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";

View File

@@ -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');
}
};

View File

@@ -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<RobotAttributes, RobotCreationAttributes> 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,

View File

@@ -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<ExecuteRunData>) {
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<ExecuteRunData>) {
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<ExecuteRunData>) {
}
);
// 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<ExecuteRunData>) {
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`);
}

View File

@@ -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
};

View File

@@ -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';

View File

@@ -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<void> => {
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<void> => {
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<void> => {
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' });
}
});

View File

@@ -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);

View File

@@ -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";

View File

@@ -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<string, any>),
scrapeListOutput: this.serializableDataByType.scrapeList.reduce((reducedObject, item, index) => {
return {
[`list-${index}`]: item,
...reducedObject,
}
}, {}),
reducedObject[`list_${index}`] = item;
return reducedObject;
}, {} as Record<string, any>),
binaryOutput: this.binaryData.reduce((reducedObject, item, index) => {
return {
[`item-${index}`]: item,
...reducedObject,
}
}, {})
reducedObject[`item_${index}`] = item;
return reducedObject;
}, {} as Record<string, any>)
}
logger.log('debug', `Interpretation finished`);

View File

@@ -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',

149
src/api/webhook.ts Normal file
View File

@@ -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<WebhookResponse> => {
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<WebhookResponse> => {
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<WebhookResponse> => {
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<WebhookResponse> => {
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<WebhookResponse> => {
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<WebhookResponse> => {
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'
};
}
};

View File

@@ -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<string | null>(null);
const [showWebhookForm, setShowWebhookForm] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<string | null>(null);
const [newWebhook, setNewWebhook] = useState<WebhookConfig>({
id: "",
url: "",
events: ["run_completed"],
active: true,
});
const [urlError, setUrlError] = useState<string | null>(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 (
<GenericModal
@@ -394,7 +701,7 @@ export const IntegrationSettingsModal = ({
}}
style={{ display: "flex", flexDirection: "column", alignItems: "center", background: 'white', color: '#ff00c3' }}
>
<img src="/public/svg/gsheet.svg" alt="Google Sheets" style={{ margin: "6px" }} />
<img src="/svg/gsheet.svg" alt="Google Sheets" style={{ margin: "6px" }} />
Google Sheets
</Button>
@@ -407,9 +714,22 @@ export const IntegrationSettingsModal = ({
}}
style={{ display: "flex", flexDirection: "column", alignItems: "center", background: 'white', color: '#ff00c3' }}
>
<img src="/public/svg/airtable.svg" alt="Airtable" style={{ margin: "6px" }} />
<img src="/svg/airtable.svg" alt="Airtable" style={{ margin: "6px" }} />
Airtable
</Button>
<Button
variant="outlined"
onClick={() => {
setSelectedIntegrationType("webhook");
setSettings({ ...settings, integrationType: "webhook" });
navigate(`/robots/${recordingId}/integrate/webhook`);
}}
style={{ display: "flex", flexDirection: "column", alignItems: "center", background: 'white', color: '#ff00c3' }}
>
<img src="/svg/webhook.svg" alt="Webhook" style={{ margin: "6px" }} />
Webhooks
</Button>
</div>
</div>
</GenericModal>
@@ -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" && (
<>
<Typography variant="h6" sx={{ marginBottom: "20px" }}>
Integrate using Webhooks
</Typography>
{settings.webhooks && settings.webhooks.length > 0 && (
<TableContainer component={Paper} sx={{ marginBottom: "30px", width: "100%" }}>
<Table>
<TableHead>
<TableRow>
<TableCell><strong>Webhook URL</strong></TableCell>
<TableCell><strong>Call when</strong></TableCell>
<TableCell><strong>Last called</strong></TableCell>
<TableCell><strong>Status</strong></TableCell>
<TableCell><strong>Actions</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{settings.webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell>{webhook.url}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{webhook.events.map((event) => (
<Chip
key={event}
label={formatEventName(event)}
size="small"
variant="outlined"
/>
))}
</Box>
</TableCell>
<TableCell>{formatLastCalled(webhook.lastCalledAt)}</TableCell>
<TableCell>
<Switch
checked={webhook.active}
onChange={() => toggleWebhookStatusSetting(webhook.id)}
size="small"
/>
</TableCell>
<TableCell>
<Box sx={{ display: "flex", gap: "8px" }}>
<IconButton
size="small"
onClick={() => testWebhookSetting(webhook)}
disabled={loading || !webhook.active}
title="Test"
>
<ScienceIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => editWebhookSetting(webhook)}
disabled={loading}
title="Edit"
>
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => removeWebhookSetting(webhook.id)}
disabled={loading}
title="Delete"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{!showWebhookForm && (
<Box sx={{ marginBottom: "20px", width: "100%" }}>
<Box sx={{ display: "flex", gap: "15px", alignItems: "center", marginBottom: "15px" }}>
<TextField
label="Webhook URL"
placeholder="https://your-api.com/webhook/endpoint"
sx={{ flex: 1 }}
value={newWebhook.url}
onChange={(e) => {
setNewWebhook({ ...newWebhook, url: e.target.value });
if (urlError) setUrlError(null);
}}
error={!!urlError}
helperText={urlError}
required
aria-describedby="webhook-url-help"
/>
<TextField
select
label="When"
value={newWebhook.events[0] || "run_completed"}
onChange={(e) => setNewWebhook({
...newWebhook,
events: [e.target.value]
})}
sx={{ minWidth: "200px" }}
required
>
<MenuItem value="run_completed">Run finished</MenuItem>
<MenuItem value="run_failed">Run failed</MenuItem>
</TextField>
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="textSecondary" sx={{ marginTop: "10px" }}>
Refer to the <a href="https://docs.maxun.dev/" style={{ color: '#ff00c3', textDecoration: 'none' }}>API documentation</a> for examples and details.
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={addWebhookSetting}
disabled={!newWebhook.url || !newWebhook.events || newWebhook.events.length === 0 || loading || !!urlError}
>
Add New Webhook
</Button>
</Box>
</Box>
)}
{showWebhookForm && (
<Card sx={{ width: "100%", marginBottom: "20px" }}>
<CardContent>
<Typography variant="h6" sx={{ marginBottom: "20px" }}>
{editingWebhook ? "Edit Webhook" : "Add New Webhook"}
</Typography>
<TextField
fullWidth
label="Webhook URL"
value={newWebhook.url}
onChange={(e) => {
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}
/>
<TextField
fullWidth
select
label="Call when"
value={newWebhook.events}
onChange={(e) => setNewWebhook({
...newWebhook,
events: typeof e.target.value === 'string' ? [e.target.value] : e.target.value
})}
SelectProps={{
multiple: true,
renderValue: (selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{(selected as string[]).map((value) => (
<Chip key={value} label={formatEventName(value)} size="small" />
))}
</Box>
),
}}
sx={{ marginBottom: "20px" }}
required
>
<MenuItem value="run_completed">Run finished</MenuItem>
<MenuItem value="run_failed">Run failed</MenuItem>
</TextField>
<FormControlLabel
control={
<Switch
checked={newWebhook.active}
onChange={(e) => setNewWebhook({ ...newWebhook, active: e.target.checked })}
/>
}
label="Active"
sx={{ marginBottom: "10px" }}
/>
</CardContent>
<CardActions>
<Button
variant="contained"
color="primary"
onClick={editingWebhook ? updateWebhookSetting : addWebhookSetting}
disabled={!newWebhook.url || !newWebhook.events || newWebhook.events.length === 0 || loading || !!urlError}
>
{loading ? (
<CircularProgress size={24} />
) : (
editingWebhook ? "Update Webhook" : "Add Webhook"
)}
</Button>
<Button
variant="outlined"
onClick={resetWebhookForm}
disabled={loading}
>
Cancel
</Button>
</CardActions>
</Card>
)}
</>
)}
</div>
</GenericModal>
);
@@ -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",
};

View File

@@ -114,6 +114,15 @@ export const Recordings = ({
preSelectedIntegrationType="airtable"
/>
);
} else if (currentPath.endsWith("/integrate/webhook")) {
return (
<IntegrationSettingsModal
isOpen={true}
handleClose={handleClose}
handleStart={() => {}}
preSelectedIntegrationType="webhook"
/>
);
} else if (currentPath.endsWith("/integrate")) {
return (
<IntegrationSettingsModal