1327 lines
47 KiB
JavaScript
1327 lines
47 KiB
JavaScript
/**
|
|
* Backend Monitor Interface — 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_backend_monitor_history') || 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 = '<div class="tree-loading">Loading tables...</div>';
|
|
try {
|
|
const data = await apiGet('/api/tables');
|
|
tablesList = data.tables || [];
|
|
elTableCountBadge.textContent = tablesList.length;
|
|
renderTablesTree(tablesList);
|
|
} catch (err) {
|
|
elTablesTree.innerHTML = `<div class="tree-empty">Error loading tables: ${escapeHtml(err.message)}</div>`;
|
|
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 = '<div class="tree-empty">No matching tables</div>';
|
|
return;
|
|
}
|
|
|
|
elTablesTree.innerHTML = '';
|
|
filtered.forEach((t) => {
|
|
const node = document.createElement('div');
|
|
node.className = 'table-node';
|
|
node.dataset.tableName = t.name;
|
|
|
|
node.innerHTML = `
|
|
<div class="table-node-header">
|
|
<div class="table-node-info">
|
|
<svg class="chevron-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="9 18 15 12 9 6"></polyline>
|
|
</svg>
|
|
<svg class="table-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
|
|
<path d="M3 9h18"></path>
|
|
<path d="M9 21V9"></path>
|
|
</svg>
|
|
<span class="table-name" title="${escapeHtml(t.name)}">${escapeHtml(t.name)}</span>
|
|
<span class="row-count-pill">${t.rowCount.toLocaleString()}</span>
|
|
</div>
|
|
<div class="table-node-actions">
|
|
<button class="node-btn btn-browse-table" title="Browse Data">Data</button>
|
|
<button class="node-btn btn-query-table" title="Script Query">SQL</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-columns-sublist"></div>
|
|
`;
|
|
|
|
// 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 = '<div style="padding:4px;color:#64748b;font-size:10px;">Loading columns...</div>';
|
|
try {
|
|
const data = await apiGet(`/api/tables/${encodeURIComponent(tableName)}/schema`);
|
|
tableSchemasCache[tableName] = data.columns || [];
|
|
renderColumnsSublist(sublist, tableSchemasCache[tableName]);
|
|
} catch (err) {
|
|
sublist.innerHTML = `<div style="padding:4px;color:#f43f5e;font-size:10px;">${escapeHtml(err.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderColumnsSublist(container, columns) {
|
|
if (!columns.length) {
|
|
container.innerHTML = '<div style="padding:4px;color:#64748b;font-size:10px;">No columns</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = columns
|
|
.map(
|
|
(c) => `
|
|
<div class="column-item" title="${escapeHtml(c.name)} (${escapeHtml(c.type)})">
|
|
<div class="col-left">
|
|
${
|
|
c.isPrimaryKey
|
|
? `<svg class="pk-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="m21 2-2 2m-6 6 2-2 4 4-2 2-6-6Z"></path>
|
|
<circle cx="7.5" cy="15.5" r="5.5"></circle>
|
|
</svg>`
|
|
: ''
|
|
}
|
|
<span>${escapeHtml(c.name)}</span>
|
|
</div>
|
|
<span class="col-type-tag">${escapeHtml(c.type)}</span>
|
|
</div>
|
|
`
|
|
)
|
|
.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 = `
|
|
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
|
|
</svg>`;
|
|
} else {
|
|
iconSvg = `
|
|
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
|
|
<path d="M3 9h18"></path>
|
|
<path d="M9 21V9"></path>
|
|
</svg>`;
|
|
}
|
|
|
|
tabEl.innerHTML = `
|
|
${iconSvg}
|
|
<span class="tab-title" title="${escapeHtml(t.title)}">${escapeHtml(t.title)}</span>
|
|
<button class="tab-close" title="Close Tab">×</button>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="editor-toolbar">
|
|
<div class="toolbar-group">
|
|
<button class="btn btn-primary btn-run-query" title="Execute Query (Ctrl+Enter)">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
|
|
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
|
</svg>
|
|
<span>Run</span>
|
|
</button>
|
|
<button class="btn btn-secondary btn-clear-sql">Clear</button>
|
|
<select class="select-table-quick">
|
|
<option value="">Insert table name...</option>
|
|
${tablesList.map((t) => `<option value="${escapeHtml(t.name)}">${escapeHtml(t.name)}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="toolbar-group">
|
|
<span style="font-size:11px;color:#64748b;">Shortcuts: Ctrl+Enter (Run)</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="editor-container">
|
|
<textarea class="editor-textarea" spellcheck="false" placeholder="Enter SQL query...">${escapeHtml(tab.state.sql || '')}</textarea>
|
|
</div>
|
|
|
|
<div class="results-container">
|
|
<div class="results-header">
|
|
<div class="results-meta">
|
|
<span class="stat-pill stat-status">Status: <strong>Ready</strong></span>
|
|
</div>
|
|
<div class="results-actions">
|
|
<input type="text" class="search-box-results" placeholder="Filter results..." style="display:none;background:#12151f;border:1px solid #2b334a;color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;">
|
|
<button class="btn btn-secondary btn-sm btn-export-csv" style="display:none;">Export CSV</button>
|
|
<button class="btn btn-secondary btn-sm btn-export-json" style="display:none;">Export JSON</button>
|
|
</div>
|
|
</div>
|
|
<div class="results-body">
|
|
<div class="empty-results">
|
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#475569" stroke-width="1.5">
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
<line x1="12" y1="8" x2="12" y2="12"></line>
|
|
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
|
</svg>
|
|
<span>Press Execute or Ctrl+Enter to run query</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = '<span class="stat-pill">Executing query...</span>';
|
|
|
|
try {
|
|
const result = await apiPost('/api/query', { sql: sqlToRun });
|
|
tab.state.results = result;
|
|
|
|
// Record to query history
|
|
addQueryToHistory(sqlToRun);
|
|
|
|
if (!result.success) {
|
|
resultsMeta.innerHTML = `
|
|
<span class="stat-pill stat-error">Status: <strong>Error</strong></span>
|
|
<span class="stat-pill">Duration: <strong>${result.executionTimeMs} ms</strong></span>
|
|
`;
|
|
resultsBody.innerHTML = `<div class="error-banner">${escapeHtml(result.error)}</div>`;
|
|
btnExportCsv.style.display = 'none';
|
|
btnExportJson.style.display = 'none';
|
|
} else {
|
|
resultsMeta.innerHTML = `
|
|
<span class="stat-pill stat-success">Status: <strong>Success</strong></span>
|
|
<span class="stat-pill">Duration: <strong>${result.executionTimeMs} ms</strong></span>
|
|
<span class="stat-pill">Rows: <strong>${(result.rows || []).length}</strong></span>
|
|
${
|
|
result.rowsAffected !== undefined
|
|
? `<span class="stat-pill">Affected: <strong>${result.rowsAffected}</strong></span>`
|
|
: ''
|
|
}
|
|
`;
|
|
|
|
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 = `
|
|
<div class="empty-results">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2">
|
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
|
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
|
</svg>
|
|
<span>Query executed successfully (${result.rowsAffected || 0} rows affected)</span>
|
|
</div>`;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
resultsMeta.innerHTML = `<span class="stat-pill stat-error">Error: <strong>${escapeHtml(err.message)}</strong></span>`;
|
|
resultsBody.innerHTML = `<div class="error-banner">${escapeHtml(err.message)}</div>`;
|
|
} 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 = `
|
|
<div class="browser-toolbar">
|
|
<div class="toolbar-group">
|
|
<div class="search-box-browser">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#64748b" 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" class="browser-search-input" placeholder="Search rows..." value="${escapeHtml(tab.state.search || '')}">
|
|
</div>
|
|
<button class="btn btn-primary btn-add-row-modal">
|
|
<svg width="13" height="13" 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>Add Row</span>
|
|
</button>
|
|
<button class="btn btn-secondary btn-refresh-browser">
|
|
<svg width="13" height="13" 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>
|
|
<span>Refresh</span>
|
|
</button>
|
|
<button class="btn btn-secondary btn-open-query-from-table" title="Open SQL query for this table">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
|
|
</svg>
|
|
<span>Query</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar-group">
|
|
<div class="pagination-controls">
|
|
<span>Show</span>
|
|
<select class="select-page-size">
|
|
<option value="25" ${tab.state.pageSize === 25 ? 'selected' : ''}>25</option>
|
|
<option value="50" ${tab.state.pageSize === 50 ? 'selected' : ''}>50</option>
|
|
<option value="100" ${tab.state.pageSize === 100 ? 'selected' : ''}>100</option>
|
|
<option value="250" ${tab.state.pageSize === 250 ? 'selected' : ''}>250</option>
|
|
</select>
|
|
<button class="btn btn-icon btn-sm btn-prev-page" title="Previous Page"><</button>
|
|
<span class="page-indicator">Page 1 of 1</span>
|
|
<button class="btn btn-icon btn-sm btn-next-page" title="Next Page">></button>
|
|
<span class="total-records-badge badge">0 rows</span>
|
|
</div>
|
|
<button class="btn btn-secondary btn-sm btn-export-table-csv">CSV</button>
|
|
<button class="btn btn-secondary btn-sm btn-export-table-json">JSON</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid-wrapper">
|
|
<div class="tree-loading" style="padding:40px;text-align:center;">Loading table data...</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = '<div class="tree-loading" style="padding:40px;text-align:center;">Loading table data...</div>';
|
|
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 = `<div class="error-banner" style="margin:20px;">${escapeHtml(err.message)}</div>`;
|
|
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 = `
|
|
<div class="empty-results">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#64748b" stroke-width="2">
|
|
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
|
|
<path d="M3 9h18"></path>
|
|
</svg>
|
|
<span>No rows found</span>
|
|
</div>`;
|
|
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 = `
|
|
<div class="th-content">
|
|
<div class="th-info">
|
|
${
|
|
col.isPrimaryKey
|
|
? `<svg class="pk-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="m21 2-2 2m-6 6 2-2 4 4-2 2-6-6Z"></path>
|
|
<circle cx="7.5" cy="15.5" r="5.5"></circle>
|
|
</svg>`
|
|
: ''
|
|
}
|
|
<span>${escapeHtml(col.name)}</span>
|
|
</div>
|
|
<span class="th-sort-icon">${sortArrow}</span>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="cell-actions">
|
|
<button class="node-btn btn-edit-row" title="Edit row">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 20h9"></path>
|
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
|
</svg>
|
|
</button>
|
|
<button class="node-btn btn-delete-row" title="Delete row" style="color:#f43f5e;">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="3 6 5 6 21 6"></polyline>
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
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 = '<div class="empty-results">No rows returned</div>';
|
|
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 '<span class="val-null">NULL</span>';
|
|
}
|
|
if (typeof val === 'boolean') {
|
|
return `<span class="val-bool ${val ? 'val-true' : 'val-false'}">${val ? 'TRUE' : 'FALSE'}</span>`;
|
|
}
|
|
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 = `
|
|
<label class="form-label">
|
|
<span>${escapeHtml(c.name)} ${c.isPrimaryKey ? '🔑' : ''}</span>
|
|
<span style="font-size:9px;color:#64748b;">${escapeHtml(c.type)}${c.isNullable ? '' : ' *'}</span>
|
|
</label>
|
|
<input type="text" class="form-input" name="${escapeHtml(c.name)}" placeholder="${
|
|
isAutoPk ? '(Auto-generated PK)' : c.isNullable ? '(NULL)' : 'Required'
|
|
}">
|
|
`;
|
|
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 = `
|
|
<label class="form-label">
|
|
<span>${escapeHtml(c.name)} ${isPk ? '🔑 (PK)' : ''}</span>
|
|
<span style="font-size:9px;color:#64748b;">${escapeHtml(c.type)}</span>
|
|
</label>
|
|
<input type="text" class="form-input" name="${escapeHtml(c.name)}" value="${
|
|
currentVal === null || currentVal === undefined ? '' : escapeHtml(String(currentVal))
|
|
}" ${isPk ? 'disabled' : ''}>
|
|
`;
|
|
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_backend_monitor_history', JSON.stringify(queryHistory));
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function openHistoryModal() {
|
|
elHistoryList.innerHTML = '';
|
|
if (queryHistory.length === 0) {
|
|
elHistoryList.innerHTML = '<div style="color:#64748b;text-align:center;padding:20px;">No query history yet</div>';
|
|
} else {
|
|
queryHistory.forEach((h) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'history-item';
|
|
item.innerHTML = `
|
|
<div class="history-query-text">${escapeHtml(h.query)}</div>
|
|
<div class="history-time">${new Date(h.timestamp).toLocaleTimeString()}</div>
|
|
`;
|
|
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, '"')
|
|
.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('-- Backend Monitor Interface\nSELECT 1 as connected;');
|
|
}
|
|
|
|
init();
|
|
})();
|