@bakery-framework/plugin-dashboard 1.2.3 → 2.0.0-alpha.11
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.
- package/package.json +5 -4
- package/src/client/dashboard.ts +2 -65
- package/src/client/parts/effects.ts +2 -2
- package/src/client/parts/stats.ts +105 -23
- package/src/client/parts/utils.ts +13 -40
- package/src/components/DBBrowser.tsx +41 -352
- package/src/index.ts +45 -3
- package/src/setup.ts +71 -40
- package/src/shell.tsx +85 -21
- package/src/authorize.ts +0 -76
- package/src/client/parts/database.ts +0 -1176
- package/src/endpoints/database.ts +0 -211
|
@@ -1,1176 +0,0 @@
|
|
|
1
|
-
import { refreshShimmerCache } from './effects'
|
|
2
|
-
import {
|
|
3
|
-
errorBox,
|
|
4
|
-
executeAction,
|
|
5
|
-
getJson,
|
|
6
|
-
ICON_DELETE,
|
|
7
|
-
ICON_EDIT,
|
|
8
|
-
ICON_EYE,
|
|
9
|
-
ICON_TABLE,
|
|
10
|
-
icon,
|
|
11
|
-
postJson,
|
|
12
|
-
setEmpty,
|
|
13
|
-
setPager,
|
|
14
|
-
setText,
|
|
15
|
-
} from './utils'
|
|
16
|
-
|
|
17
|
-
export let currentInspectedTable: string | null = null
|
|
18
|
-
export let dbCurrentPage = 1
|
|
19
|
-
export let dbPageSize = 50
|
|
20
|
-
export let dbTotalPages = 1
|
|
21
|
-
export let dbActiveFilters: Array<{
|
|
22
|
-
column: string
|
|
23
|
-
operator: string
|
|
24
|
-
value: string
|
|
25
|
-
}> = []
|
|
26
|
-
export let dbSortBy: string | null = null
|
|
27
|
-
export let dbSortOrder: 'ASC' | 'DESC' = 'ASC'
|
|
28
|
-
export let dbSchemaCache: any[] = []
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* `message` is **escaped here**, the same rule `emptyBox` follows. Both callers
|
|
32
|
-
* happen to pass a literal today, which is exactly the state a helper is in
|
|
33
|
-
* right before someone passes it an error string — markup is not expressible
|
|
34
|
-
* through this at all.
|
|
35
|
-
*/
|
|
36
|
-
export function schemaListMessage(list: HTMLElement, message: string) {
|
|
37
|
-
list.innerHTML = `<li class="results-empty" style="padding: 1rem 0;">${escapeHTML(message)}</li>`
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* A schema section (Columns / Indexes). Identifiers reach `innerHTML`, so they
|
|
42
|
-
* are escaped even though they come from the database rather than a request —
|
|
43
|
-
* the schema is only as trustworthy as whatever created the tables.
|
|
44
|
-
*/
|
|
45
|
-
function schemaSection(title: string, items: string[]): string {
|
|
46
|
-
if (items.length === 0) return ''
|
|
47
|
-
return (
|
|
48
|
-
`<div class="schema-sec"><span class="schema-sec-title">${title}</span>` +
|
|
49
|
-
`<ul class="schema-fields">${items.join('')}</ul></div>`
|
|
50
|
-
)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function buildTableSummary(t: any): string {
|
|
54
|
-
const name = escapeHTML(String(t.name))
|
|
55
|
-
return `
|
|
56
|
-
<div class="table-item-header" style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
|
57
|
-
<span class="table-item-name" style="display: inline-flex; align-items: center; gap: 0.25rem; font-weight: 500;">
|
|
58
|
-
${icon(ICON_TABLE, '1rem')}
|
|
59
|
-
<span>${name}</span>
|
|
60
|
-
</span>
|
|
61
|
-
<div style="display: flex; align-items: center; gap: 0.35rem;">
|
|
62
|
-
<span class="table-item-rows">${escapeHTML(String(t.rowCount))} rows</span>
|
|
63
|
-
<button class="btn btn-secondary" style="padding: 0.15rem 0.4rem; font-size: 0.7rem; border-radius: 0.25rem; display: inline-flex; align-items: center; gap: 0.15rem;" data-table="${name}" onclick="event.preventDefault(); event.stopPropagation(); selectDatabaseTable(this.dataset.table)">
|
|
64
|
-
${icon(ICON_EYE, '0.85rem')}
|
|
65
|
-
<span>View</span>
|
|
66
|
-
</button>
|
|
67
|
-
</div>
|
|
68
|
-
</div>
|
|
69
|
-
`
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function buildTableSchemaInfo(t: any): string {
|
|
73
|
-
const cols = (t.columns || []).map(
|
|
74
|
-
(c: any) =>
|
|
75
|
-
`<li>${escapeHTML(String(c.name))}` +
|
|
76
|
-
`<span class="field-type">${escapeHTML(String(c.type || 'NUMERIC'))}</span>` +
|
|
77
|
-
`${c.pk ? '<span class="field-badge pk">PK</span>' : ''}` +
|
|
78
|
-
`${c.notnull ? '<span class="field-badge nn">NN</span>' : ''}</li>`,
|
|
79
|
-
)
|
|
80
|
-
const idxs = (t.indexes || []).map(
|
|
81
|
-
(i: any) =>
|
|
82
|
-
`<li>${escapeHTML(String(i.name))}` +
|
|
83
|
-
`${i.unique ? '<span class="field-badge pk">UNIQ</span>' : ''}</li>`,
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
schemaSection('Columns', cols) +
|
|
88
|
-
schemaSection('Indexes', idxs) +
|
|
89
|
-
`<button class="btn-inspect" data-table="${escapeHTML(String(t.name))}" onclick="inspectTable(this.dataset.table)">Console Inspect</button>`
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export async function loadSchema() {
|
|
94
|
-
const list = document.getElementById('tables-list')
|
|
95
|
-
if (!list) return
|
|
96
|
-
try {
|
|
97
|
-
const json = await getJson('/api/_dashboard/schema')
|
|
98
|
-
|
|
99
|
-
if (json.status !== 200 || !json.data || json.data.length === 0) {
|
|
100
|
-
schemaListMessage(list, 'No tables found')
|
|
101
|
-
setText('tables-count', '(0)')
|
|
102
|
-
dbSchemaCache = []
|
|
103
|
-
return
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
dbSchemaCache = json.data
|
|
107
|
-
list.innerHTML = ''
|
|
108
|
-
setText('tables-count', `(${json.data.length})`)
|
|
109
|
-
|
|
110
|
-
json.data.forEach((t: any) => {
|
|
111
|
-
const li = document.createElement('li')
|
|
112
|
-
li.className = 'table-group'
|
|
113
|
-
|
|
114
|
-
const details = document.createElement('details')
|
|
115
|
-
details.className = 'table-details'
|
|
116
|
-
|
|
117
|
-
if (currentInspectedTable === t.name) {
|
|
118
|
-
details.setAttribute('open', '')
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const summary = document.createElement('summary')
|
|
122
|
-
summary.className = 'table-summary'
|
|
123
|
-
summary.innerHTML = buildTableSummary(t)
|
|
124
|
-
details.appendChild(summary)
|
|
125
|
-
|
|
126
|
-
const info = document.createElement('div')
|
|
127
|
-
info.className = 'table-schema-info'
|
|
128
|
-
info.innerHTML = buildTableSchemaInfo(t)
|
|
129
|
-
|
|
130
|
-
details.appendChild(info)
|
|
131
|
-
li.appendChild(details)
|
|
132
|
-
list.appendChild(li)
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
setTimeout(refreshShimmerCache, 50)
|
|
136
|
-
if (!currentInspectedTable && json.data.length > 0) {
|
|
137
|
-
selectDatabaseTable(json.data[0].name)
|
|
138
|
-
}
|
|
139
|
-
} catch (_err) {
|
|
140
|
-
schemaListMessage(list, 'Error scanning tables')
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function filterTablesList() {
|
|
145
|
-
const searchInput = document.getElementById(
|
|
146
|
-
'db-table-search',
|
|
147
|
-
) as HTMLInputElement | null
|
|
148
|
-
const filter = searchInput ? searchInput.value.toLowerCase().trim() : ''
|
|
149
|
-
const listItems = document.querySelectorAll('#tables-list > li.table-group')
|
|
150
|
-
listItems.forEach((item: any) => {
|
|
151
|
-
const nameEl = item.querySelector('.table-item-name')
|
|
152
|
-
if (nameEl) {
|
|
153
|
-
const tableName = nameEl.textContent.trim().toLowerCase()
|
|
154
|
-
if (tableName.includes(filter)) {
|
|
155
|
-
item.style.display = ''
|
|
156
|
-
} else {
|
|
157
|
-
item.style.display = 'none'
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
})
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** The cached schema entry for the table currently on screen. */
|
|
164
|
-
function currentSchema(): any | undefined {
|
|
165
|
-
return dbSchemaCache.find(t => t.name === currentInspectedTable)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const NUMERIC_TYPES = ['INTEGER', 'REAL', 'NUMERIC', 'FLOAT']
|
|
169
|
-
|
|
170
|
-
function isNumericColumn(c: any): boolean {
|
|
171
|
-
return NUMERIC_TYPES.includes((c.type || '').toUpperCase())
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function isBooleanColumn(c: any): boolean {
|
|
175
|
-
return (
|
|
176
|
-
(c.type || '').toUpperCase() === 'BOOLEAN' ||
|
|
177
|
-
c.name.toLowerCase().includes('status')
|
|
178
|
-
)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Read one form field back out with the column's declared type applied. */
|
|
182
|
-
function readColumnValue(formData: FormData, c: any): any {
|
|
183
|
-
const val: any = formData.get(c.name)
|
|
184
|
-
if (val === '' || val === null) return null
|
|
185
|
-
return isNumericColumn(c) ? Number(val) : val
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function selectDatabaseTable(tableName: string) {
|
|
189
|
-
currentInspectedTable = tableName
|
|
190
|
-
dbCurrentPage = 1
|
|
191
|
-
dbActiveFilters = []
|
|
192
|
-
dbSortBy = null
|
|
193
|
-
dbSortOrder = 'ASC'
|
|
194
|
-
|
|
195
|
-
const viewCard = document.getElementById('db-browser-view')
|
|
196
|
-
if (viewCard) viewCard.style.display = 'flex'
|
|
197
|
-
|
|
198
|
-
const titleEl = document.getElementById('current-table-title')
|
|
199
|
-
if (titleEl) {
|
|
200
|
-
titleEl.innerHTML = `${icon(ICON_TABLE, '1.25rem')}<span>${escapeHTML(String(tableName))}</span>`
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const colSelect = document.getElementById(
|
|
204
|
-
'filter-col-select',
|
|
205
|
-
) as HTMLSelectElement | null
|
|
206
|
-
if (colSelect) {
|
|
207
|
-
const tableSchema = dbSchemaCache.find(t => t.name === tableName)
|
|
208
|
-
colSelect.innerHTML = '<option value="">-- Choose Column --</option>'
|
|
209
|
-
|
|
210
|
-
if (tableSchema?.columns) {
|
|
211
|
-
tableSchema.columns.forEach((c: any) => {
|
|
212
|
-
const opt = document.createElement('option')
|
|
213
|
-
opt.value = c.name
|
|
214
|
-
opt.innerText = `${c.name} (${c.type || 'NUMERIC'})`
|
|
215
|
-
colSelect.appendChild(opt)
|
|
216
|
-
})
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const queryEl = document.getElementById(
|
|
221
|
-
'sql-query',
|
|
222
|
-
) as HTMLTextAreaElement | null
|
|
223
|
-
if (queryEl) {
|
|
224
|
-
queryEl.value = `SELECT * FROM \`${tableName}\` LIMIT 50;`
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
renderFilterChips()
|
|
228
|
-
void fetchTableData()
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* How one cell value becomes markup, for both grids on this page.
|
|
233
|
-
*
|
|
234
|
-
* The only literal markup here is `<em>null</em>`; every path that carries a
|
|
235
|
-
* value escapes it. That is not defensive habit — every XSS this repo has
|
|
236
|
-
* found came from hand-built DOM strings in this file. `is.object([])` is true
|
|
237
|
-
* by a documented decision, so this is the branch any JSON- or array-typed
|
|
238
|
-
* column lands in, and the value is a database row, i.e. whatever the last
|
|
239
|
-
* writer put there. Unescaped it was stored XSS in an origin that owns
|
|
240
|
-
* /api/_dashboard/query.
|
|
241
|
-
*
|
|
242
|
-
* `cellClass` is `''` for the ordinary scalar case, which is also the signal
|
|
243
|
-
* `formatTableCell` uses to know it may apply its own boolean-badge branch.
|
|
244
|
-
* The query console has no such branch and gaining one would be a change in
|
|
245
|
-
* behaviour, not a dedupe — so the badge stays at the one call site that had
|
|
246
|
-
* it.
|
|
247
|
-
*/
|
|
248
|
-
function cellDisplay(val: any): { displayVal: string; cellClass: string } {
|
|
249
|
-
if (val === null) {
|
|
250
|
-
return { displayVal: '<em>null</em>', cellClass: 'cell-null' }
|
|
251
|
-
}
|
|
252
|
-
if (is.object(val)) {
|
|
253
|
-
return {
|
|
254
|
-
displayVal: escapeHTML(JSON.stringify(val)),
|
|
255
|
-
cellClass: 'cell-json',
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
return { displayVal: escapeHTML(val), cellClass: '' }
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export function formatTableCell(val: any, col: string, rowidVal: any): string {
|
|
262
|
-
// The grid's extra branch, ahead of the shared scalar case: a boolean (or a
|
|
263
|
-
// 0/1 in a `status` column) renders as a badge rather than as its text.
|
|
264
|
-
const isBadge =
|
|
265
|
-
val !== null &&
|
|
266
|
-
!is.object(val) &&
|
|
267
|
-
(is.boolean(val) ||
|
|
268
|
-
(col.toLowerCase().includes('status') && (val === 0 || val === 1)))
|
|
269
|
-
|
|
270
|
-
const { displayVal, cellClass } = isBadge
|
|
271
|
-
? {
|
|
272
|
-
displayVal: val
|
|
273
|
-
? '<span class="badge badge-success">true</span>'
|
|
274
|
-
: '<span class="badge badge-secondary">false</span>',
|
|
275
|
-
cellClass: 'cell-boolean',
|
|
276
|
-
}
|
|
277
|
-
: cellDisplay(val)
|
|
278
|
-
|
|
279
|
-
// Identifiers come from the schema rather than a request, but they are still
|
|
280
|
-
// interpolated into an inline handler — keep them out of the JS string.
|
|
281
|
-
return `
|
|
282
|
-
<td
|
|
283
|
-
class="${cellClass} editable-cell"
|
|
284
|
-
data-table="${escapeHTML(String(currentInspectedTable))}"
|
|
285
|
-
data-col="${escapeHTML(String(col))}"
|
|
286
|
-
ondblclick="startInlineEdit(this, this.dataset.table, ${Number(rowidVal)}, this.dataset.col)"
|
|
287
|
-
title="Double-click to inline edit"
|
|
288
|
-
>
|
|
289
|
-
${displayVal}
|
|
290
|
-
</td>
|
|
291
|
-
`
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export function buildTableHtml(
|
|
295
|
-
rows: any[],
|
|
296
|
-
columns: string[],
|
|
297
|
-
hasRowid: boolean,
|
|
298
|
-
): string {
|
|
299
|
-
let tableHtml =
|
|
300
|
-
'<div class="table-wrapper"><table class="interactive-grid"><thead><tr>'
|
|
301
|
-
|
|
302
|
-
if (hasRowid) {
|
|
303
|
-
tableHtml += `<th style="color: var(--text-secondary); width: 60px;">rowid</th>`
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
columns.forEach((col: string) => {
|
|
307
|
-
const isSorted = dbSortBy === col
|
|
308
|
-
const arrow = isSorted ? (dbSortOrder === 'ASC' ? ' ▴' : ' ▾') : ''
|
|
309
|
-
const activeClass = isSorted ? 'class="sorted-column"' : ''
|
|
310
|
-
const safeCol = escapeHTML(String(col))
|
|
311
|
-
tableHtml += `<th data-col="${safeCol}" onclick="toggleGridSort(this.dataset.col)" style="cursor: pointer; user-select: none;" ${activeClass}>${safeCol}${arrow}</th>`
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
tableHtml += '<th style="width: 120px;">Actions</th></tr></thead><tbody>'
|
|
315
|
-
|
|
316
|
-
rows.forEach((row: any) => {
|
|
317
|
-
const rowidVal = row.rowid
|
|
318
|
-
tableHtml += '<tr>'
|
|
319
|
-
|
|
320
|
-
if (hasRowid) {
|
|
321
|
-
// A user table may declare its own `rowid` column, in which case this is
|
|
322
|
-
// a row value rather than SQLite's implicit integer. The two inline
|
|
323
|
-
// handlers below coerce it with `Number()` because they interpolate into
|
|
324
|
-
// JS; here it is displayed, so it is escaped instead of coerced — a
|
|
325
|
-
// non-numeric rowid still renders as itself rather than as NaN.
|
|
326
|
-
tableHtml += `<td style="color: var(--text-secondary); font-weight: 500;">${escapeHTML(String(rowidVal))}</td>`
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
columns.forEach((col: string) => {
|
|
330
|
-
tableHtml += formatTableCell(row[col], col, rowidVal)
|
|
331
|
-
})
|
|
332
|
-
|
|
333
|
-
// The payload goes in a data attribute, not inside an inline JS string.
|
|
334
|
-
// encodeURIComponent leaves ' ( ) untouched, so a row value like
|
|
335
|
-
// `')-alert(1)-('` used to break straight out of the onclick handler and
|
|
336
|
-
// run as the authenticated admin — a viewed row became full DB access.
|
|
337
|
-
const rowEscapedJson = escapeHTML(encodeURIComponent(JSON.stringify(row)))
|
|
338
|
-
const safeTable = escapeHTML(String(currentInspectedTable))
|
|
339
|
-
tableHtml += `
|
|
340
|
-
<td>
|
|
341
|
-
<div style="display: flex; gap: 0.35rem;">
|
|
342
|
-
<button class="btn btn-secondary" style="padding: 0.15rem 0.35rem; font-size: 0.7rem; border-radius: 0.25rem; display: inline-flex; align-items: center; gap: 0.2rem;" data-row="${rowEscapedJson}" onclick="openEditModal(this.dataset.row)">
|
|
343
|
-
${icon(ICON_EDIT, '0.85rem')}
|
|
344
|
-
<span>Edit</span>
|
|
345
|
-
</button>
|
|
346
|
-
<button class="btn btn-secondary btn-danger" style="padding: 0.15rem 0.35rem; font-size: 0.7rem; border-radius: 0.25rem; display: inline-flex; align-items: center; gap: 0.2rem;" data-table="${safeTable}" onclick="deleteTableRow(this.dataset.table, ${Number(rowidVal)})">
|
|
347
|
-
${icon(ICON_DELETE, '0.85rem')}
|
|
348
|
-
<span>Delete</span>
|
|
349
|
-
</button>
|
|
350
|
-
</div>
|
|
351
|
-
</td>
|
|
352
|
-
`
|
|
353
|
-
|
|
354
|
-
tableHtml += '</tr>'
|
|
355
|
-
})
|
|
356
|
-
|
|
357
|
-
tableHtml += '</tbody></table></div>'
|
|
358
|
-
return tableHtml
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function updatePaginationUI(totalRows: number, page: number) {
|
|
362
|
-
setText('db-rows-meta', `${totalRows} rows matching filters`)
|
|
363
|
-
setText('table-row-count-badge', `${totalRows} rows`)
|
|
364
|
-
setPager(
|
|
365
|
-
{ info: 'db-page-info', prev: 'btn-page-prev', next: 'btn-page-next' },
|
|
366
|
-
page,
|
|
367
|
-
dbTotalPages,
|
|
368
|
-
)
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* The query string for `/api/_dashboard/table-data`. The paged grid and both
|
|
373
|
-
* exporters send the same filters and sort — only the page size differs, so
|
|
374
|
-
* one builder serves all three.
|
|
375
|
-
*/
|
|
376
|
-
function tableDataParams(page: number, pageSize: number): URLSearchParams {
|
|
377
|
-
const filterObj: Record<string, string> = {}
|
|
378
|
-
dbActiveFilters.forEach(f => {
|
|
379
|
-
filterObj[f.column] = f.value
|
|
380
|
-
})
|
|
381
|
-
|
|
382
|
-
const params = new URLSearchParams({
|
|
383
|
-
tableName: currentInspectedTable || '',
|
|
384
|
-
page: page.toString(),
|
|
385
|
-
pageSize: pageSize.toString(),
|
|
386
|
-
filters: JSON.stringify(filterObj),
|
|
387
|
-
})
|
|
388
|
-
|
|
389
|
-
if (dbSortBy) {
|
|
390
|
-
params.append('sortBy', dbSortBy)
|
|
391
|
-
params.append('sortOrder', dbSortOrder)
|
|
392
|
-
}
|
|
393
|
-
return params
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function fetchTableRows(page: number, pageSize: number): Promise<any> {
|
|
397
|
-
return getJson(
|
|
398
|
-
`/api/_dashboard/table-data?${tableDataParams(page, pageSize)}`,
|
|
399
|
-
)
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function renderFetchedTableData(data: any, gridContainer: HTMLElement | null) {
|
|
403
|
-
const { rows, totalRows, page, totalPages } = data
|
|
404
|
-
dbTotalPages = totalPages || 1
|
|
405
|
-
|
|
406
|
-
updatePaginationUI(totalRows, page)
|
|
407
|
-
|
|
408
|
-
if (rows.length === 0) {
|
|
409
|
-
setEmpty(
|
|
410
|
-
gridContainer,
|
|
411
|
-
'Empty set returned. Click "Add Row" to insert data.',
|
|
412
|
-
)
|
|
413
|
-
return
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const tableSchema = currentSchema()
|
|
417
|
-
const columns = tableSchema
|
|
418
|
-
? tableSchema.columns.map((c: any) => c.name)
|
|
419
|
-
: Object.keys(rows[0]).filter(k => k !== 'rowid')
|
|
420
|
-
|
|
421
|
-
const tableHtml = buildTableHtml(
|
|
422
|
-
rows,
|
|
423
|
-
columns,
|
|
424
|
-
Object.hasOwn(rows[0], 'rowid'),
|
|
425
|
-
)
|
|
426
|
-
if (gridContainer) gridContainer.innerHTML = tableHtml
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
export async function fetchTableData() {
|
|
430
|
-
if (!currentInspectedTable) return
|
|
431
|
-
const gridContainer = document.getElementById('browser-grid-body')
|
|
432
|
-
setEmpty(gridContainer, 'Loading data...')
|
|
433
|
-
|
|
434
|
-
try {
|
|
435
|
-
const json = await fetchTableRows(dbCurrentPage, dbPageSize)
|
|
436
|
-
|
|
437
|
-
if (json.status !== 200) {
|
|
438
|
-
// `json.message` is the driver's error text and echoes the filter values
|
|
439
|
-
// that produced it. `errorBox` escapes; escaping again here would render
|
|
440
|
-
// the entities literally.
|
|
441
|
-
if (gridContainer) {
|
|
442
|
-
gridContainer.innerHTML = errorBox(`Error: ${String(json.message)}`)
|
|
443
|
-
}
|
|
444
|
-
return
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
renderFetchedTableData(json.data, gridContainer)
|
|
448
|
-
} catch (_err) {
|
|
449
|
-
if (gridContainer) {
|
|
450
|
-
gridContainer.innerHTML = errorBox('Network connection error.')
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
export function toggleGridSort(columnName: string) {
|
|
456
|
-
if (dbSortBy === columnName) {
|
|
457
|
-
dbSortOrder = dbSortOrder === 'ASC' ? 'DESC' : 'ASC'
|
|
458
|
-
} else {
|
|
459
|
-
dbSortBy = columnName
|
|
460
|
-
dbSortOrder = 'ASC'
|
|
461
|
-
}
|
|
462
|
-
void fetchTableData()
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
export function prevPage() {
|
|
466
|
-
if (dbCurrentPage > 1) {
|
|
467
|
-
dbCurrentPage--
|
|
468
|
-
void fetchTableData()
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
export function nextPage() {
|
|
473
|
-
if (dbCurrentPage < dbTotalPages) {
|
|
474
|
-
dbCurrentPage++
|
|
475
|
-
void fetchTableData()
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
export function changePageSize() {
|
|
480
|
-
const sizeEl = document.getElementById(
|
|
481
|
-
'db-page-size',
|
|
482
|
-
) as HTMLSelectElement | null
|
|
483
|
-
if (sizeEl) {
|
|
484
|
-
dbPageSize = parseInt(sizeEl.value, 10)
|
|
485
|
-
dbCurrentPage = 1
|
|
486
|
-
void fetchTableData()
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
export function startInlineEdit(
|
|
491
|
-
cell: HTMLTableCellElement,
|
|
492
|
-
tableName: string,
|
|
493
|
-
rowid: number,
|
|
494
|
-
column: string,
|
|
495
|
-
) {
|
|
496
|
-
if (cell.querySelector('input')) return
|
|
497
|
-
|
|
498
|
-
const originalHtml = cell.innerHTML
|
|
499
|
-
let text = cell.innerText
|
|
500
|
-
|
|
501
|
-
if (cell.classList.contains('cell-null')) {
|
|
502
|
-
text = ''
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
cell.classList.add('editing-active')
|
|
506
|
-
cell.removeAttribute('title')
|
|
507
|
-
|
|
508
|
-
const input = document.createElement('input')
|
|
509
|
-
input.type = 'text'
|
|
510
|
-
input.className = 'grid-inline-input'
|
|
511
|
-
input.value = text
|
|
512
|
-
|
|
513
|
-
cell.innerHTML = ''
|
|
514
|
-
cell.appendChild(input)
|
|
515
|
-
input.focus()
|
|
516
|
-
|
|
517
|
-
const finishEdit = async (save: boolean) => {
|
|
518
|
-
cell.classList.remove('editing-active')
|
|
519
|
-
cell.setAttribute('title', 'Double-click to inline edit')
|
|
520
|
-
|
|
521
|
-
if (!save || input.value.trim() === text.trim()) {
|
|
522
|
-
cell.innerHTML = originalHtml
|
|
523
|
-
return
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
const newVal = input.value.trim()
|
|
527
|
-
|
|
528
|
-
try {
|
|
529
|
-
const updateData: Record<string, any> = {}
|
|
530
|
-
updateData[column] =
|
|
531
|
-
newVal === ''
|
|
532
|
-
? null
|
|
533
|
-
: Number.isNaN(Number(newVal))
|
|
534
|
-
? newVal
|
|
535
|
-
: Number(newVal)
|
|
536
|
-
|
|
537
|
-
const json = await executeAction({
|
|
538
|
-
action: 'update-row',
|
|
539
|
-
tableName,
|
|
540
|
-
rowid,
|
|
541
|
-
row: updateData,
|
|
542
|
-
})
|
|
543
|
-
if (json.status === 200) {
|
|
544
|
-
void fetchTableData()
|
|
545
|
-
} else {
|
|
546
|
-
alert(`Failed to update: ${json.message}`)
|
|
547
|
-
cell.innerHTML = originalHtml
|
|
548
|
-
}
|
|
549
|
-
} catch (_err) {
|
|
550
|
-
alert('Error updating row cell inline')
|
|
551
|
-
cell.innerHTML = originalHtml
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
input.onkeydown = e => {
|
|
556
|
-
if (e.key === 'Enter') void finishEdit(true)
|
|
557
|
-
if (e.key === 'Escape') void finishEdit(false)
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
input.onblur = () => {
|
|
561
|
-
void finishEdit(true)
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
export function addActiveFilter() {
|
|
566
|
-
const colSelect = document.getElementById(
|
|
567
|
-
'filter-col-select',
|
|
568
|
-
) as HTMLSelectElement | null
|
|
569
|
-
const opSelect = document.getElementById(
|
|
570
|
-
'filter-op-select',
|
|
571
|
-
) as HTMLSelectElement | null
|
|
572
|
-
const valInput = document.getElementById(
|
|
573
|
-
'filter-val-input',
|
|
574
|
-
) as HTMLInputElement | null
|
|
575
|
-
|
|
576
|
-
if (!colSelect || !opSelect || !valInput) return
|
|
577
|
-
|
|
578
|
-
const column = colSelect.value
|
|
579
|
-
const operator = opSelect.value
|
|
580
|
-
const value = valInput.value.trim()
|
|
581
|
-
|
|
582
|
-
if (!column) {
|
|
583
|
-
alert('Please choose a column to filter by.')
|
|
584
|
-
return
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const isNoValOp = ['is_null', 'is_not_null'].includes(operator)
|
|
588
|
-
if (!isNoValOp && value === '') {
|
|
589
|
-
alert('Please enter a filter value.')
|
|
590
|
-
return
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
dbActiveFilters.push({
|
|
594
|
-
column,
|
|
595
|
-
operator,
|
|
596
|
-
value: isNoValOp ? operator : value,
|
|
597
|
-
})
|
|
598
|
-
valInput.value = ''
|
|
599
|
-
|
|
600
|
-
renderFilterChips()
|
|
601
|
-
dbCurrentPage = 1
|
|
602
|
-
void fetchTableData()
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
export function removeActiveFilter(idx: number) {
|
|
606
|
-
dbActiveFilters.splice(idx, 1)
|
|
607
|
-
renderFilterChips()
|
|
608
|
-
dbCurrentPage = 1
|
|
609
|
-
void fetchTableData()
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
export function clearActiveFilters() {
|
|
613
|
-
dbActiveFilters = []
|
|
614
|
-
renderFilterChips()
|
|
615
|
-
dbCurrentPage = 1
|
|
616
|
-
void fetchTableData()
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
export function renderFilterChips() {
|
|
620
|
-
const container = document.getElementById('active-filters-list')
|
|
621
|
-
if (!container) return
|
|
622
|
-
container.innerHTML = ''
|
|
623
|
-
|
|
624
|
-
if (dbActiveFilters.length === 0) {
|
|
625
|
-
container.innerHTML =
|
|
626
|
-
'<span style="font-size: 0.75rem; color: var(--text-secondary); font-style: italic;">No filters applied</span>'
|
|
627
|
-
return
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
dbActiveFilters.forEach((f, idx) => {
|
|
631
|
-
const chip = document.createElement('div')
|
|
632
|
-
chip.className = 'filter-chip'
|
|
633
|
-
|
|
634
|
-
let opDisplay = f.operator
|
|
635
|
-
if (f.operator === 'like') opDisplay = 'contains'
|
|
636
|
-
else if (f.operator === 'is_null') opDisplay = 'is null'
|
|
637
|
-
else if (f.operator === 'is_not_null') opDisplay = 'is not null'
|
|
638
|
-
|
|
639
|
-
// `f.value` is whatever was typed into the filter box; it lands in
|
|
640
|
-
// innerHTML, so it is escaped here.
|
|
641
|
-
const valuePart = ['is_null', 'is_not_null'].includes(f.operator)
|
|
642
|
-
? ''
|
|
643
|
-
: `"${escapeHTML(String(f.value))}"`
|
|
644
|
-
chip.innerHTML = `
|
|
645
|
-
<span>${escapeHTML(String(f.column))} <strong>${opDisplay}</strong> ${valuePart}</span>
|
|
646
|
-
<button onclick="removeActiveFilter(${idx})">×</button>
|
|
647
|
-
`
|
|
648
|
-
container.appendChild(chip)
|
|
649
|
-
})
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/** The `<label>` shared by both modals — column name plus its declared type. */
|
|
653
|
-
function fieldLabel(c: any, forId: string, extra = ''): string {
|
|
654
|
-
return (
|
|
655
|
-
`<label class="label" for="${forId}">${escapeHTML(String(c.name))} ` +
|
|
656
|
-
`<span class="field-type-sub">${escapeHTML(String(c.type || 'TEXT'))}</span>${extra}</label>`
|
|
657
|
-
)
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
function buildInsertFormGroup(c: any): HTMLElement {
|
|
661
|
-
const formGroup = document.createElement('div')
|
|
662
|
-
formGroup.className = 'form-group'
|
|
663
|
-
|
|
664
|
-
const name = escapeHTML(String(c.name))
|
|
665
|
-
const fieldId = `insert-field-${name}`
|
|
666
|
-
|
|
667
|
-
const inputHtml = isBooleanColumn(c)
|
|
668
|
-
? `<select class="input-field" id="${fieldId}" name="${name}">
|
|
669
|
-
<option value="1">true</option>
|
|
670
|
-
<option value="0">false</option>
|
|
671
|
-
</select>`
|
|
672
|
-
: `<input
|
|
673
|
-
class="input-field"
|
|
674
|
-
type="${isNumericColumn(c) ? 'number' : 'text'}"
|
|
675
|
-
id="${fieldId}"
|
|
676
|
-
name="${name}"
|
|
677
|
-
placeholder="Enter ${name}..."
|
|
678
|
-
${c.notnull ? 'required' : ''}
|
|
679
|
-
/>`
|
|
680
|
-
|
|
681
|
-
formGroup.innerHTML = `${fieldLabel(c, fieldId)}${inputHtml}`
|
|
682
|
-
return formGroup
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
export function openInsertModal() {
|
|
686
|
-
const modal = document.getElementById('modal-insert')
|
|
687
|
-
const container = document.getElementById('insert-fields-container')
|
|
688
|
-
if (!modal || !container) return
|
|
689
|
-
|
|
690
|
-
const tableSchema = currentSchema()
|
|
691
|
-
if (!tableSchema) return
|
|
692
|
-
|
|
693
|
-
container.innerHTML = ''
|
|
694
|
-
tableSchema.columns.forEach((c: any) => {
|
|
695
|
-
if (c.pk && (c.type || '').toUpperCase() === 'INTEGER') return
|
|
696
|
-
container.appendChild(buildInsertFormGroup(c))
|
|
697
|
-
})
|
|
698
|
-
|
|
699
|
-
modal.style.display = 'flex'
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
export function closeInsertModal() {
|
|
703
|
-
const modal = document.getElementById('modal-insert')
|
|
704
|
-
if (modal) modal.style.display = 'none'
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
export async function submitInsertRow(e: Event) {
|
|
708
|
-
e.preventDefault()
|
|
709
|
-
if (!currentInspectedTable) return
|
|
710
|
-
|
|
711
|
-
const form = document.getElementById(
|
|
712
|
-
'insert-row-form',
|
|
713
|
-
) as HTMLFormElement | null
|
|
714
|
-
if (!form) return
|
|
715
|
-
|
|
716
|
-
const formData = new FormData(form)
|
|
717
|
-
const rowData: Record<string, any> = {}
|
|
718
|
-
|
|
719
|
-
const tableSchema = currentSchema()
|
|
720
|
-
if (!tableSchema) return
|
|
721
|
-
|
|
722
|
-
tableSchema.columns.forEach((c: any) => {
|
|
723
|
-
if (c.pk && (c.type || '').toUpperCase() === 'INTEGER') return
|
|
724
|
-
rowData[c.name] = readColumnValue(formData, c)
|
|
725
|
-
})
|
|
726
|
-
|
|
727
|
-
try {
|
|
728
|
-
const json = await executeAction({
|
|
729
|
-
action: 'insert-row',
|
|
730
|
-
tableName: currentInspectedTable,
|
|
731
|
-
row: rowData,
|
|
732
|
-
})
|
|
733
|
-
if (json.status === 200) {
|
|
734
|
-
closeInsertModal()
|
|
735
|
-
void loadSchema()
|
|
736
|
-
void fetchTableData()
|
|
737
|
-
} else {
|
|
738
|
-
alert(`Failed to insert row: ${json.message}`)
|
|
739
|
-
}
|
|
740
|
-
} catch (_err) {
|
|
741
|
-
alert('Connection error while inserting row')
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
function buildEditFormInputHtml(c: any, cellVal: any): string {
|
|
746
|
-
const name = escapeHTML(String(c.name))
|
|
747
|
-
const fieldId = `edit-field-${name}`
|
|
748
|
-
|
|
749
|
-
if (c.pk)
|
|
750
|
-
return `<input class="input-field" type="text" id="${fieldId}" name="${name}" value="${escapeHTML(cellVal)}" disabled />`
|
|
751
|
-
if (isBooleanColumn(c))
|
|
752
|
-
return `<select class="input-field" id="${fieldId}" name="${name}">
|
|
753
|
-
<option value="1" ${cellVal === 1 ? 'selected' : ''}>true</option>
|
|
754
|
-
<option value="0" ${cellVal === 0 ? 'selected' : ''}>false</option>
|
|
755
|
-
</select>`
|
|
756
|
-
return `<input
|
|
757
|
-
class="input-field"
|
|
758
|
-
type="${isNumericColumn(c) ? 'number' : 'text'}"
|
|
759
|
-
id="${fieldId}"
|
|
760
|
-
name="${name}"
|
|
761
|
-
value="${cellVal === null ? '' : escapeHTML(cellVal)}"
|
|
762
|
-
${c.notnull ? 'required' : ''}
|
|
763
|
-
/>`
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
function buildEditFormGroup(c: any, row: any): HTMLElement {
|
|
767
|
-
const formGroup = document.createElement('div')
|
|
768
|
-
formGroup.className = 'form-group'
|
|
769
|
-
const cellVal = row[c.name] !== undefined ? row[c.name] : ''
|
|
770
|
-
const readOnlyBadge = c.pk
|
|
771
|
-
? ' <span class="field-badge pk" style="margin-left: 0.25rem;">READ-ONLY</span>'
|
|
772
|
-
: ''
|
|
773
|
-
const fieldId = `edit-field-${escapeHTML(String(c.name))}`
|
|
774
|
-
formGroup.innerHTML =
|
|
775
|
-
fieldLabel(c, fieldId, readOnlyBadge) + buildEditFormInputHtml(c, cellVal)
|
|
776
|
-
return formGroup
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
export function openEditModal(rowEscapedJson: string) {
|
|
780
|
-
if (!currentInspectedTable) return
|
|
781
|
-
const modal = document.getElementById('modal-edit')
|
|
782
|
-
const container = document.getElementById('edit-fields-container')
|
|
783
|
-
if (!modal || !container) return
|
|
784
|
-
|
|
785
|
-
const row = JSON.parse(decodeURIComponent(rowEscapedJson))
|
|
786
|
-
const tableSchema = currentSchema()
|
|
787
|
-
if (!tableSchema) return
|
|
788
|
-
|
|
789
|
-
const rowidEl = document.getElementById(
|
|
790
|
-
'edit-row-rowid',
|
|
791
|
-
) as HTMLInputElement | null
|
|
792
|
-
if (rowidEl)
|
|
793
|
-
rowidEl.value =
|
|
794
|
-
row.rowid !== undefined && row.rowid !== null ? String(row.rowid) : ''
|
|
795
|
-
|
|
796
|
-
container.innerHTML = ''
|
|
797
|
-
tableSchema.columns.forEach((c: any) => {
|
|
798
|
-
container.appendChild(buildEditFormGroup(c, row))
|
|
799
|
-
})
|
|
800
|
-
|
|
801
|
-
modal.style.display = 'flex'
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
export function closeEditModal() {
|
|
805
|
-
const modal = document.getElementById('modal-edit')
|
|
806
|
-
if (modal) modal.style.display = 'none'
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
export async function submitEditRow(e: Event) {
|
|
810
|
-
e.preventDefault()
|
|
811
|
-
if (!currentInspectedTable) return
|
|
812
|
-
|
|
813
|
-
const form = document.getElementById(
|
|
814
|
-
'edit-row-form',
|
|
815
|
-
) as HTMLFormElement | null
|
|
816
|
-
const rowidEl = document.getElementById(
|
|
817
|
-
'edit-row-rowid',
|
|
818
|
-
) as HTMLInputElement | null
|
|
819
|
-
if (!form || !rowidEl) return
|
|
820
|
-
|
|
821
|
-
const rowid = parseInt(rowidEl.value, 10)
|
|
822
|
-
const formData = new FormData(form)
|
|
823
|
-
const rowData: Record<string, any> = {}
|
|
824
|
-
|
|
825
|
-
const tableSchema = currentSchema()
|
|
826
|
-
if (!tableSchema) return
|
|
827
|
-
|
|
828
|
-
tableSchema.columns.forEach((c: any) => {
|
|
829
|
-
if (c.pk) return
|
|
830
|
-
rowData[c.name] = readColumnValue(formData, c)
|
|
831
|
-
})
|
|
832
|
-
|
|
833
|
-
try {
|
|
834
|
-
const json = await executeAction({
|
|
835
|
-
action: 'update-row',
|
|
836
|
-
tableName: currentInspectedTable,
|
|
837
|
-
rowid,
|
|
838
|
-
row: rowData,
|
|
839
|
-
})
|
|
840
|
-
if (json.status === 200) {
|
|
841
|
-
closeEditModal()
|
|
842
|
-
void fetchTableData()
|
|
843
|
-
} else {
|
|
844
|
-
alert(`Failed to update row: ${json.message}`)
|
|
845
|
-
}
|
|
846
|
-
} catch (_err) {
|
|
847
|
-
alert('Connection error while saving edits')
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
export function openImportModal() {
|
|
852
|
-
const modal = document.getElementById('modal-import')
|
|
853
|
-
const txt = document.getElementById(
|
|
854
|
-
'csv-import-textarea',
|
|
855
|
-
) as HTMLTextAreaElement | null
|
|
856
|
-
const fileInput = document.getElementById(
|
|
857
|
-
'csv-file-input',
|
|
858
|
-
) as HTMLInputElement | null
|
|
859
|
-
if (txt) txt.value = ''
|
|
860
|
-
if (fileInput) fileInput.value = ''
|
|
861
|
-
if (modal) modal.style.display = 'flex'
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
export function closeImportModal() {
|
|
865
|
-
const modal = document.getElementById('modal-import')
|
|
866
|
-
if (modal) modal.style.display = 'none'
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
export function handleCsvFileSelect(e: Event) {
|
|
870
|
-
const fileInput = e.target as HTMLInputElement
|
|
871
|
-
const file = fileInput.files?.[0]
|
|
872
|
-
if (!file) return
|
|
873
|
-
|
|
874
|
-
const reader = new FileReader()
|
|
875
|
-
reader.onload = evt => {
|
|
876
|
-
const text = evt.target?.result
|
|
877
|
-
const txtArea = document.getElementById(
|
|
878
|
-
'csv-import-textarea',
|
|
879
|
-
) as HTMLTextAreaElement | null
|
|
880
|
-
if (txtArea && is.string(text)) {
|
|
881
|
-
txtArea.value = text as string
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
reader.readAsText(file)
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
export async function submitImportCsv() {
|
|
888
|
-
if (!currentInspectedTable) return
|
|
889
|
-
const txtArea = document.getElementById(
|
|
890
|
-
'csv-import-textarea',
|
|
891
|
-
) as HTMLTextAreaElement | null
|
|
892
|
-
const csvContent = txtArea ? txtArea.value.trim() : ''
|
|
893
|
-
|
|
894
|
-
if (!csvContent) {
|
|
895
|
-
alert('Please paste CSV content or upload a CSV file first.')
|
|
896
|
-
return
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
try {
|
|
900
|
-
const json = await executeAction({
|
|
901
|
-
action: 'import-csv',
|
|
902
|
-
tableName: currentInspectedTable,
|
|
903
|
-
csvContent,
|
|
904
|
-
})
|
|
905
|
-
if (json.status === 200) {
|
|
906
|
-
closeImportModal()
|
|
907
|
-
void loadSchema()
|
|
908
|
-
void fetchTableData()
|
|
909
|
-
alert(json.message || 'CSV imported successfully!')
|
|
910
|
-
} else {
|
|
911
|
-
alert(`Import failed: ${json.message}`)
|
|
912
|
-
}
|
|
913
|
-
} catch (_err) {
|
|
914
|
-
alert('Network error while importing CSV')
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
export let exportMenuOpen = false
|
|
919
|
-
export function toggleExportMenu() {
|
|
920
|
-
const menu = document.getElementById('export-menu')
|
|
921
|
-
if (!menu) return
|
|
922
|
-
exportMenuOpen = !exportMenuOpen
|
|
923
|
-
menu.style.display = exportMenuOpen ? 'block' : 'none'
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
export function closeExportMenuIfOutside(e: MouseEvent) {
|
|
927
|
-
const menu = document.getElementById('export-menu')
|
|
928
|
-
const trigger = document.querySelector('.export-dropdown-wrapper button')
|
|
929
|
-
if (
|
|
930
|
-
menu &&
|
|
931
|
-
trigger &&
|
|
932
|
-
!trigger.contains(e.target as Node) &&
|
|
933
|
-
!menu.contains(e.target as Node)
|
|
934
|
-
) {
|
|
935
|
-
exportMenuOpen = false
|
|
936
|
-
menu.style.display = 'none'
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
const EXPORT_PAGE_SIZE = 100000
|
|
941
|
-
|
|
942
|
-
/** Fetch the full filtered result set both exporters work from. */
|
|
943
|
-
async function fetchExportRows(): Promise<any[] | null> {
|
|
944
|
-
const json = await fetchTableRows(1, EXPORT_PAGE_SIZE)
|
|
945
|
-
if (json.status !== 200) {
|
|
946
|
-
alert('Failed to fetch data for export')
|
|
947
|
-
return null
|
|
948
|
-
}
|
|
949
|
-
return json.data.rows
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
function downloadBlob(blob: Blob, extension: string) {
|
|
953
|
-
const link = document.createElement('a')
|
|
954
|
-
const url = URL.createObjectURL(blob)
|
|
955
|
-
link.href = url
|
|
956
|
-
link.setAttribute(
|
|
957
|
-
'download',
|
|
958
|
-
`${currentInspectedTable}_export_${new Date().toISOString().slice(0, 10)}.${extension}`,
|
|
959
|
-
)
|
|
960
|
-
document.body.appendChild(link)
|
|
961
|
-
link.click()
|
|
962
|
-
document.body.removeChild(link)
|
|
963
|
-
// The object URL pins the blob in memory for the lifetime of the document
|
|
964
|
-
// otherwise; exporting a large table repeatedly leaked one copy per click.
|
|
965
|
-
URL.revokeObjectURL(url)
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
function closeExportMenu() {
|
|
969
|
-
exportMenuOpen = false
|
|
970
|
-
const menu = document.getElementById('export-menu')
|
|
971
|
-
if (menu) menu.style.display = 'none'
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
function toCsv(rows: any[]): string {
|
|
975
|
-
const headers = Object.keys(rows[0]).filter(k => k !== 'rowid')
|
|
976
|
-
const quote = (v: string) => `"${v.replace(/"/g, '""')}"`
|
|
977
|
-
|
|
978
|
-
const lines = [headers.map(quote).join(',')]
|
|
979
|
-
for (const row of rows) {
|
|
980
|
-
lines.push(
|
|
981
|
-
headers
|
|
982
|
-
.map(h => {
|
|
983
|
-
const val = row[h]
|
|
984
|
-
if (val === null || val === undefined) return ''
|
|
985
|
-
return quote(String(is.object(val) ? JSON.stringify(val) : val))
|
|
986
|
-
})
|
|
987
|
-
.join(','),
|
|
988
|
-
)
|
|
989
|
-
}
|
|
990
|
-
return `${lines.join('\n')}\n`
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
export async function exportToCSV() {
|
|
994
|
-
if (!currentInspectedTable) return
|
|
995
|
-
closeExportMenu()
|
|
996
|
-
|
|
997
|
-
try {
|
|
998
|
-
const rows = await fetchExportRows()
|
|
999
|
-
if (!rows) return
|
|
1000
|
-
if (rows.length === 0) {
|
|
1001
|
-
alert('No data rows to export.')
|
|
1002
|
-
return
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
downloadBlob(
|
|
1006
|
-
new Blob([toCsv(rows)], { type: 'text/csv;charset=utf-8;' }),
|
|
1007
|
-
'csv',
|
|
1008
|
-
)
|
|
1009
|
-
} catch (_err) {
|
|
1010
|
-
alert('Error generating CSV export file')
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
export async function exportToJSON() {
|
|
1015
|
-
if (!currentInspectedTable) return
|
|
1016
|
-
closeExportMenu()
|
|
1017
|
-
|
|
1018
|
-
try {
|
|
1019
|
-
const rows = await fetchExportRows()
|
|
1020
|
-
if (!rows) return
|
|
1021
|
-
|
|
1022
|
-
const cleanRows = rows.map((r: any) => {
|
|
1023
|
-
const copy = { ...r }
|
|
1024
|
-
delete copy.rowid
|
|
1025
|
-
return copy
|
|
1026
|
-
})
|
|
1027
|
-
|
|
1028
|
-
downloadBlob(
|
|
1029
|
-
new Blob([JSON.stringify(cleanRows, null, 2)], {
|
|
1030
|
-
type: 'application/json;charset=utf-8;',
|
|
1031
|
-
}),
|
|
1032
|
-
'json',
|
|
1033
|
-
)
|
|
1034
|
-
} catch (_err) {
|
|
1035
|
-
alert('Error generating JSON export file')
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
export async function truncateCurrentTable() {
|
|
1040
|
-
if (!currentInspectedTable) return
|
|
1041
|
-
await truncateTable(currentInspectedTable)
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
export async function truncateTable(tableName: string) {
|
|
1045
|
-
if (
|
|
1046
|
-
!confirm(
|
|
1047
|
-
'WARNING: Are you absolutely sure you want to truncate the table "' +
|
|
1048
|
-
tableName +
|
|
1049
|
-
'"? This will delete all rows and reclaim disk space.',
|
|
1050
|
-
)
|
|
1051
|
-
)
|
|
1052
|
-
return
|
|
1053
|
-
try {
|
|
1054
|
-
const json = await executeAction({ action: 'truncate', tableName })
|
|
1055
|
-
if (json.status === 200) {
|
|
1056
|
-
await loadSchema()
|
|
1057
|
-
dbCurrentPage = 1
|
|
1058
|
-
await fetchTableData()
|
|
1059
|
-
|
|
1060
|
-
setEmpty(
|
|
1061
|
-
document.getElementById('results-body'),
|
|
1062
|
-
'Table truncated successfully.',
|
|
1063
|
-
)
|
|
1064
|
-
setText('results-meta', '')
|
|
1065
|
-
} else {
|
|
1066
|
-
alert(`Failed to truncate table: ${json.message}`)
|
|
1067
|
-
}
|
|
1068
|
-
} catch (_err) {
|
|
1069
|
-
alert('Error executing truncate action')
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
export async function deleteTableRow(tableName: string, rowid: number) {
|
|
1074
|
-
if (!confirm('Are you sure you want to delete this row?')) return
|
|
1075
|
-
try {
|
|
1076
|
-
const json = await executeAction({ action: 'delete-row', tableName, rowid })
|
|
1077
|
-
if (json.status === 200) {
|
|
1078
|
-
void loadSchema()
|
|
1079
|
-
void fetchTableData()
|
|
1080
|
-
} else {
|
|
1081
|
-
alert(`Failed to delete row: ${json.message}`)
|
|
1082
|
-
}
|
|
1083
|
-
} catch (_err) {
|
|
1084
|
-
alert('Error executing delete action')
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
export function inspectTable(tableName: string) {
|
|
1089
|
-
selectDatabaseTable(tableName)
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
export function buildResultTableHtml(rows: any[], keys: string[]): string {
|
|
1093
|
-
let tableHtml = '<div class="table-wrapper"><table><thead><tr>'
|
|
1094
|
-
keys.forEach(k => {
|
|
1095
|
-
if (k === 'rowid') {
|
|
1096
|
-
tableHtml += '<th style="color: var(--text-secondary);">rowid</th>'
|
|
1097
|
-
} else {
|
|
1098
|
-
// Column labels come from whatever the console query aliased them to.
|
|
1099
|
-
tableHtml += `<th>${escapeHTML(String(k))}</th>`
|
|
1100
|
-
}
|
|
1101
|
-
})
|
|
1102
|
-
|
|
1103
|
-
tableHtml += '</tr></thead><tbody>'
|
|
1104
|
-
|
|
1105
|
-
rows.forEach((row: any) => {
|
|
1106
|
-
tableHtml += '<tr>'
|
|
1107
|
-
keys.forEach(k => {
|
|
1108
|
-
// Same rendering as the browser grid, minus the boolean badge — see
|
|
1109
|
-
// `cellDisplay`, which both go through.
|
|
1110
|
-
tableHtml += `<td>${cellDisplay(row[k]).displayVal}</td>`
|
|
1111
|
-
})
|
|
1112
|
-
tableHtml += '</tr>'
|
|
1113
|
-
})
|
|
1114
|
-
|
|
1115
|
-
tableHtml += '</tbody></table></div>'
|
|
1116
|
-
return tableHtml
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
function handleQuerySuccess(
|
|
1120
|
-
data: any,
|
|
1121
|
-
resultsBody: HTMLElement | null,
|
|
1122
|
-
meta: HTMLElement | null,
|
|
1123
|
-
) {
|
|
1124
|
-
const { rows, isSelect, time } = data
|
|
1125
|
-
|
|
1126
|
-
if (meta) {
|
|
1127
|
-
meta.innerText = `${isSelect ? `${rows.length} rows returned` : 'Command executed successfully'} in ${time}ms`
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
if (rows.length === 0) {
|
|
1131
|
-
setEmpty(resultsBody, 'Query executed successfully. Empty set returned.')
|
|
1132
|
-
return
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
const keys = Object.keys(rows[0])
|
|
1136
|
-
const tableHtml = buildResultTableHtml(rows, keys)
|
|
1137
|
-
if (resultsBody) resultsBody.innerHTML = tableHtml
|
|
1138
|
-
|
|
1139
|
-
if (!isSelect) {
|
|
1140
|
-
void loadSchema()
|
|
1141
|
-
if (currentInspectedTable) void fetchTableData()
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
export async function runQuery() {
|
|
1146
|
-
const queryEl = document.getElementById(
|
|
1147
|
-
'sql-query',
|
|
1148
|
-
) as HTMLTextAreaElement | null
|
|
1149
|
-
const sql = queryEl ? queryEl.value.trim() : ''
|
|
1150
|
-
if (!sql) return
|
|
1151
|
-
|
|
1152
|
-
const resultsBody = document.getElementById('results-body')
|
|
1153
|
-
const meta = document.getElementById('results-meta')
|
|
1154
|
-
setEmpty(resultsBody, 'Executing command...')
|
|
1155
|
-
if (meta) meta.innerText = ''
|
|
1156
|
-
|
|
1157
|
-
try {
|
|
1158
|
-
const json = await postJson('/api/_dashboard/query', { sql })
|
|
1159
|
-
|
|
1160
|
-
if (json.status !== 200) {
|
|
1161
|
-
// The engine's error text quotes the submitted SQL back at us. `errorBox`
|
|
1162
|
-
// escapes it and supplies its own wrapping span; the `<span>` this used to
|
|
1163
|
-
// pass was nesting a second one inside it.
|
|
1164
|
-
if (resultsBody) {
|
|
1165
|
-
resultsBody.innerHTML = errorBox(
|
|
1166
|
-
`Query failed: ${String(json.message)}`,
|
|
1167
|
-
)
|
|
1168
|
-
}
|
|
1169
|
-
return
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
handleQuerySuccess(json.data, resultsBody, meta)
|
|
1173
|
-
} catch (_err) {
|
|
1174
|
-
setEmpty(resultsBody, 'Connection error.', true)
|
|
1175
|
-
}
|
|
1176
|
-
}
|