Merge pull request #31 from amhsirak/develop

feat: gsheet integration
This commit is contained in:
Karishma Shukla
2024-09-19 19:42:22 +05:30
committed by GitHub
14 changed files with 484 additions and 59 deletions

View File

@@ -10,6 +10,7 @@
"@mui/icons-material": "^5.5.1", "@mui/icons-material": "^5.5.1",
"@mui/lab": "^5.0.0-alpha.80", "@mui/lab": "^5.0.0-alpha.80",
"@mui/material": "^5.6.2", "@mui/material": "^5.6.2",
"@react-oauth/google": "^0.12.1",
"@testing-library/jest-dom": "^5.16.4", "@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1.1", "@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
@@ -27,6 +28,8 @@
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"express": "^4.17.2", "express": "^4.17.2",
"fortawesome": "^0.0.1-security", "fortawesome": "^0.0.1-security",
"google-auth-library": "^9.14.1",
"googleapis": "^144.0.0",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"joi": "^17.6.0", "joi": "^17.6.0",
"loglevel": "^1.8.0", "loglevel": "^1.8.0",

92
server/src/routes/auth.ts Normal file
View 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');
}
});

View File

@@ -1,9 +1,13 @@
import { router as record } from './record'; import { router as record } from './record';
import { router as workflow } from './workflow'; import { router as workflow } from './workflow';
import { router as storage } from './storage'; import { router as storage } from './storage';
import { router as auth } from './auth';
import { router as integration } from './integration';
export { export {
record, record,
workflow, workflow,
storage, storage,
auth,
integration,
}; };

View 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 });
}
});

View 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"}

View File

