diff --git a/index.html b/index.html index 5fa88f1..3252ff5 100644 --- a/index.html +++ b/index.html @@ -138,7 +138,7 @@
-
+

Выберите таблицу

-
- +
+ +
+ +
@@ -305,6 +308,12 @@ SELECT * FROM users LIMIT 10;">
+
+ +
@@ -380,6 +389,51 @@ SELECT * FROM users LIMIT 10;"> + + +
@@ -393,6 +447,9 @@ SELECT * FROM users LIMIT 10;"> this.currentPage = 1; this.limit = 10; this.editingRecord = null; + this.editingColumn = null; + this.primaryKey = null; + this.tableStructure = []; this.init(); } @@ -527,201 +584,146 @@ SELECT * FROM users LIMIT 10;"> document.getElementById('sqlPanel').classList.add('hidden'); document.getElementById('dataGrid').classList.remove('hidden'); + // Load table structure to get primary key + await this.loadTableStructure(); await this.loadTableData(); } async loadTableData() { + const searchQuery = document.getElementById('recordSearch').value; + try { - const response = await fetch(`/api/tables/${this.currentTable}/data?page=${this.currentPage}&limit=${this.limit}`); - if (response.status === 401) { - this.logout(); - return; - } - const result = await response.json(); + const params = new URLSearchParams({ + page: this.currentPage, + limit: this.limit, + search: searchQuery + }); - const totalRows = result.total; - const totalPages = result.totalPages; - const data = result.data; + const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`); + const data = await response.json(); - document.getElementById('currentPage').textContent = this.currentPage; - document.getElementById('totalPages').textContent = totalPages; - document.getElementById('recordCount').innerHTML = ` - Всего записей: ${totalRows} - `; - - // Render headers - if (data.length > 0) { - const headers = Object.keys(data[0]); - document.getElementById('tableHeaders').innerHTML = headers.map(h => - `` - ).join('') + ''; - - // Render rows - document.getElementById('tableBody').innerHTML = data.map((row, idx) => ` - - ${Object.values(row).map(v => ``).join('')} - - - `).join(''); - } else { - document.getElementById('tableBody').innerHTML = ''; - } - - lucide.createIcons(); - - // Update pagination buttons - document.getElementById('prevPage').disabled = this.currentPage === 1; - document.getElementById('nextPage').disabled = this.currentPage >= totalPages; + this.renderTableData(data.data); + this.updatePagination(data); } catch (err) { - this.showToast('Ошибка загрузки данных', 'error'); + this.showToast('Ошибка при загрузке данных', 'error'); } } - generateMockData(tableName) { - // Generate realistic mock data based on table name - const data = []; - const count = Math.min(this.limit, 10); + renderTableData(records) { + const headers = document.getElementById('tableHeaders'); + const body = document.getElementById('tableBody'); - for (let i = 0; i < count; i++) { - const id = (this.currentPage - 1) * this.limit + i + 1; - - if (tableName === 'users') { - data.push({ - id: id, - email: `user${id}@example.com`, - username: `user_${id}`, - created_at: new Date(Date.now() - Math.random() * 10000000000).toISOString().split('T')[0], - status: Math.random() > 0.5 ? 'active' : 'inactive', - role: ['admin', 'user', 'editor'][Math.floor(Math.random() * 3)] - }); - } else if (tableName === 'orders') { - data.push({ - id: `ORD-${1000 + id}`, - user_id: Math.floor(Math.random() * 100) + 1, - total: (Math.random() * 1000).toFixed(2), - status: ['pending', 'completed', 'cancelled'][Math.floor(Math.random() * 3)], - created_at: new Date(Date.now() - Math.random() * 10000000000).toISOString().split('T')[0] - }); - } else if (tableName === 'products') { - data.push({ - id: id, - name: `Product ${id}`, - price: (Math.random() * 500).toFixed(2), - stock: Math.floor(Math.random() * 100), - category_id: Math.floor(Math.random() * 10) + 1, - sku: `SKU-${Math.random().toString(36).substr(2, 9).toUpperCase()}` - }); - } else { - data.push({ - id: id, - name: `Record ${id}`, - value: Math.random().toString(36).substring(7), - created_at: new Date().toISOString() - }); - } - } - return data; - } - - // CRUD Operations - async showAddRecordModal() { - this.editingRecord = null; - document.getElementById('recordModalTitle').textContent = 'Добавить запись'; - - await this.generateRecordForm(); - - document.getElementById('recordModal').classList.remove('hidden'); - } - - async editRecord(idx) { - this.editingRecord = idx; - document.getElementById('recordModalTitle').textContent = 'Редактировать запись'; - - await this.generateRecordForm(); - - document.getElementById('recordModal').classList.remove('hidden'); - } - - async generateRecordForm() { - const columns = await this.getTableStructure(this.currentTable); - const container = document.getElementById('recordForm'); - - if (!columns || columns.length === 0) { - container.innerHTML = `

