mirror of
https://github.com/ershisan99/db-studio.git
synced 2025-12-18 12:33:07 +00:00
initial commit
This commit is contained in:
128
frontend/src/routes/__root.tsx
Normal file
128
frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
ModeToggle,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
buttonVariants,
|
||||
} from "@/components/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDatabasesListQuery, useTablesListQuery } from "@/services/db";
|
||||
import {
|
||||
Link,
|
||||
Outlet,
|
||||
createRootRoute,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
||||
import { Database, Rows3, Table2 } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
|
||||
const searchSchema = z.object({
|
||||
dbSchema: z.string().optional().catch(""),
|
||||
});
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root,
|
||||
validateSearch: (search) => searchSchema.parse(search),
|
||||
});
|
||||
|
||||
function Root() {
|
||||
const { data } = useDatabasesListQuery();
|
||||
|
||||
const params = useParams({ strict: false });
|
||||
const dbName = params.dbName ?? "";
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const handleSelectedSchema = (schema: string) => {
|
||||
void navigate({ to: "/db/$dbName/tables", params: { dbName: schema } });
|
||||
};
|
||||
|
||||
const { data: tables } = useTablesListQuery({ dbName });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-screen grid grid-rows-layout grid-cols-layout">
|
||||
<header className="p-2 flex gap-2 border-b items-center col-span-full">
|
||||
<ModeToggle />
|
||||
<Link to="/" className="[&.active]:font-bold">
|
||||
Home
|
||||
</Link>
|
||||
</header>
|
||||
<aside className={"p-3"}>
|
||||
<Select value={dbName} onValueChange={handleSelectedSchema}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Database Schema" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data?.map((schema) => {
|
||||
return (
|
||||
<SelectItem value={schema} key={schema}>
|
||||
{schema}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<nav className="flex flex-col gap-1 mt-4">
|
||||
{dbName && (
|
||||
<Link
|
||||
to={"/db/$dbName/tables"}
|
||||
params={{ dbName }}
|
||||
activeOptions={{ exact: true }}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded py-1.5 pl-1.5",
|
||||
"hover:bg-muted",
|
||||
"[&.active]:bg-muted [&.active]:font-semibold",
|
||||
)}
|
||||
>
|
||||
<Database className={"size-4"} /> {dbName}
|
||||
</Link>
|
||||
)}
|
||||
{tables?.map((table) => {
|
||||
return (
|
||||
<div
|
||||
key={table.table_name}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2.5 rounded py-1.5 justify-between w-full",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
className={cn(
|
||||
"w-full flex gap-2 items-center",
|
||||
"hover:underline",
|
||||
"[&.active]:font-semibold",
|
||||
)}
|
||||
to={"/db/$dbName/tables/$tableName"}
|
||||
params={{ tableName: table.table_name, dbName: dbName }}
|
||||
>
|
||||
<Table2 className={"size-4 shrink-0"} />
|
||||
{table.table_name}
|
||||
</Link>
|
||||
<Link
|
||||
className={cn(
|
||||
"hover:underline shrink-0",
|
||||
buttonVariants({ variant: "ghost", size: "iconSm" }),
|
||||
"[&.active]:bg-muted",
|
||||
)}
|
||||
title={"Explore Data"}
|
||||
aria-label={"Explore Data"}
|
||||
to={"/db/$dbName/tables/$tableName/data"}
|
||||
params={{ tableName: table.table_name, dbName: dbName }}
|
||||
search={{ pageIndex: 0, pageSize: 10 }}
|
||||
>
|
||||
<Rows3 className={"size-4 shrink-0"} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
<Outlet />
|
||||
</div>
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
frontend/src/routes/db/$dbName/tables/$tableName/data.tsx
Normal file
41
frontend/src/routes/db/$dbName/tables/$tableName/data.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { DataTable } from "@/components/db-table-view/data-table";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
const tableSearchSchema = z.object({
|
||||
pageSize: z.number().catch(10),
|
||||
pageIndex: z.number().catch(0),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/db/$dbName/tables/$tableName/data")({
|
||||
component: TableView,
|
||||
validateSearch: (search) => tableSearchSchema.parse(search),
|
||||
});
|
||||
|
||||
function TableView() {
|
||||
const { tableName, dbName } = Route.useParams();
|
||||
const { pageSize, pageIndex } = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const updatePageSize = (value: number) => {
|
||||
return void navigate({
|
||||
search: (prev) => ({ ...prev, pageSize: value, pageIndex: 0 }),
|
||||
});
|
||||
};
|
||||
const updatePageIndex = (pageIndex: number) => {
|
||||
return void navigate({ search: (prev) => ({ ...prev, pageIndex }) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3 h-layout w-layout">
|
||||
<DataTable
|
||||
dbName={dbName}
|
||||
tableName={tableName}
|
||||
pageSize={pageSize}
|
||||
pageIndex={pageIndex}
|
||||
onPageIndexChange={updatePageIndex}
|
||||
onPageSizeChange={updatePageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
frontend/src/routes/db/$dbName/tables/$tableName/index.tsx
Normal file
161
frontend/src/routes/db/$dbName/tables/$tableName/index.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type TableColumn,
|
||||
type TableForeignKey,
|
||||
type TableIndexEntry,
|
||||
useTableColumnsQuery,
|
||||
useTableForeignKeysQuery,
|
||||
useTableIndexesQuery,
|
||||
} from "@/services/db";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { ArrowRight, Table2 } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/db/$dbName/tables/$tableName/")({
|
||||
component: TableDetailsTable,
|
||||
});
|
||||
const columnHelper = createColumnHelper<TableColumn>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor("column_name", {
|
||||
header: "Column",
|
||||
}),
|
||||
columnHelper.accessor("data_type", {
|
||||
header: "Type",
|
||||
}),
|
||||
columnHelper.accessor("udt_name", {
|
||||
header: "UDT",
|
||||
}),
|
||||
columnHelper.accessor("column_comment", {
|
||||
header: "Comment",
|
||||
}),
|
||||
];
|
||||
|
||||
const tableIndexesColumnHelper = createColumnHelper<TableIndexEntry>();
|
||||
|
||||
const tableIndexesColumns = [
|
||||
tableIndexesColumnHelper.accessor("type", {
|
||||
header: "Type",
|
||||
}),
|
||||
tableIndexesColumnHelper.accessor("columns", {
|
||||
header: "Columns",
|
||||
cell: (props) => props.getValue().join(", "),
|
||||
}),
|
||||
tableIndexesColumnHelper.accessor("key", {
|
||||
header: "Key",
|
||||
}),
|
||||
];
|
||||
|
||||
const tableForeignKeysColumnHelper = createColumnHelper<TableForeignKey>();
|
||||
|
||||
const tableForeignKeysColumns = [
|
||||
tableForeignKeysColumnHelper.accessor("source", {
|
||||
header: "Source",
|
||||
cell: (props) => props.getValue().join(", "),
|
||||
}),
|
||||
tableForeignKeysColumnHelper.accessor("target", {
|
||||
header: "Target",
|
||||
cell: (props) => {
|
||||
const { table, target } = props.row.original;
|
||||
return (
|
||||
<Link
|
||||
from={Route.fullPath}
|
||||
to={"../$tableName"}
|
||||
params={{ tableName: table }}
|
||||
className={"hover:underline"}
|
||||
>
|
||||
{table} ({target.join(", ")})
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
}),
|
||||
tableForeignKeysColumnHelper.accessor("on_delete", {
|
||||
header: "On Delete",
|
||||
}),
|
||||
tableForeignKeysColumnHelper.accessor("on_update", {
|
||||
header: "On Update",
|
||||
}),
|
||||
];
|
||||
|
||||
function TableDetailsTable() {
|
||||
const { tableName: name } = Route.useParams();
|
||||
const { data: tableColumns } = useTableColumnsQuery({ name });
|
||||
const { data: tableIndexes } = useTableIndexesQuery({ name });
|
||||
const { data: tableForeignKeys } = useTableForeignKeysQuery({ name });
|
||||
|
||||
return (
|
||||
<div className={"p-3 w-layout"}>
|
||||
<div className={"flex flex-col gap-4 flex-1 max-h-full pb-3"}>
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h1 className={"text-2xl font-bold flex items-center gap-2"}>
|
||||
<Table2 /> {name}
|
||||
</h1>
|
||||
<Link
|
||||
from={Route.fullPath}
|
||||
to={"./data"}
|
||||
search={{
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
}}
|
||||
className={cn("flex gap-2 items-center", "hover:underline")}
|
||||
>
|
||||
Explore data <ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h2 className={"text-xl font-bold flex items-center gap-2"}>
|
||||
Columns
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
|
||||
}
|
||||
>
|
||||
<DataTable columns={columns} data={tableColumns ?? []} />
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h2 className={"text-xl font-bold flex items-center gap-2"}>
|
||||
Indexes
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
|
||||
}
|
||||
>
|
||||
{tableIndexes?.length ? (
|
||||
<DataTable
|
||||
columns={tableIndexesColumns}
|
||||
data={tableIndexes ?? []}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center p-4">No indexes.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h2 className={"text-xl font-bold flex items-center gap-2"}>
|
||||
Foreign Keys
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
|
||||
}
|
||||
>
|
||||
{tableForeignKeys?.length ? (
|
||||
<DataTable
|
||||
columns={tableForeignKeysColumns}
|
||||
data={tableForeignKeys ?? []}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center p-4">No foreign keys.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
frontend/src/routes/db/$dbName/tables/index.tsx
Normal file
271
frontend/src/routes/db/$dbName/tables/index.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui";
|
||||
import { cn, prettyBytes } from "@/lib/utils";
|
||||
import { type TableInfo, useTablesListQuery } from "@/services/db";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
type SortingState,
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { ArrowUp, Database } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/db/$dbName/tables/")({
|
||||
component: Component,
|
||||
});
|
||||
|
||||
const columnHelper = createColumnHelper<TableInfo>();
|
||||
|
||||
const createColumns = (dbName: string) => {
|
||||
return [
|
||||
columnHelper.accessor("table_name", {
|
||||
header: "Name",
|
||||
|
||||
cell: (props) => {
|
||||
const tableName = props.getValue();
|
||||
return (
|
||||
<div className={"flex w-full"}>
|
||||
<Link
|
||||
className={"hover:underline w-full"}
|
||||
to={"/db/$dbName/tables/$tableName"}
|
||||
params={{ dbName, tableName }}
|
||||
>
|
||||
{tableName}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("table_size", {
|
||||
header: "Table Size",
|
||||
cell: (props) => prettyBytes(props.getValue()),
|
||||
meta: {
|
||||
className: "text-end",
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("index_size", {
|
||||
header: "Index Size",
|
||||
cell: (props) => prettyBytes(props.getValue()),
|
||||
meta: {
|
||||
className: "text-end",
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("total_size", {
|
||||
header: "Total Size",
|
||||
cell: (props) => prettyBytes(props.getValue()),
|
||||
meta: {
|
||||
className: "text-end",
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("row_count", {
|
||||
header: "Rows",
|
||||
meta: {
|
||||
className: "text-end",
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("primary_key", {
|
||||
header: "Primary key",
|
||||
}),
|
||||
|
||||
columnHelper.accessor("indexes", {
|
||||
header: "Indexes",
|
||||
cell: (props) => {
|
||||
const indexes = props.getValue();
|
||||
if (!indexes) return null;
|
||||
return (
|
||||
<div>
|
||||
{indexes?.split(",").map((index) => (
|
||||
<div key={index}>{index}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.accessor("comments", {
|
||||
header: "Comment",
|
||||
}),
|
||||
|
||||
columnHelper.accessor("schema_name", {
|
||||
header: "Schema",
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
function Component() {
|
||||
const { dbName } = Route.useParams();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const { data } = useTablesListQuery({
|
||||
dbName,
|
||||
sortDesc: sorting[0]?.desc,
|
||||
sortField: sorting[0]?.id,
|
||||
});
|
||||
|
||||
const details = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
return data;
|
||||
}, [data]);
|
||||
|
||||
const columns = useMemo(() => createColumns(dbName), [dbName]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: details,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualSorting: true,
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={"p-3 h-layout w-layout"}>
|
||||
<div className={"flex flex-col gap-4 flex-1 max-h-full pb-3"}>
|
||||
<div className={"flex gap-4 items-center justify-between"}>
|
||||
<h1 className={"text-2xl font-bold flex items-center gap-2"}>
|
||||
<Database /> {dbName}
|
||||
</h1>
|
||||
<p>
|
||||
Tables: <strong>{details?.length ?? 0}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"overflow-auto rounded-md border min-h-0 h-full min-w-0 w-full"
|
||||
}
|
||||
>
|
||||
<Table
|
||||
className={"min-w-full"}
|
||||
{...{
|
||||
style: {
|
||||
width: table.getCenterTotalSize(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const sorted = header.column.getIsSorted();
|
||||
if (header.column.getCanSort()) {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
"p-0",
|
||||
header.column.columnDef.meta?.className,
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
title={
|
||||
header.column.getNextSortingOrder() === "asc"
|
||||
? "Sort ascending"
|
||||
: header.column.getNextSortingOrder() === "desc"
|
||||
? "Sort descending"
|
||||
: "Clear sort"
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
<ArrowUp
|
||||
className={cn(
|
||||
"ml-2 size-4 opacity-0",
|
||||
sorted && "opacity-100",
|
||||
(sorted as string) === "desc" && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableHead
|
||||
className={cn(
|
||||
"text-nowrap",
|
||||
header.column.columnDef.meta?.className,
|
||||
)}
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-nowrap",
|
||||
cell.column?.columnDef?.meta?.className,
|
||||
)}
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/routes/index.tsx
Normal file
26
frontend/src/routes/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Index,
|
||||
})
|
||||
|
||||
function Index() {
|
||||
const { dbSchema } = Route.useSearch()
|
||||
if (!dbSchema) {
|
||||
return (
|
||||
<div className="p-2">
|
||||
<h3>Welcome Home!</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <TableView dbSchema={dbSchema} />
|
||||
}
|
||||
|
||||
function TableView({ dbSchema }: { dbSchema: string }) {
|
||||
return (
|
||||
<div className="p-2">
|
||||
<h3>Table View</h3>
|
||||
{dbSchema}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user