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/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
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 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,
};

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.
*/
*/
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);
}
});
});

View File

@@ -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 🚀');

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 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
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 { 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>
)
}

View File

@@ -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>

View File

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