Не удалось загрузить структуру таблицы

`; + if (records.length === 0) { + headers.innerHTML = ''; + body.innerHTML = ''; return; } - - container.innerHTML = columns.map(col => ` -
- - -

${col.type}

-
- `).join(''); + + // Generate headers + const columns = Object.keys(records[0]); + headers.innerHTML = columns.map(col => ``).join('') + ''; + + // Generate rows + body.innerHTML = records.map(record => { + const pkValue = this.primaryKey ? record[this.primaryKey] : null; + const cells = columns.map(col => ``).join(''); + const actions = pkValue ? ` + + ` : ''; + + return `${cells}${actions}`; + }).join(''); } - async getTableStructure(tableName) { - try { - const response = await fetch(`/api/tables/${tableName}/structure`); - if (response.status === 401) { - this.logout(); - return []; - } - const data = await response.json(); - return data.map(col => ({ - name: col.name, - type: col.type, - nullable: col.nullable === 'YES', - default: col.default_value || '' - })); - } catch (err) { - this.showToast('Ошибка загрузки структуры', 'error'); - return []; + editRecord(pkValue) { + // Find the record by primary key + // Since we have the data, we can find it + this.editingRecord = pkValue; + this.showAddRecordModal(true); + } + + deleteRecord(pkValue) { + if (confirm('Вы уверены, что хотите удалить эту запись?')) { + fetch(`/api/tables/${this.currentTable}/records/${encodeURIComponent(pkValue)}`, { + method: 'DELETE' + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + this.showToast('Запись удалена', 'success'); + this.loadTableData(); + } else { + this.showToast('Ошибка удаления', 'error'); + } + }) + .catch(err => { + this.showToast('Ошибка удаления', 'error'); + }); } } - getInputType(pgType) { - if (pgType.includes('int')) return 'number'; - if (pgType.includes('timestamp') || pgType.includes('date')) return 'datetime-local'; - if (pgType.includes('bool')) return 'checkbox'; - return 'text'; + showAddRecordModal(isEdit = false) { + const modal = document.getElementById('recordModal'); + const title = document.getElementById('recordModalTitle'); + const form = document.getElementById('recordForm'); + + title.textContent = isEdit ? 'Редактировать запись' : 'Добавить запись'; + + // Generate form fields based on table structure + form.innerHTML = this.tableStructure.map(col => { + const value = isEdit && this.editingRecord ? '' : ''; // We'll need to load the record data + return ` +
+ + +
+ `; + }).join(''); + + if (isEdit) { + // Load existing record data + this.loadRecordData(this.editingRecord); + } + + modal.classList.remove('hidden'); + } + + async loadRecordData(pkValue) { + try { + // Since we don't have a single record endpoint, we'll load all data and find the record + const params = new URLSearchParams({ page: 1, limit: 1000 }); // Load more to find the record + const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`); + const data = await response.json(); + + const record = data.data.find(r => r[this.primaryKey] == pkValue); + if (record) { + this.tableStructure.forEach(col => { + const input = document.querySelector(`input[name="${col.name}"]`); + if (input) { + input.value = record[col.name] || ''; + } + }); + } + } catch (err) { + this.showToast('Ошибка загрузки данных записи', 'error'); + } } async saveRecord() { - const form = document.getElementById('recordForm'); - const inputs = form.querySelectorAll('input'); + const formData = new FormData(document.getElementById('recordForm')); const data = {}; - inputs.forEach(input => { - if (input.name === 'id') return; - - if (input.type === 'checkbox') { - data[input.name] = input.checked; - } else if (input.type === 'number') { - data[input.name] = parseFloat(input.value); - } else { - data[input.name] = input.value; - } - }); + + for (let [key, value] of formData.entries()) { + data[key] = value; + } try { let response; - if (this.editingRecord !== null) { - response = await fetch(`/api/tables/${this.currentTable}/records/${this.editingRecord}`, { + if (this.editingRecord) { + response = await fetch(`/api/tables/${this.currentTable}/records/${encodeURIComponent(this.editingRecord)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) @@ -734,294 +736,305 @@ SELECT * FROM users LIMIT 10;"> }); } - if (response.status === 401) { - this.logout(); - return; + const result = await response.json(); + if (result.success) { + this.showToast(this.editingRecord ? 'Запись обновлена' : 'Запись добавлена', 'success'); + this.closeModal('recordModal'); + this.loadTableData(); + this.editingRecord = null; + } else { + this.showToast('Ошибка сохранения', 'error'); + } + } catch (err) { + this.showToast('Ошибка сохранения', 'error'); + } + } + + showTableStructure() { + document.getElementById('structureTableName').textContent = this.currentTable; + this.renderStructureTable(); + document.getElementById('structureModal').classList.remove('hidden'); + } + + renderStructureTable() { + const tbody = document.getElementById('structureBody'); + tbody.innerHTML = this.tableStructure.map(col => ` + + + + + + + + `).join(''); + } + + showCreateColumnModal() { + document.getElementById('columnModalTitle').textContent = 'Добавить колонку'; + document.getElementById('columnName').value = ''; + document.getElementById('columnType').value = 'VARCHAR(255)'; + document.getElementById('columnNullable').checked = true; + document.getElementById('columnDefault').value = ''; + document.getElementById('columnPrimary').checked = false; + this.editingColumn = null; + document.getElementById('columnModal').classList.remove('hidden'); + } + + editColumn(columnName) { + const column = this.tableStructure.find(col => col.name === columnName); + if (!column) return; + + document.getElementById('columnModalTitle').textContent = 'Изменить колонку'; + document.getElementById('columnName').value = column.name; + document.getElementById('columnType').value = column.type; + document.getElementById('columnNullable').checked = column.nullable; + document.getElementById('columnDefault').value = column.default_value || ''; + document.getElementById('columnPrimary').checked = column.is_primary; + this.editingColumn = columnName; + document.getElementById('columnModal').classList.remove('hidden'); + } + + async saveColumn() { + const name = document.getElementById('columnName').value; + const type = document.getElementById('columnType').value; + const nullable = document.getElementById('columnNullable').checked; + const defaultValue = document.getElementById('columnDefault').value; + const primaryKey = document.getElementById('columnPrimary').checked; + + if (!name || !type) { + this.showToast('Название и тип колонки обязательны', 'error'); + return; + } + + try { + let response; + if (this.editingColumn) { + // Update existing column + response = await fetch(`/api/tables/${this.currentTable}/columns/${encodeURIComponent(this.editingColumn)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, nullable, defaultValue }) + }); + } else { + // Add new column + response = await fetch(`/api/tables/${this.currentTable}/columns`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, type, nullable, defaultValue, primaryKey }) + }); } const result = await response.json(); - if (result.error) throw new Error(result.error); - - this.showToast(this.editingRecord !== null ? 'Запись обновлена' : 'Запись создана', 'success'); - this.closeModal('recordModal'); - this.loadTableData(); - } catch (err) { - this.showToast('Ошибка сохранения: ' + err.message, 'error'); - } - } - - async deleteRecord(id) { - if (!confirm('Вы уверены, что хотите удалить эту запись?')) return; - - try { - const response = await fetch(`/api/tables/${this.currentTable}/records/${id}`, { - method: 'DELETE' - }); - - if (response.status === 401) { - this.logout(); - return; + if (result.success) { + this.showToast(this.editingColumn ? 'Колонка обновлена' : 'Колонка добавлена', 'success'); + this.closeModal('columnModal'); + await this.loadTableStructure(); + this.renderStructureTable(); + } else { + this.showToast('Ошибка сохранения колонки', 'error'); } - - const data = await response.json(); - if (data.error) throw new Error(data.error); - - this.showToast('Запись удалена', 'success'); - this.loadTableData(); } catch (err) { - this.showToast('Ошибка удаления', 'error'); + this.showToast('Ошибка сохранения колонки', 'error'); + } + } + + deleteColumn(columnName) { + if (confirm(`Вы уверены, что хотите удалить колонку "${columnName}"?`)) { + fetch(`/api/tables/${this.currentTable}/columns/${encodeURIComponent(columnName)}`, { + method: 'DELETE' + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + this.showToast('Колонка удалена', 'success'); + this.loadTableStructure(); + this.renderStructureTable(); + } else { + this.showToast('Ошибка удаления колонки', 'error'); + } + }) + .catch(err => { + this.showToast('Ошибка удаления колонки', 'error'); + }); } } - // Table Management showCreateTableModal() { document.getElementById('newTableName').value = ''; document.getElementById('columnsContainer').innerHTML = ''; - this.addColumnField(); - this.addColumnField(); + this.addColumnField(); // Add one default column document.getElementById('createTableModal').classList.remove('hidden'); } addColumnField() { const container = document.getElementById('columnsContainer'); - const id = Date.now(); - const div = document.createElement('div'); - div.className = 'flex gap-2 items-start'; - div.innerHTML = ` - - + -
- - -
- `; - container.appendChild(div); + container.appendChild(columnDiv); lucide.createIcons(); } - async createTable() { + createTable() { const name = document.getElementById('newTableName').value; if (!name) { this.showToast('Введите название таблицы', 'error'); return; } - const columns = []; - document.querySelectorAll('#columnsContainer > div').forEach(div => { - columns.push({ - name: div.querySelector('.column-name').value, - type: div.querySelector('.column-type').value, - pk: div.querySelector('.column-pk').checked, - nullable: div.querySelector('.column-null').checked - }); + const columnElements = document.querySelectorAll('#columnsContainer > div'); + const columns = Array.from(columnElements).map(div => { + const inputs = div.querySelectorAll('input, select'); + return { + name: inputs[0].value, + type: inputs[1].value, + pk: inputs[2].checked, + nullable: inputs[3].checked + }; }); - try { - const response = await fetch('/api/tables', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, columns }) - }); - - if (response.status === 401) { - this.logout(); - return; - } - - const data = await response.json(); - if (data.error) throw new Error(data.error); - - this.tables.push({ name, rows: 0, size: '0 KB' }); - this.renderTableList(); - this.closeModal('createTableModal'); - this.showToast(`Таблица ${name} создана`, 'success'); - } catch (err) { - this.showToast('Ошибка создания таблицы: ' + err.message, 'error'); - } - } - - async deleteTable() { - if (!confirm(`Вы уверены, что хотите удалить таблицу ${this.currentTable}? Это действие нельзя отменить.`)) return; - - try { - const response = await fetch(`/api/tables/${this.currentTable}`, { - method: 'DELETE' - }); - - if (response.status === 401) { - this.logout(); - return; - } - - const data = await response.json(); - if (data.error) throw new Error(data.error); - - this.tables = this.tables.filter(t => t.name !== this.currentTable); - this.currentTable = null; - this.renderTableList(); - document.getElementById('dataGrid').classList.add('hidden'); - document.getElementById('emptyState').classList.remove('hidden'); - document.getElementById('tableActions').classList.add('hidden'); - document.getElementById('currentTableTitle').textContent = 'Выберите таблицу'; - this.showToast('Таблица удалена', 'success'); - } catch (err) { - this.showToast('Ошибка удаления таблицы', 'error'); - } - } - - // Structure & Indexes - async showTableStructure() { - document.getElementById('structureTableName').textContent = this.currentTable; - const structure = await this.getTableStructure(this.currentTable); - - document.getElementById('structureBody').innerHTML = structure.map(col => ` - - - - - - - - `).join(''); - - document.getElementById('structureModal').classList.remove('hidden'); - } - - async dropColumn(colName) { - if (!confirm(`Удалить колонку ${colName}?`)) return; - await new Promise(r => setTimeout(r, 300)); - this.showToast(`Колонка ${colName} удалена`, 'success'); - this.showTableStructure(); - } - - async showIndexesModal() { - document.getElementById('indexesTableName').textContent = this.currentTable; - - try { - const response = await fetch(`/api/tables/${this.currentTable}/indexes`); - if (response.status === 401) { - this.logout(); - return; - } - const indexes = await response.json(); - - document.getElementById('indexesBody').innerHTML = indexes.map(idx => ` - - - - - - - - `).join(''); - - document.getElementById('indexesModal').classList.remove('hidden'); - } catch (err) { - this.showToast('Ошибка загрузки индексов', 'error'); - } - } - - showCreateIndexModal() { - document.getElementById('indexName').value = `idx_${this.currentTable}_`; - document.getElementById('indexColumns').value = ''; - document.getElementById('indexUnique').checked = false; - document.getElementById('createIndexModal').classList.remove('hidden'); - } - - async createIndex() { - const name = document.getElementById('indexName').value; - const columns = document.getElementById('indexColumns').value; - const unique = document.getElementById('indexUnique').checked; - - if (!name || !columns) { - this.showToast('Заполните все поля', 'error'); + if (columns.some(col => !col.name)) { + this.showToast('Все колонки должны иметь название', 'error'); return; } - try { - const response = await fetch(`/api/tables/${this.currentTable}/indexes`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, columns, unique }) - }); - - if (response.status === 401) { - this.logout(); - return; + fetch('/api/tables', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, columns }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + this.showToast('Таблица создана', 'success'); + this.closeModal('createTableModal'); + this.loadTables(); + } else { + this.showToast('Ошибка создания таблицы', 'error'); } - - const data = await response.json(); - if (data.error) throw new Error(data.error); - - this.closeModal('createIndexModal'); - this.closeModal('indexesModal'); - this.showToast(`Индекс ${name} создан`, 'success'); - } catch (err) { - this.showToast('Ошибка создания индекса: ' + err.message, 'error'); - } + }) + .catch(err => { + this.showToast('Ошибка создания таблицы', 'error'); + }); } - async dropIndex(name) { - if (!confirm(`Удалить индекс ${name}?`)) return; - - try { - const response = await fetch(`/api/indexes/${name}`, { + deleteTable() { + if (confirm(`Вы уверены, что хотите удалить таблицу "${this.currentTable}"?`)) { + fetch(`/api/tables/${this.currentTable}`, { method: 'DELETE' + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + this.showToast('Таблица удалена', 'success'); + this.currentTable = null; + document.getElementById('currentTableTitle').textContent = 'Выберите таблицу'; + document.getElementById('tableActions').classList.add('hidden'); + document.getElementById('emptyState').classList.remove('hidden'); + document.getElementById('dataGrid').classList.add('hidden'); + this.loadTables(); + } else { + this.showToast('Ошибка удаления таблицы', 'error'); + } + }) + .catch(err => { + this.showToast('Ошибка удаления таблицы', 'error'); }); - - if (response.status === 401) { - this.logout(); - return; - } - - const data = await response.json(); - if (data.error) throw new Error(data.error); - - this.showToast('Индекс удален', 'success'); - this.showIndexesModal(); - } catch (err) { - this.showToast('Ошибка удаления индекса', 'error'); } } - // SQL Editor showSQLPanel() { - document.getElementById('dataGrid').classList.add('hidden'); document.getElementById('emptyState').classList.add('hidden'); + document.getElementById('dataGrid').classList.add('hidden'); document.getElementById('sqlPanel').classList.remove('hidden'); - document.getElementById('currentTableTitle').textContent = 'SQL Query'; - document.getElementById('tableActions').classList.add('hidden'); + } + + executeSQL() { + const sql = document.getElementById('sqlEditor').value; + if (!sql.trim()) { + this.showToast('Введите SQL запрос', 'error'); + return; + } + + fetch('/api/query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sql }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + this.renderSQLResults(data); + document.getElementById('sqlStats').textContent = `Затронуто строк: ${data.rowCount}`; + } else { + this.showToast(data.error || 'Ошибка выполнения SQL', 'error'); + } + }) + .catch(err => { + this.showToast('Ошибка выполнения SQL', 'error'); + }); + } + + renderSQLResults(data) { + const container = document.getElementById('sqlResultsContent'); + document.getElementById('sqlResults').classList.remove('hidden'); + + if (data.rows.length === 0) { + container.innerHTML = '

Нет результатов

'; + return; + } + + const columns = Object.keys(data.rows[0]); + container.innerHTML = ` +
${h}Действия
${v} -
- - -
-
Нет данных
Нет данных
${col}Действия${record[col] || ''} + + + -
${col.name}${col.type}${col.nullable ? 'Да' : 'Нет'}${col.default_value || '-'} + + +
${col.name}${col.type}${col.nullable ? 'YES' : 'NO'}${col.default} - -
${idx.name}${idx.columns}${idx.type}${idx.unique ? 'Да' : 'Нет'} - -
+ + ${columns.map(col => ``).join('')} + + + ${data.rows.map(row => `${columns.map(col => ``).join('')}`).join('')} + +
${col}
${row[col] || ''}
+ `; } formatSQL() { const editor = document.getElementById('sqlEditor'); let sql = editor.value; - // Simple formatting - sql = sql.replace(/\s+/g, ' ').trim(); - sql = sql.replace(/\s*,\s*/g, ', '); - sql = sql.replace(/\s*=\s*/g, ' = '); - const keywords = ['SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP', 'ORDER', 'BY', 'LIMIT', 'OFFSET', 'AND', 'OR', 'NOT', 'NULL']; - keywords.forEach(kw => { - const regex = new RegExp(`\\b${kw}\\b`, 'gi'); - sql = sql.replace(regex, kw); - }); - editor.value = sql; + + // Basic formatting + sql = sql.replace(/\s+/g, ' ') + .replace(/\s*,\s*/g, ',\n ') + .replace(/\s*SELECT\s+/gi, '\nSELECT\n ') + .replace(/\s*FROM\s+/gi, '\nFROM\n ') + .replace(/\s*WHERE\s+/gi, '\nWHERE\n ') + .replace(/\s*ORDER BY\s+/gi, '\nORDER BY\n ') + .replace(/\s*LIMIT\s+/gi, '\nLIMIT\n '); + + editor.value = sql.trim(); } clearSQL() { @@ -1029,91 +1042,117 @@ SELECT * FROM users LIMIT 10;"> document.getElementById('sqlResults').classList.add('hidden'); } - async executeSQL() { - const sql = document.getElementById('sqlEditor').value.trim(); - if (!sql) { - this.showToast('Введите SQL запрос', 'error'); + showIndexesModal() { + document.getElementById('indexesTableName').textContent = this.currentTable; + this.loadIndexes(); + document.getElementById('indexesModal').classList.remove('hidden'); + } + + async loadIndexes() { + try { + const response = await fetch(`/api/tables/${this.currentTable}/indexes`); + const indexes = await response.json(); + this.renderIndexesTable(indexes); + } catch (err) { + this.showToast('Ошибка загрузки индексов', 'error'); + } + } + + renderIndexesTable(indexes) { + const tbody = document.getElementById('indexesBody'); + tbody.innerHTML = indexes.map(index => ` + + ${index.name} + ${index.columns} + ${index.type} + ${index.unique ? 'Да' : 'Нет'} + + + + + `).join(''); + } + + showCreateIndexModal() { + document.getElementById('indexName').value = ''; + document.getElementById('indexColumns').value = ''; + document.getElementById('indexUnique').checked = false; + document.getElementById('createIndexModal').classList.remove('hidden'); + } + + createIndex() { + const name = document.getElementById('indexName').value; + const columns = document.getElementById('indexColumns').value; + const unique = document.getElementById('indexUnique').checked; + + if (!name || !columns) { + this.showToast('Введите название и колонки индекса', 'error'); return; } - // Show loading - const btn = document.querySelector('#sqlPanel button'); - const originalText = btn.innerHTML; - btn.innerHTML = '
Выполнение...'; - btn.disabled = true; - - try { - const response = await fetch('/api/query', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sql }) - }); - - if (response.status === 401) { - this.logout(); - return; - } - - const result = await response.json(); - const resultsDiv = document.getElementById('sqlResults'); - const contentDiv = document.getElementById('sqlResultsContent'); - - if (result.error) { - contentDiv.innerHTML = ` -
- -

Ошибка SQL

-

${result.error}

-
- `; - lucide.createIcons(); - } else if (result.rows && result.rows.length > 0) { - const headers = Object.keys(result.rows[0]); - contentDiv.innerHTML = ` - - - ${headers.map(h => ``).join('')} - - - ${result.rows.map(row => ` - - ${Object.values(row).map(v => ``).join('')} - - `).join('')} - -
${h}
${v}
- `; - document.getElementById('sqlStats').textContent = `${result.rowCount} rows in ${result.command || 'SELECT'}`; + fetch(`/api/tables/${this.currentTable}/indexes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, columns, unique }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + this.showToast('Индекс создан', 'success'); + this.closeModal('createIndexModal'); + this.loadIndexes(); } else { - contentDiv.innerHTML = ` -
- -

Запрос выполнен успешно

-

${result.command}: ${result.rowCount} rows affected

-
- `; - lucide.createIcons(); + this.showToast('Ошибка создания индекса', 'error'); } - - resultsDiv.classList.remove('hidden'); - } catch (err) { - this.showToast('Ошибка выполнения запроса', 'error'); + }) + .catch(err => { + this.showToast('Ошибка создания индекса', 'error'); + }); + } + + deleteIndex(indexName) { + if (confirm(`Вы уверены, что хотите удалить индекс "${indexName}"?`)) { + fetch(`/api/indexes/${encodeURIComponent(indexName)}`, { + method: 'DELETE' + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + this.showToast('Индекс удален', 'success'); + this.loadIndexes(); + } else { + this.showToast('Ошибка удаления индекса', 'error'); + } + }) + .catch(err => { + this.showToast('Ошибка удаления индекса', 'error'); + }); } + } + + async loadTableStructure() { + try { + const response = await fetch(`/api/tables/${this.currentTable}/structure`); + this.tableStructure = await response.json(); + + // Find primary key + const pkColumn = this.tableStructure.find(col => col.is_primary); + this.primaryKey = pkColumn ? pkColumn.name : null; + } catch (err) { + this.showToast('Ошибка загрузки структуры таблицы', 'error'); + } + } + + updatePagination(data) { + document.getElementById('currentPage').textContent = data.page; + document.getElementById('totalPages').textContent = data.totalPages; + document.getElementById('recordCount').textContent = `Записей: ${data.total}`; - btn.innerHTML = originalText; - btn.disabled = false; - } - - // Pagination - changePage(delta) { - this.currentPage += delta; - this.loadTableData(); - } - - changeLimit(newLimit) { - this.limit = parseInt(newLimit); - this.currentPage = 1; - this.loadTableData(); + const prevBtn = document.getElementById('prevPage'); + const nextBtn = document.getElementById('nextPage'); + + prevBtn.classList.toggle('disabled', data.page <= 1); + nextBtn.classList.toggle('disabled', data.page >= data.totalPages); } // Utilities @@ -1153,6 +1192,12 @@ SELECT * FROM users LIMIT 10;"> // Event listeners document.getElementById('loginForm').addEventListener('submit', (e) => app.login(e)); + // Search input + document.getElementById('recordSearch').addEventListener('input', () => { + app.currentPage = 1; + app.loadTableData(); + }); + // Close modals on outside click window.onclick = function(event) { if (event.target.classList.contains('fixed')) { @@ -1161,4 +1206,4 @@ SELECT * FROM users LIMIT 10;"> } - + \ No newline at end of file diff --git a/server.js b/server.js index 10be390..ca471a4 100644 --- a/server.js +++ b/server.js @@ -42,6 +42,23 @@ pool.connect((err, client, release) => { } }); +// Helper: get primary key column for a table (returns null if none) +async function getPrimaryKeyColumn(tableName) { + const result = await pool.query(` + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_name = $1 + AND tc.table_schema = 'public' + LIMIT 1 + `, [tableName]); + + return result.rows[0]?.column_name || null; +} + // Middleware to check if user is authenticated const requireAuth = (req, res, next) => { if (req.session && req.session.authenticated) { @@ -149,23 +166,44 @@ app.get('/api/tables', requireAuth, async (req, res) => { } }); -// Get table data with pagination +// Get table data with pagination and search app.get('/api/tables/:tableName/data', requireAuth, async (req, res) => { const { tableName } = req.params; const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 10; + const search = req.query.search || ''; const offset = (page - 1) * limit; try { + let whereClause = ''; + let params = [limit, offset]; + + if (search) { + // Get column names for search + const columnsResult = await pool.query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 AND table_schema = 'public' + ORDER BY ordinal_position + `, [tableName]); + + const columns = columnsResult.rows.map(row => row.column_name); + const searchConditions = columns.map(col => `CAST("${col}" AS TEXT) ILIKE $${params.length + 1}`).join(' OR '); + whereClause = `WHERE ${searchConditions}`; + params.push(`%${search}%`); + } + // Get total count - const countResult = await pool.query(`SELECT COUNT(*) as total FROM "${tableName}"`); + const countResult = await pool.query(`SELECT COUNT(*) as total FROM "${tableName}" ${whereClause}`, params.slice(2)); const total = parseInt(countResult.rows[0].total); // Get data const result = await pool.query(` SELECT * FROM "${tableName}" + ${whereClause} + ORDER BY (SELECT NULL) -- No specific order, but consistent LIMIT $1 OFFSET $2 - `, [limit, offset]); + `, params); res.json({ data: result.rows, @@ -186,13 +224,22 @@ app.get('/api/tables/:tableName/structure', requireAuth, async (req, res) => { try { const result = await pool.query(` SELECT - column_name as name, - data_type as type, - is_nullable as nullable, - column_default as default_value - FROM information_schema.columns - WHERE table_name = $1 AND table_schema = 'public' - ORDER BY ordinal_position + c.column_name as name, + c.data_type as type, + c.is_nullable as nullable, + c.column_default as default_value, + CASE WHEN kcu.column_name IS NOT NULL THEN true ELSE false END as is_primary + FROM information_schema.columns c + LEFT JOIN information_schema.table_constraints tc + ON tc.table_name = c.table_name + AND tc.table_schema = c.table_schema + AND tc.constraint_type = 'PRIMARY KEY' + LEFT JOIN information_schema.key_column_usage kcu + ON kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + AND kcu.column_name = c.column_name + WHERE c.table_name = $1 AND c.table_schema = 'public' + ORDER BY c.ordinal_position `, [tableName]); res.json(result.rows); @@ -253,8 +300,8 @@ app.post('/api/tables/:tableName/records', requireAuth, async (req, res) => { }); // Update record -app.put('/api/tables/:tableName/records/:id', requireAuth, async (req, res) => { - const { tableName, id } = req.params; +app.put('/api/tables/:tableName/records/:pk', requireAuth, async (req, res) => { + const { tableName, pk } = req.params; const data = req.body; const columns = Object.keys(data); @@ -262,9 +309,9 @@ app.put('/api/tables/:tableName/records/:id', requireAuth, async (req, res) => { const setClause = columns.map((col, i) => `"${col}" = $${i + 1}`).join(', '); try { - // Assuming id column is named 'id' - in production you'd need to detect PK - const sql = `UPDATE "${tableName}" SET ${setClause} WHERE id = $${values.length + 1} RETURNING *`; - const result = await pool.query(sql, [...values, id]); + const primaryKey = await getPrimaryKeyColumn(tableName) || 'id'; + const sql = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKey}" = $${values.length + 1} RETURNING *`; + const result = await pool.query(sql, [...values, pk]); res.json({ success: true, data: result.rows[0] }); } catch (err) { res.status(500).json({ error: err.message }); @@ -272,11 +319,77 @@ app.put('/api/tables/:tableName/records/:id', requireAuth, async (req, res) => { }); // Delete record -app.delete('/api/tables/:tableName/records/:id', requireAuth, async (req, res) => { - const { tableName, id } = req.params; +app.delete('/api/tables/:tableName/records/:pk', requireAuth, async (req, res) => { + const { tableName, pk } = req.params; try { - await pool.query(`DELETE FROM "${tableName}" WHERE id = $1`, [id]); + const primaryKey = await getPrimaryKeyColumn(tableName) || 'id'; + await pool.query(`DELETE FROM "${tableName}" WHERE "${primaryKey}" = $1`, [pk]); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Add a new column +app.post('/api/tables/:tableName/columns', requireAuth, async (req, res) => { + const { tableName } = req.params; + const { name, type, nullable = true, defaultValue, primaryKey } = req.body; + + if (!name || !type) { + return res.status(400).json({ error: 'Column name and type are required' }); + } + + const parts = [`"${name}" ${type}`]; + if (primaryKey) parts.push('PRIMARY KEY'); + if (!nullable) parts.push('NOT NULL'); + if (defaultValue !== undefined && defaultValue !== null && defaultValue !== '') { + parts.push(`DEFAULT ${defaultValue}`); + } + + try { + await pool.query(`ALTER TABLE "${tableName}" ADD COLUMN ${parts.join(' ')}`); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Modify an existing column +app.put('/api/tables/:tableName/columns/:columnName', requireAuth, async (req, res) => { + const { tableName, columnName } = req.params; + const { type, nullable, defaultValue } = req.body; + + try { + if (type) { + await pool.query(`ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}" TYPE ${type}`); + } + + if (typeof nullable === 'boolean') { + const nullSql = nullable ? 'DROP NOT NULL' : 'SET NOT NULL'; + await pool.query(`ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}" ${nullSql}`); + } + + if (defaultValue !== undefined) { + if (defaultValue === null || defaultValue === '') { + await pool.query(`ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}" DROP DEFAULT`); + } else { + await pool.query(`ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}" SET DEFAULT ${defaultValue}`); + } + } + + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Drop a column +app.delete('/api/tables/:tableName/columns/:columnName', requireAuth, async (req, res) => { + const { tableName, columnName } = req.params; + + try { + await pool.query(`ALTER TABLE "${tableName}" DROP COLUMN IF EXISTS "${columnName}"`); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message });