Initial commit: FlowForge automation templates integration

This commit is contained in:
Mehmet Oezdag 2025-06-07 11:19:31 +02:00
commit 74fb343ead
85 changed files with 8427 additions and 0 deletions

37
.env.example Normal file
View File

@ -0,0 +1,37 @@
# Database Configuration
DATABASE_URL=postgres://postgres:postgres@postgres:5432/flowforge
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=flowforge
# Redis Configuration
REDIS_URL=redis://redis:6379
# JWT Authentication
JWT_SECRET=change_this_to_a_secure_random_string
JWT_EXPIRATION=86400
# Server Configuration
PORT=4000
NODE_ENV=production
# Frontend URL (for CORS)
FRONTEND_URL=https://your-domain.com
# Email Configuration (optional)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=user@example.com
SMTP_PASS=your_password
SMTP_FROM=noreply@example.com
# Logging
LOG_LEVEL=info
# Storage Configuration
STORAGE_TYPE=local
STORAGE_PATH=/app/storage
# Rate Limiting
RATE_LIMIT_WINDOW=15
RATE_LIMIT_MAX=100

31
.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# Dependencies
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Build outputs
dist/
build/
coverage/
# Logs
logs/
*.log
# Docker volumes
data/
# IDE and editor files
.idea/
.vscode/
*.swp
*.swo
.DS_Store

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# FlowForge
A self-hosted automation platform that allows you to build workflows visually with a drag-and-drop interface.
## Features
- Visual drag-and-drop flow editor
- Modular node system for extensibility
- User authentication and workflow management
- Background task processing with queues
- Docker-based deployment
## Architecture
- **Frontend**: React.js, Tailwind CSS, react-flow
- **Backend**: Node.js + Express.js
- **Queue System**: Redis + BullMQ
- **Database**: PostgreSQL
## Getting Started
### Prerequisites
- Docker and Docker Compose
- Node.js 18+ (for local development)
### Development Setup
1. Clone the repository
2. Install dependencies:
```
cd frontend && npm install
cd ../backend && npm install
```
3. Start the development environment:
```
docker-compose -f docker-compose.dev.yml up
```
4. Access the application at `http://localhost:3000`
### Production Deployment
1. Configure environment variables in `.env.production`
2. Build and start the containers:
```
docker-compose up -d
```
3. Access the application at your configured domain
## License
This project is licensed under the MIT License - see the LICENSE file for details.

16
backend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY . .
# Expose port
EXPOSE 4000
# Start server
CMD ["node", "src/index.js"]

18
backend/Dockerfile.dev Normal file
View File

@ -0,0 +1,18 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source code (this will be overridden by volume mount in dev)
COPY . .
# Expose port
EXPOSE 4000
# Start development server with nodemon for hot reloading
CMD ["npx", "nodemon", "src/index.js"]

43
backend/create-admin.js Normal file
View File

@ -0,0 +1,43 @@
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
const { db } = require('./src/config/db');
async function createAdminUser() {
try {
// Generate a new hash for the password
const password = 'FlowForge123!';
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash(password, salt);
console.log('Generated hash:', hash);
// Generate a UUID for the user
const userId = uuidv4();
// Delete any existing admin user
await db('users').where({ email: 'admin@flowforge.test' }).del();
// Insert the new admin user
const [user] = await db('users').insert({
id: userId,
email: 'admin@flowforge.test',
password: hash,
created_at: new Date(),
updated_at: new Date()
}).returning(['id', 'email', 'created_at']);
console.log('Admin user created successfully:', user);
// Verify the password works
const dbUser = await db('users').where({ email: 'admin@flowforge.test' }).first();
const isValid = await bcrypt.compare(password, dbUser.password);
console.log('Password validation:', isValid);
process.exit(0);
} catch (error) {
console.error('Error creating admin user:', error);
process.exit(1);
}
}
createAdminUser();

79
backend/fix-user.sql Normal file
View File

@ -0,0 +1,79 @@
-- Drop existing tables with foreign key constraints first
DROP TABLE IF EXISTS webhooks;
DROP TABLE IF EXISTS workflow_schedules;
DROP TABLE IF EXISTS workflow_executions;
DROP TABLE IF EXISTS workflow_versions;
DROP TABLE IF EXISTS workflows;
DROP TABLE IF EXISTS users;
-- Create users table with UUID as primary key
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create workflows table
CREATE TABLE workflows (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
nodes JSONB,
edges JSONB,
user_id UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create workflow_versions table
CREATE TABLE workflow_versions (
id UUID PRIMARY KEY,
workflow_id UUID REFERENCES workflows(id),
version INTEGER NOT NULL,
nodes JSONB,
edges JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create workflow_executions table
CREATE TABLE workflow_executions (
id UUID PRIMARY KEY,
workflow_id UUID REFERENCES workflows(id),
status VARCHAR(50) NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
logs JSONB,
results JSONB
);
-- Create workflow_schedules table
CREATE TABLE workflow_schedules (
id UUID PRIMARY KEY,
workflow_id UUID REFERENCES workflows(id),
cron_expression VARCHAR(100) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create webhooks table
CREATE TABLE webhooks (
id UUID PRIMARY KEY,
workflow_id UUID REFERENCES workflows(id),
node_id VARCHAR(255) NOT NULL,
path VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert a default admin user with UUID
-- Password is 'FlowForge123!' (hashed with bcrypt)
INSERT INTO users (id, email, password, created_at, updated_at)
VALUES (
'550e8400-e29b-41d4-a716-446655440000',
'admin@flowforge.test',
'$2b$10$3euPcmQFCiblsZeEu5s7p.9wVdLajnYhAbcjkru4KkUGBIm3WVYjK',
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
);

72
backend/logs/combined.log Normal file
View File

@ -0,0 +1,72 @@
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 07:33:40"}
{"address":"172.24.0.4","code":"ECONNREFUSED","errno":-111,"level":"error","message":"Database connection failed: connect ECONNREFUSED 172.24.0.4:5432","port":5432,"service":"flowforge-backend","stack":"Error: connect ECONNREFUSED 172.24.0.4:5432\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1555:16)","syscall":"connect","timestamp":"2025-06-07 07:33:40"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 07:40:00"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 07:40:00"}
{"level":"error","message":"error: select * from \"users\" where \"email\" = $1 limit $2 - relation \"users\" does not exist","method":"POST","path":"/api/auth/register","service":"flowforge-backend","stack":"error: select * from \"users\" where \"email\" = $1 limit $2 - relation \"users\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 07:46:07"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 07:51:51"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 07:51:51"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:04:49"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:04:49"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:04:59"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:04:59"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:05:34"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:05:34"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:05:46"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:05:46"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:05:54"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:11:39"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:11:51"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:38:45"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:38:57"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:39:03"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:39:09"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:39:11"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:39:16"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:39:18"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:40:55"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:40:55"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:30:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:41:22"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:30:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:41:26"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:30:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:41:27"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at /app/src/models/workflow.js:49:17\n at Array.map (<anonymous>)\n at getWorkflowsByUserId (/app/src/models/workflow.js:47:20)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getAll (/app/src/controllers/workflow.js:52:23)","timestamp":"2025-06-07 08:41:35"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at /app/src/models/workflow.js:49:17\n at Array.map (<anonymous>)\n at getWorkflowsByUserId (/app/src/models/workflow.js:47:20)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getAll (/app/src/controllers/workflow.js:52:23)","timestamp":"2025-06-07 08:41:35"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:30:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:41:39"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:30:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:41:41"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:43:08"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:43:09"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:43:55"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:43:55"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:44:16"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:44:16"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:36:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:47:54"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:01"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:01"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/7140abc0-968a-4728-925b-27f49f139b6d","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:07"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/7140abc0-968a-4728-925b-27f49f139b6d","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:07"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/88b47234-6ebc-47b5-87a7-f4b70976083b","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:11"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/88b47234-6ebc-47b5-87a7-f4b70976083b","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:11"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/15c42683-3af7-4581-9d2d-0fab1dab07f0","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:14"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/15c42683-3af7-4581-9d2d-0fab1dab07f0","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:14"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:49:50"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:49:50"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:50:33"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:50:33"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:50:57"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:50:57"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:54:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:51:03"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:51:08"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:51:08"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/7140abc0-968a-4728-925b-27f49f139b6d","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:51:12"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/7140abc0-968a-4728-925b-27f49f139b6d","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:51:12"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:52:00"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:52:00"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:52:12"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:52:12"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:53:54"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:53:54"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:54:09"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:54:09"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:55:59"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:55:59"}
{"level":"info","message":"Server running on port 4000","service":"flowforge-backend","timestamp":"2025-06-07 08:56:07"}
{"level":"info","message":"Database connection established successfully","service":"flowforge-backend","timestamp":"2025-06-07 08:56:07"}

34
backend/logs/error.log Normal file
View File

@ -0,0 +1,34 @@
{"address":"172.24.0.4","code":"ECONNREFUSED","errno":-111,"level":"error","message":"Database connection failed: connect ECONNREFUSED 172.24.0.4:5432","port":5432,"service":"flowforge-backend","stack":"Error: connect ECONNREFUSED 172.24.0.4:5432\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1555:16)","syscall":"connect","timestamp":"2025-06-07 07:33:40"}
{"level":"error","message":"error: select * from \"users\" where \"email\" = $1 limit $2 - relation \"users\" does not exist","method":"POST","path":"/api/auth/register","service":"flowforge-backend","stack":"error: select * from \"users\" where \"email\" = $1 limit $2 - relation \"users\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 07:46:07"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:11:39"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:11:51"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:38:45"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:38:57"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:39:03"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:39:09"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:39:11"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:39:16"}
{"level":"error","message":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"error: insert into \"workflows\" (\"connections\", \"created_at\", \"id\", \"name\", \"nodes\", \"updated_at\", \"user_id\") values ($1, $2, $3, $4, $5, $6, $7) returning \"id\", \"user_id\", \"name\", \"nodes\", \"connections\", \"created_at\", \"updated_at\" - column \"connections\" of relation \"workflows\" does not exist\n at Parser.parseErrorMessage (/app/node_modules/pg-protocol/dist/parser.js:285:98)\n at Parser.handlePacket (/app/node_modules/pg-protocol/dist/parser.js:122:29)\n at Parser.parse (/app/node_modules/pg-protocol/dist/parser.js:35:38)\n at Socket.<anonymous> (/app/node_modules/pg-protocol/dist/index.js:11:42)\n at Socket.emit (node:events:517:28)\n at addChunk (node:internal/streams/readable:368:12)\n at readableAddChunk (node:internal/streams/readable:341:9)\n at Readable.push (node:internal/streams/readable:278:10)\n at TCP.onStreamRead (node:internal/stream_base_commons:190:23)","timestamp":"2025-06-07 08:39:18"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:30:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:41:22"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:30:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:41:26"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:30:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:41:27"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at /app/src/models/workflow.js:49:17\n at Array.map (<anonymous>)\n at getWorkflowsByUserId (/app/src/models/workflow.js:47:20)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getAll (/app/src/controllers/workflow.js:52:23)","timestamp":"2025-06-07 08:41:35"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at /app/src/models/workflow.js:49:17\n at Array.map (<anonymous>)\n at getWorkflowsByUserId (/app/src/models/workflow.js:47:20)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getAll (/app/src/controllers/workflow.js:52:23)","timestamp":"2025-06-07 08:41:35"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:30:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:41:39"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:30:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:41:41"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:36:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:47:54"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:01"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:01"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/7140abc0-968a-4728-925b-27f49f139b6d","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:07"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/7140abc0-968a-4728-925b-27f49f139b6d","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:07"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/88b47234-6ebc-47b5-87a7-f4b70976083b","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:11"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/88b47234-6ebc-47b5-87a7-f4b70976083b","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:11"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/15c42683-3af7-4581-9d2d-0fab1dab07f0","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:14"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/15c42683-3af7-4581-9d2d-0fab1dab07f0","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:78:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:48:14"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:50:57"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:50:57"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"POST","path":"/api/workflows","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at createWorkflow (/app/src/models/workflow.js:54:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async create (/app/src/controllers/workflow.js:30:22)","timestamp":"2025-06-07 08:51:03"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:51:08"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/971f09e0-6490-4be3-a6cb-928e9f2ca5a5","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:51:08"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/7140abc0-968a-4728-925b-27f49f139b6d","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:51:12"}
{"level":"error","message":"SyntaxError: Unexpected token o in JSON at position 1","method":"GET","path":"/api/workflows/7140abc0-968a-4728-925b-27f49f139b6d","service":"flowforge-backend","stack":"SyntaxError: Unexpected token o in JSON at position 1\n at JSON.parse (<anonymous>)\n at getWorkflowById (/app/src/models/workflow.js:96:17)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async getById (/app/src/controllers/workflow.js:75:22)","timestamp":"2025-06-07 08:51:12"}

View File

@ -0,0 +1,62 @@
/**
* Initial database schema for FlowForge
*/
exports.up = function(knex) {
return knex.schema
// Users table
.createTable('users', function(table) {
table.uuid('id').primary();
table.string('email').notNullable().unique();
table.string('password').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
})
// Workflows table
.createTable('workflows', function(table) {
table.uuid('id').primary();
table.uuid('user_id').notNullable().references('id').inTable('users').onDelete('CASCADE');
table.string('name').notNullable();
table.jsonb('nodes').notNullable().defaultTo('[]');
table.jsonb('connections').notNullable().defaultTo('[]');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Index for faster user-based queries
table.index('user_id');
})
// Workflow logs table
.createTable('workflow_logs', function(table) {
table.uuid('id').primary();
table.uuid('workflow_id').notNullable().references('id').inTable('workflows').onDelete('CASCADE');
table.jsonb('logs').notNullable().defaultTo('[]');
table.timestamp('created_at').defaultTo(knex.fn.now());
// Index for faster workflow-based queries
table.index('workflow_id');
})
// Webhook registrations table
.createTable('webhooks', function(table) {
table.uuid('id').primary();
table.uuid('workflow_id').notNullable().references('id').inTable('workflows').onDelete('CASCADE');
table.uuid('node_id').notNullable();
table.string('path').notNullable().unique();
table.string('method').notNullable().defaultTo('POST');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Indexes
table.index('workflow_id');
table.index('path');
});
};
exports.down = function(knex) {
return knex.schema
.dropTableIfExists('webhooks')
.dropTableIfExists('workflow_logs')
.dropTableIfExists('workflows')
.dropTableIfExists('users');
};

31
backend/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "flowforge-backend",
"version": "1.0.0",
"description": "Backend for FlowForge automation platform",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest"
},
"dependencies": {
"bcrypt": "^5.1.0",
"bull": "^4.10.4",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"helmet": "^6.1.5",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"pg": "^8.10.0",
"redis": "^4.6.6",
"uuid": "^9.0.0",
"winston": "^3.8.2"
},
"devDependencies": {
"jest": "^29.5.0",
"nodemon": "^2.0.22",
"supertest": "^6.3.3"
}
}

66
backend/setup-db.sql Normal file
View File

@ -0,0 +1,66 @@
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create workflows table
CREATE TABLE IF NOT EXISTS workflows (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
nodes JSONB,
edges JSONB,
user_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create workflow_versions table
CREATE TABLE IF NOT EXISTS workflow_versions (
id SERIAL PRIMARY KEY,
workflow_id INTEGER REFERENCES workflows(id),
version INTEGER NOT NULL,
nodes JSONB,
edges JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create workflow_executions table
CREATE TABLE IF NOT EXISTS workflow_executions (
id SERIAL PRIMARY KEY,
workflow_id INTEGER REFERENCES workflows(id),
status VARCHAR(50) NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
logs JSONB,
results JSONB
);
-- Create workflow_schedules table
CREATE TABLE IF NOT EXISTS workflow_schedules (
id SERIAL PRIMARY KEY,
workflow_id INTEGER REFERENCES workflows(id),
cron_expression VARCHAR(100) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create webhooks table
CREATE TABLE IF NOT EXISTS webhooks (
id SERIAL PRIMARY KEY,
workflow_id INTEGER REFERENCES workflows(id),
node_id VARCHAR(255) NOT NULL,
path VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert a default admin user
INSERT INTO users (email, password)
VALUES ('admin@flowforge.test', '$2b$10$3euPcmQFCiblsZeEu5s7p.9wVdLajnYhAbcjkru4KkUGBIm3WVYjK')
ON CONFLICT (email) DO NOTHING;
-- Password is 'FlowForge123!' (pre-hashed with bcrypt)

34
backend/src/config/db.js Normal file
View File

@ -0,0 +1,34 @@
const knex = require('knex');
const logger = require('../utils/logger');
// Initialize knex with PostgreSQL configuration
const db = knex({
client: 'pg',
connection: process.env.DATABASE_URL,
pool: {
min: 2,
max: 10
},
migrations: {
tableName: 'knex_migrations',
directory: '../migrations'
},
debug: process.env.NODE_ENV === 'development'
});
// Test database connection
const testConnection = async () => {
try {
await db.raw('SELECT 1');
logger.info('Database connection established successfully');
return true;
} catch (error) {
logger.error('Database connection failed:', error);
return false;
}
};
// Call the test function when this module is imported
testConnection();
module.exports = { db, testConnection };

View File

@ -0,0 +1,146 @@
const jwt = require('jsonwebtoken');
const { createUser, getUserByEmail, verifyPassword } = require('../models/user');
const logger = require('../utils/logger');
/**
* Register a new user
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const register = async (req, res, next) => {
try {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({
error: 'Bad Request',
message: 'Email and password are required'
});
}
// Check if user already exists
const existingUser = await getUserByEmail(email);
if (existingUser) {
return res.status(409).json({
error: 'Conflict',
message: 'User with this email already exists'
});
}
// Create new user
const user = await createUser({ email, password });
// Generate JWT token
const token = jwt.sign(
{ id: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
// Return user data and token
res.status(201).json({
message: 'User registered successfully',
user: {
id: user.id,
email: user.email,
createdAt: user.created_at
},
token
});
} catch (error) {
next(error);
}
};
/**
* Login user
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const login = async (req, res, next) => {
try {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({
error: 'Bad Request',
message: 'Email and password are required'
});
}
// Check if user exists
const user = await getUserByEmail(email);
if (!user) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid credentials'
});
}
// Verify password
const isPasswordValid = await verifyPassword(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid credentials'
});
}
// Generate JWT token
const token = jwt.sign(
{ id: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
// Return user data and token
res.status(200).json({
message: 'Login successful',
user: {
id: user.id,
email: user.email,
createdAt: user.created_at
},
token
});
} catch (error) {
next(error);
}
};
/**
* Get current user profile
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const getProfile = async (req, res, next) => {
try {
// User is already attached to req by auth middleware
res.status(200).json({
user: req.user
});
} catch (error) {
next(error);
}
};
/**
* Logout user (client-side only)
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const logout = (req, res) => {
res.status(200).json({ message: 'Logout successful' });
};
module.exports = {
register,
login,
getProfile,
logout
};

View File

@ -0,0 +1,53 @@
const nodeRegistry = require('../services/nodeRegistry');
const logger = require('../utils/logger');
/**
* Get all available node types
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const getAllNodeTypes = async (req, res, next) => {
try {
const nodeTypes = nodeRegistry.getAllNodeTypes();
res.status(200).json({
count: nodeTypes.length,
nodes: nodeTypes
});
} catch (error) {
next(error);
}
};
/**
* Get a specific node type by type
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const getNodeType = async (req, res, next) => {
try {
const { type } = req.params;
const nodeType = nodeRegistry.getNodeType(type);
if (!nodeType) {
return res.status(404).json({
error: 'Not Found',
message: `Node type not found: ${type}`
});
}
res.status(200).json({
node: nodeType.meta
});
} catch (error) {
next(error);
}
};
module.exports = {
getAllNodeTypes,
getNodeType
};

View File

@ -0,0 +1,60 @@
const { getUserById } = require('../models/user');
const logger = require('../utils/logger');
/**
* Get current user profile
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const getProfile = async (req, res, next) => {
try {
// User is already attached to req by auth middleware
const userId = req.user.id;
// Get full user details from database
const user = await getUserById(userId);
if (!user) {
return res.status(404).json({
error: 'Not Found',
message: 'User not found'
});
}
// Return user data (excluding password)
res.status(200).json({
user: {
id: user.id,
email: user.email,
createdAt: user.created_at
}
});
} catch (error) {
next(error);
}
};
/**
* Update user profile
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const updateProfile = async (req, res, next) => {
try {
// TODO: Implement user profile update functionality
// This would include updating user details in the database
res.status(501).json({
message: 'Profile update functionality not yet implemented'
});
} catch (error) {
next(error);
}
};
module.exports = {
getProfile,
updateProfile
};

View File

@ -0,0 +1,190 @@
const {
createWorkflow,
getWorkflowsByUserId,
getWorkflowById,
updateWorkflow,
deleteWorkflow
} = require('../models/workflow');
const logger = require('../utils/logger');
/**
* Create a new workflow
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const create = async (req, res, next) => {
try {
const { name, nodes, connections } = req.body;
const userId = req.user.id;
// Validate input
if (!name) {
return res.status(400).json({
error: 'Bad Request',
message: 'Workflow name is required'
});
}
// Create workflow
const workflow = await createWorkflow({ name, nodes, connections }, userId);
res.status(201).json({
message: 'Workflow created successfully',
workflow
});
} catch (error) {
next(error);
}
};
/**
* Get all workflows for the current user
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const getAll = async (req, res, next) => {
try {
const userId = req.user.id;
// Get workflows
const workflows = await getWorkflowsByUserId(userId);
res.status(200).json({
count: workflows.length,
workflows
});
} catch (error) {
next(error);
}
};
/**
* Get workflow by ID
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const getById = async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.id;
// Get workflow
const workflow = await getWorkflowById(id, userId);
if (!workflow) {
return res.status(404).json({
error: 'Not Found',
message: 'Workflow not found'
});
}
res.status(200).json({ workflow });
} catch (error) {
next(error);
}
};
/**
* Update workflow
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const update = async (req, res, next) => {
try {
const { id } = req.params;
const { name, nodes, connections } = req.body;
const userId = req.user.id;
// Update workflow
const workflow = await updateWorkflow(id, { name, nodes, connections }, userId);
if (!workflow) {
return res.status(404).json({
error: 'Not Found',
message: 'Workflow not found'
});
}
res.status(200).json({
message: 'Workflow updated successfully',
workflow
});
} catch (error) {
next(error);
}
};
/**
* Delete workflow
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const remove = async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.id;
// Delete workflow
const deleted = await deleteWorkflow(id, userId);
if (!deleted) {
return res.status(404).json({
error: 'Not Found',
message: 'Workflow not found'
});
}
res.status(200).json({
message: 'Workflow deleted successfully'
});
} catch (error) {
next(error);
}
};
/**
* Execute workflow
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {Function} next - Express next middleware function
*/
const execute = async (req, res, next) => {
try {
const { id } = req.params;
const userId = req.user.id;
// Get workflow
const workflow = await getWorkflowById(id, userId);
if (!workflow) {
return res.status(404).json({
error: 'Not Found',
message: 'Workflow not found'
});
}
// TODO: Implement workflow execution logic with BullMQ
// This will be implemented in the workflow execution service
res.status(202).json({
message: 'Workflow execution started',
executionId: 'temp-id' // Will be replaced with actual execution ID
});
} catch (error) {
next(error);
}
};
module.exports = {
create,
getAll,
getById,
update,
remove,
execute
};

58
backend/src/index.js Normal file
View File

@ -0,0 +1,58 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { errorHandler } = require('./middleware/errorHandler');
const logger = require('./utils/logger');
// Import routes
const authRoutes = require('./routes/auth');
const workflowRoutes = require('./routes/workflows');
const userRoutes = require('./routes/users');
const nodeRoutes = require('./routes/nodes');
// Initialize express app
const app = express();
const PORT = process.env.PORT || 4000;
// Middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/workflows', workflowRoutes);
app.use('/api/users', userRoutes);
app.use('/api/nodes', nodeRoutes);
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Error handling middleware
app.use(errorHandler);
// Start server
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
logger.error('Unhandled Rejection:', err);
// Don't crash the server, but log the error
});
module.exports = app; // For testing

View File

@ -0,0 +1,57 @@
const jwt = require('jsonwebtoken');
const { getUserById } = require('../models/user');
/**
* Authentication middleware to protect routes
*/
const authenticate = async (req, res, next) => {
try {
// Get token from header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication token required'
});
}
// Verify token
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Get user from database
const user = await getUserById(decoded.id);
if (!user) {
return res.status(401).json({
error: 'Unauthorized',
message: 'User not found'
});
}
// Attach user to request object
req.user = {
id: user.id,
email: user.email
};
next();
} catch (error) {
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid token'
});
}
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Unauthorized',
message: 'Token expired'
});
}
next(error);
}
};
module.exports = { authenticate };

