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

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-dashboard",
3
- "version": "2.0.0-alpha.5",
3
+ "version": "2.0.0-alpha.6",
4
4
  "description": "Bakery dashboard plugin.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -33,9 +33,9 @@
33
33
  "!src/tests"
34
34
  ],
35
35
  "dependencies": {
36
- "@bakery-framework/core": "^2.0.0-alpha.5",
37
- "@bakery-framework/orm": "^2.0.0-alpha.5",
38
- "@bakery-framework/plugin-analytics": "^2.0.0-alpha.5"
36
+ "@bakery-framework/core": "^2.0.0-alpha.6",
37
+ "@bakery-framework/orm": "^2.0.0-alpha.6",
38
+ "@bakery-framework/plugin-analytics": "^2.0.0-alpha.6"
39
39
  },
40
40
  "engines": {
41
41
  "bun": ">=1.3.14"
@@ -1,35 +1,3 @@
1
- import {
2
- addActiveFilter,
3
- changePageSize,
4
- clearActiveFilters,
5
- closeEditModal,
6
- closeExportMenuIfOutside,
7
- closeImportModal,
8
- closeInsertModal,
9
- deleteTableRow,
10
- exportToCSV,
11
- exportToJSON,
12
- fetchTableData,
13
- filterTablesList,
14
- handleCsvFileSelect,
15
- inspectTable,
16
- loadSchema,
17
- nextPage,
18
- openEditModal,
19
- openImportModal,
20
- openInsertModal,
21
- prevPage,
22
- removeActiveFilter,
23
- runQuery,
24
- selectDatabaseTable,
25
- startInlineEdit,
26
- submitEditRow,
27
- submitImportCsv,
28
- submitInsertRow,
29
- toggleExportMenu,
30
- toggleGridSort,
31
- truncateCurrentTable,
32
- } from './parts/database'
33
1
  import { refreshShimmerCache } from './parts/effects'
34
2
  import { clearLogs, initLogsWebSocket, toggleLogsPlay } from './parts/logs'
35
3
  import {
@@ -83,9 +51,10 @@ function switchTab(tabId: string) {
83
51
  const label = document.querySelector(`.tab-btn.active span:last-child`)
84
52
  if (crumb && label) crumb.textContent = label.textContent
85
53
 
54
+ // No `database` entry: that panel is static markup now — a link to the
55
+ // explorer at `/_db` — so there is nothing to fetch when it is shown.
86
56
  match(tabId, {
87
57
  sessions: loadSessions,
88
- database: loadSchema,
89
58
  logs: initLogsWebSocket,
90
59
  'top-pages': () => loadStats(true),
91
60
  })
@@ -93,8 +62,6 @@ function switchTab(tabId: string) {
93
62
  refreshShimmerCache()
94
63
  }
95
64
 
96
- window.addEventListener('click', closeExportMenuIfOutside)
97
-
98
65
  window.addEventListener('click', e => {
99
66
  const menu = document.getElementById('profile-menu')
100
67
  const trigger = document.querySelector('.profile-trigger-btn')
@@ -125,36 +92,6 @@ w.changeSessionPageSize = changeSessionPageSize
125
92
  w.sessionKeyAction = sessionKeyAction
126
93
  w.openSessionKeyEditor = openSessionKeyEditor
127
94
 
128
- w.loadSchema = loadSchema
129
- w.filterTablesList = filterTablesList
130
- w.selectDatabaseTable = selectDatabaseTable
131
- w.fetchTableData = fetchTableData
132
- w.toggleGridSort = toggleGridSort
133
- w.prevPage = prevPage
134
- w.nextPage = nextPage
135
- w.changePageSize = changePageSize
136
- w.startInlineEdit = startInlineEdit
137
- w.addActiveFilter = addActiveFilter
138
- w.removeActiveFilter = removeActiveFilter
139
- w.clearActiveFilters = clearActiveFilters
140
- w.openInsertModal = openInsertModal
141
- w.closeInsertModal = closeInsertModal
142
- w.submitInsertRow = submitInsertRow
143
- w.openEditModal = openEditModal
144
- w.closeEditModal = closeEditModal
145
- w.submitEditRow = submitEditRow
146
- w.openImportModal = openImportModal
147
- w.closeImportModal = closeImportModal
148
- w.handleCsvFileSelect = handleCsvFileSelect
149
- w.submitImportCsv = submitImportCsv
150
- w.toggleExportMenu = toggleExportMenu
151
- w.exportToCSV = exportToCSV
152
- w.exportToJSON = exportToJSON
153
- w.truncateCurrentTable = truncateCurrentTable
154
- w.deleteTableRow = deleteTableRow
155
- w.inspectTable = inspectTable
156
- w.runQuery = runQuery
157
-
158
95
  w.initLogsWebSocket = initLogsWebSocket
159
96
  w.toggleLogsPlay = toggleLogsPlay
160
97
  w.clearLogs = clearLogs
@@ -4,8 +4,8 @@ export let refreshShimmerCache = () => {}
4
4
  * Both effects below run on import. Without this guard, importing anything that
5
5
  * transitively reaches this file outside a browser throws on the first `window`
6
6
  * access — which made every module in `client/parts/` untestable, including the
7
- * hand-built HTML in `database.ts` that has produced every XSS found in this
8
- * repo. A DOM effect with no DOM is a no-op, not an error.
7
+ * hand-built HTML in the since-retired `database.ts` that produced every XSS
8
+ * found in this repo. A DOM effect with no DOM is a no-op, not an error.
9
9
  */
10
10
  const HAS_DOM = typeof window !== 'undefined' && typeof document !== 'undefined'
11
11
 
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Shared browser-side helpers for the dashboard panels.
3
3
  *
4
- * The three panels (database, sessions, stats) were built independently and
5
- * each grew its own copy of the same four things: an element-text setter, a
6
- * `results-empty` block, a fetch/`.json()` pair, and a prev/next pager. They
7
- * are declared once here because this file is compiled into the shipped
8
- * bundle — a duplicated string literal is duplicated bytes, the minifier does
9
- * not merge them.
4
+ * The panels were built independently and each grew its own copy of the same
5
+ * four things: an element-text setter, a `results-empty` block, a
6
+ * fetch/`.json()` pair, and a prev/next pager. They are declared once here
7
+ * because this file is compiled into the shipped bundle — a duplicated string
8
+ * literal is duplicated bytes, the minifier does not merge them.
9
+ *
10
+ * Shrank when the database editor was retired: `executeAction`, `getJson`,
11
+ * `errorBox` and three icon paths had no caller left once `database.ts` went,
12
+ * and dead code in a browser bundle is bytes every operator downloads.
10
13
  */
11
14
 
12
15
  /** `innerText` on an element that may not be in the DOM yet. */
@@ -22,9 +25,10 @@ export function setText(id: string, value: string) {
22
25
  * These helpers previously took a string straight into `innerHTML`: every
23
26
  * caller happened to pass a literal or escape first, which is exactly the state
24
27
  * a codebase is in right before it stops being true. Every XSS found in this
25
- * repo came from hand-built DOM strings in these three files, including a
26
- * stored-XSS to arbitrary-SQL chain, so the default here is the safe one and
27
- * markup is not expressible through it at all.
28
+ * repo came from hand-built DOM strings in these panel files, including a
29
+ * stored-XSS to arbitrary-SQL chain through the database grid which is gone
30
+ * now, but the escaping default it argued for is not, because the sessions and
31
+ * stats panels build markup the same way from data they did not write.
28
32
  */
29
33
  export function emptyBox(message: string, isError = false): string {
30
34
  const style = isError ? ' style="color: var(--accent-red);"' : ''
@@ -53,36 +57,10 @@ export function icon(d: string, size: string): string {
53
57
  )
54
58
  }
55
59
 
56
- export const ICON_TABLE =
57
- 'M19 21H5q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21M5 8h14V5H5zm2.5 2H5v9h2.5zm9 0v9H19v-9zm-2 0h-5v9h5z'
58
- export const ICON_EYE =
59
- 'M15.188 14.688Q16.5 13.375 16.5 11.5t-1.312-3.187T12 7T8.813 8.313T7.5 11.5t1.313 3.188T12 16t3.188-1.312m-5.1-1.276Q9.3 12.625 9.3 11.5t.788-1.912T12 8.8t1.913.788t.787 1.912t-.787 1.913T12 14.2t-1.912-.787m-4.738 3.55Q2.35 14.925 1 11.5q1.35-3.425 4.35-5.462T12 4t6.65 2.038T23 11.5q-1.35 3.425-4.35 5.463T12 19t-6.65-2.037m11.838-1.45Q19.55 14.025 20.8 11.5q-1.25-2.525-3.613-4.012T12 6T6.813 7.488T3.2 11.5q1.25 2.525 3.613 4.013T12 17t5.188-1.487'
60
60
  export const ICON_EDIT =
61
61
  'M5 19h1.425L16.2 9.225L14.775 7.8L5 17.575zm-2 2v-4.25L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.438.65T21 6.4q0 .4-.137.763t-.438.662L7.25 21zM19 6.4L17.6 5zm-3.525 2.125l-.7-.725L16.2 9.225z'
62
62
  export const ICON_DELETE =
63
63
  'M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zM17 6H7v13h10zM9 17h2V8H9zm4 0h2V8h-2zM7 6v13z'
64
- export const ICON_WARN =
65
- 'M1 21L12 2l11 19zm3.45-2h15.1L12 6zm8.263-1.287Q13 17.425 13 17t-.288-.712T12 16t-.712.288T11 17t.288.713T12 18t.713-.288M11 15h2v-5h-2zm1-2.5'
66
-
67
- /**
68
- * An error block with the warning glyph, as the fetch failure paths render it.
69
- * `message` is escaped — see `emptyBox`. The two dynamic callers pass a driver
70
- * error string, which quotes the caller's own SQL back at them.
71
- */
72
- export function errorBox(message: string): string {
73
- return (
74
- `<div class="results-empty" style="color: var(--accent-red);">` +
75
- `<span style="display: inline-flex; align-items: center; gap: 0.25rem;">` +
76
- `${icon(ICON_WARN, '1.1rem')}${escapeHTML(message)}</span></div>`
77
- )
78
- }
79
-
80
- /** GET a dashboard endpoint and unwrap the JSON envelope. */
81
- export async function getJson(url: string): Promise<any> {
82
- const res = await fetch(url)
83
- return await res.json()
84
- }
85
-
86
64
  /** POST a JSON body to a dashboard endpoint and unwrap the envelope. */
87
65
  export async function postJson(url: string, body: unknown): Promise<any> {
88
66
  const res = await fetch(url, {
@@ -93,11 +71,6 @@ export async function postJson(url: string, body: unknown): Promise<any> {
93
71
  return await res.json()
94
72
  }
95
73
 
96
- /** The mutating half of the database panel; every action shares one endpoint. */
97
- export function executeAction(body: Record<string, unknown>): Promise<any> {
98
- return postJson('/api/_dashboard/execute-action', body)
99
- }
100
-
101
74
  /** Page N of M, with the prev/next buttons disabled at the ends. */
102
75
  export function setPager(
103
76
  ids: { info: string; prev: string; next: string },
@@ -1,362 +1,46 @@
1
- import { connection } from '@bakery-framework/orm/connection'
2
-
3
- const driverNames = {
4
- postgres: 'PostgreSQL',
5
- mysql: 'MySQL',
6
- sqlite: 'SQLite',
7
- }
8
-
1
+ /**
2
+ * The Database tab is a signpost, not a browser.
3
+ *
4
+ * This panel used to be the grid editor and a raw SQL prompt, backed by
5
+ * `POST /api/_dashboard/query` and `POST /api/_dashboard/execute-action` and
6
+ * gated by a single environment flag. Both endpoints are gone.
7
+ * `@bakery-framework/plugin-db-explorer` does the same work at `/_db` with an
8
+ * access level per caller instead, so the console points at it rather than
9
+ * shipping a second, weaker copy.
10
+ *
11
+ * Deliberately static: no fetch, no client module, nothing to escape. It is
12
+ * plain markup so that a tab which now only links somewhere cannot grow a
13
+ * data path back by accident.
14
+ */
9
15
  export function renderDatabaseBrowser() {
10
- const driverName = driverNames[connection.driver] || 'Database'
11
16
  return (
12
17
  <div id="panel-database" class="panel">
13
- <style>{`
14
- .db-mobile-toggle { display: none; margin-bottom: 1rem; width: 100%; justify-content: space-between; align-items: center; }
15
- @media (max-width: 900px) {
16
- .db-mobile-toggle { display: flex; }
17
- #db-sidebar { display: none; }
18
- #db-sidebar.mobile-open { display: block; margin-bottom: 1rem; }
19
- }
20
- `}</style>
21
- <div class="db-mobile-toggle">
22
- <h2 class="db-browser-mobile-title">
23
- <iconify-icon icon="lucide:database"></iconify-icon>
24
- Database Explorer
25
- </h2>
26
- <button
27
- type="button"
28
- class="btn btn-secondary"
29
- onclick="document.getElementById('db-sidebar').classList.toggle('mobile-open')">
30
- <iconify-icon icon="lucide:menu"></iconify-icon>
31
- <span>Tables Menu</span>
32
- </button>
33
- </div>
34
- <div class="db-container">
35
- {/* Left Sidebar: Discovered Tables & Schema Details */}
36
- <div id="db-sidebar" class="sidebar glass-effect">
37
- <div class="sidebar-scroll-container">
38
- <div class="sidebar-search">
39
- <input
40
- type="text"
41
- id="db-table-search"
42
- class="search-input"
43
- placeholder="Search tables..."
44
- oninput="filterTablesList()"
45
- />
46
- </div>
47
- <div class="sidebar-title">
48
- <span>DISCOVERED TABLES</span>
49
- <span id="tables-count">(0)</span>
50
- </div>
51
- <ul class="table-list" id="tables-list">
52
- <li class="results-empty">Scanning schema...</li>
53
- </ul>
18
+ <div class="browser-card glass-effect">
19
+ <div class="browser-header">
20
+ <div class="table-info">
21
+ <h2>
22
+ <iconify-icon icon="lucide:database"></iconify-icon>
23
+ <span>Database</span>
24
+ </h2>
54
25
  </div>
55
26
  </div>
56
27
 
57
- {/* Right Content Area: Main Browser and SQL Terminal */}
58
- <div class="console-container">
59
- {/* Visual Browser Header (Shown only when a table is selected) */}
60
- <div id="db-browser-view" class="browser-card glass-effect">
61
- <div class="browser-header">
62
- <div class="table-info">
63
- <h2 id="current-table-title">
64
- <iconify-icon icon="lucide:table"></iconify-icon>
65
- <span>users</span>
66
- </h2>
67
- <span id="table-row-count-badge" class="badge">
68
- 0 rows
69
- </span>
70
- </div>
71
- <div class="actions-group">
72
- <button
73
- type="button"
74
- class="btn btn-primary"
75
- onclick="openInsertModal()">
76
- <iconify-icon icon="lucide:plus"></iconify-icon>
77
- <span>Add Row</span>
78
- </button>
79
- <button
80
- type="button"
81
- class="btn btn-secondary"
82
- onclick="openImportModal()">
83
- <iconify-icon icon="lucide:file-up"></iconify-icon>
84
- <span>Import CSV</span>
85
- </button>
86
- <div class="export-dropdown-wrapper">
87
- <button
88
- type="button"
89
- class="btn btn-secondary"
90
- onclick="toggleExportMenu()">
91
- <iconify-icon icon="lucide:download"></iconify-icon>
92
- <span>Export ▾</span>
93
- </button>
94
- <div id="export-menu" class="export-menu">
95
- <button type="button" onclick="exportToCSV()">
96
- Export to CSV
97
- </button>
98
- <button type="button" onclick="exportToJSON()">
99
- Export to JSON
100
- </button>
101
- </div>
102
- </div>
103
- <button
104
- type="button"
105
- class="btn btn-secondary btn-danger"
106
- onclick="truncateCurrentTable()">
107
- <iconify-icon icon="lucide:alert-triangle"></iconify-icon>
108
- <span>Truncate</span>
109
- </button>
110
- </div>
111
- </div>
112
-
113
- {/* Filter Query Builder Bar */}
114
- <div class="filter-builder-card glass-effect">
115
- <div class="filter-builder-row">
116
- <span class="filter-label">
117
- <iconify-icon icon="lucide:filter"></iconify-icon>
118
- <span>Filters</span>
119
- </span>
120
- <select id="filter-col-select" class="filter-select">
121
- <option value="">-- Choose Column --</option>
122
- </select>
123
- <select id="filter-op-select" class="filter-select">
124
- <option value="like">contains</option>
125
- <option value="=">equals</option>
126
- <option value=">">&gt;</option>
127
- <option value="<">&lt;</option>
128
- <option value="is_null">is null</option>
129
- <option value="is_not_null">is not null</option>
130
- </select>
131
- <input
132
- type="text"
133
- id="filter-val-input"
134
- class="filter-input"
135
- placeholder="Filter value..."
136
- />
137
- <button
138
- type="button"
139
- class="btn btn-secondary"
140
- onclick="addActiveFilter()">
141
- Apply
142
- </button>
143
- <button
144
- type="button"
145
- class="btn btn-secondary"
146
- onclick="clearActiveFilters()">
147
- Clear All
148
- </button>
149
- </div>
150
- <div id="active-filters-list" class="active-filters-list">
151
- {/* Dynamically generated filter chips */}
152
- </div>
153
- </div>
154
-
155
- {/* Pagination Controls */}
156
- <div class="pagination-bar">
157
- <div class="page-size-selector">
158
- <span>Show</span>
159
- <select
160
- id="db-page-size"
161
- class="filter-select"
162
- onchange="changePageSize()">
163
- <option value="10">10 rows</option>
164
- <option value="25">25 rows</option>
165
- <option value="50" selected>
166
- 50 rows
167
- </option>
168
- <option value="100">100 rows</option>
169
- <option value="500">500 rows</option>
170
- </select>
171
- </div>
172
- <div class="page-nav">
173
- <button
174
- type="button"
175
- id="btn-page-prev"
176
- class="btn btn-secondary"
177
- onclick="prevPage()">
178
- <iconify-icon icon="lucide:chevron-left"></iconify-icon>
179
- <span>Prev</span>
180
- </button>
181
- <span id="db-page-info">Page 1 of 1</span>
182
- <button
183
- type="button"
184
- id="btn-page-next"
185
- class="btn btn-secondary"
186
- onclick="nextPage()">
187
- <span>Next</span>
188
- <iconify-icon icon="lucide:chevron-right"></iconify-icon>
189
- </button>
190
- </div>
191
- <div class="rows-meta">
192
- <span id="db-rows-meta">0 rows matching filters</span>
193
- </div>
194
- </div>
195
-
196
- {/* Interactive Data Table Grid */}
197
- <div class="results-card glass-effect">
198
- <div id="browser-grid-body">
199
- <div class="results-empty">
200
- <span>Loading table data...</span>
201
- </div>
202
- </div>
203
- </div>
204
- </div>
205
-
206
- {/* SQL Terminal Console */}
207
- <div class="editor-card glass-effect">
208
- <div class="sql-terminal-header">
209
- <h2>{driverName} SQL Terminal</h2>
210
- <span class="console-mode-badge">Read/Write Mode Enabled</span>
211
- </div>
212
- <textarea
213
- class="sql-textarea"
214
- id="sql-query"
215
- placeholder="SELECT * FROM users LIMIT 10;"
216
- />
217
- <div class="actions-row">
218
- <span class="sql-terminal-help">
219
- Supports SELECT, UPDATE, INSERT, DELETE, and administrative
220
- statements.
221
- </span>
222
- <button
223
- type="button"
224
- class="btn sql-terminal-run"
225
- onclick="runQuery()">
226
- <iconify-icon icon="lucide:play"></iconify-icon>
227
- <span>Run SQL Command</span>
228
- </button>
229
- </div>
230
- </div>
231
-
232
- {/* SQL Terminal Output */}
233
- <div class="results-card glass-effect" id="sql-console-results-card">
234
- <div class="results-header">
235
- <span>SQL TERMINAL OUTPUT</span>
236
- <span id="results-meta"></span>
237
- </div>
238
- <div id="results-body">
239
- <div class="results-empty">
240
- <span>
241
- No SQL query executed yet. Write a query above and click "Run
242
- SQL Command".
243
- </span>
244
- </div>
245
- </div>
246
- </div>
247
- </div>
248
- </div>
249
-
250
- {/* Dynamic Insert Modal */}
251
- <div id="modal-insert" class="modal-overlay">
252
- <div class="modal-card">
253
- <div class="modal-header">
254
- <h3>Add New Row</h3>
255
- <button
256
- type="button"
257
- class="modal-close"
258
- onclick="closeInsertModal()">
259
- &times;
260
- </button>
261
- </div>
262
- <form id="insert-row-form" onsubmit="submitInsertRow(event)">
263
- <div id="insert-fields-container" class="modal-fields-container">
264
- {/* Dynamically generated form fields */}
265
- </div>
266
- <div class="modal-actions">
267
- <button
268
- type="button"
269
- class="btn btn-secondary"
270
- onclick="closeInsertModal()">
271
- Cancel
272
- </button>
273
- <button type="submit" class="btn btn-primary">
274
- Add Row
275
- </button>
276
- </div>
277
- </form>
278
- </div>
279
- </div>
280
-
281
- {/* Dynamic Edit Modal */}
282
- <div id="modal-edit" class="modal-overlay">
283
- <div class="modal-card">
284
- <div class="modal-header">
285
- <h3>Edit Row Details</h3>
286
- <button
287
- type="button"
288
- class="modal-close"
289
- onclick="closeEditModal()">
290
- &times;
291
- </button>
292
- </div>
293
- <form id="edit-row-form" onsubmit="submitEditRow(event)">
294
- <input type="hidden" id="edit-row-rowid" />
295
- <div id="edit-fields-container" class="modal-fields-container">
296
- {/* Dynamically generated form fields */}
297
- </div>
298
- <div class="modal-actions">
299
- <button
300
- type="button"
301
- class="btn btn-secondary"
302
- onclick="closeEditModal()">
303
- Cancel
304
- </button>
305
- <button type="submit" class="btn btn-primary">
306
- Save Changes
307
- </button>
308
- </div>
309
- </form>
310
- </div>
311
- </div>
312
-
313
- {/* CSV Import Modal */}
314
- <div id="modal-import" class="modal-overlay">
315
- <div class="modal-card">
316
- <div class="modal-header">
317
- <h3>
318
- <iconify-icon icon="lucide:file-up"></iconify-icon>
319
- <span>Bulk Import CSV Data</span>
320
- </h3>
321
- <button
322
- type="button"
323
- class="modal-close"
324
- onclick="closeImportModal()">
325
- &times;
326
- </button>
327
- </div>
328
- <div class="modal-fields-container">
329
- <p>
330
- Paste CSV data below. The first row must contain column headers
331
- matching the table schema. Data values are automatically parsed.
332
- </p>
333
- <textarea
334
- id="csv-import-textarea"
335
- class="sql-textarea"
336
- placeholder="username,email&#10;alice,alice@example.com&#10;bob,bob@example.com"></textarea>
337
- <div class="import-upload-block">
338
- <span>Or upload a .csv file:</span>
339
- <input
340
- type="file"
341
- id="csv-file-input"
342
- accept=".csv"
343
- onchange="handleCsvFileSelect(event)"
344
- />
345
- </div>
346
- </div>
28
+ <div class="modal-fields-container">
29
+ <p>
30
+ The database browser and editor moved to the Database Explorer
31
+ plugin, served at <code>/_db</code>. It browses tables, filters and
32
+ edits rows, and does not accept raw SQL.
33
+ </p>
34
+ <p>
35
+ This console no longer reads or writes table data. If{' '}
36
+ <code>/_db</code> is not reachable, the explorer plugin is not
37
+ registered in your <code>server.config.ts</code>.
38
+ </p>
347
39
  <div class="modal-actions">
348
- <button
349
- type="button"
350
- class="btn btn-secondary"
351
- onclick="closeImportModal()">
352
- Cancel
353
- </button>
354
- <button
355
- type="button"
356
- class="btn btn-primary"
357
- onclick="submitImportCsv()">
358
- Import Rows
359
- </button>
40
+ <a class="btn btn-primary" href="/_db">
41
+ <iconify-icon icon="lucide:external-link"></iconify-icon>
42
+ <span>Open Database Explorer</span>
43
+ </a>
360
44
  </div>
361
45
  </div>
362
46
  </div>