u
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...".
|
||||
|
||||
@@ -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
357
src/sqlstudio/dbHelper.js
Normal 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
1326
src/sqlstudio/public/app.js
Normal file
File diff suppressed because it is too large
Load Diff
218
src/sqlstudio/public/index.html
Normal file
218
src/sqlstudio/public/index.html
Normal 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">×</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 • <span id="add-modal-table-name"></span></h3>
|
||||
<button class="modal-close" data-close-modal>×</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 • <span id="edit-modal-table-name"></span></h3>
|
||||
<button class="modal-close" data-close-modal>×</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>×</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>×</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>
|
||||
1256
src/sqlstudio/public/style.css
Normal file
1256
src/sqlstudio/public/style.css
Normal file
File diff suppressed because it is too large
Load Diff
176
src/sqlstudio/server.js
Normal file
176
src/sqlstudio/server.js
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user