From 34d06b888c9c82dfd37d7438f5ae14358f1fd5d7 Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Wed, 6 May 2026 02:09:15 +0000 Subject: [PATCH 1/6] Enhance database relations by adding support for inline enums and fetching related entities in details view --- api/routes.ts | 5 ++ api/schema.ts | 13 +++++- api/sql.ts | 91 ++++++++++++++++++++++++++++++++---- deno.json | 6 +-- web/pages/DeploymentPage.tsx | 60 ++++++++++++++++++------ 5 files changed, 149 insertions(+), 26 deletions(-) diff --git a/api/routes.ts b/api/routes.ts index 79b370d..72390d9 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -459,6 +459,11 @@ const defs = { name: STR('Column name'), type: STR('Column data type'), ordinal: NUM('Column ordinal position'), + relation: optional(OBJ({ + table: STR('Target table'), + column: STR('Target column'), + type: LIST(['enum', 'table'], 'Relation type'), + })), })), schema: optional(STR('Schema name')), table: STR('Table name'), diff --git a/api/schema.ts b/api/schema.ts index cf533ab..28f7f0f 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -2,6 +2,7 @@ import { ARR, type Asserted, BOOL, + LIST, NUM, OBJ, optional, @@ -70,7 +71,17 @@ export const DatabaseSchemaDef = OBJ({ dialect: STR('Detected SQL dialect'), refreshedAt: STR('ISO datetime of last refresh'), tables: ARR(OBJ({ - columns: ARR(OBJ({ name: STR(), type: STR(), ordinal: NUM() })), + columns: ARR(OBJ({ + name: STR(), + type: STR(), + ordinal: NUM(), + relation: optional(OBJ({ + table: STR('Target table'), + column: STR('Target column'), + labelColumn: optional(STR('Column to use for inlining')), + type: LIST(['enum', 'table'], 'Relation type'), + })), + })), columnsMap: optional(OBJ({})), schema: optional(STR()), table: STR(), diff --git a/api/sql.ts b/api/sql.ts index 4f7e31f..67fead1 100644 --- a/api/sql.ts +++ b/api/sql.ts @@ -75,8 +75,19 @@ async function detectDialect(endpoint: string, token: string): Promise { // Introspection queries per dialect returning columns list // Standardized output fields: table_schema (nullable), table_name, column_name, data_type, ordinal_position const INTROSPECTION: Record = { - sqlite: - `SELECT NULL AS table_schema, m.name AS table_name, p.name AS column_name, p.type AS data_type, p.cid + 1 AS ordinal_position FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid`, + sqlite: `SELECT + NULL AS table_schema, + m.name AS table_name, + p.name AS column_name, + p.type AS data_type, + p.cid + 1 AS ordinal_position, + f."table" AS ref_table, + f."to" AS ref_column + FROM sqlite_master m + JOIN pragma_table_info(m.name) p + LEFT JOIN pragma_foreign_key_list(m.name) f ON f."from" = p.name + WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' + ORDER BY m.name, p.cid`, unknown: `SELECT table_schema, table_name, column_name, data_type, ordinal_position FROM information_schema.columns ORDER BY table_schema, table_name, ordinal_position`, } @@ -84,11 +95,29 @@ const INTROSPECTION: Record = { async function fetchSchema(endpoint: string, token: string, dialect: string) { const sql = INTROSPECTION[dialect] ?? INTROSPECTION.unknown try { - return await runSQL(endpoint, token, sql) + return await runSQL(endpoint, token, sql) as { + table_schema: string | null + table_name: string + column_name: string + data_type: string + ordinal_position: number + ref_table?: string + ref_column?: string + }[] } catch { /* ignore */ } } -export type ColumnInfo = { name: string; type: string; ordinal: number } +export type ColumnInfo = { + name: string + type: string + ordinal: number + relation?: { + table: string + column: string + labelColumn?: string + type: 'enum' | 'table' + } +} type TableInfo = { schema: string | undefined table: string @@ -103,7 +132,6 @@ export async function refreshOneSchema( try { const dialect = await detectDialect(dep.sqlEndpoint, dep.sqlToken) const rows = await fetchSchema(dep.sqlEndpoint, dep.sqlToken, dialect) - // group rows const tableMap = new Map() for (const r of rows) { const schema = (r.table_schema as string) || undefined @@ -113,11 +141,41 @@ export async function refreshOneSchema( if (!tableMap.has(key)) { tableMap.set(key, { schema, table, columns: [], columnsMap: new Map() }) } - tableMap.get(key)!.columns.push({ + + const column: ColumnInfo = { name: String(r.column_name), type: String(r.data_type || ''), ordinal: Number(r.ordinal_position || 0), - }) + } + + if (r.ref_table && r.ref_column) { + column.relation = { + table: r.ref_table, + column: r.ref_column, + type: 'table', // Default + } + } + + tableMap.get(key)!.columns.push(column) + } + + // Classify relations + for (const t of tableMap.values()) { + for (const col of t.columns) { + if (col.relation) { + const ref = tableMap.get(col.relation.table) + if (ref?.columns.length === 2) { + col.relation.type = 'enum' + // Find the column that is NOT the ID column + const labelCol = ref.columns.find((c) => + c.name !== col.relation?.column + ) + if (labelCol) { + col.relation.labelColumn = labelCol.name + } + } + } + } } const tables = [...tableMap.values()].map((t) => ({ ...t, @@ -245,8 +303,23 @@ export const fetchTablesData = async ( } } - const query = - `SELECT * FROM ${params.table} ${whereClause} ${orderByClause} ${limitOffsetClause}` + const selectColumns = ['t.*'] + const joins: string[] = [] + + for (const col of columnsMap.values()) { + if (col.relation?.type === 'enum' && col.relation.labelColumn) { + const { table, column, labelColumn } = col.relation + const alias = `inline_${col.name}` + selectColumns.push(`${table}.${labelColumn} AS ${alias}`) + joins.push( + `LEFT JOIN ${table} ON t.${col.name} = ${table}.${column}`, + ) + } + } + + const query = `SELECT ${selectColumns.join(', ')} FROM ${params.table} t ${ + joins.join(' ') + } ${whereClause} ${orderByClause} ${limitOffsetClause}` const countQuery = `SELECT COUNT(*) as count FROM ${params.table} ${whereClause}` const rows = await runSQL(sqlEndpoint, sqlToken, query) diff --git a/deno.json b/deno.json index 4c34eb5..16054c6 100644 --- a/deno.json +++ b/deno.json @@ -32,10 +32,10 @@ "imports": { "./": "./", "/": "./", - "@01edu/api": "jsr:@01edu/api@^0.2.6", + "@01edu/api": "jsr:@01edu/api@^0.2.7", "@01edu/api-client": "jsr:@01edu/api-client@^0.2.6", "@01edu/api-proxy": "jsr:@01edu/api-proxy@^0.2.1", - "@01edu/signal-router": "npm:@01edu/signal-router@^0.2.2", + "@01edu/signal-router": "npm:@01edu/signal-router@^0.2.3", "@01edu/time": "jsr:@01edu/time@^0.1.0", "@deno/vite-plugin": "npm:@deno/vite-plugin@^2.0.2", "@std/assert": "jsr:@std/assert@^1.0.19", @@ -50,7 +50,7 @@ "preact": "npm:preact@^10.29.1", "@preact/preset-vite": "npm:@preact/preset-vite@^2.10.5", "@preact/signals": "npm:@preact/signals@^2.9.0", - "@clickhouse/client": "npm:@clickhouse/client@^1.18.3", + "@clickhouse/client": "npm:@clickhouse/client@^1.18.4", "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.2.4", "tailwindcss": "npm:tailwindcss@^4.2.4", "daisyui": "npm:daisyui@^5.5.19", diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index 8321819..aeaf35a 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -403,8 +403,9 @@ const DataRow = ( { row, columns, index }: { row: AnyRecord; columns: string[]; index: number }, ) => { const tableName = url.params.table || schema.data?.tables?.[0]?.table - const tableDef = schema.data?.tables?.find((t) => t.table === tableName) - const pk = tableDef?.columns?.[0]?.name + const columnsDef = schema.data?.tables?.find((t) => t.table === tableName) + ?.columns + const pk = columnsDef?.[0]?.name const rowId = pk ? String(row[pk]) : undefined return ( @@ -414,11 +415,38 @@ const DataRow = ( > - {columns.map((key) => ( - - - - ))} + {columns.map((key) => { + const colDef = columnsDef?.find((c) => c.name === key) + const value = colDef?.relation?.type === 'enum' + ? (row[`inline_${key}`] ?? row[key]) + : row[key] + + const isTableRel = colDef?.relation?.type === 'table' + + return ( + + {isTableRel + ? ( + e.stopPropagation()} + > + + + + ) + : } + + ) + })} ) @@ -1882,9 +1910,11 @@ function MetricsViewer() { effect(() => { const rowId = url.params['row-id'] const dep = url.params.dep + const relTable = url.params.rt if (dep && rowId) { - const tableName = url.params.table || schema.data?.tables?.[0]?.table + const tableName = relTable || url.params.table || + schema.data?.tables?.[0]?.table const tableDef = schema.data?.tables?.find((t) => t.table === tableName) const pk = tableDef?.columns?.[0]?.name @@ -1937,7 +1967,9 @@ const RowDetails = () => { e.preventDefault() const row = rowDetailsData.data?.rows?.[0] as AnyRecord | undefined if (!row) return - const tableName = url.params.table || schema.data?.tables?.[0]?.table + const tableName = url.params.rt || url.params.table || + schema.data?.tables?.[0]?.table + console.log('tableName', tableName) const tableDef = schema.data?.tables?.find((t) => t.table === tableName) const pk = tableDef?.columns?.[0]?.name if (!tableName || !pk) { @@ -1983,7 +2015,7 @@ const RowDetails = () => { }) toast('Row updated successfully') tableData.fetch() - navigate({ params: { drawer: null, 'row-id': null } }) + navigate({ params: { drawer: null, 'row-id': null, rt: null } }) } catch (err) { toast(err instanceof Error ? err.message : String(err), 'error') } @@ -1992,9 +2024,11 @@ const RowDetails = () => { return (
-

Row Details

+

+ {url.params.rt ? `Related: ${url.params.rt}` : 'Row Details'} +

@@ -2005,7 +2039,7 @@ const RowDetails = () => {
{Object.entries(row).map(([key, value]) => { - const tableName = url.params.table || + const tableName = url.params.rt || url.params.table || schema.data?.tables?.[0]?.table const tableDef = schema.data?.tables?.find((t) => t.table === tableName From 34b12b4b8aa33624fa41d55e5d04bf8429c1eb5f Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Wed, 6 May 2026 09:52:28 +0000 Subject: [PATCH 2/6] Refactor SQL schema fetching and update dependencies for improved stability --- api/sql.ts | 36 ++++++++++++++----------------- deno.lock | 42 +++++++++++++----------------------- web/pages/DeploymentPage.tsx | 2 +- 3 files changed, 32 insertions(+), 48 deletions(-) diff --git a/api/sql.ts b/api/sql.ts index 67fead1..bf3b01d 100644 --- a/api/sql.ts +++ b/api/sql.ts @@ -1,4 +1,5 @@ import { + DatabaseSchema, DatabaseSchemasCollection, Deployment, DeploymentsCollection, @@ -92,32 +93,24 @@ const INTROSPECTION: Record = { `SELECT table_schema, table_name, column_name, data_type, ordinal_position FROM information_schema.columns ORDER BY table_schema, table_name, ordinal_position`, } +type STANDARD_COLUMNS = { + table_schema: string | null + table_name: string + column_name: string + data_type: string + ordinal_position: number + ref_table?: string + ref_column?: string +}[] + async function fetchSchema(endpoint: string, token: string, dialect: string) { const sql = INTROSPECTION[dialect] ?? INTROSPECTION.unknown try { - return await runSQL(endpoint, token, sql) as { - table_schema: string | null - table_name: string - column_name: string - data_type: string - ordinal_position: number - ref_table?: string - ref_column?: string - }[] + return await runSQL(endpoint, token, sql) as STANDARD_COLUMNS } catch { /* ignore */ } } -export type ColumnInfo = { - name: string - type: string - ordinal: number - relation?: { - table: string - column: string - labelColumn?: string - type: 'enum' | 'table' - } -} +export type ColumnInfo = DatabaseSchema['tables'][number]['columns'][number] type TableInfo = { schema: string | undefined table: string @@ -132,6 +125,7 @@ export async function refreshOneSchema( try { const dialect = await detectDialect(dep.sqlEndpoint, dep.sqlToken) const rows = await fetchSchema(dep.sqlEndpoint, dep.sqlToken, dialect) + if (!rows || !rows.length) return const tableMap = new Map() for (const r of rows) { const schema = (r.table_schema as string) || undefined @@ -146,12 +140,14 @@ export async function refreshOneSchema( name: String(r.column_name), type: String(r.data_type || ''), ordinal: Number(r.ordinal_position || 0), + relation: undefined, } if (r.ref_table && r.ref_column) { column.relation = { table: r.ref_table, column: r.ref_column, + labelColumn: undefined, type: 'table', // Default } } diff --git a/deno.lock b/deno.lock index f3167a4..ab5d7da 100644 --- a/deno.lock +++ b/deno.lock @@ -3,7 +3,7 @@ "specifiers": { "jsr:@01edu/api-client@~0.2.6": "0.2.6", "jsr:@01edu/api-proxy@~0.2.1": "0.2.1", - "jsr:@01edu/api@~0.2.6": "0.2.6", + "jsr:@01edu/api@~0.2.7": "0.2.7", "jsr:@01edu/time@0.1": "0.1.0", "jsr:@01edu/types@~0.2.6": "0.2.6", "jsr:@cd/sqlite@~0.13.1": "0.13.1", @@ -11,10 +11,8 @@ "jsr:@denosaurs/emoji@~0.3.1": "0.3.1", "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@std/assert@^1.0.19": "1.0.19", - "jsr:@std/async@^1.3.0": "1.3.0", "jsr:@std/cli@^1.0.29": "1.0.29", "jsr:@std/crypto@^1.1.0": "1.1.0", - "jsr:@std/data-structures@^1.0.11": "1.0.11", "jsr:@std/encoding@1": "1.0.10", "jsr:@std/encoding@^1.0.10": "1.0.10", "jsr:@std/fmt@1": "1.0.10", @@ -34,8 +32,8 @@ "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/streams@^1.1.0": "1.1.0", "jsr:@std/testing@^1.0.18": "1.0.18", - "npm:@01edu/signal-router@~0.2.2": "0.2.2_@preact+signals@2.9.0__preact@10.29.1_preact@10.29.1", - "npm:@clickhouse/client@^1.18.3": "1.18.3", + "npm:@01edu/signal-router@~0.2.3": "0.2.3_@preact+signals@2.9.0__preact@10.29.1_preact@10.29.1", + "npm:@clickhouse/client@^1.18.4": "1.18.4", "npm:@deno/vite-plugin@^2.0.2": "2.0.2_vite@8.0.10", "npm:@preact/preset-vite@^2.10.5": "2.10.5_@babel+core@7.29.0_vite@8.0.10_preact@10.29.1", "npm:@preact/signals@^2.9.0": "2.9.0_preact@10.29.1", @@ -57,8 +55,8 @@ "npm:vite@^8.0.3": "8.0.10" }, "jsr": { - "@01edu/api@0.2.6": { - "integrity": "f23ec5c8fa5c78e84d67ea571d77143ff4b4e588e93212dda6953c47613279f9", + "@01edu/api@0.2.7": { + "integrity": "17198ab087829f38dafc17e08cd7fd72ce04118a0058019d6af055f5ab3ea244", "dependencies": [ "jsr:@01edu/time", "jsr:@01edu/types", @@ -128,18 +126,12 @@ "jsr:@std/internal@^1.0.12" ] }, - "@std/async@1.3.0": { - "integrity": "80485538a4f7baaa46bfe2246168069e02ed142b9f9079cd164f43bb060ad9e9" - }, "@std/cli@1.0.29": { "integrity": "fa4ef29130baa834d8a13b7d138240c3a2fcfba740bfb7afa646a360a15ec84f" }, "@std/crypto@1.1.0": { "integrity": "b8d6d0a6377a32b213af2661ed7bf1062d94feac0c57def5526a8e74a95c3ec8" }, - "@std/data-structures@1.0.11": { - "integrity": "53b98ed7efa61f107dfc14244bd2ec5557f7f7ee0bbaef6d449d7937facacb89" - }, "@std/encoding@1.0.10": { "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" }, @@ -195,17 +187,13 @@ "integrity": "d3152f57b11666bf6358d0e127c7e3488e91178b0c2d8fbf0793e1c53cd13cb1", "dependencies": [ "jsr:@std/assert", - "jsr:@std/async", - "jsr:@std/data-structures", - "jsr:@std/fs@^1.0.23", - "jsr:@std/internal@^1.0.13", - "jsr:@std/path@^1.1.4" + "jsr:@std/internal@^1.0.13" ] } }, "npm": { - "@01edu/signal-router@0.2.2_@preact+signals@2.9.0__preact@10.29.1_preact@10.29.1": { - "integrity": "sha512-synRhAav0HJnf4hJTsWyK0Jrx2j+Nm9MRHLgeEIUgqEFrY+E0+oLsWxYvjeMnRRlWFOL1Cs8f/aPRWiO+27M7w==", + "@01edu/signal-router@0.2.3_@preact+signals@2.9.0__preact@10.29.1_preact@10.29.1": { + "integrity": "sha512-Eg2ORuigaA8i3vM+Lr4k0P7+A53vZ3mKb2wsVnbBslkItaebEFwPUDxXiz2zDCJYHd06wLdvd64r/VRQQw6XFw==", "dependencies": [ "@preact/signals", "preact" @@ -365,11 +353,11 @@ "@babel/helper-validator-identifier" ] }, - "@clickhouse/client-common@1.18.3": { - "integrity": "sha512-3axzO3zvrsGT5PzDenxgWscltYCNRDbhaHWUgdsmcM9OnW/VnZn9EarOcZogr9P82Z0mQh+Jd2x+p2K4TFD2fA==" + "@clickhouse/client-common@1.18.4": { + "integrity": "sha512-kPPtv8yQmplNAxfrAJvwBJq5dd+IWRewEbXSpUvtyEJXlrB8lt/ZH63jUS81Nmd+lK5MRvpOFXPoN3iogkvg+A==" }, - "@clickhouse/client@1.18.3": { - "integrity": "sha512-340ngdYktL8PLUBK2QKSwe0o02tYfZSz1mSn1uXCEU8TxHvwh9pnQxElf9YHumDGj5gX/IdgxPsJTGMs82Hgug==", + "@clickhouse/client@1.18.4": { + "integrity": "sha512-jjCrddI+e2OVXGh/MQY92K9r8Z/iwqaZtUXNI/MfZ/y9VGYwfbQsXRzp4Jv6w4Hgxvr4sLcz9YwIvkCBQ6X/mw==", "dependencies": [ "@clickhouse/client-common" ] @@ -1181,7 +1169,7 @@ "dependencies": [ "jsr:@01edu/api-client@~0.2.6", "jsr:@01edu/api-proxy@~0.2.1", - "jsr:@01edu/api@~0.2.6", + "jsr:@01edu/api@~0.2.7", "jsr:@01edu/time@0.1", "jsr:@deno/gfm@0.12.0", "jsr:@std/assert@^1.0.19", @@ -1192,8 +1180,8 @@ "jsr:@std/http@^1.1.0", "jsr:@std/path@^1.1.4", "jsr:@std/testing@^1.0.18", - "npm:@01edu/signal-router@~0.2.2", - "npm:@clickhouse/client@^1.18.3", + "npm:@01edu/signal-router@~0.2.3", + "npm:@clickhouse/client@^1.18.4", "npm:@deno/vite-plugin@^2.0.2", "npm:@preact/preset-vite@^2.10.5", "npm:@preact/signals@^2.9.0", diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index aeaf35a..a9398c3 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -433,7 +433,7 @@ const DataRow = ( Date: Thu, 7 May 2026 16:48:19 +0000 Subject: [PATCH 3/6] feat: add primary key metadata support and visual styling to database schema viewer --- api/routes.ts | 1 + api/schema.ts | 1 + api/sql.ts | 9 +- web/pages/DeploymentPage.tsx | 297 ++++++++++++++++++++++------------- 4 files changed, 198 insertions(+), 110 deletions(-) diff --git a/api/routes.ts b/api/routes.ts index 72390d9..b49bb5d 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -459,6 +459,7 @@ const defs = { name: STR('Column name'), type: STR('Column data type'), ordinal: NUM('Column ordinal position'), + isPrimaryKey: optional(BOOL('Is primary key')), relation: optional(OBJ({ table: STR('Target table'), column: STR('Target column'), diff --git a/api/schema.ts b/api/schema.ts index 28f7f0f..be34902 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -81,6 +81,7 @@ export const DatabaseSchemaDef = OBJ({ labelColumn: optional(STR('Column to use for inlining')), type: LIST(['enum', 'table'], 'Relation type'), })), + isPrimaryKey: optional(BOOL()), })), columnsMap: optional(OBJ({})), schema: optional(STR()), diff --git a/api/sql.ts b/api/sql.ts index bf3b01d..9e22c5a 100644 --- a/api/sql.ts +++ b/api/sql.ts @@ -82,6 +82,7 @@ const INTROSPECTION: Record = { p.name AS column_name, p.type AS data_type, p.cid + 1 AS ordinal_position, + p.pk AS is_primary_key, f."table" AS ref_table, f."to" AS ref_column FROM sqlite_master m @@ -99,6 +100,7 @@ type STANDARD_COLUMNS = { column_name: string data_type: string ordinal_position: number + is_primary_key?: number ref_table?: string ref_column?: string }[] @@ -141,6 +143,7 @@ export async function refreshOneSchema( type: String(r.data_type || ''), ordinal: Number(r.ordinal_position || 0), relation: undefined, + isPrimaryKey: r.is_primary_key ? true : false, } if (r.ref_table && r.ref_column) { @@ -175,7 +178,11 @@ export async function refreshOneSchema( } const tables = [...tableMap.values()].map((t) => ({ ...t, - columns: t.columns.sort((a, b) => a.ordinal - b.ordinal), + columns: t.columns.sort((a, b) => { + if (a.isPrimaryKey && !b.isPrimaryKey) return -1 + if (!a.isPrimaryKey && b.isPrimaryKey) return 1 + return a.ordinal - b.ordinal + }), columnsMap: t.columns.reduce((obj, col) => { obj[col.name] = col return obj diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index a9398c3..2adf1a9 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -74,6 +74,70 @@ const Toast = () => { ) } +type ColumnDef = NonNullable< + ApiOutput['GET/api/deployment/schema']['tables'] +>[number]['columns'][number] + +const COLUMN_STYLES = { + pk: { + color: 'text-warning', + bg: 'bg-warning/5', + icon: Hash, + font: 'font-bold', + }, + enum: { + color: 'text-success', + bg: 'bg-success/5', + icon: Hash, + font: 'font-medium', + }, + table: { + color: 'text-info', + bg: 'bg-info/5', + icon: Link2, + font: 'font-medium', + }, + default: { color: 'text-base-content/50', bg: '', icon: Columns, font: '' }, +} as const + +const getColumnConfig = (col?: ColumnDef) => { + const key = col?.isPrimaryKey ? 'pk' : col?.relation?.type || 'default' + return COLUMN_STYLES[key as keyof typeof COLUMN_STYLES] || + COLUMN_STYLES.default +} + +const ColumnLabel = ( + { col, row, type, name }: { + col?: ColumnDef + row?: AnyRecord + type?: string + name: string + }, +) => { + const config = getColumnConfig(col) + return ( + + ) +} + // Effect to fetch schema when deployment URL changes effect(() => { const dep = url.params.dep @@ -354,37 +418,38 @@ const RowNumberCell = ({ index }: { index: number }) => ( ) -const TableCell = ({ value }: { value: unknown }) => { +const TableCell = ({ value, col }: { value: unknown; col?: ColumnDef }) => { + const config = getColumnConfig(col) const isObj = typeof value === 'object' && value !== null const stringValue = isObj ? JSON.stringify(value) : String(value ?? '') const isTooLong = stringValue.length > 100 - if (value === null || value === undefined || value === '') { - return ( - - null - - ) - } - - if (isObj) { - return ( - - {stringValue} - - ) - } - return ( - - {stringValue} - +
+ {value === null || value === undefined || value === '' + ? ( + + null + + ) + : isObj + ? ( + + {stringValue} + + ) + : ( + + {stringValue} + + )} +
) } @@ -400,11 +465,13 @@ const EmptyRow = ({ colSpan }: { colSpan: number }) => ( ) const DataRow = ( - { row, columns, index }: { row: AnyRecord; columns: string[]; index: number }, + { row, columns, index, columnsDef }: { + row: AnyRecord + columns: string[] + index: number + columnsDef?: ColumnDef[] + }, ) => { - const tableName = url.params.table || schema.data?.tables?.[0]?.table - const columnsDef = schema.data?.tables?.find((t) => t.table === tableName) - ?.columns const pk = columnsDef?.[0]?.name const rowId = pk ? String(row[pk]) : undefined @@ -417,16 +484,16 @@ const DataRow = ( {columns.map((key) => { const colDef = columnsDef?.find((c) => c.name === key) - const value = colDef?.relation?.type === 'enum' - ? (row[`inline_${key}`] ?? row[key]) - : row[key] - + const config = getColumnConfig(colDef) + const isEnum = colDef?.relation?.type === 'enum' const isTableRel = colDef?.relation?.type === 'table' + const value = isEnum ? (row[`inline_${key}`] ?? row[key]) : row[key] + return ( {isTableRel ? ( @@ -436,14 +503,14 @@ const DataRow = ( rt: colDef.relation?.table, 'row-id': String(row[key]), }} - class='flex items-center gap-1 group/rel link link-primary no-underline px-1 py-0.5' + class={`flex items-center gap-1 group/rel link no-underline ${config.color}`} onClick={(e) => e.stopPropagation()} > - +
) - : } + : } ) })} @@ -563,13 +630,14 @@ const TableFooter = ({ rows }: { rows: AnyRecord[] }) => { const TableContent = ({ rows }: { rows: AnyRecord[] }) => { let columns = Object.keys(rows[0] || {}) + const tableName = url.params.table || schema.data?.tables?.[0]?.table + const tableDef = schema.data?.tables?.find((t) => t.table === tableName) + const columnsDef = tableDef?.columns + // If in tables view, use schema columns first if (url.params.tab === 'tables') { - const tableName = url.params.table || schema.data?.tables?.[0]?.table - const tableDef = schema.data?.tables?.find((t) => t.table === tableName) - - if (tableDef?.columns) { - const schemaColumns = tableDef.columns.map((c) => c.name) + if (columnsDef) { + const schemaColumns = columnsDef.map((c) => c.name) // Add any extra columns found in rows that aren't in schema (e.g. virtual columns) const extraColumns = columns.filter((c) => !schemaColumns.includes(c)) columns = [...schemaColumns, ...extraColumns] @@ -588,6 +656,7 @@ const TableContent = ({ rows }: { rows: AnyRecord[] }) => { row={row} columns={columns} index={index} + columnsDef={columnsDef} /> )) )} @@ -771,23 +840,28 @@ function SchemaPanel() { Columns
- {table.columns.map((col, idx) => ( -
- - { + const config = getColumnConfig(col) + return ( +
- {col.name} - - - {col.type} - -
- ))} + + + {col.name} + + + {col.type} + +
+ ) + })}
)} @@ -2038,57 +2112,62 @@ const RowDetails = () => {
- {Object.entries(row).map(([key, value]) => { - const tableName = url.params.rt || url.params.table || - schema.data?.tables?.[0]?.table - const tableDef = schema.data?.tables?.find((t) => - t.table === tableName - ) - const colDef = tableDef?.columns?.find((c) => c.name === key) - const type = colDef?.type || 'String' - - const isObject = (type.includes('Map') || type.includes('Array') || - type.includes('Tuple') || type.includes('Nested') || - type.includes('JSON') || type.toLowerCase().includes('blob')) && - (typeof value === 'object' || typeof value === 'string') - const isNumber = type.includes('Int') || type.includes('Float') || - type.includes('Decimal') - const isBoolean = type.includes('Bool') - const isDate = type.includes('Date') || type.includes('Time') || - (key.endsWith('At') && - (typeof value === 'number' || !isNaN(Number(value)))) - - return ( -
- + {Object.entries(row).filter(([key]) => !key.startsWith('inline_')) + .map(([key, value]) => { + const tableName = url.params.rt || url.params.table || + schema.data?.tables?.[0]?.table + const tableDef = schema.data?.tables?.find((t) => + t.table === tableName + ) + const colDef = tableDef?.columns?.find((c) => c.name === key) + const type = colDef?.type || 'String' + const isObject = + (type.includes('Map') || type.includes('Array') || + type.includes('Tuple') || type.includes('Nested') || + type.includes('JSON') || + type.toLowerCase().includes('blob')) && + (typeof value === 'object' || typeof value === 'string') + const isNumber = type.includes('Int') || type.includes('Float') || + type.includes('Decimal') + const isBoolean = type.includes('Bool') + const isDate = type.includes('Date') || type.includes('Time') || + (key.endsWith('At') && + (typeof value === 'number' || !isNaN(Number(value)))) - {isObject - ? - : isBoolean - ? - : isDate - ? ( - - ) - : isNumber - ? - : } -
- ) - })} + return ( +
+ + + {isObject + ? + : isBoolean + ? ( + + ) + : isDate + ? ( + + ) + : isNumber + ? + : ( + + )} +
+ ) + })}
From 7ae68c2c5f4f090c218df3c61c93631f3078693e Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Thu, 7 May 2026 18:10:54 +0000 Subject: [PATCH 4/6] refactor: implement automated date formatting and modularize table cell rendering in DeploymentPage --- web/pages/DeploymentPage.tsx | 300 ++++++++++++++++++----------------- 1 file changed, 156 insertions(+), 144 deletions(-) diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index 2adf1a9..ef8dda3 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -79,25 +79,10 @@ type ColumnDef = NonNullable< >[number]['columns'][number] const COLUMN_STYLES = { - pk: { - color: 'text-warning', - bg: 'bg-warning/5', - icon: Hash, - font: 'font-bold', - }, - enum: { - color: 'text-success', - bg: 'bg-success/5', - icon: Hash, - font: 'font-medium', - }, - table: { - color: 'text-info', - bg: 'bg-info/5', - icon: Link2, - font: 'font-medium', - }, - default: { color: 'text-base-content/50', bg: '', icon: Columns, font: '' }, + pk: { color: 'text-warning', icon: Hash }, + enum: { color: 'text-success', icon: Hash }, + table: { color: 'text-info', icon: Link2 }, + default: { color: 'text-base-content/80', icon: Columns }, } as const const getColumnConfig = (col?: ColumnDef) => { @@ -106,14 +91,45 @@ const getColumnConfig = (col?: ColumnDef) => { COLUMN_STYLES.default } -const ColumnLabel = ( - { col, row, type, name }: { - col?: ColumnDef - row?: AnyRecord - type?: string - name: string - }, -) => { +const isDateColumn = (col?: ColumnDef, value?: unknown) => { + const type = col?.type || '' + const name = col?.name || '' + return type.includes('Date') || type.includes('Time') || + (name.endsWith('At') && + (typeof value === 'number' || + (typeof value === 'string' && value.length > 0 && + !isNaN(Number(value))))) +} + +const toSafeDate = (value: unknown) => { + if (value instanceof Date) return value + const num = typeof value === 'number' ? value : Number(value) + if (!isNaN(num) && value !== '' && value !== null) { + const correctTime = num < 1e12 ? num * 1000 : num + return new Date(correctTime) + } + return new Date(String(value)) +} + +const formatCellValue = (value: unknown, col?: ColumnDef) => { + const isObj = typeof value === 'object' && value !== null + if (!value || !isDateColumn(col, value)) { + return isObj ? JSON.stringify(value) : String(value ?? '') + } + + const date = toSafeDate(value) + if (isNaN(date.getTime())) return String(value) + + return date.toLocaleString([], { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +const ColumnLabel = ({ col, row }: { col: ColumnDef; row?: AnyRecord }) => { const config = getColumnConfig(col) return (
- {type || col?.type || ''} + {col?.type || ''} ) } +const DataCell = ( + { col, row, name }: { col?: ColumnDef; row: AnyRecord; name: string }, +) => { + const config = getColumnConfig(col) + const isEnum = col?.relation?.type === 'enum' + const isTableRel = col?.relation?.type === 'table' + const rawValue = row[name] + + const value = isEnum ? (row[`inline_${name}`] ?? rawValue) : rawValue + + return ( + + {isTableRel && !!rawValue + ? ( + e.stopPropagation()} + > + + + + ) + : } + + ) +} + // Effect to fetch schema when deployment URL changes effect(() => { const dep = url.params.dep @@ -421,11 +469,11 @@ const RowNumberCell = ({ index }: { index: number }) => ( const TableCell = ({ value, col }: { value: unknown; col?: ColumnDef }) => { const config = getColumnConfig(col) const isObj = typeof value === 'object' && value !== null - const stringValue = isObj ? JSON.stringify(value) : String(value ?? '') - const isTooLong = stringValue.length > 100 + const displayValue = formatCellValue(value, col) + const isTooLong = displayValue.length > 100 return ( -
+
{value === null || value === undefined || value === '' ? ( @@ -436,17 +484,17 @@ const TableCell = ({ value, col }: { value: unknown; col?: ColumnDef }) => { ? ( - {stringValue} + {displayValue} ) : ( - {stringValue} + {displayValue} )}
@@ -476,46 +524,21 @@ const DataRow = ( const rowId = pk ? String(row[pk]) : undefined return ( - + navigate({ params: { drawer: 'view-row', 'row-id': rowId, rt: null } })} > - - - {columns.map((key) => { - const colDef = columnsDef?.find((c) => c.name === key) - const config = getColumnConfig(colDef) - const isEnum = colDef?.relation?.type === 'enum' - const isTableRel = colDef?.relation?.type === 'table' - - const value = isEnum ? (row[`inline_${key}`] ?? row[key]) : row[key] - - return ( - - {isTableRel - ? ( - e.stopPropagation()} - > - - - - ) - : } - - ) - })} - - + + {columns.map((key) => ( + c.name === key)} + /> + ))} + ) } @@ -845,9 +868,7 @@ function SchemaPanel() { return (
{ - // If timestamp is close to epoch (1970), assume it's in seconds and convert to ms - const time = timestamp.getTime() - const correctTime = time < 1000000000000 ? time * 1000 : time // 1e12 ms is roughly year 2001, 1e10 sec is year 2286 - return new Date(correctTime).toLocaleString('en-UK', dateFmtConfig) +const safeFormatTimestamp = (timestamp: Date | number | string) => { + const date = toSafeDate(timestamp) + if (isNaN(date.getTime())) return String(timestamp) + return date.toLocaleString('en-UK', dateFmtConfig) .split(', ').reverse().join(' ') } @@ -2037,14 +2057,14 @@ const RowDetails = () => { ) } + const tableName = url.params.rt || url.params.table || + schema.data?.tables?.[0]?.table + const tableDef = schema.data?.tables?.find((t) => t.table === tableName) + const onUpdateRow = async (e: Event) => { e.preventDefault() const row = rowDetailsData.data?.rows?.[0] as AnyRecord | undefined if (!row) return - const tableName = url.params.rt || url.params.table || - schema.data?.tables?.[0]?.table - console.log('tableName', tableName) - const tableDef = schema.data?.tables?.find((t) => t.table === tableName) const pk = tableDef?.columns?.[0]?.name if (!tableName || !pk) { toast('Could not identify table or primary key', 'error') @@ -2112,62 +2132,54 @@ const RowDetails = () => {
- {Object.entries(row).filter(([key]) => !key.startsWith('inline_')) - .map(([key, value]) => { - const tableName = url.params.rt || url.params.table || - schema.data?.tables?.[0]?.table - const tableDef = schema.data?.tables?.find((t) => - t.table === tableName - ) - const colDef = tableDef?.columns?.find((c) => c.name === key) - const type = colDef?.type || 'String' - const isObject = - (type.includes('Map') || type.includes('Array') || - type.includes('Tuple') || type.includes('Nested') || - type.includes('JSON') || - type.toLowerCase().includes('blob')) && - (typeof value === 'object' || typeof value === 'string') - const isNumber = type.includes('Int') || type.includes('Float') || - type.includes('Decimal') - const isBoolean = type.includes('Bool') - const isDate = type.includes('Date') || type.includes('Time') || - (key.endsWith('At') && - (typeof value === 'number' || !isNaN(Number(value)))) + {tableDef?.columns.map((col) => { + const key = col.name + const value = row[key] + const type = col.type + const isObject = (type.includes('Map') || type.includes('Array') || + type.includes('Tuple') || type.includes('Nested') || + type.includes('JSON') || + type.toLowerCase().includes('blob')) && + (typeof value === 'object' || typeof value === 'string') + const isNumber = type.includes('Int') || type.includes('Float') || + type.includes('Decimal') + const isBoolean = type.includes('Bool') + const isDate = isDateColumn(col, value) - return ( -
- - - {isObject - ? - : isBoolean - ? ( - - ) - : isDate - ? ( - - ) - : isNumber - ? - : ( - - )} -
- ) - })} + return ( +
+ + + {isObject + ? + : isBoolean + ? ( + + ) + : isDate + ? ( + + ) + : isNumber + ? + : ( + + )} +
+ ) + })}
From fa637ef2e6d9326c85b7a6709c0d9ebf952298d1 Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Tue, 12 May 2026 16:20:28 +0000 Subject: [PATCH 5/6] feat: enhance DataCell and DataRow components to support primary key handling and improve table rendering --- web/pages/DeploymentPage.tsx | 54 +++++++++++++----------------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index ef8dda3..b8fd648 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -160,25 +160,28 @@ const DataCell = ( const config = getColumnConfig(col) const isEnum = col?.relation?.type === 'enum' const isTableRel = col?.relation?.type === 'table' + const isPK = col?.isPrimaryKey const rawValue = row[name] const value = isEnum ? (row[`inline_${name}`] ?? rawValue) : rawValue return ( - {isTableRel && !!rawValue + {(isTableRel || isPK) && !!rawValue ? ( e.stopPropagation()} > - + {isTableRel && ( + + )} ) : } @@ -513,34 +516,23 @@ const EmptyRow = ({ colSpan }: { colSpan: number }) => ( ) const DataRow = ( - { row, columns, index, columnsDef }: { + { row, columns, columnsDef }: { row: AnyRecord columns: string[] - index: number columnsDef?: ColumnDef[] }, -) => { - const pk = columnsDef?.[0]?.name - const rowId = pk ? String(row[pk]) : undefined - - return ( - - navigate({ params: { drawer: 'view-row', 'row-id': rowId, rt: null } })} - > - - {columns.map((key) => ( - c.name === key)} - /> - ))} - - ) -} +) => ( + + {columns.map((key) => ( + c.name === key)} + /> + ))} + +) const TableHeader = ( { columns }: { columns: (string | { key: string; label: string })[] }, @@ -551,11 +543,6 @@ const TableHeader = ( return ( - - - # - - {columns.length > 0 ? ( columns.map((col) => { @@ -678,7 +665,6 @@ const TableContent = ({ rows }: { rows: AnyRecord[] }) => { key={index} row={row} columns={columns} - index={index} columnsDef={columnsDef} /> )) From dd8b632509c55bff58e88c5677f64f812e976afd Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Thu, 14 May 2026 15:27:16 +0000 Subject: [PATCH 6/6] refactor: update columnsMap to use Record type and enhance table row key handling --- api/sql.ts | 13 ++++++------- web/pages/DeploymentPage.tsx | 4 ++-- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/api/sql.ts b/api/sql.ts index 9e22c5a..0b58626 100644 --- a/api/sql.ts +++ b/api/sql.ts @@ -117,7 +117,7 @@ type TableInfo = { schema: string | undefined table: string columns: ColumnInfo[] - columnsMap: Map + columnsMap: Record } export async function refreshOneSchema( @@ -135,7 +135,7 @@ export async function refreshOneSchema( if (!table) continue const key = (schema ? schema + '.' : '') + table if (!tableMap.has(key)) { - tableMap.set(key, { schema, table, columns: [], columnsMap: new Map() }) + tableMap.set(key, { schema, table, columns: [], columnsMap: {} }) } const column: ColumnInfo = { @@ -183,10 +183,9 @@ export async function refreshOneSchema( if (!a.isPrimaryKey && b.isPrimaryKey) return 1 return a.ordinal - b.ordinal }), - columnsMap: t.columns.reduce((obj, col) => { - obj[col.name] = col - return obj - }, {} as Record), + columnsMap: Object.fromEntries( + t.columns.map((col) => [col.name, col]), + ) as Record, })) const payload = { deploymentUrl: dep.url, @@ -306,7 +305,7 @@ export const fetchTablesData = async ( } } - const selectColumns = ['t.*'] + const selectColumns = ['t.rowid AS _rowid_', 't.*'] const joins: string[] = [] for (const col of columnsMap.values()) { diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index b8fd648..7156107 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -638,7 +638,7 @@ const TableFooter = ({ rows }: { rows: AnyRecord[] }) => { } const TableContent = ({ rows }: { rows: AnyRecord[] }) => { - let columns = Object.keys(rows[0] || {}) + let columns = Object.keys(rows[0] || {}).filter((c) => c !== '_rowid_') const tableName = url.params.table || schema.data?.tables?.[0]?.table const tableDef = schema.data?.tables?.find((t) => t.table === tableName) @@ -662,7 +662,7 @@ const TableContent = ({ rows }: { rows: AnyRecord[] }) => { : ( rows.map((row, index) => (