1160 lines
61 KiB
HTML
1160 lines
61 KiB
HTML
<!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
|
||
async showAddRecordModal() {
|
||
this.editingRecord = null;
|
||
document.getElementById('recordModalTitle').textContent = 'Добавить запись';
|
||
|
||
await this.generateRecordForm();
|
||
|
||
document.getElementById('recordModal').classList.remove('hidden');
|
||
}
|
||
|
||
async editRecord(idx) {
|
||
this.editingRecord = idx;
|
||
document.getElementById('recordModalTitle').textContent = 'Редактировать запись';
|
||
|
||
await this.generateRecordForm();
|
||
|
||
document.getElementById('recordModal').classList.remove('hidden');
|
||
}
|
||
|
||
async generateRecordForm() {
|
||
if (response.status === 401) {
|
||
this.logout();
|
||
return [];
|
||
}
|
||
const columns = await 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>
|