diff --git a/package.json b/package.json index addce564..bb991aa6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 00000000..00f51aa2 --- /dev/null +++ b/server/src/routes/auth.ts @@ -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'); + } + }); \ No newline at end of file diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index ff6f8402..72be1ebd 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -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, }; diff --git a/server/src/routes/integration.ts b/server/src/routes/integration.ts new file mode 100644 index 00000000..3c9f8519 --- /dev/null +++ b/server/src/routes/integration.ts @@ -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 }); + } +}); \ No newline at end of file diff --git a/server/src/routes/service_account_credentials.json b/server/src/routes/service_account_credentials.json new file mode 100644 index 00000000..602fcbe0 --- /dev/null +++ b/server/src/routes/service_account_credentials.json @@ -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"} \ No newline at end of file diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 08be5b7c..3d427169 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -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); } -}); +}); \ No newline at end of file diff --git a/server/src/server.ts b/server/src/server.ts index a81a216c..3e553367 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -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 🚀'); diff --git a/server/src/workflow-management/integrations/gsheet.ts b/server/src/workflow-management/integrations/gsheet.ts new file mode 100644 index 00000000..3ce55341 --- /dev/null +++ b/server/src/workflow-management/integrations/gsheet.ts @@ -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)); + } +}; + \ No newline at end of file diff --git a/server/src/workflow-management/scheduler/index.ts b/server/src/workflow-management/scheduler/index.ts index d874ac5b..07d338c1 100644 --- a/server/src/workflow-management/scheduler/index.ts +++ b/server/src/workflow-management/scheduler/index.ts @@ -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`); diff --git a/src/api/integration.ts b/src/api/integration.ts new file mode 100644 index 00000000..5a7432b6 --- /dev/null +++ b/src/api/integration.ts @@ -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 => { + 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; + } +}; \ No newline at end of file diff --git a/src/components/molecules/IntegrationSettings.tsx b/src/components/molecules/IntegrationSettings.tsx new file mode 100644 index 00000000..ed6bea50 --- /dev/null +++ b/src/components/molecules/IntegrationSettings.tsx @@ -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({ + credentials: '', + spreadsheetId: '', + range: '', + data: '', + }); + + const handleChange = (field: keyof IntegrationSettings) => (e: React.ChangeEvent) => { + setSettings({ ...settings, [field]: e.target.value }); + }; + + return ( + +
+ Google Sheets Integration + + + + + + + + +
+
+ ); +}; diff --git a/src/components/molecules/RecordingsTable.tsx b/src/components/molecules/RecordingsTable.tsx index 429b8234..191ee5cb 100644 --- a/src/components/molecules/RecordingsTable.tsx +++ b/src/components/molecules/RecordingsTable.tsx @@ -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([]); @@ -175,6 +182,12 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl handleScheduleRecording(row.name, row.params || [])} /> ); + case 'integrate': + return ( + + handleIntegrateRecording(row.name, row.params || [])} /> + + ); case 'delete': return ( @@ -245,4 +258,19 @@ const ScheduleButton = ({ handleSchedule }: ScheduleButtonProps) => { ) +} + +interface IntegrateButtonProps { + handleIntegrate: () => void; +} + +const IntegrateButton = ({ handleIntegrate }: IntegrateButtonProps) => { + return ( + { + handleIntegrate(); + }} + sx={{ '&:hover': { color: '#1976d2', backgroundColor: 'transparent' } }}> + + + ) } \ No newline at end of file diff --git a/src/components/organisms/Recordings.tsx b/src/components/organisms/Recordings.tsx index d5fb6bc5..e7c65ce4 100644 --- a/src/components/organisms/Recordings.tsx +++ b/src/components/organisms/Recordings.tsx @@ -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([]); + 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)} /> + handleIntegrateRecording(settings)} + /> diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 9b67f629..b3a36349 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -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 { } return ( - - - { DisplayContent() } + + + {DisplayContent()} ); };