This commit is contained in:
seb
2026-10-13 17:19:16 +02:00
parent 2de2bc0d90
commit c3d4fa5cd9
9 changed files with 3347 additions and 1 deletions

View File

@@ -2,6 +2,8 @@
PORT=4208
# HTTPS server port (defaults to 8443, set to 0 or false to disable)
HTTPS_PORT=8443
# SQL Studio port (served when backend is started with npm run dev)
SQL_STUDIO_PORT=4210
# Optional custom TLS cert/key paths (defaults to auto-generated certs/key.pem and certs/cert.pem)
# HTTPS_KEY_PATH=./certs/key.pem
# HTTPS_CERT_PATH=./certs/cert.pem

View File

@@ -8,7 +8,7 @@
"main": "index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch-path=./src --watch-path=./.env --watch-path=../dashdbschema/src --watch-path=../llmorbiter/src src/index.js",
"dev": "cross-env DEV_SQL_STUDIO=true node --watch-path=./src --watch-path=./.env --watch-path=../dashdbschema/src --watch-path=../llmorbiter/src src/index.js",
"manage-users": "node scripts/manageUsers.js",
"wipe-company": "node scripts/wipeCompany.js",
"docs:api": "node scripts/generateApiDocs.js",

View File

@@ -17,6 +17,7 @@ import { shutdownJavascriptSandboxWorkers } from '../services/javascriptSandbox.
import { getOrCreateCert } from '../util/cert.js';
import { createLogger } from '../util/logger.js';
import v1Router from '../v1/index.js';
import { startSqlStudio, stopSqlStudio, isDevMode } from '../sqlstudio/server.js';
const apiLog = createLogger('api');
@@ -236,6 +237,14 @@ export async function startApi() {
}
}
if (isDevMode()) {
try {
await startSqlStudio();
} catch (err) {
apiLog.error('Failed to start SQL Studio:', err.message);
}
}
return server;
})();
@@ -247,6 +256,7 @@ export async function startApi() {
}
export async function stopApi() {
await stopSqlStudio();
if (!server && !httpsServer) return;
// Long-lived SSE (and keep-alive) sockets otherwise block server.close()
// forever — node --watch hangs on "Waiting for graceful termination...".

View File

@@ -15,6 +15,7 @@ function parseHttpsPort(val) {
export default {
port: Number(process.env.PORT) || 4208,
httpsPort: parseHttpsPort(process.env.HTTPS_PORT),
sqlStudioPort: Number(process.env.SQL_STUDIO_PORT) || 4210,
httpsKeyPath: process.env.HTTPS_KEY_PATH || resolve(__dirname, '../../certs/key.pem'),
httpsCertPath: process.env.HTTPS_CERT_PATH || resolve(__dirname, '../../certs/cert.pem'),
logLevel: process.env.LOG_LEVEL || 'debug',

357
src/sqlstudio/dbHelper.js Normal file
View File

@@ -0,0 +1,357 @@
import env from '../config/env.js';
import { getProvider } from '../db/store.js';
/**
* Returns dialect ('sqlite' or 'mssql')
*/
export function getDialect(provider) {
if (provider?.dialect === 'sqlite3') return 'sqlite';
if (provider?.dialect === 'mssql') return 'mssql';
return env.dbProvider === 'sqlite' || env.dbProvider === 'better-sqlite3' ? 'sqlite' : 'mssql';
}
function sanitizeValue(val) {
if (val === null || val === undefined) return null;
if (Buffer.isBuffer(val) || val instanceof Uint8Array) {
return `<Buffer ${val.length} bytes>`;
}
if (typeof val === 'bigint') {
return val.toString();
}
return val;
}
export function sanitizeRow(row) {
if (!row || typeof row !== 'object') return row;
const sanitized = {};
for (const [key, val] of Object.entries(row)) {
sanitized[key] = sanitizeValue(val);
}
return sanitized;
}
export async function getDatabaseInfo() {
const provider = await getProvider();
const dialect = getDialect(provider);
return {
dialect,
database: dialect === 'sqlite' ? env.sqlite.filename : env.mssql.database,
server: dialect === 'sqlite' ? 'local' : (env.mssql.server || 'localhost'),
port: env.sqlStudioPort,
};
}
/**
* Returns list of tables in the database with their row counts.
*/
export async function getTables() {
const provider = await getProvider();
const dialect = getDialect(provider);
let tablesQuery = '';
if (dialect === 'sqlite') {
tablesQuery = `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name ASC;`;
} else {
tablesQuery = `SELECT TABLE_NAME as name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME NOT LIKE 'sys%' AND TABLE_NAME != 'sysdiagrams' ORDER BY TABLE_NAME ASC;`;
}
const result = await provider.query(tablesQuery);
const tableList = (result.rows || []).map((r) => ({
name: r.name,
rowCount: 0,
}));
// Fetch row counts concurrently
await Promise.all(
tableList.map(async (t) => {
try {
const countRes = await provider.query(`SELECT COUNT(*) as cnt FROM [${t.name}]`);
t.rowCount = Number(countRes.rows[0]?.cnt ?? 0);
} catch {
t.rowCount = 0;
}
})
);
return tableList;
}
/**
* Returns column schema for a given table.
*/
export async function getTableSchema(tableName) {
const provider = await getProvider();
const dialect = getDialect(provider);
if (dialect === 'sqlite') {
const res = await provider.query(`PRAGMA table_info("${tableName}")`);
return (res.rows || []).map((row) => ({
name: row.name,
type: (row.type || 'TEXT').toUpperCase(),
isNullable: row.notnull === 0,
defaultValue: row.dflt_value,
isPrimaryKey: Boolean(row.pk),
pkOrder: row.pk || 0,
}));
} else {
const sql = `
SELECT
c.COLUMN_NAME as name,
c.DATA_TYPE as type,
CASE WHEN c.IS_NULLABLE = 'YES' THEN 1 ELSE 0 END as isNullable,
c.COLUMN_DEFAULT as defaultValue,
CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END as isPrimaryKey
FROM INFORMATION_SCHEMA.COLUMNS c
LEFT JOIN (
SELECT ku.TABLE_NAME, ku.COLUMN_NAME
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku
ON tc.CONSTRAINT_TYPE = 'PRIMARY KEY' AND tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME
) pk ON c.TABLE_NAME = pk.TABLE_NAME AND c.COLUMN_NAME = pk.COLUMN_NAME
WHERE c.TABLE_NAME = @tableName
ORDER BY c.ORDINAL_POSITION;
`;
const res = await provider.query(sql, { tableName });
return (res.rows || []).map((row) => ({
name: row.name,
type: (row.type || 'VARCHAR').toUpperCase(),
isNullable: Boolean(row.isNullable),
defaultValue: row.defaultValue,
isPrimaryKey: Boolean(row.isPrimaryKey),
pkOrder: row.isPrimaryKey ? 1 : 0,
}));
}
}
/**
* Browse table data with pagination, sorting, and search.
*/
export async function getTableData(tableName, { page = 1, pageSize = 50, sortBy, sortDir = 'ASC', search = '' } = {}) {
const provider = await getProvider();
const dialect = getDialect(provider);
// Verify table exists
const tables = await getTables();
const tableExists = tables.some((t) => t.name.toLowerCase() === tableName.toLowerCase());
if (!tableExists) {
throw new Error(`Table "${tableName}" not found`);
}
const schema = await getTableSchema(tableName);
if (!schema.length) {
throw new Error(`No columns found for table "${tableName}"`);
}
const columnNames = schema.map((c) => c.name);
const primaryKeys = schema.filter((c) => c.isPrimaryKey).map((c) => c.name);
const safePage = Math.max(1, parseInt(page, 10) || 1);
const safePageSize = Math.min(500, Math.max(1, parseInt(pageSize, 10) || 50));
const offset = (safePage - 1) * safePageSize;
// Validate sorting column
let safeSortCol = sortBy && columnNames.includes(sortBy) ? sortBy : null;
if (!safeSortCol) {
safeSortCol = primaryKeys.length > 0 ? primaryKeys[0] : columnNames[0];
}
const safeSortDir = String(sortDir).toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
// Search filter
const trimmedSearch = (search || '').trim();
let whereClause = '';
const queryParams = {};
if (trimmedSearch) {
// Build search across text/varchar/char columns
const searchableCols = schema.filter((c) => {
const t = c.type.toLowerCase();
return t.includes('char') || t.includes('text') || t.includes('string');
});
const targetCols = searchableCols.length > 0 ? searchableCols : schema;
const searchConditions = targetCols.map((c, i) => {
const paramKey = `search${i}`;
queryParams[paramKey] = `%${trimmedSearch}%`;
return `CAST([${c.name}] AS NVARCHAR(MAX)) LIKE @${paramKey}`;
});
whereClause = `WHERE (${searchConditions.join(' OR ')})`;
}
// Count query
const countSql = `SELECT COUNT(*) as total FROM [${tableName}] ${whereClause}`;
const countRes = await provider.query(countSql, queryParams);
const total = Number(countRes.rows[0]?.total ?? 0);
// Data query
let dataSql = '';
if (dialect === 'sqlite') {
dataSql = `SELECT * FROM [${tableName}] ${whereClause} ORDER BY [${safeSortCol}] ${safeSortDir} LIMIT ${safePageSize} OFFSET ${offset};`;
} else {
dataSql = `SELECT * FROM [${tableName}] ${whereClause} ORDER BY [${safeSortCol}] ${safeSortDir} OFFSET ${offset} ROWS FETCH NEXT ${safePageSize} ROWS ONLY;`;
}
const dataRes = await provider.query(dataSql, queryParams);
const rows = (dataRes.rows || []).map((row) => sanitizeRow(row));
return {
tableName,
columns: schema,
rows,
total,
page: safePage,
pageSize: safePageSize,
totalPages: Math.ceil(total / safePageSize) || 1,
primaryKeys,
sortBy: safeSortCol,
sortDir: safeSortDir,
};
}
/**
* Insert a new row into the table.
*/
export async function insertRow(tableName, rowData) {
const provider = await getProvider();
const schema = await getTableSchema(tableName);
const validColNames = new Set(schema.map((c) => c.name));
// Filter to valid columns only and strip undefined / empty auto-increment PKs
const cleanData = {};
for (const [col, val] of Object.entries(rowData || {})) {
if (!validColNames.has(col)) continue;
const colDef = schema.find((c) => c.name === col);
if (colDef?.isPrimaryKey && (val === '' || val === null || val === undefined)) {
// omit auto-increment primary key so provider assigns it
continue;
}
cleanData[col] = val === '' ? (colDef?.isNullable ? null : '') : val;
}
const cols = Object.keys(cleanData);
if (!cols.length) {
throw new Error('No valid column data provided for insert');
}
const placeholders = cols.map((_, i) => `@p${i}`).join(', ');
const params = {};
cols.forEach((col, i) => {
params[`p${i}`] = cleanData[col];
});
const sql = `INSERT INTO [${tableName}] (${cols.map((c) => `[${c}]`).join(', ')}) VALUES (${placeholders});`;
const result = await provider.query(sql, params);
return {
success: true,
insertId: result.insertId,
rowsAffected: result.rowsAffected,
};
}
/**
* Update an existing row.
*/
export async function updateRow(tableName, { primaryKey, changes } = {}) {
const provider = await getProvider();
const schema = await getTableSchema(tableName);
const validColNames = new Set(schema.map((c) => c.name));
if (!primaryKey || typeof primaryKey !== 'object' || !Object.keys(primaryKey).length) {
throw new Error('Primary key specification required for updating a row');
}
if (!changes || typeof changes !== 'object' || !Object.keys(changes).length) {
throw new Error('No changes specified');
}
// Build SET clause
const setCols = Object.keys(changes).filter((c) => validColNames.has(c));
if (!setCols.length) {
throw new Error('No valid columns in changes');
}
const params = {};
const setParts = setCols.map((c, i) => {
params[`s${i}`] = changes[c];
return `[${c}] = @s${i}`;
});
// Build WHERE clause
const pkCols = Object.keys(primaryKey).filter((c) => validColNames.has(c));
const whereParts = pkCols.map((c, i) => {
params[`pk${i}`] = primaryKey[c];
return `[${c}] = @pk${i}`;
});
const sql = `UPDATE [${tableName}] SET ${setParts.join(', ')} WHERE ${whereParts.join(' AND ')};`;
const result = await provider.query(sql, params);
return {
success: true,
rowsAffected: result.rowsAffected,
};
}
/**
* Delete a row by primary key.
*/
export async function deleteRow(tableName, { primaryKey } = {}) {
const provider = await getProvider();
const schema = await getTableSchema(tableName);
const validColNames = new Set(schema.map((c) => c.name));
if (!primaryKey || typeof primaryKey !== 'object' || !Object.keys(primaryKey).length) {
throw new Error('Primary key specification required for deleting a row');
}
const pkCols = Object.keys(primaryKey).filter((c) => validColNames.has(c));
if (!pkCols.length) {
throw new Error('No valid primary key columns provided');
}
const params = {};
const whereParts = pkCols.map((c, i) => {
params[`pk${i}`] = primaryKey[c];
return `[${c}] = @pk${i}`;
});
const sql = `DELETE FROM [${tableName}] WHERE ${whereParts.join(' AND ')};`;
const result = await provider.query(sql, params);
return {
success: true,
rowsAffected: result.rowsAffected,
};
}
/**
* Execute raw arbitrary SQL query.
*/
export async function executeRawQuery(sql) {
const provider = await getProvider();
const trimmed = (sql || '').trim();
if (!trimmed) {
throw new Error('Query cannot be empty');
}
const start = performance.now();
try {
const result = await provider.query(trimmed, {});
const executionTimeMs = Math.round((performance.now() - start) * 100) / 100;
const rows = (result.rows || []).map((row) => sanitizeRow(row));
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
return {
success: true,
rows,
columns,
rowsAffected: result.rowsAffected ?? 0,
insertId: result.insertId ?? null,
executionTimeMs,
};
} catch (err) {
const executionTimeMs = Math.round((performance.now() - start) * 100) / 100;
return {
success: false,
error: err.message,
executionTimeMs,
};
}
}

1326
src/sqlstudio/public/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dash SQL Studio</title>
<!-- Google Fonts: Inter & JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app" class="app-container">
<!-- Top Header -->
<header class="app-header">
<div class="header-left">
<div class="brand">
<div class="brand-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
</div>
<span class="brand-title">Dash SQL Studio</span>
</div>
<div class="dev-badge">DEV MODE • NO AUTH</div>
</div>
<div class="header-center">
<div id="db-status-pill" class="db-status-pill">
<span class="status-dot"></span>
<span id="db-engine-name" class="engine-badge">Connecting...</span>
<span id="db-target-name" class="db-target">...</span>
</div>
</div>
<div class="header-right">
<button id="btn-run-query-header" class="btn btn-primary" title="Execute Query (Ctrl+Enter or F5)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
<span>Execute</span>
<kbd>Ctrl+↵</kbd>
</button>
<button id="btn-new-query" class="btn btn-secondary" title="Open new query tab">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span>New Query</span>
</button>
<div class="dropdown">
<button id="btn-templates-dropdown" class="btn btn-secondary dropdown-toggle">
<span>Snippets</span>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div id="templates-menu" class="dropdown-menu">
<button class="dropdown-item" data-template="select">SELECT * FROM [table] LIMIT 50</button>
<button class="dropdown-item" data-template="count">SELECT COUNT(*) FROM [table]</button>
<button class="dropdown-item" data-template="insert">INSERT INTO [table] (...) VALUES (...)</button>
<button class="dropdown-item" data-template="update">UPDATE [table] SET col = val WHERE id = ...</button>
<button class="dropdown-item" data-template="delete">DELETE FROM [table] WHERE id = ...</button>
</div>
</div>
<button id="btn-history" class="btn btn-icon" title="Query History">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
</div>
</header>
<!-- Main Workspace -->
<div class="main-layout">
<!-- Left Sidebar: Object Explorer -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"></path>
</svg>
<span>OBJECT EXPLORER</span>
<span id="table-count-badge" class="badge">0</span>
</div>
<button id="btn-refresh-tables" class="btn-icon-subtle" title="Refresh Table List">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"></path>
</svg>
</button>
</div>
<div class="sidebar-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" id="table-search-input" placeholder="Filter tables..." autocomplete="off">
<button id="btn-clear-search" class="btn-clear-search hidden">&times;</button>
</div>
<div id="tables-tree" class="tables-tree">
<div class="tree-loading">Loading tables...</div>
</div>
<div class="sidebar-footer">
<div class="sidebar-info-row">
<span class="info-label">Port:</span>
<span id="footer-port" class="info-val">...</span>
</div>
<div class="sidebar-info-row">
<span class="info-label">Dialect:</span>
<span id="footer-dialect" class="info-val">...</span>
</div>
</div>
</aside>
<!-- Center / Right Work Area -->
<main class="workspace">
<!-- Tabs Header Bar -->
<div class="tab-strip-container">
<div id="tab-strip" class="tab-strip"></div>
<button id="btn-add-tab" class="btn-add-tab" title="New Query Tab">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<!-- Tab Views Container -->
<div id="tab-views" class="tab-views"></div>
</main>
</div>
<!-- Modals -->
<!-- Add Row Modal -->
<div id="modal-add-row" class="modal-overlay hidden">
<div class="modal-dialog">
<div class="modal-header">
<h3 class="modal-title">Insert Record &bull; <span id="add-modal-table-name"></span></h3>
<button class="modal-close" data-close-modal>&times;</button>
</div>
<form id="form-add-row" class="modal-body">
<div id="add-row-fields" class="form-grid"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Cancel</button>
<button type="submit" class="btn btn-primary">Insert Record</button>
</div>
</form>
</div>
</div>
<!-- Edit Row Modal -->
<div id="modal-edit-row" class="modal-overlay hidden">
<div class="modal-dialog">
<div class="modal-header">
<h3 class="modal-title">Edit Record &bull; <span id="edit-modal-table-name"></span></h3>
<button class="modal-close" data-close-modal>&times;</button>
</div>
<form id="form-edit-row" class="modal-body">
<div id="edit-row-fields" class="form-grid"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Delete Row Modal -->
<div id="modal-delete-row" class="modal-overlay hidden">
<div class="modal-dialog modal-dialog-sm">
<div class="modal-header">
<h3 class="modal-title text-danger">Confirm Delete</h3>
<button class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to permanently delete this row from <strong id="delete-modal-table-name"></strong>?</p>
<div id="delete-modal-summary" class="delete-summary-box"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Cancel</button>
<button id="btn-confirm-delete" type="button" class="btn btn-danger">Delete Record</button>
</div>
</div>
</div>
</div>
<!-- Query History Modal -->
<div id="modal-history" class="modal-overlay hidden">
<div class="modal-dialog modal-dialog-lg">
<div class="modal-header">
<h3 class="modal-title">Query History</h3>
<div class="modal-header-actions">
<button id="btn-clear-history" class="btn btn-secondary btn-sm">Clear History</button>
<button class="modal-close" data-close-modal>&times;</button>
</div>
</div>
<div class="modal-body">
<div id="history-list" class="history-list"></div>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toast-container" class="toast-container"></div>
</div>
<script src="app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

176
src/sqlstudio/server.js Normal file
View File

@@ -0,0 +1,176 @@
import express from 'express';
import http from 'http';
import path from 'path';
import { fileURLToPath } from 'url';
import env from '../config/env.js';
import { createLogger } from '../util/logger.js';
import * as dbHelper from './dbHelper.js';
const studioLog = createLogger('sqlstudio');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let studioServer;
let studioApp;
export function isDevMode() {
return (
process.env.npm_lifecycle_event === 'dev' ||
process.env.DEV_SQL_STUDIO === 'true' ||
process.env.DEV_MODE === 'true'
);
}
export function buildStudioApp() {
const app = express();
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Unrestricted local CORS
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
// 1. Status
app.get('/api/status', async (req, res) => {
try {
const info = await dbHelper.getDatabaseInfo();
res.json(info);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 2. Tables list
app.get('/api/tables', async (req, res) => {
try {
const tables = await dbHelper.getTables();
res.json({ tables });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 3. Table schema
app.get('/api/tables/:table/schema', async (req, res) => {
try {
const schema = await dbHelper.getTableSchema(req.params.table);
res.json({ tableName: req.params.table, columns: schema });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 4. Table data (browsing)
app.get('/api/tables/:table/data', async (req, res) => {
try {
const data = await dbHelper.getTableData(req.params.table, {
page: req.query.page,
pageSize: req.query.pageSize,
sortBy: req.query.sortBy,
sortDir: req.query.sortDir,
search: req.query.search,
});
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 5. Insert row
app.post('/api/tables/:table/row', async (req, res) => {
try {
const result = await dbHelper.insertRow(req.params.table, req.body.row || req.body);
res.json(result);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// 6. Update row
app.put('/api/tables/:table/row', async (req, res) => {
try {
const { primaryKey, changes } = req.body;
const result = await dbHelper.updateRow(req.params.table, { primaryKey, changes });
res.json(result);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// 7. Delete row
app.delete('/api/tables/:table/row', async (req, res) => {
try {
const { primaryKey } = req.body;
const result = await dbHelper.deleteRow(req.params.table, { primaryKey });
res.json(result);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// 8. Raw SQL Query execution
app.post('/api/query', async (req, res) => {
try {
const { sql } = req.body;
const result = await dbHelper.executeRawQuery(sql);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Static assets
const publicDir = path.join(__dirname, 'public');
app.use(express.static(publicDir));
// Fallback to index.html for SPA routing
app.use((req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
return app;
}
export async function startSqlStudio() {
if (studioServer) {
return studioServer;
}
const app = buildStudioApp();
studioApp = app;
const serverInstance = http.createServer(app);
await new Promise((resolve, reject) => {
serverInstance.once('listening', () => resolve());
serverInstance.once('error', (err) => {
studioLog.error(`Failed to listen on port ${env.sqlStudioPort}:`, err.message);
reject(err);
});
serverInstance.listen(env.sqlStudioPort);
});
studioServer = serverInstance;
studioLog.info(`SQL Studio running on http://localhost:${env.sqlStudioPort} (dev mode, no auth)`);
return studioServer;
}
export async function stopSqlStudio() {
if (!studioServer) return;
const s = studioServer;
studioServer = undefined;
studioApp = undefined;
await new Promise((resolve, reject) => {
s.close((err) => (err ? reject(err) : resolve()));
s.closeAllConnections();
});
studioLog.info('SQL Studio stopped');
}