@mountsqli/studio 0.1.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.
- package/dist/index.d.ts +45 -0
- package/dist/index.js +959 -0
- package/package.json +36 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Studio types.
|
|
3
|
+
*/
|
|
4
|
+
interface MountSQLiConfig {
|
|
5
|
+
dialect: 'postgresql' | 'mysql' | 'sqlite' | 'mongodb';
|
|
6
|
+
connection: string | Record<string, unknown>;
|
|
7
|
+
schema: string;
|
|
8
|
+
out: string;
|
|
9
|
+
studio?: {
|
|
10
|
+
port?: number;
|
|
11
|
+
host?: string;
|
|
12
|
+
auth?: {
|
|
13
|
+
username: string;
|
|
14
|
+
password: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
casing?: 'camelCase' | 'snake_case';
|
|
18
|
+
logger?: boolean;
|
|
19
|
+
migrationsTable?: string;
|
|
20
|
+
}
|
|
21
|
+
interface StudioConfig {
|
|
22
|
+
config: MountSQLiConfig;
|
|
23
|
+
port: number;
|
|
24
|
+
host: string;
|
|
25
|
+
}
|
|
26
|
+
interface StudioOptions {
|
|
27
|
+
config: MountSQLiConfig;
|
|
28
|
+
port?: number;
|
|
29
|
+
host?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* API server — handles HTTP requests for the studio dashboard.
|
|
34
|
+
* Serves the SPA and provides REST endpoints for data operations.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
declare function mountStudio(options: StudioOptions): (req: any, res: any, next?: any) => Promise<any>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* startStudio — standalone server launcher.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
declare function startStudio(options: StudioOptions): Promise<void>;
|
|
44
|
+
|
|
45
|
+
export { type StudioConfig, type StudioOptions, mountStudio, startStudio };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
// src/ui/dashboard.ts
|
|
2
|
+
function getDashboardHTML() {
|
|
3
|
+
return `<!DOCTYPE html>
|
|
4
|
+
<html lang="en">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="UTF-8">
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
8
|
+
<title>MountSQLi Studio</title>
|
|
9
|
+
<style>
|
|
10
|
+
${getCSS()}
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="app">
|
|
15
|
+
<nav id="sidebar">
|
|
16
|
+
<div class="logo">
|
|
17
|
+
<span class="logo-icon">\u25C6</span>
|
|
18
|
+
<span class="logo-text">MountSQLi</span>
|
|
19
|
+
<span class="logo-badge">Studio</span>
|
|
20
|
+
</div>
|
|
21
|
+
<ul class="nav-links">
|
|
22
|
+
<li><a href="#" data-page="tables" class="active">\u{1F4CA} Tables</a></li>
|
|
23
|
+
<li><a href="#" data-page="query">\u{1F4BB} Query</a></li>
|
|
24
|
+
<li><a href="#" data-page="erd">\u{1F517} ER Diagram</a></li>
|
|
25
|
+
<li><a href="#" data-page="migrations">\u{1F4E6} Migrations</a></li>
|
|
26
|
+
<li><a href="#" data-page="settings">\u2699\uFE0F Settings</a></li>
|
|
27
|
+
</ul>
|
|
28
|
+
<div class="nav-footer">
|
|
29
|
+
<div id="connection-status" class="status-badge status-ok">\u25CF Connected</div>
|
|
30
|
+
</div>
|
|
31
|
+
</nav>
|
|
32
|
+
<main id="content">
|
|
33
|
+
<div id="page-tables" class="page active"></div>
|
|
34
|
+
<div id="page-query" class="page"></div>
|
|
35
|
+
<div id="page-erd" class="page"></div>
|
|
36
|
+
<div id="page-migrations" class="page"></div>
|
|
37
|
+
<div id="page-settings" class="page"></div>
|
|
38
|
+
</main>
|
|
39
|
+
</div>
|
|
40
|
+
<script>
|
|
41
|
+
${getJS()}
|
|
42
|
+
</script>
|
|
43
|
+
</body>
|
|
44
|
+
</html>`;
|
|
45
|
+
}
|
|
46
|
+
function getCSS() {
|
|
47
|
+
return `
|
|
48
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
49
|
+
|
|
50
|
+
:root {
|
|
51
|
+
--bg: #0f1117;
|
|
52
|
+
--bg-surface: #161822;
|
|
53
|
+
--bg-elevated: #1e2030;
|
|
54
|
+
--bg-hover: #252840;
|
|
55
|
+
--border: #2e3148;
|
|
56
|
+
--text: #e0e0e0;
|
|
57
|
+
--text-dim: #8888a0;
|
|
58
|
+
--accent: #7c5cfc;
|
|
59
|
+
--accent-hover: #9b7ffd;
|
|
60
|
+
--success: #4ade80;
|
|
61
|
+
--warning: #fbbf24;
|
|
62
|
+
--error: #f87171;
|
|
63
|
+
--radius: 8px;
|
|
64
|
+
--font: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
65
|
+
--font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
body {
|
|
69
|
+
font-family: var(--font-ui);
|
|
70
|
+
background: var(--bg);
|
|
71
|
+
color: var(--text);
|
|
72
|
+
height: 100vh;
|
|
73
|
+
overflow: hidden;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#app {
|
|
77
|
+
display: flex;
|
|
78
|
+
height: 100vh;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Sidebar */
|
|
82
|
+
#sidebar {
|
|
83
|
+
width: 220px;
|
|
84
|
+
background: var(--bg-surface);
|
|
85
|
+
border-right: 1px solid var(--border);
|
|
86
|
+
display: flex;
|
|
87
|
+
flex-direction: column;
|
|
88
|
+
padding: 16px 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.logo {
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: 8px;
|
|
95
|
+
padding: 0 20px 20px;
|
|
96
|
+
border-bottom: 1px solid var(--border);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.logo-icon { font-size: 20px; color: var(--accent); }
|
|
100
|
+
.logo-text { font-weight: 700; font-size: 15px; }
|
|
101
|
+
.logo-badge {
|
|
102
|
+
font-size: 10px;
|
|
103
|
+
background: var(--accent);
|
|
104
|
+
color: white;
|
|
105
|
+
padding: 2px 6px;
|
|
106
|
+
border-radius: 4px;
|
|
107
|
+
font-weight: 600;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.nav-links {
|
|
111
|
+
list-style: none;
|
|
112
|
+
padding: 12px 0;
|
|
113
|
+
flex: 1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.nav-links a {
|
|
117
|
+
display: block;
|
|
118
|
+
padding: 10px 20px;
|
|
119
|
+
color: var(--text-dim);
|
|
120
|
+
text-decoration: none;
|
|
121
|
+
font-size: 13px;
|
|
122
|
+
transition: all 0.15s;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.nav-links a:hover { background: var(--bg-hover); color: var(--text); }
|
|
126
|
+
.nav-links a.active { color: var(--accent); background: var(--bg-hover); border-right: 2px solid var(--accent); }
|
|
127
|
+
|
|
128
|
+
.nav-footer {
|
|
129
|
+
padding: 12px 20px;
|
|
130
|
+
border-top: 1px solid var(--border);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.status-badge {
|
|
134
|
+
font-size: 11px;
|
|
135
|
+
padding: 4px 8px;
|
|
136
|
+
border-radius: 4px;
|
|
137
|
+
display: inline-block;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.status-ok { color: var(--success); }
|
|
141
|
+
.status-err { color: var(--error); }
|
|
142
|
+
|
|
143
|
+
/* Main content */
|
|
144
|
+
#content {
|
|
145
|
+
flex: 1;
|
|
146
|
+
overflow: auto;
|
|
147
|
+
padding: 24px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.page { display: none; }
|
|
151
|
+
.page.active { display: block; }
|
|
152
|
+
|
|
153
|
+
/* Tables page */
|
|
154
|
+
.table-grid {
|
|
155
|
+
display: grid;
|
|
156
|
+
grid-template-columns: 220px 1fr;
|
|
157
|
+
gap: 16px;
|
|
158
|
+
height: calc(100vh - 120px);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.table-list {
|
|
162
|
+
background: var(--bg-surface);
|
|
163
|
+
border-radius: var(--radius);
|
|
164
|
+
border: 1px solid var(--border);
|
|
165
|
+
overflow-y: auto;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.table-list-header {
|
|
169
|
+
padding: 12px 16px;
|
|
170
|
+
font-weight: 600;
|
|
171
|
+
font-size: 13px;
|
|
172
|
+
border-bottom: 1px solid var(--border);
|
|
173
|
+
color: var(--text-dim);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.table-item {
|
|
177
|
+
padding: 10px 16px;
|
|
178
|
+
cursor: pointer;
|
|
179
|
+
font-size: 13px;
|
|
180
|
+
border-bottom: 1px solid var(--border);
|
|
181
|
+
transition: background 0.1s;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.table-item:hover { background: var(--bg-hover); }
|
|
185
|
+
.table-item.active { color: var(--accent); background: var(--bg-hover); }
|
|
186
|
+
|
|
187
|
+
/* Data table */
|
|
188
|
+
.data-panel {
|
|
189
|
+
background: var(--bg-surface);
|
|
190
|
+
border-radius: var(--radius);
|
|
191
|
+
border: 1px solid var(--border);
|
|
192
|
+
overflow: hidden;
|
|
193
|
+
display: flex;
|
|
194
|
+
flex-direction: column;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.data-header {
|
|
198
|
+
padding: 12px 16px;
|
|
199
|
+
border-bottom: 1px solid var(--border);
|
|
200
|
+
display: flex;
|
|
201
|
+
justify-content: space-between;
|
|
202
|
+
align-items: center;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.data-header h3 { font-size: 14px; font-weight: 600; }
|
|
206
|
+
.data-meta { font-size: 12px; color: var(--text-dim); }
|
|
207
|
+
|
|
208
|
+
.data-table-wrap {
|
|
209
|
+
flex: 1;
|
|
210
|
+
overflow: auto;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
table {
|
|
214
|
+
width: 100%;
|
|
215
|
+
border-collapse: collapse;
|
|
216
|
+
font-size: 13px;
|
|
217
|
+
font-family: var(--font);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
th {
|
|
221
|
+
position: sticky;
|
|
222
|
+
top: 0;
|
|
223
|
+
background: var(--bg-elevated);
|
|
224
|
+
padding: 10px 12px;
|
|
225
|
+
text-align: left;
|
|
226
|
+
font-weight: 600;
|
|
227
|
+
font-size: 11px;
|
|
228
|
+
text-transform: uppercase;
|
|
229
|
+
color: var(--text-dim);
|
|
230
|
+
border-bottom: 1px solid var(--border);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
td {
|
|
234
|
+
padding: 8px 12px;
|
|
235
|
+
border-bottom: 1px solid var(--border);
|
|
236
|
+
max-width: 300px;
|
|
237
|
+
overflow: hidden;
|
|
238
|
+
text-overflow: ellipsis;
|
|
239
|
+
white-space: nowrap;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
tr:hover td { background: var(--bg-hover); }
|
|
243
|
+
|
|
244
|
+
/* Query console */
|
|
245
|
+
.query-editor {
|
|
246
|
+
background: var(--bg-elevated);
|
|
247
|
+
border: 1px solid var(--border);
|
|
248
|
+
border-radius: var(--radius);
|
|
249
|
+
padding: 16px;
|
|
250
|
+
font-family: var(--font);
|
|
251
|
+
font-size: 14px;
|
|
252
|
+
color: var(--text);
|
|
253
|
+
width: 100%;
|
|
254
|
+
min-height: 120px;
|
|
255
|
+
resize: vertical;
|
|
256
|
+
outline: none;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.query-editor:focus { border-color: var(--accent); }
|
|
260
|
+
|
|
261
|
+
.query-actions {
|
|
262
|
+
margin-top: 12px;
|
|
263
|
+
display: flex;
|
|
264
|
+
gap: 8px;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.btn {
|
|
268
|
+
padding: 8px 16px;
|
|
269
|
+
border-radius: var(--radius);
|
|
270
|
+
border: 1px solid var(--border);
|
|
271
|
+
background: var(--bg-elevated);
|
|
272
|
+
color: var(--text);
|
|
273
|
+
cursor: pointer;
|
|
274
|
+
font-size: 13px;
|
|
275
|
+
transition: all 0.15s;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.btn:hover { background: var(--bg-hover); border-color: var(--accent); }
|
|
279
|
+
.btn-primary { background: var(--accent); border-color: var(--accent); color: white; }
|
|
280
|
+
.btn-primary:hover { background: var(--accent-hover); }
|
|
281
|
+
|
|
282
|
+
.query-result {
|
|
283
|
+
margin-top: 16px;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.query-stats {
|
|
287
|
+
font-size: 12px;
|
|
288
|
+
color: var(--text-dim);
|
|
289
|
+
margin-bottom: 8px;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* ER Diagram */
|
|
293
|
+
.erd-container {
|
|
294
|
+
background: var(--bg-surface);
|
|
295
|
+
border-radius: var(--radius);
|
|
296
|
+
border: 1px solid var(--border);
|
|
297
|
+
height: calc(100vh - 120px);
|
|
298
|
+
overflow: auto;
|
|
299
|
+
padding: 24px;
|
|
300
|
+
display: flex;
|
|
301
|
+
flex-wrap: wrap;
|
|
302
|
+
gap: 24px;
|
|
303
|
+
align-items: flex-start;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.erd-table {
|
|
307
|
+
background: var(--bg-elevated);
|
|
308
|
+
border: 1px solid var(--border);
|
|
309
|
+
border-radius: var(--radius);
|
|
310
|
+
min-width: 200px;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.erd-table-header {
|
|
314
|
+
padding: 10px 14px;
|
|
315
|
+
font-weight: 600;
|
|
316
|
+
font-size: 13px;
|
|
317
|
+
border-bottom: 1px solid var(--border);
|
|
318
|
+
color: var(--accent);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.erd-column {
|
|
322
|
+
padding: 6px 14px;
|
|
323
|
+
font-size: 12px;
|
|
324
|
+
font-family: var(--font);
|
|
325
|
+
display: flex;
|
|
326
|
+
justify-content: space-between;
|
|
327
|
+
gap: 8px;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.erd-column:hover { background: var(--bg-hover); }
|
|
331
|
+
.erd-col-name { color: var(--text); }
|
|
332
|
+
.erd-col-type { color: var(--text-dim); font-size: 11px; }
|
|
333
|
+
.erd-col-pk { color: var(--warning); }
|
|
334
|
+
|
|
335
|
+
/* Empty state */
|
|
336
|
+
.empty-state {
|
|
337
|
+
display: flex;
|
|
338
|
+
flex-direction: column;
|
|
339
|
+
align-items: center;
|
|
340
|
+
justify-content: center;
|
|
341
|
+
height: 300px;
|
|
342
|
+
color: var(--text-dim);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.empty-state-icon { font-size: 48px; margin-bottom: 12px; }
|
|
346
|
+
.empty-state-text { font-size: 14px; }
|
|
347
|
+
|
|
348
|
+
/* Migrations */
|
|
349
|
+
.migration-item {
|
|
350
|
+
display: flex;
|
|
351
|
+
align-items: center;
|
|
352
|
+
gap: 12px;
|
|
353
|
+
padding: 12px 16px;
|
|
354
|
+
background: var(--bg-surface);
|
|
355
|
+
border: 1px solid var(--border);
|
|
356
|
+
border-radius: var(--radius);
|
|
357
|
+
margin-bottom: 8px;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.migration-icon { font-size: 16px; }
|
|
361
|
+
.migration-name { font-size: 13px; font-family: var(--font); flex: 1; }
|
|
362
|
+
.migration-status {
|
|
363
|
+
font-size: 11px;
|
|
364
|
+
padding: 2px 8px;
|
|
365
|
+
border-radius: 4px;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.migration-applied { color: var(--success); background: rgba(74,222,128,0.1); }
|
|
369
|
+
.migration-pending { color: var(--warning); background: rgba(251,191,36,0.1); }
|
|
370
|
+
|
|
371
|
+
/* Settings */
|
|
372
|
+
.settings-group {
|
|
373
|
+
background: var(--bg-surface);
|
|
374
|
+
border: 1px solid var(--border);
|
|
375
|
+
border-radius: var(--radius);
|
|
376
|
+
padding: 20px;
|
|
377
|
+
margin-bottom: 16px;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.settings-group h3 {
|
|
381
|
+
font-size: 14px;
|
|
382
|
+
margin-bottom: 16px;
|
|
383
|
+
color: var(--text-dim);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.setting-row {
|
|
387
|
+
display: flex;
|
|
388
|
+
align-items: center;
|
|
389
|
+
margin-bottom: 12px;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.setting-label {
|
|
393
|
+
width: 140px;
|
|
394
|
+
font-size: 13px;
|
|
395
|
+
color: var(--text-dim);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.setting-value {
|
|
399
|
+
font-size: 13px;
|
|
400
|
+
font-family: var(--font);
|
|
401
|
+
}
|
|
402
|
+
`;
|
|
403
|
+
}
|
|
404
|
+
function getJS() {
|
|
405
|
+
return `
|
|
406
|
+
// ---- Navigation ----
|
|
407
|
+
document.querySelectorAll('.nav-links a').forEach(link => {
|
|
408
|
+
link.addEventListener('click', (e) => {
|
|
409
|
+
e.preventDefault();
|
|
410
|
+
const page = link.dataset.page;
|
|
411
|
+
document.querySelectorAll('.nav-links a').forEach(l => l.classList.remove('active'));
|
|
412
|
+
link.classList.add('active');
|
|
413
|
+
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
|
414
|
+
document.getElementById('page-' + page)?.classList.add('active');
|
|
415
|
+
loadPage(page);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ---- Page loaders ----
|
|
420
|
+
const loaded = {};
|
|
421
|
+
function loadPage(page) {
|
|
422
|
+
if (loaded[page]) return;
|
|
423
|
+
loaded[page] = true;
|
|
424
|
+
switch (page) {
|
|
425
|
+
case 'tables': loadTables(); break;
|
|
426
|
+
case 'query': loadQuery(); break;
|
|
427
|
+
case 'erd': loadERD(); break;
|
|
428
|
+
case 'migrations': loadMigrations(); break;
|
|
429
|
+
case 'settings': loadSettings(); break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---- Tables ----
|
|
434
|
+
async function loadTables() {
|
|
435
|
+
const el = document.getElementById('page-tables');
|
|
436
|
+
try {
|
|
437
|
+
const res = await fetch('/api/tables');
|
|
438
|
+
const data = await res.json();
|
|
439
|
+
const tables = data.tables || [];
|
|
440
|
+
|
|
441
|
+
el.innerHTML = \`
|
|
442
|
+
<h2 style="margin-bottom:16px;font-size:18px">\u{1F4CA} Tables</h2>
|
|
443
|
+
<div class="table-grid">
|
|
444
|
+
<div class="table-list">
|
|
445
|
+
<div class="table-list-header">Tables (\${tables.length})</div>
|
|
446
|
+
\${tables.map(t => \`
|
|
447
|
+
<div class="table-item" data-table="\${t}" onclick="loadTableData('\${t}')">\${t}</div>
|
|
448
|
+
\`).join('')}
|
|
449
|
+
</div>
|
|
450
|
+
<div class="data-panel">
|
|
451
|
+
<div class="data-header">
|
|
452
|
+
<h3 id="table-title">Select a table</h3>
|
|
453
|
+
<span class="data-meta" id="table-meta"></span>
|
|
454
|
+
</div>
|
|
455
|
+
<div class="data-table-wrap" id="table-data">
|
|
456
|
+
<div class="empty-state">
|
|
457
|
+
<div class="empty-state-icon">\u{1F4CB}</div>
|
|
458
|
+
<div class="empty-state-text">Select a table to view its data</div>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
\`;
|
|
464
|
+
} catch (err) {
|
|
465
|
+
el.innerHTML = '<div class="empty-state"><div class="empty-state-icon">\u274C</div><div class="empty-state-text">Failed to load tables</div></div>';
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function loadTableData(tableName) {
|
|
470
|
+
document.querySelectorAll('.table-item').forEach(i => i.classList.remove('active'));
|
|
471
|
+
document.querySelector(\`.table-item[data-table="\${tableName}"]\`)?.classList.add('active');
|
|
472
|
+
document.getElementById('table-title').textContent = tableName;
|
|
473
|
+
|
|
474
|
+
const el = document.getElementById('table-data');
|
|
475
|
+
el.innerHTML = '<div class="empty-state"><div class="empty-state-text">Loading...</div></div>';
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const res = await fetch(\`/api/tables/\${tableName}\`);
|
|
479
|
+
const data = await res.json();
|
|
480
|
+
document.getElementById('table-meta').textContent = \`\${data.count} rows\`;
|
|
481
|
+
|
|
482
|
+
if (data.rows.length === 0) {
|
|
483
|
+
el.innerHTML = '<div class="empty-state"><div class="empty-state-icon">\u{1F4ED}</div><div class="empty-state-text">No rows</div></div>';
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
el.innerHTML = \`
|
|
488
|
+
<table>
|
|
489
|
+
<thead><tr>\${data.columns.map(c => \`<th>\${c}</th>\`).join('')}</tr></thead>
|
|
490
|
+
<tbody>\${data.rows.map(row => \`<tr>\${data.columns.map(c => \`<td title="\${String(row[c] ?? '')}">\${String(row[c] ?? 'NULL')}</td>\`).join('')}</tr>\`).join('')}</tbody>
|
|
491
|
+
</table>
|
|
492
|
+
\`;
|
|
493
|
+
} catch (err) {
|
|
494
|
+
el.innerHTML = '<div class="empty-state"><div class="empty-state-icon">\u274C</div><div class="empty-state-text">Failed to load data</div></div>';
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ---- Query console ----
|
|
499
|
+
function loadQuery() {
|
|
500
|
+
document.getElementById('page-query').innerHTML = \`
|
|
501
|
+
<h2 style="margin-bottom:16px;font-size:18px">\u{1F4BB} SQL Console</h2>
|
|
502
|
+
<textarea class="query-editor" id="sql-input" placeholder="SELECT * FROM users LIMIT 100">SELECT * FROM users LIMIT 100</textarea>
|
|
503
|
+
<div class="query-actions">
|
|
504
|
+
<button class="btn btn-primary" onclick="runQuery()">\u25B6 Run Query</button>
|
|
505
|
+
<button class="btn" onclick="document.getElementById('sql-input').value='';document.getElementById('query-result').innerHTML=''">Clear</button>
|
|
506
|
+
</div>
|
|
507
|
+
<div id="query-result" class="query-result"></div>
|
|
508
|
+
\`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function runQuery() {
|
|
512
|
+
const sql = document.getElementById('sql-input').value.trim();
|
|
513
|
+
if (!sql) return;
|
|
514
|
+
const el = document.getElementById('query-result');
|
|
515
|
+
el.innerHTML = '<div class="empty-state"><div class="empty-state-text">Running...</div></div>';
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const res = await fetch('/api/query', {
|
|
519
|
+
method: 'POST',
|
|
520
|
+
headers: { 'Content-Type': 'application/json' },
|
|
521
|
+
body: JSON.stringify({ sql })
|
|
522
|
+
});
|
|
523
|
+
const data = await res.json();
|
|
524
|
+
|
|
525
|
+
if (data.error) {
|
|
526
|
+
el.innerHTML = \`<div class="query-stats" style="color:var(--error)">Error: \${data.error}</div>\`;
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let html = \`<div class="query-stats">\${data.rowCount} row(s) in \${data.duration}ms</div>\`;
|
|
531
|
+
if (data.rows.length > 0) {
|
|
532
|
+
html += \`<table><thead><tr>\${data.columns.map(c => \`<th>\${c}</th>\`).join('')}</tr></thead><tbody>\${data.rows.map(row => \`<tr>\${data.columns.map(c => \`<td>\${String(row[c] ?? 'NULL')}</td>\`).join('')}</tr>\`).join('')}</tbody></table>\`;
|
|
533
|
+
}
|
|
534
|
+
el.innerHTML = html;
|
|
535
|
+
} catch (err) {
|
|
536
|
+
el.innerHTML = \`<div class="query-stats" style="color:var(--error)">Error: \${err.message}</div>\`;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ---- ER Diagram ----
|
|
541
|
+
async function loadERD() {
|
|
542
|
+
const el = document.getElementById('page-erd');
|
|
543
|
+
try {
|
|
544
|
+
const res = await fetch('/api/erd');
|
|
545
|
+
const data = await res.json();
|
|
546
|
+
|
|
547
|
+
if (data.tables.length === 0) {
|
|
548
|
+
el.innerHTML = '<div class="empty-state"><div class="empty-state-icon">\u{1F517}</div><div class="empty-state-text">No tables found</div></div>';
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
el.innerHTML = \`
|
|
553
|
+
<h2 style="margin-bottom:16px;font-size:18px">\u{1F517} ER Diagram</h2>
|
|
554
|
+
<div class="erd-container">
|
|
555
|
+
\${data.tables.map(t => \`
|
|
556
|
+
<div class="erd-table">
|
|
557
|
+
<div class="erd-table-header">\${t.name}</div>
|
|
558
|
+
\${t.columns.map(c => \`
|
|
559
|
+
<div class="erd-column">
|
|
560
|
+
<span class="erd-col-name \${c.isPrimaryKey ? 'erd-col-pk' : ''}">\${c.isPrimaryKey ? '\u{1F511} ' : ''}\${c.name}</span>
|
|
561
|
+
<span class="erd-col-type">\${c.type}\${c.nullable ? '' : ' NOT NULL'}</span>
|
|
562
|
+
</div>
|
|
563
|
+
\`).join('')}
|
|
564
|
+
</div>
|
|
565
|
+
\`).join('')}
|
|
566
|
+
</div>
|
|
567
|
+
\`;
|
|
568
|
+
} catch (err) {
|
|
569
|
+
el.innerHTML = '<div class="empty-state"><div class="empty-state-icon">\u274C</div><div class="empty-state-text">Failed to load ERD</div></div>';
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---- Migrations ----
|
|
574
|
+
async function loadMigrations() {
|
|
575
|
+
const el = document.getElementById('page-migrations');
|
|
576
|
+
el.innerHTML = '<div class="empty-state"><div class="empty-state-text">Loading migrations...</div></div>';
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
const res = await fetch('/api/migrations');
|
|
580
|
+
const data = await res.json();
|
|
581
|
+
|
|
582
|
+
if (data.applied.length === 0 && data.pending.length === 0) {
|
|
583
|
+
el.innerHTML = \`
|
|
584
|
+
<h2 style="margin-bottom:16px;font-size:18px">\u{1F4E6} Migrations</h2>
|
|
585
|
+
<div class="empty-state">
|
|
586
|
+
<div class="empty-state-icon">\u{1F4E6}</div>
|
|
587
|
+
<div class="empty-state-text">No migrations found. Run "mountsqli generate" to create one.</div>
|
|
588
|
+
</div>
|
|
589
|
+
\`;
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
el.innerHTML = \`
|
|
594
|
+
<h2 style="margin-bottom:16px;font-size:18px">\u{1F4E6} Migrations</h2>
|
|
595
|
+
\${data.applied.map(m => \`
|
|
596
|
+
<div class="migration-item">
|
|
597
|
+
<span class="migration-icon">\u2705</span>
|
|
598
|
+
<span class="migration-name">\${m.name}</span>
|
|
599
|
+
<span class="migration-status migration-applied">applied</span>
|
|
600
|
+
</div>
|
|
601
|
+
\`).join('')}
|
|
602
|
+
\${data.pending.map(m => \`
|
|
603
|
+
<div class="migration-item">
|
|
604
|
+
<span class="migration-icon">\u23F3</span>
|
|
605
|
+
<span class="migration-name">\${m}</span>
|
|
606
|
+
<span class="migration-status migration-pending">pending</span>
|
|
607
|
+
</div>
|
|
608
|
+
\`).join('')}
|
|
609
|
+
\`;
|
|
610
|
+
} catch (err) {
|
|
611
|
+
el.innerHTML = '<div class="empty-state"><div class="empty-state-icon">\u274C</div><div class="empty-state-text">Failed to load migrations</div></div>';
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---- Settings ----
|
|
616
|
+
function loadSettings() {
|
|
617
|
+
document.getElementById('page-settings').innerHTML = \`
|
|
618
|
+
<h2 style="margin-bottom:16px;font-size:18px">\u2699\uFE0F Settings</h2>
|
|
619
|
+
<div class="settings-group">
|
|
620
|
+
<h3>Connection</h3>
|
|
621
|
+
<div class="setting-row">
|
|
622
|
+
<span class="setting-label">Status</span>
|
|
623
|
+
<span class="setting-value" style="color:var(--success)">\u25CF Connected</span>
|
|
624
|
+
</div>
|
|
625
|
+
<div class="setting-row">
|
|
626
|
+
<span class="setting-label">Dialect</span>
|
|
627
|
+
<span class="setting-value">PostgreSQL</span>
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
<div class="settings-group">
|
|
631
|
+
<h3>Studio</h3>
|
|
632
|
+
<div class="setting-row">
|
|
633
|
+
<span class="setting-label">Version</span>
|
|
634
|
+
<span class="setting-value">0.1.0</span>
|
|
635
|
+
</div>
|
|
636
|
+
<div class="setting-row">
|
|
637
|
+
<span class="setting-label">URL</span>
|
|
638
|
+
<span class="setting-value">\${window.location.origin}</span>
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
\`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ---- Init ----
|
|
645
|
+
loadPage('tables');
|
|
646
|
+
`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// src/server/api.ts
|
|
650
|
+
function mountStudio(options) {
|
|
651
|
+
return async (req, res, next) => {
|
|
652
|
+
try {
|
|
653
|
+
await handleRequest(req, res, options);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
if (next) return next(err);
|
|
656
|
+
res.status?.(500).json?.({ error: err.message }) ?? res.writeHead(500).end(JSON.stringify({ error: err.message }));
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
async function handleRequest(req, res, options) {
|
|
661
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
662
|
+
const path = url.pathname;
|
|
663
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
664
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
665
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
666
|
+
if (req.method === "OPTIONS") {
|
|
667
|
+
res.writeHead(204);
|
|
668
|
+
res.end();
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (path === "/api/tables" && req.method === "GET") {
|
|
672
|
+
return jsonResponse(res, await getTables(options));
|
|
673
|
+
}
|
|
674
|
+
if (path.startsWith("/api/tables/") && req.method === "GET") {
|
|
675
|
+
const tableName = path.split("/api/tables/")[1];
|
|
676
|
+
return jsonResponse(res, await getTableData(options, tableName));
|
|
677
|
+
}
|
|
678
|
+
if (path.startsWith("/api/tables/") && req.method === "POST") {
|
|
679
|
+
const tableName = path.split("/api/tables/")[1];
|
|
680
|
+
const body = await readBody(req);
|
|
681
|
+
return jsonResponse(res, await insertRow(options, tableName, body));
|
|
682
|
+
}
|
|
683
|
+
if (path.startsWith("/api/tables/") && req.method === "PUT") {
|
|
684
|
+
const tableName = path.split("/api/tables/")[1];
|
|
685
|
+
const body = await readBody(req);
|
|
686
|
+
return jsonResponse(res, await updateRow(options, tableName, body));
|
|
687
|
+
}
|
|
688
|
+
if (path.startsWith("/api/tables/") && req.method === "DELETE") {
|
|
689
|
+
const tableName = path.split("/api/tables/")[1];
|
|
690
|
+
const body = await readBody(req);
|
|
691
|
+
return jsonResponse(res, await deleteRow(options, tableName, body));
|
|
692
|
+
}
|
|
693
|
+
if (path === "/api/query" && req.method === "POST") {
|
|
694
|
+
const body = await readBody(req);
|
|
695
|
+
return jsonResponse(res, await executeQuery(options, body.sql));
|
|
696
|
+
}
|
|
697
|
+
if (path === "/api/migrations" && req.method === "GET") {
|
|
698
|
+
return jsonResponse(res, await getMigrations(options));
|
|
699
|
+
}
|
|
700
|
+
if (path === "/api/erd" && req.method === "GET") {
|
|
701
|
+
return jsonResponse(res, await getERD(options));
|
|
702
|
+
}
|
|
703
|
+
if (path === "/api/health" && req.method === "GET") {
|
|
704
|
+
return jsonResponse(res, { status: "ok", dialect: options.config.dialect });
|
|
705
|
+
}
|
|
706
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
707
|
+
res.end(getDashboardHTML());
|
|
708
|
+
}
|
|
709
|
+
async function getTables(options) {
|
|
710
|
+
const config = options.config;
|
|
711
|
+
if (config.dialect === "postgresql") {
|
|
712
|
+
const pg = await import("pg");
|
|
713
|
+
const client = new pg.default.Client({ connectionString: config.connection });
|
|
714
|
+
await client.connect();
|
|
715
|
+
const result = await client.query(`
|
|
716
|
+
SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename
|
|
717
|
+
`);
|
|
718
|
+
await client.end();
|
|
719
|
+
return { tables: result.rows.map((r) => r.tablename) };
|
|
720
|
+
}
|
|
721
|
+
if (config.dialect === "sqlite") {
|
|
722
|
+
const Database = (await import("better-sqlite3")).default;
|
|
723
|
+
const db = new Database(config.connection);
|
|
724
|
+
const rows = db.prepare(
|
|
725
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`
|
|
726
|
+
).all();
|
|
727
|
+
db.close();
|
|
728
|
+
return { tables: rows.map((r) => r.name) };
|
|
729
|
+
}
|
|
730
|
+
return { tables: [] };
|
|
731
|
+
}
|
|
732
|
+
async function getTableData(options, tableName) {
|
|
733
|
+
const config = options.config;
|
|
734
|
+
const safeName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
|
735
|
+
if (config.dialect === "postgresql") {
|
|
736
|
+
const pg = await import("pg");
|
|
737
|
+
const client = new pg.default.Client({ connectionString: config.connection });
|
|
738
|
+
await client.connect();
|
|
739
|
+
const countResult = await client.query(`SELECT COUNT(*) as count FROM "${safeName}"`);
|
|
740
|
+
const count = parseInt(countResult.rows[0].count);
|
|
741
|
+
const dataResult = await client.query(`SELECT * FROM "${safeName}" LIMIT 200`);
|
|
742
|
+
await client.end();
|
|
743
|
+
return {
|
|
744
|
+
columns: dataResult.fields.map((f) => f.name),
|
|
745
|
+
rows: dataResult.rows,
|
|
746
|
+
count
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
if (config.dialect === "sqlite") {
|
|
750
|
+
const Database = (await import("better-sqlite3")).default;
|
|
751
|
+
const db = new Database(config.connection);
|
|
752
|
+
const countRow = db.prepare(`SELECT COUNT(*) as count FROM "${safeName}"`).get();
|
|
753
|
+
const rows = db.prepare(`SELECT * FROM "${safeName}" LIMIT 200`).all();
|
|
754
|
+
db.close();
|
|
755
|
+
return {
|
|
756
|
+
columns: rows.length > 0 ? Object.keys(rows[0]) : [],
|
|
757
|
+
rows,
|
|
758
|
+
count: countRow?.count ?? 0
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
return { columns: [], rows: [], count: 0 };
|
|
762
|
+
}
|
|
763
|
+
async function insertRow(options, tableName, data) {
|
|
764
|
+
const config = options.config;
|
|
765
|
+
const safeName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
|
766
|
+
const columns = Object.keys(data);
|
|
767
|
+
const values = Object.values(data);
|
|
768
|
+
const placeholders = columns.map((_, i) => `$${i + 1}`);
|
|
769
|
+
if (config.dialect === "postgresql") {
|
|
770
|
+
const pg = await import("pg");
|
|
771
|
+
const client = new pg.default.Client({ connectionString: config.connection });
|
|
772
|
+
await client.connect();
|
|
773
|
+
await client.query(
|
|
774
|
+
`INSERT INTO "${safeName}" (${columns.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders.join(", ")})`,
|
|
775
|
+
values
|
|
776
|
+
);
|
|
777
|
+
await client.end();
|
|
778
|
+
return { success: true };
|
|
779
|
+
}
|
|
780
|
+
return { success: false };
|
|
781
|
+
}
|
|
782
|
+
async function updateRow(options, tableName, body) {
|
|
783
|
+
const config = options.config;
|
|
784
|
+
const safeName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
|
785
|
+
const setEntries = Object.entries(body.set);
|
|
786
|
+
const whereEntries = Object.entries(body.where);
|
|
787
|
+
const setClauses = setEntries.map(([k, _v], i) => `"${k}" = $${i + 1}`);
|
|
788
|
+
const whereClauses = whereEntries.map(([k, _v], i) => `"${k}" = $${i + 1 + setEntries.length}`);
|
|
789
|
+
const params = [...setEntries.map(([_, v]) => v), ...whereEntries.map(([_, v]) => v)];
|
|
790
|
+
if (config.dialect === "postgresql") {
|
|
791
|
+
const pg = await import("pg");
|
|
792
|
+
const client = new pg.default.Client({ connectionString: config.connection });
|
|
793
|
+
await client.connect();
|
|
794
|
+
await client.query(
|
|
795
|
+
`UPDATE "${safeName}" SET ${setClauses.join(", ")} WHERE ${whereClauses.join(" AND ")}`,
|
|
796
|
+
params
|
|
797
|
+
);
|
|
798
|
+
await client.end();
|
|
799
|
+
return { success: true };
|
|
800
|
+
}
|
|
801
|
+
return { success: false };
|
|
802
|
+
}
|
|
803
|
+
async function deleteRow(options, tableName, body) {
|
|
804
|
+
const config = options.config;
|
|
805
|
+
const safeName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
|
806
|
+
const whereEntries = Object.entries(body.where);
|
|
807
|
+
const whereClauses = whereEntries.map(([k, _v], i) => `"${k}" = $${i + 1}`);
|
|
808
|
+
const params = whereEntries.map(([_, v]) => v);
|
|
809
|
+
if (config.dialect === "postgresql") {
|
|
810
|
+
const pg = await import("pg");
|
|
811
|
+
const client = new pg.default.Client({ connectionString: config.connection });
|
|
812
|
+
await client.connect();
|
|
813
|
+
await client.query(
|
|
814
|
+
`DELETE FROM "${safeName}" WHERE ${whereClauses.join(" AND ")}`,
|
|
815
|
+
params
|
|
816
|
+
);
|
|
817
|
+
await client.end();
|
|
818
|
+
return { success: true };
|
|
819
|
+
}
|
|
820
|
+
return { success: false };
|
|
821
|
+
}
|
|
822
|
+
async function executeQuery(options, sql) {
|
|
823
|
+
const config = options.config;
|
|
824
|
+
const start = Date.now();
|
|
825
|
+
if (config.dialect === "postgresql") {
|
|
826
|
+
const pg = await import("pg");
|
|
827
|
+
const client = new pg.default.Client({ connectionString: config.connection });
|
|
828
|
+
await client.connect();
|
|
829
|
+
const result = await client.query(sql);
|
|
830
|
+
await client.end();
|
|
831
|
+
return {
|
|
832
|
+
columns: result.fields.map((f) => f.name),
|
|
833
|
+
rows: result.rows,
|
|
834
|
+
rowCount: result.rowCount ?? 0,
|
|
835
|
+
duration: Date.now() - start
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
if (config.dialect === "sqlite") {
|
|
839
|
+
const Database = (await import("better-sqlite3")).default;
|
|
840
|
+
const db = new Database(config.connection);
|
|
841
|
+
const rows = db.prepare(sql).all();
|
|
842
|
+
db.close();
|
|
843
|
+
return {
|
|
844
|
+
columns: rows.length > 0 ? Object.keys(rows[0]) : [],
|
|
845
|
+
rows,
|
|
846
|
+
rowCount: rows.length,
|
|
847
|
+
duration: Date.now() - start
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
return { columns: [], rows: [], rowCount: 0, duration: 0 };
|
|
851
|
+
}
|
|
852
|
+
async function getMigrations(options) {
|
|
853
|
+
return { applied: [], pending: [] };
|
|
854
|
+
}
|
|
855
|
+
async function getERD(options) {
|
|
856
|
+
const config = options.config;
|
|
857
|
+
if (config.dialect === "postgresql") {
|
|
858
|
+
const pg = await import("pg");
|
|
859
|
+
const client = new pg.default.Client({ connectionString: config.connection });
|
|
860
|
+
await client.connect();
|
|
861
|
+
const tablesResult = await client.query(`
|
|
862
|
+
SELECT t.table_name, c.column_name, c.data_type, c.is_nullable,
|
|
863
|
+
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_pk
|
|
864
|
+
FROM information_schema.tables t
|
|
865
|
+
JOIN information_schema.columns c ON t.table_name = c.table_name AND c.table_schema = 'public'
|
|
866
|
+
LEFT JOIN (
|
|
867
|
+
SELECT ku.column_name, ku.table_name
|
|
868
|
+
FROM information_schema.table_constraints tc
|
|
869
|
+
JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name
|
|
870
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
871
|
+
) pk ON c.column_name = pk.column_name AND c.table_name = pk.table_name
|
|
872
|
+
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE'
|
|
873
|
+
ORDER BY t.table_name, c.ordinal_position
|
|
874
|
+
`);
|
|
875
|
+
const fkResult = await client.query(`
|
|
876
|
+
SELECT
|
|
877
|
+
tc.table_name as source_table,
|
|
878
|
+
kcu.column_name as source_column,
|
|
879
|
+
ccu.table_name as target_table,
|
|
880
|
+
ccu.column_name as target_column
|
|
881
|
+
FROM information_schema.table_constraints tc
|
|
882
|
+
JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
|
|
883
|
+
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
|
|
884
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
885
|
+
`);
|
|
886
|
+
await client.end();
|
|
887
|
+
const tableMap = {};
|
|
888
|
+
for (const row of tablesResult.rows) {
|
|
889
|
+
if (!tableMap[row.table_name]) {
|
|
890
|
+
tableMap[row.table_name] = { name: row.table_name, columns: [] };
|
|
891
|
+
}
|
|
892
|
+
tableMap[row.table_name].columns.push({
|
|
893
|
+
name: row.column_name,
|
|
894
|
+
type: row.data_type,
|
|
895
|
+
nullable: row.is_nullable === "YES",
|
|
896
|
+
isPrimaryKey: row.is_pk
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
return {
|
|
900
|
+
tables: Object.values(tableMap),
|
|
901
|
+
relations: fkResult.rows.map((r) => ({
|
|
902
|
+
from: { table: r.source_table, column: r.source_column },
|
|
903
|
+
to: { table: r.target_table, column: r.target_column }
|
|
904
|
+
}))
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
return { tables: [], relations: [] };
|
|
908
|
+
}
|
|
909
|
+
function jsonResponse(res, data) {
|
|
910
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
911
|
+
res.end(JSON.stringify(data));
|
|
912
|
+
}
|
|
913
|
+
function readBody(req) {
|
|
914
|
+
return new Promise((resolve, reject) => {
|
|
915
|
+
const chunks = [];
|
|
916
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
917
|
+
req.on("end", () => {
|
|
918
|
+
try {
|
|
919
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
920
|
+
} catch {
|
|
921
|
+
resolve({});
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
req.on("error", reject);
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// src/server/start.ts
|
|
929
|
+
import { createServer } from "http";
|
|
930
|
+
async function startStudio(options) {
|
|
931
|
+
const port = options.port ?? 3333;
|
|
932
|
+
const host = options.host ?? "localhost";
|
|
933
|
+
const server = createServer(async (req, res) => {
|
|
934
|
+
try {
|
|
935
|
+
await handleRequest(req, res, options);
|
|
936
|
+
} catch (err) {
|
|
937
|
+
console.error("[Studio] Error:", err);
|
|
938
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
939
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
server.listen(port, host, () => {
|
|
943
|
+
console.log(`
|
|
944
|
+
\u{1F3A8} MountSQLi Studio running at http://${host}:${port}
|
|
945
|
+
`);
|
|
946
|
+
console.log(" Pages:");
|
|
947
|
+
console.log(" / \u2014 Dashboard home");
|
|
948
|
+
console.log(" /tables \u2014 Table explorer");
|
|
949
|
+
console.log(" /query \u2014 SQL console");
|
|
950
|
+
console.log(" /migrations \u2014 Migration status");
|
|
951
|
+
console.log(" /erd \u2014 ER diagram");
|
|
952
|
+
console.log(" /settings \u2014 Connection settings");
|
|
953
|
+
console.log("\n Press Ctrl+C to stop\n");
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
export {
|
|
957
|
+
mountStudio,
|
|
958
|
+
startStudio
|
|
959
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mountsqli/studio",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"import": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"files": ["dist", "LICENSE", "README.md"],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"dev": "tsup --watch",
|
|
17
|
+
"clean": "rm -rf dist"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@mountsqli/core": "workspace:*",
|
|
21
|
+
"@mountsqli/shared": "workspace:*"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"pg": "^8.0.0",
|
|
25
|
+
"better-sqlite3": "^11.0.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"pg": { "optional": true },
|
|
29
|
+
"better-sqlite3": { "optional": true }
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.0.0",
|
|
33
|
+
"typescript": "^5.7.0",
|
|
34
|
+
"tsup": "^8.0.0"
|
|
35
|
+
}
|
|
36
|
+
}
|