+
+
({
accessModeModal: null,
@@ -511,6 +531,9 @@ export default {
this.$refs.settings.showModal();
});
},
+ showDBModal() {
+ this.$refs.dbConfigModal.showModal();
+ },
toggleSidebar() {
this.isSidebarCollapsed = !this.isSidebarCollapsed;
window.dispatchEvent(new Event('resize'));
diff --git a/src/server/API.js b/src/server/API.js
index 44ef7d2..6d441bf 100644
--- a/src/server/API.js
+++ b/src/server/API.js
@@ -20,11 +20,13 @@ if (!isWasmMode) {
const importApi = require("./Import");
const gpt = require("./Gpt");
+ const dbConfig = require("./DBConfig");
router.use("/schema", schema);
router.use("/cypher", cypher);
router.use("/session", session);
router.use("/", state);
router.use("/gpt", gpt);
+ router.use("/db", dbConfig);
if (currentMode === MODES.READ_WRITE) {
router.use("/reset", reset);
router.use("/import", importApi);
diff --git a/src/server/DBConfig.js b/src/server/DBConfig.js
new file mode 100644
index 0000000..db90e94
--- /dev/null
+++ b/src/server/DBConfig.js
@@ -0,0 +1,49 @@
+const express = require("express");
+const router = express.Router();
+const database = require("./utils/Database");
+const sshManager = require("./utils/SSHManager");
+
+function buildResponse() {
+ const dbConfig = database.getCurrentConfig();
+ const sshConfig = sshManager.getConfig();
+ const mode = sshConfig ? "ssh" : (dbConfig.isInMemory ? "memory" : "file");
+ return { ...dbConfig, mode, ssh: sshConfig };
+}
+
+router.get("/", (_, res) => {
+ try {
+ res.send(buildResponse());
+ } catch (err) {
+ res.status(500).send({ error: err.message });
+ }
+});
+
+router.post("/", async (req, res) => {
+ try {
+ const { mode, dbDir, dbFile, ssh } = req.body;
+
+ if (mode === "ssh") {
+ if (!ssh) {
+ throw new Error("ssh config is required for SSH mode.");
+ }
+ const { host, port = 22, user, password, privateKeyPath, remoteDir, remoteFile } = ssh;
+ const mount = sshManager.mount({ host, port, user, password, privateKeyPath, remoteDir });
+ try {
+ await database.reconfigure({ dbDir: mount.mountPoint, dbFile: remoteFile || "database.kz", inMemory: false });
+ sshManager.activateMount(mount.mountPoint, mount.config);
+ } catch (err) {
+ sshManager.unmount(mount.mountPoint);
+ throw err;
+ }
+ } else {
+ await database.reconfigure({ dbDir, dbFile, inMemory: mode === "memory" });
+ sshManager.unmountAll();
+ }
+
+ res.send(buildResponse());
+ } catch (err) {
+ res.status(400).send({ error: err.message });
+ }
+});
+
+module.exports = router;
diff --git a/src/server/utils/Database.js b/src/server/utils/Database.js
index 8df36f3..f0fd7c1 100644
--- a/src/server/utils/Database.js
+++ b/src/server/utils/Database.js
@@ -112,18 +112,34 @@ class Database {
init() {
this.db = new lbug.Database(this.dbPath, this.bufferPoolSize, true, this.isReadOnlyMode);
- this.connectionPool = [];
- for (let i = 0; i < this.numberConnections; ++i) {
- const conn = {
- connection: new lbug.Connection(this.db, this.coresPerConnection),
- useCount: 0,
- id: i,
- };
- if (!isNaN(this.queryTimeout)) {
- conn.connection.setQueryTimeout(this.queryTimeout);
+ this.connectionPool = this.createConnectionPool(this.db);
+ }
+
+ createConnectionPool(db) {
+ const connectionPool = [];
+ try {
+ for (let i = 0; i < this.numberConnections; ++i) {
+ const conn = {
+ connection: new lbug.Connection(db, this.coresPerConnection),
+ useCount: 0,
+ id: i,
+ };
+ if (!isNaN(this.queryTimeout)) {
+ conn.connection.setQueryTimeout(this.queryTimeout);
+ }
+ connectionPool.push(conn);
}
- this.connectionPool.push(conn);
+ } catch (err) {
+ connectionPool.forEach((conn) => {
+ try { conn.connection.close(); } catch {}
+ });
+ throw err;
}
+ return connectionPool;
+ }
+
+ async closeConnectionPool(connectionPool) {
+ await Promise.all(connectionPool.map((conn) => conn.connection.close()));
}
get lbug() {
@@ -185,6 +201,57 @@ class Database {
});
}
+ getCurrentConfig() {
+ const isInMemory = this.dbPath === ":memory:";
+ return {
+ dbPath: this.dbPath,
+ isInMemory,
+ dbDir: isInMemory ? "" : path.dirname(this.dbPath),
+ dbFile: isInMemory ? "" : path.basename(this.dbPath),
+ };
+ }
+
+ async reconfigure({ dbDir, dbFile, inMemory }) {
+ const isAllConnectionsReleased = this.connectionPool.every(
+ (conn) => conn.useCount === 0
+ );
+ if (!isAllConnectionsReleased) {
+ throw new Error("Please make sure no queries are running before reconfiguring.");
+ }
+
+ let nextDbPath;
+ if (inMemory) {
+ nextDbPath = ":memory:";
+ } else {
+ if (!dbDir) {
+ throw new Error("dbDir is required for file-based mode.");
+ }
+ const fileName = dbFile || "database.kz";
+ nextDbPath = path.resolve(path.join(dbDir, fileName));
+ }
+
+ const nextDb = new lbug.Database(nextDbPath, this.bufferPoolSize, true, this.isReadOnlyMode);
+ let nextConnectionPool;
+ try {
+ nextConnectionPool = this.createConnectionPool(nextDb);
+ } catch (err) {
+ try { nextDb.close(); } catch {}
+ throw err;
+ }
+
+ const oldConnectionPool = this.connectionPool;
+ const oldDb = this.db;
+ this.dbPath = nextDbPath;
+ this.db = nextDb;
+ this.connectionPool = nextConnectionPool;
+ try {
+ await this.closeConnectionPool(oldConnectionPool);
+ oldDb.close();
+ } catch (err) {
+ logger.warn(`Failed to close previous database cleanly: ${err.message}`);
+ }
+ }
+
async getSchema() {
const conn = this.getConnection();
try {
diff --git a/src/server/utils/SSHManager.js b/src/server/utils/SSHManager.js
new file mode 100644
index 0000000..b049f65
--- /dev/null
+++ b/src/server/utils/SSHManager.js
@@ -0,0 +1,156 @@
+const { execFileSync } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+const os = require("os");
+const logger = require("./Logger");
+
+class SSHManager {
+ constructor() {
+ this.activeMountPoint = null;
+ this.activeConfig = null; // stored without password
+
+ const cleanup = () => {
+ this.unmountAll();
+ process.exit();
+ };
+ process.on("exit", () => this.unmountAll());
+ process.on("SIGINT", cleanup);
+ process.on("SIGTERM", cleanup);
+ }
+
+ isActive() {
+ return this.activeMountPoint !== null;
+ }
+
+ getConfig() {
+ return this.activeConfig ? { ...this.activeConfig } : null;
+ }
+
+ getMountPoint() {
+ return this.activeMountPoint;
+ }
+
+ _hasCmd(cmd) {
+ try {
+ execFileSync("which", [cmd], { stdio: "pipe" });
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ _validateConfig({ host, port, user, password, privateKeyPath, remoteDir }) {
+ if (!host || !user || !remoteDir) {
+ throw new Error("host, user, and remoteDir are required.");
+ }
+ const numericPort = Number(port);
+ if (!Number.isInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
+ throw new Error("port must be an integer between 1 and 65535.");
+ }
+ if (!password && !privateKeyPath) {
+ throw new Error("Either password or privateKeyPath is required.");
+ }
+ return numericPort;
+ }
+
+ mount({ host, port = 22, user, password, privateKeyPath, remoteDir }) {
+ const numericPort = this._validateConfig({ host, port, user, password, privateKeyPath, remoteDir });
+ if (!this._hasCmd("sshfs")) {
+ throw new Error(
+ "sshfs is not installed. Please install sshfs to use remote SSH databases."
+ );
+ }
+
+ const mountPoint = fs.mkdtempSync(path.join(os.tmpdir(), "lbug-ssh-"));
+
+ const sshOpts = [
+ `port=${numericPort}`,
+ "StrictHostKeyChecking=no",
+ "UserKnownHostsFile=/dev/null",
+ "reconnect",
+ ];
+
+ let command;
+ let args;
+ let passFile = null;
+
+ if (password) {
+ if (!this._hasCmd("sshpass")) {
+ try { fs.rmdirSync(mountPoint); } catch {}
+ throw new Error(
+ "sshpass is not installed. Install sshpass for password-based auth, or use a private key file instead."
+ );
+ }
+ passFile = path.join(os.tmpdir(), `lbug-pass-${Date.now()}`);
+ fs.writeFileSync(passFile, password, { mode: 0o600 });
+ command = "sshpass";
+ args = ["-f", passFile, "sshfs", "-o", sshOpts.join(","), `${user}@${host}:${remoteDir}`, mountPoint];
+ } else if (privateKeyPath) {
+ sshOpts.push(`IdentityFile=${privateKeyPath}`);
+ command = "sshfs";
+ args = ["-o", sshOpts.join(","), `${user}@${host}:${remoteDir}`, mountPoint];
+ }
+
+ try {
+ execFileSync(command, args, { stdio: "pipe" });
+ } catch (err) {
+ try { fs.rmdirSync(mountPoint); } catch {}
+ const stderr = err.stderr?.toString().trim() || err.message;
+ throw new Error(`SSHFS mount failed: ${stderr}`);
+ } finally {
+ if (passFile) try { fs.unlinkSync(passFile); } catch {}
+ }
+
+ logger.info(`SSH mounted: ${user}@${host}:${remoteDir} -> ${mountPoint}`);
+ return {
+ mountPoint,
+ config: {
+ host,
+ port: numericPort,
+ user,
+ remoteDir,
+ authType: password ? "password" : "key",
+ privateKeyPath: password ? undefined : privateKeyPath,
+ },
+ };
+ }
+
+ activateMount(mountPoint, config) {
+ const previousMountPoint = this.activeMountPoint;
+ this.activeMountPoint = mountPoint;
+ this.activeConfig = { ...config };
+ if (previousMountPoint && previousMountPoint !== mountPoint) {
+ this._doUnmount(previousMountPoint);
+ }
+ }
+
+ unmount(mountPoint) {
+ this._doUnmount(mountPoint);
+ }
+
+ _doUnmount(mountPoint) {
+ try {
+ try {
+ execFileSync("fusermount", ["-u", mountPoint], { stdio: "pipe" });
+ } catch {
+ execFileSync("umount", [mountPoint], { stdio: "pipe" });
+ }
+ } catch (err) {
+ logger.warn(`Unmount warning for ${mountPoint}: ${err.message}`);
+ }
+ try { fs.rmdirSync(mountPoint); } catch {}
+ if (this.activeMountPoint === mountPoint) {
+ this.activeMountPoint = null;
+ this.activeConfig = null;
+ }
+ logger.info(`SSH unmounted: ${mountPoint}`);
+ }
+
+ unmountAll() {
+ if (this.activeMountPoint) {
+ this._doUnmount(this.activeMountPoint);
+ }
+ }
+}
+
+module.exports = new SSHManager();