From 9dddc3f377433e737fa2b06db479c2ae495ebabc Mon Sep 17 00:00:00 2001 From: Verum Date: Thu, 19 Mar 2026 16:57:31 +0700 Subject: [PATCH] 888 --- .../src/controllers/metadata.controller.ts | 5 + .../src/repositories/metadata.repository.ts | 17 + backend/src/routes/metadata.routes.ts | 1 + backend/src/services/metadata.service.ts | 13 +- frontend/src/api/client.js | 14 +- frontend/src/components/shell.js | 651 +++++++++++++----- frontend/src/main.js | 290 +++++++- frontend/src/pages/login.js | 22 +- frontend/src/styles/main.css | 575 ++++++++++++---- 9 files changed, 1212 insertions(+), 376 deletions(-) diff --git a/backend/src/controllers/metadata.controller.ts b/backend/src/controllers/metadata.controller.ts index 6248837..7ca65e9 100644 --- a/backend/src/controllers/metadata.controller.ts +++ b/backend/src/controllers/metadata.controller.ts @@ -61,6 +61,11 @@ export class MetadataController { response.status(201).json({ success: true }); } + async dropIndex(request: Request, response: Response) { + await service.dropIndex(request.session.user!.id, request.params.tableName, request.params.indexName); + response.status(204).send(); + } + async createRow(request: Request, response: Response) { await service.createRow(request.session.user!.id, request.params.tableName, request.body); response.status(201).json({ success: true }); diff --git a/backend/src/repositories/metadata.repository.ts b/backend/src/repositories/metadata.repository.ts index 16257c0..870ee10 100644 --- a/backend/src/repositories/metadata.repository.ts +++ b/backend/src/repositories/metadata.repository.ts @@ -60,4 +60,21 @@ export class MetadataRepository { return rows; } + + async listIndexes(tableName: string) { + const { rows } = await pool.query( + ` + select + indexname as index_name, + indexdef as index_definition + from pg_indexes + where schemaname = 'public' + and tablename = $1 + order by indexname + `, + [tableName] + ); + + return rows; + } } diff --git a/backend/src/routes/metadata.routes.ts b/backend/src/routes/metadata.routes.ts index b79590a..36b2781 100644 --- a/backend/src/routes/metadata.routes.ts +++ b/backend/src/routes/metadata.routes.ts @@ -29,6 +29,7 @@ router.patch( ); router.delete("/tables/:tableName/columns/:columnName", asyncHandler(controller.dropColumn.bind(controller))); router.post("/tables/:tableName/indexes", validateBody(createIndexSchema), asyncHandler(controller.createIndex.bind(controller))); +router.delete("/tables/:tableName/indexes/:indexName", asyncHandler(controller.dropIndex.bind(controller))); router.post("/tables/:tableName/rows", validateBody(rowSchema), asyncHandler(controller.createRow.bind(controller))); router.put("/tables/:tableName/rows/:id", validateBody(rowSchema), asyncHandler(controller.updateRow.bind(controller))); router.delete("/tables/:tableName/rows/:id", asyncHandler(controller.deleteRow.bind(controller))); diff --git a/backend/src/services/metadata.service.ts b/backend/src/services/metadata.service.ts index a16b6d6..0f04ec9 100644 --- a/backend/src/services/metadata.service.ts +++ b/backend/src/services/metadata.service.ts @@ -16,12 +16,13 @@ export class MetadataService { async getTableDetails(tableName: string) { assertIdentifier(tableName, "table name"); - const [columns, foreignKeys] = await Promise.all([ + const [columns, foreignKeys, indexes] = await Promise.all([ repository.listTableColumns(tableName), - repository.listForeignKeys(tableName) + repository.listForeignKeys(tableName), + repository.listIndexes(tableName) ]); - return { columns, foreignKeys }; + return { columns, foreignKeys, indexes }; } async listRows(params: { @@ -132,6 +133,12 @@ export class MetadataService { await this.logSchema(userId, tableName, "create_index", payload); } + async dropIndex(userId: string, tableName: string, indexName: string) { + await this.assertSchemaPermission(userId, tableName); + await pool.query(`drop index if exists ${quoteIdentifier(indexName)}`); + await this.logSchema(userId, tableName, "drop_index", { indexName }); + } + async createRow(userId: string, tableName: string, data: Record) { await rbacService.assertPermission(userId, tableName, "write"); await this.mutateRow(userId, tableName, "insert", data); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 50209be..4d9fca4 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,4 +1,4 @@ -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api"; +const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "/api"; async function request(path, options = {}) { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -33,6 +33,18 @@ export const api = { const params = new URLSearchParams(query).toString(); return request(`/db/tables/${tableName}/rows${params ? `?${params}` : ""}`); }, + createRow: (tableName, body) => request(`/db/tables/${tableName}/rows`, { method: "POST", body: JSON.stringify(body) }), + updateRow: (tableName, id, body) => + request(`/db/tables/${tableName}/rows/${id}`, { method: "PUT", body: JSON.stringify(body) }), + deleteRow: (tableName, id) => request(`/db/tables/${tableName}/rows/${id}`, { method: "DELETE" }), + addColumn: (tableName, body) => + request(`/db/tables/${tableName}/columns`, { method: "POST", body: JSON.stringify(body) }), + alterColumn: (tableName, columnName, body) => + request(`/db/tables/${tableName}/columns/${columnName}`, { method: "PATCH", body: JSON.stringify(body) }), + dropColumn: (tableName, columnName) => request(`/db/tables/${tableName}/columns/${columnName}`, { method: "DELETE" }), + createIndex: (tableName, body) => + request(`/db/tables/${tableName}/indexes`, { method: "POST", body: JSON.stringify(body) }), + dropIndex: (tableName, indexName) => request(`/db/tables/${tableName}/indexes/${indexName}`, { method: "DELETE" }), executeSql: (sql) => request("/sql/execute", { method: "POST", body: JSON.stringify({ sql }) }), users: () => request("/admin/users"), roles: () => request("/admin/roles"), diff --git a/frontend/src/components/shell.js b/frontend/src/components/shell.js index d9e622a..f5fcbb3 100644 --- a/frontend/src/components/shell.js +++ b/frontend/src/components/shell.js @@ -1,75 +1,82 @@ export function renderAppShell(state) { const groups = groupTables(state.tables); - const activeTable = state.activeTable ? state.tables.find((table) => table.table_name === state.activeTable) : null; + const activeTableMeta = state.tables.find((table) => table.table_name === state.activeTable) || null; return `
+
-
Сессия
-
${state.user.username} · ${state.user.roleCodes.join(", ")}
+
Session
+
Добро пожаловать, ${state.user.username}
+
${state.user.roleCodes.join(", ")}
+
- ${activeTable ? `
${activeTable.table_group} / ${activeTable.table_name}
` : ""} - + ${activeTableMeta ? `
${activeTableMeta.table_group} / ${activeTableMeta.table_name}
` : ""} +
+
- ${renderPageContent(state)} + ${renderMainContent(state, activeTableMeta)}
`; } +function renderSidebarLink(activePage, id, label) { + return ``; +} + function groupTables(tables) { const map = new Map(); - tables.forEach((table) => { + + for (const table of tables) { if (!map.has(table.table_group)) { map.set(table.table_group, { name: table.table_group, @@ -78,104 +85,260 @@ function groupTables(tables) { } map.get(table.table_group).tables.push(table); - }); + } return [...map.values()]; } -function renderPageContent(state) { +function renderMainContent(state, activeTableMeta) { if (state.page === "users") { - return renderUsers(state.users); + return renderFlatPage("Пользователи", "Управление пользователями и их ролями.", renderRowsTable(state.users)); } if (state.page === "roles") { - return renderRoles(state.roles); + return renderFlatPage("Роли и доступы", "RBAC и права на группы таблиц.", renderRowsTable(state.roles)); } if (state.page === "audit") { - return renderAudit(state.auditLogs); + return renderFlatPage("Аудит", "Входы, SQL, изменения данных и схемы.", renderRowsTable(state.auditLogs)); } if (state.page === "logs") { - return renderLogViewer(state.postgresLogs); + return ` +
+
+
Observability
+

Логи PostgreSQL

+

Контейнерные логи для диагностики запросов и проблем старта.

+
+
+
+
+ + +
+
+ ${state.postgresLogs.map((line) => `
${escapeHtml(line)}
`).join("")} +
+
+ `; } - return renderOverview(state); + return renderOverview(state, activeTableMeta); } -function renderOverview(state) { +function renderFlatPage(title, description, content) { + return ` +
+
+
Management
+

${title}

+

${description}

+
+
+
${content}
+ `; +} + +function renderOverview(state, activeTableMeta) { return `
- Production-oriented panel -

Управление PostgreSQL с RBAC, аудитом и SQL console.

+
Production Workspace
+

Современная панель управления PostgreSQL

+

+ Синий интерфейс, живая SQL console, CRUD по данным, управление колонками и индексами, + а также аудит всех ключевых операций. +

-
${state.tables.length}Таблиц
-
${state.auditLogs.length}Записей аудита
-
${state.postgresLogs.length}Лог-строк
+
${state.tables.length}таблиц
+
${state.rows.total || 0}строк в выборке
+
${state.auditLogs.length}событий аудита
+
- ${[ - ["data", "Данные"], - ["structure", "Структура"], - ["sql", "SQL Console"], - ["indexes", "Индексы"] - ] - .map( - ([tab, label]) => ` - - ` - ) - .join("")} + ${renderTab(state.activeTab, "data", "Данные")} + ${renderTab(state.activeTab, "structure", "Структура")} + ${renderTab(state.activeTab, "sql", "SQL Console")} + ${renderTab(state.activeTab, "indexes", "Индексы")}
+
-
- ${renderActiveTab(state)} -
-
-

Быстрые действия

- - - +
+ ${renderActiveTab(state, activeTableMeta)}
+
`; } -function renderActiveTab(state) { - if (!state.activeTable) { - return `
Выберите таблицу в левом меню, чтобы открыть данные и структуру.
`; +function renderTab(activeTab, id, label) { + return ``; +} + +function renderActiveTab(state, activeTableMeta) { + if (!activeTableMeta) { + return `
Выберите таблицу слева, чтобы открыть рабочую область.
`; } if (state.activeTab === "structure") { - const columns = state.tableDetails.columns || []; - const fks = state.tableDetails.foreignKeys || []; - return ` -

Структура: ${state.activeTable}

-
-
-

Колонки

-
- ${columns - .map( - (column) => ` -
- ${column.column_name} - ${column.data_type} - ${column.is_nullable === "YES" ? "nullable" : "required"} -
- ` - ) - .join("")} -
+ return renderStructureTab(state); + } + + if (state.activeTab === "sql") { + return renderSqlTab(state); + } + + if (state.activeTab === "indexes") { + return renderIndexesTab(state); + } + + return renderDataTab(state); +} + +function renderDataTab(state) { + return ` +
+
+

Данные: ${state.activeTable}

+

Пагинация, фильтрация, сортировка и запись данных без перехода в другие экраны.

+
+
+ + + + +
+
+ +
+
+
+

Добавить запись

+ Поля строятся по колонкам таблицы
-
+
+ ${renderColumnInputs(state.tableDetails.columns, null)} +
+ +
+
+
+ +
+
+

Редактировать запись

+ Выберите строку из таблицы ниже, затем сохраните изменения +
+ ${ + state.selectedRow + ? ` +
+ ${renderColumnInputs(state.tableDetails.columns, state.selectedRow)} +
+ + +
+
+ ` + : `
Пока не выбрана запись для редактирования.
` + } +
+
+ +
+
Всего строк: ${state.rows.total}
+
+ +
Страница ${state.pageNumber}
+ +
+
+ + ${renderRowsTable(state.rows.rows || [], { + selectable: true, + selectedRowId: state.selectedRow?.id, + allowDelete: true + })} + `; +} + +function renderStructureTab(state) { + return ` +
+
+

Структура: ${state.activeTable}

+

Изменяйте колонки и отслеживайте связи без выхода из панели.

+
+
+ +
+
+
+

Добавить колонку

+
+
+ + + +
+
+
+ +
+
+

Изменить тип колонки

+
+
+ + +
+
+
+
+ +
+
+
+

Колонки

+
+
+ ${state.tableDetails.columns + .map( + (column) => ` +
+
+ ${column.column_name} +
${column.data_type} · ${column.is_nullable === "YES" ? "nullable" : "required"}
+
+ +
+ ` + ) + .join("")} +
+
+ +
+

Foreign Keys

-
- ${fks.length - ? fks +
+
+ ${ + state.tableDetails.foreignKeys.length + ? state.tableDetails.foreignKeys .map( (fk) => `
@@ -185,70 +348,215 @@ function renderActiveTab(state) { ` ) .join("") - : `
Связи не найдены
`} -
+ : `
Связи не найдены
` + }
-
- `; - } - - if (state.activeTab === "sql") { - return ` -

SQL Console

- -
- -
-
- ${state.sqlResult ? renderRowsTable(state.sqlResult.rows) : `
Результат появится после выполнения запроса.
`} -
- `; - } - - if (state.activeTab === "indexes") { - return ` -

Индексы и оптимизация

-

- В backend уже подготовлены endpoints для создания индексов. Следующий шаг для production: - рекомендации по индексам, explain plans и heatmaps по slow queries. -

-
- Текущая реализация хранит индексные операции через backend и логирует их в аудит. -
- `; - } - - return ` -
-

Данные: ${state.activeTable}

- +
- ${renderRowsTable(state.rows.rows || [])} `; } -function renderRowsTable(rows) { +function renderSqlTab(state) { + return ` +
+
+

SQL Console

+

Выполняйте запросы с учётом backend-ограничений и аудита.

+
+
+ + +
+
+ + + + ${ + state.sqlMeta + ? ` +
+
Команда: ${state.sqlMeta.command}
+
Строк: ${state.sqlMeta.rowCount ?? 0}
+
Время: ${state.sqlMeta.durationMs} ms
+
+ ` + : "" + } + +
+ ${state.sqlResult ? renderRowsTable(state.sqlResult) : `
Результат запроса появится здесь.
`} +
+ `; +} + +function renderIndexesTab(state) { + return ` +
+
+

Индексы: ${state.activeTable}

+

Создавайте и удаляйте индексы прямо из интерфейса.

+
+
+ +
+
+
+

Создать индекс

+
+
+ + + +
+
+
+ +
+
+

Текущие индексы

+
+
+ ${ + state.tableDetails.indexes.length + ? state.tableDetails.indexes + .map( + (index) => ` +
+
+ ${index.index_name} +
${escapeHtml(index.index_definition)}
+
+ +
+ ` + ) + .join("") + : `
Индексы не найдены
` + } +
+
+
+ `; +} + +function renderSidePanel(state, activeTableMeta) { + return ` +
+
+
Active Scope
+

${activeTableMeta ? activeTableMeta.table_name : "Нет выбранной таблицы"}

+

${activeTableMeta ? `Группа: ${activeTableMeta.table_group}` : "Выберите таблицу в sidebar."}

+
+ + +
+
+ +
+
Quick SQL
+
+ + + +
+
+ +
+
Status
+
+
Rows Loaded${state.rows.rows.length}
+
Columns${state.tableDetails.columns.length}
+
Indexes${state.tableDetails.indexes.length}
+
+
+ + ${ + state.notice + ? ` +
+
Notification
+
${escapeHtml(state.notice)}
+
+ ` + : "" + } +
+ `; +} + +function renderSortOptions(state) { + const columns = state.tableDetails.columns || []; + + return columns + .map( + (column) => ` + + ` + ) + .join(""); +} + +function renderColumnInputs(columns, values) { + return columns + .filter((column) => column.column_name !== "id") + .map((column) => { + const value = values?.[column.column_name]; + return ` + + `; + }) + .join(""); +} + +function renderRowsTable(rows, options = {}) { if (!rows.length) { return `
Нет данных для отображения.
`; } const columns = Object.keys(rows[0]); + return `
- ${columns.map((column) => ``).join("")} + + ${columns.map((column) => ``).join("")} + ${options.selectable ? "" : ""} + ${rows - .map( - (row) => ` - ${columns.map((column) => ``).join("")} - ` - ) + .map((row) => { + const selected = options.selectedRowId !== undefined && String(options.selectedRowId) === String(row.id); + return ` + + ${columns.map((column) => ``).join("")} + ${ + options.selectable + ? ` + + ` + : "" + } + + `; + }) .join("")}
${column}
${column}Действия
${formatCell(row[column])}
${formatCell(row[column])} + + ${ + options.allowDelete + ? `` + : "" + } +
@@ -262,50 +570,17 @@ function formatCell(value) { } if (typeof value === "object") { - return JSON.stringify(value); + return `${escapeHtml(JSON.stringify(value))}`; } - return String(value); + return escapeHtml(String(value)); } -function renderUsers(users) { - return ` -
-

Управление пользователями

-

Список пользователей и их ролей. Следующий шаг: CRUD формы и назначение ролей.

-
- ${renderRowsTable(users)} - `; -} - -function renderRoles(roles) { - return ` -
-

Роли и доступы

-

RBAC хранится в PostgreSQL и поддерживает групповые права на таблицы.

-
- ${renderRowsTable(roles)} - `; -} - -function renderAudit(logs) { - return ` -
-

Аудит

-

Логируются входы, SQL, изменения данных и схемы.

-
- ${renderRowsTable(logs)} - `; -} - -function renderLogViewer(logs) { - return ` -
-

Логи PostgreSQL

-

Чтение контейнерных логов для диагностики и мониторинга.

-
-
- ${logs.map((line) => `
${line}
`).join("")} -
- `; +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); } diff --git a/frontend/src/main.js b/frontend/src/main.js index a1ddcd5..1be35b6 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -6,19 +6,28 @@ import "./styles/main.css"; const state = { user: null, error: "", + notice: "", + noticeType: "success", tables: [], activeTable: "", activeTab: "data", page: "overview", rows: { rows: [], total: 0 }, - tableDetails: { columns: [], foreignKeys: [] }, + tableDetails: { columns: [], foreignKeys: [], indexes: [] }, users: [], roles: [], auditLogs: [], postgresLogs: [], + logsSearch: "", sqlDraft: "", sqlResult: null, - search: "" + sqlMeta: null, + search: "", + sortBy: "", + sortDirection: "asc", + pageNumber: 1, + pageSize: 25, + selectedRow: null }; const app = document.querySelector("#app"); @@ -46,7 +55,7 @@ async function hydrateDashboard() { api.users(), api.roles(), api.audit(), - api.postgresLogs() + api.postgresLogs(state.logsSearch) ]); state.tables = tablesPayload.tables; @@ -61,6 +70,7 @@ async function hydrateDashboard() { if (state.activeTable) { await Promise.all([loadRows(), loadTableDetails()]); + state.sqlDraft ||= `select * from ${state.activeTable} limit 20;`; } } @@ -70,9 +80,11 @@ async function loadRows() { } state.rows = await api.rows(state.activeTable, { - page: 1, - pageSize: 25, - search: state.search + page: state.pageNumber, + pageSize: state.pageSize, + search: state.search, + sortBy: state.sortBy, + sortDirection: state.sortDirection }); } @@ -82,6 +94,9 @@ async function loadTableDetails() { } state.tableDetails = await api.tableDetails(state.activeTable); + if (!state.sortBy && state.tableDetails.columns[0]) { + state.sortBy = state.tableDetails.columns[0].column_name; + } } function render() { @@ -90,45 +105,65 @@ function render() { } 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.querySelector("#loginForm")?.addEventListener("submit", wrapAction(handleLogin)); + document.querySelector("#logoutButton")?.addEventListener("click", wrapAction(handleLogout)); + document.querySelector("#createRowForm")?.addEventListener("submit", wrapAction(handleCreateRow)); + document.querySelector("#editRowForm")?.addEventListener("submit", wrapAction(handleEditRow)); + document.querySelector("#addColumnForm")?.addEventListener("submit", wrapAction(handleAddColumn)); + document.querySelector("#alterColumnForm")?.addEventListener("submit", wrapAction(handleAlterColumn)); + document.querySelector("#createIndexForm")?.addEventListener("submit", wrapAction(handleCreateIndex)); document.querySelectorAll("[data-page]").forEach((element) => { - element.addEventListener("click", async (event) => { - state.page = event.currentTarget.dataset.page; - render(); - }); + element.addEventListener( + "click", + wrapAction(async (event) => { + state.page = event.currentTarget.dataset.page; + }) + ); }); document.querySelectorAll("[data-tab]").forEach((element) => { - element.addEventListener("click", async (event) => { - state.activeTab = event.currentTarget.dataset.tab; - render(); - }); + element.addEventListener( + "click", + wrapAction(async (event) => { + state.activeTab = event.currentTarget.dataset.tab; + }) + ); }); 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(); - }); + element.addEventListener( + "click", + wrapAction(async (event) => { + state.activeTable = event.currentTarget.dataset.table; + state.page = "overview"; + state.activeTab = "data"; + state.pageNumber = 1; + state.selectedRow = null; + state.sqlDraft = `select * from ${state.activeTable} limit 20;`; + await Promise.all([loadTableDetails(), loadRows()]); + }) + ); }); document.querySelectorAll("[data-action]").forEach((element) => { - element.addEventListener("click", handleAction); + element.addEventListener("click", wrapAction(handleAction)); }); } +function wrapAction(handler) { + return async (event) => { + try { + await handler(event); + render(); + } catch (error) { + state.notice = error.message || "Unexpected error"; + state.noticeType = "error"; + render(); + } + }; +} + async function handleLogin(event) { event.preventDefault(); const formData = new FormData(event.currentTarget); @@ -139,20 +174,20 @@ async function handleLogin(event) { username: formData.get("username"), password: formData.get("password") }); + state.user = payload.user; + state.notice = "Сессия успешно открыта"; + state.noticeType = "success"; 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) { @@ -160,17 +195,200 @@ async function handleAction(event) { if (action === "refresh") { await hydrateDashboard(); + setNotice("Данные обновлены"); + return; } if (action === "search") { state.search = document.querySelector("#tableSearchInput")?.value || ""; + state.sortBy = document.querySelector("#sortBySelect")?.value || state.sortBy; + state.sortDirection = document.querySelector("#sortDirectionSelect")?.value || "asc"; + state.pageNumber = 1; await loadRows(); + setNotice("Фильтр применён"); + return; } if (action === "run-sql") { state.sqlDraft = document.querySelector("#sqlEditor")?.value || ""; - state.sqlResult = await api.executeSql(state.sqlDraft); + const result = await api.executeSql(state.sqlDraft); + state.sqlResult = result.rows; + state.sqlMeta = { + command: result.command, + rowCount: result.rowCount, + durationMs: result.durationMs + }; + setNotice("SQL-запрос выполнен"); + return; } - render(); + if (action === "fill-select-sql") { + state.sqlDraft = `select * from ${state.activeTable} limit 20;`; + return; + } + + if (action === "insert-sql-template") { + state.activeTab = "sql"; + state.sqlDraft = event.currentTarget.dataset.sqlTemplate || state.sqlDraft; + return; + } + + if (action === "select-row") { + const rowId = event.currentTarget.dataset.rowId; + state.selectedRow = state.rows.rows.find((row) => String(row.id) === String(rowId)) || null; + setNotice("Запись выбрана для редактирования"); + return; + } + + if (action === "clear-row-selection") { + state.selectedRow = null; + return; + } + + if (action === "delete-row") { + const rowId = event.currentTarget.dataset.rowId; + await api.deleteRow(state.activeTable, rowId); + state.selectedRow = null; + await refreshTableState(); + setNotice("Запись удалена"); + return; + } + + if (action === "drop-column") { + const columnName = event.currentTarget.dataset.columnName; + await api.dropColumn(state.activeTable, columnName); + await refreshTableState(); + setNotice(`Колонка ${columnName} удалена`); + return; + } + + if (action === "drop-index") { + const indexName = event.currentTarget.dataset.indexName; + await api.dropIndex(state.activeTable, indexName); + await refreshTableState(); + setNotice(`Индекс ${indexName} удалён`); + return; + } + + if (action === "prev-page") { + state.pageNumber = Math.max(1, state.pageNumber - 1); + await loadRows(); + return; + } + + if (action === "next-page") { + state.pageNumber += 1; + await loadRows(); + return; + } + + if (action === "filter-logs") { + state.logsSearch = document.querySelector("#logsSearchInput")?.value || ""; + const payload = await api.postgresLogs(state.logsSearch); + state.postgresLogs = payload.logs; + setNotice("Логи отфильтрованы"); + } +} + +async function handleCreateRow(event) { + event.preventDefault(); + const payload = formToPayload(event.currentTarget); + await api.createRow(state.activeTable, payload); + event.currentTarget.reset(); + await refreshTableState(); + setNotice("Новая запись добавлена"); +} + +async function handleEditRow(event) { + event.preventDefault(); + if (!state.selectedRow?.id) { + throw new Error("Не выбрана строка для редактирования"); + } + + const payload = formToPayload(event.currentTarget); + await api.updateRow(state.activeTable, state.selectedRow.id, payload); + state.selectedRow = null; + await refreshTableState(); + setNotice("Изменения сохранены"); +} + +async function handleAddColumn(event) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + await api.addColumn(state.activeTable, { + name: formData.get("name"), + type: formData.get("type"), + nullable: formData.get("nullable") === "on" + }); + event.currentTarget.reset(); + await refreshTableState(); + setNotice("Колонка добавлена"); +} + +async function handleAlterColumn(event) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + await api.alterColumn(state.activeTable, formData.get("columnName"), { + dataType: formData.get("dataType") + }); + event.currentTarget.reset(); + await refreshTableState(); + setNotice("Тип колонки изменён"); +} + +async function handleCreateIndex(event) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + await api.createIndex(state.activeTable, { + indexName: formData.get("indexName"), + columns: String(formData.get("columns")) + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + unique: formData.get("unique") === "on" + }); + event.currentTarget.reset(); + await refreshTableState(); + setNotice("Индекс создан"); +} + +function formToPayload(form) { + const formData = new FormData(form); + const payload = {}; + + for (const [key, rawValue] of formData.entries()) { + const value = String(rawValue).trim(); + if (value === "") { + continue; + } + + if (value === "true" || value === "false") { + payload[key] = value === "true"; + continue; + } + + if (!Number.isNaN(Number(value)) && value !== "") { + payload[key] = Number(value); + continue; + } + + payload[key] = value; + } + + return payload; +} + +async function refreshTableState() { + await Promise.all([loadRows(), loadTableDetails(), refreshAdminViews()]); +} + +async function refreshAdminViews() { + const [auditPayload, logsPayload] = await Promise.all([api.audit(), api.postgresLogs(state.logsSearch)]); + state.auditLogs = auditPayload.logs; + state.postgresLogs = logsPayload.logs; +} + +function setNotice(message, type = "success") { + state.notice = message; + state.noticeType = type; } diff --git a/frontend/src/pages/login.js b/frontend/src/pages/login.js index 6649d1e..79a09f2 100644 --- a/frontend/src/pages/login.js +++ b/frontend/src/pages/login.js @@ -1,24 +1,40 @@ export function renderLoginPage(state) { return `