Files
PG-admi-onefile/index.html
2026-03-20 14:55:36 +07:00

1755 lines
92 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PostgreSQL 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="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.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>
<!-- 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.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.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
};
}
return this.currentUser?.permissions || {
folders: null,
canCreate: false,
canEdit: false,
canDelete: false,
canViewLogs: false,
canRunSql: 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()"]');
if (addBtn) addBtn.style.display = this.canEditTable() ? '' : 'none';
if (deleteTableBtn) deleteTableBtn.style.display = this.canDeleteTable() ? '' : '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
const columns = records.length > 0 ? Object.keys(records[0]) : this.tableStructure.map(col => col.name);
headers.innerHTML = columns.map(col => {
const isSorted = this.sortColumn === col;
const arrow = isSorted ? (this.sortDirection === 'ASC' ? '↑' : '↓') : '';
return `<th class="text-left p-3 cursor-pointer hover:bg-slate-100 select-none" onclick="app.sortBy('${col}')">${col} ${arrow}</th>`;
}).join('') + '<th class="text-left p-3">Действия</th>';
// 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] : null;
const cells = columns.map(col => {
const colDef = this.tableStructure.find(c => c.name === col);
let displayValue = record[col] || '';
if (colDef && colDef.type.toLowerCase() === 'boolean') {
displayValue = record[col] === true || record[col] === 'true' ? '✓' : '✗';
}
return `<td class="p-3 border-b border-slate-100">${displayValue}</td>`;
}).join('');
const canEdit = this.canEditTable();
const canDelete = this.canDeleteTable();
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 {
// Since we don't have a single record endpoint, we'll load all data and find the record
const params = new URLSearchParams({ page: 1, limit: 1000 }); // Load more to find the record
const response = await fetch(`/api/tables/${this.currentTable}/data?${params}`);
const data = await response.json();
const record = data.data.find(r => r[this.primaryKey] == pkValue);
if (record) {
this.tableStructure.forEach(col => {
const input = document.querySelector(`[name="${col.name}"]`);
if (input) {
if (col.type.toLowerCase() === 'boolean') {
input.checked = record[col.name] === true || record[col.name] === 'true';
} else {
input.value = record[col.name] || '';
}
}
});
}
} catch (err) {
this.showToast('Ошибка загрузки данных записи', 'error');
}
}
async saveRecord() {
const data = {};
this.tableStructure.forEach(col => {
const input = document.querySelector(`[name="${col.name}"]`);
if (input) {
if (col.type.toLowerCase() === 'boolean') {
data[col.name] = input.checked;
} else {
data[col.name] = input.value;
}
}
});
// 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;
}
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>