This commit is contained in:
2026-03-19 16:07:35 +07:00
commit 39b0358b08
63 changed files with 3128 additions and 0 deletions

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package.json
COPY vite.config.js vite.config.js
RUN npm install
COPY public public
COPY src src
COPY index.html index.html
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PostgreSQL Control Center</title>
<script type="module" src="/src/main.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

15
frontend/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "@pg-control/frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {},
"devDependencies": {
"vite": "^6.0.1"
}
}

View File

@@ -0,0 +1,42 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:4000/api";
async function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
...(options.headers || {})
},
...options
});
if (response.status === 204) {
return null;
}
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "Request failed");
}
return payload;
}
export const api = {
me: () => request("/auth/me"),
login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }),
logout: () => request("/auth/logout", { method: "POST" }),
tables: () => request("/db/tables"),
tableDetails: (tableName) => request(`/db/tables/${tableName}/details`),
rows: (tableName, query = {}) => {
const params = new URLSearchParams(query).toString();
return request(`/db/tables/${tableName}/rows${params ? `?${params}` : ""}`);
},
executeSql: (sql) => request("/sql/execute", { method: "POST", body: JSON.stringify({ sql }) }),
users: () => request("/admin/users"),
roles: () => request("/admin/roles"),
audit: (search = "") => request(`/admin/audit${search ? `?search=${encodeURIComponent(search)}` : ""}`),
postgresLogs: (search = "") =>
request(`/admin/postgres-logs${search ? `?search=${encodeURIComponent(search)}` : ""}`)
};

View File

