diff --git a/.env.example b/.env.example index 795c73d..ddc1b2b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/package.json b/package.json index 0f0615e..682756e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/index.js b/src/api/index.js index bf1f00b..5cc1a73 100644 --- a/src/api/index.js +++ b/src/api/index.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...". diff --git a/src/config/env.js b/src/config/env.js index 2932469..776bedb 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -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', diff --git a/src/sqlstudio/dbHelper.js b/src/sqlstudio/dbHelper.js new file mode 100644 index 0000000..5ba7c10 --- /dev/null +++ b/src/sqlstudio/dbHelper.js @@ -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 ``; + } + 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, + }; + } +} diff --git a/src/sqlstudio/public/app.js b/src/sqlstudio/public/app.js new file mode 100644 index 0000000..5e520c8 --- /dev/null +++ b/src/sqlstudio/public/app.js @@ -0,0 +1,1326 @@ +/** + * Dash SQL Studio — Web Database Studio + */ + +(function () { + 'use strict'; + + // State + let dbStatus = null; + let tablesList = []; + let tableSchemasCache = {}; + let tabs = []; + let activeTabId = null; + let tabCounter = 1; + let queryHistory = []; + + try { + const saved = localStorage.getItem('dash_sql_studio_history'); + if (saved) queryHistory = JSON.parse(saved); + } catch (e) { + queryHistory = []; + } + + // DOM Elements + const elDbStatusPill = document.getElementById('db-status-pill'); + const elDbEngineName = document.getElementById('db-engine-name'); + const elDbTargetName = document.getElementById('db-target-name'); + const elFooterPort = document.getElementById('footer-port'); + const elFooterDialect = document.getElementById('footer-dialect'); + + const elBtnRunHeader = document.getElementById('btn-run-query-header'); + const elBtnNewQuery = document.getElementById('btn-new-query'); + const elBtnTemplates = document.getElementById('btn-templates-dropdown'); + const elTemplatesMenu = document.getElementById('templates-menu'); + const elBtnHistory = document.getElementById('btn-history'); + + const elTableCountBadge = document.getElementById('table-count-badge'); + const elBtnRefreshTables = document.getElementById('btn-refresh-tables'); + const elTableSearchInput = document.getElementById('table-search-input'); + const elBtnClearSearch = document.getElementById('btn-clear-search'); + const elTablesTree = document.getElementById('tables-tree'); + + const elTabStrip = document.getElementById('tab-strip'); + const elBtnAddTab = document.getElementById('btn-add-tab'); + const elTabViews = document.getElementById('tab-views'); + + // Modals + const elModalAddRow = document.getElementById('modal-add-row'); + const elFormAddRow = document.getElementById('form-add-row'); + const elAddModalTableName = document.getElementById('add-modal-table-name'); + const elAddRowFields = document.getElementById('add-row-fields'); + + const elModalEditRow = document.getElementById('modal-edit-row'); + const elFormEditRow = document.getElementById('form-edit-row'); + const elEditModalTableName = document.getElementById('edit-modal-table-name'); + const elEditRowFields = document.getElementById('edit-row-fields'); + + const elModalDeleteRow = document.getElementById('modal-delete-row'); + const elDeleteModalTableName = document.getElementById('delete-modal-table-name'); + const elDeleteModalSummary = document.getElementById('delete-modal-summary'); + const elBtnConfirmDelete = document.getElementById('btn-confirm-delete'); + + const elModalHistory = document.getElementById('modal-history'); + const elHistoryList = document.getElementById('history-list'); + const elBtnClearHistory = document.getElementById('btn-clear-history'); + + const elToastContainer = document.getElementById('toast-container'); + + // Active modal pending action storage + let pendingDelete = null; + let pendingEdit = null; + let pendingAdd = null; + + // ========================================================================== + // Toast Alerts + // ========================================================================== + function showToast(message, type = 'info') { + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + elToastContainer.appendChild(toast); + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + toast.style.transition = 'all 0.2s ease'; + setTimeout(() => toast.remove(), 250); + }, 3200); + } + + // ========================================================================== + // API Fetch Helpers + // ========================================================================== + async function apiGet(endpoint) { + const res = await fetch(endpoint); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP error ${res.status}`); + return data; + } + + async function apiPost(endpoint, body) { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP error ${res.status}`); + return data; + } + + async function apiPut(endpoint, body) { + const res = await fetch(endpoint, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP error ${res.status}`); + return data; + } + + async function apiDelete(endpoint, body) { + const res = await fetch(endpoint, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP error ${res.status}`); + return data; + } + + // ========================================================================== + // Database Status & Tables + // ========================================================================== + async function loadDbStatus() { + try { + dbStatus = await apiGet('/api/status'); + elDbEngineName.textContent = dbStatus.dialect.toUpperCase(); + elDbTargetName.textContent = dbStatus.database || dbStatus.server; + elFooterPort.textContent = String(dbStatus.port || window.location.port || 4210); + elFooterDialect.textContent = dbStatus.dialect; + } catch (err) { + elDbEngineName.textContent = 'OFFLINE'; + elDbTargetName.textContent = err.message; + showToast('Failed to connect to database: ' + err.message, 'error'); + } + } + + async function loadTables() { + elTablesTree.innerHTML = '
Loading tables...
'; + try { + const data = await apiGet('/api/tables'); + tablesList = data.tables || []; + elTableCountBadge.textContent = tablesList.length; + renderTablesTree(tablesList); + } catch (err) { + elTablesTree.innerHTML = `
Error loading tables: ${escapeHtml(err.message)}
`; + showToast('Error loading tables: ' + err.message, 'error'); + } + } + + function renderTablesTree(tables) { + const filter = (elTableSearchInput.value || '').trim().toLowerCase(); + const filtered = tables.filter((t) => t.name.toLowerCase().includes(filter)); + + if (filtered.length === 0) { + elTablesTree.innerHTML = '
No matching tables
'; + return; + } + + elTablesTree.innerHTML = ''; + filtered.forEach((t) => { + const node = document.createElement('div'); + node.className = 'table-node'; + node.dataset.tableName = t.name; + + node.innerHTML = ` +
+
+ + + + + + + + + ${escapeHtml(t.name)} + ${t.rowCount.toLocaleString()} +
+
+ + +
+
+
+ `; + + // Header click toggles expand or opens table + const header = node.querySelector('.table-node-header'); + const infoArea = node.querySelector('.table-node-info'); + const btnBrowse = node.querySelector('.btn-browse-table'); + const btnQuery = node.querySelector('.btn-query-table'); + + infoArea.addEventListener('click', (e) => { + e.stopPropagation(); + toggleTableExpand(node, t.name); + }); + + header.addEventListener('dblclick', () => { + openTableTab(t.name); + }); + + btnBrowse.addEventListener('click', (e) => { + e.stopPropagation(); + openTableTab(t.name); + }); + + btnQuery.addEventListener('click', (e) => { + e.stopPropagation(); + const sql = dbStatus?.dialect === 'mssql' + ? `SELECT TOP 50 * FROM [${t.name}];` + : `SELECT * FROM [${t.name}] LIMIT 50;`; + openQueryTab(sql, `Query: ${t.name}`); + }); + + elTablesTree.appendChild(node); + }); + } + + async function toggleTableExpand(node, tableName) { + const isExpanded = node.classList.contains('expanded'); + if (isExpanded) { + node.classList.remove('expanded'); + return; + } + + node.classList.add('expanded'); + const sublist = node.querySelector('.table-columns-sublist'); + + if (tableSchemasCache[tableName]) { + renderColumnsSublist(sublist, tableSchemasCache[tableName]); + return; + } + + sublist.innerHTML = '
Loading columns...
'; + try { + const data = await apiGet(`/api/tables/${encodeURIComponent(tableName)}/schema`); + tableSchemasCache[tableName] = data.columns || []; + renderColumnsSublist(sublist, tableSchemasCache[tableName]); + } catch (err) { + sublist.innerHTML = `
${escapeHtml(err.message)}
`; + } + } + + function renderColumnsSublist(container, columns) { + if (!columns.length) { + container.innerHTML = '
No columns
'; + return; + } + container.innerHTML = columns + .map( + (c) => ` +
+
+ ${ + c.isPrimaryKey + ? ` + + + ` + : '' + } + ${escapeHtml(c.name)} +
+ ${escapeHtml(c.type)} +
+ ` + ) + .join(''); + } + + // ========================================================================== + // Tab Management + // ========================================================================== + function createTab({ type, title, state = {} }) { + const tabId = 'tab_' + tabCounter++; + const tab = { id: tabId, type, title, state }; + tabs.push(tab); + + renderTabsHeader(); + renderTabView(tab); + activateTab(tabId); + return tab; + } + + function activateTab(tabId) { + activeTabId = tabId; + document.querySelectorAll('.tab').forEach((el) => { + el.classList.toggle('active', el.dataset.tabId === tabId); + }); + document.querySelectorAll('.tab-view').forEach((el) => { + el.classList.toggle('active', el.id === `view_${tabId}`); + }); + } + + function closeTab(tabId) { + const index = tabs.findIndex((t) => t.id === tabId); + if (index === -1) return; + + tabs.splice(index, 1); + const viewEl = document.getElementById(`view_${tabId}`); + if (viewEl) viewEl.remove(); + + if (tabs.length === 0) { + openQueryTab(); + } else if (activeTabId === tabId) { + const nextIndex = Math.max(0, index - 1); + activateTab(tabs[nextIndex].id); + } + renderTabsHeader(); + } + + function renderTabsHeader() { + elTabStrip.innerHTML = ''; + tabs.forEach((t) => { + const tabEl = document.createElement('div'); + tabEl.className = `tab ${t.id === activeTabId ? 'active' : ''}`; + tabEl.dataset.tabId = t.id; + + let iconSvg = ''; + if (t.type === 'query') { + iconSvg = ` + + + `; + } else { + iconSvg = ` + + + + + `; + } + + tabEl.innerHTML = ` + ${iconSvg} + ${escapeHtml(t.title)} + + `; + + tabEl.addEventListener('click', (e) => { + if (e.target.closest('.tab-close')) return; + activateTab(t.id); + }); + + tabEl.querySelector('.tab-close').addEventListener('click', (e) => { + e.stopPropagation(); + closeTab(t.id); + }); + + elTabStrip.appendChild(tabEl); + }); + } + + function renderTabView(tab) { + const viewEl = document.createElement('div'); + viewEl.className = 'tab-view'; + viewEl.id = `view_${tab.id}`; + + if (tab.type === 'query') { + renderQueryTabViewContent(viewEl, tab); + } else if (tab.type === 'table') { + renderTableBrowserViewContent(viewEl, tab); + } + + elTabViews.appendChild(viewEl); + } + + // ========================================================================== + // Query Editor Tab View + // ========================================================================== + function openQueryTab(initialSql = '', title = null) { + const count = tabs.filter((t) => t.type === 'query').length + 1; + const tabTitle = title || `Query ${count}`; + const defaultSql = initialSql || '-- Write SQL query here\nSELECT 1 as test;'; + return createTab({ + type: 'query', + title: tabTitle, + state: { sql: defaultSql, results: null }, + }); + } + + function renderQueryTabViewContent(container, tab) { + container.className = 'tab-view query-tab-view'; + container.innerHTML = ` +
+
+ + + +
+
+ Shortcuts: Ctrl+Enter (Run) +
+
+ +
+ +
+ +
+
+
+ Status: Ready +
+
+ + + +
+
+
+
+ + + + + + Press Execute or Ctrl+Enter to run query +
+
+
+ `; + + const textarea = container.querySelector('.editor-textarea'); + const btnRun = container.querySelector('.btn-run-query'); + const btnClear = container.querySelector('.btn-clear-sql'); + const selectQuickTable = container.querySelector('.select-table-quick'); + const resultsBody = container.querySelector('.results-body'); + const resultsMeta = container.querySelector('.results-meta'); + const btnExportCsv = container.querySelector('.btn-export-csv'); + const btnExportJson = container.querySelector('.btn-export-json'); + + // Keep state in sync + textarea.addEventListener('input', () => { + tab.state.sql = textarea.value; + }); + + // Indent with tab key + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Tab') { + e.preventDefault(); + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end); + textarea.selectionStart = textarea.selectionEnd = start + 2; + tab.state.sql = textarea.value; + } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + executeQuery(); + } else if (e.key === 'F5') { + e.preventDefault(); + executeQuery(); + } + }); + + btnClear.addEventListener('click', () => { + textarea.value = ''; + tab.state.sql = ''; + textarea.focus(); + }); + + selectQuickTable.addEventListener('change', () => { + const val = selectQuickTable.value; + if (!val) return; + insertAtCursor(textarea, `[${val}] `); + tab.state.sql = textarea.value; + selectQuickTable.value = ''; + }); + + btnRun.addEventListener('click', executeQuery); + + async function executeQuery() { + const sqlToRun = textarea.value.trim(); + if (!sqlToRun) { + showToast('Query is empty', 'error'); + return; + } + + btnRun.disabled = true; + resultsMeta.innerHTML = 'Executing query...'; + + try { + const result = await apiPost('/api/query', { sql: sqlToRun }); + tab.state.results = result; + + // Record to query history + addQueryToHistory(sqlToRun); + + if (!result.success) { + resultsMeta.innerHTML = ` + Status: Error + Duration: ${result.executionTimeMs} ms + `; + resultsBody.innerHTML = `
${escapeHtml(result.error)}
`; + btnExportCsv.style.display = 'none'; + btnExportJson.style.display = 'none'; + } else { + resultsMeta.innerHTML = ` + Status: Success + Duration: ${result.executionTimeMs} ms + Rows: ${(result.rows || []).length} + ${ + result.rowsAffected !== undefined + ? `Affected: ${result.rowsAffected}` + : '' + } + `; + + if (result.rows && result.rows.length > 0) { + btnExportCsv.style.display = 'inline-flex'; + btnExportJson.style.display = 'inline-flex'; + renderGrid(resultsBody, result.columns, result.rows); + } else { + btnExportCsv.style.display = 'none'; + btnExportJson.style.display = 'none'; + resultsBody.innerHTML = ` +
+ + + + + Query executed successfully (${result.rowsAffected || 0} rows affected) +
`; + } + } + } catch (err) { + resultsMeta.innerHTML = `Error: ${escapeHtml(err.message)}`; + resultsBody.innerHTML = `
${escapeHtml(err.message)}
`; + } finally { + btnRun.disabled = false; + } + } + + btnExportCsv.addEventListener('click', () => { + if (tab.state.results?.rows) { + exportToCsv('query_results.csv', tab.state.results.columns, tab.state.results.rows); + } + }); + + btnExportJson.addEventListener('click', () => { + if (tab.state.results?.rows) { + exportToJson('query_results.json', tab.state.results.rows); + } + }); + } + + // ========================================================================== + // Table Browser Tab View + // ========================================================================== + function openTableTab(tableName) { + const existing = tabs.find((t) => t.type === 'table' && t.state.tableName === tableName); + if (existing) { + activateTab(existing.id); + return existing; + } + + return createTab({ + type: 'table', + title: tableName, + state: { + tableName, + page: 1, + pageSize: 50, + sortBy: '', + sortDir: 'ASC', + search: '', + data: null, + }, + }); + } + + function renderTableBrowserViewContent(container, tab) { + container.className = 'tab-view table-browser-view'; + container.innerHTML = ` +
+
+
+ + + + + +
+ + + +
+ +
+
+ Show + + + Page 1 of 1 + + 0 rows +
+ + +
+
+ +
+
Loading table data...
+
+ `; + + const searchInput = container.querySelector('.browser-search-input'); + const btnAddRow = container.querySelector('.btn-add-row-modal'); + const btnRefresh = container.querySelector('.btn-refresh-browser'); + const btnQuery = container.querySelector('.btn-open-query-from-table'); + const selectPageSize = container.querySelector('.select-page-size'); + const btnPrevPage = container.querySelector('.btn-prev-page'); + const btnNextPage = container.querySelector('.btn-next-page'); + const pageIndicator = container.querySelector('.page-indicator'); + const totalRecordsBadge = container.querySelector('.total-records-badge'); + const gridWrapper = container.querySelector('.grid-wrapper'); + const btnExportCsv = container.querySelector('.btn-export-table-csv'); + const btnExportJson = container.querySelector('.btn-export-table-json'); + + // Debounced search + let searchDebounce = null; + searchInput.addEventListener('input', () => { + clearTimeout(searchDebounce); + searchDebounce = setTimeout(() => { + tab.state.search = searchInput.value; + tab.state.page = 1; + fetchTableData(); + }, 350); + }); + + selectPageSize.addEventListener('change', () => { + tab.state.pageSize = parseInt(selectPageSize.value, 10); + tab.state.page = 1; + fetchTableData(); + }); + + btnPrevPage.addEventListener('click', () => { + if (tab.state.page > 1) { + tab.state.page--; + fetchTableData(); + } + }); + + btnNextPage.addEventListener('click', () => { + if (tab.state.data && tab.state.page < tab.state.data.totalPages) { + tab.state.page++; + fetchTableData(); + } + }); + + btnRefresh.addEventListener('click', () => { + fetchTableData(); + }); + + btnQuery.addEventListener('click', () => { + const sql = dbStatus?.dialect === 'mssql' + ? `SELECT TOP 50 * FROM [${tab.state.tableName}];` + : `SELECT * FROM [${tab.state.tableName}] LIMIT 50;`; + openQueryTab(sql, `Query: ${tab.state.tableName}`); + }); + + btnAddRow.addEventListener('click', () => { + openAddRowModal(tab.state.tableName, tab.state.data?.columns || [], () => { + fetchTableData(); + }); + }); + + btnExportCsv.addEventListener('click', () => { + if (tab.state.data?.rows) { + exportToCsv(`${tab.state.tableName}.csv`, tab.state.data.columns.map((c) => c.name), tab.state.data.rows); + } + }); + + btnExportJson.addEventListener('click', () => { + if (tab.state.data?.rows) { + exportToJson(`${tab.state.tableName}.json`, tab.state.data.rows); + } + }); + + async function fetchTableData() { + gridWrapper.innerHTML = '
Loading table data...
'; + try { + const queryParams = new URLSearchParams({ + page: tab.state.page, + pageSize: tab.state.pageSize, + sortBy: tab.state.sortBy || '', + sortDir: tab.state.sortDir || 'ASC', + search: tab.state.search || '', + }); + + const data = await apiGet(`/api/tables/${encodeURIComponent(tab.state.tableName)}/data?${queryParams.toString()}`); + tab.state.data = data; + + pageIndicator.textContent = `Page ${data.page} of ${data.totalPages}`; + totalRecordsBadge.textContent = `${data.total.toLocaleString()} rows`; + + btnPrevPage.disabled = data.page <= 1; + btnNextPage.disabled = data.page >= data.totalPages; + + renderEditableGrid(gridWrapper, data, tab); + } catch (err) { + gridWrapper.innerHTML = `
${escapeHtml(err.message)}
`; + showToast('Error loading table data: ' + err.message, 'error'); + } + } + + // Trigger initial fetch + fetchTableData(); + } + + // ========================================================================== + // Grid Rendering (Editable Table) + // ========================================================================== + function renderEditableGrid(container, data, tab) { + if (!data.rows || data.rows.length === 0) { + container.innerHTML = ` +
+ + + + + No rows found +
`; + return; + } + + const tableEl = document.createElement('table'); + tableEl.className = 'data-table'; + + // Header + const thead = document.createElement('thead'); + const trHead = document.createElement('tr'); + + // Index column + const thIndex = document.createElement('th'); + thIndex.className = 'th-index'; + thIndex.textContent = '#'; + trHead.appendChild(thIndex); + + // Action column + const thAction = document.createElement('th'); + thAction.className = 'th-action'; + thAction.textContent = 'Actions'; + trHead.appendChild(thAction); + + // Columns + data.columns.forEach((col) => { + const th = document.createElement('th'); + const isSorted = tab.state.sortBy === col.name; + const sortArrow = isSorted ? (tab.state.sortDir === 'DESC' ? ' ▼' : ' ▲') : ''; + + th.innerHTML = ` +
+
+ ${ + col.isPrimaryKey + ? ` + + + ` + : '' + } + ${escapeHtml(col.name)} +
+ ${sortArrow} +
+ `; + + th.addEventListener('click', () => { + if (tab.state.sortBy === col.name) { + tab.state.sortDir = tab.state.sortDir === 'ASC' ? 'DESC' : 'ASC'; + } else { + tab.state.sortBy = col.name; + tab.state.sortDir = 'ASC'; + } + tab.state.page = 1; + // Re-render + const parentTab = container.closest('.tab-view'); + if (parentTab) { + const refreshBtn = parentTab.querySelector('.btn-refresh-browser'); + if (refreshBtn) refreshBtn.click(); + } + }); + + trHead.appendChild(th); + }); + thead.appendChild(trHead); + tableEl.appendChild(thead); + + // Body + const tbody = document.createElement('tbody'); + data.rows.forEach((row, rowIdx) => { + const tr = document.createElement('tr'); + + // Row number + const tdIdx = document.createElement('td'); + tdIdx.className = 'td-index'; + tdIdx.textContent = (data.page - 1) * data.pageSize + rowIdx + 1; + tr.appendChild(tdIdx); + + // Actions + const tdAction = document.createElement('td'); + tdAction.className = 'td-action'; + tdAction.innerHTML = ` +
+ + +
+ `; + + tdAction.querySelector('.btn-edit-row').addEventListener('click', () => { + openEditRowModal(data.tableName, data.columns, row, () => { + const refreshBtn = container.closest('.tab-view')?.querySelector('.btn-refresh-browser'); + if (refreshBtn) refreshBtn.click(); + }); + }); + + tdAction.querySelector('.btn-delete-row').addEventListener('click', () => { + openDeleteRowModal(data.tableName, data.columns, row, () => { + const refreshBtn = container.closest('.tab-view')?.querySelector('.btn-refresh-browser'); + if (refreshBtn) refreshBtn.click(); + }); + }); + + tr.appendChild(tdAction); + + // Cells + data.columns.forEach((col) => { + const td = document.createElement('td'); + const val = row[col.name]; + td.innerHTML = formatCellValue(val); + td.title = val === null || val === undefined ? 'NULL' : String(val); + + td.addEventListener('click', () => { + copyToClipboard(val === null || val === undefined ? '' : String(val)); + }); + + tr.appendChild(td); + }); + + tbody.appendChild(tr); + }); + + tableEl.appendChild(tbody); + container.innerHTML = ''; + container.appendChild(tableEl); + } + + // ========================================================================== + // Query Results Grid + // ========================================================================== + function renderGrid(container, columns, rows) { + if (!rows.length) { + container.innerHTML = '
No rows returned
'; + return; + } + + const tableEl = document.createElement('table'); + tableEl.className = 'data-table'; + + const thead = document.createElement('thead'); + const trHead = document.createElement('tr'); + + const thIdx = document.createElement('th'); + thIdx.className = 'th-index'; + thIdx.textContent = '#'; + trHead.appendChild(thIdx); + + columns.forEach((colName) => { + const th = document.createElement('th'); + th.textContent = colName; + trHead.appendChild(th); + }); + thead.appendChild(trHead); + tableEl.appendChild(thead); + + const tbody = document.createElement('tbody'); + rows.forEach((row, i) => { + const tr = document.createElement('tr'); + const tdIdx = document.createElement('td'); + tdIdx.className = 'td-index'; + tdIdx.textContent = i + 1; + tr.appendChild(tdIdx); + + columns.forEach((colName) => { + const td = document.createElement('td'); + const val = row[colName]; + td.innerHTML = formatCellValue(val); + td.title = val === null || val === undefined ? 'NULL' : String(val); + + td.addEventListener('click', () => { + copyToClipboard(val === null || val === undefined ? '' : String(val)); + }); + + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + tableEl.appendChild(tbody); + + container.innerHTML = ''; + container.appendChild(tableEl); + } + + function formatCellValue(val) { + if (val === null || val === undefined) { + return 'NULL'; + } + if (typeof val === 'boolean') { + return `${val ? 'TRUE' : 'FALSE'}`; + } + return escapeHtml(String(val)); + } + + // ========================================================================== + // Modals Implementation + // ========================================================================== + function openAddRowModal(tableName, columns, onSuccess) { + elAddModalTableName.textContent = tableName; + elAddRowFields.innerHTML = ''; + pendingAdd = { tableName, onSuccess }; + + columns.forEach((c) => { + const isAutoPk = c.isPrimaryKey && (c.type.includes('INT') || c.type.includes('SERIAL')); + const group = document.createElement('div'); + group.className = 'form-group'; + group.innerHTML = ` + + + `; + elAddRowFields.appendChild(group); + }); + + elModalAddRow.classList.remove('hidden'); + } + + elFormAddRow.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!pendingAdd) return; + + const formData = new FormData(elFormAddRow); + const rowObj = {}; + for (const [key, val] of formData.entries()) { + if (val !== '') { + rowObj[key] = val; + } + } + + try { + await apiPost(`/api/tables/${encodeURIComponent(pendingAdd.tableName)}/row`, { row: rowObj }); + showToast('Record inserted successfully', 'success'); + elModalAddRow.classList.add('hidden'); + if (pendingAdd.onSuccess) pendingAdd.onSuccess(); + } catch (err) { + showToast('Insert failed: ' + err.message, 'error'); + } + }); + + function openEditRowModal(tableName, columns, row, onSuccess) { + elEditModalTableName.textContent = tableName; + elEditRowFields.innerHTML = ''; + pendingEdit = { tableName, row, columns, onSuccess }; + + const pkCols = columns.filter((c) => c.isPrimaryKey).map((c) => c.name); + + columns.forEach((c) => { + const isPk = c.isPrimaryKey; + const currentVal = row[c.name]; + const group = document.createElement('div'); + group.className = 'form-group'; + group.innerHTML = ` + + + `; + elEditRowFields.appendChild(group); + }); + + elModalEditRow.classList.remove('hidden'); + } + + elFormEditRow.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!pendingEdit) return; + + const { tableName, row, columns, onSuccess } = pendingEdit; + const pkCols = columns.filter((c) => c.isPrimaryKey).map((c) => c.name); + + // Primary key payload + const pkObj = {}; + if (pkCols.length > 0) { + pkCols.forEach((col) => { + pkObj[col] = row[col]; + }); + } else { + // Fallback: use all columns + columns.forEach((c) => { + pkObj[c.name] = row[c.name]; + }); + } + + // Changes payload + const formData = new FormData(elFormEditRow); + const changes = {}; + columns.forEach((c) => { + if (c.isPrimaryKey) return; + const newVal = formData.get(c.name); + if (newVal !== (row[c.name] === null ? '' : String(row[c.name]))) { + changes[c.name] = newVal === '' ? (c.isNullable ? null : '') : newVal; + } + }); + + if (Object.keys(changes).length === 0) { + showToast('No fields were changed', 'info'); + elModalEditRow.classList.add('hidden'); + return; + } + + try { + await apiPut(`/api/tables/${encodeURIComponent(tableName)}/row`, { + primaryKey: pkObj, + changes, + }); + showToast('Record updated successfully', 'success'); + elModalEditRow.classList.add('hidden'); + if (onSuccess) onSuccess(); + } catch (err) { + showToast('Update failed: ' + err.message, 'error'); + } + }); + + function openDeleteRowModal(tableName, columns, row, onSuccess) { + elDeleteModalTableName.textContent = tableName; + pendingDelete = { tableName, row, columns, onSuccess }; + + const pkCols = columns.filter((c) => c.isPrimaryKey).map((c) => c.name); + let summaryText = ''; + if (pkCols.length > 0) { + summaryText = pkCols.map((c) => `${c} = ${row[c]}`).join(', '); + } else { + summaryText = Object.entries(row) + .slice(0, 4) + .map(([k, v]) => `${k}: ${v}`) + .join('\n'); + } + + elDeleteModalSummary.textContent = summaryText; + elModalDeleteRow.classList.remove('hidden'); + } + + elBtnConfirmDelete.addEventListener('click', async () => { + if (!pendingDelete) return; + const { tableName, row, columns, onSuccess } = pendingDelete; + const pkCols = columns.filter((c) => c.isPrimaryKey).map((c) => c.name); + + const pkObj = {}; + if (pkCols.length > 0) { + pkCols.forEach((c) => { + pkObj[c] = row[c]; + }); + } else { + columns.forEach((c) => { + pkObj[c.name] = row[c.name]; + }); + } + + try { + await apiDelete(`/api/tables/${encodeURIComponent(tableName)}/row`, { primaryKey: pkObj }); + showToast('Record deleted successfully', 'success'); + elModalDeleteRow.classList.add('hidden'); + if (onSuccess) onSuccess(); + } catch (err) { + showToast('Delete failed: ' + err.message, 'error'); + } + }); + + // Modal dismiss handlers + document.querySelectorAll('[data-close-modal]').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('.modal-overlay').forEach((m) => m.classList.add('hidden')); + }); + }); + + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + document.querySelectorAll('.modal-overlay').forEach((m) => m.classList.add('hidden')); + elTemplatesMenu.classList.remove('show'); + } + }); + + // ========================================================================== + // Query History + // ========================================================================== + function addQueryToHistory(sql) { + if (!sql) return; + const item = { query: sql, timestamp: new Date().toISOString() }; + queryHistory.unshift(item); + if (queryHistory.length > 50) queryHistory.pop(); + try { + localStorage.setItem('dash_sql_studio_history', JSON.stringify(queryHistory)); + } catch (e) { + // ignore + } + } + + function openHistoryModal() { + elHistoryList.innerHTML = ''; + if (queryHistory.length === 0) { + elHistoryList.innerHTML = '
No query history yet
'; + } else { + queryHistory.forEach((h) => { + const item = document.createElement('div'); + item.className = 'history-item'; + item.innerHTML = ` +
${escapeHtml(h.query)}
+
${new Date(h.timestamp).toLocaleTimeString()}
+ `; + item.addEventListener('click', () => { + openQueryTab(h.query); + elModalHistory.classList.add('hidden'); + }); + elHistoryList.appendChild(item); + }); + } + elModalHistory.classList.remove('hidden'); + } + + elBtnHistory.addEventListener('click', openHistoryModal); + + elBtnClearHistory.addEventListener('click', () => { + queryHistory = []; + try { + localStorage.removeItem('dash_sql_studio_history'); + } catch (e) {} + openHistoryModal(); + showToast('Query history cleared', 'info'); + }); + + // Snippets Dropdown + elBtnTemplates.addEventListener('click', (e) => { + e.stopPropagation(); + elTemplatesMenu.classList.toggle('show'); + }); + + document.addEventListener('click', () => { + elTemplatesMenu.classList.remove('show'); + }); + + document.querySelectorAll('[data-template]').forEach((btn) => { + btn.addEventListener('click', () => { + const t = btn.dataset.template; + let sql = ''; + if (t === 'select') sql = 'SELECT * FROM [table] LIMIT 50;'; + else if (t === 'count') sql = 'SELECT COUNT(*) as total FROM [table];'; + else if (t === 'insert') sql = 'INSERT INTO [table] ([col1], [col2]) VALUES (\'val1\', \'val2\');'; + else if (t === 'update') sql = 'UPDATE [table] SET [col1] = \'new_val\' WHERE [id] = 1;'; + else if (t === 'delete') sql = 'DELETE FROM [table] WHERE [id] = 1;'; + openQueryTab(sql); + }); + }); + + // Global Header Run button + elBtnRunHeader.addEventListener('click', () => { + const activeView = document.querySelector('.tab-view.active'); + if (activeView) { + const runBtn = activeView.querySelector('.btn-run-query'); + if (runBtn) runBtn.click(); + else showToast('Active tab is not a query tab', 'info'); + } + }); + + elBtnNewQuery.addEventListener('click', () => openQueryTab()); + elBtnAddTab.addEventListener('click', () => openQueryTab()); + elBtnRefreshTables.addEventListener('click', () => loadTables()); + + // Search input filter + elTableSearchInput.addEventListener('input', () => { + const val = elTableSearchInput.value.trim(); + elBtnClearSearch.classList.toggle('hidden', val === ''); + renderTablesTree(tablesList); + }); + + elBtnClearSearch.addEventListener('click', () => { + elTableSearchInput.value = ''; + elBtnClearSearch.classList.add('hidden'); + renderTablesTree(tablesList); + }); + + // ========================================================================== + // Export & Utility Helpers + // ========================================================================== + function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function insertAtCursor(textarea, text) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + textarea.value = textarea.value.substring(0, start) + text + textarea.value.substring(end); + textarea.selectionStart = textarea.selectionEnd = start + text.length; + textarea.focus(); + } + + function copyToClipboard(text) { + if (!text) return; + navigator.clipboard.writeText(text).then( + () => showToast('Copied to clipboard', 'info'), + () => showToast('Failed to copy', 'error') + ); + } + + function exportToCsv(filename, columns, rows) { + const lines = []; + lines.push(columns.map((c) => `"${c.replace(/"/g, '""')}"`).join(',')); + rows.forEach((r) => { + lines.push( + columns + .map((c) => { + const v = r[c]; + if (v === null || v === undefined) return ''; + return `"${String(v).replace(/"/g, '""')}"`; + }) + .join(',') + ); + }); + const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' }); + downloadBlob(blob, filename); + } + + function exportToJson(filename, rows) { + const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' }); + downloadBlob(blob, filename); + } + + function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast(`Downloaded ${filename}`, 'success'); + } + + // ========================================================================== + // Initialization + // ========================================================================== + async function init() { + await loadDbStatus(); + await loadTables(); + // Default initial query tab + openQueryTab('-- Dash SQL Studio\nSELECT 1 as connected;'); + } + + init(); +})(); diff --git a/src/sqlstudio/public/index.html b/src/sqlstudio/public/index.html new file mode 100644 index 0000000..3edb262 --- /dev/null +++ b/src/sqlstudio/public/index.html @@ -0,0 +1,218 @@ + + + + + + Dash SQL Studio + + + + + + + +
+ +
+
+
+
+ + + + + +
+ Dash SQL Studio +
+
DEV MODE • NO AUTH
+
+ +
+
+ + Connecting... + ... +
+
+ +
+ + + + + + + +
+
+ + +
+ + + + +
+ +
+
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + +
+
+ + + + diff --git a/src/sqlstudio/public/style.css b/src/sqlstudio/public/style.css new file mode 100644 index 0000000..7db38c8 --- /dev/null +++ b/src/sqlstudio/public/style.css @@ -0,0 +1,1256 @@ +/* ========================================================================== + Dash SQL Studio — Modern SSMS Dark Theme + ========================================================================== */ + +:root { + --bg-app: #0a0c10; + --bg-header: #10131a; + --bg-sidebar: #131722; + --bg-card: #181c28; + --bg-surface: #1e2333; + --bg-input: #12151f; + --bg-table-stripe: #151925; + --bg-table-hover: #1f2538; + + --border-subtle: #202637; + --border-medium: #2b334a; + --border-active: #38bdf8; + + --text-main: #f1f5f9; + --text-muted: #94a3b8; + --text-dim: #64748b; + + --accent-cyan: #00f0ff; + --accent-blue: #38bdf8; + --accent-blue-hover: #0284c7; + --accent-emerald: #10b981; + --accent-emerald-dim: rgba(16, 185, 129, 0.15); + --accent-rose: #f43f5e; + --accent-rose-dim: rgba(244, 63, 94, 0.15); + --accent-amber: #f59e0b; + --accent-purple: #a855f7; + + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: 'JetBrains Mono', Consolas, "Courier New", monospace; + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 10px; + --radius-full: 9999px; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6); +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.5; + color: var(--text-main); + background-color: var(--bg-app); + overflow: hidden; + height: 100vh; + width: 100vw; + -webkit-font-smoothing: antialiased; +} + +/* Custom Scrollbars */ +::-webkit-scrollbar { + width: 7px; + height: 7px; +} +::-webkit-scrollbar-track { + background: var(--bg-sidebar); +} +::-webkit-scrollbar-thumb { + background: #2a3144; + border-radius: var(--radius-sm); +} +::-webkit-scrollbar-thumb:hover { + background: #3b4560; +} + +/* App Container */ +.app-container { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + overflow: hidden; +} + +/* ========================================================================== + Header + ========================================================================== */ +.app-header { + height: 48px; + background-color: var(--bg-header); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + flex-shrink: 0; + z-index: 10; +} + +.header-left, .header-center, .header-right { + display: flex; + align-items: center; + gap: 12px; +} + +.brand { + display: flex; + align-items: center; + gap: 8px; + user-select: none; +} + +.brand-icon { + width: 28px; + height: 28px; + background: linear-gradient(135deg, #0284c7, #00f0ff); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + color: #000; + box-shadow: 0 0 12px rgba(0, 240, 255, 0.3); +} + +.brand-title { + font-weight: 700; + font-size: 14px; + letter-spacing: -0.2px; + background: linear-gradient(90deg, #ffffff, #94a3b8); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.dev-badge { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + color: #38bdf8; + background: rgba(56, 189, 248, 0.12); + border: 1px solid rgba(56, 189, 248, 0.3); + padding: 2px 7px; + border-radius: var(--radius-full); +} + +.db-status-pill { + display: flex; + align-items: center; + gap: 8px; + background: var(--bg-sidebar); + border: 1px solid var(--border-medium); + padding: 3px 12px; + border-radius: var(--radius-full); + font-size: 12px; +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: var(--radius-full); + background-color: var(--accent-emerald); + box-shadow: 0 0 8px var(--accent-emerald); + animation: pulse-green 2s infinite ease-in-out; +} + +@keyframes pulse-green { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.9); } +} + +.engine-badge { + font-weight: 600; + text-transform: uppercase; + color: var(--accent-cyan); + font-size: 11px; +} + +.db-target { + color: var(--text-muted); + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ========================================================================== + Buttons & Controls + ========================================================================== */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font-family: var(--font-sans); + font-size: 12px; + font-weight: 500; + padding: 5px 12px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s ease; + user-select: none; + white-space: nowrap; +} + +.btn kbd { + font-family: var(--font-mono); + font-size: 10px; + background: rgba(0, 0, 0, 0.25); + padding: 1px 4px; + border-radius: 3px; + margin-left: 2px; +} + +.btn-primary { + background: linear-gradient(135deg, #0284c7, #0ea5e9); + color: #fff; + border-color: #38bdf8; + box-shadow: 0 0 10px rgba(14, 165, 233, 0.25); +} +.btn-primary:hover { + background: linear-gradient(135deg, #0ea5e9, #38bdf8); + box-shadow: 0 0 14px rgba(56, 189, 248, 0.4); +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text-main); + border-color: var(--border-medium); +} +.btn-secondary:hover { + background: var(--bg-surface); + border-color: #475569; +} + +.btn-danger { + background: var(--accent-rose); + color: #fff; + border-color: #e11d48; +} +.btn-danger:hover { + background: #e11d48; +} + +.btn-sm { + padding: 3px 8px; + font-size: 11px; +} + +.btn-icon { + background: var(--bg-card); + border: 1px solid var(--border-medium); + color: var(--text-muted); + padding: 6px; + border-radius: var(--radius-sm); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} +.btn-icon:hover { + color: var(--text-main); + background: var(--bg-surface); + border-color: #475569; +} + +.btn-icon-subtle { + background: transparent; + border: none; + color: var(--text-dim); + padding: 4px; + border-radius: var(--radius-sm); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.15s ease; +} +.btn-icon-subtle:hover { + color: var(--text-main); + background: rgba(255, 255, 255, 0.05); +} + +/* Dropdown */ +.dropdown { + position: relative; +} +.dropdown-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + background: var(--bg-card); + border: 1px solid var(--border-medium); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: 4px; + min-width: 260px; + z-index: 50; + display: none; + flex-direction: column; +} +.dropdown-menu.show { + display: flex; +} +.dropdown-item { + font-family: var(--font-mono); + font-size: 11px; + padding: 6px 10px; + background: transparent; + border: none; + color: var(--text-main); + text-align: left; + cursor: pointer; + border-radius: var(--radius-sm); + transition: background 0.15s ease; +} +.dropdown-item:hover { + background: var(--bg-surface); + color: var(--accent-cyan); +} + +/* ========================================================================== + Main Layout & Sidebar + ========================================================================== */ +.main-layout { + display: flex; + flex: 1; + overflow: hidden; +} + +.sidebar { + width: 270px; + background-color: var(--bg-sidebar); + border-right: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + flex-shrink: 0; + user-select: none; +} + +.sidebar-header { + height: 38px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.sidebar-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; + color: var(--text-muted); +} + +.badge { + background: rgba(255, 255, 255, 0.08); + font-size: 10px; + padding: 1px 6px; + border-radius: var(--radius-full); + color: var(--text-main); +} + +.sidebar-search { + padding: 8px 12px; + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-sidebar); + border-bottom: 1px solid var(--border-subtle); + position: relative; +} + +.sidebar-search svg { + color: var(--text-dim); + flex-shrink: 0; +} + +.sidebar-search input { + background: var(--bg-input); + border: 1px solid var(--border-subtle); + color: var(--text-main); + font-size: 12px; + padding: 5px 8px; + border-radius: var(--radius-sm); + width: 100%; + outline: none; + transition: border-color 0.15s ease; +} +.sidebar-search input:focus { + border-color: var(--accent-blue); +} + +.btn-clear-search { + position: absolute; + right: 18px; + background: transparent; + border: none; + color: var(--text-dim); + font-size: 14px; + cursor: pointer; +} +.btn-clear-search:hover { + color: var(--text-main); +} + +.tables-tree { + flex: 1; + overflow-y: auto; + padding: 6px 4px; +} + +.tree-loading, .tree-empty { + padding: 16px; + text-align: center; + color: var(--text-dim); + font-size: 12px; +} + +/* Tree Item */ +.table-node { + margin-bottom: 1px; + border-radius: var(--radius-sm); + overflow: hidden; +} + +.table-node-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 8px; + cursor: pointer; + border-radius: var(--radius-sm); + transition: background 0.12s ease; +} +.table-node-header:hover { + background-color: var(--bg-card); +} +.table-node.active > .table-node-header { + background-color: var(--bg-surface); +} + +.table-node-info { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +} + +.chevron-icon { + width: 12px; + height: 12px; + color: var(--text-dim); + transition: transform 0.15s ease; + flex-shrink: 0; +} +.table-node.expanded .chevron-icon { + transform: rotate(90deg); +} + +.table-icon { + width: 14px; + height: 14px; + color: #38bdf8; + flex-shrink: 0; +} + +.table-name { + font-weight: 500; + font-size: 12px; + color: var(--text-main); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-count-pill { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + background: rgba(255, 255, 255, 0.04); + padding: 1px 5px; + border-radius: var(--radius-full); + margin-left: 4px; +} + +.table-node-actions { + display: none; + gap: 2px; +} +.table-node-header:hover .table-node-actions { + display: flex; +} + +.node-btn { + background: transparent; + border: none; + color: var(--text-dim); + padding: 2px 4px; + border-radius: 3px; + cursor: pointer; + font-size: 10px; +} +.node-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-main); +} + +/* Columns submenu in tree */ +.table-columns-sublist { + display: none; + padding-left: 24px; + padding-bottom: 4px; +} +.table-node.expanded .table-columns-sublist { + display: block; +} + +.column-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px 6px; + font-size: 11px; + color: var(--text-muted); + border-radius: var(--radius-sm); +} +.column-item:hover { + background: rgba(255, 255, 255, 0.03); + color: var(--text-main); +} + +.col-left { + display: flex; + align-items: center; + gap: 5px; +} + +.pk-icon { + width: 11px; + height: 11px; + color: var(--accent-amber); +} + +.col-type-tag { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); +} + +.sidebar-footer { + padding: 8px 12px; + border-top: 1px solid var(--border-subtle); + background: var(--bg-header); + font-size: 11px; + display: flex; + flex-direction: column; + gap: 4px; +} +.sidebar-info-row { + display: flex; + justify-content: space-between; +} +.info-label { + color: var(--text-dim); +} +.info-val { + font-family: var(--font-mono); + color: var(--text-muted); +} + +/* ========================================================================== + Workspace & Tab System + ========================================================================== */ +.workspace { + flex: 1; + display: flex; + flex-direction: column; + background-color: var(--bg-app); + overflow: hidden; +} + +.tab-strip-container { + height: 38px; + background-color: var(--bg-header); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: stretch; + padding: 0 4px; + overflow-x: auto; + flex-shrink: 0; +} + +.tab-strip { + display: flex; + align-items: flex-end; + gap: 2px; + flex: 1; + overflow-x: auto; +} + +.tab { + height: 32px; + background: var(--bg-sidebar); + border: 1px solid var(--border-subtle); + border-bottom: none; + border-top-left-radius: var(--radius-sm); + border-top-right-radius: var(--radius-sm); + padding: 0 10px; + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); + cursor: pointer; + user-select: none; + max-width: 180px; + min-width: 110px; + position: relative; + transition: all 0.12s ease; +} +.tab:hover { + background: var(--bg-card); + color: var(--text-main); +} +.tab.active { + background: var(--bg-app); + color: var(--accent-cyan); + border-color: var(--border-medium); + font-weight: 500; +} +.tab.active::before { + content: ''; + position: absolute; + top: -1px; + left: 0; + right: 0; + height: 2px; + background: var(--accent-cyan); + box-shadow: 0 0 8px var(--accent-cyan); +} + +.tab-icon { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.tab-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tab-close { + background: transparent; + border: none; + color: var(--text-dim); + border-radius: 3px; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + cursor: pointer; +} +.tab-close:hover { + background: rgba(255, 255, 255, 0.15); + color: var(--text-main); +} + +.btn-add-tab { + background: transparent; + border: none; + color: var(--text-dim); + padding: 0 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.btn-add-tab:hover { + color: var(--text-main); +} + +.tab-views { + flex: 1; + position: relative; + overflow: hidden; +} + +.tab-view { + position: absolute; + inset: 0; + display: none; + flex-direction: column; +} +.tab-view.active { + display: flex; +} + +/* ========================================================================== + Query Editor Tab View + ========================================================================== */ +.query-tab-view { + display: flex; + flex-direction: column; + height: 100%; +} + +.editor-toolbar { + height: 38px; + background: var(--bg-sidebar); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + flex-shrink: 0; +} + +.toolbar-group { + display: flex; + align-items: center; + gap: 8px; +} + +.select-table-quick { + background: var(--bg-input); + border: 1px solid var(--border-subtle); + color: var(--text-main); + font-size: 12px; + padding: 4px 8px; + border-radius: var(--radius-sm); + outline: none; +} + +.editor-container { + height: 40%; + min-height: 120px; + background: var(--bg-app); + border-bottom: 1px solid var(--border-subtle); + position: relative; + display: flex; +} + +.editor-textarea { + width: 100%; + height: 100%; + background: transparent; + border: none; + outline: none; + color: #e2e8f0; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + padding: 12px; + resize: none; + white-space: pre; + tab-size: 2; +} + +/* Results Container */ +.results-container { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg-app); + overflow: hidden; +} + +.results-header { + height: 36px; + background: var(--bg-sidebar); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + flex-shrink: 0; +} + +.results-meta { + display: flex; + align-items: center; + gap: 12px; + font-size: 11px; +} + +.stat-pill { + display: flex; + align-items: center; + gap: 5px; + color: var(--text-muted); +} +.stat-pill strong { + font-family: var(--font-mono); + color: var(--text-main); +} +.stat-success strong { + color: var(--accent-emerald); +} +.stat-error strong { + color: var(--accent-rose); +} + +.results-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.results-body { + flex: 1; + overflow: auto; + position: relative; +} + +.error-banner { + margin: 16px; + padding: 14px; + background: var(--accent-rose-dim); + border: 1px solid rgba(244, 63, 94, 0.4); + border-radius: var(--radius-md); + color: #fecdd3; + font-family: var(--font-mono); + font-size: 12px; + white-space: pre-wrap; +} + +.empty-results { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--text-dim); + gap: 8px; + font-size: 13px; +} + +/* ========================================================================== + Table Browser View + ========================================================================== */ +.table-browser-view { + display: flex; + flex-direction: column; + height: 100%; +} + +.browser-toolbar { + height: 44px; + background: var(--bg-sidebar); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 12px; + flex-shrink: 0; +} + +.search-box-browser { + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-input); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 3px 8px; + width: 240px; +} +.search-box-browser input { + background: transparent; + border: none; + outline: none; + color: var(--text-main); + font-size: 12px; + width: 100%; +} + +.pagination-controls { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-muted); +} + +.pagination-controls select { + background: var(--bg-input); + border: 1px solid var(--border-subtle); + color: var(--text-main); + font-size: 11px; + padding: 2px 6px; + border-radius: var(--radius-sm); + outline: none; +} + +.grid-wrapper { + flex: 1; + overflow: auto; + position: relative; +} + +/* Data Table Grid */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + white-space: nowrap; +} + +.data-table thead { + position: sticky; + top: 0; + background: #151824; + z-index: 5; + box-shadow: 0 1px 0 var(--border-medium); +} + +.data-table th { + padding: 8px 12px; + text-align: left; + font-weight: 600; + color: var(--text-muted); + border-right: 1px solid var(--border-subtle); + user-select: none; + cursor: pointer; + transition: background 0.15s ease; +} +.data-table th:hover { + background: var(--bg-surface); + color: var(--text-main); +} + +.th-content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.th-info { + display: flex; + align-items: center; + gap: 4px; +} + +.th-sort-icon { + font-size: 10px; + color: var(--accent-cyan); +} + +.data-table th.th-action, +.data-table td.td-action { + width: 70px; + text-align: center; + cursor: default; +} + +.data-table th.th-index, +.data-table td.td-index { + width: 45px; + text-align: right; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 11px; +} + +.data-table tbody tr { + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s ease; +} +.data-table tbody tr:nth-child(even) { + background-color: var(--bg-table-stripe); +} +.data-table tbody tr:hover { + background-color: var(--bg-table-hover); +} + +.data-table td { + padding: 6px 12px; + border-right: 1px solid var(--border-subtle); + font-family: var(--font-mono); + font-size: 12px; + color: #cbd5e1; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Cell Value Types */ +.val-null { + font-style: italic; + color: #64748b; + font-size: 11px; + background: rgba(255, 255, 255, 0.04); + padding: 1px 4px; + border-radius: 3px; +} + +.val-bool { + font-weight: 600; + font-size: 11px; +} +.val-bool.val-true { + color: var(--accent-emerald); +} +.val-bool.val-false { + color: #94a3b8; +} + +.cell-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +/* ========================================================================== + Modals + ========================================================================== */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + animation: fadeIn 0.15s ease; +} +.modal-overlay.hidden { + display: none; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal-dialog { + background: var(--bg-card); + border: 1px solid var(--border-medium); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + width: 600px; + max-width: 90vw; + max-height: 85vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.modal-dialog-sm { + width: 420px; +} + +.modal-dialog-lg { + width: 780px; +} + +.modal-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-title { + font-size: 14px; + font-weight: 600; +} +.text-danger { + color: var(--accent-rose); +} + +.modal-close { + background: transparent; + border: none; + color: var(--text-dim); + font-size: 20px; + cursor: pointer; +} +.modal-close:hover { + color: var(--text-main); +} + +.modal-body { + padding: 18px; + overflow-y: auto; + flex: 1; +} + +.modal-footer { + padding: 12px 18px; + border-top: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + background: var(--bg-sidebar); +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 4px; +} +.form-group.full-width { + grid-column: 1 / -1; +} + +.form-label { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: space-between; +} + +.form-input { + background: var(--bg-input); + border: 1px solid var(--border-medium); + color: var(--text-main); + font-family: var(--font-mono); + font-size: 12px; + padding: 6px 10px; + border-radius: var(--radius-sm); + outline: none; + transition: border-color 0.15s ease; +} +.form-input:focus { + border-color: var(--accent-blue); +} +.form-input:disabled { + opacity: 0.6; + background: rgba(0, 0, 0, 0.2); +} + +.delete-summary-box { + margin: 14px 0; + padding: 12px; + background: var(--bg-input); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 11px; + max-height: 180px; + overflow-y: auto; +} + +/* History List */ +.history-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.history-item { + background: var(--bg-input); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 10px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + cursor: pointer; + transition: all 0.12s ease; +} +.history-item:hover { + border-color: var(--accent-blue); + background: var(--bg-surface); +} + +.history-query-text { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-main); + white-space: pre-wrap; + word-break: break-all; + flex: 1; +} + +.history-time { + font-size: 10px; + color: var(--text-dim); + white-space: nowrap; +} + +/* ========================================================================== + Toasts + ========================================================================== */ +.toast-container { + position: fixed; + bottom: 20px; + right: 20px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 200; +} + +.toast { + background: var(--bg-card); + border: 1px solid var(--border-medium); + box-shadow: var(--shadow-lg); + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: 12px; + display: flex; + align-items: center; + gap: 10px; + animation: slideIn 0.2s ease; + min-width: 220px; +} + +.toast.toast-success { + border-left: 4px solid var(--accent-emerald); +} +.toast.toast-error { + border-left: 4px solid var(--accent-rose); +} +.toast.toast-info { + border-left: 4px solid var(--accent-cyan); +} + +@keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +.hidden { + display: none !important; +} diff --git a/src/sqlstudio/server.js b/src/sqlstudio/server.js new file mode 100644 index 0000000..04c80d6 --- /dev/null +++ b/src/sqlstudio/server.js @@ -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'); +}