@bakery-framework/plugin-dashboard 2.0.0-alpha.5 → 2.0.0-alpha.7

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.
@@ -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})">&times;</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
- }