query_console 0.1.0 → 0.2.1
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.
- checksums.yaml +4 -4
- data/README.md +204 -28
- data/app/controllers/query_console/application_controller.rb +6 -3
- data/app/controllers/query_console/explain_controller.rb +47 -0
- data/app/controllers/query_console/queries_controller.rb +4 -1
- data/app/controllers/query_console/schema_controller.rb +32 -0
- data/app/javascript/query_console/controllers/editor_controller.js +182 -45
- data/app/services/query_console/audit_logger.rb +29 -3
- data/app/services/query_console/explain_runner.rb +137 -0
- data/app/services/query_console/runner.rb +56 -3
- data/app/services/query_console/schema_introspector.rb +244 -0
- data/app/services/query_console/sql_limiter.rb +10 -0
- data/app/services/query_console/sql_validator.rb +33 -6
- data/app/views/query_console/explain/_results.html.erb +89 -0
- data/app/views/query_console/queries/_results.html.erb +40 -4
- data/app/views/query_console/queries/new.html.erb +843 -328
- data/config/importmap.rb +8 -0
- data/config/routes.rb +5 -0
- data/lib/query_console/configuration.rb +21 -1
- data/lib/query_console/version.rb +1 -1
- metadata +16 -14
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html>
|
|
3
3
|
<head>
|
|
4
|
-
<title>Query Console</title>
|
|
4
|
+
<title>Query Console v0.2.0</title>
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<%= csrf_meta_tags %>
|
|
7
7
|
<meta name="turbo-refresh-method" content="morph">
|
|
8
8
|
<meta name="turbo-refresh-scroll" content="preserve">
|
|
9
9
|
|
|
10
10
|
<style>
|
|
11
|
-
* {
|
|
12
|
-
box-sizing: border-box;
|
|
13
|
-
}
|
|
11
|
+
* { box-sizing: border-box; }
|
|
14
12
|
|
|
15
13
|
body {
|
|
16
14
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
@@ -21,10 +19,11 @@
|
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
.container {
|
|
24
|
-
max-width:
|
|
22
|
+
max-width: 1600px;
|
|
25
23
|
margin: 0 auto;
|
|
26
24
|
}
|
|
27
25
|
|
|
26
|
+
/* Banner */
|
|
28
27
|
.banner {
|
|
29
28
|
background: #fff3cd;
|
|
30
29
|
border: 1px solid #ffc107;
|
|
@@ -33,79 +32,51 @@
|
|
|
33
32
|
margin-bottom: 20px;
|
|
34
33
|
color: #856404;
|
|
35
34
|
position: relative;
|
|
36
|
-
transition: all 0.3s ease;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
.banner.collapsed {
|
|
40
|
-
padding: 10px 15px;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
.banner.collapsed .banner-content {
|
|
44
|
-
display: none;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
.banner.collapsed h2 {
|
|
48
|
-
margin: 0;
|
|
49
35
|
}
|
|
50
36
|
|
|
37
|
+
.banner.collapsed .banner-content { display: none; }
|
|
51
38
|
.banner h2 {
|
|
52
39
|
margin: 0 0 10px 0;
|
|
53
40
|
font-size: 18px;
|
|
54
41
|
padding-right: 30px;
|
|
55
42
|
}
|
|
56
|
-
|
|
57
43
|
.banner p {
|
|
58
44
|
margin: 5px 0;
|
|
59
45
|
font-size: 14px;
|
|
60
46
|
}
|
|
61
47
|
|
|
62
|
-
.
|
|
48
|
+
.section-toggle {
|
|
63
49
|
position: absolute;
|
|
64
|
-
top:
|
|
50
|
+
top: 50%;
|
|
65
51
|
right: 15px;
|
|
52
|
+
transform: translateY(-50%);
|
|
66
53
|
background: transparent;
|
|
67
54
|
border: none;
|
|
68
|
-
color: #856404;
|
|
69
|
-
font-size: 20px;
|
|
70
55
|
cursor: pointer;
|
|
71
|
-
padding:
|
|
72
|
-
|
|
73
|
-
height: 24px;
|
|
74
|
-
display: flex;
|
|
75
|
-
align-items: center;
|
|
76
|
-
justify-content: center;
|
|
77
|
-
transition: transform 0.3s ease;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
.banner-toggle:hover {
|
|
81
|
-
transform: scale(1.2);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.banner.collapsed .banner-toggle {
|
|
85
|
-
transform: rotate(180deg);
|
|
56
|
+
padding: 4px 8px;
|
|
57
|
+
font-size: 16px;
|
|
86
58
|
}
|
|
87
59
|
|
|
60
|
+
/* Main Layout */
|
|
88
61
|
.main-layout {
|
|
89
62
|
display: grid;
|
|
90
|
-
grid-template-columns: 1fr
|
|
63
|
+
grid-template-columns: 1fr 400px;
|
|
91
64
|
gap: 20px;
|
|
65
|
+
align-items: start;
|
|
92
66
|
}
|
|
93
67
|
|
|
68
|
+
/* Editor Section */
|
|
94
69
|
.editor-section {
|
|
95
70
|
background: white;
|
|
96
71
|
border-radius: 8px;
|
|
97
72
|
padding: 20px;
|
|
98
73
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
.editor-section.collapsed {
|
|
103
|
-
padding: 15px 20px;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
.editor-section.collapsed .editor-content {
|
|
107
|
-
display: none;
|
|
74
|
+
min-width: 0; /* Allow grid to constrain this column */
|
|
75
|
+
width: 100%; /* Take full grid column width */
|
|
76
|
+
position: relative;
|
|
108
77
|
}
|
|
78
|
+
|
|
79
|
+
.editor-section.collapsed .editor-content { display: none; }
|
|
109
80
|
|
|
110
81
|
.editor-header {
|
|
111
82
|
display: flex;
|
|
@@ -113,37 +84,12 @@
|
|
|
113
84
|
align-items: center;
|
|
114
85
|
margin-bottom: 15px;
|
|
115
86
|
position: relative;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.editor-section.collapsed .editor-header {
|
|
119
|
-
margin-bottom: 0;
|
|
87
|
+
padding-right: 40px; /* Increased from 30px to give more space for toggle button */
|
|
120
88
|
}
|
|
121
89
|
|
|
122
90
|
.editor-header h3 {
|
|
123
91
|
margin: 0;
|
|
124
|
-
font-size:
|
|
125
|
-
flex-grow: 1;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
.section-toggle {
|
|
129
|
-
background: transparent;
|
|
130
|
-
border: none;
|
|
131
|
-
color: #6c757d;
|
|
132
|
-
font-size: 18px;
|
|
133
|
-
cursor: pointer;
|
|
134
|
-
padding: 5px 10px;
|
|
135
|
-
margin-left: 10px;
|
|
136
|
-
transition: transform 0.3s ease, color 0.2s;
|
|
137
|
-
order: 1;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
.section-toggle:hover {
|
|
141
|
-
color: #007bff;
|
|
142
|
-
transform: scale(1.2);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
.collapsed .section-toggle {
|
|
146
|
-
transform: rotate(180deg);
|
|
92
|
+
font-size: 16px;
|
|
147
93
|
}
|
|
148
94
|
|
|
149
95
|
.button-group {
|
|
@@ -151,13 +97,13 @@
|
|
|
151
97
|
gap: 10px;
|
|
152
98
|
}
|
|
153
99
|
|
|
154
|
-
.btn-primary, .btn-secondary
|
|
155
|
-
padding:
|
|
100
|
+
.btn-primary, .btn-secondary {
|
|
101
|
+
padding: 8px 16px;
|
|
156
102
|
border: none;
|
|
157
103
|
border-radius: 4px;
|
|
158
|
-
font-size: 14px;
|
|
159
104
|
cursor: pointer;
|
|
160
|
-
|
|
105
|
+
font-size: 14px;
|
|
106
|
+
font-weight: 500;
|
|
161
107
|
}
|
|
162
108
|
|
|
163
109
|
.btn-primary {
|
|
@@ -165,15 +111,10 @@
|
|
|
165
111
|
color: white;
|
|
166
112
|
}
|
|
167
113
|
|
|
168
|
-
.btn-primary:hover
|
|
114
|
+
.btn-primary:hover {
|
|
169
115
|
background: #0056b3;
|
|
170
116
|
}
|
|
171
117
|
|
|
172
|
-
.btn-primary:disabled {
|
|
173
|
-
background: #6c757d;
|
|
174
|
-
cursor: not-allowed;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
118
|
.btn-secondary {
|
|
178
119
|
background: #6c757d;
|
|
179
120
|
color: white;
|
|
@@ -183,239 +124,638 @@
|
|
|
183
124
|
background: #545b62;
|
|
184
125
|
}
|
|
185
126
|
|
|
186
|
-
|
|
187
|
-
|
|
127
|
+
/* DML Warning Styles */
|
|
128
|
+
.btn-dml {
|
|
129
|
+
background-color: #ff6b6b;
|
|
188
130
|
color: white;
|
|
189
|
-
|
|
190
|
-
padding:
|
|
131
|
+
border: none;
|
|
132
|
+
padding: 8px 16px;
|
|
133
|
+
border-radius: 4px;
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
font-size: 14px;
|
|
136
|
+
font-weight: 500;
|
|
191
137
|
}
|
|
192
138
|
|
|
193
|
-
.btn-
|
|
194
|
-
background: #
|
|
139
|
+
.btn-dml:hover {
|
|
140
|
+
background-color: #ff5252;
|
|
195
141
|
}
|
|
196
142
|
|
|
197
|
-
|
|
143
|
+
.dml-warning {
|
|
144
|
+
background-color: #fff3cd;
|
|
145
|
+
border-left: 4px solid #ffc107;
|
|
146
|
+
padding: 12px 16px;
|
|
147
|
+
margin-bottom: 16px;
|
|
148
|
+
margin-top: 16px;
|
|
149
|
+
border-radius: 4px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.dml-warning-icon {
|
|
153
|
+
color: #ff6b6b;
|
|
154
|
+
font-weight: bold;
|
|
155
|
+
margin-right: 8px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* SQL Editor (CodeMirror) */
|
|
159
|
+
.sql-editor-container {
|
|
198
160
|
width: 100%;
|
|
199
161
|
min-height: 200px;
|
|
200
|
-
padding: 15px;
|
|
201
|
-
border: 1px solid #ddd;
|
|
202
162
|
border-radius: 4px;
|
|
203
|
-
|
|
163
|
+
overflow: hidden;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.sql-editor-container:focus-within {
|
|
167
|
+
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.cm-editor {
|
|
171
|
+
height: 100%;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.cm-scroller {
|
|
175
|
+
min-height: 200px;
|
|
176
|
+
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
|
204
177
|
font-size: 14px;
|
|
205
|
-
|
|
206
|
-
|
|
178
|
+
line-height: 1.5;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.cm-content {
|
|
182
|
+
padding: 12px;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.cm-focused {
|
|
186
|
+
outline: none !important;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
/* Results Containers - prevent horizontal expansion */
|
|
191
|
+
turbo-frame#query-results,
|
|
192
|
+
turbo-frame#explain-results {
|
|
193
|
+
display: block;
|
|
194
|
+
width: 100%;
|
|
195
|
+
min-width: 0;
|
|
196
|
+
overflow-x: auto;
|
|
207
197
|
}
|
|
208
198
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
199
|
+
/* Right Panel */
|
|
200
|
+
.right-panel {
|
|
201
|
+
display: flex;
|
|
202
|
+
flex-direction: column;
|
|
203
|
+
gap: 20px;
|
|
213
204
|
}
|
|
214
205
|
|
|
215
|
-
|
|
206
|
+
/* Tabbed Section */
|
|
207
|
+
.tabbed-section {
|
|
216
208
|
background: white;
|
|
217
209
|
border-radius: 8px;
|
|
218
|
-
padding: 20px;
|
|
219
210
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
220
|
-
|
|
221
|
-
overflow-y: auto;
|
|
222
|
-
transition: all 0.3s ease;
|
|
211
|
+
overflow: hidden;
|
|
223
212
|
}
|
|
213
|
+
|
|
214
|
+
.tabbed-section.collapsed .tab-content { display: none; }
|
|
224
215
|
|
|
225
|
-
.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
216
|
+
.tabs {
|
|
217
|
+
display: flex;
|
|
218
|
+
border-bottom: 1px solid #dee2e6;
|
|
219
|
+
background: #f8f9fa;
|
|
229
220
|
}
|
|
230
221
|
|
|
231
|
-
.
|
|
232
|
-
|
|
222
|
+
.tab {
|
|
223
|
+
padding: 12px 20px;
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
border: none;
|
|
226
|
+
background: transparent;
|
|
227
|
+
border-bottom: 2px solid transparent;
|
|
228
|
+
font-size: 14px;
|
|
229
|
+
font-weight: 500;
|
|
233
230
|
}
|
|
234
231
|
|
|
235
|
-
.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
align-items: center;
|
|
239
|
-
margin-bottom: 15px;
|
|
240
|
-
position: relative;
|
|
232
|
+
.tab.active {
|
|
233
|
+
border-bottom-color: #007bff;
|
|
234
|
+
color: #007bff;
|
|
241
235
|
}
|
|
242
236
|
|
|
243
|
-
.
|
|
244
|
-
|
|
237
|
+
.tab-content {
|
|
238
|
+
padding: 15px;
|
|
239
|
+
max-height: 400px;
|
|
240
|
+
overflow-y: auto;
|
|
245
241
|
}
|
|
246
242
|
|
|
247
|
-
.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
243
|
+
.tab-pane {
|
|
244
|
+
display: none;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.tab-pane.active {
|
|
248
|
+
display: block;
|
|
251
249
|
}
|
|
252
250
|
|
|
253
|
-
|
|
251
|
+
/* History & Schema Lists */
|
|
252
|
+
ul.item-list {
|
|
254
253
|
list-style: none;
|
|
255
254
|
padding: 0;
|
|
256
255
|
margin: 0;
|
|
257
256
|
}
|
|
258
257
|
|
|
259
|
-
.
|
|
260
|
-
|
|
258
|
+
ul.item-list li {
|
|
259
|
+
padding: 10px;
|
|
260
|
+
border-bottom: 1px solid #eee;
|
|
261
|
+
cursor: pointer;
|
|
261
262
|
}
|
|
262
263
|
|
|
263
|
-
.
|
|
264
|
-
width: 100%;
|
|
264
|
+
ul.item-list li:hover {
|
|
265
265
|
background: #f8f9fa;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* Saved Queries Section */
|
|
269
|
+
.saved-section {
|
|
270
|
+
background: white;
|
|
271
|
+
border-radius: 8px;
|
|
272
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
273
|
+
padding: 15px;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.saved-section.collapsed .saved-content {
|
|
277
|
+
display: none;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.saved-header {
|
|
281
|
+
display: flex;
|
|
282
|
+
justify-content: space-between;
|
|
283
|
+
align-items: center;
|
|
284
|
+
margin-bottom: 10px;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.saved-header h4 {
|
|
288
|
+
margin: 0;
|
|
289
|
+
font-size: 14px;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.saved-query-item {
|
|
293
|
+
padding: 10px;
|
|
266
294
|
border: 1px solid #dee2e6;
|
|
267
295
|
border-radius: 4px;
|
|
268
|
-
|
|
269
|
-
|
|
296
|
+
margin-bottom: 8px;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.saved-query-item:hover {
|
|
300
|
+
background: #f8f9fa;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* Schema Explorer */
|
|
304
|
+
.schema-search {
|
|
305
|
+
width: 100%;
|
|
306
|
+
padding: 8px;
|
|
307
|
+
border: 1px solid #ddd;
|
|
308
|
+
border-radius: 4px;
|
|
309
|
+
margin-bottom: 10px;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.table-item {
|
|
313
|
+
padding: 8px;
|
|
270
314
|
cursor: pointer;
|
|
271
|
-
|
|
315
|
+
border-bottom: 1px solid #eee;
|
|
272
316
|
}
|
|
273
317
|
|
|
274
|
-
.
|
|
275
|
-
background: #
|
|
276
|
-
border-color: #007bff;
|
|
277
|
-
transform: translateX(2px);
|
|
318
|
+
.table-item:hover {
|
|
319
|
+
background: #f8f9fa;
|
|
278
320
|
}
|
|
279
321
|
|
|
280
|
-
.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
322
|
+
.columns-list {
|
|
323
|
+
margin-top: 10px;
|
|
324
|
+
padding-left: 15px;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.column-item {
|
|
328
|
+
padding: 6px;
|
|
329
|
+
font-size: 13px;
|
|
330
|
+
border-left: 2px solid #007bff;
|
|
331
|
+
margin-bottom: 4px;
|
|
332
|
+
background: #f8f9fa;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/* Quick Actions */
|
|
336
|
+
.quick-actions {
|
|
337
|
+
display: flex;
|
|
338
|
+
gap: 6px;
|
|
339
|
+
margin-top: 6px;
|
|
288
340
|
}
|
|
289
341
|
|
|
290
|
-
.
|
|
342
|
+
.quick-action-btn {
|
|
343
|
+
padding: 4px 8px;
|
|
291
344
|
font-size: 11px;
|
|
292
|
-
|
|
345
|
+
background: #e9ecef;
|
|
346
|
+
border: 1px solid #dee2e6;
|
|
347
|
+
border-radius: 3px;
|
|
348
|
+
cursor: pointer;
|
|
293
349
|
}
|
|
294
350
|
|
|
295
|
-
.
|
|
296
|
-
|
|
297
|
-
font-style: italic;
|
|
298
|
-
text-align: center;
|
|
299
|
-
padding: 20px;
|
|
351
|
+
.quick-action-btn:hover {
|
|
352
|
+
background: #dee2e6;
|
|
300
353
|
}
|
|
301
354
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
355
|
+
/* Results */
|
|
356
|
+
.results-container {
|
|
357
|
+
margin-top: 20px;
|
|
306
358
|
}
|
|
307
|
-
</style>
|
|
308
|
-
</head>
|
|
309
|
-
<body>
|
|
310
|
-
<div class="container">
|
|
311
|
-
<!-- Security Banner - Collapsible -->
|
|
312
|
-
<div class="banner" id="security-banner" data-controller="collapsible" data-collapsible-key-value="banner">
|
|
313
|
-
<button class="banner-toggle" data-action="click->collapsible#toggle" title="Toggle banner">▼</button>
|
|
314
|
-
<h2>🔍 Read-Only SQL Query Console</h2>
|
|
315
|
-
<div class="banner-content">
|
|
316
|
-
<p><strong>Security Notice:</strong> This console is enabled only in development by default.</p>
|
|
317
|
-
<p>All queries are logged and restricted to read-only operations (SELECT statements only).</p>
|
|
318
|
-
<p>Multiple statements and write operations are blocked for security.</p>
|
|
319
|
-
</div>
|
|
320
|
-
</div>
|
|
321
359
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
} do |f| %>
|
|
329
|
-
<div class="editor-section" id="editor-section" data-controller="collapsible" data-collapsible-key-value="editor">
|
|
330
|
-
<div class="editor-header">
|
|
331
|
-
<h3>SQL Editor</h3>
|
|
332
|
-
<div class="button-group">
|
|
333
|
-
<button type="button" data-action="click->editor#clear" data-editor-target="clearButton" class="btn-secondary">Clear</button>
|
|
334
|
-
<%= f.submit "Run Query", class: "btn-primary", data: { editor_target: "runButton" } %>
|
|
335
|
-
</div>
|
|
336
|
-
<button type="button" class="section-toggle" data-action="click->collapsible#toggle" title="Toggle editor">▼</button>
|
|
337
|
-
</div>
|
|
360
|
+
.table-wrapper {
|
|
361
|
+
overflow: auto;
|
|
362
|
+
border: 1px solid #dee2e6;
|
|
363
|
+
border-radius: 4px;
|
|
364
|
+
max-height: 500px;
|
|
365
|
+
}
|
|
338
366
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
367
|
+
.results-table {
|
|
368
|
+
width: 100%;
|
|
369
|
+
border-collapse: collapse;
|
|
370
|
+
}
|
|
343
371
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
</ul>
|
|
365
|
-
</div>
|
|
366
|
-
</div>
|
|
367
|
-
</div>
|
|
368
|
-
</div>
|
|
372
|
+
.results-table thead {
|
|
373
|
+
position: sticky;
|
|
374
|
+
top: 0;
|
|
375
|
+
background: #f8f9fa;
|
|
376
|
+
z-index: 10;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.results-table th,
|
|
380
|
+
.results-table td {
|
|
381
|
+
padding: 8px 12px;
|
|
382
|
+
text-align: left;
|
|
383
|
+
border: 1px solid #dee2e6;
|
|
384
|
+
white-space: nowrap;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.results-table th {
|
|
388
|
+
font-weight: 600;
|
|
389
|
+
background: #e9ecef;
|
|
390
|
+
}
|
|
391
|
+
</style>
|
|
369
392
|
|
|
370
|
-
<!-- Load Hotwire and Stimulus from CDN -->
|
|
371
393
|
<script type="importmap">
|
|
372
394
|
{
|
|
373
395
|
"imports": {
|
|
374
396
|
"@hotwired/turbo-rails": "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.min.js",
|
|
375
|
-
"@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.min.js"
|
|
397
|
+
"@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.min.js",
|
|
398
|
+
"codemirror": "https://esm.sh/codemirror@6.0.1",
|
|
399
|
+
"@codemirror/lang-sql": "https://esm.sh/@codemirror/lang-sql@6.6.0"
|
|
376
400
|
}
|
|
377
401
|
}
|
|
378
402
|
</script>
|
|
379
403
|
|
|
380
|
-
<!-- Load Stimulus controllers inline -->
|
|
381
404
|
<script type="module">
|
|
382
405
|
import * as Turbo from "@hotwired/turbo-rails"
|
|
383
406
|
import { Application, Controller } from "@hotwired/stimulus"
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
407
|
+
import { EditorView, basicSetup } from "codemirror"
|
|
408
|
+
import { sql } from "@codemirror/lang-sql"
|
|
409
|
+
|
|
388
410
|
const application = Application.start()
|
|
389
|
-
|
|
390
|
-
|
|
411
|
+
|
|
391
412
|
// Collapsible Controller
|
|
392
413
|
class CollapsibleController extends Controller {
|
|
393
414
|
static values = { key: String }
|
|
394
415
|
|
|
395
416
|
connect() {
|
|
396
|
-
this.storageKey = `query_console.${this.keyValue}_collapsed`
|
|
397
417
|
this.loadState()
|
|
398
418
|
}
|
|
399
419
|
|
|
400
|
-
toggle(
|
|
401
|
-
event.preventDefault()
|
|
420
|
+
toggle() {
|
|
402
421
|
this.element.classList.toggle('collapsed')
|
|
403
|
-
|
|
404
|
-
const isCollapsed = this.element.classList.contains('collapsed')
|
|
405
|
-
localStorage.setItem(this.storageKey, isCollapsed ? 'true' : 'false')
|
|
406
|
-
event.target.textContent = isCollapsed ? '▲' : '▼'
|
|
422
|
+
this.saveState()
|
|
407
423
|
}
|
|
408
424
|
|
|
409
425
|
loadState() {
|
|
410
|
-
const
|
|
411
|
-
|
|
426
|
+
const key = this.keyValue
|
|
427
|
+
const collapsed = localStorage.getItem(`qc.collapsed.${key}`) === 'true'
|
|
428
|
+
if (collapsed) {
|
|
412
429
|
this.element.classList.add('collapsed')
|
|
413
|
-
const button = this.element.querySelector('.section-toggle, .banner-toggle')
|
|
414
|
-
if (button) button.textContent = '▲'
|
|
415
430
|
}
|
|
416
431
|
}
|
|
432
|
+
|
|
433
|
+
saveState() {
|
|
434
|
+
const key = this.keyValue
|
|
435
|
+
const collapsed = this.element.classList.contains('collapsed')
|
|
436
|
+
localStorage.setItem(`qc.collapsed.${key}`, collapsed)
|
|
437
|
+
}
|
|
417
438
|
}
|
|
418
|
-
|
|
439
|
+
application.register("collapsible", CollapsibleController)
|
|
440
|
+
|
|
441
|
+
// Tabs Controller
|
|
442
|
+
class TabsController extends Controller {
|
|
443
|
+
static targets = ["tab", "pane"]
|
|
444
|
+
|
|
445
|
+
connect() {
|
|
446
|
+
this.showTab(0)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
select(event) {
|
|
450
|
+
const index = this.tabTargets.indexOf(event.currentTarget)
|
|
451
|
+
this.showTab(index)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
showTab(index) {
|
|
455
|
+
this.tabTargets.forEach((tab, i) => {
|
|
456
|
+
tab.classList.toggle('active', i === index)
|
|
457
|
+
})
|
|
458
|
+
this.paneTargets.forEach((pane, i) => {
|
|
459
|
+
pane.classList.toggle('active', i === index)
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
application.register("tabs", TabsController)
|
|
464
|
+
|
|
465
|
+
// Editor Controller (CodeMirror)
|
|
466
|
+
class EditorController extends Controller {
|
|
467
|
+
static targets = ["container"]
|
|
468
|
+
|
|
469
|
+
connect() {
|
|
470
|
+
this.initializeCodeMirror()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
disconnect() {
|
|
474
|
+
if (this.view) {
|
|
475
|
+
this.view.destroy()
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
initializeCodeMirror() {
|
|
480
|
+
try {
|
|
481
|
+
this.view = new EditorView({
|
|
482
|
+
doc: "SELECT * FROM users LIMIT 10;",
|
|
483
|
+
extensions: [
|
|
484
|
+
basicSetup,
|
|
485
|
+
sql(),
|
|
486
|
+
EditorView.lineWrapping,
|
|
487
|
+
EditorView.theme({
|
|
488
|
+
"&": {
|
|
489
|
+
fontSize: "14px"
|
|
490
|
+
},
|
|
491
|
+
".cm-content": {
|
|
492
|
+
fontFamily: "'Monaco', 'Menlo', 'Courier New', monospace",
|
|
493
|
+
minHeight: "200px",
|
|
494
|
+
padding: "12px"
|
|
495
|
+
},
|
|
496
|
+
".cm-scroller": {
|
|
497
|
+
overflow: "auto"
|
|
498
|
+
},
|
|
499
|
+
"&.cm-focused": {
|
|
500
|
+
outline: "none"
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
],
|
|
504
|
+
parent: this.containerTarget
|
|
505
|
+
})
|
|
506
|
+
} catch (error) {
|
|
507
|
+
console.error('CodeMirror initialization error:', error)
|
|
508
|
+
// Fallback to simple textarea
|
|
509
|
+
this.containerTarget.innerHTML = '<textarea class="sql-editor" style="width:100%; min-height:200px; font-family:monospace; padding:12px;">SELECT * FROM users LIMIT 10;</textarea>'
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
getSql() {
|
|
514
|
+
return this.view.state.doc.toString()
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
setSql(text) {
|
|
518
|
+
this.view.dispatch({
|
|
519
|
+
changes: {
|
|
520
|
+
from: 0,
|
|
521
|
+
to: this.view.state.doc.length,
|
|
522
|
+
insert: text
|
|
523
|
+
}
|
|
524
|
+
})
|
|
525
|
+
this.view.focus()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
insertAtCursor(text) {
|
|
529
|
+
const selection = this.view.state.selection.main
|
|
530
|
+
this.view.dispatch({
|
|
531
|
+
changes: {
|
|
532
|
+
from: selection.from,
|
|
533
|
+
to: selection.to,
|
|
534
|
+
insert: text
|
|
535
|
+
},
|
|
536
|
+
selection: {
|
|
537
|
+
anchor: selection.from + text.length
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
this.view.focus()
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
clearEditor() {
|
|
544
|
+
this.setSql('')
|
|
545
|
+
|
|
546
|
+
// Clear query results
|
|
547
|
+
const queryFrame = document.querySelector('turbo-frame#query-results')
|
|
548
|
+
if (queryFrame) {
|
|
549
|
+
queryFrame.innerHTML = '<div style="color: #6c757d; text-align: center; padding: 40px; margin-top: 20px;"><p>Enter a query above and click "Run Query" to see results here.</p></div>'
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Clear explain results
|
|
553
|
+
const explainFrame = document.querySelector('turbo-frame#explain-results')
|
|
554
|
+
if (explainFrame) {
|
|
555
|
+
explainFrame.innerHTML = ''
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
isDmlQuery(sql) {
|
|
560
|
+
const trimmed = sql.trim().toLowerCase()
|
|
561
|
+
return /^(insert|update|delete|merge)\b/.test(trimmed)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
runQuery() {
|
|
565
|
+
const sql = this.getSql().trim()
|
|
566
|
+
if (!sql) {
|
|
567
|
+
alert('Please enter a SQL query')
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Check if it's a DML query and confirm with user
|
|
572
|
+
if (this.isDmlQuery(sql)) {
|
|
573
|
+
const confirmed = confirm(
|
|
574
|
+
'⚠️ DATA MODIFICATION WARNING\n\n' +
|
|
575
|
+
'This query will INSERT, UPDATE, or DELETE data.\n\n' +
|
|
576
|
+
'• All changes are PERMANENT and cannot be undone\n' +
|
|
577
|
+
'• All operations are logged\n\n' +
|
|
578
|
+
'Do you want to proceed?'
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if (!confirmed) {
|
|
582
|
+
return // User cancelled
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Clear explain results when running query
|
|
587
|
+
const explainFrame = document.querySelector('turbo-frame#explain-results')
|
|
588
|
+
if (explainFrame) {
|
|
589
|
+
explainFrame.innerHTML = ''
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Store for history
|
|
593
|
+
window._lastExecutedSQL = sql
|
|
594
|
+
|
|
595
|
+
// Create form with Turbo Frame target
|
|
596
|
+
const form = document.createElement('form')
|
|
597
|
+
form.method = 'POST'
|
|
598
|
+
form.action = '<%= query_console.run_path %>'
|
|
599
|
+
form.setAttribute('data-turbo-frame', 'query-results')
|
|
600
|
+
form.innerHTML = `
|
|
601
|
+
<input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
|
|
602
|
+
<input type="hidden" name="authenticity_token" value="${document.querySelector('meta[name=csrf-token]').content}">
|
|
603
|
+
`
|
|
604
|
+
document.body.appendChild(form)
|
|
605
|
+
form.requestSubmit()
|
|
606
|
+
document.body.removeChild(form)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
explainQuery() {
|
|
610
|
+
const sql = this.getSql().trim()
|
|
611
|
+
if (!sql) {
|
|
612
|
+
alert('Please enter a SQL query')
|
|
613
|
+
return
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Clear query results when running explain
|
|
617
|
+
const queryFrame = document.querySelector('turbo-frame#query-results')
|
|
618
|
+
if (queryFrame) {
|
|
619
|
+
queryFrame.innerHTML = ''
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Create form with Turbo Frame target
|
|
623
|
+
const form = document.createElement('form')
|
|
624
|
+
form.method = 'POST'
|
|
625
|
+
form.action = '<%= query_console.explain_path %>'
|
|
626
|
+
form.setAttribute('data-turbo-frame', 'explain-results')
|
|
627
|
+
form.innerHTML = `
|
|
628
|
+
<input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
|
|
629
|
+
<input type="hidden" name="authenticity_token" value="${document.querySelector('meta[name=csrf-token]').content}">
|
|
630
|
+
`
|
|
631
|
+
document.body.appendChild(form)
|
|
632
|
+
form.requestSubmit()
|
|
633
|
+
document.body.removeChild(form)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
escapeHtml(text) {
|
|
637
|
+
const div = document.createElement('div')
|
|
638
|
+
div.textContent = text
|
|
639
|
+
return div.innerHTML
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
application.register("editor", EditorController)
|
|
643
|
+
|
|
644
|
+
// Schema Controller
|
|
645
|
+
class SchemaController extends Controller {
|
|
646
|
+
static targets = ["search", "tablesList", "details"]
|
|
647
|
+
|
|
648
|
+
connect() {
|
|
649
|
+
this.loadTables()
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async loadTables() {
|
|
653
|
+
try {
|
|
654
|
+
const response = await fetch('<%= query_console.schema_tables_path %>')
|
|
655
|
+
this.tables = await response.json()
|
|
656
|
+
this.renderTables()
|
|
657
|
+
} catch (error) {
|
|
658
|
+
console.error('Failed to load tables:', error)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
filterTables(event) {
|
|
663
|
+
const query = event.target.value.toLowerCase()
|
|
664
|
+
this.renderTables(query)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
renderTables(filter = '') {
|
|
668
|
+
const filtered = filter ?
|
|
669
|
+
this.tables.filter(t => t.name.toLowerCase().includes(filter)) :
|
|
670
|
+
this.tables
|
|
671
|
+
|
|
672
|
+
this.tablesListTarget.innerHTML = filtered.map(table =>
|
|
673
|
+
`<div class="table-item" data-action="click->schema#selectTable" data-table-name="${table.name}">
|
|
674
|
+
📊 ${table.name} <small>(${table.kind})</small>
|
|
675
|
+
</div>`
|
|
676
|
+
).join('')
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async selectTable(event) {
|
|
680
|
+
const tableName = event.currentTarget.dataset.tableName
|
|
681
|
+
|
|
682
|
+
try {
|
|
683
|
+
const response = await fetch(`<%= query_console.schema_tables_path %>/${tableName}`)
|
|
684
|
+
const tableData = await response.json()
|
|
685
|
+
this.renderTableDetails(tableData)
|
|
686
|
+
} catch (error) {
|
|
687
|
+
console.error('Failed to load table details:', error)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
renderTableDetails(table) {
|
|
692
|
+
const editor = this.application.getControllerForElementAndIdentifier(
|
|
693
|
+
document.querySelector('[data-controller="editor"]'),
|
|
694
|
+
'editor'
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
this.detailsTarget.innerHTML = `
|
|
698
|
+
<h5>${table.name}</h5>
|
|
699
|
+
<div class="quick-actions">
|
|
700
|
+
<button class="quick-action-btn" data-action="click->schema#insertSelectAll" data-table="${table.name}">
|
|
701
|
+
SELECT * FROM ${table.name}
|
|
702
|
+
</button>
|
|
703
|
+
<button class="quick-action-btn" data-action="click->schema#copyTableName" data-table="${table.name}">
|
|
704
|
+
📋 Copy Table Name
|
|
705
|
+
</button>
|
|
706
|
+
</div>
|
|
707
|
+
<div class="columns-list">
|
|
708
|
+
${table.columns.map(col => `
|
|
709
|
+
<div class="column-item">
|
|
710
|
+
<strong>${col.name}</strong> <code>${col.db_type}</code>
|
|
711
|
+
${col.nullable ? '<span>NULL</span>' : '<span>NOT NULL</span>'}
|
|
712
|
+
<div class="quick-actions">
|
|
713
|
+
<button class="quick-action-btn" data-action="click->schema#insertColumn" data-column="${col.name}">
|
|
714
|
+
Insert
|
|
715
|
+
</button>
|
|
716
|
+
<button class="quick-action-btn" data-action="click->schema#insertWhere" data-column="${col.name}">
|
|
717
|
+
WHERE
|
|
718
|
+
</button>
|
|
719
|
+
</div>
|
|
720
|
+
</div>
|
|
721
|
+
`).join('')}
|
|
722
|
+
</div>
|
|
723
|
+
`
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
insertSelectAll(event) {
|
|
727
|
+
const table = event.currentTarget.dataset.table
|
|
728
|
+
const editor = this.getEditor()
|
|
729
|
+
editor.setSql(`SELECT * FROM ${table} LIMIT 100;`)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
insertColumn(event) {
|
|
733
|
+
const column = event.currentTarget.dataset.column
|
|
734
|
+
const editor = this.getEditor()
|
|
735
|
+
editor.insertAtCursor(column)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
insertWhere(event) {
|
|
739
|
+
const column = event.currentTarget.dataset.column
|
|
740
|
+
const editor = this.getEditor()
|
|
741
|
+
editor.insertAtCursor(`WHERE ${column} = `)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
copyTableName(event) {
|
|
745
|
+
const table = event.currentTarget.dataset.table
|
|
746
|
+
navigator.clipboard.writeText(table)
|
|
747
|
+
alert(`Copied: ${table}`)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
getEditor() {
|
|
751
|
+
return this.application.getControllerForElementAndIdentifier(
|
|
752
|
+
document.querySelector('[data-controller~="editor"]'),
|
|
753
|
+
'editor'
|
|
754
|
+
)
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
application.register("schema", SchemaController)
|
|
758
|
+
|
|
419
759
|
// History Controller
|
|
420
760
|
class HistoryController extends Controller {
|
|
421
761
|
static targets = ["list"]
|
|
@@ -425,38 +765,60 @@
|
|
|
425
765
|
}
|
|
426
766
|
|
|
427
767
|
connect() {
|
|
428
|
-
this.
|
|
429
|
-
document.addEventListener('editor:executed', (e) => this.
|
|
768
|
+
this.render()
|
|
769
|
+
document.addEventListener('editor:executed', (e) => this.addQuery(e.detail.sql))
|
|
430
770
|
}
|
|
431
771
|
|
|
432
|
-
|
|
772
|
+
addQuery(sql) {
|
|
433
773
|
const history = this.getHistory()
|
|
434
774
|
history.unshift({
|
|
435
|
-
sql:
|
|
436
|
-
timestamp:
|
|
775
|
+
sql: sql,
|
|
776
|
+
timestamp: new Date().toISOString()
|
|
437
777
|
})
|
|
438
778
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
779
|
+
if (history.length > this.maxItemsValue) {
|
|
780
|
+
history.pop()
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
this.saveHistory(history)
|
|
784
|
+
this.render()
|
|
442
785
|
}
|
|
443
786
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
787
|
+
render() {
|
|
788
|
+
const history = this.getHistory()
|
|
789
|
+
this.listTarget.innerHTML = history.length ?
|
|
790
|
+
history.map((item, index) => `
|
|
791
|
+
<li data-action="click->history#load" data-index="${index}">
|
|
792
|
+
<div style="font-size: 12px; color: #6c757d;">${new Date(item.timestamp).toLocaleString()}</div>
|
|
793
|
+
<div style="font-size: 13px; margin-top: 4px;">${this.truncate(item.sql, 100)}</div>
|
|
794
|
+
</li>
|
|
795
|
+
`).join('') :
|
|
796
|
+
'<li style="color: #6c757d; text-align: center; padding: 20px;">No query history</li>'
|
|
448
797
|
}
|
|
449
798
|
|
|
450
|
-
|
|
451
|
-
event.
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
799
|
+
load(event) {
|
|
800
|
+
const index = parseInt(event.currentTarget.dataset.index)
|
|
801
|
+
const history = this.getHistory()
|
|
802
|
+
const query = history[index]
|
|
803
|
+
|
|
804
|
+
if (query) {
|
|
805
|
+
const editor = this.getEditor()
|
|
806
|
+
editor.setSql(query.sql)
|
|
455
807
|
}
|
|
456
808
|
}
|
|
457
809
|
|
|
458
|
-
|
|
459
|
-
this.
|
|
810
|
+
getEditor() {
|
|
811
|
+
return this.application.getControllerForElementAndIdentifier(
|
|
812
|
+
document.querySelector('[data-controller~="editor"]'),
|
|
813
|
+
'editor'
|
|
814
|
+
)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
clear() {
|
|
818
|
+
if (confirm('Clear all query history?')) {
|
|
819
|
+
localStorage.removeItem(this.storageKeyValue)
|
|
820
|
+
this.render()
|
|
821
|
+
}
|
|
460
822
|
}
|
|
461
823
|
|
|
462
824
|
getHistory() {
|
|
@@ -464,102 +826,255 @@
|
|
|
464
826
|
return stored ? JSON.parse(stored) : []
|
|
465
827
|
}
|
|
466
828
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
this.listTarget.innerHTML = '<li class="empty-history">No query history yet</li>'
|
|
470
|
-
return
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
this.listTarget.innerHTML = history.map(item => `
|
|
474
|
-
<li class="history-item">
|
|
475
|
-
<button type="button" class="history-item-button"
|
|
476
|
-
data-action="click->history#load"
|
|
477
|
-
data-sql="${this.escapeHtml(item.sql)}">
|
|
478
|
-
<div class="history-item-sql">${this.escapeHtml(this.truncate(item.sql, 100))}</div>
|
|
479
|
-
<div class="history-item-time">${this.formatTime(item.timestamp)}</div>
|
|
480
|
-
</button>
|
|
481
|
-
</li>
|
|
482
|
-
`).join('')
|
|
829
|
+
saveHistory(history) {
|
|
830
|
+
localStorage.setItem(this.storageKeyValue, JSON.stringify(history))
|
|
483
831
|
}
|
|
484
832
|
|
|
485
833
|
truncate(str, length) {
|
|
486
834
|
return str.length > length ? str.substring(0, length) + '...' : str
|
|
487
835
|
}
|
|
488
|
-
|
|
489
|
-
formatTime(timestamp) {
|
|
490
|
-
const date = new Date(timestamp)
|
|
491
|
-
const diff = Date.now() - date
|
|
492
|
-
if (diff < 60000) return 'just now'
|
|
493
|
-
if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`
|
|
494
|
-
if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`
|
|
495
|
-
return date.toLocaleDateString()
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
escapeHtml(text) {
|
|
499
|
-
const div = document.createElement('div')
|
|
500
|
-
div.textContent = text
|
|
501
|
-
return div.innerHTML
|
|
502
|
-
}
|
|
503
836
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
loadQuery(sql) {
|
|
514
|
-
this.textareaTarget.value = sql
|
|
515
|
-
this.textareaTarget.focus()
|
|
516
|
-
this.element.scrollIntoView({ behavior: 'smooth' })
|
|
837
|
+
application.register("history", HistoryController)
|
|
838
|
+
|
|
839
|
+
// Saved Queries Controller
|
|
840
|
+
class SavedController extends Controller {
|
|
841
|
+
static targets = ["list"]
|
|
842
|
+
static values = {
|
|
843
|
+
storageKey: { type: String, default: "query_console.saved.v1" }
|
|
517
844
|
}
|
|
518
845
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
this.textareaTarget.value = ''
|
|
522
|
-
this.textareaTarget.focus()
|
|
846
|
+
connect() {
|
|
847
|
+
this.render()
|
|
523
848
|
}
|
|
524
849
|
|
|
525
|
-
|
|
526
|
-
const
|
|
850
|
+
save() {
|
|
851
|
+
const editor = this.getEditor()
|
|
852
|
+
const sql = editor.getSql()
|
|
527
853
|
|
|
528
|
-
if (!sql) {
|
|
529
|
-
|
|
530
|
-
alert('Please enter a SQL query')
|
|
854
|
+
if (!sql.trim()) {
|
|
855
|
+
alert('Nothing to save')
|
|
531
856
|
return
|
|
532
857
|
}
|
|
533
858
|
|
|
534
|
-
|
|
535
|
-
|
|
859
|
+
const name = prompt('Query name:')
|
|
860
|
+
if (!name) return
|
|
536
861
|
|
|
537
|
-
|
|
538
|
-
this.runButtonTarget.disabled = true
|
|
539
|
-
this.runButtonTarget.value = 'Running...'
|
|
862
|
+
const tags = prompt('Tags (comma-separated, optional):')
|
|
540
863
|
|
|
541
|
-
|
|
864
|
+
const saved = this.getSaved()
|
|
865
|
+
saved.push({
|
|
866
|
+
id: Date.now().toString(),
|
|
867
|
+
name: name,
|
|
868
|
+
tags: tags ? tags.split(',').map(t => t.trim()) : [],
|
|
869
|
+
sql: sql,
|
|
870
|
+
createdAt: new Date().toISOString(),
|
|
871
|
+
updatedAt: new Date().toISOString()
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
this.saveSaved(saved)
|
|
875
|
+
this.render()
|
|
542
876
|
}
|
|
543
877
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
878
|
+
load(event) {
|
|
879
|
+
const id = event.currentTarget.dataset.id
|
|
880
|
+
const saved = this.getSaved()
|
|
881
|
+
const query = saved.find(q => q.id === id)
|
|
547
882
|
|
|
548
|
-
if (
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
sql: window._lastExecutedSQL,
|
|
552
|
-
timestamp: new Date().toISOString()
|
|
553
|
-
}
|
|
554
|
-
}))
|
|
555
|
-
delete window._lastExecutedSQL
|
|
883
|
+
if (query) {
|
|
884
|
+
const editor = this.getEditor()
|
|
885
|
+
editor.setSql(query.sql)
|
|
556
886
|
}
|
|
557
887
|
}
|
|
888
|
+
|
|
889
|
+
delete(event) {
|
|
890
|
+
if (!confirm('Delete this saved query?')) return
|
|
891
|
+
|
|
892
|
+
const id = event.currentTarget.dataset.id
|
|
893
|
+
const saved = this.getSaved().filter(q => q.id !== id)
|
|
894
|
+
this.saveSaved(saved)
|
|
895
|
+
this.render()
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
exportJSON() {
|
|
899
|
+
const saved = this.getSaved()
|
|
900
|
+
const json = JSON.stringify(saved, null, 2)
|
|
901
|
+
navigator.clipboard.writeText(json)
|
|
902
|
+
alert('Saved queries copied to clipboard!')
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
importJSON() {
|
|
906
|
+
const json = prompt('Paste saved queries JSON:')
|
|
907
|
+
if (!json) return
|
|
908
|
+
|
|
909
|
+
try {
|
|
910
|
+
const imported = JSON.parse(json)
|
|
911
|
+
const saved = this.getSaved()
|
|
912
|
+
this.saveSaved([...saved, ...imported])
|
|
913
|
+
this.render()
|
|
914
|
+
alert(`Imported ${imported.length} queries`)
|
|
915
|
+
} catch (error) {
|
|
916
|
+
alert('Invalid JSON: ' + error.message)
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
render() {
|
|
921
|
+
const saved = this.getSaved()
|
|
922
|
+
this.listTarget.innerHTML = saved.length ?
|
|
923
|
+
saved.map(query => `
|
|
924
|
+
<div class="saved-query-item">
|
|
925
|
+
<strong>${query.name}</strong>
|
|
926
|
+
${query.tags.length ? `<div><small>🏷 ${query.tags.join(', ')}</small></div>` : ''}
|
|
927
|
+
<div style="font-size: 12px; color: #6c757d; margin: 4px 0;">
|
|
928
|
+
${new Date(query.updatedAt).toLocaleString()}
|
|
929
|
+
</div>
|
|
930
|
+
<div class="quick-actions">
|
|
931
|
+
<button class="quick-action-btn" data-action="click->saved#load" data-id="${query.id}">Load</button>
|
|
932
|
+
<button class="quick-action-btn" data-action="click->saved#delete" data-id="${query.id}">Delete</button>
|
|
933
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
`).join('') :
|
|
936
|
+
'<div style="color: #6c757d; text-align: center; padding: 20px;">No saved queries</div>'
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
getSaved() {
|
|
940
|
+
const stored = localStorage.getItem(this.storageKeyValue)
|
|
941
|
+
return stored ? JSON.parse(stored) : []
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
saveSaved(saved) {
|
|
945
|
+
localStorage.setItem(this.storageKeyValue, JSON.stringify(saved))
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
getEditor() {
|
|
949
|
+
return this.application.getControllerForElementAndIdentifier(
|
|
950
|
+
document.querySelector('[data-controller~="editor"]'),
|
|
951
|
+
'editor'
|
|
952
|
+
)
|
|
953
|
+
}
|
|
558
954
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
955
|
+
application.register("saved", SavedController)
|
|
956
|
+
</script>
|
|
957
|
+
</head>
|
|
958
|
+
<body>
|
|
959
|
+
<div class="container">
|
|
960
|
+
<!-- Banner -->
|
|
961
|
+
<div class="banner" data-controller="collapsible" data-collapsible-key-value="banner">
|
|
962
|
+
<h2>🔍 <% if QueryConsole.configuration.enable_dml %>SQL Query Console<% else %>Read-Only SQL Query Console<% end %> <small>v0.2.0</small></h2>
|
|
963
|
+
<div class="banner-content">
|
|
964
|
+
<p>
|
|
965
|
+
<strong>Security:</strong>
|
|
966
|
+
<% if QueryConsole.configuration.enable_dml %>
|
|
967
|
+
DML queries (INSERT, UPDATE, DELETE) are enabled. All queries are logged. Use with caution.
|
|
968
|
+
<% else %>
|
|
969
|
+
Read-only SELECT & WITH queries only. All queries are logged.
|
|
970
|
+
<% end %>
|
|
971
|
+
</p>
|
|
972
|
+
<p><strong>New in v0.2.0:</strong> EXPLAIN query plans, Interactive Schema Explorer with quick insert buttons, Saved Queries with tags, import/export, and localStorage-based Query History!</p>
|
|
973
|
+
</div>
|
|
974
|
+
<button class="section-toggle" data-action="click->collapsible#toggle" type="button">▼</button>
|
|
975
|
+
</div>
|
|
976
|
+
|
|
977
|
+
<div class="main-layout">
|
|
978
|
+
<!-- Editor Section -->
|
|
979
|
+
<div class="editor-section" data-controller="editor collapsible" data-collapsible-key-value="editor_section">
|
|
980
|
+
<div class="editor-header">
|
|
981
|
+
<h3>SQL Editor</h3>
|
|
982
|
+
<div class="button-group">
|
|
983
|
+
<button class="btn-secondary" data-action="click->editor#clearEditor" type="button">Clear</button>
|
|
984
|
+
<button class="btn-secondary" data-action="click->editor#explainQuery" type="button">⚡ Explain</button>
|
|
985
|
+
<button class="btn-primary" data-action="click->editor#runQuery" type="button">▶ Run Query</button>
|
|
986
|
+
</div>
|
|
987
|
+
<button class="section-toggle" data-action="click->collapsible#toggle" title="Toggle editor" type="button">▼</button>
|
|
988
|
+
</div>
|
|
989
|
+
|
|
990
|
+
<div class="editor-content" data-collapsible-target="content">
|
|
991
|
+
<!-- SQL Editor (CodeMirror) -->
|
|
992
|
+
<div data-editor-target="container" class="sql-editor-container"></div>
|
|
993
|
+
|
|
994
|
+
<!-- Results Area -->
|
|
995
|
+
<%= turbo_frame_tag "query-results" do %>
|
|
996
|
+
<div style="color: #6c757d; text-align: center; padding: 40px; margin-top: 20px;">
|
|
997
|
+
<p>Enter a query above and click "Run Query" to see results here.</p>
|
|
998
|
+
</div>
|
|
999
|
+
<% end %>
|
|
1000
|
+
|
|
1001
|
+
<!-- Explain Results Area -->
|
|
1002
|
+
<%= turbo_frame_tag "explain-results" do %>
|
|
1003
|
+
<% end %>
|
|
1004
|
+
</div>
|
|
1005
|
+
</div>
|
|
1006
|
+
|
|
1007
|
+
<!-- Right Panel -->
|
|
1008
|
+
<div class="right-panel">
|
|
1009
|
+
<!-- Tabbed Section (History / Schema / Saved Queries) -->
|
|
1010
|
+
<div class="tabbed-section" data-controller="tabs collapsible" data-collapsible-key-value="tabs_section">
|
|
1011
|
+
<div class="tabs" style="position: relative;">
|
|
1012
|
+
<button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">📜 History</button>
|
|
1013
|
+
<button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">📊 Schema</button>
|
|
1014
|
+
<button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">💾 Saved</button>
|
|
1015
|
+
<button class="section-toggle" data-action="click->collapsible#toggle" title="Toggle tabs" type="button" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: transparent; border: none; cursor: pointer; padding: 4px 8px; font-size: 14px;">▼</button>
|
|
1016
|
+
</div>
|
|
1017
|
+
|
|
1018
|
+
<div class="tab-content" data-collapsible-target="content">
|
|
1019
|
+
<!-- History Tab -->
|
|
1020
|
+
<div class="tab-pane" data-tabs-target="pane">
|
|
1021
|
+
<div data-controller="history">
|
|
1022
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
|
1023
|
+
<h4 style="margin: 0; font-size: 14px;">Recent Queries</h4>
|
|
1024
|
+
<button class="quick-action-btn" data-action="click->history#clear" type="button">Clear</button>
|
|
1025
|
+
</div>
|
|
1026
|
+
<ul class="item-list" data-history-target="list"></ul>
|
|
1027
|
+
</div>
|
|
1028
|
+
</div>
|
|
1029
|
+
|
|
1030
|
+
<!-- Schema Tab -->
|
|
1031
|
+
<div class="tab-pane" data-tabs-target="pane">
|
|
1032
|
+
<div data-controller="schema">
|
|
1033
|
+
<input
|
|
1034
|
+
type="text"
|
|
1035
|
+
class="schema-search"
|
|
1036
|
+
placeholder="🔍 Search tables..."
|
|
1037
|
+
data-schema-target="search"
|
|
1038
|
+
data-action="input->schema#filterTables">
|
|
1039
|
+
<div data-schema-target="tablesList"></div>
|
|
1040
|
+
<div data-schema-target="details" style="margin-top: 15px;"></div>
|
|
1041
|
+
</div>
|
|
1042
|
+
</div>
|
|
1043
|
+
|
|
1044
|
+
<!-- Saved Queries Tab -->
|
|
1045
|
+
<div class="tab-pane" data-tabs-target="pane">
|
|
1046
|
+
<div data-controller="saved">
|
|
1047
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
|
1048
|
+
<h4 style="margin: 0; font-size: 14px;">Saved Queries</h4>
|
|
1049
|
+
<div style="display: flex; gap: 5px;">
|
|
1050
|
+
<button class="quick-action-btn" data-action="click->saved#save" type="button">💾 Save</button>
|
|
1051
|
+
<button class="quick-action-btn" data-action="click->saved#exportJSON" type="button">📤 Export</button>
|
|
1052
|
+
<button class="quick-action-btn" data-action="click->saved#importJSON" type="button">📥 Import</button>
|
|
1053
|
+
</div>
|
|
1054
|
+
</div>
|
|
1055
|
+
<div data-saved-target="list"></div>
|
|
1056
|
+
</div>
|
|
1057
|
+
</div>
|
|
1058
|
+
</div>
|
|
1059
|
+
</div>
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>
|
|
1062
|
+
</div>
|
|
1063
|
+
</div>
|
|
1064
|
+
|
|
1065
|
+
<script>
|
|
1066
|
+
// Track query execution for history
|
|
1067
|
+
document.addEventListener('turbo:submit-end', (event) => {
|
|
1068
|
+
if (window._lastExecutedSQL) {
|
|
1069
|
+
document.dispatchEvent(new CustomEvent('editor:executed', {
|
|
1070
|
+
detail: {
|
|
1071
|
+
sql: window._lastExecutedSQL,
|
|
1072
|
+
timestamp: new Date().toISOString()
|
|
1073
|
+
}
|
|
1074
|
+
}))
|
|
1075
|
+
delete window._lastExecutedSQL
|
|
1076
|
+
}
|
|
1077
|
+
})
|
|
563
1078
|
</script>
|
|
564
1079
|
</body>
|
|
565
1080
|
</html>
|