View File

@ -0,0 +1,41 @@
const logger = require('../utils/logger');
/**
* Global error handling middleware
*/
const errorHandler = (err, req, res, next) => {
// Log the error
logger.error(`${err.name}: ${err.message}`, {
stack: err.stack,
path: req.path,
method: req.method
});
// Default error status and message
const status = err.status || 500;
const message = err.message || 'Internal Server Error';
// Handle specific error types
if (err.name === 'ValidationError') {
return res.status(400).json({
error: 'Validation Error',
message: err.message,
details: err.details
});
}
if (err.name === 'UnauthorizedError') {
return res.status(401).json({
error: 'Unauthorized',
message: 'Invalid or expired token'
});
}
// Return error response
res.status(status).json({
error: err.name || 'Error',
message: status === 500 ? 'Internal Server Error' : message
});
};
module.exports = { errorHandler };

View File

@ -0,0 +1,72 @@
const { db } = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const bcrypt = require('bcrypt');
/**
* Create a new user
* @param {Object} userData - User data
* @returns {Object} Created user
*/
const createUser = async (userData) => {
// Hash password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(userData.password, salt);
// Generate UUID for user
const userId = uuidv4();
// Insert user into database
const [user] = await db('users')
.insert({
id: userId,
email: userData.email.toLowerCase(),
password: hashedPassword,
created_at: new Date()
})
.returning(['id', 'email', 'created_at']);
return user;
};
/**
* Get user by email
* @param {string} email - User email
* @returns {Object|null} User object or null if not found
*/
const getUserByEmail = async (email) => {
const user = await db('users')
.where({ email: email.toLowerCase() })
.first();
return user;
};
/**
* Get user by ID
* @param {string} id - User ID
* @returns {Object|null} User object or null if not found
*/
const getUserById = async (id) => {
const user = await db('users')
.where({ id })
.first();
return user;
};
/**
* Verify user password
* @param {string} password - Plain text password
* @param {string} hashedPassword - Hashed password from database
* @returns {boolean} True if password matches, false otherwise
*/
const verifyPassword = async (password, hashedPassword) => {
return await bcrypt.compare(password, hashedPassword);
};
module.exports = {
createUser,
getUserByEmail,
getUserById,
verifyPassword
};

View File

@ -0,0 +1,181 @@
const { db } = require('../config/db');
const { v4: uuidv4 } = require('uuid');
/**
* Safely parse JSON data
* @param {string|Object} data - Data to parse
* @returns {Object} Parsed data
*/
const safeJsonParse = (data) => {
if (!data) return [];
if (typeof data === 'string') {
try {
return JSON.parse(data);
} catch (error) {
console.error('Error parsing JSON:', error);
return [];
}
}
return data; // Already an object
};
/**
* Safely stringify JSON data
* @param {string|Object} data - Data to stringify
* @returns {string} Stringified data
*/
const safeJsonStringify = (data) => {
if (!data) return '[]';
if (typeof data === 'string') {
return data; // Already a string
}
return JSON.stringify(data);
};
/**
* Create a new workflow
* @param {Object} workflowData - Workflow data
* @param {string} userId - User ID
* @returns {Object} Created workflow
*/
const createWorkflow = async (workflowData, userId) => {
// Generate UUID for workflow
const workflowId = uuidv4();
// Safely stringify nodes and connections
const nodesToStore = safeJsonStringify(workflowData.nodes || []);
const connectionsToStore = safeJsonStringify(workflowData.connections || []);
// Insert workflow into database
const [workflow] = await db('workflows')
.insert({
id: workflowId,
user_id: userId,
name: workflowData.name,
nodes: nodesToStore,
connections: connectionsToStore,
edges: connectionsToStore, // Also store in edges for backward compatibility
created_at: new Date(),
updated_at: new Date()
})
.returning(['id', 'user_id', 'name', 'nodes', 'connections', 'edges', 'created_at', 'updated_at']);
// Safely parse JSON fields
return {
...workflow,
nodes: safeJsonParse(workflow.nodes),
connections: safeJsonParse(workflow.connections || workflow.edges)
};
};
/**
* Get all workflows for a user
* @param {string} userId - User ID
* @returns {Array} Array of workflows
*/
const getWorkflowsByUserId = async (userId) => {
const workflows = await db('workflows')
.where({ user_id: userId })
.select('*')
.orderBy('updated_at', 'desc');
// Safely parse JSON fields
return workflows.map(workflow => ({
...workflow,
nodes: safeJsonParse(workflow.nodes),
connections: safeJsonParse(workflow.connections || workflow.edges)
}));
};
/**
* Get workflow by ID
* @param {string} id - Workflow ID
* @param {string} userId - User ID (for authorization)
* @returns {Object|null} Workflow object or null if not found
*/
const getWorkflowById = async (id, userId) => {
const workflow = await db('workflows')
.where({ id, user_id: userId })
.first();
if (!workflow) {
return null;
}
// Safely parse JSON fields
// Use connections if available, otherwise fall back to edges
const connections = workflow.connections ? safeJsonParse(workflow.connections) : safeJsonParse(workflow.edges);
return {
...workflow,
nodes: safeJsonParse(workflow.nodes),
connections: connections
};
};
/**
* Update workflow
* @param {string} id - Workflow ID
* @param {Object} workflowData - Updated workflow data
* @param {string} userId - User ID (for authorization)
* @returns {Object|null} Updated workflow or null if not found
*/
const updateWorkflow = async (id, workflowData, userId) => {
// Check if workflow exists and belongs to user
const existingWorkflow = await getWorkflowById(id, userId);
if (!existingWorkflow) {
return null;
}
// Safely stringify nodes and connections
const nodesToStore = safeJsonStringify(workflowData.nodes || existingWorkflow.nodes);
const connectionsToStore = safeJsonStringify(workflowData.connections || existingWorkflow.connections);
// Update workflow
const [workflow] = await db('workflows')
.where({ id, user_id: userId })
.update({
name: workflowData.name || existingWorkflow.name,
nodes: nodesToStore,
connections: connectionsToStore,
edges: connectionsToStore, // Also update edges for backward compatibility
updated_at: new Date()
})
.returning(['id', 'user_id', 'name', 'nodes', 'connections', 'edges', 'created_at', 'updated_at']);
// Safely parse JSON fields
return {
...workflow,
nodes: safeJsonParse(workflow.nodes),
connections: safeJsonParse(workflow.connections || workflow.edges)
};
};
/**
* Delete workflow
* @param {string} id - Workflow ID
* @param {string} userId - User ID (for authorization)
* @returns {boolean} True if deleted, false if not found
*/
const deleteWorkflow = async (id, userId) => {
// Check if workflow exists and belongs to user
const existingWorkflow = await getWorkflowById(id, userId);
if (!existingWorkflow) {
return false;
}
// Delete workflow
await db('workflows')
.where({ id, user_id: userId })
.delete();
return true;
};
module.exports = {
createWorkflow,
getWorkflowsByUserId,
getWorkflowById,
updateWorkflow,
deleteWorkflow
};

View File

@ -0,0 +1,18 @@
{
"name": "Delay",
"type": "delay",
"icon": "clock",
"description": "Pause workflow execution for a specified time",
"category": "Flow Control",
"version": "1.0.0",
"configSchema": [
{ "key": "duration", "type": "number", "label": "Duration", "required": true, "default": 1000 },
{ "key": "unit", "type": "select", "label": "Unit", "options": ["milliseconds", "seconds", "minutes", "hours"], "default": "milliseconds" }
],
"inputs": [
{ "key": "input", "label": "Input" }
],
"outputs": [
{ "key": "output", "label": "Output" }
]
}

View File

@ -0,0 +1,47 @@
const logger = require('../../utils/logger');
/**
* Delay Node Runner
* Pauses workflow execution for a specified time
*/
async function run(nodeConfig, inputData) {
try {
// Calculate delay in milliseconds
let delayMs = nodeConfig.duration || 1000;
// Convert based on unit
switch (nodeConfig.unit) {
case 'seconds':
delayMs *= 1000;
break;
case 'minutes':
delayMs *= 60 * 1000;
break;
case 'hours':
delayMs *= 60 * 60 * 1000;
break;
default:
// Default is milliseconds, no conversion needed
break;
}
logger.debug('Delay node executing', {
duration: nodeConfig.duration,
unit: nodeConfig.unit,
delayMs
});
// Create a promise that resolves after the delay
await new Promise(resolve => setTimeout(resolve, delayMs));
// Pass through the input data to the output
return {
output: inputData.input || {}
};
} catch (error) {
logger.error('Delay node error', { error: error.message });
throw error;
}
}
module.exports = { run };

View File

@ -0,0 +1,33 @@
{
"name": "Email",
"type": "email",
"icon": "envelope",
"description": "Send an email via SMTP",
"category": "Communication",
"version": "1.0.0",
"configSchema": [
{ "key": "to", "type": "text", "label": "To", "required": true },
{ "key": "subject", "type": "text", "label": "Subject", "required": true },
{ "key": "body", "type": "text", "label": "Body", "multiline": true, "required": true },
{ "key": "isHtml", "type": "boolean", "label": "HTML Content", "default": false },
{ "key": "from", "type": "text", "label": "From (optional)" },
{ "key": "cc", "type": "text", "label": "CC" },
{ "key": "bcc", "type": "text", "label": "BCC" },
{ "key": "smtpConfig", "type": "object", "label": "SMTP Configuration", "properties": [
{ "key": "host", "type": "text", "label": "Host", "required": true },
{ "key": "port", "type": "number", "label": "Port", "required": true, "default": 587 },
{ "key": "secure", "type": "boolean", "label": "Use SSL/TLS", "default": false },
{ "key": "auth", "type": "object", "label": "Authentication", "properties": [
{ "key": "user", "type": "text", "label": "Username", "required": true },
{ "key": "pass", "type": "password", "label": "Password", "required": true }
]}
]}
],
"inputs": [
{ "key": "input", "label": "Input" }
],
"outputs": [
{ "key": "output", "label": "Output" },
{ "key": "error", "label": "Error" }
]
}

View File

@ -0,0 +1,78 @@
const nodemailer = require('nodemailer');
const logger = require('../../utils/logger');
/**
* Email Node Runner
* Sends an email using SMTP
*/
async function run(nodeConfig, inputData) {
try {
logger.debug('Email node executing');
// Check required configuration
if (!nodeConfig.to || !nodeConfig.subject || !nodeConfig.body) {
throw new Error('Missing required email configuration (to, subject, or body)');
}
if (!nodeConfig.smtpConfig || !nodeConfig.smtpConfig.host) {
throw new Error('Missing SMTP configuration');
}
// Create transporter
const transporter = nodemailer.createTransport({
host: nodeConfig.smtpConfig.host,
port: nodeConfig.smtpConfig.port || 587,
secure: nodeConfig.smtpConfig.secure || false,
auth: nodeConfig.smtpConfig.auth ? {
user: nodeConfig.smtpConfig.auth.user,
pass: nodeConfig.smtpConfig.auth.pass
} : undefined
});
// Prepare email options
const mailOptions = {
from: nodeConfig.from,
to: nodeConfig.to,
subject: nodeConfig.subject,
cc: nodeConfig.cc,
bcc: nodeConfig.bcc
};
// Set email content based on HTML flag
if (nodeConfig.isHtml) {
mailOptions.html = nodeConfig.body;
} else {
mailOptions.text = nodeConfig.body;
}
// Send email
const info = await transporter.sendMail(mailOptions);
logger.debug('Email sent successfully', {
messageId: info.messageId,
to: nodeConfig.to
});
// Return success response
return {
output: {
success: true,
messageId: info.messageId,
response: info.response,
timestamp: new Date().toISOString()
}
};
} catch (error) {
logger.error('Email node error', { error: error.message });
// Return error through the error output
return {
error: {
message: error.message,
stack: error.stack
}
};
}
}
module.exports = { run };

View File

@ -0,0 +1,19 @@
{
"name": "Function",
"type": "function",
"icon": "code",
"description": "Execute custom JavaScript code",
"category": "Advanced",
"version": "1.0.0",
"configSchema": [
{ "key": "code", "type": "code", "language": "javascript", "label": "Code", "required": true, "default": "// Input data is available as the 'input' variable\n// Must return an object with the output data\nreturn { result: input };" },
{ "key": "timeout", "type": "number", "label": "Timeout (ms)", "default": 5000 }
],
"inputs": [
{ "key": "input", "label": "Input" }
],
"outputs": [
{ "key": "output", "label": "Output" },
{ "key": "error", "label": "Error" }
]
}

View File

@ -0,0 +1,81 @@
const { VM } = require('vm2');
const logger = require('../../utils/logger');
/**
* Function Node Runner
* Executes custom JavaScript code in a sandboxed environment
*/
async function run(nodeConfig, inputData) {
try {
logger.debug('Function node executing');
// Check required configuration
if (!nodeConfig.code) {
throw new Error('Missing function code');
}
// Set timeout (default: 5000ms)
const timeout = nodeConfig.timeout || 5000;
// Create a sandboxed VM
const vm = new VM({
timeout,
sandbox: {
// Provide input data to the sandbox
input: inputData.input || {},
// Provide console methods that log to our logger
console: {
log: (...args) => logger.info('Function node log:', ...args),
info: (...args) => logger.info('Function node info:', ...args),
warn: (...args) => logger.warn('Function node warn:', ...args),
error: (...args) => logger.error('Function node error:', ...args)
}
},
// Prevent access to Node.js internal modules
require: {
external: false,
builtin: ['path', 'util', 'buffer'],
root: "./",
mock: {
fs: {
readFileSync: () => 'Not allowed'
}
}
}
});
// Wrap the code in an async function
const wrappedCode = `
(async function() {
${nodeConfig.code}
})();
`;
// Execute the code
const result = await vm.run(wrappedCode);
// Validate the result
if (result === undefined || result === null) {
return {
output: {}
};
}
// Return the result
return {
output: result
};
} catch (error) {
logger.error('Function node error', { error: error.message });
// Return error through the error output
return {
error: {
message: error.message,
stack: error.stack
}
};
}
}
module.exports = { run };

View File

@ -0,0 +1,22 @@
{
"name": "HTTP Request",
"type": "http-request",
"icon": "globe",
"description": "Send an HTTP request to an external API",
"category": "Network",
"version": "1.0.0",
"configSchema": [
{ "key": "url", "type": "text", "label": "URL", "required": true },
{ "key": "method", "type": "select", "label": "Method", "options": ["GET", "POST", "PUT", "DELETE", "PATCH"], "default": "GET" },
{ "key": "headers", "type": "keyValue", "label": "Headers" },
{ "key": "body", "type": "json", "label": "Body", "showIf": { "method": ["POST", "PUT", "PATCH"] } },
{ "key": "timeout", "type": "number", "label": "Timeout (ms)", "default": 5000 }
],
"inputs": [
{ "key": "input", "label": "Input" }
],
"outputs": [
{ "key": "output", "label": "Output" },
{ "key": "error", "label": "Error" }
]
}

View File

@ -0,0 +1,81 @@
const axios = require('axios');
const logger = require('../../utils/logger');
/**
* HTTP Request Node Runner
* Executes an HTTP request based on the node configuration
*/
async function run(nodeConfig, inputData) {
try {
logger.debug('HTTP Request node executing', {
url: nodeConfig.url,
method: nodeConfig.method
});
// Build request config
const requestConfig = {
url: nodeConfig.url,
method: nodeConfig.method || 'GET',
timeout: nodeConfig.timeout || 5000
};
// Add headers if provided
if (nodeConfig.headers && Object.keys(nodeConfig.headers).length > 0) {
requestConfig.headers = nodeConfig.headers;
}
// Add request body for POST, PUT, PATCH methods
if (['POST', 'PUT', 'PATCH'].includes(requestConfig.method) && nodeConfig.body) {
try {
// If body is a string that looks like JSON, parse it
if (typeof nodeConfig.body === 'string') {
requestConfig.data = JSON.parse(nodeConfig.body);
} else {
requestConfig.data = nodeConfig.body;
}
} catch (error) {
// If parsing fails, use as is
requestConfig.data = nodeConfig.body;
}
}
// Execute the HTTP request
const response = await axios(requestConfig);
// Return the response data
return {
output: {
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data
}
};
} catch (error) {
logger.error('HTTP Request node error', {
error: error.message,
url: nodeConfig.url
});
// Format axios error response
const errorResponse = {
message: error.message,
code: error.code
};
// Add response data if available
if (error.response) {
errorResponse.status = error.response.status;
errorResponse.statusText = error.response.statusText;
errorResponse.headers = error.response.headers;
errorResponse.data = error.response.data;
}
// Return error through the error output
return {
error: errorResponse
};
}
}
module.exports = { run };

View File

@ -0,0 +1,19 @@
{
"name": "Logger",
"type": "logger",
"icon": "file-text",
"description": "Log data to the console and workflow logs",
"category": "Utility",
"version": "1.0.0",
"configSchema": [
{ "key": "level", "type": "select", "label": "Log Level", "options": ["debug", "info", "warn", "error"], "default": "info" },
{ "key": "message", "type": "text", "label": "Message", "required": true },
{ "key": "logInputData", "type": "boolean", "label": "Log Input Data", "default": true }
],
"inputs": [
{ "key": "input", "label": "Input" }
],
"outputs": [
{ "key": "output", "label": "Output" }
]
}

View File

@ -0,0 +1,55 @@
const logger = require('../../utils/logger');
/**
* Logger Node Runner
* Logs data to the console and workflow logs
*/
async function run(nodeConfig, inputData) {
try {
const level = nodeConfig.level || 'info';
const message = nodeConfig.message || 'Logger node executed';
const logInputData = nodeConfig.logInputData !== false; // Default to true
// Prepare log data
const logData = {
message,
timestamp: new Date().toISOString()
};
// Add input data if configured
if (logInputData && inputData && inputData.input) {
logData.data = inputData.input;
}
// Log with the appropriate level
switch (level) {
case 'debug':
logger.debug(message, logData);
break;
case 'warn':
logger.warn(message, logData);
break;
case 'error':
logger.error(message, logData);
break;
case 'info':
default:
logger.info(message, logData);
break;
}
// Pass through the input data to the output
return {
output: inputData.input || {}
};
} catch (error) {
logger.error('Logger node error', { error: error.message });
// Even if there's an error, try to pass through the input data
return {
output: inputData.input || {}
};
}
}
module.exports = { run };

View File

@ -0,0 +1,17 @@
{
"name": "Webhook",
"type": "webhook",
"icon": "link",
"description": "Trigger a workflow via a webhook endpoint",
"category": "Trigger",
"version": "1.0.0",
"configSchema": [
{ "key": "path", "type": "text", "label": "Path", "required": true, "description": "The webhook path (e.g., /my-webhook)" },
{ "key": "method", "type": "select", "label": "Method", "options": ["GET", "POST", "PUT", "DELETE", "ANY"], "default": "POST" },
{ "key": "description", "type": "text", "label": "Description", "multiline": true }
],
"inputs": [],
"outputs": [
{ "key": "output", "label": "Output" }
]
}

View File

