888
This commit is contained in:
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
<div class="sql-result">
|
</section>
|
||||||
${state.sqlResult ? renderRowsTable(state.sqlResult.rows) : `<div class="empty-inline">Результат появится после выполнения запроса.</div>`}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
|
||||||
|
|
||||||
if (state.activeTab === "indexes") {
|
|
||||||
return `
|
|
||||||
<h3>Индексы и оптимизация</h3>
|
|
||||||
<p class="muted">
|
|
||||||
В backend уже подготовлены endpoints для создания индексов. Следующий шаг для production:
|
|
||||||
рекомендации по индексам, explain plans и heatmaps по slow queries.
|
|
||||||
</p>
|
|
||||||
<div class="info-panel">
|
|
||||||
<span>Текущая реализация хранит индексные операции через backend и логирует их в аудит.</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="section-header">
|
|
||||||
<h3>Данные: ${state.activeTable}</h3>
|
|
||||||
<div class="search-box">
|
|
||||||
<input id="tableSearchInput" value="${state.search || ""}" placeholder="Поиск по всем колонкам" />
|
|
||||||
<button class="secondary-button" data-action="search">Искать</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${renderRowsTable(state.rows.rows || [])}
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRowsTable(rows) {
|
function renderSqlTab(state) {
|
||||||
|
return `
|
||||||
|
<div class="section-header">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 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("&", "&")
|
||||||
<h2>Управление пользователями</h2>
|
.replaceAll("<", "<")
|
||||||
<p class="muted">Список пользователей и их ролей. Следующий шаг: CRUD формы и назначение ролей.</p>
|
.replaceAll(">", ">")
|
||||||
</div>
|
.replaceAll('"', """)
|
||||||
${renderRowsTable(users)}
|
.replaceAll("'", "'");
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user