query_console 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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +382 -0
- data/Rakefile +11 -0
- data/app/controllers/query_console/application_controller.rb +38 -0
- data/app/controllers/query_console/queries_controller.rb +43 -0
- data/app/javascript/query_console/application.js +20 -0
- data/app/javascript/query_console/controllers/collapsible_controller.js +42 -0
- data/app/javascript/query_console/controllers/editor_controller.js +77 -0
- data/app/javascript/query_console/controllers/history_controller.js +124 -0
- data/app/services/query_console/audit_logger.rb +50 -0
- data/app/services/query_console/runner.rb +89 -0
- data/app/services/query_console/sql_limiter.rb +48 -0
- data/app/services/query_console/sql_validator.rb +72 -0
- data/app/views/query_console/queries/_results.html.erb +191 -0
- data/app/views/query_console/queries/new.html.erb +565 -0
- data/config/importmap.rb +13 -0
- data/config/routes.rb +4 -0
- data/lib/generators/query_console/install/README +28 -0
- data/lib/generators/query_console/install/install_generator.rb +19 -0
- data/lib/generators/query_console/install/templates/query_console.rb +61 -0
- data/lib/query_console/configuration.rb +41 -0
- data/lib/query_console/engine.rb +29 -0
- data/lib/query_console/version.rb +3 -0
- data/lib/query_console.rb +7 -0
- metadata +159 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Query Console</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<meta name="turbo-refresh-method" content="morph">
|
|
8
|
+
<meta name="turbo-refresh-scroll" content="preserve">
|
|
9
|
+
|
|
10
|
+
<style>
|
|
11
|
+
* {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
17
|
+
margin: 0;
|
|
18
|
+
padding: 20px;
|
|
19
|
+
background: #f5f5f5;
|
|
20
|
+
color: #333;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.container {
|
|
24
|
+
max-width: 1400px;
|
|
25
|
+
margin: 0 auto;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.banner {
|
|
29
|
+
background: #fff3cd;
|
|
30
|
+
border: 1px solid #ffc107;
|
|
31
|
+
border-radius: 4px;
|
|
32
|
+
padding: 15px;
|
|
33
|
+
margin-bottom: 20px;
|
|
34
|
+
color: #856404;
|
|
35
|
+
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
|
+
}
|
|
50
|
+
|
|
51
|
+
.banner h2 {
|
|
52
|
+
margin: 0 0 10px 0;
|
|
53
|
+
font-size: 18px;
|
|
54
|
+
padding-right: 30px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.banner p {
|
|
58
|
+
margin: 5px 0;
|
|
59
|
+
font-size: 14px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.banner-toggle {
|
|
63
|
+
position: absolute;
|
|
64
|
+
top: 15px;
|
|
65
|
+
right: 15px;
|
|
66
|
+
background: transparent;
|
|
67
|
+
border: none;
|
|
68
|
+
color: #856404;
|
|
69
|
+
font-size: 20px;
|
|
70
|
+
cursor: pointer;
|
|
71
|
+
padding: 0;
|
|
72
|
+
width: 24px;
|
|
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);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.main-layout {
|
|
89
|
+
display: grid;
|
|
90
|
+
grid-template-columns: 1fr 300px;
|
|
91
|
+
gap: 20px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.editor-section {
|
|
95
|
+
background: white;
|
|
96
|
+
border-radius: 8px;
|
|
97
|
+
padding: 20px;
|
|
98
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
99
|
+
transition: all 0.3s ease;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.editor-section.collapsed {
|
|
103
|
+
padding: 15px 20px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.editor-section.collapsed .editor-content {
|
|
107
|
+
display: none;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.editor-header {
|
|
111
|
+
display: flex;
|
|
112
|
+
justify-content: space-between;
|
|
113
|
+
align-items: center;
|
|
114
|
+
margin-bottom: 15px;
|
|
115
|
+
position: relative;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.editor-section.collapsed .editor-header {
|
|
119
|
+
margin-bottom: 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.editor-header h3 {
|
|
123
|
+
margin: 0;
|
|
124
|
+
font-size: 20px;
|
|
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);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.button-group {
|
|
150
|
+
display: flex;
|
|
151
|
+
gap: 10px;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.btn-primary, .btn-secondary, .btn-danger {
|
|
155
|
+
padding: 10px 20px;
|
|
156
|
+
border: none;
|
|
157
|
+
border-radius: 4px;
|
|
158
|
+
font-size: 14px;
|
|
159
|
+
cursor: pointer;
|
|
160
|
+
transition: all 0.2s;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.btn-primary {
|
|
164
|
+
background: #007bff;
|
|
165
|
+
color: white;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.btn-primary:hover:not(:disabled) {
|
|
169
|
+
background: #0056b3;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.btn-primary:disabled {
|
|
173
|
+
background: #6c757d;
|
|
174
|
+
cursor: not-allowed;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.btn-secondary {
|
|
178
|
+
background: #6c757d;
|
|
179
|
+
color: white;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.btn-secondary:hover {
|
|
183
|
+
background: #545b62;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.btn-danger {
|
|
187
|
+
background: #dc3545;
|
|
188
|
+
color: white;
|
|
189
|
+
font-size: 12px;
|
|
190
|
+
padding: 5px 10px;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.btn-danger:hover {
|
|
194
|
+
background: #c82333;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
textarea {
|
|
198
|
+
width: 100%;
|
|
199
|
+
min-height: 200px;
|
|
200
|
+
padding: 15px;
|
|
201
|
+
border: 1px solid #ddd;
|
|
202
|
+
border-radius: 4px;
|
|
203
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
204
|
+
font-size: 14px;
|
|
205
|
+
resize: vertical;
|
|
206
|
+
margin-bottom: 15px;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
textarea:focus {
|
|
210
|
+
outline: none;
|
|
211
|
+
border-color: #007bff;
|
|
212
|
+
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.history-section {
|
|
216
|
+
background: white;
|
|
217
|
+
border-radius: 8px;
|
|
218
|
+
padding: 20px;
|
|
219
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
220
|
+
max-height: 800px;
|
|
221
|
+
overflow-y: auto;
|
|
222
|
+
transition: all 0.3s ease;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.history-section.collapsed {
|
|
226
|
+
padding: 15px 20px;
|
|
227
|
+
max-height: none;
|
|
228
|
+
overflow: visible;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.history-section.collapsed .history-content {
|
|
232
|
+
display: none;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.history-header {
|
|
236
|
+
display: flex;
|
|
237
|
+
justify-content: space-between;
|
|
238
|
+
align-items: center;
|
|
239
|
+
margin-bottom: 15px;
|
|
240
|
+
position: relative;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.history-section.collapsed .history-header {
|
|
244
|
+
margin-bottom: 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.history-header h3 {
|
|
248
|
+
margin: 0;
|
|
249
|
+
font-size: 18px;
|
|
250
|
+
flex-grow: 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.history-list {
|
|
254
|
+
list-style: none;
|
|
255
|
+
padding: 0;
|
|
256
|
+
margin: 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.history-item {
|
|
260
|
+
margin-bottom: 10px;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.history-item-button {
|
|
264
|
+
width: 100%;
|
|
265
|
+
background: #f8f9fa;
|
|
266
|
+
border: 1px solid #dee2e6;
|
|
267
|
+
border-radius: 4px;
|
|
268
|
+
padding: 10px;
|
|
269
|
+
text-align: left;
|
|
270
|
+
cursor: pointer;
|
|
271
|
+
transition: all 0.2s;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.history-item-button:hover {
|
|
275
|
+
background: #e9ecef;
|
|
276
|
+
border-color: #007bff;
|
|
277
|
+
transform: translateX(2px);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.history-item-sql {
|
|
281
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
282
|
+
font-size: 12px;
|
|
283
|
+
color: #495057;
|
|
284
|
+
margin-bottom: 5px;
|
|
285
|
+
white-space: nowrap;
|
|
286
|
+
overflow: hidden;
|
|
287
|
+
text-overflow: ellipsis;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.history-item-time {
|
|
291
|
+
font-size: 11px;
|
|
292
|
+
color: #6c757d;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.empty-history {
|
|
296
|
+
color: #6c757d;
|
|
297
|
+
font-style: italic;
|
|
298
|
+
text-align: center;
|
|
299
|
+
padding: 20px;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
@media (max-width: 768px) {
|
|
303
|
+
.main-layout {
|
|
304
|
+
grid-template-columns: 1fr;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
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
|
+
|
|
322
|
+
<div class="main-layout">
|
|
323
|
+
<!-- SQL Editor Section - Collapsible -->
|
|
324
|
+
<%= form_with url: query_console.run_path, method: :post, data: {
|
|
325
|
+
turbo_frame: "query-results",
|
|
326
|
+
controller: "editor",
|
|
327
|
+
action: "turbo:submit-end->editor#querySuccess turbo:frame-render->editor#querySuccess submit->editor#handleSubmit"
|
|
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>
|
|
338
|
+
|
|
339
|
+
<div class="editor-content">
|
|
340
|
+
<%= f.text_area :sql,
|
|
341
|
+
placeholder: "Enter your SELECT query here...\n\nExample:\nSELECT * FROM users LIMIT 10;",
|
|
342
|
+
data: { editor_target: "textarea" } %>
|
|
343
|
+
|
|
344
|
+
<!-- Turbo Frame for Results -->
|
|
345
|
+
<%= turbo_frame_tag "query-results", data: { editor_target: "results" } do %>
|
|
346
|
+
<div style="color: #6c757d; text-align: center; padding: 40px;">
|
|
347
|
+
<p>Enter a query above and click "Run Query" to see results here.</p>
|
|
348
|
+
</div>
|
|
349
|
+
<% end %>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
<% end %>
|
|
353
|
+
|
|
354
|
+
<!-- Query History Section - Collapsible -->
|
|
355
|
+
<div class="history-section" id="history-section" data-controller="history collapsible" data-collapsible-key-value="history_section">
|
|
356
|
+
<div class="history-header">
|
|
357
|
+
<h3>Query History</h3>
|
|
358
|
+
<button data-action="click->history#clear" class="btn-danger">Clear</button>
|
|
359
|
+
<button class="section-toggle" data-action="click->collapsible#toggle" title="Toggle history">▼</button>
|
|
360
|
+
</div>
|
|
361
|
+
<div class="history-content">
|
|
362
|
+
<ul class="history-list" data-history-target="list">
|
|
363
|
+
<li class="empty-history">No query history yet</li>
|
|
364
|
+
</ul>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<!-- Load Hotwire and Stimulus from CDN -->
|
|
371
|
+
<script type="importmap">
|
|
372
|
+
{
|
|
373
|
+
"imports": {
|
|
374
|
+
"@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"
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
</script>
|
|
379
|
+
|
|
380
|
+
<!-- Load Stimulus controllers inline -->
|
|
381
|
+
<script type="module">
|
|
382
|
+
import * as Turbo from "@hotwired/turbo-rails"
|
|
383
|
+
import { Application, Controller } from "@hotwired/stimulus"
|
|
384
|
+
|
|
385
|
+
// Make Turbo available globally
|
|
386
|
+
window.Turbo = Turbo
|
|
387
|
+
|
|
388
|
+
const application = Application.start()
|
|
389
|
+
window.Stimulus = application
|
|
390
|
+
|
|
391
|
+
// Collapsible Controller
|
|
392
|
+
class CollapsibleController extends Controller {
|
|
393
|
+
static values = { key: String }
|
|
394
|
+
|
|
395
|
+
connect() {
|
|
396
|
+
this.storageKey = `query_console.${this.keyValue}_collapsed`
|
|
397
|
+
this.loadState()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
toggle(event) {
|
|
401
|
+
event.preventDefault()
|
|
402
|
+
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 ? '▲' : '▼'
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
loadState() {
|
|
410
|
+
const isCollapsed = localStorage.getItem(this.storageKey) === 'true'
|
|
411
|
+
if (isCollapsed) {
|
|
412
|
+
this.element.classList.add('collapsed')
|
|
413
|
+
const button = this.element.querySelector('.section-toggle, .banner-toggle')
|
|
414
|
+
if (button) button.textContent = '▲'
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// History Controller
|
|
420
|
+
class HistoryController extends Controller {
|
|
421
|
+
static targets = ["list"]
|
|
422
|
+
static values = {
|
|
423
|
+
storageKey: { type: String, default: "query_console.history.v1" },
|
|
424
|
+
maxItems: { type: Number, default: 20 }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
connect() {
|
|
428
|
+
this.loadHistory()
|
|
429
|
+
document.addEventListener('editor:executed', (e) => this.add(e))
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
add(event) {
|
|
433
|
+
const history = this.getHistory()
|
|
434
|
+
history.unshift({
|
|
435
|
+
sql: event.detail.sql.trim(),
|
|
436
|
+
timestamp: event.detail.timestamp
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
const trimmed = history.slice(0, this.maxItemsValue)
|
|
440
|
+
localStorage.setItem(this.storageKeyValue, JSON.stringify(trimmed))
|
|
441
|
+
this.renderHistory(trimmed)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
load(event) {
|
|
445
|
+
event.preventDefault()
|
|
446
|
+
const sql = event.currentTarget.dataset.sql
|
|
447
|
+
document.dispatchEvent(new CustomEvent('history:load', { detail: { sql } }))
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
clear(event) {
|
|
451
|
+
event.preventDefault()
|
|
452
|
+
if (confirm("Clear all query history?")) {
|
|
453
|
+
localStorage.removeItem(this.storageKeyValue)
|
|
454
|
+
this.renderHistory([])
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
loadHistory() {
|
|
459
|
+
this.renderHistory(this.getHistory())
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
getHistory() {
|
|
463
|
+
const stored = localStorage.getItem(this.storageKeyValue)
|
|
464
|
+
return stored ? JSON.parse(stored) : []
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
renderHistory(history) {
|
|
468
|
+
if (history.length === 0) {
|
|
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('')
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
truncate(str, length) {
|
|
486
|
+
return str.length > length ? str.substring(0, length) + '...' : str
|
|
487
|
+
}
|
|
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
|
+
}
|
|
504
|
+
|
|
505
|
+
// Editor Controller
|
|
506
|
+
class EditorController extends Controller {
|
|
507
|
+
static targets = ["textarea", "runButton"]
|
|
508
|
+
|
|
509
|
+
connect() {
|
|
510
|
+
document.addEventListener('history:load', (e) => this.loadQuery(e.detail.sql))
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
loadQuery(sql) {
|
|
514
|
+
this.textareaTarget.value = sql
|
|
515
|
+
this.textareaTarget.focus()
|
|
516
|
+
this.element.scrollIntoView({ behavior: 'smooth' })
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
clear(event) {
|
|
520
|
+
event.preventDefault()
|
|
521
|
+
this.textareaTarget.value = ''
|
|
522
|
+
this.textareaTarget.focus()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
handleSubmit(event) {
|
|
526
|
+
const sql = this.textareaTarget.value.trim()
|
|
527
|
+
|
|
528
|
+
if (!sql) {
|
|
529
|
+
event.preventDefault()
|
|
530
|
+
alert('Please enter a SQL query')
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Store SQL for after execution
|
|
535
|
+
window._lastExecutedSQL = sql
|
|
536
|
+
|
|
537
|
+
// Show loading state
|
|
538
|
+
this.runButtonTarget.disabled = true
|
|
539
|
+
this.runButtonTarget.value = 'Running...'
|
|
540
|
+
|
|
541
|
+
// Let form submit naturally (Turbo will intercept it)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
querySuccess() {
|
|
545
|
+
this.runButtonTarget.disabled = false
|
|
546
|
+
this.runButtonTarget.value = 'Run Query'
|
|
547
|
+
|
|
548
|
+
if (window._lastExecutedSQL) {
|
|
549
|
+
document.dispatchEvent(new CustomEvent('editor:executed', {
|
|
550
|
+
detail: {
|
|
551
|
+
sql: window._lastExecutedSQL,
|
|
552
|
+
timestamp: new Date().toISOString()
|
|
553
|
+
}
|
|
554
|
+
}))
|
|
555
|
+
delete window._lastExecutedSQL
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
application.register("collapsible", CollapsibleController)
|
|
561
|
+
application.register("history", HistoryController)
|
|
562
|
+
application.register("editor", EditorController)
|
|
563
|
+
</script>
|
|
564
|
+
</body>
|
|
565
|
+
</html>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Configure JavaScript module imports for QueryConsole engine
|
|
2
|
+
# This uses importmap-rails to manage JavaScript dependencies without bundling
|
|
3
|
+
|
|
4
|
+
# Pin Hotwire dependencies
|
|
5
|
+
pin "@hotwired/turbo-rails", to: "turbo.min.js"
|
|
6
|
+
pin "@hotwired/stimulus", to: "stimulus.min.js"
|
|
7
|
+
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
|
8
|
+
|
|
9
|
+
# Pin application and controllers
|
|
10
|
+
pin "query_console/application", to: "query_console/application.js"
|
|
11
|
+
pin_all_from File.expand_path("../app/javascript/controllers/query_console", __dir__),
|
|
12
|
+
under: "controllers/query_console",
|
|
13
|
+
to: "query_console/controllers"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
===============================================================================
|
|
2
|
+
|
|
3
|
+
QueryConsole has been installed!
|
|
4
|
+
|
|
5
|
+
Next steps:
|
|
6
|
+
|
|
7
|
+
1. Configure authorization in config/initializers/query_console.rb
|
|
8
|
+
You MUST set the authorize hook or access will be denied.
|
|
9
|
+
|
|
10
|
+
2. Mount the engine in your config/routes.rb:
|
|
11
|
+
|
|
12
|
+
mount QueryConsole::Engine, at: "/query_console"
|
|
13
|
+
|
|
14
|
+
3. Start your Rails server and visit:
|
|
15
|
+
http://localhost:3000/query_console
|
|
16
|
+
|
|
17
|
+
Security Notes:
|
|
18
|
+
- By default, only enabled in development environment
|
|
19
|
+
- Requires explicit authorization hook configuration
|
|
20
|
+
- All queries are logged with actor information
|
|
21
|
+
- Only SELECT/WITH queries allowed (read-only)
|
|
22
|
+
- Multiple statements blocked
|
|
23
|
+
- Results limited to prevent resource exhaustion
|
|
24
|
+
|
|
25
|
+
For more information, see:
|
|
26
|
+
https://github.com/JohnsonGnanasekar/query_console
|
|
27
|
+
|
|
28
|
+
===============================================================================
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require 'rails/generators'
|
|
2
|
+
|
|
3
|
+
module QueryConsole
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path('templates', __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Creates a QueryConsole initializer in your application."
|
|
9
|
+
|
|
10
|
+
def copy_initializer
|
|
11
|
+
template "query_console.rb", "config/initializers/query_console.rb"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show_readme
|
|
15
|
+
readme "README" if behavior == :invoke
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# QueryConsole Configuration
|
|
2
|
+
#
|
|
3
|
+
# This initializer configures the QueryConsole engine for your application.
|
|
4
|
+
# By default, the console is ONLY enabled in development environment.
|
|
5
|
+
|
|
6
|
+
QueryConsole.configure do |config|
|
|
7
|
+
# Environments where the console is enabled
|
|
8
|
+
# Default: ["development"]
|
|
9
|
+
# Uncomment to enable in additional environments (use with caution!)
|
|
10
|
+
# config.enabled_environments = %w[development staging]
|
|
11
|
+
|
|
12
|
+
# Authorization hook - REQUIRED for security
|
|
13
|
+
# This lambda/proc receives the controller and must return true to allow access
|
|
14
|
+
# Default: nil (denies all access - you MUST configure this)
|
|
15
|
+
#
|
|
16
|
+
# Example with Devise:
|
|
17
|
+
# config.authorize = ->(controller) {
|
|
18
|
+
# controller.current_user&.admin?
|
|
19
|
+
# }
|
|
20
|
+
#
|
|
21
|
+
# Example with basic authentication:
|
|
22
|
+
# config.authorize = ->(controller) {
|
|
23
|
+
# controller.authenticate_or_request_with_http_basic do |username, password|
|
|
24
|
+
# username == "admin" && password == Rails.application.credentials.query_console_password
|
|
25
|
+
# end
|
|
26
|
+
# }
|
|
27
|
+
#
|
|
28
|
+
# For development without authentication (NOT RECOMMENDED FOR PRODUCTION):
|
|
29
|
+
# config.authorize = ->(_controller) { true }
|
|
30
|
+
|
|
31
|
+
# Track who ran each query (for audit logs)
|
|
32
|
+
# Default: ->(_controller) { "unknown" }
|
|
33
|
+
#
|
|
34
|
+
# config.current_actor = ->(controller) {
|
|
35
|
+
# controller.current_user&.email || "anonymous"
|
|
36
|
+
# }
|
|
37
|
+
|
|
38
|
+
# Maximum number of rows to return
|
|
39
|
+
# Default: 500
|
|
40
|
+
# config.max_rows = 1000
|
|
41
|
+
|
|
42
|
+
# Query timeout in milliseconds
|
|
43
|
+
# Default: 3000 (3 seconds)
|
|
44
|
+
# config.timeout_ms = 5000
|
|
45
|
+
|
|
46
|
+
# Forbidden SQL keywords (in addition to defaults)
|
|
47
|
+
# Default includes: update, delete, insert, drop, alter, create, grant, revoke, truncate, etc.
|
|
48
|
+
# config.forbidden_keywords += %w[your_custom_keyword]
|
|
49
|
+
|
|
50
|
+
# Allowed query starting keywords
|
|
51
|
+
# Default: %w[select with]
|
|
52
|
+
# config.allowed_starts_with = %w[select with explain]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# IMPORTANT: Mount the engine in your routes.rb:
|
|
56
|
+
#
|
|
57
|
+
# Rails.application.routes.draw do
|
|
58
|
+
# mount QueryConsole::Engine, at: "/query_console"
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
# Then visit: http://localhost:3000/query_console
|