123
This commit is contained in:
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package.json
|
||||
COPY vite.config.js vite.config.js
|
||||
RUN npm install
|
||||
|
||||
COPY public public
|
||||
COPY src src
|
||||
COPY index.html index.html
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PostgreSQL Control Center</title>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
15
frontend/package.json
Normal file
15
frontend/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@pg-control/frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.1"
|
||||
}
|
||||
}
|
||||
42
frontend/src/api/client.js
Normal file
42
frontend/src/api/client.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:4000/api";
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {})
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "Request failed");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request("/auth/me"),
|
||||
login: (body) => request("/auth/login", { method: "POST", body: JSON.stringify(body) }),
|
||||
logout: () => request("/auth/logout", { method: "POST" }),
|
||||
tables: () => request("/db/tables"),
|
||||
tableDetails: (tableName) => request(`/db/tables/${tableName}/details`),
|
||||
rows: (tableName, query = {}) => {
|
||||
const params = new URLSearchParams(query).toString();
|
||||
return request(`/db/tables/${tableName}/rows${params ? `?${params}` : ""}`);
|
||||
},
|
||||
executeSql: (sql) => request("/sql/execute", { method: "POST", body: JSON.stringify({ sql }) }),
|
||||
users: () => request("/admin/users"),
|
||||
roles: () => request("/admin/roles"),
|
||||
audit: (search = "") => request(`/admin/audit${search ? `?search=${encodeURIComponent(search)}` : ""}`),
|
||||
postgresLogs: (search = "") =>
|
||||
request(`/admin/postgres-logs${search ? `?search=${encodeURIComponent(search)}` : ""}`)
|
||||
};
|
||||
311
frontend/src/components/shell.js
Normal file
311
frontend/src/components/shell.js
Normal file
@@ -0,0 +1,311 @@
|
||||
export function renderAppShell(state) {
|
||||
const groups = groupTables(state.tables);
|
||||
const activeTable = state.activeTable ? state.tables.find((table) => table.table_name === state.activeTable) : null;
|
||||
|
||||
return `
|
||||
<div class="layout-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand-block">
|
||||
<span class="brand-kicker">PostgreSQL Control Center</span>
|
||||
<h1>Database Admin</h1>
|
||||
<p>Управление схемой, данными, SQL и аудитом.</p>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-caption">Группы таблиц</div>
|
||||
${groups
|
||||
.map(
|
||||
(group) => `
|
||||
<section class="sidebar-group">
|
||||
<div class="group-title">${group.name}</div>
|
||||
${group.tables
|
||||
.map(
|
||||
(table) => `
|
||||
<button class="nav-table ${state.activeTable === table.table_name ? "is-active" : ""}" data-table="${table.table_name}">
|
||||
<span>${table.table_name}</span>
|
||||
</button>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</section>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-caption">Навигация</div>
|
||||
${[
|
||||
["overview", "Обзор"],
|
||||
["users", "Пользователи"],
|
||||
["roles", "Роли"],
|
||||
["audit", "Аудит"],
|
||||
["logs", "Логи PostgreSQL"]
|
||||
]
|
||||
.map(
|
||||
([id, label]) => `
|
||||
<button class="nav-link ${state.page === id ? "is-active" : ""}" data-page="${id}">${label}</button>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main-panel">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="eyebrow">Сессия</div>
|
||||
<div class="user-badge">${state.user.username} · ${state.user.roleCodes.join(", ")}</div>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
${activeTable ? `<div class="table-chip">${activeTable.table_group} / ${activeTable.table_name}</div>` : ""}
|
||||
<button id="logoutButton" class="ghost-button">Выйти</button>
|
||||
</div>
|
||||
</header>
|
||||
<section class="content-panel">
|
||||
${renderPageContent(state)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function groupTables(tables) {
|
||||
const map = new Map();
|
||||
tables.forEach((table) => {
|
||||
if (!map.has(table.table_group)) {
|
||||
map.set(table.table_group, {
|
||||
name: table.table_group,
|
||||
tables: []
|
||||
});
|
||||
}
|
||||
|
||||
map.get(table.table_group).tables.push(table);
|
||||
});
|
||||
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
function renderPageContent(state) {
|
||||
if (state.page === "users") {
|
||||
return renderUsers(state.users);
|
||||
}
|
||||
|
||||
if (state.page === "roles") {
|
||||
return renderRoles(state.roles);
|
||||
}
|
||||
|
||||
if (state.page === "audit") {
|
||||
return renderAudit(state.auditLogs);
|
||||
}
|
||||
|
||||
if (state.page === "logs") {
|
||||
return renderLogViewer(state.postgresLogs);
|
||||
}
|
||||
|
||||
return renderOverview(state);
|
||||
}
|
||||
|
||||
function renderOverview(state) {
|
||||
return `
|
||||
<div class="hero-card">
|
||||
<div>
|
||||
<span class="hero-kicker">Production-oriented panel</span>
|
||||
<h2>Управление PostgreSQL с RBAC, аудитом и SQL console.</h2>
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div><strong>${state.tables.length}</strong><span>Таблиц</span></div>
|
||||
<div><strong>${state.auditLogs.length}</strong><span>Записей аудита</span></div>
|
||||
<div><strong>${state.postgresLogs.length}</strong><span>Лог-строк</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-strip">
|
||||
${[
|
||||
["data", "Данные"],
|
||||
["structure", "Структура"],
|
||||
["sql", "SQL Console"],
|
||||
["indexes", "Индексы"]
|
||||
]
|
||||
.map(
|
||||
([tab, label]) => `
|
||||
<button class="tab-button ${state.activeTab === tab ? "is-active" : ""}" data-tab="${tab}">${label}</button>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
<div class="workspace-grid">
|
||||
<section class="workspace-card">
|
||||
${renderActiveTab(state)}
|
||||
</section>
|
||||
<section class="workspace-card side-actions">
|
||||
<h3>Быстрые действия</h3>
|
||||
<button class="primary-button" data-action="refresh">Обновить данные</button>
|
||||
<button class="secondary-button" data-page="audit">Открыть аудит</button>
|
||||
<button class="secondary-button" data-page="logs">Открыть логи PostgreSQL</button>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderActiveTab(state) {
|
||||
if (!state.activeTable) {
|
||||
return `<div class="empty-state">Выберите таблицу в левом меню, чтобы открыть данные и структуру.</div>`;
|
||||
}
|
||||
|
||||
if (state.activeTab === "structure") {
|
||||
const columns = state.tableDetails.columns || [];
|
||||
const fks = state.tableDetails.foreignKeys || [];
|
||||
return `
|
||||
<h3>Структура: ${state.activeTable}</h3>
|
||||
<div class="mini-grid">
|
||||
<div>
|
||||
<h4>Колонки</h4>
|
||||
<div class="data-list">
|
||||
${columns
|
||||
.map(
|
||||
(column) => `
|
||||
<div class="data-list-item">
|
||||
<strong>${column.column_name}</strong>
|
||||
<span>${column.data_type}</span>
|
||||
<span>${column.is_nullable === "YES" ? "nullable" : "required"}</span>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Foreign Keys</h4>
|
||||
<div class="data-list">
|
||||
${fks.length
|
||||
? fks
|
||||
.map(
|
||||
(fk) => `
|
||||
<div class="data-list-item">
|
||||
<strong>${fk.column_name}</strong>
|
||||
<span>${fk.foreign_table_name}.${fk.foreign_column_name}</span>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")
|
||||
: `<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 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 `
|
||||
<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) {
|
||||
if (!rows.length) {
|
||||
return `<div class="empty-state">Нет данных для отображения.</div>`;
|
||||
}
|
||||
|
||||
const columns = Object.keys(rows[0]);
|
||||
return `
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>${columns.map((column) => `<th>${column}</th>`).join("")}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>${columns.map((column) => `<td>${formatCell(row[column])}</td>`).join("")}</tr>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatCell(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return `<span class="cell-null">null</span>`;
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
return `
|
||||
<div class="section-header">
|
||||
<h2>Управление пользователями</h2>
|
||||
<p class="muted">Список пользователей и их ролей. Следующий шаг: CRUD формы и назначение ролей.</p>
|
||||
</div>
|
||||
${renderRowsTable(users)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRoles(roles) {
|
||||
return `
|
||||
<div class="section-header">
|
||||
<h2>Роли и доступы</h2>
|
||||
<p class="muted">RBAC хранится в PostgreSQL и поддерживает групповые права на таблицы.</p>
|
||||
</div>
|
||||
${renderRowsTable(roles)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAudit(logs) {
|
||||
return `
|
||||
<div class="section-header">
|
||||
<h2>Аудит</h2>
|
||||
<p class="muted">Логируются входы, SQL, изменения данных и схемы.</p>
|
||||
</div>
|
||||
${renderRowsTable(logs)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLogViewer(logs) {
|
||||
return `
|
||||
<div class="section-header">
|
||||
<h2>Логи PostgreSQL</h2>
|
||||
<p class="muted">Чтение контейнерных логов для диагностики и мониторинга.</p>
|
||||
</div>
|
||||
<div class="log-viewer">
|
||||
${logs.map((line) => `<div class="log-line">${line}</div>`).join("")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
176
frontend/src/main.js
Normal file
176
frontend/src/main.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { api } from "./api/client.js";
|
||||
import { renderAppShell } from "./components/shell.js";
|
||||
import { renderLoginPage } from "./pages/login.js";
|
||||
import "./styles/main.css";
|
||||
|
||||
const state = {
|
||||
user: null,
|
||||
error: "",
|
||||
tables: [],
|
||||
activeTable: "",
|
||||
activeTab: "data",
|
||||
page: "overview",
|
||||
rows: { rows: [], total: 0 },
|
||||
tableDetails: { columns: [], foreignKeys: [] },
|
||||
users: [],
|
||||
roles: [],
|
||||
auditLogs: [],
|
||||
postgresLogs: [],
|
||||
sqlDraft: "",
|
||||
sqlResult: null,
|
||||
search: ""
|
||||
};
|
||||
|
||||
const app = document.querySelector("#app");
|
||||
|
||||
bootstrap();
|
||||
|
||||
async function bootstrap() {
|
||||
try {
|
||||
const { user } = await api.me();
|
||||
state.user = user;
|
||||
|
||||
if (user) {
|
||||
await hydrateDashboard();
|
||||
}
|
||||
} catch {
|
||||
state.user = null;
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
async function hydrateDashboard() {
|
||||
const [tablesPayload, usersPayload, rolesPayload, auditPayload, logsPayload] = await Promise.all([
|
||||
api.tables(),
|
||||
api.users(),
|
||||
api.roles(),
|
||||
api.audit(),
|
||||
api.postgresLogs()
|
||||
]);
|
||||
|
||||
state.tables = tablesPayload.tables;
|
||||
state.users = usersPayload.users;
|
||||
state.roles = rolesPayload.roles;
|
||||
state.auditLogs = auditPayload.logs;
|
||||
state.postgresLogs = logsPayload.logs;
|
||||
|
||||
if (!state.activeTable && state.tables[0]) {
|
||||
state.activeTable = state.tables[0].table_name;
|
||||
}
|
||||
|
||||
if (state.activeTable) {
|
||||
await Promise.all([loadRows(), loadTableDetails()]);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRows() {
|
||||
if (!state.activeTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.rows = await api.rows(state.activeTable, {
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
search: state.search
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTableDetails() {
|
||||
if (!state.activeTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.tableDetails = await api.tableDetails(state.activeTable);
|
||||
}
|
||||
|
||||
function render() {
|
||||
app.innerHTML = state.user ? renderAppShell(state) : renderLoginPage(state);
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const loginForm = document.querySelector("#loginForm");
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener("submit", handleLogin);
|
||||
}
|
||||
|
||||
const logoutButton = document.querySelector("#logoutButton");
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener("click", handleLogout);
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-page]").forEach((element) => {
|
||||
element.addEventListener("click", async (event) => {
|
||||
state.page = event.currentTarget.dataset.page;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-tab]").forEach((element) => {
|
||||
element.addEventListener("click", async (event) => {
|
||||
state.activeTab = event.currentTarget.dataset.tab;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-table]").forEach((element) => {
|
||||
element.addEventListener("click", async (event) => {
|
||||
state.activeTable = event.currentTarget.dataset.table;
|
||||
state.page = "overview";
|
||||
state.sqlDraft = `select * from ${state.activeTable} limit 20;`;
|
||||
await Promise.all([loadRows(), loadTableDetails()]);
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-action]").forEach((element) => {
|
||||
element.addEventListener("click", handleAction);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
state.error = "";
|
||||
|
||||
try {
|
||||
const payload = await api.login({
|
||||
username: formData.get("username"),
|
||||
password: formData.get("password")
|
||||
});
|
||||
state.user = payload.user;
|
||||
await hydrateDashboard();
|
||||
} catch (error) {
|
||||
state.error = error.message;
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await api.logout();
|
||||
state.user = null;
|
||||
state.error = "";
|
||||
render();
|
||||
}
|
||||
|
||||
async function handleAction(event) {
|
||||
const action = event.currentTarget.dataset.action;
|
||||
|
||||
if (action === "refresh") {
|
||||
await hydrateDashboard();
|
||||
}
|
||||
|
||||
if (action === "search") {
|
||||
state.search = document.querySelector("#tableSearchInput")?.value || "";
|
||||
await loadRows();
|
||||
}
|
||||
|
||||
if (action === "run-sql") {
|
||||
state.sqlDraft = document.querySelector("#sqlEditor")?.value || "";
|
||||
state.sqlResult = await api.executeSql(state.sqlDraft);
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
27
frontend/src/pages/login.js
Normal file
27
frontend/src/pages/login.js
Normal file
@@ -0,0 +1,27 @@
|
||||
export function renderLoginPage(state) {
|
||||
return `
|
||||
<div class="login-page">
|
||||
<section class="login-panel">
|
||||
<div class="login-copy">
|
||||
<span class="hero-kicker">Secure Admin Access</span>
|
||||
<h1>Production-grade PostgreSQL admin panel</h1>
|
||||
<p>
|
||||
Session-based auth, RBAC по группам таблиц, аудит действий, безопасный SQL console и контейнерные логи.
|
||||
</p>
|
||||
</div>
|
||||
<form id="loginForm" class="login-form">
|
||||
<label>
|
||||
<span>Логин</span>
|
||||
<input name="username" value="root" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Пароль</span>
|
||||
<input name="password" type="password" value="ChangeMe123!" required />
|
||||
</label>
|
||||
<button class="primary-button" type="submit">Войти</button>
|
||||
${state.error ? `<div class="error-banner">${state.error}</div>` : ""}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
351
frontend/src/styles/main.css
Normal file
351
frontend/src/styles/main.css
Normal file
@@ -0,0 +1,351 @@
|
||||
:root {
|
||||
--bg: #f3efe6;
|
||||
--bg-strong: #e1d6c4;
|
||||
--panel: rgba(255, 250, 242, 0.86);
|
||||
--panel-strong: #fff8ec;
|
||||
--ink: #1f2a24;
|
||||
--ink-muted: #5b665e;
|
||||
--accent: #0c6a5b;
|
||||
--accent-strong: #12453d;
|
||||
--accent-soft: #c7e3da;
|
||||
--border: rgba(31, 42, 36, 0.12);
|
||||
--danger: #8e3b35;
|
||||
--shadow: 0 20px 50px rgba(35, 34, 28, 0.12);
|
||||
--radius: 24px;
|
||||
font-family: "Segoe UI", "Trebuchet MS", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(12, 106, 91, 0.18), transparent 32%),
|
||||
linear-gradient(135deg, #f6f2e8, #e8efe5 58%, #e4ddd0);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 24px;
|
||||
width: min(1100px, 100%);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 32px;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.layout-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 28px;
|
||||
background: rgba(25, 42, 35, 0.95);
|
||||
color: #f8efe0;
|
||||
}
|
||||
|
||||
.brand-block h1,
|
||||
.hero-card h2,
|
||||
.login-copy h1 {
|
||||
margin: 8px 0 12px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.brand-kicker,
|
||||
.hero-kicker,
|
||||
.eyebrow,
|
||||
.sidebar-caption {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.sidebar-caption,
|
||||
.brand-kicker {
|
||||
color: rgba(248, 239, 224, 0.72);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.sidebar-group {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 13px;
|
||||
color: rgba(248, 239, 224, 0.72);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.nav-table,
|
||||
.nav-link,
|
||||
.tab-button,
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.ghost-button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: 180ms ease;
|
||||
}
|
||||
|
||||
.nav-table,
|
||||
.nav-link {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border-radius: 14px;
|
||||
padding: 11px 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.nav-table:hover,
|
||||
.nav-link:hover,
|
||||
.nav-table.is-active,
|
||||
.nav-link.is-active {
|
||||
background: rgba(255, 248, 236, 0.12);
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.hero-card,
|
||||
.workspace-card,
|
||||
.login-form,
|
||||
.login-copy {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 20px 22px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topbar-actions,
|
||||
.form-actions,
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 26px;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.hero-stats div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hero-stats strong {
|
||||
font-size: 32px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
.tab-strip {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 248, 236, 0.7);
|
||||
}
|
||||
|
||||
.tab-button.is-active,
|
||||
.primary-button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary-button,
|
||||
.ghost-button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.9fr 0.8fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.workspace-card,
|
||||
.login-copy,
|
||||
.login-form {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.data-list,
|
||||
.log-viewer {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.data-list-item,
|
||||
.log-line,
|
||||
.info-panel,
|
||||
.table-chip,
|
||||
.user-badge {
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.data-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background: rgba(12, 106, 91, 0.08);
|
||||
}
|
||||
|
||||
.sql-editor,
|
||||
.login-form input,
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fffdf8;
|
||||
}
|
||||
|
||||
.sql-editor {
|
||||
min-height: 220px;
|
||||
resize: vertical;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.login-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.empty-inline,
|
||||
.cell-null {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 28px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 248, 236, 0.5);
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(142, 59, 53, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.layout-shell,
|
||||
.login-panel,
|
||||
.workspace-grid,
|
||||
.mini-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.section-header,
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
8
frontend/vite.config.js
Normal file
8
frontend/vite.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user