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