diff --git a/backend/src/services/metadata.service.ts b/backend/src/services/metadata.service.ts index 0f04ec9..3b29ab6 100644 --- a/backend/src/services/metadata.service.ts +++ b/backend/src/services/metadata.service.ts @@ -80,13 +80,16 @@ export class MetadataService { }; } - async createTable(userId: string, payload: { tableName: string; columns: { name: string; type: string; nullable?: boolean }[] }) { + async createTable( + userId: string, + payload: { tableName: string; columns: { name: string; type: string; nullable?: boolean; primaryKey?: boolean }[] } + ) { await this.assertSchemaPermission(userId, payload.tableName); const columnSql = payload.columns .map( - (column: { name: string; type: string; nullable?: boolean }) => - `${quoteIdentifier(column.name)} ${column.type}${column.nullable ? "" : " NOT NULL"}` + (column: { name: string; type: string; nullable?: boolean; primaryKey?: boolean }) => + `${quoteIdentifier(column.name)} ${column.type}${column.primaryKey ? " PRIMARY KEY" : column.nullable ? "" : " NOT NULL"}` ) .join(", "); diff --git a/backend/src/validators/metadata.validators.ts b/backend/src/validators/metadata.validators.ts index 9d1a904..57322b6 100644 --- a/backend/src/validators/metadata.validators.ts +++ b/backend/src/validators/metadata.validators.ts @@ -6,7 +6,8 @@ export const createTableSchema = z.object({ z.object({ name: z.string().min(1), type: z.string().min(1), - nullable: z.boolean().optional() + nullable: z.boolean().optional(), + primaryKey: z.boolean().optional() }) ).min(1) }); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 4d9fca4..b6c7821 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -28,6 +28,8 @@ export const api = { login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }), logout: () => request("/auth/logout", { method: "POST" }), tables: () => request("/db/tables"), + createTable: (body) => request("/db/tables", { method: "POST", body: JSON.stringify(body) }), + deleteTable: (tableName) => request(`/db/tables/${tableName}`, { method: "DELETE" }), tableDetails: (tableName) => request(`/db/tables/${tableName}/details`), rows: (tableName, query = {}) => { const params = new URLSearchParams(query).toString(); diff --git a/frontend/src/components/shell.js b/frontend/src/components/shell.js index f5fcbb3..14b3a25 100644 --- a/frontend/src/components/shell.js +++ b/frontend/src/components/shell.js @@ -66,6 +66,7 @@ export function renderAppShell(state) { + ${renderCreateTableModal(state)} `; } @@ -201,6 +202,7 @@ function renderActiveTab(state, activeTableMeta) { } function renderDataTab(state) { + const filteredRows = applyColumnFilters(state.rows.rows || [], state.columnFilters); return `
@@ -214,10 +216,41 @@ function renderDataTab(state) { +
+ ${ + state.showColumnFilters + ? ` +
+
+

Фильтры по колонкам

+
+
+ ${state.tableDetails.columns + .map( + (column) => ` + + ` + ) + .join("")} +
+
+ ` + : "" + } +
@@ -254,7 +287,7 @@ function renderDataTab(state) {
-
Всего строк: ${state.rows.total}
+
Всего строк: ${state.rows.total} · После фильтров: ${filteredRows.length}
Страница ${state.pageNumber}
@@ -262,7 +295,7 @@ function renderDataTab(state) {
- ${renderRowsTable(state.rows.rows || [], { + ${renderRowsTable(filteredRows, { selectable: true, selectedRowId: state.selectedRow?.id, allowDelete: true @@ -452,6 +485,14 @@ function renderSidePanel(state, activeTableMeta) {
+
+
Schema Actions
+
+ + +
+
+
Quick SQL
@@ -484,6 +525,60 @@ function renderSidePanel(state, activeTableMeta) { `; } +function renderCreateTableModal(state) { + if (!state.isCreateTableModalOpen) { + return ""; + } + + return ` + + `; +} + function renderSortOptions(state) { const columns = state.tableDetails.columns || []; @@ -564,6 +659,20 @@ function renderRowsTable(rows, options = {}) { `; } +function applyColumnFilters(rows, columnFilters) { + if (!Object.keys(columnFilters).length) { + return rows; + } + + return rows.filter((row) => + Object.entries(columnFilters).every(([column, value]) => + String(row[column] ?? "") + .toLowerCase() + .includes(String(value).toLowerCase()) + ) + ); +} + function formatCell(value) { if (value === null || value === undefined) { return `null`; diff --git a/frontend/src/main.js b/frontend/src/main.js index 1be35b6..d29780a 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -27,7 +27,11 @@ const state = { sortDirection: "asc", pageNumber: 1, pageSize: 25, - selectedRow: null + selectedRow: null, + columnFilters: {}, + showColumnFilters: false, + isCreateTableModalOpen: false, + newTableColumns: [{ id: 1, name: "", type: "varchar(255)", nullable: true, primaryKey: false }] }; const app = document.querySelector("#app"); @@ -112,6 +116,7 @@ function bindEvents() { document.querySelector("#addColumnForm")?.addEventListener("submit", wrapAction(handleAddColumn)); document.querySelector("#alterColumnForm")?.addEventListener("submit", wrapAction(handleAlterColumn)); document.querySelector("#createIndexForm")?.addEventListener("submit", wrapAction(handleCreateIndex)); + document.querySelector("#createTableForm")?.addEventListener("submit", wrapAction(handleCreateTable)); document.querySelectorAll("[data-page]").forEach((element) => { element.addEventListener( @@ -149,6 +154,10 @@ function bindEvents() { document.querySelectorAll("[data-action]").forEach((element) => { element.addEventListener("click", wrapAction(handleAction)); }); + + document.querySelectorAll("[data-input-action='set-column-filter']").forEach((element) => { + element.addEventListener("input", wrapAction(handleAction)); + }); } function wrapAction(handler) { @@ -209,6 +218,11 @@ async function handleAction(event) { return; } + if (action === "toggle-column-filters") { + state.showColumnFilters = !state.showColumnFilters; + return; + } + if (action === "run-sql") { state.sqlDraft = document.querySelector("#sqlEditor")?.value || ""; const result = await api.executeSql(state.sqlDraft); @@ -287,6 +301,62 @@ async function handleAction(event) { const payload = await api.postgresLogs(state.logsSearch); state.postgresLogs = payload.logs; setNotice("Логи отфильтрованы"); + return; + } + + if (action === "set-column-filter") { + const column = event.currentTarget.dataset.column; + const value = event.currentTarget.value.trim(); + if (value) { + state.columnFilters[column] = value; + } else { + delete state.columnFilters[column]; + } + return; + } + + if (action === "open-create-table-modal") { + state.isCreateTableModalOpen = true; + return; + } + + if (action === "close-create-table-modal") { + state.isCreateTableModalOpen = false; + return; + } + + if (action === "add-table-column") { + state.newTableColumns.push({ + id: Date.now(), + name: "", + type: "varchar(255)", + nullable: true, + primaryKey: false + }); + return; + } + + if (action === "remove-table-column") { + const columnId = Number(event.currentTarget.dataset.columnId); + state.newTableColumns = state.newTableColumns.filter((column) => column.id !== columnId); + if (!state.newTableColumns.length) { + state.newTableColumns = [{ id: Date.now(), name: "", type: "varchar(255)", nullable: true, primaryKey: false }]; + } + return; + } + + if (action === "delete-table") { + if (!state.activeTable) { + throw new Error("Сначала выберите таблицу"); + } + + await api.deleteTable(state.activeTable); + state.activeTable = ""; + state.selectedRow = null; + state.tableDetails = { columns: [], foreignKeys: [], indexes: [] }; + state.rows = { rows: [], total: 0 }; + await hydrateDashboard(); + setNotice("Таблица удалена"); } } @@ -352,6 +422,35 @@ async function handleCreateIndex(event) { setNotice("Индекс создан"); } +async function handleCreateTable(event) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const tableName = String(formData.get("tableName") || "").trim(); + + if (!tableName) { + throw new Error("Введите название таблицы"); + } + + const columns = state.newTableColumns.map((column) => ({ + name: String(formData.get(`column-name-${column.id}`) || "").trim(), + type: String(formData.get(`column-type-${column.id}`) || "").trim(), + nullable: formData.get(`column-nullable-${column.id}`) === "on", + primaryKey: formData.get(`column-primary-${column.id}`) === "on" + })); + + if (columns.some((column) => !column.name || !column.type)) { + throw new Error("У каждой колонки должны быть имя и тип"); + } + + await api.createTable({ tableName, columns }); + state.isCreateTableModalOpen = false; + state.newTableColumns = [{ id: Date.now(), name: "", type: "varchar(255)", nullable: true, primaryKey: false }]; + await hydrateDashboard(); + state.activeTable = tableName; + await refreshTableState(); + setNotice("Таблица создана"); +} + function formToPayload(form) { const formData = new FormData(form); const payload = {}; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index c626551..a5c17a5 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -451,6 +451,16 @@ textarea { margin-bottom: 20px; } +.filter-panel { + margin-bottom: 20px; +} + +.filter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; +} + .subpanel { padding: 20px; border-radius: var(--radius-lg); @@ -473,6 +483,10 @@ textarea { gap: 10px; } +.inline-check { + white-space: nowrap; +} + .checkbox-field input { width: auto; } @@ -567,6 +581,48 @@ td { word-break: break-word; } +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 24px; + background: rgba(2, 9, 19, 0.72); + backdrop-filter: blur(8px); +} + +.modal-card { + width: min(980px, 100%); + max-height: 88vh; + overflow: auto; + padding: 24px; + border-radius: 28px; + border: 1px solid rgba(137, 181, 255, 0.16); + background: rgba(10, 21, 38, 0.98); + box-shadow: var(--shadow); +} + +.modal-header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 18px; +} + +.table-column-builder { + display: grid; + gap: 12px; +} + +.builder-row { + display: grid; + grid-template-columns: 1.3fr 1.2fr auto auto auto; + gap: 10px; + align-items: center; +} + .table-toolbar { justify-content: space-between; margin: 18px 0; @@ -614,7 +670,8 @@ button:disabled { .hero-card, .section-header, .subpanel-header, - .table-toolbar { + .table-toolbar, + .modal-header { flex-direction: column; align-items: stretch; } @@ -633,4 +690,8 @@ button:disabled { .login-form { padding: 20px; } + + .builder-row { + grid-template-columns: 1fr; + } } diff --git a/index.html b/index.html new file mode 100644 index 0000000..e2a92cf --- /dev/null +++ b/index.html @@ -0,0 +1,1452 @@ + + + + + + PostgreSQL SensoLab Panel + + + + + + + + + +
+
+
+
+ +
+

PostgreSQL SensoLab

+

Войдите для управления базой данных

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