@@ -0,0 +1,311 @@
export function renderAppShell(state) {
const groups = groupTables(state.tables);
const activeTable = state.activeTable ? state.tables.find((table) => table.table_name === state.activeTable) : null;
return `
<div class="layout-shell">
<aside class="sidebar">
<div class="brand-block">
<span class="brand-kicker">PostgreSQL Control Center</span>
<h1>Database Admin</h1>
<p>Управление схемой, данными, SQL и аудитом.</p>
</div>
<div class="sidebar-section">
<div class="sidebar-caption">Группы таблиц</div>
${groups
.map(
(group) => `
<section class="sidebar-group">
<div class="group-title">${group.name}</div>
${group.tables
.map(
(table) => `
<button class="nav-table ${state.activeTable === table.table_name ? "is-active" : ""}" data-table="${table.table_name}">
<span>${table.table_name}</span>
</button>
`
)
.join("")}
</section>
`
)
.join("")}
</div>
<div class="sidebar-section">
<div class="sidebar-caption">Навигация</div>
${[
["overview", "Обзор"],
["users", "Пользователи"],
["roles", "Роли"],
["audit", "Аудит"],
["logs", "Логи PostgreSQL"]
]
.map(
([id, label]) => `
<button class="nav-link ${state.page === id ? "is-active" : ""}" data-page="${id}">${label}</button>
`
)
.join("")}
</div>
</aside>
<main class="main-panel">
<header class="topbar">
<div>
<div class="eyebrow">Сессия</div>
<div class="user-badge">${state.user.username} · ${state.user.roleCodes.join(", ")}</div>
</div>
<div class="topbar-actions">
${activeTable ? `<div class="table-chip">${activeTable.table_group} / ${activeTable.table_name}</div>` : ""}
<button id="logoutButton" class="ghost-button">Выйти</button>
</div>
</header>
<section class="content-panel">
${renderPageContent(state)}
</section>
</main>
</div>
`;
}
function groupTables(tables) {
const map = new Map();
tables.forEach((table) => {
if (!map.has(table.table_group)) {
map.set(table.table_group, {
name: table.table_group,
tables: []
});
}
map.get(table.table_group).tables.push(table);
});
return [...map.values()];
}
function renderPageContent(state) {
if (state.page === "users") {
return renderUsers(state.users);
}
if (state.page === "roles") {
return renderRoles(state.roles);
}
if (state.page === "audit") {
return renderAudit(state.auditLogs);
}
if (state.page === "logs") {
return renderLogViewer(state.postgresLogs);
}
return renderOverview(state);
}
function renderOverview(state) {
return `
<div class="hero-card">
<div>
<span class="hero-kicker">Production-oriented panel</span>
<h2>Управление PostgreSQL с RBAC, аудитом и SQL console.</h2>
</div>
<div class="hero-stats">
<div><strong>${state.tables.length}</strong><span>Таблиц</span></div>
<div><strong>${state.auditLogs.length}</strong><span>Записей аудита</span></div>
<div><strong>${state.postgresLogs.length}</strong><span>Лог-строк</span></div>
</div>
</div>
<div class="tab-strip">
${[
["data", "Данные"],
["structure", "Структура"],
["sql", "SQL Console"],
["indexes", "Индексы"]
]
.map(
([tab, label]) => `
<button class="tab-button ${state.activeTab === tab ? "is-active" : ""}" data-tab="${tab}">${label}</button>
`
)
.join("")}
</div>
<div class="workspace-grid">
<section class="workspace-card">
${renderActiveTab(state)}
</section>
<section class="workspace-card side-actions">
<h3>Быстрые действия</h3>
<button class="primary-button" data-action="refresh">Обновить данные</button>
<button class="secondary-button" data-page="audit">Открыть аудит</button>
<button class="secondary-button" data-page="logs">Открыть логи PostgreSQL</button>
</section>
</div>
`;
}
function renderActiveTab(state) {
if (!state.activeTable) {
return `<div class="empty-state">Выберите таблицу в левом меню, чтобы открыть данные и структуру.</div>`;
}
if (state.activeTab === "structure") {
const columns = state.tableDetails.columns || [];
const fks = state.tableDetails.foreignKeys || [];
return `
<h3>Структура: ${state.activeTable}</h3>
<div class="mini-grid">
<div>
<h4>Колонки</h4>
<div class="data-list">
${columns
.map(
(column) => `
<div class="data-list-item">
<strong>${column.column_name}</strong>
<span>${column.data_type}</span>
<span>${column.is_nullable === "YES" ? "nullable" : "required"}</span>
</div>
`
)
.join("")}
</div>
</div>
<div>
<h4>Foreign Keys</h4>
<div class="data-list">
${fks.length
? fks
.map(
(fk) => `
<div class="data-list-item">
<strong>${fk.column_name}</strong>
<span>${fk.foreign_table_name}.${fk.foreign_column_name}</span>
</div>
`
)
.join("")
: `<div class="empty-inline">Связи не найдены</div>`}
</div>
</div>
</div>
`;
}
if (state.activeTab === "sql") {
return `
<h3>SQL Console</h3>
<textarea id="sqlEditor" class="sql-editor" placeholder="select * from ${state.activeTable} limit 20;">${state.sqlDraft}</textarea>
<div class="form-actions">
<button class="primary-button" data-action="run-sql">Выполнить</button>
</div>
<div class="sql-result">
${state.sqlResult ? renderRowsTable(state.sqlResult.rows) : `<div class="empty-inline">Результат появится после выполнения запроса.</div>`}
</div>
`;
}
if (state.activeTab === "indexes") {
return `
<h3>Индексы и оптимизация</h3>
<p class="muted">
В backend уже подготовлены endpoints для создания индексов. Следующий шаг для production:
рекомендации по индексам, explain plans и heatmaps по slow queries.
</p>
<div class="info-panel">
<span>Текущая реализация хранит индексные операции через backend и логирует их в аудит.</span>
</div>
`;
}
return `
<div class="section-header">
<h3>Данные: ${state.activeTable}</h3>
<div class="search-box">
<input id="tableSearchInput" value="${state.search || ""}" placeholder="Поиск по всем колонкам" />
<button class="secondary-button" data-action="search">Искать</button>
</div>
</div>
${renderRowsTable(state.rows.rows || [])}
`;
}
function renderRowsTable(rows) {
if (!rows.length) {
return `<div class="empty-state">Нет данных для отображения.</div>`;
}
const columns = Object.keys(rows[0]);
return `
<div class="table-wrapper">
<table>
<thead>
<tr>${columns.map((column) => `<th>${column}</th>`).join("")}</tr>
</thead>
<tbody>
${rows
.map(
(row) => `
<tr>${columns.map((column) => `<td>${formatCell(row[column])}</td>`).join("")}</tr>
`
)
.join("")}
</tbody>
</table>
</div>
`;
}
function formatCell(value) {
if (value === null || value === undefined) {
return `<span class="cell-null">null</span>`;
}
if (typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}
function renderUsers(users) {
return `
<div class="section-header">
<h2>Управление пользователями</h2>
<p class="muted">Список пользователей и их ролей. Следующий шаг: CRUD формы и назначение ролей.</p>
</div>
${renderRowsTable(users)}
`;
}
function renderRoles(roles) {
return `
<div class="section-header">
<h2>Роли и доступы</h2>
<p class="muted">RBAC хранится в PostgreSQL и поддерживает групповые права на таблицы.</p>
</div>
${renderRowsTable(roles)}
`;
}
function renderAudit(logs) {
return `
<div class="section-header">
<h2>Аудит</h2>
<p class="muted">Логируются входы, SQL, изменения данных и схемы.</p>
</div>
${renderRowsTable(logs)}
`;
}
function renderLogViewer(logs) {
return `
<div class="section-header">
<h2>Логи PostgreSQL</h2>
<p class="muted">Чтение контейнерных логов для диагностики и мониторинга.</p>
</div>
<div class="log-viewer">
${logs.map((line) => `<div class="log-line">${line}</div>`).join("")}
</div>
`;
}

176
frontend/src/main.js Normal file
View File

@@ -0,0 +1,176 @@
import { api } from "./api/client.js";
import { renderAppShell } from "./components/shell.js";
import { renderLoginPage } from "./pages/login.js";
import "./styles/main.css";
const state = {
user: null,
error: "",
tables: [],
activeTable: "",
activeTab: "data",
page: "overview",
rows: { rows: [], total: 0 },
tableDetails: { columns: [], foreignKeys: [] },
users: [],
roles: [],
auditLogs: [],
postgresLogs: [],
sqlDraft: "",
sqlResult: null,
search: ""
};
const app = document.querySelector("#app");
bootstrap();
async function bootstrap() {
try {
const { user } = await api.me();
state.user = user;
if (user) {
await hydrateDashboard();
}
} catch {
state.user = null;
}
render();
}
async function hydrateDashboard() {
const [tablesPayload, usersPayload, rolesPayload, auditPayload, logsPayload] = await Promise.all([
api.tables(),
api.users(),
api.roles(),
api.audit(),
api.postgresLogs()
]);
state.tables = tablesPayload.tables;
state.users = usersPayload.users;
state.roles = rolesPayload.roles;
state.auditLogs = auditPayload.logs;
state.postgresLogs = logsPayload.logs;
if (!state.activeTable && state.tables[0]) {
state.activeTable = state.tables[0].table_name;
}
if (state.activeTable) {
await Promise.all([loadRows(), loadTableDetails()]);
}
}
async function loadRows() {
if (!state.activeTable) {
return;
}
state.rows = await api.rows(state.activeTable, {
page: 1,
pageSize: 25,
search: state.search
});
}
async function loadTableDetails() {
if (!state.activeTable) {
return;
}
state.tableDetails = await api.tableDetails(state.activeTable);
}
function render() {
app.innerHTML = state.user ? renderAppShell(state) : renderLoginPage(state);
bindEvents();
}
function bindEvents() {
const loginForm = document.querySelector("#loginForm");
if (loginForm) {
loginForm.addEventListener("submit", handleLogin);
}
const logoutButton = document.querySelector("#logoutButton");
if (logoutButton) {
logoutButton.addEventListener("click", handleLogout);
}
document.querySelectorAll("[data-page]").forEach((element) => {
element.addEventListener("click", async (event) => {
state.page = event.currentTarget.dataset.page;
render();
});
});
document.querySelectorAll("[data-tab]").forEach((element) => {
element.addEventListener("click", async (event) => {
state.activeTab = event.currentTarget.dataset.tab;
render();
});
});
document.querySelectorAll("[data-table]").forEach((element) => {
element.addEventListener("click", async (event) => {
state.activeTable = event.currentTarget.dataset.table;
state.page = "overview";
state.sqlDraft = `select * from ${state.activeTable} limit 20;`;
await Promise.all([loadRows(), loadTableDetails()]);
render();
});
});
document.querySelectorAll("[data-action]").forEach((element) => {
element.addEventListener("click", handleAction);
});
}
async function handleLogin(event) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
state.error = "";
try {
const payload = await api.login({
username: formData.get("username"),
password: formData.get("password")
});
state.user = payload.user;
await hydrateDashboard();
} catch (error) {
state.error = error.message;
}
render();
}
async function handleLogout() {
await api.logout();
state.user = null;
state.error = "";
render();
}
async function handleAction(event) {
const action = event.currentTarget.dataset.action;
if (action === "refresh") {
await hydrateDashboard();
}
if (action === "search") {
state.search = document.querySelector("#tableSearchInput")?.value || "";
await loadRows();
}
if (action === "run-sql") {
state.sqlDraft = document.querySelector("#sqlEditor")?.value || "";
state.sqlResult = await api.executeSql(state.sqlDraft);
}
render();
}

