sqlite_dashboard 1.0.0 → 1.0.2
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 +111 -34
- data/app/controllers/sqlite_dashboard/databases_controller.rb +51 -0
- data/app/javascript/controllers/saved_queries_controller.js +379 -0
- data/app/models/sqlite_dashboard/application_record.rb +7 -0
- data/app/models/sqlite_dashboard/saved_query.rb +14 -0
- data/app/views/layouts/sqlite_dashboard/application.html.erb +512 -4
- data/app/views/sqlite_dashboard/databases/index.html.erb +53 -4
- data/app/views/sqlite_dashboard/databases/show.html.erb +36 -2
- data/app/views/sqlite_dashboard/databases/worksheet.html.erb +111 -0
- data/config/routes.rb +9 -0
- data/db/migrate/20250101000001_create_sqlite_dashboard_saved_queries.rb +17 -0
- data/lib/generators/sqlite_dashboard/install_generator.rb +4 -0
- data/lib/generators/sqlite_dashboard/templates/README +48 -17
- data/lib/generators/sqlite_dashboard/templates/initializer.rb +29 -1
- data/lib/sqlite_dashboard/version.rb +1 -1
- data/sqlite_dashboard.gemspec +1 -0
- metadata +22 -6
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = [
|
|
5
|
+
"queryInput",
|
|
6
|
+
"queryName",
|
|
7
|
+
"queryDescription",
|
|
8
|
+
"databaseSelector",
|
|
9
|
+
"list",
|
|
10
|
+
"saveError"
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
console.log("SavedQueries controller connected")
|
|
15
|
+
this.loadSavedQueries()
|
|
16
|
+
this.initializeCodeMirror()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
initializeCodeMirror() {
|
|
20
|
+
if (typeof CodeMirror !== 'undefined' && this.hasQueryInputTarget) {
|
|
21
|
+
this.editor = CodeMirror.fromTextArea(this.queryInputTarget, {
|
|
22
|
+
mode: 'text/x-sql',
|
|
23
|
+
theme: 'default',
|
|
24
|
+
lineNumbers: true,
|
|
25
|
+
lineWrapping: true,
|
|
26
|
+
autoCloseBrackets: true,
|
|
27
|
+
matchBrackets: true,
|
|
28
|
+
indentWithTabs: true,
|
|
29
|
+
smartIndent: true,
|
|
30
|
+
extraKeys: {
|
|
31
|
+
"Ctrl-Enter": () => this.execute(),
|
|
32
|
+
"Cmd-Enter": () => this.execute(),
|
|
33
|
+
"Ctrl-Space": "autocomplete"
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async loadSavedQueries() {
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch('/sqlite_dashboard/saved_queries')
|
|
42
|
+
const queries = await response.json()
|
|
43
|
+
this.renderSavedQueries(queries)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('Error loading saved queries:', error)
|
|
46
|
+
if (this.hasListTarget) {
|
|
47
|
+
this.listTarget.innerHTML = `
|
|
48
|
+
<div class="text-danger small text-center py-3">
|
|
49
|
+
<i class="fas fa-exclamation-triangle"></i> Error loading queries
|
|
50
|
+
</div>
|
|
51
|
+
`
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
renderSavedQueries(queries) {
|
|
57
|
+
if (!this.hasListTarget) return
|
|
58
|
+
|
|
59
|
+
if (queries.length === 0) {
|
|
60
|
+
this.listTarget.innerHTML = `
|
|
61
|
+
<div class="text-muted small text-center py-3">
|
|
62
|
+
No saved queries
|
|
63
|
+
</div>
|
|
64
|
+
`
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const html = queries.map(query => `
|
|
69
|
+
<div class="saved-query-item" data-query-id="${query.id}">
|
|
70
|
+
<div class="d-flex justify-content-between align-items-start">
|
|
71
|
+
<div class="flex-grow-1" style="cursor: pointer;" data-action="click->saved-queries#loadQuery" data-query='${JSON.stringify(query)}'>
|
|
72
|
+
<div class="fw-bold small">${this.escapeHtml(query.name)}</div>
|
|
73
|
+
${query.description ? `<div class="text-muted" style="font-size: 0.75rem;">${this.escapeHtml(query.description)}</div>` : ''}
|
|
74
|
+
${query.database_name ? `<div class="text-info" style="font-size: 0.7rem;"><i class="fas fa-database"></i> ${this.escapeHtml(query.database_name)}</div>` : ''}
|
|
75
|
+
</div>
|
|
76
|
+
<button class="btn btn-sm btn-link text-danger p-0" data-action="click->saved-queries#deleteQuery" data-query-id="${query.id}">
|
|
77
|
+
<i class="fas fa-trash"></i>
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
`).join('')
|
|
82
|
+
|
|
83
|
+
this.listTarget.innerHTML = html
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
escapeHtml(text) {
|
|
87
|
+
const div = document.createElement('div')
|
|
88
|
+
div.textContent = text
|
|
89
|
+
return div.innerHTML
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async execute() {
|
|
93
|
+
const databaseId = this.databaseSelectorTarget.value
|
|
94
|
+
if (!databaseId) {
|
|
95
|
+
alert('Please select a database first')
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const query = this.editor ? this.editor.getValue() : this.queryInputTarget.value
|
|
100
|
+
if (!query.trim()) {
|
|
101
|
+
alert('Please enter a query')
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetch(`/sqlite_dashboard/databases/${databaseId}/execute_query`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({ query })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const data = await response.json()
|
|
116
|
+
|
|
117
|
+
if (data.error) {
|
|
118
|
+
this.renderError(data.error)
|
|
119
|
+
} else {
|
|
120
|
+
this.renderResults(data, query, databaseId)
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error('Error executing query:', error)
|
|
124
|
+
this.renderError(error.message)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
renderResults(data, query, databaseId) {
|
|
129
|
+
const resultsContainer = document.getElementById('worksheet-results')
|
|
130
|
+
|
|
131
|
+
if (data.message) {
|
|
132
|
+
resultsContainer.innerHTML = `
|
|
133
|
+
<div class="alert alert-success">
|
|
134
|
+
<i class="fas fa-check-circle"></i> ${data.message}
|
|
135
|
+
</div>
|
|
136
|
+
`
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const { columns, rows } = data
|
|
141
|
+
|
|
142
|
+
let html = `
|
|
143
|
+
<div class="results-header">
|
|
144
|
+
<h6>Query Results</h6>
|
|
145
|
+
<div class="text-muted small">${rows.length} row(s) returned</div>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="table-responsive">
|
|
148
|
+
<table class="table table-striped table-hover">
|
|
149
|
+
<thead>
|
|
150
|
+
<tr>
|
|
151
|
+
${columns.map(col => `<th>${this.escapeHtml(col)}</th>`).join('')}
|
|
152
|
+
</tr>
|
|
153
|
+
</thead>
|
|
154
|
+
<tbody>
|
|
155
|
+
${rows.map(row => `
|
|
156
|
+
<tr>
|
|
157
|
+
${row.map(cell => `<td>${cell !== null ? this.escapeHtml(String(cell)) : '<span class="text-muted">NULL</span>'}</td>`).join('')}
|
|
158
|
+
</tr>
|
|
159
|
+
`).join('')}
|
|
160
|
+
</tbody>
|
|
161
|
+
</table>
|
|
162
|
+
</div>
|
|
163
|
+
`
|
|
164
|
+
|
|
165
|
+
resultsContainer.innerHTML = html
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
renderError(error) {
|
|
169
|
+
const resultsContainer = document.getElementById('worksheet-results')
|
|
170
|
+
resultsContainer.innerHTML = `
|
|
171
|
+
<div class="alert alert-danger">
|
|
172
|
+
<i class="fas fa-exclamation-circle"></i> <strong>Error:</strong> ${this.escapeHtml(error)}
|
|
173
|
+
</div>
|
|
174
|
+
`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
clear() {
|
|
178
|
+
if (this.editor) {
|
|
179
|
+
this.editor.setValue('')
|
|
180
|
+
} else {
|
|
181
|
+
this.queryInputTarget.value = ''
|
|
182
|
+
}
|
|
183
|
+
document.getElementById('worksheet-results').innerHTML = `
|
|
184
|
+
<div class="text-muted text-center py-5">
|
|
185
|
+
<i class="fas fa-database fa-3x mb-3"></i>
|
|
186
|
+
<p>Select a database and execute a query to see results</p>
|
|
187
|
+
</div>
|
|
188
|
+
`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async saveQuery() {
|
|
192
|
+
const name = this.queryNameTarget.value.trim()
|
|
193
|
+
const description = this.queryDescriptionTarget.value.trim()
|
|
194
|
+
const query = this.editor ? this.editor.getValue() : this.queryInputTarget.value
|
|
195
|
+
const databaseId = this.databaseSelectorTarget.value
|
|
196
|
+
|
|
197
|
+
// Get database name from selector
|
|
198
|
+
const databaseName = databaseId ?
|
|
199
|
+
this.databaseSelectorTarget.options[this.databaseSelectorTarget.selectedIndex].text :
|
|
200
|
+
null
|
|
201
|
+
|
|
202
|
+
if (!name) {
|
|
203
|
+
this.showSaveError('Query name is required')
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!query.trim()) {
|
|
208
|
+
this.showSaveError('Query cannot be empty')
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch('/sqlite_dashboard/saved_queries', {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
headers: {
|
|
216
|
+
'Content-Type': 'application/json',
|
|
217
|
+
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
|
|
218
|
+
},
|
|
219
|
+
body: JSON.stringify({
|
|
220
|
+
saved_query: {
|
|
221
|
+
name,
|
|
222
|
+
description,
|
|
223
|
+
query,
|
|
224
|
+
database_name: databaseName
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const data = await response.json()
|
|
230
|
+
|
|
231
|
+
if (response.ok) {
|
|
232
|
+
// Close modal
|
|
233
|
+
const modal = bootstrap.Modal.getInstance(document.getElementById('saveQueryModal'))
|
|
234
|
+
modal.hide()
|
|
235
|
+
|
|
236
|
+
// Clear form
|
|
237
|
+
this.queryNameTarget.value = ''
|
|
238
|
+
this.queryDescriptionTarget.value = ''
|
|
239
|
+
this.hideSaveError()
|
|
240
|
+
|
|
241
|
+
// Reload saved queries
|
|
242
|
+
await this.loadSavedQueries()
|
|
243
|
+
|
|
244
|
+
// Show success message
|
|
245
|
+
alert('Query saved successfully!')
|
|
246
|
+
} else {
|
|
247
|
+
this.showSaveError(data.error || 'Failed to save query')
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.error('Error saving query:', error)
|
|
251
|
+
this.showSaveError(error.message)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
showSaveError(message) {
|
|
256
|
+
if (this.hasSaveErrorTarget) {
|
|
257
|
+
this.saveErrorTarget.textContent = message
|
|
258
|
+
this.saveErrorTarget.classList.remove('d-none')
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
hideSaveError() {
|
|
263
|
+
if (this.hasSaveErrorTarget) {
|
|
264
|
+
this.saveErrorTarget.classList.add('d-none')
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
loadQuery(event) {
|
|
269
|
+
const queryData = JSON.parse(event.currentTarget.dataset.query)
|
|
270
|
+
|
|
271
|
+
if (this.editor) {
|
|
272
|
+
this.editor.setValue(queryData.query)
|
|
273
|
+
} else {
|
|
274
|
+
this.queryInputTarget.value = queryData.query
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// If database_name is present, try to select it
|
|
278
|
+
if (queryData.database_name && this.hasDatabaseSelectorTarget) {
|
|
279
|
+
const option = Array.from(this.databaseSelectorTarget.options)
|
|
280
|
+
.find(opt => opt.text === queryData.database_name)
|
|
281
|
+
if (option) {
|
|
282
|
+
this.databaseSelectorTarget.value = option.value
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async deleteQuery(event) {
|
|
288
|
+
event.stopPropagation()
|
|
289
|
+
const queryId = event.currentTarget.dataset.queryId
|
|
290
|
+
|
|
291
|
+
if (!confirm('Are you sure you want to delete this saved query?')) {
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const response = await fetch(`/sqlite_dashboard/saved_queries/${queryId}`, {
|
|
297
|
+
method: 'DELETE',
|
|
298
|
+
headers: {
|
|
299
|
+
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
if (response.ok) {
|
|
304
|
+
await this.loadSavedQueries()
|
|
305
|
+
} else {
|
|
306
|
+
const data = await response.json()
|
|
307
|
+
alert(data.error || 'Failed to delete query')
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
console.error('Error deleting query:', error)
|
|
311
|
+
alert('Failed to delete query')
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
refresh() {
|
|
316
|
+
this.loadSavedQueries()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async exportCSV() {
|
|
320
|
+
const databaseId = this.databaseSelectorTarget.value
|
|
321
|
+
if (!databaseId) {
|
|
322
|
+
alert('Please select a database first')
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const query = this.editor ? this.editor.getValue() : this.queryInputTarget.value
|
|
327
|
+
if (!query.trim()) {
|
|
328
|
+
alert('Please enter a query')
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// For CSV export, we'll make a form submission since we need to download a file
|
|
333
|
+
const form = document.createElement('form')
|
|
334
|
+
form.method = 'POST'
|
|
335
|
+
form.action = `/sqlite_dashboard/databases/${databaseId}/export_csv`
|
|
336
|
+
|
|
337
|
+
const csrfToken = document.querySelector('[name="csrf-token"]').content
|
|
338
|
+
form.innerHTML = `
|
|
339
|
+
<input type="hidden" name="authenticity_token" value="${csrfToken}">
|
|
340
|
+
<input type="hidden" name="query" value="${this.escapeHtml(query)}">
|
|
341
|
+
<input type="hidden" name="include_headers" value="true">
|
|
342
|
+
`
|
|
343
|
+
|
|
344
|
+
document.body.appendChild(form)
|
|
345
|
+
form.submit()
|
|
346
|
+
document.body.removeChild(form)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async exportJSON() {
|
|
350
|
+
const databaseId = this.databaseSelectorTarget.value
|
|
351
|
+
if (!databaseId) {
|
|
352
|
+
alert('Please select a database first')
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const query = this.editor ? this.editor.getValue() : this.queryInputTarget.value
|
|
357
|
+
if (!query.trim()) {
|
|
358
|
+
alert('Please enter a query')
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// For JSON export, we'll make a form submission since we need to download a file
|
|
363
|
+
const form = document.createElement('form')
|
|
364
|
+
form.method = 'POST'
|
|
365
|
+
form.action = `/sqlite_dashboard/databases/${databaseId}/export_json`
|
|
366
|
+
|
|
367
|
+
const csrfToken = document.querySelector('[name="csrf-token"]').content
|
|
368
|
+
form.innerHTML = `
|
|
369
|
+
<input type="hidden" name="authenticity_token" value="${csrfToken}">
|
|
370
|
+
<input type="hidden" name="query" value="${this.escapeHtml(query)}">
|
|
371
|
+
<input type="hidden" name="format" value="array">
|
|
372
|
+
<input type="hidden" name="pretty_print" value="true">
|
|
373
|
+
`
|
|
374
|
+
|
|
375
|
+
document.body.appendChild(form)
|
|
376
|
+
form.submit()
|
|
377
|
+
document.body.removeChild(form)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SqliteDashboard
|
|
4
|
+
class SavedQuery < ApplicationRecord
|
|
5
|
+
self.table_name = "dashboard_saved_queries"
|
|
6
|
+
|
|
7
|
+
validates :name, presence: true, uniqueness: true
|
|
8
|
+
validates :query, presence: true
|
|
9
|
+
validates :description, presence: true
|
|
10
|
+
|
|
11
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
12
|
+
scope :for_database, ->(database_name) { where(database_name: database_name) }
|
|
13
|
+
end
|
|
14
|
+
end
|