Files
pg-adminus/index.html
2026-03-18 14:24:58 +07:00

1152 lines
61 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>
<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 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-2" id="recordCount">
<!-- Record count will be shown here -->
</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="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">
<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.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');
await this.loadTableData();
}
async loadTableData() {
try {
const response = await fetch(`/api/tables/${this.currentTable}/data?page=${this.currentPage}&limit=${this.limit}`);
if (response.status === 401) {
this.logout();
return;
}
const result = await response.json();
const totalRows = result.total;
const totalPages = result.totalPages;
const data = result.data;
document.getElementById('currentPage').textContent = this.currentPage;
document.getElementById('totalPages').textContent = totalPages;
document.getElementById('recordCount').innerHTML = `
<span class="text-sm text-slate-600">Всего записей: <span class="font-semibold">${totalRows}</span></span>
`;
// Render headers
if (data.length > 0) {
const headers = Object.keys(data[0]);
document.getElementById('tableHeaders').innerHTML = headers.map(h =>
`<th class="p-3 font-medium">${h}</th>`
).join('') + '<th class="p-3 font-medium w-24">Действия</th>';
// Render rows
document.getElementById('tableBody').innerHTML = data.map((row, idx) => `
<tr class="hover:bg-slate-50 transition-colors group">
${Object.values(row).map(v => `<td class="p-3 text-slate-700">${v}</td>`).join('')}
<td class="p-3">
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onclick="app.editRecord(${row.id || idx})" class="p-1 hover:bg-blue-100 text-blue-600 rounded">
<i data-lucide="pencil" class="w-4 h-4"></i>
</button>
<button onclick="app.deleteRecord(${row.id || idx})" class="p-1 hover:bg-red-100 text-red-600 rounded">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</td>
</tr>
`).join('');
} else {
document.getElementById('tableBody').innerHTML = '<tr><td colspan="100" class="p-8 text-center text-slate-400">Нет данных</td></tr>';
}
lucide.createIcons();
// Update pagination buttons
document.getElementById('prevPage').disabled = this.currentPage === 1;
document.getElementById('nextPage').disabled = this.currentPage >= totalPages;
} catch (err) {
this.showToast('Ошибка загрузки данных', 'error');
}
}
generateMockData(tableName) {
// Generate realistic mock data based on table name
const data = [];
const count = Math.min(this.limit, 10);
for (let i = 0; i < count; i++) {
const id = (this.currentPage - 1) * this.limit + i + 1;
if (tableName === 'users') {
data.push({
id: id,
email: `user${id}@example.com`,
username: `user_${id}`,
created_at: new Date(Date.now() - Math.random() * 10000000000).toISOString().split('T')[0],
status: Math.random() > 0.5 ? 'active' : 'inactive',
role: ['admin', 'user', 'editor'][Math.floor(Math.random() * 3)]
});
} else if (tableName === 'orders') {
data.push({
id: `ORD-${1000 + id}`,
user_id: Math.floor(Math.random() * 100) + 1,
total: (Math.random() * 1000).toFixed(2),
status: ['pending', 'completed', 'cancelled'][Math.floor(Math.random() * 3)],
created_at: new Date(Date.now() - Math.random() * 10000000000).toISOString().split('T')[0]
});
} else if (tableName === 'products') {
data.push({
id: id,
name: `Product ${id}`,
price: (Math.random() * 500).toFixed(2),
stock: Math.floor(Math.random() * 100),
category_id: Math.floor(Math.random() * 10) + 1,
sku: `SKU-${Math.random().toString(36).substr(2, 9).toUpperCase()}`
});
} else {
data.push({
id: id,
name: `Record ${id}`,
value: Math.random().toString(36).substring(7),
created_at: new Date().toISOString()
});
}
}
return data;
}
// CRUD Operations
showAddRecordModal() {
this.editingRecord = null;
document.getElementById('recordModalTitle').textContent = 'Добавить запись';
this.generateRecordForm();
document.getElementById('recordModal').classList.remove('hidden');
}
editRecord(idx) {
this.editingRecord = idx;
document.getElementById('recordModalTitle').textContent = 'Редактировать запись';
this.generateRecordForm();
document.getElementById('recordModal').classList.remove('hidden');
}
generateRecordForm() {
// Get columns based on current table structure
const columns = this.getTableStructure(this.currentTable);
const container = document.getElementById('recordForm');
container.innerHTML = columns.map(col => `
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">
${col.name}
${col.nullable ? '' : '<span class="text-red-500">*</span>'}
</label>
<input type="${this.getInputType(col.type)}"
name="${col.name}"
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"
${col.default ? `value="${col.default}"` : ''}>
<p class="text-xs text-slate-500 mt-1">${col.type}</p>
</div>
`).join('');
}
async getTableStructure(tableName) {
try {
const response = await fetch(`/api/tables/${tableName}/structure`);
if (response.status === 401) {
this.logout();
return [];
}
const data = await response.json();
return data.map(col => ({
name: col.name,
type: col.type,
nullable: col.nullable === 'YES',
default: col.default_value || ''
}));
} catch (err) {
this.showToast('Ошибка загрузки структуры', 'error');
return [];
}
}
getInputType(pgType) {
if (pgType.includes('int')) return 'number';
if (pgType.includes('timestamp') || pgType.includes('date')) return 'datetime-local';
if (pgType.includes('bool')) return 'checkbox';
return 'text';
}
async saveRecord() {
const form = document.getElementById('recordForm');
const inputs = form.querySelectorAll('input');
const data = {};
inputs.forEach(input => {
if (input.type === 'number') {
data[input.name] = parseFloat(input.value);
} else {
data[input.name] = input.value;
}
});
try {
let response;
if (this.editingRecord !== null) {
response = await fetch(`/api/tables/${this.currentTable}/records/${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)
});
}
if (response.status === 401) {
this.logout();
return;
}
const result = await response.json();
if (result.error) throw new Error(result.error);
this.showToast(this.editingRecord !== null ? 'Запись обновлена' : 'Запись создана', 'success');
this.closeModal('recordModal');
this.loadTableData();
} catch (err) {
this.showToast('Ошибка сохранения: ' + err.message, 'error');
}
}
async deleteRecord(id) {
if (!confirm('Вы уверены, что хотите удалить эту запись?')) return;
try {
const response = await fetch(`/api/tables/${this.currentTable}/records/${id}`, {
method: 'DELETE'
});
if (response.status === 401) {
this.logout();
return;
}
const data = await response.json();
if (data.error) throw new Error(data.error);
this.showToast('Запись удалена', 'success');
this.loadTableData();
} catch (err) {
this.showToast('Ошибка удаления', 'error');
}
}
// Table Management
showCreateTableModal() {
document.getElementById('newTableName').value = '';
document.getElementById('columnsContainer').innerHTML = '';
this.addColumnField();
this.addColumnField();
document.getElementById('createTableModal').classList.remove('hidden');
}
addColumnField() {
const container = document.getElementById('columnsContainer');
const id = Date.now();
const div = document.createElement('div');
div.className = 'flex gap-2 items-start';
div.innerHTML = `
<input type="text" placeholder="Имя колонки" class="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none column-name">
<select class="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none column-type">
<option value="serial">serial</option>
<option value="integer">integer</option>
<option value="varchar(255)">varchar(255)</option>
<option value="text">text</option>
<option value="boolean">boolean</option>
<option value="timestamp">timestamp</option>
<option value="decimal(10,2)">decimal(10,2)</option>
<option value="jsonb">jsonb</option>
</select>
<div class="flex items-center gap-2 px-2">
<input type="checkbox" class="column-pk" title="Primary Key">
<input type="checkbox" class="column-null" title="NULL">
</div>
<button onclick="this.parentElement.remove()" class="p-2 text-red-500 hover:bg-red-50 rounded-lg">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
`;
container.appendChild(div);
lucide.createIcons();
}
async createTable() {
const name = document.getElementById('newTableName').value;
if (!name) {
this.showToast('Введите название таблицы', 'error');
return;
}
const columns = [];
document.querySelectorAll('#columnsContainer > div').forEach(div => {
columns.push({
name: div.querySelector('.column-name').value,
type: div.querySelector('.column-type').value,
pk: div.querySelector('.column-pk').checked,
nullable: div.querySelector('.column-null').checked
});
});
try {
const response = await fetch('/api/tables', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, columns })
});
if (response.status === 401) {
this.logout();
return;
}
const data = await response.json();
if (data.error) throw new Error(data.error);
this.tables.push({ name, rows: 0, size: '0 KB' });
this.renderTableList();
this.closeModal('createTableModal');
this.showToast(`Таблица ${name} создана`, 'success');
} catch (err) {
this.showToast('Ошибка создания таблицы: ' + err.message, 'error');
}
}
async deleteTable() {
if (!confirm(`Вы уверены, что хотите удалить таблицу ${this.currentTable}? Это действие нельзя отменить.`)) return;
try {
const response = await fetch(`/api/tables/${this.currentTable}`, {
method: 'DELETE'
});
if (response.status === 401) {
this.logout();
return;
}
const data = await response.json();
if (data.error) throw new Error(data.error);
this.tables = this.tables.filter(t => t.name !== this.currentTable);
this.currentTable = null;
this.renderTableList();
document.getElementById('dataGrid').classList.add('hidden');
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('tableActions').classList.add('hidden');
document.getElementById('currentTableTitle').textContent = 'Выберите таблицу';
this.showToast('Таблица удалена', 'success');
} catch (err) {
this.showToast('Ошибка удаления таблицы', 'error');
}
}
// Structure & Indexes
async showTableStructure() {
document.getElementById('structureTableName').textContent = this.currentTable;
const structure = await this.getTableStructure(this.currentTable);
document.getElementById('structureBody').innerHTML = structure.map(col => `
<tr class="hover:bg-slate-50">
<td class="p-3 font-mono text-sm">${col.name}</td>
<td class="p-3 text-slate-600">${col.type}</td>
<td class="p-3">${col.nullable ? '<span class="text-green-600">YES</span>' : '<span class="text-red-600">NO</span>'}</td>
<td class="p-3 text-slate-500">${col.default}</td>
<td class="p-3">
<button class="text-red-600 hover:text-red-700 text-sm font-medium" onclick="app.dropColumn('${col.name}')">Удалить</button>
</td>
</tr>
`).join('');
document.getElementById('structureModal').classList.remove('hidden');
}
async dropColumn(colName) {
if (!confirm(`Удалить колонку ${colName}?`)) return;
await new Promise(r => setTimeout(r, 300));
this.showToast(`Колонка ${colName} удалена`, 'success');
this.showTableStructure();
}
async showIndexesModal() {
document.getElementById('indexesTableName').textContent = this.currentTable;
try {
const response = await fetch(`/api/tables/${this.currentTable}/indexes`);
if (response.status === 401) {
this.logout();
return;
}
const indexes = await response.json();
document.getElementById('indexesBody').innerHTML = indexes.map(idx => `
<tr class="hover:bg-slate-50">
<td class="p-3 font-mono text-sm">${idx.name}</td>
<td class="p-3">${idx.columns}</td>
<td class="p-3 text-slate-600">${idx.type}</td>
<td class="p-3">${idx.unique ? '<span class="text-green-600">Да</span>' : '<span class="text-slate-500">Нет</span>'}</td>
<td class="p-3">
<button onclick="app.dropIndex('${idx.name}')" class="text-red-600 hover:text-red-700 text-sm font-medium">Удалить</button>
</td>
</tr>
`).join('');
document.getElementById('indexesModal').classList.remove('hidden');
} catch (err) {
this.showToast('Ошибка загрузки индексов', 'error');
}
}
showCreateIndexModal() {
document.getElementById('indexName').value = `idx_${this.currentTable}_`;
document.getElementById('indexColumns').value = '';
document.getElementById('indexUnique').checked = false;
document.getElementById('createIndexModal').classList.remove('hidden');
}
async createIndex() {
const name = document.getElementById('indexName').value;
const columns = document.getElementById('indexColumns').value;
const unique = document.getElementById('indexUnique').checked;
if (!name || !columns) {
this.showToast('Заполните все поля', 'error');
return;
}
try {
const response = await fetch(`/api/tables/${this.currentTable}/indexes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, columns, unique })
});
if (response.status === 401) {
this.logout();
return;
}
const data = await response.json();
if (data.error) throw new Error(data.error);
this.closeModal('createIndexModal');
this.closeModal('indexesModal');
this.showToast(`Индекс ${name} создан`, 'success');
} catch (err) {
this.showToast('Ошибка создания индекса: ' + err.message, 'error');
}
}
async dropIndex(name) {
if (!confirm(`Удалить индекс ${name}?`)) return;
try {
const response = await fetch(`/api/indexes/${name}`, {
method: 'DELETE'
});
if (response.status === 401) {
this.logout();
return;
}
const data = await response.json();
if (data.error) throw new Error(data.error);
this.showToast('Индекс удален', 'success');
this.showIndexesModal();
} catch (err) {
this.showToast('Ошибка удаления индекса', 'error');
}
}
// SQL Editor
showSQLPanel() {
document.getElementById('dataGrid').classList.add('hidden');
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('sqlPanel').classList.remove('hidden');
document.getElementById('currentTableTitle').textContent = 'SQL Query';
document.getElementById('tableActions').classList.add('hidden');
}
formatSQL() {
const editor = document.getElementById('sqlEditor');
let sql = editor.value;
// Simple formatting
sql = sql.replace(/\s+/g, ' ').trim();
sql = sql.replace(/\s*,\s*/g, ', ');
sql = sql.replace(/\s*=\s*/g, ' = ');
const keywords = ['SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP', 'ORDER', 'BY', 'LIMIT', 'OFFSET', 'AND', 'OR', 'NOT', 'NULL'];
keywords.forEach(kw => {
const regex = new RegExp(`\\b${kw}\\b`, 'gi');
sql = sql.replace(regex, kw);
});
editor.value = sql;
}
clearSQL() {
document.getElementById('sqlEditor').value = '';
document.getElementById('sqlResults').classList.add('hidden');
}
async executeSQL() {
const sql = document.getElementById('sqlEditor').value.trim();
if (!sql) {
this.showToast('Введите SQL запрос', 'error');
return;
}
// Show loading
const btn = document.querySelector('#sqlPanel button');
const originalText = btn.innerHTML;
btn.innerHTML = '<div class="loader w-4 h-4 border-2"></div> Выполнение...';
btn.disabled = true;
try {
const response = await fetch('/api/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sql })
});
if (response.status === 401) {
this.logout();
return;
}
const result = await response.json();
const resultsDiv = document.getElementById('sqlResults');
const contentDiv = document.getElementById('sqlResultsContent');
if (result.error) {
contentDiv.innerHTML = `
<div class="p-8 text-center text-red-600">
<i data-lucide="alert-circle" class="w-12 h-12 mx-auto mb-2"></i>
<p class="font-medium">Ошибка SQL</p>
<p class="text-sm mt-1">${result.error}</p>
</div>
`;
lucide.createIcons();
} else if (result.rows && result.rows.length > 0) {
const headers = Object.keys(result.rows[0]);
contentDiv.innerHTML = `
<table class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>${headers.map(h => `<th class="p-3 text-left font-medium text-slate-600">${h}</th>`).join('')}</tr>
</thead>
<tbody class="divide-y divide-slate-200">
${result.rows.map(row => `
<tr class="hover:bg-slate-50">
${Object.values(row).map(v => `<td class="p-3">${v}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('sqlStats').textContent = `${result.rowCount} rows in ${result.command || 'SELECT'}`;
} else {
contentDiv.innerHTML = `
<div class="p-8 text-center text-slate-600">
<i data-lucide="check-circle" class="w-12 h-12 text-green-500 mx-auto mb-2"></i>
<p class="font-medium">Запрос выполнен успешно</p>
<p class="text-sm text-slate-500 mt-1">${result.command}: ${result.rowCount} rows affected</p>
</div>
`;
lucide.createIcons();
}
resultsDiv.classList.remove('hidden');
} catch (err) {
this.showToast('Ошибка выполнения запроса', 'error');
}
btn.innerHTML = originalText;
btn.disabled = false;
}
// Pagination
changePage(delta) {
this.currentPage += delta;
this.loadTableData();
}
changeLimit(newLimit) {
this.limit = parseInt(newLimit);
this.currentPage = 1;
this.loadTableData();
}
// 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));
// Close modals on outside click
window.onclick = function(event) {
if (event.target.classList.contains('fixed')) {
event.target.classList.add('hidden');
}
}
</script>
</body>
</html>