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.
@@ -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>
@@ -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,4 @@
1
+ QueryConsole::Engine.routes.draw do
2
+ root to: "queries#new"
3
+ post "run", to: "queries#run"
4
+ end
@@ -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