@@ -1,6 +1,6 @@
/** /**
* RESTful API endpoints handling the recording storage. * RESTful API endpoints handling the recording storage.
*/ */
import { Router } from 'express'; import { Router } from 'express';
import logger from "../logger"; import logger from "../logger";
@@ -13,6 +13,7 @@ import { uuid } from "uuidv4";
import { workflowQueue } from '../workflow-management/scheduler'; import { workflowQueue } from '../workflow-management/scheduler';
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import cron from 'node-cron'; import cron from 'node-cron';
import { googleSheetUpdateTasks, processGoogleSheetUpdates } from '../workflow-management/integrations/gsheet';
export const router = Router(); export const router = Router();
@@ -20,7 +21,7 @@ export const router = Router();
* Logs information about recordings API. * Logs information about recordings API.
*/ */
router.all('/', (req, res, next) => { 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 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`); await deleteFile(`./../storage/recordings/${req.params.fileName}.waw.json`);
return res.send(true); return res.send(true);
} catch (e) { } 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`); logger.log('info', `Error while deleting a recording with name: ${req.params.fileName}.waw.json`);
return res.send(false); return res.send(false);
} }
@@ -72,7 +73,7 @@ router.delete('/runs/:fileName', async (req, res) => {
await deleteFile(`./../storage/runs/${req.params.fileName}.json`); await deleteFile(`./../storage/runs/${req.params.fileName}.json`);
return res.send(true); return res.send(true);
} catch (e) { } 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`); logger.log('info', `Error while deleting a run with name: ${req.params.fileName}.json`);
return res.send(false); return res.send(false);
} }
@@ -117,7 +118,7 @@ router.put('/runs/:fileName', async (req, res) => {
runId: runId, runId: runId,
}); });
} catch (e) { } 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`); logger.log('info', `Error while creating a run with name: ${req.params.fileName}.json`);
return res.send(''); 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) => { router.post('/runs/run/:fileName/:runId', async (req, res) => {
try { try {
// read the recording from storage
const recording = await readFile(`./../storage/recordings/${req.params.fileName}.waw.json`) const recording = await readFile(`./../storage/recordings/${req.params.fileName}.waw.json`)
const parsedRecording = JSON.parse(recording); const parsedRecording = JSON.parse(recording);
// read the run from storage
const run = await readFile(`./../storage/runs/${req.params.fileName}_${req.params.runId}.json`) const run = await readFile(`./../storage/runs/${req.params.fileName}_${req.params.runId}.json`)
const parsedRun = JSON.parse(run); const parsedRun = JSON.parse(run);
@@ -168,27 +168,34 @@ router.post('/runs/run/:fileName/:runId', async (req, res) => {
} }
})(); })();
await destroyRemoteBrowser(parsedRun.browserId); await destroyRemoteBrowser(parsedRun.browserId);
const run_meta = { const run_meta = {
...parsedRun, ...parsedRun,
status: interpretationInfo.result, status: 'success',
finishedAt: new Date().toLocaleString(), finishedAt: new Date().toLocaleString(),
duration: durString, duration: durString,
browserId: null, browserId: parsedRun.browserId,
log: interpretationInfo.log.join('\n'), log: interpretationInfo.log.join('\n'),
serializableOutput: interpretationInfo.serializableOutput, serializableOutput: interpretationInfo.serializableOutput,
binaryOutput: interpretationInfo.binaryOutput, binaryOutput: interpretationInfo.binaryOutput,
}; };
fs.mkdirSync('../storage/runs', { recursive: true }) fs.mkdirSync('../storage/runs', { recursive: true })
await saveFile( await saveFile(
`../storage/runs/${parsedRun.name}_${req.params.runId}.json`, `../storage/runs/${parsedRun.name}_${req.params.runId}.json`,
JSON.stringify(run_meta, null, 2) JSON.stringify(run_meta, null, 2)
); );
return res.send(true); googleSheetUpdateTasks[req.params.runId] = {
} else { name: parsedRun.name,
throw new Error('Could not destroy browser'); runId: req.params.runId,
} status: 'pending',
retries: 5,
};
processGoogleSheetUpdates();
return res.send(true);
} else {
throw new Error('Could not destroy browser');
}
} catch (e) { } 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`); logger.log('info', `Error while running a recording with name: ${req.params.fileName}_${req.params.runId}.json`);
return res.send(false); return res.send(false);
} }
@@ -291,11 +298,9 @@ router.put('/schedule/:fileName/', async (req, res) => {
*/ */
router.post('/runs/abort/:fileName/:runId', async (req, res) => { router.post('/runs/abort/:fileName/:runId', async (req, res) => {
try { try {
// read the run from storage
const run = await readFile(`./../storage/runs/${req.params.fileName}_${req.params.runId}.json`) const run = await readFile(`./../storage/runs/${req.params.fileName}_${req.params.runId}.json`)
const parsedRun = JSON.parse(run); const parsedRun = JSON.parse(run);
//get current log
const browser = browserPool.getRemoteBrowser(parsedRun.browserId); const browser = browserPool.getRemoteBrowser(parsedRun.browserId);
const currentLog = browser?.interpreter.debugMessages.join('/n'); const currentLog = browser?.interpreter.debugMessages.join('/n');
const serializableOutput = browser?.interpreter.serializableData.reduce((reducedObject, item, index) => { const serializableOutput = browser?.interpreter.serializableData.reduce((reducedObject, item, index) => {
@@ -326,7 +331,7 @@ router.post('/runs/abort/:fileName/:runId', async (req, res) => {
); );
return res.send(true); return res.send(true);
} catch (e) { } 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`); logger.log('info', `Error while running a recording with name: ${req.params.fileName}_${req.params.runId}.json`);
return res.send(false); return res.send(false);
} }

View File

@@ -2,13 +2,11 @@ import express from 'express';
import http from 'http'; import http from 'http';
import cors from 'cors'; import cors from 'cors';
import 'dotenv/config'; import 'dotenv/config';
import { record, workflow, storage, auth, integration } from './routes';
import { record, workflow, storage } from './routes';
import { BrowserPool } from "./browser-management/classes/BrowserPool"; import { BrowserPool } from "./browser-management/classes/BrowserPool";
import logger from './logger' import logger from './logger'
import { SERVER_PORT } from "./constants/config"; import { SERVER_PORT } from "./constants/config";
import { Server } from "socket.io"; import { Server } from "socket.io";
import { worker } from './workflow-management/scheduler';
const app = express(); const app = express();
app.use(cors()); app.use(cors());
@@ -30,6 +28,8 @@ export const browserPool = new BrowserPool();
app.use('/record', record); app.use('/record', record);
app.use('/workflow', workflow); app.use('/workflow', workflow);
app.use('/storage', storage); app.use('/storage', storage);
app.use('/auth', auth);
app.use('/integration', integration);
app.get('/', function (req, res) { app.get('/', function (req, res) {
return res.send('Maxun server started 🚀'); return res.send('Maxun server started 🚀');

View 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));
}
};