@ -0,0 +1,50 @@
const logger = require('../../utils/logger');
/**
* Webhook Node Runner
* This node doesn't have a traditional "run" function since it's triggered by HTTP requests.
* Instead, it provides functions to register and handle webhook endpoints.
*/
/**
* Generate a unique webhook URL for a workflow node
* @param {string} workflowId - The workflow ID
* @param {string} nodeId - The node ID
* @returns {string} The webhook path
*/
function generateWebhookPath(workflowId, nodeId) {
return `/webhook/${workflowId}/${nodeId}`;
}
/**
* Handle an incoming webhook request
* @param {Object} req - Express request object
* @param {Object} nodeConfig - Node configuration
* @returns {Object} Data to pass to the next node
*/
function handleRequest(req, nodeConfig) {
logger.debug('Webhook node triggered', {
path: req.path,
method: req.method
});
// Prepare the output data
const output = {
method: req.method,
path: req.path,
query: req.query || {},
params: req.params || {},
headers: req.headers || {},
body: req.body || {}
};
// Return the output data
return {
output
};
}
module.exports = {
generateWebhookPath,
handleRequest
};

View File

@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const { register, login, getProfile, logout } = require('../controllers/auth');
const { authenticate } = require('../middleware/auth');
// Public routes
router.post('/register', register);
router.post('/login', login);
router.post('/logout', logout);
// Protected routes
router.get('/me', authenticate, getProfile);
module.exports = router;

View File

@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { getAllNodeTypes, getNodeType } = require('../controllers/node');
const { authenticate } = require('../middleware/auth');
// All node routes require authentication
router.use(authenticate);
// Get all node types
router.get('/', getAllNodeTypes);
// Get specific node type
router.get('/:type', getNodeType);
module.exports = router;

View File

@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { getProfile, updateProfile } = require('../controllers/user');
const { authenticate } = require('../middleware/auth');
// All user routes require authentication
router.use(authenticate);
// Get current user profile
router.get('/me', getProfile);
// Update user profile
router.put('/me', updateProfile);
module.exports = router;

View File

@ -0,0 +1,26 @@
const express = require('express');
const router = express.Router();
const {
create,
getAll,
getById,
update,
remove,
execute
} = require('../controllers/workflow');
const { authenticate } = require('../middleware/auth');
// All workflow routes require authentication
router.use(authenticate);
// CRUD operations
router.post('/', create);
router.get('/', getAll);
router.get('/:id', getById);
router.put('/:id', update);
router.delete('/:id', remove);
// Workflow execution
router.post('/:id/execute', execute);
module.exports = router;

View File

@ -0,0 +1,116 @@
const fs = require('fs');
const path = require('path');
const logger = require('../utils/logger');
class NodeRegistry {
constructor() {
this.nodes = new Map();
this.nodesPath = path.join(__dirname, '../nodes');
}
/**
* Initialize the node registry by loading all available nodes
*/
async initialize() {
try {
logger.info('Initializing node registry');
// Get all node type directories
const nodeTypes = fs.readdirSync(this.nodesPath, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
// Load each node type
for (const nodeType of nodeTypes) {
await this.loadNodeType(nodeType);
}
logger.info(`Node registry initialized with ${this.nodes.size} node types`);
return true;
} catch (error) {
logger.error('Failed to initialize node registry', { error: error.message });
return false;
}
}
/**
* Load a specific node type
* @param {string} nodeType - The node type to load
*/
async loadNodeType(nodeType) {
try {
const nodePath = path.join(this.nodesPath, nodeType);
// Load meta.json
const metaPath = path.join(nodePath, 'meta.json');
if (!fs.existsSync(metaPath)) {
logger.warn(`Node type ${nodeType} missing meta.json, skipping`);
return;
}
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
// Load runner.js
const runnerPath = path.join(nodePath, 'runner.js');
if (!fs.existsSync(runnerPath)) {
logger.warn(`Node type ${nodeType} missing runner.js, skipping`);
return;
}
const runner = require(runnerPath);
// Register the node type
this.nodes.set(nodeType, {
meta,
runner
});
logger.debug(`Loaded node type: ${nodeType}`);
} catch (error) {
logger.error(`Failed to load node type: ${nodeType}`, { error: error.message });
}
}
/**
* Get all registered node types
* @returns {Array} Array of node type metadata
*/
getAllNodeTypes() {
return Array.from(this.nodes.values()).map(node => node.meta);
}
/**
* Get a specific node type by type
* @param {string} nodeType - The node type to get
* @returns {Object|null} Node type or null if not found
*/
getNodeType(nodeType) {
return this.nodes.get(nodeType) || null;
}
/**
* Execute a node
* @param {string} nodeType - The node type to execute
* @param {Object} nodeConfig - Node configuration
* @param {Object} inputData - Input data for the node
* @returns {Promise<Object>} Node execution result
*/
async executeNode(nodeType, nodeConfig, inputData) {
const node = this.getNodeType(nodeType);
if (!node) {
throw new Error(`Node type not found: ${nodeType}`);
}
if (!node.runner.run) {
throw new Error(`Node type ${nodeType} does not have a run method`);
}
return await node.runner.run(nodeConfig, inputData);
}
}
// Create and export a singleton instance
const nodeRegistry = new NodeRegistry();
module.exports = nodeRegistry;

View File

@ -0,0 +1,306 @@
const Bull = require('bull');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../config/db');
const nodeRegistry = require('./nodeRegistry');
const logger = require('../utils/logger');
class WorkflowExecutor {
constructor() {
// Initialize the workflow queue
this.workflowQueue = new Bull('workflow-execution', process.env.REDIS_URL);
// Initialize the execution logs map
this.executionLogs = new Map();
// Set up queue processing
this.setupQueueProcessor();
}
/**
* Set up the queue processor
*/
setupQueueProcessor() {
this.workflowQueue.process(async (job) => {
const { workflowId, executionId, triggerNodeId, triggerData } = job.data;
return await this.processWorkflow(workflowId, executionId, triggerNodeId, triggerData);
});
// Handle completed jobs
this.workflowQueue.on('completed', (job, result) => {
logger.info(`Workflow execution completed: ${job.data.executionId}`, {
workflowId: job.data.workflowId,
success: result.success
});
});
// Handle failed jobs
this.workflowQueue.on('failed', (job, error) => {
logger.error(`Workflow execution failed: ${job.data.executionId}`, {
workflowId: job.data.workflowId,
error: error.message
});
});
}
/**
* Execute a workflow
* @param {string} workflowId - The workflow ID
* @param {string} triggerNodeId - The ID of the trigger node
* @param {Object} triggerData - Data from the trigger
* @returns {Object} Execution details
*/
async executeWorkflow(workflowId, triggerNodeId, triggerData) {
// Generate a unique execution ID
const executionId = uuidv4();
// Initialize execution logs
this.executionLogs.set(executionId, []);
// Add the job to the queue
await this.workflowQueue.add({
workflowId,
executionId,
triggerNodeId,
triggerData
});
logger.info(`Workflow execution queued: ${executionId}`, { workflowId });
return {
executionId,
status: 'queued',
timestamp: new Date().toISOString()
};
}
/**
* Process a workflow execution
* @param {string} workflowId - The workflow ID
* @param {string} executionId - The execution ID
* @param {string} triggerNodeId - The ID of the trigger node
* @param {Object} triggerData - Data from the trigger
* @returns {Object} Execution result
*/
async processWorkflow(workflowId, executionId, triggerNodeId, triggerData) {
try {
// Log execution start
this.logExecution(executionId, 'info', 'Workflow execution started', {
workflowId,
triggerNodeId
});
// Get the workflow from the database
const workflow = await db('workflows')
.where({ id: workflowId })
.first();
if (!workflow) {
throw new Error(`Workflow not found: ${workflowId}`);
}
// Parse JSON fields
const nodes = JSON.parse(workflow.nodes);
const connections = JSON.parse(workflow.connections);
// Find the trigger node
const triggerNode = nodes.find(node => node.id === triggerNodeId);
if (!triggerNode) {
throw new Error(`Trigger node not found: ${triggerNodeId}`);
}
// Execute the workflow starting from the trigger node
const result = await this.executeNode(
executionId,
triggerNode,
nodes,
connections,
triggerData
);
// Log execution completion
this.logExecution(executionId, 'info', 'Workflow execution completed', {
workflowId,
success: true
});
// Store execution logs in the database
await this.saveExecutionLogs(workflowId, executionId);
return {
success: true,
executionId,
result
};
} catch (error) {
// Log execution error
this.logExecution(executionId, 'error', 'Workflow execution failed', {
workflowId,
error: error.message
});
// Store execution logs in the database
await this.saveExecutionLogs(workflowId, executionId);
return {
success: false,
executionId,
error: error.message
};
}
}
/**
* Execute a node and follow connections to next nodes
* @param {string} executionId - The execution ID
* @param {Object} node - The node to execute
* @param {Array} allNodes - All nodes in the workflow
* @param {Array} connections - All connections in the workflow
* @param {Object} inputData - Input data for the node
* @returns {Object} Node execution results
*/
async executeNode(executionId, node, allNodes, connections, inputData) {
try {
// Log node execution start
this.logExecution(executionId, 'debug', `Executing node: ${node.id}`, {
nodeType: node.type,
nodeId: node.id
});
// Execute the node
const nodeResult = await nodeRegistry.executeNode(
node.type,
node.config || {},
{ input: inputData }
);
// Log node execution result
this.logExecution(executionId, 'debug', `Node execution result: ${node.id}`, {
nodeId: node.id,
outputs: Object.keys(nodeResult)
});
// Find connections from this node
const nodeConnections = connections.filter(conn => conn.from === node.id);
// Execute connected nodes
const nextResults = {};
for (const conn of nodeConnections) {
const nextNode = allNodes.find(n => n.id === conn.to);
if (nextNode) {
// Get the output data from the current node
const outputData = nodeResult.output || {};
// Execute the next node
nextResults[nextNode.id] = await this.executeNode(
executionId,
nextNode,
allNodes,
connections,
outputData
);
}
}
// Return combined results
return {
nodeId: node.id,
nodeType: node.type,
result: nodeResult,
nextNodes: nextResults
};
} catch (error) {
// Log node execution error
this.logExecution(executionId, 'error', `Node execution error: ${node.id}`, {
nodeId: node.id,
error: error.message
});
throw error;
}
}
/**
* Log an execution event
* @param {string} executionId - The execution ID
* @param {string} level - Log level (debug, info, warn, error)
* @param {string} message - Log message
* @param {Object} data - Additional log data
*/
logExecution(executionId, level, message, data = {}) {
// Create log entry
const logEntry = {
timestamp: new Date().toISOString(),
level,
message,
data
};
// Add to execution logs
if (this.executionLogs.has(executionId)) {
this.executionLogs.get(executionId).push(logEntry);
} else {
this.executionLogs.set(executionId, [logEntry]);
}
// Also log to system logger
logger[level](message, { executionId, ...data });
}
/**
* Save execution logs to the database
* @param {string} workflowId - The workflow ID
* @param {string} executionId - The execution ID
*/
async saveExecutionLogs(workflowId, executionId) {
try {
// Get logs for this execution
const logs = this.executionLogs.get(executionId) || [];
// Insert into database
await db('workflow_logs').insert({
id: executionId,
workflow_id: workflowId,
logs: JSON.stringify(logs),
created_at: new Date()
});
// Clear logs from memory
this.executionLogs.delete(executionId);
} catch (error) {
logger.error('Failed to save execution logs', {
executionId,
error: error.message
});
}
}
/**
* Get execution logs
* @param {string} executionId - The execution ID
* @returns {Array} Execution logs
*/
async getExecutionLogs(executionId) {
// Check if logs are in memory
if (this.executionLogs.has(executionId)) {
return this.executionLogs.get(executionId);
}
// Get logs from database
const logRecord = await db('workflow_logs')
.where({ id: executionId })
.first();
if (!logRecord) {
return [];
}
return JSON.parse(logRecord.logs);
}
}
// Create and export a singleton instance
const workflowExecutor = new WorkflowExecutor();
module.exports = workflowExecutor;

View File

@ -0,0 +1,34 @@
const winston = require('winston');
// Define log format
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
);
// Create the logger instance
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: logFormat,
defaultMeta: { service: 'flowforge-backend' },
transports: [
// Write all logs with level 'error' and below to error.log
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
// Write all logs to combined.log
new winston.transports.File({ filename: 'logs/combined.log' }),
],
});
// If we're not in production, also log to the console
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}));
}
module.exports = logger;

24
backend/test-bcrypt.js Normal file
View File

@ -0,0 +1,24 @@
const bcrypt = require('bcrypt');
async function testBcrypt() {
const password = 'FlowForge123!';
// Generate a new hash
const salt = await bcrypt.genSalt(10);
const hash = await bcrypt.hash(password, salt);
console.log('Generated hash:', hash);
console.log('Hash length:', hash.length);
// Test the hash we're using in the database
const dbHash = '$2b$10$3euPcmQFCiblsZeEu5s7p.9wVdLajnYhAbcjkru4KkUGBIm3WVYjK';
// Compare the password with both hashes
const isValidNew = await bcrypt.compare(password, hash);
const isValidDB = await bcrypt.compare(password, dbHash);
console.log('Is valid with new hash:', isValidNew);
console.log('Is valid with DB hash:', isValidDB);
}
testBcrypt().catch(console.error);

68
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,68 @@
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- REACT_APP_API_URL=http://localhost:4000
networks:
- flowforge-network
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
volumes:
- ./backend:/app
- /app/node_modules
ports:
- "4000:4000"
environment:
- NODE_ENV=development
- PORT=4000
- DATABASE_URL=postgres://postgres:postgres@postgres:5432/flowforge
- REDIS_URL=redis://redis:6379
- JWT_SECRET=dev_secret_change_in_production
depends_on:
- postgres
- redis
networks:
- flowforge-network
postgres:
image: postgres:14-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=flowforge
ports:
- "5432:5432"
volumes:
- postgres-dev-data:/var/lib/postgresql/data
networks:
- flowforge-network
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-dev-data:/data
networks:
- flowforge-network
networks:
flowforge-network:
driver: bridge
volumes:
postgres-dev-data:
redis-dev-data:

73
docker-compose.yml Normal file
View File

@ -0,0 +1,73 @@
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- backend
networks:
- flowforge-network
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
env_file:
- .env
depends_on:
- postgres
- redis
networks:
- flowforge-network
postgres:
image: postgres:14-alpine
restart: unless-stopped
env_file:
- .env
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- flowforge-network
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis-data:/data
networks:
- flowforge-network
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf:/etc/nginx/conf.d
- ./nginx/certbot/conf:/etc/letsencrypt
- ./nginx/certbot/www:/var/www/certbot
depends_on:
- frontend
- backend
networks:
- flowforge-network
certbot:
image: certbot/certbot
volumes:
- ./nginx/certbot/conf:/etc/letsencrypt
- ./nginx/certbot/www:/var/www/certbot
networks:
flowforge-network:
driver: bridge
volumes:
postgres-data:
redis-data:

29
frontend/Dockerfile Normal file
View File

@ -0,0 +1,29 @@
# Build stage
FROM node:18-alpine as build
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets from the build stage
COPY --from=build /app/build /usr/share/nginx/html
# Copy nginx configuration
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

18
frontend/Dockerfile.dev Normal file
View File

@ -0,0 +1,18 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source code (this will be overridden by volume mount in dev)
COPY . .
# Expose port
EXPOSE 3000
# Start development server with hot reloading
CMD ["npm", "start"]

View File

@ -0,0 +1,203 @@
# FlowForge Frontend Integration Guide
This guide provides instructions for integrating the new components we've created into the existing WorkflowEditor.js file.
## Step 1: Import New Components
Add these imports at the top of your `WorkflowEditor.js` file:
```javascript
import Modal from '../components/common/Modal';
import WorkflowEditorTabs from '../components/workflow/WorkflowEditorTabs';
import WorkflowEditorActions from '../components/workflow/WorkflowEditorActions';
import NodeTester from '../components/workflow/NodeTester';
import ExecutionResults from '../components/execution/ExecutionResults';
```
## Step 2: Add New State Variables
Add these state variables inside your WorkflowEditor component:
```javascript
const [showNodeTester, setShowNodeTester] = useState(false);
const [showExecutionResults, setShowExecutionResults] = useState(false);
const [latestExecutionId, setLatestExecutionId] = useState(null);
const [currentVersion, setCurrentVersion] = useState(1);
const [showTabs, setShowTabs] = useState(true);
```
## Step 3: Add New Methods
Add these methods inside your WorkflowEditor component:
```javascript
// Duplicate a node
const handleDuplicateNode = (node) => {
const newNode = {
...node,
id: `${node.id}-copy-${Date.now()}`,
position: {
x: node.position.x + 50,
y: node.position.y + 50
}
};
setNodes((nds) => nds.concat(newNode));
};
// Delete a node
const handleDeleteNode = (node) => {
setNodes((nds) => nds.filter((n) => n.id !== node.id));
setEdges((eds) => eds.filter((e) => e.source !== node.id && e.target !== node.id));
setSelectedNode(null);
};
// Handle workflow execution
const handleExecuteWorkflow = (executionId) => {
setLatestExecutionId(executionId);
};
// Handle version restoration
const handleRestoreVersion = async (version) => {
try {
setIsLoading(true);
const response = await api.get(`/api/workflows/${id}/versions/${version}`);
const workflowData = response.data.workflow;
// Update workflow data
setWorkflow({
...workflow,
name: workflowData.name,
description: workflowData.description
});
// Convert backend nodes/connections to React Flow format
const flowNodes = workflowData.nodes.map(node => ({
id: node.id,
type: 'customNode',
position: { x: node.position_x, y: node.position_y },
data: {
label: node.name,
nodeType: node.type,
config: node.config || {}
}
}));
const flowEdges = workflowData.connections.map(conn => ({
id: conn.id,
source: conn.source_node_id,
target: conn.target_node_id,
sourceHandle: conn.source_handle,
targetHandle: conn.target_handle
}));
setNodes(flowNodes);
setEdges(flowEdges);
setCurrentVersion(version);
toast.success(`Restored workflow to version ${version}`);
} catch (error) {
console.error('Error restoring version:', error);
toast.error('Failed to restore workflow version');
} finally {
setIsLoading(false);
}
};
```
## Step 4: Update the Return Statement
Replace the existing buttons in the workflow header with the WorkflowEditorActions component:
```jsx
<div className="flex space-x-2">
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
<WorkflowEditorActions
workflowId={id}
selectedNode={selectedNode}
onDuplicateNode={handleDuplicateNode}
onDeleteNode={handleDeleteNode}
onExecuteWorkflow={handleExecuteWorkflow}
/>
</div>
```
## Step 5: Add the Tabs Component
Add the WorkflowEditorTabs component at the bottom of your component, just before the closing div of the main container:
```jsx
{/* Workflow Tabs */}
{id && id !== 'new' && showTabs && (
<div className="border-t border-gray-200">
<WorkflowEditorTabs
workflowId={id}
nodes={nodes}
currentVersion={currentVersion}
onRestoreVersion={handleRestoreVersion}
/>
</div>
)}
```
## Step 6: Add Toggle Button for Tabs
Add a button to toggle the tabs visibility in the workflow header:
```jsx
<button
onClick={() => setShowTabs(!showTabs)}
className="ml-2 inline-flex items-center p-1 border border-gray-300 rounded text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
{showTabs ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5 10a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clipRule="evenodd" />
</svg>
)}
</button>
```
## Step 7: Fetch Current Version on Load
Update the fetchData method to get the current version:
```javascript
// If editing existing workflow, fetch it
if (id && id !== 'new') {
const workflowResponse = await api.get(`/api/workflows/${id}`);
const workflowData = workflowResponse.data.workflow;
setWorkflow({
id: workflowData.id,
name: workflowData.name,
description: workflowData.description
});
// Set current version
setCurrentVersion(workflowData.version || 1);
// Rest of the existing code...
}
```
## Testing Your Integration
After making these changes, you should be able to:
1. Test individual nodes with the NodeTester component
2. View execution history in the tabs
3. Schedule workflows with the CronScheduler component
4. Manage workflow versions with the VersionHistory component
5. View and copy webhook URLs with the WebhookManager component
If you encounter any issues, check the browser console for errors and verify that all the components are properly imported and integrated.

45
frontend/nginx/nginx.conf Normal file
View File

@ -0,0 +1,45 @@
server {
listen 80;
server_name localhost;
# Root directory for static files
root /usr/share/nginx/html;
index index.html;
# Compression settings
gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/xml;
gzip_disable "MSIE [1-6]\.";
# Security headers
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
# Handle React Router paths
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# API proxy
location /api/ {
proxy_pass http://backend:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

46
frontend/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "flowforge-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18",
"axios": "^1.4.0",
"jwt-decode": "^3.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"reactflow": "^11.7.0",
"react-router-dom": "^6.14.1",
"react-scripts": "5.0.1",
"react-toastify": "^9.1.3",
"tailwindcss": "^3.3.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24"
}
}

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0ea5e9" />
<meta
name="description"
content="FlowForge - Self-hosted personal automation platform"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>FlowForge</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,25 @@
{
"short_name": "FlowForge",
"name": "FlowForge Automation Platform",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#0ea5e9",
"background_color": "#f9fafb"
}

67
frontend/src/App.js Normal file
View File

@ -0,0 +1,67 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './hooks/useAuth';
// Layouts
import MainLayout from './components/layouts/MainLayout';
import AuthLayout from './components/layouts/AuthLayout';
// Pages
import Dashboard from './pages/Dashboard';
import WorkflowEditor from './pages/WorkflowEditor';
import WorkflowList from './pages/WorkflowList';
import Login from './pages/Login';
import Register from './pages/Register';
import NotFound from './pages/NotFound';
import Profile from './pages/Profile';
import TestPage from './pages/TestPage';
import TemplatesPage from './pages/TemplatesPage';
// Protected route component
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <div className="flex items-center justify-center h-screen">Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
};
function App() {
return (
<Routes>
{/* Auth routes */}
<Route path="/" element={<AuthLayout />}>
<Route path="login" element={<Login />} />
<Route path="register" element={<Register />} />
</Route>
{/* Protected routes */}
<Route path="/" element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}>
<Route index element={<Dashboard />} />
<Route path="workflows" element={<WorkflowList />} />
<Route path="workflows/new" element={<WorkflowEditor />} />
<Route path="workflows/:id" element={<WorkflowEditor />} />
<Route path="profile" element={<Profile />} />
<Route path="templates" element={<TemplatesPage />} />
</Route>
{/* Test route - accessible without authentication */}
<Route path="/test" element={<TestPage />} />
{/* 404 route */}
<Route path="*" element={<NotFound />} />
</Routes>
);
}
export default App;

