@geekmidas/studio 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/{DataBrowser-hGwiTffZ.d.cts → DataBrowser-B-jz8KBR.d.mts} +5 -2
  2. package/dist/DataBrowser-B-jz8KBR.d.mts.map +1 -0
  3. package/dist/{DataBrowser-SOcqmZb2.d.mts → DataBrowser-BTe9HWJy.d.cts} +5 -2
  4. package/dist/DataBrowser-BTe9HWJy.d.cts.map +1 -0
  5. package/dist/{DataBrowser-c-Gs6PZB.cjs → DataBrowser-D8c_pBf4.cjs} +4 -4
  6. package/dist/DataBrowser-D8c_pBf4.cjs.map +1 -0
  7. package/dist/{DataBrowser-DQ3-ZxdV.mjs → DataBrowser-kgcI9ApJ.mjs} +4 -4
  8. package/dist/DataBrowser-kgcI9ApJ.mjs.map +1 -0
  9. package/dist/Studio-CYzz3wD2.d.cts +152 -0
  10. package/dist/Studio-CYzz3wD2.d.cts.map +1 -0
  11. package/dist/Studio-D5yGscb8.d.mts +152 -0
  12. package/dist/Studio-D5yGscb8.d.mts.map +1 -0
  13. package/dist/data/index.cjs +1 -1
  14. package/dist/data/index.d.cts +1 -1
  15. package/dist/data/index.d.mts +1 -1
  16. package/dist/data/index.mjs +1 -1
  17. package/dist/index.cjs +33 -3
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +4 -131
  20. package/dist/index.d.mts +4 -131
  21. package/dist/index.mjs +33 -3
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/server/hono.cjs +168 -21
  24. package/dist/server/hono.cjs.map +1 -1
  25. package/dist/server/hono.d.cts +13 -2
  26. package/dist/server/hono.d.cts.map +1 -0
  27. package/dist/server/hono.d.mts +13 -2
  28. package/dist/server/hono.d.mts.map +1 -0
  29. package/dist/server/hono.mjs +168 -21
  30. package/dist/server/hono.mjs.map +1 -1
  31. package/dist/types-BZv87Ikv.mjs.map +1 -1
  32. package/dist/types-CMttUZYk.cjs.map +1 -1
  33. package/package.json +5 -5
  34. package/src/Studio.ts +341 -292
  35. package/src/__tests__/Studio.spec.ts +447 -0
  36. package/src/data/DataBrowser.ts +147 -143
  37. package/src/data/__tests__/DataBrowser.integration.spec.ts +404 -404
  38. package/src/data/__tests__/filtering.integration.spec.ts +726 -726
  39. package/src/data/__tests__/introspection.integration.spec.ts +340 -340
  40. package/src/data/__tests__/pagination.spec.ts +123 -0
  41. package/src/data/filtering.ts +154 -154
  42. package/src/data/introspection.ts +141 -141
  43. package/src/data/pagination.ts +15 -15
  44. package/src/index.ts +22 -24
  45. package/src/server/__tests__/hono.integration.spec.ts +605 -347
  46. package/src/server/hono.ts +392 -190
  47. package/src/types.ts +138 -138
  48. package/src/ui-assets.ts +10 -13
  49. package/tsconfig.json +9 -0
  50. package/tsdown.config.ts +9 -9
  51. package/ui/package.json +28 -22
  52. package/ui/src/App.tsx +95 -235
  53. package/ui/src/api.ts +184 -42
  54. package/ui/src/components/FilterPanel.tsx +198 -198
  55. package/ui/src/components/NavRail.tsx +183 -0
  56. package/ui/src/components/RowDetail.tsx +106 -106
  57. package/ui/src/components/StudioHeader.tsx +109 -0
  58. package/ui/src/components/TableList.tsx +49 -49
  59. package/ui/src/components/TableView.tsx +530 -485
  60. package/ui/src/main.tsx +3 -3
  61. package/ui/src/pages/DashboardPage.tsx +500 -0
  62. package/ui/src/pages/DatabasePage.tsx +226 -0
  63. package/ui/src/pages/EndpointDetailsPage.tsx +288 -0
  64. package/ui/src/pages/ExceptionsPage.tsx +268 -0
  65. package/ui/src/pages/LogsPage.tsx +228 -0
  66. package/ui/src/pages/MonitoringPage.tsx +46 -0
  67. package/ui/src/pages/PerformancePage.tsx +307 -0
  68. package/ui/src/pages/RequestsPage.tsx +379 -0
  69. package/ui/src/providers/StudioProvider.tsx +194 -0
  70. package/ui/src/styles.css +53 -142
  71. package/ui/src/types.ts +154 -30
  72. package/ui/tsconfig.tsbuildinfo +1 -1
  73. package/ui/vite.config.ts +6 -6
  74. package/dist/DataBrowser-DQ3-ZxdV.mjs.map +0 -1
  75. package/dist/DataBrowser-c-Gs6PZB.cjs.map +0 -1
