This commit is contained in:
2026-03-19 18:00:46 +07:00
commit f72ad2769f
98 changed files with 9299 additions and 0 deletions

6
frontend/src/app/App.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
export function App() {
return <RouterProvider router={router} />;
}

View File

@@ -0,0 +1,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { PropsWithChildren } from "react";
const queryClient = new QueryClient();
export function AppProviders({ children }: PropsWithChildren) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -0,0 +1,33 @@
import { createBrowserRouter, Navigate } from "react-router-dom";
import { DashboardPage } from "../pages/DashboardPage";
import { LoginPage } from "../pages/LoginPage";
import { UsersPage } from "../pages/UsersPage";
import { RolesPage } from "../pages/RolesPage";
import { AuditPage } from "../pages/AuditPage";
export const router = createBrowserRouter([
{
path: "/login",
element: <LoginPage />
},
{
path: "/",
element: <DashboardPage />
},
{
path: "/users",
element: <UsersPage />
},
{
path: "/roles",
element: <RolesPage />
},
{
path: "/audit",
element: <AuditPage />
},
{
path: "*",
element: <Navigate to="/" replace />
}
]);

297
frontend/src/app/styles.css Normal file
View File

@@ -0,0 +1,297 @@
:root {
color-scheme: light;
font-family: "Inter", sans-serif;
--bg: #f4f7fb;
--panel: rgba(255, 255, 255, 0.96);
--panel-solid: #ffffff;
--sidebar: #0f172a;
--sidebar-muted: #94a3b8;
--line: #dbe3ef;
--text: #1e293b;
--muted: #64748b;
--primary: #2563eb;
--primary-soft: #dbeafe;
--success: #16a34a;
--danger: #dc2626;
--shadow: 0 30px 70px rgba(15, 23, 42, 0.12);
--radius: 18px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
radial-gradient(circle at top right, rgba(37, 99, 235, 0.18), transparent 28%),
linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
color: var(--text);
}
button,
input,
select,
textarea {
font: inherit;
}
#root {
min-height: 100vh;
}
.app-shell {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
background: linear-gradient(180deg, #0f172a 0%, #111827 100%);
color: white;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
}
.badge {
background: rgba(37, 99, 235, 0.18);
color: #bfdbfe;
border: 1px solid rgba(191, 219, 254, 0.24);
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
}
.sidebar-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar-title {
color: var(--sidebar-muted);
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 11px;
font-weight: 700;
}
.sidebar-item,
.nav-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: rgba(255, 255, 255, 0.92);
text-decoration: none;
padding: 12px 14px;
border-radius: 14px;
transition: 0.2s ease;
border: 1px solid transparent;
}
.sidebar-item:hover,
.nav-link:hover,
.sidebar-item.active,
.nav-link.active {
background: rgba(30, 41, 59, 0.9);
border-color: rgba(59, 130, 246, 0.25);
}
.main-area {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.topbar,
.panel,
.card {
background: var(--panel);
border: 1px solid rgba(219, 227, 239, 0.9);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
border-radius: var(--radius);
}
.topbar {
padding: 18px 22px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.content-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 20px;
}
.panel {
padding: 20px;
}
.section-title {
margin: 0 0 14px;
font-size: 22px;
}
.muted {
color: var(--muted);
}
.row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.input,
.select,
.textarea {
width: 100%;
border: 1px solid var(--line);
background: white;
border-radius: 14px;
padding: 12px 14px;
outline: none;
}
.textarea {
min-height: 180px;
resize: vertical;
font-family: "JetBrains Mono", monospace;
}
.button {
border: 0;
border-radius: 14px;
padding: 12px 16px;
cursor: pointer;
font-weight: 600;
transition: 0.2s ease;
}
.button.primary {
background: var(--primary);
color: white;
}
.button.secondary {
background: #e2e8f0;
color: var(--text);
}
.button.danger {
background: #fee2e2;
color: var(--danger);
}
.button:hover {
transform: translateY(-1px);
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 16px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 12px 14px;
border-bottom: 1px solid #edf2f7;
vertical-align: top;
}
thead {
background: #f8fafc;
}
.tabs {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.tab {
border: 1px solid var(--line);
background: white;
border-radius: 999px;
padding: 10px 14px;
cursor: pointer;
font-weight: 600;
}
.tab.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.login-card {
width: min(100%, 420px);
padding: 36px;
}
.stack {
display: grid;
gap: 14px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
background: #eff6ff;
color: #1d4ed8;
border-radius: 999px;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
}
.code {
font-family: "JetBrains Mono", monospace;
}
.empty-state {
padding: 48px 20px;
text-align: center;
color: var(--muted);
}
@media (max-width: 1080px) {
.app-shell {
grid-template-columns: 1fr;
}
.content-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,22 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "../../shared/api/client";
import type { SessionUser } from "../../shared/types";
export function useSession() {
return useQuery({
queryKey: ["session"],
queryFn: async () => {
const response = await api.get<{ user: SessionUser }>("/auth/session");
return response.data.user;
},
retry: false
});
}
export function useLogout() {
const queryClient = useQueryClient();
return async () => {
await api.post("/auth/logout");
queryClient.clear();
};
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import { AppProviders } from "./app/providers";
import "./app/styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<AppProviders>
<App />
</AppProviders>
);

View File

@@ -0,0 +1,50 @@
import { Navigate, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { api } from "../shared/api/client";
import { useLogout, useSession } from "../features/auth/use-session";
import { Sidebar } from "../widgets/Sidebar";
import { Topbar } from "../widgets/Topbar";
import { AuditTable } from "../widgets/AuditTable";
export function AuditPage() {
const navigate = useNavigate();
const session = useSession();
const logout = useLogout();
const auditQuery = useQuery({
queryKey: ["audit"],
queryFn: async () => {
const response = await api.get<Array<Record<string, unknown>>>("/audit?page=1&limit=50");
return response.data;
},
enabled: session.isSuccess
});
if (session.isError) {
return <Navigate to="/login" replace />;
}
if (session.isLoading || !session.data) {
return <main className="login-page"><section className="panel login-card">Loading audit log...</section></main>;
}
return (
<div className="app-shell">
<Sidebar groups={[]} currentTable={null} onSelectTable={() => undefined} />
<main className="main-area">
<Topbar
user={session.data}
onOpenSql={() => navigate("/")}
onLogout={async () => {
await logout();
navigate("/login");
}}
/>
<section className="panel">
<h1 className="section-title">Audit Trail</h1>
<p className="muted">Authentication events, SQL executions, and administrative changes are collected here.</p>
<AuditTable rows={auditQuery.data ?? []} />
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { useMemo, useState } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { api } from "../shared/api/client";
import type { SidebarGroup, TableColumn, TableIndex } from "../shared/types";
import { useLogout, useSession } from "../features/auth/use-session";
import { Sidebar } from "../widgets/Sidebar";
import { Topbar } from "../widgets/Topbar";
import { DataGrid } from "../widgets/DataGrid";
import { SchemaEditor } from "../widgets/SchemaEditor";
import { SqlConsole } from "../widgets/SqlConsole";
import { LogViewer } from "../widgets/LogViewer";
type RecordResponse = {
data: Array<Record<string, unknown>>;
meta: {
page: number;
limit: number;
total: number;
totalPages: number;
};
};
export function DashboardPage() {
const navigate = useNavigate();
const session = useSession();
const logout = useLogout();
const [currentTable, setCurrentTable] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"data" | "structure" | "sql" | "logs">("data");
const groupsQuery = useQuery({
queryKey: ["sidebar"],
queryFn: async () => {
const response = await api.get<SidebarGroup[]>("/navigation/sidebar");
return response.data;
},
enabled: session.isSuccess
});
const columnsQuery = useQuery({
queryKey: ["structure", currentTable],
queryFn: async () => {
const response = await api.get<TableColumn[]>(`/tables/${currentTable}/structure`);
return response.data;
},
enabled: Boolean(currentTable)
});
const indexesQuery = useQuery({
queryKey: ["indexes", currentTable],
queryFn: async () => {
const response = await api.get<TableIndex[]>(`/tables/${currentTable}/indexes`);
return response.data;
},
enabled: Boolean(currentTable)
});
const relationsQuery = useQuery({
queryKey: ["relations", currentTable],
queryFn: async () => {
const response = await api.get<Array<Record<string, unknown>>>(`/tables/${currentTable}/relations`);
return response.data;
},
enabled: Boolean(currentTable)
});
const recordsQuery = useQuery({
queryKey: ["records", currentTable],
queryFn: async () => {
const response = await api.get<Array<Record<string, unknown>>>(
`/tables/${currentTable}/records?page=1&limit=25&search=&filters={}`
);
return {
data: response.data,
meta: response.meta as RecordResponse["meta"]
};
},
enabled: Boolean(currentTable)
});
const logsQuery = useQuery({
queryKey: ["logs"],
queryFn: async () => {
const response = await api.get<string[]>("/logs?q=&severity=&page=1&limit=50");
return response.data;
},
enabled: session.isSuccess
});
const tableCount = useMemo(
() => groupsQuery.data?.reduce((sum, group) => sum + group.tables.length, 0) ?? 0,
[groupsQuery.data]
);
if (session.isError) {
return <Navigate to="/login" replace />;
}
if (session.isLoading || !session.data) {
return <main className="login-page"><section className="panel login-card">Loading session...</section></main>;
}
return (
<div className="app-shell">
<Sidebar groups={groupsQuery.data ?? []} currentTable={currentTable} onSelectTable={setCurrentTable} />
<main className="main-area">
<Topbar
user={session.data}
onOpenSql={() => setActiveTab("sql")}
onLogout={async () => {
await logout();
navigate("/login");
}}
/>
<section className="panel">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<div>
<h1 className="section-title">{currentTable ?? "Choose a table from the sidebar"}</h1>
<div className="muted">
{tableCount} tables across managed groups. Current role: <span className="code">{session.data.roleSlug}</span>
</div>
</div>
<div className="status-pill">Control DB + target DB mediated through backend API</div>
</div>
</section>
<div className="tabs">
{[
["data", "Data"],
["structure", "Structure"],
["sql", "SQL"],
["logs", "Logs"]
].map(([value, label]) => (
<button
key={value}
className={`tab ${activeTab === value ? "active" : ""}`}
type="button"
onClick={() => setActiveTab(value as typeof activeTab)}
>
{label}
</button>
))}
</div>
<div className="content-grid">
<div className="stack">
{activeTab === "data" ? <DataGrid rows={recordsQuery.data?.data ?? []} /> : null}
{activeTab === "structure" ? (
<SchemaEditor
columns={columnsQuery.data ?? []}
indexes={indexesQuery.data ?? []}
relations={relationsQuery.data ?? []}
/>
) : null}
<SqlConsole visible={activeTab === "sql"} />
{activeTab === "logs" ? <LogViewer logs={logsQuery.data ?? []} /> : null}
</div>
<aside className="stack">
<section className="panel">
<h3 className="section-title">Current Table Summary</h3>
<div className="stack muted">
<div>Columns: {columnsQuery.data?.length ?? 0}</div>
<div>Indexes: {indexesQuery.data?.length ?? 0}</div>
<div>Relations: {relationsQuery.data?.length ?? 0}</div>
<div>Loaded rows: {recordsQuery.data?.data.length ?? 0}</div>
</div>
</section>
<section className="panel">
<h3 className="section-title">Operational Notes</h3>
<div className="stack muted">
<div>Backend enforces RBAC and table/group scoping.</div>
<div>SQL console is mediated by a guard layer and fully audited.</div>
<div>Schema and record mutations are intended to flow only through API policies.</div>
</div>
</section>
</aside>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { Database } from "lucide-react";
import { api } from "../shared/api/client";
export function LoginPage() {
const navigate = useNavigate();
const [username, setUsername] = useState("root");
const [password, setPassword] = useState("root12345");
const [error, setError] = useState("");
return (
<main className="login-page">
<section className="panel login-card stack">
<div style={{ textAlign: "center" }}>
<div className="brand" style={{ justifyContent: "center", color: "var(--text)" }}>
<Database size={36} />
<div>PostgreSQL SensoLab</div>
</div>
<p className="muted">Secure PostgreSQL administration with RBAC, audit, and SQL guardrails.</p>
</div>
<label className="stack">
<span>Username</span>
<input className="input" value={username} onChange={(event) => setUsername(event.target.value)} />
</label>
<label className="stack">
<span>Password</span>
<input
className="input"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{error ? <div className="muted" style={{ color: "var(--danger)" }}>{error}</div> : null}
<button
className="button primary"
type="button"
onClick={async () => {
try {
setError("");
await api.post("/auth/login", { username, password });
navigate("/");
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Login failed");
}
}}
>
Sign in
</button>
</section>
</main>
);
}

View File

@@ -0,0 +1,69 @@
import { Navigate, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { api } from "../shared/api/client";
import { useLogout, useSession } from "../features/auth/use-session";
import { Sidebar } from "../widgets/Sidebar";
import { Topbar } from "../widgets/Topbar";
export function RolesPage() {
const navigate = useNavigate();
const session = useSession();
const logout = useLogout();
const rolesQuery = useQuery({
queryKey: ["roles"],
queryFn: async () => {
const response = await api.get<Array<Record<string, unknown>>>("/roles");
return response.data;
},
enabled: session.isSuccess
});
if (session.isError) {
return <Navigate to="/login" replace />;
}
if (session.isLoading || !session.data) {
return <main className="login-page"><section className="panel login-card">Loading roles...</section></main>;
}
return (
<div className="app-shell">
<Sidebar groups={[]} currentTable={null} onSelectTable={() => undefined} />
<main className="main-area">
<Topbar
user={session.data}
onOpenSql={() => navigate("/")}
onLogout={async () => {
await logout();
navigate("/login");
}}
/>
<section className="panel">
<h1 className="section-title">Roles and Permissions</h1>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Description</th>
<th>Permissions</th>
</tr>
</thead>
<tbody>
{(rolesQuery.data ?? []).map((row) => (
<tr key={String(row.id)}>
<td>{String(row.name ?? "")}</td>
<td>{String(row.slug ?? "")}</td>
<td>{String(row.description ?? "")}</td>
<td className="code">{JSON.stringify(row.permissions ?? [])}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { Navigate, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { api } from "../shared/api/client";
import { useLogout, useSession } from "../features/auth/use-session";
import { Sidebar } from "../widgets/Sidebar";
import { Topbar } from "../widgets/Topbar";
export function UsersPage() {
const navigate = useNavigate();
const session = useSession();
const logout = useLogout();
const usersQuery = useQuery({
queryKey: ["users"],
queryFn: async () => {
const response = await api.get<Array<Record<string, unknown>>>("/users");
return response.data;
},
enabled: session.isSuccess
});
if (session.isError) {
return <Navigate to="/login" replace />;
}
if (session.isLoading || !session.data) {
return <main className="login-page"><section className="panel login-card">Loading users...</section></main>;
}
return (
<div className="app-shell">
<Sidebar groups={[]} currentTable={null} onSelectTable={() => undefined} />
<main className="main-area">
<Topbar
user={session.data}
onOpenSql={() => navigate("/")}
onLogout={async () => {
await logout();
navigate("/login");
}}
/>
<section className="panel">
<h1 className="section-title">User Management</h1>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Username</th>
<th>Active</th>
<th>Locked</th>
<th>Roles</th>
</tr>
</thead>
<tbody>
{(usersQuery.data ?? []).map((row) => (
<tr key={String(row.id)}>
<td>{String(row.username ?? "")}</td>
<td>{String(row.is_active ?? "")}</td>
<td>{String(row.is_locked ?? "")}</td>
<td className="code">{JSON.stringify(row.roles ?? [])}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,55 @@
export type ApiSuccess<T> = {
success: true;
data: T;
meta: Record<string, unknown>;
};
export type ApiError = {
success: false;
error: {
code: string;
message: string;
details?: unknown;
requestId: string;
};
};
export type ApiResponse<T> = ApiSuccess<T> | ApiError;
const API_BASE = "/api/v1";
async function request<T>(path: string, init?: RequestInit): Promise<ApiSuccess<T>> {
const response = await fetch(`${API_BASE}${path}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {})
},
...init
});
const payload = (await response.json()) as ApiResponse<T>;
if (!response.ok || !payload.success) {
throw new Error(payload.success ? "Request failed" : payload.error.message);
}
return payload;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: "POST",
body: body ? JSON.stringify(body) : undefined
}),
put: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: "PUT",
body: body ? JSON.stringify(body) : undefined
}),
delete: <T>(path: string) =>
request<T>(path, {
method: "DELETE"
})
};

View File

@@ -0,0 +1,41 @@
export type PermissionGrant = {
resource: string;
action: string;
scopeType: string;
scopeValue: string | null;
};
export type SessionUser = {
id: string;
username: string;
roleSlug: string;
isRoot: boolean;
permissions: PermissionGrant[];
};
export type SidebarGroup = {
slug: string;
name: string;
tables: Array<{
name: string;
schema: string;
group_slug: string;
display_name: string;
estimated_rows: number;
}>;
};
export type TableColumn = {
name: string;
type: string;
nullable: boolean;
default_value: string | null;
is_primary: boolean;
};
export type TableIndex = {
name: string;
definition: string;
unique: boolean;
type: string;
};

View File

@@ -0,0 +1,32 @@
type AuditTableProps = {
rows: Array<Record<string, unknown>>;
};
export function AuditTable({ rows }: AuditTableProps) {
return (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>Resource</th>
<th>Status</th>
<th>Actor</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={String(row.id)}>
<td>{String(row.created_at ?? "")}</td>
<td>{String(row.action ?? "")}</td>
<td>{String(row.resource_type ?? "")}</td>
<td>{String(row.status ?? "")}</td>
<td>{String(row.actor_user_id ?? "")}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,44 @@
type DataGridProps = {
rows: Array<Record<string, unknown>>;
};
export function DataGrid({ rows }: DataGridProps) {
if (rows.length === 0) {
return <div className="empty-state">No rows found for the current query.</div>;
}
const columns = Object.keys(rows[0]);
return (
<div className="table-wrap">
<table>
<thead>
<tr>
{columns.map((column) => (
<th key={column}>{column}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index}>
{columns.map((column) => (
<td key={column}>{renderValue(row[column])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function renderValue(value: unknown) {
if (value === null || value === undefined) {
return <span className="muted">null</span>;
}
if (typeof value === "object") {
return <code className="code">{JSON.stringify(value)}</code>;
}
return String(value);
}

View File

@@ -0,0 +1,27 @@
type LogViewerProps = {
logs: string[];
};
export function LogViewer({ logs }: LogViewerProps) {
return (
<section className="panel">
<h3 className="section-title">PostgreSQL Logs</h3>
<div className="table-wrap" style={{ maxHeight: 360 }}>
<table>
<thead>
<tr>
<th>Entry</th>
</tr>
</thead>
<tbody>
{logs.map((line, index) => (
<tr key={index}>
<td className="code">{line}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}

View File

@@ -0,0 +1,93 @@
import type { TableColumn, TableIndex } from "../shared/types";
type SchemaEditorProps = {
columns: TableColumn[];
indexes: TableIndex[];
relations: Array<Record<string, unknown>>;
};
export function SchemaEditor({ columns, indexes, relations }: SchemaEditorProps) {
return (
<div className="stack">
<section className="panel">
<h3 className="section-title">Columns</h3>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Nullable</th>
<th>Default</th>
<th>PK</th>
</tr>
</thead>
<tbody>
{columns.map((column) => (
<tr key={column.name}>
<td>{column.name}</td>
<td>{column.type}</td>
<td>{column.nullable ? "Yes" : "No"}</td>
<td>{column.default_value ?? "-"}</td>
<td>{column.is_primary ? "Yes" : "No"}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="panel">
<h3 className="section-title">Indexes</h3>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Unique</th>
<th>Definition</th>
</tr>
</thead>
<tbody>
{indexes.map((index) => (
<tr key={index.name}>
<td>{index.name}</td>
<td>{index.type}</td>
<td>{index.unique ? "Yes" : "No"}</td>
<td className="code">{index.definition}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="panel">
<h3 className="section-title">Relations</h3>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Constraint</th>
<th>Column</th>
<th>Foreign Table</th>
<th>Foreign Column</th>
</tr>
</thead>
<tbody>
{relations.map((relation, index) => (
<tr key={index}>
<td>{String(relation.constraint_name ?? "")}</td>
<td>{String(relation.column_name ?? "")}</td>
<td>{String(relation.foreign_table_name ?? "")}</td>
<td>{String(relation.foreign_column_name ?? "")}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { Database, FileClock, Shield, Users } from "lucide-react";
import { NavLink } from "react-router-dom";
import type { SidebarGroup } from "../shared/types";
type SidebarProps = {
groups: SidebarGroup[];
currentTable: string | null;
onSelectTable: (tableName: string) => void;
};
export function Sidebar({ groups, currentTable, onSelectTable }: SidebarProps) {
return (
<aside className="sidebar">
<div className="brand">
<Database size={28} />
<div>
<div>PostgreSQL SensoLab</div>
<div className="badge">Production control plane</div>
</div>
</div>
<div className="sidebar-group">
<div className="sidebar-title">Navigation</div>
<NavLink className="nav-link" to="/">
<span>Dashboard</span>
<Database size={16} />
</NavLink>
<NavLink className="nav-link" to="/users">
<span>Users</span>
<Users size={16} />
</NavLink>
<NavLink className="nav-link" to="/roles">
<span>Roles</span>
<Shield size={16} />
</NavLink>
<NavLink className="nav-link" to="/audit">
<span>Audit</span>
<FileClock size={16} />
</NavLink>
</div>
<div className="sidebar-group" style={{ flex: 1, overflow: "auto" }}>
<div className="sidebar-title">Table Groups</div>
{groups.map((group) => (
<div key={group.slug} className="sidebar-group">
<div className="muted" style={{ fontSize: 13, fontWeight: 700 }}>
{group.name}
</div>
{group.tables.map((table) => (
<button
key={table.name}
className={`sidebar-item ${currentTable === table.name ? "active" : ""}`}
onClick={() => onSelectTable(table.name)}
type="button"
>
<span>{table.display_name}</span>
<span className="badge">{table.estimated_rows}</span>
</button>
))}
</div>
))}
</div>
</aside>
);
}

View File

@@ -0,0 +1,87 @@
import { useState } from "react";
import { api } from "../shared/api/client";
type SqlConsoleProps = {
visible: boolean;
};
export function SqlConsole({ visible }: SqlConsoleProps) {
const [sql, setSql] = useState("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';");
const [result, setResult] = useState<Record<string, unknown>[]>([]);
const [meta, setMeta] = useState<{
rowCount: number;
durationMs: number;
statementType: string;
} | null>(null);
const [error, setError] = useState("");
if (!visible) {
return null;
}
return (
<section className="panel">
<h3 className="section-title">SQL Console</h3>
<div className="stack">
<textarea className="textarea" value={sql} onChange={(event) => setSql(event.target.value)} />
<div className="row">
<button
className="button primary"
type="button"
onClick={async () => {
try {
setError("");
const response = await api.post<{
rows: Record<string, unknown>[];
rowCount: number;
durationMs: number;
statementType: string;
fields: Array<{ name: string }>;
notice?: string;
}>("/sql/execute", { sql });
setResult(response.data.rows);
setMeta({
rowCount: response.data.rowCount,
durationMs: response.data.durationMs,
statementType: response.data.statementType
});
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "SQL execution failed");
}
}}
>
Execute
</button>
</div>
{error ? <div className="muted" style={{ color: "var(--danger)" }}>{error}</div> : null}
{meta ? (
<div className="muted">
{meta.statementType} · {meta.rowCount} rows · {meta.durationMs} ms
</div>
) : null}
{result.length > 0 ? (
<div className="table-wrap">
<table>
<thead>
<tr>
{Object.keys(result[0]).map((key) => (
<th key={key}>{key}</th>
))}
</tr>
</thead>
<tbody>
{result.map((row, index) => (
<tr key={index}>
{Object.keys(result[0]).map((key) => (
<td key={key}>{String(row[key] ?? "")}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
</section>
);
}

View File

@@ -0,0 +1,33 @@
import { LogOut, ShieldCheck, Terminal } from "lucide-react";
import type { SessionUser } from "../shared/types";
type TopbarProps = {
user: SessionUser;
onOpenSql: () => void;
onLogout: () => void;
};
export function Topbar({ user, onOpenSql, onLogout }: TopbarProps) {
return (
<header className="topbar">
<div>
<div className="status-pill">
<ShieldCheck size={16} />
<span>
{user.username} · {user.roleSlug}
</span>
</div>
</div>
<div className="row">
<button className="button secondary" type="button" onClick={onOpenSql}>
<Terminal size={16} style={{ marginRight: 8 }} />
SQL Console
</button>
<button className="button danger" type="button" onClick={onLogout}>
<LogOut size={16} style={{ marginRight: 8 }} />
Logout
</button>
</div>
</header>
);
}