diff --git a/go_server/blueprints/connection/connection.go b/go_server/blueprints/connection/connection.go new file mode 100644 index 00000000..1f1ff9ee --- /dev/null +++ b/go_server/blueprints/connection/connection.go @@ -0,0 +1,38 @@ +package connection + +import ( + Utils "go_module/src" + "log" +) + +var prePath = "connection" + +// AddHandleFunc adds the specific module handle function to the server +func AddHandleFunc() { + Utils.CreateHandleFunc(prePath+"/register_ssh_key", registerSSHKey) + Utils.CreateHandleFunc(prePath+"/connection_test_request", connectionTestRequest) +} + +// validateSSHKey checks if the key is valid and saves it locally to the server +// Returns the status of the validation +func registerSSHKey(jsonConfig string, id string) (string, error) { + log.Println("Registering SSH Key: ", id) + response, err := Utils.StartPythonScripts(jsonConfig, "../pythonCode/modules/connection/register_ssh_key.py", id) + Utils.RemoveIdFromScripts(id) + if err != nil { + return "", err + } + return response, nil +} + +// handleProgress handles the request to get the progress of the experiment +// It returns the progress of the experiment +func connectionTestRequest(jsonConfig string, id string) (string, error) { + log.Println("Connection test request: ", id) + response, err := Utils.StartPythonScripts(jsonConfig, "../pythonCode/modules/connection/connection_test_request.py", id) + Utils.RemoveIdFromScripts(id) + if err != nil { + return "", err + } + return response, nil +} diff --git a/go_server/main.exe b/go_server/main.exe index 86db7f56..41a374be 100644 Binary files a/go_server/main.exe and b/go_server/main.exe differ diff --git a/go_server/main.exe~ b/go_server/main.exe~ deleted file mode 100644 index d9880542..00000000 Binary files a/go_server/main.exe~ and /dev/null differ diff --git a/go_server/main.go b/go_server/main.go index 21c8cc80..f4aac2d6 100644 --- a/go_server/main.go +++ b/go_server/main.go @@ -5,6 +5,7 @@ import ( "fmt" MEDprofiles "go_module/blueprints/MEDprofiles_" Application "go_module/blueprints/application" + Connection "go_module/blueprints/connection" Evaluation "go_module/blueprints/evaluation" Exploratory "go_module/blueprints/exploratory" ExtractionImage "go_module/blueprints/extraction_image" @@ -26,6 +27,7 @@ func main() { // Here is where you add the handle functions to the server Learning.AddHandleFunc() + Connection.AddHandleFunc() Evaluation.AddHandleFunc() Exploratory.AddHandleFunc() ExtractionImage.AddHandleFunc() diff --git a/main/background.js b/main/background.js index 9cf2e9b7..6cd9e352 100644 --- a/main/background.js +++ b/main/background.js @@ -1,3 +1,9 @@ +// Force Electron headless mode if --no-gui is present +if (process.argv.some(arg => arg.includes('--no-gui'))) { + process.env.ELECTRON_ENABLE_HEADLESS = '1' + // On some Linux systems, also clear DISPLAY + process.env.DISPLAY = '' +} import { app, ipcMain, Menu, dialog, BrowserWindow, protocol, shell, nativeTheme } from "electron" import axios from "axios" import os from "os" @@ -6,7 +12,16 @@ import { createWindow, TerminalManager } from "./helpers" import { installExtension, REACT_DEVELOPER_TOOLS } from "electron-extension-installer" import MEDconfig from "../medomics.dev" import { runServer, findAvailablePort } from "./utils/server" -import { setWorkingDirectory, getRecentWorkspacesOptions, loadWorkspaces, createMedomicsDirectory, updateWorkspace, createWorkingDirectory } from "./utils/workspace" +import { + setWorkingDirectory, + getRecentWorkspacesOptions, + loadWorkspaces, + createMedomicsDirectory, + createRemoteMedomicsDirectory, + updateWorkspace, + createWorkingDirectory, + createRemoteWorkingDirectory +} from "./utils/workspace" import { getBundledPythonEnvironment, getInstalledPythonPackages, @@ -16,6 +31,32 @@ import { installRequiredPythonPackages } from "./utils/pythonEnv" import { installMongoDB, checkRequirements } from "./utils/installation" +import { + getTunnelState, + getActiveTunnel, + detectRemoteOS, + getRemoteWorkspacePath, + checkRemotePortOpen +} from './utils/remoteFunctions.js' +import { checkJupyterIsRunning, startJupyterServer, stopJupyterServer } from "./utils/jupyterServer.js" +import express from "express" +import bodyParser from "body-parser" + +const cors = require("cors"); +const expressApp = express(); +expressApp.use(bodyParser.json()); +expressApp.use(cors()); + +expressApp.use(function(req, res, next) { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); + next(); +}); +const EXPRESS_PORT = 3000; +expressApp.listen(EXPRESS_PORT, () => { + console.log(`Express server listening on port ${EXPRESS_PORT}`); +}); + const fs = require("fs") const terminalManager = new TerminalManager() @@ -30,6 +71,8 @@ var hasBeenSet = false const isProd = process.env.NODE_ENV === "production" let splashScreen // The splash screen is the window that is displayed while the application is loading export var mainWindow // The main window is the window of the application +// Robust headless mode detection +const isHeadless = process.argv.some(arg => arg.includes('--no-gui')) //**** AUTO UPDATER ****// const { autoUpdater } = require("electron-updater") @@ -180,7 +223,9 @@ if (isProd) { app.setPath("userData", `${app.getPath("userData")} (development)`) } -;(async () => { + +// Main async startup +(async () => { await app.whenReady() protocol.registerFileProtocol("local", (request, callback) => { @@ -197,34 +242,43 @@ if (isProd) { event.reply("get-file-path-reply", path.resolve(configPath)) }) - splashScreen = new BrowserWindow({ - icon: path.join(__dirname, "../resources/MEDomicsLabWithShadowNoText100.png"), - width: 700, - height: 700, - transparent: true, - frame: false, - alwaysOnTop: true, - center: true, - show: true - }) + if (!isHeadless) { + splashScreen = new BrowserWindow({ + icon: path.join(__dirname, "../resources/MEDomicsLabWithShadowNoText100.png"), + width: 700, + height: 700, + transparent: true, + frame: false, + alwaysOnTop: true, + center: true, + show: true + }) - mainWindow = createWindow("main", { - width: 1500, - height: 1000, - show: false - }) + mainWindow = createWindow("main", { + width: 1500, + height: 1000, + show: false + }) - if (isProd) { - splashScreen.loadFile(path.join(__dirname, "splash.html")) + if (isProd) { + splashScreen.loadFile(path.join(__dirname, "splash.html")) + } else { + splashScreen.loadFile(path.join(__dirname, "../main/splash.html")) + } + splashScreen.once("ready-to-show", () => { + splashScreen.show() + splashScreen.focus() + splashScreen.setAlwaysOnTop(true) + }) } else { - splashScreen.loadFile(path.join(__dirname, "../main/splash.html")) + // Headless/server-only mode + mainWindow = undefined; + splashScreen = undefined; + console.log("Running in headless/server-only mode: no GUI will be created."); } - splashScreen.once("ready-to-show", () => { - splashScreen.show() - splashScreen.focus() - splashScreen.setAlwaysOnTop(true) - }) - const openRecentWorkspacesSubmenuOptions = getRecentWorkspacesOptions(null, mainWindow, hasBeenSet, serverPort) + + // Use mainWindow only if not headless + const openRecentWorkspacesSubmenuOptions = getRecentWorkspacesOptions(null, !isHeadless ? mainWindow : null, hasBeenSet, serverPort) console.log("openRecentWorkspacesSubmenuOptions", JSON.stringify(openRecentWorkspacesSubmenuOptions, null, 2)) const menuTemplate = [ { @@ -342,9 +396,193 @@ if (isProd) { }) ipcMain.handle("setWorkingDirectory", async (event, data) => { + return setWorkspaceDirectory(data) + }) + + // Helper to normalize paths for cross-platform compatibility + function normalizePathForPlatform(p) { + if (!p) return p + // Always convert Windows backslashes to forward slashes first + let normalized = p.replace(/\\/g, '/') + if (process.platform === 'win32') { + // On Windows, convert all forward slashes to backslashes + normalized = normalized.replace(/\//g, '\\') + // Remove leading slash if present (e.g. '/C:/path') + if (normalized.match(/^\\[A-Za-z]:/)) { + normalized = normalized.slice(1) + } + } + return normalized + } + + // Remote express requests + expressApp.post("/set-working-directory", async (req, res, next) =>{ + let workspacePath = normalizePathForPlatform(req.body.workspacePath) + console.log("Received request to set workspace directory from remote: ", workspacePath) + try { + const result = await setWorkspaceDirectory(workspacePath); + if (result && result.hasBeenSet) { + console.log('Workspace (from remote) set to: ' + workspacePath) + result.isRemote = true; + res.json({ success: true, workspace: result }); + } else { + console.log('Workspace specified by remote could not be set : ', err) + res.status(500).json({ success: false, error: err.message }); + } + } catch (err) { + console.log('Error setting workspace directory from remote : ', err) + res.status(500).json({ success: false, error: err.message }); + } + }) + + expressApp.get("/get-working-dir-tree", (req, res) => { + try { + let requestPath = normalizePathForPlatform(req.query.requestedPath) + console.log("Received request to get working directory tree for path: ", requestPath) + const workingDirectory = dirTree(requestPath) + if (!workingDirectory) { + console.log("No working directory found for the requested path:" + requestPath) + res.status(500).json({ success: false, error: "Working directory not found" }) + } + res.json({ success: true, workingDirectory: workingDirectory }) + } catch (err) { + console.error("Error getting working directory: ", err) + res.status(500).json({ success: false, error: err.message }) + } + }) + + expressApp.post("/insert-object-into-collection", async (req, res) => { + try { + if (!req.body) { + console.error("No object provided in request body") + return res.status(400).json({ success: false, error: "No object provided" }) + } else if (!req.body.objectPath || !req.body.medDataObject) { + console.error("Invalid request body: objectPath and medDataObject are required") + return res.status(400).json({ success: false, error: "Invalid request body" }) + } + console.log("Received request to insert object into collection: ", req.body) + await mainWindow.webContents.send("insertObjectIntoCollection", req.body) + res.status(200).json({ success: true, message: "Object insertion request received" }) + } catch (err) { + console.error("Error inserting object into remote collection: ", err) + res.status(500).json({ success: false, error: err.message }) + } + }) + + expressApp.post("/download-collection-to-file", async (req, res) => { + try { + if (!req.body) { + console.error("No object provided in request body") + return res.status(400).json({ success: false, error: "No object provided" }) + } else if (!req.body.collectionId || !req.body.filePath || !req.body.type) { + console.error("Invalid request body: downloadCollectionToFile requires collectionId, filePath, and type") + return res.status(400).json({ success: false, error: "Invalid request body" }) + } + console.log("Received request to download collection to file: ", req.body) + await mainWindow.webContents.send("downloadCollectionToFile", req.body) + res.status(200).json({ success: true, message: "Collection download request received" }) + } catch (err) { + console.error("Error downloading object to file: ", err) + res.status(500).json({ success: false, error: err.message }) + } + }) + + expressApp.get("/get-bundled-python-environment", (req, res) => { + try { + console.log("Received request to get bundled python environment") + const pythEnv = getBundledPythonEnvironment() + if (!pythEnv) { + res.status(500).json({ success: false, error: "Bundled python environment not found" }) + } + res.status(200).json({ success: true, pythonEnv: pythEnv }) + } catch (err) { + console.error("Error getting bundled python environment: ", err) + res.status(500).json({ success: false, error: err.message }) + } + }) + + expressApp.get("/get-installed-python-packages", (req, res) => { + try { + console.log("Received request to get installed python packages") + const pythonPackages = getBundledPythonEnvironment() + if (!pythonPackages) { + res.status(500).json({ success: false, error: "No installed python packages found" }) + } + res.status(200).json({ success: true, packages: pythonPackages }) + } catch (err) { + console.error("Error getting installed python packages: ", err) + res.status(500).json({ success: false, error: err.message }) + } + }) + + expressApp.post("/start-mongo", async (req, res) => { + try { + if (!req.body) { + console.error("No object provided in request body") + return res.status(400).json({ success: false, error: "No object provided" }) + } else if (!req.body.workspacePath) { + console.error("Invalid request body: startMongo requires a workspacePath") + return res.status(400).json({ success: false, error: "Invalid request body (no path provided)" }) + } + + let workspacePath = normalizePathForPlatform(req.body.workspacePath) + console.log("Received request to start mongoDB with path : ", workspacePath) + startMongoDB(workspacePath, mongoProcess) + res.status(200).json({ success: true, message: "Started MongoDB on remote server" }) + } catch (err) { + console.error("Error starting MongoDB (request from remote client): ", err) + res.status(500).json({ success: false, error: err.message }) + } + }) + + expressApp.get("/check-jupyter-status", async (req, res) => { + try { + console.log("Received request to check Jupyter status") + const result = await checkJupyterIsRunning() + res.status(200).json({ running: result.running, error: result.error || null }) + } catch (err) { + console.error("Error checking Jupyter server status: ", err) + res.status(500).json({ running: false, error: err.message }) + } + }) + + expressApp.post("/start-jupyter-server", async (req, res) => { + try { + if (!req.body) { + console.error("No object provided in request body") + return res.status(400).json({ running: false, error: "No object provided" }) + } else if (!req.body.workspacePath) { + console.error("Invalid request body: startJupyterServer requires a workspacePath") + return res.status(400).json({ running: false, error: "Invalid request body (no path provided)" }) + } + + let workspacePath = normalizePathForPlatform(req.body.workspacePath) + console.log("Received request to start Jupyter Server with path : ", workspacePath) + const result = await startJupyterServer(workspacePath) + console.log("Jupyter server started: ", result) + res.status(200).json({ running: result.running, error: result.error || null }) + } catch (err) { + console.error("Error starting Jupyter (request from remote client): ", err) + res.status(500).json({ running: false, error: err.message }) + } + }) + + expressApp.post("/stop-jupyter-server", async (req, res) => { + try { + console.log("Received request to stop Jupyter Server") + const result = stopJupyterServer() + res.status(200).json(result) + } catch (err) { + console.error("Error stopping Jupyter (request from remote client): ", err) + res.status(500).json({ running: false, error: err.message }) + } + }) + + + const setWorkspaceDirectory = async (data) => { app.setPath("sessionData", data) + console.log(`setWorkspaceDirectory : ${data}`) createWorkingDirectory() // Create DATA & EXPERIMENTS directories - console.log(`setWorkingDirectory : ${data}`) createMedomicsDirectory(data) hasBeenSet = true try { @@ -376,8 +614,71 @@ if (isProd) { } catch (error) { console.error("Failed to change workspace: ", error) } + } + + ipcMain.handle("setRemoteWorkingDirectory", async (event, data) => { + app.setPath("remoteSessionData", data) + createRemoteWorkingDirectory() // Create DATA & EXPERIMENTS directories + console.log(`setWorkspaceDirectory (remote) : ${data}`) + createRemoteMedomicsDirectory(data) + hasBeenSet = true + try { + // Stop MongoDB if it's running + await stopMongoDB(mongoProcess) + // Kill mongod on remote via SSH exec + if (activeTunnel && typeof activeTunnel.exec === 'function') { + // 1. Detect remote OS + const remoteOS = await detectRemoteOS() + // 2. Run the appropriate kill command + let killCmd + if (remoteOS === 'unix' | remoteOS === 'linux' || remoteOS === 'darwin') { + killCmd = 'pkill -f mongod || killall mongod || true' + } else { + // Windows: try taskkill + killCmd = 'taskkill /IM mongod.exe /F' + } + await new Promise((resolve) => { + activeTunnel.exec(killCmd, (err, stream) => { + if (err) return resolve() + stream.on('close', () => resolve()) + stream.on('data', () => {}) + stream.stderr.on('data', () => {}) + }) + }) + } else { + // Fallback: local logic if no tunnel + if (process.platform === "win32") { + // Kill the process on the port + // killProcessOnPort(serverPort) + } else if (process.platform === "darwin") { + await new Promise((resolve) => { + exec("pkill -f mongod", (error, stdout, stderr) => { + resolve() + }) + }) + } else { + try { + execSync("killall mongod") + } catch (error) { + console.warn("Failed to kill mongod: ", error) + } + } + } + // Start MongoDB with the new configuration + startMongoDB(data, mongoProcess) + return { + workingDirectory: dirTree(app.getPath("remoteSessionData")), + hasBeenSet: hasBeenSet, + newPort: serverPort, + isRemote: false, + success: true + } + } catch (error) { + console.error("Failed to change workspace: ", error) + } }) + /** * @description Returns the path of the specified directory of the app * @param {String} path The path to get @@ -435,6 +736,7 @@ if (isProd) { */ ipcMain.handle("get-settings", async () => { const userDataPath = app.getPath("userData") + console.log("userDataPath: ", userDataPath) const settingsFilePath = path.join(userDataPath, "settings.json") if (fs.existsSync(settingsFilePath)) { const settings = JSON.parse(fs.readFileSync(settingsFilePath, "utf8")) @@ -491,7 +793,7 @@ if (isProd) { serverProcess.kill() } console.log("Received Python path: ", pythonPath) - if (MEDconfig.runServerAutomatically) { + if (MEDconfig.runServerAutomatically) { runServer(isProd, serverPort, serverProcess, serverState, pythonPath) .then((process) => { serverProcess = process @@ -541,11 +843,34 @@ if (isProd) { let recentWorkspaces = loadWorkspaces() event.reply("recentWorkspaces", recentWorkspaces) } else if (data === "updateWorkingDirectory") { - event.reply("updateDirectory", { - workingDirectory: dirTree(app.getPath("sessionData")), - hasBeenSet: hasBeenSet, - newPort: serverPort - }) // Sends the folder structure to Next.js + const activeTunnel = getActiveTunnel() + const tunnel = getTunnelState() + if (activeTunnel && tunnel) { + // If an SSH tunnel is active, we set the remote workspace path + const remoteWorkspacePath = getRemoteWorkspacePath() + axios.get(`http://${tunnel.host}:3000/get-working-dir-tree`, { params: { requestedPath: remoteWorkspacePath } }) + .then((response) => { + if (response.data.success && response.data.workingDirectory) { + event.reply("updateDirectory", { + workingDirectory: response.data.workingDirectory, + hasBeenSet: true, + newPort: tunnel.localBackendPort, + isRemote: true + }) // Sends the folder structure to Next.js + } else { + console.error("Failed to get remote working directory tree: ", response.data.error) + } + }) + .catch((error) => { + console.error("Error getting remote working directory tree: ", error) + }) + } else { + event.reply("updateDirectory", { + workingDirectory: dirTree(app.getPath("sessionData")), + hasBeenSet: hasBeenSet, + newPort: serverPort + }) // Sends the folder structure to Next.js + } } else if (data === "getServerPort") { event.reply("getServerPort", { newPort: serverPort @@ -560,17 +885,18 @@ if (isProd) { mainWindow.webContents.send("toggleDarkMode") }) - if (isProd) { - await mainWindow.loadURL("app://./index.html") - } else { - const port = process.argv[2] - await mainWindow.loadURL(`http://localhost:${port}/`) - mainWindow.webContents.openDevTools() + if (!isHeadless) { + if (isProd) { + await mainWindow.loadURL("app://./index.html") + } else { + const port = process.argv[2] + await mainWindow.loadURL(`http://localhost:${port}/`) + mainWindow.webContents.openDevTools() + } + splashScreen.destroy() + mainWindow.maximize() + mainWindow.show() } - - splashScreen.destroy() - mainWindow.maximize() - mainWindow.show() })() ipcMain.handle("request", async (_, axios_request) => { @@ -580,6 +906,23 @@ ipcMain.handle("request", async (_, axios_request) => { // Python environment handling ipcMain.handle("getInstalledPythonPackages", async (event, pythonPath) => { + const activeTunnel = getActiveTunnel() + const tunnel = getTunnelState() + if (activeTunnel && tunnel) { + let pythonPackages = null + await axios.get(`http://${tunnel.host}:3000/get-installed-python-packages`, { params: { pythonPath: pythonPath } }) + .then((response) => { + if (response.data.success && response.data.packages) { + pythonPackages = response.data.packages + } else { + console.error("Failed to get remote Python packages: ", response.data.error) + } + }) + .catch((error) => { + console.error("Error getting remote Python packages: ", error) + }) + return pythonPackages + } return getInstalledPythonPackages(pythonPath) }) @@ -595,7 +938,25 @@ ipcMain.handle("installMongoDB", async (event) => { }) ipcMain.handle("getBundledPythonEnvironment", async (event) => { - return getBundledPythonEnvironment() + const activeTunnel = getActiveTunnel() + const tunnel = getTunnelState() + if (activeTunnel && tunnel) { + let pythonEnv = null + await axios.get(`http://${tunnel.host}:3000/get-bundled-python-environment`) + .then((response) => { + if (response.data.success && response.data.pythonEnv) { + pythonEnv = response.data.pythonEnv + } else { + console.error("Failed to get remote bundled Python environment: ", response.data.error) + } + }) + .catch((error) => { + console.error("Error getting remote bundled Python environment: ", error) + }) + return pythonEnv + } else { + return getBundledPythonEnvironment() + } }) ipcMain.handle("installBundledPythonExecutable", async (event) => { @@ -634,17 +995,22 @@ ipcMain.on("restartApp", (event, data, args) => { }) ipcMain.handle("checkMongoIsRunning", async (event) => { - // Check if something is running on the port MEDconfig.mongoPort - let port = MEDconfig.mongoPort + const activeTunnel = getActiveTunnel() + const tunnel = getTunnelState() let isRunning = false - if (process.platform === "win32") { - isRunning = exec(`netstat -ano | findstr :${port}`).toString().trim() !== "" - } else if (process.platform === "darwin") { - isRunning = exec(`lsof -i :${port}`).toString().trim() !== "" + if (activeTunnel && tunnel) { + isRunning = await checkRemotePortOpen(activeTunnel, tunnel.remoteDBPort) } else { - isRunning = exec(`netstat -tuln | grep ${port}`).toString().trim() !== "" + // Check if something is running on the port MEDconfig.mongoPort + let port = MEDconfig.mongoPort + if (process.platform === "win32") { + isRunning = exec(`netstat -ano | findstr :${port}`).toString().trim() !== "" + } else if (process.platform === "darwin") { + isRunning = exec(`lsof -i :${port}`).toString().trim() !== "" + } else { + isRunning = exec(`netstat -tuln | grep ${port}`).toString().trim() !== "" + } } - return isRunning }) @@ -774,19 +1140,22 @@ ipcMain.handle("terminal-get-cwd", async (event, terminalId) => { * @returns {BrowserWindow} The new window */ function openWindowFromURL(url) { - let window = new BrowserWindow({ - icon: path.join(__dirname, "../resources/MEDomicsLabWithShadowNoText100.png"), - width: 700, - height: 700, - transparent: true, - center: true - }) + const isHeadless = process.argv.some(arg => arg.includes('--no-gui')) + if (!isHeadless) { + let window = new BrowserWindow({ + icon: path.join(__dirname, "../resources/MEDomicsLabWithShadowNoText100.png"), + width: 700, + height: 700, + transparent: true, + center: true + }) - window.loadURL(url) - window.once("ready-to-show", () => { - window.show() - window.focus() - }) + window.loadURL(url) + window.once("ready-to-show", () => { + window.show() + window.focus() + }) + } } // Function to start MongoDB @@ -926,3 +1295,4 @@ export function getMongoDBPath() { return "mongod" } } + diff --git a/main/background.js.orig b/main/background.js.orig new file mode 100644 index 00000000..a9e8e7cd --- /dev/null +++ b/main/background.js.orig @@ -0,0 +1,1141 @@ +<<<<<<< HEAD +// Force Electron headless mode if --no-gui is present +if (process.argv.some(arg => arg.includes('--no-gui'))) { + process.env.ELECTRON_ENABLE_HEADLESS = '1' + // On some Linux systems, also clear DISPLAY + process.env.DISPLAY = '' +} +import { app, ipcMain, Menu, dialog, BrowserWindow, protocol, shell } from "electron" +======= +import { app, ipcMain, Menu, dialog, BrowserWindow, protocol, shell, nativeTheme } from "electron" +>>>>>>> origin/develop +import axios from "axios" +import os from "os" +import serve from "electron-serve" +import { createWindow, TerminalManager } from "./helpers" +import { installExtension, REACT_DEVELOPER_TOOLS } from "electron-extension-installer" +import MEDconfig from "../medomics.dev" +import { runServer, findAvailablePort } from "./utils/server" +import { + setWorkingDirectory, + getRecentWorkspacesOptions, + loadWorkspaces, + createMedomicsDirectory, + createRemoteMedomicsDirectory, + updateWorkspace, + createWorkingDirectory, + createRemoteWorkingDirectory +} from "./utils/workspace" +import { + getBundledPythonEnvironment, + getInstalledPythonPackages, + installPythonPackage, + installBundledPythonExecutable, + checkPythonRequirements, + installRequiredPythonPackages +} from "./utils/pythonEnv" +import { installMongoDB, checkRequirements } from "./utils/installation" +<<<<<<< HEAD +import { + getTunnelState, + getActiveTunnel, + detectRemoteOS, + getRemoteWorkspacePath, +} from './utils/remoteFunctions.js' +import express from "express" +import bodyParser from "body-parser" + +const cors = require("cors"); +const expressApp = express(); +expressApp.use(bodyParser.json()); +expressApp.use(cors()); + +expressApp.use(function(req, res, next) { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); + next(); +}); +const EXPRESS_PORT = 3000; +expressApp.listen(EXPRESS_PORT, () => { + console.log(`Express server listening on port ${EXPRESS_PORT}`); +}); + +======= +>>>>>>> origin/develop + +const fs = require("fs") +const terminalManager = new TerminalManager() +var path = require("path") +let mongoProcess = null +const dirTree = require("directory-tree") +const { exec, spawn, execSync } = require("child_process") +let serverProcess = null +const serverState = { serverIsRunning: false } +var serverPort = MEDconfig.defaultPort +var hasBeenSet = false +const isProd = process.env.NODE_ENV === "production" +let splashScreen // The splash screen is the window that is displayed while the application is loading +export var mainWindow // The main window is the window of the application +// Robust headless mode detection +const isHeadless = process.argv.some(arg => arg.includes('--no-gui')) + +//**** AUTO UPDATER ****// +const { autoUpdater } = require("electron-updater") +const log = require("electron-log") + +autoUpdater.logger = log +autoUpdater.logger.transports.file.level = "info" +autoUpdater.autoDownload = false +autoUpdater.autoInstallOnAppQuit = true + +//*********** LOG **************// This is used to send the console.log messages to the main window +//**** ELECTRON-LOG ****// +// Electron log path +// By default, it writes logs to the following locations: +// on Linux: ~/.config/{app name}/logs/main.log +// on macOS: ~/Library/Logs/{app name}/main.log +// on Windows: %USERPROFILE%\AppData\Roaming\{app name}\logs\main.log +const APP_NAME = isProd ? "medomicslab-application" : "medomicslab-application (development)" + +const originalConsoleLog = console.log +/** + * @description Sends the console.log messages to the main window + * @param {*} message The message to send + * @summary We redefine the console.log function to send the messages to the main window + */ +console.log = function () { + try { + originalConsoleLog(...arguments) + log.log(...arguments) + if (mainWindow !== undefined) { + // Safely serialize all arguments to a string + const msg = Array.from(arguments) + .map((arg) => { + if (typeof arg === "string") return arg + try { + return JSON.stringify(arg) + } catch { + return util.inspect(arg, { depth: 2 }) + } + }) + .join(" ") + mainWindow.webContents.send("log", msg) + } + } catch (error) { + console.error(error) + } +} + +//**** AUTO-UPDATER ****// + +function sendStatusToWindow(text) { + if (mainWindow && mainWindow.webContents) { + mainWindow.showMessage(text) + } +} + +autoUpdater.on("checking-for-update", () => { + console.log("DEBUG: checking for update") + sendStatusToWindow("Checking for update...") +}) + +autoUpdater.on("update-available", (info) => { + log.info("Update available:", info) + + // Show a dialog to ask the user if they want to download the update + const dialogOpts = { + type: "info", + buttons: ["Download", "Later"], + title: "Application Update", + message: "A new version is available", + detail: `MEDomicsLab ${info.version} is available. You have ${app.getVersion()}. Would you like to download it now?` + } + + dialog.showMessageBox(mainWindow, dialogOpts).then((returnValue) => { + if (returnValue.response === 0) { + // If the user clicked "Download" + sendStatusToWindow("Downloading update...") + autoUpdater.downloadUpdate() + } + }) +}) + +autoUpdater.on("update-not-available", (info) => { + info = JSON.stringify(info) + sendStatusToWindow(`Update not available. ${info}`) + sendStatusToWindow(`Current version: ${app.getVersion()}`) +}) + +autoUpdater.on("error", (err) => { + sendStatusToWindow("Error in auto-updater. " + err) +}) + +autoUpdater.on("download-progress", (progressObj) => { + let log_message = `Download speed: ${progressObj.bytesPerSecond} - ` + log_message += `Downloaded ${progressObj.percent.toFixed(2)}% ` + log_message += `(${progressObj.transferred}/${progressObj.total})` + log.info(log_message) + sendStatusToWindow(log_message) + mainWindow.webContents.send("update-download-progress", progressObj) +}) + +autoUpdater.on("update-downloaded", (info) => { + log.info("Update downloaded:", info) + let downloadPath, debFilePath + let dialogOpts = { + type: "info", + buttons: ["Restart", "Later"], + title: "Application Update", + message: "Update Downloaded", + detail: `MEDomicsLab ${info.version} has been downloaded. Restart the application to apply the updates.` + } + + // For Linux, provide additional instructions + if (process.platform === "linux") { + downloadPath = path.join(process.env.HOME, ".cache", "medomicslab-application-updater", "pending") + debFilePath = info.files[0].url.split("/").pop() + dialogOpts = { + type: "info", + buttons: ["Copy Command & Quit", "Copy Command", "Later"], + title: "Application Update", + message: "Update Downloaded", + detail: `MEDomicsLab ${info.version} has been downloaded. On Linux, you may need to run the installer with sudo:\n\nsudo dpkg -i ${path.join(downloadPath, debFilePath)} \n\nClick 'Copy Command & Restart' to copy this command to your clipboard and restart the application, or 'Copy Command' to just copy it.` + } + } + + dialog.showMessageBox(mainWindow, dialogOpts).then((returnValue) => { + if (process.platform === "linux") { + if (returnValue.response === 0 || returnValue.response === 1) { + // Construct the command to install the deb file + const command = `sudo dpkg -i "${path.join(downloadPath, debFilePath)}"` + + // Copy to clipboard + require("electron").clipboard.writeText(command) + + if (returnValue.response === 0) { + autoUpdater.quitAndInstall() + } + } + } else if (returnValue.response === 0) { + autoUpdater.quitAndInstall() + } + }) +}) + +if (isProd) { + serve({ directory: "app" }) +} else { + app.setPath("userData", `${app.getPath("userData")} (development)`) +} + + +// Main async startup +(async () => { + await app.whenReady() + + protocol.registerFileProtocol("local", (request, callback) => { + const url = request.url.replace(/^local:\/\//, "") + const decodedUrl = decodeURI(url) + try { + return callback(decodedUrl) + } catch (error) { + console.error("ERROR: registerLocalProtocol: Could not get file path:", error) + } + }) + + ipcMain.on("get-file-path", (event, configPath) => { + event.reply("get-file-path-reply", path.resolve(configPath)) + }) + + if (!isHeadless) { + splashScreen = new BrowserWindow({ + icon: path.join(__dirname, "../resources/MEDomicsLabWithShadowNoText100.png"), + width: 700, + height: 700, + transparent: true, + frame: false, + alwaysOnTop: true, + center: true, + show: true + }) + + mainWindow = createWindow("main", { + width: 1500, + height: 1000, + show: false + }) + + if (isProd) { + splashScreen.loadFile(path.join(__dirname, "splash.html")) + } else { + splashScreen.loadFile(path.join(__dirname, "../main/splash.html")) + } + splashScreen.once("ready-to-show", () => { + splashScreen.show() + splashScreen.focus() + splashScreen.setAlwaysOnTop(true) + }) + } else { + // Headless/server-only mode + mainWindow = undefined; + splashScreen = undefined; + console.log("Running in headless/server-only mode: no GUI will be created."); + } + + // Use mainWindow only if not headless + const openRecentWorkspacesSubmenuOptions = getRecentWorkspacesOptions(null, !isHeadless ? mainWindow : null, hasBeenSet, serverPort) + console.log("openRecentWorkspacesSubmenuOptions", JSON.stringify(openRecentWorkspacesSubmenuOptions, null, 2)) + const menuTemplate = [ + { + label: "File", + submenu: [{ label: "Open recent", submenu: getRecentWorkspacesOptions(null, mainWindow, hasBeenSet, serverPort) }, { type: "separator" }, { role: "quit" }] + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { type: "separator" }, + { + role: "preferences", + label: "Preferences", + click: () => { + console.log("đŸ‘‹") + }, + submenu: [ + { + label: "Toggle dark mode", + click: () => app.emit("toggleDarkMode") + } + ] + } + ] + }, + { + label: "Help", + submenu: [ + { + label: "Report an issue", + click() { + openWindowFromURL("https://forms.office.com/r/8tbTBHL4bv") + } + }, + { + label: "Contact us", + click() { + openWindowFromURL("https://forms.office.com/r/Zr8xJbQs64") + } + }, + { + label: "Join Us on Discord !", + click() { + openWindowFromURL("https://discord.gg/ZbaGj8E6mP") + } + }, + { + label: "Documentation", + click() { + openWindowFromURL("https://medomics-udes.gitbook.io/medomicslab-docs") + } + }, + { type: "separator" }, + { role: "reload" }, + { role: "forcereload" }, + { role: "toggledevtools" }, + { type: "separator" }, + { role: "resetzoom" }, + { role: "zoomin" }, + { role: "zoomout" }, + { type: "separator" } + ] + } + ] + + console.log("running mode:", isProd ? "production" : "development") + console.log("process.resourcesPath: ", process.resourcesPath) + console.log(MEDconfig.runServerAutomatically ? "Server will start automatically here (in background of the application)" : "Server must be started manually") + let bundledPythonPath = getBundledPythonEnvironment() + if (MEDconfig.runServerAutomatically && bundledPythonPath !== null) { + // Find the bundled python environment + if (bundledPythonPath !== null) { + runServer(isProd, serverPort, serverProcess, serverState, bundledPythonPath) + .then((process) => { + serverProcess = process + console.log("Server process started: ", serverProcess) + }) + .catch((err) => { + console.error("Failed to start server: ", err) + }) + } + } else { + //**** NO SERVER ****// + findAvailablePort(MEDconfig.defaultPort) + .then((port) => { + serverPort = port + }) + .catch((err) => { + console.error(err) + }) + } + const menu = Menu.buildFromTemplate(menuTemplate) + Menu.setApplicationMenu(menu) + + ipcMain.on("getRecentWorkspaces", (event, data) => { + // Receives a message from Next.js + console.log("GetRecentWorkspaces : ", data) + if (data === "requestRecentWorkspaces") { + // If the message is "requestRecentWorkspaces", the function getRecentWorkspaces is called + getRecentWorkspacesOptions(event, mainWindow, hasBeenSet, serverPort) + } + }) + + ipcMain.handle("updateWorkspace", async (event, data) => { + // Receives a message from Next.js to update workspace + console.error("updateWorkspace : ", data) + console.error("updateWorkspace event : ", event) + updateWorkspace(data) + }) + + ipcMain.handle("setWorkingDirectory", async (event, data) => { + return setWorkspaceDirectory(data) + }) + + // Remote express requests + expressApp.post("/set-working-directory", async (req, res, next) =>{ + let workspacePath = req.body.workspacePath + if (process.platform === "win32") { + if (workspacePath.startsWith("/")) { + workspacePath = workspacePath.slice(1) + } + } + console.log("Received request to set workspace directory from remote: ", workspacePath) + try { + const result = await setWorkspaceDirectory(workspacePath); + if (result && result.hasBeenSet) { + console.log('Workspace (from remote) set to: ' + workspacePath) + result.isRemote = true; + res.json({ success: true, workspace: result }); + } else { + console.log('Workspace specified by remote could not be set : ', err) + res.status(500).json({ success: false, error: err.message }); + } + } catch (err) { + console.log('Error setting workspace directory from remote : ', err) + res.status(500).json({ success: false, error: err.message }); + } + }) + + expressApp.get("/get-working-dir-tree", (req, res) => { + try { + let requestPath = req.query.requestedPath + if (process.platform === "win32") { + if (requestPath.startsWith("/")) { + requestPath = requestPath.slice(1) + } + } + console.log("Received request to get working directory tree for path: ", requestPath) + const workingDirectory = dirTree(requestPath) + if (!workingDirectory) { + console.log("No working directory found for the requested path:" + requestPath) + res.status(500).json({ success: false, error: "Working directory not found" }) + } + res.json({ success: true, workingDirectory: workingDirectory }) + } catch (err) { + console.error("Error getting working directory: ", err) + res.status(500).json({ success: false, error: err.message }) + } + }) + + expressApp.post("/insert-object-into-collection", async (req, res) => { + try { + console.log("Received request to insert object into collection: ", req.body) + mainWindow.webContents.send("insertObjectIntoCollection", req.body) + res.status(200).json({ success: true, message: "Object insertion request received" }) + } catch (err) { + console.error("Error inserting object into remote collection: ", err) + res.status(500).json({ success: false, error: err.message }) + } + }) + + const setWorkspaceDirectory = async (data) => { + app.setPath("sessionData", data) + console.log(`setWorkspaceDirectory : ${data}`) + createWorkingDirectory() // Create DATA & EXPERIMENTS directories + createMedomicsDirectory(data) + hasBeenSet = true + try { + // Stop MongoDB if it's running + await stopMongoDB(mongoProcess) + if (process.platform === "win32") { + // Kill the process on the port + // killProcessOnPort(serverPort) + } else if (process.platform === "darwin") { + await new Promise((resolve) => { + exec("pkill -f mongod", (error, stdout, stderr) => { + resolve() + }) + }) + } else { + try { + execSync("killall mongod") + } catch (error) { + console.warn("Failed to kill mongod: ", error) + } + } + // Start MongoDB with the new configuration + startMongoDB(data, mongoProcess) + return { + workingDirectory: dirTree(app.getPath("sessionData")), + hasBeenSet: hasBeenSet, + newPort: serverPort + } + } catch (error) { + console.error("Failed to change workspace: ", error) + } + } + + ipcMain.handle("setRemoteWorkingDirectory", async (event, data) => { + app.setPath("remoteSessionData", data) + createRemoteWorkingDirectory() // Create DATA & EXPERIMENTS directories + console.log(`setWorkspaceDirectory (remote) : ${data}`) + createRemoteMedomicsDirectory(data) + hasBeenSet = true + try { + // Stop MongoDB if it's running + await stopMongoDB(mongoProcess) + // Kill mongod on remote via SSH exec + if (activeTunnel && typeof activeTunnel.exec === 'function') { + // 1. Detect remote OS + const remoteOS = await detectRemoteOS() + // 2. Run the appropriate kill command + let killCmd + if (remoteOS === 'unix' | remoteOS === 'linux' || remoteOS === 'darwin') { + killCmd = 'pkill -f mongod || killall mongod || true' + } else { + // Windows: try taskkill + killCmd = 'taskkill /IM mongod.exe /F' + } + await new Promise((resolve) => { + activeTunnel.exec(killCmd, (err, stream) => { + if (err) return resolve() + stream.on('close', () => resolve()) + stream.on('data', () => {}) + stream.stderr.on('data', () => {}) + }) + }) + } else { + // Fallback: local logic if no tunnel + if (process.platform === "win32") { + // Kill the process on the port + // killProcessOnPort(serverPort) + } else if (process.platform === "darwin") { + await new Promise((resolve) => { + exec("pkill -f mongod", (error, stdout, stderr) => { + resolve() + }) + }) + } else { + try { + execSync("killall mongod") + } catch (error) { + console.warn("Failed to kill mongod: ", error) + } + } + } + // Start MongoDB with the new configuration + startMongoDB(data, mongoProcess) + return { + workingDirectory: dirTree(app.getPath("remoteSessionData")), + hasBeenSet: hasBeenSet, + newPort: serverPort, + isRemote: false, + success: true + } + } catch (error) { + console.error("Failed to change workspace: ", error) + } + }) + + + /** + * @description Returns the path of the specified directory of the app + * @param {String} path The path to get + * @returns {Promise} The path of the specified directory of the app + */ + ipcMain.handle("appGetPath", async (_event, path) => { + return app.getPath(path) + }) + + /** + * @description Returns the version of the app + * @returns {Promise} The version of the app + */ + ipcMain.handle("getAppVersion", async () => { + return app.getVersion() + }) + + /** + * @description Copies the source file to the destination file set by the user in the dialog + * @param {String} source The source file to copy + * @param {String} defaultPath The default path to set in the dialog - If null, the default path will be the user's home directory + * @returns {Promise} The destination file + */ + ipcMain.handle("appCopyFile", async (_event, source) => { + // Get the filename from the source path + let filename = path.basename(source) + let extension = path.extname(source).slice(1) + console.log("extension", extension) + const { filePath } = await dialog.showSaveDialog({ + title: "Save file", + defaultPath: filename.length > 0 ? filename : source, + filters: [{ name: extension, extensions: [extension] }] + }) + if (filePath) { + fs.copyFileSync(source, filePath) + return filePath + } + }) + + /** + * @description select path to folder + * @returns {String} path to the selected folder + */ + ipcMain.handle("select-folder-path", async (event) => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ["openDirectory"] + }) + return result + }) + + /** + * @description Returns the settings + * @returns {Object} The settings + * @summary Returns the settings from the settings file if it exists, otherwise returns an empty object + */ + ipcMain.handle("get-settings", async () => { + const userDataPath = app.getPath("userData") + const settingsFilePath = path.join(userDataPath, "settings.json") + if (fs.existsSync(settingsFilePath)) { + const settings = JSON.parse(fs.readFileSync(settingsFilePath, "utf8")) + return settings + } else { + return {} + } + }) + + /** + * @description Saves the settings + * @param {*} event The event + * @param {*} settings The settings to save + */ + ipcMain.on("save-settings", async (_event, settings) => { + const userDataPath = app.getPath("userData") + const settingsFilePath = path.join(userDataPath, "settings.json") + console.log("settings to save : ", settingsFilePath, settings) + fs.writeFileSync(settingsFilePath, JSON.stringify(settings)) + }) + + /** + * @description Returns the server status + * @returns {Boolean} True if the server is running, false otherwise + */ + ipcMain.handle("server-is-running", async () => { + return serverState.serverIsRunning + }) + + /** + * @description Kills the server + * @returns {Boolean} True if the server was killed successfully, false otherwise + * @summary Kills the server if it is running + */ + ipcMain.handle("kill-server", async () => { + if (serverProcess) { + let success = await serverProcess.kill() + serverState.serverIsRunning = false + return success + } else { + return null + } + }) + + /** + * @description Starts the server + * @param {*} event The event + * @param {*} pythonPath The path to the python executable (optional) - If null, the default python executable will be used (see environment variables MED_ENV) + * @returns {Boolean} True if the server is running, false otherwise + */ + ipcMain.handle("start-server", async (_event, pythonPath = null) => { + if (serverProcess) { + // kill the server if it is already running + serverProcess.kill() + } + console.log("Received Python path: ", pythonPath) + if (MEDconfig.runServerAutomatically) { + runServer(isProd, serverPort, serverProcess, serverState, pythonPath) + .then((process) => { + serverProcess = process + console.log(`success: ${serverState.serverIsRunning}`) + return serverState.serverIsRunning + }) + .catch((err) => { + console.error("Failed to start server: ", err) + serverState.serverIsRunning = false + return false + }) + } + return serverState.serverIsRunning + }) + + /** + * @description Opens the dialog to select the python executable path and returns the path to Next.js + * @param {*} event + * @param {*} data + * @returns {String} The path to the python executable + */ + ipcMain.handle("open-dialog-exe", async (event, data) => { + if (process.platform !== "win32") { + const { filePaths } = await dialog.showOpenDialog({ + title: "Select the path to the python executable", + properties: ["openFile"], + filters: [{ name: "Python Executable", extensions: ["*"] }] + }) + return filePaths[0] + } else { + const { filePaths } = await dialog.showOpenDialog({ + title: "Select the path to the python executable", + properties: ["openFile"], + filters: [{ name: "Executable", extensions: ["exe"] }] + }) + return filePaths[0] + } + }) + + ipcMain.on("messageFromNext", (event, data, args) => { + // Receives a message from Next.js + console.log("messageFromNext : ", data) + if (data === "requestDialogFolder") { + // If the message is "requestDialogFolder", the function setWorkingDirectory is called + setWorkingDirectory(event, mainWindow) + } else if (data === "getRecentWorkspaces") { + let recentWorkspaces = loadWorkspaces() + event.reply("recentWorkspaces", recentWorkspaces) + } else if (data === "updateWorkingDirectory") { + const activeTunnel = getActiveTunnel() + const tunnel = getTunnelState() + console.log("tunnelState: ", tunnel) + if (activeTunnel && tunnel) { + // If an SSH tunnel is active, we set the remote workspace path + const remoteWorkspacePath = getRemoteWorkspacePath() + axios.get(`http://${tunnel.host}:3000/get-working-dir-tree`, { params: { requestedPath: remoteWorkspacePath } }) + .then((response) => { + console.log("Response from remote get-working-dir-tree: ", response.data) + if (response.data.success && response.data.workingDirectory) { + event.reply("updateDirectory", { + workingDirectory: response.data.workingDirectory, + hasBeenSet: true, + newPort: tunnel.localBackendPort, + isRemote: true + }) // Sends the folder structure to Next.js + } else { + console.error("Failed to get remote working directory tree: ", response.data.error) + } + }) + .catch((error) => { + console.error("Error getting remote working directory tree: ", error) + }) + } else { + event.reply("updateDirectory", { + workingDirectory: dirTree(app.getPath("sessionData")), + hasBeenSet: hasBeenSet, + newPort: serverPort + }) // Sends the folder structure to Next.js + } + } else if (data === "getServerPort") { + event.reply("getServerPort", { + newPort: serverPort + }) // Sends the folder structure to Next.js + } else if (data === "requestAppExit") { + app.exit() + } + }) + + app.on("toggleDarkMode", () => { + console.log("toggleDarkMode") + mainWindow.webContents.send("toggleDarkMode") + }) + + if (!isHeadless) { + if (isProd) { + await mainWindow.loadURL("app://./index.html") + } else { + const port = process.argv[2] + await mainWindow.loadURL(`http://localhost:${port}/`) + mainWindow.webContents.openDevTools() + } + splashScreen.destroy() + mainWindow.maximize() + mainWindow.show() + } +})() + +ipcMain.handle("request", async (_, axios_request) => { + const result = await axios(axios_request) + return { data: result.data, status: result.status } +}) + +// Python environment handling +ipcMain.handle("getInstalledPythonPackages", async (event, pythonPath) => { + return getInstalledPythonPackages(pythonPath) +}) + +ipcMain.handle("installMongoDB", async (event) => { + // Check if MongoDB is installed + let mongoDBInstalled = getMongoDBPath() + if (mongoDBInstalled === null) { + // If MongoDB is not installed, install it + return installMongoDB() + } else { + return true + } +}) + +ipcMain.handle("getBundledPythonEnvironment", async (event) => { + return getBundledPythonEnvironment() +}) + +ipcMain.handle("installBundledPythonExecutable", async (event) => { + // Check if Python is installed + let pythonInstalled = getBundledPythonEnvironment() + if (pythonInstalled === null) { + // If Python is not installed, install it + return installBundledPythonExecutable(mainWindow) + } else { + // Check if the required packages are installed + let requirementsInstalled = checkPythonRequirements() + if (requirementsInstalled) { + return true + } else { + await installRequiredPythonPackages(mainWindow) + return true + } + } +}) + +ipcMain.handle("checkRequirements", async (event) => { + return checkRequirements() +}) + +ipcMain.handle("checkPythonRequirements", async (event) => { + return checkPythonRequirements() +}) + +ipcMain.handle("checkMongoDBisInstalled", async (event) => { + return getMongoDBPath() +}) + +ipcMain.on("restartApp", (event, data, args) => { + app.relaunch() + app.quit() +}) + +ipcMain.handle("checkMongoIsRunning", async (event) => { + // Check if something is running on the port MEDconfig.mongoPort + let port = MEDconfig.mongoPort + let isRunning = false + if (process.platform === "win32") { + isRunning = exec(`netstat -ano | findstr :${port}`).toString().trim() !== "" + } else if (process.platform === "darwin") { + isRunning = exec(`lsof -i :${port}`).toString().trim() !== "" + } else { + isRunning = exec(`netstat -tuln | grep ${port}`).toString().trim() !== "" + } + + return isRunning +}) + +app.on("window-all-closed", () => { + console.log("app quit") + // Clean up terminals + terminalManager.cleanup() + stopMongoDB(mongoProcess) + if (MEDconfig.runServerAutomatically) { + try { + // Check if the serverProcess has the kill method + serverProcess.kill() + console.log("serverProcess killed") + } catch (error) { + console.log("serverProcess already killed") + } + } + app.quit() +}) + +app.on("ready", async () => { + if (MEDconfig.useReactDevTools) { + await installExtension(REACT_DEVELOPER_TOOLS, { + loadExtensionOptions: { + allowFileAccess: true + } + }) + } + autoUpdater.checkForUpdatesAndNotify() +}) + +// Handle theme toggle +ipcMain.handle("toggle-theme", (event, theme) => { + if (theme === "dark") { + nativeTheme.themeSource = "dark" + } else if (theme === "light") { + nativeTheme.themeSource = "light" + } else { + nativeTheme.themeSource = "system" + } + return nativeTheme.shouldUseDarkColors +}) + +ipcMain.handle("get-theme", () => { + return nativeTheme.themeSource // Return the themeSource instead of shouldUseDarkColors +}) + +// Forward nativeTheme updated event to renderer +nativeTheme.on("updated", () => { + if (mainWindow && mainWindow.webContents) { + mainWindow.webContents.send("theme-updated") + } +}) + +// Terminal IPC Handlers +ipcMain.handle("terminal-create", async (event, options) => { + try { + // Ensure cwd is a string, not an object + let cwd = options.cwd + if (typeof cwd === "object" && cwd !== null) { + // If cwd is an object, try to extract a path property or use a default + cwd = cwd.path || cwd.workingDirectory || os.homedir() + } else if (!cwd || typeof cwd !== "string") { + // If cwd is null, undefined, or not a string, use home directory + cwd = os.homedir() + } + + const terminalInfo = terminalManager.createTerminal(options.terminalId, { + cwd: cwd, + cols: options.cols, + rows: options.rows, + useIPython: options.useIPython || false + }) + + // Set up event handlers for this terminal + terminalManager.setupTerminalEventHandlers(options.terminalId, mainWindow) + + return terminalInfo + } catch (error) { + console.error("Failed to create terminal:", error) + throw error + } +}) + +// Clone an existing terminal - used for split terminal functionality +ipcMain.handle("terminal-clone", async (event, sourceTerminalId, newTerminalId, options) => { + try { + const terminalInfo = terminalManager.cloneTerminal(sourceTerminalId, newTerminalId, { + cols: options.cols, + rows: options.rows + }) + + // Set up event handlers for the cloned terminal + terminalManager.setupTerminalEventHandlers(newTerminalId, mainWindow) + + return terminalInfo + } catch (error) { + console.error("Failed to clone terminal:", error) + throw error + } +}) + +ipcMain.on("terminal-input", (event, terminalId, data) => { + terminalManager.writeToTerminal(terminalId, data) +}) + +ipcMain.on("terminal-resize", (event, terminalId, cols, rows) => { + terminalManager.resizeTerminal(terminalId, cols, rows) +}) + +ipcMain.handle("terminal-kill", async (event, terminalId) => { + terminalManager.killTerminal(terminalId) +}) + +ipcMain.handle("terminal-list", async () => { + return terminalManager.getAllTerminals() +}) + +// Get current working directory of a terminal +ipcMain.handle("terminal-get-cwd", async (event, terminalId) => { + return terminalManager.getCurrentWorkingDirectory(terminalId) +}) + +/** + * @description Open a new window from an URL + * @param {*} url The URL of the page to open + * @returns {BrowserWindow} The new window + */ +function openWindowFromURL(url) { + const isHeadless = process.argv.some(arg => arg.includes('--no-gui')) + if (!isHeadless) { + let window = new BrowserWindow({ + icon: path.join(__dirname, "../resources/MEDomicsLabWithShadowNoText100.png"), + width: 700, + height: 700, + transparent: true, + center: true + }) + + window.loadURL(url) + window.once("ready-to-show", () => { + window.show() + window.focus() + }) + } +} + +// Function to start MongoDB +function startMongoDB(workspacePath) { + const mongoConfigPath = path.join(workspacePath, ".medomics", "mongod.conf") + if (fs.existsSync(mongoConfigPath)) { + console.log("Starting MongoDB with config: " + mongoConfigPath) + let mongod = getMongoDBPath() + if (process.platform !== "darwin") { + mongoProcess = spawn(mongod, ["--config", mongoConfigPath]) + } else { + if (fs.existsSync(getMongoDBPath())) { + mongoProcess = spawn(getMongoDBPath(), ["--config", mongoConfigPath]) + } else { + mongoProcess = spawn("/opt/homebrew/Cellar/mongodb-community/7.0.12/bin/mongod", ["--config", mongoConfigPath], { shell: true }) + } + } + mongoProcess.stdout.on("data", (data) => { + console.log(`MongoDB stdout: ${data}`) + }) + + mongoProcess.stderr.on("data", (data) => { + console.error(`MongoDB stderr: ${data}`) + }) + + mongoProcess.on("close", (code) => { + console.log(`MongoDB process exited with code ${code}`) + }) + + mongoProcess.on("error", (err) => { + console.error("Failed to start MongoDB: ", err) + // reject(err) + }) + } else { + const errorMsg = `MongoDB config file does not exist: ${mongoConfigPath}` + console.error(errorMsg) + } +} + +// Function to stop MongoDB +async function stopMongoDB(mongoProcess) { + return new Promise((resolve, reject) => { + if (mongoProcess) { + mongoProcess.on("exit", () => { + mongoProcess = null + resolve() + }) + try { + mongoProcess.kill() + resolve() + } catch (error) { + console.log("Error while stopping MongoDB ", error) + // reject() + } + } else { + resolve() + } + }) +} + +export function getMongoDBPath() { + if (process.platform === "win32") { + // Check if mongod is in the process.env.PATH + const paths = process.env.PATH.split(path.delimiter) + for (let i = 0; i < paths.length; i++) { + const binPath = path.join(paths[i], "mongod.exe") + if (fs.existsSync(binPath)) { + console.log("mongod found in PATH") + return binPath + } + } + // Check if mongod is in the default installation path on Windows - C:\Program Files\MongoDB\Server\\bin\mongod.exe + const programFilesPath = process.env["ProgramFiles"] + if (programFilesPath) { + const mongoPath = path.join(programFilesPath, "MongoDB", "Server") + // Check if the MongoDB directory exists + if (!fs.existsSync(mongoPath)) { + console.error("MongoDB directory not found") + return null + } + const dirs = fs.readdirSync(mongoPath) + for (let i = 0; i < dirs.length; i++) { + const binPath = path.join(mongoPath, dirs[i], "bin", "mongod.exe") + if (fs.existsSync(binPath)) { + return binPath + } + } + } + console.error("mongod not found") + return null + } else if (process.platform === "darwin") { + // Check if it is installed in the .medomics directory + const binPath = path.join(process.env.HOME, ".medomics", "mongodb", "bin", "mongod") + if (fs.existsSync(binPath)) { + console.log("mongod found in .medomics directory") + return binPath + } + if (process.env.NODE_ENV !== "production") { + // Check if mongod is in the process.env.PATH + const paths = process.env.PATH.split(path.delimiter) + for (let i = 0; i < paths.length; i++) { + const binPath = path.join(paths[i], "mongod") + if (fs.existsSync(binPath)) { + console.log("mongod found in PATH") + return binPath + } + } + // Check if mongod is in the default installation path on macOS - /usr/local/bin/mongod + const binPath = "/usr/local/bin/mongod" + if (fs.existsSync(binPath)) { + return binPath + } + } + console.error("mongod not found") + return null + } else if (process.platform === "linux") { + // Check if mongod is in the process.env.PATH + const paths = process.env.PATH.split(path.delimiter) + for (let i = 0; i < paths.length; i++) { + const binPath = path.join(paths[i], "mongod") + if (fs.existsSync(binPath)) { + return binPath + } + } + console.error("mongod not found in PATH" + paths) + // Check if mongod is in the default installation path on Linux - /usr/bin/mongod + if (fs.existsSync("/usr/bin/mongod")) { + return "/usr/bin/mongod" + } + console.error("mongod not found in /usr/bin/mongod") + + if (fs.existsSync("/home/" + process.env.USER + "/.medomics/mongodb/bin/mongod")) { + return "/home/" + process.env.USER + "/.medomics/mongodb/bin/mongod" + } + return null + } else { + return "mongod" + } +} + diff --git a/main/sshKeygen.js b/main/sshKeygen.js new file mode 100644 index 00000000..bec8773d --- /dev/null +++ b/main/sshKeygen.js @@ -0,0 +1,20 @@ +// SSH key generation utility for Electron main process +const forge = require('node-forge') + +/** + * Generate an RSA SSH key pair + * @param {string} comment - Comment to append to the public key + * @param {string} username - Username for the key (optional, for comment) + * @returns {Promise<{privateKey: string, publicKey: string}>} + */ +export async function generateSSHKeyPair(comment = '', username = '') { + return new Promise((resolve, reject) => { + forge.pki.rsa.generateKeyPair({ bits: 2048, workers: 2 }, (err, keypair) => { + if (err) return reject(err) + const privateKey = forge.pki.privateKeyToPem(keypair.privateKey) + // OpenSSH public key format + const sshPublic = forge.ssh.publicKeyToOpenSSH(keypair.publicKey, `${username || 'user'}@${comment}`) + resolve({ privateKey, publicKey: sshPublic }) + }); + }); +} diff --git a/main/utils/jupyterServer.js b/main/utils/jupyterServer.js new file mode 100644 index 00000000..904ac4dc --- /dev/null +++ b/main/utils/jupyterServer.js @@ -0,0 +1,218 @@ +import fs from "fs" +import { getBundledPythonEnvironment } from "./pythonEnv" +import { ipcMain } from "electron" +import { mainWindow } from "../background" + +const util = require("util") +const exec = util.promisify(require("child_process").exec) +const { spawn } = require('child_process') + +let jupyterStatus = { running: false, error: null } +let jupyterPort = 8900 + +async function getPythonPath() { + let pythonPath = getBundledPythonEnvironment() + // Check if pythonPath is set + if (pythonPath === "") { + console.error("Python path is not set. Jupyter server cannot be started.") + return null + } + return pythonPath +} + + +export async function startJupyterServer(workspacePath, port = 8900) { + if (!workspacePath) { + return { running: false, error: "No workspace path found. Jupyter server cannot be started." } + } + const pythonPath = await getPythonPath() + + if (!pythonPath) { + return { running: false, error: "Python path is not set. Jupyter server cannot be started." } + } + const configSet = await setJupyterConfig(pythonPath) + if (!configSet.success) { + return { running: false, error: configSet.error } + } + console.log("Checking if Jupyter server is already running before spawning: ", jupyterStatus.running) + if (!jupyterStatus.running) { + const jupyter = spawn(pythonPath, [ + '-m', 'jupyter', 'notebook', + `--NotebookApp.token=''`, + `--NotebookApp.password=''`, + '--no-browser', + `--port=${port}`, + `${workspacePath}/DATA` + ]) + jupyter.stderr.on('data', (data) => { + console.log(`[Jupyter STDOUT]: ${data}`) + if (data.toString().includes(port.toString())) { + console.log("Jupyter server is ready and running.") + mainWindow.webContents.send("jupyterReady") + } + }) + jupyter.on('close', (code) => { + console.log(`[Jupyter] exited with code ${code}`) + }) + jupyterPort = port + return { running: true, error: null } + } +} + +async function getJupyterPid (port) { + if (!port) { + throw new Error("Port is required to get Jupyter PID") + } + const { exec } = require('child_process') + const { promisify } = require('util') + const execAsync = promisify(exec) + + const platform = process.platform + const command = platform === 'win32' + ? `netstat -ano | findstr :${port}` + : `lsof -ti :${port} | head -n 1` + + try { + const { stdout, stderr } = await execAsync(command) + if (stderr) throw new Error(stderr) + + return platform === 'win32' + ? stdout.trim().split(/\s+/).pop() + : stdout.trim() + } catch (error) { + throw new Error(`PID lookup failed: ${error.message}`) + } + } + +async function setJupyterConfig(pythonPathArg) { + if (!pythonPathArg) { + return { success: false, error: "Python path is not set. Cannot configure Jupyter." } + } + // Check if jupyter is installed + try { + await exec(`${pythonPathArg} -m jupyter --version`).then((result) => { + const trimmedVersion = result.stdout.split("\n") + const includesJupyter = trimmedVersion.some((line) => line.startsWith("jupyter")) + if (!includesJupyter) { + throw new Error("Jupyter is not installed") + } + }) + } catch (error) { + return { success: false, error: "Jupyter is not installed. Please install Jupyter to use this feature."} + } + // Check if jupyter_notebook_config.py exists and update it + try { + const result = await exec(`${pythonPathArg} -m jupyter --paths`) + if (result.stderr) { + console.error("Error getting Jupyter paths:", result.stderr) + return { success: false, error: "Failed to get Jupyter paths." } + } + const configPath = result.stdout.split("\n").find(line => line.includes(".jupyter")) + + if (configPath) { + const configFilePath = configPath.trim() + "/jupyter_notebook_config.py" + + // Check if the file exists + if (!fs.existsSync(configFilePath)) { + try { + // Await the config generation + const output = await exec(`${pythonPathArg} -m jupyter notebook --generate-config`) + if (output.stderr) { + console.error("Error generating Jupyter config:", output.stderr) + return { success: false, error: "Error generating Jupyter config. Please check the console for more details." } + } + } catch (error) { + console.error("Error generating config:", error) + return {success: false, error: "Failed to generate Jupyter config" } + } + } + + // Get last line of configfilepath + const lastLine = fs.readFileSync(configFilePath, "utf8").split("\n").slice(-1)[0] + + if (!lastLine.includes("c.NotebookApp.tornado_settings") || + !lastLine.includes("c.ServerApp.allow_unauthenticated_access")) { + // Add config settings + fs.appendFileSync(configFilePath, `\nc.ServerApp.allow_unauthenticated_access = True`) + fs.appendFileSync(configFilePath, `\nc.NotebookApp.tornado_settings={'headers': {'Content-Security-Policy': "frame-ancestors 'self' http://localhost:8888;"}}`) + } + return { success: true, error: null } + } + } catch (error) { + console.error("Error in Jupyter config setup:", error) + return { running: false, error: "Failed to configure Jupyter." } + } +} + +export async function stopJupyterServer() { + const pythonPath = await getPythonPath() + + if (!pythonPath) { + console.error("Python path is not set. Cannot stop Jupyter server.") + return { running: false, error: "Python path is not set. Cannot stop Jupyter server." } + } + + try { + // Get the PID first + const pid = await getJupyterPid(jupyterPort) + + if (!pid) { + console.log("No running Jupyter server found") + return { running: false, error: "No running Jupyter server found" } + } + + // Platform-specific kill command + const killCommand = process.platform === 'win32' + ? `taskkill /PID ${pid} /F` + : `kill ${pid}` + + await exec(killCommand) + console.log(`Successfully stopped Jupyter server (PID: ${pid})`) + return { running: false, error: null } + } catch (error) { + console.error("Error stopping Jupyter server:", error) + // Fallback to original method if PID method fails + try { + await exec(`${pythonPath} -m jupyter notebook stop ${jupyterPort}`) + return { running: false, error: null } + } catch (fallbackError) { + console.error("Fallback stop method also failed:", fallbackError) + return { running: true, error: "Failed to stop server" } + } + } +} + +export async function checkJupyterIsRunning() { + console.log("Checking if Jupyter server is running on port", jupyterPort) + try { + const pythonPath = await getPythonPath() + console.log("Python path for checking Jupyter status:", pythonPath) + if (!pythonPath) { + console.log("Python path is not set. Cannot check Jupyter server status.") + return { running: false, error: "Python path is not set. Cannot check Jupyter server status." } + } + const result = await exec(`${pythonPath} -m jupyter notebook list`) + console.log("Jupyter notebook list result:", result) + if (result.stderr) { + console.log("Error checking Jupyter server status:", result.stderr) + return { running: false, error: "Jupyter server is not running. You can start it from the settings page." } + } + const isRunning = result.stdout.includes(jupyterPort.toString()) + console.log("Is Jupyter server running:", isRunning) + return { running: isRunning, error: isRunning ? null : "Jupyter server is not running. You can start it from the settings page." } + } catch (error) { + return { running: false, error: error } + } +} + +ipcMain.handle("startJupyterServer", async (event, workspacePath, port) => { + return startJupyterServer(workspacePath, port) +}) + +ipcMain.handle("stopJupyterServer", async () => { + return stopJupyterServer() +}) + +ipcMain.handle("checkJupyterIsRunning", async () => { + return checkJupyterIsRunning() +}) diff --git a/main/utils/remoteFunctions.js b/main/utils/remoteFunctions.js new file mode 100644 index 00000000..9496c9c9 --- /dev/null +++ b/main/utils/remoteFunctions.js @@ -0,0 +1,1152 @@ +import { Client } from "ssh2" +import { app, ipcMain } from "electron" +import { mainWindow } from "../background" +import { generateSSHKeyPair } from '../sshKeygen' +const net = require("net") +var path = require("path") +const fs = require("fs") + +// Global tunnel state for remote connection management +let activeTunnel = null +let activeTunnelServer = null + +let mongoDBLocalPort = null +let mongoDBRemotePort = null + +let jupyterLocalPort = null +let jupyterRemotePort = null + +let remoteWorkspacePath = null + +export function setActiveTunnel(tunnel) { + activeTunnel = tunnel +} +export function setActiveTunnelServer(server) { + activeTunnelServer = server +} +export function getActiveTunnel() { + return activeTunnel +} +export function getActiveTunnelServer() { + return activeTunnelServer +} +export function setRemoteWorkspacePath(path) { + remoteWorkspacePath = path +} +export function getRemoteWorkspacePath() { + return remoteWorkspacePath +} + +// Tunnel information and state management +let tunnelInfo = { + host: null, + tunnelActive: false, + localAddress: "localhost", + localBackendPort: null, + remoteBackendPort: null, + localDBPort: null, + remoteDBPort: null, + localJupyterPort: null, + remoteJupyterPort: null, + remotePort: null, + username: null, +} + +export function setTunnelState(info) { + // Exclude password + const { password, privateKey, ...safeInfo } = info + tunnelInfo = { ...tunnelInfo, ...safeInfo, tunnelActive: safeInfo.tunnelActive } +} + +export function clearTunnelState() { + tunnelInfo = { + host: null, + tunnelActive: false, + localAddress: "localhost", + localBackendPort: null, + remoteBackendPort: null, + localDBPort: null, + remoteDBPort: null, + localJupyterPort: null, + remoteJupyterPort: null, + remotePort: null, + username: null, + } +} + +export function getTunnelState() { + return tunnelInfo +} + +ipcMain.handle('getTunnelState', () => { + return getTunnelState() +}) + +ipcMain.handle('setTunnelState', (_event, info) => { + setTunnelState(info) + mainWindow.webContents.send('tunnelStateUpdate', info) +}) + +ipcMain.handle('clearTunnelState', () => { + clearTunnelState() + mainWindow.webContents.send('tunnelStateClear') +}) + + +/** + * Starts an SSH tunnel and creates the backend port forwarding server only. + * MongoDB tunnel can be created later by calling startMongoTunnel. + * @param {Object} params - SSH and port config. + * @param {string} params.host - Address of the remote host. + * @param {string} params.username - Username for SSH connection. + * @param {string} [params.privateKey] - Private key for SSH authentication. + * @param {string} [params.password] - Password for SSH authentication. + * @param {number|string} params.remotePort - Port of the SSH connection + * @param {number|string} params.localBackendPort - Local port forwarded to the remote backend. + * @param {number|string} params.remoteBackendPort - Port on the remote host for the backend server. + * @param {number|string} params.localDBPort - Local port for the MongoDB server. + * @param {number|string} params.remoteDBPort - Port on the remote host for the MongoDB server. + * @param {number|string} params.localJupyterPort - Local port for the Jupyter server. + * @param {number|string} params.remoteJupyterPort - Port on the remote host for the Jupyter server. + * @returns {Promise<{success: boolean}>} + */ +export async function startSSHTunnel({ host, username, privateKey, password, remotePort, localBackendPort, remoteBackendPort, localDBPort, remoteDBPort, localJupyterPort, remoteJupyterPort }) { + return new Promise((resolve, reject) => { + mongoDBLocalPort = localDBPort + mongoDBRemotePort = remoteDBPort + jupyterLocalPort = localJupyterPort + jupyterRemotePort = remoteJupyterPort + + if (activeTunnelServer) { + try { + activeTunnelServer.backendServer.close() + } catch {} + try { + activeTunnelServer.mongoServer && activeTunnelServer.mongoServer.close() + } catch {} + try { + activeTunnelServer.jupyterServer && activeTunnelServer.jupyterServer.close() + } catch {} + setActiveTunnelServer(null) + } + if (activeTunnel) { + try { + activeTunnel.end() + } catch {} + setActiveTunnel(null) + } + const connConfig = { + host, + port: parseInt(remotePort), + username + } + if (privateKey) connConfig.privateKey = privateKey + if (password) connConfig.password = password + const conn = new Client() + conn + .on("ready", () => { + console.log("SSH connection established to", host) + // Backend port forwarding only + const backendServer = net.createServer((socket) => { + conn.forwardOut(socket.localAddress || "127.0.0.1", socket.localPort || 0, "127.0.0.1", parseInt(remoteBackendPort), (err, stream) => { + if (err) { + console.error(err) + socket.destroy() + return + } + socket.pipe(stream).pipe(socket) + }) + }) + backendServer.listen(localBackendPort, "127.0.0.1") + backendServer.on("error", (e) => { + conn.end() + console.error("Connection to backend server error:", e) + reject(new Error("Backend local server error: " + e.message)) + }) + + setActiveTunnel(conn) + setActiveTunnelServer({ backendServer: backendServer }) + resolve({ success: true }) + }) + .on("error", (err) => { + reject(new Error("SSH connection error: " + err.message)) + }) + .connect(connConfig) + }) +} + +/** + * Checks if a port is open on the remote host via SSH. + * @param {Client} conn - The active SSH2 Client connection. + * @param {number|string} port - The port to check. + * @returns {Promise} + */ +export async function checkRemotePortOpen(conn, port, loadBlocking = false) { + if (loadBlocking) { + mainWindow.webContents.send("setSidebarLoading", { processing: true, message: "Checking if MongoDB is running on server..." }) + } + // Use detectRemoteOS to determine the remote OS and select the right command + const remoteOS = await detectRemoteOS() + let checkCmd + if (remoteOS === "win32") { + // Windows: use netstat and findstr + checkCmd = `netstat -an | findstr :${port}` + } else { + // Linux/macOS: use ss or netstat/grep + checkCmd = `bash -c "command -v ss >/dev/null 2>&1 && ss -ltn | grep :${port} || netstat -an | grep LISTEN | grep :${port}" || netstat -an | grep :${port}` + } + return new Promise((resolve, reject) => { + conn.exec(checkCmd, (err, stream) => { + if (err) { + console.log("SSH exec error:", err) + return reject(err) + } + let found = false + let stdout = "" + let stderr = "" + stream.on("data", (data) => { + stdout += data.toString() + if (data.toString().includes(port)) found = true + }) + stream.stderr.on("data", (data) => { + stderr += data.toString() + }) + stream.on("close", (code, signal) => { + resolve(found) + }) + }) + }) +} + +/** + * @description Starts the MongoDB port forwarding tunnel using an existing SSH connection. + * Checks if the remote port is open before creating the tunnel, with retries. + * @returns {Promise<{success: boolean}>} + */ +export async function startMongoTunnel() { + mainWindow.webContents.send("setSidebarLoading", { processing: true, message: "Starting MongoDB Tunnel..." }) + return new Promise(async (resolve, reject) => { + const conn = getActiveTunnel() + if (!conn) { + reject(new Error("No active SSH connection for MongoDB tunnel.")) + } + + // Retry logic: up to 5 times, 3s delay + let portOpen = false + let attempts = 0 + const maxAttempts = 5 + const delayMs = 3000 + while (attempts < maxAttempts && !portOpen) { + try { + console.log(`Checking if remote MongoDB port ${mongoDBRemotePort} is open...`) + portOpen = await checkRemotePortOpen(conn, mongoDBRemotePort) + } catch (e) { + // If SSH command fails, treat as not open + portOpen = false + } + if (!portOpen) { + attempts++ + if (attempts < maxAttempts) { + await new Promise((res) => setTimeout(res, delayMs)) + } + } + } + if (!portOpen) { + reject(new Error(`MongoDB server is not listening on remote port ${mongoDBRemotePort} after ${maxAttempts} attempts.`)) + } + + // If mongoServer already exists, close it first + if (activeTunnelServer && activeTunnelServer.mongoServer) { + try { + activeTunnelServer.mongoServer.close() + } catch {} + } + const mongoServer = net.createServer((socket) => { + conn.forwardOut(socket.localAddress || "127.0.0.1", socket.localPort || 0, "127.0.0.1", parseInt(mongoDBRemotePort), (err, stream) => { + if (err) { + console.error(err) + socket.destroy() + return + } + socket.pipe(stream).pipe(socket) + }) + }) + mongoServer.listen(mongoDBLocalPort, "127.0.0.1") + + mongoServer.on("error", (e) => { + conn.end() + console.error("Connection to backend Mongo error:", e) + reject(new Error("Mongo local server error: " + e.message)) + }) + + // Update activeTunnelServer to include mongoServer + setActiveTunnelServer({ + ...(activeTunnelServer || {}), + mongoServer: mongoServer + }) + resolve({ success: true }) + }) +} + +/** + * @description Confirms that the mongoDB tunnel is active and the server is listening. + * @returns {Promise<{success: boolean, error?: string}>} + */ +export async function confirmMongoTunnel(loadBlocking = false) { + if (loadBlocking) { + mainWindow.webContents.send("setSidebarLoading", { processing: true, message: "Confirming that the MongoDB tunnel is active..." }) + } + console.log("Confirming MongoDB tunnel is active...") + return new Promise((resolve, reject) => { + // Check the value of activeTunnelServer.mongoServer every 3000 ms, up to 10 times + let attempts = 0 + const maxAttempts = 10 + const interval = setInterval(() => { + if (activeTunnelServer && activeTunnelServer.mongoServer) { + clearInterval(interval) + console.log("MongoDB tunnel is active and listening.") + resolve({ success: true }) + } else { + attempts++ + if (attempts >= maxAttempts) { + clearInterval(interval) + reject({ success: false, error: "MongoDB tunnel is not listening after multiple attempts." }) + } + } + }, 3000) + }) +} + +/** + * @description Stops the SSH tunnel and closes all forwarded servers. + * @returns {Promise<{success: boolean, error?: string}>} + */ +export async function stopSSHTunnel() { + let success = false + let error = null + if (activeTunnelServer) { + try { + await new Promise((resolve, reject) => { + activeTunnelServer.backendServer.close((err) => { + if (err) reject(err) + else resolve() + }) + }) + await new Promise((resolve, reject) => { + activeTunnelServer.mongoServer.close((err) => { + if (err) reject(err) + else resolve() + }) + }) + setActiveTunnelServer(null) + success = true + } catch (e) { + error = e.message || String(e) + } + } + if (activeTunnel) { + try { + activeTunnel.end() + } catch {} + setActiveTunnel(null) + success = true + } + if (success) return { success: true } + return { success: false, error: error || "No active tunnel" } +} + +/** + * @description Starts the Jupyter port forwarding tunnel using an existing SSH connection. + * Checks if the remote port is open before creating the tunnel, with retries. + * @returns {Promise<{success: boolean}>} + */ +export async function startJupyterTunnel() { + return new Promise(async (resolve, reject) => { + const conn = getActiveTunnel() + if (!conn) { + reject(new Error("No active SSH connection for Jupyter tunnel.")) + } + + // If jupyterServer already exists, return + if (activeTunnelServer && activeTunnelServer.jupyterServer) { + resolve({ success: true }) + } + + // Retry logic: up to 5 times, 3s delay + let portOpen = false + let attempts = 0 + const maxAttempts = 5 + const delayMs = 3000 + while (attempts < maxAttempts && !portOpen) { + try { + console.log(`Checking if remote Jupyter port ${jupyterRemotePort} is open...`) + portOpen = await checkRemotePortOpen(conn, jupyterRemotePort) + } catch (e) { + // If SSH command fails, treat as not open + portOpen = false + } + if (!portOpen) { + attempts++ + if (attempts < maxAttempts) { + await new Promise((res) => setTimeout(res, delayMs)) + } + } + } + if (!portOpen) { + reject(new Error(`Jupyter server is not listening on remote port ${jupyterRemotePort} after ${maxAttempts} attempts.`)) + } + + const jupyterServer = net.createServer((socket) => { + conn.forwardOut(socket.localAddress || "127.0.0.1", socket.localPort || 0, "127.0.0.1", parseInt(jupyterRemotePort), (err, stream) => { + if (err) { + console.error(err) + socket.destroy() + return + } + socket.pipe(stream).pipe(socket) + }) + }) + jupyterServer.listen(jupyterLocalPort, "127.0.0.1") + + jupyterServer.on("error", (e) => { + conn.end() + console.error("Connection to backend Mongo error:", e) + reject(new Error("Mongo local server error: " + e.message)) + }) + + // Update activeTunnelServer to include jupyterServer + setActiveTunnelServer({ + ...(activeTunnelServer || {}), + jupyterServer: jupyterServer + }) + resolve({ success: true }) + }) +} + + +/** + * @description This function uses SFTP to check if a file exists at the given remote path. + * @param {string} filePath - The remote path of the file to check + * @returns {string>} - Status of the file existence check: "exists", "does not exist", "sftp error", or "tunnel inactive" + */ +export async function checkRemoteFileExists(filePath) { + // Ensure tunnel is active and SSH client is available + const activeTunnel = getActiveTunnel() + if (!activeTunnel) { + const errMsg = 'No active SSH tunnel for remote file check.' + console.error(errMsg) + return "tunnel inactive" + } + + const getSftp = () => new Promise((resolve, reject) => { + activeTunnel.sftp((err, sftp) => { + if (err) return reject(err) + resolve(sftp) + }) + }) + + const statFile = (sftp, filePath) => new Promise((resolve, reject) => { + sftp.stat(filePath, (err, stats) => { + if (err) return resolve(false) // File does not exist + const exists = stats && ((stats.isFile && stats.isFile()) || (stats.isDirectory && stats.isDirectory())) + resolve(exists) + }) + }) + + try { + const sftp = await getSftp() + const exists = await statFile(sftp, filePath) + sftp.end && sftp.end() + if (exists) { + return "exists" + } else { + return "does not exist" + } + } catch (error) { + console.error("SFTP error:", error) + return "sftp error" + } +} + +/** + * @description This function uses SFTP to call lstat on a remote file path. + * @param {string} filePath - The remote path of the file to check + * @returns {{ isDir: boolean, isFile: boolean, stats: Object } | string} - Returns an object with file stats or "sftp error" if an error occurs. + */ +export async function getRemoteLStat(filePath) { + // Ensure tunnel is active and SSH client is available + const activeTunnel = getActiveTunnel() + if (!activeTunnel) { + const errMsg = 'No active SSH tunnel for remote lstat.' + console.error(errMsg) + return null + } + const getSftp = () => new Promise((resolve, reject) => { + activeTunnel.sftp((err, sftp) => { + if (err) return reject(err) + resolve(sftp) + }) + }) + + const lstatFile = (sftp, filePath) => new Promise((resolve, reject) => { + sftp.stat(filePath, (err, stats) => { + if (err) return reject(err) // File does not exist + resolve(stats) + }) + }) + + try { + const sftp = await getSftp() + const fileStats = await lstatFile(sftp, filePath) + sftp.end && sftp.end() + return { isDir: fileStats.isDirectory(), isFile: fileStats.isFile(), stats: fileStats } + } catch (error) { + console.error("SFTP error:", error) + return "sftp error" + } +} + +/** + * @description This function uses SFTP to rename a remote file. + * @param {string} oldPath - The remote path of the file to rename + * @param {string} newPath - The new remote path of the file + * @returns {{ success: boolean, error: string }} - Returns an object indicating success or failure with an error message. + */ +ipcMain.handle('renameRemoteFile', async (_event, { oldPath, newPath }) => { + function sftpRename(sftp, oldPath, newPath) { + return new Promise((resolve, reject) => { + sftp.rename(oldPath, newPath, (err) => { + if (err) reject(err) + else resolve() + }) + }) + } + + const activeTunnel = getActiveTunnel() + if (!activeTunnel) return { success: false, error: 'No active SSH tunnel' } + return new Promise((resolve) => { + activeTunnel.sftp(async (err, sftp) => { + if (err) return resolve({ success: false, error: err.message }) + try { + await sftpRename(sftp, oldPath, newPath) + if (typeof sftp.end === 'function') sftp.end() + resolve({ success: true }) + } catch (e) { + if (typeof sftp.end === 'function') sftp.end() + resolve({ success: false, error: e.message }) + } + }) + }) +}) + +/** + * @description This function uses SFTP to delete a remote file. + * @param {string} path - The remote path of the file to delete + * @param {boolean} recursive - Whether do also delete all contents if the path is a directory + * @returns {{ success: boolean, error: string }} - Returns an object indicating success or failure with an error message. + */ +ipcMain.handle('deleteRemoteFile', async (_event, { path, recursive = true }) => { + const activeTunnel = getActiveTunnel() + if (!activeTunnel) return { success: false, error: 'No active SSH tunnel' } + + function getSftp(callback) { + if (!activeTunnel) return callback(new Error('No active SSH tunnel')) + if (activeTunnel.sftp) { + return activeTunnel.sftp(callback) + } else if (activeTunnel.sshClient && activeTunnel.sshClient.sftp) { + return activeTunnel.sshClient.sftp(callback) + } else { + return callback(new Error('No SFTP available')) + } + } + + // Helper: recursively delete files and folders + async function sftpDeleteRecursive(sftp, targetPath) { + // Stat the path to determine if file or directory + const stats = await new Promise((res, rej) => { + sftp.stat(targetPath, (err, stat) => { + if (err) return rej(err) + res(stat) + }) + }) + if (stats.isDirectory()) { + // List directory contents + const entries = await new Promise((res, rej) => { + sftp.readdir(targetPath, (err, list) => { + if (err) return rej(err) + res(list) + }) + }) + // Recursively delete each entry + for (const entry of entries) { + if (entry.filename === '.' || entry.filename === '..') continue + const entryPath = targetPath.replace(/[\\/]$/, '') + '/' + entry.filename + await sftpDeleteRecursive(sftp, entryPath) + } + // Remove the directory itself + await new Promise((res, rej) => { + sftp.rmdir(targetPath, (err) => { + if (err) return rej(err) + res() + }) + }) + } else { + // Remove file + await new Promise((res, rej) => { + sftp.unlink(targetPath, (err) => { + if (err) return rej(err) + res() + }) + }) + } + } + + return new Promise((resolve) => { + getSftp(async (err, sftp) => { + if (err) return resolve({ success: false, error: err.message }) + let sftpClosed = false + function closeSftp() { + if (sftp && !sftpClosed) { + if (typeof sftp.end === 'function') { + try { sftp.end() } catch (e) {} + } else if (typeof sftp.close === 'function') { + try { sftp.close() } catch (e) {} + } + sftpClosed = true + } + } + try { + if (recursive) { + await sftpDeleteRecursive(sftp, path) + } else { + // Non-recursive: try to delete as file, then as empty dir + try { + await new Promise((res, rej) => { + sftp.unlink(path, (err) => err ? rej(err) : res()) + }) + } catch (e) { + // If not a file, try as empty directory + await new Promise((res, rej) => { + sftp.rmdir(path, (err) => err ? rej(err) : res()) + }) + } + } + closeSftp() + resolve({ success: true }) + } catch (e) { + closeSftp() + resolve({ success: false, error: e.message }) + } + }) + }) +}) + +/** + * @description This function uses a terminal command to detect the operating system of the remote server. + * @returns {Promise} + */ +export async function detectRemoteOS() { + return new Promise((resolve, reject) => { + activeTunnel.exec("uname -s", (err, stream) => { + if (err) { + // Assume Windows if uname fails + resolve("win32") + return + } + let output = "" + stream.on("data", (outputData) => { + output += outputData.toString() + }) + stream.on("close", () => { + const out = output.trim().toLowerCase() + if (out.includes("linux")) { + resolve("linux") + } else if (out.includes("darwin")) { + resolve("darwin") + } else if (out.includes("bsd")) { + resolve("unix") + } else { + resolve("win32") + } + }) + stream.stderr.on("data", () => resolve("win32")) + }) + }) +} + +/** + * Cross-platform equivalent to path.dirname(): works for both '/' and '\\' separators. + * @param {string} filePath - The path to extract the directory from. + * @returns {string} Directory path + */ +export function remoteDirname(filePath) { + if (!filePath) return '' + // Always use forward slash for remote paths + const normalized = filePath.replace(/\\/g, '/') + const idx = normalized.lastIndexOf('/') + if (idx === -1) return '' + if (idx === 0) return '/' + return normalized.slice(0, idx) +} + +/** + * Helper function to create a directory recursively using SFTP. + * @param {Object} sftp - The SFTP client instance. + * @param {string} fullPath - The path of the lowest-level directory to create, including all parent directories. + */ +async function sftpMkdirRecursive(sftp, fullPath) { + // Always use forward slash for remote paths + const normalized = fullPath.replace(/\\/g, '/') + const sep = '/' + const parts = normalized.split(sep).filter(Boolean) + let current = normalized.startsWith(sep) ? sep : '' + for (const part of parts) { + current = current === sep ? current + part : current + sep + part + try { + // Try to stat the directory + await new Promise((res, rej) => { + sftp.stat(current, (err, stats) => { + if (!err && stats && stats.isDirectory()) res() + else rej() + }) + }) + } catch { + // Directory does not exist, try to create + await new Promise((res, rej) => { + sftp.mkdir(current, (err) => { + if (!err) res() + else rej(err) + }) + }) + } + } +} + +/** + * @description This request handler creates a new remote folder in the specified parent path. + * @param {string} path - The parent path where the new folder will be created + * @param {string} folderName - The name of the new folder to be created + * @returns {Promise<{success: boolean, error?: string}>} + */ +ipcMain.handle('createRemoteFolder', async (_event, { path: parentPath, folderName, recursive = false }) => { + const activeTunnel = getActiveTunnel() + // Helper to get SFTP client + function getSftp(cb) { + if (!activeTunnel) return cb(new Error('No active SSH tunnel')) + if (activeTunnel.sftp) { + return activeTunnel.sftp(cb) + } else if (activeTunnel.sshClient && activeTunnel.sshClient.sftp) { + return activeTunnel.sshClient.sftp(cb) + } else { + return cb(new Error('No SFTP available')) + } + } + // Normalize path for SFTP: always use absolute, default to home dir as '.' + function normalizePath(p) { + if (!p || p === '') return '.' + if (p === '~') return '.' + if (p.startsWith('~/')) return p.replace(/^~\//, '') + // Always use forward slash for remote paths + return p.replace(/\\/g, '/') + } + return new Promise((resolve) => { + getSftp(async (err, sftp) => { + if (err) return resolve({ success: false, error: err.message }) + let sftpClosed = false + function closeSftp() { + if (sftp && !sftpClosed) { + if (typeof sftp.end === 'function') { + try { sftp.end() } catch (e) {} + } else if (typeof sftp.close === 'function') { + try { sftp.close() } catch (e) {} + } + sftpClosed = true + } + } + try { + console.log('Creating folder', folderName, 'in', parentPath) + const parent = normalizePath(parentPath) + // Step 1: resolve canonical parent path + let canonicalParent = await new Promise((res, rej) => { + sftp.realpath(parent, (e, abs) => e ? res(parent) : res(abs)) + }) + // Step 2: build new folder path + let newFolderPath = folderName ? canonicalParent.replace(/\/$/, '') + '/' + folderName : canonicalParent + // Step 3: create directory + if (recursive) { + await sftpMkdirRecursive(sftp, newFolderPath) + } else { + await new Promise((res, rej) => { + sftp.mkdir(newFolderPath, (e) => e ? rej(e) : res()) + }) + } + closeSftp() + console.log('Folder created successfully') + resolve({ success: true }) + } catch (e) { + closeSftp() + console.error('Error creating remote folder:', e) + resolve({ success: false, error: e.message }) + } + }) + }) +}) + + +/** + * @description This request handler manages the remote navigation of folders on the server. + * @param {string} action - 'list' to display files and folders, 'up' to go back a directory or 'into' to enter it + * @param {string} path - The remote path to navigate + * @param {string} dirName - The name of the directory to enter (only used for 'into' action) + * @returns {Promise<{success: boolean, error?: string}>} + */ +ipcMain.handle('navigateRemoteDirectory', async (_event, { action, path: currentPath, dirName }) => { + const activeTunnel = getActiveTunnel() + // Helper to get SFTP client + function getSftp(cb) { + if (!activeTunnel) return cb(new Error('No active SSH tunnel')) + if (activeTunnel.sftp) { + // ssh2 v1.15+ attaches sftp method directly + return activeTunnel.sftp(cb) + } else if (activeTunnel.sshClient && activeTunnel.sshClient.sftp) { + return activeTunnel.sshClient.sftp(cb) + } else { + return cb(new Error('No SFTP available')) + } + } + + // Promisified SFTP realpath + function sftpRealpath(sftp, p) { + return new Promise((resolve, reject) => { + sftp.realpath(p, (err, absPath) => { + if (err) return reject(err) + resolve(absPath) + }) + }) + } + + // Promisified SFTP readdir + function sftpReaddir(sftp, p) { + return new Promise((resolve, reject) => { + sftp.readdir(p, (err, list) => { + if (err) return reject(err) + resolve(list) + }) + }) + } + + // Normalize path for SFTP: always use absolute, default to home dir as '.' + function normalizePath(p) { + if (!p || p === '') return '.' // SFTP: '.' means home dir + if (p === '~') return '.' + if (p.startsWith('~/')) return p.replace(/^~\//, '') + // Always use forward slash for remote paths + return p.replace(/\\/g, '/') + } + + return new Promise((resolve) => { + getSftp(async (err, sftp) => { + if (err) return resolve({ path: currentPath, contents: [], error: err.message }) + let targetPath = normalizePath(currentPath) + let sftpClosed = false + // Helper to close SFTP session safely + function closeSftp() { + if (sftp && !sftpClosed) { + if (typeof sftp.end === 'function') { + try { sftp.end() } catch (e) {} + } else if (typeof sftp.close === 'function') { + try { sftp.close() } catch (e) {} + } + sftpClosed = true + } + } + try { + // Step 1: resolve canonical path (absolute) + let canonicalPath = await sftpRealpath(sftp, targetPath).catch(() => targetPath) + // Step 2: handle navigation action + if (action === 'up') { + // Go up one directory + if (canonicalPath === '/' || canonicalPath === '' || canonicalPath === '.') { + // Already at root/home + // List current + } else { + let parts = canonicalPath.split('/').filter(Boolean) + if (parts.length > 1) { + parts.pop() + canonicalPath = '/' + parts.join('/') + } else { + canonicalPath = '/' + } + } + } else if (action === 'into' && dirName) { + // Always join using absolute path + if (canonicalPath === '/' || canonicalPath === '') { + canonicalPath = '/' + dirName + } else if (canonicalPath === '.') { + // Home dir: get its absolute path + canonicalPath = await sftpRealpath(sftp, '.').catch(() => '/') + canonicalPath = canonicalPath.replace(/\/$/, '') + '/' + dirName + } else { + canonicalPath = canonicalPath.replace(/\/$/, '') + '/' + dirName + } + // Re-resolve in case of symlinks + canonicalPath = await sftpRealpath(sftp, canonicalPath).catch(() => canonicalPath) + } else if (action === 'list') { + // Just list current + } + // Step 3: list directory + let entries = await sftpReaddir(sftp, canonicalPath).catch(() => []) + let contents = Array.isArray(entries) + ? entries.filter(e => e.filename !== '.' && e.filename !== '..').map(e => ({ + name: e.filename, + type: e.attrs.isDirectory() ? 'dir' : 'file' + })) + : [] + closeSftp() + resolve({ path: canonicalPath, contents }) + } catch (e) { + closeSftp() + resolve({ path: currentPath, contents: [], error: e.message }) + } + }) + }) +}) + +ipcMain.handle('startSSHTunnel', async (_event, params) => { + return startSSHTunnel(params) +}) + +ipcMain.handle('startMongoTunnel', async () => { + return startMongoTunnel() +}) + +ipcMain.handle('confirmMongoTunnel', async (_event, loadBlocking ) => { + return confirmMongoTunnel(loadBlocking) +}) + +ipcMain.handle('stopSSHTunnel', async () => { + return stopSSHTunnel() +}) + +ipcMain.handle('getRemoteLStat', async (_event, path) => { + return getRemoteLStat(path) +}) + +ipcMain.handle('checkRemoteFileExists', async (_event, path) => { + return checkRemoteFileExists(path) +}) + +ipcMain.handle('setRemoteWorkspacePath', async (_event, path) => { + return setRemoteWorkspacePath(path) +}) + +ipcMain.handle('startJupyterTunnel', async () => { + return startJupyterTunnel() +}) + +/** + * @description This request handler lists the contents of a remote directory on the server. + * @param {string} path - The remote path of the folder to list + * @returns {Promise<{success: boolean, error?: string}>} + */ +ipcMain.handle('listRemoteDirectory', async (_event, { path: remotePath }) => { + return new Promise((resolve, reject) => { + const activeTunnel = getActiveTunnel() + if (!activeTunnel) { + return resolve({ path: remotePath, contents: [], error: 'No active SSH tunnel' }) + } + try { + activeTunnel.sftp((err, sftp) => { + if (err || !sftp) return resolve({ path: remotePath, contents: [], error: err ? err.message : 'No SFTP' }) + // Normalize path for SFTP: always use absolute, default to home dir as '.' + function normalizePath(p) { + if (!p || p === '') return '.' // SFTP: '.' means home dir + if (p === '~') return '.' + if (p.startsWith('~/')) return p.replace(/^~\//, '') + // Always use forward slash for remote paths + return p.replace(/\\/g, '/') + } + const targetPath = normalizePath(remotePath) + // First, resolve canonical/absolute path + sftp.realpath(targetPath, (err2, absPath) => { + const canonicalPath = (!err2 && absPath) ? absPath : targetPath + sftp.readdir(canonicalPath, (err3, list) => { + // Always close SFTP session after use + if (sftp && typeof sftp.end === 'function') { + try { sftp.end() } catch (e) {} + } else if (sftp && typeof sftp.close === 'function') { + try { sftp.close() } catch (e) {} + } + if (err3) return resolve({ path: canonicalPath, contents: [], error: err3.message }) + const contents = Array.isArray(list) + ? list.filter(e => e.filename !== '.' && e.filename !== '..').map(e => ({ + name: e.filename, + type: e.attrs.isDirectory() ? 'dir' : 'file' + })) + : [] + resolve({ path: canonicalPath, contents }) + }) + }) + }) + } catch (e) { + resolve({ path: remotePath, contents: [], error: e.message }) + } + }) +}) + +// SSH key management +ipcMain.handle('generateSSHKey', async (_event, { comment, username }) => { + try { + const userDataPath = app.getPath('userData') + const privKeyPath = path.join(userDataPath, `${username || 'user'}_id_rsa`) + const pubKeyPath = path.join(userDataPath, `${username || 'user'}_id_rsa.pub`) + let privateKey, publicKey + if (fs.existsSync(privKeyPath) && fs.existsSync(pubKeyPath)) { + privateKey = fs.readFileSync(privKeyPath, 'utf8') + publicKey = fs.readFileSync(pubKeyPath, 'utf8') + } else { + const result = await generateSSHKeyPair(comment, username) + privateKey = result.privateKey + publicKey = result.publicKey + fs.writeFileSync(privKeyPath, privateKey, { mode: 0o600 }) + fs.writeFileSync(pubKeyPath, publicKey, { mode: 0o644 }) + } + return { privateKey, publicKey } + } catch (err) { + return { error: err.message } + } +}) + +ipcMain.handle('getSSHKey', async (_event, { username }) => { + try { + const userDataPath = app.getPath('userData') + const privKeyPath = path.join(userDataPath, `${username || 'user'}_id_rsa`) + const pubKeyPath = path.join(userDataPath, `${username || 'user'}_id_rsa.pub`) + let privateKey, publicKey + if (fs.existsSync(privKeyPath) && fs.existsSync(pubKeyPath)) { + privateKey = fs.readFileSync(privKeyPath, 'utf8') + publicKey = fs.readFileSync(pubKeyPath, 'utf8') + return { privateKey, publicKey } + } else { + return { privateKey: '', publicKey: '' } + } + } catch (err) { + return { error: err.message } + } +}) + + + +// ----- Unused ----- +// export function getRemoteMongoDBPath() { +// const remotePlatform = detectRemoteOS() + +// if (remotePlatform === "win32") { +// // Check if mongod is in the process.env.PATH +// const paths = process.env.PATH.split(path.delimiter) +// for (let i = 0; i < paths.length; i++) { +// const binPath = path.join(paths[i], "mongod.exe") +// if (fs.existsSync(binPath)) { +// console.log("mongod found in PATH") +// return binPath +// } +// } +// // Check if mongod is in the default installation path on Windows - C:\Program Files\MongoDB\Server\\bin\mongod.exe +// const programFilesPath = process.env["ProgramFiles"] +// if (programFilesPath) { +// const mongoPath = path.join(programFilesPath, "MongoDB", "Server") +// // Check if the MongoDB directory exists +// if (!fs.existsSync(mongoPath)) { +// console.error("MongoDB directory not found") +// return null +// } +// const dirs = fs.readdirSync(mongoPath) +// for (let i = 0; i < dirs.length; i++) { +// const binPath = path.join(mongoPath, dirs[i], "bin", "mongod.exe") +// if (fs.existsSync(binPath)) { +// return binPath +// } +// } +// } +// console.error("mongod not found") +// return null +// } else if (process.platform === "darwin") { +// // Check if it is installed in the .medomics directory +// const binPath = path.join(process.env.HOME, ".medomics", "mongodb", "bin", "mongod") +// if (fs.existsSync(binPath)) { +// console.log("mongod found in .medomics directory") +// return binPath +// } +// if (process.env.NODE_ENV !== "production") { +// // Check if mongod is in the process.env.PATH +// const paths = process.env.PATH.split(path.delimiter) +// for (let i = 0; i < paths.length; i++) { +// const binPath = path.join(paths[i], "mongod") +// if (fs.existsSync(binPath)) { +// console.log("mongod found in PATH") +// return binPath +// } +// } +// // Check if mongod is in the default installation path on macOS - /usr/local/bin/mongod +// const binPath = "/usr/local/bin/mongod" +// if (fs.existsSync(binPath)) { +// return binPath +// } +// } +// console.error("mongod not found") +// return null +// } else if (process.platform === "linux") { +// // Check if mongod is in the process.env.PATH +// const paths = process.env.PATH.split(path.delimiter) +// for (let i = 0; i < paths.length; i++) { +// const binPath = path.join(paths[i], "mongod") +// if (fs.existsSync(binPath)) { +// return binPath +// } +// } +// console.error("mongod not found in PATH" + paths) +// // Check if mongod is in the default installation path on Linux - /usr/bin/mongod +// if (fs.existsSync("/usr/bin/mongod")) { +// return "/usr/bin/mongod" +// } +// console.error("mongod not found in /usr/bin/mongod") + +// if (fs.existsSync("/home/" + process.env.USER + "/.medomics/mongodb/bin/mongod")) { +// return "/home/" + process.env.USER + "/.medomics/mongodb/bin/mongod" +// } +// return null +// } else { +// return "mongod" +// } +// } + +// export function checkRemoteFolderExists(folderPath) { +// // Ensure tunnel is active and SSH client is available +// const tunnelObject = getActiveTunnel() +// if (!tunnelObject) { +// const errMsg = "No active SSH tunnel for remote folder creation." +// console.error(errMsg) +// return Promise.resolve("tunnel inactive") +// } + +// return new Promise((resolve, reject) => { +// tunnelObject.sftp((err, sftp) => { +// if (err) { +// console.error("SFTP error:", err) +// resolve("sftp error") +// return +// } + +// // Check if folder exists +// sftp.stat(folderPath, (statErr, stats) => { +// if (!statErr && stats && stats.isDirectory()) { +// // Folder exists +// sftp.end && sftp.end() +// resolve("exists") +// } else { +// resolve("does not exist") +// } +// }) +// }) +// }) +// } \ No newline at end of file diff --git a/main/utils/workspace.js b/main/utils/workspace.js index 247a39ca..359c4684 100644 --- a/main/utils/workspace.js +++ b/main/utils/workspace.js @@ -1,5 +1,6 @@ import { app, dialog, ipcRenderer } from "electron" import MEDconfig from "../../medomics.dev" +import { getTunnelState } from "./remoteFunctions" const fs = require("fs") var path = require("path") @@ -172,6 +173,14 @@ export function createWorkingDirectory() { createFolder("EXPERIMENTS") } +// Function to create the working directory on the server +export function createRemoteWorkingDirectory() { + // See the workspace menuTemplate in the repository + const folderPath = app.getPath("remoteSessionData") + createRemoteFolder(folderPath + "/DATA") + createRemoteFolder(folderPath + "/EXPERIMENTS") +} + // Function to create a folder from a given path function createFolder(folderString) { // Creates a folder in the working directory @@ -188,6 +197,46 @@ function createFolder(folderString) { } } +// Function to create a folder on the server from a given path using SFTP +function createRemoteFolder(folderPath, callback) { + // Use SFTP via active tunnel to create a folder in the remote working directory + const tunnel = getTunnelState() + if (!tunnel || !tunnel.tunnelActive || !tunnel.tunnelObject || !tunnel.tunnelObject.sshClient) { + const errMsg = 'No active SSH tunnel for remote folder creation.' + console.error(errMsg) + if (callback) callback(new Error(errMsg)) + return + } + + tunnel.tunnelObject.sshClient.sftp((err, sftp) => { + if (err) { + console.error('SFTP error:', err) + if (callback) callback(err) + return + } + // Check if folder exists + sftp.stat(folderPath, (statErr, stats) => { + if (!statErr && stats && stats.isDirectory && stats.isDirectory()) { + // Folder exists + if (callback) callback(null, 'exists') + sftp.end && sftp.end() + return + } + // Try to create folder + sftp.mkdir(folderPath, { mode: 0o755 }, (mkErr) => { + if (mkErr) { + console.error('SFTP mkdir error:', mkErr) + if (callback) callback(mkErr) + } else { + console.log('Remote folder created successfully!') + if (callback) callback(null, 'created') + } + sftp.end && sftp.end() + }) + }) + }) +} + // Function to create the .medomics directory and necessary files export const createMedomicsDirectory = (directoryPath) => { const medomicsDir = path.join(directoryPath, ".medomics") @@ -220,3 +269,72 @@ export const createMedomicsDirectory = (directoryPath) => { fs.writeFileSync(mongoConfigPath, mongoConfig) } } + + +// Function to create the .medomics directory and necessary files +export const createRemoteMedomicsDirectory = (directoryPath) => { + const medomicsDir = path.join(directoryPath, ".medomics") + const mongoDataDir = path.join(medomicsDir, "MongoDBdata") + const mongoConfigPath = path.join(medomicsDir, "mongod.conf") + + // Create the .medomics directory on the remote server + createRemoteFolder(medomicsDir, (err) => { + if (err) { + console.error("Error creating remote .medomics directory:", err) + return + } + console.log(".medomics directory created successfully on remote server.") + }) + + // Create the mongoDataDir directory on the remote server + createRemoteFolder(mongoDataDir, (err) => { + if (err) { + console.error("Error creating remote .medomics directory:", err) + return + } + console.log(".medomics directory created successfully on remote server.") + }) + + // SFTP: Check if mongod.conf exists and write if not + const tunnel = getTunnelState() + if (!tunnel || !tunnel.tunnelActive || !tunnel.tunnelObject || !tunnel.tunnelObject.sshClient) { + console.error('No active SSH tunnel for remote file creation.') + return + } + tunnel.tunnelObject.sshClient.sftp((err, sftp) => { + if (err) { + console.error('SFTP error:', err) + return + } + sftp.stat(mongoConfigPath, (statErr, stats) => { + if (!statErr && stats && stats.isFile && stats.isFile()) { + // File exists, do nothing + sftp.end && sftp.end() + return + } + // File does not exist, write it + const mongoConfig = ` + systemLog: + destination: file + path: ${path.join(medomicsDir, "mongod.log")} + logAppend: true + storage: + dbPath: ${mongoDataDir} + net: + bindIp: localhost + port: ${MEDconfig.mongoPort} + ` + const writeStream = sftp.createWriteStream(mongoConfigPath, { encoding: 'utf8', mode: 0o644 }) + writeStream.on('error', (e) => { + console.error('SFTP write error:', e) + sftp.end && sftp.end() + }) + writeStream.on('finish', () => { + console.log('mongod.conf created successfully on remote server.') + sftp.end && sftp.end() + }) + writeStream.write(mongoConfig) + writeStream.end() + }) + }) +} \ No newline at end of file diff --git a/package.json b/package.json index 4f43b2c2..6a12765b 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "repository": "https://github.com/MEDomics-UdeS/MEDomicsLab", "scripts": { "dev": "nextron", + "dev:headless": "nextron --electron-options=\"--no-gui\"", "dev:linux": "bash ./utilScripts/go_build.sh && nextron ", "build": "nextron build", "build:linux": "bash ./utilScripts/build_preparation_linux.sh && nextron build --linux", @@ -35,6 +36,7 @@ "ace-builds": "^1.24.1", "axios": "^1.3.3", "bootstrap-icons": "^1.11.1", + "cors": "^2.8.5", "csv": "^6.3.3", "csv-parser": "^3.0.0", "d3": "^7.8.5", @@ -51,6 +53,7 @@ "eslint-config-next": "^13.5.3", "eslint-config-prettier": "^8.10.0", "eslint-plugin-prettier": "^5.2.3", + "express": "^5.1.0", "file-saver": "^2.0.5", "flexlayout-react": "^0.7.7", "html-react-parser": "^3.0.12", @@ -63,6 +66,8 @@ "lodash": "^4.17.21", "mongodb": "^6.6.2", "mongodb-client-encryption": "^6.0.1", + "node-forge": "^1.3.1", + "node-loader": "^2.1.0", "node-pty": "^1.1.0-beta34", "node-sys": "^1.2.4", "papaparse": "^5.4.1", @@ -95,6 +100,7 @@ "react-tooltip": "^5.10.0", "react-zoom-pan-pinch": "^3.1.0", "reactflow": "^11.5.6", + "ssh2": "^1.16.0", "three": "^0.156.1", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.0/xlsx-0.20.0.tgz", "zip-local": "^0.3.5" diff --git a/pythonCode/med_libs/server_utils.py b/pythonCode/med_libs/server_utils.py index 07aa879d..a18d590b 100644 --- a/pythonCode/med_libs/server_utils.py +++ b/pythonCode/med_libs/server_utils.py @@ -1,3 +1,4 @@ +import json import os import sys import traceback diff --git a/pythonCode/modules/connection/__init__.py b/pythonCode/modules/connection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pythonCode/modules/connection/connection_test_request.py b/pythonCode/modules/connection/connection_test_request.py new file mode 100644 index 00000000..3dd15406 --- /dev/null +++ b/pythonCode/modules/connection/connection_test_request.py @@ -0,0 +1,56 @@ +import json +import sys +import os +from pathlib import Path + +sys.path.append( + str(Path(os.path.dirname(os.path.abspath(__file__))).parent.parent)) +from med_libs.server_utils import go_print +from med_libs.GoExecutionScript import GoExecutionScript, parse_arguments + +json_params_dict, id_ = parse_arguments() +go_print("running script.py:" + id_) + + +class GoExecScriptConnectionTest(GoExecutionScript): + """ + This class is used to execute a process from Go + + Args: + json_params: The input json params + _id: The id of the page that made the request if any + """ + + def __init__(self, json_params: dict, _id: str = None): + super().__init__(json_params, _id) + self.results = {"data": "nothing to return"} + + def _custom_process(self, json_config: dict) -> dict: + """ + This function is used to test if the connection to the server is working. + + Args: + json_config: The input json params + """ + go_print(json.dumps(json_config, indent=4)) + + # Get the directory where the current script is located + script_dir = os.path.dirname(os.path.abspath(__file__)) + + # Define the path for your new text file + file_path = os.path.join(script_dir, "test.txt") + + # Write something to the file + with open(file_path, "w") as f: + f.write("This is a test file created next to connection_test_request.py.\n") + + self.results = { + "status": "success", + "message": "Connection test successful", + "data": "yippie" + } + return self.results + + +script = GoExecScriptConnectionTest(json_params_dict, id_) +script.start() \ No newline at end of file diff --git a/pythonCode/modules/connection/register_ssh_key.py b/pythonCode/modules/connection/register_ssh_key.py new file mode 100644 index 00000000..b45d1f6c --- /dev/null +++ b/pythonCode/modules/connection/register_ssh_key.py @@ -0,0 +1,44 @@ +import json +import sys +import os +from pathlib import Path +# add a .parent to the import if your script is in a subfolder of modules folder : +# sys.path.append( + #str(Path(os.path.dirname(os.path.abspath(__file__))).parent.parent)) +sys.path.append( + str(Path(os.path.dirname(os.path.abspath(__file__))).parent)) +from med_libs.server_utils import go_print +from med_libs.GoExecutionScript import GoExecutionScript, parse_arguments + +json_params_dict, id_ = parse_arguments() +go_print("running script.py:" + id_) + + +class GoExecScriptRegisterSSHKey(GoExecutionScript): + """ + This class is used to execute a process from Go + + Args: + json_params: The input json params + _id: The id of the page that made the request if any + """ + + def __init__(self, json_params: dict, _id: str = None): + super().__init__(json_params, _id) + self.results = {"data": "nothing to return"} + + def _custom_process(self, json_config: dict) -> dict: + """ + This function is used to register and validate an SSH key + sent through the established tunnel. + + Args: + json_config: The input json params + """ + go_print(json.dumps(json_config, indent=4)) + + return self.results + + +script = GoExecScriptRegisterSSHKey(json_params_dict, id_) +script.start() \ No newline at end of file diff --git a/pythonCode/modules/connection/test.txt b/pythonCode/modules/connection/test.txt new file mode 100644 index 00000000..8adc6066 --- /dev/null +++ b/pythonCode/modules/connection/test.txt @@ -0,0 +1 @@ +This is a test file created next to connection_test_request.py. diff --git a/pythonCode/submodules/MEDimage b/pythonCode/submodules/MEDimage index 24511d92..11aef7b3 160000 --- a/pythonCode/submodules/MEDimage +++ b/pythonCode/submodules/MEDimage @@ -1 +1 @@ -Subproject commit 24511d9217cb62b266b41f73e24fc44601b6bfaf +Subproject commit 11aef7b3998694083de48f5274ad6234e3d46f4a diff --git a/pythonCode/submodules/MEDprofiles b/pythonCode/submodules/MEDprofiles index 50b20931..23b62a0e 160000 --- a/pythonCode/submodules/MEDprofiles +++ b/pythonCode/submodules/MEDprofiles @@ -1 +1 @@ -Subproject commit 50b2093113a5bd3272157a7160fff68161af250f +Subproject commit 23b62a0e610fcfc1389d682d55caf16f27002de0 diff --git a/renderer/components/dbComponents/utils.js b/renderer/components/dbComponents/utils.js index 677b880e..66f41e63 100644 --- a/renderer/components/dbComponents/utils.js +++ b/renderer/components/dbComponents/utils.js @@ -1,5 +1,11 @@ +import { getTunnelState } from "../../utilities/tunnelState" + const MongoClient = require("mongodb").MongoClient -const mongoUrl = "mongodb://127.0.0.1:54017" +function getMongoUrl() { + // Use tunnel state if available + const tunnel = getTunnelState() + return "mongodb://127.0.0.1:" + (tunnel && tunnel.tunnelActive && tunnel.localDBPort ? tunnel.localDBPort : "54017") +} /** * @description Check if a database exists @@ -7,7 +13,7 @@ const mongoUrl = "mongodb://127.0.0.1:54017" * @param {String} dbname */ export const collectionExists = async (collectionName, dbname = "data") => { - const client = new MongoClient(mongoUrl) + const client = new MongoClient(getMongoUrl()) try { await client.connect() const db = client.db(dbname) @@ -30,7 +36,7 @@ export const collectionExists = async (collectionName, dbname = "data") => { * @returns {Array} fetchedData */ export const getCollectionData = async (collectionName, first = null, rows = null, dbname = "data") => { - const client = new MongoClient(mongoUrl) + const client = new MongoClient(getMongoUrl()) let fetchedData = [] try { await client.connect() @@ -81,7 +87,7 @@ export const getCollectionData = async (collectionName, first = null, rows = nul * @returns {Array} fetchedDataFiltered */ export const getCollectionDataFilterd = async (collectionName, filter, first = null, rows = null, sortCriteria = null, dbname = "data") => { - const client = new MongoClient(mongoUrl) + const client = new MongoClient(getMongoUrl()) let fetchedData = [] try { await client.connect() @@ -126,7 +132,7 @@ export const getCollectionDataFilterd = async (collectionName, filter, first = n * @description Get documents count with filter use */ export const getCollectionDataCount = async (collectionName, filter, dbname = "data") => { - const client = new MongoClient(mongoUrl) + const client = new MongoClient(getMongoUrl()) try { await client.connect() const db = client.db(dbname) @@ -148,7 +154,7 @@ export const getCollectionDataCount = async (collectionName, filter, dbname = "d * @returns columnTypes */ export const getCollectionColumnTypes = async (collectionName, dbname = "data") => { - const client = new MongoClient(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }) + const client = new MongoClient(getMongoUrl(), { useNewUrlParser: true, useUnifiedTopology: true }) try { await client.connect() const db = client.db(dbname) diff --git a/renderer/components/flow/JupyterNoteBookViewer.jsx b/renderer/components/flow/JupyterNoteBookViewer.jsx index e520b63e..c0b0b77d 100644 --- a/renderer/components/flow/JupyterNoteBookViewer.jsx +++ b/renderer/components/flow/JupyterNoteBookViewer.jsx @@ -1,18 +1,20 @@ -import React, { useContext, useEffect, useState } from "react" +import { useEffect, useState } from "react" import path from "node:path" import Iframe from "react-iframe" import { defaultJupyterPort } from "../layout/flexlayout/mainContainerClass" -import { LayoutModelContext } from "../layout/layoutContext" import { ipcRenderer } from "electron" +import { useTunnel } from "../tunnel/TunnelContext" /** * Jupyter Notebook viewer * @param {string} filePath - the path of the file to edit + * @param {string} startJupyterServer - function to start the Jupyter server + * @param {boolean} isRemote - whether the file is remote or local + * @param {object} jupyterStatus - status of the Jupyter server (running, error) + * @param {function} setJupyterStatus - function to set the Jupyter server status * @returns {JSX.Element} - A Jupyter Notebook viewer */ -const JupyterNotebookViewer = ({ filePath, startJupyterServer }) => { - const exec = require("child_process").exec - const {jupyterStatus, setJupyterStatus} = useContext(LayoutModelContext) +const JupyterNotebookViewer = ({ filePath, startJupyterServer, isRemote = false, jupyterStatus, setJupyterStatus }) => { const [loading, setLoading] = useState(true) const fileName = path.basename(filePath) // Get the file name from the path // Get the relative path after "DATA" in the filePath @@ -20,45 +22,38 @@ const JupyterNotebookViewer = ({ filePath, startJupyterServer }) => { const match = filePath.replace(/\\/g, "/").match(/DATA\/(.+)$/) const relativePath = match ? match[1] : filePath - const getPythonPath = async () => { - let pythonPath = "" - await ipcRenderer.invoke("getBundledPythonEnvironment").then((res) => { - pythonPath = res - }) - // Check if pythonPath is set - if (pythonPath === "") { - return null - } - return pythonPath - } + const tunnel = useTunnel() const checkJupyterServerRunning = async () => { - try { - const pythonPath = await getPythonPath() - if (!pythonPath) { - console.error("Python path is not set. Cannot check Jupyter server status.") - return false - } - const result = await exec(`${pythonPath} -m jupyter notebook list`) - if (result.stderr) { - return false - } - return result.stdout.includes(defaultJupyterPort.toString()) - } catch (error) { - console.error("Error checking Jupyter server status:", error) - return false - } + return await ipcRenderer.invoke("checkJupyterIsRunning") } + ipcRenderer.on("jupyterReady", () => { + if (filePath) { + refreshIframe() + } + }) + useEffect(() => { + console.log("JupyterNoteBookViewer mounted, checking Jupyter server status...") + const runJupyter = async () => { const isRunning = await checkJupyterServerRunning() - if (!isRunning) { + console.log("Jupyter server running status:", isRunning) + if (!isRunning.running) { // Start the Jupyter server setLoading(true) try{ await startJupyterServer() - setJupyterStatus({ running: true, error: null }) + if (isRemote) { + let tunnelSuccess = await ipcRenderer.invoke('startJupyterTunnel') + console.log("SSH Tunnel start result:", tunnelSuccess, jupyterStatus) + if (!tunnelSuccess) { + setJupyterStatus({ running: false, error: "Failed to start SSH tunnel for Jupyter. Please check the tunnel settings." }) + setLoading(false) + return + } + } setLoading(false) } catch (error) { setLoading(false) @@ -66,14 +61,17 @@ const JupyterNotebookViewer = ({ filePath, startJupyterServer }) => { console.error("Error starting Jupyter server:", error) return } - setLoading(false) - } + } + setLoading(false) } runJupyter() } , []) const getJupyterURL = () => { + if (isRemote) { + return "http://localhost:" + tunnel.localJupyterPort + "/notebooks/" + relativePath + } return "http://localhost:" + defaultJupyterPort + "/notebooks/" + relativePath } diff --git a/renderer/components/layout/flexlayout/mainContainerClass.tsx b/renderer/components/layout/flexlayout/mainContainerClass.tsx index 7025b911..d39b7448 100644 --- a/renderer/components/layout/flexlayout/mainContainerClass.tsx +++ b/renderer/components/layout/flexlayout/mainContainerClass.tsx @@ -65,6 +65,8 @@ import { WorkspaceContext } from "../../workspace/workspaceContext" import { confirmDialog } from "primereact/confirmdialog" import JupyterNotebookViewer from "../../flow/JupyterNoteBookViewer" import { ipcRenderer } from "electron" +import axios from "axios" +import { useTunnel } from "../../tunnel/TunnelContext" const util = require("util") const exec = util.promisify(require("child_process").exec) @@ -106,6 +108,7 @@ const MainContainer = (props) => { const { layoutRequestQueue, setLayoutRequestQueue, isEditorOpen, setIsEditorOpen, jupyterStatus, setJupyterStatus } = React.useContext(LayoutModelContext) as unknown as LayoutContextType const { globalData, setGlobalData } = React.useContext(DataContext) as unknown as DataContextType const { workspace } = React.useContext(WorkspaceContext) as unknown as { workspace: any } + const tunnel = useTunnel() return ( { globalData={globalData} setGlobalData={setGlobalData} workspace={workspace} + tunnel={tunnel} /> ) } @@ -135,6 +139,7 @@ class MainInnerContainer extends React.Component { - if (!port) { - throw new Error("Port is required to get Jupyter PID") - } - const { exec } = require('child_process') - const { promisify } = require('util') - const execAsync = promisify(exec) - - const platform = process.platform - const command = platform === 'win32' - ? `netstat -ano | findstr :${port}` - : `lsof -ti :${port} | head -n 1` - - try { - const { stdout, stderr } = await execAsync(command) - if (stderr) throw new Error(stderr) - - return platform === 'win32' - ? stdout.trim().split(/\s+/).pop() - : stdout.trim() - } catch (error) { - throw new Error(`PID lookup failed: ${error.message}`) - } - } - - - startJupyterServer = async () => { - const { jupyterStatus, setJupyterStatus } = this.props as LayoutContextType - // Get Python path - const pythonPath = await this.getPythonPath() - if (!pythonPath) { - toast.error("Python path is not set. Jupyter server cannot be started.") - setJupyterStatus({ running: false, error: "Python path is not set. Jupyter server cannot be started." }) - return - } - - await this.setJupyterConfig() - const workspacePath = this.props.workspace?.workingDirectory?.path - if (!workspacePath) { - toast.error("No workspace path found. Jupyter server cannot be started.") - setJupyterStatus({ running: false, error: "No workspace path found. Jupyter server cannot be started." }) - return - } - if (!jupyterStatus.running) { - const jupyter = spawn(pythonPath, [ - '-m', 'jupyter', 'notebook', - `--NotebookApp.token=''`, - `--NotebookApp.password=''`, - '--no-browser', - `--port=${defaultJupyterPort}`, - `${workspacePath}/DATA` - ]) - this.jupyterStarting = false - setJupyterStatus({running: true, error: null }) - } - } - - getPythonPath = async () => { - const { setJupyterStatus } = this.props as LayoutContextType - let pythonPath = "" - await ipcRenderer.invoke("getBundledPythonEnvironment").then((res) => { - pythonPath = res - }) - // Check if pythonPath is set - if (pythonPath === "") { - toast.error("Python path is not set. Jupyter server cannot be started.") - setJupyterStatus({ running: false, error: "Python path is not set. Jupyter server cannot be started." }) - return null - } - return pythonPath - } - + checkJupyterIsRunning = async () => { const { setJupyterStatus } = this.props as LayoutContextType - try { - const pythonPath = await this.getPythonPath() - if (!pythonPath) { - setJupyterStatus({ running: false, error: "Python path is not set. Cannot check Jupyter server status." }) - console.error("Python path is not set. Cannot check Jupyter server status.") - return false - } - const result = await exec(`${pythonPath} -m jupyter notebook list`) - if (result.stderr) { - setJupyterStatus({ running: false, error: "Jupyter server is not running. You can start it from the settings page." }) - console.error("Error checking Jupyter server status:", result.stderr) - return false - } - const isRunning = result.stdout.includes(defaultJupyterPort.toString()) - setJupyterStatus({ running: isRunning, error: isRunning ? null : "Jupyter server is not running. You can start it from the settings page." }) - return isRunning - } catch (error) { - setJupyterStatus({ running: false, error: "Error while checking Jupyter server status." }) - console.error("Error checking Jupyter server status:", error) - return false + if (this.props.workspace?.isRemote) { + axios.get(`http://${this.props.tunnel.host}:3000/check-jupyter-running`) + .then((response) => { + setJupyterStatus(response.data) + }) + .catch((error) => { + console.error("Error checking Jupyter on remote server: ", error) + toast.error("Error checking Jupyter on remote server: ", error) + setJupyterStatus({ running: false, error: "Error checking Jupyter on remote server: " + error }) + }) + } else { + setJupyterStatus(await ipcRenderer.invoke("checkJupyterIsRunning")) } } - setJupyterConfig = async () => { - const { setJupyterStatus } = this.props as LayoutContextType - let pythonPath = await this.getPythonPath() - if (!pythonPath) { - setJupyterStatus({ running: false, error: "Python path is not set. Cannot configure Jupyter." }) - console.error("Python path is not set. Cannot configure Jupyter.") - return - } - // Check if jupyter is installed - try { - await exec(`${pythonPath} -m jupyter --version`).then((result) => { - const trimmedVersion = result.stdout.split("\n") - const includesJupyter = trimmedVersion.some((line) => line.startsWith("jupyter")) - if (!includesJupyter) { - throw new Error("Jupyter is not installed") - } - }) - } catch (error) { - toast.error("Jupyter is not installed. Please install Jupyter to use this feature.") - console.error("Jupyter is not installed", error) + startJupyterServer = async () => { + console.log("Starting Jupyter server, remote:", this.props.workspace?.isRemote) + if (!this.props.workspace?.workingDirectory?.path) { return } - // Check if jupyter_notebook_config.py exists and update it - try { - const result = await exec(`${pythonPath} -m jupyter --paths`) - if (result.stderr) { - setJupyterStatus({ running: false, error: "Failed to get Jupyter paths." }) - console.error("Error getting Jupyter paths:", result.stderr) - toast.error("Failed to locate Jupyter config directory.") - return - } - const configPath = result.stdout.split("\n").find(line => line.includes(".jupyter")) - - if (configPath) { - const configFilePath = configPath.trim() + "/jupyter_notebook_config.py" - - // Check if the file exists - if (!fs.existsSync(configFilePath)) { - try { - // Await the config generation - const output = await exec(`${pythonPath} -m jupyter notebook --generate-config`) - if (output.stderr) { - console.error("Error generating Jupyter config:", output.stderr) - toast.error("Error generating Jupyter config. Please check the console for more details.") - return - } - } catch (error) { - console.error("Error generating config:", error) - toast.error("Failed to generate Jupyter config") - return + const { setJupyterStatus } = this.props as LayoutContextType + if (this.props.workspace?.isRemote) { + axios.post(`http://${this.props.tunnel.host}:3000/start-jupyter-server`, { workspacePath: this.props.workspace?.workingDirectory?.path } ) + .then((response) => { + setJupyterStatus(response.data) + if (response.data.running) { + console.log("Jupyter server started on remote server") + toast.success("Jupyter server started on remote server") + } else { + console.error("Error starting Jupyter on remote server: ", response.data.error) + toast.error("Error starting Jupyter on remote server: " + response.data.error) } - } - - // Get last line of configfilepath - const lastLine = fs.readFileSync(configFilePath, "utf8").split("\n").slice(-1)[0] - - if (!lastLine.includes("c.NotebookApp.tornado_settings") || - !lastLine.includes("c.ServerApp.allow_unauthenticated_access")) { - // Add config settings - fs.appendFileSync(configFilePath, `\nc.ServerApp.allow_unauthenticated_access = True`) - fs.appendFileSync(configFilePath, `\nc.NotebookApp.tornado_settings={'headers': {'Content-Security-Policy': "frame-ancestors 'self' http://localhost:8888;"}}`) - } - } - } catch (error) { - setJupyterStatus({ running: false, error: "Failed to configure Jupyter." }) - console.error("Error in Jupyter config setup:", error) - toast.error("Failed to configure Jupyter") + }) + .catch((error) => { + console.error("Error starting Jupyter on remote server: ", error) + toast.error("Error starting Jupyter on remote server: ", error) + setJupyterStatus({ running: false, error: "Error starting Jupyter on remote server: " + error }) + }) + } else { + setJupyterStatus(await ipcRenderer.invoke("startJupyterServer", this.props.workspace?.workingDirectory?.path)) + console.log("Jupyter server started locally, status: ", this.props.jupyterStatus) } } - + stopJupyterServer = async () => { const { setJupyterStatus } = this.props as LayoutContextType - const pythonPath = await this.getPythonPath() - - if (!pythonPath) { - setJupyterStatus({ running: false, error: "Python path is not set. Cannot stop Jupyter server." }) - console.error("Python path is not set. Cannot stop Jupyter server.") - return - } - - try { - // Get the PID first - const pid = await this.getJupyterPid(defaultJupyterPort) - - if (!pid) { - console.log("No running Jupyter server found") - setJupyterStatus({ running: false, error: null }) - return - } - - // Platform-specific kill command - const killCommand = process.platform === 'win32' - ? `taskkill /PID ${pid} /F` - : `kill ${pid}` - - await exec(killCommand) - console.log(`Successfully stopped Jupyter server (PID: ${pid})`) - setJupyterStatus({ running: false, error: null }) - } catch (error) { - console.error("Error stopping Jupyter server:", error) - // Fallback to original method if PID method fails - try { - await exec(`${pythonPath} -m jupyter notebook stop ${defaultJupyterPort}`) - setJupyterStatus({ running: false, error: null }) - } catch (fallbackError) { - console.error("Fallback stop method also failed:", fallbackError) - setJupyterStatus({ - running: false, - error: "Failed to stop server" + if (this.props.workspace?.isRemote) { + axios.post(`http://${this.props.tunnel.host}:3000/stop-jupyter-server`) + .then((response) => { + setJupyterStatus(response.data) + if (!response.data.error) { + console.log("Jupyter server stopped on remote server") + toast.success("Jupyter server stopped on remote server") + } else { + console.error("Error stopping Jupyter on remote server: ", response.data.error) + toast.error("Error stopping Jupyter on remote server: " + response.data.error) + } }) - } - } finally { - this.jupyterStarting = false + .catch((error) => { + console.error("Error stopping Jupyter on remote server: ", error) + toast.error("Error stopping Jupyter on remote server: ", error) + setJupyterStatus({ running: this.props.jupyterStatus.running, error: "Error checking Jupyter on remote server: " + error }) + }) + } else { + setJupyterStatus(await ipcRenderer.invoke("stopJupyterServer")) } } @@ -1198,10 +1060,22 @@ class MainInnerContainer extends React.Component + return } } else if (component === "Settings") { - return + return } else if (component !== "") { if (node.getExtraData().data == null) { const config = node.getConfig() diff --git a/renderer/components/layout/iconSidebar.jsx b/renderer/components/layout/iconSidebar.jsx index e989a9aa..7c6bb5c7 100644 --- a/renderer/components/layout/iconSidebar.jsx +++ b/renderer/components/layout/iconSidebar.jsx @@ -8,14 +8,18 @@ import { Tooltip } from "primereact/tooltip" import { LayoutModelContext } from "./layoutContext" import { PiFlaskFill } from "react-icons/pi" import { FaMagnifyingGlassChart } from "react-icons/fa6" +import { TbCloudDataConnection } from "react-icons/tb"; import { FaDatabase } from "react-icons/fa6" import { LuNetwork } from "react-icons/lu" import { Button } from "primereact/button" import { TbFileExport } from "react-icons/tb" import { VscChromeClose } from "react-icons/vsc" import { PiGraphFill } from "react-icons/pi" -import { MdOutlineGroups3, MdSunny } from "react-icons/md" -import { MdOutlineDarkMode } from "react-icons/md"; +import ConnectionModal from "../mainPages/connectionModal" +import { toast } from "react-toastify" +import { useTunnel } from "../tunnel/TunnelContext" +import { FaCircle } from "react-icons/fa" +import { MdOutlineGroups3, MdSunny, MdOutlineDarkMode } from "react-icons/md" import { useTheme } from "../theme/themeContext" /** @@ -32,6 +36,9 @@ const IconSidebar = ({ onSidebarItemSelect }) => { const [developerModeNav, setDeveloperModeNav] = useState(true) const [extractionBtnstate, setExtractionBtnstate] = useState(false) const [buttonClass, setButtonClass] = useState("") + const [showConnectionModal, setShowConnectionModal] = useState(false) + + const tunnel = useTunnel() const delayOptions = { showDelay: 750, hideDelay: 0 } @@ -95,6 +102,10 @@ const IconSidebar = ({ onSidebarItemSelect }) => { setButtonClass(buttonClass === "" ? "show" : "") } + const handleRemoteConnect = () => { + toast.success("Connected to remote workspace!"); + } + function handleThemeToggleClick() { toggleTheme() } @@ -105,6 +116,7 @@ const IconSidebar = ({ onSidebarItemSelect }) => { {/* ------------------------------------------- Tooltips ----------------------------------------- */} + @@ -149,6 +161,46 @@ const IconSidebar = ({ onSidebarItemSelect }) => { + { + setShowConnectionModal(true) + }} + style={{ position: "relative" }} + > + + {/* SSH Tunnel status indicator */} + {tunnel.tunnelActive && ( + + + + + + )} + +
@@ -359,6 +411,14 @@ const IconSidebar = ({ onSidebarItemSelect }) => { {/* div that puts the buttons to the bottom of the sidebar*/}
+ + {showConnectionModal && setShowConnectionModal(false)} + onConnect={handleRemoteConnect} + />} + {/* ------------------------------------------- DARK/LIGHT MODE BUTTON ----------------------------------------- */} { onClick={() => { handleThemeToggleClick() }} - disabled={isDisabled} > {isDarkMode ? ( diff --git a/renderer/components/layout/layoutManager.jsx b/renderer/components/layout/layoutManager.jsx index ee1437a9..4c4238df 100644 --- a/renderer/components/layout/layoutManager.jsx +++ b/renderer/components/layout/layoutManager.jsx @@ -26,6 +26,7 @@ import { WorkspaceContext } from "../workspace/workspaceContext" import { requestBackend } from "../../utilities/requests" import { toast } from "react-toastify" import NotificationOverlay from "../generalPurpose/notificationOverlay" +import SidebarLoadingOverlay from "./sidebarTools/SidebarLoadingOverlay" import os from "os" @@ -286,8 +287,9 @@ const LayoutManager = (props) => {
-
+
{renderSidebarComponent()} +
diff --git a/renderer/components/layout/sidebarTools/SidebarLoadingContext.jsx b/renderer/components/layout/sidebarTools/SidebarLoadingContext.jsx new file mode 100644 index 00000000..05bda8d2 --- /dev/null +++ b/renderer/components/layout/sidebarTools/SidebarLoadingContext.jsx @@ -0,0 +1,32 @@ +import React, { createContext, useContext, useState } from "react" + +// Context for sidebar loading state + +export const SidebarLoadingContext = createContext({ + sidebarProcessing: false, + setSidebarProcessing: () => {}, + sidebarProcessingMessage: "", + setSidebarProcessingMessage: () => {}, +}) + +export function useSidebarLoading() { + return useContext(SidebarLoadingContext) +} + +export function SidebarLoadingProvider({ children }) { + const [sidebarProcessing, setSidebarProcessing] = useState(false) + const [sidebarProcessingMessage, setSidebarProcessingMessage] = useState("") + + React.useEffect(() => { + console.log("[SidebarLoadingContext] sidebarProcessing:", sidebarProcessing) + }, [sidebarProcessing]) + + React.useEffect(() => { + console.log("[SidebarLoadingContext] sidebarProcessingMessage:", sidebarProcessingMessage) + }, [sidebarProcessingMessage]) + return ( + + {children} + + ); +} diff --git a/renderer/components/layout/sidebarTools/SidebarLoadingController.jsx b/renderer/components/layout/sidebarTools/SidebarLoadingController.jsx new file mode 100644 index 00000000..057e7c06 --- /dev/null +++ b/renderer/components/layout/sidebarTools/SidebarLoadingController.jsx @@ -0,0 +1,31 @@ +import { useEffect } from "react" +import { ipcRenderer } from "electron" +import { useSidebarLoading } from "./SidebarLoadingContext" + +export default function SidebarLoadingController() { + const { setSidebarProcessing, setSidebarProcessingMessage } = useSidebarLoading() + + useEffect(() => { + // Listen for custom event dispatched on window + function handleSidebarLoadingEvent(e) { + const { processing, message } = e.detail || {} + setSidebarProcessing(processing) + setSidebarProcessingMessage(message) + console.log("[CustomEvent] sidebarProcessing:", processing, "sidebarProcessingMessage:", message) + } + window.addEventListener("sidebarLoading", handleSidebarLoadingEvent) + + // Still listen for IPC from main process if needed + ipcRenderer.on("setSidebarLoading", (event, { processing, message }) => { + setSidebarProcessing(processing) + setSidebarProcessingMessage(message) + console.log("[IPC] sidebarProcessing:", processing, "sidebarProcessingMessage:", message) + }) + + return () => { + window.removeEventListener("sidebarLoading", handleSidebarLoadingEvent) + ipcRenderer.removeAllListeners("setSidebarLoading") + } + }, []) + return null +} \ No newline at end of file diff --git a/renderer/components/layout/sidebarTools/SidebarLoadingOverlay.jsx b/renderer/components/layout/sidebarTools/SidebarLoadingOverlay.jsx new file mode 100644 index 00000000..c4e1cf0d --- /dev/null +++ b/renderer/components/layout/sidebarTools/SidebarLoadingOverlay.jsx @@ -0,0 +1,33 @@ +import { ProgressSpinner } from "primereact/progressspinner" +import { useSidebarLoading } from "./SidebarLoadingContext" + +const SidebarLoadingOverlay = () => { + const { sidebarProcessing, sidebarProcessingMessage } = useSidebarLoading() + if (!sidebarProcessing) return null + return ( +
+
+ +
+
+ {sidebarProcessingMessage || "Loading workspace..."} +
+
+ ) +} + +export default SidebarLoadingOverlay \ No newline at end of file diff --git a/renderer/components/layout/sidebarTools/directoryTree/sidebarDirectoryTreeControlled.jsx b/renderer/components/layout/sidebarTools/directoryTree/sidebarDirectoryTreeControlled.jsx index ce8063cd..f9621fc7 100644 --- a/renderer/components/layout/sidebarTools/directoryTree/sidebarDirectoryTreeControlled.jsx +++ b/renderer/components/layout/sidebarTools/directoryTree/sidebarDirectoryTreeControlled.jsx @@ -42,7 +42,7 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD const [dirTree, setDirTree] = useState({}) // We get the directory tree from the workspace const [isDropping, setIsDropping] = useState(false) // Set if the item is getting dropped something in (for elements outside of the tree) const [isDirectoryTreeFocused, setIsDirectoryTreeFocused] = useState(false) // New state to track focus - + const { globalData } = useContext(DataContext) // We get the global data from the context to retrieve the directory tree of the workspace, thus retrieving the data files const { dispatchLayout, developerMode, isEditorOpen } = useContext(LayoutModelContext) const { workspace } = useContext(WorkspaceContext) @@ -82,10 +82,8 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD * @note - This function is called when the user presses a key. */ const handleKeyPress = (event) => { - if (event.key === "Delete" && tree.current.isRenaming === false) { - if (selectedItems.length > 0) { - onDeleteSequentially(globalData, workspace.workingDirectory.path, setIsDialogShowing, selectedItems) - } + if (event.key === "Delete" && selectedItems.length > 0 && tree.current.isRenaming === false) { + onDeleteSequentially(globalData, workspace.workingDirectory.path, setIsDialogShowing, selectedItems, 0, workspace.isRemote) } else if (event.code === "KeyC" && event.ctrlKey) { setCopiedItems(selectedItems) } else if (event.code === "KeyX" && event.ctrlKey) { @@ -131,7 +129,7 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD } } else if (event.code === "Backspace" && event.metaKey) { if (selectedItems.length > 0) { - onDeleteSequentially(globalData, workspace.workingDirectory.path, setIsDialogShowing, selectedItems) + onDeleteSequentially(globalData, workspace.workingDirectory.path, setIsDialogShowing, selectedItems, 0, workspace.isRemote) } } } @@ -173,7 +171,7 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD toast.error("Please close the editor before renaming") return } - rename(globalData, workspace.workingDirectory.path, item, newName) + rename(globalData, workspace.workingDirectory.path, item, newName, workspace.isRemote) } /** @@ -237,17 +235,17 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD onOpen(props.index) break case "sync": - MEDDataObject.sync(globalData, props.index, workspace.workingDirectory.path) + MEDDataObject.sync(globalData, props.index, workspace.workingDirectory.path, true, new Set(), workspace.isRemote) MEDDataObject.updateWorkspaceDataObject() break case "rename": onRename(props.index) break case "delete": - onDeleteSequentially(globalData, workspace.workingDirectory.path, setIsDialogShowing, [props.index]) + onDeleteSequentially(globalData, workspace.workingDirectory.path, setIsDialogShowing, selectedItems, 0, workspace.isRemote) break case "rmFromWs": - MEDDataObject.deleteObjectAndChildrenFromWorkspace(globalData, props.index, workspace.workingDirectory.path) + MEDDataObject.deleteObjectAndChildrenFromWorkspace(globalData, props.index, workspace.workingDirectory.path, true, workspace.isRemote) break case "revealInFileExplorer": if (globalData[props.index]) { @@ -437,7 +435,7 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD return ( <> -
+
@@ -456,7 +454,7 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD onClick={(e) => { e.preventDefault() e.stopPropagation() - createFolder(globalData, selectedItems, workspace.workingDirectory.path) + createFolder(globalData, selectedItems, workspace.workingDirectory.path, workspace.isRemote) }} > @@ -566,10 +564,12 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD PandasProfiling - - - Reveal in File Explorer - + { !workspace.isRemote && + + + Reveal in File Explorer + + } Sync @@ -607,10 +607,12 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD PandasProfiling - - - Reveal in File Explorer - + { !workspace.isRemote && + + + Reveal in File Explorer + + } Sync @@ -643,10 +645,12 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD Learning module (default) - - - Reveal in File Explorer - + { !workspace.isRemote && + + + Reveal in File Explorer + + } Sync @@ -666,10 +670,12 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD - - - Reveal in File Explorer - + { !workspace.isRemote && + + + Reveal in File Explorer + + } Sync @@ -716,10 +722,12 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD VSCode - - - Reveal in File Explorer - + { !workspace.isRemote && + + + Reveal in File Explorer + + } Sync @@ -762,10 +770,12 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD VSCode - - - Reveal in File Explorer - + { !workspace.isRemote && + + + Reveal in File Explorer + + } Sync @@ -799,10 +809,12 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD Image viewer (default) - - - Reveal in File Explorer - + { !workspace.isRemote && + + + Reveal in File Explorer + + } Sync @@ -835,10 +847,12 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD PDF viewer (default) - - - Reveal in File Explorer - + { !workspace.isRemote && + + + Reveal in File Explorer + + } Sync @@ -872,10 +886,12 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD Text editor (default) - - - Reveal in File Explorer - + { !workspace.isRemote && + + + Reveal in File Explorer + + } Sync @@ -914,10 +930,12 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD Application Module - - - Reveal in File Explorer - + { !workspace.isRemote && + + + Reveal in File Explorer + + } Sync @@ -937,10 +955,12 @@ const SidebarDirectoryTreeControlled = ({ setExternalSelectedItems, setExternalD - - - Reveal in File Explorer - + { !workspace.isRemote && + + + Reveal in File Explorer + + } Sync diff --git a/renderer/components/layout/sidebarTools/directoryTree/utils.js b/renderer/components/layout/sidebarTools/directoryTree/utils.js index 8131a344..778fc4eb 100644 --- a/renderer/components/layout/sidebarTools/directoryTree/utils.js +++ b/renderer/components/layout/sidebarTools/directoryTree/utils.js @@ -5,6 +5,7 @@ import { confirmDialog } from "primereact/confirmdialog" import { toast } from "react-toastify" import { insertMEDDataObjectIfNotExists } from "../../../mongoDB/mongoDBUtils" import { MEDDataObject } from "../../../workspace/NewMedDataObject" +import { ipcRenderer } from "electron" const untouchableIDs = ["ROOT", "DATA", "EXPERIMENTS"] @@ -68,10 +69,11 @@ export function fromJSONtoTree(data, showHiddenFiles) { * @param {String} workspacePath - The workspace path * @param {Object} item - The item linked to a `MedDataObject` to rename * @param {string} newName - The new name of the `MedDataObject` + * @param {string} isRemote - A flag indicating if the workspace is remote * @returns {void} * @note - This function is called when the user renames a file or folder in the directory tree, either by F2 or by right-clicking and selecting "Rename". */ -export function rename(globalData, workspacePath, item, newName) { +export function rename(globalData, workspacePath, item, newName, isRemote = false) { if (newName == "") { toast.error("Error: Name cannot be empty") return @@ -99,7 +101,7 @@ export function rename(globalData, workspacePath, item, newName) { toast.error("Error: This name cannot be changed") return } - MEDDataObject.rename(globalData, item.index, newName, workspacePath) + MEDDataObject.rename(globalData, item.index, newName, workspacePath, isRemote) } /** @@ -124,11 +126,12 @@ export function onPaste(globalData, copiedObjectId, placeToCopyId) { /** * This function deletes a list of `MEDDataObject` in the workspace. * @param {[string]} items - The list `MEDDataObject` to delete - * @param {Int} index The index of the item to delete + * @param {Int} index - The index of the item to delete + * @param {Int} isRemote - A flag indicating if the workspace is remote * @returns {void} * @note - This function is called when the user deletes files or folders in the directory tree, either by pressing the delete key or by right-clicking and selecting "Delete". */ -export async function onDeleteSequentially(globalData, workspacePath, setIsDialogShowing, items, index = 0) { +export async function onDeleteSequentially(globalData, workspacePath, setIsDialogShowing, items, index = 0, isRemote = false) { MEDDataObject.updateWorkspaceDataObject() // Update the workspace data object before deleting MEDDataObject.verifyLockedObjects(globalData) // Verify if the locked objects and unlock them if they are not linked to any other object const id = items[index] @@ -141,7 +144,7 @@ export async function onDeleteSequentially(globalData, workspacePath, setIsDialo icon: "pi pi-info-circle", closable: false, accept: async () => { - await MEDDataObject.deleteObjectAndChildren(globalData, id, workspacePath) + await MEDDataObject.deleteObjectAndChildren(globalData, id, workspacePath, isRemote) toast.success(`Deleted ${globalData[id].name}`) setIsDialogShowing(false) }, @@ -157,7 +160,7 @@ export async function onDeleteSequentially(globalData, workspacePath, setIsDialo } if (untouchableIDs.includes(id)) { toast.warning(`Cannot delete this element ${globalData[id].name}`) - onDeleteSequentially(globalData, workspacePath, setIsDialogShowing, items, index + 1) // Move to the next item + onDeleteSequentially(globalData, workspacePath, setIsDialogShowing, items, index + 1, isRemote) // Move to the next item } else { setIsDialogShowing(true) confirmDialog({ @@ -167,17 +170,17 @@ export async function onDeleteSequentially(globalData, workspacePath, setIsDialo closable: false, accept: async () => { const name = globalData[id].name - await MEDDataObject.deleteObjectAndChildren(globalData, id, workspacePath) + await MEDDataObject.deleteObjectAndChildren(globalData, id, workspacePath, isRemote) toast.success(`Deleted ${name}`) setIsDialogShowing(false) setTimeout(() => { - onDeleteSequentially(globalData, workspacePath, setIsDialogShowing, items, index + 1) // Move to the next item + onDeleteSequentially(globalData, workspacePath, setIsDialogShowing, items, index + 1, isRemote) // Move to the next item }, 1000) }, reject: () => { setIsDialogShowing(false) setTimeout(() => { - onDeleteSequentially(globalData, workspacePath, setIsDialogShowing, items, index + 1) // Move to the next item + onDeleteSequentially(globalData, workspacePath, setIsDialogShowing, items, index + 1, isRemote) // Move to the next item }, 1000) } }) @@ -191,7 +194,7 @@ export async function onDeleteSequentially(globalData, workspacePath, setIsDialo * @param {Array} selectedItems - The array of selected items in the directory tree * @returns {void} */ -export async function createFolder(globalData, selectedItems, workspacePath) { +export async function createFolder(globalData, selectedItems, workspacePath, isRemote = false) { if (selectedItems && selectedItems.length > 0) { const item = globalData[selectedItems[0]] let parentID = null @@ -226,14 +229,18 @@ export async function createFolder(globalData, selectedItems, workspacePath) { MEDDataObject.updateWorkspaceDataObject() // Check if the folder already exists - if (!fs.existsSync(medObject.path)) { - fs.mkdir(medObject.path, { recursive: true }, (err) => { - if (err) { - console.error(err) - return - } - console.log("Folder created successfully!") - }) + if (isRemote) { + await ipcRenderer.invoke('createRemoteFolder', { path: medObject.path }) + } else { + if (!fs.existsSync(medObject.path)) { + fs.mkdir(medObject.path, { recursive: true }, (err) => { + if (err) { + console.error(err) + return + } + console.log("Folder created successfully!") + }) + } } } else { toast.warning("Please select a directory") diff --git a/renderer/components/mainPages/connectionModal.jsx b/renderer/components/mainPages/connectionModal.jsx new file mode 100644 index 00000000..9e4e1067 --- /dev/null +++ b/renderer/components/mainPages/connectionModal.jsx @@ -0,0 +1,939 @@ +import { useState, useEffect, useContext } from "react" +import { Dialog } from "primereact/dialog" +import { toast } from "react-toastify" +import { InputText } from "primereact/inputtext" +import { Password } from 'primereact/password' +import { InputNumber } from 'primereact/inputnumber' +import { ProgressSpinner } from 'primereact/progressspinner' +import { ipcRenderer } from "electron" +import { requestBackend } from "../../utilities/requests" +import { ServerConnectionContext } from "../serverConnection/connectionContext" +import { useTunnel } from "../tunnel/TunnelContext" +import { getTunnelState } from "../../utilities/tunnelState" +import { Button } from "@blueprintjs/core" +import { GoFile, GoFileDirectoryFill, GoChevronDown, GoChevronUp } from "react-icons/go" +import { FaFolderPlus } from "react-icons/fa" +import { WorkspaceContext } from "../workspace/workspaceContext" +import { IoMdClose, IoIosRefresh } from "react-icons/io" +import axios from "axios" + +/** + * + * @returns {JSX.Element} The connection modal used for establishing a connection to a remote server + */ +const ConnectionModal = ({ visible, closable, onClose, onConnect }) =>{ + const [showAdvanced, setShowAdvanced] = useState(false) + + // Connection info form fields + const [host, setHost] = useState("") + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [remotePort, setRemotePort] = useState("22") + const [localBackendPort, setLocalBackendPort] = useState("54280") + const [remoteBackendPort, setRemoteBackendPort] = useState("54288") + const [localDBPort, setLocalDBPort] = useState("54020") + const [remoteDBPort, setRemoteDBPort] = useState("54017") + const [localJupyterPort, setLocalJupyterPort] = useState("8890") + const [remoteJupyterPort, setRemoteJupyterPort] = useState("8900") + const [privateKey, setPrivateKey] = useState("") + const [publicKey, setPublicKey] = useState("") + const [keyComment, setKeyComment] = useState("medomicslab-app") + + // Connection state + const [keyGenerated, setKeyGenerated] = useState(false) + const [registerStatus, setRegisterStatus] = useState("") + const [tunnelStatus, setTunnelStatus] = useState("") + const [tunnelActive, setTunnelActive] = useState(false) + const [reconnectAttempts, setReconnectAttempts] = useState(0) + const maxReconnectAttempts = 3 + const reconnectDelay = 3000 // ms + const [connectionInfo, setConnectionInfo] = useState(null) + const { workspace, setWorkspace } = useContext(WorkspaceContext) + + // Process/loading states + const [connectionProcessing, setConnectionProcessing] = useState(false) + const [navigationProcessing, setNavigationProcessing] = useState(false) + + // Validation state + const [inputErrors, setInputErrors] = useState({}) + const [inputValid, setInputValid] = useState(false) + const [localPortWarning, setLocalPortWarning] = useState("") + + const { port } = useContext(ServerConnectionContext) // we get the port for server connexion + const tunnelContext = useTunnel() + + // Directory browser state + const [directoryContents, setDirectoryContents] = useState([]) + const [remoteDirPath, setRemoteDirPath] = useState("") + + // const registerPublicKey = async (publicKeyToRegister, usernameToRegister) => { + // setRegisterStatus("Registering...") + // toast.info("Registering your SSH public key with the backend...") + // await requestBackend( + // port, + // "/connection/register_ssh_key", + // { + // username: usernameToRegister, + // publicKey: publicKeyToRegister + // }, + // async (jsonResponse) => { + // console.log("received results:", jsonResponse) + // if (!jsonResponse.error) { + // setRegisterStatus("Public key registered successfully!") + // toast.success("Your SSH public key was registered successfully.") + // } else { + // setRegisterStatus("Failed to register public key: " + jsonResponse.error) + // toast.error(jsonResponse.error) + // } + // }, + // (err) => { + // setRegisterStatus("Failed to register public key: " + err) + // toast.error(err) + // } + // ) + // } + + const handleGenerateKey = async () => { + try { + const result = await ipcRenderer.invoke('generateSSHKey', { comment: keyComment, username }) + if (result && result.publicKey && result.privateKey) { + setPublicKey(result.publicKey) + setPrivateKey(result.privateKey) + setKeyGenerated(true) + toast.success("A new SSH key pair was generated.") + } else if (result && result.error) { + alert('Key generation failed: ' + result.error) + toast.error("Key Generation Failed: " + result.error) + } else { + alert('Key generation failed: Unknown error.') + toast.error("Key Generation Failed: Unknown error.") + } + } catch (err) { + alert('Key generation failed: ' + err.message) + toast.error("Key Generation Failed: " + err.message) + } + } + + // Tunnel error handler and auto-reconnect + useEffect(() => { + if (!tunnelActive && reconnectAttempts > 0 && reconnectAttempts <= maxReconnectAttempts && connectionInfo) { + setTunnelStatus(`Reconnecting... (attempt ${reconnectAttempts} of ${maxReconnectAttempts})`) + toast.warn(`Attempt ${reconnectAttempts} of ${maxReconnectAttempts} to reconnect SSH tunnel.`) + const timer = setTimeout(() => { + handleConnectBackend(connectionInfo, true) + }, reconnectDelay) + return () => clearTimeout(timer) + } + if (reconnectAttempts > maxReconnectAttempts) { + setConnectionProcessing(false) + setTunnelStatus("Failed to reconnect SSH tunnel after multiple attempts.") + toast.error("Failed to reconnect SSH tunnel after multiple attempts.") + setReconnectAttempts(0) + } + }, [tunnelActive, reconnectAttempts, connectionInfo]) + + // On modal open, check for existing tunnel and sync state + useEffect(() => { + if (visible) { + const tunnel = getTunnelState() + if (tunnel.tunnelActive) { + setTunnelActive(true) + setHost(tunnel.host || "") + setUsername(tunnel.username || "") + setRemotePort(tunnel.remotePort || "22") + setLocalBackendPort(tunnel.localBackendPort || "54280") + setRemoteBackendPort(tunnel.remoteBackendPort || "54288") + setLocalDBPort(tunnel.localDBPort || "54020") + setRemoteDBPort(tunnel.remoteDBPort || "54017") + setLocalJupyterPort(tunnel.localJupyterPort || "8890") + setRemoteJupyterPort(tunnel.remoteJupyterPort || "8900") + setTunnelStatus("SSH tunnel is already established.") + tunnelContext.setTunnelInfo(tunnel) // Sync React context + } else { + setTunnelActive(false) + setTunnelStatus("") + } + } + }, [visible]) + + // Updated connect handler with error handling and auto-reconnect + const handleConnectBackend = async (info, isReconnect = false) => { + setConnectionProcessing(true) + setTunnelStatus(isReconnect ? "Reconnecting..." : "Connecting...") + toast.info(isReconnect ? "Reconnecting SSH tunnel..." : "Establishing SSH tunnel...") + const connInfo = info || { host, username, privateKey, password, remotePort, localBackendPort, remoteBackendPort, localDBPort, remoteDBPort, localJupyterPort, remoteJupyterPort } + setConnectionInfo(connInfo) + // --- Host validation --- + const hostPattern = /^(?!-)[A-Za-z0-9-]{1,63}(? ({ + name: item.name, + type: item.type === 'directory' || item.type === 'dir' ? 'dir' : 'file' + }))) + } else { + setDirectoryContents([]) + } + } else { + setDirectoryContents([]) + } + } catch (err) { + setDirectoryContents([]) + } finally { + setNavigationProcessing(false) + } + } else if (result && result.error) { + setTunnelStatus("Failed to establish SSH tunnel: " + result.error) + setTunnelActive(false) + setReconnectAttempts((prev) => prev + 1) + toast.error("Tunnel failed: " + result.error) + } else { + setTunnelStatus("Failed to establish SSH tunnel: Unknown error.") + setTunnelActive(false) + setReconnectAttempts((prev) => prev + 1) + toast.error("Tunnel Failed, Unknown error.") + } + } catch (err) { + let errorMsg = err && err.message ? err.message : String(err) + if (err && err.stack) { + errorMsg += "\nStack: " + err.stack + } + setTunnelStatus("Failed to establish SSH tunnel: " + errorMsg) + setTunnelActive(false) + setReconnectAttempts((prev) => prev + 1) + toast.error("Tunnel Failed: " + errorMsg) + } + } + + const handleConnectMongoDB = async () => { + try { + const result = await ipcRenderer.invoke('startMongoTunnel') + if (result && result.success) { + toast.success("MongoDB tunnel established.") + } else if (result && result.error) { + toast.error("MongoDB Tunnel failed: " + result.error) + } else { + toast.error("MongoDB Tunnel Failed, Unknown error.") + } + } catch (err) { + let errorMsg = err && err.message ? err.message : String(err) + if (err && err.stack) { + errorMsg += "\nStack: " + err.stack + } + toast.error("MongoDB Tunnel Failed: " + errorMsg) + } + } + + const handleDisconnect = async () => { + setConnectionProcessing(true) + setTunnelStatus("Disconnecting...") + toast.info("Disconnecting SSH tunnel...") + try { + const result = await ipcRenderer.invoke('stopSSHTunnel') + if (result && result.success) { + setTunnelActive(false) + setTunnelStatus("SSH tunnel disconnected.") + tunnelContext.clearTunnelInfo() + ipcRenderer.invoke("setRemoteWorkspacePath", null) + ipcRenderer.invoke("clearTunnelState") + toast.success("SSH tunnel disconnected.") + setDirectoryContents([]) + setRemoteDirPath("") + setWorkspace({ + hasBeenSet: false, + workingDirectory: "", + isRemote: false + }) + } else { + setTunnelStatus("Failed to disconnect tunnel: " + (result?.error || 'Unknown error')) + toast.error("Disconnect Failed: " + result?.error || 'Unknown error') + } + } catch (err) { + setTunnelStatus("Failed to disconnect tunnel: " + (err.message || err)) + toast.error("Disconnect Failed: ", err.message || String(err)) + } finally { + setConnectionProcessing(false) + } + } + + useEffect(() => { + // When modal opens and username is set, check for existing SSH key (do NOT generate) + if (visible && username) { + (async () => { + try { + const result = await ipcRenderer.invoke('getSSHKey', { username }) + if (result && result.publicKey && result.privateKey) { + setPublicKey(result.publicKey) + setPrivateKey(result.privateKey) + setKeyGenerated(!!result.publicKey) + } else { + setPublicKey("") + setPrivateKey("") + setKeyGenerated(false) + } + } catch { + setPublicKey("") + setPrivateKey("") + setKeyGenerated(false) + } + })() + } + }, [visible, username, keyComment]) + + const sendTestRequest = async () => { + console.log("Port: ", port) + console.log("Tunnel state: ", getTunnelState()) + console.log("Tunnel context: ", tunnelContext.tunnelActive) + // if (!tunnelActive) { + // toast.error("SSH tunnel is not active. Please connect first.") + // return + // } + await requestBackend( + port, + "/connection/connection_test_request", + { data: "" }, + async (jsonResponse) => { + console.log("Test Request Response: ",jsonResponse) + if (!jsonResponse.error) { + setRegisterStatus("Test request successful!") + } else { + setRegisterStatus("Test request failed: " + jsonResponse.error) + toast.error(jsonResponse.error) + } + }, + (err) => { + setRegisterStatus("Test request failed: " + err) + toast.error(err) + } + ) + } + + // DirectoryBrowser component + const DirectoryBrowser = ({ directoryContents, onDirClick, navigationProcessing }) => { + if (!directoryContents || directoryContents.length === 0) { + return
No files or folders to display.
+ } + return ( +
+
    + {directoryContents.map((item, idx) => ( +
  • onDirClick && onDirClick(item.name) : undefined} + > + + {item.type === 'dir' ? ( + + ) : ( + + )} + + {item.name} +
  • + ))} +
+ {navigationProcessing && ( +
+ +
+ )} +
+ ) + } + + // Input validation logic + useEffect(() => { + const errors = {} + let warning = "" + // Strict IPv4 regex + const ipv4Pattern = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/ + // Hostname regex (RFC 1123, simple) + const hostnamePattern = /^(?!-)[A-Za-z0-9-]{1,63}(? 65535) { + errors.remotePort = "Remote SSH port must be 1-65535." + } + if (!localBackendPort || isNaN(Number(localBackendPort)) || Number(localBackendPort) < 1 || Number(localBackendPort) > 65535) { + errors.localBackendPort = "Local backend port must be 1-65535." + } + if (!remoteBackendPort || isNaN(Number(remoteBackendPort)) || Number(remoteBackendPort) < 1 || Number(remoteBackendPort) > 65535) { + errors.remoteBackendPort = "Remote backend port must be 1-65535." + } + if (!localDBPort || isNaN(Number(localDBPort)) || Number(localDBPort) < 1 || Number(localDBPort) > 65535) { + errors.localDBPort = "Local MongoDB port must be 1-65535." + } + if (!remoteDBPort || isNaN(Number(remoteDBPort)) || Number(remoteDBPort) < 1 || Number(remoteDBPort) > 65535) { + errors.remoteDBPort = "Remote MongoDB port must be 1-65535." + } + if (!keyGenerated || !publicKey || !privateKey) { + errors.key = "SSH key must be generated." + } + // Warn if localBackendPort matches the main server port + if (String(localBackendPort) === String(port)) { + warning = `Warning: Local backend port (${localBackendPort}) is the same as the main server port (${port}). This may cause conflicts if the local backend is running.` + } + setInputErrors(errors) + setInputValid(Object.keys(errors).length === 0) + setLocalPortWarning(warning) + }, [host, username, remotePort, localBackendPort, remoteBackendPort, localDBPort, remoteDBPort, keyGenerated, publicKey, privateKey, port]) + + // New folder modal state + const [showNewFolderModal, setShowNewFolderModal] = useState(false) + const [newFolderName, setNewFolderName] = useState("") + const [creatingFolder, setCreatingFolder] = useState(false) + + const handleCreateFolder = async () => { + setCreatingFolder(true) + try { + const result = await ipcRenderer.invoke('createRemoteFolder', { + path: remoteDirPath, + folderName: newFolderName.trim() + }) + if (result && result.success) { + const navResult = await ipcRenderer.invoke('navigateRemoteDirectory', { + action: 'list', + path: remoteDirPath + }) + if (navResult && navResult.path) setRemoteDirPath(navResult.path) + if (Array.isArray(navResult?.contents)) { + setDirectoryContents(navResult.contents.map(item => ({ + name: item.name, + type: item.type === 'dir' ? 'dir' : 'file' + }))) + } else { + setDirectoryContents([]) + } + setShowNewFolderModal(false) + setNewFolderName("") + } else { + toast.error('Failed to create folder: ' + (result && result.error ? result.error : 'Unknown error')) + } + } catch (err) { + toast.error('Failed to create folder: ' + (err && err.message ? err.message : String(err))) + } finally { + setCreatingFolder(false) + } + } + + return ( + +
+
+

SSH Tunnel Connection

+ +
+ + + +
+ +
+ {showAdvanced && <> +
+ + + +
+
+ + + +
+ } +
+
+ + {inputErrors.key &&
{inputErrors.key}
} + {keyGenerated && ( +
+ Public Key: +
{publicKey}
+ {registerStatus &&
{registerStatus}
} +
+ )} +
+ + +
+ {tunnelStatus && ( +
+
+ { connectionProcessing && ()} {tunnelStatus} +
+
+ )} + + {/* Directory Browser Section */} +
+
+

Remote Directory Browser

+ + + +
+
+ Path: {remoteDirPath} +
+ { + if (!tunnelActive || navigationProcessing) return + setNavigationProcessing(true) + try { + let navResult + if (dirName === '..') { + navResult = await ipcRenderer.invoke('navigateRemoteDirectory', { + action: 'up', + path: remoteDirPath + }) + } else { + navResult = await ipcRenderer.invoke('navigateRemoteDirectory', { + action: 'into', + path: remoteDirPath, + dirName + }) + } + if (navResult && navResult.path) setRemoteDirPath(navResult.path) + if (Array.isArray(navResult?.contents)) { + setDirectoryContents(navResult.contents.map(item => ({ + name: item.name, + type: item.type === 'dir' ? 'dir' : 'file' + }))) + } else { + setDirectoryContents([]) + } + } catch { + setDirectoryContents([]) + } finally { + setNavigationProcessing(false) + } + } + } + navigationProcessing={navigationProcessing} + /> +
+
+ {/* New Folder Modal */} + {showNewFolderModal && ( + setShowNewFolderModal(false)} + closable + footer={ +
+ + +
+ } + > +
+ + setNewFolderName(e.target.value)} + autoFocus + disabled={creatingFolder} + onKeyDown={e => { + if (e.key === 'Enter' && newFolderName.trim() && !creatingFolder) { + e.preventDefault() + handleCreateFolder() + } + }} + placeholder="e.g. my_new_folder" + /> +
+
+ )} + +
+ ) +} + +export default ConnectionModal diff --git a/renderer/components/mainPages/home.jsx b/renderer/components/mainPages/home.jsx index 58a3dc4b..cae3035e 100644 --- a/renderer/components/mainPages/home.jsx +++ b/renderer/components/mainPages/home.jsx @@ -11,8 +11,8 @@ import { randomUUID } from "crypto" import { requestBackend } from "../../utilities/requests" import { ServerConnectionContext } from "../serverConnection/connectionContext" import { toast } from "react-toastify" -import { FaRegQuestionCircle } from "react-icons/fa" - +import { FaRegQuestionCircle } from "react-icons/fa"; +import ConnectionModal from "./connectionModal" /** * @returns the home page component @@ -23,6 +23,9 @@ const HomePage = () => { const [appVersion, setAppVersion] = useState("") const [sampleGenerated, setSampleGenerated] = useState(false) const { port } = useContext(ServerConnectionContext) + const [showConnectionModal, setShowConnectionModal] = useState(false) + + const [requirementsMet, setRequirementsMet] = useState(true) async function handleWorkspaceChange() { @@ -52,14 +55,12 @@ const HomePage = () => { "/input/generate_sample_data/", jsonToSend, async (jsonResponse) => { - console.log("jsonResponse", jsonResponse) if (jsonResponse.error) { - console.log("Sample data error") if (jsonResponse.error.message) { - console.error(jsonResponse.error.message) + console.error("Sample data generating error: ", jsonResponse.error.message) toast.error(jsonResponse.error.message) } else { - console.error(jsonResponse.error) + console.error("Sample data generating error: ", jsonResponse.error) toast.error(jsonResponse.error) } } else { @@ -72,7 +73,7 @@ const HomePage = () => { }, (error) => { console.log(error) - toast.error("Error generating sample data " + error) + toast.error("Error generating sample data :", error) } ) } @@ -130,6 +131,10 @@ const HomePage = () => { ipcRenderer.send("messageFromNext", "getRecentWorkspaces") }, []) + const handleRemoteConnect = () => { + toast.success("Connected to remote workspace!"); + }; + return ( <>
{ Set Workspace
Or open a recent workspace
- + {recentWorkspaces.map((workspace, index) => { if (index > 4) return return ( @@ -177,6 +182,10 @@ const HomePage = () => { ) })} +
Or connect to a remote workspace
+ ) : (
@@ -253,6 +262,13 @@ const HomePage = () => {
+ {!requirementsMet && process.platform !=="darwin" && } + {showConnectionModal && setShowConnectionModal(false)} + onConnect={handleRemoteConnect} + />} {!requirementsMet && process.platform !== "darwin" && ( diff --git a/renderer/components/mainPages/settings.jsx b/renderer/components/mainPages/settings.jsx index ba544d29..b4586af8 100644 --- a/renderer/components/mainPages/settings.jsx +++ b/renderer/components/mainPages/settings.jsx @@ -17,6 +17,9 @@ import { Column } from "primereact/column" import { WorkspaceContext } from "../workspace/workspaceContext" import FirstSetupModal from "../generalPurpose/installation/firstSetupModal" import { requestBackend } from "../../utilities/requests" +import { useTunnel } from "../tunnel/TunnelContext" +import axios from "axios" +import { toast } from "react-toastify" const util = require("util") const exec = util.promisify(require("child_process").exec) @@ -24,18 +27,19 @@ const exec = util.promisify(require("child_process").exec) * Settings page * @returns {JSX.Element} Settings page */ -const SettingsPage = ({pageId = "settings", checkJupyterIsRunning, startJupyterServer, stopJupyterServer}) => { +const SettingsPage = ({pageId = "settings", checkJupyterIsRunning, startJupyterServer, stopJupyterServer, jupyterStatus, setJupyterStatus}) => { const { workspace, port } = useContext(WorkspaceContext) const [settings, setSettings] = useState(null) // Settings object const [serverIsRunning, setServerIsRunning] = useState(false) // Boolean to know if the server is running const [mongoServerIsRunning, setMongoServerIsRunning] = useState(false) // Boolean to know if the server is running - const [jupyterServerIsRunning, setjupyterServerIsRunning] = useState(false) // Boolean to know if Jupyter Noteobok is running const [activeIndex, setActiveIndex] = useState(0) // Index of the active tab const [condaPath, setCondaPath] = useState("") // Path to the conda environment const [seed, setSeed] = useState(54288) // Seed for random number generation const [pythonEmbedded, setPythonEmbedded] = useState({}) // Boolean to know if python is embedded const [showPythonPackages, setShowPythonPackages] = useState(false) // Boolean to know if python packages are shown + const tunnel = useTunnel() + /** * Check if the mongo server is running and set the state * @returns {void} @@ -128,7 +132,7 @@ const SettingsPage = ({pageId = "settings", checkJupyterIsRunning, startJupyterS }) } }) - }, 5000) + }, workspace.isRemote ? 10000 : 5000) // Greater interval if remote workspace since requests take longer return () => clearInterval(interval) }) @@ -146,33 +150,68 @@ const SettingsPage = ({pageId = "settings", checkJupyterIsRunning, startJupyterS const getJupyterStatus = async () => { console.log("Checking jupyter status") - const running = await checkJupyterIsRunning() - setjupyterServerIsRunning(running) + let running = false + if (workspace.isRemote) { + axios.get(`http://${tunnel.host}:3000/check-jupyter-status`) + .then((response) => { + console.log("Jupyter status on remote server: ", response) + if (response.status == 200 && response.data.running) { + console.log("Jupyter is running on remote server") + setJupyterStatus(response.data) + } else { + console.error("Jupyter check on server failed: ", response.data.error) + setJupyterStatus(response.data) + } + }) + .catch((error) => { + console.error("Error checking Jupyter status on remote server: ", error) + setJupyterStatus({ running: false, error: error.message }) + }) + } else { + await checkJupyterIsRunning() + } } const startMongo = () => { let workspacePath = workspace.workingDirectory.path - const mongoConfigPath = path.join(workspacePath, ".medomics", "mongod.conf") - let mongod = getMongoDBPath() - let mongoResult = spawn(mongod, ["--config", mongoConfigPath]) - - mongoResult.stdout.on("data", (data) => { - console.log(`MongoDB stdout: ${data}`) - }) - - mongoResult.stderr.on("data", (data) => { - console.error(`MongoDB stderr: ${data}`) - }) - - mongoResult.on("close", (code) => { - console.log(`MongoDB process exited with code ${code}`) - }) - - mongoResult.on("error", (err) => { - console.error("Failed to start MongoDB: ", err) - // reject(err) - }) - console.log("Mongo result from start ", mongoResult) + if (workspace.isRemote) { + axios.post(`http://${tunnel.host}:3000/start-mongo`, { workspacePath: workspacePath } ) + .then((response) => { + if (response.data.success) { + toast.success("MongoDB started successfully on remote server") + console.log("MongoDB started successfully on remote server") + } else { + toast.error("Failed to start MongoDB on remote server: ", response.data.error) + console.error("Failed to start MongoDB on remote server: ", response.data.error) + } + }) + .catch((error) => { + console.error("Error starting MongoDB on remote server: ", error) + toast.error("Error starting MongoDB on remote server: ", error) + }) + } else { + const mongoConfigPath = path.join(workspacePath, ".medomics", "mongod.conf") + let mongod = getMongoDBPath() + let mongoResult = spawn(mongod, ["--config", mongoConfigPath]) + + mongoResult.stdout.on("data", (data) => { + console.log(`MongoDB stdout: ${data}`) + }) + + mongoResult.stderr.on("data", (data) => { + console.error(`MongoDB stderr: ${data}`) + }) + + mongoResult.on("close", (code) => { + console.log(`MongoDB process exited with code ${code}`) + }) + + mongoResult.on("error", (err) => { + console.error("Failed to start MongoDB: ", err) + // reject(err) + }) + console.log("Mongo result from start ", mongoResult) + } } const installMongoDB = () => { @@ -283,21 +322,21 @@ const SettingsPage = ({pageId = "settings", checkJupyterIsRunning, startJupyterS
Jupyter Notebook server status :
-
{jupyterServerIsRunning ? "Running" : "Stopped"}
- {jupyterServerIsRunning ? : } +
{jupyterStatus.running ? "Running" : "Stopped"}
+ {jupyterStatus.running ? : }