@@ -1,519 +1,564 @@
1
1
  import { useCallback, useEffect, useState } from 'react';
2
2
  import * as api from '../api';
3
3
  import type {
4
- FilterConfig,
5
- QueryResult,
6
- SortConfig,
7
- TableInfo,
4
+ FilterConfig,
5
+ QueryResult,
6
+ SortConfig,
7
+ TableInfo,
8
8
  } from '../types';
9
9
  import { FilterPanel } from './FilterPanel';
10
10
 
11
- interface TableViewProps {
12
- tableName: string;
13
- tableInfo: TableInfo | null;
14
- onRowSelect: (row: Record<string, unknown>) => void;
11
+ export interface ForeignKeyClickInfo {
12
+ targetTable: string;
13
+ targetColumn: string;
14
+ value: unknown;
15
+ }
16
+
17
+ export interface TableViewProps {
18
+ tableName: string;
19
+ tableInfo: TableInfo | null;
20
+ onRowSelect: (row: Record<string, unknown>) => void;
21
+ onForeignKeyClick?: (info: ForeignKeyClickInfo) => void;
22
+ initialFilters?: FilterConfig[];
15
23
  }
16
24
 
17
25
  const PAGE_SIZES = [25, 50, 100];
18
26
 
19
27
  // Column type icons
20
28
  function ColumnTypeIcon({ type }: { type: string }) {
21
- switch (type) {
22
- case 'string':
23
- return (
24
- <svg
25
- className="w-3.5 h-3.5"
26
- fill="none"
27
- stroke="currentColor"
28
- viewBox="0 0 24 24"
29
- >
30
- <path
31
- strokeLinecap="round"
32
- strokeLinejoin="round"
33
- strokeWidth={2}
34
- d="M4 6h16M4 12h16M4 18h7"
35
- />
36
- </svg>
37
- );
38
- case 'number':
39
- return (
40
- <svg
41
- className="w-3.5 h-3.5"
42
- fill="none"
43
- stroke="currentColor"
44
- viewBox="0 0 24 24"
45
- >
46
- <path
47
- strokeLinecap="round"
48
- strokeLinejoin="round"
49
- strokeWidth={2}
50
- d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
51
- />
52
- </svg>
53
- );
54
- case 'boolean':
55
- return (
56
- <svg
57
- className="w-3.5 h-3.5"
58
- fill="none"
59
- stroke="currentColor"
60
- viewBox="0 0 24 24"
61
- >
62
- <path
63
- strokeLinecap="round"
64
- strokeLinejoin="round"
65
- strokeWidth={2}
66
- d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
67
- />
68
- </svg>
69
- );
70
- case 'date':
71
- case 'datetime':
72
- return (
73
- <svg
74
- className="w-3.5 h-3.5"
75
- fill="none"
76
- stroke="currentColor"
77
- viewBox="0 0 24 24"
78
- >
79
- <path
80
- strokeLinecap="round"
81
- strokeLinejoin="round"
82
- strokeWidth={2}
83
- d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
84
- />
85
- </svg>
86
- );
87
- case 'json':
88
- return (
89
- <svg
90
- className="w-3.5 h-3.5"
91
- fill="none"
92
- stroke="currentColor"
93
- viewBox="0 0 24 24"
94
- >
95
- <path
96
- strokeLinecap="round"
97
- strokeLinejoin="round"
98
- strokeWidth={2}
99
- d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
100
- />
101
- </svg>
102
- );
103
- case 'uuid':
104
- return (
105
- <svg
106
- className="w-3.5 h-3.5"
107
- fill="none"
108
- stroke="currentColor"
109
- viewBox="0 0 24 24"
110
- >
111
- <path
112
- strokeLinecap="round"
113
- strokeLinejoin="round"
114
- strokeWidth={2}
115
- d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
116
- />
117
- </svg>
118
- );
119
- default:
120
- return (
121
- <svg
122
- className="w-3.5 h-3.5"
123
- fill="none"
124
- stroke="currentColor"
125
- viewBox="0 0 24 24"
126
- >
127
- <path
128
- strokeLinecap="round"
129
- strokeLinejoin="round"
130
- strokeWidth={2}
131
- d="M4 6h16M4 12h16m-7 6h7"
132
- />
133
- </svg>
134
- );
135
- }
29
+ switch (type) {
30
+ case 'string':
31
+ return (
32
+ <svg
33
+ className="w-3.5 h-3.5"
34
+ fill="none"
35
+ stroke="currentColor"
36
+ viewBox="0 0 24 24"
37
+ >
38
+ <path
39
+ strokeLinecap="round"
40
+ strokeLinejoin="round"
41
+ strokeWidth={2}
42
+ d="M4 6h16M4 12h16M4 18h7"
43
+ />
44
+ </svg>
45
+ );
46
+ case 'number':
47
+ return (
48
+ <svg
49
+ className="w-3.5 h-3.5"
50
+ fill="none"
51
+ stroke="currentColor"
52
+ viewBox="0 0 24 24"
53
+ >
54
+ <path
55
+ strokeLinecap="round"
56
+ strokeLinejoin="round"
57
+ strokeWidth={2}
58
+ d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
59
+ />
60
+ </svg>
61
+ );
62
+ case 'boolean':
63
+ return (
64
+ <svg
65
+ className="w-3.5 h-3.5"
66
+ fill="none"
67
+ stroke="currentColor"
68
+ viewBox="0 0 24 24"
69
+ >
70
+ <path
71
+ strokeLinecap="round"
72
+ strokeLinejoin="round"
73
+ strokeWidth={2}
74
+ d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
75
+ />
76
+ </svg>
77
+ );
78
+ case 'date':
79
+ case 'datetime':
80
+ return (
81
+ <svg
82
+ className="w-3.5 h-3.5"
83
+ fill="none"
84
+ stroke="currentColor"
85
+ viewBox="0 0 24 24"
86
+ >
87
+ <path
88
+ strokeLinecap="round"
89
+ strokeLinejoin="round"
90
+ strokeWidth={2}
91
+ d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
92
+ />
93
+ </svg>
94
+ );
95
+ case 'json':
96
+ return (
97
+ <svg
98
+ className="w-3.5 h-3.5"
99
+ fill="none"
100
+ stroke="currentColor"
101
+ viewBox="0 0 24 24"
102
+ >
103
+ <path
104
+ strokeLinecap="round"
105
+ strokeLinejoin="round"
106
+ strokeWidth={2}
107
+ d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
108
+ />
109
+ </svg>
110
+ );
111
+ case 'uuid':
112
+ return (
113
+ <svg
114
+ className="w-3.5 h-3.5"
115
+ fill="none"
116
+ stroke="currentColor"
117
+ viewBox="0 0 24 24"
118
+ >
119
+ <path
120
+ strokeLinecap="round"
121
+ strokeLinejoin="round"
122
+ strokeWidth={2}
123
+ d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
124
+ />
125
+ </svg>
126
+ );
127
+ default:
128
+ return (
129
+ <svg
130
+ className="w-3.5 h-3.5"
131
+ fill="none"
132
+ stroke="currentColor"
133
+ viewBox="0 0 24 24"
134
+ >
135
+ <path
136
+ strokeLinecap="round"
137
+ strokeLinejoin="round"
138
+ strokeWidth={2}
139
+ d="M4 6h16M4 12h16m-7 6h7"
140
+ />
141
+ </svg>
142
+ );
143
+ }
136
144
  }
137
145
 
138
146
  export function TableView({
139
- tableName,
140
- tableInfo,
141
- onRowSelect,
147
+ tableName,
148
+ tableInfo,
149
+ onRowSelect,
150
+ onForeignKeyClick,
151
+ initialFilters,
142
152
  }: TableViewProps) {
143
- const [data, setData] = useState<QueryResult | null>(null);
144
- const [loading, setLoading] = useState(true);
145
- const [error, setError] = useState<string | null>(null);
146
- const [sort, setSort] = useState<SortConfig[]>([]);
147
- const [filters, setFilters] = useState<FilterConfig[]>([]);
148
- const [showFilters, setShowFilters] = useState(false);
149
- const [pageSize, setPageSize] = useState(50);
150
- const [cursors, setCursors] = useState<string[]>([]);
151
- const [currentPage, setCurrentPage] = useState(0);
153
+ const [data, setData] = useState<QueryResult | null>(null);
154
+ const [loading, setLoading] = useState(true);
155
+ const [error, setError] = useState<string | null>(null);
156
+ const [sort, setSort] = useState<SortConfig[]>([]);
157
+ const [filters, setFilters] = useState<FilterConfig[]>(initialFilters ?? []);
158
+ const [showFilters, setShowFilters] = useState(false);
159
+ const [pageSize, setPageSize] = useState(50);
160
+ const [cursors, setCursors] = useState<string[]>([]);
161
+ const [currentPage, setCurrentPage] = useState(0);
162
+
163
+ const loadData = useCallback(
164
+ async (cursor?: string) => {
165
+ setLoading(true);
166
+ setError(null);
167
+ try {
168
+ // Filter out filters with empty values (except is_null/is_not_null which don't need values)
169
+ const validFilters = filters.filter(
170
+ (f) =>
171
+ f.operator === 'is_null' ||
172
+ f.operator === 'is_not_null' ||
173
+ (f.value && f.value.trim() !== ''),
174
+ );
175
+
176
+ const result = await api.queryTable(tableName, {
177
+ pageSize,
178
+ cursor,
179
+ sort: sort.length > 0 ? sort : undefined,
180
+ filters: validFilters.length > 0 ? validFilters : undefined,
181
+ });
182
+ setData(result);
183
+ } catch (err) {
184
+ setError(err instanceof Error ? err.message : 'Failed to load data');
185
+ } finally {
186
+ setLoading(false);
187
+ }
188
+ },
189
+ [tableName, pageSize, sort, filters],
190
+ );
152
191
 
153
- const loadData = useCallback(
154
- async (cursor?: string) => {
155
- setLoading(true);
156
- setError(null);
157
- try {
158
- // Filter out filters with empty values (except is_null/is_not_null which don't need values)
159
- const validFilters = filters.filter(
160
- (f) =>
161
- f.operator === 'is_null' ||
162
- f.operator === 'is_not_null' ||
163
- (f.value && f.value.trim() !== ''),
164
- );
192
+ // Reset and reload when table, sort, or filters change
193
+ useEffect(() => {
194
+ setCursors([]);
195
+ setCurrentPage(0);
196
+ loadData();
197
+ }, [loadData]);
165
198
 
166
- const result = await api.queryTable(tableName, {
167
- pageSize,
168
- cursor,
169
- sort: sort.length > 0 ? sort : undefined,
170
- filters: validFilters.length > 0 ? validFilters : undefined,
171
- });
172
- setData(result);
173
- } catch (err) {
174
- setError(err instanceof Error ? err.message : 'Failed to load data');
175
- } finally {
176
- setLoading(false);
177
- }
178
- },
179
- [tableName, pageSize, sort, filters],
180
- );
199
+ // Update filters when initialFilters changes (e.g., FK navigation)
200
+ useEffect(() => {
201
+ if (initialFilters) {
202
+ setFilters(initialFilters);
203
+ setShowFilters(initialFilters.length > 0);
204
+ }
205
+ }, [initialFilters]);
181
206
 
182
- // Reset and reload when table, sort, or filters change
183
- useEffect(() => {
184
- setCursors([]);
185
- setCurrentPage(0);
186
- loadData();
187
- }, [tableName, sort, filters, pageSize]);
207
+ const handleSort = useCallback((column: string) => {
208
+ setSort((prev) => {
209
+ const existing = prev.find((s) => s.column === column);
210
+ if (!existing) {
211
+ return [{ column, direction: 'asc' }];
212
+ }
213
+ if (existing.direction === 'asc') {
214
+ return [{ column, direction: 'desc' }];
215
+ }
216
+ return [];
217
+ });
218
+ }, []);
188
219
 
189
- const handleSort = useCallback((column: string) => {
190
- setSort((prev) => {
191
- const existing = prev.find((s) => s.column === column);
192
- if (!existing) {
193
- return [{ column, direction: 'asc' }];
194
- }
195
- if (existing.direction === 'asc') {
196
- return [{ column, direction: 'desc' }];
197
- }
198
- return [];
199
- });
200
- }, []);
220
+ const handleNextPage = useCallback(() => {
221
+ if (data?.nextCursor) {
222
+ setCursors((prev) => [...prev, data.nextCursor!]);
223
+ setCurrentPage((prev) => prev + 1);
224
+ loadData(data.nextCursor);
225
+ }
226
+ }, [data, loadData]);
201
227
 
202
- const handleNextPage = useCallback(() => {
203
- if (data?.nextCursor) {
204
- setCursors((prev) => [...prev, data.nextCursor!]);
205
- setCurrentPage((prev) => prev + 1);
206
- loadData(data.nextCursor);
207
- }
208
- }, [data, loadData]);
228
+ const handlePrevPage = useCallback(() => {
229
+ if (currentPage > 0) {
230
+ const newCursors = cursors.slice(0, -1);
231
+ setCursors(newCursors);
232
+ setCurrentPage((prev) => prev - 1);
233
+ loadData(newCursors[newCursors.length - 1] || undefined);
234
+ }
235
+ }, [currentPage, cursors, loadData]);
209
236
 
210
- const handlePrevPage = useCallback(() => {
211
- if (currentPage > 0) {
212
- const newCursors = cursors.slice(0, -1);
213
- setCursors(newCursors);
214
- setCurrentPage((prev) => prev - 1);
215
- loadData(newCursors[newCursors.length - 1] || undefined);
216
- }
217
- }, [currentPage, cursors, loadData]);
237
+ const formatValue = (value: unknown): string => {
238
+ if (value === null) return 'NULL';
239
+ if (value === undefined) return '';
240
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
241
+ if (value instanceof Date) return value.toISOString();
242
+ if (typeof value === 'object') return JSON.stringify(value);
243
+ return String(value);
244
+ };
218
245
 
219
- const formatValue = (value: unknown): string => {
220
- if (value === null) return 'NULL';
221
- if (value === undefined) return '';
222
- if (typeof value === 'boolean') return value ? 'true' : 'false';
223
- if (value instanceof Date) return value.toISOString();
224
- if (typeof value === 'object') return JSON.stringify(value);
225
- return String(value);
226
- };
246
+ if (!tableInfo) {
247
+ return (
248
+ <div className="flex-1 flex items-center justify-center text-slate-500">
249
+ <div className="flex items-center gap-2">
250
+ <svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
251
+ <circle
252
+ className="opacity-25"
253
+ cx="12"
254
+ cy="12"
255
+ r="10"
256
+ stroke="currentColor"
257
+ strokeWidth="4"
258
+ />
259
+ <path
260
+ className="opacity-75"
261
+ fill="currentColor"
262
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
263
+ />
264
+ </svg>
265
+ Loading table info...
266
+ </div>
267
+ </div>
268
+ );
269
+ }
227
270
 
228
- if (!tableInfo) {
229
- return (
230
- <div className="flex-1 flex items-center justify-center text-slate-500">
231
- <div className="flex items-center gap-2">
232
- <svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
233
- <circle
234
- className="opacity-25"
235
- cx="12"
236
- cy="12"
237
- r="10"
238
- stroke="currentColor"
239
- strokeWidth="4"
240
- />
241
- <path
242
- className="opacity-75"
243
- fill="currentColor"
244
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
245
- />
246
- </svg>
247
- Loading table info...
248
- </div>
249
- </div>
250
- );
251
- }
271
+ const columns = tableInfo.columns;
272
+ const startRow = currentPage * pageSize + 1;
273
+ const endRow = startRow + (data?.rows.length || 0) - 1;
252
274
 
253
- const columns = tableInfo.columns;
254
- const startRow = currentPage * pageSize + 1;
255
- const endRow = startRow + (data?.rows.length || 0) - 1;
275
+ return (
276
+ <div className="flex-1 flex flex-col overflow-hidden bg-studio-bg">
277
+ {/* Toolbar */}
278
+ <div className="bg-studio-surface border-b border-studio-border px-4 py-2 flex items-center justify-between shrink-0">
279
+ <div className="flex items-center gap-2">
280
+ {/* Filter button */}
281
+ <button
282
+ onClick={() => setShowFilters(!showFilters)}
283
+ className={`btn btn-default ${showFilters || filters.length > 0 ? 'text-emerald-400' : ''}`}
284
+ >
285
+ <svg
286
+ className="w-4 h-4"
287
+ fill="none"
288
+ stroke="currentColor"
289
+ viewBox="0 0 24 24"
290
+ >
291
+ <path
292
+ strokeLinecap="round"
293
+ strokeLinejoin="round"
294
+ strokeWidth={2}
295
+ d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
296
+ />
297
+ </svg>
298
+ Filter
299
+ {filters.length > 0 && (
300
+ <span className="badge">{filters.length}</span>
301
+ )}
302
+ </button>
256
303
 
257
- return (
258
- <div className="flex-1 flex flex-col overflow-hidden bg-studio-bg">
259
- {/* Toolbar */}
260
- <div className="bg-studio-surface border-b border-studio-border px-4 py-2 flex items-center justify-between shrink-0">
261
- <div className="flex items-center gap-2">
262
- {/* Filter button */}
263
- <button
264
- onClick={() => setShowFilters(!showFilters)}
265
- className={`btn btn-default ${showFilters || filters.length > 0 ? 'text-emerald-400' : ''}`}
266
- >
267
- <svg
268
- className="w-4 h-4"
269
- fill="none"
270
- stroke="currentColor"
271
- viewBox="0 0 24 24"
272
- >
273
- <path
274
- strokeLinecap="round"
275
- strokeLinejoin="round"
276
- strokeWidth={2}
277
- d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
278
- />
279
- </svg>
280
- Filter
281
- {filters.length > 0 && (
282
- <span className="badge">{filters.length}</span>
283
- )}
284
- </button>
304
+ {/* Sort indicator */}
305
+ {sort.length > 0 && (
306
+ <div className="flex items-center gap-1 text-sm text-slate-400">
307
+ <span>Sorted by</span>
308
+ <span className="text-emerald-400">{sort[0].column}</span>
309
+ <span>({sort[0].direction})</span>
310
+ <button
311
+ onClick={() => setSort([])}
312
+ className="p-0.5 hover:bg-studio-hover rounded"
313
+ >
314
+ <svg
315
+ className="w-3.5 h-3.5"
316
+ fill="none"
317
+ stroke="currentColor"
318
+ viewBox="0 0 24 24"
319
+ >
320
+ <path
321
+ strokeLinecap="round"
322
+ strokeLinejoin="round"
323
+ strokeWidth={2}
324
+ d="M6 18L18 6M6 6l12 12"
325
+ />
326
+ </svg>
327
+ </button>
328
+ </div>
329
+ )}
330
+ </div>
285
331
 
286
- {/* Sort indicator */}
287
- {sort.length > 0 && (
288
- <div className="flex items-center gap-1 text-sm text-slate-400">
289
- <span>Sorted by</span>
290
- <span className="text-emerald-400">{sort[0].column}</span>
291
- <span>({sort[0].direction})</span>
292
- <button
293
- onClick={() => setSort([])}
294
- className="p-0.5 hover:bg-studio-hover rounded"
295
- >
296
- <svg
297
- className="w-3.5 h-3.5"
298
- fill="none"
299
- stroke="currentColor"
300
- viewBox="0 0 24 24"
301
- >
302
- <path
303
- strokeLinecap="round"
304
- strokeLinejoin="round"
305
- strokeWidth={2}
306
- d="M6 18L18 6M6 6l12 12"
307
- />
308
- </svg>
309
- </button>
310
- </div>
311
- )}
312
- </div>
332
+ <div className="flex items-center gap-3 text-sm text-slate-400">
333
+ {tableInfo.estimatedRowCount !== undefined && (
334
+ <span>{tableInfo.estimatedRowCount.toLocaleString()} rows</span>
335
+ )}
336
+ </div>
337
+ </div>
313
338
 
314
- <div className="flex items-center gap-3 text-sm text-slate-400">
315
- {tableInfo.estimatedRowCount !== undefined && (
316
- <span>{tableInfo.estimatedRowCount.toLocaleString()} rows</span>
317
- )}
318
- </div>
319
- </div>
339
+ {/* Filter Panel */}
340
+ {showFilters && (
341
+ <FilterPanel
342
+ columns={columns}
343
+ filters={filters}
344
+ onFiltersChange={setFilters}
345
+ onClose={() => setShowFilters(false)}
346
+ />
347
+ )}
320
348
 
321
- {/* Filter Panel */}
322
- {showFilters && (
323
- <FilterPanel
324
- columns={columns}
325
- filters={filters}
326
- onFiltersChange={setFilters}
327
- onClose={() => setShowFilters(false)}
328
- />
329
- )}
349
+ {/* Error state */}
350
+ {error && (
351
+ <div className="p-4 bg-red-500/10 border-b border-red-500/20 text-red-400 text-sm">
352
+ {error}
353
+ </div>
354
+ )}
330
355
 
331
- {/* Error state */}
332
- {error && (
333
- <div className="p-4 bg-red-500/10 border-b border-red-500/20 text-red-400 text-sm">
334
- {error}
335
- </div>
336
- )}
356
+ {/* Data Grid */}
357
+ <div className="flex-1 overflow-auto">
358
+ <table className="data-grid">
359
+ <thead>
360
+ <tr>
361
+ {/* Row number header */}
362
+ <th className="w-12 px-3 py-2 text-xs font-normal text-slate-500 bg-studio-surface">
363
+ #
364
+ </th>
365
+ {columns.map((col) => (
366
+ <th
367
+ key={col.name}
368
+ onClick={() => handleSort(col.name)}
369
+ className="px-3 py-2 text-left cursor-pointer hover:bg-studio-hover transition-colors bg-studio-surface"
370
+ >
371
+ <div className="flex items-center gap-2">
372
+ <span className="text-slate-500">
373
+ <ColumnTypeIcon type={col.type} />
374
+ </span>
375
+ <span className="text-sm font-medium text-slate-300 truncate">
376
+ {col.name}
377
+ </span>
378
+ {col.isPrimaryKey && (
379
+ <span className="text-[10px] px-1 py-0.5 rounded bg-amber-500/20 text-amber-400">
380
+ PK
381
+ </span>
382
+ )}
383
+ {col.isForeignKey && (
384
+ <span className="text-[10px] px-1 py-0.5 rounded bg-blue-500/20 text-blue-400">
385
+ FK
386
+ </span>
387
+ )}
388
+ {sort.find((s) => s.column === col.name) && (
389
+ <span className="text-emerald-400">
390
+ {sort[0].direction === 'asc' ? '↑' : '↓'}
391
+ </span>
392
+ )}
393
+ </div>
394
+ </th>
395
+ ))}
396
+ </tr>
397
+ </thead>
398
+ <tbody>
399
+ {loading && !data ? (
400
+ <tr>
401
+ <td
402
+ colSpan={columns.length + 1}
403
+ className="text-center py-8 text-slate-500"
404
+ >
405
+ <div className="flex items-center justify-center gap-2">
406
+ <svg
407
+ className="w-5 h-5 animate-spin"
408
+ fill="none"
409
+ viewBox="0 0 24 24"
410
+ >
411
+ <circle
412
+ className="opacity-25"
413
+ cx="12"
414
+ cy="12"
415
+ r="10"
416
+ stroke="currentColor"
417
+ strokeWidth="4"
418
+ />
419
+ <path
420
+ className="opacity-75"
421
+ fill="currentColor"
422
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
423
+ />
424
+ </svg>
425
+ Loading...
426
+ </div>
427
+ </td>
428
+ </tr>
429
+ ) : data?.rows.length === 0 ? (
430
+ <tr>
431
+ <td
432
+ colSpan={columns.length + 1}
433
+ className="text-center py-8 text-slate-500"
434
+ >
435
+ No data found
436
+ </td>
437
+ </tr>
438
+ ) : (
439
+ data?.rows.map((row, idx) => (
440
+ <tr
441
+ key={idx}
442
+ onClick={() => onRowSelect(row)}
443
+ className="cursor-pointer"
444
+ >
445
+ {/* Row number */}
446
+ <td className="row-number px-3 py-2 text-right">
447
+ {startRow + idx}
448
+ </td>
449
+ {columns.map((col) => {
450
+ const value = row[col.name];
451
+ const isFkClickable =
452
+ col.isForeignKey &&
453
+ col.foreignKeyTable &&
454
+ col.foreignKeyColumn &&
455
+ value !== null &&
456
+ onForeignKeyClick;
337
457
 
338
- {/* Data Grid */}
339
- <div className="flex-1 overflow-auto">
340
- <table className="data-grid">
341
- <thead>
342
- <tr>
343
- {/* Row number header */}
344
- <th className="w-12 px-3 py-2 text-xs font-normal text-slate-500 bg-studio-surface">
345
- #
346
- </th>
347
- {columns.map((col) => (
348
- <th
349
- key={col.name}
350
- onClick={() => handleSort(col.name)}
351
- className="px-3 py-2 text-left cursor-pointer hover:bg-studio-hover transition-colors bg-studio-surface"
352
- >
353
- <div className="flex items-center gap-2">
354
- <span className="text-slate-500">
355
- <ColumnTypeIcon type={col.type} />
356
- </span>
357
- <span className="text-sm font-medium text-slate-300 truncate">
358
- {col.name}
359
- </span>
360
- {col.isPrimaryKey && (
361
- <span className="text-[10px] px-1 py-0.5 rounded bg-amber-500/20 text-amber-400">
362
- PK
363
- </span>
364
- )}
365
- {col.isForeignKey && (
366
- <span className="text-[10px] px-1 py-0.5 rounded bg-blue-500/20 text-blue-400">
367
- FK
368
- </span>
369
- )}
370
- {sort.find((s) => s.column === col.name) && (
371
- <span className="text-emerald-400">
372
- {sort[0].direction === 'asc' ? '↑' : '↓'}
373
- </span>
374
- )}
375
- </div>
376
- </th>
377
- ))}
378
- </tr>
379
- </thead>
380
- <tbody>
381
- {loading && !data ? (
382
- <tr>
383
- <td
384
- colSpan={columns.length + 1}
385
- className="text-center py-8 text-slate-500"
386
- >
387
- <div className="flex items-center justify-center gap-2">
388
- <svg
389
- className="w-5 h-5 animate-spin"
390
- fill="none"
391
- viewBox="0 0 24 24"
392
- >
393
- <circle
394
- className="opacity-25"
395
- cx="12"
396
- cy="12"
397
- r="10"
398
- stroke="currentColor"
399
- strokeWidth="4"
400
- />
401
- <path
402
- className="opacity-75"
403
- fill="currentColor"
404
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
405
- />
406
- </svg>
407
- Loading...
408
- </div>
409
- </td>
410
- </tr>
411
- ) : data?.rows.length === 0 ? (
412
- <tr>
413
- <td
414
- colSpan={columns.length + 1}
415
- className="text-center py-8 text-slate-500"
416
- >
417
- No data found
418
- </td>
419
- </tr>
420
- ) : (
421
- data?.rows.map((row, idx) => (
422
- <tr
423
- key={idx}
424
- onClick={() => onRowSelect(row)}
425
- className="cursor-pointer"
426
- >
427
- {/* Row number */}
428
- <td className="row-number px-3 py-2 text-right">
429
- {startRow + idx}
430
- </td>
431
- {columns.map((col) => (
432
- <td
433
- key={col.name}
434
- className={`px-3 py-2 text-sm max-w-xs truncate ${
435
- row[col.name] === null ? 'cell-null' : 'text-slate-300'
436
- }`}
437
- >
438
- {formatValue(row[col.name])}
439
- </td>
440
- ))}
441
- </tr>
442
- ))
443
- )}
444
- </tbody>
445
- </table>
446
- </div>
458
+ return (
459
+ <td
460
+ key={col.name}
461
+ className={`px-3 py-2 text-sm max-w-xs truncate ${
462
+ value === null ? 'cell-null' : 'text-slate-300'
463
+ }`}
464
+ >
465
+ {isFkClickable ? (
466
+ <button
467
+ type="button"
468
+ onClick={(e) => {
469
+ e.stopPropagation();
470
+ onForeignKeyClick({
471
+ targetTable: col.foreignKeyTable!,
472
+ targetColumn: col.foreignKeyColumn!,
473
+ value,
474
+ });
475
+ }}
476
+ className="text-blue-400 hover:text-blue-300 hover:underline transition-colors"
477
+ >
478
+ {formatValue(value)}
479
+ </button>
480
+ ) : (
481
+ formatValue(value)
482
+ )}
483
+ </td>
484
+ );
485
+ })}
486
+ </tr>
487
+ ))
488
+ )}
489
+ </tbody>
490
+ </table>
491
+ </div>
447
492
 
448
- {/* Pagination */}
449
- <div className="bg-studio-surface border-t border-studio-border px-4 py-2 flex items-center justify-between shrink-0">
450
- <div className="flex items-center gap-4 text-sm text-slate-400">
451
- <div className="flex items-center gap-2">
452
- <span>Rows per page:</span>
453
- <select
454
- value={pageSize}
455
- onChange={(e) => setPageSize(Number(e.target.value))}
456
- className="select py-1"
457
- >
458
- {PAGE_SIZES.map((size) => (
459
- <option key={size} value={size}>
460
- {size}
461
- </option>
462
- ))}
463
- </select>
464
- </div>
465
- {data && data.rows.length > 0 && (
466
- <span>
467
- Showing {startRow} - {endRow}
468
- {tableInfo.estimatedRowCount !== undefined &&
469
- ` of ${tableInfo.estimatedRowCount.toLocaleString()}`}
470
- </span>
471
- )}
472
- </div>
493
+ {/* Pagination */}
494
+ <div className="bg-studio-surface border-t border-studio-border px-4 py-2 flex items-center justify-between shrink-0">
495
+ <div className="flex items-center gap-4 text-sm text-slate-400">
496
+ <div className="flex items-center gap-2">
497
+ <span>Rows per page:</span>
498
+ <select
499
+ value={pageSize}
500
+ onChange={(e) => setPageSize(Number(e.target.value))}
501
+ className="select py-1"
502
+ >
503
+ {PAGE_SIZES.map((size) => (
504
+ <option key={size} value={size}>
505
+ {size}
506
+ </option>
507
+ ))}
508
+ </select>
509
+ </div>
510
+ {data && data.rows.length > 0 && (
511
+ <span>
512
+ Showing {startRow} - {endRow}
513
+ {tableInfo.estimatedRowCount !== undefined &&
514
+ ` of ${tableInfo.estimatedRowCount.toLocaleString()}`}
515
+ </span>
516
+ )}
517
+ </div>
473
518
 
474
- <div className="flex items-center gap-2">
475
- <button
476
- onClick={handlePrevPage}
477
- disabled={currentPage === 0 || loading}
478
- className="btn btn-default disabled:opacity-50 disabled:cursor-not-allowed"
479
- >
480
- <svg
481
- className="w-4 h-4"
482
- fill="none"
483
- stroke="currentColor"
484
- viewBox="0 0 24 24"
485
- >
486
- <path
487
- strokeLinecap="round"
488
- strokeLinejoin="round"
489
- strokeWidth={2}
490
- d="M15 19l-7-7 7-7"
491
- />
492
- </svg>
493
- Previous
494
- </button>
495
- <button
496
- onClick={handleNextPage}
497
- disabled={!data?.hasMore || loading}
498
- className="btn btn-default disabled:opacity-50 disabled:cursor-not-allowed"
499
- >
500
- Next
501
- <svg
502
- className="w-4 h-4"
503
- fill="none"
504
- stroke="currentColor"
505
- viewBox="0 0 24 24"
506
- >
507
- <path
508
- strokeLinecap="round"
509
- strokeLinejoin="round"
510
- strokeWidth={2}
511
- d="M9 5l7 7-7 7"
512
- />
513
- </svg>
514
- </button>
515
- </div>
516
- </div>
517
- </div>
518
- );
519
+ <div className="flex items-center gap-2">
520
+ <button
521
+ onClick={handlePrevPage}
522
+ disabled={currentPage === 0 || loading}
523
+ className="btn btn-default disabled:opacity-50 disabled:cursor-not-allowed"
524
+ >
525
+ <svg
526
+ className="w-4 h-4"
527
+ fill="none"
528
+ stroke="currentColor"
529
+ viewBox="0 0 24 24"
530
+ >
531
+ <path
532
+ strokeLinecap="round"
533
+ strokeLinejoin="round"
534
+ strokeWidth={2}
535
+ d="M15 19l-7-7 7-7"
536
+ />
537
+ </svg>
538
+ Previous
539
+ </button>
540
+ <button
541
+ onClick={handleNextPage}
542
+ disabled={!data?.hasMore || loading}
543
+ className="btn btn-default disabled:opacity-50 disabled:cursor-not-allowed"
544
+ >
545
+ Next
546
+ <svg
547
+ className="w-4 h-4"
548
+ fill="none"
549
+ stroke="currentColor"
550
+ viewBox="0 0 24 24"
551
+ >
552
+ <path
553
+ strokeLinecap="round"
554
+ strokeLinejoin="round"
555
+ strokeWidth={2}
556
+ d="M9 5l7 7-7 7"
557
+ />
558
+ </svg>
559
+ </button>
560
+ </div>
561
+ </div>
562
+ </div>
563
+ );
519
564
  }