From d85df4c3083473faad50fd4a92947f0bd6b09eef Mon Sep 17 00:00:00 2001 From: andres Date: Mon, 8 Jul 2024 18:41:12 +0200 Subject: [PATCH] add login support (wonky still) --- api/bun.lockb | Bin 10683 -> 11041 bytes api/package.json | 1 + api/src/drivers/driver.interface.ts | 51 +++ api/src/drivers/postgres.ts | 352 +++++++++++++++++ api/src/index.ts | 412 ++++++-------------- frontend/bun.lockb | Bin 135306 -> 137358 bytes frontend/package.json | 3 + frontend/src/components/ui/card.tsx | 79 ++++ frontend/src/components/ui/index.ts | 4 + frontend/src/components/ui/input.tsx | 19 +- frontend/src/components/ui/tabs.tsx | 53 +++ frontend/src/components/ui/toggle-group.tsx | 59 +++ frontend/src/components/ui/toggle.tsx | 43 ++ frontend/src/routeTree.gen.ts | 18 + frontend/src/routes/__root.tsx | 31 +- frontend/src/routes/auth/login.tsx | 237 +++++++++++ frontend/src/services/db/db.hooks.ts | 10 +- frontend/src/services/db/db.instance.ts | 7 +- frontend/src/services/db/db.service.ts | 8 + frontend/src/services/db/db.types.ts | 18 + frontend/src/state/db-session-store.ts | 82 ++++ 21 files changed, 1183 insertions(+), 304 deletions(-) create mode 100644 api/src/drivers/driver.interface.ts create mode 100644 api/src/drivers/postgres.ts create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/toggle-group.tsx create mode 100644 frontend/src/components/ui/toggle.tsx create mode 100644 frontend/src/routes/auth/login.tsx create mode 100644 frontend/src/state/db-session-store.ts diff --git a/api/bun.lockb b/api/bun.lockb index 4478f52766c6aac81337998e596d5136bc88855f..06c8d450a7a50e618156452bf356e973d4c5d843 100644 GIT binary patch delta 1705 zcmc&!drXs86u-Bx(eh{k3oU)}bf|pr6$A>{0*ljt3owNSbv(9o8;=aIWD_G&W(-zF zusIuHOfwD1jIg*r8lByI5D7%g<|NygIyV_nvu75TOa|L<=YF)oKlac5+DXp&oqNvt z?sp&O{yyG*vg#<8TKhr8;kvZ`%ARvIncGfHw0DYKcMfEKnRu{{LQs?;XXQxbP{p_T;zjrJ~)R5BDvN z=I%Qr>%MfO;9kQ1lED+6`XAp)$Z}En)b#LF34EXbr?YKga-=$M@pg7a^{&yERWV!P zVuWVf%?=+AY}p5DFw`2K6V-Z=ka>&VVOW_O%@Z=mRrccACUE!}4| zXP`TG@Qc4@n~x=L?i^{}Vmqb6Q>A2Cxr`VNZ4nOGs|<%UWhqrchtdI0s>0zL97RH^ z$^kcV)Tb(?(Qr%Uplg7SbWk;T(8j=bXk)<|<)9kyqSe9=Xyc$L+Cg=`x1$rOG!$D= z1@-D-C|8GxtWsZ%dRhu&3SQBwU%ALZJy>-%Zu!xQuwWc2E>38X$H!L#pJW-}k}d^i zb$XEKHEJW)m;&W%5RoK^65{o`DArr7pX!hon9Q;d6XdM*7z>0$w>~Ex-@ed@?ElC! z!i!;3utsI5gHQD)g^K+Jr92*%^f@xhbGck5t1u4abfR}OM096FrwyMG@a9!ULg`TAF{SNxc=w^SQ$ymohb;KRF%e_f8LkHu-sC|m z+LaI3s1q&P_jp3qA-1assX$yp`$0dZv2|NBw}kr}UPN?YVjj_kIEH9JJc7s$_zEI^ zEYrOjm!OC4GwgQ0dnOheOI}L^pE+0i^$1*|Q*;MpIf=dj1(Z6_@)F$-ohT zcuBj3BV-3|CxDS}rfKL@N!rusdlr2Ja_Fmsf=Ut|!*g#1ov_WAK`o3?1j3kuc5V}7 zI1QbGf*{c{(5fZ63p!E02U)emeW!v8>IhYVwoDRuH6cBqE88YKk6O2^eU94GvPQ}X ziOO0OwJupZiP|n%TSQIHqhZi$VTj`A@v&rLi039IWac*`Gh)c>s#V0hBpPHlGNb>B zxDR?_Uhbwv=v>KvR z4_QMTh7CIS11%%87~>E&>R`+mr6yQ3#;Fv2avyph~mmoG`y6J4K5|vw~g2x&^59(mCsjs(cVzgV7fbXmUm# zm-W)$_lX(UG4gBUI++xkRfbY2*hO;Ajn%z6oDgw61m~Sjffa`pZaN(TOI{B~m&3ve zN{`G6Y0!xR>9)KC=K>}nGo=E0UCmYOB=~ErrR7Cz8$EpM@>H^IM4T9mHj7YnHLKa7 zu!=UhU5&~ZQTkUy6QMvT&>;7^JXn+t=V+on5M&WQ9CGicXW^#XPG?}nZFeX`YvD>w z-_P4eO>%BM%bkZhkDV6bO^@Bd5)mz%U9ih zu0<{i;P%?8m3i9ze%srhJ@JcgEg>zkYbPWv!d`D%`M4-s#8(5Syds?UZp^QEALU`v TAB0l1g@$0(=grUiJA{7#=ptC; diff --git a/api/package.json b/api/package.json index 255bb08..9099af1 100644 --- a/api/package.json +++ b/api/package.json @@ -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" diff --git a/api/src/drivers/driver.interface.ts b/api/src/drivers/driver.interface.ts new file mode 100644 index 0000000..cc60795 --- /dev/null +++ b/api/src/drivers/driver.interface.ts @@ -0,0 +1,51 @@ +export type WithSort = T & { sortField?: string; sortDesc?: boolean }; +export type WithPagination = T & { perPage: number; page: number }; +export type WithSortPagination = WithPagination>; + +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; + getAllTables( + credentials: Credentials, + args: WithSort<{ dbName: string }>, + ): Promise; + getTableData( + credentials: Credentials, + args: WithSortPagination<{ tableName: string; dbName: string }>, + ): Promise<{ + count: number; + data: Record[]; + }>; + getTableColumns( + credentials: Credentials, + args: { dbName: string; tableName: string }, + ): Promise; + getTableIndexes( + credentials: Credentials, + args: { dbName: string; tableName: string }, + ): Promise; + getTableForeignKeys( + credentials: Credentials, + args: { dbName: string; tableName: string }, + ): Promise; + executeQuery( + credentials: Credentials, + query: string, + ): Promise<{ + count: number; + data: Record[]; + }>; +} diff --git a/api/src/drivers/postgres.ts b/api/src/drivers/postgres.ts new file mode 100644 index 0000000..70b0e86 --- /dev/null +++ b/api/src/drivers/postgres.ts @@ -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["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; + 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(); diff --git a/api/src/index.ts b/api/src/index.ts index 5a7beb5..7b9836b 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -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, - }; -} diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 5ca93d9b3ea95f527c595e591cd7caf9d2b718ae..f6465b626c8d80d9579987219ac5d682390cb3ff 100644 GIT binary patch delta 24191 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 delta 23007 zcmeHvd0bUh_xIUHu5wHS92f)zbDog-3dp@+D(Y1brwitUOri)VXhA7hmR8O?+s;Fp z!;|J%2F{w5mGf+lP0ps78kY9^t}}40?0J0N_xFC@e>xw&XAOJrwf9% zzq#0XT0pazr}Aq5JhI1{ebdZ`Ce}M$baGW;^;sh;mANLq_tk(%bC(CMvP4(c>2|;3 zd7YRlNK(eg+?;~+oV21*`2{ICX;M$*@|2{Kk?AR^1<2!8Ns_8T&IVOLi*nP{l2wtf zCgin{o&jxW(#lN-tqwUgKR-P`Rg&BxQ@*_1`~v8tWtAn#r5K9UPLfm=G$$n|Hw!kW zL8b}{Q-)_#4Gaa{Am4$W41TZEk?Hxl*`w1XKVPP6h`WoG9NmioeNGQ0_TqGuqJ!6mw!34t0s zcw|aydcGu`gG~7vq7~GT(YR6iY(2l>-~=bBqna>0ePm{Oup}837rJU5XQbp8j6h3A zrjN>}Mn@tH6vAIYsVm~#B?(zevUr5-S6s7(R^2gBB$TA&6b?@*NF9 zn~>pNamf@g_$zr$Q%&fdYa)6_~J^Qu`w7_6y2KsS~Yno z1w&d5OD{Z&cu;dc1*HknwZ0^wWhEh?XmN@1tfsHW%PaX6|AzTR&QEHf(UYJkSdyQc zlaoGDlI|d#tgURQ(MyoY>0yP`H zlH?B>51HIfM>!f^K6*YgsHapcl{kYTj}vsmds=Jq;6N?f1^L-ogW=)(kg1_PK&dyP zL8-w%25FJ`L8pzOr?{8F4h@63pfvwq1EmP$>NF%2_Gp3e0Yjmy3`z!Xg=iU*!nBGu zf@02;ECHo~-X8`?ZU;&o+ZdEQH-SLQCBpjuB>VjG@ zd~S%ZFADC|+rmBr-D@l-iM}%XUzT zd;};)Zb@xWjG~ge7OiJafRcVAC`Mh$3{Yx!KBymPFP(;i)`RS+%fCiz^;`gj|HUOo zz|cfZ&mTN0D?3d(9H;fb7Ep@#GM!G-X^~FTLCKFqQ1UNYr_FR)Tc=E?H#%zhPU&=y zN->1igP|d`P^Xi0TBy@ZQ0lSXI(<&3K|1x*>1G7FD)KD_rAgK#L7M}{`<+0GsYMnr)bf{6A&s#--GhmCZOr0{T2PpmE~P_Hp2R0< z9&~_AhQCSH8j?3My`Uf~edMT|yzEh#SvhC>YU%qyU6C&(HFsnV%6x|W==+k4WK3ad z!1R9F;28}{32C4;K<&s#1v3X|%R)y`nk3CYY0y>&rTm@rd=L9;4HknmM^cBRWDi4y zty8qwkEeF4jO>(5AIKDe?sfb%kN&vYV{*VCb;Zv~e7(}R_zD~*$9JV^^(P||^&rQA zQakmh6y7Sbv$OIGr2O>ZqiOwJg8VcY;xe^C-x8GCI|X_gyDx)MdAt#+t5arrPJUrd zsx%Y}co^Ef4$VN1wJ4dZF(uk8)wjfdPFQPff5C2sz1MZGhsOdsez>C&4%{n)_9wfQ0_cQ~<6FO14K zIit}#A06u)Yu~WL?H4CjmzUPIvZ1`ZwpBhW^GFX1^W}veR%KKrNs2_1op`K|nXTdF z9#;8WB_8Q%VV!xQro zKdc=k->u9ey)Dej3%#u@pO@mknwNW9m9teODHg>{oOzg)dU(-m52-Sb^)@SmAz@aO zxbmHzX8E8qe^AH5EIh);YFdg1aa$hk86{s(c$tqyX@n_9jgdL3S27^AgTy#XY~ut0 zd3~bf@2c`LUyD);qYtYBd0D58GX$zq^I`=gONI1$L@tRXM2Yh`IXqMlV*go)Nx zGl7O)fkf5GYO`CoRHS*Dl{`okQl!Dbe?ih>#_E}s%X-c#JQh+g1{bx&q*^J4MCFi% zt|;Y&{#NC#u9GPstTiw9x3V{QM18BW6%(7rprt*nGcJZohi z@xo`VrYd-7#!!c_KD->-c|4+lRsPzY7d5c3`n;S*z{R?xb;(&sF-UXb4DMMm_xq@pY532CMD97Oli`RiIZ=C+dcKbh51Mu@tjaRz zBGvrpO63A1sukWs^2O>xlGcVKNK~uZwaPL`)G?AeU#>!;S`|Ld-^`lw(pFX_qn;#n zg02$Z*~+YZ0*P{KjO~ zG6xcQqIKm_e;yfVVVS%z&?=YJ=VgHwlmD~o@bQjPhJqujjM^q|ewG&nS(NKg258EL zW~C0^e@McD;AJ*-g4A9ed2@JSuvNa+fR_bZSR^kGwwiJpVpQ;Gk0`dD7lv5n8x46` zh{a@YBuOp#7>_8^tKb58biF8L1GpqumH5s^X62d2&_lwU#N3MF<)K#7Ea>9+!_X-C zd}Ce|W-$$G0-yPquqb6UIO+kcCh+$LBHcXNt147W0yCPM1N$M{Dn=fF`P zLf_EL>hK7&)${`1+v9k&IZD|Eu8U>^z4BvoZRvzXNCR3((x8g8ZIEb`X=$}uO485@ zX#u1G71D2zXjN04Qo6RPh%{`@fK*|{bQw}tJ|?=YGXkx<;%PR$2&oH2OFr3}m$kJh z!x1AIEvOrd%JD!R8EsLV@oryHM(GNP+(&5Yo0YdA^-!BYL+=+zG%zr{nwU+&7-^9_ z+B-_l4(4SRi)j}W7XGktlp=*lQa`O8w6z~3EjAckre!42ilxM2-6V5bBwm6PUm@*< zq%{K(G`Ztxk2u4TAz}PMj79kr3M(>W(ljy4rf^;qYf;)^ZE9CxVLYT>71Bvaj(7!_ z(Ifn!xvevp!4;WmU=C2gvD&q4;|zgZ*IKs`k{&sD^b;g%owk@pMqnsX>rBhQbySC^ zKOC#5PZFG=I>+V)_(udY(V*5M`>5A)l;rMc_2uF>pOq zT~j=?`>Wh*;IdTiCb$%pOF&??)K%azRb9>JBq?3xUIaH#S8fni9@{jm@ZK!Gs29K4er$n?mz|S)7ePP1lL8) zw+>t^{~hT5_wqL)$H2Dtf4+U;~~+shlC!VZRZdASWM@j4Ch-LM#;63c#+*A_f6tuc8h6l zk~$K?qD)F(dPl(OZR!uM4S$F=c_uhaIhwG|%<|8D`GX{j(&c$Al$GeAF3*0R7bRPi zub|Xc)^Ux@rsi0jBKer$DANXT87k-9pJp+1$_Q}iS8d%jZH0t4tO0GEP(DY^`YX6J zl^Zw^3!Tbs2B#J`)x=sKqw0EsLuaF!<>1=!=)rBB!C;vr+fB?$bc(k0;?WplR>nc9 znA1urBy_GeF%$)>6paY=9YBs7%p(U_lsBNnIzUTQL$m2LNos8#sp^9+I7&`Q<&gs| zGEe1211-vz&{O4jum_rzhmcxA!XoBvHZ@D5pi*1E1*duULOOz`>W+cKEI@F4GPK^q z3ltumy&;9ehW6qx3sNW~yykeAOdIbO`~j5jLW%i=SDtbB5bmZ7{X&0-oq)Tr|j zI6S!FPp@HE$W?Bc#)U=6zYOCK(k*hUY#y0mF=b@aa)`&c>059Z(dH;QYB+y@q{tj~ z$;yZ_ad5r)LvWYDC8(S^mxev*UICY=a!0^*Q#t=UJ)fML#~%!dEN&>Nn?#ZmXj6az z&2-var!7F~dYn=|^b~y-r3aiO1MLC29;aj%Yok;NumN;EMlnAAsQ8Oah7nq|fmlFP zLs$#c>v2jA!YZITfcdUoL>cd2;K{QKoB{u0vW97=UPMX8WKu7pWCb%xrI3RY!mMSCRO@Itf0_b`YC4-XzqEi66h*G&}B;X=S_TB-g zA+rI>KL?-JSNxzH)Tu-9ZuoY^mMk)XM z0NGik>sL_-NwHd2Jc&}lwR$>HGQ3`wpN5kC4FJ)NdO4zGr}!h>sDe^}GL-3bGbmk; zQ%c{WYBfsbw(Bxc3jH2keiEhWw-+EgUugM?Rfbyfm8MYXGvH4ERKaP0($DDh94P58 z>hiasCg3ta`fC8?zoF9~LFppu4LGBA8oaKcPSpQ3sX4eFr)06; ze_dZ6l=3wOr3kbHtqw{%?Q{_(d(ohjubnP;0F|l#z(p1CxGEv#Rp6^MtD$=8oPOY%WnCZk=bwvy)m2HO`8S0?RaiCOz zP1nbRQn>_Bx*n&b@2;mO>gkVDveR4F_f}`RuAqW_^aP@=kkdhF(hk-2k5kG&OxHh7 zX%dfso|5u)I!dQ6kP;VBVhh!`4Px{JqGY&8&+xJ?j{&8IyrS#JgVIHm*w=BRiYI|m z`N=w+0!opb4oVkMT9sz&atZ-)Q88U7cq zob(O#Q@qIi^Jb_&xc_-GRNs64c{BWf^=26K-@Xyf-rzkpv^C$i-o|IGcHxaS*w|Zq z-C8^E^nnW>18Fj!z1q%yf%I^-4d2}_{=m*xta0I{H8wV#C$6#cq_r-5_&OWod?h63 zbuRoE%FX2UH`@8fkY3wpV}c)oG-SOC5Bt!@X7h0$+PT*T7ydn@xjg72JO3Qg%#Unr zKEDX*#f>h!!zLU4S7iDoJ8$}-3%?6#F^~P&&QC&m?_(QV!f!*G_>l|mU20?R@x`Tf z-ewb8R%T<%d19HJUxxG{q?J4w9?kmLg}ZFFvG;lMX82d?!nZ-{OCdQQMh_javAaC$h@IWzJ8{3yy^q@2FMI^<<$N#h z4|x4!cJ?bT#QisZ=$Ku8DDmdU`^b+Z{tC$N5oMg5+|>#$0*w8MyT|+=ArJm9uc`4BR?vV>NjxBzs|%=2_=3)FADH zBUEb^>hS~)TwTm|9$4^0OdeMbP ze`90y`5WKBKS(zqHQ*87!oP3e-?ui_h+l!!=3DsposBi&v%iCXkRC#6#yfux|GtBN z-`iLVUJj|#_werr8*9Z^{s8|Vxm>d0AB>VO!M`8iAEY3zT!w#_;NN8%3*n`ZoG-(_ zD>fF!v#!8DNc$j}x%XB0cLn}kwXq1k7n0Xi_;<~Qf3_&R2LB+Pg%r)3U59_y;NNu{ z{yXdxq^8&5-whj!B-mDcH)zMayA8FC$Ty-o~mZ!Pf79~=MB~G z9InJ#Y`AzYL3T;N2UKjClq7??A>)^7#%d3o<+aZuV~tAd@3)j^++Rv+(Y7*kHhqAv z)+ncHa^@iB>=72N{huSfIgHHB&K{K~NmHXg9LqA8e6|Bs$GHDJC$Xgx^EJH}uNAkF zMQ`K3T2r&L0*2;mTMg=exbnrf z%#RAW4Ap+)Atj|E6SSLDQ~nCdy_JEjFsP=gsvV@V;v+nT?hdJ{4$jIQo(_w_!#C}( z%7SC6UeJG&652(~w=qlcqu;d)c_b_9rs`>%nJz=7aQNwOQ!#oT`sntou8Y-mw0RH; z&_%n%WQ(?9VgS0H({;4{vR&0+SF63Qq@Ch7G^Ipa%T$>5U#9E2j!;m2w1dob9r;4} zXshgPUDsLH(Z=pfUDpLVYH&4xzC$6q-PC;jwCzOt!i5UN>xF6mTj)Ca*LwV^yIiw$ zU3XpQ2ARHlp)JWoT{nno!?hwogm+`>o6$zn41fb~12chl00GPbXya)DFj0(%XPyzB zV7&lupbp>z_yTo-dH{8VKS05E2WW%R4R|DObZ0)roltWZpe4`>2mo3GK|nA-{SyX+ z17;ushy;QHGyMQeKZJW`TKI?&o z0Bu0pfg~VVPQ%yrU9LNPm z0<^c=9B3hnDcxCc@h@m-Iq(3$KQXA=W<5ahpBGXgPz1aLybO#1#sY(Y6aX`s{(nkJ z2C$5%U-GsCeGZ_Hf~SG6fiu8a;2dxsxBy%PzM)8e3+6lEd*BD)5^x!~0$c^I0oQ>W zz)j#sfWGS33w!~5348_Y1NH+4fP=sx;4p9mI0_sCjsquvlRznu#ELNt!8ij7@C;B5 zm;kf{S^*(I7_bLkH3#TRJlat12+%%oHjn`f0kQzvDp~{3mQa6yzQ3g}26};x2VMhS z2POa$QNOxg5G?`50ay*COrQ_oiGp5$D?p18eg2_M=>bTi4a{Z0a$pKD6_^CP0t^L) z0JH|tnnOG3U4d@EKOVZ z0q+8|{Z8Ld<^YYs4+iZ3dU3G0xbs+<;o>1zZ4H5w8H-fUN*EY7=10v~}RgxoMlidb0DfH#uuK zPSGQWDS~f`pk(GJy7pnt;sS1LiiqvQJVikt=EkNIUsKg5QXi8M>Tko)2jGoJQFJNH zi-AP|RYK9G?xM=x0;ocY3OPPgjDYQQiU$Rafne;a006E&OB7^IfH0MNLi z@kpT3Mpm*sRSZXs-iFEuzERm~wE~exLGRRuRdac~HPa~3N4#ifZnX_P1$G131tyCT zcIFwo2CR|%@A~0Gz1T)jqd)Zc!%W>jo1vPFW}+r^5k>(^W}ab&6Et=H-06#;Gb*A= zsKXtd+_Dtn$AA&KGF{#RdI+Eay&Z@EL`q-uRbF3aG94t9c%v^fE4#q%1a^qMeOb>g zN5LKe4g)uV8^Cqo8sG}l1IQ@pe*$g;KLWP^N~bbE1KT8#@jPo>d>{Nh;4VO&_B$xe zIwznKP#I_nGyt9j=&SW8fYvMe8lToI`r17LXaksm>cqoNRnW?ywD797^&2h1q<5i5 z17)Pum{w(~h$=A**8uMhxB-oUMgUbvd8k3&04<*M{{yvwS^%X#4r5`ZGL)Z!PzTfp zFrj``K^6D`^#EU>E&3wh#Czcz2TkF0HOwObO&`!TYzjC^d(dt z13BJ`n=v>`prn2;0Bxu1CxEws?*eoNIstJ&M}VF??Ewr~%Y$YRzpevjIT?p&0FQ(Q`6g$X~Ske%O6Zoh%lzI6YKWh)nJurK^=a?|$3l$YKntsX9(W43< zJzp33g9CynNXGB&UtODipm{_>scI>Z{;l0ltj<8)$>L%Lda$o(oe6_ys);U{Fqnx| ziu$SdpzFUsn3i%+HHd-X_}zT;ogM4Ga0?rP6f**Vl!oFk_vk^|?m;SH0^~=s4&*o`CpdPvDA=+oN<_+#+j@Ll| zji34_Z@+1pckV`0-8s0ODW=1ee8)?y%ElO`5AGE46@`y-Ll*hOI`Hq3J(9!7Rc~Kw-m#XG%)f}~*z}6KHVa>-lv0znh(8lbWsSA)7Glt^q zE`o9p=Rv{-;$xg-5Z`*&yWU^yu7!NzTCI~rF*3-y&rhnHmJzLTii(eMror{JzP-=3 z-O(PVA~3J9(99An7gc^Be2A2auw3+TjL6Dm-F#~LYjLT1#a`FCXwm~mL+gn{ls!aT z1M%rvU;9;RxqJGsN1^*>BOjLYfS^E05q^1C0sk&TsAL>`;PL3$4%^opnS!F`fMArB zPBsuZc`VrH4xSaXBD~}k^&o3PuM9UBZ=;55rPvJP%uOgGSc`W?FlU`*twq=fR-cU* z_7RwTcyvq~$!dv3Bbbwqan6C#$tUSveCX$>59=+3Dn@J{0blf!5lZe=bN_zRE$=0& zFM+g3^c8n>bKRFCCwG%Nt7yfLygzj#L?&|pXTD=D3mb^_k5Iz z7wZZ@GDZCtkT|)ym^2pCa%FR||0On{;m+pTuhIVMxRDawLUezbwP!m;;)|$6BUO#m zu`R{g36H0C7{xN5!m8HDKUr;HST&9>@Nw(;%lP$6Cdv#moyM%B`*=7&WRHd$fAm$p z9w1ugLv3m;{NKbxF^)@E{$Xm&?&|wjI_$J-E&5GD&i<{%{)xzGoYb%?Ze#n)O`btl z>5n4mwbtU+7=$xk?1cX^9Cmvhq8?8~XKfMe=yCNmPJme5t>BGYVV|9K)L7P9?4Zc+ zX)Rh#eyYef4ix>0Q6f4}j4o!uzQ$=3=B-sWZT3z0OdT!iJHp^V@kudjF7J@VEtIHg z42<^!MU4{HUM>w(^_0G&g!zki#<3bkW<%*~9BNT-_L&PWZmxYrAB>3lk3r(M5{LPQ zhWRJH0jXgTEaa4T#-H^#iMboV?~eu{rJs*u^!6A%tJmb^Yn;`w<^Fr+g{PBOJGxSvJYI50sK^`7fHiP_!y0SLcG-lp~9O#tv-J+<0Vfqi+~AFHR}JkqWqr%vdCRjKfp5w7L@;K7TisB`oH&oJ*Rw+M+P_ zlegWNvEcr75*jH^ZM6@UBf52+H7e%KJ^JDuj(-`}tvc#)MP3)tVG0v+UbHwjh55^` zM!z$amB>e;#Vb?sq|uMtDcM}hqelO!vtMyycuB%~gAXIo;^0)QIK}}ziF0n=j9GN( zkt2t3giqyy!)e+3@5qjnUKUYr8p;}n{UD1W;t4;GtK&$q zTg6Q(YaC}(wd_#!;rk;6C#X_s^}uJIK)#*xC}#1txPoSf9WM$-`X*0+ltIgGQF27CObxcb$) z=SYc;6KAMp#%WBS-z)KZd1-I%$dMi=+;BibxN&|{56i(8zIEM~J8~E&Jh^zbzp~(i z(Vsa|mc@wyRMt2XDtpkozaO7oiVt{3%#2f`4lQ=8`$^L)367Ne@nRLq`WnYCh0I7T z+BtvG7@1W{&}OG^g1CSjKE{DfV}b+xcGax(toqEwOQW$9qwQYQ60vVHzbBY$*IhH0 z&|SRzHYzlZbK2y!=gh%_kG|92T7p8QyzXM{+t@GAHctHTxG}yx&z*I^b?n5h!i}AV zHTv8l^$=b&v7B3ah^{m7rqij1Snv*LuO6ZVa=3A*QO>enj0Q~RYEe3 zeL8h@qG&K<#Xd*M%pPJtm0jHfdwr|f83a;}H) zMg6|U;Zwuo!>hfL-)*8JhkH-a9XY~{9DBjvSqm#kK{AvOQT` zSOl7p{LVa(k;&o~Rcai=RCjupoYY191+`#!0Ct^?gPN+%=+Mi3{K+^qC6wOV)+Gze zEZF!gStQM38S*#DVh72;C5zLu*hYDOUomYq8f6@s)t7DD(s}ocC|C$Yk7AH$F4w9Y zf^9ITWN{9;^#*D>wUNYzOKJT?%Q-M+9H`Z3&$6CPe1>9c!idiFeqzuZ?DA-yh5vo9 z{yxAD`-|VG65|xEFFYR9uIhUGn4^+r14Q6lw6fy>F#uuoHO}#JegDy_QF9trcjVLq z{6u1OhL-62_f6Fsq|GaDSqu_G=3$y;i@Edgd0E>|l{Lq`!k@4`5~6*S_es%qZ>B6> z-}~X11HI8od{m`%zjcbJHy;+=MdEx|93+O##|KEg(b_&*Z71Xl5l7}TUNt36TM@Jj z|0c%9`CoD0WOr!3a}(`$>Vw$uT_1?P|G77eGsUi5o-?Os>)l;pOaHJmB~8REL@z8s zv-~jalZ%#ay<7X-2aeiyrHO)tEI8aa&+OJGWAf&m`05C9&~EV)7AmBK1_aYqP+Yn= z1M6~fy0}L+jI+*i$MO!RmN(z$sKq!9ZNRBp0r89Kyx~aE$BEHL|7N^kF6@7od0>xS zkN%%#sv-WNQ)suz=q)3#YVlf?X=^znS*%@*_&jldshbsg4$VfbNGkNi%)ylM*QLN% z<**cVeHRstPu4ac9m|N3P|D{;ZmLdo3g1X)j z@Az2`EOO*9PKMjMs*}~%-e#dA<>+vcLuHM#;u>M3DMJ zkb}N#`MUQ@OYQIbd*?&026>!k@9MwT3O!dWroYFmb^6C^>hW#DzG5#{f7E87R?SbY zo36gcK4P1GSk980ifzYPGf{95Ukp^_SvGx!%iTej>njgZ>-;@*B zh$}zNyoB>1)?dV&z!&^gk7BnF`S9(@Q8r8CkgvyMWnVbN4wF4a%sb2mi_XVbE3Kl$ zBdoo!9)Z!R2T{>W$Kc+~Bdm+Ka01+pV`xi5GQX7C;&+lI$ysTeGEcHcl|{-?e3@H* zl(i5sCt2l9!_Ko;7@4Xp8XiOCzDHs7i({ +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index eba1ad7..41f0a57 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -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"; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 677d05f..a5e556f 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -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 {} const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { + ({ className, ...props }, ref) => { return ( - ) - } -) -Input.displayName = "Input" + ); + }, +); +Input.displayName = "Input"; -export { Input } +export { Input }; diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..f57fffd --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/frontend/src/components/ui/toggle-group.tsx b/frontend/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..4e4eed6 --- /dev/null +++ b/frontend/src/components/ui/toggle-group.tsx @@ -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 +>({ + size: "default", + variant: "default", +}); + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)); + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext); + + return ( + + {children} + + ); +}); + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +export { ToggleGroup, ToggleGroupItem }; diff --git a/frontend/src/components/ui/toggle.tsx b/frontend/src/components/ui/toggle.tsx new file mode 100644 index 0000000..9ecac28 --- /dev/null +++ b/frontend/src/components/ui/toggle.tsx @@ -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, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 1566629..de6ddf6 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -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" }, diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index fb48c19..845bc9f 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -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 ( <>
{showSidebar && ( <> + {sessions.length > 0 && ( + + )} + + + + + Postgres + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + ) : ( +
+ + +
+ )} + + + + + + +
+ ); +} diff --git a/frontend/src/services/db/db.hooks.ts b/frontend/src/services/db/db.hooks.ts index 4e2c000..559556a 100644 --- a/frontend/src/services/db/db.hooks.ts +++ b/frontend/src/services/db/db.hooks.ts @@ -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, }); }; diff --git a/frontend/src/services/db/db.instance.ts b/frontend/src/services/db/db.instance.ts index 14b827c..9963a17 100644 --- a/frontend/src/services/db/db.instance.ts +++ b/frontend/src/services/db/db.instance.ts @@ -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", +}); diff --git a/frontend/src/services/db/db.service.ts b/frontend/src/services/db/db.service.ts index 8e3b1a1..e6a03b9 100644 --- a/frontend/src/services/db/db.service.ts +++ b/frontend/src/services/db/db.service.ts @@ -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(); + } + getDatabasesList() { return dbInstance.get("api/databases").json(); } diff --git a/frontend/src/services/db/db.types.ts b/frontend/src/services/db/db.types.ts index 27fc082..af66d15 100644 --- a/frontend/src/services/db/db.types.ts +++ b/frontend/src/services/db/db.types.ts @@ -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; // Tables List diff --git a/frontend/src/state/db-session-store.ts b/frontend/src/state/db-session-store.ts new file mode 100644 index 0000000..0964371 --- /dev/null +++ b/frontend/src/state/db-session-store.ts @@ -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 | Omit, + ) => void; + removeSession: (sessionId: number) => void; +}; + +const useSessionStoreBase = create()( + 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);