@geekmidas/studio 9.0.2 → 10.0.0-alpha.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 (50) hide show
  1. package/package.json +8 -5
  2. package/CHANGELOG.md +0 -82
  3. package/src/Studio.ts +0 -367
  4. package/src/__tests__/Studio.spec.ts +0 -447
  5. package/src/data/DataBrowser.ts +0 -170
  6. package/src/data/__tests__/DataBrowser.integration.spec.ts +0 -418
  7. package/src/data/__tests__/filtering.integration.spec.ts +0 -741
  8. package/src/data/__tests__/introspection.integration.spec.ts +0 -352
  9. package/src/data/__tests__/pagination.spec.ts +0 -123
  10. package/src/data/filtering.ts +0 -191
  11. package/src/data/index.ts +0 -1
  12. package/src/data/introspection.ts +0 -220
  13. package/src/data/pagination.ts +0 -33
  14. package/src/index.ts +0 -29
  15. package/src/server/__tests__/hono.integration.spec.ts +0 -619
  16. package/src/server/hono.ts +0 -427
  17. package/src/types.ts +0 -278
  18. package/src/ui-assets.ts +0 -37
  19. package/tsconfig.json +0 -9
  20. package/tsdown.config.ts +0 -13
  21. package/ui/CHANGELOG.md +0 -26
  22. package/ui/index.html +0 -12
  23. package/ui/node_modules/.bin/tsc +0 -21
  24. package/ui/node_modules/.bin/tsserver +0 -21
  25. package/ui/node_modules/.bin/vite +0 -21
  26. package/ui/package.json +0 -30
  27. package/ui/src/App.tsx +0 -100
  28. package/ui/src/api.ts +0 -213
  29. package/ui/src/components/FilterPanel.tsx +0 -213
  30. package/ui/src/components/NavRail.tsx +0 -183
  31. package/ui/src/components/RowDetail.tsx +0 -119
  32. package/ui/src/components/StudioHeader.tsx +0 -109
  33. package/ui/src/components/TableList.tsx +0 -58
  34. package/ui/src/components/TableView.tsx +0 -564
  35. package/ui/src/main.tsx +0 -10
  36. package/ui/src/pages/DashboardPage.tsx +0 -500
  37. package/ui/src/pages/DatabasePage.tsx +0 -226
  38. package/ui/src/pages/EndpointDetailsPage.tsx +0 -288
  39. package/ui/src/pages/ExceptionsPage.tsx +0 -268
  40. package/ui/src/pages/LogsPage.tsx +0 -228
  41. package/ui/src/pages/MonitoringPage.tsx +0 -46
  42. package/ui/src/pages/PerformancePage.tsx +0 -307
  43. package/ui/src/pages/RequestsPage.tsx +0 -379
  44. package/ui/src/providers/StudioProvider.tsx +0 -194
  45. package/ui/src/styles.css +0 -105
  46. package/ui/src/types.ts +0 -174
  47. package/ui/src/vite-env.d.ts +0 -1
  48. package/ui/tsconfig.json +0 -21
  49. package/ui/tsconfig.tsbuildinfo +0 -1
  50. package/ui/vite.config.ts +0 -12