View File

@ -0,0 +1,92 @@
import React, { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
const Modal = ({
isOpen,
onClose,
title,
children,
size = 'md',
showCloseButton = true,
footer = null
}) => {
// Size classes
const sizeClasses = {
sm: 'sm:max-w-lg',
md: 'sm:max-w-xl',
lg: 'sm:max-w-3xl',
xl: 'sm:max-w-5xl',
full: 'sm:max-w-full sm:h-screen'
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="fixed z-10 inset-0 overflow-y-auto" onClose={onClose}>
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className={`inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle ${sizeClasses[size]} sm:w-full`}>
{/* Header */}
{title && (
<div className="bg-white px-4 py-5 border-b border-gray-200 sm:px-6 flex justify-between items-center">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
{title}
</Dialog.Title>
{showCloseButton && (
<button
type="button"
className="bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
onClick={onClose}
>
<span className="sr-only">Close</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
)}
</div>
)}
{/* Body */}
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
{children}
</div>
{/* Footer */}
{footer && (
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
{footer}
</div>
)}
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};
export default Modal;

View File

@ -0,0 +1,304 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import workflowService from '../../services/workflow';
import Modal from '../common/Modal';
import ExecutionResults from './ExecutionResults';
import {
ClockIcon,
CheckCircleIcon,
ExclamationCircleIcon,
ArrowPathIcon,
EyeIcon
} from '@heroicons/react/24/outline';
const ExecutionHistory = ({ workflowId }) => {
const [executions, setExecutions] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedExecution, setSelectedExecution] = useState(null);
const [showExecutionModal, setShowExecutionModal] = useState(false);
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
total: 0
});
// Fetch execution history
useEffect(() => {
const fetchExecutionHistory = async () => {
try {
setLoading(true);
const response = await workflowService.getExecutionHistory(workflowId, {
limit: pagination.limit,
offset: (pagination.page - 1) * pagination.limit
}).catch(err => {
// If executions not found, handle gracefully (new workflow)
if (err.response && err.response.status === 404) {
console.log('No execution history found - this is likely a new workflow');
return { data: { executions: [], total: 0 } };
}
throw err;
});
setExecutions(response.data.executions || []);
setPagination(prev => ({
...prev,
total: response.data.total || 0
}));
} catch (error) {
console.error('Error fetching execution history:', error);
// Don't show error toast for 404s as they're expected for new workflows
if (!error.response || error.response.status !== 404) {
toast.error('Failed to load execution history');
}
} finally {
setLoading(false);
}
};
if (workflowId) {
fetchExecutionHistory();
}
}, [workflowId, pagination.page, pagination.limit]);
// Format timestamp
const formatTimestamp = (timestamp) => {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleString();
};
// Format duration
const formatDuration = (startTime, endTime) => {
if (!startTime || !endTime) return '';
const start = new Date(startTime);
const end = new Date(endTime);
const durationMs = end - start;
if (durationMs < 1000) {
return `${durationMs}ms`;
} else if (durationMs < 60000) {
return `${(durationMs / 1000).toFixed(2)}s`;
} else {
const minutes = Math.floor(durationMs / 60000);
const seconds = ((durationMs % 60000) / 1000).toFixed(0);
return `${minutes}m ${seconds}s`;
}
};
// Get status icon
const getStatusIcon = (status) => {
switch (status) {
case 'success':
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case 'error':
return <ExclamationCircleIcon className="h-5 w-5 text-red-500" />;
case 'running':
return <ArrowPathIcon className="h-5 w-5 text-blue-500 animate-spin" />;
default:
return <ClockIcon className="h-5 w-5 text-gray-500" />;
}
};
// View execution details
const viewExecution = (execution) => {
setSelectedExecution(execution);
setShowExecutionModal(true);
};
// Handle page change
const handlePageChange = (newPage) => {
if (newPage > 0 && newPage <= Math.ceil(pagination.total / pagination.limit)) {
setPagination(prev => ({
...prev,
page: newPage
}));
}
};
return (
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 border-b border-gray-200 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Execution History
</h3>
<p className="mt-1 text-sm text-gray-500">
View past executions of this workflow
</p>
</div>
{loading ? (
<div className="px-4 py-5 sm:p-6">
<div className="animate-pulse space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 bg-gray-200 rounded"></div>
))}
</div>
</div>
) : executions.length > 0 ? (
<>
<ul className="divide-y divide-gray-200">
{executions.map((execution) => (
<li key={execution.id} className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="flex-shrink-0">
{getStatusIcon(execution.status)}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
Execution #{execution.id}
</div>
<div className="text-xs text-gray-500">
Started: {formatTimestamp(execution.started_at)}
</div>
{execution.completed_at && (
<div className="text-xs text-gray-500">
Duration: {formatDuration(execution.started_at, execution.completed_at)}
</div>
)}
</div>
</div>
<div>
<button
onClick={() => viewExecution(execution)}
className="inline-flex items-center p-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<EyeIcon className="h-4 w-4" />
</button>
</div>
</div>
</li>
))}
</ul>
{/* Pagination */}
{pagination.total > pagination.limit && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page >= Math.ceil(pagination.total / pagination.limit)}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{(pagination.page - 1) * pagination.limit + 1}</span> to{' '}
<span className="font-medium">
{Math.min(pagination.page * pagination.limit, pagination.total)}
</span>{' '}
of <span className="font-medium">{pagination.total}</span> results
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
<span className="sr-only">Previous</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</button>
{/* Page numbers */}
{[...Array(Math.ceil(pagination.total / pagination.limit))].map((_, i) => {
const pageNumber = i + 1;
const isCurrentPage = pageNumber === pagination.page;
// Show limited page numbers
if (
pageNumber === 1 ||
pageNumber === Math.ceil(pagination.total / pagination.limit) ||
(pageNumber >= pagination.page - 1 && pageNumber <= pagination.page + 1)
) {
return (
<button
key={pageNumber}
onClick={() => handlePageChange(pageNumber)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
isCurrentPage
? 'z-10 bg-primary-50 border-primary-500 text-primary-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{pageNumber}
</button>
);
}
// Show ellipsis
if (
(pageNumber === 2 && pagination.page > 3) ||
(pageNumber === Math.ceil(pagination.total / pagination.limit) - 1 && pagination.page < Math.ceil(pagination.total / pagination.limit) - 2)
) {
return (
<span
key={pageNumber}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700"
>
...
</span>
);
}
return null;
})}
<button
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page >= Math.ceil(pagination.total / pagination.limit)}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
<span className="sr-only">Next</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</div>
)}
</>
) : (
<div className="px-4 py-5 sm:p-6 text-center">
<ClockIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No executions yet</h3>
<p className="mt-1 text-sm text-gray-500">
This workflow hasn't been executed yet.
</p>
</div>
)}
{/* Execution details modal */}
{selectedExecution && (
<Modal
isOpen={showExecutionModal}
onClose={() => setShowExecutionModal(false)}
size="lg"
>
<ExecutionResults
workflowId={workflowId}
executionId={selectedExecution.id}
onClose={() => setShowExecutionModal(false)}
/>
</Modal>
)}
</div>
);
};
export default ExecutionHistory;

View File

@ -0,0 +1,262 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import api from '../../services/api';
import { XMarkIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
const ExecutionResults = ({ workflowId, executionId, onClose }) => {
const [execution, setExecution] = useState(null);
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [expandedNodes, setExpandedNodes] = useState({});
useEffect(() => {
const fetchExecutionResults = async () => {
try {
setLoading(true);
// Fetch execution details
const executionResponse = await api.get(`/api/workflows/${workflowId}/executions/${executionId}`)
.catch(err => {
// If execution not found, handle gracefully
if (err.response && err.response.status === 404) {
console.log('Execution not found or still initializing');
return { data: { execution: { status: 'initializing' } } };
}
throw err;
});
setExecution(executionResponse.data.execution);
// Only fetch logs if execution exists and is not initializing
if (executionResponse.data.execution && executionResponse.data.execution.status !== 'initializing') {
const logsResponse = await api.get(`/api/workflows/${workflowId}/executions/${executionId}/logs`)
.catch(err => {
// If logs not found, handle gracefully
if (err.response && err.response.status === 404) {
return { data: { logs: [] } };
}
throw err;
});
setLogs(logsResponse.data.logs || []);
}
} catch (error) {
console.error('Error fetching execution results:', error);
toast.error('Failed to load execution results');
} finally {
setLoading(false);
}
};
if (workflowId && executionId) {
fetchExecutionResults();
}
}, [workflowId, executionId]);
// Toggle expanded state for a node
const toggleNodeExpanded = (nodeId) => {
setExpandedNodes(prev => ({
...prev,
[nodeId]: !prev[nodeId]
}));
};
// Format timestamp
const formatTimestamp = (timestamp) => {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleString();
};
// Format duration
const formatDuration = (startTime, endTime) => {
if (!startTime || !endTime) return '';
const start = new Date(startTime);
const end = new Date(endTime);
const durationMs = end - start;
if (durationMs < 1000) {
return `${durationMs}ms`;
} else if (durationMs < 60000) {
return `${(durationMs / 1000).toFixed(2)}s`;
} else {
const minutes = Math.floor(durationMs / 60000);
const seconds = ((durationMs % 60000) / 1000).toFixed(0);
return `${minutes}m ${seconds}s`;
}
};
// Get status badge
const getStatusBadge = (status) => {
switch (status) {
case 'success':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Success
</span>
);
case 'error':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Error
</span>
);
case 'running':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Running
</span>
);
case 'initializing':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Initializing
</span>
);
default:
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{status}
</span>
);
}
};
return (
<div className="bg-white shadow-xl rounded-lg overflow-hidden max-w-4xl w-full max-h-[80vh] flex flex-col">
{/* Header */}
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Execution Results
</h3>
<button
onClick={onClose}
className="rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="p-6 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary-500"></div>
</div>
) : (
<div className="divide-y divide-gray-200">
{/* Execution summary */}
{execution && (
<div className="px-4 py-5 sm:p-6">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-gray-500">Status</dt>
<dd className="mt-1">{getStatusBadge(execution.status)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Started At</dt>
<dd className="mt-1 text-sm text-gray-900">{formatTimestamp(execution.started_at)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Completed At</dt>
<dd className="mt-1 text-sm text-gray-900">{formatTimestamp(execution.completed_at)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Duration</dt>
<dd className="mt-1 text-sm text-gray-900">
{formatDuration(execution.started_at, execution.completed_at)}
</dd>
</div>
{execution.error && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500">Error</dt>
<dd className="mt-1 text-sm text-red-600 bg-red-50 p-3 rounded">
{execution.error}
</dd>
</div>
)}
</dl>
</div>
)}
{/* Node execution logs */}
<div className="px-4 py-5 sm:p-6">
<h4 className="text-base font-medium text-gray-900 mb-4">Node Execution Logs</h4>
{logs.length > 0 ? (
<div className="space-y-4">
{logs.map((log) => (
<div key={log.id} className="border border-gray-200 rounded-md overflow-hidden">
<div
className="bg-gray-50 px-4 py-3 flex justify-between items-center cursor-pointer"
onClick={() => toggleNodeExpanded(log.id)}
>
<div className="flex items-center">
<div className="font-medium text-gray-900">{log.node_name}</div>
<div className="ml-2 text-xs text-gray-500">({log.node_type})</div>
<div className="ml-3">{getStatusBadge(log.status)}</div>
</div>
<div className="flex items-center">
<div className="text-xs text-gray-500 mr-2">
{formatDuration(log.started_at, log.completed_at)}
</div>
{expandedNodes[log.id] ? (
<ChevronUpIcon className="h-5 w-5 text-gray-400" />
) : (
<ChevronDownIcon className="h-5 w-5 text-gray-400" />
)}
</div>
</div>
{expandedNodes[log.id] && (
<div className="px-4 py-3 border-t border-gray-200">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<h5 className="text-sm font-medium text-gray-700 mb-1">Input</h5>
<pre className="text-xs bg-gray-50 p-2 rounded overflow-x-auto">
{JSON.stringify(log.input_data, null, 2)}
</pre>
</div>
<div>
<h5 className="text-sm font-medium text-gray-700 mb-1">Output</h5>
<pre className="text-xs bg-gray-50 p-2 rounded overflow-x-auto">
{JSON.stringify(log.output_data, null, 2)}
</pre>
</div>
</div>
{log.error && (
<div className="mt-3">
<h5 className="text-sm font-medium text-red-700 mb-1">Error</h5>
<pre className="text-xs bg-red-50 text-red-700 p-2 rounded overflow-x-auto">
{log.error}
</pre>
</div>
)}
</div>
)}
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm">No execution logs available</p>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-3 bg-gray-50 text-right sm:px-6">
<button
onClick={onClose}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Close
</button>
</div>
</div>
);
};
export default ExecutionResults;

View File

@ -0,0 +1,37 @@
import React from 'react';
import { Outlet, Navigate } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';
const AuthLayout = () => {
const { isAuthenticated, isLoading } = useAuth();
// Show loading indicator while checking authentication
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary-500"></div>
</div>
);
}
// Redirect to dashboard if already authenticated
if (isAuthenticated) {
return <Navigate to="/" replace />;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<h1 className="text-4xl font-extrabold text-primary-600">FlowForge</h1>
<p className="mt-2 text-sm text-gray-600">
Your personal automation platform
</p>
</div>
<Outlet />
</div>
</div>
);
};
export default AuthLayout;

View File

@ -0,0 +1,187 @@
import React, { useState } from 'react';
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';
// Icons
import {
Bars3Icon,
XMarkIcon,
HomeIcon,
ArrowPathIcon,
DocumentDuplicateIcon,
UserIcon,
ArrowRightOnRectangleIcon
} from '@heroicons/react/24/outline';
const MainLayout = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { currentUser, logout } = useAuth();
const location = useLocation();
const navigate = useNavigate();
// Navigation items
const navigation = [
{ name: 'Dashboard', href: '/', icon: HomeIcon },
{ name: 'Workflows', href: '/workflows', icon: ArrowPathIcon },
{ name: 'Templates', href: '/templates', icon: DocumentDuplicateIcon },
];
// Check if a navigation item is active
const isActive = (path) => {
if (path === '/') {
return location.pathname === '/';
}
return location.pathname.startsWith(path);
};
return (
<div className="h-screen flex overflow-hidden bg-gray-100">
{/* Mobile sidebar */}
<div className={`${sidebarOpen ? 'block' : 'hidden'} md:hidden fixed inset-0 z-40`}>
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)}></div>
<div className="relative flex-1 flex flex-col max-w-xs w-full pt-5 pb-4 bg-primary-700">
<div className="absolute top-0 right-0 -mr-12 pt-2">
<button
type="button"
className="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
onClick={() => setSidebarOpen(false)}
>
<span className="sr-only">Close sidebar</span>
<XMarkIcon className="h-6 w-6 text-white" aria-hidden="true" />
</button>
</div>
<div className="flex-shrink-0 flex items-center px-4">
<h1 className="text-2xl font-bold text-white">FlowForge</h1>
</div>
<div className="mt-5 flex-1 h-0 overflow-y-auto">
<nav className="px-2 space-y-1">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
className={`${
isActive(item.href)
? 'bg-primary-800 text-white'
: 'text-primary-100 hover:bg-primary-600'
} group flex items-center px-2 py-2 text-base font-medium rounded-md`}
>
<item.icon
className="mr-4 h-6 w-6 text-primary-300"
aria-hidden="true"
/>
{item.name}
</Link>
))}
</nav>
</div>
<div className="flex-shrink-0 flex border-t border-primary-800 p-4">
<div className="flex items-center">
<div className="ml-3">
<p className="text-base font-medium text-white">{currentUser?.email}</p>
<button
onClick={logout}
className="text-sm font-medium text-primary-200 hover:text-white flex items-center"
>
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />
Sign out
</button>
</div>
</div>
</div>
</div>
</div>
{/* Desktop sidebar */}
<div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1">
<div className="flex items-center h-16 flex-shrink-0 px-4 bg-primary-700">
<h1 className="text-2xl font-bold text-white">FlowForge</h1>
</div>
<div className="flex-1 flex flex-col overflow-y-auto bg-primary-700">
<nav className="flex-1 px-2 py-4 space-y-1">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
className={`${
isActive(item.href)
? 'bg-primary-800 text-white'
: 'text-primary-100 hover:bg-primary-600'
} group flex items-center px-2 py-2 text-sm font-medium rounded-md`}
>
<item.icon
className="mr-3 h-5 w-5 text-primary-300"
aria-hidden="true"
/>
{item.name}
</Link>
))}
</nav>
</div>
<div className="flex-shrink-0 flex border-t border-primary-800 p-4 bg-primary-700">
<div className="flex items-center">
<div>
<p className="text-sm font-medium text-white">{currentUser?.email}</p>
<button
onClick={logout}
className="text-xs font-medium text-primary-200 hover:text-white flex items-center"
>
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />
Sign out
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Main content */}
<div className="flex flex-col w-0 flex-1 overflow-hidden">
<div className="relative z-10 flex-shrink-0 flex h-16 bg-white shadow">
<button
type="button"
className="px-4 border-r border-gray-200 text-gray-500 md:hidden"
onClick={() => setSidebarOpen(true)}
>
<span className="sr-only">Open sidebar</span>
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
</button>
<div className="flex-1 px-4 flex justify-between">
<div className="flex-1 flex items-center">
<h1 className="text-xl font-semibold text-gray-900">
{location.pathname === '/' && 'Dashboard'}
{location.pathname === '/workflows' && 'Workflows'}
{location.pathname.startsWith('/workflows/new') && 'Create Workflow'}
{location.pathname.match(/^\/workflows\/[^/]+$/) && 'Edit Workflow'}
{location.pathname === '/templates' && 'Automation Templates'}
{location.pathname === '/profile' && 'Profile'}
</h1>
</div>
<div className="ml-4 flex items-center md:ml-6">
<button
type="button"
className="p-1 rounded-full text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
onClick={() => navigate('/profile')}
>
<span className="sr-only">View profile</span>
<UserIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>
</div>
<main className="flex-1 relative overflow-y-auto focus:outline-none">
<div className="py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<Outlet />
</div>
</div>
</main>
</div>
</div>
);
};
export default MainLayout;

View File

