@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,362 +1,51 @@
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
+ * Rendered **only when nothing serves `/_db`**. With the explorer mounted the
12
+ * nav entry links straight to it and this panel never appears — a tab whose
13
+ * whole content is "go there" is a click of ceremony in front of going there.
14
+ * Without it, this is what explains where the editor went.
15
+ *
16
+ * Deliberately static: no fetch, no client module, nothing to escape. It is
17
+ * plain markup so that a tab which now only links somewhere cannot grow a
18
+ * data path back by accident.
19
+ */
9
20
  export function renderDatabaseBrowser() {
10
- const driverName = driverNames[connection.driver] || 'Database'
11
21
  return (
12
22
  <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>
23
+ <div class="browser-card glass-effect">
24
+ <div class="browser-header">
25
+ <div class="table-info">
26
+ <h2>
27
+ <iconify-icon icon="lucide:database"></iconify-icon>
28
+ <span>Database</span>
29
+ </h2>
54
30
  </div>
55
31
  </div>
56
32
 
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>
33
+ <div class="modal-fields-container">
34
+ <p>
35
+ The database browser and editor moved to the Database Explorer
36
+ plugin, served at <code>/_db</code>. It browses tables, filters and
37
+ edits rows, and does not accept raw SQL.
38
+ </p>
39
+ <p>
40
+ This console no longer reads or writes table data. If{' '}
41
+ <code>/_db</code> is not reachable, the explorer plugin is not
42
+ registered in your <code>server.config.ts</code>.
43
+ </p>
347
44
  <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>
45
+ <a class="btn btn-primary" href="/_db">
46
+ <iconify-icon icon="lucide:external-link"></iconify-icon>
47
+ <span>Open Database Explorer</span>
48
+ </a>
360
49
  </div>
361
50
  </div>
362
51
  </div>
package/src/setup.ts CHANGED
@@ -26,12 +26,6 @@ import {
26
26
  // the tree, allow-listed by name in `tests/conventions.test.ts`.
27
27
  import { setupAnalytics } from '@bakery-framework/plugin-analytics/setup'
28
28
  import { isAnalyticsAuthorized } from '@bakery-framework/plugin-analytics/stats'
29
- import {
30
- handleExecuteAction,
31
- handleQuery,
32
- handleSchema,
33
- handleTableData,
34
- } from './endpoints/database'
35
29
  import {
36
30
  handleDeleteSession,
37
31
  handleGetSessions,
@@ -209,9 +203,14 @@ async function checkAuthMiddleware(req: Request, path: string) {
209
203
  *
210
204
  * Necessary but **not sufficient on its own**. `SAFE_METHODS` exempts GET, and
211
205
  * `processBody` reads a GET's query string as the body, so a plain
212
- * `<img src="/api/_dashboard/execute-action?action=truncate&tableName=…">`
213
- * sails past this guard. The mutating keys in the table below are
214
- * method-qualified for exactly that reason; neither half closes the hole alone.
206
+ * `<img src="/api/_dashboard/sessions/delete?id=victim">` sails past this
207
+ * guard. The mutating keys in the table below are method-qualified for exactly
208
+ * that reason; neither half closes the hole alone.
209
+ *
210
+ * The example used to be `execute-action?action=truncate`, which was the worst
211
+ * of them — a link that emptied a table. That endpoint is gone with the grid
212
+ * editor, and the two session routes are what is left to protect. The hazard
213
+ * is unchanged; only the blast radius shrank.
215
214
  */
216
215
  function checkCsrfMiddleware(req: Request, url: URL): DashboardResponse | null {
217
216
  const reason = checkCsrf(req, url)
@@ -241,10 +240,6 @@ const dashboardRoutes = {
241
240
  '/api/_dashboard/sessions': (_req, url) => handleGetSessions(url),
242
241
  'POST /api/_dashboard/sessions/delete': req => handleDeleteSession(req),
243
242
  'POST /api/_dashboard/sessions/update': req => handleUpdateSession(req),
244
- '/api/_dashboard/schema': () => handleSchema(),
245
- '/api/_dashboard/table-data': (_req, url) => handleTableData(url),
246
- 'POST /api/_dashboard/query': req => handleQuery(req),
247
- 'POST /api/_dashboard/execute-action': req => handleExecuteAction(req),
248
243
  } satisfies PluginRouteTable
249
244
 
250
245
  const dispatchDashboardRoute = routeTable(dashboardRoutes)
@@ -279,5 +274,24 @@ export async function handleDashboardRequest(
279
274
  const csrfBlock = checkCsrfMiddleware(req, url)
280
275
  if (csrfBlock) return csrfBlock
281
276
 
282
- return dispatchDashboardRoute(req, url)
277
+ const result = await dispatchDashboardRoute(req, url)
278
+ if (result) return result
279
+
280
+ // `dispatch` answers `null` for a path no key matched, and a handler
281
+ // returning `null` means "not mine" — which core turns into **204 No
282
+ // Content**. Every unmatched request under this namespace therefore answered
283
+ // 204: a status that reads as success and carries nothing.
284
+ //
285
+ // Retiring the write endpoints is what made that matter. A script still
286
+ // posting to `/api/_dashboard/execute-action` was told 204, took it for
287
+ // success, and silently changed nothing. For a route that has been deleted
288
+ // that is the worst available answer.
289
+ //
290
+ // **Only under `/api/`, and that boundary is load-bearing.** The stylesheet
291
+ // is served by `mountRoutes`, not by a key in the table above, so `null` is
292
+ // precisely how `/_dashboard/style.css` *falls through* to the static
293
+ // pipeline. A blanket 404 here claims it and the console loads unstyled —
294
+ // which is what happened when this was first written without the condition.
295
+ if (path.startsWith('/api/')) return response.error('Not Found', 404)
296
+ return null
283
297
  }
package/src/shell.tsx CHANGED
@@ -1,3 +1,6 @@
1
+ import { getFrameworkVersion } from '@bakery-framework/core'
2
+ import { Bakery } from '@bakery-framework/core/core/bakery'
3
+ import { Try } from '@bakery-framework/core/utils/common'
1
4
  import { renderDatabaseBrowser } from './components/DBBrowser'
2
5
  import { renderLogsPanel } from './components/LogsPanel'
3
6
  import { renderSessionsPanel } from './components/SessionsPanel'
@@ -10,25 +13,89 @@ import { renderTopPagesPanel } from './components/TopPagesPanel'
10
13
  * Keeping that contract lets the chrome be replaced without touching the ~3k
11
14
  * lines of panel client code.
12
15
  */
13
- const NAV = [
14
- {
15
- group: 'Observability',
16
- items: [
17
- { id: 'stats', label: 'Overview' },
18
- { id: 'top-pages', label: 'Traffic' },
19
- { id: 'logs', label: 'Logs' },
20
- ],
21
- },
22
- {
23
- group: 'Data',
24
- items: [
25
- { id: 'database', label: 'Database' },
26
- { id: 'sessions', label: 'Sessions' },
27
- ],
28
- },
29
- ]
30
-
31
- function NavItem({ id, label }: { id: string; label: string }) {
16
+ /**
17
+ * A path nothing should claim, used as the control below.
18
+ */
19
+ const NOT_A_ROUTE = '/__bakery_probe_no_handler_serves_this__'
20
+
21
+ /**
22
+ * Is anything serving `/_db`?
23
+ *
24
+ * Asked **behaviourally**, never by importing db-explorer: a plugin-to-plugin
25
+ * import is a package-graph edge, and `tests/conventions.test.ts` allows
26
+ * exactly one of those (this package → analytics). It is also the better
27
+ * question — the console wants to know whether that path leads somewhere, not
28
+ * which package put it there, so an application serving its own explorer at
29
+ * `/_db` gets the link too.
30
+ *
31
+ * **The control probe is what makes this work.** `StaticHandler.canHandle()`
32
+ * returns `true` unconditionally — it is the priority-0 fallback and claims
33
+ * every path — so "does some handler claim `/_db`" is always yes. A handler
34
+ * that claims `/_db` *and declines a path nobody serves* is claiming a
35
+ * namespace rather than catching everything.
36
+ *
37
+ * `canHandle` signatures vary across handlers and some read the request, so a
38
+ * throw here means "not the one we are looking for" rather than an error.
39
+ */
40
+ function explorerIsMounted(): boolean {
41
+ for (const handler of Bakery.handlers.fetch.list()) {
42
+ const claims = (path: string) =>
43
+ Try.return(() => (handler as any).canHandle?.(path) === true, false)
44
+ if (claims('/_db') && !claims(NOT_A_ROUTE)) return true
45
+ }
46
+ return false
47
+ }
48
+
49
+ interface NavEntry {
50
+ id: string
51
+ label: string
52
+ /** Present when this entry leaves the console rather than switching a tab. */
53
+ href?: string
54
+ }
55
+
56
+ function navSections(explorerMounted: boolean): {
57
+ group: string
58
+ items: NavEntry[]
59
+ }[] {
60
+ return [
61
+ {
62
+ group: 'Observability',
63
+ items: [
64
+ { id: 'stats', label: 'Overview' },
65
+ { id: 'top-pages', label: 'Traffic' },
66
+ { id: 'logs', label: 'Logs' },
67
+ ],
68
+ },
69
+ {
70
+ group: 'Data',
71
+ items: [
72
+ // The console does not browse the database any more, so Database is a
73
+ // way *out* of it when the explorer is mounted — a link, not a tab.
74
+ // Without it the entry stays a tab showing the panel that explains
75
+ // where the editor went and how to get it back; an entry that silently
76
+ // navigates to a 404 would be worse than either.
77
+ explorerMounted
78
+ ? { id: 'database', label: 'Database', href: '/_db' }
79
+ : { id: 'database', label: 'Database' },
80
+ { id: 'sessions', label: 'Sessions' },
81
+ ],
82
+ },
83
+ ]
84
+ }
85
+
86
+ function NavItem({ id, label, href }: NavEntry) {
87
+ if (href) {
88
+ return (
89
+ <a class="tab-btn" href={href}>
90
+ <span class="nav-dot"></span>
91
+ <span>{label}</span>
92
+ <span class="nav-external" aria-hidden="true">
93
+
94
+ </span>
95
+ </a>
96
+ )
97
+ }
98
+
32
99
  return (
33
100
  <button
34
101
  type="button"
@@ -41,6 +108,9 @@ function NavItem({ id, label }: { id: string; label: string }) {
41
108
  }
42
109
 
43
110
  export default function Dashboard() {
111
+ const explorerMounted = explorerIsMounted()
112
+ const NAV = navSections(explorerMounted)
113
+
44
114
  return (
45
115
  <html lang="en">
46
116
  <head>
@@ -62,14 +132,20 @@ export default function Dashboard() {
62
132
  <nav class="rail-group">
63
133
  <div class="rail-group-label">{section.group}</div>
64
134
  {section.items.map(item => (
65
- <NavItem id={item.id} label={item.label} />
135
+ <NavItem id={item.id} label={item.label} href={item.href} />
66
136
  ))}
67
137
  </nav>
68
138
  ))}
69
139
 
70
140
  <div class="rail-foot">
71
141
  <span>Bakery</span>
72
- <span id="rail-version">v3</span>
142
+ {/* Was the literal `v3`, which was never any version of anything
143
+ — nothing filled the id, and the framework was on 1.x when it
144
+ was written. `getFrameworkVersion()` reads core's own
145
+ manifest, which is the number this label claims to be; the
146
+ app's version is a different question and `BAKERY_VERSION`
147
+ answers that one despite its name. */}
148
+ <span id="rail-version">v{getFrameworkVersion()}</span>
73
149
  </div>
74
150
  </aside>
75
151
 
@@ -125,7 +201,7 @@ export default function Dashboard() {
125
201
  {renderStatsPanel()}
126
202
  {renderTopPagesPanel()}
127
203
  {renderSessionsPanel()}
128
- {renderDatabaseBrowser()}
204
+ {explorerMounted ? null : renderDatabaseBrowser()}
129
205
  {renderLogsPanel()}
130
206
  </main>
131
207
  </div>