mirror of
https://github.com/ershisan99/db-studio.git
synced 2025-12-16 12:33:05 +00:00
add login support (wonky still)
This commit is contained in:
BIN
api/bun.lockb
BIN
api/bun.lockb
Binary file not shown.
@@ -7,6 +7,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@elysiajs/cors": "^1.0.2",
|
"@elysiajs/cors": "^1.0.2",
|
||||||
|
"@elysiajs/jwt": "^1.0.2",
|
||||||
"@it-incubator/prettier-config": "^0.1.2",
|
"@it-incubator/prettier-config": "^0.1.2",
|
||||||
"elysia": "latest",
|
"elysia": "latest",
|
||||||
"postgres": "^3.4.4"
|
"postgres": "^3.4.4"
|
||||||
|
|||||||
51
api/src/drivers/driver.interface.ts
Normal file
51
api/src/drivers/driver.interface.ts
Normal 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
352
api/src/drivers/postgres.ts
Normal 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();
|
||||||
412
api/src/index.ts
412
api/src/index.ts
@@ -1,39 +1,90 @@
|
|||||||
import cors from "@elysiajs/cors";
|
import cors from "@elysiajs/cors";
|
||||||
|
import { jwt } from "@elysiajs/jwt";
|
||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import postgres from "postgres";
|
import { postgresDriver } from "./drivers/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);
|
|
||||||
|
|
||||||
|
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" })
|
const app = new Elysia({ prefix: "/api" })
|
||||||
|
.use(
|
||||||
|
jwt({
|
||||||
|
name: "jwt",
|
||||||
|
secret: "Fischl von Luftschloss Narfidort",
|
||||||
|
schema: credentialsSchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
.get("/", () => "Hello Elysia")
|
.get("/", () => "Hello Elysia")
|
||||||
.get("/databases", async () => {
|
.post(
|
||||||
const databases = await getDatabases();
|
"/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();
|
return new Response(JSON.stringify(databases, null, 2)).json();
|
||||||
})
|
})
|
||||||
.get("/databases/:dbName/tables", async ({ query, params }) => {
|
.get(
|
||||||
const { sortField, sortDesc } = query;
|
"/databases/:dbName/tables",
|
||||||
const { dbName } = params;
|
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(
|
.get(
|
||||||
"databases/:dbName/tables/:tableName/data",
|
"databases/:dbName/tables/:tableName/data",
|
||||||
async ({ params, query }) => {
|
async ({ query, params, jwt, set, cookie: { auth } }) => {
|
||||||
const { tableName, dbName } = params;
|
const { tableName, dbName } = params;
|
||||||
const { perPage = "50", page = "0", sortField, sortDesc } = query;
|
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,
|
tableName,
|
||||||
dbName,
|
dbName,
|
||||||
perPage: Number.parseInt(perPage, 10),
|
perPage: Number.parseInt(perPage, 10),
|
||||||
@@ -45,37 +96,74 @@ const app = new Elysia({ prefix: "/api" })
|
|||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
"databases/:dbName/tables/:tableName/columns",
|
"databases/:dbName/tables/:tableName/columns",
|
||||||
async ({ params, query }) => {
|
async ({ params, jwt, set, cookie: { auth } }) => {
|
||||||
const { tableName, dbName } = params;
|
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();
|
return new Response(JSON.stringify(columns, null, 2)).json();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
"databases/:dbName/tables/:tableName/indexes",
|
"databases/:dbName/tables/:tableName/indexes",
|
||||||
async ({ params, query }) => {
|
async ({ params, jwt, set, cookie: { auth } }) => {
|
||||||
const { tableName, dbName } = params;
|
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();
|
return new Response(JSON.stringify(indexes, null, 2)).json();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
"databases/:dbName/tables/:tableName/foreign-keys",
|
"databases/:dbName/tables/:tableName/foreign-keys",
|
||||||
async ({ params, query }) => {
|
async ({ params, jwt, set, cookie: { auth } }) => {
|
||||||
const { tableName, dbName } = params;
|
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();
|
return new Response(JSON.stringify(foreignKeys, null, 2)).json();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
"raw",
|
"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 { query } = body;
|
||||||
const result = await sql.unsafe(query);
|
return await postgresDriver.executeQuery(credentials, query);
|
||||||
return new Response(JSON.stringify(result, null, 2)).json();
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body: t.Object({
|
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);
|
.listen(3000);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
|
`🦊 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.
@@ -20,6 +20,9 @@
|
|||||||
"@radix-ui/react-select": "^2.1.1",
|
"@radix-ui/react-select": "^2.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^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-query": "^5.50.1",
|
||||||
"@tanstack/react-router": "^1.43.12",
|
"@tanstack/react-router": "^1.43.12",
|
||||||
"@tanstack/react-table": "^8.19.2",
|
"@tanstack/react-table": "^8.19.2",
|
||||||
|
|||||||
79
frontend/src/components/ui/card.tsx
Normal file
79
frontend/src/components/ui/card.tsx
Normal 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 }
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./button";
|
export * from "./button";
|
||||||
|
export * from "./card";
|
||||||
export * from "./data-table-pagination";
|
export * from "./data-table-pagination";
|
||||||
export * from "./dialog";
|
export * from "./dialog";
|
||||||
export * from "./dropdown-menu";
|
export * from "./dropdown-menu";
|
||||||
@@ -11,3 +12,6 @@ export * from "./sql-data-table";
|
|||||||
export * from "./sql-data-table-cell";
|
export * from "./sql-data-table-cell";
|
||||||
export * from "./switch";
|
export * from "./switch";
|
||||||
export * from "./table";
|
export * from "./table";
|
||||||
|
export * from "./tabs";
|
||||||
|
export * from "./toggle";
|
||||||
|
export * from "./toggle-group";
|
||||||
|
|||||||
@@ -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
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
|
||||||
className={cn(
|
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",
|
"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}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Input.displayName = "Input"
|
Input.displayName = "Input";
|
||||||
|
|
||||||
export { Input }
|
export { Input };
|
||||||
|
|||||||
53
frontend/src/components/ui/tabs.tsx
Normal file
53
frontend/src/components/ui/tabs.tsx
Normal 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 }
|
||||||
59
frontend/src/components/ui/toggle-group.tsx
Normal file
59
frontend/src/components/ui/toggle-group.tsx
Normal 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 };
|
||||||
43
frontend/src/components/ui/toggle.tsx
Normal file
43
frontend/src/components/ui/toggle.tsx
Normal 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 }
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
import { Route as rootRoute } from './routes/__root'
|
import { Route as rootRoute } from './routes/__root'
|
||||||
import { Route as IndexImport } from './routes/index'
|
import { Route as IndexImport } from './routes/index'
|
||||||
import { Route as RawIndexImport } from './routes/raw/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 DbDbNameTablesIndexImport } from './routes/db/$dbName/tables/index'
|
||||||
import { Route as DbDbNameTablesTableNameIndexImport } from './routes/db/$dbName/tables/$tableName/index'
|
import { Route as DbDbNameTablesTableNameIndexImport } from './routes/db/$dbName/tables/$tableName/index'
|
||||||
import { Route as DbDbNameTablesTableNameDataImport } from './routes/db/$dbName/tables/$tableName/data'
|
import { Route as DbDbNameTablesTableNameDataImport } from './routes/db/$dbName/tables/$tableName/data'
|
||||||
@@ -29,6 +30,11 @@ const RawIndexRoute = RawIndexImport.update({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const AuthLoginRoute = AuthLoginImport.update({
|
||||||
|
path: '/auth/login',
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const DbDbNameTablesIndexRoute = DbDbNameTablesIndexImport.update({
|
const DbDbNameTablesIndexRoute = DbDbNameTablesIndexImport.update({
|
||||||
path: '/db/$dbName/tables/',
|
path: '/db/$dbName/tables/',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
@@ -57,6 +63,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexImport
|
preLoaderRoute: typeof IndexImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
|
'/auth/login': {
|
||||||
|
id: '/auth/login'
|
||||||
|
path: '/auth/login'
|
||||||
|
fullPath: '/auth/login'
|
||||||
|
preLoaderRoute: typeof AuthLoginImport
|
||||||
|
parentRoute: typeof rootRoute
|
||||||
|
}
|
||||||
'/raw/': {
|
'/raw/': {
|
||||||
id: '/raw/'
|
id: '/raw/'
|
||||||
path: '/raw'
|
path: '/raw'
|
||||||
@@ -92,6 +105,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
export const routeTree = rootRoute.addChildren({
|
export const routeTree = rootRoute.addChildren({
|
||||||
IndexRoute,
|
IndexRoute,
|
||||||
|
AuthLoginRoute,
|
||||||
RawIndexRoute,
|
RawIndexRoute,
|
||||||
DbDbNameTablesIndexRoute,
|
DbDbNameTablesIndexRoute,
|
||||||
DbDbNameTablesTableNameDataRoute,
|
DbDbNameTablesTableNameDataRoute,
|
||||||
@@ -107,6 +121,7 @@ export const routeTree = rootRoute.addChildren({
|
|||||||
"filePath": "__root.tsx",
|
"filePath": "__root.tsx",
|
||||||
"children": [
|
"children": [
|
||||||
"/",
|
"/",
|
||||||
|
"/auth/login",
|
||||||
"/raw/",
|
"/raw/",
|
||||||
"/db/$dbName/tables/",
|
"/db/$dbName/tables/",
|
||||||
"/db/$dbName/tables/$tableName/data",
|
"/db/$dbName/tables/$tableName/data",
|
||||||
@@ -116,6 +131,9 @@ export const routeTree = rootRoute.addChildren({
|
|||||||
"/": {
|
"/": {
|
||||||
"filePath": "index.tsx"
|
"filePath": "index.tsx"
|
||||||
},
|
},
|
||||||
|
"/auth/login": {
|
||||||
|
"filePath": "auth/login.tsx"
|
||||||
|
},
|
||||||
"/raw/": {
|
"/raw/": {
|
||||||
"filePath": "raw/index.tsx"
|
"filePath": "raw/index.tsx"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useDatabasesListQuery, useTablesListQuery } from "@/services/db";
|
import { useDatabasesListQuery, useTablesListQuery } from "@/services/db";
|
||||||
import { useUiStore } from "@/state";
|
import { useUiStore } from "@/state";
|
||||||
|
import { useSessionStore } from "@/state/db-session-store";
|
||||||
import {
|
import {
|
||||||
Link,
|
Link,
|
||||||
Outlet,
|
Outlet,
|
||||||
@@ -29,6 +30,8 @@ export const Route = createRootRoute({
|
|||||||
function Root() {
|
function Root() {
|
||||||
const showSidebar = useUiStore.use.showSidebar();
|
const showSidebar = useUiStore.use.showSidebar();
|
||||||
const toggleSidebar = useUiStore.use.toggleSidebar();
|
const toggleSidebar = useUiStore.use.toggleSidebar();
|
||||||
|
const sessions = useSessionStore.use.sessions();
|
||||||
|
const currentSessionId = useSessionStore.use.currentSessionId();
|
||||||
|
|
||||||
const { data } = useDatabasesListQuery();
|
const { data } = useDatabasesListQuery();
|
||||||
const params = useParams({ strict: false });
|
const params = useParams({ strict: false });
|
||||||
@@ -40,7 +43,6 @@ function Root() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data: tables } = useTablesListQuery({ dbName });
|
const { data: tables } = useTablesListQuery({ dbName });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -74,8 +76,33 @@ function Root() {
|
|||||||
<aside className={"p-3"}>
|
<aside className={"p-3"}>
|
||||||
{showSidebar && (
|
{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}>
|
<Select value={dbName} onValueChange={handleSelectedDb}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full mt-4">
|
||||||
<SelectValue placeholder="Select a Database" />
|
<SelectValue placeholder="Select a Database" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
237
frontend/src/routes/auth/login.tsx
Normal file
237
frontend/src/routes/auth/login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,9 +9,14 @@ import type {
|
|||||||
GetTableForeignKeysArgs,
|
GetTableForeignKeysArgs,
|
||||||
GetTableIndexesArgs,
|
GetTableIndexesArgs,
|
||||||
GetTablesListArgs,
|
GetTablesListArgs,
|
||||||
QueryRawSqlArgs,
|
|
||||||
} from "./db.types";
|
} from "./db.types";
|
||||||
|
|
||||||
|
export const useLoginMutation = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: dbService.login,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useDatabasesListQuery = () => {
|
export const useDatabasesListQuery = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [DB_QUERY_KEYS.DATABASES.ALL],
|
queryKey: [DB_QUERY_KEYS.DATABASES.ALL],
|
||||||
@@ -90,7 +95,6 @@ export const useQueryRawSqlMutation = () => {
|
|||||||
}
|
}
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
},
|
},
|
||||||
mutationFn: ({ query }: QueryRawSqlArgs) =>
|
mutationFn: dbService.queryRawSql,
|
||||||
dbService.queryRawSql({ query }),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ky from 'ky'
|
import ky from "ky";
|
||||||
|
|
||||||
export const dbInstance = ky.create({
|
export const dbInstance = ky.create({
|
||||||
prefixUrl: 'http://localhost:3000'
|
credentials: "include",
|
||||||
})
|
prefixUrl: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
GetTableIndexesArgs,
|
GetTableIndexesArgs,
|
||||||
GetTablesListArgs,
|
GetTablesListArgs,
|
||||||
GetTablesListResponse,
|
GetTablesListResponse,
|
||||||
|
LoginArgs,
|
||||||
|
LoginResponse,
|
||||||
QueryRawSqlArgs,
|
QueryRawSqlArgs,
|
||||||
QueryRawSqlResponse,
|
QueryRawSqlResponse,
|
||||||
TableColumns,
|
TableColumns,
|
||||||
@@ -17,6 +19,12 @@ import type {
|
|||||||
} from "@/services/db/db.types";
|
} from "@/services/db/db.types";
|
||||||
|
|
||||||
class DbService {
|
class DbService {
|
||||||
|
login(data: LoginArgs) {
|
||||||
|
return dbInstance
|
||||||
|
.post("api/auth/login", { json: data })
|
||||||
|
.json<LoginResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
getDatabasesList() {
|
getDatabasesList() {
|
||||||
return dbInstance.get("api/databases").json<DatabasesResponse>();
|
return dbInstance.get("api/databases").json<DatabasesResponse>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
export type DatabasesResponse = Array<string>;
|
||||||
|
|
||||||
// Tables List
|
// Tables List
|
||||||
|
|||||||
82
frontend/src/state/db-session-store.ts
Normal file
82
frontend/src/state/db-session-store.ts
Normal 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);
|
||||||
Reference in New Issue
Block a user