View File

@@ -8,6 +8,7 @@ import { readFile, saveFile } from "../storage";
import { createRemoteBrowserForRun, destroyRemoteBrowser } from '../../browser-management/controller'; import { createRemoteBrowserForRun, destroyRemoteBrowser } from '../../browser-management/controller';
import logger from '../../logger'; import logger from '../../logger';
import { browserPool } from "../../server"; import { browserPool } from "../../server";
import { googleSheetUpdateTasks, processGoogleSheetUpdates } from "../integrations/gsheet";
const connection = new IORedis({ const connection = new IORedis({
host: 'localhost', host: 'localhost',
@@ -52,9 +53,9 @@ worker.on('failed', async (job: any, err) => {
logger.log(`info`, `Worker and queue have been closed after failure.`); logger.log(`info`, `Worker and queue have been closed after failure.`);
}); });
async function jobCounts () { async function jobCounts() {
const jobCounts = await workflowQueue.getJobCounts(); const jobCounts = await workflowQueue.getJobCounts();
console.log('Jobs:', jobCounts); console.log('Jobs:', jobCounts);
} }
jobCounts(); jobCounts();
@@ -140,10 +141,10 @@ async function executeRun(fileName: string, runId: string) {
const updated_run_meta = { const updated_run_meta = {
...parsedRun, ...parsedRun,
status: interpretationInfo.result, status: 'success',
finishedAt: new Date().toLocaleString(), finishedAt: new Date().toLocaleString(),
duration: durString, duration: durString,
browserId: null, browserId: parsedRun.browserId,
log: interpretationInfo.log.join('\n'), log: interpretationInfo.log.join('\n'),
serializableOutput: interpretationInfo.serializableOutput, serializableOutput: interpretationInfo.serializableOutput,
binaryOutput: interpretationInfo.binaryOutput, binaryOutput: interpretationInfo.binaryOutput,
@@ -153,7 +154,13 @@ async function executeRun(fileName: string, runId: string) {
`../storage/runs/${fileName}_${runId}.json`, `../storage/runs/${fileName}_${runId}.json`,
JSON.stringify(updated_run_meta, null, 2) JSON.stringify(updated_run_meta, null, 2)
); );
googleSheetUpdateTasks[runId] = {
name: parsedRun.name,
runId: runId,
status: 'pending',
retries: 5,
};
processGoogleSheetUpdates();
return true; return true;
} catch (error: any) { } catch (error: any) {
logger.log('info', `Error while running a recording with name: ${fileName}_${runId}.json`); logger.log('info', `Error while running a recording with name: ${fileName}_${runId}.json`);

16
src/api/integration.ts Normal file
View 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;
}
};

View 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>
);
};

View File

