query_console 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +19 -0
- 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 +2 -0
- data/app/controllers/query_console/schema_controller.rb +32 -0
- data/app/services/query_console/audit_logger.rb +6 -2
- data/app/services/query_console/explain_runner.rb +137 -0
- data/app/services/query_console/schema_introspector.rb +244 -0
- data/app/views/query_console/explain/_results.html.erb +89 -0
- data/app/views/query_console/queries/_results.html.erb +5 -1
- data/app/views/query_console/queries/new.html.erb +720 -328
- data/config/importmap.rb +8 -0
- data/config/routes.rb +5 -0
- data/lib/query_console/configuration.rb +19 -1
- data/lib/query_console/version.rb +1 -1
- metadata +10 -11
|
@@ -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,191 +124,229 @@
|
|
|
183
124
|
background: #545b62;
|
|
184
125
|
}
|
|
185
126
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
color: white;
|
|
189
|
-
font-size: 12px;
|
|
190
|
-
padding: 5px 10px;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
.btn-danger:hover {
|
|
194
|
-
background: #c82333;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
textarea {
|
|
127
|
+
/* SQL Editor Textarea */
|
|
128
|
+
.sql-editor {
|
|
198
129
|
width: 100%;
|
|
199
130
|
min-height: 200px;
|
|
200
|
-
padding:
|
|
131
|
+
padding: 12px;
|
|
201
132
|
border: 1px solid #ddd;
|
|
202
133
|
border-radius: 4px;
|
|
203
|
-
font-family: 'Monaco', 'Menlo', '
|
|
134
|
+
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
|
204
135
|
font-size: 14px;
|
|
136
|
+
line-height: 1.5;
|
|
205
137
|
resize: vertical;
|
|
206
|
-
margin-bottom: 15px;
|
|
207
138
|
}
|
|
208
139
|
|
|
209
|
-
|
|
140
|
+
.sql-editor:focus {
|
|
210
141
|
outline: none;
|
|
211
142
|
border-color: #007bff;
|
|
212
|
-
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
|
|
143
|
+
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
/* Results Containers - prevent horizontal expansion */
|
|
148
|
+
turbo-frame#query-results,
|
|
149
|
+
turbo-frame#explain-results {
|
|
150
|
+
display: block;
|
|
151
|
+
width: 100%;
|
|
152
|
+
min-width: 0;
|
|
153
|
+
overflow-x: auto;
|
|
213
154
|
}
|
|
214
155
|
|
|
215
|
-
|
|
156
|
+
/* Right Panel */
|
|
157
|
+
.right-panel {
|
|
158
|
+
display: flex;
|
|
159
|
+
flex-direction: column;
|
|
160
|
+
gap: 20px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* Tabbed Section */
|
|
164
|
+
.tabbed-section {
|
|
216
165
|
background: white;
|
|
217
166
|
border-radius: 8px;
|
|
218
|
-
padding: 20px;
|
|
219
167
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
220
|
-
|
|
221
|
-
overflow-y: auto;
|
|
222
|
-
transition: all 0.3s ease;
|
|
168
|
+
overflow: hidden;
|
|
223
169
|
}
|
|
170
|
+
|
|
171
|
+
.tabbed-section.collapsed .tab-content { display: none; }
|
|
224
172
|
|
|
225
|
-
.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
173
|
+
.tabs {
|
|
174
|
+
display: flex;
|
|
175
|
+
border-bottom: 1px solid #dee2e6;
|
|
176
|
+
background: #f8f9fa;
|
|
229
177
|
}
|
|
230
178
|
|
|
231
|
-
.
|
|
232
|
-
|
|
179
|
+
.tab {
|
|
180
|
+
padding: 12px 20px;
|
|
181
|
+
cursor: pointer;
|
|
182
|
+
border: none;
|
|
183
|
+
background: transparent;
|
|
184
|
+
border-bottom: 2px solid transparent;
|
|
185
|
+
font-size: 14px;
|
|
186
|
+
font-weight: 500;
|
|
233
187
|
}
|
|
234
188
|
|
|
235
|
-
.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
align-items: center;
|
|
239
|
-
margin-bottom: 15px;
|
|
240
|
-
position: relative;
|
|
189
|
+
.tab.active {
|
|
190
|
+
border-bottom-color: #007bff;
|
|
191
|
+
color: #007bff;
|
|
241
192
|
}
|
|
242
193
|
|
|
243
|
-
.
|
|
244
|
-
|
|
194
|
+
.tab-content {
|
|
195
|
+
padding: 15px;
|
|
196
|
+
max-height: 400px;
|
|
197
|
+
overflow-y: auto;
|
|
245
198
|
}
|
|
246
199
|
|
|
247
|
-
.
|
|
248
|
-
|
|
249
|
-
font-size: 18px;
|
|
250
|
-
flex-grow: 1;
|
|
200
|
+
.tab-pane {
|
|
201
|
+
display: none;
|
|
251
202
|
}
|
|
252
203
|
|
|
253
|
-
.
|
|
204
|
+
.tab-pane.active {
|
|
205
|
+
display: block;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* History & Schema Lists */
|
|
209
|
+
ul.item-list {
|
|
254
210
|
list-style: none;
|
|
255
211
|
padding: 0;
|
|
256
212
|
margin: 0;
|
|
257
213
|
}
|
|
258
214
|
|
|
259
|
-
.
|
|
260
|
-
|
|
215
|
+
ul.item-list li {
|
|
216
|
+
padding: 10px;
|
|
217
|
+
border-bottom: 1px solid #eee;
|
|
218
|
+
cursor: pointer;
|
|
261
219
|
}
|
|
262
220
|
|
|
263
|
-
.
|
|
264
|
-
width: 100%;
|
|
221
|
+
ul.item-list li:hover {
|
|
265
222
|
background: #f8f9fa;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* Saved Queries Section */
|
|
226
|
+
.saved-section {
|
|
227
|
+
background: white;
|
|
228
|
+
border-radius: 8px;
|
|
229
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
230
|
+
padding: 15px;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.saved-section.collapsed .saved-content {
|
|
234
|
+
display: none;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.saved-header {
|
|
238
|
+
display: flex;
|
|
239
|
+
justify-content: space-between;
|
|
240
|
+
align-items: center;
|
|
241
|
+
margin-bottom: 10px;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.saved-header h4 {
|
|
245
|
+
margin: 0;
|
|
246
|
+
font-size: 14px;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.saved-query-item {
|
|
250
|
+
padding: 10px;
|
|
266
251
|
border: 1px solid #dee2e6;
|
|
267
252
|
border-radius: 4px;
|
|
268
|
-
|
|
269
|
-
|
|
253
|
+
margin-bottom: 8px;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.saved-query-item:hover {
|
|
257
|
+
background: #f8f9fa;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* Schema Explorer */
|
|
261
|
+
.schema-search {
|
|
262
|
+
width: 100%;
|
|
263
|
+
padding: 8px;
|
|
264
|
+
border: 1px solid #ddd;
|
|
265
|
+
border-radius: 4px;
|
|
266
|
+
margin-bottom: 10px;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.table-item {
|
|
270
|
+
padding: 8px;
|
|
270
271
|
cursor: pointer;
|
|
271
|
-
|
|
272
|
+
border-bottom: 1px solid #eee;
|
|
272
273
|
}
|
|
273
274
|
|
|
274
|
-
.
|
|
275
|
-
background: #
|
|
276
|
-
border-color: #007bff;
|
|
277
|
-
transform: translateX(2px);
|
|
275
|
+
.table-item:hover {
|
|
276
|
+
background: #f8f9fa;
|
|
278
277
|
}
|
|
279
278
|
|
|
280
|
-
.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
color: #495057;
|
|
284
|
-
margin-bottom: 5px;
|
|
285
|
-
white-space: nowrap;
|
|
286
|
-
overflow: hidden;
|
|
287
|
-
text-overflow: ellipsis;
|
|
279
|
+
.columns-list {
|
|
280
|
+
margin-top: 10px;
|
|
281
|
+
padding-left: 15px;
|
|
288
282
|
}
|
|
289
283
|
|
|
290
|
-
.
|
|
284
|
+
.column-item {
|
|
285
|
+
padding: 6px;
|
|
286
|
+
font-size: 13px;
|
|
287
|
+
border-left: 2px solid #007bff;
|
|
288
|
+
margin-bottom: 4px;
|
|
289
|
+
background: #f8f9fa;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* Quick Actions */
|
|
293
|
+
.quick-actions {
|
|
294
|
+
display: flex;
|
|
295
|
+
gap: 6px;
|
|
296
|
+
margin-top: 6px;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.quick-action-btn {
|
|
300
|
+
padding: 4px 8px;
|
|
291
301
|
font-size: 11px;
|
|
292
|
-
|
|
302
|
+
background: #e9ecef;
|
|
303
|
+
border: 1px solid #dee2e6;
|
|
304
|
+
border-radius: 3px;
|
|
305
|
+
cursor: pointer;
|
|
293
306
|
}
|
|
294
307
|
|
|
295
|
-
.
|
|
296
|
-
|
|
297
|
-
font-style: italic;
|
|
298
|
-
text-align: center;
|
|
299
|
-
padding: 20px;
|
|
308
|
+
.quick-action-btn:hover {
|
|
309
|
+
background: #dee2e6;
|
|
300
310
|
}
|
|
301
311
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
312
|
+
/* Results */
|
|
313
|
+
.results-container {
|
|
314
|
+
margin-top: 20px;
|
|
306
315
|
}
|
|
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
316
|
|
|
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>
|
|
317
|
+
.table-wrapper {
|
|
318
|
+
overflow: auto;
|
|
319
|
+
border: 1px solid #dee2e6;
|
|
320
|
+
border-radius: 4px;
|
|
321
|
+
max-height: 500px;
|
|
322
|
+
}
|
|
338
323
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
324
|
+
.results-table {
|
|
325
|
+
width: 100%;
|
|
326
|
+
border-collapse: collapse;
|
|
327
|
+
}
|
|
343
328
|
|
|
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>
|
|
329
|
+
.results-table thead {
|
|
330
|
+
position: sticky;
|
|
331
|
+
top: 0;
|
|
332
|
+
background: #f8f9fa;
|
|
333
|
+
z-index: 10;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.results-table th,
|
|
337
|
+
.results-table td {
|
|
338
|
+
padding: 8px 12px;
|
|
339
|
+
text-align: left;
|
|
340
|
+
border: 1px solid #dee2e6;
|
|
341
|
+
white-space: nowrap;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.results-table th {
|
|
345
|
+
font-weight: 600;
|
|
346
|
+
background: #e9ecef;
|
|
347
|
+
}
|
|
348
|
+
</style>
|
|
369
349
|
|
|
370
|
-
<!-- Load Hotwire and Stimulus from CDN -->
|
|
371
350
|
<script type="importmap">
|
|
372
351
|
{
|
|
373
352
|
"imports": {
|
|
@@ -377,45 +356,281 @@
|
|
|
377
356
|
}
|
|
378
357
|
</script>
|
|
379
358
|
|
|
380
|
-
<!-- Load Stimulus controllers inline -->
|
|
381
359
|
<script type="module">
|
|
382
360
|
import * as Turbo from "@hotwired/turbo-rails"
|
|
383
361
|
import { Application, Controller } from "@hotwired/stimulus"
|
|
384
|
-
|
|
385
|
-
// Make Turbo available globally
|
|
386
|
-
window.Turbo = Turbo
|
|
387
|
-
|
|
362
|
+
|
|
388
363
|
const application = Application.start()
|
|
389
|
-
|
|
390
|
-
|
|
364
|
+
|
|
391
365
|
// Collapsible Controller
|
|
392
366
|
class CollapsibleController extends Controller {
|
|
393
367
|
static values = { key: String }
|
|
394
368
|
|
|
395
369
|
connect() {
|
|
396
|
-
this.storageKey = `query_console.${this.keyValue}_collapsed`
|
|
397
370
|
this.loadState()
|
|
398
371
|
}
|
|
399
372
|
|
|
400
|
-
toggle(
|
|
401
|
-
event.preventDefault()
|
|
373
|
+
toggle() {
|
|
402
374
|
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 ? '▲' : '▼'
|
|
375
|
+
this.saveState()
|
|
407
376
|
}
|
|
408
377
|
|
|
409
378
|
loadState() {
|
|
410
|
-
const
|
|
411
|
-
|
|
379
|
+
const key = this.keyValue
|
|
380
|
+
const collapsed = localStorage.getItem(`qc.collapsed.${key}`) === 'true'
|
|
381
|
+
if (collapsed) {
|
|
412
382
|
this.element.classList.add('collapsed')
|
|
413
|
-
const button = this.element.querySelector('.section-toggle, .banner-toggle')
|
|
414
|
-
if (button) button.textContent = '▲'
|
|
415
383
|
}
|
|
416
384
|
}
|
|
385
|
+
|
|
386
|
+
saveState() {
|
|
387
|
+
const key = this.keyValue
|
|
388
|
+
const collapsed = this.element.classList.contains('collapsed')
|
|
389
|
+
localStorage.setItem(`qc.collapsed.${key}`, collapsed)
|
|
390
|
+
}
|
|
417
391
|
}
|
|
418
|
-
|
|
392
|
+
application.register("collapsible", CollapsibleController)
|
|
393
|
+
|
|
394
|
+
// Tabs Controller
|
|
395
|
+
class TabsController extends Controller {
|
|
396
|
+
static targets = ["tab", "pane"]
|
|
397
|
+
|
|
398
|
+
connect() {
|
|
399
|
+
this.showTab(0)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
select(event) {
|
|
403
|
+
const index = this.tabTargets.indexOf(event.currentTarget)
|
|
404
|
+
this.showTab(index)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
showTab(index) {
|
|
408
|
+
this.tabTargets.forEach((tab, i) => {
|
|
409
|
+
tab.classList.toggle('active', i === index)
|
|
410
|
+
})
|
|
411
|
+
this.paneTargets.forEach((pane, i) => {
|
|
412
|
+
pane.classList.toggle('active', i === index)
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
application.register("tabs", TabsController)
|
|
417
|
+
|
|
418
|
+
// Editor Controller (Simple Textarea)
|
|
419
|
+
class EditorController extends Controller {
|
|
420
|
+
static targets = ["textarea"]
|
|
421
|
+
|
|
422
|
+
getSql() {
|
|
423
|
+
return this.textareaTarget.value
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
setSql(text) {
|
|
427
|
+
this.textareaTarget.value = text
|
|
428
|
+
this.textareaTarget.focus()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
insertAtCursor(text) {
|
|
432
|
+
const textarea = this.textareaTarget
|
|
433
|
+
const start = textarea.selectionStart
|
|
434
|
+
const end = textarea.selectionEnd
|
|
435
|
+
const before = textarea.value.substring(0, start)
|
|
436
|
+
const after = textarea.value.substring(end)
|
|
437
|
+
|
|
438
|
+
textarea.value = before + text + after
|
|
439
|
+
textarea.selectionStart = textarea.selectionEnd = start + text.length
|
|
440
|
+
textarea.focus()
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
clearEditor() {
|
|
444
|
+
this.textareaTarget.value = ''
|
|
445
|
+
this.textareaTarget.focus()
|
|
446
|
+
|
|
447
|
+
// Clear query results
|
|
448
|
+
const queryFrame = document.querySelector('turbo-frame#query-results')
|
|
449
|
+
if (queryFrame) {
|
|
450
|
+
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>'
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Clear explain results
|
|
454
|
+
const explainFrame = document.querySelector('turbo-frame#explain-results')
|
|
455
|
+
if (explainFrame) {
|
|
456
|
+
explainFrame.innerHTML = ''
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
runQuery() {
|
|
461
|
+
const sql = this.getSql()
|
|
462
|
+
if (!sql.trim()) {
|
|
463
|
+
alert('Please enter a SQL query')
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Clear explain results when running query
|
|
468
|
+
const explainFrame = document.querySelector('turbo-frame#explain-results')
|
|
469
|
+
if (explainFrame) {
|
|
470
|
+
explainFrame.innerHTML = ''
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Store for history
|
|
474
|
+
window._lastExecutedSQL = sql
|
|
475
|
+
|
|
476
|
+
// Create form with Turbo Frame target
|
|
477
|
+
const form = document.createElement('form')
|
|
478
|
+
form.method = 'POST'
|
|
479
|
+
form.action = '<%= query_console.run_path %>'
|
|
480
|
+
form.setAttribute('data-turbo-frame', 'query-results')
|
|
481
|
+
form.innerHTML = `
|
|
482
|
+
<input type="hidden" name="sql" value="${sql.replace(/"/g, '"')}">
|
|
483
|
+
<input type="hidden" name="authenticity_token" value="${document.querySelector('meta[name=csrf-token]').content}">
|
|
484
|
+
`
|
|
485
|
+
document.body.appendChild(form)
|
|
486
|
+
form.requestSubmit()
|
|
487
|
+
document.body.removeChild(form)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
explainQuery() {
|
|
491
|
+
const sql = this.getSql()
|
|
492
|
+
if (!sql.trim()) {
|
|
493
|
+
alert('Please enter a SQL query')
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Clear query results when running explain
|
|
498
|
+
const queryFrame = document.querySelector('turbo-frame#query-results')
|
|
499
|
+
if (queryFrame) {
|
|
500
|
+
queryFrame.innerHTML = ''
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Create form with Turbo Frame target
|
|
504
|
+
const form = document.createElement('form')
|
|
505
|
+
form.method = 'POST'
|
|
506
|
+
form.action = '<%= query_console.explain_path %>'
|
|
507
|
+
form.setAttribute('data-turbo-frame', 'explain-results')
|
|
508
|
+
form.innerHTML = `
|
|
509
|
+
<input type="hidden" name="sql" value="${sql.replace(/"/g, '"')}">
|
|
510
|
+
<input type="hidden" name="authenticity_token" value="${document.querySelector('meta[name=csrf-token]').content}">
|
|
511
|
+
`
|
|
512
|
+
document.body.appendChild(form)
|
|
513
|
+
form.requestSubmit()
|
|
514
|
+
document.body.removeChild(form)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
application.register("editor", EditorController)
|
|
518
|
+
|
|
519
|
+
// Schema Controller
|
|
520
|
+
class SchemaController extends Controller {
|
|
521
|
+
static targets = ["search", "tablesList", "details"]
|
|
522
|
+
|
|
523
|
+
connect() {
|
|
524
|
+
this.loadTables()
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async loadTables() {
|
|
528
|
+
try {
|
|
529
|
+
const response = await fetch('<%= query_console.schema_tables_path %>')
|
|
530
|
+
this.tables = await response.json()
|
|
531
|
+
this.renderTables()
|
|
532
|
+
} catch (error) {
|
|
533
|
+
console.error('Failed to load tables:', error)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
filterTables(event) {
|
|
538
|
+
const query = event.target.value.toLowerCase()
|
|
539
|
+
this.renderTables(query)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
renderTables(filter = '') {
|
|
543
|
+
const filtered = filter ?
|
|
544
|
+
this.tables.filter(t => t.name.toLowerCase().includes(filter)) :
|
|
545
|
+
this.tables
|
|
546
|
+
|
|
547
|
+
this.tablesListTarget.innerHTML = filtered.map(table =>
|
|
548
|
+
`<div class="table-item" data-action="click->schema#selectTable" data-table-name="${table.name}">
|
|
549
|
+
📊 ${table.name} <small>(${table.kind})</small>
|
|
550
|
+
</div>`
|
|
551
|
+
).join('')
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async selectTable(event) {
|
|
555
|
+
const tableName = event.currentTarget.dataset.tableName
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
const response = await fetch(`<%= query_console.schema_tables_path %>/${tableName}`)
|
|
559
|
+
const tableData = await response.json()
|
|
560
|
+
this.renderTableDetails(tableData)
|
|
561
|
+
} catch (error) {
|
|
562
|
+
console.error('Failed to load table details:', error)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
renderTableDetails(table) {
|
|
567
|
+
const editor = this.application.getControllerForElementAndIdentifier(
|
|
568
|
+
document.querySelector('[data-controller="editor"]'),
|
|
569
|
+
'editor'
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
this.detailsTarget.innerHTML = `
|
|
573
|
+
<h5>${table.name}</h5>
|
|
574
|
+
<div class="quick-actions">
|
|
575
|
+
<button class="quick-action-btn" data-action="click->schema#insertSelectAll" data-table="${table.name}">
|
|
576
|
+
SELECT * FROM ${table.name}
|
|
577
|
+
</button>
|
|
578
|
+
<button class="quick-action-btn" data-action="click->schema#copyTableName" data-table="${table.name}">
|
|
579
|
+
📋 Copy Table Name
|
|
580
|
+
</button>
|
|
581
|
+
</div>
|
|
582
|
+
<div class="columns-list">
|
|
583
|
+
${table.columns.map(col => `
|
|
584
|
+
<div class="column-item">
|
|
585
|
+
<strong>${col.name}</strong> <code>${col.db_type}</code>
|
|
586
|
+
${col.nullable ? '<span>NULL</span>' : '<span>NOT NULL</span>'}
|
|
587
|
+
<div class="quick-actions">
|
|
588
|
+
<button class="quick-action-btn" data-action="click->schema#insertColumn" data-column="${col.name}">
|
|
589
|
+
Insert
|
|
590
|
+
</button>
|
|
591
|
+
<button class="quick-action-btn" data-action="click->schema#insertWhere" data-column="${col.name}">
|
|
592
|
+
WHERE
|
|
593
|
+
</button>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
`).join('')}
|
|
597
|
+
</div>
|
|
598
|
+
`
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
insertSelectAll(event) {
|
|
602
|
+
const table = event.currentTarget.dataset.table
|
|
603
|
+
const editor = this.getEditor()
|
|
604
|
+
editor.setSql(`SELECT * FROM ${table} LIMIT 100;`)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
insertColumn(event) {
|
|
608
|
+
const column = event.currentTarget.dataset.column
|
|
609
|
+
const editor = this.getEditor()
|
|
610
|
+
editor.insertAtCursor(column)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
insertWhere(event) {
|
|
614
|
+
const column = event.currentTarget.dataset.column
|
|
615
|
+
const editor = this.getEditor()
|
|
616
|
+
editor.insertAtCursor(`WHERE ${column} = `)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
copyTableName(event) {
|
|
620
|
+
const table = event.currentTarget.dataset.table
|
|
621
|
+
navigator.clipboard.writeText(table)
|
|
622
|
+
alert(`Copied: ${table}`)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
getEditor() {
|
|
626
|
+
return this.application.getControllerForElementAndIdentifier(
|
|
627
|
+
document.querySelector('[data-controller~="editor"]'),
|
|
628
|
+
'editor'
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
application.register("schema", SchemaController)
|
|
633
|
+
|
|
419
634
|
// History Controller
|
|
420
635
|
class HistoryController extends Controller {
|
|
421
636
|
static targets = ["list"]
|
|
@@ -425,38 +640,60 @@
|
|
|
425
640
|
}
|
|
426
641
|
|
|
427
642
|
connect() {
|
|
428
|
-
this.
|
|
429
|
-
document.addEventListener('editor:executed', (e) => this.
|
|
643
|
+
this.render()
|
|
644
|
+
document.addEventListener('editor:executed', (e) => this.addQuery(e.detail.sql))
|
|
430
645
|
}
|
|
431
646
|
|
|
432
|
-
|
|
647
|
+
addQuery(sql) {
|
|
433
648
|
const history = this.getHistory()
|
|
434
649
|
history.unshift({
|
|
435
|
-
sql:
|
|
436
|
-
timestamp:
|
|
650
|
+
sql: sql,
|
|
651
|
+
timestamp: new Date().toISOString()
|
|
437
652
|
})
|
|
438
653
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
654
|
+
if (history.length > this.maxItemsValue) {
|
|
655
|
+
history.pop()
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
this.saveHistory(history)
|
|
659
|
+
this.render()
|
|
442
660
|
}
|
|
443
661
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
662
|
+
render() {
|
|
663
|
+
const history = this.getHistory()
|
|
664
|
+
this.listTarget.innerHTML = history.length ?
|
|
665
|
+
history.map((item, index) => `
|
|
666
|
+
<li data-action="click->history#load" data-index="${index}">
|
|
667
|
+
<div style="font-size: 12px; color: #6c757d;">${new Date(item.timestamp).toLocaleString()}</div>
|
|
668
|
+
<div style="font-size: 13px; margin-top: 4px;">${this.truncate(item.sql, 100)}</div>
|
|
669
|
+
</li>
|
|
670
|
+
`).join('') :
|
|
671
|
+
'<li style="color: #6c757d; text-align: center; padding: 20px;">No query history</li>'
|
|
448
672
|
}
|
|
449
673
|
|
|
450
|
-
|
|
451
|
-
event.
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
674
|
+
load(event) {
|
|
675
|
+
const index = parseInt(event.currentTarget.dataset.index)
|
|
676
|
+
const history = this.getHistory()
|
|
677
|
+
const query = history[index]
|
|
678
|
+
|
|
679
|
+
if (query) {
|
|
680
|
+
const editor = this.getEditor()
|
|
681
|
+
editor.setSql(query.sql)
|
|
455
682
|
}
|
|
456
683
|
}
|
|
457
684
|
|
|
458
|
-
|
|
459
|
-
this.
|
|
685
|
+
getEditor() {
|
|
686
|
+
return this.application.getControllerForElementAndIdentifier(
|
|
687
|
+
document.querySelector('[data-controller~="editor"]'),
|
|
688
|
+
'editor'
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
clear() {
|
|
693
|
+
if (confirm('Clear all query history?')) {
|
|
694
|
+
localStorage.removeItem(this.storageKeyValue)
|
|
695
|
+
this.render()
|
|
696
|
+
}
|
|
460
697
|
}
|
|
461
698
|
|
|
462
699
|
getHistory() {
|
|
@@ -464,102 +701,257 @@
|
|
|
464
701
|
return stored ? JSON.parse(stored) : []
|
|
465
702
|
}
|
|
466
703
|
|
|
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('')
|
|
704
|
+
saveHistory(history) {
|
|
705
|
+
localStorage.setItem(this.storageKeyValue, JSON.stringify(history))
|
|
483
706
|
}
|
|
484
707
|
|
|
485
708
|
truncate(str, length) {
|
|
486
709
|
return str.length > length ? str.substring(0, length) + '...' : str
|
|
487
710
|
}
|
|
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
711
|
}
|
|
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' })
|
|
712
|
+
application.register("history", HistoryController)
|
|
713
|
+
|
|
714
|
+
// Saved Queries Controller
|
|
715
|
+
class SavedController extends Controller {
|
|
716
|
+
static targets = ["list"]
|
|
717
|
+
static values = {
|
|
718
|
+
storageKey: { type: String, default: "query_console.saved.v1" }
|
|
517
719
|
}
|
|
518
720
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
this.textareaTarget.value = ''
|
|
522
|
-
this.textareaTarget.focus()
|
|
721
|
+
connect() {
|
|
722
|
+
this.render()
|
|
523
723
|
}
|
|
524
724
|
|
|
525
|
-
|
|
526
|
-
const
|
|
725
|
+
save() {
|
|
726
|
+
const editor = this.getEditor()
|
|
727
|
+
const sql = editor.getSql()
|
|
527
728
|
|
|
528
|
-
if (!sql) {
|
|
529
|
-
|
|
530
|
-
alert('Please enter a SQL query')
|
|
729
|
+
if (!sql.trim()) {
|
|
730
|
+
alert('Nothing to save')
|
|
531
731
|
return
|
|
532
732
|
}
|
|
533
733
|
|
|
534
|
-
|
|
535
|
-
|
|
734
|
+
const name = prompt('Query name:')
|
|
735
|
+
if (!name) return
|
|
736
|
+
|
|
737
|
+
const tags = prompt('Tags (comma-separated, optional):')
|
|
536
738
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
739
|
+
const saved = this.getSaved()
|
|
740
|
+
saved.push({
|
|
741
|
+
id: Date.now().toString(),
|
|
742
|
+
name: name,
|
|
743
|
+
tags: tags ? tags.split(',').map(t => t.trim()) : [],
|
|
744
|
+
sql: sql,
|
|
745
|
+
createdAt: new Date().toISOString(),
|
|
746
|
+
updatedAt: new Date().toISOString()
|
|
747
|
+
})
|
|
540
748
|
|
|
541
|
-
|
|
749
|
+
this.saveSaved(saved)
|
|
750
|
+
this.render()
|
|
542
751
|
}
|
|
543
752
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
753
|
+
load(event) {
|
|
754
|
+
const id = event.currentTarget.dataset.id
|
|
755
|
+
const saved = this.getSaved()
|
|
756
|
+
const query = saved.find(q => q.id === id)
|
|
547
757
|
|
|
548
|
-
if (
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
sql: window._lastExecutedSQL,
|
|
552
|
-
timestamp: new Date().toISOString()
|
|
553
|
-
}
|
|
554
|
-
}))
|
|
555
|
-
delete window._lastExecutedSQL
|
|
758
|
+
if (query) {
|
|
759
|
+
const editor = this.getEditor()
|
|
760
|
+
editor.setSql(query.sql)
|
|
556
761
|
}
|
|
557
762
|
}
|
|
763
|
+
|
|
764
|
+
delete(event) {
|
|
765
|
+
if (!confirm('Delete this saved query?')) return
|
|
766
|
+
|
|
767
|
+
const id = event.currentTarget.dataset.id
|
|
768
|
+
const saved = this.getSaved().filter(q => q.id !== id)
|
|
769
|
+
this.saveSaved(saved)
|
|
770
|
+
this.render()
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
exportJSON() {
|
|
774
|
+
const saved = this.getSaved()
|
|
775
|
+
const json = JSON.stringify(saved, null, 2)
|
|
776
|
+
navigator.clipboard.writeText(json)
|
|
777
|
+
alert('Saved queries copied to clipboard!')
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
importJSON() {
|
|
781
|
+
const json = prompt('Paste saved queries JSON:')
|
|
782
|
+
if (!json) return
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
const imported = JSON.parse(json)
|
|
786
|
+
const saved = this.getSaved()
|
|
787
|
+
this.saveSaved([...saved, ...imported])
|
|
788
|
+
this.render()
|
|
789
|
+
alert(`Imported ${imported.length} queries`)
|
|
790
|
+
} catch (error) {
|
|
791
|
+
alert('Invalid JSON: ' + error.message)
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
render() {
|
|
796
|
+
const saved = this.getSaved()
|
|
797
|
+
this.listTarget.innerHTML = saved.length ?
|
|
798
|
+
saved.map(query => `
|
|
799
|
+
<div class="saved-query-item">
|
|
800
|
+
<strong>${query.name}</strong>
|
|
801
|
+
${query.tags.length ? `<div><small>🏷 ${query.tags.join(', ')}</small></div>` : ''}
|
|
802
|
+
<div style="font-size: 12px; color: #6c757d; margin: 4px 0;">
|
|
803
|
+
${new Date(query.updatedAt).toLocaleString()}
|
|
804
|
+
</div>
|
|
805
|
+
<div class="quick-actions">
|
|
806
|
+
<button class="quick-action-btn" data-action="click->saved#load" data-id="${query.id}">Load</button>
|
|
807
|
+
<button class="quick-action-btn" data-action="click->saved#delete" data-id="${query.id}">Delete</button>
|
|
808
|
+
</div>
|
|
809
|
+
</div>
|
|
810
|
+
`).join('') :
|
|
811
|
+
'<div style="color: #6c757d; text-align: center; padding: 20px;">No saved queries</div>'
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
getSaved() {
|
|
815
|
+
const stored = localStorage.getItem(this.storageKeyValue)
|
|
816
|
+
return stored ? JSON.parse(stored) : []
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
saveSaved(saved) {
|
|
820
|
+
localStorage.setItem(this.storageKeyValue, JSON.stringify(saved))
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
getEditor() {
|
|
824
|
+
return this.application.getControllerForElementAndIdentifier(
|
|
825
|
+
document.querySelector('[data-controller~="editor"]'),
|
|
826
|
+
'editor'
|
|
827
|
+
)
|
|
828
|
+
}
|
|
558
829
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
830
|
+
application.register("saved", SavedController)
|
|
831
|
+
</script>
|
|
832
|
+
</head>
|
|
833
|
+
<body>
|
|
834
|
+
<div class="container">
|
|
835
|
+
<!-- Banner -->
|
|
836
|
+
<div class="banner" data-controller="collapsible" data-collapsible-key-value="banner">
|
|
837
|
+
<h2>🔍 Read-Only SQL Query Console <small>v0.2.0</small></h2>
|
|
838
|
+
<div class="banner-content">
|
|
839
|
+
<p><strong>Security:</strong> Read-only SELECT & WITH queries only. All queries are logged.</p>
|
|
840
|
+
<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>
|
|
841
|
+
</div>
|
|
842
|
+
<button class="section-toggle" data-action="click->collapsible#toggle" type="button">▼</button>
|
|
843
|
+
</div>
|
|
844
|
+
|
|
845
|
+
<div class="main-layout">
|
|
846
|
+
<!-- Editor Section -->
|
|
847
|
+
<div class="editor-section" data-controller="editor collapsible" data-collapsible-key-value="editor_section">
|
|
848
|
+
<div class="editor-header">
|
|
849
|
+
<h3>SQL Editor</h3>
|
|
850
|
+
<div class="button-group">
|
|
851
|
+
<button class="btn-secondary" data-action="click->editor#clearEditor" type="button">Clear</button>
|
|
852
|
+
<button class="btn-secondary" data-action="click->editor#explainQuery" type="button">⚡ Explain</button>
|
|
853
|
+
<button class="btn-primary" data-action="click->editor#runQuery" type="button">▶ Run Query</button>
|
|
854
|
+
</div>
|
|
855
|
+
<button class="section-toggle" data-action="click->collapsible#toggle" title="Toggle editor" type="button">▼</button>
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<div class="editor-content" data-collapsible-target="content">
|
|
859
|
+
<!-- SQL Editor -->
|
|
860
|
+
<textarea
|
|
861
|
+
data-editor-target="textarea"
|
|
862
|
+
class="sql-editor"
|
|
863
|
+
placeholder="Enter your SELECT or WITH query here...
|
|
864
|
+
|
|
865
|
+
Examples:
|
|
866
|
+
SELECT * FROM users LIMIT 10;
|
|
867
|
+
SELECT id, name, email FROM users WHERE active = true;
|
|
868
|
+
|
|
869
|
+
Use the Schema Explorer to discover tables and columns!">SELECT * FROM users LIMIT 10;</textarea>
|
|
870
|
+
|
|
871
|
+
<!-- Results Area -->
|
|
872
|
+
<%= turbo_frame_tag "query-results" do %>
|
|
873
|
+
<div style="color: #6c757d; text-align: center; padding: 40px; margin-top: 20px;">
|
|
874
|
+
<p>Enter a query above and click "Run Query" to see results here.</p>
|
|
875
|
+
</div>
|
|
876
|
+
<% end %>
|
|
877
|
+
|
|
878
|
+
<!-- Explain Results Area -->
|
|
879
|
+
<%= turbo_frame_tag "explain-results" do %>
|
|
880
|
+
<% end %>
|
|
881
|
+
</div>
|
|
882
|
+
</div>
|
|
883
|
+
|
|
884
|
+
<!-- Right Panel -->
|
|
885
|
+
<div class="right-panel">
|
|
886
|
+
<!-- Tabbed Section (History / Schema / Saved Queries) -->
|
|
887
|
+
<div class="tabbed-section" data-controller="tabs collapsible" data-collapsible-key-value="tabs_section">
|
|
888
|
+
<div class="tabs" style="position: relative;">
|
|
889
|
+
<button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">📜 History</button>
|
|
890
|
+
<button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">📊 Schema</button>
|
|
891
|
+
<button class="tab" data-tabs-target="tab" data-action="click->tabs#select" type="button">💾 Saved</button>
|
|
892
|
+
<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>
|
|
893
|
+
</div>
|
|
894
|
+
|
|
895
|
+
<div class="tab-content" data-collapsible-target="content">
|
|
896
|
+
<!-- History Tab -->
|
|
897
|
+
<div class="tab-pane" data-tabs-target="pane">
|
|
898
|
+
<div data-controller="history">
|
|
899
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
|
900
|
+
<h4 style="margin: 0; font-size: 14px;">Recent Queries</h4>
|
|
901
|
+
<button class="quick-action-btn" data-action="click->history#clear" type="button">Clear</button>
|
|
902
|
+
</div>
|
|
903
|
+
<ul class="item-list" data-history-target="list"></ul>
|
|
904
|
+
</div>
|
|
905
|
+
</div>
|
|
906
|
+
|
|
907
|
+
<!-- Schema Tab -->
|
|
908
|
+
<div class="tab-pane" data-tabs-target="pane">
|
|
909
|
+
<div data-controller="schema">
|
|
910
|
+
<input
|
|
911
|
+
type="text"
|
|
912
|
+
class="schema-search"
|
|
913
|
+
placeholder="🔍 Search tables..."
|
|
914
|
+
data-schema-target="search"
|
|
915
|
+
data-action="input->schema#filterTables">
|
|
916
|
+
<div data-schema-target="tablesList"></div>
|
|
917
|
+
<div data-schema-target="details" style="margin-top: 15px;"></div>
|
|
918
|
+
</div>
|
|
919
|
+
</div>
|
|
920
|
+
|
|
921
|
+
<!-- Saved Queries Tab -->
|
|
922
|
+
<div class="tab-pane" data-tabs-target="pane">
|
|
923
|
+
<div data-controller="saved">
|
|
924
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
|
925
|
+
<h4 style="margin: 0; font-size: 14px;">Saved Queries</h4>
|
|
926
|
+
<div style="display: flex; gap: 5px;">
|
|
927
|
+
<button class="quick-action-btn" data-action="click->saved#save" type="button">💾 Save</button>
|
|
928
|
+
<button class="quick-action-btn" data-action="click->saved#exportJSON" type="button">📤 Export</button>
|
|
929
|
+
<button class="quick-action-btn" data-action="click->saved#importJSON" type="button">📥 Import</button>
|
|
930
|
+
</div>
|
|
931
|
+
</div>
|
|
932
|
+
<div data-saved-target="list"></div>
|
|
933
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
</div>
|
|
936
|
+
</div>
|
|
937
|
+
</div>
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
</div>
|
|
941
|
+
|
|
942
|
+
<script>
|
|
943
|
+
// Track query execution for history
|
|
944
|
+
document.addEventListener('turbo:submit-end', (event) => {
|
|
945
|
+
if (window._lastExecutedSQL) {
|
|
946
|
+
document.dispatchEvent(new CustomEvent('editor:executed', {
|
|
947
|
+
detail: {
|
|
948
|
+
sql: window._lastExecutedSQL,
|
|
949
|
+
timestamp: new Date().toISOString()
|
|
950
|
+
}
|
|
951
|
+
}))
|
|
952
|
+
delete window._lastExecutedSQL
|
|
953
|
+
}
|
|
954
|
+
})
|
|
563
955
|
</script>
|
|
564
956
|
</body>
|
|
565
957
|
</html>
|