@ -0,0 +1,353 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import api from '../../services/api';
import {
CalendarIcon,
ClockIcon,
PlusIcon,
TrashIcon
} from '@heroicons/react/24/outline';
const CronScheduler = ({ workflowId }) => {
const [schedules, setSchedules] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddForm, setShowAddForm] = useState(false);
const [newSchedule, setNewSchedule] = useState({
cron: '0 0 * * *', // Default: daily at midnight
enabled: true,
name: ''
});
// Predefined cron expressions
const cronPresets = [
{ label: 'Every minute', value: '* * * * *' },
{ label: 'Every hour', value: '0 * * * *' },
{ label: 'Every day at midnight', value: '0 0 * * *' },
{ label: 'Every Monday at 9:00 AM', value: '0 9 * * 1' },
{ label: 'Every month on the 1st at midnight', value: '0 0 1 * *' }
];
// Fetch schedules
useEffect(() => {
const fetchSchedules = async () => {
try {
setLoading(true);
const response = await api.get(`/api/workflows/${workflowId}/schedules`)
.catch(err => {
// If schedules not found, handle gracefully (new workflow)
if (err.response && err.response.status === 404) {
console.log('No schedules found - this is likely a new workflow');
return { data: { schedules: [] } };
}
throw err;
});
setSchedules(response.data.schedules || []);
} catch (error) {
console.error('Error fetching schedules:', error);
// Don't show error toast for 404s as they're expected for new workflows
if (!error.response || error.response.status !== 404) {
toast.error('Failed to load workflow schedules');
}
} finally {
setLoading(false);
}
};
if (workflowId) {
fetchSchedules();
}
}, [workflowId]);
// Add new schedule
const handleAddSchedule = async (e) => {
e.preventDefault();
if (!newSchedule.name.trim()) {
toast.error('Schedule name is required');
return;
}
try {
const response = await api.post(`/api/workflows/${workflowId}/schedules`, newSchedule);
setSchedules([...schedules, response.data.schedule]);
setNewSchedule({
cron: '0 0 * * *',
enabled: true,
name: ''
});
setShowAddForm(false);
toast.success('Schedule added successfully');
} catch (error) {
console.error('Error adding schedule:', error);
toast.error('Failed to add schedule');
}
};
// Delete schedule
const handleDeleteSchedule = async (scheduleId) => {
try {
await api.delete(`/api/workflows/${workflowId}/schedules/${scheduleId}`);
setSchedules(schedules.filter(s => s.id !== scheduleId));
toast.success('Schedule deleted successfully');
} catch (error) {
console.error('Error deleting schedule:', error);
toast.error('Failed to delete schedule');
}
};
// Toggle schedule enabled/disabled
const handleToggleSchedule = async (schedule) => {
try {
const updatedSchedule = { ...schedule, enabled: !schedule.enabled };
await api.put(`/api/workflows/${workflowId}/schedules/${schedule.id}`, updatedSchedule);
setSchedules(schedules.map(s => s.id === schedule.id ? { ...s, enabled: !s.enabled } : s));
toast.success(`Schedule ${updatedSchedule.enabled ? 'enabled' : 'disabled'} successfully`);
} catch (error) {
console.error('Error updating schedule:', error);
toast.error('Failed to update schedule');
}
};
// Get human-readable description of cron expression
const getCronDescription = (cron) => {
const preset = cronPresets.find(p => p.value === cron);
if (preset) {
return preset.label;
}
// Basic descriptions for common patterns
const parts = cron.split(' ');
if (parts.length !== 5) return cron;
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
if (minute === '*' && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
return 'Every minute';
}
if (minute === '0' && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
return 'Every hour';
}
if (minute === '0' && hour === '0' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
return 'Every day at midnight';
}
return cron;
};
// Format next run time
const formatNextRun = (nextRun) => {
if (!nextRun) return 'Not scheduled';
const date = new Date(nextRun);
return date.toLocaleString();
};
return (
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 border-b border-gray-200 sm:px-6 flex justify-between items-center">
<div>
<h3 className="text-lg leading-6 font-medium text-gray-900">
Scheduled Executions
</h3>
<p className="mt-1 text-sm text-gray-500">
Set up automatic workflow execution on a schedule
</p>
</div>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" />
Add Schedule
</button>
</div>
{/* Add schedule form */}
{showAddForm && (
<div className="px-4 py-5 sm:p-6 border-b border-gray-200">
<form onSubmit={handleAddSchedule}>
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<label htmlFor="schedule-name" className="block text-sm font-medium text-gray-700">
Schedule Name
</label>
<div className="mt-1">
<input
type="text"
id="schedule-name"
value={newSchedule.name}
onChange={(e) => setNewSchedule({ ...newSchedule, name: e.target.value })}
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="Daily Backup"
required
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="cron-expression" className="block text-sm font-medium text-gray-700">
Cron Expression
</label>
<div className="mt-1">
<input
type="text"
id="cron-expression"
value={newSchedule.cron}
onChange={(e) => setNewSchedule({ ...newSchedule, cron: e.target.value })}
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder="0 0 * * *"
required
/>
</div>
</div>
<div className="sm:col-span-6">
<label className="block text-sm font-medium text-gray-700">
Preset Schedules
</label>
<div className="mt-1 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{cronPresets.map((preset) => (
<button
key={preset.value}
type="button"
onClick={() => setNewSchedule({ ...newSchedule, cron: preset.value })}
className="inline-flex items-center px-2.5 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
{preset.label}
</button>
))}
</div>
<p className="mt-2 text-sm text-gray-500">
Format: minute hour day-of-month month day-of-week
</p>
</div>
<div className="sm:col-span-6">
<div className="flex items-start">
<div className="flex items-center h-5">
<input
id="schedule-enabled"
type="checkbox"
checked={newSchedule.enabled}
onChange={(e) => setNewSchedule({ ...newSchedule, enabled: e.target.checked })}
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="schedule-enabled" className="font-medium text-gray-700">
Enabled
</label>
<p className="text-gray-500">Enable this schedule immediately after creation</p>
</div>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:flex sm:flex-row-reverse">
<button
type="submit"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
>
Add Schedule
</button>
<button
type="button"
onClick={() => setShowAddForm(false)}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Schedules list */}
{loading ? (
<div className="px-4 py-5 sm:p-6">
<div className="animate-pulse space-y-4">
{[...Array(2)].map((_, i) => (
<div key={i} className="h-16 bg-gray-200 rounded"></div>
))}
</div>
</div>
) : schedules.length > 0 ? (
<ul className="divide-y divide-gray-200">
{schedules.map((schedule) => (
<li key={schedule.id} className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className={`flex-shrink-0 h-10 w-10 rounded-md flex items-center justify-center ${
schedule.enabled ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-600'
}`}>
<CalendarIcon className="h-6 w-6" />
</div>
<div className="ml-4">
<div className="flex items-center">
<div className="text-sm font-medium text-gray-900">{schedule.name}</div>
<span className={`ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
schedule.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}>
{schedule.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="text-sm text-gray-500">
{getCronDescription(schedule.cron)}
</div>
<div className="flex items-center text-xs text-gray-500 mt-1">
<ClockIcon className="h-4 w-4 mr-1" />
Next run: {formatNextRun(schedule.next_run)}
</div>
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleToggleSchedule(schedule)}
className={`inline-flex items-center p-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded ${
schedule.enabled
? 'text-red-700 hover:bg-red-50'
: 'text-green-700 hover:bg-green-50'
} bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500`}
>
{schedule.enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => handleDeleteSchedule(schedule.id)}
className="inline-flex items-center p-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<TrashIcon className="h-4 w-4 text-red-500" />
</button>
</div>
</div>
</li>
))}
</ul>
) : (
<div className="px-4 py-5 sm:p-6 text-center">
<CalendarIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No schedules</h3>
<p className="mt-1 text-sm text-gray-500">
Create a schedule to run this workflow automatically.
</p>
{!showAddForm && (
<div className="mt-6">
<button
type="button"
onClick={() => setShowAddForm(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PlusIcon className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
New Schedule
</button>
</div>
)}
</div>
)}
</div>
);
};
export default CronScheduler;

View File

@ -0,0 +1,108 @@
import React, { memo } from 'react';
import { Handle, Position } from 'reactflow';
const getNodeColor = (type) => {
switch (type) {
case 'webhook':
return 'border-blue-500';
case 'http-request':
return 'border-green-500';
case 'function':
return 'border-purple-500';
case 'delay':
return 'border-amber-500';
case 'email':
return 'border-red-500';
case 'logger':
return 'border-gray-500';
default:
return 'border-gray-300';
}
};
const getNodeIcon = (type) => {
switch (type) {
case 'webhook':
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
case 'http-request':
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
);
case 'function':
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
);
case 'delay':
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
case 'email':
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
);
case 'logger':
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
);
default:
return (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
);
}
};
const CustomNode = ({ data, selected }) => {
const nodeColor = getNodeColor(data.nodeType);
const nodeIcon = getNodeIcon(data.nodeType);
return (
<div className={`${selected ? 'ring-2 ring-primary-400' : ''} bg-white rounded-md shadow-md border-l-4 ${nodeColor} p-3 min-w-[180px]`}>
{/* Input handle */}
<Handle
type="target"
position={Position.Top}
className="w-3 h-3 rounded-full bg-gray-400 border-2 border-white"
/>
{/* Node content */}
<div className="flex items-center">
<div className="flex-shrink-0 text-gray-500">
{nodeIcon}
</div>
<div className="ml-2 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">
{data.label}
</div>
<div className="text-xs text-gray-500">
{data.nodeType}
</div>
</div>
</div>
{/* Output handle */}
<Handle
type="source"
position={Position.Bottom}
className="w-3 h-3 rounded-full bg-gray-400 border-2 border-white"
/>
</div>
);
};
export default memo(CustomNode);

View File

@ -0,0 +1,275 @@
import React, { useState, useEffect } from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import api from '../../services/api';
const NodeConfigPanel = ({ node, onConfigUpdate, onClose }) => {
const [nodeMeta, setNodeMeta] = useState(null);
const [config, setConfig] = useState({});
const [nodeName, setNodeName] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchNodeMeta = async () => {
try {
setLoading(true);
const response = await api.get(`/api/nodes/${node.data.nodeType}/meta`);
setNodeMeta(response.data);
// Initialize config with existing values or defaults
const initialConfig = { ...node.data.config };
setConfig(initialConfig);
// Set node name
setNodeName(node.data.label);
} catch (error) {
console.error('Error fetching node metadata:', error);
} finally {
setLoading(false);
}
};
if (node) {
fetchNodeMeta();
}
}, [node]);
// Handle config field change
const handleConfigChange = (key, value) => {
const updatedConfig = { ...config, [key]: value };
setConfig(updatedConfig);
onConfigUpdate(node.id, updatedConfig);
};
// Handle node name change
const handleNameChange = (e) => {
const newName = e.target.value;
setNodeName(newName);
// Update node data in parent component
const updatedNode = {
...node,
data: {
...node.data,
label: newName
}
};
// This will trigger a re-render of the node with the new name
onConfigUpdate(node.id, node.data.config, updatedNode);
};
// Render form field based on schema type
const renderFormField = (key, schema) => {
const value = config[key] !== undefined ? config[key] : (schema.default || '');
switch (schema.type) {
case 'string':
if (schema.enum) {
return (
<select
id={key}
value={value}
onChange={(e) => handleConfigChange(key, e.target.value)}
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
>
{schema.enum.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
);
}
return (
<input
type="text"
id={key}
value={value}
onChange={(e) => handleConfigChange(key, e.target.value)}
placeholder={schema.description || ''}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
);
case 'number':
case 'integer':
return (
<input
type="number"
id={key}
value={value}
onChange={(e) => handleConfigChange(key, parseFloat(e.target.value))}
placeholder={schema.description || ''}
min={schema.minimum}
max={schema.maximum}
step={schema.type === 'integer' ? 1 : 'any'}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
);
case 'boolean':
return (
<input
type="checkbox"
id={key}
checked={!!value}
onChange={(e) => handleConfigChange(key, e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
);
case 'object':
return (
<textarea
id={key}
value={typeof value === 'object' ? JSON.stringify(value, null, 2) : value}
onChange={(e) => {
try {
const parsedValue = JSON.parse(e.target.value);
handleConfigChange(key, parsedValue);
} catch (error) {
// Allow invalid JSON during typing
handleConfigChange(key, e.target.value);
}
}}
rows={5}
placeholder={schema.description || ''}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm font-mono"
/>
);
default:
return (
<input
type="text"
id={key}
value={value}
onChange={(e) => handleConfigChange(key, e.target.value)}
placeholder={schema.description || ''}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
);
}
};
if (loading) {
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Node Configuration</h3>
<button
onClick={onClose}
className="rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded"></div>
<div className="h-24 bg-gray-200 rounded"></div>
<div className="h-8 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Node Configuration</h3>
<button
onClick={onClose}
className="rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="space-y-6">
{/* Node name */}
<div>
<label htmlFor="node-name" className="block text-sm font-medium text-gray-700">
Node Name
</label>
<input
type="text"
id="node-name"
value={nodeName}
onChange={handleNameChange}
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
{/* Node type info */}
<div>
<h4 className="text-sm font-medium text-gray-700">Node Type</h4>
<p className="mt-1 text-sm text-gray-500">{node.data.nodeType}</p>
{nodeMeta?.description && (
<p className="mt-1 text-xs text-gray-500">{nodeMeta.description}</p>
)}
</div>
{/* Configuration fields */}
{nodeMeta?.config?.schema?.properties && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Configuration</h4>
<div className="space-y-4">
{Object.entries(nodeMeta.config.schema.properties).map(([key, schema]) => (
<div key={key}>
<label htmlFor={key} className="block text-sm font-medium text-gray-700">
{schema.title || key}
{schema.required && <span className="text-red-500">*</span>}
</label>
{schema.description && (
<p className="mt-1 text-xs text-gray-500">{schema.description}</p>
)}
<div className="mt-1">
{renderFormField(key, schema)}
</div>
</div>
))}
</div>
</div>
)}
{/* Input/Output info */}
<div className="pt-4 border-t border-gray-200">
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Inputs</h4>
{nodeMeta?.inputs?.length > 0 ? (
<ul className="text-xs text-gray-500 space-y-1">
{nodeMeta.inputs.map((input, index) => (
<li key={index} className="flex items-center">
<span className="w-2 h-2 bg-gray-400 rounded-full mr-2"></span>
{input.name || 'Default'}
</li>
))}
</ul>
) : (
<p className="text-xs text-gray-500">No inputs</p>
)}
</div>
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Outputs</h4>
{nodeMeta?.outputs?.length > 0 ? (
<ul className="text-xs text-gray-500 space-y-1">
{nodeMeta.outputs.map((output, index) => (
<li key={index} className="flex items-center">
<span className="w-2 h-2 bg-gray-400 rounded-full mr-2"></span>
{output.name || 'Default'}
</li>
))}
</ul>
) : (
<p className="text-xs text-gray-500">No outputs</p>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default NodeConfigPanel;

View File

@ -0,0 +1,197 @@
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import nodeService from '../../services/nodes';
import Modal from '../common/Modal';
import { PlayIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
const NodeTester = ({ node, onClose }) => {
const [inputData, setInputData] = useState('{}');
const [testResults, setTestResults] = useState(null);
const [loading, setLoading] = useState(false);
const [inputError, setInputError] = useState(null);
// Test node configuration with provided input
const handleTestNode = async () => {
// Validate JSON input
try {
JSON.parse(inputData);
setInputError(null);
} catch (error) {
setInputError('Invalid JSON format');
return;
}
try {
setLoading(true);
const parsedInput = JSON.parse(inputData);
const response = await nodeService.testNodeConfig(
node.data.nodeType,
node.data.config || {},
parsedInput
);
setTestResults(response.data);
toast.success('Node test completed successfully');
} catch (error) {
console.error('Error testing node:', error);
toast.error('Failed to test node configuration');
// If we have error details from the API
if (error.response?.data?.error) {
setTestResults({
success: false,
error: error.response.data.error,
output: null
});
} else {
setTestResults({
success: false,
error: error.message || 'An unknown error occurred',
output: null
});
}
} finally {
setLoading(false);
}
};
// Format JSON for display
const formatJSON = (json) => {
try {
if (typeof json === 'string') {
return JSON.stringify(JSON.parse(json), null, 2);
}
return JSON.stringify(json, null, 2);
} catch (error) {
return json;
}
};
return (
<div className="bg-white shadow-xl rounded-lg overflow-hidden max-w-4xl w-full">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Test Node: {node.data.label}
</h3>
<p className="mt-1 text-sm text-gray-500">
Test this node with sample input data
</p>
</div>
<div className="px-4 py-5 sm:p-6">
<div className="space-y-6">
{/* Input section */}
<div>
<label htmlFor="input-data" className="block text-sm font-medium text-gray-700">
Input Data (JSON)
</label>
<div className="mt-1">
<textarea
id="input-data"
name="input-data"
rows={5}
className={`shadow-sm block w-full focus:ring-primary-500 focus:border-primary-500 sm:text-sm border-gray-300 rounded-md font-mono ${
inputError ? 'border-red-300' : ''
}`}
value={inputData}
onChange={(e) => setInputData(e.target.value)}
placeholder='{"key": "value"}'
/>
{inputError && (
<p className="mt-1 text-sm text-red-600">{inputError}</p>
)}
</div>
<p className="mt-2 text-sm text-gray-500">
Enter sample input data in JSON format to test this node
</p>
</div>
{/* Test button */}
<div>
<button
type="button"
onClick={handleTestNode}
disabled={loading}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
{loading ? (
<>
<ArrowPathIcon className="h-5 w-5 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<PlayIcon className="h-5 w-5 mr-2" />
Run Test
</>
)}
</button>
</div>
{/* Results section */}
{testResults && (
<div className="border rounded-md overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h4 className="text-sm font-medium text-gray-900">Test Results</h4>
</div>
<div className="px-4 py-3">
<div className="space-y-4">
{/* Status */}
<div>
<div className="text-sm font-medium text-gray-700">Status:</div>
<div className={`mt-1 text-sm ${testResults.success ? 'text-green-600' : 'text-red-600'}`}>
{testResults.success ? 'Success' : 'Error'}
</div>
</div>
{/* Error (if any) */}
{testResults.error && (
<div>
<div className="text-sm font-medium text-gray-700">Error:</div>
<div className="mt-1 bg-red-50 text-red-700 p-3 rounded text-sm font-mono overflow-auto">
{testResults.error}
</div>
</div>
)}
{/* Output */}
{testResults.output !== null && (
<div>
<div className="text-sm font-medium text-gray-700">Output:</div>
<div className="mt-1 bg-gray-50 p-3 rounded text-sm font-mono overflow-auto">
<pre>{formatJSON(testResults.output)}</pre>
</div>
</div>
)}
{/* Execution time */}
{testResults.execution_time && (
<div>
<div className="text-sm font-medium text-gray-700">Execution Time:</div>
<div className="mt-1 text-sm text-gray-600">
{testResults.execution_time} ms
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
<div className="px-4 py-3 bg-gray-50 text-right sm:px-6">
<button
type="button"
onClick={onClose}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
Close
</button>
</div>
</div>
);
};
export default NodeTester;

View File

@ -0,0 +1,77 @@
import React from 'react';
const NodeTypeSelector = ({ nodeTypes }) => {
// Handle drag start for node types
const onDragStart = (event, nodeType) => {
// Set data for the drag operation
event.dataTransfer.setData('application/reactflow/type', 'customNode');
event.dataTransfer.setData('application/reactflow/data', JSON.stringify(nodeType));
event.dataTransfer.effectAllowed = 'move';
};
// Group node types by category
const groupedNodeTypes = nodeTypes.reduce((acc, nodeType) => {
const category = nodeType.category || 'Other';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(nodeType);
return acc;
}, {});
// Get node color class based on type
const getNodeColorClass = (type) => {
switch (type) {
case 'webhook':
return 'border-blue-500 bg-blue-50';
case 'http-request':
return 'border-green-500 bg-green-50';
case 'function':
return 'border-purple-500 bg-purple-50';
case 'delay':
return 'border-amber-500 bg-amber-50';
case 'email':
return 'border-red-500 bg-red-50';
case 'logger':
return 'border-gray-500 bg-gray-50';
default:
return 'border-gray-300 bg-gray-50';
}
};
return (
<div className="p-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">Node Types</h3>
<div className="space-y-6">
{Object.entries(groupedNodeTypes).map(([category, nodes]) => (
<div key={category} className="space-y-2">
<h4 className="text-sm font-medium text-gray-700">{category}</h4>
<div className="space-y-2">
{nodes.map((nodeType) => (
<div
key={nodeType.type}
className={`p-2 border-l-4 ${getNodeColorClass(nodeType.type)} rounded cursor-grab shadow-sm hover:shadow`}
draggable
onDragStart={(event) => onDragStart(event, nodeType)}
>
<div className="text-sm font-medium">{nodeType.name}</div>
<div className="text-xs text-gray-500">{nodeType.description}</div>
</div>
))}
</div>
</div>
))}
</div>
<div className="mt-6 pt-4 border-t border-gray-200">
<p className="text-xs text-gray-500">
Drag nodes to the canvas to build your workflow
</p>
</div>
</div>
);
};
export default NodeTypeSelector;

View File

@ -0,0 +1,280 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import api from '../../services/api';
import Modal from '../common/Modal';
import {
ClockIcon,
ArrowPathIcon,
EyeIcon,
CheckIcon
} from '@heroicons/react/24/outline';
const VersionHistory = ({ workflowId, currentVersion, onRestoreVersion }) => {
const [versions, setVersions] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedVersion, setSelectedVersion] = useState(null);
const [showVersionModal, setShowVersionModal] = useState(false);
// Fetch version history
useEffect(() => {
const fetchVersionHistory = async () => {
try {
setLoading(true);
const response = await api.get(`/api/workflows/${workflowId}/versions`)
.catch(err => {
// If versions not found, handle gracefully (new workflow)
if (err.response && err.response.status === 404) {
console.log('No version history found - this is likely a new workflow');
return { data: { versions: [] } };
}
throw err;
});
setVersions(response.data.versions || []);
} catch (error) {
console.error('Error fetching version history:', error);
// Don't show error toast for 404s as they're expected for new workflows
if (!error.response || error.response.status !== 404) {
toast.error('Failed to load workflow versions');
}
} finally {
setLoading(false);
}
};
if (workflowId) {
fetchVersionHistory();
}
}, [workflowId, currentVersion]);
// Format timestamp
const formatTimestamp = (timestamp) => {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleString();
};
// View version details
const viewVersion = (version) => {
setSelectedVersion(version);
setShowVersionModal(true);
};
// Restore version
const handleRestoreVersion = async (version) => {
try {
await api.post(`/api/workflows/${workflowId}/versions/${version.version}/restore`);
toast.success(`Restored workflow to version ${version.version}`);
onRestoreVersion(version.version);
setShowVersionModal(false);
} catch (error) {
console.error('Error restoring version:', error);
toast.error('Failed to restore workflow version');
}
};
// Render JSON diff
const renderDiff = (current, previous) => {
if (!current || !previous) return null;
// This is a simple visual diff, a more sophisticated diff library could be used
try {
const currentObj = typeof current === 'string' ? JSON.parse(current) : current;
const previousObj = typeof previous === 'string' ? JSON.parse(previous) : previous;
// Compare nodes count
const currentNodesCount = currentObj.nodes?.length || 0;
const previousNodesCount = previousObj.nodes?.length || 0;
// Compare edges count
const currentEdgesCount = currentObj.edges?.length || 0;
const previousEdgesCount = previousObj.edges?.length || 0;
return (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Nodes:</span>
<span className={currentNodesCount !== previousNodesCount ? 'font-medium text-amber-600' : ''}>
{currentNodesCount} {currentNodesCount !== previousNodesCount && `(was ${previousNodesCount})`}
</span>
</div>
<div className="flex justify-between">
<span>Edges:</span>
<span className={currentEdgesCount !== previousEdgesCount ? 'font-medium text-amber-600' : ''}>
{currentEdgesCount} {currentEdgesCount !== previousEdgesCount && `(was ${previousEdgesCount})`}
</span>
</div>
</div>
);
} catch (error) {
console.error('Error parsing JSON for diff:', error);
return <div className="text-red-500 text-sm">Error comparing versions</div>;
}
};
return (
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 border-b border-gray-200 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Version History
</h3>
<p className="mt-1 text-sm text-gray-500">
View and restore previous versions of this workflow
</p>
</div>
{loading ? (
<div className="px-4 py-5 sm:p-6">
<div className="animate-pulse space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 bg-gray-200 rounded"></div>
))}
</div>
</div>
) : versions.length > 0 ? (
<ul className="divide-y divide-gray-200">
{versions.map((version) => (
<li key={version.version} className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className={`flex-shrink-0 h-10 w-10 rounded-md flex items-center justify-center ${
version.version === currentVersion ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-600'
}`}>
{version.version === currentVersion ? (
<CheckIcon className="h-6 w-6" />
) : (
<ClockIcon className="h-6 w-6" />
)}
</div>
<div className="ml-4">
<div className="flex items-center">
<div className="text-sm font-medium text-gray-900">
Version {version.version}
</div>
{version.version === currentVersion && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Current
</span>
)}
</div>
<div className="text-sm text-gray-500">
Created: {formatTimestamp(version.created_at)}
</div>
{version.comment && (
<div className="text-sm text-gray-700 mt-1">
"{version.comment}"
</div>
)}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => viewVersion(version)}
className="inline-flex items-center p-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<EyeIcon className="h-4 w-4" />
</button>
{version.version !== currentVersion && (
<button
onClick={() => handleRestoreVersion(version)}
className="inline-flex items-center p-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<ArrowPathIcon className="h-4 w-4 text-amber-500" />
</button>
)}
</div>
</div>
{/* Show diff with previous version if available */}
{version.version > 1 && versions.find(v => v.version === version.version - 1) && (
<div className="mt-2 ml-14">
<div className="text-xs text-gray-500 mb-1">Changes from previous version:</div>
{renderDiff(
version.workflow_data,
versions.find(v => v.version === version.version - 1)?.workflow_data
)}
</div>
)}
</li>
))}
</ul>
) : (
<div className="px-4 py-5 sm:p-6 text-center">
<ClockIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No version history</h3>
<p className="mt-1 text-sm text-gray-500">
Save changes to your workflow to create new versions.
</p>
</div>
)}
{/* Version details modal */}
{selectedVersion && (
<Modal
isOpen={showVersionModal}
onClose={() => setShowVersionModal(false)}
title={`Version ${selectedVersion.version} Details`}
size="lg"
footer={
<>
{selectedVersion.version !== currentVersion && (
<button
type="button"
onClick={() => handleRestoreVersion(selectedVersion)}
className="inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-amber-600 text-base font-medium text-white hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-amber-500 sm:ml-3 sm:w-auto sm:text-sm"
>
Restore This Version
</button>
)}
<button
type="button"
onClick={() => setShowVersionModal(false)}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Close
</button>
</>
}
>
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-gray-900">Version Information</h4>
<dl className="mt-2 grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-gray-500">Version Number</dt>
<dd className="mt-1 text-sm text-gray-900">{selectedVersion.version}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Created At</dt>
<dd className="mt-1 text-sm text-gray-900">{formatTimestamp(selectedVersion.created_at)}</dd>
</div>
{selectedVersion.comment && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500">Comment</dt>
<dd className="mt-1 text-sm text-gray-900">{selectedVersion.comment}</dd>
</div>
)}
</dl>
</div>
<div>
<h4 className="text-sm font-medium text-gray-900">Workflow Data</h4>
<div className="mt-2 bg-gray-50 p-4 rounded-md overflow-auto max-h-96">
<pre className="text-xs text-gray-800">
{JSON.stringify(
typeof selectedVersion.workflow_data === 'string'
? JSON.parse(selectedVersion.workflow_data)
: selectedVersion.workflow_data,
null, 2
)}
</pre>
</div>
</div>
</div>
</Modal>
)}
</div>
);
};
export default VersionHistory;

View File

@ -0,0 +1,137 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import { ClipboardDocumentIcon, CheckIcon } from '@heroicons/react/24/outline';
import nodeService from '../../services/nodes';
const WebhookManager = ({ workflowId, nodes }) => {
const [webhooks, setWebhooks] = useState({});
const [loading, setLoading] = useState(true);
const [copiedWebhook, setCopiedWebhook] = useState(null);
// Get webhook nodes from the workflow
const webhookNodes = nodes.filter(node => node.data.nodeType === 'webhook');
// Fetch webhook URLs for all webhook nodes
useEffect(() => {
const fetchWebhookUrls = async () => {
if (webhookNodes.length === 0) {
setLoading(false);
return;
}
try {
setLoading(true);
const webhookData = {};
// Fetch webhook URL for each webhook node
for (const node of webhookNodes) {
try {
const response = await nodeService.getWebhookUrl(workflowId, node.id);
webhookData[node.id] = response.data.webhookUrl;
} catch (error) {
console.error(`Error fetching webhook URL for node ${node.id}:`, error);
webhookData[node.id] = null;
}
}
setWebhooks(webhookData);
} catch (error) {
console.error('Error fetching webhook URLs:', error);
toast.error('Failed to load webhook URLs');
} finally {
setLoading(false);
}
};
if (workflowId && webhookNodes.length > 0) {
fetchWebhookUrls();
} else {
setLoading(false);
}
}, [workflowId, webhookNodes]);
// Copy webhook URL to clipboard
const copyToClipboard = (nodeId, url) => {
navigator.clipboard.writeText(url).then(
() => {
setCopiedWebhook(nodeId);
setTimeout(() => setCopiedWebhook(null), 2000);
toast.success('Webhook URL copied to clipboard');
},
() => {
toast.error('Failed to copy webhook URL');
}
);
};
if (webhookNodes.length === 0) {
return null;
}
return (
<div className="mt-6 bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 border-b border-gray-200 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Webhook Endpoints
</h3>
<p className="mt-1 text-sm text-gray-500">
Use these URLs to trigger your workflow from external services
</p>
</div>
<ul className="divide-y divide-gray-200">
{webhookNodes.map((node) => (
<li key={node.id} className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-blue-100 text-blue-600 rounded-md flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{node.data.label}</div>
<div className="text-xs text-gray-500">Node ID: {node.id}</div>
</div>
</div>
</div>
<div className="mt-2">
{loading ? (
<div className="animate-pulse h-8 bg-gray-200 rounded w-full"></div>
) : webhooks[node.id] ? (
<div className="flex items-center mt-1 relative rounded-md shadow-sm">
<input
type="text"
readOnly
value={webhooks[node.id]}
className="focus:ring-primary-500 focus:border-primary-500 block w-full pr-10 sm:text-sm border-gray-300 rounded-md bg-gray-50"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<button
type="button"
onClick={() => copyToClipboard(node.id, webhooks[node.id])}
className="text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
{copiedWebhook === node.id ? (
<CheckIcon className="h-5 w-5 text-green-500" aria-hidden="true" />
) : (
<ClipboardDocumentIcon className="h-5 w-5" aria-hidden="true" />
)}
</button>
</div>
</div>
) : (
<div className="text-sm text-red-500">
Failed to load webhook URL. Please save the workflow first.
</div>
)}
</div>
</li>
))}
</ul>
</div>
);
};
export default WebhookManager;

View File

@ -0,0 +1,158 @@
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import Modal from '../common/Modal';
import NodeTester from './NodeTester';
import ExecutionResults from '../execution/ExecutionResults';
import {
PlayIcon,
PencilIcon,
DocumentDuplicateIcon,
TrashIcon,
BeakerIcon
} from '@heroicons/react/24/outline';
import api from '../../services/api';
const WorkflowEditorActions = ({
workflowId,
selectedNode,
onDuplicateNode,
onDeleteNode,
onExecuteWorkflow
}) => {
const [showNodeTester, setShowNodeTester] = useState(false);
const [showExecutionResults, setShowExecutionResults] = useState(false);
const [latestExecutionId, setLatestExecutionId] = useState(null);
// Execute the workflow
const handleExecute = async () => {
try {
const response = await api.post(`/api/workflows/${workflowId}/execute`);
setLatestExecutionId(response.data.execution.id);
setShowExecutionResults(true);
toast.success('Workflow execution started');
if (onExecuteWorkflow) {
onExecuteWorkflow(response.data.execution.id);
}
} catch (error) {
console.error('Error executing workflow:', error);
toast.error('Failed to execute workflow');
}
};
// Open node tester
const handleTestNode = () => {
if (!selectedNode) {
toast.warning('Please select a node to test');
return;
}
setShowNodeTester(true);
};
// Duplicate selected node
const handleDuplicateNode = () => {
if (!selectedNode) {
toast.warning('Please select a node to duplicate');
return;
}
if (onDuplicateNode) {
onDuplicateNode(selectedNode);
toast.success('Node duplicated');
}
};
// Delete selected node
const handleDeleteNode = () => {
if (!selectedNode) {
toast.warning('Please select a node to delete');
return;
}
if (onDeleteNode) {
onDeleteNode(selectedNode);
toast.success('Node deleted');
}
};
return (
<>
<div className="flex space-x-2">
{/* Execute workflow button */}
<button
onClick={handleExecute}
disabled={!workflowId || workflowId === 'new'}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
title="Execute workflow"
>
<PlayIcon className="h-4 w-4 mr-1" />
Execute
</button>
{/* Node actions */}
<div className="border-l border-gray-300 pl-2 flex space-x-1">
{/* Test node button */}
<button
onClick={handleTestNode}
disabled={!selectedNode}
className="inline-flex items-center p-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
title="Test selected node"
>
<BeakerIcon className="h-4 w-4" />
</button>
{/* Duplicate node button */}
<button
onClick={handleDuplicateNode}
disabled={!selectedNode}
className="inline-flex items-center p-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
title="Duplicate selected node"
>
<DocumentDuplicateIcon className="h-4 w-4" />
</button>
{/* Delete node button */}
<button
onClick={handleDeleteNode}
disabled={!selectedNode}
className="inline-flex items-center p-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
title="Delete selected node"
>
<TrashIcon className="h-4 w-4 text-red-500" />
</button>
</div>
</div>
{/* Node tester modal */}
{selectedNode && (
<Modal
isOpen={showNodeTester}
onClose={() => setShowNodeTester(false)}
size="lg"
>
<NodeTester
node={selectedNode}
onClose={() => setShowNodeTester(false)}
/>
</Modal>
)}
{/* Execution results modal */}
{latestExecutionId && (
<Modal
isOpen={showExecutionResults}
onClose={() => setShowExecutionResults(false)}
size="lg"
>
<ExecutionResults
workflowId={workflowId}
executionId={latestExecutionId}
onClose={() => setShowExecutionResults(false)}
/>
</Modal>
)}
</>
);
};
export default WorkflowEditorActions;

View File

@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { Tab } from '@headlessui/react';
import ExecutionHistory from '../execution/ExecutionHistory';
import CronScheduler from '../scheduling/CronScheduler';
import VersionHistory from './VersionHistory';
import WebhookManager from './WebhookManager';
function classNames(...classes) {
return classes.filter(Boolean).join(' ');
}
const WorkflowEditorTabs = ({
workflowId,
nodes,
currentVersion,
onRestoreVersion
}) => {
const [selectedTab, setSelectedTab] = useState(0);
const tabs = [
{ name: 'Execution History', component: <ExecutionHistory workflowId={workflowId} /> },
{ name: 'Scheduling', component: <CronScheduler workflowId={workflowId} /> },
{ name: 'Version History', component: <VersionHistory
workflowId={workflowId}
currentVersion={currentVersion}
onRestoreVersion={onRestoreVersion}
/> },
{ name: 'Webhooks', component: <WebhookManager workflowId={workflowId} nodes={nodes} /> }
];
return (
<div className="w-full bg-white shadow">
<Tab.Group selectedIndex={selectedTab} onChange={setSelectedTab}>
<Tab.List className="flex space-x-1 border-b border-gray-200 px-4">
{tabs.map((tab, index) => (
<Tab
key={index}
className={({ selected }) =>
classNames(
'py-4 px-4 text-sm font-medium leading-5 text-gray-700',
'focus:outline-none',
selected
? 'border-b-2 border-primary-500 text-primary-600'
: 'text-gray-500 hover:text-gray-700 hover:border-gray-300'
)
}
>
{tab.name}
</Tab>
))}
</Tab.List>
<Tab.Panels className="p-4">
{tabs.map((tab, index) => (
<Tab.Panel key={index}>
{tab.component}
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
</div>
);
};
export default WorkflowEditorTabs;

View File

@ -0,0 +1,141 @@
import React, { createContext, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import jwt_decode from 'jwt-decode';
import api from '../services/api';
// Create context
export const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [token, setToken] = useState(localStorage.getItem('token'));
const [isLoading, setIsLoading] = useState(true);
const navigate = useNavigate();
// Check if token is valid
const isTokenValid = (token) => {
if (!token) return false;
try {
const decoded = jwt_decode(token);
const currentTime = Date.now() / 1000;
return decoded.exp > currentTime;
} catch (error) {
return false;
}
};
// Initialize auth state
useEffect(() => {
const initAuth = async () => {
if (token && isTokenValid(token)) {
// Set auth header
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
try {
// Get user profile
const response = await api.get('/api/auth/me');
setCurrentUser(response.data.user);
setIsAuthenticated(true);
} catch (error) {
// Token might be invalid
logout();
}
} else if (token) {
// Token exists but is invalid
logout();
}
setIsLoading(false);
};
initAuth();
}, [token]);
// Login function
const login = async (email, password) => {
try {
const response = await api.post('/api/auth/login', { email, password });
const { token, user } = response.data;
// Save token to local storage
localStorage.setItem('token', token);
// Set auth header
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
// Update state
setToken(token);
setCurrentUser(user);
setIsAuthenticated(true);
return { success: true };
} catch (error) {
return {
success: false,
message: error.response?.data?.message || 'Login failed'
};
}
};
// Register function
const register = async (email, password) => {
try {
const response = await api.post('/api/auth/register', { email, password });
const { token, user } = response.data;
// Save token to local storage
localStorage.setItem('token', token);
// Set auth header
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
// Update state
setToken(token);
setCurrentUser(user);
setIsAuthenticated(true);
return { success: true };
} catch (error) {
return {
success: false,
message: error.response?.data?.message || 'Registration failed'
};
}
};
// Logout function
const logout = () => {
// Remove token from local storage
localStorage.removeItem('token');
// Remove auth header
delete api.defaults.headers.common['Authorization'];
// Update state
setToken(null);
setCurrentUser(null);
setIsAuthenticated(false);
// Redirect to login
navigate('/login');
};
// Context value
const value = {
currentUser,
isAuthenticated,
isLoading,
login,
register,
logout
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

View File

@ -0,0 +1,6 @@
import { useContext } from 'react';
import { AuthContext } from '../context/AuthContext';
export const useAuth = () => {
return useContext(AuthContext);
};

72
frontend/src/index.css Normal file
View File

@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@apply bg-gray-50;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Custom styles for react-flow */
.react-flow__node {
@apply shadow-md rounded-md;
}
.react-flow__node-default {
@apply bg-white border border-gray-200;
}
.react-flow__handle {
@apply w-3 h-3;
}
.react-flow__handle-top {
@apply top-0 -translate-y-1/2;
}
.react-flow__handle-bottom {
@apply bottom-0 translate-y-1/2;
}
.react-flow__edge-path {
@apply stroke-2 stroke-gray-400;
}
.react-flow__controls {
@apply shadow-md;
}
/* Node types */
.node-webhook {
@apply border-l-4 border-l-blue-500;
}
.node-http-request {
@apply border-l-4 border-l-green-500;
}
.node-function {
@apply border-l-4 border-l-purple-500;
}
.node-delay {
@apply border-l-4 border-l-amber-500;
}
.node-email {
@apply border-l-4 border-l-red-500;
}
.node-logger {
@apply border-l-4 border-l-gray-500;
}

20
frontend/src/index.js Normal file
View File

@ -0,0 +1,20 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './index.css';
import App from './App';
import { AuthProvider } from './context/AuthContext';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
<ToastContainer position="bottom-right" />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,182 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import api from '../services/api';
import { PlusIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
const Dashboard = () => {
const [recentWorkflows, setRecentWorkflows] = useState([]);
const [stats, setStats] = useState({
totalWorkflows: 0,
executionsToday: 0,
activeWebhooks: 0
});
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDashboardData = async () => {
try {
// Fetch workflows
const workflowsResponse = await api.get('/api/workflows');
const workflows = workflowsResponse.data.workflows || [];
// Sort by updated_at and take the 5 most recent
const sortedWorkflows = [...workflows].sort(
(a, b) => new Date(b.updated_at) - new Date(a.updated_at)
).slice(0, 5);
setRecentWorkflows(sortedWorkflows);
// Set stats
setStats({
totalWorkflows: workflows.length,
executionsToday: 0, // This would come from a separate API endpoint
activeWebhooks: workflows.reduce((count, workflow) => {
// Count webhook nodes in each workflow
const webhookNodes = workflow.nodes.filter(node => node.type === 'webhook');
return count + webhookNodes.length;
}, 0)
});
} catch (error) {
console.error('Error fetching dashboard data:', error);
} finally {
setLoading(false);
}
};
fetchDashboardData();
}, []);
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-3">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">
Total Workflows
</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900">
{loading ? (
<div className="animate-pulse h-8 w-16 bg-gray-200 rounded"></div>
) : (
stats.totalWorkflows
)}
</dd>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">
Executions Today
</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900">
{loading ? (
<div className="animate-pulse h-8 w-16 bg-gray-200 rounded"></div>
) : (
stats.executionsToday
)}
</dd>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">
Active Webhooks
</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900">
{loading ? (
<div className="animate-pulse h-8 w-16 bg-gray-200 rounded"></div>
) : (
stats.activeWebhooks
)}
</dd>
</div>
</div>
</div>
{/* Recent Workflows */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 border-b border-gray-200 sm:px-6 flex justify-between items-center">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Recent Workflows
</h3>
<Link
to="/workflows/new"
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" />
New Workflow
</Link>
</div>
<div className="px-4 py-5 sm:p-6">
{loading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="animate-pulse flex items-center">
<div className="h-10 w-10 rounded-md bg-gray-200"></div>
<div className="ml-4 flex-1">
<div className="h-4 w-3/4 bg-gray-200 rounded"></div>
<div className="mt-2 h-3 w-1/2 bg-gray-200 rounded"></div>
</div>
</div>
))}
</div>
) : recentWorkflows.length > 0 ? (
<ul className="divide-y divide-gray-200">
{recentWorkflows.map((workflow) => (
<li key={workflow.id} className="py-4">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-primary-100 text-primary-600 rounded-md flex items-center justify-center">
<ArrowPathIcon className="h-6 w-6" />
</div>
<div className="ml-4 flex-1">
<div className="flex items-center justify-between">
<Link
to={`/workflows/${workflow.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900"
>
{workflow.name}
</Link>
<div className="text-xs text-gray-500">
{new Date(workflow.updated_at).toLocaleDateString()}
</div>
</div>
<div className="mt-1 text-xs text-gray-500">
{workflow.nodes.length} nodes, {workflow.connections.length} connections
</div>
</div>
</div>
</li>
))}
</ul>
) : (
<div className="text-center py-6">
<p className="text-gray-500 text-sm">
You don't have any workflows yet.
</p>
<Link
to="/workflows/new"
className="mt-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700"
>
Create your first workflow
</Link>
</div>
)}
</div>
{recentWorkflows.length > 0 && (
<div className="px-4 py-4 sm:px-6">
<Link
to="/workflows"
className="text-sm font-medium text-primary-600 hover:text-primary-500"
>
View all workflows
<span aria-hidden="true"> &rarr;</span>
</Link>
</div>
)}
</div>
</div>
);
};
export default Dashboard;

