Files
PG-admi-onefile/public/assets/app.js
2026-03-20 17:12:34 +07:00

1701 lines
83 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
}
goHome() {
// Close any open panels
this.hideWorkspacePanels();
// Reset to table view
this.currentTable = null;
this.setToolbarMode('default');
document.getElementById('currentTableTitle').textContent = 'Select table';
document.getElementById('tableActions').classList.add('hidden');
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('dataGrid').classList.add('hidden');
document.getElementById('managementPanel').classList.add('hidden');
document.getElementById('sqlPanel').classList.add('hidden');
document.getElementById('logsPanel').classList.add('hidden');
// Close sidebar on mobile
this.closeSidebar();
// Scroll to top
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
}
// 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 => `
<div onclick="app.selectTable('${table.name}')"
class="sidebar-item px-4 py-3 cursor-pointer border-l-2 ${this.currentTable === table.name ? 'border-blue-500 bg-slate-800/50 text-white' : 'border-transparent hover:text-white'} transition-all flex items-center justify-between group">
<div class="flex items-center gap-3">
<i data-lucide="table-2" class="w-4 h-4 opacity-70"></i>
<span class="text-sm font-medium">${table.name.replace(`${folder}__`, '')}</span>
</div>
<span class="text-xs opacity-50 group-hover:opacity-100">${table.rows}</span>
</div>
`).join('');
return `
<div class="mb-2">
<button onclick="app.toggleFolder('${folder}')" class="w-full px-4 py-2 text-xs text-slate-400 uppercase tracking-wider font-semibold flex items-center justify-between hover:text-white transition-colors">
<span>${label}</span>
<span class="flex items-center gap-2">
<span>${grouped[folder].length}</span>
<i data-lucide="${expanded ? 'chevron-down' : 'chevron-right'}" class="w-4 h-4"></i>
</span>
</button>
<div class="${expanded ? '' : 'hidden'} sidebar-collapsible overflow-hidden">
${tablesHtml}
</div>
</div>
`;
}).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 `<th class="text-left p-3 cursor-pointer hover:bg-slate-100 select-none" onclick="app.sortBy('${col}')">${col} ${arrow}</th>`;
}).join('') + '<th class="text-left p-3">Действия</th>';
// 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 = '<tr><td colspan="100%" class="text-center py-8 text-slate-500">Нет данных</td></tr>';
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 `<td class="p-3 border-b border-slate-100">${displayValue}</td>`;
}).join('');
const canEdit = this.canEditTable();
const canDelete = this.canDeleteTable();
const actionValue = JSON.stringify(String(pkValue));
const actions = pkValue ? `
<td class="p-3 border-b border-slate-100">
${canEdit ? `<button onclick="app.editRecord('${pkValue}')" class="text-blue-600 hover:underline mr-2">Редактировать</button>` : ''}
${canDelete ? `<button onclick="app.deleteRecord('${pkValue}')" class="text-red-600 hover:underline">Удалить</button>` : ''}
</td>
` : '<td class="p-3 border-b border-slate-100">-</td>';
return `<tr class="hover:bg-slate-50">${cells}${actions}</tr>`;
}).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 `<td class="p-2"><input type="text" data-col="${col}" placeholder="Фильтр ${col}" class="w-full px-2 py-1 text-xs border border-slate-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" oninput="app.updateFilter('${col}', this.value)" value="${this.filters[col] || ''}"></td>`;
}).join('') + '<td class="p-2"><button onclick="app.clearFilters()" class="text-xs text-slate-500 hover:text-slate-700">Очистить</button></td>';
}
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 = `<input type="checkbox" name="${col.name}" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500">`;
} else if (col.type.toLowerCase().includes('json')) {
inputHtml = `<textarea name="${col.name}" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" placeholder='{"key": "value"}'></textarea>`;
} 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 = `<input type="${inputType}" name="${col.name}" value="${value}" ${step} ${readonly} class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none${readonlyClass}" placeholder="${col.type}">`;
}
return `
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">${col.name} (${col.type})</label>
${inputHtml}
</div>
`;
}).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 => `
<tr class="hover:bg-slate-50">
<td class="p-3">${col.name}</td>
<td class="p-3">${col.type}</td>
<td class="p-3">${col.nullable ? 'Да' : 'Нет'}</td>
<td class="p-3">${col.default_value || '-'}</td>
<td class="p-3">
<button onclick="app.editColumn('${col.name}')" class="text-blue-600 hover:underline mr-2">Изменить</button>
<button onclick="app.deleteColumn('${col.name}')" class="text-red-600 hover:underline">Удалить</button>
</td>
</tr>
`).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 = `
<input type="text" placeholder="Название" class="flex-1 px-3 py-2 border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none" required>
<select class="px-3 py-2 border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none">
<option value="VARCHAR(255)">VARCHAR(255)</option>
<option value="TEXT">TEXT</option>
<option value="INTEGER">INTEGER</option>
<option value="BIGINT">BIGINT</option>
<option value="DECIMAL">DECIMAL</option>
<option value="BOOLEAN">BOOLEAN</option>
<option value="DATE">DATE</option>
<option value="TIMESTAMP">TIMESTAMP</option>
<option value="UUID">UUID</option>
<option value="JSON">JSON</option>
<option value="JSONB">JSONB</option>
</select>
<label class="flex items-center gap-1 text-sm">
<input type="checkbox" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"> PK
</label>
<label class="flex items-center gap-1 text-sm">
<input type="checkbox" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500" checked> NULL
</label>
<button onclick="this.parentElement.remove()" class="p-2 text-red-600 hover:bg-red-50 rounded">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
`;
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 = '<p class="text-center py-8 text-slate-500">Нет результатов</p>';
return;
}
const columns = Object.keys(data.rows[0]);
container.innerHTML = `
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
<tr>${columns.map(col => `<th class="text-left p-3">${col}</th>`).join('')}</tr>
</thead>
<tbody class="divide-y divide-slate-200">
${data.rows.map(row => `<tr>${columns.map(col => `<td class="p-3">${row[col] || ''}</td>`).join('')}</tr>`).join('')}
</tbody>
</table>
`;
}
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 = '<option value="">Select container</option>' + data.map(container =>
`<option value="${container.name}" ${this.currentContainer === container.name ? 'selected' : ''}>${container.name} · ${container.status}</option>`
).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 => `
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" value="${value}" ${selectedSet.has(value) ? 'checked' : ''} class="w-4 h-4">
<span>${value}</span>
</label>
`).join('')
: '<div class="text-sm text-slate-500">No options available</div>';
}
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 => `
<tr class="border-b border-slate-200">
<td class="p-3">${user.username}</td>
<td class="p-3">${user.role}</td>
<td class="p-3">${user.disabled ? 'disabled' : 'active'}</td>
<td class="p-3">
<button onclick='app.editUser(${JSON.stringify(JSON.stringify(user))})' class="text-blue-600 hover:underline mr-3">Edit</button>
<button onclick='app.deleteUser(${JSON.stringify(user.username)})' class="text-red-600 hover:underline">Delete</button>
</td>
</tr>
`).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 = ['<option value="default">Common folder</option>']
.concat(folders.filter(folder => folder !== 'default').map(folder => `<option value="${folder}">${folder}</option>`))
.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 => `
<div class="border border-slate-200 rounded-xl p-4 flex items-center justify-between gap-4">
<div>
<div class="font-medium text-slate-800">${backup.filename}</div>
<div class="text-sm text-slate-500">${backup.createdAt} - ${backup.kind} - ${backup.size} bytes</div>
</div>
<div class="flex items-center gap-2">
<button onclick='app.restoreBackup(${JSON.stringify(backup.filename)})' class="px-4 py-2 bg-amber-50 text-amber-700 rounded-lg">Restore</button>
<a href="/api/backups/${encodeURIComponent(backup.filename)}/download" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Download</a>
</div>
</div>
`).join('')
: '<div class="text-sm text-slate-500">No backups yet.</div>';
} 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 uploadBackup() {
const fileInput = document.getElementById('backupFileInput');
const file = fileInput.files[0];
if (!file) {
this.showToast('No file selected', 'error');
return;
}
if (!file.name.endsWith('.tar.gz')) {
this.showToast('Only .tar.gz files are supported', 'error');
fileInput.value = '';
return;
}
try {
// Show loading state
const uploadBtn = Array.from(document.querySelectorAll('button')).find(btn => btn.textContent.includes('Upload archive'));
const originalText = uploadBtn.textContent;
uploadBtn.disabled = true;
uploadBtn.textContent = 'Uploading...';
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/backups/upload', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to upload backup');
}
this.showToast('Backup uploaded successfully', 'success');
fileInput.value = '';
await this.loadBackups();
} catch (err) {
this.showToast(err.message, 'error');
fileInput.value = '';
} finally {
const uploadBtn = Array.from(document.querySelectorAll('button')).find(btn => btn.textContent.includes('Upload') || btn.textContent.includes('Uploading'));
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.textContent = 'Upload archive';
}
}
}
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 => `
<div class="border border-slate-200 rounded-xl p-4">
<div class="flex items-center justify-between gap-4">
<div class="font-medium text-slate-800">${entry.summary || entry.event}</div>
<div class="text-xs text-slate-500">${entry.timestamp}</div>
</div>
<div class="text-sm text-slate-600 mt-1">Who: ${entry.actor} · Source: ${entry.source || 'WEB'}</div>
<div class="text-xs text-slate-500 mt-2">${entry.event}</div>
<pre class="mt-3 text-xs bg-slate-50 rounded-lg p-3 overflow-auto">${JSON.stringify(entry.details, null, 2)}</pre>
</div>
`).join('')
: '<div class="text-sm text-slate-500">Audit log is empty.</div>';
} 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 => `
<tr class="hover:bg-slate-50">
<td class="p-3">${index.name}</td>
<td class="p-3">${index.columns}</td>
<td class="p-3">${index.type}</td>
<td class="p-3">${index.unique ? 'Да' : 'Нет'}</td>
<td class="p-3">
<button onclick="app.deleteIndex('${index.name}')" class="text-red-600 hover:underline">Удалить</button>
</td>
</tr>
`).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 = `
<i data-lucide="${type === 'success' ? 'check' : type === 'error' ? 'alert-circle' : 'info'}" class="w-5 h-5"></i>
<span class="font-medium">${message}</span>
`;
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');
}
}