This commit is contained in:
2026-03-19 16:57:31 +07:00
parent 7eddfb28b0
commit 9dddc3f377
9 changed files with 1212 additions and 376 deletions

View File

@@ -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 });

View File

@@ -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;
}
}

View File

@@ -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)));

View File

@@ -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<string, unknown>) {
await rbacService.assertPermission(userId, tableName, "write");
await this.mutateRow(userId, tableName, "insert", data);

View File

@@ -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"),

View File

@@ -1,17 +1,21 @@
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 `
<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 class="brand-mark">PG</div>
<div>
<div class="brand-kicker">PostgreSQL Control Center</div>
<h1>Blue Console</h1>
<p>Управление данными, схемой, SQL и аудитом в одном интерфейсе.</p>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-caption">Группы таблиц</div>
<div class="sidebar-caption">Table Groups</div>
${groups
.map(
(group) => `
@@ -21,7 +25,8 @@ export function renderAppShell(state) {
.map(
(table) => `
<button class="nav-table ${state.activeTable === table.table_name ? "is-active" : ""}" data-table="${table.table_name}">
<span>${table.table_name}</span>
<span class="nav-table-name">${table.table_name}</span>
<span class="nav-table-meta">${group.name}</span>
</button>
`
)
@@ -31,45 +36,47 @@ export function renderAppShell(state) {
)
.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 class="sidebar-caption">Workspace</div>
${renderSidebarLink(state.page, "overview", "Обзор")}
${renderSidebarLink(state.page, "users", "Пользователи")}
${renderSidebarLink(state.page, "roles", "Роли")}
${renderSidebarLink(state.page, "audit", "Аудит")}
${renderSidebarLink(state.page, "logs", "Логи PostgreSQL")}
</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 class="eyebrow">Session</div>
<div class="topbar-title">Добро пожаловать, ${state.user.username}</div>
<div class="topbar-subtitle">${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>
${activeTableMeta ? `<div class="table-chip">${activeTableMeta.table_group} / ${activeTableMeta.table_name}</div>` : ""}
<button class="ghost-button" id="logoutButton">Выйти</button>
</div>
</header>
<section class="content-panel">
${renderPageContent(state)}
${renderMainContent(state, activeTableMeta)}
</section>
</main>
</div>
`;
}
function renderSidebarLink(activePage, id, label) {
return `<button class="nav-link ${activePage === id ? "is-active" : ""}" data-page="${id}">${label}</button>`;
}
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 `
<div class="hero-card compact">
<div>
<div class="hero-kicker">Observability</div>
<h2>Логи PostgreSQL</h2>
<p>Контейнерные логи для диагностики запросов и проблем старта.</p>
</div>
</div>
<section class="workspace-card">
<div class="toolbar">
<input id="logsSearchInput" value="${escapeHtml(state.logsSearch)}" placeholder="Фильтр по логам" />
<button class="secondary-button" data-action="filter-logs">Фильтровать</button>
</div>
<div class="log-viewer">
${state.postgresLogs.map((line) => `<div class="log-line">${escapeHtml(line)}</div>`).join("")}
</div>
</section>
`;
}
return renderOverview(state);
return renderOverview(state, activeTableMeta);
}
function renderOverview(state) {
function renderFlatPage(title, description, content) {
return `
<div class="hero-card compact">
<div>
<div class="hero-kicker">Management</div>
<h2>${title}</h2>
<p>${description}</p>
</div>
</div>
<section class="workspace-card">${content}</section>
`;
}
function renderOverview(state, activeTableMeta) {
return `
<div class="hero-card">
<div>
<span class="hero-kicker">Production-oriented panel</span>
<h2>Управление PostgreSQL с RBAC, аудитом и SQL console.</h2>
<div class="hero-kicker">Production Workspace</div>
<h2>Современная панель управления PostgreSQL</h2>
<p>
Синий интерфейс, живая SQL console, CRUD по данным, управление колонками и индексами,
а также аудит всех ключевых операций.
</p>
</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><strong>${state.tables.length}</strong><span>таблиц</span></div>
<div><strong>${state.rows.total || 0}</strong><span>строк в выборке</span></div>
<div><strong>${state.auditLogs.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("")}
${renderTab(state.activeTab, "data", "Данные")}
${renderTab(state.activeTab, "structure", "Структура")}
${renderTab(state.activeTab, "sql", "SQL Console")}
${renderTab(state.activeTab, "indexes", "Индексы")}
</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 class="workspace-card workspace-main">
${renderActiveTab(state, activeTableMeta)}
</section>
<aside class="workspace-card workspace-side">
${renderSidePanel(state, activeTableMeta)}
</aside>
</div>
`;
}
function renderActiveTab(state) {
if (!state.activeTable) {
return `<div class="empty-state">Выберите таблицу в левом меню, чтобы открыть данные и структуру.</div>`;
function renderTab(activeTab, id, label) {
return `<button class="tab-button ${activeTab === id ? "is-active" : ""}" data-tab="${id}">${label}</button>`;
}
function renderActiveTab(state, activeTableMeta) {
if (!activeTableMeta) {
return `<div class="empty-state">Выберите таблицу слева, чтобы открыть рабочую область.</div>`;
}
if (state.activeTab === "structure") {
const columns = state.tableDetails.columns || [];
const fks = state.tableDetails.foreignKeys || [];
return renderStructureTab(state);
}
if (state.activeTab === "sql") {
return renderSqlTab(state);
}
if (state.activeTab === "indexes") {
return renderIndexesTab(state);
}
return renderDataTab(state);
}
function renderDataTab(state) {
return `
<h3>Структура: ${state.activeTable}</h3>
<div class="mini-grid">
<div class="section-header">
<div>
<h3>Данные: ${state.activeTable}</h3>
<p class="muted">Пагинация, фильтрация, сортировка и запись данных без перехода в другие экраны.</p>
</div>
<div class="toolbar">
<input id="tableSearchInput" value="${escapeHtml(state.search)}" placeholder="Поиск по всем колонкам" />
<select id="sortBySelect">${renderSortOptions(state)}</select>
<select id="sortDirectionSelect">
<option value="asc" ${state.sortDirection === "asc" ? "selected" : ""}>ASC</option>
<option value="desc" ${state.sortDirection === "desc" ? "selected" : ""}>DESC</option>
</select>
<button class="secondary-button" data-action="search">Применить</button>
</div>
</div>
<div class="panel-grid">
<section class="subpanel">
<div class="subpanel-header">
<h4>Добавить запись</h4>
<span class="muted">Поля строятся по колонкам таблицы</span>
</div>
<form id="createRowForm" class="form-grid">
${renderColumnInputs(state.tableDetails.columns, null)}
<div class="form-actions">
<button class="primary-button" type="submit">Добавить запись</button>
</div>
</form>
</section>
<section class="subpanel">
<div class="subpanel-header">
<h4>Редактировать запись</h4>
<span class="muted">Выберите строку из таблицы ниже, затем сохраните изменения</span>
</div>
${
state.selectedRow
? `
<form id="editRowForm" class="form-grid">
${renderColumnInputs(state.tableDetails.columns, state.selectedRow)}
<div class="form-actions split">
<button class="secondary-button" type="button" data-action="clear-row-selection">Сбросить</button>
<button class="primary-button" type="submit">Сохранить изменения</button>
</div>
</form>
`
: `<div class="empty-inline">Пока не выбрана запись для редактирования.</div>`
}
</section>
</div>
<div class="table-toolbar">
<div class="muted">Всего строк: ${state.rows.total}</div>
<div class="toolbar">
<button class="secondary-button" data-action="prev-page" ${state.pageNumber <= 1 ? "disabled" : ""}>Назад</button>
<div class="page-pill">Страница ${state.pageNumber}</div>
<button class="secondary-button" data-action="next-page" ${state.rows.rows.length < state.pageSize ? "disabled" : ""}>Вперёд</button>
</div>
</div>
${renderRowsTable(state.rows.rows || [], {
selectable: true,
selectedRowId: state.selectedRow?.id,
allowDelete: true
})}
`;
}
function renderStructureTab(state) {
return `
<div class="section-header">
<div>
<h3>Структура: ${state.activeTable}</h3>
<p class="muted">Изменяйте колонки и отслеживайте связи без выхода из панели.</p>
</div>
</div>
<div class="panel-grid">
<section class="subpanel">
<div class="subpanel-header">
<h4>Добавить колонку</h4>
</div>
<form id="addColumnForm" class="form-grid compact-form">
<label><span>Имя</span><input name="name" required /></label>
<label><span>Тип</span><input name="type" placeholder="varchar(255)" required /></label>
<label class="checkbox-field"><input name="nullable" type="checkbox" /><span>Nullable</span></label>
<div class="form-actions"><button class="primary-button" type="submit">Добавить</button></div>
</form>
</section>
<section class="subpanel">
<div class="subpanel-header">
<h4>Изменить тип колонки</h4>
</div>
<form id="alterColumnForm" class="form-grid compact-form">
<label>
<span>Колонка</span>
<select name="columnName">${state.tableDetails.columns
.map((column) => `<option value="${column.column_name}">${column.column_name}</option>`)
.join("")}</select>
</label>
<label><span>Новый тип</span><input name="dataType" placeholder="text" required /></label>
<div class="form-actions"><button class="secondary-button" type="submit">Изменить тип</button></div>
</form>
</section>
</div>
<div class="panel-grid">
<section class="subpanel">
<div class="subpanel-header">
<h4>Колонки</h4>
</div>
<div class="data-list">
${columns
${state.tableDetails.columns
.map(
(column) => `
<div class="data-list-item">
<div class="data-list-item wide">
<div>
<strong>${column.column_name}</strong>
<span>${column.data_type}</span>
<span>${column.is_nullable === "YES" ? "nullable" : "required"}</span>
<div class="muted">${column.data_type} · ${column.is_nullable === "YES" ? "nullable" : "required"}</div>
</div>
<button class="danger-button" data-action="drop-column" data-column-name="${column.column_name}">Удалить</button>
</div>
`
)
.join("")}
</div>
</div>
<div>
</section>
<section class="subpanel">
<div class="subpanel-header">
<h4>Foreign Keys</h4>
</div>
<div class="data-list">
${fks.length
? fks
${
state.tableDetails.foreignKeys.length
? state.tableDetails.foreignKeys
.map(
(fk) => `
<div class="data-list-item">
@@ -185,70 +348,215 @@ function renderActiveTab(state) {
`
)
.join("")
: `<div class="empty-inline">Связи не найдены</div>`}
</div>
: `<div class="empty-inline">Связи не найдены</div>`
}
</div>
</section>
</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>
`;
}
function renderSqlTab(state) {
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>
<h3>SQL Console</h3>
<p class="muted">Выполняйте запросы с учётом backend-ограничений и аудита.</p>
</div>
<div class="toolbar">
<button class="secondary-button" data-action="fill-select-sql">Заполнить SELECT</button>
<button class="primary-button" data-action="run-sql">Выполнить</button>
</div>
</div>
${renderRowsTable(state.rows.rows || [])}
<textarea id="sqlEditor" class="sql-editor" placeholder="select * from ${state.activeTable} limit 20;">${escapeHtml(state.sqlDraft)}</textarea>
${
state.sqlMeta
? `
<div class="sql-meta">
<div class="meta-pill">Команда: ${state.sqlMeta.command}</div>
<div class="meta-pill">Строк: ${state.sqlMeta.rowCount ?? 0}</div>
<div class="meta-pill">Время: ${state.sqlMeta.durationMs} ms</div>
</div>
`
: ""
}
<div class="subpanel">
${state.sqlResult ? renderRowsTable(state.sqlResult) : `<div class="empty-inline">Результат запроса появится здесь.</div>`}
</div>
`;
}
function renderRowsTable(rows) {
function renderIndexesTab(state) {
return `
<div class="section-header">
<div>
<h3>Индексы: ${state.activeTable}</h3>
<p class="muted">Создавайте и удаляйте индексы прямо из интерфейса.</p>
</div>
</div>
<div class="panel-grid">
<section class="subpanel">
<div class="subpanel-header">
<h4>Создать индекс</h4>
</div>
<form id="createIndexForm" class="form-grid compact-form">
<label><span>Имя индекса</span><input name="indexName" required /></label>
<label><span>Колонки через запятую</span><input name="columns" placeholder="email, status" required /></label>
<label class="checkbox-field"><input name="unique" type="checkbox" /><span>Unique</span></label>
<div class="form-actions"><button class="primary-button" type="submit">Создать индекс</button></div>
</form>
</section>
<section class="subpanel">
<div class="subpanel-header">
<h4>Текущие индексы</h4>
</div>
<div class="data-list">
${
state.tableDetails.indexes.length
? state.tableDetails.indexes
.map(
(index) => `
<div class="data-list-item wide stacked">
<div>
<strong>${index.index_name}</strong>
<div class="muted code-snippet">${escapeHtml(index.index_definition)}</div>
</div>
<button class="danger-button" data-action="drop-index" data-index-name="${index.index_name}">Удалить</button>
</div>
`
)
.join("")
: `<div class="empty-inline">Индексы не найдены</div>`
}
</div>
</section>
</div>
`;
}
function renderSidePanel(state, activeTableMeta) {
return `
<div class="side-stack">
<section class="side-card focus-card">
<div class="eyebrow">Active Scope</div>
<h3>${activeTableMeta ? activeTableMeta.table_name : "Нет выбранной таблицы"}</h3>
<p>${activeTableMeta ? `Группа: ${activeTableMeta.table_group}` : "Выберите таблицу в sidebar."}</p>
<div class="side-actions">
<button class="primary-button" data-action="refresh">Обновить</button>
<button class="secondary-button" data-page="audit">Аудит</button>
</div>
</section>
<section class="side-card">
<div class="eyebrow">Quick SQL</div>
<div class="quick-sql-list">
<button class="secondary-button" data-action="insert-sql-template" data-sql-template="select * from ${state.activeTable} limit 20;">SELECT *</button>
<button class="secondary-button" data-action="insert-sql-template" data-sql-template="select count(*) from ${state.activeTable};">COUNT</button>
<button class="secondary-button" data-action="insert-sql-template" data-sql-template="select * from ${state.activeTable} order by id desc limit 20;">Latest Rows</button>
</div>
</section>
<section class="side-card">
<div class="eyebrow">Status</div>
<div class="status-list">
<div class="status-item"><span>Rows Loaded</span><strong>${state.rows.rows.length}</strong></div>
<div class="status-item"><span>Columns</span><strong>${state.tableDetails.columns.length}</strong></div>
<div class="status-item"><span>Indexes</span><strong>${state.tableDetails.indexes.length}</strong></div>
</div>
</section>
${
state.notice
? `
<section class="side-card notice-card ${state.noticeType === "error" ? "is-error" : "is-success"}">
<div class="eyebrow">Notification</div>
<div>${escapeHtml(state.notice)}</div>
</section>
`
: ""
}
</div>
`;
}
function renderSortOptions(state) {
const columns = state.tableDetails.columns || [];
return columns
.map(
(column) => `
<option value="${column.column_name}" ${state.sortBy === column.column_name ? "selected" : ""}>
${column.column_name}
</option>
`
)
.join("");
}
function renderColumnInputs(columns, values) {
return columns
.filter((column) => column.column_name !== "id")
.map((column) => {
const value = values?.[column.column_name];
return `
<label>
<span>${column.column_name}</span>
<input
name="${column.column_name}"
value="${value === undefined || value === null ? "" : escapeHtml(String(value))}"
placeholder="${column.data_type}"
/>
</label>
`;
})
.join("");
}
function renderRowsTable(rows, options = {}) {
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>
<tr>
${columns.map((column) => `<th>${column}</th>`).join("")}
${options.selectable ? "<th>Действия</th>" : ""}
</tr>
</thead>
<tbody>
${rows
.map(
(row) => `
<tr>${columns.map((column) => `<td>${formatCell(row[column])}</td>`).join("")}</tr>
.map((row) => {
const selected = options.selectedRowId !== undefined && String(options.selectedRowId) === String(row.id);
return `
<tr class="${selected ? "row-selected" : ""}">
${columns.map((column) => `<td>${formatCell(row[column])}</td>`).join("")}
${
options.selectable
? `
<td class="row-actions">
<button class="secondary-button small-button" data-action="select-row" data-row-id="${row.id}">Редактировать</button>
${
options.allowDelete
? `<button class="danger-button small-button" data-action="delete-row" data-row-id="${row.id}">Удалить</button>`
: ""
}
</td>
`
)
: ""
}
</tr>
`;
})
.join("")}
</tbody>
</table>
@@ -262,50 +570,17 @@ function formatCell(value) {
}
if (typeof value === "object") {
return JSON.stringify(value);
return `<span class="code-snippet">${escapeHtml(JSON.stringify(value))}</span>`;
}
return String(value);
return escapeHtml(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>
`;
function escapeHtml(value) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

View File

@@ -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) => {
element.addEventListener(
"click",
wrapAction(async (event) => {
state.page = event.currentTarget.dataset.page;
render();
});
})
);
});
document.querySelectorAll("[data-tab]").forEach((element) => {
element.addEventListener("click", async (event) => {
element.addEventListener(
"click",
wrapAction(async (event) => {
state.activeTab = event.currentTarget.dataset.tab;
render();
});
})
);
});
document.querySelectorAll("[data-table]").forEach((element) => {
element.addEventListener("click", async (event) => {
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([loadRows(), loadTableDetails()]);
render();
});
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;
}

View File

@@ -1,24 +1,40 @@
export function renderLoginPage(state) {
return `
<div class="login-page">
<div class="login-backdrop"></div>
<section class="login-panel">
<div class="login-copy">
<span class="hero-kicker">Secure Admin Access</span>
<div class="hero-kicker">Secure Admin Access</div>
<h1>Production-grade PostgreSQL admin panel</h1>
<p>
Session-based auth, RBAC по группам таблиц, аудит действий, безопасный SQL console и контейнерные логи.
Бело-сине-черный интерфейс, RBAC по группам таблиц, аудит действий,
безопасная SQL console и рабочее управление схемой и данными.
</p>
<div class="login-features">
<div class="feature-pill">RBAC & Audit</div>
<div class="feature-pill">SQL Workspace</div>
<div class="feature-pill">Schema & Indexes</div>
</div>
</div>
<form id="loginForm" class="login-form">
<div class="form-title">
<h2>Вход в консоль</h2>
<p>Используй root-аккаунт или назначенного администратора группы.</p>
</div>
<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>
<button class="primary-button login-button" type="submit">Войти</button>
${state.error ? `<div class="error-banner">${state.error}</div>` : ""}
</form>
</section>

View File

@@ -1,61 +1,186 @@
: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);
--bg: #07111f;
--bg-2: #0d1b2f;
--bg-soft: #122742;
--panel: rgba(11, 23, 41, 0.88);
--panel-2: rgba(16, 34, 59, 0.94);
--panel-light: rgba(240, 247, 255, 0.06);
--surface: #ffffff;
--surface-2: #eaf3ff;
--ink: #04101d;
--ink-soft: #4d6786;
--ink-invert: #f5f9ff;
--blue: #2f7df6;
--blue-2: #5aa2ff;
--blue-3: #123764;
--border: rgba(120, 170, 255, 0.2);
--danger: #ff6b7a;
--success: #4fd1a8;
--shadow: 0 30px 80px rgba(1, 10, 26, 0.45);
--radius-xl: 28px;
--radius-lg: 22px;
--radius-md: 16px;
--radius-sm: 12px;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
}
* {
box-sizing: border-box;
}
body {
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(47, 125, 246, 0.26), transparent 22%),
radial-gradient(circle at top right, rgba(90, 162, 255, 0.14), transparent 18%),
linear-gradient(160deg, #020813 0%, #07111f 30%, #0c1b31 100%);
color: var(--ink-invert);
}
button,
input,
select,
textarea {
font: inherit;
}
#app {
min-height: 100vh;
button {
border: 0;
cursor: pointer;
}
input,
select,
textarea {
width: 100%;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.96);
color: var(--ink);
padding: 12px 14px;
}
textarea {
resize: vertical;
}
.login-page {
position: relative;
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
}
.login-backdrop {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 20%, rgba(47, 125, 246, 0.25), transparent 24%),
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.1), transparent 20%),
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent);
pointer-events: none;
}
.login-panel {
position: relative;
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;
grid-template-columns: 1.15fr 0.85fr;
gap: 28px;
width: min(1180px, 100%);
padding: 28px;
border-radius: 36px;
border: 1px solid rgba(137, 181, 255, 0.16);
background: rgba(8, 17, 30, 0.76);
backdrop-filter: blur(24px);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.login-copy,
.login-form,
.topbar,
.hero-card,
.workspace-card,
.side-card {
border-radius: var(--radius-xl);
border: 1px solid rgba(137, 181, 255, 0.14);
box-shadow: var(--shadow);
}
.login-copy {
padding: 34px;
background:
linear-gradient(145deg, rgba(46, 104, 206, 0.28), rgba(255, 255, 255, 0.03)),
rgba(11, 25, 44, 0.92);
}
.login-copy h1,
.brand-block h1,
.hero-card h2,
.form-title h2,
.topbar-title {
margin: 10px 0 14px;
font-family: "Georgia", "Times New Roman", serif;
}
.hero-kicker,
.brand-kicker,
.eyebrow,
.sidebar-caption {
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 11px;
color: #90bfff;
}
.login-features {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 28px;
}
.feature-pill,
.page-pill,
.meta-pill {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 999px;
border: 1px solid rgba(137, 181, 255, 0.22);
background: rgba(255, 255, 255, 0.06);
color: var(--ink-invert);
padding: 8px 12px;
}
.login-form {
padding: 30px;
background: rgba(248, 251, 255, 0.98);
color: var(--ink);
display: grid;
gap: 18px;
}
.form-title p,
.muted {
color: var(--ink-soft);
}
.login-form label,
.form-grid label {
display: grid;
gap: 8px;
}
.login-button {
width: 100%;
}
.layout-shell {
@@ -65,46 +190,44 @@ textarea {
}
.sidebar {
padding: 28px;
background: rgba(25, 42, 35, 0.95);
color: #f8efe0;
padding: 26px;
background:
linear-gradient(180deg, rgba(8, 18, 32, 0.98), rgba(10, 20, 36, 0.98)),
var(--bg);
border-right: 1px solid rgba(137, 181, 255, 0.12);
}
.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-block {
display: grid;
grid-template-columns: 60px 1fr;
gap: 16px;
align-items: start;
}
.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);
.brand-mark {
width: 60px;
height: 60px;
display: grid;
place-items: center;
border-radius: 18px;
background: linear-gradient(145deg, #2f7df6, #0f3f87);
color: white;
font-weight: 700;
font-size: 22px;
}
.sidebar-section {
margin-top: 28px;
}
.sidebar-group {
margin-top: 16px;
.sidebar-group + .sidebar-group {
margin-top: 18px;
}
.group-title {
font-size: 13px;
color: rgba(248, 239, 224, 0.72);
margin-bottom: 8px;
color: #8eb7f7;
font-size: 13px;
}
.nav-table,
@@ -112,9 +235,8 @@ textarea {
.tab-button,
.primary-button,
.secondary-button,
.ghost-button {
border: 0;
cursor: pointer;
.ghost-button,
.danger-button {
transition: 180ms ease;
}
@@ -122,64 +244,91 @@ textarea {
.nav-link {
width: 100%;
text-align: left;
color: inherit;
border-radius: 16px;
color: var(--ink-invert);
background: transparent;
border-radius: 14px;
padding: 11px 14px;
padding: 12px 14px;
margin-bottom: 6px;
}
.nav-table {
display: flex;
justify-content: space-between;
gap: 10px;
}
.nav-table-meta {
color: #7fa8e2;
font-size: 12px;
}
.nav-table:hover,
.nav-link:hover,
.nav-table.is-active,
.nav-link.is-active {
background: rgba(255, 248, 236, 0.12);
background: rgba(47, 125, 246, 0.18);
}
.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);
padding: 22px;
}
.topbar {
padding: 20px 22px;
padding: 22px 24px;
background: rgba(9, 19, 33, 0.82);
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
}
.topbar-title {
font-size: 28px;
}
.topbar-subtitle {
color: #8bb7fb;
}
.topbar-actions,
.toolbar,
.form-actions,
.search-box {
.side-actions,
.table-toolbar {
display: flex;
align-items: center;
gap: 12px;
}
.content-panel {
padding-top: 22px;
padding-top: 20px;
}
.hero-card,
.workspace-card,
.side-card {
background: rgba(10, 21, 38, 0.84);
}
.hero-card {
padding: 28px;
display: flex;
justify-content: space-between;
gap: 20px;
padding: 26px;
gap: 24px;
}
.hero-card.compact {
margin-bottom: 18px;
}
.hero-card p {
max-width: 760px;
color: #bed4f8;
}
.hero-stats {
display: flex;
gap: 20px;
gap: 18px;
align-items: flex-start;
}
.hero-stats div {
@@ -188,58 +337,148 @@ textarea {
}
.hero-stats strong {
font-size: 32px;
font-family: Georgia, "Times New Roman", serif;
font-size: 34px;
color: #ffffff;
}
.tab-strip {
display: flex;
gap: 10px;
margin: 18px 0;
flex-wrap: wrap;
}
.tab-button,
.primary-button,
.secondary-button,
.ghost-button,
.danger-button {
border-radius: 999px;
padding: 11px 16px;
}
.tab-button {
padding: 10px 16px;
border-radius: 999px;
background: rgba(255, 248, 236, 0.7);
background: rgba(255, 255, 255, 0.07);
color: var(--ink-invert);
}
.tab-button.is-active,
.primary-button {
background: var(--accent);
background: linear-gradient(135deg, var(--blue), var(--blue-2));
color: white;
}
.secondary-button,
.ghost-button {
background: transparent;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(137, 181, 255, 0.18);
color: var(--ink-invert);
}
.danger-button {
background: rgba(255, 107, 122, 0.12);
border: 1px solid rgba(255, 107, 122, 0.25);
color: #ffd9de;
}
.small-button {
padding: 8px 12px;
border-radius: 12px;
}
.workspace-grid {
display: grid;
grid-template-columns: 1.9fr 0.8fr;
grid-template-columns: minmax(0, 2fr) 360px;
gap: 20px;
}
.workspace-card,
.login-copy,
.login-form {
padding: 24px;
.workspace-card {
padding: 22px;
}
.section-header {
.side-stack {
display: grid;
gap: 16px;
}
.side-card {
padding: 20px;
}
.focus-card {
background: linear-gradient(160deg, rgba(23, 49, 87, 0.96), rgba(7, 20, 37, 0.96));
}
.notice-card.is-success {
border-color: rgba(79, 209, 168, 0.28);
}
.notice-card.is-error {
border-color: rgba(255, 107, 122, 0.3);
}
.status-list,
.quick-sql-list {
display: grid;
gap: 10px;
}
.status-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid rgba(137, 181, 255, 0.12);
}
.status-item:last-child {
border-bottom: 0;
}
.section-header,
.subpanel-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-bottom: 18px;
align-items: flex-start;
margin-bottom: 16px;
}
.mini-grid {
.panel-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-bottom: 20px;
}
.subpanel {
padding: 20px;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(137, 181, 255, 0.12);
}
.form-grid {
display: grid;
gap: 14px;
}
.compact-form {
max-width: 520px;
}
.checkbox-field {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox-field input {
width: auto;
}
.split {
justify-content: space-between;
}
.data-list,
@@ -249,26 +488,34 @@ textarea {
}
.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);
.log-line {
padding: 14px 16px;
border-radius: var(--radius-md);
border: 1px solid rgba(137, 181, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.data-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 14px;
}
.data-list-item.wide {
align-items: flex-start;
}
.data-list-item.stacked {
align-items: stretch;
}
.table-wrapper {
overflow: auto;
border-radius: 18px;
border: 1px solid var(--border);
border: 1px solid rgba(137, 181, 255, 0.16);
background: rgba(5, 13, 24, 0.56);
}
table {
@@ -280,72 +527,110 @@ th,
td {
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid rgba(137, 181, 255, 0.12);
vertical-align: top;
}
th {
background: rgba(12, 106, 91, 0.08);
background: rgba(47, 125, 246, 0.12);
color: #dfeeff;
}
.sql-editor,
.login-form input,
.search-box input {
width: 100%;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--border);
background: #fffdf8;
td {
color: #f4f8ff;
}
.sql-editor {
min-height: 220px;
resize: vertical;
margin-bottom: 14px;
}
.login-form {
display: grid;
gap: 18px;
}
.login-form label {
display: grid;
.row-actions {
display: flex;
gap: 8px;
}
.muted,
.empty-inline,
.cell-null {
color: var(--ink-muted);
.row-selected {
background: rgba(47, 125, 246, 0.12);
}
.empty-state {
padding: 28px;
border-radius: 20px;
background: rgba(255, 248, 236, 0.5);
border: 1px dashed var(--border);
.sql-editor {
min-height: 240px;
margin-bottom: 16px;
font-family: "Consolas", "Courier New", monospace;
}
.sql-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.code-snippet {
font-family: "Consolas", "Courier New", monospace;
word-break: break-word;
}
.table-toolbar {
justify-content: space-between;
margin: 18px 0;
}
.empty-state,
.empty-inline {
padding: 22px;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.04);
border: 1px dashed rgba(137, 181, 255, 0.18);
color: #afcaf2;
}
.cell-null {
color: #86aede;
}
.error-banner {
padding: 12px 14px;
border-radius: 14px;
background: rgba(142, 59, 53, 0.12);
color: var(--danger);
background: rgba(255, 107, 122, 0.14);
color: #b62234;
}
@media (max-width: 1100px) {
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 1280px) {
.workspace-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1120px) {
.layout-shell,
.login-panel,
.workspace-grid,
.mini-grid {
.panel-grid {
grid-template-columns: 1fr;
}
.topbar,
.hero-card,
.section-header,
.topbar {
.subpanel-header,
.table-toolbar {
flex-direction: column;
align-items: stretch;
}
}
@media (max-width: 720px) {
.main-panel,
.sidebar,
.login-page {
padding: 16px;
}
.login-panel,
.workspace-card,
.login-copy,
.login-form {
padding: 20px;
}
}