Files
dashback/src/backendmonitor/public/app.js
2026-10-13 17:49:36 +02:00

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">&times;</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">&lt;</button>
<span class="page-indicator">Page 1 of 1</span>
<button class="btn btn-icon btn-sm btn-next-page" title="Next Page">&gt;</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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();
})();