// Application Logic class PostgresAdmin { constructor() { this.currentUser = null; this.currentTable = null; this.tables = []; this.currentRows = []; this.folderState = JSON.parse(localStorage.getItem('pg_folder_state') || '{}'); this.themePreference = localStorage.getItem('pg_theme') || 'system'; this.currentContainer = ''; this.logStream = null; this.logsBuffer = []; this.currentSettings = null; this.currentPage = 1; this.limit = 10; this.editingRecord = null; this.editingColumn = null; this.primaryKey = null; this.tableStructure = []; this.filters = {}; this.filterColumns = []; this.sortColumn = ''; this.sortDirection = 'ASC'; this.init(); } init() { this.applyTheme(this.themePreference); const themeSelect = document.getElementById('themeSelect'); if (themeSelect) themeSelect.value = this.themePreference; const saved = localStorage.getItem('pg_admin_session'); if (saved) { this.currentUser = JSON.parse(saved); fetch('/api/session') .then(response => response.json()) .then(data => { if (data.authenticated) { this.currentUser = data; localStorage.setItem('pg_admin_session', JSON.stringify(this.currentUser)); this.showMainApp(); } else { localStorage.removeItem('pg_admin_session'); } }) .catch(() => { localStorage.removeItem('pg_admin_session'); }); } // Initialize Lucide icons lucide.createIcons(); // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.key === 'Enter' && !document.getElementById('sqlPanel').classList.contains('hidden')) { this.executeSQL(); } }); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (this.themePreference === 'system') { this.applyTheme('system'); } }); } applyTheme(mode) { const resolved = mode === 'system' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : mode; document.body.dataset.theme = resolved; } setTheme(mode) { this.themePreference = mode; localStorage.setItem('pg_theme', mode); this.applyTheme(mode); } toggleSidebar() { document.getElementById('sidebar').classList.toggle('sidebar-open'); document.getElementById('mobileBackdrop').classList.toggle('hidden'); } closeSidebar() { document.getElementById('sidebar').classList.remove('sidebar-open'); document.getElementById('mobileBackdrop').classList.add('hidden'); } // Auth Methods async login(e) { e.preventDefault(); const username = document.getElementById('adminUser').value; const password = document.getElementById('adminPass').value; if (!username || !password) { this.showToast('Введите логин и пароль', 'error'); return; } // Show loader document.getElementById('loginText').classList.add('hidden'); document.getElementById('loginLoader').classList.remove('hidden'); try { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (data.success) { this.currentUser = { username, role: data.role, permissions: data.permissions, dbInfo: data.dbInfo }; localStorage.setItem('pg_admin_session', JSON.stringify(this.currentUser)); this.showToast('Авторизация успешна!', 'success'); this.showMainApp(); } else { this.showToast(data.error || 'Ошибка авторизации', 'error'); } } catch (err) { this.showToast('Ошибка подключения к серверу', 'error'); console.error(err); } // Reset loader document.getElementById('loginText').classList.remove('hidden'); document.getElementById('loginLoader').classList.add('hidden'); } async logout() { this.stopLogStream(); await fetch('/api/logout', { method: 'POST' }); localStorage.removeItem('pg_admin_session'); location.reload(); } showMainApp() { document.getElementById('loginScreen').classList.add('hidden'); document.getElementById('mainApp').classList.remove('hidden'); const dbInfo = this.currentUser.dbInfo || {}; document.getElementById('connectionStatus').textContent = `${dbInfo.host}:${dbInfo.port}/${dbInfo.database}`; document.getElementById('roleBadge').textContent = this.currentUser.role || 'viewer'; document.getElementById('logsButton').classList.toggle('hidden', !this.getPermissions().canViewLogs); document.getElementById('managementButton').classList.toggle('hidden', !this.getPermissions().canManageUsers); document.querySelector('button[onclick="app.showSQLPanel()"]').style.display = this.getPermissions().canRunSql ? '' : 'none'; document.querySelector('button[onclick="app.showCreateTableModal()"]').style.display = this.getPermissions().canCreate ? '' : 'none'; this.loadTables(); } hideWorkspacePanels() { ['emptyState', 'dataGrid', 'sqlPanel', 'logsPanel', 'managementPanel'].forEach((id) => { const node = document.getElementById(id); if (node) { node.classList.add('hidden'); } }); } setToolbarMode(mode) { document.getElementById('tableActions').classList.toggle('hidden', mode !== 'table'); document.getElementById('recordControls').classList.toggle('hidden', mode !== 'table'); } getPermissions() { if (this.currentUser?.role === 'superadmin') { return { folders: null, canCreate: true, canEdit: true, canDelete: true, canViewLogs: true, canRunSql: true, canManageUsers: true, canMoveTables: true }; } return this.currentUser?.permissions || { folders: null, canCreate: false, canEdit: false, canDelete: false, canViewLogs: false, canRunSql: false, canManageUsers: false, canMoveTables: false }; } getCurrentTableFolder() { if (!this.currentTable) return null; const parts = this.currentTable.split('__'); return parts.length > 1 ? parts[0] : 'default'; } canCreate() { const perms = this.getPermissions(); return perms.canCreate; } canEditTable() { const perms = this.getPermissions(); const folder = this.getCurrentTableFolder(); if (!perms.canEdit) return false; if (!perms.folders) return true; return perms.folders.includes(folder); } canDeleteTable() { const perms = this.getPermissions(); const folder = this.getCurrentTableFolder(); if (!perms.canDelete) return false; if (!perms.folders) return true; return perms.folders.includes(folder); } // Data Loading async loadTables() { try { const response = await fetch('/api/tables'); if (response.status === 401) { this.logout(); return; } this.tables = await response.json(); this.renderTableList(); } catch (err) { this.showToast('Ошибка загрузки таблиц', 'error'); } } renderTableList() { const container = document.getElementById('tableList'); const search = document.getElementById('tableSearch').value.toLowerCase(); const filtered = this.tables.filter(t => t.name.toLowerCase().includes(search)); const grouped = filtered.reduce((acc, table) => { const parts = table.name.split('__'); const folder = parts.length > 1 ? parts[0] : 'default'; if (!acc[folder]) acc[folder] = []; acc[folder].push(table); return acc; }, {}); const folderOrder = Object.keys(grouped).sort(); container.innerHTML = folderOrder.map(folder => { const expanded = this.folderState[folder] !== false; const label = folder === 'default' ? 'Общие' : folder; const tablesHtml = grouped[folder].map(table => ` `).join(''); return `
`; }).join(''); lucide.createIcons(); } toggleFolder(folder) { this.folderState[folder] = this.folderState[folder] === false; localStorage.setItem('pg_folder_state', JSON.stringify(this.folderState)); this.renderTableList(); } filterTables(query) { this.renderTableList(); } async selectTable(tableName) { this.currentTable = tableName; this.currentPage = 1; this.renderTableList(); this.closeSidebar(); document.getElementById('currentTableTitle').textContent = tableName; this.setToolbarMode('table'); this.hideWorkspacePanels(); document.getElementById('dataGrid').classList.remove('hidden'); // Update action buttons based on permissions const addBtn = document.querySelector('#tableActions button[onclick="app.showAddRecordModal()"]'); const deleteTableBtn = document.querySelector('#tableActions button[onclick="app.deleteTable()"]'); const moveTableBtn = document.querySelector('#tableActions button[onclick="app.showMoveTableModal()"]'); if (addBtn) addBtn.style.display = this.canEditTable() ? '' : 'none'; if (deleteTableBtn) deleteTableBtn.style.display = this.canDeleteTable() ? '' : 'none'; if (moveTableBtn) moveTableBtn.style.display = this.getPermissions().canMoveTables ? '' : 'none'; // Load table structure to get primary key await this.loadTableStructure(); await this.loadTableData(); } async loadTableData() { const searchQuery = document.getElementById('recordSearch').value; try { const params = new URLSearchParams({ page: this.currentPage, limit: this.limit, search: searchQuery, filters: JSON.stringify(this.filters), sortColumn: this.sortColumn, sortDirection: this.sortDirection }); const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`); const data = await response.json(); this.renderTableData(data.data); this.updatePagination(data); } catch (err) { this.showToast('Ошибка при загрузке данных', 'error'); } } renderTableData(records) { const headers = document.getElementById('tableHeaders'); const filterInputs = document.getElementById('filterInputs'); const body = document.getElementById('tableBody'); // Generate headers this.currentRows = records; const columns = (records.length > 0 ? Object.keys(records[0]) : this.tableStructure.map(col => col.name)).filter(col => col !== '__rowid'); headers.innerHTML = columns.map(col => { const isSorted = this.sortColumn === col; const arrow = isSorted ? (this.sortDirection === 'ASC' ? '↑' : '↓') : ''; return `${col} ${arrow}`; }).join('') + 'Действия'; // Render filter inputs once per column set and keep values in sync if (JSON.stringify(this.filterColumns) !== JSON.stringify(columns)) { this.filterColumns = columns; this.renderFilterRow(columns); } else { this.updateFilterInputs(columns); } if (records.length === 0) { body.innerHTML = 'Нет данных'; return; } body.innerHTML = records.map(record => { const pkValue = this.primaryKey && record[this.primaryKey] !== undefined && record[this.primaryKey] !== null && record[this.primaryKey] !== '' ? record[this.primaryKey] : record.__rowid; const cells = columns.map(col => { const colDef = this.tableStructure.find(c => c.name === col); let displayValue = record[col] || ''; if (colDef && colDef.type.toLowerCase() === 'boolean') { displayValue = record[col] === true || record[col] === 'true' ? '✓' : '✗'; } return `${displayValue}`; }).join(''); const canEdit = this.canEditTable(); const canDelete = this.canDeleteTable(); const actionValue = JSON.stringify(String(pkValue)); const actions = pkValue ? ` ${canEdit ? `` : ''} ${canDelete ? `` : ''} ` : '-'; return `${cells}${actions}`; }).join(''); } // Filter row helpers (do not re-render inputs on every data refresh) renderFilterRow(columns) { const filterInputs = document.getElementById('filterInputs'); filterInputs.innerHTML = columns.map(col => { return ``; }).join('') + ''; } updateFilterInputs(columns) { columns.forEach(col => { const input = document.querySelector(`#filterInputs input[data-col="${col}"]`); if (input) { input.value = this.filters[col] || ''; } }); } sortBy(column) { if (this.sortColumn === column) { if (this.sortDirection === 'ASC') { this.sortDirection = 'DESC'; } else { // Third click: remove sorting this.sortColumn = ''; this.sortDirection = 'ASC'; } } else { this.sortColumn = column; this.sortDirection = 'ASC'; } this.currentPage = 1; this.loadTableData(); } 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(); this.loadTables(); } else { this.showToast('Ошибка удаления', 'error'); } }) .catch(err => { this.showToast('Ошибка удаления', 'error'); }); } } 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 const columnsToRender = this.tableStructure.filter(col => { const metaFields = ['created_at', 'created_by', 'updated_at', 'updated_by']; if (!isEdit && metaFields.includes(col.name)) { return false; } // For new records, skip UUID/uid columns so they get generated/ignored by the server if (!isEdit && (col.name.toLowerCase() === 'uid' || col.type.toLowerCase() === 'uuid')) { return false; } return true; }); form.innerHTML = columnsToRender.map(col => { const value = isEdit && this.editingRecord ? '' : ''; let inputHtml = ''; if (col.type.toLowerCase() === 'boolean') { inputHtml = ``; } else if (col.type.toLowerCase().includes('json')) { inputHtml = ``; } else { let inputType = 'text'; let step = ''; if (col.type.toLowerCase().includes('int')) { inputType = 'number'; } else if (col.type.toLowerCase().includes('decimal') || col.type.toLowerCase().includes('numeric')) { inputType = 'number'; step = 'step="0.01"'; } else if (col.type.toLowerCase() === 'date') { inputType = 'date'; } else if (col.type.toLowerCase().includes('timestamp')) { inputType = 'datetime-local'; } const metaFields = ['created_at', 'created_by', 'updated_at', 'updated_by']; const readonly = metaFields.includes(col.name) ? 'readonly' : ''; const readonlyClass = metaFields.includes(col.name) ? ' bg-slate-100 text-slate-500' : ''; inputHtml = ``; } return `
${inputHtml}
`; }).join(''); if (isEdit) { // Load existing record data this.loadRecordData(this.editingRecord); } modal.classList.remove('hidden'); } async loadRecordData(pkValue) { try { let record = this.currentRows.find(r => { const rowKey = this.primaryKey && r[this.primaryKey] !== undefined && r[this.primaryKey] !== null && r[this.primaryKey] !== '' ? r[this.primaryKey] : r.__rowid; return String(rowKey) === String(pkValue); }); if (!record) { const params = new URLSearchParams({ page: 1, limit: 1000 }); const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`); const data = await response.json(); this.currentRows = data.data || []; record = this.currentRows.find(r => { const rowKey = this.primaryKey && r[this.primaryKey] !== undefined && r[this.primaryKey] !== null && r[this.primaryKey] !== '' ? r[this.primaryKey] : r.__rowid; return String(rowKey) === String(pkValue); }); } if (record) { this.tableStructure.forEach(col => { const input = document.querySelector(`[name="${col.name}"]`); if (input) { if (col.type.toLowerCase() === 'boolean') { input.checked = record[col.name] === true || record[col.name] === 'true'; } else { input.value = record[col.name] || ''; } } }); } } catch (err) { this.showToast('Ошибка загрузки данных записи', 'error'); } } async saveRecord() { const data = {}; this.tableStructure.forEach(col => { const input = document.querySelector(`[name="${col.name}"]`); if (input) { if (col.type.toLowerCase() === 'boolean') { data[col.name] = input.checked; } else { data[col.name] = input.value; } } }); // Ensure UUID/UID columns are sent (as empty strings) so the server can auto-generate values if (!this.editingRecord) { this.tableStructure.forEach(col => { if (col.name.toLowerCase() === 'uid' || col.type.toLowerCase() === 'uuid') { if (data[col.name] === undefined || data[col.name] === '') { data[col.name] = ''; } } }); } try { let response; 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) }); } else { response = await fetch(`/api/tables/${this.currentTable}/records`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); } const result = await response.json(); if (result.success) { this.showToast(this.editingRecord ? 'Запись обновлена' : 'Запись добавлена', 'success'); this.closeModal('recordModal'); this.loadTableData(); // Refresh table list counts when rows change (new insert/delete) if (!this.editingRecord) { this.loadTables(); } this.editingRecord = null; } else { this.showToast(result.error || 'Ошибка сохранения', '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 => ` ${col.name} ${col.type} ${col.nullable ? 'Да' : 'Нет'} ${col.default_value || '-'} `).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.success) { this.showToast(this.editingColumn ? 'Колонка обновлена' : 'Колонка добавлена', 'success'); this.closeModal('columnModal'); await this.loadTableStructure(); this.renderStructureTable(); } else { this.showToast('Ошибка сохранения колонки', 'error'); } } catch (err) { 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'); }); } } showCreateTableModal() { document.getElementById('newTableFolder').value = ''; document.getElementById('newTableName').value = ''; document.getElementById('columnsContainer').innerHTML = ''; this.addColumnField(); // Add one default column document.getElementById('createTableModal').classList.remove('hidden'); } addColumnField() { const container = document.getElementById('columnsContainer'); const columnDiv = document.createElement('div'); columnDiv.className = 'flex items-center gap-2 p-3 bg-slate-50 rounded-lg'; columnDiv.innerHTML = ` `; container.appendChild(columnDiv); lucide.createIcons(); } createTable() { const folder = document.getElementById('newTableFolder').value.trim(); const name = document.getElementById('newTableName').value.trim(); if (!name) { this.showToast('Введите название таблицы', 'error'); return; } const tableName = folder ? `${folder}__${name}` : name; 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 }; }); if (columns.some(col => !col.name)) { this.showToast('Все колонки должны иметь название', 'error'); return; } fetch('/api/tables', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: tableName, columns }) }) .then(response => response.json()) .then(data => { if (data.success) { this.showToast('Таблица создана', 'success'); this.closeModal('createTableModal'); this.loadTables(); } else { this.showToast('Ошибка создания таблицы', 'error'); } }) .catch(err => { this.showToast('Ошибка создания таблицы', 'error'); }); } 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'); }); } } showSQLPanel() { if (!this.getPermissions().canRunSql) { this.showToast('SQL доступ разрешен только администраторам', 'error'); return; } this.setToolbarMode('workspace'); this.hideWorkspacePanels(); document.getElementById('sqlPanel').classList.remove('hidden'); document.getElementById('currentTableTitle').textContent = 'SQL Query'; } 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', 'X-Request-Source': 'WEB' }, 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 = ` ${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; // 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(); } applySQLTemplate(type) { const table = this.currentTable || 'your_table'; const templates = { select: `SELECT *\nFROM "${table}"\nLIMIT 50;`, count: `SELECT COUNT(*) AS total\nFROM "${table}";`, insert: `INSERT INTO "${table}" ("column_name")\nVALUES ('value')\nRETURNING *;`, update: `UPDATE "${table}"\nSET "column_name" = 'value'\nWHERE "id" = 'your-id'\nRETURNING *;`, delete: `DELETE FROM "${table}"\nWHERE "id" = 'your-id'\nRETURNING *;`, schema: `SELECT column_name, data_type, is_nullable, column_default\nFROM information_schema.columns\nWHERE table_schema = 'public' AND table_name = '${table}'\nORDER BY ordinal_position;`, }; document.getElementById('sqlEditor').value = templates[type] || ''; } clearSQL() { document.getElementById('sqlEditor').value = ''; document.getElementById('sqlResults').classList.add('hidden'); } async showLogsPanel() { if (!this.getPermissions().canViewLogs) { this.showToast('Просмотр логов разрешен только администраторам', 'error'); return; } this.setToolbarMode('workspace'); this.hideWorkspacePanels(); document.getElementById('logsPanel').classList.remove('hidden'); document.getElementById('currentTableTitle').textContent = 'Container logs'; await this.loadContainers(); } async loadContainers() { try { const response = await fetch('/api/containers'); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Не удалось получить контейнеры'); } const select = document.getElementById('containerSelect'); select.innerHTML = '' + data.map(container => `` ).join(''); if (!this.currentContainer && data[0]) { this.currentContainer = data[0].name; select.value = this.currentContainer; } if (this.currentContainer) { await this.refreshLogs(); } } catch (err) { document.getElementById('logStatus').textContent = err.message; this.showToast(err.message, 'error'); } } async changeContainer(value) { this.currentContainer = value; this.stopLogStream(); if (value) { await this.refreshLogs(); } } async refreshLogs() { if (!this.currentContainer) return; try { const response = await fetch(`/api/containers/${encodeURIComponent(this.currentContainer)}/logs`); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Не удалось загрузить логи'); } this.logsBuffer = (data.logs || '').split(/\r?\n/).filter(Boolean).slice(-400); this.renderLogs(); document.getElementById('logStatus').textContent = `${data.container.name} · ${data.container.status}`; } catch (err) { document.getElementById('logStatus').textContent = err.message; this.showToast(err.message, 'error'); } } toggleLogStream() { if (this.logStream) { this.stopLogStream(); } else { this.startLogStream(); } } startLogStream() { if (!this.currentContainer) return; this.stopLogStream(); this.logStream = new EventSource(`/api/containers/${encodeURIComponent(this.currentContainer)}/logs/stream`); document.getElementById('logStreamButton').textContent = 'Stop live'; document.getElementById('logStatus').textContent = `Streaming ${this.currentContainer}`; this.logStream.addEventListener('log', (event) => { const payload = JSON.parse(event.data); this.logsBuffer.push(payload.line); this.logsBuffer = this.logsBuffer.slice(-800); this.renderLogs(); }); this.logStream.addEventListener('error', () => { this.stopLogStream(); document.getElementById('logStatus').textContent = 'Live stream stopped'; }); } stopLogStream() { if (this.logStream) { this.logStream.close(); this.logStream = null; } document.getElementById('logStreamButton').textContent = 'Start live'; } clearLogs() { this.logsBuffer = []; this.renderLogs(); } renderLogs() { const output = document.getElementById('logOutput'); output.textContent = this.logsBuffer.length ? this.logsBuffer.join('\n') : 'No logs yet.'; output.scrollTop = output.scrollHeight; } parseList(value) { return value.split(',').map(item => item.trim()).filter(Boolean); } getAvailableFolders() { return Array.from(new Set(this.tables.map(table => { const parts = table.name.split('__'); return parts.length > 1 ? parts[0] : 'default'; }))).sort(); } renderOptionChecklist(containerId, values, selected = []) { const container = document.getElementById(containerId); const selectedSet = new Set(selected || []); container.innerHTML = values.length ? values.map(value => ` `).join('') : '
No options available
'; } getCheckedValues(containerId) { return Array.from(document.querySelectorAll(`#${containerId} input[type="checkbox"]:checked`)).map(input => input.value); } async showManagementPanel(section = 'settings') { if (!this.getPermissions().canManageUsers) { this.showToast('Management is available only for admins', 'error'); return; } this.setToolbarMode('workspace'); this.hideWorkspacePanels(); document.getElementById('managementPanel').classList.remove('hidden'); document.getElementById('currentTableTitle').textContent = 'Management'; ['settings', 'backups', 'users', 'audit'].forEach((item) => { document.getElementById(`managementSection-${item}`).classList.toggle('hidden', item !== section); document.getElementById(`managementTab-${item}`).className = item === section ? 'w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left bg-slate-100 text-slate-900' : 'w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left text-slate-700 hover:bg-slate-100'; }); if (section === 'settings') { await this.loadSettings(); } else if (section === 'backups') { await this.loadBackups(); } else if (section === 'users') { this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name)); await this.loadUsers(); this.resetUserForm(); } else if (section === 'audit') { await this.loadAuditLog(); } } async showUsersModal() { await this.showManagementPanel('users'); } async loadUsers() { try { const response = await fetch('/api/users'); const users = await response.json(); if (!response.ok) { throw new Error(users.error || 'Failed to load users'); } document.getElementById('managementUsersTableBody').innerHTML = users.map(user => ` ${user.username} ${user.role} ${user.disabled ? 'disabled' : 'active'} `).join(''); } catch (err) { this.showToast(err.message, 'error'); } } editUser(serializedUser) { const user = JSON.parse(serializedUser); document.getElementById('managementUserEditMode').value = user.username; document.getElementById('managementUserUsername').value = user.username; document.getElementById('managementUserUsername').disabled = true; document.getElementById('managementUserPassword').value = ''; document.getElementById('managementUserRole').value = user.role; this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.view?.folders || []); this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.delete?.folders || []); this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name), user.access?.edit?.tables || []); document.getElementById('managementUserDisabled').checked = Boolean(user.disabled); } resetUserForm() { document.getElementById('managementUserEditMode').value = ''; document.getElementById('managementUserUsername').value = ''; document.getElementById('managementUserUsername').disabled = false; document.getElementById('managementUserPassword').value = ''; document.getElementById('managementUserRole').value = 'viewer'; this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default')); this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name)); document.getElementById('managementUserDisabled').checked = false; } async saveUser() { const username = document.getElementById('managementUserUsername').value.trim(); const editMode = document.getElementById('managementUserEditMode').value; const password = document.getElementById('managementUserPassword').value; const payload = { username, password, role: document.getElementById('managementUserRole').value, disabled: document.getElementById('managementUserDisabled').checked, access: { view: { folders: this.getCheckedValues('managementUserViewFoldersList'), tables: [] }, create: { folders: this.getCheckedValues('managementUserViewFoldersList'), tables: [] }, edit: { folders: [], tables: this.getCheckedValues('managementUserEditTablesList') }, delete: { folders: this.getCheckedValues('managementUserDeleteFoldersList'), tables: [] }, }, }; try { const response = await fetch(editMode ? `/api/users/${encodeURIComponent(editMode)}` : '/api/users', { method: editMode ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Failed to save user'); } this.showToast('User saved', 'success'); this.resetUserForm(); this.loadUsers(); } catch (err) { this.showToast(err.message, 'error'); } } async deleteUser(username) { if (!confirm(`Delete user ${username}?`)) return; try { const response = await fetch(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Failed to delete user'); } this.showToast('User deleted', 'success'); this.loadUsers(); } catch (err) { this.showToast(err.message, 'error'); } } showMoveTableModal() { const parts = (this.currentTable || '').split('__'); const currentFolder = parts.length > 1 ? parts[0] : 'default'; const folders = this.getAvailableFolders(); document.getElementById('moveTableFolder').innerHTML = [''] .concat(folders.filter(folder => folder !== 'default').map(folder => ``)) .join(''); document.getElementById('moveTableFolder').value = currentFolder; document.getElementById('moveTableName').value = parts.length > 1 ? parts.slice(1).join('__') : this.currentTable || ''; document.getElementById('moveTableModal').classList.remove('hidden'); } async moveTable() { const folder = document.getElementById('moveTableFolder').value.trim(); const name = document.getElementById('moveTableName').value.trim(); if (!name) { this.showToast('Table name is required', 'error'); return; } try { const response = await fetch(`/api/tables/${encodeURIComponent(this.currentTable)}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder: folder === 'default' ? '' : folder, name }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Failed to move table'); } this.currentTable = data.name; this.closeModal('moveTableModal'); this.loadTables(); this.selectTable(data.name); this.showToast('Table moved', 'success'); } catch (err) { this.showToast(err.message, 'error'); } } async showAuditModal() { await this.showManagementPanel('audit'); } async showBackupsModal() { await this.showManagementPanel('backups'); } async showSettingsModal() { await this.showManagementPanel('settings'); } async loadBackups() { try { const response = await fetch('/api/backups'); const backups = await response.json(); if (!response.ok) { throw new Error(backups.error || 'Failed to load backups'); } document.getElementById('managementBackupsList').innerHTML = backups.length ? backups.map(backup => `
${backup.filename}
${backup.createdAt} - ${backup.kind} - ${backup.size} bytes
Download
`).join('') : '
No backups yet.
'; } catch (err) { this.showToast(err.message, 'error'); } } async createBackup() { try { const response = await fetch('/api/backups', { method: 'POST', headers: { 'X-Request-Source': 'WEB' }, }); const result = await response.json(); if (!response.ok) { throw new Error(result.error || 'Failed to create backup'); } this.showToast('Archive created', 'success'); this.loadBackups(); } catch (err) { this.showToast(err.message, 'error'); } } async restoreBackup(filename) { if (!confirm(`Restore backup ${filename}? The current database will be replaced.`)) { return; } try { const response = await fetch(`/api/backups/${encodeURIComponent(filename)}/restore`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Request-Source': 'WEB', }, body: JSON.stringify({ restoreAppSnapshot: document.getElementById('managementRestoreAppSnapshot').checked, }), }); const result = await response.json(); if (!response.ok) { throw new Error(result.error || 'Failed to restore backup'); } this.showToast('Backup restored', 'success'); await this.loadTables(); if (this.currentTable) { await this.selectTable(this.currentTable); } } catch (err) { this.showToast(err.message, 'error'); } } async loadSettings() { try { const response = await fetch('/api/settings'); const settings = await response.json(); if (!response.ok) { throw new Error(settings.error || 'Failed to load settings'); } this.currentSettings = settings; document.getElementById('managementSettingsBackupsEnabled').checked = Boolean(settings.backups?.enabled); document.getElementById('managementSettingsBackupTime').value = `${String(settings.backups?.hour ?? 3).padStart(2, '0')}:${String(settings.backups?.minute ?? 0).padStart(2, '0')}`; document.getElementById('managementSettingsKeepLast').value = settings.backups?.keepLast ?? 14; document.getElementById('managementSettingsIncludeAppSnapshot').checked = settings.backups?.includeAppSnapshot !== false; document.getElementById('managementSettingsTelegramEnabled').checked = Boolean(settings.telegram?.enabled); document.getElementById('managementSettingsTelegramToken').value = settings.telegram?.botToken || ''; document.getElementById('managementSettingsTelegramChatId').value = settings.telegram?.chatId || ''; } catch (err) { this.showToast(err.message, 'error'); } } async saveSettings() { const [hour, minute] = (document.getElementById('managementSettingsBackupTime').value || '03:00').split(':').map(Number); const payload = { backups: { enabled: document.getElementById('managementSettingsBackupsEnabled').checked, hour, minute, keepLast: Number(document.getElementById('managementSettingsKeepLast').value || 14), includeAppSnapshot: document.getElementById('managementSettingsIncludeAppSnapshot').checked, }, telegram: { enabled: document.getElementById('managementSettingsTelegramEnabled').checked, botToken: document.getElementById('managementSettingsTelegramToken').value.trim(), chatId: document.getElementById('managementSettingsTelegramChatId').value.trim(), }, }; try { const response = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Request-Source': 'WEB', }, body: JSON.stringify(payload), }); const result = await response.json(); if (!response.ok) { throw new Error(result.error || 'Failed to save settings'); } this.currentSettings = result.settings; this.showToast('Settings saved', 'success'); } catch (err) { this.showToast(err.message, 'error'); } } async loadAuditLog() { try { const response = await fetch('/api/audit'); const entries = await response.json(); if (!response.ok) { throw new Error(entries.error || 'Failed to load audit log'); } document.getElementById('managementAuditList').innerHTML = entries.length ? entries.map(entry => `
${entry.summary || entry.event}
${entry.timestamp}
Who: ${entry.actor} · Source: ${entry.source || 'WEB'}
${entry.event}
${JSON.stringify(entry.details, null, 2)}
`).join('') : '
Audit log is empty.
'; } catch (err) { this.showToast(err.message, '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; } 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 { 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'); }); } } updateFilterColumn(index, column) { this.filters[index].column = column; this.renderFilters(); // Re-render to update disabled state } updateFilterOperator(index, operator) { this.filters[index].operator = operator; this.renderFilters(); // Re-render to update disabled state } toggleFilters() { const filterRow = document.getElementById('filterRow'); filterRow.classList.toggle('hidden'); } updateFilter(column, value) { if (value.trim()) { this.filters[column] = value.trim(); } else { delete this.filters[column]; } this.currentPage = 1; this.loadTableData(); } clearFilters() { this.filters = {}; // Update all input values document.querySelectorAll('#filterInputs input').forEach(input => { input.value = ''; }); this.currentPage = 1; this.loadTableData(); } 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 : (this.tableStructure.find(col => col.name === 'id') ? 'id' : 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}`; 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 closeModal(modalId) { document.getElementById(modalId).classList.add('hidden'); } showToast(message, type = 'info') { const container = document.getElementById('toastContainer'); const toast = document.createElement('div'); const colors = { success: 'bg-green-600', error: 'bg-red-600', info: 'bg-blue-600' }; toast.className = `${colors[type]} text-white px-6 py-3 rounded-xl shadow-lg flex items-center gap-3 fade-in transform transition-all`; toast.innerHTML = ` ${message} `; container.appendChild(toast); lucide.createIcons(); setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateY(10px)'; setTimeout(() => toast.remove(), 300); }, 3000); } } // Initialize app const app = new PostgresAdmin(); // 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') && event.target.id !== 'loginScreen') { event.target.classList.add('hidden'); } }