Merge branch 'develop' into delete_runs_notification
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
# App Setup
|
# App Setup
|
||||||
NODE_ENV=production # Set to 'development' or 'production' as required
|
NODE_ENV=production # Set to 'development' or 'production' as required
|
||||||
JWT_SECRET=your_jwt_secret_key # Replace with a secure JWT secret key
|
JWT_SECRET=a9Z$kLq7^f03GzNw!bP9dH4xV6sT2yXl3O8vR@uYq3 # Replace with a secure JWT secret key
|
||||||
DB_NAME=maxun # Your PostgreSQL database name
|
DB_NAME=maxun # Your PostgreSQL database name
|
||||||
DB_USER=postgres # PostgreSQL username
|
DB_USER=postgres # PostgreSQL username
|
||||||
DB_PASSWORD=postgres # PostgreSQL password
|
DB_PASSWORD=postgres # PostgreSQL password
|
||||||
DB_HOST=postgres # Host for PostgreSQL in Docker
|
DB_HOST=postgres # Host for PostgreSQL in Docker
|
||||||
DB_PORT=5432 # Port for PostgreSQL (default: 5432)
|
DB_PORT=5432 # Port for PostgreSQL (default: 5432)
|
||||||
ENCRYPTION_KEY=your_encryption_key # Key for encrypting sensitive data (passwords and proxies)
|
ENCRYPTION_KEY=f4d5e6a7b8c9d0e1f23456789abcdef01234567890abcdef123456789abcdef0 # Key for encrypting sensitive data (passwords and proxies)
|
||||||
MINIO_ENDPOINT=minio # MinIO endpoint in Docker
|
MINIO_ENDPOINT=minio # MinIO endpoint in Docker
|
||||||
MINIO_PORT=9000 # Port for MinIO (default: 9000)
|
MINIO_PORT=9000 # Port for MinIO (default: 9000)
|
||||||
MINIO_ACCESS_KEY=minio_access_key # MinIO access key
|
MINIO_ACCESS_KEY=minio_access_key # MinIO access key
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ Maxun lets you train a robot in 2 minutes and scrape the web on auto-pilot. Web
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://maxun-website.vercel.app/"><b>Website</b></a> |
|
<a href="https://maxun-website.vercel.app/"><b>Website</b></a> |
|
||||||
<a href="https://discord.com/invite/NFhWDCdb"><b>Discord</b></a> |
|
<a href="https://discord.gg/5GbPjBUkws"><b>Discord</b></a> |
|
||||||
<a href="https://x.com/maxun_io"><b>Twitter</b></a> |
|
<a href="https://x.com/maxun_io"><b>Twitter</b></a> |
|
||||||
<a href="https://docs.google.com/forms/d/e/1FAIpQLSdbD2uhqC4sbg4eLZ9qrFbyrfkXZ2XsI6dQ0USRCQNZNn5pzg/viewform"><b>Join Maxun Cloud</b></a> |
|
<a href="https://docs.google.com/forms/d/e/1FAIpQLSdbD2uhqC4sbg4eLZ9qrFbyrfkXZ2XsI6dQ0USRCQNZNn5pzg/viewform"><b>Join Maxun Cloud</b></a> |
|
||||||
<a href="https://www.youtube.com/@MaxunOSS"><b>Watch Tutorials</b></a>
|
<a href="https://www.youtube.com/@MaxunOSS"><b>Watch Tutorials</b></a>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<a href="https://trendshift.io/repositories/12113" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12113" alt="getmaxun%2Fmaxun | Trendshift" style="width: 250px; height: 55px; margin-top: 10px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
@@ -61,8 +64,8 @@ You can access the frontend at http://localhost:5173/ and backend at http://loca
|
|||||||
|
|
||||||
| Variable | Mandatory | Description | If Not Set |
|
| Variable | Mandatory | Description | If Not Set |
|
||||||
|-----------------------|-----------|----------------------------------------------------------------------------------------------|--------------------------------------------------------------|
|
|-----------------------|-----------|----------------------------------------------------------------------------------------------|--------------------------------------------------------------|
|
||||||
| `BACKEND_URL` | Yes | URL to run backend on. | Backend won't start. If not sure, set to http://localhost:8080 |
|
| `BACKEND_URL` | Yes | URL to run backend on. | Default value: http://localhost:8080 |
|
||||||
| `VITE_BACKEND_URL` | Yes | URL to run backend on. | Backend won't start. If not sure, set to http://localhost:8080 |
|
| `VITE_BACKEND_URL` | Yes | URL used by frontend to connect to backend | Default value: http://localhost:8080 |
|
||||||
| `JWT_SECRET` | Yes | Secret key used to sign and verify JSON Web Tokens (JWTs) for authentication. | JWT authentication will not work. |
|
| `JWT_SECRET` | Yes | Secret key used to sign and verify JSON Web Tokens (JWTs) for authentication. | JWT authentication will not work. |
|
||||||
| `DB_NAME` | Yes | Name of the Postgres database to connect to. | Database connection will fail. |
|
| `DB_NAME` | Yes | Name of the Postgres database to connect to. | Database connection will fail. |
|
||||||
| `DB_USER` | Yes | Username for Postgres database authentication. | Database connection will fail. |
|
| `DB_USER` | Yes | Username for Postgres database authentication. | Database connection will fail. |
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
|
||||||
command: server /data
|
command: server /data --console-address :9001
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "9000:9000" # API port
|
||||||
|
- "9001:9001" # WebUI port
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
|
|
||||||
|
|||||||
0
esbuild.config.js
Normal file
0
esbuild.config.js
Normal file
@@ -39,6 +39,7 @@
|
|||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"joi": "^17.6.0",
|
"joi": "^17.6.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"loglevel": "^1.8.0",
|
"loglevel": "^1.8.0",
|
||||||
"loglevel-plugin-remote": "^0.6.8",
|
"loglevel-plugin-remote": "^0.6.8",
|
||||||
"maxun-core": "^0.0.3",
|
"maxun-core": "^0.0.3",
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
"moment-timezone": "^0.5.45",
|
"moment-timezone": "^0.5.45",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"playwright": "^1.20.1",
|
"playwright": "^1.48.2",
|
||||||
"playwright-extra": "^4.3.6",
|
"playwright-extra": "^4.3.6",
|
||||||
"posthog-node": "^4.2.1",
|
"posthog-node": "^4.2.1",
|
||||||
"prismjs": "^1.28.0",
|
"prismjs": "^1.28.0",
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/loglevel": "^1.6.3",
|
"@types/loglevel": "^1.6.3",
|
||||||
"@types/node": "22.7.9",
|
"@types/node": "22.7.9",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
@@ -102,6 +104,7 @@
|
|||||||
"ajv": "^8.8.2",
|
"ajv": "^8.8.2",
|
||||||
"concurrently": "^7.0.0",
|
"concurrently": "^7.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"nodemon": "^2.0.15",
|
"nodemon": "^2.0.15",
|
||||||
"ts-node": "^10.4.0",
|
"ts-node": "^10.4.0",
|
||||||
"vite": "^5.4.10"
|
"vite": "^5.4.10"
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ export class RemoteBrowser {
|
|||||||
const workflow = this.generator.AddGeneratedFlags(this.generator.getWorkflowFile());
|
const workflow = this.generator.AddGeneratedFlags(this.generator.getWorkflowFile());
|
||||||
await this.initializeNewPage();
|
await this.initializeNewPage();
|
||||||
if (this.currentPage) {
|
if (this.currentPage) {
|
||||||
|
this.currentPage.setViewportSize({ height: 400, width: 900 });
|
||||||
const params = this.generator.getParams();
|
const params = this.generator.getParams();
|
||||||
if (params) {
|
if (params) {
|
||||||
this.interpreterSettings.params = params.reduce((acc, param) => {
|
this.interpreterSettings.params = params.reduce((acc, param) => {
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ export const requireSignIn = (req: UserRequest, res: Response, next: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
verify(token, secret, (err: any, user: any) => {
|
verify(token, secret, (err: any, user: any) => {
|
||||||
console.log(err)
|
if (err) {
|
||||||
|
console.log('JWT verification error:', err);
|
||||||
if (err) return res.sendStatus(403)
|
return res.sendStatus(403);
|
||||||
|
}
|
||||||
req.user = user;
|
req.user = { ...user, id: user.userId }; // Normalize to include `id`
|
||||||
|
next();
|
||||||
next()
|
});
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,480 +1,559 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from "express";
|
||||||
import User from '../models/User';
|
import User from "../models/User";
|
||||||
import Robot from '../models/Robot';
|
import Robot from "../models/Robot";
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from "jsonwebtoken";
|
||||||
import { hashPassword, comparePassword } from '../utils/auth';
|
import { hashPassword, comparePassword } from "../utils/auth";
|
||||||
import { requireSignIn } from '../middlewares/auth';
|
import { requireSignIn } from "../middlewares/auth";
|
||||||
import { genAPIKey } from '../utils/api';
|
import { genAPIKey } from "../utils/api";
|
||||||
import { google } from 'googleapis';
|
import { google } from "googleapis";
|
||||||
import { capture } from "../utils/analytics"
|
import { capture } from "../utils/analytics";
|
||||||
export const router = Router();
|
export const router = Router();
|
||||||
|
|
||||||
interface AuthenticatedRequest extends Request {
|
interface AuthenticatedRequest extends Request {
|
||||||
user?: { id: string };
|
user?: { id: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post('/register', async (req, res) => {
|
router.post("/register", async (req, res) => {
|
||||||
console.log('Received request at /auth/register');
|
console.log("Received request at /auth/register");
|
||||||
console.log('Received body:', req.body);
|
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email) return res.status(400).send("Email is required");
|
||||||
|
if (!password || password.length < 6)
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.send("Password is required and must be at least 6 characters");
|
||||||
|
|
||||||
|
let userExist = await User.findOne({ raw: true, where: { email } });
|
||||||
|
if (userExist) return res.status(400).send("User already exists");
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(password);
|
||||||
|
|
||||||
|
let user: any;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { email, password } = req.body
|
user = await User.create({ email, password: hashedPassword });
|
||||||
|
|
||||||
if (!email) return res.status(400).send('Email is required')
|
|
||||||
if (!password || password.length < 6) return res.status(400).send('Password is required and must be at least 6 characters')
|
|
||||||
|
|
||||||
let userExist = await User.findOne({ raw: true, where: { email } });
|
|
||||||
if (userExist) return res.status(400).send('User already exists')
|
|
||||||
|
|
||||||
const hashedPassword = await hashPassword(password)
|
|
||||||
|
|
||||||
let user: any;
|
|
||||||
|
|
||||||
try {
|
|
||||||
user = await User.create({ email, password: hashedPassword });
|
|
||||||
} catch (
|
|
||||||
error: any
|
|
||||||
) {
|
|
||||||
console.log(`Could not create user - ${error}`)
|
|
||||||
return res.status(500).send(`Could not create user - ${error.message}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.JWT_SECRET) {
|
|
||||||
console.log('JWT_SECRET is not defined in the environment');
|
|
||||||
return res.status(500).send('Internal Server Error');
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { expiresIn: '12h' });
|
|
||||||
user.password = undefined as unknown as string
|
|
||||||
res.cookie('token', token, {
|
|
||||||
httpOnly: true
|
|
||||||
})
|
|
||||||
capture(
|
|
||||||
'maxun-oss-user-registered',
|
|
||||||
{
|
|
||||||
email: user.email,
|
|
||||||
userId: user.id,
|
|
||||||
registeredAt: new Date().toISOString()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
console.log(`User registered - ${user.email}`)
|
|
||||||
res.json(user)
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(`Could not register user - ${error}`)
|
console.log(`Could not create user - ${error}`);
|
||||||
res.status(500).send(`Could not register user - ${error.message}`)
|
return res.status(500).send(`Could not create user - ${error.message}`);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
router.post('/login', async (req, res) => {
|
if (!process.env.JWT_SECRET) {
|
||||||
try {
|
console.log("JWT_SECRET is not defined in the environment");
|
||||||
const { email, password } = req.body;
|
return res.status(500).send("Internal Server Error");
|
||||||
if (!email || !password) return res.status(400).send('Email and password are required')
|
|
||||||
if (password.length < 6) return res.status(400).send('Password must be at least 6 characters')
|
|
||||||
|
|
||||||
let user = await User.findOne({ raw: true, where: { email } });
|
|
||||||
if (!user) return res.status(400).send('User does not exist');
|
|
||||||
|
|
||||||
const match = await comparePassword(password, user.password)
|
|
||||||
if (!match) return res.status(400).send('Invalid email or password')
|
|
||||||
|
|
||||||
const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string, { expiresIn: '12h' });
|
|
||||||
|
|
||||||
// return user and token to client, exclude hashed password
|
|
||||||
if (user) {
|
|
||||||
user.password = undefined as unknown as string;
|
|
||||||
}
|
|
||||||
res.cookie('token', token, {
|
|
||||||
httpOnly: true
|
|
||||||
})
|
|
||||||
res.json(user)
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(400).send(`Could not login user - ${error.message}`)
|
|
||||||
console.log(`Could not login user - ${error}`)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
router.get('/logout', async (req, res) => {
|
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET as string);
|
||||||
try {
|
user.password = undefined as unknown as string;
|
||||||
res.clearCookie('token')
|
res.cookie("token", token, {
|
||||||
return res.json({ message: 'Logout successful' })
|
httpOnly: true,
|
||||||
} catch (error: any) {
|
});
|
||||||
res.status(500).send(`Could not logout user - ${error.message}`)
|
capture("maxun-oss-user-registered", {
|
||||||
}
|
email: user.email,
|
||||||
})
|
userId: user.id,
|
||||||
|
registeredAt: new Date().toISOString(),
|
||||||
router.get('/current-user', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
});
|
||||||
try {
|
console.log(`User registered - ${user.email}`);
|
||||||
if (!req.user) {
|
res.json(user);
|
||||||
return res.status(401).json({ ok: false, error: 'Unauthorized' });
|
} catch (error: any) {
|
||||||
}
|
console.log(`Could not register user - ${error}`);
|
||||||
const user = await User.findByPk(req.user.id, {
|
res.status(500).send(`Could not register user - ${error.message}`);
|
||||||
attributes: { exclude: ['password'] },
|
}
|
||||||
});
|
|
||||||
if (!user) {
|
|
||||||
return res.status(404).json({ ok: false, error: 'User not found' });
|
|
||||||
} else {
|
|
||||||
return res.status(200).json({ ok: true, user: user });
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error in current-user route:', error);
|
|
||||||
return res.status(500).json({ ok: false, error: `Could not fetch current user: ${error.message}` });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/user/:id', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
router.post("/login", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { email, password } = req.body;
|
||||||
if (!id) {
|
if (!email || !password)
|
||||||
return res.status(400).json({ message: 'User ID is required' });
|
return res.status(400).send("Email and password are required");
|
||||||
}
|
if (password.length < 6)
|
||||||
|
return res.status(400).send("Password must be at least 6 characters");
|
||||||
|
|
||||||
const user = await User.findByPk(id, {
|
let user = await User.findOne({ raw: true, where: { email } });
|
||||||
attributes: { exclude: ['password'] },
|
if (!user) return res.status(400).send("User does not exist");
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
const match = await comparePassword(password, user.password);
|
||||||
return res.status(404).json({ message: 'User not found' });
|
if (!match) return res.status(400).send("Invalid email or password");
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({ message: 'User fetched successfully', user });
|
const token = jwt.sign({ id: user?.id }, process.env.JWT_SECRET as string);
|
||||||
} catch (error: any) {
|
|
||||||
return res.status(500).json({ message: 'Error fetching user', error: error.message });
|
// return user and token to client, exclude hashed password
|
||||||
|
if (user) {
|
||||||
|
user.password = undefined as unknown as string;
|
||||||
}
|
}
|
||||||
|
res.cookie("token", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
});
|
||||||
|
capture("maxun-oss-user-login", {
|
||||||
|
email: user.email,
|
||||||
|
userId: user.id,
|
||||||
|
loggedInAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
res.json(user);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(400).send(`Could not login user - ${error.message}`);
|
||||||
|
console.log(`Could not login user - ${error}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/generate-api-key', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
router.get("/logout", async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.clearCookie("token");
|
||||||
|
return res.json({ message: "Logout successful" });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).send(`Could not logout user - ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/current-user",
|
||||||
|
requireSignIn,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ ok: false, error: 'Unauthorized' });
|
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
const user = await User.findByPk(req.user.id, {
|
const user = await User.findByPk(req.user.id, {
|
||||||
attributes: { exclude: ['password'] },
|
attributes: { exclude: ["password"] },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ ok: false, error: "User not found" });
|
||||||
|
} else {
|
||||||
|
return res.status(200).json({ ok: true, user: user });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error in current-user route:", error);
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({
|
||||||
|
ok: false,
|
||||||
|
error: `Could not fetch current user: ${error.message}`,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!user) {
|
router.get(
|
||||||
return res.status(404).json({ message: 'User not found' });
|
"/user/:id",
|
||||||
}
|
requireSignIn,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ message: "User ID is required" });
|
||||||
|
}
|
||||||
|
|
||||||
if (user.api_key) {
|
const user = await User.findByPk(id, {
|
||||||
return res.status(400).json({ message: 'API key already exists' });
|
attributes: { exclude: ["password"] },
|
||||||
}
|
});
|
||||||
const apiKey = genAPIKey();
|
|
||||||
|
|
||||||
await user.update({ api_key: apiKey });
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
capture(
|
return res
|
||||||
'maxun-oss-api-key-created',
|
.status(200)
|
||||||
{
|
.json({ message: "User fetched successfully", user });
|
||||||
user_id: user.id,
|
} catch (error: any) {
|
||||||
created_at: new Date().toISOString()
|
return res
|
||||||
}
|
.status(500)
|
||||||
)
|
.json({ message: "Error fetching user", error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return res.status(200).json({
|
router.post(
|
||||||
message: 'API key generated successfully',
|
"/generate-api-key",
|
||||||
api_key: apiKey,
|
requireSignIn,
|
||||||
});
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
const user = await User.findByPk(req.user.id, {
|
||||||
|
attributes: { exclude: ["password"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.api_key) {
|
||||||
|
return res.status(400).json({ message: "API key already exists" });
|
||||||
|
}
|
||||||
|
const apiKey = genAPIKey();
|
||||||
|
|
||||||
|
await user.update({ api_key: apiKey });
|
||||||
|
|
||||||
|
capture("maxun-oss-api-key-created", {
|
||||||
|
user_id: user.id,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: "API key generated successfully",
|
||||||
|
api_key: apiKey,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({ message: 'Error generating API key', error });
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: "Error generating API key", error });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/api-key', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
router.get(
|
||||||
|
"/api-key",
|
||||||
|
requireSignIn,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ ok: false, error: 'Unauthorized' });
|
return res.status(401).json({ ok: false, error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findByPk(req.user.id, {
|
const user = await User.findByPk(req.user.id, {
|
||||||
raw: true,
|
raw: true,
|
||||||
attributes: ['api_key'],
|
attributes: ["api_key"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({ message: 'User not found' });
|
return res.status(404).json({ message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
message: 'API key fetched successfully',
|
message: "API key fetched successfully",
|
||||||
api_key: user.api_key || null,
|
api_key: user.api_key || null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(500).json({ message: 'Error fetching API key', error });
|
return res.status(500).json({ message: "Error fetching API key", error });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
router.delete('/delete-api-key', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/delete-api-key",
|
||||||
|
requireSignIn,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).send({ error: 'Unauthorized' });
|
return res.status(401).send({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await User.findByPk(req.user.id, { raw: true });
|
const user = await User.findByPk(req.user.id, { raw: true });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({ message: 'User not found' });
|
return res.status(404).json({ message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.api_key) {
|
if (!user.api_key) {
|
||||||
return res.status(404).json({ message: 'API Key not found' });
|
return res.status(404).json({ message: "API Key not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await User.update({ api_key: null }, { where: { id: req.user.id } });
|
await User.update({ api_key: null }, { where: { id: req.user.id } });
|
||||||
|
|
||||||
capture(
|
capture("maxun-oss-api-key-deleted", {
|
||||||
'maxun-oss-api-key-deleted',
|
user_id: user.id,
|
||||||
{
|
deleted_at: new Date().toISOString(),
|
||||||
user_id: user.id,
|
});
|
||||||
deleted_at: new Date().toISOString()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return res.status(200).json({ message: 'API Key deleted successfully' });
|
return res.status(200).json({ message: "API Key deleted successfully" });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return res.status(500).json({ message: 'Error deleting API key', error: error.message });
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: "Error deleting API key", error: error.message });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const oauth2Client = new google.auth.OAuth2(
|
const oauth2Client = new google.auth.OAuth2(
|
||||||
process.env.GOOGLE_CLIENT_ID,
|
process.env.GOOGLE_CLIENT_ID,
|
||||||
process.env.GOOGLE_CLIENT_SECRET,
|
process.env.GOOGLE_CLIENT_SECRET,
|
||||||
process.env.GOOGLE_REDIRECT_URI
|
process.env.GOOGLE_REDIRECT_URI
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 1: Redirect to Google for authentication
|
// Step 1: Redirect to Google for authentication
|
||||||
router.get('/google', (req, res) => {
|
router.get("/google", (req, res) => {
|
||||||
const { robotId } = req.query;
|
const { robotId } = req.query;
|
||||||
if (!robotId) {
|
if (!robotId) {
|
||||||
return res.status(400).json({ message: 'Robot ID is required' });
|
return res.status(400).json({ message: "Robot ID is required" });
|
||||||
}
|
}
|
||||||
const scopes = [
|
const scopes = [
|
||||||
'https://www.googleapis.com/auth/spreadsheets',
|
"https://www.googleapis.com/auth/spreadsheets",
|
||||||
'https://www.googleapis.com/auth/userinfo.email',
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
'https://www.googleapis.com/auth/drive.readonly',
|
"https://www.googleapis.com/auth/drive.readonly",
|
||||||
];
|
];
|
||||||
const url = oauth2Client.generateAuthUrl({
|
const url = oauth2Client.generateAuthUrl({
|
||||||
access_type: 'offline',
|
access_type: "offline",
|
||||||
prompt: 'consent', // Ensures you get a refresh token on first login
|
prompt: "consent", // Ensures you get a refresh token on first login
|
||||||
scope: scopes,
|
scope: scopes,
|
||||||
state: robotId.toString(),
|
state: robotId.toString(),
|
||||||
});
|
});
|
||||||
res.redirect(url);
|
res.redirect(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 2: Handle Google OAuth callback
|
// Step 2: Handle Google OAuth callback
|
||||||
router.get('/google/callback', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
router.get(
|
||||||
|
"/google/callback",
|
||||||
|
requireSignIn,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
const { code, state } = req.query;
|
const { code, state } = req.query;
|
||||||
try {
|
try {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return res.status(400).json({ message: 'Robot ID is required' });
|
return res.status(400).json({ message: "Robot ID is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const robotId = state
|
const robotId = state;
|
||||||
|
|
||||||
// Get access and refresh tokens
|
// Get access and refresh tokens
|
||||||
if (typeof code !== 'string') {
|
if (typeof code !== "string") {
|
||||||
return res.status(400).json({ message: 'Invalid code' });
|
return res.status(400).json({ message: "Invalid code" });
|
||||||
}
|
}
|
||||||
const { tokens } = await oauth2Client.getToken(code);
|
const { tokens } = await oauth2Client.getToken(code);
|
||||||
oauth2Client.setCredentials(tokens);
|
oauth2Client.setCredentials(tokens);
|
||||||
|
|
||||||
// Get user profile from Google
|
// Get user profile from Google
|
||||||
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
|
const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client });
|
||||||
const { data: { email } } = await oauth2.userinfo.get();
|
const {
|
||||||
|
data: { email },
|
||||||
|
} = await oauth2.userinfo.get();
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return res.status(400).json({ message: 'Email not found' });
|
return res.status(400).json({ message: "Email not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).send({ error: 'Unauthorized' });
|
return res.status(401).send({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the currently authenticated user (from `requireSignIn`)
|
// Get the currently authenticated user (from `requireSignIn`)
|
||||||
let user = await User.findOne({ where: { id: req.user.id } });
|
let user = await User.findOne({ where: { id: req.user.id } });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(400).json({ message: 'User not found' });
|
return res.status(400).json({ message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
let robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } });
|
let robot = await Robot.findOne({
|
||||||
|
where: { "recording_meta.id": robotId },
|
||||||
|
});
|
||||||
|
|
||||||
if (!robot) {
|
if (!robot) {
|
||||||
return res.status(400).json({ message: 'Robot not found' });
|
return res.status(400).json({ message: "Robot not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
robot = await robot.update({
|
robot = await robot.update({
|
||||||
google_sheet_email: email,
|
google_sheet_email: email,
|
||||||
google_access_token: tokens.access_token,
|
google_access_token: tokens.access_token,
|
||||||
google_refresh_token: tokens.refresh_token,
|
google_refresh_token: tokens.refresh_token,
|
||||||
});
|
});
|
||||||
capture(
|
capture("maxun-oss-google-sheet-integration-created", {
|
||||||
'maxun-oss-google-sheet-integration-created',
|
user_id: user.id,
|
||||||
{
|
robot_id: robot.recording_meta.id,
|
||||||
user_id: user.id,
|
created_at: new Date().toISOString(),
|
||||||
robot_id: robot.recording_meta.id,
|
});
|
||||||
created_at: new Date().toISOString()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// List user's Google Sheets from their Google Drive
|
// List user's Google Sheets from their Google Drive
|
||||||
const drive = google.drive({ version: 'v3', auth: oauth2Client });
|
const drive = google.drive({ version: "v3", auth: oauth2Client });
|
||||||
const response = await drive.files.list({
|
const response = await drive.files.list({
|
||||||
q: "mimeType='application/vnd.google-apps.spreadsheet'", // List only Google Sheets files
|
q: "mimeType='application/vnd.google-apps.spreadsheet'", // List only Google Sheets files
|
||||||
fields: 'files(id, name)', // Retrieve the ID and name of each file
|
fields: "files(id, name)", // Retrieve the ID and name of each file
|
||||||
});
|
});
|
||||||
|
|
||||||
const files = response.data.files || [];
|
const files = response.data.files || [];
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return res.status(404).json({ message: 'No spreadsheets found.' });
|
return res.status(404).json({ message: "No spreadsheets found." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token for session
|
// Generate JWT token for session
|
||||||
const jwtToken = jwt.sign({ userId: user.id }, process.env.JWT_SECRET as string, { expiresIn: '12h' });
|
const jwtToken = jwt.sign(
|
||||||
res.cookie('token', jwtToken, { httpOnly: true });
|
{ userId: user.id },
|
||||||
|
process.env.JWT_SECRET as string
|
||||||
|
);
|
||||||
|
res.cookie("token", jwtToken, { httpOnly: true });
|
||||||
|
|
||||||
res.json({
|
// res.json({
|
||||||
message: 'Google authentication successful',
|
// message: 'Google authentication successful',
|
||||||
google_sheet_email: robot.google_sheet_email,
|
// google_sheet_email: robot.google_sheet_email,
|
||||||
jwtToken,
|
// jwtToken,
|
||||||
files
|
// files
|
||||||
});
|
// });
|
||||||
|
|
||||||
|
res.cookie("robot_auth_status", "success", {
|
||||||
|
httpOnly: false,
|
||||||
|
maxAge: 60000,
|
||||||
|
}); // 1-minute expiration
|
||||||
|
res.cookie("robot_auth_message", "Robot successfully authenticated", {
|
||||||
|
httpOnly: false,
|
||||||
|
maxAge: 60000,
|
||||||
|
});
|
||||||
|
res.redirect(`http://localhost:5173`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(500).json({ message: `Google OAuth error: ${error.message}` });
|
res.status(500).json({ message: `Google OAuth error: ${error.message}` });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Step 3: Get data from Google Sheets
|
// Step 3: Get data from Google Sheets
|
||||||
router.post('/gsheets/data', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
router.post(
|
||||||
|
"/gsheets/data",
|
||||||
|
requireSignIn,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
const { spreadsheetId, robotId } = req.body;
|
const { spreadsheetId, robotId } = req.body;
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).send({ error: 'Unauthorized' });
|
return res.status(401).send({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
const user = await User.findByPk(req.user.id, { raw: true });
|
const user = await User.findByPk(req.user.id, { raw: true });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(400).json({ message: 'User not found' });
|
return res.status(400).json({ message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId }, raw: true });
|
const robot = await Robot.findOne({
|
||||||
|
where: { "recording_meta.id": robotId },
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (!robot) {
|
if (!robot) {
|
||||||
return res.status(400).json({ message: 'Robot not found' });
|
return res.status(400).json({ message: "Robot not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set Google OAuth credentials
|
// Set Google OAuth credentials
|
||||||
oauth2Client.setCredentials({
|
oauth2Client.setCredentials({
|
||||||
access_token: robot.google_access_token,
|
access_token: robot.google_access_token,
|
||||||
refresh_token: robot.google_refresh_token,
|
refresh_token: robot.google_refresh_token,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sheets = google.sheets({ version: 'v4', auth: oauth2Client });
|
const sheets = google.sheets({ version: "v4", auth: oauth2Client });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch data from the spreadsheet (you can let the user choose a specific range too)
|
// Fetch data from the spreadsheet (you can let the user choose a specific range too)
|
||||||
const sheetData = await sheets.spreadsheets.values.get({
|
const sheetData = await sheets.spreadsheets.values.get({
|
||||||
spreadsheetId,
|
spreadsheetId,
|
||||||
range: 'Sheet1!A1:D5', // Default range, could be dynamic based on user input
|
range: "Sheet1!A1:D5", // Default range, could be dynamic based on user input
|
||||||
});
|
});
|
||||||
res.json(sheetData.data);
|
res.json(sheetData.data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(500).json({ message: `Error accessing Google Sheets: ${error.message}` });
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: `Error accessing Google Sheets: ${error.message}` });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Step 4: Get user's Google Sheets files (new route)
|
// Step 4: Get user's Google Sheets files (new route)
|
||||||
router.get('/gsheets/files', requireSignIn, async (req, res) => {
|
router.get("/gsheets/files", requireSignIn, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const robotId = req.query.robotId;
|
const robotId = req.query.robotId;
|
||||||
const robot = await Robot.findOne({ where: { 'recording_meta.id': robotId }, raw: true });
|
const robot = await Robot.findOne({
|
||||||
|
where: { "recording_meta.id": robotId },
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (!robot) {
|
if (!robot) {
|
||||||
return res.status(400).json({ message: 'Robot not found' });
|
return res.status(400).json({ message: "Robot not found" });
|
||||||
}
|
|
||||||
|
|
||||||
oauth2Client.setCredentials({
|
|
||||||
access_token: robot.google_access_token,
|
|
||||||
refresh_token: robot.google_refresh_token,
|
|
||||||
});
|
|
||||||
|
|
||||||
// List user's Google Sheets files from their Google Drive
|
|
||||||
const drive = google.drive({ version: 'v3', auth: oauth2Client });
|
|
||||||
const response = await drive.files.list({
|
|
||||||
q: "mimeType='application/vnd.google-apps.spreadsheet'",
|
|
||||||
fields: 'files(id, name)',
|
|
||||||
});
|
|
||||||
|
|
||||||
const files = response.data.files || [];
|
|
||||||
if (files.length === 0) {
|
|
||||||
return res.status(404).json({ message: 'No spreadsheets found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(files);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.log('Error fetching Google Sheets files:', error);
|
|
||||||
res.status(500).json({ message: `Error retrieving Google Sheets files: ${error.message}` });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oauth2Client.setCredentials({
|
||||||
|
access_token: robot.google_access_token,
|
||||||
|
refresh_token: robot.google_refresh_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// List user's Google Sheets files from their Google Drive
|
||||||
|
const drive = google.drive({ version: "v3", auth: oauth2Client });
|
||||||
|
const response = await drive.files.list({
|
||||||
|
q: "mimeType='application/vnd.google-apps.spreadsheet'",
|
||||||
|
fields: "files(id, name)",
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = response.data.files || [];
|
||||||
|
if (files.length === 0) {
|
||||||
|
return res.status(404).json({ message: "No spreadsheets found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(files);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log("Error fetching Google Sheets files:", error);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({
|
||||||
|
message: `Error retrieving Google Sheets files: ${error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 5: Update robot's google_sheet_id when a Google Sheet is selected
|
// Step 5: Update robot's google_sheet_id when a Google Sheet is selected
|
||||||
router.post('/gsheets/update', requireSignIn, async (req, res) => {
|
router.post("/gsheets/update", requireSignIn, async (req, res) => {
|
||||||
const { spreadsheetId, spreadsheetName, robotId } = req.body;
|
const { spreadsheetId, spreadsheetName, robotId } = req.body;
|
||||||
|
|
||||||
if (!spreadsheetId || !robotId) {
|
if (!spreadsheetId || !robotId) {
|
||||||
return res.status(400).json({ message: 'Spreadsheet ID and Robot ID are required' });
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: "Spreadsheet ID and Robot ID are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let robot = await Robot.findOne({
|
||||||
|
where: { "recording_meta.id": robotId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!robot) {
|
||||||
|
return res.status(404).json({ message: "Robot not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await robot.update({
|
||||||
let robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } });
|
google_sheet_id: spreadsheetId,
|
||||||
|
google_sheet_name: spreadsheetName,
|
||||||
|
});
|
||||||
|
|
||||||
if (!robot) {
|
res.json({ message: "Robot updated with selected Google Sheet ID" });
|
||||||
return res.status(404).json({ message: 'Robot not found' });
|
} catch (error: any) {
|
||||||
}
|
res.status(500).json({ message: `Error updating robot: ${error.message}` });
|
||||||
|
}
|
||||||
await robot.update({ google_sheet_id: spreadsheetId, google_sheet_name: spreadsheetName });
|
|
||||||
|
|
||||||
res.json({ message: 'Robot updated with selected Google Sheet ID' });
|
|
||||||
} catch (error: any) {
|
|
||||||
res.status(500).json({ message: `Error updating robot: ${error.message}` });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/gsheets/remove', requireSignIn, async (req: AuthenticatedRequest, res) => {
|
router.post(
|
||||||
|
"/gsheets/remove",
|
||||||
|
requireSignIn,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
const { robotId } = req.body;
|
const { robotId } = req.body;
|
||||||
if (!robotId) {
|
if (!robotId) {
|
||||||
return res.status(400).json({ message: 'Robot ID is required' });
|
return res.status(400).json({ message: "Robot ID is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).send({ error: 'Unauthorized' });
|
return res.status(401).send({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let robot = await Robot.findOne({ where: { 'recording_meta.id': robotId } });
|
let robot = await Robot.findOne({
|
||||||
|
where: { "recording_meta.id": robotId },
|
||||||
|
});
|
||||||
|
|
||||||
if (!robot) {
|
if (!robot) {
|
||||||
return res.status(404).json({ message: 'Robot not found' });
|
return res.status(404).json({ message: "Robot not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await robot.update({
|
await robot.update({
|
||||||
google_sheet_id: null,
|
google_sheet_id: null,
|
||||||
google_sheet_name: null,
|
google_sheet_name: null,
|
||||||
google_sheet_email: null,
|
google_sheet_email: null,
|
||||||
google_access_token: null,
|
google_access_token: null,
|
||||||
google_refresh_token: null
|
google_refresh_token: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
capture(
|
capture("maxun-oss-google-sheet-integration-removed", {
|
||||||
'maxun-oss-google-sheet-integration-removed',
|
user_id: req.user.id,
|
||||||
{
|
robot_id: robotId,
|
||||||
user_id: req.user.id,
|
deleted_at: new Date().toISOString(),
|
||||||
robot_id: robotId,
|
});
|
||||||
deleted_at: new Date().toISOString()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
res.json({ message: 'Google Sheets integration removed successfully' });
|
res.json({ message: "Google Sheets integration removed successfully" });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(500).json({ message: `Error removing Google Sheets integration: ${error.message}` });
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({
|
||||||
|
message: `Error removing Google Sheets integration: ${error.message}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -12,47 +12,48 @@ const minioClient = new Client({
|
|||||||
minioClient.bucketExists('maxun-test')
|
minioClient.bucketExists('maxun-test')
|
||||||
.then((exists) => {
|
.then((exists) => {
|
||||||
if (exists) {
|
if (exists) {
|
||||||
console.log('MinIO was connected successfully.');
|
console.log('MinIO connected successfully.');
|
||||||
} else {
|
} else {
|
||||||
console.log('Bucket does not exist, but MinIO was connected.');
|
console.log('MinIO connected successfully.');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('Error connecting to MinIO:', err);
|
console.error('Error connecting to MinIO:', err);
|
||||||
})
|
})
|
||||||
|
|
||||||
async function createBucketWithPolicy(bucketName: string, policy?: 'public-read' | 'private') {
|
async function createBucketWithPolicy(bucketName: string, policy = 'public-read') {
|
||||||
try {
|
try {
|
||||||
const bucketExists = await minioClient.bucketExists(bucketName);
|
const bucketExists = await minioClient.bucketExists(bucketName);
|
||||||
if (!bucketExists) {
|
if (!bucketExists) {
|
||||||
await minioClient.makeBucket(bucketName);
|
await minioClient.makeBucket(bucketName);
|
||||||
console.log(`Bucket ${bucketName} created successfully.`);
|
console.log(`Bucket ${bucketName} created successfully.`);
|
||||||
|
|
||||||
if (policy === 'public-read') {
|
|
||||||
// Define a public-read policy
|
|
||||||
const policyJSON = {
|
|
||||||
Version: "2012-10-17",
|
|
||||||
Statement: [
|
|
||||||
{
|
|
||||||
Effect: "Allow",
|
|
||||||
Principal: "",
|
|
||||||
Action: ["s3:GetObject"],
|
|
||||||
Resource: [`arn:aws:s3:::${bucketName}/*`]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
await minioClient.setBucketPolicy(bucketName, JSON.stringify(policyJSON));
|
|
||||||
console.log(`Public-read policy applied to bucket ${bucketName}.`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log(`Bucket ${bucketName} already exists.`);
|
console.log(`Bucket ${bucketName} already exists.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (policy === 'public-read') {
|
||||||
|
// Apply public-read policy after confirming the bucket exists
|
||||||
|
const policyJSON = {
|
||||||
|
Version: "2012-10-17",
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: "Allow",
|
||||||
|
Principal: "*",
|
||||||
|
Action: ["s3:GetObject"],
|
||||||
|
Resource: [`arn:aws:s3:::${bucketName}/*`]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
await minioClient.setBucketPolicy(bucketName, JSON.stringify(policyJSON));
|
||||||
|
console.log(`Public-read policy applied to bucket ${bucketName}.`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in bucket creation or policy application:', error);
|
console.error('Error in bucket creation or policy application:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class BinaryOutputService {
|
class BinaryOutputService {
|
||||||
private bucketName: string;
|
private bucketName: string;
|
||||||
|
|
||||||
|
|||||||
@@ -6,29 +6,37 @@ export const hashPassword = (password: string): Promise<string> => {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
bcrypt.genSalt(12, (err, salt) => {
|
bcrypt.genSalt(12, (err, salt) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(err)
|
reject(err);
|
||||||
}
|
}
|
||||||
bcrypt.hash(password, salt, (err, hash) => {
|
bcrypt.hash(password, salt, (err, hash) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(err)
|
reject(err);
|
||||||
}
|
}
|
||||||
resolve(hash)
|
resolve(hash);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
// password from frontend and hash from database
|
// password from frontend and hash from database
|
||||||
export const comparePassword = (password: string, hash: string): Promise<boolean> => {
|
export const comparePassword = (password: string, hash: string): Promise<boolean> => {
|
||||||
return bcrypt.compare(password, hash)
|
return bcrypt.compare(password, hash);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const encrypt = (text: string): string => {
|
export const encrypt = (text: string): string => {
|
||||||
const ivLength = 16;
|
const ivLength = 16;
|
||||||
const iv = crypto.randomBytes(ivLength);
|
const iv = crypto.randomBytes(ivLength);
|
||||||
const algorithm = 'aes-256-cbc';
|
const algorithm = 'aes-256-cbc';
|
||||||
const key = Buffer.from(getEnvVariable('ENCRYPTION_KEY'), 'hex');
|
|
||||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
// Retrieve the encryption key or generate a new one if invalid or empty
|
||||||
|
let key = getEnvVariable('ENCRYPTION_KEY');
|
||||||
|
if (!key || key.length !== 64) { // aes-256-cbc requires a 256-bit key, which is 64 hex characters
|
||||||
|
console.warn('Invalid or missing ENCRYPTION_KEY, generating a new one.');
|
||||||
|
key = crypto.randomBytes(32).toString('hex'); // Generate a new 256-bit (32-byte) key
|
||||||
|
}
|
||||||
|
const keyBuffer = Buffer.from(key, 'hex');
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv(algorithm, keyBuffer, iv);
|
||||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||||
encrypted += cipher.final('hex');
|
encrypted += cipher.final('hex');
|
||||||
return `${iv.toString('hex')}:${encrypted}`;
|
return `${iv.toString('hex')}:${encrypted}`;
|
||||||
@@ -37,9 +45,17 @@ export const encrypt = (text: string): string => {
|
|||||||
export const decrypt = (encryptedText: string): string => {
|
export const decrypt = (encryptedText: string): string => {
|
||||||
const [iv, encrypted] = encryptedText.split(':');
|
const [iv, encrypted] = encryptedText.split(':');
|
||||||
const algorithm = "aes-256-cbc";
|
const algorithm = "aes-256-cbc";
|
||||||
const key = Buffer.from(getEnvVariable('ENCRYPTION_KEY'), 'hex');
|
|
||||||
const decipher = crypto.createDecipheriv(algorithm, key, Buffer.from(iv, 'hex'));
|
// Retrieve the encryption key or generate a new one if invalid or empty
|
||||||
|
let key = getEnvVariable('ENCRYPTION_KEY');
|
||||||
|
if (!key || key.length !== 64) { // aes-256-cbc requires a 256-bit key, which is 64 hex characters
|
||||||
|
console.warn('Invalid or missing ENCRYPTION_KEY, generating a new one.');
|
||||||
|
key = crypto.randomBytes(32).toString('hex'); // Generate a new 256-bit (32-byte) key
|
||||||
|
}
|
||||||
|
const keyBuffer = Buffer.from(key, 'hex');
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(algorithm, keyBuffer, Buffer.from(iv, 'hex'));
|
||||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||||
decrypted += decipher.final('utf8');
|
decrypted += decipher.final('utf8');
|
||||||
return decrypted;
|
return decrypted;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,36 @@ export const getStoredRecordings = async (): Promise<string[] | null> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateRecording = async (id: string, data: { name?: string; limit?: number }): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`${apiUrl}/storage/recordings/${id}`, data);
|
||||||
|
if (response.status === 200) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Couldn't update recording with id ${id}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error updating recording: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const duplicateRecording = async (id: string, targetUrl: string): Promise<any> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${apiUrl}/storage/recordings/${id}/duplicate`, {
|
||||||
|
targetUrl,
|
||||||
|
});
|
||||||
|
if (response.status === 201) {
|
||||||
|
return response.data; // Returns the duplicated robot details
|
||||||
|
} else {
|
||||||
|
throw new Error(`Couldn't duplicate recording with id ${id}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error duplicating recording: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getStoredRuns = async (): Promise<string[] | null> => {
|
export const getStoredRuns = async (): Promise<string[] | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${apiUrl}/storage/runs`);
|
const response = await axios.get(`${apiUrl}/storage/runs`);
|
||||||
|
|||||||
@@ -1,227 +1,322 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from "react";
|
||||||
import { GenericModal } from "../atoms/GenericModal";
|
import { GenericModal } from "../atoms/GenericModal";
|
||||||
import { MenuItem, Typography, CircularProgress, Alert, AlertTitle, Chip } from "@mui/material";
|
import {
|
||||||
|
MenuItem,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
Chip,
|
||||||
|
} from "@mui/material";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import { useGlobalInfoStore } from '../../context/globalInfo';
|
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||||
import { getStoredRecording } from '../../api/storage';
|
import { getStoredRecording } from "../../api/storage";
|
||||||
import { apiUrl } from '../../apiConfig.js';
|
import { apiUrl } from "../../apiConfig.js";
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
interface IntegrationProps {
|
interface IntegrationProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
handleStart: (data: IntegrationSettings) => void;
|
handleStart: (data: IntegrationSettings) => void;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
}
|
}
|
||||||
export interface IntegrationSettings {
|
export interface IntegrationSettings {
|
||||||
spreadsheetId: string;
|
spreadsheetId: string;
|
||||||
spreadsheetName: string;
|
spreadsheetName: string;
|
||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IntegrationSettingsModal = ({ isOpen, handleStart, handleClose }: IntegrationProps) => {
|
export const IntegrationSettingsModal = ({
|
||||||
const [settings, setSettings] = useState<IntegrationSettings>({
|
isOpen,
|
||||||
spreadsheetId: '',
|
handleStart,
|
||||||
spreadsheetName: '',
|
handleClose,
|
||||||
data: '',
|
}: IntegrationProps) => {
|
||||||
});
|
const [settings, setSettings] = useState<IntegrationSettings>({
|
||||||
|
spreadsheetId: "",
|
||||||
|
spreadsheetName: "",
|
||||||
|
data: "",
|
||||||
|
});
|
||||||
|
|
||||||
const [spreadsheets, setSpreadsheets] = useState<{ id: string, name: string }[]>([]);
|
const [spreadsheets, setSpreadsheets] = useState<
|
||||||
const [loading, setLoading] = useState(false);
|
{ id: string; name: string }[]
|
||||||
const [error, setError] = useState<string | null>(null);
|
>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { recordingId, notify } = useGlobalInfoStore();
|
const { recordingId, notify } = useGlobalInfoStore();
|
||||||
const [recording, setRecording] = useState<any>(null);
|
const [recording, setRecording] = useState<any>(null);
|
||||||
|
|
||||||
const authenticateWithGoogle = () => {
|
const authenticateWithGoogle = () => {
|
||||||
window.location.href = `${apiUrl}/auth/google?robotId=${recordingId}`;
|
window.location.href = `${apiUrl}/auth/google?robotId=${recordingId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOAuthCallback = async () => {
|
const handleOAuthCallback = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${apiUrl}/auth/google/callback`);
|
const response = await axios.get(`${apiUrl}/auth/google/callback`);
|
||||||
const { google_sheet_email, files } = response.data;
|
const { google_sheet_email, files } = response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Error authenticating with Google');
|
setError("Error authenticating with Google");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpreadsheetFiles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`${apiUrl}/auth/gsheets/files?robotId=${recordingId}`,
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
setSpreadsheets(response.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
"Error fetching spreadsheet files:",
|
||||||
|
error.response?.data?.message || error.message
|
||||||
|
);
|
||||||
|
notify(
|
||||||
|
"error",
|
||||||
|
`Error fetching spreadsheet files: ${
|
||||||
|
error.response?.data?.message || error.message
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchSpreadsheetFiles = async () => {
|
const handleSpreadsheetSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
try {
|
const selectedSheet = spreadsheets.find(
|
||||||
const response = await axios.get(`${apiUrl}/auth/gsheets/files?robotId=${recordingId}`, {
|
(sheet) => sheet.id === e.target.value
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
setSpreadsheets(response.data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error fetching spreadsheet files:', error.response?.data?.message || error.message);
|
|
||||||
notify('error', `Error fetching spreadsheet files: ${error.response?.data?.message || error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSpreadsheetSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const selectedSheet = spreadsheets.find(sheet => sheet.id === e.target.value);
|
|
||||||
if (selectedSheet) {
|
|
||||||
setSettings({ ...settings, spreadsheetId: selectedSheet.id, spreadsheetName: selectedSheet.name });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateGoogleSheetId = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.post(
|
|
||||||
`${apiUrl}/auth/gsheets/update`,
|
|
||||||
{ spreadsheetId: settings.spreadsheetId, spreadsheetName: settings.spreadsheetName, robotId: recordingId },
|
|
||||||
{ withCredentials: true }
|
|
||||||
);
|
|
||||||
console.log('Google Sheet ID updated:', response.data);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error updating Google Sheet ID:', error.response?.data?.message || error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeIntegration = async () => {
|
|
||||||
try {
|
|
||||||
await axios.post(
|
|
||||||
`${apiUrl}/auth/gsheets/remove`,
|
|
||||||
{ robotId: recordingId },
|
|
||||||
{ withCredentials: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
setRecording(null);
|
|
||||||
setSpreadsheets([]);
|
|
||||||
setSettings({ spreadsheetId: '', spreadsheetName: '', data: '' });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error removing Google Sheets integration:', error.response?.data?.message || error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check if we're on the callback URL
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const code = urlParams.get('code');
|
|
||||||
if (code) {
|
|
||||||
handleOAuthCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchRecordingInfo = async () => {
|
|
||||||
if (!recordingId) return;
|
|
||||||
const recording = await getStoredRecording(recordingId);
|
|
||||||
if (recording) {
|
|
||||||
setRecording(recording);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchRecordingInfo();
|
|
||||||
}, [recordingId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GenericModal isOpen={isOpen} onClose={handleClose}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', marginLeft: '65px' }}>
|
|
||||||
<Typography variant="h6" sx={{ margin: '15px 0px' }}>Integrate with Google Sheet <Chip label="beta" color="primary" variant="outlined" /></Typography>
|
|
||||||
|
|
||||||
{recording && recording.google_sheet_id ? (
|
|
||||||
<>
|
|
||||||
<Alert severity="info">
|
|
||||||
<AlertTitle>Google Sheet Integrated Successfully.</AlertTitle>
|
|
||||||
Every time this robot creates a successful run, its captured data is appended to your {recording.google_sheet_name} Google Sheet. You can check the data updates <a href={`https://docs.google.com/spreadsheets/d/${recording.google_sheet_id}`} target="_blank" rel="noreferrer">here</a>.
|
|
||||||
<br />
|
|
||||||
<strong>Note:</strong> The data extracted before integrating with Google Sheets will not be synced in the Google Sheet. Only the data extracted after the integration will be synced.
|
|
||||||
</Alert>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
onClick={removeIntegration}
|
|
||||||
style={{ marginTop: '15px' }}
|
|
||||||
>
|
|
||||||
Remove Integration
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{!recording?.google_sheet_email ? (
|
|
||||||
<>
|
|
||||||
<p>If you enable this option, every time this robot runs a task successfully, its captured data will be appended to your Google Sheet.</p>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={authenticateWithGoogle}
|
|
||||||
style={{ marginBottom: '15px' }}
|
|
||||||
>
|
|
||||||
Authenticate with Google
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{recording.google_sheet_email && (
|
|
||||||
<Typography sx={{ margin: '20px 0px 30px 0px' }}>
|
|
||||||
Authenticated as: {recording.google_sheet_email}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<CircularProgress sx={{ marginBottom: '15px' }} />
|
|
||||||
) : error ? (
|
|
||||||
<Typography color="error">{error}</Typography>
|
|
||||||
) : spreadsheets.length === 0 ? (
|
|
||||||
<>
|
|
||||||
<div style={{ display: 'flex', gap: '10px' }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={fetchSpreadsheetFiles}
|
|
||||||
>
|
|
||||||
Fetch Google Spreadsheets
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
onClick={removeIntegration}
|
|
||||||
>
|
|
||||||
Remove Integration
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
sx={{ marginBottom: '15px' }}
|
|
||||||
select
|
|
||||||
label="Select Google Sheet"
|
|
||||||
required
|
|
||||||
value={settings.spreadsheetId}
|
|
||||||
onChange={handleSpreadsheetSelect}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{spreadsheets.map(sheet => (
|
|
||||||
<MenuItem key={sheet.id} value={sheet.id}>
|
|
||||||
{sheet.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
{settings.spreadsheetId && (
|
|
||||||
<Typography sx={{ marginBottom: '10px' }}>
|
|
||||||
Selected Sheet: {spreadsheets.find(s => s.id === settings.spreadsheetId)?.name} (ID: {settings.spreadsheetId})
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => {
|
|
||||||
updateGoogleSheetId();
|
|
||||||
handleStart(settings);
|
|
||||||
}}
|
|
||||||
style={{ marginTop: '10px' }}
|
|
||||||
disabled={!settings.spreadsheetId || loading}
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</GenericModal>
|
|
||||||
);
|
);
|
||||||
|
if (selectedSheet) {
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
spreadsheetId: selectedSheet.id,
|
||||||
|
spreadsheetName: selectedSheet.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGoogleSheetId = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${apiUrl}/auth/gsheets/update`,
|
||||||
|
{
|
||||||
|
spreadsheetId: settings.spreadsheetId,
|
||||||
|
spreadsheetName: settings.spreadsheetName,
|
||||||
|
robotId: recordingId,
|
||||||
|
},
|
||||||
|
{ withCredentials: true }
|
||||||
|
);
|
||||||
|
console.log("Google Sheet ID updated:", response.data);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
"Error updating Google Sheet ID:",
|
||||||
|
error.response?.data?.message || error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeIntegration = async () => {
|
||||||
|
try {
|
||||||
|
await axios.post(
|
||||||
|
`${apiUrl}/auth/gsheets/remove`,
|
||||||
|
{ robotId: recordingId },
|
||||||
|
{ withCredentials: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
setRecording(null);
|
||||||
|
setSpreadsheets([]);
|
||||||
|
setSettings({ spreadsheetId: "", spreadsheetName: "", data: "" });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
"Error removing Google Sheets integration:",
|
||||||
|
error.response?.data?.message || error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if there is a success message in cookies
|
||||||
|
const status = Cookies.get("robot_auth_status");
|
||||||
|
const message = Cookies.get("robot_auth_message");
|
||||||
|
|
||||||
|
if (status === "success" && message) {
|
||||||
|
notify("success", message);
|
||||||
|
// Clear the cookies after reading
|
||||||
|
Cookies.remove("robot_auth_status");
|
||||||
|
Cookies.remove("robot_auth_message");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're on the callback URL
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const code = urlParams.get("code");
|
||||||
|
if (code) {
|
||||||
|
handleOAuthCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRecordingInfo = async () => {
|
||||||
|
if (!recordingId) return;
|
||||||
|
const recording = await getStoredRecording(recordingId);
|
||||||
|
if (recording) {
|
||||||
|
setRecording(recording);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRecordingInfo();
|
||||||
|
}, [recordingId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GenericModal isOpen={isOpen} onClose={handleClose} modalStyle={modalStyle}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
marginLeft: "65px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">
|
||||||
|
Integrate with Google Sheet{" "}
|
||||||
|
<Chip label="beta" color="primary" variant="outlined" />
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{recording && recording.google_sheet_id ? (
|
||||||
|
<>
|
||||||
|
<Alert severity="info">
|
||||||
|
<AlertTitle>Google Sheet Integrated Successfully.</AlertTitle>
|
||||||
|
Every time this robot creates a successful run, its captured data
|
||||||
|
is appended to your {recording.google_sheet_name} Google Sheet.
|
||||||
|
You can check the data updates{" "}
|
||||||
|
<a
|
||||||
|
href={`https://docs.google.com/spreadsheets/d/${recording.google_sheet_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
<br />
|
||||||
|
<strong>Note:</strong> The data extracted before integrating with
|
||||||
|
Google Sheets will not be synced in the Google Sheet. Only the
|
||||||
|
data extracted after the integration will be synced.
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={removeIntegration}
|
||||||
|
style={{ marginTop: "15px" }}
|
||||||
|
>
|
||||||
|
Remove Integration
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!recording?.google_sheet_email ? (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
If you enable this option, every time this robot runs a task
|
||||||
|
successfully, its captured data will be appended to your
|
||||||
|
Google Sheet.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={authenticateWithGoogle}
|
||||||
|
>
|
||||||
|
Authenticate with Google
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{recording.google_sheet_email && (
|
||||||
|
<Typography sx={{ margin: "20px 0px 30px 0px" }}>
|
||||||
|
Authenticated as: {recording.google_sheet_email}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<CircularProgress sx={{ marginBottom: "15px" }} />
|
||||||
|
) : error ? (
|
||||||
|
<Typography color="error">{error}</Typography>
|
||||||
|
) : spreadsheets.length === 0 ? (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "flex", gap: "10px" }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={fetchSpreadsheetFiles}
|
||||||
|
>
|
||||||
|
Fetch Google Spreadsheets
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={removeIntegration}
|
||||||
|
>
|
||||||
|
Remove Integration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
sx={{ marginBottom: "15px" }}
|
||||||
|
select
|
||||||
|
label="Select Google Sheet"
|
||||||
|
required
|
||||||
|
value={settings.spreadsheetId}
|
||||||
|
onChange={handleSpreadsheetSelect}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{spreadsheets.map((sheet) => (
|
||||||
|
<MenuItem key={sheet.id} value={sheet.id}>
|
||||||
|
{sheet.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{settings.spreadsheetId && (
|
||||||
|
<Typography sx={{ marginBottom: "10px" }}>
|
||||||
|
Selected Sheet:{" "}
|
||||||
|
{
|
||||||
|
spreadsheets.find(
|
||||||
|
(s) => s.id === settings.spreadsheetId
|
||||||
|
)?.name
|
||||||
|
}{" "}
|
||||||
|
(ID: {settings.spreadsheetId})
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => {
|
||||||
|
updateGoogleSheetId();
|
||||||
|
handleStart(settings);
|
||||||
|
}}
|
||||||
|
style={{ marginTop: "10px" }}
|
||||||
|
disabled={!settings.spreadsheetId || loading}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GenericModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const modalStyle = {
|
||||||
|
top: "40%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
width: "50%",
|
||||||
|
backgroundColor: "background.paper",
|
||||||
|
p: 4,
|
||||||
|
height: "fit-content",
|
||||||
|
display: "block",
|
||||||
|
padding: "20px",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,21 +65,21 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
|
|||||||
{!isRecording ? (
|
{!isRecording ? (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
component="a"
|
component="a"
|
||||||
href="https://discord.gg/NFhWDCdb"
|
href="https://discord.gg/5GbPjBUkws"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderRadius: '5px',
|
borderRadius: '5px',
|
||||||
padding: '8px',
|
padding: '8px',
|
||||||
marginRight: '10px',
|
marginRight: '30px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DiscordIcon sx={{ marginRight: '5px' }} />
|
<DiscordIcon sx={{ marginRight: '5px' }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<iframe src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" frameBorder="0" scrolling="0" width="170" height="30" title="GitHub"></iframe>
|
<iframe src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" frameBorder="0" scrolling="0" width="170" height="30" title="GitHub"></iframe>
|
||||||
<IconButton onClick={handleMenuOpen} sx={{
|
<IconButton onClick={handleMenuOpen} sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import axios from 'axios';
|
|||||||
import { apiUrl } from '../../apiConfig';
|
import { apiUrl } from '../../apiConfig';
|
||||||
import { Menu as MenuIcon } from '@mui/icons-material';
|
import { Menu as MenuIcon } from '@mui/icons-material';
|
||||||
|
|
||||||
|
|
||||||
/** TODO:
|
/** TODO:
|
||||||
* 1. allow editing existing robot after persisting browser steps
|
* 1. allow editing existing robot after persisting browser steps
|
||||||
* 2. show robot settings: id, url, etc.
|
* 2. show robot settings: id, url, etc.
|
||||||
@@ -261,11 +260,13 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
|
|||||||
<OptionsButton
|
<OptionsButton
|
||||||
handleEdit={() => handleEditRobot(row.id, row.name, row.params || [])}
|
handleEdit={() => handleEditRobot(row.id, row.name, row.params || [])}
|
||||||
handleDelete={() => {
|
handleDelete={() => {
|
||||||
|
|
||||||
checkRunsForRecording(row.id).then((result: boolean) => {
|
checkRunsForRecording(row.id).then((result: boolean) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
notify('warning', 'Cannot delete recording as it has active runs');
|
notify('warning', 'Cannot delete recording as it has active runs');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
deleteRecordingFromStorage(row.id).then((result: boolean) => {
|
deleteRecordingFromStorage(row.id).then((result: boolean) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
setRows([]);
|
setRows([]);
|
||||||
|
|||||||
164
src/components/molecules/RobotDuplicate.tsx
Normal file
164
src/components/molecules/RobotDuplicate.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { GenericModal } from "../atoms/GenericModal";
|
||||||
|
import { TextField, Typography, Box, Button, Chip } from "@mui/material";
|
||||||
|
import { modalStyle } from "./AddWhereCondModal";
|
||||||
|
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||||
|
import { duplicateRecording, getStoredRecording } from '../../api/storage';
|
||||||
|
import { WhereWhatPair } from 'maxun-core';
|
||||||
|
import { getUserById } from "../../api/auth";
|
||||||
|
|
||||||
|
interface RobotMeta {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
pairs: number;
|
||||||
|
updatedAt: string;
|
||||||
|
params: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RobotWorkflow {
|
||||||
|
workflow: WhereWhatPair[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduleConfig {
|
||||||
|
runEvery: number;
|
||||||
|
runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS';
|
||||||
|
startFrom: 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY';
|
||||||
|
atTimeStart?: string;
|
||||||
|
atTimeEnd?: string;
|
||||||
|
timezone: string;
|
||||||
|
lastRunAt?: Date;
|
||||||
|
nextRunAt?: Date;
|
||||||
|
cronExpression?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RobotSettings {
|
||||||
|
id: string;
|
||||||
|
userId?: number;
|
||||||
|
recording_meta: RobotMeta;
|
||||||
|
recording: RobotWorkflow;
|
||||||
|
google_sheet_email?: string | null;
|
||||||
|
google_sheet_name?: string | null;
|
||||||
|
google_sheet_id?: string | null;
|
||||||
|
google_access_token?: string | null;
|
||||||
|
google_refresh_token?: string | null;
|
||||||
|
schedule?: ScheduleConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RobotSettingsProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
handleStart: (settings: RobotSettings) => void;
|
||||||
|
handleClose: () => void;
|
||||||
|
initialSettings?: RobotSettings | null;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||||
|
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||||
|
const [targetUrl, setTargetUrl] = useState<string | undefined>('');
|
||||||
|
const { recordingId, notify } = useGlobalInfoStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
getRobot();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Update the targetUrl when the robot data is loaded
|
||||||
|
if (robot) {
|
||||||
|
const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1];
|
||||||
|
const url = lastPair?.what.find(action => action.action === "goto")?.args?.[0];
|
||||||
|
setTargetUrl(url);
|
||||||
|
}
|
||||||
|
}, [robot]);
|
||||||
|
|
||||||
|
const getRobot = async () => {
|
||||||
|
if (recordingId) {
|
||||||
|
const robot = await getStoredRecording(recordingId);
|
||||||
|
setRobot(robot);
|
||||||
|
} else {
|
||||||
|
notify('error', 'Could not find robot details. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1];
|
||||||
|
|
||||||
|
// // Find the `goto` action in `what` and retrieve its arguments
|
||||||
|
// const targetUrl = lastPair?.what.find(action => action.action === "goto")?.args?.[0];
|
||||||
|
|
||||||
|
const handleTargetUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setTargetUrl(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!robot || !targetUrl) {
|
||||||
|
notify('error', 'Target URL is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("handle save");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await duplicateRecording(robot.recording_meta.id, targetUrl);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
notify('success', 'Target URL updated successfully.');
|
||||||
|
handleStart(robot); // Inform parent about the updated robot
|
||||||
|
handleClose(); // Close the modal
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
notify('error', 'Failed to update the Target URL. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notify('error', 'An error occurred while updating the Target URL.');
|
||||||
|
console.error('Error updating Target URL:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GenericModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
modalStyle={modalStyle}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<Typography variant="h5" style={{ marginBottom: '20px' }}>Duplicate Robot</Typography>
|
||||||
|
<Box style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{
|
||||||
|
robot && (
|
||||||
|
<>
|
||||||
|
<span>Robot duplication is useful to extract data from pages with the same structure.</span>
|
||||||
|
<br />
|
||||||
|
<span>
|
||||||
|
Example: If you've created a robot for <code>producthunt.com/topics/api</code>, you can duplicate it to scrape similar pages
|
||||||
|
like <code>producthunt.com/topics/database</code> without training a robot from scratch.
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
<span>
|
||||||
|
<b>⚠️ Ensure the new page has the same structure as the original page.</b>
|
||||||
|
</span>
|
||||||
|
<TextField
|
||||||
|
label="Robot Target URL"
|
||||||
|
key="Robot Target URL"
|
||||||
|
value={targetUrl}
|
||||||
|
onChange={handleTargetUrlChange}
|
||||||
|
style={{ marginBottom: '20px', marginTop: '30px' }}
|
||||||
|
/>
|
||||||
|
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}>
|
||||||
|
<Button variant="contained" color="primary">
|
||||||
|
Duplicate Robot
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
</GenericModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
179
src/components/molecules/RobotEdit.tsx
Normal file
179
src/components/molecules/RobotEdit.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { GenericModal } from "../atoms/GenericModal";
|
||||||
|
import { TextField, Typography, Box, Button } from "@mui/material";
|
||||||
|
import { modalStyle } from "./AddWhereCondModal";
|
||||||
|
import { useGlobalInfoStore } from '../../context/globalInfo';
|
||||||
|
import { getStoredRecording, updateRecording } from '../../api/storage';
|
||||||
|
import { WhereWhatPair } from 'maxun-core';
|
||||||
|
|
||||||
|
interface RobotMeta {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
pairs: number;
|
||||||
|
updatedAt: string;
|
||||||
|
params: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RobotWorkflow {
|
||||||
|
workflow: WhereWhatPair[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RobotEditOptions {
|
||||||
|
name: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduleConfig {
|
||||||
|
runEvery: number;
|
||||||
|
runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS';
|
||||||
|
startFrom: 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY';
|
||||||
|
atTimeStart?: string;
|
||||||
|
atTimeEnd?: string;
|
||||||
|
timezone: string;
|
||||||
|
lastRunAt?: Date;
|
||||||
|
nextRunAt?: Date;
|
||||||
|
cronExpression?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RobotSettings {
|
||||||
|
id: string;
|
||||||
|
userId?: number;
|
||||||
|
recording_meta: RobotMeta;
|
||||||
|
recording: RobotWorkflow;
|
||||||
|
google_sheet_email?: string | null;
|
||||||
|
google_sheet_name?: string | null;
|
||||||
|
google_sheet_id?: string | null;
|
||||||
|
google_access_token?: string | null;
|
||||||
|
google_refresh_token?: string | null;
|
||||||
|
schedule?: ScheduleConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RobotSettingsProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
handleStart: (settings: RobotSettings) => void;
|
||||||
|
handleClose: () => void;
|
||||||
|
initialSettings?: RobotSettings | null;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
|
||||||
|
console.log("robot edit");
|
||||||
|
const [robot, setRobot] = useState<RobotSettings | null>(null);
|
||||||
|
const { recordingId, notify } = useGlobalInfoStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
getRobot();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const getRobot = async () => {
|
||||||
|
if (recordingId) {
|
||||||
|
const robot = await getStoredRecording(recordingId);
|
||||||
|
setRobot(robot);
|
||||||
|
} else {
|
||||||
|
notify('error', 'Could not find robot details. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRobotNameChange = (newName: string) => {
|
||||||
|
setRobot((prev) =>
|
||||||
|
prev ? { ...prev, recording_meta: { ...prev.recording_meta, name: newName } } : prev
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLimitChange = (newLimit: number) => {
|
||||||
|
setRobot((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
|
||||||
|
const updatedWorkflow = [...prev.recording.workflow];
|
||||||
|
|
||||||
|
if (
|
||||||
|
updatedWorkflow.length > 0 &&
|
||||||
|
updatedWorkflow[0]?.what &&
|
||||||
|
updatedWorkflow[0].what.length > 0 &&
|
||||||
|
updatedWorkflow[0].what[0].args &&
|
||||||
|
updatedWorkflow[0].what[0].args.length > 0 &&
|
||||||
|
updatedWorkflow[0].what[0].args[0]
|
||||||
|
) {
|
||||||
|
updatedWorkflow[0].what[0].args[0].limit = newLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, recording: { ...prev.recording, workflow: updatedWorkflow } };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!robot) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: robot.recording_meta.name,
|
||||||
|
limit: robot.recording.workflow[0]?.what[0]?.args?.[0]?.limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await updateRecording(robot.recording_meta.id, payload);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
notify('success', 'Robot updated successfully.');
|
||||||
|
handleStart(robot); // Inform parent about the updated robot
|
||||||
|
handleClose(); // Close the modal
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
notify('error', 'Failed to update the robot. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notify('error', 'An error occurred while updating the robot.');
|
||||||
|
console.error('Error updating robot:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GenericModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
modalStyle={modalStyle}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<Typography variant="h5" style={{ marginBottom: '20px' }}>Edit Robot</Typography>
|
||||||
|
<Box style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{
|
||||||
|
robot && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Change Robot Name"
|
||||||
|
key="Change Robot Name"
|
||||||
|
type='text'
|
||||||
|
value={robot.recording_meta.name}
|
||||||
|
onChange={(e) => handleRobotNameChange(e.target.value)}
|
||||||
|
style={{ marginBottom: '20px' }}
|
||||||
|
/>
|
||||||
|
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
|
||||||
|
<TextField
|
||||||
|
label="Robot Limit"
|
||||||
|
type="number"
|
||||||
|
value={robot.recording.workflow[0].what[0].args[0].limit || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleLimitChange(parseInt(e.target.value, 10) || 0)
|
||||||
|
}
|
||||||
|
style={{ marginBottom: '20px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}>
|
||||||
|
<Button variant="contained" color="primary">
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
</GenericModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -104,6 +104,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
|
|||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
label="Robot Target URL"
|
label="Robot Target URL"
|
||||||
|
key="Robot Target URL"
|
||||||
value={targetUrl}
|
value={targetUrl}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
@@ -112,6 +113,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Robot ID"
|
label="Robot ID"
|
||||||
|
key="Robot ID"
|
||||||
value={robot.recording_meta.id}
|
value={robot.recording_meta.id}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
@@ -120,7 +122,8 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Created By User"
|
label="Created By User"
|
||||||
value={userEmail}
|
key="Created By User"
|
||||||
|
value={userEmail ? userEmail : ''}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
}}
|
}}
|
||||||
@@ -128,6 +131,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Robot Created At"
|
label="Robot Created At"
|
||||||
|
key="Robot Created At"
|
||||||
value={robot.recording_meta.createdAt}
|
value={robot.recording_meta.createdAt}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { Box, Tabs, Typography, Tab, Paper } from "@mui/material";
|
import { Box, Tabs, Typography, Tab, Paper, Button } from "@mui/material";
|
||||||
import Highlight from "react-highlight";
|
import Highlight from "react-highlight";
|
||||||
import Button from "@mui/material/Button";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Data } from "./RunsTable";
|
import { Data } from "./RunsTable";
|
||||||
import { TabPanel, TabContext } from "@mui/lab";
|
import { TabPanel, TabContext } from "@mui/lab";
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
|
||||||
import ImageIcon from '@mui/icons-material/Image';
|
|
||||||
import ArticleIcon from '@mui/icons-material/Article';
|
import ArticleIcon from '@mui/icons-material/Article';
|
||||||
|
import ImageIcon from '@mui/icons-material/Image';
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
|
||||||
import Table from '@mui/material/Table';
|
import Table from '@mui/material/Table';
|
||||||
import TableBody from '@mui/material/TableBody';
|
import TableBody from '@mui/material/TableBody';
|
||||||
import TableCell from '@mui/material/TableCell';
|
import TableCell from '@mui/material/TableCell';
|
||||||
@@ -32,7 +29,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTab(tab);
|
setTab(tab);
|
||||||
}, [interpretationInProgress])
|
}, [interpretationInProgress]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (row.serializableOutput && Object.keys(row.serializableOutput).length > 0) {
|
if (row.serializableOutput && Object.keys(row.serializableOutput).length > 0) {
|
||||||
@@ -47,6 +44,29 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
}
|
}
|
||||||
}, [row.serializableOutput]);
|
}, [row.serializableOutput]);
|
||||||
|
|
||||||
|
// Function to convert table data to CSV format
|
||||||
|
const convertToCSV = (data: any[], columns: string[]): string => {
|
||||||
|
const header = columns.join(',');
|
||||||
|
const rows = data.map(row =>
|
||||||
|
columns.map(col => JSON.stringify(row[col], null, 2)).join(',')
|
||||||
|
);
|
||||||
|
return [header, ...rows].join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to download CSV file when called
|
||||||
|
const downloadCSV = () => {
|
||||||
|
const csvContent = convertToCSV(tableData, columns);
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute("download", "data.csv");
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ width: '100%' }}>
|
<Box sx={{ width: '100%' }}>
|
||||||
<TabContext value={tab}>
|
<TabContext value={tab}>
|
||||||
@@ -54,7 +74,6 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
<Tabs value={tab} onChange={(e, newTab) => setTab(newTab)} aria-label="run-content-tabs">
|
<Tabs value={tab} onChange={(e, newTab) => setTab(newTab)} aria-label="run-content-tabs">
|
||||||
<Tab label="Output Data" value='output' />
|
<Tab label="Output Data" value='output' />
|
||||||
<Tab label="Log" value='log' />
|
<Tab label="Log" value='log' />
|
||||||
{/* <Tab label="Input" value='input' /> */}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
<TabPanel value='log'>
|
<TabPanel value='log'>
|
||||||
@@ -94,16 +113,19 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
<ArticleIcon sx={{ marginRight: '15px' }} />
|
<ArticleIcon sx={{ marginRight: '15px' }} />
|
||||||
Captured Data
|
Captured Data
|
||||||
</Typography>
|
</Typography>
|
||||||
{Object.keys(row.serializableOutput).map((key) => {
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 2 }}>
|
||||||
return (
|
<Typography>
|
||||||
<div key={`number-of-serializable-output-${key}`}>
|
<a style={{ textDecoration: 'none' }} href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput, null, 2)}`}
|
||||||
<Typography sx={{ margin: '20px 0px 20px 0px' }}>
|
download="data.json">
|
||||||
<a style={{ textDecoration: 'none' }} href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput[key], null, 2)}`}
|
Download as JSON
|
||||||
download={key}>Download as JSON</a>
|
</a>
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
<Typography
|
||||||
)
|
onClick={downloadCSV}
|
||||||
})}
|
>
|
||||||
|
<a style={{ textDecoration: 'none', cursor: 'pointer' }}>Download as CSV</a>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
{tableData.length > 0 ? (
|
{tableData.length > 0 ? (
|
||||||
<TableContainer component={Paper} sx={{ maxHeight: 440, marginTop: 2 }}>
|
<TableContainer component={Paper} sx={{ maxHeight: 440, marginTop: 2 }}>
|
||||||
<Table stickyHeader aria-label="sticky table">
|
<Table stickyHeader aria-label="sticky table">
|
||||||
@@ -139,12 +161,12 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{row.binaryOutput
|
{row.binaryOutput && Object.keys(row.binaryOutput).length !== 0 &&
|
||||||
&& Object.keys(row.binaryOutput).length !== 0 &&
|
|
||||||
<div>
|
<div>
|
||||||
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
|
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<ImageIcon sx={{ marginRight: '15px' }} />
|
<ImageIcon sx={{ marginRight: '15px' }} />
|
||||||
Captured Screenshot</Typography>
|
Captured Screenshot
|
||||||
|
</Typography>
|
||||||
{Object.keys(row.binaryOutput).map((key) => {
|
{Object.keys(row.binaryOutput).map((key) => {
|
||||||
try {
|
try {
|
||||||
const imageUrl = row.binaryOutput[key];
|
const imageUrl = row.binaryOutput[key];
|
||||||
@@ -152,10 +174,10 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
<Box key={`number-of-binary-output-${key}`} sx={{
|
<Box key={`number-of-binary-output-${key}`} sx={{
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
}}>
|
}}>
|
||||||
<Typography key={`binary-output-key-${key}`} sx={{ margin: '20px 0px 20px 0px' }}>
|
<Typography sx={{ margin: '20px 0px' }}>
|
||||||
<a href={imageUrl} download={key} style={{ textDecoration: 'none' }}>Download Screenshot</a>
|
<a href={imageUrl} download={key} style={{ textDecoration: 'none' }}>Download Screenshot</a>
|
||||||
</Typography>
|
</Typography>
|
||||||
<img key={`image-${key}`} src={imageUrl} alt={key} height='auto' width='700px' />
|
<img src={imageUrl} alt={key} height='auto' width='700px' />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -171,4 +193,4 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
|
|||||||
</TabContext>
|
</TabContext>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface Data {
|
|||||||
// task: string;
|
// task: string;
|
||||||
log: string;
|
log: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
|
robotId: string;
|
||||||
|
robotMetaId: string;
|
||||||
interpreterSettings: RunSettings;
|
interpreterSettings: RunSettings;
|
||||||
serializableOutput: any;
|
serializableOutput: any;
|
||||||
binaryOutput: any;
|
binaryOutput: any;
|
||||||
@@ -63,8 +65,6 @@ export const RunsTable = (
|
|||||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||||
const [rows, setRows] = useState<Data[]>([]);
|
const [rows, setRows] = useState<Data[]>([]);
|
||||||
|
|
||||||
console.log(`rows runs: ${JSON.stringify(rows)}`);
|
|
||||||
|
|
||||||
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
|
||||||
|
|
||||||
const handleChangePage = (event: unknown, newPage: number) => {
|
const handleChangePage = (event: unknown, newPage: number) => {
|
||||||
@@ -105,12 +105,12 @@ export const RunsTable = (
|
|||||||
fetchRuns();
|
fetchRuns();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group runs by recording name
|
// Group runs by robot meta id
|
||||||
const groupedRows = rows.reduce((acc, row) => {
|
const groupedRows = rows.reduce((acc, row) => {
|
||||||
if (!acc[row.name]) {
|
if (!acc[row.robotMetaId]) {
|
||||||
acc[row.name] = [];
|
acc[row.robotMetaId] = [];
|
||||||
}
|
}
|
||||||
acc[row.name].push(row);
|
acc[row.robotMetaId].push(row);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, Data[]>);
|
}, {} as Record<string, Data[]>);
|
||||||
|
|
||||||
@@ -120,10 +120,10 @@ export const RunsTable = (
|
|||||||
All Runs
|
All Runs
|
||||||
</Typography>
|
</Typography>
|
||||||
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
|
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
|
||||||
{Object.entries(groupedRows).map(([name, group]) => (
|
{Object.entries(groupedRows).map(([id, data]) => (
|
||||||
<Accordion key={name}>
|
<Accordion key={id}>
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
<Typography variant="h6">{name}</Typography>
|
<Typography variant="h6">{data[data.length - 1].name}</Typography>
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails>
|
<AccordionDetails>
|
||||||
<Table stickyHeader aria-label="sticky table">
|
<Table stickyHeader aria-label="sticky table">
|
||||||
@@ -142,17 +142,19 @@ export const RunsTable = (
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{group.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((row) => (
|
{data
|
||||||
<CollapsibleRow
|
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||||
row={row}
|
.map((row) => (
|
||||||
handleDelete={handleDelete}
|
<CollapsibleRow
|
||||||
key={`row-${row.id}`}
|
row={row}
|
||||||
isOpen={runId === row.runId && runningRecordingName === row.name}
|
handleDelete={handleDelete}
|
||||||
currentLog={currentInterpretationLog}
|
key={`row-${row.id}`}
|
||||||
abortRunHandler={abortRunHandler}
|
isOpen={runId === row.runId && runningRecordingName === row.name}
|
||||||
runningRecordingName={runningRecordingName}
|
currentLog={currentInterpretationLog}
|
||||||
/>
|
abortRunHandler={abortRunHandler}
|
||||||
))}
|
runningRecordingName={runningRecordingName}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import BrowserNavBar from "../molecules/BrowserNavBar";
|
import BrowserNavBar from "../molecules/BrowserNavBar";
|
||||||
import { BrowserWindow } from "./BrowserWindow";
|
import { BrowserWindow } from "./BrowserWindow";
|
||||||
import { useBrowserDimensionsStore } from "../../context/browserDimensions";
|
import { useBrowserDimensionsStore } from "../../context/browserDimensions";
|
||||||
import { BrowserTabs } from "../molecules/BrowserTabs";
|
import { BrowserTabs } from "../molecules/BrowserTabs";
|
||||||
import { useSocketStore } from "../../context/socket";
|
import { useSocketStore } from "../../context/socket";
|
||||||
import { getCurrentTabs, getCurrentUrl, interpretCurrentRecording } from "../../api/recording";
|
import {
|
||||||
import { Box } from '@mui/material';
|
getCurrentTabs,
|
||||||
|
getCurrentUrl,
|
||||||
|
interpretCurrentRecording,
|
||||||
|
} from "../../api/recording";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
import { InterpretationLog } from "../molecules/InterpretationLog";
|
import { InterpretationLog } from "../molecules/InterpretationLog";
|
||||||
|
|
||||||
// TODO: Tab !show currentUrl after recordingUrl global state
|
// TODO: Tab !show currentUrl after recordingUrl global state
|
||||||
@@ -14,107 +18,125 @@ export const BrowserContent = () => {
|
|||||||
const { width } = useBrowserDimensionsStore();
|
const { width } = useBrowserDimensionsStore();
|
||||||
const { socket } = useSocketStore();
|
const { socket } = useSocketStore();
|
||||||
|
|
||||||
const [tabs, setTabs] = useState<string[]>(['current']);
|
const [tabs, setTabs] = useState<string[]>(["current"]);
|
||||||
const [tabIndex, setTabIndex] = React.useState(0);
|
const [tabIndex, setTabIndex] = React.useState(0);
|
||||||
const [showOutputData, setShowOutputData] = useState(false);
|
const [showOutputData, setShowOutputData] = useState(false);
|
||||||
|
|
||||||
const handleChangeIndex = useCallback((index: number) => {
|
const handleChangeIndex = useCallback(
|
||||||
setTabIndex(index);
|
(index: number) => {
|
||||||
}, [tabIndex])
|
setTabIndex(index);
|
||||||
|
},
|
||||||
|
[tabIndex]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCloseTab = useCallback((index: number) => {
|
const handleCloseTab = useCallback(
|
||||||
// the tab needs to be closed on the backend
|
(index: number) => {
|
||||||
socket?.emit('closeTab', {
|
// the tab needs to be closed on the backend
|
||||||
index,
|
socket?.emit("closeTab", {
|
||||||
isCurrent: tabIndex === index,
|
index,
|
||||||
});
|
isCurrent: tabIndex === index,
|
||||||
// change the current index as current tab gets closed
|
});
|
||||||
if (tabIndex === index) {
|
// change the current index as current tab gets closed
|
||||||
if (tabs.length > index + 1) {
|
if (tabIndex === index) {
|
||||||
handleChangeIndex(index);
|
if (tabs.length > index + 1) {
|
||||||
|
handleChangeIndex(index);
|
||||||
|
} else {
|
||||||
|
handleChangeIndex(index - 1);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
handleChangeIndex(index - 1);
|
handleChangeIndex(tabIndex - 1);
|
||||||
}
|
}
|
||||||
} else {
|
// update client tabs
|
||||||
handleChangeIndex(tabIndex - 1);
|
setTabs((prevState) => [
|
||||||
}
|
...prevState.slice(0, index),
|
||||||
// update client tabs
|
...prevState.slice(index + 1),
|
||||||
setTabs((prevState) => [
|
]);
|
||||||
...prevState.slice(0, index),
|
},
|
||||||
...prevState.slice(index + 1)
|
[tabs, socket, tabIndex]
|
||||||
])
|
);
|
||||||
}, [tabs, socket, tabIndex]);
|
|
||||||
|
|
||||||
const handleAddNewTab = useCallback(() => {
|
const handleAddNewTab = useCallback(() => {
|
||||||
// Adds new tab by pressing the plus button
|
// Adds new tab by pressing the plus button
|
||||||
socket?.emit('addTab');
|
socket?.emit("addTab");
|
||||||
// Adds a new tab to the end of the tabs array and shifts focus
|
// Adds a new tab to the end of the tabs array and shifts focus
|
||||||
setTabs((prevState) => [...prevState, 'new tab']);
|
setTabs((prevState) => [...prevState, "new tab"]);
|
||||||
handleChangeIndex(tabs.length);
|
handleChangeIndex(tabs.length);
|
||||||
}, [socket, tabs]);
|
}, [socket, tabs]);
|
||||||
|
|
||||||
const handleNewTab = useCallback((tab: string) => {
|
const handleNewTab = useCallback(
|
||||||
// Adds a new tab to the end of the tabs array and shifts focus
|
(tab: string) => {
|
||||||
setTabs((prevState) => [...prevState, tab]);
|
// Adds a new tab to the end of the tabs array and shifts focus
|
||||||
// changes focus on the new tab - same happens in the remote browser
|
setTabs((prevState) => [...prevState, tab]);
|
||||||
handleChangeIndex(tabs.length);
|
// changes focus on the new tab - same happens in the remote browser
|
||||||
handleTabChange(tabs.length);
|
handleChangeIndex(tabs.length);
|
||||||
}, [tabs]);
|
handleTabChange(tabs.length);
|
||||||
|
},
|
||||||
|
[tabs]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTabChange = useCallback((index: number) => {
|
const handleTabChange = useCallback(
|
||||||
// page screencast and focus needs to be changed on backend
|
(index: number) => {
|
||||||
socket?.emit('changeTab', index);
|
// page screencast and focus needs to be changed on backend
|
||||||
}, [socket]);
|
socket?.emit("changeTab", index);
|
||||||
|
},
|
||||||
|
[socket]
|
||||||
|
);
|
||||||
|
|
||||||
const handleUrlChanged = (url: string) => {
|
const handleUrlChanged = (url: string) => {
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
if (parsedUrl.hostname) {
|
if (parsedUrl.hostname) {
|
||||||
const host = parsedUrl.hostname.match(/\b(?!www\.)[a-zA-Z0-9]+/g)?.join('.')
|
const host = parsedUrl.hostname
|
||||||
|
.match(/\b(?!www\.)[a-zA-Z0-9]+/g)
|
||||||
|
?.join(".");
|
||||||
if (host && host !== tabs[tabIndex]) {
|
if (host && host !== tabs[tabIndex]) {
|
||||||
setTabs((prevState) => [
|
setTabs((prevState) => [
|
||||||
...prevState.slice(0, tabIndex),
|
...prevState.slice(0, tabIndex),
|
||||||
host,
|
host,
|
||||||
...prevState.slice(tabIndex + 1)
|
...prevState.slice(tabIndex + 1),
|
||||||
])
|
]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (tabs[tabIndex] !== 'new tab') {
|
if (tabs[tabIndex] !== "new tab") {
|
||||||
setTabs((prevState) => [
|
setTabs((prevState) => [
|
||||||
...prevState.slice(0, tabIndex),
|
...prevState.slice(0, tabIndex),
|
||||||
'new tab',
|
"new tab",
|
||||||
...prevState.slice(tabIndex + 1)
|
...prevState.slice(tabIndex + 1),
|
||||||
])
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabHasBeenClosedHandler = useCallback((index: number) => {
|
const tabHasBeenClosedHandler = useCallback(
|
||||||
handleCloseTab(index);
|
(index: number) => {
|
||||||
}, [handleCloseTab])
|
handleCloseTab(index);
|
||||||
|
},
|
||||||
|
[handleCloseTab]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.on('newTab', handleNewTab);
|
socket.on("newTab", handleNewTab);
|
||||||
socket.on('tabHasBeenClosed', tabHasBeenClosedHandler);
|
socket.on("tabHasBeenClosed", tabHasBeenClosedHandler);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.off('newTab', handleNewTab);
|
socket.off("newTab", handleNewTab);
|
||||||
socket.off('tabHasBeenClosed', tabHasBeenClosedHandler);
|
socket.off("tabHasBeenClosed", tabHasBeenClosedHandler);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [socket, handleNewTab])
|
}, [socket, handleNewTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getCurrentTabs().then((response) => {
|
getCurrentTabs()
|
||||||
if (response) {
|
.then((response) => {
|
||||||
setTabs(response);
|
if (response) {
|
||||||
}
|
setTabs(response);
|
||||||
}).catch((error) => {
|
}
|
||||||
console.log("Fetching current url failed");
|
})
|
||||||
})
|
.catch((error) => {
|
||||||
}, [])
|
console.log("Fetching current url failed");
|
||||||
|
});
|
||||||
|
}, [handleUrlChanged]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="browser">
|
<div id="browser">
|
||||||
@@ -134,7 +156,6 @@ export const BrowserContent = () => {
|
|||||||
<BrowserWindow />
|
<BrowserWindow />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const BrowserContentWrapper = styled.div`
|
const BrowserContentWrapper = styled.div``;
|
||||||
`;
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Tabs from '@mui/material/Tabs';
|
|||||||
import Tab from '@mui/material/Tab';
|
import Tab from '@mui/material/Tab';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { Paper, Button } from "@mui/material";
|
import { Paper, Button } from "@mui/material";
|
||||||
import { AutoAwesome, FormatListBulleted, VpnKey, Usb, Article, Link, CloudQueue } from "@mui/icons-material";
|
import { AutoAwesome, FormatListBulleted, VpnKey, Usb, CloudQueue, Code } from "@mui/icons-material";
|
||||||
import { apiUrl } from "../../apiConfig";
|
import { apiUrl } from "../../apiConfig";
|
||||||
|
|
||||||
interface MainMenuProps {
|
interface MainMenuProps {
|
||||||
@@ -87,8 +87,8 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
<hr />
|
<hr />
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'left' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'left' }}>
|
||||||
<Button href={`${apiUrl}/api-docs/`} target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Article />}>
|
<Button href={`${apiUrl}/api-docs/`} target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Code />}>
|
||||||
API Docs
|
Website To API
|
||||||
</Button>
|
</Button>
|
||||||
<Button href="https://forms.gle/hXjgqDvkEhPcaBW76" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}>
|
<Button href="https://forms.gle/hXjgqDvkEhPcaBW76" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}>
|
||||||
Join Maxun Cloud
|
Join Maxun Cloud
|
||||||
@@ -103,7 +103,7 @@ const buttonStyles = {
|
|||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
fontSize: 'medium',
|
fontSize: 'medium',
|
||||||
padding: '6px 16px 6px 22px',
|
padding: '6px 16px 6px 22px',
|
||||||
minHeight: '48px',
|
minHeight: '48px',
|
||||||
minWidth: '100%',
|
minWidth: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { RunSettings, RunSettingsModal } from "../molecules/RunSettings";
|
|||||||
import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSettings";
|
import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSettings";
|
||||||
import { IntegrationSettings, IntegrationSettingsModal } from "../molecules/IntegrationSettings";
|
import { IntegrationSettings, IntegrationSettingsModal } from "../molecules/IntegrationSettings";
|
||||||
import { RobotSettings, RobotSettingsModal } from "../molecules/RobotSettings";
|
import { RobotSettings, RobotSettingsModal } from "../molecules/RobotSettings";
|
||||||
|
import { RobotEditModal } from '../molecules/RobotEdit';
|
||||||
|
import { RobotDuplicationModal } from '../molecules/RobotDuplicate';
|
||||||
|
|
||||||
interface RecordingsProps {
|
interface RecordingsProps {
|
||||||
handleEditRecording: (id: string, fileName: string) => void;
|
handleEditRecording: (id: string, fileName: string) => void;
|
||||||
@@ -18,10 +20,14 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
|
|||||||
const [scheduleSettingsAreOpen, setScheduleSettingsAreOpen] = useState(false);
|
const [scheduleSettingsAreOpen, setScheduleSettingsAreOpen] = useState(false);
|
||||||
const [integrateSettingsAreOpen, setIntegrateSettingsAreOpen] = useState(false);
|
const [integrateSettingsAreOpen, setIntegrateSettingsAreOpen] = useState(false);
|
||||||
const [robotSettingsAreOpen, setRobotSettingsAreOpen] = useState(false);
|
const [robotSettingsAreOpen, setRobotSettingsAreOpen] = useState(false);
|
||||||
|
const [robotEditAreOpen, setRobotEditAreOpen] = useState(false);
|
||||||
|
const [robotDuplicateAreOpen, setRobotDuplicateAreOpen] = useState(false);
|
||||||
const [params, setParams] = useState<string[]>([]);
|
const [params, setParams] = useState<string[]>([]);
|
||||||
const [selectedRecordingId, setSelectedRecordingId] = useState<string>('');
|
const [selectedRecordingId, setSelectedRecordingId] = useState<string>('');
|
||||||
const handleIntegrateRecording = (id: string, settings: IntegrationSettings) => {};
|
const handleIntegrateRecording = (id: string, settings: IntegrationSettings) => {};
|
||||||
const handleSettingsRecording = (id: string, settings: RobotSettings) => {};
|
const handleSettingsRecording = (id: string, settings: RobotSettings) => {};
|
||||||
|
const handleEditRobot = (id: string, settings: RobotSettings) => {};
|
||||||
|
const handleDuplicateRobot = (id: string, settings: RobotSettings) => {};
|
||||||
|
|
||||||
const handleSettingsAndIntegrate = (id: string, name: string, params: string[]) => {
|
const handleSettingsAndIntegrate = (id: string, name: string, params: string[]) => {
|
||||||
if (params.length === 0) {
|
if (params.length === 0) {
|
||||||
@@ -75,6 +81,32 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEditRobotOption = (id: string, name: string, params: string[]) => {
|
||||||
|
if (params.length === 0) {
|
||||||
|
setRobotEditAreOpen(true);
|
||||||
|
setRecordingInfo(id, name);
|
||||||
|
setSelectedRecordingId(id);
|
||||||
|
} else {
|
||||||
|
setParams(params);
|
||||||
|
setRobotEditAreOpen(true);
|
||||||
|
setRecordingInfo(id, name);
|
||||||
|
setSelectedRecordingId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDuplicateRobotOption = (id: string, name: string, params: string[]) => {
|
||||||
|
if (params.length === 0) {
|
||||||
|
setRobotDuplicateAreOpen(true);
|
||||||
|
setRecordingInfo(id, name);
|
||||||
|
setSelectedRecordingId(id);
|
||||||
|
} else {
|
||||||
|
setParams(params);
|
||||||
|
setRobotDuplicateAreOpen(true);
|
||||||
|
setRecordingInfo(id, name);
|
||||||
|
setSelectedRecordingId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setParams([]);
|
setParams([]);
|
||||||
setRunSettingsAreOpen(false);
|
setRunSettingsAreOpen(false);
|
||||||
@@ -103,6 +135,20 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
|
|||||||
setSelectedRecordingId('');
|
setSelectedRecordingId('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRobotEditClose = () => {
|
||||||
|
setParams([]);
|
||||||
|
setRobotEditAreOpen(false);
|
||||||
|
setRecordingInfo('', '');
|
||||||
|
setSelectedRecordingId('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRobotDuplicateClose = () => {
|
||||||
|
setParams([]);
|
||||||
|
setRobotDuplicateAreOpen(false);
|
||||||
|
setRecordingInfo('', '');
|
||||||
|
setSelectedRecordingId('');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<RunSettingsModal isOpen={runSettingsAreOpen}
|
<RunSettingsModal isOpen={runSettingsAreOpen}
|
||||||
@@ -123,6 +169,14 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
|
|||||||
handleClose={handleRobotSettingsClose}
|
handleClose={handleRobotSettingsClose}
|
||||||
handleStart={(settings) => handleSettingsRecording(selectedRecordingId, settings)}
|
handleStart={(settings) => handleSettingsRecording(selectedRecordingId, settings)}
|
||||||
/>
|
/>
|
||||||
|
<RobotEditModal isOpen={robotEditAreOpen}
|
||||||
|
handleClose={handleRobotEditClose}
|
||||||
|
handleStart={(settings) => handleEditRobot(selectedRecordingId,settings)}
|
||||||
|
/>
|
||||||
|
<RobotDuplicationModal isOpen={robotDuplicateAreOpen}
|
||||||
|
handleClose={handleRobotDuplicateClose}
|
||||||
|
handleStart={(settings) => handleDuplicateRobot(selectedRecordingId, settings)}
|
||||||
|
/>
|
||||||
<Grid container direction="column" sx={{ padding: '30px' }}>
|
<Grid container direction="column" sx={{ padding: '30px' }}>
|
||||||
<Grid item xs>
|
<Grid item xs>
|
||||||
<RecordingsTable
|
<RecordingsTable
|
||||||
@@ -131,6 +185,8 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
|
|||||||
handleScheduleRecording={handleSettingsAndSchedule}
|
handleScheduleRecording={handleSettingsAndSchedule}
|
||||||
handleIntegrateRecording={handleSettingsAndIntegrate}
|
handleIntegrateRecording={handleSettingsAndIntegrate}
|
||||||
handleSettingsRecording={handleRobotSettings}
|
handleSettingsRecording={handleRobotSettings}
|
||||||
|
handleEditRobot={handleEditRobotOption}
|
||||||
|
handleDuplicateRobot={handleDuplicateRobotOption}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -90,4 +90,4 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AuthContext, AuthProvider };
|
export { AuthContext, AuthProvider };
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ a {
|
|||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
monospace;
|
monospace;
|
||||||
|
color: #ff00c3;
|
||||||
}
|
}
|
||||||
|
|
||||||
#browser-actions {
|
#browser-actions {
|
||||||
@@ -53,9 +54,6 @@ code {
|
|||||||
transform-origin: top left; /* Keep the position fixed */
|
transform-origin: top left; /* Keep the position fixed */
|
||||||
}
|
}
|
||||||
|
|
||||||
#browser {
|
|
||||||
}
|
|
||||||
|
|
||||||
#browser-window {
|
#browser-window {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user