14
public/svg/webhook.svg
Normal file
14
public/svg/webhook.svg
Normal 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 |
@@ -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',
|
||||
|
||||
@@ -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";
|
||||
|
||||
27
server/src/db/migrations/20250527105655-add-webhooks.js
Normal file
27
server/src/db/migrations/20250527105655-add-webhooks.js
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
493
server/src/routes/webhook.ts
Normal file
493
server/src/routes/webhook.ts
Normal 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' });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
149
src/api/webhook.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user