@@ -10,6 +10,7 @@
|
||||
"@mui/icons-material": "^5.5.1",
|
||||
"@mui/lab": "^5.0.0-alpha.80",
|
||||
"@mui/material": "^5.6.2",
|
||||
"@react-oauth/google": "^0.12.1",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
@@ -27,6 +28,8 @@
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.17.2",
|
||||
"fortawesome": "^0.0.1-security",
|
||||
"google-auth-library": "^9.14.1",
|
||||
"googleapis": "^144.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"joi": "^17.6.0",
|
||||
"loglevel": "^1.8.0",
|
||||
|
||||
92
server/src/routes/auth.ts
Normal file
92
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Router } from 'express';;
|
||||
import { google, sheets_v4 } from "googleapis";
|
||||
import { OAuth2Client } from 'google-auth-library'
|
||||
|
||||
export const router = Router()
|
||||
|
||||
const oauth2Client = new OAuth2Client(
|
||||
'_CLIENT_ID',
|
||||
'_CLIENT_SECRET',
|
||||
'_REDIRECT_URI'
|
||||
);
|
||||
|
||||
// initialize Google OAuth 2.0 flow
|
||||
router.get('/auth/google', (req, res) => {
|
||||
const url = oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: [
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
'https://www.googleapis.com/auth/spreadsheets'
|
||||
]
|
||||
});
|
||||
res.redirect(url);
|
||||
});
|
||||
|
||||
// Callback route for Google OAuth 2.0
|
||||
router.get('/auth/google/callback', async (req, res) => {
|
||||
const code = req.query.code;
|
||||
if (typeof code !== 'string') {
|
||||
res.status(400).send('Invalid authorization code');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { tokens } = await oauth2Client.getToken(code);
|
||||
oauth2Client.setCredentials(tokens);
|
||||
// TODO: Store tokens securely (e.g., in a database)
|
||||
res.send('Authentication successful');
|
||||
} catch (error) {
|
||||
console.error('Error during authentication:', error);
|
||||
res.status(500).send('Authentication failed');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sheets', async (req, res) => {
|
||||
try {
|
||||
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)'
|
||||
});
|
||||
res.json(response.data.files);
|
||||
} catch (error) {
|
||||
console.error('Error listing sheets:', error);
|
||||
res.status(500).send('Failed to list sheets');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sheets/:sheetId', async (req, res) => {
|
||||
try {
|
||||
const sheets = google.sheets({ version: 'v4', auth: oauth2Client });
|
||||
const response = await sheets.spreadsheets.values.get({
|
||||
spreadsheetId: req.params.sheetId,
|
||||
range: 'Sheet1', // Adjust range as needed
|
||||
});
|
||||
res.json(response.data.values);
|
||||
} catch (error) {
|
||||
console.error('Error reading sheet:', error);
|
||||
res.status(500).send('Failed to read sheet');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sheets/:sheetId', async (req, res) => {
|
||||
try {
|
||||
const sheets = google.sheets({ version: 'v4', auth: oauth2Client });
|
||||
|
||||
const request: sheets_v4.Params$Resource$Spreadsheets$Values$Append = {
|
||||
spreadsheetId: req.params.sheetId,
|
||||
range: 'Sheet1', // Adjust range as needed
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: {
|
||||
values: [req.body.values], // Expect an array of values in the request body
|
||||
},
|
||||
};
|
||||
|
||||
const response = await sheets.spreadsheets.values.append(request);
|
||||
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error writing to sheet:', error);
|
||||
res.status(500).send('Failed to write to sheet');
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { router as record } from './record';
|
||||
import { router as workflow } from './workflow';
|
||||
import { router as storage } from './storage';
|
||||
import { router as auth } from './auth';
|
||||
import { router as integration } from './integration';
|
||||
|
||||
export {
|
||||
record,
|
||||
workflow,
|
||||
storage,
|
||||
auth,
|
||||
integration,
|
||||
};
|
||||
|
||||
23
server/src/routes/integration.ts
Normal file
23
server/src/routes/integration.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import logger from "../logger";
|
||||
import { loadIntegrations, saveIntegrations } from '../workflow-management/integrations/gsheet';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
router.post('/upload-credentials', async (req, res) => {
|
||||
try {
|
||||
const { fileName, credentials, spreadsheetId, range } = req.body;
|
||||
if (!fileName || !credentials || !spreadsheetId || !range) {
|
||||
return res.status(400).json({ message: 'Credentials, Spreadsheet ID, and Range are required.' });
|
||||
}
|
||||
// *** TEMPORARILY WE STORE CREDENTIALS HERE ***
|
||||
let integrations = loadIntegrations(fileName);
|
||||
integrations = { fileName, spreadsheetId, range, credentials };
|
||||
saveIntegrations(fileName, integrations);
|
||||
logger.log('info', 'Service account credentials saved successfully.');
|
||||
return res.send(true);
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Error saving credentials: ${error.message}`);
|
||||
return res.status(500).json({ message: 'Failed to save credentials.', error: error.message });
|
||||
}
|
||||
});
|
||||
1
server/src/routes/service_account_credentials.json
Normal file
1
server/src/routes/service_account_credentials.json
Normal file
@@ -0,0 +1 @@
|
||||
{"type":"service_account","project_id":"maxun12345","private_key_id":"45859ac1196ba6a371bf5427eb990e44b61d4237","private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC0Sx73gWatvgGX\nhhtt/isiaH+EL1Y9K3X0404ZZndm7iNvILk190Jv2RjDNjASvWZBMcH72MKwaMkP\neYspK33DiA7+IDzE+tVefrC8PFPBepO3VtVfNeKe6xBMa3j2TwlfVgpDqPQBun69\nLuhvG1QMh1GfCnTi0wSeksRSiTS2KlmAuY79I/Y8KRQefLwoPZDDjVD9/3B0Xgly\nC3kKAtmfOSi+U1BUdVf3J58Tj57Yge2QpSBHBPjxwL60iadJoBSgcvBUXg+VgdXg\nqNyENYau5ohyvOjtW4Fhv/d7g/lyi84NBEn6+2ljFzY2xE0tZYM1CLouCU9MeDDu\ngvcHzGOnAgMBAAECggEAUwJbSZ18hvX6iacnLedeOMNgIp4g2J0P/CZDqRIyW2O9\nUMcCP0SOhCyyZ/6k95vYXp/oLmpIiSxVlhhh6BysFMCqFnsFP7Q1VQKDoMct0OD8\n7ea+6s1Gf+C+alD5Kn6NVCWrKCe74Kfa/oOAZNdyRSpwfAc2ddCuScxNM6sUZ+Ik\nK89fM576j8rfBAF1JW4ynQE/oM1y9A+88o1g602tOC5rm4vZH/Jv9tZwnfHMV+S7\n2PMSGDD1N4Mq1z3Gg3HjRHC/zo6SW6Jvk4O6mx9scIaui+u3nn7qQUVCLSMiFV3i\nsPv9zLktadu0h9o+6pCHSDuOFvFQOFIVEMU0fmgkQQKBgQD1Vnitk6X3aRBI/1u9\nJCk1l0GarfvdUed5fb3qRwH85vEhwmWKyglwDtWxxYNSNOIlpxOGKnq9CMdpkF15\nQO1bgbhsrbx75YYT29ulbAhLjm/tIP3OdvokzsH/Lz9qVh0AnEi+lgueRjerALvZ\nfZuLeSFi3FlxEX229a89TN9c0QKBgQC8IP4ea+Zu9hK+8qsF6htQEMqYagAMI63R\nwTVzRHL4H/GGYcs39ZjA9F0y0g65MAtT7+PfR8QifNUAJNRAnu5/CPDyQX9GvBM2\n9uBnwI1pJSdse1m0ez7kigl2TQDYaOADoSvRGALmBAFXXB7CNXgy8l1aQBiRvZjy\nulBQCcn29wKBgQDYQxM5ns9L2lc6oa6Sec/Bp8Vyvi8olsw+sfK5E0LTVhf0tFGi\nGBpdpxYEEKGD044NtstlFwj+nUB684ESI4OXiC+zzSo65MZdtw5VMXfWcoaDNvPE\nDejOjVtAwLtb1vDV2u3c4pL3P9pOaOUuAKUeOvaNGMPXAZ4Zq1R/6sVyIQKBgH93\nCzapfPPpnkHqQZ48RE03U015ZJbVBcx80C5NTmh3nDmkwQAlU15JM2xfjsJCnyo7\n+3UpNub3FYqHaZhvFsDT2g0J+6Z9f7daBinF+QootlF2Mg1rA+3s6QRSoCQAyucq\nqHl/f1dBl3cNX3nOqKY8OKwRiZQVli+/tPLF7yV5AoGBAI8+aLt3R9rioBtPFDUK\nuwxoeide0LH8UWFa5H+LV/13sWk6io/E6gdQOi1A2CrOdR+YFTCUG3VbIszbWCba\ne4Ws8Yy0CAvz0ePVE2aySJE0aRQWZH2BjPIckeGE8AGxViS1JiiX+wruYJiLSzx9\n+ejlxtjjkUsMoDd3trSI/s5d\n-----END PRIVATE KEY-----\n","client_email":"maxun-999@maxun12345.iam.gserviceaccount.com","client_id":"108198347095941384494","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/maxun-999%40maxun12345.iam.gserviceaccount.com","universe_domain":"googleapis.com"}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* RESTful API endpoints handling the recording storage.
|
||||
*/
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import logger from "../logger";
|
||||
@@ -13,6 +13,7 @@ import { uuid } from "uuidv4";
|
||||
import { workflowQueue } from '../workflow-management/scheduler';
|
||||
import moment from 'moment-timezone';
|
||||
import cron from 'node-cron';
|
||||
import { googleSheetUpdateTasks, processGoogleSheetUpdates } from '../workflow-management/integrations/gsheet';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
@@ -20,7 +21,7 @@ export const router = Router();
|
||||
* Logs information about recordings API.
|
||||
*/
|
||||
router.all('/', (req, res, next) => {
|
||||
logger.log('debug',`The recordings API was invoked: ${req.url}`)
|
||||
logger.log('debug', `The recordings API was invoked: ${req.url}`)
|
||||
next() // pass control to the next handler
|
||||
})
|
||||
|
||||
@@ -45,7 +46,7 @@ router.delete('/recordings/:fileName', async (req, res) => {
|
||||
await deleteFile(`./../storage/recordings/${req.params.fileName}.waw.json`);
|
||||
return res.send(true);
|
||||
} catch (e) {
|
||||
const {message} = e as Error;
|
||||
const { message } = e as Error;
|
||||
logger.log('info', `Error while deleting a recording with name: ${req.params.fileName}.waw.json`);
|
||||
return res.send(false);
|
||||
}
|
||||
@@ -72,7 +73,7 @@ router.delete('/runs/:fileName', async (req, res) => {
|
||||
await deleteFile(`./../storage/runs/${req.params.fileName}.json`);
|
||||
return res.send(true);
|
||||
} catch (e) {
|
||||
const {message} = e as Error;
|
||||
const { message } = e as Error;
|
||||
logger.log('info', `Error while deleting a run with name: ${req.params.fileName}.json`);
|
||||
return res.send(false);
|
||||
}
|
||||
@@ -117,7 +118,7 @@ router.put('/runs/:fileName', async (req, res) => {
|
||||
runId: runId,
|
||||
});
|
||||
} catch (e) {
|
||||
const {message} = e as Error;
|
||||
const { message } = e as Error;
|
||||
logger.log('info', `Error while creating a run with name: ${req.params.fileName}.json`);
|
||||
return res.send('');
|
||||
}
|
||||
@@ -144,10 +145,9 @@ router.get('/runs/run/:fileName/:runId', async (req, res) => {
|
||||
*/
|
||||
router.post('/runs/run/:fileName/:runId', async (req, res) => {
|
||||
try {
|
||||
// read the recording from storage
|
||||
const recording = await readFile(`./../storage/recordings/${req.params.fileName}.waw.json`)
|
||||
const parsedRecording = JSON.parse(recording);
|
||||
// read the run from storage
|
||||
|
||||
const run = await readFile(`./../storage/runs/${req.params.fileName}_${req.params.runId}.json`)
|
||||
const parsedRun = JSON.parse(run);
|
||||
|
||||
@@ -168,27 +168,34 @@ router.post('/runs/run/:fileName/:runId', async (req, res) => {
|
||||
}
|
||||
})();
|
||||
await destroyRemoteBrowser(parsedRun.browserId);
|
||||
const run_meta = {
|
||||
...parsedRun,
|
||||
status: interpretationInfo.result,
|
||||
finishedAt: new Date().toLocaleString(),
|
||||
duration: durString,
|
||||
browserId: null,
|
||||
log: interpretationInfo.log.join('\n'),
|
||||
serializableOutput: interpretationInfo.serializableOutput,
|
||||
binaryOutput: interpretationInfo.binaryOutput,
|
||||
};
|
||||
fs.mkdirSync('../storage/runs', { recursive: true })
|
||||
await saveFile(
|
||||
`../storage/runs/${parsedRun.name}_${req.params.runId}.json`,
|
||||
JSON.stringify(run_meta, null, 2)
|
||||
);
|
||||
return res.send(true);
|
||||
} else {
|
||||
throw new Error('Could not destroy browser');
|
||||
}
|
||||
const run_meta = {
|
||||
...parsedRun,
|
||||
status: 'success',
|
||||
finishedAt: new Date().toLocaleString(),
|
||||
duration: durString,
|
||||
browserId: parsedRun.browserId,
|
||||
log: interpretationInfo.log.join('\n'),
|
||||
serializableOutput: interpretationInfo.serializableOutput,
|
||||
binaryOutput: interpretationInfo.binaryOutput,
|
||||
};
|
||||
fs.mkdirSync('../storage/runs', { recursive: true })
|
||||
await saveFile(
|
||||
`../storage/runs/${parsedRun.name}_${req.params.runId}.json`,
|
||||
JSON.stringify(run_meta, null, 2)
|
||||
);
|
||||
googleSheetUpdateTasks[req.params.runId] = {
|
||||
name: parsedRun.name,
|
||||
runId: req.params.runId,
|
||||
status: 'pending',
|
||||
retries: 5,
|
||||
};
|
||||
processGoogleSheetUpdates();
|
||||
return res.send(true);
|
||||
} else {
|
||||
throw new Error('Could not destroy browser');
|
||||
}
|
||||
} catch (e) {
|
||||
const {message} = e as Error;
|
||||
const { message } = e as Error;
|
||||
logger.log('info', `Error while running a recording with name: ${req.params.fileName}_${req.params.runId}.json`);
|
||||
return res.send(false);
|
||||
}
|
||||
@@ -198,12 +205,12 @@ router.put('/schedule/:fileName/', async (req, res) => {
|
||||
console.log(req.body);
|
||||
try {
|
||||
const { fileName } = req.params;
|
||||
const {
|
||||
runEvery,
|
||||
runEveryUnit,
|
||||
startFrom,
|
||||
atTime,
|
||||
timezone
|
||||
const {
|
||||
runEvery,
|
||||
runEveryUnit,
|
||||
startFrom,
|
||||
atTime,
|
||||
timezone
|
||||
} = req.body;
|
||||
|
||||
if (!fileName || !runEvery || !runEveryUnit || !startFrom || !atTime || !timezone) {
|
||||
@@ -258,7 +265,7 @@ router.put('/schedule/:fileName/', async (req, res) => {
|
||||
await workflowQueue.add(
|
||||
'run workflow',
|
||||
{ fileName, runId },
|
||||
{
|
||||
{
|
||||
repeat: {
|
||||
pattern: cronExpression,
|
||||
tz: timezone
|
||||
@@ -266,8 +273,8 @@ router.put('/schedule/:fileName/', async (req, res) => {
|
||||
}
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
message: 'success',
|
||||
res.status(200).json({
|
||||
message: 'success',
|
||||
runId,
|
||||
// cronExpression,
|
||||
// nextRunTime: getNextRunTime(cronExpression, timezone)
|
||||
@@ -291,11 +298,9 @@ router.put('/schedule/:fileName/', async (req, res) => {
|
||||
*/
|
||||
router.post('/runs/abort/:fileName/:runId', async (req, res) => {
|
||||
try {
|
||||
// read the run from storage
|
||||
const run = await readFile(`./../storage/runs/${req.params.fileName}_${req.params.runId}.json`)
|
||||
const parsedRun = JSON.parse(run);
|
||||
|
||||
//get current log
|
||||
const browser = browserPool.getRemoteBrowser(parsedRun.browserId);
|
||||
const currentLog = browser?.interpreter.debugMessages.join('/n');
|
||||
const serializableOutput = browser?.interpreter.serializableData.reduce((reducedObject, item, index) => {
|
||||
@@ -326,8 +331,8 @@ router.post('/runs/abort/:fileName/:runId', async (req, res) => {
|
||||
);
|
||||
return res.send(true);
|
||||
} catch (e) {
|
||||
const {message} = e as Error;
|
||||
const { message } = e as Error;
|
||||
logger.log('info', `Error while running a recording with name: ${req.params.fileName}_${req.params.runId}.json`);
|
||||
return res.send(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,11 @@ import express from 'express';
|
||||
import http from 'http';
|
||||
import cors from 'cors';
|
||||
import 'dotenv/config';
|
||||
|
||||
import { record, workflow, storage } from './routes';
|
||||
import { record, workflow, storage, auth, integration } from './routes';
|
||||
import { BrowserPool } from "./browser-management/classes/BrowserPool";
|
||||
import logger from './logger'
|
||||
import { SERVER_PORT } from "./constants/config";
|
||||
import { Server } from "socket.io";
|
||||
import { worker } from './workflow-management/scheduler';
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
@@ -30,6 +28,8 @@ export const browserPool = new BrowserPool();
|
||||
app.use('/record', record);
|
||||
app.use('/workflow', workflow);
|
||||
app.use('/storage', storage);
|
||||
app.use('/auth', auth);
|
||||
app.use('/integration', integration);
|
||||
|
||||
app.get('/', function (req, res) {
|
||||
return res.send('Maxun server started 🚀');
|
||||
|
||||
125
server/src/workflow-management/integrations/gsheet.ts
Normal file
125
server/src/workflow-management/integrations/gsheet.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { google } from "googleapis";
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import logger from "../../logger";
|
||||
import { readFile } from "../storage";
|
||||
interface GoogleSheetUpdateTask {
|
||||
name: string;
|
||||
runId: string;
|
||||
status: 'pending' | 'completed' | 'failed';
|
||||
retries: number;
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
export let googleSheetUpdateTasks: { [runId: string]: GoogleSheetUpdateTask } = {};
|
||||
|
||||
|
||||
// *** Temporary Path to the JSON file that will store the integration details ***
|
||||
const getIntegrationsFilePath = (fileName: string) => path.join(__dirname, `integrations-${fileName}.json`);
|
||||
|
||||
export function loadIntegrations(fileName: string) {
|
||||
const filePath = getIntegrationsFilePath(fileName);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const data = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function saveIntegrations(fileName: string, integrations: any) {
|
||||
const filePath = getIntegrationsFilePath(fileName);
|
||||
fs.writeFileSync(filePath, JSON.stringify(integrations, null, 2));
|
||||
}
|
||||
|
||||
export async function updateGoogleSheet(fileName: string, runId: string) {
|
||||
try {
|
||||
const run = await readFile(`./../storage/runs/${fileName}_${runId}.json`);
|
||||
const parsedRun = JSON.parse(run);
|
||||
|
||||
if (parsedRun.status === 'success' && parsedRun.serializableOutput) {
|
||||
const data = parsedRun.serializableOutput['item-0'] as { [key: string]: any }[];
|
||||
const integrationConfig = await loadIntegrations(fileName);
|
||||
|
||||
if (integrationConfig) {
|
||||
const { fileName, spreadsheetId, range, credentials } = integrationConfig;
|
||||
|
||||
if (fileName && spreadsheetId && range && credentials) {
|
||||
// Convert data to Google Sheets format (headers and rows)
|
||||
const headers = Object.keys(data[0]);
|
||||
const rows = data.map((row: { [key: string]: any }) => Object.values(row));
|
||||
const outputData = [headers, ...rows];
|
||||
|
||||
await writeDataToSheet(fileName, spreadsheetId, range, outputData);
|
||||
logger.log('info', `Data written to Google Sheet successfully for ${fileName}_${runId}`);
|
||||
}
|
||||
}
|
||||
logger.log('error', `Google Sheet integration not configured for ${fileName}_${runId}`);
|
||||
}
|
||||
logger.log('error', `Run not successful or no data to update for ${fileName}_${runId}`);
|
||||
} catch (error: any) {
|
||||
logger.log('error', `Failed to write data to Google Sheet for ${fileName}_${runId}: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export async function writeDataToSheet(fileName: string, spreadsheetId: string, range: string, data: any[]) {
|
||||
try {
|
||||
const integrationCredentialsPath = getIntegrationsFilePath(fileName);
|
||||
const integrationCredentials = JSON.parse(fs.readFileSync(integrationCredentialsPath, 'utf-8'));;
|
||||
|
||||
const auth = new google.auth.GoogleAuth({
|
||||
credentials: {
|
||||
client_email: integrationCredentials.credentials.client_email,
|
||||
private_key: integrationCredentials.credentials.private_key,
|
||||
},
|
||||
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
|
||||
});
|
||||
|
||||
const authToken = await auth.getClient();
|
||||
const sheets = google.sheets({ version: 'v4', auth: authToken as any });
|
||||
|
||||
const resource = { values: data };
|
||||
|
||||
await sheets.spreadsheets.values.append({
|
||||
spreadsheetId,
|
||||
range,
|
||||
valueInputOption: 'USER_ENTERED',
|
||||
requestBody: resource,
|
||||
});
|
||||
|
||||
logger.log(`info`, `Data written to Google Sheet: ${spreadsheetId}, Range: ${range}`);
|
||||
} catch (error: any) {
|
||||
logger.log(`error`, `Error writing data to Google Sheet: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const processGoogleSheetUpdates = async () => {
|
||||
while (true) {
|
||||
let hasPendingTasks = false;
|
||||
for (const runId in googleSheetUpdateTasks) {
|
||||
const task = googleSheetUpdateTasks[runId];
|
||||
if (task.status === 'pending') {
|
||||
hasPendingTasks = true;
|
||||
try {
|
||||
await updateGoogleSheet(task.name, task.runId);
|
||||
console.log(`Successfully updated Google Sheets for run ${task.runId}`);
|
||||
delete googleSheetUpdateTasks[runId];
|
||||
} catch (error: any) {
|
||||
if (task.retries < MAX_RETRIES) {
|
||||
googleSheetUpdateTasks[runId].retries += 1;
|
||||
} else {
|
||||
// Mark as failed after maximum retries
|
||||
googleSheetUpdateTasks[runId].status = 'failed';
|
||||
}
|
||||
console.error(`Failed to update Google Sheets for run ${task.runId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasPendingTasks) {
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { readFile, saveFile } from "../storage";
|
||||
import { createRemoteBrowserForRun, destroyRemoteBrowser } from '../../browser-management/controller';
|
||||
import logger from '../../logger';
|
||||
import { browserPool } from "../../server";
|
||||
import { googleSheetUpdateTasks, processGoogleSheetUpdates } from "../integrations/gsheet";
|
||||
|
||||
const connection = new IORedis({
|
||||
host: 'localhost',
|
||||
@@ -52,9 +53,9 @@ worker.on('failed', async (job: any, err) => {
|
||||
logger.log(`info`, `Worker and queue have been closed after failure.`);
|
||||
});
|
||||
|
||||
async function jobCounts () {
|
||||
async function jobCounts() {
|
||||
const jobCounts = await workflowQueue.getJobCounts();
|
||||
console.log('Jobs:', jobCounts);
|
||||
console.log('Jobs:', jobCounts);
|
||||
}
|
||||
|
||||
jobCounts();
|
||||
@@ -140,10 +141,10 @@ async function executeRun(fileName: string, runId: string) {
|
||||
|
||||
const updated_run_meta = {
|
||||
...parsedRun,
|
||||
status: interpretationInfo.result,
|
||||
status: 'success',
|
||||
finishedAt: new Date().toLocaleString(),
|
||||
duration: durString,
|
||||
browserId: null,
|
||||
browserId: parsedRun.browserId,
|
||||
log: interpretationInfo.log.join('\n'),
|
||||
serializableOutput: interpretationInfo.serializableOutput,
|
||||
binaryOutput: interpretationInfo.binaryOutput,
|
||||
@@ -153,7 +154,13 @@ async function executeRun(fileName: string, runId: string) {
|
||||
`../storage/runs/${fileName}_${runId}.json`,
|
||||
JSON.stringify(updated_run_meta, null, 2)
|
||||
);
|
||||
|
||||
googleSheetUpdateTasks[runId] = {
|
||||
name: parsedRun.name,
|
||||
runId: runId,
|
||||
status: 'pending',
|
||||
retries: 5,
|
||||
};
|
||||
processGoogleSheetUpdates();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
logger.log('info', `Error while running a recording with name: ${fileName}_${runId}.json`);
|
||||
|
||||
16
src/api/integration.ts
Normal file
16
src/api/integration.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { default as axios } from "axios";
|
||||
|
||||
// todo: proper typescript types for params
|
||||
export const handleUploadCredentials = async (fileName: any, credentials: any, spreadsheetId: any, range: any): Promise<boolean> => {
|
||||
try {
|
||||
const response = await axios.post('http://localhost:8080/integration/upload-credentials', { fileName, credentials: JSON.parse(credentials), spreadsheetId, range });
|
||||
if (response.status === 200) {
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error(`Couldn't make gsheet integration for ${fileName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading credentials:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
82
src/components/molecules/IntegrationSettings.tsx
Normal file
82
src/components/molecules/IntegrationSettings.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { GenericModal } from "../atoms/GenericModal";
|
||||
import { MenuItem, TextField, Typography } from "@mui/material";
|
||||
import Button from "@mui/material/Button";
|
||||
import { modalStyle } from "./AddWhereCondModal";
|
||||
|
||||
interface IntegrationProps {
|
||||
isOpen: boolean;
|
||||
handleStart: (data: IntegrationSettings) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
export interface IntegrationSettings {
|
||||
credentials: string;
|
||||
spreadsheetId: string;
|
||||
range: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export const IntegrationSettingsModal = ({ isOpen, handleStart, handleClose }: IntegrationProps) => {
|
||||
|
||||
const [settings, setSettings] = useState<IntegrationSettings>({
|
||||
credentials: '',
|
||||
spreadsheetId: '',
|
||||
range: '',
|
||||
data: '',
|
||||
});
|
||||
|
||||
const handleChange = (field: keyof IntegrationSettings) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({ ...settings, [field]: e.target.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
modalStyle={modalStyle}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
marginLeft: '65px',
|
||||
}}>
|
||||
<Typography sx={{ margin: '20px 0px' }}>Google Sheets Integration</Typography>
|
||||
|
||||
<TextField
|
||||
sx={{ marginBottom: '15px' }}
|
||||
label="Service Account JSON"
|
||||
multiline
|
||||
rows={10}
|
||||
required
|
||||
value={settings.credentials}
|
||||
onChange={handleChange('credentials')}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
sx={{ marginBottom: '15px' }}
|
||||
label="Google Spreadsheet ID"
|
||||
required
|
||||
value={settings.spreadsheetId}
|
||||
onChange={handleChange('spreadsheetId')}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
sx={{ marginBottom: '15px' }}
|
||||
label="Range (e.g., Sheet1!A1:B2)"
|
||||
required
|
||||
value={settings.range}
|
||||
onChange={handleChange('range')}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Button variant="contained" color="primary" onClick={() => handleStart(settings)} style={{ marginTop: '10px' }}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
@@ -11,11 +11,12 @@ import { useEffect } from "react";
|
||||
import { WorkflowFile } from "maxun-core";
|
||||
import { IconButton } from "@mui/material";
|
||||
import { Schedule, DeleteForever, Edit, PlayCircle } from "@mui/icons-material";
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import { useGlobalInfoStore } from "../../context/globalInfo";
|
||||
import { deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage";
|
||||
|
||||
interface Column {
|
||||
id: 'interpret' | 'name' | 'create_date' | 'edit' | 'pairs' | 'update_date' | 'delete' | 'schedule';
|
||||
id: 'interpret' | 'name' | 'create_date' | 'edit' | 'pairs' | 'update_date' | 'delete' | 'schedule' | 'integrate';
|
||||
label: string;
|
||||
minWidth?: number;
|
||||
align?: 'right';
|
||||
@@ -46,6 +47,11 @@ const columns: readonly Column[] = [
|
||||
label: 'Schedule',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
id: 'integrate',
|
||||
label: 'Integrate',
|
||||
minWidth: 80,
|
||||
},
|
||||
{
|
||||
id: 'update_date',
|
||||
label: 'Updated at',
|
||||
@@ -73,9 +79,10 @@ interface RecordingsTableProps {
|
||||
handleEditRecording: (fileName: string) => void;
|
||||
handleRunRecording: (fileName: string, params: string[]) => void;
|
||||
handleScheduleRecording: (fileName: string, params: string[]) => void;
|
||||
handleIntegrateRecording: (fileName: string, params: string[]) => void;
|
||||
}
|
||||
|
||||
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording }: RecordingsTableProps) => {
|
||||
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording }: RecordingsTableProps) => {
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = React.useState(10);
|
||||
const [rows, setRows] = React.useState<Data[]>([]);
|
||||
@@ -175,6 +182,12 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
|
||||
<ScheduleButton handleSchedule={() => handleScheduleRecording(row.name, row.params || [])} />
|
||||
</TableCell>
|
||||
);
|
||||
case 'integrate':
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align}>
|
||||
<IntegrateButton handleIntegrate={() => handleIntegrateRecording(row.name, row.params || [])} />
|
||||
</TableCell>
|
||||
);
|
||||
case 'delete':
|
||||
return (
|
||||
<TableCell key={column.id} align={column.align}>
|
||||
@@ -245,4 +258,19 @@ const ScheduleButton = ({ handleSchedule }: ScheduleButtonProps) => {
|
||||
<Schedule />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
interface IntegrateButtonProps {
|
||||
handleIntegrate: () => void;
|
||||
}
|
||||
|
||||
const IntegrateButton = ({ handleIntegrate }: IntegrateButtonProps) => {
|
||||
return (
|
||||
<IconButton aria-label="add" size="small" onClick={() => {
|
||||
handleIntegrate();
|
||||
}}
|
||||
sx={{ '&:hover': { color: '#1976d2', backgroundColor: 'transparent' } }}>
|
||||
<LinkIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
@@ -3,19 +3,33 @@ import { RecordingsTable } from "../molecules/RecordingsTable";
|
||||
import { Grid } from "@mui/material";
|
||||
import { RunSettings, RunSettingsModal } from "../molecules/RunSettings";
|
||||
import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSettings";
|
||||
import { IntegrationSettings, IntegrationSettingsModal } from "../molecules/IntegrationSettings";
|
||||
|
||||
interface RecordingsProps {
|
||||
handleEditRecording: (fileName: string) => void;
|
||||
handleRunRecording: (settings: RunSettings) => void;
|
||||
handleScheduleRecording: (settings: ScheduleSettings) => void;
|
||||
handleIntegrateRecording: (settings: IntegrationSettings) => void;
|
||||
setFileName: (fileName: string) => void;
|
||||
}
|
||||
|
||||
export const Recordings = ({ handleEditRecording, handleRunRecording, setFileName, handleScheduleRecording }: RecordingsProps) => {
|
||||
export const Recordings = ({ handleEditRecording, handleRunRecording, setFileName, handleScheduleRecording, handleIntegrateRecording }: RecordingsProps) => {
|
||||
const [runSettingsAreOpen, setRunSettingsAreOpen] = useState(false);
|
||||
const [scheduleSettingsAreOpen, setScheduleSettingsAreOpen] = useState(false);
|
||||
const [integrateSettingsAreOpen, setIntegrateSettingsAreOpen] = useState(false);
|
||||
const [params, setParams] = useState<string[]>([]);
|
||||
|
||||
const handleSettingsAndIntegrate = (fileName: string, params: string[]) => {
|
||||
if (params.length === 0) {
|
||||
setIntegrateSettingsAreOpen(true);
|
||||
setFileName(fileName);
|
||||
} else {
|
||||
setParams(params);
|
||||
setIntegrateSettingsAreOpen(true);
|
||||
setFileName(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSettingsAndRun = (fileName: string, params: string[]) => {
|
||||
if (params.length === 0) {
|
||||
setRunSettingsAreOpen(true);
|
||||
@@ -44,6 +58,12 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setFileNam
|
||||
setFileName('');
|
||||
}
|
||||
|
||||
const handleIntegrateClose = () => {
|
||||
setParams([]);
|
||||
setIntegrateSettingsAreOpen(false);
|
||||
setFileName('');
|
||||
}
|
||||
|
||||
const handleScheduleClose = () => {
|
||||
setParams([]);
|
||||
setScheduleSettingsAreOpen(false);
|
||||
@@ -62,12 +82,17 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setFileNam
|
||||
handleClose={handleScheduleClose}
|
||||
handleStart={(settings) => handleScheduleRecording(settings)}
|
||||
/>
|
||||
<IntegrationSettingsModal isOpen={integrateSettingsAreOpen}
|
||||
handleClose={handleIntegrateClose}
|
||||
handleStart={(settings) => handleIntegrateRecording(settings)}
|
||||
/>
|
||||
<Grid container direction="column" sx={{ padding: '30px' }}>
|
||||
<Grid item xs>
|
||||
<RecordingsTable
|
||||
handleEditRecording={handleEditRecording}
|
||||
handleRunRecording={handleSettingsAndRun}
|
||||
handleScheduleRecording={handleSettingsAndSchedule}
|
||||
handleIntegrateRecording={handleSettingsAndIntegrate}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -5,10 +5,12 @@ import { Recordings } from "../components/organisms/Recordings";
|
||||
import { Runs } from "../components/organisms/Runs";
|
||||
import { useGlobalInfoStore } from "../context/globalInfo";
|
||||
import { createRunForStoredRecording, interpretStoredRecording, notifyAboutAbort, scheduleStoredRecording } from "../api/storage";
|
||||
import { handleUploadCredentials } from "../api/integration"
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { stopRecording } from "../api/recording";
|
||||
import { RunSettings } from "../components/molecules/RunSettings";
|
||||
import { ScheduleSettings } from "../components/molecules/ScheduleSettings";
|
||||
import { IntegrationSettings } from "../components/molecules/IntegrationSettings";
|
||||
|
||||
interface MainPageProps {
|
||||
handleEditRecording: (fileName: string) => void;
|
||||
@@ -55,8 +57,8 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
||||
setRunningRecordingName(fileName);
|
||||
}
|
||||
|
||||
const readyForRunHandler = useCallback( (browserId: string, runId: string) => {
|
||||
interpretStoredRecording(runningRecordingName, runId).then( async (interpretation: boolean) => {
|
||||
const readyForRunHandler = useCallback((browserId: string, runId: string) => {
|
||||
interpretStoredRecording(runningRecordingName, runId).then(async (interpretation: boolean) => {
|
||||
if (!aborted) {
|
||||
if (interpretation) {
|
||||
notify('success', `Interpretation of ${runningRecordingName} succeeded`);
|
||||
@@ -78,8 +80,8 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
||||
}, [currentInterpretationLog])
|
||||
|
||||
const handleRunRecording = useCallback((settings: RunSettings) => {
|
||||
createRunForStoredRecording(runningRecordingName, settings).then(({browserId, runId}: CreateRunResponse) => {
|
||||
setIds({browserId, runId});
|
||||
createRunForStoredRecording(runningRecordingName, settings).then(({ browserId, runId }: CreateRunResponse) => {
|
||||
setIds({ browserId, runId });
|
||||
const socket =
|
||||
io(`http://localhost:8080/${browserId}`, {
|
||||
transports: ["websocket"],
|
||||
@@ -103,7 +105,7 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
||||
|
||||
const handleScheduleRecording = (settings: ScheduleSettings) => {
|
||||
scheduleStoredRecording(runningRecordingName, settings)
|
||||
.then(({message, runId}: ScheduleRunResponse) => {
|
||||
.then(({ message, runId }: ScheduleRunResponse) => {
|
||||
if (message === 'success') {
|
||||
notify('success', `Recording ${runningRecordingName} scheduled successfully`);
|
||||
} else {
|
||||
@@ -112,6 +114,17 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
||||
});
|
||||
}
|
||||
|
||||
const handleIntegrateRecording = (settings: IntegrationSettings) => {
|
||||
handleUploadCredentials(runningRecordingName, settings.credentials, settings.spreadsheetId, settings.range)
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
notify('success', `Service Account credentials saved successfully.`);
|
||||
} else {
|
||||
notify('error', `Failed to save credentials.`);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const DisplayContent = () => {
|
||||
switch (content) {
|
||||
case 'recordings':
|
||||
@@ -120,6 +133,7 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
||||
handleRunRecording={handleRunRecording}
|
||||
setFileName={setFileName}
|
||||
handleScheduleRecording={handleScheduleRecording}
|
||||
handleIntegrateRecording={handleIntegrateRecording}
|
||||
/>;
|
||||
case 'runs':
|
||||
return <Runs
|
||||
@@ -134,9 +148,9 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction='row' spacing={0} sx={{minHeight: '800px'}}>
|
||||
<MainMenu value={content} handleChangeContent={setContent}/>
|
||||
{ DisplayContent() }
|
||||
<Stack direction='row' spacing={0} sx={{ minHeight: '800px' }}>
|
||||
<MainMenu value={content} handleChangeContent={setContent} />
|
||||
{DisplayContent()}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user