Files
pg-adminus/index.html
2026-03-18 17:26:38 +07:00

1281 lines
68 KiB
HTML
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.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PostgreSQL Admin Panel</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.font-mono { font-family: 'JetBrains Mono', monospace; }
.glass-panel {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.sidebar-item:hover {
background: linear-gradient(90deg, rgba(59, 130, 246, 0.1) 0%, transparent 100%);
}
.sql-keyword { color: #c678dd; }
.sql-string { color: #98c379; }
.sql-function { color: #61afef; }
.sql-comment { color: #5c6370; font-style: italic; }
/* Custom scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #f1f5f9; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.fade-in { animation: fadeIn 0.3s ease-in; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.loader {
border: 3px solid #f3f3f3;
border-top: 3px solid #3b82f6;
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body class="bg-slate-50 text-slate-800 overflow-hidden">
<!-- Login Screen -->
<div id="loginScreen" class="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900">
<div class="w-full max-w-md p-8 glass-panel rounded-2xl shadow-2xl">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-blue-600 rounded-2xl mb-4 shadow-lg shadow-blue-600/30">
<i data-lucide="database" class="w-8 h-8 text-white"></i>
</div>
<h1 class="text-2xl font-bold text-slate-800">PostgreSQL Admin</h1>
<p class="text-slate-500 mt-2">Войдите для управления базой данных</p>
<div class="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-100">
<p class="text-xs text-blue-600 font-medium">🔒 Настройки БД скрыты в .env файле сервера</p>
</div>
</div>
<form id="loginForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Логин администратора</label>
<input type="text" id="adminUser" value="admin" 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 transition-all" placeholder="admin">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Пароль</label>
<input type="password" id="adminPass" value="admin" 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 transition-all" placeholder="••••••••">
</div>
<button type="submit" class="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-all transform hover:scale-[1.02] shadow-lg shadow-blue-600/30 flex items-center justify-center gap-2">
<span id="loginText">Войти</span>
<div id="loginLoader" class="loader hidden" style="width: 20px; height: 20px; border-width: 2px;"></div>
</button>
</form>
<div class="mt-6 text-center space-y-1">
<p class="text-xs text-slate-400">По умолчанию: admin / admin</p>
<p class="text-xs text-slate-400">Настройки подключения к БД в .env</p>
</div>
</div>
</div>
<!-- Main Application -->
<div id="mainApp" class="hidden h-screen flex flex-col">
<!-- Header -->
<header class="bg-white border-b border-slate-200 h-16 flex items-center justify-between px-6 shadow-sm z-10">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 text-blue-600">
<i data-lucide="database" class="w-6 h-6"></i>
<span class="font-bold text-lg">PostgreSQL Admin</span>
</div>
<div class="h-6 w-px bg-slate-200 mx-2"></div>
<div class="flex items-center gap-2 text-sm text-slate-600 bg-slate-100 px-3 py-1 rounded-full">
<div class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span id="connectionStatus">localhost:5432/postgres</span>
</div>
</div>
<div class="flex items-center gap-3">
<button onclick="app.showSQLPanel()" class="flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors text-sm font-medium">
<i data-lucide="terminal" class="w-4 h-4"></i>
SQL Query
</button>
<div class="h-6 w-px bg-slate-200"></div>
<button onclick="app.logout()" class="flex items-center gap-2 px-4 py-2 text-slate-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors text-sm font-medium">
<i data-lucide="log-out" class="w-4 h-4"></i>
Выйти
</button>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar -->
<aside class="w-64 bg-slate-900 text-slate-300 flex flex-col">
<div class="p-4 border-b border-slate-800">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wider">Таблицы</span>
<button onclick="app.showCreateTableModal()" class="p-1 hover:bg-slate-800 rounded transition-colors" title="Создать таблицу">
<i data-lucide="plus" class="w-4 h-4"></i>
</button>
</div>
<input type="text" id="tableSearch" placeholder="Поиск..." class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-500 text-slate-300" oninput="app.filterTables(this.value)">
</div>
<div id="tableList" class="flex-1 overflow-y-auto py-2">
<!-- Tables will be rendered here -->
</div>
<div class="p-4 border-t border-slate-800">
<div class="flex items-center gap-2 text-xs text-slate-500">
<i data-lucide="hard-drive" class="w-4 h-4"></i>
<span id="dbSize">24.5 MB</span>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col bg-slate-50 overflow-hidden">
<!-- Toolbar -->
<div id="toolbar" class="bg-white border-b border-slate-200 p-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<h2 id="currentTableTitle" class="text-xl font-semibold text-slate-800">Выберите таблицу</h2>
<div id="tableActions" class="hidden flex items-center gap-2">
<button onclick="app.showAddRecordModal()" class="flex items-center gap-2 px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm">
<i data-lucide="plus" class="w-4 h-4"></i>
Добавить запись
</button>
<button onclick="app.showTableStructure()" class="flex items-center gap-2 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm">
<i data-lucide="settings" class="w-4 h-4"></i>
Структура
</button>
<button onclick="app.showIndexesModal()" class="flex items-center gap-2 px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm">
<i data-lucide="list-tree" class="w-4 h-4"></i>
Индексы
</button>
<button onclick="app.deleteTable()" class="flex items-center gap-2 px-3 py-1.5 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors text-sm">
<i data-lucide="trash-2" class="w-4 h-4"></i>
Удалить
</button>
</div>
</div>
<div class="flex items-center gap-3" id="recordControls">
<input id="recordSearch" type="text" placeholder="Поиск по записям..." class="w-56 border border-slate-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button onclick="app.toggleFilters()" class="flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm">
<i data-lucide="filter" class="w-4 h-4"></i>
Фильтры
</button>
<div class="text-sm text-slate-600" id="recordCount">
<!-- Record count will be shown here -->
</div>
</div>
</div>
<!-- Content Area -->
<div id="contentArea" class="flex-1 overflow-auto p-6">
<!-- Dynamic content: Table data, SQL editor, Structure, etc. -->
<div id="emptyState" class="flex flex-col items-center justify-center h-full text-slate-400">
<i data-lucide="database" class="w-16 h-16 mb-4 opacity-20"></i>
<p class="text-lg">Выберите таблицу из списка слева или выполните SQL-запрос</p>
</div>
<div id="dataGrid" class="hidden bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
<tr id="tableHeaders"></tr>
</thead>
<tbody id="filterRow" class="hidden bg-blue-50 border-b border-slate-200">
<tr id="filterInputs"></tr>
</tbody>
<tbody id="tableBody" class="divide-y divide-slate-200"></tbody>
</table>
</div>
<div class="p-4 border-t border-slate-200 flex items-center justify-between bg-slate-50">
<div class="flex items-center gap-2">
<button onclick="app.changePage(-1)" class="p-2 hover:bg-slate-200 rounded-lg disabled:opacity-50" id="prevPage">
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</button>
<span class="text-sm text-slate-600">Страница <span id="currentPage">1</span> из <span id="totalPages">1</span></span>
<button onclick="app.changePage(1)" class="p-2 hover:bg-slate-200 rounded-lg disabled:opacity-50" id="nextPage">
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</button>
</div>
<div class="flex items-center gap-2">
<select onchange="app.changeLimit(this.value)" class="bg-white border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-blue-500">
<option value="10">10 строк</option>
<option value="25">25 строк</option>
<option value="50">50 строк</option>
<option value="100">100 строк</option>
</select>
</div>
</div>
</div>
<!-- SQL Editor Panel -->
<div id="sqlPanel" class="hidden h-full flex flex-col gap-4">
<div class="bg-slate-900 rounded-xl overflow-hidden flex flex-col flex-1 shadow-lg">
<div class="bg-slate-800 px-4 py-2 flex items-center justify-between border-b border-slate-700">
<div class="flex items-center gap-2 text-slate-300 text-sm">
<i data-lucide="terminal" class="w-4 h-4"></i>
<span>SQL Query Editor</span>
</div>
<div class="flex items-center gap-2">
<button onclick="app.formatSQL()" class="px-3 py-1 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded transition-colors">
Форматировать
</button>
<button onclick="app.clearSQL()" class="px-3 py-1 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded transition-colors">
Очистить
</button>
</div>
</div>
<textarea id="sqlEditor" class="flex-1 bg-slate-900 text-slate-300 p-4 font-mono text-sm resize-none outline-none" placeholder="-- Введите SQL запрос здесь
SELECT * FROM users LIMIT 10;"></textarea>
</div>
<div class="flex justify-end">
<button onclick="app.executeSQL()" class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-all shadow-lg shadow-blue-600/20">
<i data-lucide="play" class="w-4 h-4"></i>
Выполнить (Ctrl+Enter)
</button>
</div>
<div id="sqlResults" class="hidden bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden flex-1">
<div class="p-4 border-b border-slate-200 bg-slate-50 flex items-center justify-between">
<span class="font-medium text-slate-700">Результаты</span>
<span id="sqlStats" class="text-sm text-slate-500"></span>
</div>
<div class="overflow-x-auto max-h-96" id="sqlResultsContent"></div>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Modals -->
<!-- Create Table Modal -->
<div id="createTableModal" class="hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col fade-in">
<div class="p-6 border-b border-slate-200 flex items-center justify-between">
<h3 class="text-xl font-bold text-slate-800">Создать новую таблицу</h3>
<button onclick="app.closeModal('createTableModal')" class="p-2 hover:bg-slate-100 rounded-lg transition-colors">
<i data-lucide="x" class="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div class="p-6 overflow-y-auto flex-1">
<div class="mb-4">
<label class="block text-sm font-medium text-slate-700 mb-1">Название таблицы</label>
<input type="text" id="newTableName" 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="users">
</div>
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-slate-700">Колонки</label>
<button onclick="app.addColumnField()" class="text-sm text-blue-600 hover:text-blue-700 font-medium">+ Добавить колонку</button>
</div>
<div id="columnsContainer" class="space-y-2">
<!-- Column fields will be added here -->
</div>
</div>
</div>
<div class="p-6 border-t border-slate-200 bg-slate-50 flex justify-end gap-3">
<button onclick="app.closeModal('createTableModal')" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors">Отмена</button>
<button onclick="app.createTable()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors shadow-lg shadow-blue-600/20">Создать таблицу</button>
</div>
</div>
</div>
<!-- Add/Edit Record Modal -->
<div id="recordModal" class="hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col fade-in">
<div class="p-6 border-b border-slate-200 flex items-center justify-between">
<h3 id="recordModalTitle" class="text-xl font-bold text-slate-800">Добавить запись</h3>
<button onclick="app.closeModal('recordModal')" class="p-2 hover:bg-slate-100 rounded-lg transition-colors">
<i data-lucide="x" class="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div id="recordForm" class="p-6 overflow-y-auto flex-1 space-y-4">
<!-- Form fields will be generated here -->
</div>
<div class="p-6 border-t border-slate-200 bg-slate-50 flex justify-end gap-3">
<button onclick="app.closeModal('recordModal')" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors">Отмена</button>
<button onclick="app.saveRecord()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors shadow-lg shadow-blue-600/20">Сохранить</button>
</div>
</div>
</div>
<!-- Table Structure Modal -->
<div id="structureModal" class="hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col fade-in">
<div class="p-6 border-b border-slate-200 flex items-center justify-between">
<h3 class="text-xl font-bold text-slate-800">Структура таблицы: <span id="structureTableName"></span></h3>
<button onclick="app.closeModal('structureModal')" class="p-2 hover:bg-slate-100 rounded-lg transition-colors">
<i data-lucide="x" class="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div class="p-6 overflow-y-auto flex-1">
<div class="mb-4 flex justify-end">
<button onclick="app.showCreateColumnModal()" class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
<i data-lucide="plus" class="w-4 h-4"></i>
Добавить колонку
</button>
</div>
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
<tr>
<th class="text-left p-3">Колонка</th>
<th class="text-left p-3">Тип</th>
<th class="text-left p-3">NULL</th>
<th class="text-left p-3">По умолчанию</th>
<th class="text-left p-3">Действия</th>
</tr>
</thead>
<tbody id="structureBody" class="divide-y divide-slate-200"></tbody>
</table>
</div>
</div>
</div>
<!-- Indexes Modal -->
<div id="indexesModal" class="hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col fade-in">
<div class="p-6 border-b border-slate-200 flex items-center justify-between">
<h3 class="text-xl font-bold text-slate-800">Индексы таблицы: <span id="indexesTableName"></span></h3>
<button onclick="app.closeModal('indexesModal')" class="p-2 hover:bg-slate-100 rounded-lg transition-colors">
<i data-lucide="x" class="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div class="p-6 overflow-y-auto flex-1">
<div class="mb-4 flex justify-end">
<button onclick="app.showCreateIndexModal()" class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm">
<i data-lucide="plus" class="w-4 h-4"></i>
Создать индекс
</button>
</div>
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600 font-medium border-b border-slate-200">
<tr>
<th class="text-left p-3">Название</th>
<th class="text-left p-3">Колонки</th>
<th class="text-left p-3">Тип</th>
<th class="text-left p-3">Уникальный</th>
<th class="text-left p-3">Действия</th>
</tr>
</thead>
<tbody id="indexesBody" class="divide-y divide-slate-200"></tbody>
</table>
</div>
</div>
</div>
<!-- Create Index Modal -->
<div id="createIndexModal" class="hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md fade-in">
<div class="p-6 border-b border-slate-200">
<h3 class="text-xl font-bold text-slate-800">Создать индекс</h3>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Название индекса</label>
<input type="text" id="indexName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none" placeholder="idx_users_email">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Колонки (через запятую)</label>
<input type="text" id="indexColumns" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none" placeholder="email, created_at">
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="indexUnique" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500">
<label for="indexUnique" class="text-sm text-slate-700">Уникальный индекс</label>
</div>
</div>
<div class="p-6 border-t border-slate-200 bg-slate-50 flex justify-end gap-3">
<button onclick="app.closeModal('createIndexModal')" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg">Отмена</button>
<button onclick="app.createIndex()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Создать</button>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toastContainer" class="fixed bottom-6 right-6 z-50 flex flex-col gap-2"></div>
<script>
// Application Logic
class PostgresAdmin {
constructor() {
this.currentUser = null;
this.currentTable = null;
this.tables = [];
this.currentPage = 1;
this.limit = 10;
this.editingRecord = null;
this.editingColumn = null;
this.primaryKey = null;
this.tableStructure = [];
this.filters = {};
this.sortColumn = '';
this.sortDirection = 'ASC';
this.init();
}
init() {
// Check for saved session
const saved = localStorage.getItem('pg_admin_session');
if (saved) {
this.currentUser = JSON.parse(saved);
this.showMainApp();
}
// 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();
}
});
}
// 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,
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() {
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}`;
this.loadTables();
}
// 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();
container.innerHTML = this.tables
.filter(t => t.name.toLowerCase().includes(search))
.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}</span>
</div>
<span class="text-xs opacity-50 group-hover:opacity-100">${table.rows}</span>
</div>
`).join('');
lucide.createIcons();
}
filterTables(query) {
this.renderTableList();
}
async selectTable(tableName) {
this.currentTable = tableName;
this.currentPage = 1;
this.renderTableList();
document.getElementById('currentTableTitle').textContent = tableName;
document.getElementById('tableActions').classList.remove('hidden');
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('sqlPanel').classList.add('hidden');
document.getElementById('dataGrid').classList.remove('hidden');
// Load table structure to get primary key
await this.loadTableStructure();
await this.loadTableData();
}
async loadTableData() {
const searchQuery = document.getElementById('recordSearch').value;
try {
const 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
const columns = records.length > 0 ? Object.keys(records[0]) : this.tableStructure.map(col => col.name);
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>';
// Generate filter inputs
filterInputs.innerHTML = columns.map(col => {
return `<td class="p-2"><input type="text" 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>';
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] : null;
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 actions = pkValue ? `
<td class="p-3 border-b border-slate-100">
<button onclick="app.editRecord('${pkValue}')" class="text-blue-600 hover:underline mr-2">Редактировать</button>
<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('');
}
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();
} 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
form.innerHTML = this.tableStructure.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';
}
inputHtml = `<input type="${inputType}" name="${col.name}" value="${value}" ${step} 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="${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 {
// Since we don't have a single record endpoint, we'll load all data and find the record
const params = new URLSearchParams({ page: 1, limit: 1000 }); // Load more to find the record
const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`);
const data = await response.json();
const record = data.data.find(r => r[this.primaryKey] == pkValue);
if (record) {
this.tableStructure.forEach(col => {
const input = document.querySelector(`[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;
}
}
});
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();
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('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 name = document.getElementById('newTableName').value;
if (!name) {
this.showToast('Введите название таблицы', 'error');
return;
}
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, 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() {
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('dataGrid').classList.add('hidden');
document.getElementById('sqlPanel').classList.remove('hidden');
}
executeSQL() {
const sql = document.getElementById('sqlEditor').value;
if (!sql.trim()) {
this.showToast('Введите SQL запрос', 'error');
return;
}
fetch('/api/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sql })
})
.then(response => response.json())
.then(data => {
if (data.success) {
this.renderSQLResults(data);
document.getElementById('sqlStats').textContent = `Затронуто строк: ${data.rowCount}`;
} else {
this.showToast(data.error || 'Ошибка выполнения SQL', 'error');
}
})
.catch(err => {
this.showToast('Ошибка выполнения SQL', 'error');
});
}
renderSQLResults(data) {
const container = document.getElementById('sqlResultsContent');
document.getElementById('sqlResults').classList.remove('hidden');
if (data.rows.length === 0) {
container.innerHTML = '<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();
}
clearSQL() {
document.getElementById('sqlEditor').value = '';
document.getElementById('sqlResults').classList.add('hidden');
}
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');
}
}
</script>
</body>
</html>