106
frontend/src/pages/Login.js Normal file
View File

@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { toast } from 'react-toastify';
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
if (!email || !password) {
toast.error('Please enter both email and password');
return;
}
setIsSubmitting(true);
try {
const result = await login(email, password);
if (!result.success) {
toast.error(result.message || 'Login failed');
} else {
toast.success('Login successful');
}
} catch (error) {
toast.error('An error occurred during login');
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<h2 className="text-center text-2xl font-bold text-gray-900 mb-6">
Sign in to your account
</h2>
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
<div className="mt-6">
<div className="text-sm text-center">
<p className="text-gray-600">
Don't have an account?{' '}
<Link to="/register" className="font-medium text-primary-600 hover:text-primary-500">
Sign up
</Link>
</p>
</div>
</div>
</div>
);
};
export default Login;

View File

@ -0,0 +1,31 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
const NotFound = () => {
const { isAuthenticated } = useAuth();
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8 text-center">
<div>
<h1 className="text-9xl font-extrabold text-primary-600">404</h1>
<h2 className="mt-6 text-3xl font-bold text-gray-900">Page not found</h2>
<p className="mt-2 text-sm text-gray-600">
The page you're looking for doesn't exist or has been moved.
</p>
</div>
<div className="mt-8">
<Link
to={isAuthenticated ? '/' : '/login'}
className="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
{isAuthenticated ? 'Back to Dashboard' : 'Back to Login'}
</Link>
</div>
</div>
</div>
);
};
export default NotFound;

View File

