add login support (wonky still)

This commit is contained in:
2024-07-08 18:41:12 +02:00
parent 7b9f93448b
commit d85df4c308
21 changed files with 1183 additions and 304 deletions

Binary file not shown.

View File

@@ -7,6 +7,7 @@
},
"dependencies": {
"@elysiajs/cors": "^1.0.2",
"@elysiajs/jwt": "^1.0.2",
"@it-incubator/prettier-config": "^0.1.2",
"elysia": "latest",
"postgres": "^3.4.4"

View File

@@ -0,0 +1,51 @@
export type WithSort<T> = T & { sortField?: string; sortDesc?: boolean };
export type WithPagination<T> = T & { perPage: number; page: number };
export type WithSortPagination<T> = WithPagination<WithSort<T>>;
export type Credentials =
| {
username: string;
password: string;
host: string;
type: string;
port: string;
database: string;
ssl: string;
}
| {
connectionString: string;
};
export interface Driver {
getAllDatabases(credentials: Credentials): Promise<string[]>;
getAllTables(
credentials: Credentials,
args: WithSort<{ dbName: string }>,
): Promise<any[]>;
getTableData(
credentials: Credentials,
args: WithSortPagination<{ tableName: string; dbName: string }>,
): Promise<{
count: number;
data: Record<string, any>[];
}>;
getTableColumns(
credentials: Credentials,
args: { dbName: string; tableName: string },
): Promise<any[]>;
getTableIndexes(
credentials: Credentials,
args: { dbName: string; tableName: string },
): Promise<any[]>;
getTableForeignKeys(
credentials: Credentials,
args: { dbName: string; tableName: string },
): Promise<any[]>;
executeQuery(
credentials: Credentials,
query: string,
): Promise<{
count: number;
data: Record<string, any>[];
}>;
}

352
api/src/drivers/postgres.ts Normal file
View File

@@ -0,0 +1,352 @@
import postgres, { type Options } from "postgres";
import type {
Credentials,
Driver,
WithSort,
WithSortPagination,
} from "./driver.interface";
export class PostgresDriver implements Driver {
private parseSsl(ssl: string) {
switch (ssl) {
case "false":
return false;
case "true":
return true;
default:
return ssl as Options<any>["ssl"];
}
}
parseCredentials({
username,
password,
host,
type,
port,
database,
ssl,
}: {
username: string;
password: string;
host: string;
type: string;
port: string;
database: string;
ssl: string;
}) {
return {
username,
password,
host,
type,
port: Number.parseInt(port, 10),
database,
ssl: this.parseSsl(ssl),
};
}
private async queryRunner(credentials: Credentials) {
let sql: postgres.Sql<any>;
if ("connectionString" in credentials) {
sql = postgres(credentials.connectionString);
} else {
sql = postgres("", this.parseCredentials(credentials));
}
return sql;
}
private getActions(matchString: string) {
const onActions = "RESTRICT|NO ACTION|CASCADE|SET NULL|SET DEFAULT";
const onDeleteRegex = new RegExp(`ON DELETE (${onActions})`);
const onUpdateRegex = new RegExp(`ON UPDATE (${onActions})`);
const onDeleteMatch = matchString.match(onDeleteRegex);
const onUpdateMatch = matchString.match(onUpdateRegex);
const onDeleteAction = onDeleteMatch ? onDeleteMatch[1] : "NO ACTION";
const onUpdateAction = onUpdateMatch ? onUpdateMatch[1] : "NO ACTION";
return {
onDelete: onDeleteAction,
onUpdate: onUpdateAction,
};
}
async getAllDatabases(credentials: Credentials) {
const sql = await this.queryRunner(credentials);
const result = await sql`
SELECT nspname
FROM pg_catalog.pg_namespace;`;
void sql.end();
return result.map(({ nspname }) => nspname);
}
async getAllTables(
credentials: Credentials,
{ sortDesc, sortField, dbName }: WithSort<{ dbName: string }>,
) {
const sql = await this.queryRunner(credentials);
const tables = await sql`
WITH primary_keys AS (SELECT pg_class.relname AS table_name,
pg_namespace.nspname AS schema_name,
pg_attribute.attname AS primary_key
FROM pg_index
JOIN
pg_class ON pg_class.oid = pg_index.indrelid
JOIN
pg_attribute ON pg_attribute.attrelid = pg_class.oid AND
pg_attribute.attnum = ANY (pg_index.indkey)
JOIN
pg_namespace ON pg_namespace.oid = pg_class.relnamespace
WHERE pg_index.indisprimary)
SELECT t.schemaname AS schema_name,
t.tablename AS table_name,
pg_total_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS total_size,
pg_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS table_size,
pg_total_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) -
pg_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS index_size,
COALESCE(
(SELECT obj_description((quote_ident(t.schemaname) || '.' || quote_ident(t.tablename))::regclass)),
''
) AS comments,
(SELECT reltuples::bigint
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = t.tablename
AND n.nspname = t.schemaname) AS row_count,
(SELECT string_agg(indexname, ', ')
FROM pg_indexes
WHERE tablename = t.tablename
AND schemaname = t.schemaname) AS indexes,
t.tableowner AS owner,
COALESCE(
(SELECT string_agg(pk.primary_key, ', ')
FROM primary_keys pk
WHERE pk.schema_name = t.schemaname
AND pk.table_name = t.tablename),
''
) AS primary_key
FROM pg_tables t
WHERE t.schemaname = ${dbName}
ORDER BY ${
sortField
? sql`${sql(sortField)}
${sortDesc ? sql`DESC` : sql`ASC`}`
: sql`t.schemaname, t.tablename`
}`;
void sql.end();
return tables.map((table) => ({
...table,
total_size: Number.parseInt(table.total_size, 10),
table_size: Number.parseInt(table.table_size, 10),
index_size: Number.parseInt(table.index_size, 10),
row_count: Number.parseInt(table.row_count, 10),
}));
}
async getTableData(
credentials: Credentials,
{
tableName,
dbName,
perPage,
page,
sortDesc,
sortField,
}: WithSortPagination<{ tableName: string; dbName: string }>,
) {
const sql = await this.queryRunner(credentials);
const offset = (perPage * page).toString();
const rows = sql`
SELECT COUNT(*)
FROM ${sql(dbName)}.${sql(tableName)}`;
const tables = sql`
SELECT *
FROM ${sql(dbName)}.${sql(tableName)}
${sortField ? sql`ORDER BY ${sql(sortField)} ${sortDesc ? sql`DESC` : sql`ASC`}` : sql``}
LIMIT ${perPage} OFFSET ${offset}
`;
const [[count], data] = await Promise.all([rows, tables]);
void sql.end();
return {
count: count.count,
data,
};
}
async getTableColumns(
credentials: Credentials,
{ tableName, dbName }: { dbName: string; tableName: string },
) {
const sql = await this.queryRunner(credentials);
const result = await sql`
SELECT
cols.column_name,
cols.data_type,
cols.udt_name,
pgd.description AS column_comment
FROM
information_schema.columns AS cols
LEFT JOIN
pg_catalog.pg_statio_all_tables AS st ON st.relname = cols.table_name
LEFT JOIN
pg_catalog.pg_description AS pgd ON pgd.objoid = st.relid AND pgd.objsubid = cols.ordinal_position
WHERE
cols.table_name = ${tableName}
AND cols.table_schema = ${dbName}
ORDER BY
cols.ordinal_position;
`;
void sql.end();
return result;
}
async getTableIndexes(
credentials: Credentials,
{ dbName, tableName }: { dbName: string; tableName: string },
) {
const sql = await this.queryRunner(credentials);
const [tableOidResult] = await sql`
SELECT oid
FROM pg_class
WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = ${dbName})
AND relname = ${tableName}
`;
const tableOid = tableOidResult.oid;
const columnsResult = await sql`
SELECT attnum, attname
FROM pg_attribute
WHERE attrelid = ${tableOid}
AND attnum > 0
`;
const columns = {};
columnsResult.forEach((row) => {
columns[row.attnum] = row.attname;
});
const indexResult = await sql`
SELECT
relname,
indisunique::int,
indisprimary::int,
indkey,
(indpred IS NOT NULL)::int as indispartial
FROM pg_index i
JOIN pg_class ci ON ci.oid = i.indexrelid
WHERE i.indrelid = ${tableOid}
`;
void sql.end();
return indexResult.map((row) => {
return {
relname: row.relname,
key: row.relname,
type: row.indispartial
? "INDEX"
: row.indisprimary
? "PRIMARY"
: row.indisunique
? "UNIQUE"
: "INDEX",
columns: row.indkey.split(" ").map((indkey) => columns[indkey]),
};
});
}
async getTableForeignKeys(
credentials: Credentials,
{ dbName, tableName }: { dbName: string; tableName: string },
) {
const sql = await this.queryRunner(credentials);
const result = await sql`
SELECT
conname,
condeferrable::int AS deferrable,
pg_get_constraintdef(oid) AS definition
FROM
pg_constraint
WHERE
conrelid = (
SELECT pc.oid
FROM pg_class AS pc
INNER JOIN pg_namespace AS pn ON (pn.oid = pc.relnamespace)
WHERE pc.relname = ${tableName}
AND pn.nspname = ${dbName}
)
AND contype = 'f'::char
ORDER BY conkey, conname
`;
void sql.end();
return result.map((row) => {
const match = row.definition.match(
/FOREIGN KEY\s*\((.+)\)\s*REFERENCES (.+)\((.+)\)(.*)$/iy,
);
if (match) {
const sourceColumns = match[1]
.split(",")
.map((col) => col.replaceAll('"', "").trim());
const targetTableMatch = match[2].match(
/^(("([^"]|"")+"|[^"]+)\.)?"?("([^"]|"")+"|[^"]+)$/,
);
const targetTable = targetTableMatch
? targetTableMatch[0].trim()
: null;
const targetColumns = match[3]
.split(",")
.map((col) => col.replaceAll('"', "").trim());
const { onDelete, onUpdate } = this.getActions(match[4]);
return {
conname: row.conname,
deferrable: Boolean(row.deferrable),
definition: row.definition,
source: sourceColumns,
ns: targetTableMatch
? targetTableMatch[0].replaceAll('"', "").trim()
: null,
table: targetTable.replaceAll('"', ""),
target: targetColumns,
on_delete: onDelete ?? "NO ACTION",
on_update: onUpdate ?? "NO ACTION",
};
}
});
}
async executeQuery(credentials: Credentials, query: string) {
const sql = await this.queryRunner(credentials);
const result = await sql.unsafe(query);
void sql.end();
return {
count: result.length,
data: result,
};
}
}
export const postgresDriver = new PostgresDriver();

View File

@@ -1,39 +1,90 @@
import cors from "@elysiajs/cors";
import { jwt } from "@elysiajs/jwt";
import { Elysia, t } from "elysia";
import postgres from "postgres";
const DB_URL = Bun.env.DB_URL;
if (!DB_URL) {
console.error("❗DB_URL not found in environment variables");
process.exit(1);
}
const sql = postgres(DB_URL);
const [{ version }] = await sql`SELECT version()`;
console.log("pg version: ", version);
import { postgresDriver } from "./drivers/postgres";
const credentialsSchema = t.Union([
t.Object({
username: t.String(),
password: t.String(),
host: t.String(),
type: t.String(),
port: t.String(),
database: t.String(),
ssl: t.String(),
}),
t.Object({
connectionString: t.String(),
}),
]);
const app = new Elysia({ prefix: "/api" })
.use(
jwt({
name: "jwt",
secret: "Fischl von Luftschloss Narfidort",
schema: credentialsSchema,
}),
)
.get("/", () => "Hello Elysia")
.get("/databases", async () => {
const databases = await getDatabases();
.post(
"/auth/login",
async ({ body, jwt, cookie: { auth } }) => {
const databases = await postgresDriver.getAllDatabases(body);
auth.set({
value: await jwt.sign(body),
httpOnly: true,
});
return { success: true, databases };
},
{ body: credentialsSchema },
)
.get("/databases", async ({ jwt, set, cookie: { auth } }) => {
const credentials = await jwt.verify(auth.value);
if (!credentials) {
set.status = 401;
return "Unauthorized";
}
const databases = await postgresDriver.getAllDatabases(credentials);
return new Response(JSON.stringify(databases, null, 2)).json();
})
.get("/databases/:dbName/tables", async ({ query, params }) => {
const { sortField, sortDesc } = query;
const { dbName } = params;
.get(
"/databases/:dbName/tables",
async ({ query, params, jwt, set, cookie: { auth } }) => {
const { sortField, sortDesc } = query;
const { dbName } = params;
const credentials = await jwt.verify(auth.value);
const tables = await getTables(dbName, sortField, sortDesc === "true");
if (!credentials) {
set.status = 401;
return "Unauthorized";
}
return new Response(JSON.stringify(tables, null, 2)).json();
})
const tables = await postgresDriver.getAllTables(credentials, {
dbName,
sortField,
sortDesc: sortDesc === "true",
});
return new Response(JSON.stringify(tables, null, 2)).json();
},
)
.get(
"databases/:dbName/tables/:tableName/data",
async ({ params, query }) => {
async ({ query, params, jwt, set, cookie: { auth } }) => {
const { tableName, dbName } = params;
const { perPage = "50", page = "0", sortField, sortDesc } = query;
return getTableData({
const credentials = await jwt.verify(auth.value);
if (!credentials) {
set.status = 401;
return "Unauthorized";
}
return postgresDriver.getTableData(credentials, {
tableName,
dbName,
perPage: Number.parseInt(perPage, 10),
@@ -45,37 +96,74 @@ const app = new Elysia({ prefix: "/api" })
)
.get(
"databases/:dbName/tables/:tableName/columns",
async ({ params, query }) => {
async ({ params, jwt, set, cookie: { auth } }) => {
const { tableName, dbName } = params;
const credentials = await jwt.verify(auth.value);
const columns = await getColumns(dbName, tableName);
if (!credentials) {
set.status = 401;
return "Unauthorized";
}
const columns = await postgresDriver.getTableColumns(credentials, {
dbName,
tableName,
});
return new Response(JSON.stringify(columns, null, 2)).json();
},
)
.get(
"databases/:dbName/tables/:tableName/indexes",
async ({ params, query }) => {
async ({ params, jwt, set, cookie: { auth } }) => {
const { tableName, dbName } = params;
const credentials = await jwt.verify(auth.value);
const indexes = await getIndexes(dbName, tableName);
if (!credentials) {
set.status = 401;
return "Unauthorized";
}
const indexes = await postgresDriver.getTableIndexes(credentials, {
dbName,
tableName,
});
return new Response(JSON.stringify(indexes, null, 2)).json();
},
)
.get(
"databases/:dbName/tables/:tableName/foreign-keys",
async ({ params, query }) => {
async ({ params, jwt, set, cookie: { auth } }) => {
const { tableName, dbName } = params;
const credentials = await jwt.verify(auth.value);
if (!credentials) {
set.status = 401;
return "Unauthorized";
}
const foreignKeys = await postgresDriver.getTableForeignKeys(
credentials,
{
dbName,
tableName,
},
);
const foreignKeys = await getForeignKeys(dbName, tableName);
return new Response(JSON.stringify(foreignKeys, null, 2)).json();
},
)
.post(
"raw",
async ({ body }) => {
async ({ body, jwt, set, cookie: { auth } }) => {
const credentials = await jwt.verify(auth.value);
if (!credentials) {
set.status = 401;
return "Unauthorized";
}
const { query } = body;
const result = await sql.unsafe(query);
return new Response(JSON.stringify(result, null, 2)).json();
return await postgresDriver.executeQuery(credentials, query);
},
{
body: t.Object({
@@ -83,262 +171,14 @@ const app = new Elysia({ prefix: "/api" })
}),
},
)
.use(cors())
.use(
cors({
origin: ["localhost:5173"],
allowedHeaders: ["Content-Type", "Authorization"],
}),
)
.listen(3000);
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
);
async function getIndexes(dbName: string, tableName: string) {
const [tableOidResult] = await sql`
SELECT oid
FROM pg_class
WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = ${dbName})
AND relname = ${tableName}
`;
const tableOid = tableOidResult.oid;
const columnsResult = await sql`
SELECT attnum, attname
FROM pg_attribute
WHERE attrelid = ${tableOid}
AND attnum > 0
`;
const columns = {};
columnsResult.forEach((row) => {
columns[row.attnum] = row.attname;
});
const indexResult = await sql`
SELECT
relname,
indisunique::int,
indisprimary::int,
indkey,
(indpred IS NOT NULL)::int as indispartial
FROM pg_index i
JOIN pg_class ci ON ci.oid = i.indexrelid
WHERE i.indrelid = ${tableOid}
`;
return indexResult.map((row) => {
return {
relname: row.relname,
key: row.relname,
type: row.indispartial
? "INDEX"
: row.indisprimary
? "PRIMARY"
: row.indisunique
? "UNIQUE"
: "INDEX",
columns: row.indkey.split(" ").map((indkey) => columns[indkey]),
};
});
}
async function getColumns(dbName: string, tableName: string) {
return await sql`
SELECT
cols.column_name,
cols.data_type,
cols.udt_name,
pgd.description AS column_comment
FROM
information_schema.columns AS cols
LEFT JOIN
pg_catalog.pg_statio_all_tables AS st ON st.relname = cols.table_name
LEFT JOIN
pg_catalog.pg_description AS pgd ON pgd.objoid = st.relid AND pgd.objsubid = cols.ordinal_position
WHERE
cols.table_name = ${tableName}
AND cols.table_schema = ${dbName}
ORDER BY
cols.ordinal_position;
`;
}
async function getForeignKeys(dbName: string, tableName: string) {
const result = await sql`
SELECT
conname,
condeferrable::int AS deferrable,
pg_get_constraintdef(oid) AS definition
FROM
pg_constraint
WHERE
conrelid = (
SELECT pc.oid
FROM pg_class AS pc
INNER JOIN pg_namespace AS pn ON (pn.oid = pc.relnamespace)
WHERE pc.relname = ${tableName}
AND pn.nspname = ${dbName}
)
AND contype = 'f'::char
ORDER BY conkey, conname
`;
return result.map((row) => {
const match = row.definition.match(
/FOREIGN KEY\s*\((.+)\)\s*REFERENCES (.+)\((.+)\)(.*)$/iy,
);
if (match) {
const sourceColumns = match[1]
.split(",")
.map((col) => col.replaceAll('"', "").trim());
const targetTableMatch = match[2].match(
/^(("([^"]|"")+"|[^"]+)\.)?"?("([^"]|"")+"|[^"]+)$/,
);
const targetTable = targetTableMatch ? targetTableMatch[0].trim() : null;
const targetColumns = match[3]
.split(",")
.map((col) => col.replaceAll('"', "").trim());
const { onDelete, onUpdate } = getActions(match[4]);
return {
conname: row.conname,
deferrable: Boolean(row.deferrable),
definition: row.definition,
source: sourceColumns,
ns: targetTableMatch
? targetTableMatch[0].replaceAll('"', "").trim()
: null,
table: targetTable.replaceAll('"', ""),
target: targetColumns,
on_delete: onDelete ?? "NO ACTION",
on_update: onUpdate ?? "NO ACTION",
};
}
});
}
function getActions(matchString: string) {
const onActions = "RESTRICT|NO ACTION|CASCADE|SET NULL|SET DEFAULT";
const onDeleteRegex = new RegExp(`ON DELETE (${onActions})`);
const onUpdateRegex = new RegExp(`ON UPDATE (${onActions})`);
const onDeleteMatch = matchString.match(onDeleteRegex);
const onUpdateMatch = matchString.match(onUpdateRegex);
const onDeleteAction = onDeleteMatch ? onDeleteMatch[1] : "NO ACTION";
const onUpdateAction = onUpdateMatch ? onUpdateMatch[1] : "NO ACTION";
return {
onDelete: onDeleteAction,
onUpdate: onUpdateAction,
};
}
async function getTables(
dbName: string,
sortField?: string,
sortDesc?: boolean,
) {
const tables = await sql`
WITH primary_keys AS (SELECT pg_class.relname AS table_name,
pg_namespace.nspname AS schema_name,
pg_attribute.attname AS primary_key
FROM pg_index
JOIN
pg_class ON pg_class.oid = pg_index.indrelid
JOIN
pg_attribute ON pg_attribute.attrelid = pg_class.oid AND
pg_attribute.attnum = ANY (pg_index.indkey)
JOIN
pg_namespace ON pg_namespace.oid = pg_class.relnamespace
WHERE pg_index.indisprimary)
SELECT t.schemaname AS schema_name,
t.tablename AS table_name,
pg_total_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS total_size,
pg_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS table_size,
pg_total_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) -
pg_relation_size(quote_ident(t.schemaname) || '.' || quote_ident(t.tablename)) AS index_size,
COALESCE(
(SELECT obj_description((quote_ident(t.schemaname) || '.' || quote_ident(t.tablename))::regclass)),
''
) AS comments,
(SELECT reltuples::bigint
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = t.tablename
AND n.nspname = t.schemaname) AS row_count,
(SELECT string_agg(indexname, ', ')
FROM pg_indexes
WHERE tablename = t.tablename
AND schemaname = t.schemaname) AS indexes,
t.tableowner AS owner,
COALESCE(
(SELECT string_agg(pk.primary_key, ', ')
FROM primary_keys pk
WHERE pk.schema_name = t.schemaname
AND pk.table_name = t.tablename),
''
) AS primary_key
FROM pg_tables t
WHERE t.schemaname = ${dbName}
ORDER BY ${
sortField
? sql`${sql(sortField)}
${sortDesc ? sql`DESC` : sql`ASC`}`
: sql`t.schemaname, t.tablename`
}`;
return tables.map((table) => ({
...table,
total_size: Number.parseInt(table.total_size, 10),
table_size: Number.parseInt(table.table_size, 10),
index_size: Number.parseInt(table.index_size, 10),
row_count: Number.parseInt(table.row_count, 10),
}));
}
async function getDatabases() {
const result = await sql`
SELECT nspname
FROM pg_catalog.pg_namespace;`;
return result.map(({ nspname }) => nspname);
}
async function getTableData({
tableName,
dbName,
perPage,
page,
sortDesc,
sortField,
}: {
tableName: string;
dbName: string;
perPage: number;
page: number;
sortField?: string;
sortDesc?: boolean;
}) {
const offset = (perPage * page).toString();
const rows = sql`
SELECT COUNT(*)
FROM ${sql(dbName)}.${sql(tableName)}`;
const tables = sql`
SELECT *
FROM ${sql(dbName)}.${sql(tableName)}
${
sortField
? sql`ORDER BY ${sql(sortField)} ${sortDesc ? sql`DESC` : sql`ASC`}`
: sql``
}
LIMIT ${perPage} OFFSET ${offset}
`;
const [[count], data] = await Promise.all([rows, tables]);
return {
count: count.count,
data,
};
}

Binary file not shown.

View File

@@ -20,6 +20,9 @@
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@tanstack/react-query": "^5.50.1",
"@tanstack/react-router": "^1.43.12",
"@tanstack/react-table": "^8.19.2",

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -1,4 +1,5 @@
export * from "./button";
export * from "./card";
export * from "./data-table-pagination";
export * from "./dialog";
export * from "./dropdown-menu";
@@ -11,3 +12,6 @@ export * from "./sql-data-table";
export * from "./sql-data-table-cell";
export * from "./switch";
export * from "./table";
export * from "./tabs";
export * from "./toggle";
export * from "./toggle-group";

View File

@@ -1,25 +1,24 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
({ className, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
);
},
);
Input.displayName = "Input";
export { Input }
export { Input };

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,59 @@
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import type { VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
import { toggleVariants } from "./toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -13,6 +13,7 @@
import { Route as rootRoute } from './routes/__root'
import { Route as IndexImport } from './routes/index'
import { Route as RawIndexImport } from './routes/raw/index'
import { Route as AuthLoginImport } from './routes/auth/login'
import { Route as DbDbNameTablesIndexImport } from './routes/db/$dbName/tables/index'
import { Route as DbDbNameTablesTableNameIndexImport } from './routes/db/$dbName/tables/$tableName/index'
import { Route as DbDbNameTablesTableNameDataImport } from './routes/db/$dbName/tables/$tableName/data'
@@ -29,6 +30,11 @@ const RawIndexRoute = RawIndexImport.update({
getParentRoute: () => rootRoute,
} as any)
const AuthLoginRoute = AuthLoginImport.update({
path: '/auth/login',
getParentRoute: () => rootRoute,
} as any)
const DbDbNameTablesIndexRoute = DbDbNameTablesIndexImport.update({
path: '/db/$dbName/tables/',
getParentRoute: () => rootRoute,
@@ -57,6 +63,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/auth/login': {
id: '/auth/login'
path: '/auth/login'
fullPath: '/auth/login'
preLoaderRoute: typeof AuthLoginImport
parentRoute: typeof rootRoute
}
'/raw/': {
id: '/raw/'
path: '/raw'
@@ -92,6 +105,7 @@ declare module '@tanstack/react-router' {
export const routeTree = rootRoute.addChildren({
IndexRoute,
AuthLoginRoute,
RawIndexRoute,
DbDbNameTablesIndexRoute,
DbDbNameTablesTableNameDataRoute,
@@ -107,6 +121,7 @@ export const routeTree = rootRoute.addChildren({
"filePath": "__root.tsx",
"children": [
"/",
"/auth/login",
"/raw/",
"/db/$dbName/tables/",
"/db/$dbName/tables/$tableName/data",
@@ -116,6 +131,9 @@ export const routeTree = rootRoute.addChildren({
"/": {
"filePath": "index.tsx"
},
"/auth/login": {
"filePath": "auth/login.tsx"
},
"/raw/": {
"filePath": "raw/index.tsx"
},

View File

@@ -12,6 +12,7 @@ import {
import { cn } from "@/lib/utils";
import { useDatabasesListQuery, useTablesListQuery } from "@/services/db";
import { useUiStore } from "@/state";
import { useSessionStore } from "@/state/db-session-store";
import {
Link,
Outlet,
@@ -29,6 +30,8 @@ export const Route = createRootRoute({
function Root() {
const showSidebar = useUiStore.use.showSidebar();
const toggleSidebar = useUiStore.use.toggleSidebar();
const sessions = useSessionStore.use.sessions();
const currentSessionId = useSessionStore.use.currentSessionId();
const { data } = useDatabasesListQuery();
const params = useParams({ strict: false });
@@ -40,7 +43,6 @@ function Root() {
};
const { data: tables } = useTablesListQuery({ dbName });
return (
<>
<div
@@ -74,8 +76,33 @@ function Root() {
<aside className={"p-3"}>
{showSidebar && (
<>
{sessions.length > 0 && (
<Select
value={currentSessionId ? currentSessionId.toString() : ""}
>
<SelectTrigger className="max-w-full">
<SelectValue placeholder="Select a Database" />
</SelectTrigger>
<SelectContent>
{sessions?.map((session) => {
const text =
"connectionString" in session
? session.connectionString
: `${session.host}:${session.port}/${session.database}`;
return (
<SelectItem
value={session.id.toString()}
key={session.id}
>
{text}
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
<Select value={dbName} onValueChange={handleSelectedDb}>
<SelectTrigger className="w-full">
<SelectTrigger className="w-full mt-4">
<SelectValue placeholder="Select a Database" />
</SelectTrigger>
<SelectContent>

View File

@@ -0,0 +1,237 @@
import {
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui";
import { useLoginMutation } from "@/services/db";
import { useSessionStore } from "@/state/db-session-store";
import { createFileRoute } from "@tanstack/react-router";
import { type FormEventHandler, useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/auth/login")({
component: LoginForm,
});
function LoginForm() {
const [connectionMethod, setConnectionMethod] =
useState<string>("connectionString");
const { mutateAsync } = useLoginMutation();
const addSession = useSessionStore.use.addSession();
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const connectionString = formData.get("connectionString");
if (connectionMethod === "connectionString") {
if (connectionString != null && typeof connectionString === "string") {
try {
await mutateAsync({ connectionString });
addSession({ connectionString });
} catch (error) {
console.log(error);
toast.error("Invalid connection string");
return;
}
} else {
toast.error("Please fill all fields");
}
return;
}
const username = formData.get("username");
const password = formData.get("password");
const host = formData.get("host");
const type = formData.get("type");
const port = formData.get("port");
const database = formData.get("database");
const ssl = formData.get("ssl");
if (
database == null ||
host == null ||
password == null ||
port == null ||
ssl == null ||
type == null ||
username == null
) {
toast.error("Please fill all fields");
return;
}
if (
typeof database !== "string" ||
typeof host !== "string" ||
typeof password !== "string" ||
typeof port !== "string" ||
typeof ssl !== "string" ||
typeof type !== "string" ||
typeof username !== "string"
) {
return;
}
try {
await mutateAsync({
username,
password,
host,
type,
port,
database,
ssl,
});
addSession({ username, password, host, type, port, database, ssl });
} catch (error) {
console.log(error);
toast.error("Invalid connection string");
return;
}
};
return (
<div className={"flex w-layout h-layout items-center justify-center p-4"}>
<Card className="w-full max-w-sm max-h-full overflow-y-auto">
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardDescription>
Enter your database credentials below.
</CardDescription>
</CardHeader>
<CardContent>
<form
className="grid gap-4"
id={"login-form"}
onSubmit={handleSubmit}
>
<ToggleGroup
type="single"
className="w-full border gap-0.5 rounded-md"
value={connectionMethod}
onValueChange={setConnectionMethod}
>
<ToggleGroupItem
value="fields"
className={"w-full rounded-r-none"}
>
Fields
</ToggleGroupItem>
<ToggleGroupItem
value="connectionString"
className={"w-full rounded-l-none"}
>
Connection string
</ToggleGroupItem>
</ToggleGroup>
{connectionMethod === "fields" ? (
<>
<div className="grid gap-2">
<Label htmlFor="dbType">Database type</Label>
<Select defaultValue={"postgres"} name={"type"}>
<SelectTrigger className="w-full" id={"dbType"}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="postgres">Postgres</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="host">Host</Label>
<Input
id="host"
name="host"
type="text"
required
placeholder={"127.0.0.1"}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="port">Port</Label>
<Input
id="port"
name="port"
type="text"
required
defaultValue={"5432"}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">User</Label>
<Input id="username" name="username" type="text" required />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
name="password"
id="password"
type="password"
required
placeholder={"********"}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="database">Database</Label>
<Input
name="database"
id="database"
type="text"
defaultValue={"postgres"}
placeholder={"postgres"}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ssl">SSL mode</Label>
<Select defaultValue={"false"} name={"ssl"}>
<SelectTrigger className="w-full" id={"ssl"}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">false</SelectItem>
<SelectItem value="true">true</SelectItem>
<SelectItem value="require">require</SelectItem>
<SelectItem value="allow">allow</SelectItem>
<SelectItem value="prefer">prefer</SelectItem>
<SelectItem value="verify-full">verify-full</SelectItem>
</SelectContent>
</Select>
</div>
</>
) : (
<div className="grid gap-2">
<Label htmlFor="connectionString">Connection string</Label>
<Input
name="connectionString"
id="connectionString"
type="text"
required
placeholder={
"postgres://postgres:postgres@localhost:5432/postgres"
}
/>
</div>
)}
</form>
</CardContent>
<CardFooter>
<Button className="w-full" form={"login-form"}>
Sign in
</Button>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -9,9 +9,14 @@ import type {
GetTableForeignKeysArgs,
GetTableIndexesArgs,
GetTablesListArgs,
QueryRawSqlArgs,
} from "./db.types";
export const useLoginMutation = () => {
return useMutation({
mutationFn: dbService.login,
});
};
export const useDatabasesListQuery = () => {
return useQuery({
queryKey: [DB_QUERY_KEYS.DATABASES.ALL],
@@ -90,7 +95,6 @@ export const useQueryRawSqlMutation = () => {
}
toast.error(error.message);
},
mutationFn: ({ query }: QueryRawSqlArgs) =>
dbService.queryRawSql({ query }),
mutationFn: dbService.queryRawSql,
});
};

View File

@@ -1,5 +1,6 @@
import ky from 'ky'
import ky from "ky";
export const dbInstance = ky.create({
prefixUrl: 'http://localhost:3000'
})
credentials: "include",
prefixUrl: "http://localhost:3000",
});

View File

@@ -9,6 +9,8 @@ import type {
GetTableIndexesArgs,
GetTablesListArgs,
GetTablesListResponse,
LoginArgs,
LoginResponse,
QueryRawSqlArgs,
QueryRawSqlResponse,
TableColumns,
@@ -17,6 +19,12 @@ import type {
} from "@/services/db/db.types";
class DbService {
login(data: LoginArgs) {
return dbInstance
.post("api/auth/login", { json: data })
.json<LoginResponse>();
}
getDatabasesList() {
return dbInstance.get("api/databases").json<DatabasesResponse>();
}

View File

@@ -1,3 +1,21 @@
export type LoginArgs =
| {
username: string;
password: string;
host: string;
type: string;
port: string;
ssl: string;
database: string;
}
| {
connectionString: string;
};
export type LoginResponse = {
success: boolean;
};
export type DatabasesResponse = Array<string>;
// Tables List

View File

@@ -0,0 +1,82 @@
import { createSelectors } from "@/lib/create-selectors";
import { toast } from "sonner";
import { create } from "zustand";
import { persist } from "zustand/middleware";
type SessionFields = {
username: string;
password: string;
host: string;
type: string;
port: string;
database: string;
ssl: string;
id: number;
};
type SessionConnectionString = {
id: number;
connectionString: string;
};
type Session = SessionFields | SessionConnectionString;
type SesionState = {
sessions: Session[];
currentSessionId: number | null;
setCurrentSessionId: (sessionId: number | null) => void;
addSession: (
session: Omit<SessionConnectionString, "id"> | Omit<SessionFields, "id">,
) => void;
removeSession: (sessionId: number) => void;
};
const useSessionStoreBase = create<SesionState>()(
persist(
(set) => {
return {
currentSessionId: null,
setCurrentSessionId: (sessionId) => {
set(() => ({ currentSessionId: sessionId }));
},
sessions: [],
addSession: (session) => {
set((state) => {
const id = state.sessions.length
? Math.max(...state.sessions.map((s) => s.id)) + 1
: 1;
let isExisting = false;
for (const s of state.sessions) {
if (
"connectionString" in s &&
"connectionString" in session &&
s.connectionString === session.connectionString
) {
isExisting = true;
break;
}
if ("host" in s && "host" in session && s.host === session.host)
isExisting = true;
}
if (isExisting) {
toast.error("Session already exists");
return state;
}
return {
sessions: [...state.sessions, { ...session, id }],
currentSessionId: id,
};
});
},
removeSession: (sessionId) => {
set((state) => ({
sessions: state.sessions.filter((s) => s.id !== sessionId),
}));
},
};
},
{
name: "db-session-storage",
},
),
);
export const useSessionStore = createSelectors(useSessionStoreBase);