From cc7d7d71b9009dbac8b6c87803e5dea8f70b1868 Mon Sep 17 00:00:00 2001 From: andres Date: Sun, 7 Jul 2024 00:48:39 +0200 Subject: [PATCH] initial commit --- .gitignore | 1 + api/.gitignore | 44 +++ api/README.md | 19 ++ api/biome.json | 22 ++ api/bun.lockb | Bin 0 -> 10683 bytes api/package.json | 20 ++ api/src/index.ts | 294 ++++++++++++++++++ api/tsconfig.json | 105 +++++++ frontend/.gitignore | 24 ++ frontend/README.md | 30 ++ frontend/biome.json | 20 ++ frontend/bun.lockb | Bin 0 -> 129925 bytes frontend/components.json | 17 + frontend/index.html | 13 + frontend/package.json | 47 +++ frontend/postcss.config.js | 6 + frontend/public/vite.svg | 1 + .../components/db-table-view/data-table.tsx | 241 ++++++++++++++ frontend/src/components/ui/button.tsx | 57 ++++ .../components/ui/data-table-pagination.tsx | 96 ++++++ frontend/src/components/ui/data-table.tsx | 80 +++++ frontend/src/components/ui/dropdown-menu.tsx | 203 ++++++++++++ frontend/src/components/ui/index.ts | 7 + frontend/src/components/ui/mode-toggle.tsx | 37 +++ frontend/src/components/ui/select.tsx | 162 ++++++++++ frontend/src/components/ui/table.tsx | 120 +++++++ frontend/src/components/ui/theme-provider.tsx | 71 +++++ frontend/src/index.css | 129 ++++++++ frontend/src/lib/utils.ts | 24 ++ frontend/src/main.tsx | 36 +++ frontend/src/routeTree.gen.ts | 115 +++++++ frontend/src/routes/__root.tsx | 128 ++++++++ .../db/$dbName/tables/$tableName/data.tsx | 41 +++ .../db/$dbName/tables/$tableName/index.tsx | 161 ++++++++++ .../src/routes/db/$dbName/tables/index.tsx | 271 ++++++++++++++++ frontend/src/routes/index.tsx | 26 ++ frontend/src/services/db/db.hooks.ts | 64 ++++ frontend/src/services/db/db.instance.ts | 5 + frontend/src/services/db/db.query-keys.ts | 12 + frontend/src/services/db/db.service.ts | 50 +++ frontend/src/services/db/db.types.ts | 64 ++++ frontend/src/services/db/index.ts | 2 + frontend/src/tanstack-table-typedef.ts | 8 + frontend/src/vite-env.d.ts | 2 + frontend/tailwind.config.js | 77 +++++ frontend/tsconfig.app.json | 27 ++ frontend/tsconfig.json | 11 + frontend/tsconfig.node.json | 13 + frontend/vite.config.ts | 14 + 49 files changed, 3017 insertions(+) create mode 100644 .gitignore create mode 100644 api/.gitignore create mode 100644 api/README.md create mode 100644 api/biome.json create mode 100644 api/bun.lockb create mode 100644 api/package.json create mode 100644 api/src/index.ts create mode 100644 api/tsconfig.json create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/biome.json create mode 100644 frontend/bun.lockb create mode 100644 frontend/components.json create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/components/db-table-view/data-table.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/data-table-pagination.tsx create mode 100644 frontend/src/components/ui/data-table.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/index.ts create mode 100644 frontend/src/components/ui/mode-toggle.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/theme-provider.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/routeTree.gen.ts create mode 100644 frontend/src/routes/__root.tsx create mode 100644 frontend/src/routes/db/$dbName/tables/$tableName/data.tsx create mode 100644 frontend/src/routes/db/$dbName/tables/$tableName/index.tsx create mode 100644 frontend/src/routes/db/$dbName/tables/index.tsx create mode 100644 frontend/src/routes/index.tsx create mode 100644 frontend/src/services/db/db.hooks.ts create mode 100644 frontend/src/services/db/db.instance.ts create mode 100644 frontend/src/services/db/db.query-keys.ts create mode 100644 frontend/src/services/db/db.service.ts create mode 100644 frontend/src/services/db/db.types.ts create mode 100644 frontend/src/services/db/index.ts create mode 100644 frontend/src/tanstack-table-typedef.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..fb94ae7 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/*.trace +**/*.zip +**/*.tar.gz +**/*.tgz +**/*.log +package-lock.json +**/*.bun + +.env \ No newline at end of file diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..bebf061 --- /dev/null +++ b/api/README.md @@ -0,0 +1,19 @@ +# Elysia with Bun runtime + +## Getting Started + +To get started with this template, simply paste this command into your terminal: + +```bash +bun create elysia ./elysia-example +``` + +## Development + +To start the development server run: + +```bash +bun run dev +``` + +Open http://localhost:3000/ with your browser to see the result. diff --git a/api/biome.json b/api/biome.json new file mode 100644 index 0000000..ec7a9f9 --- /dev/null +++ b/api/biome.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 2 + }, + "json": { + "parser": { + "allowComments": true, + "allowTrailingCommas": true + } + } +} diff --git a/api/bun.lockb b/api/bun.lockb new file mode 100644 index 0000000000000000000000000000000000000000..4478f52766c6aac81337998e596d5136bc88855f GIT binary patch literal 10683 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 literal 0 HcmV?d00001 diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..255bb08 --- /dev/null +++ b/api/package.json @@ -0,0 +1,20 @@ +{ + "name": "api", + "version": "1.0.50", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "bun run --watch src/index.ts" + }, + "dependencies": { + "@elysiajs/cors": "^1.0.2", + "@it-incubator/prettier-config": "^0.1.2", + "elysia": "latest", + "postgres": "^3.4.4" + }, + "devDependencies": { + "@biomejs/biome": "1.8.3", + "bun-types": "latest" + }, + "prettier": "@it-incubator/prettier-config", + "module": "src/index.js" +} diff --git a/api/src/index.ts b/api/src/index.ts new file mode 100644 index 0000000..fa33b4a --- /dev/null +++ b/api/src/index.ts @@ -0,0 +1,294 @@ +import cors from "@elysiajs/cors"; +import { Elysia } 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); + +const app = new Elysia({ prefix: "/api" }) + .get("/", () => "Hello Elysia") + .get("/databases", async () => { + const databases = await getDatabases(); + return new Response(JSON.stringify(databases, null, 2)).json(); + }) + .get("/databases/:dbName/tables", async ({ query, params }) => { + const { sortField, sortDesc } = query; + const { dbName } = params; + + const tables = await getTables(dbName, sortField, sortDesc === "true"); + + return new Response(JSON.stringify(tables, null, 2)).json(); + }) + .get("databases/:dbName/tables/:name/data", async ({ params, query }) => { + const { name, dbName } = params; + const { perPage = "50", page = "0" } = query; + + const offset = ( + Number.parseInt(perPage, 10) * Number.parseInt(page, 10) + ).toString(); + + const rows = sql` + SELECT COUNT(*) + FROM ${sql(dbName)}.${sql(name)}`; + + const tables = sql` + SELECT * + FROM ${sql(dbName)}.${sql(name)} + LIMIT ${perPage} OFFSET ${offset}`; + + const [[count], data] = await Promise.all([rows, tables]); + + return { + count: count.count, + data, + }; + }) + .get("db/tables/:name/columns", async ({ params, query }) => { + const { name } = params; + + const columns = await getColumns(name); + return new Response(JSON.stringify(columns, null, 2)).json(); + }) + .get("db/tables/:name/indexes", async ({ params, query }) => { + const { name } = params; + + const indexes = await getIndexes(name); + return new Response(JSON.stringify(indexes, null, 2)).json(); + }) + .get("db/tables/:name/foreign-keys", async ({ params, query }) => { + const { name } = params; + + const foreignKeys = await getForeignKeys(name); + return new Response(JSON.stringify(foreignKeys, null, 2)).json(); + }) + .use(cors()) + .listen(3000); + +console.log( + `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`, +); + +async function getIndexes(table: string) { + const returnObj = {}; + + const [tableOidResult] = await sql` + SELECT oid + FROM pg_class + WHERE relname = ${table} + `; + + 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(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} + ORDER BY + cols.ordinal_position; + `; +} + +async function getForeignKeys(table: 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 = ${table} + ) + AND contype = 'f'::char + ORDER BY conkey, conname + `; + + return result.map((row) => { + const match = row.definition.match( + /FOREIGN KEY\s*\((.+)\)\s*REFERENCES (.+)\((.+)\)(.*)$/iy, + ); + console.log(match); + console.log("match[0]", match[0]); + console.log("match[1]", match[1]); + console.log("match[2]", match[2]); + if (match) { + const sourceColumns = match[1] + .split(",") + .map((col) => col.replaceAll('"', "").trim()); + const targetTableMatch = match[2].match( + /^(("([^"]|"")+"|[^"]+)\.)?"?("([^"]|"")+"|[^"]+)$/, + ); + console.log("targetTableMatch", targetTableMatch); + 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); +} diff --git a/api/tsconfig.json b/api/tsconfig.json new file mode 100644 index 0000000..2ca47bb --- /dev/null +++ b/api/tsconfig.json @@ -0,0 +1,105 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ES2022" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "types": [ + "bun-types" + ] /* Specify type package names to be included without being referenced in a source file. */, + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..0d6babe --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +} +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/frontend/biome.json b/frontend/biome.json new file mode 100644 index 0000000..7b092ce --- /dev/null +++ b/frontend/biome.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "organizeImports": { "enabled": true }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "warn" + } + } + }, + "formatter": { "indentStyle": "space", "indentWidth": 2 }, + "json": { + "parser": { + "allowComments": true, + "allowTrailingCommas": true + } + } +} diff --git a/frontend/bun.lockb b/frontend/bun.lockb new file mode 100644 index 0000000000000000000000000000000000000000..e9442af5eb521a03154419864da264bc900e93b0 GIT binary patch literal 129925 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 literal 0 HcmV?d00001 diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..f7ee37b --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "/components", + "utils": "@/lib/utils" + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..82aa064 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + OpenStudio + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bd58e03 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint:types": "tsc -b", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@it-incubator/prettier-config": "^0.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-slot": "^1.1.0", + "@tanstack/react-query": "^5.50.1", + "@tanstack/react-router": "^1.43.12", + "@tanstack/react-table": "^8.19.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "ky": "^1.4.0", + "lucide-react": "^0.400.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8" + }, + "devDependencies": { + "@biomejs/biome": "1.8.3", + "@tanstack/react-query-devtools": "^5.50.1", + "@tanstack/router-devtools": "^1.43.12", + "@tanstack/router-plugin": "^1.43.12", + "@types/node": "^20.14.10", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.7.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.4", + "typescript": "^5.5.3", + "vite": "^5.3.1" + }, + "prettier": "@it-incubator/prettier-config" +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/db-table-view/data-table.tsx b/frontend/src/components/db-table-view/data-table.tsx new file mode 100644 index 0000000..fee3541 --- /dev/null +++ b/frontend/src/components/db-table-view/data-table.tsx @@ -0,0 +1,241 @@ +import { + DataTablePagination, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui"; +import { cn } from "@/lib/utils"; +import { useTableColumnsQuery, useTableDataQuery } from "@/services/db"; +import { + type ColumnDef, + type OnChangeFn, + type PaginationState, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { Rows3 } from "lucide-react"; +import { useMemo } from "react"; +import { z } from "zod"; + +function isUrl(value: string) { + return z.string().url().safeParse(value).success; +} + +const imageUrlRegex = new RegExp( + /(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|svg|webp|bmp))/i, +); + +function isImageUrl(value: string) { + return value.match(imageUrlRegex); +} + +export const DataTable = ({ + tableName, + dbName, + pageIndex, + pageSize, + onPageSizeChange, + onPageIndexChange, +}: { + tableName: string; + pageIndex: number; + dbName: string; + pageSize: number; + onPageIndexChange: (pageIndex: number) => void; + onPageSizeChange: (pageSize: number) => void; +}) => { + const { data: details } = useTableColumnsQuery({ name: tableName }); + const { data } = useTableDataQuery({ + tableName, + dbName, + perPage: pageSize, + page: pageIndex, + }); + + const paginationUpdater: OnChangeFn = (args) => { + if (typeof args === "function") { + const newArgs = args({ + pageIndex, + pageSize, + }); + if (newArgs.pageSize !== pageSize) { + onPageSizeChange(newArgs.pageSize); + } else if (newArgs.pageIndex !== pageIndex) { + onPageIndexChange(newArgs.pageIndex); + } + } else { + onPageSizeChange(args.pageSize); + onPageIndexChange(args.pageIndex); + } + }; + + const columns = useMemo(() => { + if (!details) return [] as ColumnDef[]; + + return details.map(({ column_name, udt_name, data_type }) => ({ + accessorKey: column_name, + title: column_name, + size: 300, + header: () => { + return ( +
+ {`${column_name} [${udt_name.toUpperCase()}]`} +
+ ); + }, + sortable: true, + cell: ({ row }) => { + const value = row.getValue(column_name) as any; + let finalValue = value; + if (udt_name === "timestamp") { + finalValue = new Date(value as string).toLocaleString(); + } + if (typeof value === "string" && isUrl(value)) { + const isImage = isImageUrl(value); + return ( +
+
+ {value} + {isImage && ( + {"preview"} + )} +
+
+ ); + } + return ( +
+ {finalValue} +
+ ); + }, + })) as ColumnDef[]; + }, [details]); + + const table = useReactTable({ + data: data?.data ?? [], + columns, + columnResizeMode: "onChange", + getCoreRowModel: getCoreRowModel(), + rowCount: data?.count ?? 0, + state: { + pagination: { + pageIndex, + pageSize, + }, + }, + onPaginationChange: paginationUpdater, + manualPagination: true, + }); + + return ( +
+
+

+ {tableName} +

+

+ Rows: {data?.count} +

+
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
header.column.resetSize(), + onMouseDown: header.getResizeHandler(), + onTouchStart: header.getResizeHandler(), + className: `resizer ${header.column.getIsResizing() ? "isResizing" : ""}`, + }} + /> + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ); +}; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..cc5b148 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +import { Slot } from "@radix-ui/react-slot"; +import { type VariantProps, cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { forwardRef } from "react"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + iconSm: "size-8", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/frontend/src/components/ui/data-table-pagination.tsx b/frontend/src/components/ui/data-table-pagination.tsx new file mode 100644 index 0000000..7633ca2 --- /dev/null +++ b/frontend/src/components/ui/data-table-pagination.tsx @@ -0,0 +1,96 @@ +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui"; +import type { Table } from "@tanstack/react-table"; +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; + +interface DataTablePaginationProps { + table: Table; +} + +export function DataTablePagination({ + table, +}: DataTablePaginationProps) { + return ( +
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. +
+
+
+

Rows per page

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/components/ui/data-table.tsx b/frontend/src/components/ui/data-table.tsx new file mode 100644 index 0000000..5a135e4 --- /dev/null +++ b/frontend/src/components/ui/data-table.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +} diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..709f0aa --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,203 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { + type ComponentPropsWithoutRef, + type ElementRef, + type HTMLAttributes, + forwardRef, +} from "react"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..de391d7 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,7 @@ +export * from "./button"; +export * from "./data-table-pagination"; +export * from "./dropdown-menu"; +export * from "./mode-toggle"; +export * from "./select"; +export * from "./table"; +export * from "./theme-provider"; diff --git a/frontend/src/components/ui/mode-toggle.tsx b/frontend/src/components/ui/mode-toggle.tsx new file mode 100644 index 0000000..75fd84b --- /dev/null +++ b/frontend/src/components/ui/mode-toggle.tsx @@ -0,0 +1,37 @@ +import { Moon, Sun } from "lucide-react"; + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + useTheme, +} from "@/components/ui"; + +export function ModeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ); +} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..3cfdb52 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,162 @@ +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { + type ComponentPropsWithoutRef, + type ElementRef, + forwardRef, +} from "react"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 0000000..2ad2e0b --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,120 @@ +import { cn } from "@/lib/utils"; +import { + type HTMLAttributes, + type TdHTMLAttributes, + type ThHTMLAttributes, + forwardRef, +} from "react"; + +const Table = forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ), +); +Table.displayName = "Table"; + +const TableHeader = forwardRef< + HTMLTableSectionElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = forwardRef< + HTMLTableSectionElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = forwardRef< + HTMLTableSectionElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = forwardRef< + HTMLTableRowElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = forwardRef< + HTMLTableCellElement, + ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableHead.displayName = "TableHead"; + +const TableCell = forwardRef< + HTMLTableCellElement, + TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableCell.displayName = "TableCell"; + +const TableCaption = forwardRef< + HTMLTableCaptionElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/frontend/src/components/ui/theme-provider.tsx b/frontend/src/components/ui/theme-provider.tsx new file mode 100644 index 0000000..4847d98 --- /dev/null +++ b/frontend/src/components/ui/theme-provider.tsx @@ -0,0 +1,71 @@ +import { createContext, useContext, useEffect, useState } from 'react' + +type Theme = 'dark' | 'light' | 'system' + +type ThemeProviderProps = { + children: React.ReactNode + defaultTheme?: Theme + storageKey?: string +} + +type ThemeProviderState = { + theme: Theme + setTheme: (theme: Theme) => void +} + +const initialState: ThemeProviderState = { + theme: 'system', + setTheme: () => null, +} + +const ThemeProviderContext = createContext(initialState) + +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'vite-ui-theme', + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme + ) + + useEffect(() => { + const root = window.document.documentElement + + root.classList.remove('light', 'dark') + + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + + root.classList.add(systemTheme) + return + } + + root.classList.add(theme) + }, [theme]) + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme) + setTheme(theme) + }, + } + + return ( + + {children} + + ) +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext) + + if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider') + + return context +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..8862324 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,129 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } +} + +@layer base { + :root { + text-underline-position: under; + } + .grid-rows-layout { + grid-template-rows: 60px 1fr; + } + + .grid-cols-layout { + grid-template-columns: 264px 1fr; + } + + .max-w-layout { + max-width: calc(100vw - 264px); + } + + .w-layout { + width: calc(100vw - 264px); + } + .resizer { + position: absolute; + top: 0; + height: 100%; + width: 5px; + background: rgba(100, 100, 100, 0.5); + cursor: col-resize; + user-select: none; + touch-action: none; + right: 0; + } + + .resizer.isResizing { + background: rgba(100, 100, 100, 20); + opacity: 1; + } + + tr { + width: fit-content; + } + + @media (hover: hover) { + .resizer { + opacity: 0; + } + + *:hover > .resizer { + opacity: 1; + } + } + + .h-layout { + height: calc(100vh - 60px); + } + + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..b19bf57 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,24 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +type Valuable = { [K in keyof T as T[K] extends null | undefined ? never : K]: T[K] } + +export function getValuable>(obj: T): V { + return Object.fromEntries( + Object.entries(obj).filter( + ([, v]) => !((typeof v === 'string' && !v.length) || v === null || typeof v === 'undefined') + ) + ) as V +} + +export function prettyBytes(bytes: number): string { + const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'] + if (bytes === 0) return '0 B' + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + const size = bytes / Math.pow(1024, i) + return `${size.toFixed(2)} ${units[i]}` +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ed8319f --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,36 @@ +import { RouterProvider, createRouter } from "@tanstack/react-router"; +import { StrictMode } from "react"; +import ReactDOM from "react-dom/client"; +import "./index.css"; +import { ThemeProvider } from "@/components/ui"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +// Import the generated route tree +import { routeTree } from "./routeTree.gen"; + +// Create a new router instance +const router = createRouter({ routeTree }); +// Register the router instance for type safety +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +const queryClient = new QueryClient(); + +// Render the app +const rootElement = document.getElementById("root"); +if (rootElement && !rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement); + root.render( + + + + + + + + , + ); +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000..4f8a674 --- /dev/null +++ b/frontend/src/routeTree.gen.ts @@ -0,0 +1,115 @@ +/* prettier-ignore-start */ + +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file is auto-generated by TanStack Router + +// Import Routes + +import { Route as rootRoute } from './routes/__root' +import { Route as IndexImport } from './routes/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 DbDbNameTablesTableNameDataImport } from './routes/db/$dbName/tables/$tableName/data' + +// Create/Update Routes + +const IndexRoute = IndexImport.update({ + path: '/', + getParentRoute: () => rootRoute, +} as any) + +const DbDbNameTablesIndexRoute = DbDbNameTablesIndexImport.update({ + path: '/db/$dbName/tables/', + getParentRoute: () => rootRoute, +} as any) + +const DbDbNameTablesTableNameIndexRoute = + DbDbNameTablesTableNameIndexImport.update({ + path: '/db/$dbName/tables/$tableName/', + getParentRoute: () => rootRoute, + } as any) + +const DbDbNameTablesTableNameDataRoute = + DbDbNameTablesTableNameDataImport.update({ + path: '/db/$dbName/tables/$tableName/data', + getParentRoute: () => rootRoute, + } as any) + +// Populate the FileRoutesByPath interface + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexImport + parentRoute: typeof rootRoute + } + '/db/$dbName/tables/': { + id: '/db/$dbName/tables/' + path: '/db/$dbName/tables' + fullPath: '/db/$dbName/tables' + preLoaderRoute: typeof DbDbNameTablesIndexImport + parentRoute: typeof rootRoute + } + '/db/$dbName/tables/$tableName/data': { + id: '/db/$dbName/tables/$tableName/data' + path: '/db/$dbName/tables/$tableName/data' + fullPath: '/db/$dbName/tables/$tableName/data' + preLoaderRoute: typeof DbDbNameTablesTableNameDataImport + parentRoute: typeof rootRoute + } + '/db/$dbName/tables/$tableName/': { + id: '/db/$dbName/tables/$tableName/' + path: '/db/$dbName/tables/$tableName' + fullPath: '/db/$dbName/tables/$tableName' + preLoaderRoute: typeof DbDbNameTablesTableNameIndexImport + parentRoute: typeof rootRoute + } + } +} + +// Create and export the route tree + +export const routeTree = rootRoute.addChildren({ + IndexRoute, + DbDbNameTablesIndexRoute, + DbDbNameTablesTableNameDataRoute, + DbDbNameTablesTableNameIndexRoute, +}) + +/* prettier-ignore-end */ + +/* ROUTE_MANIFEST_START +{ + "routes": { + "__root__": { + "filePath": "__root.tsx", + "children": [ + "/", + "/db/$dbName/tables/", + "/db/$dbName/tables/$tableName/data", + "/db/$dbName/tables/$tableName/" + ] + }, + "/": { + "filePath": "index.tsx" + }, + "/db/$dbName/tables/": { + "filePath": "db/$dbName/tables/index.tsx" + }, + "/db/$dbName/tables/$tableName/data": { + "filePath": "db/$dbName/tables/$tableName/data.tsx" + }, + "/db/$dbName/tables/$tableName/": { + "filePath": "db/$dbName/tables/$tableName/index.tsx" + } + } +} +ROUTE_MANIFEST_END */ diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..88ea91f --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -0,0 +1,128 @@ +import { + ModeToggle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + buttonVariants, +} from "@/components/ui"; +import { cn } from "@/lib/utils"; +import { useDatabasesListQuery, useTablesListQuery } from "@/services/db"; +import { + Link, + Outlet, + createRootRoute, + useNavigate, + useParams, +} from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/router-devtools"; +import { Database, Rows3, Table2 } from "lucide-react"; +import { z } from "zod"; + +const searchSchema = z.object({ + dbSchema: z.string().optional().catch(""), +}); + +export const Route = createRootRoute({ + component: Root, + validateSearch: (search) => searchSchema.parse(search), +}); + +function Root() { + const { data } = useDatabasesListQuery(); + + const params = useParams({ strict: false }); + const dbName = params.dbName ?? ""; + const navigate = useNavigate({ from: Route.fullPath }); + + const handleSelectedSchema = (schema: string) => { + void navigate({ to: "/db/$dbName/tables", params: { dbName: schema } }); + }; + + const { data: tables } = useTablesListQuery({ dbName }); + + return ( + <> +
+
+ + + Home + +
+ + +
+ + + ); +} diff --git a/frontend/src/routes/db/$dbName/tables/$tableName/data.tsx b/frontend/src/routes/db/$dbName/tables/$tableName/data.tsx new file mode 100644 index 0000000..765ee41 --- /dev/null +++ b/frontend/src/routes/db/$dbName/tables/$tableName/data.tsx @@ -0,0 +1,41 @@ +import { DataTable } from "@/components/db-table-view/data-table"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { z } from "zod"; + +const tableSearchSchema = z.object({ + pageSize: z.number().catch(10), + pageIndex: z.number().catch(0), +}); + +export const Route = createFileRoute("/db/$dbName/tables/$tableName/data")({ + component: TableView, + validateSearch: (search) => tableSearchSchema.parse(search), +}); + +function TableView() { + const { tableName, dbName } = Route.useParams(); + const { pageSize, pageIndex } = Route.useSearch(); + const navigate = useNavigate({ from: Route.fullPath }); + + const updatePageSize = (value: number) => { + return void navigate({ + search: (prev) => ({ ...prev, pageSize: value, pageIndex: 0 }), + }); + }; + const updatePageIndex = (pageIndex: number) => { + return void navigate({ search: (prev) => ({ ...prev, pageIndex }) }); + }; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/routes/db/$dbName/tables/$tableName/index.tsx b/frontend/src/routes/db/$dbName/tables/$tableName/index.tsx new file mode 100644 index 0000000..853ea1c --- /dev/null +++ b/frontend/src/routes/db/$dbName/tables/$tableName/index.tsx @@ -0,0 +1,161 @@ +import { DataTable } from "@/components/ui/data-table"; +import { cn } from "@/lib/utils"; +import { + type TableColumn, + type TableForeignKey, + type TableIndexEntry, + useTableColumnsQuery, + useTableForeignKeysQuery, + useTableIndexesQuery, +} from "@/services/db"; +import { Link, createFileRoute } from "@tanstack/react-router"; +import { createColumnHelper } from "@tanstack/react-table"; +import { ArrowRight, Table2 } from "lucide-react"; + +export const Route = createFileRoute("/db/$dbName/tables/$tableName/")({ + component: TableDetailsTable, +}); +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.accessor("column_name", { + header: "Column", + }), + columnHelper.accessor("data_type", { + header: "Type", + }), + columnHelper.accessor("udt_name", { + header: "UDT", + }), + columnHelper.accessor("column_comment", { + header: "Comment", + }), +]; + +const tableIndexesColumnHelper = createColumnHelper(); + +const tableIndexesColumns = [ + tableIndexesColumnHelper.accessor("type", { + header: "Type", + }), + tableIndexesColumnHelper.accessor("columns", { + header: "Columns", + cell: (props) => props.getValue().join(", "), + }), + tableIndexesColumnHelper.accessor("key", { + header: "Key", + }), +]; + +const tableForeignKeysColumnHelper = createColumnHelper(); + +const tableForeignKeysColumns = [ + tableForeignKeysColumnHelper.accessor("source", { + header: "Source", + cell: (props) => props.getValue().join(", "), + }), + tableForeignKeysColumnHelper.accessor("target", { + header: "Target", + cell: (props) => { + const { table, target } = props.row.original; + return ( + + {table} ({target.join(", ")}) + + ); + }, + }), + tableForeignKeysColumnHelper.accessor("on_delete", { + header: "On Delete", + }), + tableForeignKeysColumnHelper.accessor("on_update", { + header: "On Update", + }), +]; + +function TableDetailsTable() { + const { tableName: name } = Route.useParams(); + const { data: tableColumns } = useTableColumnsQuery({ name }); + const { data: tableIndexes } = useTableIndexesQuery({ name }); + const { data: tableForeignKeys } = useTableForeignKeysQuery({ name }); + + return ( +
+
+
+

+ {name} +

+ + Explore data + +
+ +
+

+ Columns +

+
+
+ +
+ +
+

+ Indexes +

+
+
+ {tableIndexes?.length ? ( + + ) : ( +
No indexes.
+ )} +
+
+

+ Foreign Keys +

+
+
+ {tableForeignKeys?.length ? ( + + ) : ( +
No foreign keys.
+ )} +
+
+
+ ); +} diff --git a/frontend/src/routes/db/$dbName/tables/index.tsx b/frontend/src/routes/db/$dbName/tables/index.tsx new file mode 100644 index 0000000..68c3401 --- /dev/null +++ b/frontend/src/routes/db/$dbName/tables/index.tsx @@ -0,0 +1,271 @@ +import { + Button, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui"; +import { cn, prettyBytes } from "@/lib/utils"; +import { type TableInfo, useTablesListQuery } from "@/services/db"; +import { Link, createFileRoute } from "@tanstack/react-router"; +import { + type SortingState, + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { ArrowUp, Database } from "lucide-react"; +import { useMemo, useState } from "react"; + +export const Route = createFileRoute("/db/$dbName/tables/")({ + component: Component, +}); + +const columnHelper = createColumnHelper(); + +const createColumns = (dbName: string) => { + return [ + columnHelper.accessor("table_name", { + header: "Name", + + cell: (props) => { + const tableName = props.getValue(); + return ( +
+ + {tableName} + +
+ ); + }, + }), + + columnHelper.accessor("table_size", { + header: "Table Size", + cell: (props) => prettyBytes(props.getValue()), + meta: { + className: "text-end", + }, + }), + + columnHelper.accessor("index_size", { + header: "Index Size", + cell: (props) => prettyBytes(props.getValue()), + meta: { + className: "text-end", + }, + }), + + columnHelper.accessor("total_size", { + header: "Total Size", + cell: (props) => prettyBytes(props.getValue()), + meta: { + className: "text-end", + }, + }), + + columnHelper.accessor("row_count", { + header: "Rows", + meta: { + className: "text-end", + }, + }), + + columnHelper.accessor("primary_key", { + header: "Primary key", + }), + + columnHelper.accessor("indexes", { + header: "Indexes", + cell: (props) => { + const indexes = props.getValue(); + if (!indexes) return null; + return ( +
+ {indexes?.split(",").map((index) => ( +
{index}
+ ))} +
+ ); + }, + }), + + columnHelper.accessor("comments", { + header: "Comment", + }), + + columnHelper.accessor("schema_name", { + header: "Schema", + }), + ]; +}; + +function Component() { + const { dbName } = Route.useParams(); + const [sorting, setSorting] = useState([]); + + const { data } = useTablesListQuery({ + dbName, + sortDesc: sorting[0]?.desc, + sortField: sorting[0]?.id, + }); + + const details = useMemo(() => { + if (!data) { + return []; + } + return data; + }, [data]); + + const columns = useMemo(() => createColumns(dbName), [dbName]); + + const table = useReactTable({ + data: details, + columns, + getCoreRowModel: getCoreRowModel(), + manualSorting: true, + state: { + sorting, + }, + onSortingChange: setSorting, + }); + + return ( +
+
+
+

+ {dbName} +

+

+ Tables: {details?.length ?? 0} +

+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const sorted = header.column.getIsSorted(); + if (header.column.getCanSort()) { + return ( + + + + ); + } + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx new file mode 100644 index 0000000..076c0d3 --- /dev/null +++ b/frontend/src/routes/index.tsx @@ -0,0 +1,26 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Index, +}) + +function Index() { + const { dbSchema } = Route.useSearch() + if (!dbSchema) { + return ( +
+

Welcome Home!

+
+ ) + } + return +} + +function TableView({ dbSchema }: { dbSchema: string }) { + return ( +
+

Table View

+ {dbSchema} +
+ ) +} diff --git a/frontend/src/services/db/db.hooks.ts b/frontend/src/services/db/db.hooks.ts new file mode 100644 index 0000000..88cdc03 --- /dev/null +++ b/frontend/src/services/db/db.hooks.ts @@ -0,0 +1,64 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { DB_QUERY_KEYS } from "./db.query-keys"; +import { dbService } from "./db.service"; +import type { GetTableDataArgs, GetTablesListArgs } from "./db.types"; + +export const useDatabasesListQuery = () => { + return useQuery({ + queryKey: [DB_QUERY_KEYS.DATABASES.ALL], + queryFn: () => dbService.getDatabasesList(), + placeholderData: keepPreviousData, + }); +}; + +export const useTablesListQuery = (args: GetTablesListArgs) => { + return useQuery({ + queryKey: [DB_QUERY_KEYS.TABLES.ALL, args], + queryFn: () => dbService.getTablesList(args), + enabled: !!args.dbName, + placeholderData: keepPreviousData, + }); +}; + +export const useTableDataQuery = (args: GetTableDataArgs) => { + return useQuery({ + queryKey: [DB_QUERY_KEYS.TABLES.DATA, args], + queryFn: () => dbService.getTableData(args), + placeholderData: (previousData, previousQuery) => { + if ( + typeof previousQuery?.queryKey[1] !== "string" && + (previousQuery?.queryKey[1].dbName !== args.dbName || + previousQuery?.queryKey[1].tableName !== args.tableName) + ) { + return undefined; + } + return previousData; + }, + enabled: !!args.tableName && !!args.dbName, + }); +}; + +export const useTableColumnsQuery = (args: { name?: string }) => { + return useQuery({ + queryKey: [DB_QUERY_KEYS.TABLES.COLUMNS, args], + queryFn: () => dbService.getTableColumns(args.name ?? ""), + placeholderData: keepPreviousData, + enabled: !!args.name, + }); +}; + +export const useTableIndexesQuery = (args: { name?: string }) => { + return useQuery({ + queryKey: [DB_QUERY_KEYS.TABLES.INDEXES, args], + queryFn: () => dbService.getTableIndexes(args.name ?? ""), + enabled: !!args.name, + }); +}; + +export const useTableForeignKeysQuery = (args: { name?: string }) => { + return useQuery({ + queryKey: [DB_QUERY_KEYS.TABLES.FOREIGN_KEYS, args], + queryFn: () => dbService.getTableForeignKeys(args.name ?? ""), + enabled: !!args.name, + }); +}; diff --git a/frontend/src/services/db/db.instance.ts b/frontend/src/services/db/db.instance.ts new file mode 100644 index 0000000..14b827c --- /dev/null +++ b/frontend/src/services/db/db.instance.ts @@ -0,0 +1,5 @@ +import ky from 'ky' + +export const dbInstance = ky.create({ + prefixUrl: 'http://localhost:3000' +}) diff --git a/frontend/src/services/db/db.query-keys.ts b/frontend/src/services/db/db.query-keys.ts new file mode 100644 index 0000000..536ae7b --- /dev/null +++ b/frontend/src/services/db/db.query-keys.ts @@ -0,0 +1,12 @@ +export const DB_QUERY_KEYS = { + DATABASES: { + ALL: "databases.all", + }, + TABLES: { + ALL: "tables.all", + DATA: "tables.data", + COLUMNS: "tables.columns", + INDEXES: "tables.indexes", + FOREIGN_KEYS: "tables.foreign_keys", + }, +} as const; diff --git a/frontend/src/services/db/db.service.ts b/frontend/src/services/db/db.service.ts new file mode 100644 index 0000000..439e31d --- /dev/null +++ b/frontend/src/services/db/db.service.ts @@ -0,0 +1,50 @@ +import { getValuable } from "@/lib/utils"; +import { dbInstance } from "@/services/db/db.instance"; +import type { + DatabasesResponse, + GetTableDataArgs, + GetTableDataResponse, + GetTablesListArgs, + GetTablesListResponse, + TableColumns, + TableForeignKeys, + TableIndexes, +} from "@/services/db/db.types"; + +class DbService { + getDatabasesList() { + return dbInstance.get("api/databases").json(); + } + + getTablesList({ dbName, sortDesc, sortField }: GetTablesListArgs) { + return dbInstance + .get(`api/databases/${dbName}/tables`, { + searchParams: getValuable({ sortField, sortDesc }), + }) + .json(); + } + + getTableData({ dbName, tableName, page, perPage }: GetTableDataArgs) { + return dbInstance + .get(`api/databases/${dbName}/tables/${tableName}/data`, { + searchParams: getValuable({ perPage, page }), + }) + .json(); + } + + getTableColumns(name: string) { + return dbInstance.get(`api/db/tables/${name}/columns`).json(); + } + + getTableIndexes(name: string) { + return dbInstance.get(`api/db/tables/${name}/indexes`).json(); + } + + getTableForeignKeys(name: string) { + return dbInstance + .get(`api/db/tables/${name}/foreign-keys`) + .json(); + } +} + +export const dbService = new DbService(); diff --git a/frontend/src/services/db/db.types.ts b/frontend/src/services/db/db.types.ts new file mode 100644 index 0000000..e714f4e --- /dev/null +++ b/frontend/src/services/db/db.types.ts @@ -0,0 +1,64 @@ +export type DatabasesResponse = Array; + +// Tables List +export type GetTablesListArgs = { + dbName: string; + sortField?: string; + sortDesc?: boolean; +}; +export type TableInfo = { + comments: string; + index_size: number; + indexes: string; + owner: string; + primary_key: string; + row_count: number; + schema_name: string; + table_name: string; + table_size: number; + total_size: number; +}; +export type GetTablesListResponse = TableInfo[]; + +// Table Data +export type GetTableDataArgs = { + tableName: string; + dbName: string; + perPage?: number; + page?: number; +}; + +export type GetTableDataResponse = { + count: number; + data: Array>; +}; + +export type TableColumn = { + column_name: string; + data_type: string; + udt_name: string; + column_comment?: any; +}; +export type TableColumns = TableColumn[]; + +export type TableIndexEntry = { + key: string; + type: string; + columns: string[]; + descs: any[]; + lengths: any[]; +}; +export type TableIndexes = TableIndexEntry[]; + +export type TableForeignKey = { + conname: string; + deferrable: boolean; + definition: string; + source: string[]; + ns: string; + table: string; + target: string[]; + on_delete: string; + on_update: string; +}; +export type TableForeignKeys = TableForeignKey[]; diff --git a/frontend/src/services/db/index.ts b/frontend/src/services/db/index.ts new file mode 100644 index 0000000..68a92d5 --- /dev/null +++ b/frontend/src/services/db/index.ts @@ -0,0 +1,2 @@ +export * from './db.hooks' +export * from './db.types' diff --git a/frontend/src/tanstack-table-typedef.ts b/frontend/src/tanstack-table-typedef.ts new file mode 100644 index 0000000..e0f0500 --- /dev/null +++ b/frontend/src/tanstack-table-typedef.ts @@ -0,0 +1,8 @@ +import { RowData } from '@tanstack/react-table' + +declare module '@tanstack/table-core' { + // @ts-expect-error - this is a global module augmentation + interface ColumnMeta { + className?: string + } +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..ed77210 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..7cb7e37 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,77 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} \ No newline at end of file diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..16db678 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..ea9d0cd --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..3afdd6e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..0d4c7f1 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' +import { TanStackRouterVite } from '@tanstack/router-plugin/vite' +import * as path from 'node:path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [TanStackRouterVite(), react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +})