Initial commit: FlowForge automation templates integration
This commit is contained in:
commit
74fb343ead
37
.env.example
Normal file
37
.env.example
Normal 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
31
.gitignore
vendored
Normal 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
52
README.md
Normal 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
16
backend/Dockerfile
Normal 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
18
backend/Dockerfile.dev
Normal 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
43
backend/create-admin.js
Normal 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
79
backend/fix-user.sql
Normal 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
72
backend/logs/combined.log
Normal 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
34
backend/logs/error.log
Normal 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"}
|
62
backend/migrations/20250607_initial_schema.js
Normal file
62
backend/migrations/20250607_initial_schema.js
Normal 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
31
backend/package.json
Normal 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
66
backend/setup-db.sql
Normal 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
34
backend/src/config/db.js
Normal 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 };
|
146
backend/src/controllers/auth.js
Normal file
146
backend/src/controllers/auth.js
Normal 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
|
||||
};
|
53
backend/src/controllers/node.js
Normal file
53
backend/src/controllers/node.js
Normal 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
|
||||
};
|
60
backend/src/controllers/user.js
Normal file
60
backend/src/controllers/user.js
Normal 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
|
||||
};
|
190
backend/src/controllers/workflow.js
Normal file
190
backend/src/controllers/workflow.js
Normal 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
58
backend/src/index.js
Normal 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
|
57
backend/src/middleware/auth.js
Normal file
57
backend/src/middleware/auth.js
Normal 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 };
|
41
backend/src/middleware/errorHandler.js
Normal file
41
backend/src/middleware/errorHandler.js
Normal 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 };
|
72
backend/src/models/user.js
Normal file
72
backend/src/models/user.js
Normal 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
|
||||
};
|
181
backend/src/models/workflow.js
Normal file
181
backend/src/models/workflow.js
Normal 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
|
||||
};
|
18
backend/src/nodes/delay/meta.json
Normal file
18
backend/src/nodes/delay/meta.json
Normal 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" }
|
||||
]
|
||||
}
|
47
backend/src/nodes/delay/runner.js
Normal file
47
backend/src/nodes/delay/runner.js
Normal 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 };
|
33
backend/src/nodes/email/meta.json
Normal file
33
backend/src/nodes/email/meta.json
Normal 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" }
|
||||
]
|
||||
}
|
78
backend/src/nodes/email/runner.js
Normal file
78
backend/src/nodes/email/runner.js
Normal 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 };
|
19
backend/src/nodes/function/meta.json
Normal file
19
backend/src/nodes/function/meta.json
Normal 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" }
|
||||
]
|
||||
}
|
81
backend/src/nodes/function/runner.js
Normal file
81
backend/src/nodes/function/runner.js
Normal 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 };
|
22
backend/src/nodes/http-request/meta.json
Normal file
22
backend/src/nodes/http-request/meta.json
Normal 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" }
|
||||
]
|
||||
}
|
81
backend/src/nodes/http-request/runner.js
Normal file
81
backend/src/nodes/http-request/runner.js
Normal 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 };
|
19
backend/src/nodes/logger/meta.json
Normal file
19
backend/src/nodes/logger/meta.json
Normal 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" }
|
||||
]
|
||||
}
|
55
backend/src/nodes/logger/runner.js
Normal file
55
backend/src/nodes/logger/runner.js
Normal 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 };
|
17
backend/src/nodes/webhook/meta.json
Normal file
17
backend/src/nodes/webhook/meta.json
Normal 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" }
|
||||
]
|
||||
}
|
50
backend/src/nodes/webhook/runner.js
Normal file
50
backend/src/nodes/webhook/runner.js
Normal 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
|
||||
};
|
14
backend/src/routes/auth.js
Normal file
14
backend/src/routes/auth.js
Normal 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;
|
15
backend/src/routes/nodes.js
Normal file
15
backend/src/routes/nodes.js
Normal 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;
|
15
backend/src/routes/users.js
Normal file
15
backend/src/routes/users.js
Normal 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;
|
26
backend/src/routes/workflows.js
Normal file
26
backend/src/routes/workflows.js
Normal 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;
|
116
backend/src/services/nodeRegistry.js
Normal file
116
backend/src/services/nodeRegistry.js
Normal 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;
|
306
backend/src/services/workflowExecutor.js
Normal file
306
backend/src/services/workflowExecutor.js
Normal 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;
|
34
backend/src/utils/logger.js
Normal file
34
backend/src/utils/logger.js
Normal 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
24
backend/test-bcrypt.js
Normal 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
68
docker-compose.dev.yml
Normal 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
73
docker-compose.yml
Normal 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
29
frontend/Dockerfile
Normal 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
18
frontend/Dockerfile.dev
Normal 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"]
|
203
frontend/integration-guide.md
Normal file
203
frontend/integration-guide.md
Normal 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
45
frontend/nginx/nginx.conf
Normal 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
46
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
23
frontend/public/index.html
Normal file
23
frontend/public/index.html
Normal 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>
|
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal 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
67
frontend/src/App.js
Normal 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;
|
92
frontend/src/components/common/Modal.js
Normal file
92
frontend/src/components/common/Modal.js
Normal 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">
|
||||
​
|
||||
</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;
|
304
frontend/src/components/execution/ExecutionHistory.js
Normal file
304
frontend/src/components/execution/ExecutionHistory.js
Normal 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;
|
262
frontend/src/components/execution/ExecutionResults.js
Normal file
262
frontend/src/components/execution/ExecutionResults.js
Normal 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;
|
37
frontend/src/components/layouts/AuthLayout.js
Normal file
37
frontend/src/components/layouts/AuthLayout.js
Normal 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;
|
187
frontend/src/components/layouts/MainLayout.js
Normal file
187
frontend/src/components/layouts/MainLayout.js
Normal 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;
|
353
frontend/src/components/scheduling/CronScheduler.js
Normal file
353
frontend/src/components/scheduling/CronScheduler.js
Normal 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;
|
108
frontend/src/components/workflow/CustomNode.js
Normal file
108
frontend/src/components/workflow/CustomNode.js
Normal 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);
|
275
frontend/src/components/workflow/NodeConfigPanel.js
Normal file
275
frontend/src/components/workflow/NodeConfigPanel.js
Normal 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;
|
197
frontend/src/components/workflow/NodeTester.js
Normal file
197
frontend/src/components/workflow/NodeTester.js
Normal 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;
|
77
frontend/src/components/workflow/NodeTypeSelector.js
Normal file
77
frontend/src/components/workflow/NodeTypeSelector.js
Normal 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;
|
280
frontend/src/components/workflow/VersionHistory.js
Normal file
280
frontend/src/components/workflow/VersionHistory.js
Normal 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;
|
137
frontend/src/components/workflow/WebhookManager.js
Normal file
137
frontend/src/components/workflow/WebhookManager.js
Normal 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;
|
158
frontend/src/components/workflow/WorkflowEditorActions.js
Normal file
158
frontend/src/components/workflow/WorkflowEditorActions.js
Normal 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;
|
64
frontend/src/components/workflow/WorkflowEditorTabs.js
Normal file
64
frontend/src/components/workflow/WorkflowEditorTabs.js
Normal 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;
|
141
frontend/src/context/AuthContext.js
Normal file
141
frontend/src/context/AuthContext.js
Normal 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>
|
||||
);
|
||||
};
|
6
frontend/src/hooks/useAuth.js
Normal file
6
frontend/src/hooks/useAuth.js
Normal 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
72
frontend/src/index.css
Normal 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
20
frontend/src/index.js
Normal 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>
|
||||
);
|
182
frontend/src/pages/Dashboard.js
Normal file
182
frontend/src/pages/Dashboard.js
Normal 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"> →</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
106
frontend/src/pages/Login.js
Normal file
106
frontend/src/pages/Login.js
Normal 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;
|
31
frontend/src/pages/NotFound.js
Normal file
31
frontend/src/pages/NotFound.js
Normal 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;
|
185
frontend/src/pages/Profile.js
Normal file
185
frontend/src/pages/Profile.js
Normal 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;
|
135
frontend/src/pages/Register.js
Normal file
135
frontend/src/pages/Register.js
Normal 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;
|
137
frontend/src/pages/TemplatesPage.js
Normal file
137
frontend/src/pages/TemplatesPage.js
Normal 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;
|
166
frontend/src/pages/TestPage.js
Normal file
166
frontend/src/pages/TestPage.js
Normal 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;
|
503
frontend/src/pages/WorkflowEditor.js
Normal file
503
frontend/src/pages/WorkflowEditor.js
Normal 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;
|
201
frontend/src/pages/WorkflowList.js
Normal file
201
frontend/src/pages/WorkflowList.js
Normal 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;
|
36
frontend/src/services/api.js
Normal file
36
frontend/src/services/api.js
Normal 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;
|
46
frontend/src/services/nodes.js
Normal file
46
frontend/src/services/nodes.js
Normal 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;
|
93
frontend/src/services/workflow.js
Normal file
93
frontend/src/services/workflow.js
Normal 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;
|
526
frontend/src/templates/templates.js
Normal file
526
frontend/src/templates/templates.js
Normal 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;
|
169
frontend/src/test-components.js
Normal file
169
frontend/src/test-components.js
Normal 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;
|
41
frontend/tailwind.config.js
Normal file
41
frontend/tailwind.config.js
Normal 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: [],
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user