View File

@@ -0,0 +1,27 @@
export function renderLoginPage(state) {
return `
<div class="login-page">
<section class="login-panel">
<div class="login-copy">
<span class="hero-kicker">Secure Admin Access</span>
<h1>Production-grade PostgreSQL admin panel</h1>
<p>
Session-based auth, RBAC по группам таблиц, аудит действий, безопасный SQL console и контейнерные логи.
</p>
</div>
<form id="loginForm" class="login-form">
<label>
<span>Логин</span>
<input name="username" value="root" required />
</label>
<label>
<span>Пароль</span>
<input name="password" type="password" value="ChangeMe123!" required />
</label>
<button class="primary-button" type="submit">Войти</button>
${state.error ? `<div class="error-banner">${state.error}</div>` : ""}
</form>
</section>
</div>
`;
}

View File

@@ -0,0 +1,351 @@
:root {
--bg: #f3efe6;
--bg-strong: #e1d6c4;
--panel: rgba(255, 250, 242, 0.86);
--panel-strong: #fff8ec;
--ink: #1f2a24;
--ink-muted: #5b665e;
--accent: #0c6a5b;
--accent-strong: #12453d;
--accent-soft: #c7e3da;
--border: rgba(31, 42, 36, 0.12);
--danger: #8e3b35;
--shadow: 0 20px 50px rgba(35, 34, 28, 0.12);
--radius: 24px;
font-family: "Segoe UI", "Trebuchet MS", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(12, 106, 91, 0.18), transparent 32%),
linear-gradient(135deg, #f6f2e8, #e8efe5 58%, #e4ddd0);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
button,
input,
textarea {
font: inherit;
}
#app {
min-height: 100vh;
}
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
}
.login-panel {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 24px;
width: min(1100px, 100%);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 32px;
padding: 32px;
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.layout-shell {
display: grid;
grid-template-columns: 320px 1fr;
min-height: 100vh;
}
.sidebar {
padding: 28px;
background: rgba(25, 42, 35, 0.95);
color: #f8efe0;
}
.brand-block h1,
.hero-card h2,
.login-copy h1 {
margin: 8px 0 12px;
font-family: Georgia, "Times New Roman", serif;
line-height: 1.05;
}
.brand-kicker,
.hero-kicker,
.eyebrow,
.sidebar-caption {
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 12px;
color: var(--ink-muted);
}
.sidebar-caption,
.brand-kicker {
color: rgba(248, 239, 224, 0.72);
}
.sidebar-section {
margin-top: 28px;
}
.sidebar-group {
margin-top: 16px;
}
.group-title {
font-size: 13px;
color: rgba(248, 239, 224, 0.72);
margin-bottom: 8px;
}
.nav-table,
.nav-link,
.tab-button,
.primary-button,
.secondary-button,
.ghost-button {
border: 0;
cursor: pointer;
transition: 180ms ease;
}
.nav-table,
.nav-link {
width: 100%;
text-align: left;
color: inherit;
background: transparent;
border-radius: 14px;
padding: 11px 14px;
margin-bottom: 6px;
}
.nav-table:hover,
.nav-link:hover,
.nav-table.is-active,
.nav-link.is-active {
background: rgba(255, 248, 236, 0.12);
}
.main-panel {
padding: 24px;
}
.topbar,
.hero-card,
.workspace-card,
.login-form,
.login-copy {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.topbar {
padding: 20px 22px;
display: flex;
justify-content: space-between;
align-items: center;
}
.topbar-actions,
.form-actions,
.search-box {
display: flex;
align-items: center;
gap: 12px;
}
.content-panel {
padding-top: 22px;
}
.hero-card {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 26px;
}
.hero-stats {
display: flex;
gap: 20px;
}
.hero-stats div {
display: grid;
gap: 6px;
}
.hero-stats strong {
font-size: 32px;
font-family: Georgia, "Times New Roman", serif;
}
.tab-strip {
display: flex;
gap: 10px;
margin: 18px 0;
}
.tab-button {
padding: 10px 16px;
border-radius: 999px;
background: rgba(255, 248, 236, 0.7);
}
.tab-button.is-active,
.primary-button {
background: var(--accent);
color: white;
}
.secondary-button,
.ghost-button {
background: transparent;
border: 1px solid var(--border);
}
.workspace-grid {
display: grid;
grid-template-columns: 1.9fr 0.8fr;
gap: 20px;
}
.workspace-card,
.login-copy,
.login-form {
padding: 24px;
}
.section-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-bottom: 18px;
}
.mini-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.data-list,
.log-viewer {
display: grid;
gap: 10px;
}
.data-list-item,
.log-line,
.info-panel,
.table-chip,
.user-badge {
padding: 12px 14px;
border-radius: 16px;
background: var(--panel-strong);
border: 1px solid var(--border);
}
.data-list-item {
display: flex;
justify-content: space-between;
gap: 12px;
}
.table-wrapper {
overflow: auto;
border-radius: 18px;
border: 1px solid var(--border);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
background: rgba(12, 106, 91, 0.08);
}
.sql-editor,
.login-form input,
.search-box input {
width: 100%;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--border);
background: #fffdf8;
}
.sql-editor {
min-height: 220px;
resize: vertical;
margin-bottom: 14px;
}
.login-form {
display: grid;
gap: 18px;
}
.login-form label {
display: grid;
gap: 8px;
}
.muted,
.empty-inline,
.cell-null {
color: var(--ink-muted);
}
.empty-state {
padding: 28px;
border-radius: 20px;
background: rgba(255, 248, 236, 0.5);
border: 1px dashed var(--border);
}
.error-banner {
padding: 12px 14px;
border-radius: 14px;
background: rgba(142, 59, 53, 0.12);
color: var(--danger);
}
@media (max-width: 1100px) {
.layout-shell,
.login-panel,
.workspace-grid,
.mini-grid {
grid-template-columns: 1fr;
}
.hero-card,
.section-header,
.topbar {
flex-direction: column;
align-items: stretch;
}
}

8
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
export default defineConfig({
server: {
host: "0.0.0.0",
port: 5173
}
});