// 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 `
${tablesHtml}
`;
}).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 => `| ${col} | `).join('')}
${data.rows.map(row => `${columns.map(col => `| ${row[col] || ''} | `).join('')}
`).join('')}
`;
}
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
`).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');
}
}