Compare commits

...

2 Commits

Author SHA1 Message Date
e51f34d7be ну хоть примеры 2026-03-20 16:09:07 +07:00
7123aac2cc ебать что это блять 2026-03-20 16:08:38 +07:00
12 changed files with 1548 additions and 1196 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
PORT=3000
NODE_ENV=production
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin
DB_HOST=db
DB_PORT=5432
DB_NAME=testdb
DB_USER=postgres
DB_PASSWORD=postgres
SESSION_SECRET=change_me_to_long_random_secret
ENABLE_TELEGRAM_NOTIFICATIONS=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=

2
.gitignore vendored
View File

@@ -164,3 +164,5 @@ tmp/
temp/
*.tmp
.cache/
audit.log
backups/

116
public/assets/app.css Normal file
View File

@@ -0,0 +1,116 @@
: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;
}
}

View File

@@ -1,698 +1,4 @@
<!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="auditButton" onclick="app.showAuditModal()" 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="history" class="w-4 h-4"></i>
Audit
</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 flex-wrap gap-2">
<button onclick="app.applySQLTemplate('select')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">SELECT *</button>
<button onclick="app.applySQLTemplate('count')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">COUNT rows</button>
<button onclick="app.applySQLTemplate('insert')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">INSERT row</button>
<button onclick="app.applySQLTemplate('update')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">UPDATE row</button>
<button onclick="app.applySQLTemplate('delete')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">DELETE row</button>
<button onclick="app.applySQLTemplate('schema')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">Describe table</button>
</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>
<select id="moveTableFolder" class="w-full px-4 py-2 border border-slate-300 rounded-lg"></select>
</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>
<details class="border border-slate-300 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-700">Choose folders</summary>
<div id="userViewFoldersList" class="mt-3 grid gap-2"></div>
</details>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Editable tables</label>
<details class="border border-slate-300 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-700">Choose tables</summary>
<div id="userEditTablesList" class="mt-3 grid gap-2 max-h-48 overflow-auto"></div>
</details>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Deletable folders</label>
<details class="border border-slate-300 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-700">Choose folders</summary>
<div id="userDeleteFoldersList" class="mt-3 grid gap-2"></div>
</details>
</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>
<div id="auditModal" 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-5xl 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">Audit log</h3>
<button onclick="app.closeModal('auditModal')" 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-auto">
<div class="flex justify-end mb-4">
<button onclick="app.loadAuditLog()" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Refresh</button>
</div>
<div id="auditList" class="space-y-3"></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
// Application Logic
class PostgresAdmin {
constructor() {
this.currentUser = null;
@@ -845,6 +151,7 @@ SELECT * FROM users LIMIT 10;"></textarea>
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.getElementById('backupsButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
document.getElementById('auditButton').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';
@@ -1149,6 +456,10 @@ SELECT * FROM users LIMIT 10;"></textarea>
// Generate form fields based on table structure
const columnsToRender = this.tableStructure.filter(col => {
const metaFields = ['created_at', 'created_by', 'updated_at', 'updated_by'];
if (!isEdit && metaFields.includes(col.name)) {
return false;
}
// 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;
@@ -1179,7 +490,10 @@ SELECT * FROM users LIMIT 10;"></textarea>
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}">`;
const metaFields = ['created_at', 'created_by', 'updated_at', 'updated_by'];
const readonly = metaFields.includes(col.name) ? 'readonly' : '';
const readonlyClass = metaFields.includes(col.name) ? ' bg-slate-100 text-slate-500' : '';
inputHtml = `<input type="${inputType}" name="${col.name}" value="${value}" ${step} ${readonly} 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${readonlyClass}" placeholder="${col.type}">`;
}
return `
@@ -1907,6 +1221,52 @@ SELECT * FROM users LIMIT 10;"></textarea>
document.getElementById('auditModal').classList.remove('hidden');
}
async showBackupsModal() {
await this.loadBackups();
document.getElementById('backupsModal').classList.remove('hidden');
}
async loadBackups() {
try {
const response = await fetch('/api/backups');
const backups = await response.json();
if (!response.ok) {
throw new Error(backups.error || 'Failed to load backups');
}
document.getElementById('backupsList').innerHTML = backups.length
? backups.map(backup => `
<div class="border border-slate-200 rounded-xl p-4 flex items-center justify-between gap-4">
<div>
<div class="font-medium text-slate-800">${backup.filename}</div>
<div class="text-sm text-slate-500">${backup.createdAt} · ${backup.size} bytes</div>
</div>
<a href="/api/backups/${encodeURIComponent(backup.filename)}/download" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Download</a>
</div>
`).join('')
: '<div class="text-sm text-slate-500">No backups yet.</div>';
} catch (err) {
this.showToast(err.message, 'error');
}
}
async createBackup() {
try {
const response = await fetch('/api/backups', {
method: 'POST',
headers: { 'X-Request-Source': 'WEB' },
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to create backup');
}
this.showToast('Backup created', 'success');
this.loadBackups();
} catch (err) {
this.showToast(err.message, 'error');
}
}
async loadAuditLog() {
try {
const response = await fetch('/api/audit');
@@ -2130,6 +1490,3 @@ SELECT * FROM users LIMIT 10;"></textarea>
event.target.classList.add('hidden');
}
}
</script>
</body>
</html>

View File

Before

Width:  |  Height:  |  Size: 513 B

After

Width:  |  Height:  |  Size: 513 B

603
public/index.html Normal file
View File

@@ -0,0 +1,603 @@
<!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">
<link rel="stylesheet" href="assets/app.css">
</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="backupsButton" onclick="app.showBackupsModal()" 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="archive" class="w-4 h-4"></i>
Backups
</button>
<button id="auditButton" onclick="app.showAuditModal()" 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="history" class="w-4 h-4"></i>
Audit
</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 flex-wrap gap-2">
<button onclick="app.applySQLTemplate('select')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">SELECT *</button>
<button onclick="app.applySQLTemplate('count')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">COUNT rows</button>
<button onclick="app.applySQLTemplate('insert')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">INSERT row</button>
<button onclick="app.applySQLTemplate('update')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">UPDATE row</button>
<button onclick="app.applySQLTemplate('delete')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">DELETE row</button>
<button onclick="app.applySQLTemplate('schema')" class="px-3 py-2 bg-slate-800 text-slate-200 rounded-lg text-xs">Describe table</button>
</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>
<select id="moveTableFolder" class="w-full px-4 py-2 border border-slate-300 rounded-lg"></select>
</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>
<details class="border border-slate-300 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-700">Choose folders</summary>
<div id="userViewFoldersList" class="mt-3 grid gap-2"></div>
</details>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Editable tables</label>
<details class="border border-slate-300 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-700">Choose tables</summary>
<div id="userEditTablesList" class="mt-3 grid gap-2 max-h-48 overflow-auto"></div>
</details>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Deletable folders</label>
<details class="border border-slate-300 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-700">Choose folders</summary>
<div id="userDeleteFoldersList" class="mt-3 grid gap-2"></div>
</details>
</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>
<div id="auditModal" 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-5xl 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">Audit log</h3>
<button onclick="app.closeModal('auditModal')" 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-auto">
<div class="flex justify-end mb-4">
<button onclick="app.loadAuditLog()" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Refresh</button>
</div>
<div id="auditList" class="space-y-3"></div>
</div>
</div>
</div>
<div id="backupsModal" 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">Backups</h3>
<button onclick="app.closeModal('backupsModal')" 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-auto">
<div class="flex justify-between items-center mb-4">
<p class="text-sm text-slate-500">Create and download recovery snapshots.</p>
<button onclick="app.createBackup()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Create backup</button>
</div>
<div id="backupsList" class="space-y-3"></div>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div id="toastContainer" class="fixed bottom-6 right-6 z-50 flex flex-col gap-2"></div>
<script src="assets/app.js"></script>
</body>
</html>

608
server.js
View File

@@ -3,502 +3,48 @@ const express = require('express');
const { Pool } = require('pg');
const session = require('express-session');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const http = require('http');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
let usersConfig = { users: [] };
try {
usersConfig = JSON.parse(fs.readFileSync(path.join(__dirname, 'users.json'), 'utf8'));
} catch (err) {
console.warn('⚠️ users.json not found or invalid JSON. Falling back to env-based admin only.');
}
const rolePermissions = {
superadmin: { folders: null, canCreate: true, canEdit: true, canDelete: true },
frontend_admin: { folders: ['frontend'], canCreate: true, canEdit: true, canDelete: true },
backend_admin: { folders: ['backend'], canCreate: true, canEdit: true, canDelete: true },
frontend_moder: { folders: ['frontend'], canCreate: true, canEdit: true, canDelete: false },
backend_moder: { folders: ['backend'], canCreate: true, canEdit: true, canDelete: false },
viewer: { folders: null, canCreate: false, canEdit: false, canDelete: false },
};
function getUser(username) {
return usersConfig.users.find(u => u.username === username);
}
function getTableFolder(tableName) {
if (!tableName) return 'default';
const parts = tableName.split('__');
return parts.length > 1 ? parts[0] : 'default';
}
function getRolePermissions(role) {
return rolePermissions[role] || rolePermissions.viewer;
}
function canAccessTable(role, tableName) {
const perms = getRolePermissions(role);
if (!perms.folders) return true;
const folder = getTableFolder(tableName);
return perms.folders.includes(folder);
}
const USERS_FILE = path.join(__dirname, 'users.json');
const AUDIT_LOG_FILE = path.join(__dirname, 'audit.log');
const DOCKER_SOCKET_PATH = process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock';
const DOCKER_API_PREFIX = process.env.DOCKER_API_PREFIX || '/v1.41';
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const ALLOWED_SQL_TYPES = new Set(['VARCHAR(255)', 'TEXT', 'INTEGER', 'BIGINT', 'DECIMAL', 'BOOLEAN', 'DATE', 'TIMESTAMP', 'UUID', 'JSON', 'JSONB']);
const LEGACY_ROLE_MAP = {
frontend_admin: { role: 'admin', folders: ['frontend'] },
backend_admin: { role: 'admin', folders: ['backend'] },
frontend_moder: { role: 'moderator', folders: ['frontend'] },
backend_moder: { role: 'moderator', folders: ['backend'] },
viewer: { role: 'viewer', folders: null },
superadmin: { role: 'superadmin', folders: null },
};
function readUsersConfig() {
try {
const parsed = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
const users = Array.isArray(parsed.users) ? parsed.users : [];
return { users: users.map(normalizeUser).filter(Boolean) };
} catch (err) {
console.warn('users.json not found or invalid JSON. Falling back to env-based superadmin only.');
return { users: [] };
}
}
function normalizeUser(user) {
if (!user || typeof user.username !== 'string') {
return null;
}
const legacy = LEGACY_ROLE_MAP[user.role];
const role = legacy ? legacy.role : user.role;
const folders = Array.isArray(user.folders)
? user.folders.filter(Boolean)
: legacy
? legacy.folders
: null;
return {
username: user.username,
password: typeof user.password === 'string' ? user.password : undefined,
passwordHash: typeof user.passwordHash === 'string' ? user.passwordHash : undefined,
role: ['admin', 'moderator', 'viewer', 'superadmin'].includes(role) ? role : 'viewer',
folders,
access: normalizeAccess(user.access, role, folders),
disabled: Boolean(user.disabled),
};
}
function getUser(username) {
return readUsersConfig().users.find((user) => user.username === username) || null;
}
function normalizeScope(scope, fallbackFolders = null) {
if (scope === null) {
return { folders: null, tables: null };
}
return {
folders: Array.isArray(scope?.folders)
? scope.folders.filter(Boolean)
: fallbackFolders,
tables: Array.isArray(scope?.tables)
? scope.tables.filter(Boolean)
: [],
};
}
function normalizeAccess(access, role, folders = null) {
if (role === 'superadmin') {
return {
view: { folders: null, tables: null },
create: { folders: null, tables: null },
edit: { folders: null, tables: null },
delete: { folders: null, tables: null },
};
}
const baseFolders = folders && folders.length ? folders : null;
const defaultsByRole = {
admin: {
view: { folders: baseFolders, tables: [] },
create: { folders: baseFolders, tables: [] },
edit: { folders: baseFolders, tables: [] },
delete: { folders: baseFolders, tables: [] },
},
moderator: {
view: { folders: baseFolders, tables: [] },
create: { folders: baseFolders, tables: [] },
edit: { folders: baseFolders, tables: [] },
delete: { folders: [], tables: [] },
},
viewer: {
view: { folders: baseFolders, tables: [] },
create: { folders: [], tables: [] },
edit: { folders: [], tables: [] },
delete: { folders: [], tables: [] },
},
};
const defaults = defaultsByRole[role] || defaultsByRole.viewer;
const source = access && typeof access === 'object' ? access : {};
return {
view: normalizeScope(source.view, defaults.view.folders),
create: normalizeScope(source.create, defaults.create.folders),
edit: normalizeScope(source.edit, defaults.edit.folders),
delete: normalizeScope(source.delete, defaults.delete.folders),
};
}
function getRolePermissions(role, folders = null, access = null) {
const normalizedAccess = normalizeAccess(access, role, folders);
if (role === 'superadmin') {
return {
role,
folders: null,
access: normalizedAccess,
canCreate: true,
canEdit: true,
canDelete: true,
canViewLogs: true,
canRunSql: true,
canManageUsers: true,
canMoveTables: true,
};
}
if (role === 'admin') {
return {
role,
folders: folders && folders.length ? folders : null,
access: normalizedAccess,
canCreate: true,
canEdit: true,
canDelete: true,
canViewLogs: true,
canRunSql: true,
canManageUsers: true,
canMoveTables: true,
};
}
if (role === 'moderator') {
return {
role,
folders: folders && folders.length ? folders : null,
access: normalizedAccess,
canCreate: true,
canEdit: true,
canDelete: false,
canViewLogs: false,
canRunSql: false,
canManageUsers: false,
canMoveTables: false,
};
}
return {
role: 'viewer',
folders: folders && folders.length ? folders : null,
access: normalizedAccess,
canCreate: false,
canEdit: false,
canDelete: false,
canViewLogs: false,
canRunSql: false,
canManageUsers: false,
canMoveTables: false,
};
}
function isScopeAllowed(scope, tableName) {
if (!scope) return false;
if (scope.tables === null || scope.folders === null) return true;
const folder = getTableFolder(tableName);
return scope.tables.includes(tableName) || scope.folders.includes(folder);
}
function canAccessTable(permissionsOrRole, tableName, folders = null, access = null, action = 'view') {
const perms = typeof permissionsOrRole === 'string'
? getRolePermissions(permissionsOrRole, folders, access)
: permissionsOrRole;
return isScopeAllowed(perms.access?.[action], tableName);
}
function canAccessFolder(permissions, folder, action = 'view') {
const scope = permissions.access?.[action];
if (!scope) return false;
if (scope.folders === null) return true;
return scope.folders.includes(folder);
}
function isValidIdentifier(value) {
return SAFE_IDENTIFIER.test(value);
}
function quoteIdentifier(identifier) {
if (!isValidIdentifier(identifier)) {
throw new Error(`Unsafe identifier: ${identifier}`);
}
return `"${identifier}"`;
}
function createSessionUser({ username, role, folders, access }) {
return {
username,
role,
permissions: getRolePermissions(role, folders, access),
};
}
async function verifyPassword(user, password) {
if (!user || user.disabled) {
return false;
}
if (user.passwordHash) {
return bcrypt.compare(password, user.passwordHash);
}
return user.password === password;
}
function sanitizeUser(user) {
return {
username: user.username,
role: user.role,
folders: user.folders,
access: user.access,
disabled: user.disabled,
};
}
function validateScopeInput(scope) {
if (scope === null) {
return { folders: null, tables: null };
}
return {
folders: Array.isArray(scope?.folders) ? scope.folders.filter(Boolean) : [],
tables: Array.isArray(scope?.tables) ? scope.tables.filter(Boolean) : [],
};
}
function validateUserPayload(payload, { allowPasswordOptional = false } = {}) {
if (!payload || typeof payload.username !== 'string' || !payload.username.trim()) {
throw new Error('Username is required');
}
if (!['admin', 'moderator', 'viewer'].includes(payload.role)) {
throw new Error('Invalid role');
}
if (!allowPasswordOptional && (!payload.password || typeof payload.password !== 'string')) {
throw new Error('Password is required');
}
const folders = Array.isArray(payload.folders) ? payload.folders.filter(Boolean) : null;
const access = payload.access && typeof payload.access === 'object'
? {
view: validateScopeInput(payload.access.view),
create: validateScopeInput(payload.access.create),
edit: validateScopeInput(payload.access.edit),
delete: validateScopeInput(payload.access.delete),
}
: normalizeAccess(null, payload.role, folders);
return {
username: payload.username.trim(),
password: typeof payload.password === 'string' ? payload.password : undefined,
role: payload.role,
folders,
access,
disabled: Boolean(payload.disabled),
};
}
function writeUsersConfig(users) {
const serialized = JSON.stringify({
users: users.map((user) => {
const payload = {
username: user.username,
role: user.role,
folders: user.folders,
access: user.access,
disabled: Boolean(user.disabled),
};
if (user.passwordHash) {
payload.passwordHash = user.passwordHash;
} else if (user.password) {
payload.password = user.password;
}
return payload;
}),
}, null, 2);
fs.writeFileSync(USERS_FILE, serialized, 'utf8');
}
function appendAudit(event, actor, details = {}) {
const source = details.source || 'WEB';
const entry = {
timestamp: new Date().toISOString(),
event,
actor: actor || 'system',
source,
summary: formatAuditSummary(event, details),
details,
};
fs.appendFileSync(AUDIT_LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8');
}
function formatAuditSummary(event, details = {}) {
const summaries = {
'login.success': `successful login`,
'login.failed': `failed login attempt`,
'logout': `logout`,
'user.created': `created user ${details.username || ''}`.trim(),
'user.updated': `updated user ${details.username || ''}`.trim(),
'user.deleted': `deleted user ${details.username || ''}`.trim(),
'table.created': `created table ${details.table || ''}`.trim(),
'table.deleted': `deleted table ${details.table || ''}`.trim(),
'table.moved': `moved table ${details.from || ''} to ${details.to || ''}`.trim(),
'record.created': `created record in ${details.table || ''}`.trim(),
'record.updated': `updated record in ${details.table || ''}`.trim(),
'record.deleted': `deleted record from ${details.table || ''}`.trim(),
'sql.executed': `executed ${details.command || 'SQL'} query`,
};
return summaries[event] || event;
}
function getAuditSource(req, fallback = 'WEB') {
const header = String(req?.headers?.['x-request-source'] || '').trim().toUpperCase();
if (header === 'AI' || header === 'WEB') {
return header;
}
return fallback;
}
function readAuditLog(limit = 200) {
if (!fs.existsSync(AUDIT_LOG_FILE)) {
return [];
}
return fs.readFileSync(AUDIT_LOG_FILE, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch (error) {
return null;
}
})
.filter(Boolean)
.slice(-limit)
.reverse();
}
function dockerRequest(requestPath, { stream = false } = {}) {
return new Promise((resolve, reject) => {
const req = http.request({
socketPath: DOCKER_SOCKET_PATH,
path: `${DOCKER_API_PREFIX}${requestPath}`,
method: 'GET',
}, (response) => {
if (stream) {
if (response.statusCode >= 400) {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => reject(new Error(Buffer.concat(chunks).toString('utf8') || 'Docker stream error')));
return;
}
resolve(response);
return;
}
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
const body = Buffer.concat(chunks);
if (response.statusCode >= 400) {
reject(new Error(body.toString('utf8') || 'Docker API error'));
return;
}
resolve(body);
});
});
req.on('error', reject);
req.end();
});
}
function demuxDockerChunk(buffer) {
let offset = 0;
let output = '';
while (offset + 8 <= buffer.length) {
const payloadLength = buffer.readUInt32BE(offset + 4);
const payloadStart = offset + 8;
const payloadEnd = payloadStart + payloadLength;
if (payloadEnd > buffer.length) {
output += buffer.slice(offset).toString('utf8');
return output;
}
output += buffer.slice(payloadStart, payloadEnd).toString('utf8');
offset = payloadEnd;
}
if (offset < buffer.length) {
output += buffer.slice(offset).toString('utf8');
}
return output;
}
async function listContainers() {
const body = await dockerRequest('/containers/json?all=1');
const containers = JSON.parse(body.toString('utf8'));
return containers.map((container) => ({
id: container.Id,
name: container.Names?.[0]?.replace(/^\//, '') || container.Id.slice(0, 12),
state: container.State,
status: container.Status,
image: container.Image,
}));
}
async function resolveContainer(nameOrId) {
const containers = await listContainers();
const container = containers.find((item) =>
item.id === nameOrId || item.id.startsWith(nameOrId) || item.name === nameOrId
);
if (!container) {
throw new Error('Container not found');
}
return container;
}
const {
ALLOWED_SQL_TYPES,
canAccessFolder,
canAccessTable,
createSessionUser,
getTableFolder,
getUser,
isValidIdentifier,
quoteIdentifier,
readUsersConfig,
sanitizeUser,
validateUserPayload,
verifyPassword,
writeUsersConfig,
} = require('./src/lib/access');
const {
appendAudit,
getAuditSource,
readAuditLog,
} = require('./src/lib/audit');
const {
demuxDockerChunk,
dockerRequest,
listContainers,
resolveContainer,
} = require('./src/lib/docker');
const {
createBackup,
getBackupPath,
listBackups,
} = require('./src/services/backups');
const {
notifyError,
} = require('./src/services/notifications');
const app = express();
// Middleware
app.use(cors());
app.use(express.json({ limit: '1mb' }));
app.use(express.static('.'));
app.use(express.static('./public'));
// Session configuration
app.use(session({
@@ -534,6 +80,29 @@ pool.connect((err, client, release) => {
}
});
function applyRecordMetadata(structure, payload, currentUser, { isCreate = false } = {}) {
const data = { ...payload };
const hasColumn = (name) => structure.some((col) => col.column_name === name);
if (isCreate) {
if (hasColumn('created_by') && !data.created_by) {
data.created_by = currentUser.username;
}
if (hasColumn('created_at') && !data.created_at) {
data.created_at = new Date().toISOString();
}
}
if (hasColumn('updated_by')) {
data.updated_by = currentUser.username;
}
if (hasColumn('updated_at')) {
data.updated_at = new Date().toISOString();
}
return data;
}
// Helper: get primary key column for a table (returns null if none)
async function getPrimaryKeyColumn(tableName) {
const result = await pool.query(`
@@ -772,6 +341,39 @@ app.get('/api/audit', requireAuth, requirePermission(
res.json(readAuditLog(limit));
});
app.get('/api/backups', requireAuth, requirePermission(
(permissions) => permissions.canManageUsers,
'Backup access denied'
), (req, res) => {
res.json(listBackups());
});
app.post('/api/backups', requireAuth, requirePermission(
(permissions) => permissions.canManageUsers,
'Backup access denied'
), async (req, res) => {
try {
const backup = await createBackup(pool, req.currentUser.username);
appendAudit('backup.created', req.currentUser.username, { filename: backup.filename, source: getAuditSource(req) });
res.json({ success: true, backup });
} catch (err) {
notifyError('Backup creation failed', err, { actor: req.currentUser.username }).catch(() => {});
res.status(500).json({ success: false, error: err.message });
}
});
app.get('/api/backups/:filename/download', requireAuth, requirePermission(
(permissions) => permissions.canManageUsers,
'Backup access denied'
), (req, res) => {
try {
const filePath = getBackupPath(req.params.filename);
res.download(filePath, req.params.filename);
} catch (err) {
res.status(404).json({ success: false, error: err.message });
}
});
// Get all tables
app.get('/api/tables', requireAuth, async (req, res) => {
try {
@@ -914,6 +516,7 @@ app.post('/api/tables', requireAuth, requirePermission((permissions, req) => {
}
try {
const reservedColumns = new Set(columns.map((col) => col.name));
const columnsSQL = columns.map((col) => {
if (!isValidIdentifier(col.name) || !ALLOWED_SQL_TYPES.has(col.type)) {
throw new Error('Invalid column definition');
@@ -923,7 +526,12 @@ app.post('/api/tables', requireAuth, requirePermission((permissions, req) => {
if (col.pk) def += ' PRIMARY KEY';
if (!col.nullable && !col.pk) def += ' NOT NULL';
return def;
}).join(', ');
}).concat([
!reservedColumns.has('created_at') ? `"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` : null,
!reservedColumns.has('created_by') ? `"created_by" VARCHAR(255)` : null,
!reservedColumns.has('updated_at') ? `"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` : null,
!reservedColumns.has('updated_by') ? `"updated_by" VARCHAR(255)` : null,
].filter(Boolean)).join(', ');
await pool.query(`CREATE TABLE ${quoteIdentifier(name)} (${columnsSQL})`);
appendAudit('table.created', req.currentUser.username, { table: name, source: getAuditSource(req) });
@@ -985,7 +593,8 @@ app.post('/api/tables/:tableName/records', requireAuth, requireTableAccess, requ
const structure = structureResult.rows;
const filteredData = {};
for (const [key, value] of Object.entries(data)) {
const dataWithMetadata = applyRecordMetadata(structure, data, req.currentUser, { isCreate: true });
for (const [key, value] of Object.entries(dataWithMetadata)) {
const colInfo = structure.find(col => col.column_name === key);
if (!colInfo || value === '') {
continue;
@@ -1020,15 +629,22 @@ app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, r
), async (req, res) => {
const { tableName, pk } = req.params;
const data = req.body || {};
const columns = Object.keys(data).filter(isValidIdentifier);
try {
const primaryKey = await getPrimaryKeyColumn(tableName);
const structure = await pool.query(`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = $1 AND table_schema = 'public'
`, [tableName]).then((result) => result.rows);
const dataWithMetadata = applyRecordMetadata(structure, data, req.currentUser, { isCreate: false });
const columns = Object.keys(dataWithMetadata).filter(isValidIdentifier);
if (!columns.length) {
return res.status(400).json({ success: false, error: 'No valid fields to update' });
}
try {
const primaryKey = await getPrimaryKeyColumn(tableName);
const values = columns.map((column) => data[column]);
const values = columns.map((column) => dataWithMetadata[column]);
const setClause = columns.map((col, i) => `${quoteIdentifier(col)} = $${i + 1}`).join(', ');
const whereClause = primaryKey
? `${quoteIdentifier(primaryKey)} = $${values.length + 1}`
@@ -1326,3 +942,5 @@ app.listen(PORT, () => {
console.log('');
console.log('📝 Make sure to configure your database in .env file');
});

325
src/lib/access.js Normal file
View File

@@ -0,0 +1,325 @@
const fs = require('fs');
const path = require('path');
const bcrypt = require('bcryptjs');
const USERS_FILE = path.join(__dirname, '..', '..', 'users.json');
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const ALLOWED_SQL_TYPES = new Set(['VARCHAR(255)', 'TEXT', 'INTEGER', 'BIGINT', 'DECIMAL', 'BOOLEAN', 'DATE', 'TIMESTAMP', 'UUID', 'JSON', 'JSONB']);
const LEGACY_ROLE_MAP = {
frontend_admin: { role: 'admin', folders: ['frontend'] },
backend_admin: { role: 'admin', folders: ['backend'] },
frontend_moder: { role: 'moderator', folders: ['frontend'] },
backend_moder: { role: 'moderator', folders: ['backend'] },
viewer: { role: 'viewer', folders: null },
superadmin: { role: 'superadmin', folders: null },
};
function getTableFolder(tableName) {
if (!tableName) return 'default';
const parts = tableName.split('__');
return parts.length > 1 ? parts[0] : 'default';
}
function readUsersConfig() {
try {
const parsed = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
const users = Array.isArray(parsed.users) ? parsed.users : [];
return { users: users.map(normalizeUser).filter(Boolean) };
} catch (err) {
console.warn('users.json not found or invalid JSON. Falling back to env-based superadmin only.');
return { users: [] };
}
}
function normalizeUser(user) {
if (!user || typeof user.username !== 'string') {
return null;
}
const legacy = LEGACY_ROLE_MAP[user.role];
const role = legacy ? legacy.role : user.role;
const folders = Array.isArray(user.folders)
? user.folders.filter(Boolean)
: legacy
? legacy.folders
: null;
return {
username: user.username,
password: typeof user.password === 'string' ? user.password : undefined,
passwordHash: typeof user.passwordHash === 'string' ? user.passwordHash : undefined,
role: ['admin', 'moderator', 'viewer', 'superadmin'].includes(role) ? role : 'viewer',
folders,
access: normalizeAccess(user.access, role, folders),
disabled: Boolean(user.disabled),
};
}
function getUser(username) {
return readUsersConfig().users.find((user) => user.username === username) || null;
}
function normalizeScope(scope, fallbackFolders = null) {
if (scope === null) {
return { folders: null, tables: null };
}
return {
folders: Array.isArray(scope?.folders)
? scope.folders.filter(Boolean)
: fallbackFolders,
tables: Array.isArray(scope?.tables)
? scope.tables.filter(Boolean)
: [],
};
}
function normalizeAccess(access, role, folders = null) {
if (role === 'superadmin') {
return {
view: { folders: null, tables: null },
create: { folders: null, tables: null },
edit: { folders: null, tables: null },
delete: { folders: null, tables: null },
};
}
const baseFolders = folders && folders.length ? folders : null;
const defaultsByRole = {
admin: {
view: { folders: baseFolders, tables: [] },
create: { folders: baseFolders, tables: [] },
edit: { folders: baseFolders, tables: [] },
delete: { folders: baseFolders, tables: [] },
},
moderator: {
view: { folders: baseFolders, tables: [] },
create: { folders: baseFolders, tables: [] },
edit: { folders: baseFolders, tables: [] },
delete: { folders: [], tables: [] },
},
viewer: {
view: { folders: baseFolders, tables: [] },
create: { folders: [], tables: [] },
edit: { folders: [], tables: [] },
delete: { folders: [], tables: [] },
},
};
const defaults = defaultsByRole[role] || defaultsByRole.viewer;
const source = access && typeof access === 'object' ? access : {};
return {
view: normalizeScope(source.view, defaults.view.folders),
create: normalizeScope(source.create, defaults.create.folders),
edit: normalizeScope(source.edit, defaults.edit.folders),
delete: normalizeScope(source.delete, defaults.delete.folders),
};
}
function getRolePermissions(role, folders = null, access = null) {
const normalizedAccess = normalizeAccess(access, role, folders);
if (role === 'superadmin') {
return {
role,
folders: null,
access: normalizedAccess,
canCreate: true,
canEdit: true,
canDelete: true,
canViewLogs: true,
canRunSql: true,
canManageUsers: true,
canMoveTables: true,
};
}
if (role === 'admin') {
return {
role,
folders: folders && folders.length ? folders : null,
access: normalizedAccess,
canCreate: true,
canEdit: true,
canDelete: true,
canViewLogs: true,
canRunSql: true,
canManageUsers: true,
canMoveTables: true,
};
}
if (role === 'moderator') {
return {
role,
folders: folders && folders.length ? folders : null,
access: normalizedAccess,
canCreate: true,
canEdit: true,
canDelete: false,
canViewLogs: false,
canRunSql: false,
canManageUsers: false,
canMoveTables: false,
};
}
return {
role: 'viewer',
folders: folders && folders.length ? folders : null,
access: normalizedAccess,
canCreate: false,
canEdit: false,
canDelete: false,
canViewLogs: false,
canRunSql: false,
canManageUsers: false,
canMoveTables: false,
};
}
function isScopeAllowed(scope, tableName) {
if (!scope) return false;
if (scope.tables === null || scope.folders === null) return true;
const folder = getTableFolder(tableName);
return scope.tables.includes(tableName) || scope.folders.includes(folder);
}
function canAccessTable(permissionsOrRole, tableName, folders = null, access = null, action = 'view') {
const perms = typeof permissionsOrRole === 'string'
? getRolePermissions(permissionsOrRole, folders, access)
: permissionsOrRole;
return isScopeAllowed(perms.access?.[action], tableName);
}
function canAccessFolder(permissions, folder, action = 'view') {
const scope = permissions.access?.[action];
if (!scope) return false;
if (scope.folders === null) return true;
return scope.folders.includes(folder);
}
function isValidIdentifier(value) {
return SAFE_IDENTIFIER.test(value);
}
function quoteIdentifier(identifier) {
if (!isValidIdentifier(identifier)) {
throw new Error(`Unsafe identifier: ${identifier}`);
}
return `"${identifier}"`;
}
function createSessionUser({ username, role, folders, access }) {
return {
username,
role,
permissions: getRolePermissions(role, folders, access),
};
}
async function verifyPassword(user, password) {
if (!user || user.disabled) {
return false;
}
if (user.passwordHash) {
return bcrypt.compare(password, user.passwordHash);
}
return user.password === password;
}
function sanitizeUser(user) {
return {
username: user.username,
role: user.role,
folders: user.folders,
access: user.access,
disabled: user.disabled,
};
}
function validateScopeInput(scope) {
if (scope === null) {
return { folders: null, tables: null };
}
return {
folders: Array.isArray(scope?.folders) ? scope.folders.filter(Boolean) : [],
tables: Array.isArray(scope?.tables) ? scope.tables.filter(Boolean) : [],
};
}
function validateUserPayload(payload, { allowPasswordOptional = false } = {}) {
if (!payload || typeof payload.username !== 'string' || !payload.username.trim()) {
throw new Error('Username is required');
}
if (!['admin', 'moderator', 'viewer'].includes(payload.role)) {
throw new Error('Invalid role');
}
if (!allowPasswordOptional && (!payload.password || typeof payload.password !== 'string')) {
throw new Error('Password is required');
}
const folders = Array.isArray(payload.folders) ? payload.folders.filter(Boolean) : null;
const access = payload.access && typeof payload.access === 'object'
? {
view: validateScopeInput(payload.access.view),
create: validateScopeInput(payload.access.create),
edit: validateScopeInput(payload.access.edit),
delete: validateScopeInput(payload.access.delete),
}
: normalizeAccess(null, payload.role, folders);
return {
username: payload.username.trim(),
password: typeof payload.password === 'string' ? payload.password : undefined,
role: payload.role,
folders,
access,
disabled: Boolean(payload.disabled),
};
}
function writeUsersConfig(users) {
const serialized = JSON.stringify({
users: users.map((user) => {
const payload = {
username: user.username,
role: user.role,
folders: user.folders,
access: user.access,
disabled: Boolean(user.disabled),
};
if (user.passwordHash) {
payload.passwordHash = user.passwordHash;
} else if (user.password) {
payload.password = user.password;
}
return payload;
}),
}, null, 2);
fs.writeFileSync(USERS_FILE, serialized, 'utf8');
}
module.exports = {
ALLOWED_SQL_TYPES,
canAccessFolder,
canAccessTable,
createSessionUser,
getRolePermissions,
getTableFolder,
getUser,
isValidIdentifier,
normalizeAccess,
quoteIdentifier,
readUsersConfig,
sanitizeUser,
validateUserPayload,
verifyPassword,
writeUsersConfig,
};

73
src/lib/audit.js Normal file
View File

@@ -0,0 +1,73 @@
const fs = require('fs');
const path = require('path');
const AUDIT_LOG_FILE = path.join(__dirname, '..', '..', 'audit.log');
function formatAuditSummary(event, details = {}) {
const summaries = {
'login.success': 'successful login',
'login.failed': 'failed login attempt',
'logout': 'logout',
'user.created': `created user ${details.username || ''}`.trim(),
'user.updated': `updated user ${details.username || ''}`.trim(),
'user.deleted': `deleted user ${details.username || ''}`.trim(),
'backup.created': `created backup ${details.filename || ''}`.trim(),
'table.created': `created table ${details.table || ''}`.trim(),
'table.deleted': `deleted table ${details.table || ''}`.trim(),
'table.moved': `moved table ${details.from || ''} to ${details.to || ''}`.trim(),
'record.created': `created record in ${details.table || ''}`.trim(),
'record.updated': `updated record in ${details.table || ''}`.trim(),
'record.deleted': `deleted record from ${details.table || ''}`.trim(),
'sql.executed': `executed ${details.command || 'SQL'} query`,
};
return summaries[event] || event;
}
function getAuditSource(req, fallback = 'WEB') {
const header = String(req?.headers?.['x-request-source'] || '').trim().toUpperCase();
if (header === 'AI' || header === 'WEB') {
return header;
}
return fallback;
}
function appendAudit(event, actor, details = {}) {
const source = details.source || 'WEB';
const entry = {
timestamp: new Date().toISOString(),
event,
actor: actor || 'system',
source,
summary: formatAuditSummary(event, details),
details,
};
fs.appendFileSync(AUDIT_LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8');
}
function readAuditLog(limit = 200) {
if (!fs.existsSync(AUDIT_LOG_FILE)) {
return [];
}
return fs.readFileSync(AUDIT_LOG_FILE, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch (error) {
return null;
}
})
.filter(Boolean)
.slice(-limit)
.reverse();
}
module.exports = {
appendAudit,
formatAuditSummary,
getAuditSource,
readAuditLog,
};

96
src/lib/docker.js Normal file
View File

@@ -0,0 +1,96 @@
const http = require('http');
const DOCKER_SOCKET_PATH = process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock';
const DOCKER_API_PREFIX = process.env.DOCKER_API_PREFIX || '/v1.41';
function dockerRequest(requestPath, { stream = false } = {}) {
return new Promise((resolve, reject) => {
const req = http.request({
socketPath: DOCKER_SOCKET_PATH,
path: `${DOCKER_API_PREFIX}${requestPath}`,
method: 'GET',
}, (response) => {
if (stream) {
if (response.statusCode >= 400) {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => reject(new Error(Buffer.concat(chunks).toString('utf8') || 'Docker stream error')));
return;
}
resolve(response);
return;
}
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
const body = Buffer.concat(chunks);
if (response.statusCode >= 400) {
reject(new Error(body.toString('utf8') || 'Docker API error'));
return;
}
resolve(body);
});
});
req.on('error', reject);
req.end();
});
}
function demuxDockerChunk(buffer) {
let offset = 0;
let output = '';
while (offset + 8 <= buffer.length) {
const payloadLength = buffer.readUInt32BE(offset + 4);
const payloadStart = offset + 8;
const payloadEnd = payloadStart + payloadLength;
if (payloadEnd > buffer.length) {
output += buffer.slice(offset).toString('utf8');
return output;
}
output += buffer.slice(payloadStart, payloadEnd).toString('utf8');
offset = payloadEnd;
}
if (offset < buffer.length) {
output += buffer.slice(offset).toString('utf8');
}
return output;
}
async function listContainers() {
const body = await dockerRequest('/containers/json?all=1');
const containers = JSON.parse(body.toString('utf8'));
return containers.map((container) => ({
id: container.Id,
name: container.Names?.[0]?.replace(/^\//, '') || container.Id.slice(0, 12),
state: container.State,
status: container.Status,
image: container.Image,
}));
}
async function resolveContainer(nameOrId) {
const containers = await listContainers();
const container = containers.find((item) =>
item.id === nameOrId || item.id.startsWith(nameOrId) || item.name === nameOrId
);
if (!container) {
throw new Error('Container not found');
}
return container;
}
module.exports = {
demuxDockerChunk,
dockerRequest,
listContainers,
resolveContainer,
};

102
src/services/backups.js Normal file
View File

@@ -0,0 +1,102 @@
const fs = require('fs');
const path = require('path');
const BACKUPS_DIR = path.join(__dirname, '..', '..', 'backups');
const USERS_FILE = path.join(__dirname, '..', '..', 'users.json');
const AUDIT_LOG_FILE = path.join(__dirname, '..', '..', 'audit.log');
function ensureBackupsDir() {
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
}
function makeBackupFilename() {
const now = new Date().toISOString().replace(/[:.]/g, '-');
return `backup-${now}.json`;
}
async function createBackup(pool, actor = 'system') {
ensureBackupsDir();
const tablesResult = await pool.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name
`);
const tables = [];
for (const row of tablesResult.rows) {
const tableName = row.table_name;
const structure = await pool.query(`
SELECT
c.column_name AS name,
c.data_type AS type,
c.is_nullable AS nullable,
c.column_default AS default_value
FROM information_schema.columns c
WHERE c.table_name = $1 AND c.table_schema = 'public'
ORDER BY c.ordinal_position
`, [tableName]);
const data = await pool.query(`SELECT * FROM "${tableName}"`);
tables.push({
name: tableName,
structure: structure.rows,
rows: data.rows,
});
}
const backup = {
meta: {
createdAt: new Date().toISOString(),
createdBy: actor,
version: 1,
},
users: fs.existsSync(USERS_FILE) ? JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')) : { users: [] },
audit: fs.existsSync(AUDIT_LOG_FILE)
? fs.readFileSync(AUDIT_LOG_FILE, 'utf8').split(/\r?\n/).filter(Boolean)
: [],
tables,
};
const filename = makeBackupFilename();
const filePath = path.join(BACKUPS_DIR, filename);
fs.writeFileSync(filePath, JSON.stringify(backup, null, 2), 'utf8');
return {
filename,
filePath,
size: fs.statSync(filePath).size,
createdAt: backup.meta.createdAt,
};
}
function listBackups() {
ensureBackupsDir();
return fs.readdirSync(BACKUPS_DIR)
.filter((name) => name.endsWith('.json'))
.map((name) => {
const filePath = path.join(BACKUPS_DIR, name);
const stats = fs.statSync(filePath);
return {
filename: name,
size: stats.size,
createdAt: stats.birthtime.toISOString(),
};
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
function getBackupPath(filename) {
const filePath = path.join(BACKUPS_DIR, filename);
if (!filePath.startsWith(BACKUPS_DIR) || !fs.existsSync(filePath)) {
throw new Error('Backup not found');
}
return filePath;
}
module.exports = {
BACKUPS_DIR,
createBackup,
getBackupPath,
listBackups,
};

View File

@@ -0,0 +1,43 @@
async function sendTelegramMessage(text) {
const enabled = process.env.ENABLE_TELEGRAM_NOTIFICATIONS === 'true';
const token = process.env.TELEGRAM_BOT_TOKEN;
const chatId = process.env.TELEGRAM_CHAT_ID;
if (!enabled || !token || !chatId || !text) {
return { sent: false, reason: 'disabled' };
}
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'HTML',
disable_web_page_preview: true,
}),
});
if (!response.ok) {
const details = await response.text();
throw new Error(`Telegram API error: ${details}`);
}
return { sent: true };
}
async function notifyError(title, error, context = {}) {
const message = [
'<b>PG Admin error</b>',
title,
error?.message || String(error || ''),
Object.keys(context).length ? JSON.stringify(context) : '',
].filter(Boolean).join('\n');
return sendTelegramMessage(message);
}
module.exports = {
notifyError,
sendTelegramMessage,
};