2020 lines
107 KiB
HTML
2020 lines
107 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 SensoLab 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>
|
||
:root {
|
||
color-scheme: light;
|
||
}
|
||
body[data-theme="dark"] {
|
||
color-scheme: dark;
|
||
}
|
||
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); } }
|
||
body {
|
||
transition: background-color 0.25s ease, color 0.25s ease;
|
||
}
|
||
body[data-theme="dark"] .bg-white { background-color: #111827 !important; }
|
||
body[data-theme="dark"] .bg-slate-50 { background-color: #0f172a !important; }
|
||
body[data-theme="dark"] .bg-slate-100 { background-color: #1e293b !important; }
|
||
body[data-theme="dark"] .bg-slate-800 { background-color: #0f172a !important; }
|
||
body[data-theme="dark"] .bg-slate-900 { background-color: #020617 !important; }
|
||
body[data-theme="dark"] .bg-blue-50 { background-color: rgba(59, 130, 246, 0.16) !important; }
|
||
body[data-theme="dark"] .bg-green-50 { background-color: rgba(34, 197, 94, 0.16) !important; }
|
||
body[data-theme="dark"] .bg-red-50 { background-color: rgba(239, 68, 68, 0.16) !important; }
|
||
body[data-theme="dark"] .text-slate-800 { color: #e2e8f0 !important; }
|
||
body[data-theme="dark"] .text-slate-700 { color: #cbd5e1 !important; }
|
||
body[data-theme="dark"] .text-slate-600 { color: #94a3b8 !important; }
|
||
body[data-theme="dark"] .text-slate-500, body[data-theme="dark"] .text-slate-400 { color: #64748b !important; }
|
||
body[data-theme="dark"] .text-slate-300 { color: #cbd5e1 !important; }
|
||
body[data-theme="dark"] .border-slate-200 { border-color: #1e293b !important; }
|
||
body[data-theme="dark"] .border-slate-300 { border-color: #334155 !important; }
|
||
body[data-theme="dark"] .border-slate-700 { border-color: #334155 !important; }
|
||
body[data-theme="dark"] .border-slate-800 { border-color: #1e293b !important; }
|
||
body[data-theme="dark"] input,
|
||
body[data-theme="dark"] textarea,
|
||
body[data-theme="dark"] select {
|
||
background-color: #0f172a !important;
|
||
color: #e2e8f0 !important;
|
||
border-color: #334155 !important;
|
||
}
|
||
body[data-theme="dark"] .glass-panel {
|
||
background: rgba(15, 23, 42, 0.92);
|
||
border-color: rgba(148, 163, 184, 0.12);
|
||
}
|
||
body[data-theme="dark"] ::-webkit-scrollbar-track { background: #0f172a; }
|
||
body[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #334155; }
|
||
.sidebar-collapsible {
|
||
transition: max-height 0.24s ease;
|
||
}
|
||
.log-terminal {
|
||
min-height: 18rem;
|
||
max-height: 28rem;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
@media (max-width: 900px) {
|
||
#mobileBackdrop:not(.hidden) {
|
||
display: block;
|
||
}
|
||
#sidebar {
|
||
position: fixed;
|
||
inset: 0 auto 0 0;
|
||
z-index: 30;
|
||
width: min(88vw, 20rem);
|
||
transform: translateX(-100%);
|
||
transition: transform 0.25s ease;
|
||
}
|
||
#sidebar.sidebar-open {
|
||
transform: translateX(0);
|
||
}
|
||
#mainHeader {
|
||
padding-left: 1rem;
|
||
padding-right: 1rem;
|
||
}
|
||
#toolbar {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 0.75rem;
|
||
}
|
||
#recordControls {
|
||
width: 100%;
|
||
flex-wrap: wrap;
|
||
}
|
||
#recordSearch {
|
||
width: 100%;
|
||
}
|
||
#contentArea {
|
||
padding: 1rem;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="bg-slate-50 text-slate-800 overflow-hidden" data-theme="light">
|
||
|
||
<!-- 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 SensoLab</h1>
|
||
<p class="text-slate-500 mt-2">Войдите для управления базой данных</p>
|
||
</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>
|
||
</div>
|
||
|
||
<!-- Main Application -->
|
||
<div id="mainApp" class="hidden h-screen flex flex-col">
|
||
<!-- Header -->
|
||
<div id="mobileBackdrop" class="hidden fixed inset-0 bg-slate-950/50 z-20" onclick="app.closeSidebar()"></div>
|
||
<header id="mainHeader" 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">
|
||
<button onclick="app.toggleSidebar()" class="lg:hidden p-2 rounded-lg bg-slate-100 text-slate-700">
|
||
<i data-lucide="menu" class="w-4 h-4"></i>
|
||
</button>
|
||
<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 SensoLab</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">
|
||
<select id="themeSelect" onchange="app.setTheme(this.value)" class="border border-slate-200 rounded-lg px-3 py-2 text-sm bg-white text-slate-700">
|
||
<option value="light">Light</option>
|
||
<option value="dark">Dark</option>
|
||
<option value="system">System</option>
|
||
</select>
|
||
<button id="usersButton" onclick="app.showUsersModal()" class="hidden 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 font-medium">
|
||
<i data-lucide="users" class="w-4 h-4"></i>
|
||
Users
|
||
</button>
|
||
<button id="logsButton" onclick="app.showLogsPanel()" class="hidden 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 font-medium">
|
||
<i data-lucide="scroll-text" class="w-4 h-4"></i>
|
||
Logs
|
||
</button>
|
||
<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>
|
||
<div id="roleBadge" class="px-3 py-1 rounded-full bg-slate-100 text-slate-600 text-xs font-semibold uppercase tracking-wide"></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 id="sidebar" 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.showMoveTableModal()" 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="folder-input" class="w-4 h-4"></i>
|
||
Move
|
||
</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 id="logsPanel" class="hidden h-full flex flex-col gap-4">
|
||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-4 flex flex-wrap items-center gap-3">
|
||
<select id="containerSelect" onchange="app.changeContainer(this.value)" class="border border-slate-300 rounded-lg px-3 py-2 text-sm min-w-56">
|
||
<option value="">Select container</option>
|
||
</select>
|
||
<button onclick="app.refreshLogs()" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm">Refresh</button>
|
||
<button onclick="app.toggleLogStream()" id="logStreamButton" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm">Start live</button>
|
||
<button onclick="app.clearLogs()" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm">Clear</button>
|
||
<div id="logStatus" class="text-sm text-slate-500">Logs are available for admin roles.</div>
|
||
</div>
|
||
<div class="bg-slate-900 text-slate-200 rounded-xl border border-slate-800 p-4 font-mono text-sm log-terminal overflow-auto" id="logOutput">Select a container to load recent logs.</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="newTableFolder" 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="frontend">
|
||
<p class="text-xs text-slate-500 mt-1">Если указано, имя таблицы будет создано как <code>папка__имя</code>. Оставьте пустым для создания в корне.</p>
|
||
</div>
|
||
<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>
|
||
|
||
<!-- Column Modal -->
|
||
<div id="columnModal" 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 flex items-center justify-between">
|
||
<h3 id="columnModalTitle" class="text-xl font-bold text-slate-800">Добавить колонку</h3>
|
||
<button onclick="app.closeModal('columnModal')" 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 space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Название колонки</label>
|
||
<input type="text" id="columnName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Тип</label>
|
||
<select id="columnType" class="w-full px-4 py-2 border border-slate-300 rounded-lg 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>
|
||
</div>
|
||
<div class="flex items-center gap-4">
|
||
<label class="flex items-center gap-2">
|
||
<input type="checkbox" id="columnNullable" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500">
|
||
<span class="text-sm text-slate-700">NULL</span>
|
||
</label>
|
||
<label class="flex items-center gap-2">
|
||
<input type="checkbox" id="columnPrimary" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500">
|
||
<span class="text-sm text-slate-700">Первичный ключ</span>
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Значение по умолчанию</label>
|
||
<input type="text" id="columnDefault" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none">
|
||
</div>
|
||
</div>
|
||
<div class="p-6 border-t border-slate-200 bg-slate-50 flex justify-end gap-3">
|
||
<button onclick="app.closeModal('columnModal')" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg">Отмена</button>
|
||
<button onclick="app.saveColumn()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Сохранить</button>
|
||
</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>
|
||
|
||
<div id="moveTableModal" 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">Move table</h3>
|
||
</div>
|
||
<div class="p-6 space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Folder</label>
|
||
<input type="text" id="moveTableFolder" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="frontend">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Table name</label>
|
||
<input type="text" id="moveTableName" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="users">
|
||
</div>
|
||
</div>
|
||
<div class="p-6 border-t border-slate-200 bg-slate-50 flex justify-end gap-3">
|
||
<button onclick="app.closeModal('moveTableModal')" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg">Cancel</button>
|
||
<button onclick="app.moveTable()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="usersModal" 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-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">Users & access</h3>
|
||
<button onclick="app.closeModal('usersModal')" 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="grid lg:grid-cols-[1.2fr,0.8fr] gap-0 flex-1 min-h-0">
|
||
<div class="border-r border-slate-200 overflow-auto">
|
||
<table class="w-full text-sm">
|
||
<thead class="bg-slate-50 border-b border-slate-200">
|
||
<tr>
|
||
<th class="text-left p-3">Username</th>
|
||
<th class="text-left p-3">Role</th>
|
||
<th class="text-left p-3">Status</th>
|
||
<th class="text-left p-3">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="usersTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="p-6 overflow-auto space-y-3">
|
||
<input type="hidden" id="userEditMode" value="">
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Username</label>
|
||
<input type="text" id="userUsername" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Password</label>
|
||
<input type="text" id="userPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="Leave empty to keep current">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Role</label>
|
||
<select id="userRole" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||
<option value="viewer">viewer</option>
|
||
<option value="moderator">moderator</option>
|
||
<option value="admin">admin</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Readable folders</label>
|
||
<input type="text" id="userViewFolders" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="frontend, backend">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Editable tables</label>
|
||
<input type="text" id="userEditTables" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="users, frontend__users">
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-1">Deletable folders</label>
|
||
<input type="text" id="userDeleteFolders" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="frontend">
|
||
</div>
|
||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||
<input type="checkbox" id="userDisabled" class="w-4 h-4">
|
||
Disable login
|
||
</label>
|
||
<div class="flex justify-end gap-3 pt-2">
|
||
<button onclick="app.resetUserForm()" class="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">Reset</button>
|
||
<button onclick="app.saveUser()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Save user</button>
|
||
</div>
|
||
</div>
|
||
</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.currentRows = [];
|
||
this.folderState = JSON.parse(localStorage.getItem('pg_folder_state') || '{}');
|
||
this.themePreference = localStorage.getItem('pg_theme') || 'system';
|
||
this.currentContainer = '';
|
||
this.logStream = null;
|
||
this.logsBuffer = [];
|
||
this.currentPage = 1;
|
||
this.limit = 10;
|
||
this.editingRecord = null;
|
||
this.editingColumn = null;
|
||
this.primaryKey = null;
|
||
this.tableStructure = [];
|
||
this.filters = {};
|
||
this.filterColumns = [];
|
||
this.sortColumn = '';
|
||
this.sortDirection = 'ASC';
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
this.applyTheme(this.themePreference);
|
||
const themeSelect = document.getElementById('themeSelect');
|
||
if (themeSelect) themeSelect.value = this.themePreference;
|
||
|
||
const saved = localStorage.getItem('pg_admin_session');
|
||
if (saved) {
|
||
this.currentUser = JSON.parse(saved);
|
||
fetch('/api/session')
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.authenticated) {
|
||
this.currentUser = data;
|
||
localStorage.setItem('pg_admin_session', JSON.stringify(this.currentUser));
|
||
this.showMainApp();
|
||
} else {
|
||
localStorage.removeItem('pg_admin_session');
|
||
}
|
||
})
|
||
.catch(() => {
|
||
localStorage.removeItem('pg_admin_session');
|
||
});
|
||
}
|
||
|
||
// Initialize Lucide icons
|
||
lucide.createIcons();
|
||
|
||
// Keyboard shortcuts
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.ctrlKey && e.key === 'Enter' && !document.getElementById('sqlPanel').classList.contains('hidden')) {
|
||
this.executeSQL();
|
||
}
|
||
});
|
||
|
||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||
if (this.themePreference === 'system') {
|
||
this.applyTheme('system');
|
||
}
|
||
});
|
||
}
|
||
|
||
applyTheme(mode) {
|
||
const resolved = mode === 'system'
|
||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||
: mode;
|
||
document.body.dataset.theme = resolved;
|
||
}
|
||
|
||
setTheme(mode) {
|
||
this.themePreference = mode;
|
||
localStorage.setItem('pg_theme', mode);
|
||
this.applyTheme(mode);
|
||
}
|
||
|
||
toggleSidebar() {
|
||
document.getElementById('sidebar').classList.toggle('sidebar-open');
|
||
document.getElementById('mobileBackdrop').classList.toggle('hidden');
|
||
}
|
||
|
||
closeSidebar() {
|
||
document.getElementById('sidebar').classList.remove('sidebar-open');
|
||
document.getElementById('mobileBackdrop').classList.add('hidden');
|
||
}
|
||
|
||
// Auth Methods
|
||
async login(e) {
|
||
e.preventDefault();
|
||
const username = document.getElementById('adminUser').value;
|
||
const password = document.getElementById('adminPass').value;
|
||
|
||
if (!username || !password) {
|
||
this.showToast('Введите логин и пароль', 'error');
|
||
return;
|
||
}
|
||
|
||
// Show loader
|
||
document.getElementById('loginText').classList.add('hidden');
|
||
document.getElementById('loginLoader').classList.remove('hidden');
|
||
|
||
try {
|
||
const response = await fetch('/api/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, password })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.currentUser = {
|
||
username,
|
||
role: data.role,
|
||
permissions: data.permissions,
|
||
dbInfo: data.dbInfo
|
||
};
|
||
localStorage.setItem('pg_admin_session', JSON.stringify(this.currentUser));
|
||
this.showToast('Авторизация успешна!', 'success');
|
||
this.showMainApp();
|
||
} else {
|
||
this.showToast(data.error || 'Ошибка авторизации', 'error');
|
||
}
|
||
} catch (err) {
|
||
this.showToast('Ошибка подключения к серверу', 'error');
|
||
console.error(err);
|
||
}
|
||
|
||
// Reset loader
|
||
document.getElementById('loginText').classList.remove('hidden');
|
||
document.getElementById('loginLoader').classList.add('hidden');
|
||
}
|
||
|
||
async logout() {
|
||
this.stopLogStream();
|
||
await fetch('/api/logout', { method: 'POST' });
|
||
localStorage.removeItem('pg_admin_session');
|
||
location.reload();
|
||
}
|
||
|
||
showMainApp() {
|
||
document.getElementById('loginScreen').classList.add('hidden');
|
||
document.getElementById('mainApp').classList.remove('hidden');
|
||
|
||
const dbInfo = this.currentUser.dbInfo || {};
|
||
document.getElementById('connectionStatus').textContent =
|
||
`${dbInfo.host}:${dbInfo.port}/${dbInfo.database}`;
|
||
document.getElementById('roleBadge').textContent = this.currentUser.role || 'viewer';
|
||
document.getElementById('logsButton').classList.toggle('hidden', !this.getPermissions().canViewLogs);
|
||
document.getElementById('usersButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
|
||
document.querySelector('button[onclick="app.showSQLPanel()"]').style.display = this.getPermissions().canRunSql ? '' : 'none';
|
||
document.querySelector('button[onclick="app.showCreateTableModal()"]').style.display = this.getPermissions().canCreate ? '' : 'none';
|
||
|
||
this.loadTables();
|
||
}
|
||
|
||
getPermissions() {
|
||
if (this.currentUser?.role === 'superadmin') {
|
||
return {
|
||
folders: null,
|
||
canCreate: true,
|
||
canEdit: true,
|
||
canDelete: true,
|
||
canViewLogs: true,
|
||
canRunSql: true,
|
||
canManageUsers: true,
|
||
canMoveTables: true
|
||
};
|
||
}
|
||
|
||
return this.currentUser?.permissions || {
|
||
folders: null,
|
||
canCreate: false,
|
||
canEdit: false,
|
||
canDelete: false,
|
||
canViewLogs: false,
|
||
canRunSql: false,
|
||
canManageUsers: false,
|
||
canMoveTables: false
|
||
};
|
||
}
|
||
|
||
getCurrentTableFolder() {
|
||
if (!this.currentTable) return null;
|
||
const parts = this.currentTable.split('__');
|
||
return parts.length > 1 ? parts[0] : 'default';
|
||
}
|
||
|
||
canCreate() {
|
||
const perms = this.getPermissions();
|
||
return perms.canCreate;
|
||
}
|
||
|
||
canEditTable() {
|
||
const perms = this.getPermissions();
|
||
const folder = this.getCurrentTableFolder();
|
||
if (!perms.canEdit) return false;
|
||
if (!perms.folders) return true;
|
||
return perms.folders.includes(folder);
|
||
}
|
||
|
||
canDeleteTable() {
|
||
const perms = this.getPermissions();
|
||
const folder = this.getCurrentTableFolder();
|
||
if (!perms.canDelete) return false;
|
||
if (!perms.folders) return true;
|
||
return perms.folders.includes(folder);
|
||
}
|
||
|
||
// Data Loading
|
||
async loadTables() {
|
||
try {
|
||
const response = await fetch('/api/tables');
|
||
if (response.status === 401) {
|
||
this.logout();
|
||
return;
|
||
}
|
||
this.tables = await response.json();
|
||
this.renderTableList();
|
||
} catch (err) {
|
||
this.showToast('Ошибка загрузки таблиц', 'error');
|
||
}
|
||
}
|
||
|
||
renderTableList() {
|
||
const container = document.getElementById('tableList');
|
||
const search = document.getElementById('tableSearch').value.toLowerCase();
|
||
|
||
const filtered = this.tables.filter(t => t.name.toLowerCase().includes(search));
|
||
const grouped = filtered.reduce((acc, table) => {
|
||
const parts = table.name.split('__');
|
||
const folder = parts.length > 1 ? parts[0] : 'default';
|
||
if (!acc[folder]) acc[folder] = [];
|
||
acc[folder].push(table);
|
||
return acc;
|
||
}, {});
|
||
|
||
const folderOrder = Object.keys(grouped).sort();
|
||
container.innerHTML = folderOrder.map(folder => {
|
||
const expanded = this.folderState[folder] !== false;
|
||
const label = folder === 'default' ? 'Общие' : folder;
|
||
const tablesHtml = grouped[folder].map(table => `
|
||
<div onclick="app.selectTable('${table.name}')"
|
||
class="sidebar-item px-4 py-3 cursor-pointer border-l-2 ${this.currentTable === table.name ? 'border-blue-500 bg-slate-800/50 text-white' : 'border-transparent hover:text-white'} transition-all flex items-center justify-between group">
|
||
<div class="flex items-center gap-3">
|
||
<i data-lucide="table-2" class="w-4 h-4 opacity-70"></i>
|
||
<span class="text-sm font-medium">${table.name.replace(`${folder}__`, '')}</span>
|
||
</div>
|
||
<span class="text-xs opacity-50 group-hover:opacity-100">${table.rows}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
return `
|
||
<div class="mb-2">
|
||
<button onclick="app.toggleFolder('${folder}')" class="w-full px-4 py-2 text-xs text-slate-400 uppercase tracking-wider font-semibold flex items-center justify-between hover:text-white transition-colors">
|
||
<span>${label}</span>
|
||
<span class="flex items-center gap-2">
|
||
<span>${grouped[folder].length}</span>
|
||
<i data-lucide="${expanded ? 'chevron-down' : 'chevron-right'}" class="w-4 h-4"></i>
|
||
</span>
|
||
</button>
|
||
<div class="${expanded ? '' : 'hidden'} sidebar-collapsible overflow-hidden">
|
||
${tablesHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
lucide.createIcons();
|
||
}
|
||
|
||
toggleFolder(folder) {
|
||
this.folderState[folder] = this.folderState[folder] === false;
|
||
localStorage.setItem('pg_folder_state', JSON.stringify(this.folderState));
|
||
this.renderTableList();
|
||
}
|
||
|
||
filterTables(query) {
|
||
this.renderTableList();
|
||
}
|
||
|
||
async selectTable(tableName) {
|
||
this.currentTable = tableName;
|
||
this.currentPage = 1;
|
||
this.renderTableList();
|
||
this.closeSidebar();
|
||
|
||
document.getElementById('currentTableTitle').textContent = tableName;
|
||
document.getElementById('tableActions').classList.remove('hidden');
|
||
document.getElementById('emptyState').classList.add('hidden');
|
||
document.getElementById('sqlPanel').classList.add('hidden');
|
||
document.getElementById('logsPanel').classList.add('hidden');
|
||
document.getElementById('dataGrid').classList.remove('hidden');
|
||
|
||
// Update action buttons based on permissions
|
||
const addBtn = document.querySelector('#tableActions button[onclick="app.showAddRecordModal()"]');
|
||
const deleteTableBtn = document.querySelector('#tableActions button[onclick="app.deleteTable()"]');
|
||
const moveTableBtn = document.querySelector('#tableActions button[onclick="app.showMoveTableModal()"]');
|
||
if (addBtn) addBtn.style.display = this.canEditTable() ? '' : 'none';
|
||
if (deleteTableBtn) deleteTableBtn.style.display = this.canDeleteTable() ? '' : 'none';
|
||
if (moveTableBtn) moveTableBtn.style.display = this.getPermissions().canMoveTables ? '' : 'none';
|
||
|
||
// Load table structure to get primary key
|
||
await this.loadTableStructure();
|
||
await this.loadTableData();
|
||
}
|
||
|
||
async loadTableData() {
|
||
const searchQuery = document.getElementById('recordSearch').value;
|
||
|
||
try {
|
||
const params = new URLSearchParams({
|
||
page: this.currentPage,
|
||
limit: this.limit,
|
||
search: searchQuery,
|
||
filters: JSON.stringify(this.filters),
|
||
sortColumn: this.sortColumn,
|
||
sortDirection: this.sortDirection
|
||
});
|
||
|
||
const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`);
|
||
const data = await response.json();
|
||
|
||
this.renderTableData(data.data);
|
||
this.updatePagination(data);
|
||
} catch (err) {
|
||
this.showToast('Ошибка при загрузке данных', 'error');
|
||
}
|
||
}
|
||
|
||
renderTableData(records) {
|
||
const headers = document.getElementById('tableHeaders');
|
||
const filterInputs = document.getElementById('filterInputs');
|
||
const body = document.getElementById('tableBody');
|
||
|
||
// Generate headers
|
||
this.currentRows = records;
|
||
const columns = (records.length > 0 ? Object.keys(records[0]) : this.tableStructure.map(col => col.name)).filter(col => col !== '__rowid');
|
||
headers.innerHTML = columns.map(col => {
|
||
const isSorted = this.sortColumn === col;
|
||
const arrow = isSorted ? (this.sortDirection === 'ASC' ? '↑' : '↓') : '';
|
||
return `<th class="text-left p-3 cursor-pointer hover:bg-slate-100 select-none" onclick="app.sortBy('${col}')">${col} ${arrow}</th>`;
|
||
}).join('') + '<th class="text-left p-3">Действия</th>';
|
||
|
||
// Render filter inputs once per column set and keep values in sync
|
||
if (JSON.stringify(this.filterColumns) !== JSON.stringify(columns)) {
|
||
this.filterColumns = columns;
|
||
this.renderFilterRow(columns);
|
||
} else {
|
||
this.updateFilterInputs(columns);
|
||
}
|
||
|
||
if (records.length === 0) {
|
||
body.innerHTML = '<tr><td colspan="100%" class="text-center py-8 text-slate-500">Нет данных</td></tr>';
|
||
return;
|
||
}
|
||
body.innerHTML = records.map(record => {
|
||
const pkValue = this.primaryKey && record[this.primaryKey] !== undefined && record[this.primaryKey] !== null && record[this.primaryKey] !== ''
|
||
? record[this.primaryKey]
|
||
: record.__rowid;
|
||
const cells = columns.map(col => {
|
||
const colDef = this.tableStructure.find(c => c.name === col);
|
||
let displayValue = record[col] || '';
|
||
if (colDef && colDef.type.toLowerCase() === 'boolean') {
|
||
displayValue = record[col] === true || record[col] === 'true' ? '✓' : '✗';
|
||
}
|
||
return `<td class="p-3 border-b border-slate-100">${displayValue}</td>`;
|
||
}).join('');
|
||
const canEdit = this.canEditTable();
|
||
const canDelete = this.canDeleteTable();
|
||
const actionValue = JSON.stringify(String(pkValue));
|
||
const actions = pkValue ? `
|
||
<td class="p-3 border-b border-slate-100">
|
||
${canEdit ? `<button onclick="app.editRecord('${pkValue}')" class="text-blue-600 hover:underline mr-2">Редактировать</button>` : ''}
|
||
${canDelete ? `<button onclick="app.deleteRecord('${pkValue}')" class="text-red-600 hover:underline">Удалить</button>` : ''}
|
||
</td>
|
||
` : '<td class="p-3 border-b border-slate-100">-</td>';
|
||
|
||
return `<tr class="hover:bg-slate-50">${cells}${actions}</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
// Filter row helpers (do not re-render inputs on every data refresh)
|
||
renderFilterRow(columns) {
|
||
const filterInputs = document.getElementById('filterInputs');
|
||
filterInputs.innerHTML = columns.map(col => {
|
||
return `<td class="p-2"><input type="text" data-col="${col}" placeholder="Фильтр ${col}" class="w-full px-2 py-1 text-xs border border-slate-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500" oninput="app.updateFilter('${col}', this.value)" value="${this.filters[col] || ''}"></td>`;
|
||
}).join('') + '<td class="p-2"><button onclick="app.clearFilters()" class="text-xs text-slate-500 hover:text-slate-700">Очистить</button></td>';
|
||
}
|
||
|
||
updateFilterInputs(columns) {
|
||
columns.forEach(col => {
|
||
const input = document.querySelector(`#filterInputs input[data-col="${col}"]`);
|
||
if (input) {
|
||
input.value = this.filters[col] || '';
|
||
}
|
||
});
|
||
}
|
||
|
||
sortBy(column) {
|
||
if (this.sortColumn === column) {
|
||
if (this.sortDirection === 'ASC') {
|
||
this.sortDirection = 'DESC';
|
||
} else {
|
||
// Third click: remove sorting
|
||
this.sortColumn = '';
|
||
this.sortDirection = 'ASC';
|
||
}
|
||
} else {
|
||
this.sortColumn = column;
|
||
this.sortDirection = 'ASC';
|
||
}
|
||
this.currentPage = 1;
|
||
this.loadTableData();
|
||
}
|
||
|
||
editRecord(pkValue) {
|
||
// Find the record by primary key
|
||
// Since we have the data, we can find it
|
||
this.editingRecord = pkValue;
|
||
this.showAddRecordModal(true);
|
||
}
|
||
|
||
deleteRecord(pkValue) {
|
||
if (confirm('Вы уверены, что хотите удалить эту запись?')) {
|
||
fetch(`/api/tables/${this.currentTable}/records/${encodeURIComponent(pkValue)}`, {
|
||
method: 'DELETE'
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
this.showToast('Запись удалена', 'success');
|
||
this.loadTableData();
|
||
this.loadTables();
|
||
} else {
|
||
this.showToast('Ошибка удаления', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
this.showToast('Ошибка удаления', 'error');
|
||
});
|
||
}
|
||
}
|
||
|
||
showAddRecordModal(isEdit = false) {
|
||
const modal = document.getElementById('recordModal');
|
||
const title = document.getElementById('recordModalTitle');
|
||
const form = document.getElementById('recordForm');
|
||
|
||
title.textContent = isEdit ? 'Редактировать запись' : 'Добавить запись';
|
||
|
||
// Generate form fields based on table structure
|
||
const columnsToRender = this.tableStructure.filter(col => {
|
||
// For new records, skip UUID/uid columns so they get generated/ignored by the server
|
||
if (!isEdit && (col.name.toLowerCase() === 'uid' || col.type.toLowerCase() === 'uuid')) {
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
form.innerHTML = columnsToRender.map(col => {
|
||
const value = isEdit && this.editingRecord ? '' : '';
|
||
let inputHtml = '';
|
||
|
||
if (col.type.toLowerCase() === 'boolean') {
|
||
inputHtml = `<input type="checkbox" name="${col.name}" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500">`;
|
||
} else if (col.type.toLowerCase().includes('json')) {
|
||
inputHtml = `<textarea name="${col.name}" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" placeholder='{"key": "value"}'></textarea>`;
|
||
} else {
|
||
let inputType = 'text';
|
||
let step = '';
|
||
|
||
if (col.type.toLowerCase().includes('int')) {
|
||
inputType = 'number';
|
||
} else if (col.type.toLowerCase().includes('decimal') || col.type.toLowerCase().includes('numeric')) {
|
||
inputType = 'number';
|
||
step = 'step="0.01"';
|
||
} else if (col.type.toLowerCase() === 'date') {
|
||
inputType = 'date';
|
||
} else if (col.type.toLowerCase().includes('timestamp')) {
|
||
inputType = 'datetime-local';
|
||
}
|
||
|
||
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 {
|
||
let record = this.currentRows.find(r => {
|
||
const rowKey = this.primaryKey && r[this.primaryKey] !== undefined && r[this.primaryKey] !== null && r[this.primaryKey] !== ''
|
||
? r[this.primaryKey]
|
||
: r.__rowid;
|
||
return String(rowKey) === String(pkValue);
|
||
});
|
||
|
||
if (!record) {
|
||
const params = new URLSearchParams({ page: 1, limit: 1000 });
|
||
const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`);
|
||
const data = await response.json();
|
||
this.currentRows = data.data || [];
|
||
record = this.currentRows.find(r => {
|
||
const rowKey = this.primaryKey && r[this.primaryKey] !== undefined && r[this.primaryKey] !== null && r[this.primaryKey] !== ''
|
||
? r[this.primaryKey]
|
||
: r.__rowid;
|
||
return String(rowKey) === String(pkValue);
|
||
});
|
||
}
|
||
|
||
if (record) {
|
||
this.tableStructure.forEach(col => {
|
||
const input = document.querySelector(`[name="${col.name}"]`);
|
||
if (input) {
|
||
if (col.type.toLowerCase() === 'boolean') {
|
||
input.checked = record[col.name] === true || record[col.name] === 'true';
|
||
} else {
|
||
input.value = record[col.name] || '';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
} catch (err) {
|
||
this.showToast('Ошибка загрузки данных записи', 'error');
|
||
}
|
||
}
|
||
|
||
async saveRecord() {
|
||
const data = {};
|
||
|
||
this.tableStructure.forEach(col => {
|
||
const input = document.querySelector(`[name="${col.name}"]`);
|
||
if (input) {
|
||
if (col.type.toLowerCase() === 'boolean') {
|
||
data[col.name] = input.checked;
|
||
} else {
|
||
data[col.name] = input.value;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Ensure UUID/UID columns are sent (as empty strings) so the server can auto-generate values
|
||
if (!this.editingRecord) {
|
||
this.tableStructure.forEach(col => {
|
||
if (col.name.toLowerCase() === 'uid' || col.type.toLowerCase() === 'uuid') {
|
||
if (data[col.name] === undefined || data[col.name] === '') {
|
||
data[col.name] = '';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
try {
|
||
let response;
|
||
if (this.editingRecord) {
|
||
response = await fetch(`/api/tables/${this.currentTable}/records/${encodeURIComponent(this.editingRecord)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data)
|
||
});
|
||
} else {
|
||
response = await fetch(`/api/tables/${this.currentTable}/records`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data)
|
||
});
|
||
}
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
this.showToast(this.editingRecord ? 'Запись обновлена' : 'Запись добавлена', 'success');
|
||
this.closeModal('recordModal');
|
||
this.loadTableData();
|
||
// Refresh table list counts when rows change (new insert/delete)
|
||
if (!this.editingRecord) {
|
||
this.loadTables();
|
||
}
|
||
this.editingRecord = null;
|
||
} else {
|
||
this.showToast(result.error || 'Ошибка сохранения', 'error');
|
||
}
|
||
} catch (err) {
|
||
this.showToast('Ошибка сохранения', 'error');
|
||
}
|
||
}
|
||
|
||
showTableStructure() {
|
||
document.getElementById('structureTableName').textContent = this.currentTable;
|
||
this.renderStructureTable();
|
||
document.getElementById('structureModal').classList.remove('hidden');
|
||
}
|
||
|
||
renderStructureTable() {
|
||
const tbody = document.getElementById('structureBody');
|
||
tbody.innerHTML = this.tableStructure.map(col => `
|
||
<tr class="hover:bg-slate-50">
|
||
<td class="p-3">${col.name}</td>
|
||
<td class="p-3">${col.type}</td>
|
||
<td class="p-3">${col.nullable ? 'Да' : 'Нет'}</td>
|
||
<td class="p-3">${col.default_value || '-'}</td>
|
||
<td class="p-3">
|
||
<button onclick="app.editColumn('${col.name}')" class="text-blue-600 hover:underline mr-2">Изменить</button>
|
||
<button onclick="app.deleteColumn('${col.name}')" class="text-red-600 hover:underline">Удалить</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
showCreateColumnModal() {
|
||
document.getElementById('columnModalTitle').textContent = 'Добавить колонку';
|
||
document.getElementById('columnName').value = '';
|
||
document.getElementById('columnType').value = 'VARCHAR(255)';
|
||
document.getElementById('columnNullable').checked = true;
|
||
document.getElementById('columnDefault').value = '';
|
||
document.getElementById('columnPrimary').checked = false;
|
||
this.editingColumn = null;
|
||
document.getElementById('columnModal').classList.remove('hidden');
|
||
}
|
||
|
||
editColumn(columnName) {
|
||
const column = this.tableStructure.find(col => col.name === columnName);
|
||
if (!column) return;
|
||
|
||
document.getElementById('columnModalTitle').textContent = 'Изменить колонку';
|
||
document.getElementById('columnName').value = column.name;
|
||
document.getElementById('columnType').value = column.type;
|
||
document.getElementById('columnNullable').checked = column.nullable;
|
||
document.getElementById('columnDefault').value = column.default_value || '';
|
||
document.getElementById('columnPrimary').checked = column.is_primary;
|
||
this.editingColumn = columnName;
|
||
document.getElementById('columnModal').classList.remove('hidden');
|
||
}
|
||
|
||
async saveColumn() {
|
||
const name = document.getElementById('columnName').value;
|
||
const type = document.getElementById('columnType').value;
|
||
const nullable = document.getElementById('columnNullable').checked;
|
||
const defaultValue = document.getElementById('columnDefault').value;
|
||
const primaryKey = document.getElementById('columnPrimary').checked;
|
||
|
||
if (!name || !type) {
|
||
this.showToast('Название и тип колонки обязательны', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
let response;
|
||
if (this.editingColumn) {
|
||
// Update existing column
|
||
response = await fetch(`/api/tables/${this.currentTable}/columns/${encodeURIComponent(this.editingColumn)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ type, nullable, defaultValue })
|
||
});
|
||
} else {
|
||
// Add new column
|
||
response = await fetch(`/api/tables/${this.currentTable}/columns`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, type, nullable, defaultValue, primaryKey })
|
||
});
|
||
}
|
||
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
this.showToast(this.editingColumn ? 'Колонка обновлена' : 'Колонка добавлена', 'success');
|
||
this.closeModal('columnModal');
|
||
await this.loadTableStructure();
|
||
this.renderStructureTable();
|
||
} else {
|
||
this.showToast('Ошибка сохранения колонки', 'error');
|
||
}
|
||
} catch (err) {
|
||
this.showToast('Ошибка сохранения колонки', 'error');
|
||
}
|
||
}
|
||
|
||
deleteColumn(columnName) {
|
||
if (confirm(`Вы уверены, что хотите удалить колонку "${columnName}"?`)) {
|
||
fetch(`/api/tables/${this.currentTable}/columns/${encodeURIComponent(columnName)}`, {
|
||
method: 'DELETE'
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
this.showToast('Колонка удалена', 'success');
|
||
this.loadTableStructure();
|
||
this.renderStructureTable();
|
||
} else {
|
||
this.showToast('Ошибка удаления колонки', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
this.showToast('Ошибка удаления колонки', 'error');
|
||
});
|
||
}
|
||
}
|
||
|
||
showCreateTableModal() {
|
||
document.getElementById('newTableFolder').value = '';
|
||
document.getElementById('newTableName').value = '';
|
||
document.getElementById('columnsContainer').innerHTML = '';
|
||
this.addColumnField(); // Add one default column
|
||
document.getElementById('createTableModal').classList.remove('hidden');
|
||
}
|
||
|
||
addColumnField() {
|
||
const container = document.getElementById('columnsContainer');
|
||
const columnDiv = document.createElement('div');
|
||
columnDiv.className = 'flex items-center gap-2 p-3 bg-slate-50 rounded-lg';
|
||
columnDiv.innerHTML = `
|
||
<input type="text" placeholder="Название" class="flex-1 px-3 py-2 border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none" required>
|
||
<select class="px-3 py-2 border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 outline-none">
|
||
<option value="VARCHAR(255)">VARCHAR(255)</option>
|
||
<option value="TEXT">TEXT</option>
|
||
<option value="INTEGER">INTEGER</option>
|
||
<option value="BIGINT">BIGINT</option>
|
||
<option value="DECIMAL">DECIMAL</option>
|
||
<option value="BOOLEAN">BOOLEAN</option>
|
||
<option value="DATE">DATE</option>
|
||
<option value="TIMESTAMP">TIMESTAMP</option>
|
||
<option value="UUID">UUID</option>
|
||
<option value="JSON">JSON</option>
|
||
<option value="JSONB">JSONB</option>
|
||
</select>
|
||
<label class="flex items-center gap-1 text-sm">
|
||
<input type="checkbox" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"> PK
|
||
</label>
|
||
<label class="flex items-center gap-1 text-sm">
|
||
<input type="checkbox" class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500" checked> NULL
|
||
</label>
|
||
<button onclick="this.parentElement.remove()" class="p-2 text-red-600 hover:bg-red-50 rounded">
|
||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||
</button>
|
||
`;
|
||
container.appendChild(columnDiv);
|
||
lucide.createIcons();
|
||
}
|
||
|
||
createTable() {
|
||
const folder = document.getElementById('newTableFolder').value.trim();
|
||
const name = document.getElementById('newTableName').value.trim();
|
||
if (!name) {
|
||
this.showToast('Введите название таблицы', 'error');
|
||
return;
|
||
}
|
||
|
||
const tableName = folder ? `${folder}__${name}` : name;
|
||
|
||
const columnElements = document.querySelectorAll('#columnsContainer > div');
|
||
const columns = Array.from(columnElements).map(div => {
|
||
const inputs = div.querySelectorAll('input, select');
|
||
return {
|
||
name: inputs[0].value,
|
||
type: inputs[1].value,
|
||
pk: inputs[2].checked,
|
||
nullable: inputs[3].checked
|
||
};
|
||
});
|
||
|
||
if (columns.some(col => !col.name)) {
|
||
this.showToast('Все колонки должны иметь название', 'error');
|
||
return;
|
||
}
|
||
|
||
fetch('/api/tables', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: tableName, columns })
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
this.showToast('Таблица создана', 'success');
|
||
this.closeModal('createTableModal');
|
||
this.loadTables();
|
||
} else {
|
||
this.showToast('Ошибка создания таблицы', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
this.showToast('Ошибка создания таблицы', 'error');
|
||
});
|
||
}
|
||
|
||
deleteTable() {
|
||
if (confirm(`Вы уверены, что хотите удалить таблицу "${this.currentTable}"?`)) {
|
||
fetch(`/api/tables/${this.currentTable}`, {
|
||
method: 'DELETE'
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
this.showToast('Таблица удалена', 'success');
|
||
this.currentTable = null;
|
||
document.getElementById('currentTableTitle').textContent = 'Выберите таблицу';
|
||
document.getElementById('tableActions').classList.add('hidden');
|
||
document.getElementById('emptyState').classList.remove('hidden');
|
||
document.getElementById('dataGrid').classList.add('hidden');
|
||
this.loadTables();
|
||
} else {
|
||
this.showToast('Ошибка удаления таблицы', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
this.showToast('Ошибка удаления таблицы', 'error');
|
||
});
|
||
}
|
||
}
|
||
|
||
showSQLPanel() {
|
||
if (!this.getPermissions().canRunSql) {
|
||
this.showToast('SQL доступ разрешен только администраторам', 'error');
|
||
return;
|
||
}
|
||
document.getElementById('emptyState').classList.add('hidden');
|
||
document.getElementById('dataGrid').classList.add('hidden');
|
||
document.getElementById('logsPanel').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');
|
||
}
|
||
|
||
async showLogsPanel() {
|
||
if (!this.getPermissions().canViewLogs) {
|
||
this.showToast('Просмотр логов разрешен только администраторам', 'error');
|
||
return;
|
||
}
|
||
|
||
document.getElementById('emptyState').classList.add('hidden');
|
||
document.getElementById('dataGrid').classList.add('hidden');
|
||
document.getElementById('sqlPanel').classList.add('hidden');
|
||
document.getElementById('logsPanel').classList.remove('hidden');
|
||
await this.loadContainers();
|
||
}
|
||
|
||
async loadContainers() {
|
||
try {
|
||
const response = await fetch('/api/containers');
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Не удалось получить контейнеры');
|
||
}
|
||
|
||
const select = document.getElementById('containerSelect');
|
||
select.innerHTML = '<option value="">Select container</option>' + data.map(container =>
|
||
`<option value="${container.name}" ${this.currentContainer === container.name ? 'selected' : ''}>${container.name} · ${container.status}</option>`
|
||
).join('');
|
||
|
||
if (!this.currentContainer && data[0]) {
|
||
this.currentContainer = data[0].name;
|
||
select.value = this.currentContainer;
|
||
}
|
||
|
||
if (this.currentContainer) {
|
||
await this.refreshLogs();
|
||
}
|
||
} catch (err) {
|
||
document.getElementById('logStatus').textContent = err.message;
|
||
this.showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async changeContainer(value) {
|
||
this.currentContainer = value;
|
||
this.stopLogStream();
|
||
if (value) {
|
||
await this.refreshLogs();
|
||
}
|
||
}
|
||
|
||
async refreshLogs() {
|
||
if (!this.currentContainer) return;
|
||
try {
|
||
const response = await fetch(`/api/containers/${encodeURIComponent(this.currentContainer)}/logs`);
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Не удалось загрузить логи');
|
||
}
|
||
this.logsBuffer = (data.logs || '').split(/\r?\n/).filter(Boolean).slice(-400);
|
||
this.renderLogs();
|
||
document.getElementById('logStatus').textContent = `${data.container.name} · ${data.container.status}`;
|
||
} catch (err) {
|
||
document.getElementById('logStatus').textContent = err.message;
|
||
this.showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
toggleLogStream() {
|
||
if (this.logStream) {
|
||
this.stopLogStream();
|
||
} else {
|
||
this.startLogStream();
|
||
}
|
||
}
|
||
|
||
startLogStream() {
|
||
if (!this.currentContainer) return;
|
||
this.stopLogStream();
|
||
this.logStream = new EventSource(`/api/containers/${encodeURIComponent(this.currentContainer)}/logs/stream`);
|
||
document.getElementById('logStreamButton').textContent = 'Stop live';
|
||
document.getElementById('logStatus').textContent = `Streaming ${this.currentContainer}`;
|
||
|
||
this.logStream.addEventListener('log', (event) => {
|
||
const payload = JSON.parse(event.data);
|
||
this.logsBuffer.push(payload.line);
|
||
this.logsBuffer = this.logsBuffer.slice(-800);
|
||
this.renderLogs();
|
||
});
|
||
|
||
this.logStream.addEventListener('error', () => {
|
||
this.stopLogStream();
|
||
document.getElementById('logStatus').textContent = 'Live stream stopped';
|
||
});
|
||
}
|
||
|
||
stopLogStream() {
|
||
if (this.logStream) {
|
||
this.logStream.close();
|
||
this.logStream = null;
|
||
}
|
||
document.getElementById('logStreamButton').textContent = 'Start live';
|
||
}
|
||
|
||
clearLogs() {
|
||
this.logsBuffer = [];
|
||
this.renderLogs();
|
||
}
|
||
|
||
renderLogs() {
|
||
const output = document.getElementById('logOutput');
|
||
output.textContent = this.logsBuffer.length ? this.logsBuffer.join('\n') : 'No logs yet.';
|
||
output.scrollTop = output.scrollHeight;
|
||
}
|
||
|
||
parseList(value) {
|
||
return value.split(',').map(item => item.trim()).filter(Boolean);
|
||
}
|
||
|
||
async showUsersModal() {
|
||
await this.loadUsers();
|
||
this.resetUserForm();
|
||
document.getElementById('usersModal').classList.remove('hidden');
|
||
}
|
||
|
||
async loadUsers() {
|
||
try {
|
||
const response = await fetch('/api/users');
|
||
const users = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(users.error || 'Failed to load users');
|
||
}
|
||
|
||
document.getElementById('usersTableBody').innerHTML = users.map(user => `
|
||
<tr class="border-b border-slate-200">
|
||
<td class="p-3">${user.username}</td>
|
||
<td class="p-3">${user.role}</td>
|
||
<td class="p-3">${user.disabled ? 'disabled' : 'active'}</td>
|
||
<td class="p-3">
|
||
<button onclick='app.editUser(${JSON.stringify(JSON.stringify(user))})' class="text-blue-600 hover:underline mr-3">Edit</button>
|
||
<button onclick='app.deleteUser(${JSON.stringify(user.username)})' class="text-red-600 hover:underline">Delete</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
} catch (err) {
|
||
this.showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
editUser(serializedUser) {
|
||
const user = JSON.parse(serializedUser);
|
||
document.getElementById('userEditMode').value = user.username;
|
||
document.getElementById('userUsername').value = user.username;
|
||
document.getElementById('userUsername').disabled = true;
|
||
document.getElementById('userPassword').value = '';
|
||
document.getElementById('userRole').value = user.role;
|
||
document.getElementById('userViewFolders').value = (user.access?.view?.folders || []).join(', ');
|
||
document.getElementById('userEditTables').value = (user.access?.edit?.tables || []).join(', ');
|
||
document.getElementById('userDeleteFolders').value = (user.access?.delete?.folders || []).join(', ');
|
||
document.getElementById('userDisabled').checked = Boolean(user.disabled);
|
||
}
|
||
|
||
resetUserForm() {
|
||
document.getElementById('userEditMode').value = '';
|
||
document.getElementById('userUsername').value = '';
|
||
document.getElementById('userUsername').disabled = false;
|
||
document.getElementById('userPassword').value = '';
|
||
document.getElementById('userRole').value = 'viewer';
|
||
document.getElementById('userViewFolders').value = '';
|
||
document.getElementById('userEditTables').value = '';
|
||
document.getElementById('userDeleteFolders').value = '';
|
||
document.getElementById('userDisabled').checked = false;
|
||
}
|
||
|
||
async saveUser() {
|
||
const username = document.getElementById('userUsername').value.trim();
|
||
const editMode = document.getElementById('userEditMode').value;
|
||
const password = document.getElementById('userPassword').value;
|
||
const payload = {
|
||
username,
|
||
password,
|
||
role: document.getElementById('userRole').value,
|
||
disabled: document.getElementById('userDisabled').checked,
|
||
access: {
|
||
view: { folders: this.parseList(document.getElementById('userViewFolders').value), tables: [] },
|
||
create: { folders: [], tables: [] },
|
||
edit: { folders: [], tables: this.parseList(document.getElementById('userEditTables').value) },
|
||
delete: { folders: this.parseList(document.getElementById('userDeleteFolders').value), tables: [] },
|
||
},
|
||
};
|
||
|
||
try {
|
||
const response = await fetch(editMode ? `/api/users/${encodeURIComponent(editMode)}` : '/api/users', {
|
||
method: editMode ? 'PUT' : 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Failed to save user');
|
||
}
|
||
this.showToast('User saved', 'success');
|
||
this.resetUserForm();
|
||
this.loadUsers();
|
||
} catch (err) {
|
||
this.showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async deleteUser(username) {
|
||
if (!confirm(`Delete user ${username}?`)) return;
|
||
try {
|
||
const response = await fetch(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Failed to delete user');
|
||
}
|
||
this.showToast('User deleted', 'success');
|
||
this.loadUsers();
|
||
} catch (err) {
|
||
this.showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
showMoveTableModal() {
|
||
const parts = (this.currentTable || '').split('__');
|
||
document.getElementById('moveTableFolder').value = parts.length > 1 ? parts[0] : '';
|
||
document.getElementById('moveTableName').value = parts.length > 1 ? parts.slice(1).join('__') : this.currentTable || '';
|
||
document.getElementById('moveTableModal').classList.remove('hidden');
|
||
}
|
||
|
||
async moveTable() {
|
||
const folder = document.getElementById('moveTableFolder').value.trim();
|
||
const name = document.getElementById('moveTableName').value.trim();
|
||
if (!name) {
|
||
this.showToast('Table name is required', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/tables/${encodeURIComponent(this.currentTable)}/move`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ folder, name }),
|
||
});
|
||
const data = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Failed to move table');
|
||
}
|
||
this.currentTable = data.name;
|
||
this.closeModal('moveTableModal');
|
||
this.loadTables();
|
||
this.selectTable(data.name);
|
||
this.showToast('Table moved', 'success');
|
||
} catch (err) {
|
||
this.showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
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>
|