@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../hooks/useAuth';
import api from '../services/api';
import { toast } from 'react-toastify';
const Profile = () => {
const { currentUser, logout } = useAuth();
const [formData, setFormData] = useState({
email: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (currentUser) {
setFormData(prev => ({
...prev,
email: currentUser.email
}));
}
}, [currentUser]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (formData.newPassword !== formData.confirmPassword) {
toast.error('New passwords do not match');
return;
}
setIsSubmitting(true);
try {
const payload = {
currentPassword: formData.currentPassword
};
if (formData.newPassword) {
payload.newPassword = formData.newPassword;
}
const response = await api.put('/api/auth/me', payload);
toast.success('Profile updated successfully');
// Reset password fields
setFormData(prev => ({
...prev,
currentPassword: '',
newPassword: '',
confirmPassword: ''
}));
} catch (error) {
toast.error(error.response?.data?.message || 'Failed to update profile');
console.error('Error updating profile:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-3xl mx-auto">
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Profile Settings
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Update your account information
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:p-6">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input
id="email"
name="email"
type="email"
disabled
value={formData.email}
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md bg-gray-50"
/>
<p className="mt-1 text-xs text-gray-500">
Email address cannot be changed
</p>
</div>
</div>
<div className="pt-4 border-t border-gray-200">
<h4 className="text-md font-medium text-gray-900">Change Password</h4>
<div className="mt-4">
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700">
Current Password
</label>
<div className="mt-1">
<input
id="currentPassword"
name="currentPassword"
type="password"
value={formData.currentPassword}
onChange={handleChange}
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
</div>
<div className="mt-4">
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700">
New Password
</label>
<div className="mt-1">
<input
id="newPassword"
name="newPassword"
type="password"
value={formData.newPassword}
onChange={handleChange}
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
</div>
<div className="mt-4">
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm New Password
</label>
<div className="mt-1">
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<h3 className="text-md font-medium text-gray-900">Danger Zone</h3>
<div className="mt-4">
<button
type="button"
onClick={logout}
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Sign Out
</button>
</div>
</div>
</div>
</div>
);
};
export default Profile;

View File

@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { toast } from 'react-toastify';
const Register = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { register } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
if (!email || !password || !confirmPassword) {
toast.error('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
if (password.length < 8) {
toast.error('Password must be at least 8 characters long');
return;
}
setIsSubmitting(true);
try {
const result = await register(email, password);
if (!result.success) {
toast.error(result.message || 'Registration failed');
} else {
toast.success('Registration successful');
}
} catch (error) {
toast.error('An error occurred during registration');
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<h2 className="text-center text-2xl font-bold text-gray-900 mb-6">
Create your account
</h2>
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<div className="mt-1">
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
{isSubmitting ? 'Creating account...' : 'Create account'}
</button>
</div>
</form>
<div className="mt-6">
<div className="text-sm text-center">
<p className="text-gray-600">
Already have an account?{' '}
<Link to="/login" className="font-medium text-primary-600 hover:text-primary-500">
Sign in
</Link>
</p>
</div>
</div>
</div>
);
};
export default Register;

View File

@ -0,0 +1,137 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import automationTemplates from '../templates/templates';
import api from '../services/api';
const TemplatesPage = () => {
const navigate = useNavigate();
const [selectedCategory, setSelectedCategory] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
// Extract unique tags from all templates
const allTags = [...new Set(automationTemplates.flatMap(template => template.tags))];
// Filter templates based on category and search query
const filteredTemplates = automationTemplates.filter(template => {
const matchesCategory = selectedCategory === 'all' || template.tags.includes(selectedCategory);
const matchesSearch = searchQuery === '' ||
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
return matchesCategory && matchesSearch;
});
// Create a new workflow from template
const createFromTemplate = async (template) => {
try {
console.log('Creating workflow from template:', template.name);
// Create a new workflow with template data
// Make sure we're sending proper data structures
const requestData = {
name: template.name,
// Ensure nodes and connections are arrays
nodes: Array.isArray(template.nodes) ? template.nodes : [],
connections: Array.isArray(template.connections) ? template.connections : []
};
console.log('Sending request data:', requestData);
const response = await api.post('/api/workflows', requestData);
console.log('Response from server:', response.data);
const newWorkflowId = response.data.workflow.id;
toast.success(`Workflow "${template.name}" created successfully!`);
// Wait a moment before navigating to ensure the backend has processed the workflow
setTimeout(() => {
// Navigate to the workflow editor
navigate(`/workflows/${newWorkflowId}`);
}, 500);
} catch (error) {
console.error('Error creating workflow from template:', error);
console.error('Error response:', error.response?.data);
toast.error(`Failed to create workflow: ${error.message}`);
}
};
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Automation Templates</h1>
{/* Search and Filter */}
<div className="flex flex-col md:flex-row justify-between mb-8 gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Search templates..."
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex-shrink-0">
<select
className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
>
<option value="all">All Categories</option>
{allTags.map(tag => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
</div>
</div>
{/* Templates Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTemplates.map(template => (
<div
key={template.id}
className="bg-white rounded-lg shadow-md overflow-hidden border border-gray-200 hover:shadow-lg transition-shadow"
>
<div className="p-6">
<h2 className="text-xl font-semibold mb-2">{template.name}</h2>
<p className="text-gray-600 mb-4">{template.description}</p>
<div className="flex flex-wrap gap-2 mb-4">
{template.tags.map(tag => (
<span
key={`${template.id}-${tag}`}
className="bg-gray-100 text-gray-600 px-2 py-1 rounded-md text-sm"
>
{tag}
</span>
))}
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">
{template.nodes.length} nodes
</span>
<button
onClick={() => createFromTemplate(template)}
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
>
Use Template
</button>
</div>
</div>
</div>
))}
</div>
{filteredTemplates.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 text-xl">No templates found matching your criteria.</p>
</div>
)}
</div>
);
};
export default TemplatesPage;

View File

@ -0,0 +1,166 @@
import React, { useState } from 'react';
import Modal from '../components/common/Modal';
import ExecutionResults from '../components/execution/ExecutionResults';
import ExecutionHistory from '../components/execution/ExecutionHistory';
import CronScheduler from '../components/scheduling/CronScheduler';
import VersionHistory from '../components/workflow/VersionHistory';
import WebhookManager from '../components/workflow/WebhookManager';
import NodeTester from '../components/workflow/NodeTester';
import WorkflowEditorTabs from '../components/workflow/WorkflowEditorTabs';
import WorkflowEditorActions from '../components/workflow/WorkflowEditorActions';
const TestPage = () => {
const [showModal, setShowModal] = useState(false);
const [activeComponent, setActiveComponent] = useState(null);
// Mock data for testing
const mockWorkflowId = '123';
const mockExecutionId = '456';
const mockNodes = [
{
id: 'node1',
type: 'webhook',
data: { label: 'Webhook Node' }
},
{
id: 'node2',
type: 'http-request',
data: { label: 'HTTP Request' }
}
];
const mockSelectedNode = {
id: 'node1',
type: 'webhook',
data: {
label: 'Webhook Node',
config: { path: '/webhook/test' }
}
};
const renderComponent = (component) => {
setActiveComponent(component);
setShowModal(true);
};
return (
<div className="p-8 bg-gray-50 min-h-screen">
<h1 className="text-3xl font-bold mb-8 text-center">FlowForge Component Testing</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8 max-w-4xl mx-auto">
<button
onClick={() => renderComponent('executionResults')}
className="p-4 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Test Execution Results
</button>
<button
onClick={() => renderComponent('executionHistory')}
className="p-4 bg-green-500 text-white rounded hover:bg-green-600"
>
Test Execution History
</button>
<button
onClick={() => renderComponent('cronScheduler')}
className="p-4 bg-purple-500 text-white rounded hover:bg-purple-600"
>
Test Cron Scheduler
</button>
<button
onClick={() => renderComponent('versionHistory')}
className="p-4 bg-yellow-500 text-white rounded hover:bg-yellow-600"
>
Test Version History
</button>
<button
onClick={() => renderComponent('webhookManager')}
className="p-4 bg-red-500 text-white rounded hover:bg-red-600"
>
Test Webhook Manager
</button>
<button
onClick={() => renderComponent('nodeTester')}
className="p-4 bg-indigo-500 text-white rounded hover:bg-indigo-600"
>
Test Node Tester
</button>
</div>
<div className="border p-4 rounded-lg shadow-md mb-8 bg-white max-w-4xl mx-auto">
<h2 className="text-xl font-semibold mb-4">WorkflowEditorActions Component</h2>
<WorkflowEditorActions
workflowId={mockWorkflowId}
selectedNode={mockSelectedNode}
onDuplicateNode={(node) => console.log('Duplicate node:', node)}
onDeleteNode={(node) => console.log('Delete node:', node)}
onExecuteWorkflow={(id) => console.log('Execute workflow:', id)}
/>
</div>
<div className="border p-4 rounded-lg shadow-md bg-white max-w-4xl mx-auto">
<h2 className="text-xl font-semibold mb-4">WorkflowEditorTabs Component</h2>
<WorkflowEditorTabs
workflowId={mockWorkflowId}
nodes={mockNodes}
currentVersion={1}
onRestoreVersion={(version) => console.log('Restore version:', version)}
/>
</div>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
size="lg"
title={activeComponent ? activeComponent.charAt(0).toUpperCase() + activeComponent.slice(1) : ''}
>
{activeComponent === 'executionResults' && (
<ExecutionResults
workflowId={mockWorkflowId}
executionId={mockExecutionId}
onClose={() => setShowModal(false)}
/>
)}
{activeComponent === 'executionHistory' && (
<ExecutionHistory
workflowId={mockWorkflowId}
/>
)}
{activeComponent === 'cronScheduler' && (
<CronScheduler
workflowId={mockWorkflowId}
/>
)}
{activeComponent === 'versionHistory' && (
<VersionHistory
workflowId={mockWorkflowId}
currentVersion={1}
onRestoreVersion={(version) => console.log('Restore version:', version)}
/>
)}
{activeComponent === 'webhookManager' && (
<WebhookManager
workflowId={mockWorkflowId}
nodes={mockNodes}
/>
)}
{activeComponent === 'nodeTester' && (
<NodeTester
node={mockSelectedNode}
onClose={() => setShowModal(false)}
/>
)}
</Modal>
</div>
);
};
export default TestPage;

View File

@ -0,0 +1,503 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import ReactFlow, {
ReactFlowProvider,
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState
} from 'reactflow';
import 'reactflow/dist/style.css';
import api from '../services/api';
import { toast } from 'react-toastify';
// Custom node components
import NodeTypeSelector from '../components/workflow/NodeTypeSelector';
import NodeConfigPanel from '../components/workflow/NodeConfigPanel';
import CustomNode from '../components/workflow/CustomNode';
// New components
import Modal from '../components/common/Modal';
import WorkflowEditorTabs from '../components/workflow/WorkflowEditorTabs';
import WorkflowEditorActions from '../components/workflow/WorkflowEditorActions';
import NodeTester from '../components/workflow/NodeTester';
import ExecutionResults from '../components/execution/ExecutionResults';
const WorkflowEditor = () => {
const { id } = useParams();
const navigate = useNavigate();
const reactFlowWrapper = useRef(null);
const [reactFlowInstance, setReactFlowInstance] = useState(null);
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [workflow, setWorkflow] = useState({
name: 'New Workflow',
description: ''
});
const [nodeTypes, setNodeTypes] = useState([]);
const [selectedNode, setSelectedNode] = useState(null);
const [isAddingNode, setIsAddingNode] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// New state variables
const [showNodeTester, setShowNodeTester] = useState(false);
const [showExecutionResults, setShowExecutionResults] = useState(false);
const [latestExecutionId, setLatestExecutionId] = useState(null);
const [currentVersion, setCurrentVersion] = useState(1);
const [showTabs, setShowTabs] = useState(true);
// Node type definitions for React Flow
const nodeTypeDefinitions = {
customNode: CustomNode
};
// Load workflow if editing existing one
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true);
// Fetch available node types
const nodeTypesResponse = await api.get('/api/nodes');
setNodeTypes(nodeTypesResponse.data.nodes || []);
// If editing existing workflow, fetch it
if (id && id !== 'new') {
const workflowResponse = await api.get(`/api/workflows/${id}`);
const workflowData = workflowResponse.data.workflow;
setWorkflow({
id: workflowData.id,
name: workflowData.name,
description: workflowData.description
});
// Set current version
setCurrentVersion(workflowData.version || 1);
// Convert backend nodes/connections to React Flow format
const flowNodes = workflowData.nodes.map(node => ({
id: node.id,
type: 'customNode',
position: { x: node.position_x, y: node.position_y },
data: {
label: node.name,
nodeType: node.type,
config: node.config || {}
}
}));
const flowEdges = workflowData.connections.map(conn => ({
id: conn.id,
source: conn.source_node_id,
target: conn.target_node_id,
sourceHandle: conn.source_handle,
targetHandle: conn.target_handle
}));
setNodes(flowNodes);
setEdges(flowEdges);
}
} catch (error) {
console.error('Error loading workflow editor:', error);
toast.error('Failed to load workflow editor');
} finally {
setIsLoading(false);
}
};
fetchData();
}, [id]);
// Handle connections between nodes
const onConnect = useCallback((params) => {
setEdges((eds) => addEdge(params, eds));
}, [setEdges]);
// Handle drag over for node palette
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
// Handle drop from node palette
const onDrop = useCallback(
(event) => {
event.preventDefault();
const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect();
const nodeType = event.dataTransfer.getData('application/reactflow/type');
const nodeData = JSON.parse(event.dataTransfer.getData('application/reactflow/data'));
// Check if the dropped element is valid
if (typeof nodeType === 'undefined' || !nodeType) {
return;
}
const position = reactFlowInstance.project({
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top,
});
const newNode = {
id: `node_${Date.now()}`,
type: 'customNode',
position,
data: {
label: nodeData.name,
nodeType: nodeData.type,
config: {}
},
};
setNodes((nds) => nds.concat(newNode));
setSelectedNode(newNode);
},
[reactFlowInstance, setNodes]
);
// Handle node selection
const onNodeClick = useCallback((event, node) => {
setSelectedNode(node);
}, []);
// Handle node config update
const onNodeConfigUpdate = useCallback((nodeId, config) => {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
config
}
};
}
return node;
})
);
}, [setNodes]);
// Handle pane click (deselect node)
const onPaneClick = useCallback(() => {
setSelectedNode(null);
}, []);
// Handle workflow name change
const handleNameChange = (e) => {
setWorkflow({
...workflow,
name: e.target.value
});
};
// Handle workflow description change
const handleDescriptionChange = (e) => {
setWorkflow({
...workflow,
description: e.target.value
});
};
// Save workflow
const handleSave = async () => {
if (!workflow.name.trim()) {
toast.error('Please enter a workflow name');
return;
}
setIsSaving(true);
try {
// Convert React Flow nodes/edges to backend format
const workflowNodes = nodes.map(node => ({
id: node.id,
name: node.data.label,
type: node.data.nodeType,
position_x: node.position.x,
position_y: node.position.y,
config: node.data.config
}));
const workflowConnections = edges.map(edge => ({
id: edge.id,
source_node_id: edge.source,
target_node_id: edge.target,
source_handle: edge.sourceHandle || 'default',
target_handle: edge.targetHandle || 'default'
}));
const workflowData = {
name: workflow.name,
description: workflow.description,
nodes: workflowNodes,
connections: workflowConnections
};
let response;
if (id && id !== 'new') {
// Update existing workflow
response = await api.put(`/api/workflows/${id}`, workflowData);
toast.success('Workflow updated successfully');
} else {
// Create new workflow
response = await api.post('/api/workflows', workflowData);
toast.success('Workflow created successfully');
navigate(`/workflows/${response.data.workflow.id}`);
}
} catch (error) {
console.error('Error saving workflow:', error);
toast.error('Failed to save workflow');
} finally {
setIsSaving(false);
}
};
// Execute workflow
const handleExecute = async () => {
if (!id || id === 'new') {
toast.error('Please save the workflow before executing');
return;
}
try {
const response = await api.post(`/api/workflows/${id}/execute`);
toast.success('Workflow execution started');
handleExecuteWorkflow(response.data.executionId);
} catch (error) {
console.error('Error executing workflow:', error);
toast.error('Failed to execute workflow');
}
};
// Duplicate a node
const handleDuplicateNode = (node) => {
const newNode = {
...node,
id: `${node.id}-copy-${Date.now()}`,
position: {
x: node.position.x + 50,
y: node.position.y + 50
}
};
setNodes((nds) => nds.concat(newNode));
};
// Delete a node
const handleDeleteNode = (node) => {
setNodes((nds) => nds.filter((n) => n.id !== node.id));
setEdges((eds) => eds.filter((e) => e.source !== node.id && e.target !== node.id));
setSelectedNode(null);
};
// Handle workflow execution
const handleExecuteWorkflow = (executionId) => {
setLatestExecutionId(executionId);
setShowExecutionResults(true);
};
// Handle version restoration
const handleRestoreVersion = async (version) => {
try {
setIsLoading(true);
const response = await api.get(`/api/workflows/${id}/versions/${version}`);
const workflowData = response.data.workflow;
// Update workflow data
setWorkflow({
...workflow,
name: workflowData.name,
description: workflowData.description
});
// Convert backend nodes/connections to React Flow format
const flowNodes = workflowData.nodes.map(node => ({
id: node.id,
type: 'customNode',
position: { x: node.position_x, y: node.position_y },
data: {
label: node.name,
nodeType: node.type,
config: node.config || {}
}
}));
const flowEdges = workflowData.connections.map(conn => ({
id: conn.id,
source: conn.source_node_id,
target: conn.target_node_id,
sourceHandle: conn.source_handle,
targetHandle: conn.target_handle
}));
setNodes(flowNodes);
setEdges(flowEdges);
setCurrentVersion(version);
toast.success(`Restored workflow to version ${version}`);
} catch (error) {
console.error('Error restoring version:', error);
toast.error('Failed to restore workflow version');
} finally {
setIsLoading(false);
}
};
return (
<div className="h-[calc(100vh-6rem)]">
{isLoading ? (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary-500"></div>
</div>
) : (
<div className="h-full flex flex-col">
{/* Workflow Header */}
<div className="bg-white p-4 border-b border-gray-200 flex items-center justify-between">
<div className="flex-1">
<input
type="text"
value={workflow.name}
onChange={handleNameChange}
placeholder="Workflow Name"
className="text-xl font-semibold border-none focus:ring-0 w-full"
/>
<input
type="text"
value={workflow.description}
onChange={handleDescriptionChange}
placeholder="Add a description..."
className="text-sm text-gray-500 border-none focus:ring-0 w-full"
/>
</div>
<div className="flex space-x-2 items-center">
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
<WorkflowEditorActions
workflowId={id}
selectedNode={selectedNode}
onDuplicateNode={handleDuplicateNode}
onDeleteNode={handleDeleteNode}
onExecuteWorkflow={handleExecuteWorkflow}
onTestNode={() => setShowNodeTester(true)}
/>
<button
onClick={() => setShowTabs(!showTabs)}
className="ml-2 inline-flex items-center p-1 border border-gray-300 rounded text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
{showTabs ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5 10a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clipRule="evenodd" />
</svg>
)}
</button>
</div>
</div>
{/* Workflow Editor */}
<div className="flex-1 flex">
{/* Node Palette */}
<div className="w-64 bg-white border-r border-gray-200 overflow-y-auto">
<NodeTypeSelector nodeTypes={nodeTypes} />
</div>
{/* Flow Canvas */}
<div className="flex-1 h-full" ref={reactFlowWrapper}>
<ReactFlowProvider>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={setReactFlowInstance}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypeDefinitions}
fitView
>
<Controls />
<MiniMap />
<Background color="#aaa" gap={16} />
</ReactFlow>
</ReactFlowProvider>
</div>
{/* Node Configuration Panel */}
{selectedNode && (
<div className="w-80 bg-white border-l border-gray-200 overflow-y-auto">
<NodeConfigPanel
node={selectedNode}
onConfigUpdate={onNodeConfigUpdate}
onClose={() => setSelectedNode(null)}
/>
</div>
)}
</div>
{/* Workflow Tabs */}
{id && id !== 'new' && showTabs && (
<div className="border-t border-gray-200">
<WorkflowEditorTabs
workflowId={id}
nodes={nodes}
currentVersion={currentVersion}
onRestoreVersion={handleRestoreVersion}
/>
</div>
)}
{/* Modals */}
<Modal
isOpen={showNodeTester}
onClose={() => setShowNodeTester(false)}
title="Test Node"
size="lg"
>
{selectedNode && (
<NodeTester
node={selectedNode}
onClose={() => setShowNodeTester(false)}
/>
)}
</Modal>
<Modal
isOpen={showExecutionResults}
onClose={() => setShowExecutionResults(false)}
title="Execution Results"
size="xl"
>
{latestExecutionId && (
<ExecutionResults
key={latestExecutionId}
workflowId={id}
executionId={latestExecutionId}
onClose={() => setShowExecutionResults(false)}
/>
)}
</Modal>
</div>
)}
</div>
);
};
export default WorkflowEditor;

View File

@ -0,0 +1,201 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import api from '../services/api';
import { toast } from 'react-toastify';
import {
PlusIcon,
ArrowPathIcon,
PencilIcon,
TrashIcon,
PlayIcon,
ClockIcon,
CheckCircleIcon,
ExclamationCircleIcon
} from '@heroicons/react/24/outline';
const WorkflowList = () => {
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
const [executingWorkflows, setExecutingWorkflows] = useState({});
const navigate = useNavigate();
useEffect(() => {
fetchWorkflows();
}, []);
const fetchWorkflows = async () => {
try {
setLoading(true);
const response = await api.get('/api/workflows');
setWorkflows(response.data.workflows || []);
} catch (error) {
console.error('Error fetching workflows:', error);
toast.error('Failed to load workflows');
} finally {
setLoading(false);
}
};
const handleDelete = async (id, name) => {
if (window.confirm(`Are you sure you want to delete workflow "${name}"?`)) {
try {
await api.delete(`/api/workflows/${id}`);
toast.success(`Workflow "${name}" deleted`);
setWorkflows(workflows.filter(workflow => workflow.id !== id));
} catch (error) {
console.error('Error deleting workflow:', error);
toast.error(`Failed to delete workflow "${name}"`);
}
}
};
const handleExecute = async (id, name) => {
try {
setExecutingWorkflows(prev => ({ ...prev, [id]: true }));
await api.post(`/api/workflows/${id}/execute`);
toast.success(`Workflow "${name}" execution started`);
} catch (error) {
console.error('Error executing workflow:', error);
toast.error(`Failed to execute workflow "${name}"`);
} finally {
setExecutingWorkflows(prev => ({ ...prev, [id]: false }));
}
};
const getStatusBadge = (workflow) => {
// This is a placeholder - in a real app, you'd get the actual status from the API
const status = workflow.status || 'idle';
switch (status) {
case 'running':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<ClockIcon className="-ml-0.5 mr-1.5 h-3 w-3" />
Running
</span>
);
case 'success':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<CheckCircleIcon className="-ml-0.5 mr-1.5 h-3 w-3" />
Success
</span>
);
case 'error':
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<ExclamationCircleIcon className="-ml-0.5 mr-1.5 h-3 w-3" />
Error
</span>
);
default:
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Idle
</span>
);
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-gray-900">Workflows</h1>
<Link
to="/workflows/new"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PlusIcon className="-ml-1 mr-2 h-5 w-5" />
New Workflow
</Link>
</div>
<div className="bg-white shadow overflow-hidden sm:rounded-md">
{loading ? (
<div className="p-6">
<div className="animate-pulse space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 bg-gray-200 rounded"></div>
))}
</div>
</div>
) : workflows.length > 0 ? (
<ul className="divide-y divide-gray-200">
{workflows.map((workflow) => (
<li key={workflow.id}>
<div className="px-4 py-4 sm:px-6 flex items-center justify-between">
<div className="flex items-center min-w-0 flex-1">
<div className="flex-shrink-0 h-10 w-10 bg-primary-100 text-primary-600 rounded-md flex items-center justify-center">
<ArrowPathIcon className="h-6 w-6" />
</div>
<div className="ml-4 min-w-0 flex-1">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-primary-600 truncate">
{workflow.name}
</h3>
<div className="mt-1 flex items-center text-xs text-gray-500">
<span className="truncate">
{workflow.nodes.length} nodes, {workflow.connections.length} connections
</span>
<span className="mx-2"></span>
<span>
Updated {new Date(workflow.updated_at).toLocaleDateString()}
</span>
</div>
</div>
<div className="ml-2 flex-shrink-0">
{getStatusBadge(workflow)}
</div>
</div>
</div>
</div>
<div className="ml-4 flex-shrink-0 flex space-x-2">
<button
onClick={() => handleExecute(workflow.id, workflow.name)}
disabled={executingWorkflows[workflow.id]}
className="inline-flex items-center p-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PlayIcon className="h-4 w-4" />
</button>
<button
onClick={() => navigate(`/workflows/${workflow.id}`)}
className="inline-flex items-center p-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(workflow.id, workflow.name)}
className="inline-flex items-center p-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
</li>
))}
</ul>
) : (
<div className="text-center py-12">
<ArrowPathIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No workflows</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by creating a new workflow.
</p>
<div className="mt-6">
<Link
to="/workflows/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PlusIcon className="-ml-1 mr-2 h-5 w-5" aria-hidden="true" />
New Workflow
</Link>
</div>
</div>
)}
</div>
</div>
);
};
export default WorkflowList;