@@ -11,11 +11,12 @@ import { useEffect } from "react";
import { WorkflowFile } from "maxun-core"; import { WorkflowFile } from "maxun-core";
import { IconButton } from "@mui/material"; import { IconButton } from "@mui/material";
import { Schedule, DeleteForever, Edit, PlayCircle } from "@mui/icons-material"; import { Schedule, DeleteForever, Edit, PlayCircle } from "@mui/icons-material";
import LinkIcon from '@mui/icons-material/Link';
import { useGlobalInfoStore } from "../../context/globalInfo"; import { useGlobalInfoStore } from "../../context/globalInfo";
import { deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; import { deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage";
interface Column { 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; label: string;
minWidth?: number; minWidth?: number;
align?: 'right'; align?: 'right';
@@ -46,6 +47,11 @@ const columns: readonly Column[] = [
label: 'Schedule', label: 'Schedule',
minWidth: 80, minWidth: 80,
}, },
{
id: 'integrate',
label: 'Integrate',
minWidth: 80,
},
{ {
id: 'update_date', id: 'update_date',
label: 'Updated at', label: 'Updated at',
@@ -73,9 +79,10 @@ interface RecordingsTableProps {
handleEditRecording: (fileName: string) => void; handleEditRecording: (fileName: string) => void;
handleRunRecording: (fileName: string, params: string[]) => void; handleRunRecording: (fileName: string, params: string[]) => void;
handleScheduleRecording: (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 [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10); const [rowsPerPage, setRowsPerPage] = React.useState(10);
const [rows, setRows] = React.useState<Data[]>([]); const [rows, setRows] = React.useState<Data[]>([]);
@@ -175,6 +182,12 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
<ScheduleButton handleSchedule={() => handleScheduleRecording(row.name, row.params || [])} /> <ScheduleButton handleSchedule={() => handleScheduleRecording(row.name, row.params || [])} />
</TableCell> </TableCell>
); );
case 'integrate':
return (
<TableCell key={column.id} align={column.align}>
<IntegrateButton handleIntegrate={() => handleIntegrateRecording(row.name, row.params || [])} />
</TableCell>
);
case 'delete': case 'delete':
return ( return (
<TableCell key={column.id} align={column.align}> <TableCell key={column.id} align={column.align}>
@@ -246,3 +259,18 @@ const ScheduleButton = ({ handleSchedule }: ScheduleButtonProps) => {
</IconButton> </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>
)
}

View File

@@ -3,19 +3,33 @@ import { RecordingsTable } from "../molecules/RecordingsTable";
import { Grid } from "@mui/material"; import { Grid } from "@mui/material";
import { RunSettings, RunSettingsModal } from "../molecules/RunSettings"; import { RunSettings, RunSettingsModal } from "../molecules/RunSettings";
import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSettings"; import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSettings";
import { IntegrationSettings, IntegrationSettingsModal } from "../molecules/IntegrationSettings";
interface RecordingsProps { interface RecordingsProps {
handleEditRecording: (fileName: string) => void; handleEditRecording: (fileName: string) => void;
handleRunRecording: (settings: RunSettings) => void; handleRunRecording: (settings: RunSettings) => void;
handleScheduleRecording: (settings: ScheduleSettings) => void; handleScheduleRecording: (settings: ScheduleSettings) => void;
handleIntegrateRecording: (settings: IntegrationSettings) => void;
setFileName: (fileName: string) => 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 [runSettingsAreOpen, setRunSettingsAreOpen] = useState(false);
const [scheduleSettingsAreOpen, setScheduleSettingsAreOpen] = useState(false); const [scheduleSettingsAreOpen, setScheduleSettingsAreOpen] = useState(false);
const [integrateSettingsAreOpen, setIntegrateSettingsAreOpen] = useState(false);
const [params, setParams] = useState<string[]>([]); 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[]) => { const handleSettingsAndRun = (fileName: string, params: string[]) => {
if (params.length === 0) { if (params.length === 0) {
setRunSettingsAreOpen(true); setRunSettingsAreOpen(true);
@@ -44,6 +58,12 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setFileNam
setFileName(''); setFileName('');
} }
const handleIntegrateClose = () => {
setParams([]);
setIntegrateSettingsAreOpen(false);
setFileName('');
}
const handleScheduleClose = () => { const handleScheduleClose = () => {
setParams([]); setParams([]);
setScheduleSettingsAreOpen(false); setScheduleSettingsAreOpen(false);
@@ -62,12 +82,17 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setFileNam
handleClose={handleScheduleClose} handleClose={handleScheduleClose}
handleStart={(settings) => handleScheduleRecording(settings)} handleStart={(settings) => handleScheduleRecording(settings)}
/> />
<IntegrationSettingsModal isOpen={integrateSettingsAreOpen}
handleClose={handleIntegrateClose}
handleStart={(settings) => handleIntegrateRecording(settings)}
/>
<Grid container direction="column" sx={{ padding: '30px' }}> <Grid container direction="column" sx={{ padding: '30px' }}>
<Grid item xs> <Grid item xs>
<RecordingsTable <RecordingsTable
handleEditRecording={handleEditRecording} handleEditRecording={handleEditRecording}
handleRunRecording={handleSettingsAndRun} handleRunRecording={handleSettingsAndRun}
handleScheduleRecording={handleSettingsAndSchedule} handleScheduleRecording={handleSettingsAndSchedule}
handleIntegrateRecording={handleSettingsAndIntegrate}
/> />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -5,10 +5,12 @@ import { Recordings } from "../components/organisms/Recordings";
import { Runs } from "../components/organisms/Runs"; import { Runs } from "../components/organisms/Runs";
import { useGlobalInfoStore } from "../context/globalInfo"; import { useGlobalInfoStore } from "../context/globalInfo";
import { createRunForStoredRecording, interpretStoredRecording, notifyAboutAbort, scheduleStoredRecording } from "../api/storage"; import { createRunForStoredRecording, interpretStoredRecording, notifyAboutAbort, scheduleStoredRecording } from "../api/storage";
import { handleUploadCredentials } from "../api/integration"
import { io, Socket } from "socket.io-client"; import { io, Socket } from "socket.io-client";
import { stopRecording } from "../api/recording"; import { stopRecording } from "../api/recording";
import { RunSettings } from "../components/molecules/RunSettings"; import { RunSettings } from "../components/molecules/RunSettings";
import { ScheduleSettings } from "../components/molecules/ScheduleSettings"; import { ScheduleSettings } from "../components/molecules/ScheduleSettings";
import { IntegrationSettings } from "../components/molecules/IntegrationSettings";
interface MainPageProps { interface MainPageProps {
handleEditRecording: (fileName: string) => void; handleEditRecording: (fileName: string) => void;
@@ -55,8 +57,8 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
setRunningRecordingName(fileName); setRunningRecordingName(fileName);
} }
const readyForRunHandler = useCallback( (browserId: string, runId: string) => { const readyForRunHandler = useCallback((browserId: string, runId: string) => {
interpretStoredRecording(runningRecordingName, runId).then( async (interpretation: boolean) => { interpretStoredRecording(runningRecordingName, runId).then(async (interpretation: boolean) => {
if (!aborted) { if (!aborted) {
if (interpretation) { if (interpretation) {
notify('success', `Interpretation of ${runningRecordingName} succeeded`); notify('success', `Interpretation of ${runningRecordingName} succeeded`);
@@ -78,8 +80,8 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
}, [currentInterpretationLog]) }, [currentInterpretationLog])
const handleRunRecording = useCallback((settings: RunSettings) => { const handleRunRecording = useCallback((settings: RunSettings) => {
createRunForStoredRecording(runningRecordingName, settings).then(({browserId, runId}: CreateRunResponse) => { createRunForStoredRecording(runningRecordingName, settings).then(({ browserId, runId }: CreateRunResponse) => {
setIds({browserId, runId}); setIds({ browserId, runId });
const socket = const socket =
io(`http://localhost:8080/${browserId}`, { io(`http://localhost:8080/${browserId}`, {
transports: ["websocket"], transports: ["websocket"],
@@ -103,7 +105,7 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
const handleScheduleRecording = (settings: ScheduleSettings) => { const handleScheduleRecording = (settings: ScheduleSettings) => {
scheduleStoredRecording(runningRecordingName, settings) scheduleStoredRecording(runningRecordingName, settings)
.then(({message, runId}: ScheduleRunResponse) => { .then(({ message, runId }: ScheduleRunResponse) => {
if (message === 'success') { if (message === 'success') {
notify('success', `Recording ${runningRecordingName} scheduled successfully`); notify('success', `Recording ${runningRecordingName} scheduled successfully`);
} else { } 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 = () => { const DisplayContent = () => {
switch (content) { switch (content) {
case 'recordings': case 'recordings':
@@ -120,6 +133,7 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
handleRunRecording={handleRunRecording} handleRunRecording={handleRunRecording}
setFileName={setFileName} setFileName={setFileName}
handleScheduleRecording={handleScheduleRecording} handleScheduleRecording={handleScheduleRecording}
handleIntegrateRecording={handleIntegrateRecording}
/>; />;
case 'runs': case 'runs':
return <Runs return <Runs
@@ -134,9 +148,9 @@ export const MainPage = ({ handleEditRecording }: MainPageProps) => {
} }
return ( return (
<Stack direction='row' spacing={0} sx={{minHeight: '800px'}}> <Stack direction='row' spacing={0} sx={{ minHeight: '800px' }}>
<MainMenu value={content} handleChangeContent={setContent}/> <MainMenu value={content} handleChangeContent={setContent} />
{ DisplayContent() } {DisplayContent()}
</Stack> </Stack>
); );
}; };