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 }); 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) { async createRow(request: Request, response: Response) {
await service.createRow(request.session.user!.id, request.params.tableName, request.body); await service.createRow(request.session.user!.id, request.params.tableName, request.body);
response.status(201).json({ success: true }); response.status(201).json({ success: true });

View File

@@ -60,4 +60,21 @@ export class MetadataRepository {
return rows; 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.delete("/tables/:tableName/columns/:columnName", asyncHandler(controller.dropColumn.bind(controller)));
router.post("/tables/:tableName/indexes", validateBody(createIndexSchema), asyncHandler(controller.createIndex.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.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.put("/tables/:tableName/rows/:id", validateBody(rowSchema), asyncHandler(controller.updateRow.bind(controller)));
router.delete("/tables/:tableName/rows/:id", asyncHandler(controller.deleteRow.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) { async getTableDetails(tableName: string) {
assertIdentifier(tableName, "table name"); assertIdentifier(tableName, "table name");
const [columns, foreignKeys] = await Promise.all([ const [columns, foreignKeys, indexes] = await Promise.all([
repository.listTableColumns(tableName), repository.listTableColumns(tableName),
repository.listForeignKeys(tableName) repository.listForeignKeys(tableName),
repository.listIndexes(tableName)
]); ]);
return { columns, foreignKeys }; return { columns, foreignKeys, indexes };
} }
async listRows(params: { async listRows(params: {
@@ -132,6 +133,12 @@ export class MetadataService {
await this.logSchema(userId, tableName, "create_index", payload); 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>) { async createRow(userId: string, tableName: string, data: Record<string, unknown>) {
await rbacService.assertPermission(userId, tableName, "write"); await rbacService.assertPermission(userId, tableName, "write");
await this.mutateRow(userId, tableName, "insert", data); 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 = {}) { async function request(path, options = {}) {
const response = await fetch(`${API_BASE_URL}${path}`, { const response = await fetch(`${API_BASE_URL}${path}`, {
@@ -33,6 +33,18 @@ export const api = {
const params = new URLSearchParams(query).toString(); const params = new URLSearchParams(query).toString();
return request(`/db/tables/${tableName}/rows${params ? `?${params}` : ""}`); 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 }) }), executeSql: (sql) => request("/sql/execute", { method: "POST", body: JSON.stringify({ sql }) }),
users: () => request("/admin/users"), users: () => request("/admin/users"),
roles: () => request("/admin/roles"), roles: () => request("/admin/roles"),

View File

@@ -1,17 +1,21 @@
export function renderAppShell(state) { export function renderAppShell(state) {
const groups = groupTables(state.tables); 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 ` return `
<div class="layout-shell"> <div class="layout-shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand-block"> <div class="brand-block">
<span class="brand-kicker">PostgreSQL Control Center</span> <div class="brand-mark">PG</div>
<h1>Database Admin</h1> <div>
<p>Управление схемой, данными, SQL и аудитом.</p> <div class="brand-kicker">PostgreSQL Control Center</div>
<h1>Blue Console</h1>
<p>Управление данными, схемой, SQL и аудитом в одном интерфейсе.</p>
</div> </div>
</div>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-caption">Группы таблиц</div> <div class="sidebar-caption">Table Groups</div>
${groups ${groups
.map( .map(
(group) => ` (group) => `
@@ -21,7 +25,8 @@ export function renderAppShell(state) {
.map( .map(
(table) => ` (table) => `
<button class="nav-table ${state.activeTable === table.table_name ? "is-active" : ""}" data-table="${table.table_name}"> <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> </button>
` `
) )
@@ -31,45 +36,47 @@ export function renderAppShell(state) {
) )
.join("")} .join("")}
</div> </div>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-caption">Навигация</div> <div class="sidebar-caption">Workspace</div>
${[ ${renderSidebarLink(state.page, "overview", "Обзор")}
["overview", "Обзор"], ${renderSidebarLink(state.page, "users", "Пользователи")}
["users", "Пользователи"], ${renderSidebarLink(state.page, "roles", "Роли")}
["roles", "Роли"], ${renderSidebarLink(state.page, "audit", "Аудит")}
["audit", "Аудит"], ${renderSidebarLink(state.page, "logs", "Логи PostgreSQL")}
["logs", "Логи PostgreSQL"]
]
.map(
([id, label]) => `
<button class="nav-link ${state.page === id ? "is-active" : ""}" data-page="${id}">${label}</button>
`
)
.join("")}
</div> </div>
</aside> </aside>
<main class="main-panel"> <main class="main-panel">
<header class="topbar"> <header class="topbar">
<div> <div>
<div class="eyebrow">Сессия</div> <div class="eyebrow">Session</div>
<div class="user-badge">${state.user.username} · ${state.user.roleCodes.join(", ")}</div> <div class="topbar-title">Добро пожаловать, ${state.user.username}</div>
<div class="topbar-subtitle">${state.user.roleCodes.join(", ")}</div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
${activeTable ? `<div class="table-chip">${activeTable.table_group} / ${activeTable.table_name}</div>` : ""} ${activeTableMeta ? `<div class="table-chip">${activeTableMeta.table_group} / ${activeTableMeta.table_name}</div>` : ""}
<button id="logoutButton" class="ghost-button">Выйти</button> <button class="ghost-button" id="logoutButton">Выйти</button>
</div> </div>
</header> </header>
<section class="content-panel"> <section class="content-panel">
${renderPageContent(state)} ${renderMainContent(state, activeTableMeta)}
</section> </section>
</main> </main>
</div> </div>
`; `;
} }
function renderSidebarLink(activePage, id, label) {
return `<button class="nav-link ${activePage === id ? "is-active" : ""}" data-page="${id}">${label}</button>`;
}
function groupTables(tables) { function groupTables(tables) {
const map = new Map(); const map = new Map();
tables.forEach((table) => {
for (const table of tables) {
if (!map.has(table.table_group)) { if (!map.has(table.table_group)) {
map.set(table.table_group, { map.set(table.table_group, {
name: table.table_group, name: table.table_group,
@@ -78,104 +85,260 @@ function groupTables(tables) {
} }
map.get(table.table_group).tables.push(table); map.get(table.table_group).tables.push(table);
}); }
return [...map.values()]; return [...map.values()];
} }
function renderPageContent(state) { function renderMainContent(state, activeTableMeta) {
if (state.page === "users") { if (state.page === "users") {
return renderUsers(state.users); return renderFlatPage("Пользователи", "Управление пользователями и их ролями.", renderRowsTable(state.users));
} }
if (state.page === "roles") { if (state.page === "roles") {
return renderRoles(state.roles); return renderFlatPage("Роли и доступы", "RBAC и права на группы таблиц.", renderRowsTable(state.roles));
} }
if (state.page === "audit") { if (state.page === "audit") {
return renderAudit(state.auditLogs); return renderFlatPage("Аудит", "Входы, SQL, изменения данных и схемы.", renderRowsTable(state.auditLogs));
} }
if (state.page === "logs") { 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 ` return `
<div class="hero-card"> <div class="hero-card">
<div> <div>
<span class="hero-kicker">Production-oriented panel</span> <div class="hero-kicker">Production Workspace</div>
<h2>Управление PostgreSQL с RBAC, аудитом и SQL console.</h2> <h2>Современная панель управления PostgreSQL</h2>
<p>
Синий интерфейс, живая SQL console, CRUD по данным, управление колонками и индексами,
а также аудит всех ключевых операций.
</p>
</div> </div>
<div class="hero-stats"> <div class="hero-stats">
<div><strong>${state.tables.length}</strong><span>Таблиц</span></div> <div><strong>${state.tables.length}</strong><span>таблиц</span></div>
<div><strong>${state.auditLogs.length}</strong><span>Записей аудита</span></div> <div><strong>${state.rows.total || 0}</strong><span>строк в выборке</span></div>
<div><strong>${state.postgresLogs.length}</strong><span>Лог-строк</span></div> <div><strong>${state.auditLogs.length}</strong><span>событий аудита</span></div>
</div> </div>
</div> </div>
<div class="tab-strip"> <div class="tab-strip">
${[ ${renderTab(state.activeTab, "data", "Данные")}
["data", "Данные"], ${renderTab(state.activeTab, "structure", "Структура")}
["structure", "Структура"], ${renderTab(state.activeTab, "sql", "SQL Console")}
["sql", "SQL Console"], ${renderTab(state.activeTab, "indexes", "Индексы")}
["indexes", "Индексы"]
]
.map(
([tab, label]) => `
<button class="tab-button ${state.activeTab === tab ? "is-active" : ""}" data-tab="${tab}">${label}</button>
`
)
.join("")}
</div> </div>
<div class="workspace-grid"> <div class="workspace-grid">
<section class="workspace-card"> <section class="workspace-card workspace-main">
${renderActiveTab(state)} ${renderActiveTab(state, activeTableMeta)}
</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> </section>
<aside class="workspace-card workspace-side">
${renderSidePanel(state, activeTableMeta)}
</aside>
</div> </div>
`; `;
} }
function renderActiveTab(state) { function renderTab(activeTab, id, label) {
if (!state.activeTable) { return `<button class="tab-button ${activeTab === id ? "is-active" : ""}" data-tab="${id}">${label}</button>`;
return `<div class="empty-state">Выберите таблицу в левом меню, чтобы открыть данные и структуру.</div>`; }
function renderActiveTab(state, activeTableMeta) {
if (!activeTableMeta) {
return `<div class="empty-state">Выберите таблицу слева, чтобы открыть рабочую область.</div>`;
} }
if (state.activeTab === "structure") { if (state.activeTab === "structure") {
const columns = state.tableDetails.columns || []; return renderStructureTab(state);
const fks = state.tableDetails.foreignKeys || []; }
if (state.activeTab === "sql") {
return renderSqlTab(state);
}
if (state.activeTab === "indexes") {
return renderIndexesTab(state);
}
return renderDataTab(state);
}
function renderDataTab(state) {
return ` return `
<h3>Структура: ${state.activeTable}</h3> <div class="section-header">
<div class="mini-grid">
<div> <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> <h4>Колонки</h4>
</div>
<div class="data-list"> <div class="data-list">
${columns ${state.tableDetails.columns
.map( .map(
(column) => ` (column) => `
<div class="data-list-item"> <div class="data-list-item wide">
<div>
<strong>${column.column_name}</strong> <strong>${column.column_name}</strong>
<span>${column.data_type}</span> <div class="muted">${column.data_type} · ${column.is_nullable === "YES" ? "nullable" : "required"}</div>
<span>${column.is_nullable === "YES" ? "nullable" : "required"}</span> </div>
<button class="danger-button" data-action="drop-column" data-column-name="${column.column_name}">Удалить</button>
</div> </div>
` `
) )
.join("")} .join("")}
</div> </div>
</div> </section>
<div>
<section class="subpanel">
<div class="subpanel-header">
<h4>Foreign Keys</h4> <h4>Foreign Keys</h4>
</div>
<div class="data-list"> <div class="data-list">
${fks.length ${
? fks state.tableDetails.foreignKeys.length
? state.tableDetails.foreignKeys
.map( .map(
(fk) => ` (fk) => `
<div class="data-list-item"> <div class="data-list-item">
@@ -185,70 +348,215 @@ function renderActiveTab(state) {
` `
) )
.join("") .join("")
: `<div class="empty-inline">Связи не найдены</div>`} : `<div class="empty-inline">Связи не найдены</div>`
</div> }
</div> </div>
</section>
</div> </div>
`; `;
} }
if (state.activeTab === "sql") { function renderSqlTab(state) {
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 ` return `
<div class="section-header"> <div class="section-header">
<h3>Данные: ${state.activeTable}</h3> <div>
<div class="search-box"> <h3>SQL Console</h3>
<input id="tableSearchInput" value="${state.search || ""}" placeholder="Поиск по всем колонкам" /> <p class="muted">Выполняйте запросы с учётом backend-ограничений и аудита.</p>
<button class="secondary-button" data-action="search">Искать</button> </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>
</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) { if (!rows.length) {
return `<div class="empty-state">Нет данных для отображения.</div>`; return `<div class="empty-state">Нет данных для отображения.</div>`;
} }
const columns = Object.keys(rows[0]); const columns = Object.keys(rows[0]);
return ` return `
<div class="table-wrapper"> <div class="table-wrapper">
<table> <table>
<thead> <thead>
<tr>${columns.map((column) => `<th>${column}</th>`).join("")}</tr> <tr>
${columns.map((column) => `<th>${column}</th>`).join("")}
${options.selectable ? "<th>Действия</th>" : ""}
</tr>
</thead> </thead>
<tbody> <tbody>
${rows ${rows
.map( .map((row) => {
(row) => ` const selected = options.selectedRowId !== undefined && String(options.selectedRowId) === String(row.id);
<tr>${columns.map((column) => `<td>${formatCell(row[column])}</td>`).join("")}</tr> 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("")} .join("")}
</tbody> </tbody>
</table> </table>
@@ -262,50 +570,17 @@ function formatCell(value) {
} }
if (typeof value === "object") { 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) { function escapeHtml(value) {
return ` return value
<div class="section-header"> .replaceAll("&", "&amp;")
<h2>Управление пользователями</h2> .replaceAll("<", "&lt;")
<p class="muted">Список пользователей и их ролей. Следующий шаг: CRUD формы и назначение ролей.</p> .replaceAll(">", "&gt;")
</div> .replaceAll('"', "&quot;")
${renderRowsTable(users)} .replaceAll("'", "&#39;");
`;
}
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>
`;
} }

View File

@@ -6,19 +6,28 @@ import "./styles/main.css";
const state = { const state = {
user: null, user: null,
error: "", error: "",
notice: "",
noticeType: "success",
tables: [], tables: [],
activeTable: "", activeTable: "",
activeTab: "data", activeTab: "data",
page: "overview", page: "overview",
rows: { rows: [], total: 0 }, rows: { rows: [], total: 0 },
tableDetails: { columns: [], foreignKeys: [] }, tableDetails: { columns: [], foreignKeys: [], indexes: [] },
users: [], users: [],
roles: [], roles: [],
auditLogs: [], auditLogs: [],
postgresLogs: [], postgresLogs: [],
logsSearch: "",
sqlDraft: "", sqlDraft: "",
sqlResult: null, sqlResult: null,
search: "" sqlMeta: null,
search: "",
sortBy: "",
sortDirection: "asc",
pageNumber: 1,
pageSize: 25,
selectedRow: null
}; };
const app = document.querySelector("#app"); const app = document.querySelector("#app");
@@ -46,7 +55,7 @@ async function hydrateDashboard() {
api.users(), api.users(),
api.roles(), api.roles(),
api.audit(), api.audit(),
api.postgresLogs() api.postgresLogs(state.logsSearch)
]); ]);
state.tables = tablesPayload.tables; state.tables = tablesPayload.tables;
@@ -61,6 +70,7 @@ async function hydrateDashboard() {
if (state.activeTable) { if (state.activeTable) {
await Promise.all([loadRows(), loadTableDetails()]); 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, { state.rows = await api.rows(state.activeTable, {
page: 1, page: state.pageNumber,
pageSize: 25, pageSize: state.pageSize,
search: state.search search: state.search,
sortBy: state.sortBy,
sortDirection: state.sortDirection
}); });
} }
@@ -82,6 +94,9 @@ async function loadTableDetails() {
} }
state.tableDetails = await api.tableDetails(state.activeTable); state.tableDetails = await api.tableDetails(state.activeTable);
if (!state.sortBy && state.tableDetails.columns[0]) {
state.sortBy = state.tableDetails.columns[0].column_name;
}
} }
function render() { function render() {
@@ -90,45 +105,65 @@ function render() {
} }
function bindEvents() { function bindEvents() {
const loginForm = document.querySelector("#loginForm"); document.querySelector("#loginForm")?.addEventListener("submit", wrapAction(handleLogin));
if (loginForm) { document.querySelector("#logoutButton")?.addEventListener("click", wrapAction(handleLogout));
loginForm.addEventListener("submit", handleLogin); document.querySelector("#createRowForm")?.addEventListener("submit", wrapAction(handleCreateRow));
} document.querySelector("#editRowForm")?.addEventListener("submit", wrapAction(handleEditRow));
document.querySelector("#addColumnForm")?.addEventListener("submit", wrapAction(handleAddColumn));
const logoutButton = document.querySelector("#logoutButton"); document.querySelector("#alterColumnForm")?.addEventListener("submit", wrapAction(handleAlterColumn));
if (logoutButton) { document.querySelector("#createIndexForm")?.addEventListener("submit", wrapAction(handleCreateIndex));
logoutButton.addEventListener("click", handleLogout);
}
document.querySelectorAll("[data-page]").forEach((element) => { document.querySelectorAll("[data-page]").forEach((element) => {
element.addEventListener("click", async (event) => { element.addEventListener(
"click",
wrapAction(async (event) => {
state.page = event.currentTarget.dataset.page; state.page = event.currentTarget.dataset.page;
render(); })
}); );
}); });
document.querySelectorAll("[data-tab]").forEach((element) => { document.querySelectorAll("[data-tab]").forEach((element) => {
element.addEventListener("click", async (event) => { element.addEventListener(
"click",
wrapAction(async (event) => {
state.activeTab = event.currentTarget.dataset.tab; state.activeTab = event.currentTarget.dataset.tab;
render(); })
}); );
}); });
document.querySelectorAll("[data-table]").forEach((element) => { document.querySelectorAll("[data-table]").forEach((element) => {
element.addEventListener("click", async (event) => { element.addEventListener(
"click",
wrapAction(async (event) => {
state.activeTable = event.currentTarget.dataset.table; state.activeTable = event.currentTarget.dataset.table;
state.page = "overview"; state.page = "overview";
state.activeTab = "data";
state.pageNumber = 1;
state.selectedRow = null;
state.sqlDraft = `select * from ${state.activeTable} limit 20;`; state.sqlDraft = `select * from ${state.activeTable} limit 20;`;
await Promise.all([loadRows(), loadTableDetails()]); await Promise.all([loadTableDetails(), loadRows()]);
render(); })
}); );
}); });
document.querySelectorAll("[data-action]").forEach((element) => { 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) { async function handleLogin(event) {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
@@ -139,20 +174,20 @@ async function handleLogin(event) {
username: formData.get("username"), username: formData.get("username"),
password: formData.get("password") password: formData.get("password")
}); });
state.user = payload.user; state.user = payload.user;
state.notice = "Сессия успешно открыта";
state.noticeType = "success";
await hydrateDashboard(); await hydrateDashboard();
} catch (error) { } catch (error) {
state.error = error.message; state.error = error.message;
} }
render();
} }
async function handleLogout() { async function handleLogout() {
await api.logout(); await api.logout();
state.user = null; state.user = null;
state.error = ""; state.error = "";
render();
} }
async function handleAction(event) { async function handleAction(event) {
@@ -160,17 +195,200 @@ async function handleAction(event) {
if (action === "refresh") { if (action === "refresh") {
await hydrateDashboard(); await hydrateDashboard();
setNotice("Данные обновлены");
return;
} }
if (action === "search") { if (action === "search") {
state.search = document.querySelector("#tableSearchInput")?.value || ""; 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(); await loadRows();
setNotice("Фильтр применён");
return;
} }
if (action === "run-sql") { if (action === "run-sql") {
state.sqlDraft = document.querySelector("#sqlEditor")?.value || ""; 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) { export function renderLoginPage(state) {
return ` return `
<div class="login-page"> <div class="login-page">
<div class="login-backdrop"></div>
<section class="login-panel"> <section class="login-panel">
<div class="login-copy"> <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> <h1>Production-grade PostgreSQL admin panel</h1>
<p> <p>
Session-based auth, RBAC по группам таблиц, аудит действий, безопасный SQL console и контейнерные логи. Бело-сине-черный интерфейс, RBAC по группам таблиц, аудит действий,
безопасная SQL console и рабочее управление схемой и данными.
</p> </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>
</div>
<form id="loginForm" class="login-form"> <form id="loginForm" class="login-form">
<div class="form-title">
<h2>Вход в консоль</h2>
<p>Используй root-аккаунт или назначенного администратора группы.</p>
</div>
<label> <label>
<span>Логин</span> <span>Логин</span>
<input name="username" value="root" required /> <input name="username" value="root" required />
</label> </label>
<label> <label>
<span>Пароль</span> <span>Пароль</span>
<input name="password" type="password" value="ChangeMe123!" required /> <input name="password" type="password" value="ChangeMe123!" required />
</label> </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>` : ""} ${state.error ? `<div class="error-banner">${state.error}</div>` : ""}
</form> </form>
</section> </section>

View File

@@ -1,61 +1,186 @@
:root { :root {
--bg: #f3efe6; --bg: #07111f;
--bg-strong: #e1d6c4; --bg-2: #0d1b2f;
--panel: rgba(255, 250, 242, 0.86); --bg-soft: #122742;
--panel-strong: #fff8ec; --panel: rgba(11, 23, 41, 0.88);
--ink: #1f2a24; --panel-2: rgba(16, 34, 59, 0.94);
--ink-muted: #5b665e; --panel-light: rgba(240, 247, 255, 0.06);
--accent: #0c6a5b; --surface: #ffffff;
--accent-strong: #12453d; --surface-2: #eaf3ff;
--accent-soft: #c7e3da; --ink: #04101d;
--border: rgba(31, 42, 36, 0.12); --ink-soft: #4d6786;
--danger: #8e3b35; --ink-invert: #f5f9ff;
--shadow: 0 20px 50px rgba(35, 34, 28, 0.12); --blue: #2f7df6;
--radius: 24px; --blue-2: #5aa2ff;
font-family: "Segoe UI", "Trebuchet MS", sans-serif; --blue-3: #123764;
color: var(--ink); --border: rgba(120, 170, 255, 0.2);
background: --danger: #ff6b7a;
radial-gradient(circle at top left, rgba(12, 106, 91, 0.18), transparent 32%), --success: #4fd1a8;
linear-gradient(135deg, #f6f2e8, #e8efe5 58%, #e4ddd0); --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; box-sizing: border-box;
} }
body { html,
body,
#app {
margin: 0; margin: 0;
min-height: 100%;
}
body {
min-height: 100vh; 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, button,
input, input,
select,
textarea { textarea {
font: inherit; font: inherit;
} }
#app { button {
min-height: 100vh; 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 { .login-page {
position: relative;
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 32px; 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 { .login-panel {
position: relative;
display: grid; display: grid;
grid-template-columns: 1.1fr 0.9fr; grid-template-columns: 1.15fr 0.85fr;
gap: 24px; gap: 28px;
width: min(1100px, 100%); width: min(1180px, 100%);
background: var(--panel); padding: 28px;
border: 1px solid var(--border); border-radius: 36px;
border-radius: 32px; border: 1px solid rgba(137, 181, 255, 0.16);
padding: 32px; background: rgba(8, 17, 30, 0.76);
backdrop-filter: blur(24px);
box-shadow: var(--shadow); 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 { .layout-shell {
@@ -65,46 +190,44 @@ textarea {
} }
.sidebar { .sidebar {
padding: 28px; padding: 26px;
background: rgba(25, 42, 35, 0.95); background:
color: #f8efe0; 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, .brand-block {
.hero-card h2, display: grid;
.login-copy h1 { grid-template-columns: 60px 1fr;
margin: 8px 0 12px; gap: 16px;
font-family: Georgia, "Times New Roman", serif; align-items: start;
line-height: 1.05;
} }
.brand-kicker, .brand-mark {
.hero-kicker, width: 60px;
.eyebrow, height: 60px;
.sidebar-caption { display: grid;
text-transform: uppercase; place-items: center;
letter-spacing: 0.16em; border-radius: 18px;
font-size: 12px; background: linear-gradient(145deg, #2f7df6, #0f3f87);
color: var(--ink-muted); color: white;
} font-weight: 700;
font-size: 22px;
.sidebar-caption,
.brand-kicker {
color: rgba(248, 239, 224, 0.72);
} }
.sidebar-section { .sidebar-section {
margin-top: 28px; margin-top: 28px;
} }
.sidebar-group { .sidebar-group + .sidebar-group {
margin-top: 16px; margin-top: 18px;
} }
.group-title { .group-title {
font-size: 13px;
color: rgba(248, 239, 224, 0.72);
margin-bottom: 8px; margin-bottom: 8px;
color: #8eb7f7;
font-size: 13px;
} }
.nav-table, .nav-table,
@@ -112,9 +235,8 @@ textarea {
.tab-button, .tab-button,
.primary-button, .primary-button,
.secondary-button, .secondary-button,
.ghost-button { .ghost-button,
border: 0; .danger-button {
cursor: pointer;
transition: 180ms ease; transition: 180ms ease;
} }
@@ -122,64 +244,91 @@ textarea {
.nav-link { .nav-link {
width: 100%; width: 100%;
text-align: left; text-align: left;
color: inherit; border-radius: 16px;
color: var(--ink-invert);
background: transparent; background: transparent;
border-radius: 14px; padding: 12px 14px;
padding: 11px 14px;
margin-bottom: 6px; 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-table:hover,
.nav-link:hover, .nav-link:hover,
.nav-table.is-active, .nav-table.is-active,
.nav-link.is-active { .nav-link.is-active {
background: rgba(255, 248, 236, 0.12); background: rgba(47, 125, 246, 0.18);
} }
.main-panel { .main-panel {
padding: 24px; padding: 22px;
}
.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 { .topbar {
padding: 20px 22px; padding: 22px 24px;
background: rgba(9, 19, 33, 0.82);
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
justify-content: space-between;
}
.topbar-title {
font-size: 28px;
}
.topbar-subtitle {
color: #8bb7fb;
} }
.topbar-actions, .topbar-actions,
.toolbar,
.form-actions, .form-actions,
.search-box { .side-actions,
.table-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.content-panel { .content-panel {
padding-top: 22px; padding-top: 20px;
}
.hero-card,
.workspace-card,
.side-card {
background: rgba(10, 21, 38, 0.84);
} }
.hero-card { .hero-card {
padding: 28px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 24px;
padding: 26px; }
.hero-card.compact {
margin-bottom: 18px;
}
.hero-card p {
max-width: 760px;
color: #bed4f8;
} }
.hero-stats { .hero-stats {
display: flex; display: flex;
gap: 20px; gap: 18px;
align-items: flex-start;
} }
.hero-stats div { .hero-stats div {
@@ -188,58 +337,148 @@ textarea {
} }
.hero-stats strong { .hero-stats strong {
font-size: 32px; font-size: 34px;
font-family: Georgia, "Times New Roman", serif; color: #ffffff;
} }
.tab-strip { .tab-strip {
display: flex; display: flex;
gap: 10px; gap: 10px;
margin: 18px 0; margin: 18px 0;
flex-wrap: wrap;
}
.tab-button,
.primary-button,
.secondary-button,
.ghost-button,
.danger-button {
border-radius: 999px;
padding: 11px 16px;
} }
.tab-button { .tab-button {
padding: 10px 16px; background: rgba(255, 255, 255, 0.07);
border-radius: 999px; color: var(--ink-invert);
background: rgba(255, 248, 236, 0.7);
} }
.tab-button.is-active, .tab-button.is-active,
.primary-button { .primary-button {
background: var(--accent); background: linear-gradient(135deg, var(--blue), var(--blue-2));
color: white; color: white;
} }
.secondary-button, .secondary-button,
.ghost-button { .ghost-button {
background: transparent; background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border); 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 { .workspace-grid {
display: grid; display: grid;
grid-template-columns: 1.9fr 0.8fr; grid-template-columns: minmax(0, 2fr) 360px;
gap: 20px; gap: 20px;
} }
.workspace-card, .workspace-card {
.login-copy, padding: 22px;
.login-form {
padding: 24px;
} }
.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; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
align-items: center; align-items: flex-start;
margin-bottom: 18px; margin-bottom: 16px;
} }
.mini-grid { .panel-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 18px; 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, .data-list,
@@ -249,26 +488,34 @@ textarea {
} }
.data-list-item, .data-list-item,
.log-line,
.info-panel,
.table-chip, .table-chip,
.user-badge { .log-line {
padding: 12px 14px; padding: 14px 16px;
border-radius: 16px; border-radius: var(--radius-md);
background: var(--panel-strong); border: 1px solid rgba(137, 181, 255, 0.12);
border: 1px solid var(--border); background: rgba(255, 255, 255, 0.04);
} }
.data-list-item { .data-list-item {
display: flex; display: flex;
align-items: center;
justify-content: space-between; 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 { .table-wrapper {
overflow: auto; overflow: auto;
border-radius: 18px; 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 { table {
@@ -280,72 +527,110 @@ th,
td { td {
text-align: left; text-align: left;
padding: 12px 14px; padding: 12px 14px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid rgba(137, 181, 255, 0.12);
vertical-align: top; vertical-align: top;
} }
th { th {
background: rgba(12, 106, 91, 0.08); background: rgba(47, 125, 246, 0.12);
color: #dfeeff;
} }
.sql-editor, td {
.login-form input, color: #f4f8ff;
.search-box input {
width: 100%;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--border);
background: #fffdf8;
} }
.sql-editor { .row-actions {
min-height: 220px; display: flex;
resize: vertical;
margin-bottom: 14px;
}
.login-form {
display: grid;
gap: 18px;
}
.login-form label {
display: grid;
gap: 8px; gap: 8px;
} }
.muted, .row-selected {
.empty-inline, background: rgba(47, 125, 246, 0.12);
.cell-null {
color: var(--ink-muted);
} }
.empty-state { .sql-editor {
padding: 28px; min-height: 240px;
border-radius: 20px; margin-bottom: 16px;
background: rgba(255, 248, 236, 0.5); font-family: "Consolas", "Courier New", monospace;
border: 1px dashed var(--border); }
.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 { .error-banner {
padding: 12px 14px; padding: 12px 14px;
border-radius: 14px; border-radius: 14px;
background: rgba(142, 59, 53, 0.12); background: rgba(255, 107, 122, 0.14);
color: var(--danger); 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, .layout-shell,
.login-panel, .login-panel,
.workspace-grid, .panel-grid {
.mini-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.topbar,
.hero-card, .hero-card,
.section-header, .section-header,
.topbar { .subpanel-header,
.table-toolbar {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
} }
@media (max-width: 720px) {
.main-panel,
.sidebar,
.login-page {
padding: 16px;
}
.login-panel,
.workspace-card,
.login-copy,
.login-form {
padding: 20px;
}
}