View File

@ -0,0 +1,36 @@
import axios from 'axios';
// Create axios instance
const api = axios.create({
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:4000',
headers: {
'Content-Type': 'application/json'
}
});
// Add request interceptor for authentication
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Add response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
// Handle 401 Unauthorized errors
if (error.response && error.response.status === 401) {
// Clear token if it's invalid
localStorage.removeItem('token');
}
return Promise.reject(error);
}
);
export default api;

View File

@ -0,0 +1,46 @@
import api from './api';
/**
* Service for node-related API calls
*/
const nodeService = {
/**
* Get all available node types
* @returns {Promise} Promise resolving to node types data
*/
getNodeTypes: async () => {
return api.get('/api/nodes');
},
/**
* Get metadata for a specific node type
* @param {string} type Node type identifier
* @returns {Promise} Promise resolving to node metadata
*/
getNodeMeta: async (type) => {
return api.get(`/api/nodes/${type}/meta`);
},
/**
* Get webhook URL for a specific workflow and node
* @param {string} workflowId Workflow ID
* @param {string} nodeId Node ID
* @returns {Promise} Promise resolving to webhook URL data
*/
getWebhookUrl: async (workflowId, nodeId) => {
return api.get(`/api/workflows/${workflowId}/webhooks/${nodeId}`);
},
/**
* Test a node configuration
* @param {string} type Node type identifier
* @param {Object} config Node configuration to test
* @param {Object} input Test input data
* @returns {Promise} Promise resolving to test results
*/
testNodeConfig: async (type, config, input = {}) => {
return api.post(`/api/nodes/${type}/test`, { config, input });
}
};
export default nodeService;

View File

@ -0,0 +1,93 @@
import api from './api';
/**
* Service for workflow-related API calls
*/
const workflowService = {
/**
* Get all workflows for the current user
* @returns {Promise} Promise resolving to workflow data
*/
getWorkflows: async () => {
return api.get('/api/workflows');
},
/**
* Get a specific workflow by ID
* @param {string} id Workflow ID
* @returns {Promise} Promise resolving to workflow data
*/
getWorkflow: async (id) => {
return api.get(`/api/workflows/${id}`);
},
/**
* Create a new workflow
* @param {Object} workflow Workflow data
* @returns {Promise} Promise resolving to created workflow
*/
createWorkflow: async (workflow) => {
return api.post('/api/workflows', workflow);
},
/**
* Update an existing workflow
* @param {string} id Workflow ID
* @param {Object} workflow Updated workflow data
* @returns {Promise} Promise resolving to updated workflow
*/
updateWorkflow: async (id, workflow) => {
return api.put(`/api/workflows/${id}`, workflow);
},
/**
* Delete a workflow
* @param {string} id Workflow ID
* @returns {Promise} Promise resolving on successful deletion
*/
deleteWorkflow: async (id) => {
return api.delete(`/api/workflows/${id}`);
},
/**
* Execute a workflow
* @param {string} id Workflow ID
* @param {Object} input Optional input data for the workflow
* @returns {Promise} Promise resolving to execution data
*/
executeWorkflow: async (id, input = {}) => {
return api.post(`/api/workflows/${id}/execute`, { input });
},
/**
* Get execution history for a workflow
* @param {string} id Workflow ID
* @param {Object} params Query parameters (limit, offset)
* @returns {Promise} Promise resolving to execution history
*/
getExecutionHistory: async (id, params = {}) => {
return api.get(`/api/workflows/${id}/executions`, { params });
},
/**
* Get details of a specific execution
* @param {string} workflowId Workflow ID
* @param {string} executionId Execution ID
* @returns {Promise} Promise resolving to execution details
*/
getExecution: async (workflowId, executionId) => {
return api.get(`/api/workflows/${workflowId}/executions/${executionId}`);
},
/**
* Get logs for a specific execution
* @param {string} workflowId Workflow ID
* @param {string} executionId Execution ID
* @returns {Promise} Promise resolving to execution logs
*/
getExecutionLogs: async (workflowId, executionId) => {
return api.get(`/api/workflows/${workflowId}/executions/${executionId}/logs`);
}
};
export default workflowService;

View File

@ -0,0 +1,526 @@
// Automation template definitions
const automationTemplates = [
{
id: 'tweet-to-sheets',
name: 'New Tweet → Save to Google Sheets',
description: 'Whenever a new tweet is posted on a Twitter account, log the tweet content, date, and link into a Google Sheet.',
tags: ['twitter', 'google-sheets', 'social-media'],
nodes: [
{
id: 'twitter-trigger',
type: 'trigger',
name: 'Twitter Trigger',
position: { x: 100, y: 100 },
config: {
triggerType: 'twitter',
accountName: '@username'
}
},
{
id: 'google-sheets',
type: 'action',
name: 'Google Sheets',
position: { x: 400, y: 100 },
config: {
actionType: 'google-sheets',
operation: 'append-row',
spreadsheetId: '',
sheetName: 'Tweets'
}
}
],
connections: [
{
id: 'conn-1',
source_node_id: 'twitter-trigger',
target_node_id: 'google-sheets',
source_handle: 'output',
target_handle: 'input'
}
]
},
{
id: 'webhook-to-email',
name: 'Webhook Receives Form Data → Send Email',
description: 'When form data is sent to a webhook, format the message and email it to a predefined address.',
tags: ['webhook', 'email', 'form'],
nodes: [
{
id: 'webhook-trigger',
type: 'trigger',
name: 'Webhook',
position: { x: 100, y: 100 },
config: {
triggerType: 'webhook',
path: '/form-submission'
}
},
{
id: 'email-sender',
type: 'action',
name: 'Send Email',
position: { x: 400, y: 100 },
config: {
actionType: 'email',
to: 'recipient@example.com',
subject: 'New Form Submission'
}
}
],
connections: [
{
id: 'conn-1',
source_node_id: 'webhook-trigger',
target_node_id: 'email-sender',
source_handle: 'output',
target_handle: 'input'
}
]
},
{
id: 'github-to-discord',
name: 'New GitHub Issue → Send Discord Notification',
description: 'On creation of a new GitHub issue in a repository, post a message in a Discord channel.',
tags: ['github', 'discord', 'developer'],
nodes: [
{
id: 'github-trigger',
type: 'trigger',
name: 'GitHub Webhook',
position: { x: 100, y: 100 },
config: {
triggerType: 'github',
event: 'issues',
action: 'opened'
}
},
{
id: 'discord-action',
type: 'action',
name: 'Discord Message',
position: { x: 400, y: 100 },
config: {
actionType: 'discord',
webhookUrl: '',
channel: 'github-notifications'
}
}
],
connections: [
{
id: 'conn-1',
source_node_id: 'github-trigger',
target_node_id: 'discord-action',
source_handle: 'output',
target_handle: 'input'
}
]
},
{
id: 'weather-forecast',
name: 'Every Morning at 08:00 → Get Weather Forecast → Send Email',
description: 'Trigger daily at 08:00 to fetch weather data from a weather API and email it to the user.',
tags: ['schedule', 'weather', 'email'],
nodes: [
{
id: 'schedule-trigger',
type: 'trigger',
name: 'Schedule',
position: { x: 100, y: 100 },
config: {
triggerType: 'schedule',
cronExpression: '0 8 * * *',
timezone: 'UTC'
}
},
{
id: 'weather-api',
type: 'action',
name: 'Weather API',
position: { x: 400, y: 100 },
config: {
actionType: 'http-request',
method: 'GET',
url: 'https://api.weatherapi.com/v1/forecast.json',
params: {
key: '{{API_KEY}}',
q: 'London',
days: 1
}
}
},
{
id: 'email-sender',
type: 'action',
name: 'Send Email',
position: { x: 700, y: 100 },
config: {
actionType: 'email',
to: 'user@example.com',
subject: 'Today\'s Weather Forecast'
}
}
],
connections: [
{
id: 'conn-1',
source_node_id: 'schedule-trigger',
target_node_id: 'weather-api',
source_handle: 'output',
target_handle: 'input'
},
{
id: 'conn-2',
source_node_id: 'weather-api',
target_node_id: 'email-sender',
source_handle: 'output',
target_handle: 'input'
}
]
},
{
id: 'notion-to-telegram',
name: 'New Row in Notion Database → Send Telegram Message',
description: 'When a new entry is added to a Notion table, send the content as a Telegram message.',
tags: ['notion', 'telegram', 'productivity'],
nodes: [
{
id: 'notion-trigger',
type: 'trigger',
name: 'Notion Database',
position: { x: 100, y: 100 },
config: {
triggerType: 'notion',
databaseId: ''
}
},
{
id: 'telegram-action',
type: 'action',
name: 'Telegram Message',
position: { x: 400, y: 100 },
config: {
actionType: 'telegram',
botToken: '{{BOT_TOKEN}}',
chatId: ''
}
}
],
connections: [
{
id: 'conn-1',
source_node_id: 'notion-trigger',
target_node_id: 'telegram-action',
source_handle: 'output',
target_handle: 'input'
}
]
},
{
id: 'youtube-rss-to-email',
name: 'New YouTube Video Detected via RSS → Email to Subscribers',
description: 'Monitor an RSS feed for new YouTube videos, and email the video details to all subscribers.',
tags: ['youtube', 'rss', 'email', 'subscribers'],
nodes: [
{
id: 'rss-trigger',
type: 'trigger',
name: 'RSS Feed',
position: { x: 100, y: 100 },
config: {
triggerType: 'rss',
feedUrl: 'https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID',
checkInterval: 3600 // Check every hour
}
},
{
id: 'get-subscribers',
type: 'action',
name: 'Get Subscribers',
position: { x: 400, y: 100 },
config: {
actionType: 'database',
operation: 'query',
query: 'SELECT email FROM subscribers WHERE active = true'
}
},
{
id: 'email-sender',
type: 'action',
name: 'Send Email',
position: { x: 700, y: 100 },
config: {
actionType: 'email',
subject: 'New Video: {{title}}',
body: 'Check out this new video: {{link}}'
}
}
],
connections: [
{
id: 'conn-1',
source_node_id: 'rss-trigger',
target_node_id: 'get-subscribers',
source_handle: 'output',
target_handle: 'input'
},
{
id: 'conn-2',
source_node_id: 'get-subscribers',
target_node_id: 'email-sender',
source_handle: 'output',
target_handle: 'input'
}
]
},
{
id: 'chatgpt-to-pdf',
name: 'Send Message to ChatGPT → Convert Reply to PDF → Send Email',
description: 'Accept a message via webhook, get a ChatGPT response, turn it into a PDF, and email it back.',
tags: ['chatgpt', 'pdf', 'email', 'ai'],
nodes: [
{
id: 'webhook-trigger',
type: 'trigger',
name: 'Webhook',
position: { x: 100, y: 100 },
config: {
triggerType: 'webhook',
path: '/chatgpt-request'
}
},
{
id: 'chatgpt-action',
type: 'action',
name: 'ChatGPT API',
position: { x: 400, y: 100 },
config: {
actionType: 'openai',
model: 'gpt-4',
prompt: '{{message}}',
temperature: 0.7
}
},
{
id: 'pdf-converter',
type: 'action',
name: 'Convert to PDF',
position: { x: 700, y: 100 },
config: {
actionType: 'pdf-generator',
title: 'ChatGPT Response',
content: '{{response}}'
}
},
{
id: 'email-sender',
type: 'action',
name: 'Send Email',
position: { x: 1000, y: 100 },
config: {
actionType: 'email',
to: '{{email}}',
subject: 'Your ChatGPT Response',
attachments: ['{{pdf_file}}']
}
}
],
connections: [
{
id: 'conn-1',
source_node_id: 'webhook-trigger',
target_node_id: 'chatgpt-action',
source_handle: 'output',
target_handle: 'input'
},
{
id: 'conn-2',
source_node_id: 'chatgpt-action',
target_node_id: 'pdf-converter',
source_handle: 'output',
target_handle: 'input'
},
{
id: 'conn-3',
source_node_id: 'pdf-converter',
target_node_id: 'email-sender',
source_handle: 'output',
target_handle: 'input'
}
]
},
{
id: 'sheets-to-whatsapp',
name: 'New Google Sheet Row → Send WhatsApp Message',
description: 'When a new row is added to a Google Sheet (e.g., new order), send a WhatsApp message to the customer.',
tags: ['google-sheets', 'whatsapp', 'notification'],
nodes: [
{
id: 'sheets-trigger',
type: 'trigger',
name: 'Google Sheets',
position: { x: 100, y: 100 },
config: {
triggerType: 'google-sheets',
spreadsheetId: '',
sheetName: 'Orders'
}
},
{
id: 'whatsapp-action',
type: 'action',
name: 'WhatsApp Message',
position: { x: 400, y: 100 },
config: {
actionType: 'whatsapp',
to: '{{phone}}',
message: 'Hello {{name}}, your order #{{order_id}} has been received!'
}
}
],
connections: [
{
id: 'conn-1',
source_node_id: 'sheets-trigger',
target_node_id: 'whatsapp-action',
source_handle: 'output',
target_handle: 'input'
}
]
},
{
id: 'file-virus-scan',
name: 'Receive File via Webhook → Scan with VirusTotal → Email Report',
description: 'Upload a file through a webhook, scan it using the VirusTotal API, and send the scan report via email.',
tags: ['security', 'file-upload', 'virus-scan'],
nodes: [
{
id: 'webhook-trigger',
type: 'trigger',
name: 'Webhook',
position: { x: 100, y: 100 },
config: {
triggerType: 'webhook',
path: '/file-upload',
acceptFiles: true
}
},
{
id: 'virustotal-action',
type: 'action',
name: 'VirusTotal Scan',
position: { x: 400, y: 100 },
config: {
actionType: 'virustotal',
apiKey: '{{API_KEY}}'
}
},
{
id: 'email-sender',
type: 'action',
name: 'Send Email',
position: { x: 700, y: 100 },
config: {
actionType: 'email',
to: '{{email}}',
subject: 'File Scan Results',
body: 'Scan results for {{filename}}: {{scan_results}}'
}
}
],
connections: [
{
id: 'conn-1',
source_node_id: 'webhook-trigger',
target_node_id: 'virustotal-action',
source_handle: 'output',
target_handle: 'input'
},
{
id: 'conn-2',
source_node_id: 'virustotal-action',
target_node_id: 'email-sender',
source_handle: 'output',
target_handle: 'input'
}
]
},
{
id: 'website-monitor',
name: 'Check Website Every 10 Minutes → If Changed → Send Telegram Alert',
description: 'Monitor a web page (e.g., product page) for changes. If the content differs, send a Telegram notification.',
tags: ['monitoring', 'website', 'telegram'],
nodes: [
{
id: 'schedule-trigger',
type: 'trigger',
name: 'Schedule',
position: { x: 100, y: 100 },
config: {
triggerType: 'schedule',
cronExpression: '*/10 * * * *', // Every 10 minutes
timezone: 'UTC'
}
},
{
id: 'http-request',
type: 'action',
name: 'HTTP Request',
position: { x: 400, y: 100 },
config: {
actionType: 'http-request',
method: 'GET',
url: 'https://example.com/product-page'
}
},
{
id: 'content-diff',
type: 'logic',
name: 'Check for Changes',
position: { x: 700, y: 100 },
config: {
logicType: 'content-diff',
selector: '.product-price',
storageKey: 'last-product-price'
}
},
{
id: 'telegram-action',
type: 'action',
name: 'Telegram Alert',
position: { x: 1000, y: 100 },
config: {
actionType: 'telegram',
botToken: '{{BOT_TOKEN}}',
chatId: '',
message: 'Website content has changed! Check it out: https://example.com/product-page'
}
}
],
connections: [
{
id: 'conn-1',
source_node_id: 'schedule-trigger',
target_node_id: 'http-request',
source_handle: 'output',
target_handle: 'input'
},
{
id: 'conn-2',
source_node_id: 'http-request',
target_node_id: 'content-diff',
source_handle: 'output',
target_handle: 'input'
},
{
id: 'conn-3',
source_node_id: 'content-diff',
target_node_id: 'telegram-action',
source_handle: 'changed',
target_handle: 'input'
}
]
}
];
export default automationTemplates;

View File

@ -0,0 +1,169 @@
import React from 'react';
import ReactDOM from 'react-dom';
import ExecutionResults from './components/execution/ExecutionResults';
import ExecutionHistory from './components/execution/ExecutionHistory';
import CronScheduler from './components/scheduling/CronScheduler';
import VersionHistory from './components/workflow/VersionHistory';
import WebhookManager from './components/workflow/WebhookManager';
import NodeTester from './components/workflow/NodeTester';
import WorkflowEditorTabs from './components/workflow/WorkflowEditorTabs';
import WorkflowEditorActions from './components/workflow/WorkflowEditorActions';
import Modal from './components/common/Modal';
import './index.css';
// Mock data for testing
const mockWorkflowId = '123';
const mockExecutionId = '456';
const mockNodes = [
{
id: 'node1',
type: 'webhook',
data: { label: 'Webhook Node' }
},
{
id: 'node2',
type: 'http-request',
data: { label: 'HTTP Request' }
}
];
const mockSelectedNode = {
id: 'node1',
type: 'webhook',
data: {
label: 'Webhook Node',
config: { path: '/webhook/test' }
}
};
// Test component that renders all our components
const TestComponents = () => {
const [showModal, setShowModal] = React.useState(false);
const [activeComponent, setActiveComponent] = React.useState(null);
const renderComponent = (component) => {
setActiveComponent(component);
setShowModal(true);
};
return (
<div className="p-8">
<h1 className="text-3xl font-bold mb-8">FlowForge Component Testing</h1>
<div className="grid grid-cols-2 gap-4 mb-8">
<button
onClick={() => renderComponent('executionResults')}
className="p-4 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Test Execution Results
</button>
<button
onClick={() => renderComponent('executionHistory')}
className="p-4 bg-green-500 text-white rounded hover:bg-green-600"
>
Test Execution History
</button>
<button
onClick={() => renderComponent('cronScheduler')}
className="p-4 bg-purple-500 text-white rounded hover:bg-purple-600"
>
Test Cron Scheduler
</button>
<button
onClick={() => renderComponent('versionHistory')}
className="p-4 bg-yellow-500 text-white rounded hover:bg-yellow-600"
>
Test Version History
</button>
<button
onClick={() => renderComponent('webhookManager')}
className="p-4 bg-red-500 text-white rounded hover:bg-red-600"
>
Test Webhook Manager
</button>
<button
onClick={() => renderComponent('nodeTester')}
className="p-4 bg-indigo-500 text-white rounded hover:bg-indigo-600"
>
Test Node Tester
</button>
</div>
<div className="border p-4 rounded-lg shadow-md mb-8">
<h2 className="text-xl font-semibold mb-4">WorkflowEditorActions Component</h2>
<WorkflowEditorActions
workflowId={mockWorkflowId}
selectedNode={mockSelectedNode}
onDuplicateNode={(node) => console.log('Duplicate node:', node)}
onDeleteNode={(node) => console.log('Delete node:', node)}
onExecuteWorkflow={(id) => console.log('Execute workflow:', id)}
/>
</div>
<div className="border p-4 rounded-lg shadow-md">
<h2 className="text-xl font-semibold mb-4">WorkflowEditorTabs Component</h2>
<WorkflowEditorTabs
workflowId={mockWorkflowId}
nodes={mockNodes}
currentVersion={1}
onRestoreVersion={(version) => console.log('Restore version:', version)}
/>
</div>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
size="lg"
title={activeComponent ? activeComponent.charAt(0).toUpperCase() + activeComponent.slice(1) : ''}
>
{activeComponent === 'executionResults' && (
<ExecutionResults
workflowId={mockWorkflowId}
executionId={mockExecutionId}
onClose={() => setShowModal(false)}
/>
)}
{activeComponent === 'executionHistory' && (
<ExecutionHistory
workflowId={mockWorkflowId}
/>
)}
{activeComponent === 'cronScheduler' && (
<CronScheduler
workflowId={mockWorkflowId}
/>
)}
{activeComponent === 'versionHistory' && (
<VersionHistory
workflowId={mockWorkflowId}
currentVersion={1}
onRestoreVersion={(version) => console.log('Restore version:', version)}
/>
)}
{activeComponent === 'webhookManager' && (
<WebhookManager
workflowId={mockWorkflowId}
nodes={mockNodes}
/>
)}
{activeComponent === 'nodeTester' && (
<NodeTester
node={mockSelectedNode}
onClose={() => setShowModal(false)}
/>
)}
</Modal>
</div>
);
};
export default TestComponents;

View File

@ -0,0 +1,41 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
"./public/index.html"
],
theme: {
extend: {
colors: {
'primary': {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
'secondary': {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
},
},
plugins: [],
}