@@ -1,447 +0,0 @@
1
- import { InMemoryStorage } from '@geekmidas/telescope';
2
- import {
3
- CamelCasePlugin,
4
- type Generated,
5
- Kysely,
6
- PostgresDialect,
7
- } from 'kysely';
8
- import pg from 'pg';
9
- import {
10
- afterAll,
11
- afterEach,
12
- beforeAll,
13
- beforeEach,
14
- describe,
15
- expect,
16
- it,
17
- vi,
18
- } from 'vitest';
19
- import { TEST_DATABASE_CONFIG } from '../../../testkit/test/globalSetup';
20
- import { Studio } from '../Studio';
21
- import { Direction } from '../types';
22
-
23
- interface TestDatabase {
24
- studioTestUsers: {
25
- id: Generated<number>;
26
- name: string;
27
- email: string;
28
- };
29
- }
30
-
31
- describe('Studio', () => {
32
- let db: Kysely<TestDatabase>;
33
- let studio: Studio<TestDatabase>;
34
- let storage: InMemoryStorage;
35
-
36
- beforeAll(async () => {
37
- db = new Kysely<TestDatabase>({
38
- dialect: new PostgresDialect({
39
- pool: new pg.Pool({
40
- ...TEST_DATABASE_CONFIG,
41
- database: 'postgres',
42
- }),
43
- }),
44
- plugins: [new CamelCasePlugin()],
45
- });
46
-
47
- // Create test table
48
- await db.schema
49
- .createTable('studio_test_users')
50
- .ifNotExists()
51
- .addColumn('id', 'serial', (col) => col.primaryKey())
52
- .addColumn('name', 'varchar(255)', (col) => col.notNull())
53
- .addColumn('email', 'varchar(255)', (col) => col.notNull())
54
- .execute();
55
- });
56
-
57
- beforeEach(() => {
58
- storage = new InMemoryStorage();
59
- studio = new Studio({
60
- monitoring: {
61
- storage,
62
- },
63
- data: {
64
- db,
65
- cursor: { field: 'id', direction: Direction.Desc },
66
- },
67
- });
68
- });
69
-
70
- afterEach(async () => {
71
- studio.destroy();
72
- await db.deleteFrom('studioTestUsers').execute();
73
- });
74
-
75
- afterAll(async () => {
76
- await db.schema.dropTable('studio_test_users').ifExists().execute();
77
- await db.destroy();
78
- });
79
-
80
- describe('configuration', () => {
81
- it('should expose path property with default value', () => {
82
- expect(studio.path).toBe('/__studio');
83
- });
84
-
85
- it('should allow custom path', () => {
86
- const customStudio = new Studio({
87
- monitoring: { storage },
88
- data: { db, cursor: { field: 'id', direction: Direction.Desc } },
89
- path: '/custom-studio',
90
- });
91
- expect(customStudio.path).toBe('/custom-studio');
92
- customStudio.destroy();
93
- });
94
-
95
- it('should expose enabled property', () => {
96
- expect(studio.enabled).toBe(true);
97
- });
98
-
99
- it('should allow disabling studio', () => {
100
- const disabledStudio = new Studio({
101
- monitoring: { storage },
102
- data: { db, cursor: { field: 'id', direction: Direction.Desc } },
103
- enabled: false,
104
- });
105
- expect(disabledStudio.enabled).toBe(false);
106
- disabledStudio.destroy();
107
- });
108
-
109
- it('should expose recordBody property', () => {
110
- expect(studio.recordBody).toBe(true);
111
- });
112
-
113
- it('should expose maxBodySize property', () => {
114
- expect(studio.maxBodySize).toBe(64 * 1024); // 64KB default
115
- });
116
-
117
- it('should expose data browser instance', () => {
118
- expect(studio.data).toBeDefined();
119
- });
120
- });
121
-
122
- describe('monitoring - recording', () => {
123
- it('should record requests', async () => {
124
- const requestId = await studio.recordRequest({
125
- method: 'GET',
126
- path: '/api/users',
127
- status: 200,
128
- duration: 100,
129
- });
130
-
131
- expect(requestId).toBeDefined();
132
- expect(typeof requestId).toBe('string');
133
- });
134
-
135
- it('should record log entries in batch', async () => {
136
- await studio.log([
137
- { level: 'info', message: 'Test log 1' },
138
- { level: 'error', message: 'Test log 2' },
139
- ]);
140
-
141
- const logs = await studio.getLogs();
142
- expect(logs.length).toBeGreaterThanOrEqual(2);
143
- });
144
-
145
- it('should log debug messages', async () => {
146
- await studio.debug('Debug message', { foo: 'bar' });
147
-
148
- const logs = await studio.getLogs({ level: 'debug' });
149
- expect(logs.some((l) => l.message === 'Debug message')).toBe(true);
150
- });
151
-
152
- it('should log info messages', async () => {
153
- await studio.info('Info message', { key: 'value' });
154
-
155
- const logs = await studio.getLogs({ level: 'info' });
156
- expect(logs.some((l) => l.message === 'Info message')).toBe(true);
157
- });
158
-
159
- it('should log warning messages', async () => {
160
- await studio.warn('Warning message');
161
-
162
- const logs = await studio.getLogs({ level: 'warn' });
163
- expect(logs.some((l) => l.message === 'Warning message')).toBe(true);
164
- });
165
-
166
- it('should log error messages', async () => {
167
- await studio.error('Error message');
168
-
169
- const logs = await studio.getLogs({ level: 'error' });
170
- expect(logs.some((l) => l.message === 'Error message')).toBe(true);
171
- });
172
-
173
- it('should record exceptions', async () => {
174
- const error = new Error('Test error');
175
- await studio.exception(error);
176
-
177
- const exceptions = await studio.getExceptions();
178
- expect(exceptions.some((e) => e.message === 'Test error')).toBe(true);
179
- });
180
-
181
- it('should record exceptions with request ID', async () => {
182
- const requestId = await studio.recordRequest({
183
- method: 'GET',
184
- path: '/api/users',
185
- status: 500,
186
- duration: 50,
187
- });
188
-
189
- await studio.exception(new Error('Request error'), requestId);
190
-
191
- const exceptions = await studio.getExceptions();
192
- expect(exceptions.some((e) => e.requestId === requestId)).toBe(true);
193
- });
194
- });
195
-
196
- describe('monitoring - querying', () => {
197
- beforeEach(async () => {
198
- // Record some test data
199
- await studio.recordRequest({
200
- method: 'GET',
201
- path: '/api/users',
202
- status: 200,
203
- duration: 100,
204
- });
205
- await studio.recordRequest({
206
- method: 'POST',
207
- path: '/api/users',
208
- status: 201,
209
- duration: 150,
210
- });
211
- });
212
-
213
- it('should get all requests', async () => {
214
- const requests = await studio.getRequests();
215
- expect(requests.length).toBeGreaterThanOrEqual(2);
216
- });
217
-
218
- it('should get requests with limit', async () => {
219
- const requests = await studio.getRequests({ limit: 1 });
220
- expect(requests.length).toBe(1);
221
- });
222
-
223
- it('should get a single request by ID', async () => {
224
- const requestId = await studio.recordRequest({
225
- method: 'DELETE',
226
- path: '/api/users/123',
227
- status: 204,
228
- duration: 50,
229
- });
230
-
231
- const request = await studio.getRequest(requestId);
232
- expect(request).toBeDefined();
233
- expect(request?.method).toBe('DELETE');
234
- });
235
-
236
- it('should return null for non-existent request', async () => {
237
- const request = await studio.getRequest('non-existent-id');
238
- expect(request).toBeNull();
239
- });
240
-
241
- it('should get exceptions', async () => {
242
- await studio.exception(new Error('Test exception'));
243
- const exceptions = await studio.getExceptions();
244
- expect(exceptions.length).toBeGreaterThanOrEqual(1);
245
- });
246
-
247
- it('should get a single exception by ID', async () => {
248
- await studio.exception(new Error('Another exception'));
249
- const exceptions = await studio.getExceptions();
250
- const first = exceptions[0];
251
-
252
- const exception = await studio.getException(first.id);
253
- expect(exception).toBeDefined();
254
- });
255
-
256
- it('should return null for non-existent exception', async () => {
257
- const exception = await studio.getException('non-existent-id');
258
- expect(exception).toBeNull();
259
- });
260
-
261
- it('should get logs', async () => {
262
- await studio.info('Log entry');
263
- const logs = await studio.getLogs();
264
- expect(logs.length).toBeGreaterThanOrEqual(1);
265
- });
266
-
267
- it('should get storage stats', async () => {
268
- const stats = await studio.getStats();
269
- expect(stats).toBeDefined();
270
- expect(typeof stats.requests).toBe('number');
271
- });
272
- });
273
-
274
- describe('metrics', () => {
275
- it('should get aggregated metrics', () => {
276
- const metrics = studio.getMetrics();
277
- expect(metrics).toBeDefined();
278
- expect(typeof metrics.totalRequests).toBe('number');
279
- });
280
-
281
- it('should get endpoint metrics', () => {
282
- const endpoints = studio.getEndpointMetrics();
283
- expect(Array.isArray(endpoints)).toBe(true);
284
- });
285
-
286
- it('should get status distribution', () => {
287
- const distribution = studio.getStatusDistribution();
288
- expect(distribution).toBeDefined();
289
- });
290
-
291
- it('should reset metrics', () => {
292
- // Just ensure it doesn't throw
293
- studio.resetMetrics();
294
- });
295
- });
296
-
297
- describe('path ignoring', () => {
298
- it('should ignore studio paths', () => {
299
- expect(studio.shouldIgnore('/__studio')).toBe(true);
300
- expect(studio.shouldIgnore('/__studio/api/requests')).toBe(true);
301
- });
302
-
303
- it('should not ignore other paths', () => {
304
- expect(studio.shouldIgnore('/api/users')).toBe(false);
305
- });
306
-
307
- it('should respect custom ignore patterns', () => {
308
- const studioWithPatterns = new Studio({
309
- monitoring: {
310
- storage,
311
- ignorePatterns: ['/health', '/metrics'],
312
- },
313
- data: {
314
- db,
315
- cursor: { field: 'id', direction: Direction.Desc },
316
- },
317
- });
318
-
319
- expect(studioWithPatterns.shouldIgnore('/health')).toBe(true);
320
- expect(studioWithPatterns.shouldIgnore('/api/users')).toBe(false);
321
-
322
- studioWithPatterns.destroy();
323
- });
324
- });
325
-
326
- describe('pruning', () => {
327
- it('should prune old entries', async () => {
328
- // Record some data
329
- await studio.recordRequest({
330
- method: 'GET',
331
- path: '/old-request',
332
- status: 200,
333
- duration: 100,
334
- });
335
-
336
- // Prune entries older than now (should remove all)
337
- const futureDate = new Date(Date.now() + 1000);
338
- const pruned = await studio.prune(futureDate);
339
-
340
- expect(typeof pruned).toBe('number');
341
- });
342
- });
343
-
344
- describe('WebSocket management', () => {
345
- it('should add WebSocket clients', () => {
346
- const mockWs = {
347
- send: vi.fn(),
348
- readyState: 1, // WebSocket.OPEN
349
- } as any;
350
-
351
- // Should not throw
352
- studio.addWsClient(mockWs);
353
- });
354
-
355
- it('should remove WebSocket clients', () => {
356
- const mockWs = {
357
- send: vi.fn(),
358
- readyState: 1,
359
- } as any;
360
-
361
- studio.addWsClient(mockWs);
362
- studio.removeWsClient(mockWs);
363
- });
364
-
365
- it('should broadcast events to connected clients', () => {
366
- const mockWs = {
367
- send: vi.fn(),
368
- readyState: 1,
369
- } as any;
370
-
371
- studio.addWsClient(mockWs);
372
-
373
- studio.broadcast({
374
- type: 'request',
375
- payload: { id: '123' },
376
- timestamp: Date.now(),
377
- });
378
-
379
- expect(mockWs.send).toHaveBeenCalled();
380
- });
381
-
382
- it('should remove clients that fail to receive', () => {
383
- const mockWs = {
384
- send: vi.fn().mockImplementation(() => {
385
- throw new Error('Connection closed');
386
- }),
387
- readyState: 1,
388
- } as any;
389
-
390
- studio.addWsClient(mockWs);
391
-
392
- // Broadcast should not throw
393
- studio.broadcast({
394
- type: 'log',
395
- payload: { message: 'test' },
396
- timestamp: Date.now(),
397
- });
398
-
399
- // Client should be removed after failed send
400
- // (internal state, hard to verify directly)
401
- });
402
- });
403
-
404
- describe('destroy', () => {
405
- it('should clean up resources', () => {
406
- const s = new Studio({
407
- monitoring: { storage },
408
- data: {
409
- db,
410
- cursor: { field: 'id', direction: Direction.Desc },
411
- },
412
- });
413
-
414
- // Add a WebSocket client
415
- const mockWs = { send: vi.fn() } as any;
416
- s.addWsClient(mockWs);
417
-
418
- // Should not throw
419
- s.destroy();
420
- });
421
- });
422
-
423
- describe('data browser integration', () => {
424
- it('should access the underlying database through data browser', async () => {
425
- // Insert test data
426
- await db
427
- .insertInto('studioTestUsers')
428
- .values({ name: 'Test User', email: 'test@example.com' })
429
- .execute();
430
-
431
- // Query through data browser
432
- const result = await studio.data.query({
433
- table: 'studio_test_users',
434
- pageSize: 10,
435
- });
436
-
437
- expect(result.rows.length).toBeGreaterThanOrEqual(1);
438
- });
439
-
440
- it('should get schema through data browser', async () => {
441
- const schema = await studio.data.getSchema();
442
-
443
- const tableNames = schema.tables.map((t) => t.name);
444
- expect(tableNames).toContain('studio_test_users');
445
- });
446
- });
447
- });
@@ -1,170 +0,0 @@
1
- import type { Kysely, SelectQueryBuilder } from 'kysely';
2
- import {
3
- type CursorConfig,
4
- type DataBrowserOptions,
5
- Direction,
6
- type QueryOptions,
7
- type QueryResult,
8
- type SchemaInfo,
9
- type TableInfo,
10
- } from '../types';
11
- import { applyFilters, applySorting } from './filtering';
12
- import { introspectSchema } from './introspection';
13
- import { decodeCursor, encodeCursor } from './pagination';
14
-
15
- /**
16
- * Database browser for introspecting and querying PostgreSQL databases.
17
- *
18
- * @example
19
- * ```typescript
20
- * const browser = new DataBrowser({
21
- * db: kyselyInstance,
22
- * cursor: { field: 'id', direction: Direction.Desc },
23
- * });
24
- *
25
- * const schema = await browser.getSchema();
26
- * const result = await browser.query({ table: 'users', pageSize: 20 });
27
- * ```
28
- */
29
- export class DataBrowser<DB = unknown> {
30
- private db: Kysely<DB>;
31
- private options: Required<DataBrowserOptions<DB>>;
32
- private schemaCache: SchemaInfo | null = null;
33
- private schemaCacheExpiry = 0;
34
- private readonly CACHE_TTL_MS = 60 * 1000; // 1 minute
35
-
36
- constructor(options: Required<DataBrowserOptions<DB>>) {
37
- this.db = options.db;
38
- this.options = options;
39
- }
40
-
41
- // ============================================
42
- // Schema Introspection
43
- // ============================================
44
-
45
- /**
46
- * Get the database schema information.
47
- * Results are cached for 1 minute.
48
- *
49
- * @param forceRefresh - Force a refresh of the cache
50
- */
51
- async getSchema(forceRefresh = false): Promise<SchemaInfo> {
52
- const now = Date.now();
53
-
54
- if (!forceRefresh && this.schemaCache && now < this.schemaCacheExpiry) {
55
- return this.schemaCache;
56
- }
57
-
58
- const schema = await introspectSchema(this.db, this.options.excludeTables);
59
-
60
- this.schemaCache = schema;
61
- this.schemaCacheExpiry = now + this.CACHE_TTL_MS;
62
-
63
- return schema;
64
- }
65
-
66
- /**
67
- * Get information about a specific table.
68
- */
69
- async getTableInfo(tableName: string): Promise<TableInfo | null> {
70
- const schema = await this.getSchema();
71
- return schema.tables.find((t) => t.name === tableName) ?? null;
72
- }
73
-
74
- // ============================================
75
- // Data Querying
76
- // ============================================
77
-
78
- /**
79
- * Query table data with pagination, filtering, and sorting.
80
- */
81
- async query(options: QueryOptions): Promise<QueryResult> {
82
- const tableInfo = await this.getTableInfo(options.table);
83
-
84
- if (!tableInfo) {
85
- throw new Error(`Table '${options.table}' not found`);
86
- }
87
-
88
- const cursorConfig = this.getCursorConfig(options.table);
89
- const pageSize = Math.min(
90
- options.pageSize ?? this.options.defaultPageSize,
91
- 100,
92
- );
93
-
94
- // Build base query selecting all columns
95
- let query = this.db
96
- .selectFrom(options.table as any)
97
- .selectAll() as SelectQueryBuilder<any, any, any>;
98
-
99
- // Apply filters if provided
100
- if (options.filters && options.filters.length > 0) {
101
- query = applyFilters(query, options.filters, tableInfo);
102
- }
103
-
104
- // Apply sorting if provided, otherwise use cursor field
105
- if (options.sort && options.sort.length > 0) {
106
- query = applySorting(query, options.sort, tableInfo);
107
- } else {
108
- query = query.orderBy(cursorConfig.field as any, cursorConfig.direction);
109
- }
110
-
111
- // Handle cursor-based pagination
112
- if (options.cursor) {
113
- const cursorValue = decodeCursor(options.cursor);
114
- const operator = cursorConfig.direction === Direction.Asc ? '>' : '<';
115
- query = query.where(cursorConfig.field as any, operator, cursorValue);
116
- }
117
-
118
- // Fetch one extra row to determine if there are more results
119
- const rows = await query.limit(pageSize + 1).execute();
120
-
121
- const hasMore = rows.length > pageSize;
122
- const resultRows = hasMore ? rows.slice(0, pageSize) : rows;
123
-
124
- // Generate cursors
125
- let nextCursor: string | null = null;
126
- let prevCursor: string | null = null;
127
-
128
- if (hasMore && resultRows.length > 0) {
129
- const lastRow = resultRows[resultRows.length - 1];
130
- if (lastRow) {
131
- nextCursor = encodeCursor(lastRow[cursorConfig.field]);
132
- }
133
- }
134
-
135
- // For prev cursor, we need to know if there are previous results
136
- // This would require a separate query, so we'll only set it if there's an input cursor
137
- if (options.cursor && resultRows.length > 0) {
138
- const firstRow = resultRows[0];
139
- if (firstRow) {
140
- prevCursor = encodeCursor(firstRow[cursorConfig.field]);
141
- }
142
- }
143
-
144
- return {
145
- rows: resultRows,
146
- hasMore,
147
- nextCursor,
148
- prevCursor,
149
- };
150
- }
151
-
152
- // ============================================
153
- // Configuration Access
154
- // ============================================
155
-
156
- /**
157
- * Get the cursor configuration for a table.
158
- * Returns the table-specific config if defined, otherwise the default.
159
- */
160
- getCursorConfig(tableName: string): CursorConfig {
161
- return this.options.tableCursors[tableName] ?? this.options.cursor;
162
- }
163
-
164
- /**
165
- * Get the underlying Kysely database instance.
166
- */
167
- get database(): Kysely<DB> {
168
- return this.db;
169
- }
170
- }