@bakery-framework/plugin-dashboard 1.0.0
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/LICENSE +19 -0
- package/README.md +44 -0
- package/package.json +42 -0
- package/src/authorize.ts +76 -0
- package/src/client/dashboard.ts +165 -0
- package/src/client/parts/database.ts +1176 -0
- package/src/client/parts/effects.ts +327 -0
- package/src/client/parts/logs.ts +132 -0
- package/src/client/parts/sessions.ts +382 -0
- package/src/client/parts/stats.ts +957 -0
- package/src/client/parts/utils.ts +237 -0
- package/src/components/DBBrowser.tsx +365 -0
- package/src/components/LogsPanel.tsx +35 -0
- package/src/components/SessionsPanel.tsx +90 -0
- package/src/components/StatsPanel.tsx +265 -0
- package/src/components/TopPagesPanel.tsx +57 -0
- package/src/endpoints/database.ts +211 -0
- package/src/endpoints/sessions.ts +59 -0
- package/src/index.ts +42 -0
- package/src/paths.ts +14 -0
- package/src/setup.ts +268 -0
- package/src/shell.tsx +137 -0
- package/src/test-fixtures.ts +55 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export function renderSessionsPanel() {
|
|
2
|
+
return (
|
|
3
|
+
<div id="panel-sessions" class="panel">
|
|
4
|
+
<div class="session-browser-header glass-effect">
|
|
5
|
+
<div class="actions-row session-browser-title-row">
|
|
6
|
+
<div>
|
|
7
|
+
<h2>Active Evictable Sessions</h2>
|
|
8
|
+
<span class="session-browser-subtitle">
|
|
9
|
+
Search, sort, and page through in-memory session records.
|
|
10
|
+
</span>
|
|
11
|
+
</div>
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
class="btn btn-secondary"
|
|
15
|
+
onclick="loadSessions()">
|
|
16
|
+
<iconify-icon icon="lucide:refresh-cw"></iconify-icon>
|
|
17
|
+
<span>Refresh Sessions</span>
|
|
18
|
+
</button>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div class="session-browser-controls">
|
|
22
|
+
<input
|
|
23
|
+
id="session-search-input"
|
|
24
|
+
class="search-input session-search-input"
|
|
25
|
+
type="text"
|
|
26
|
+
placeholder="Search by session id or value..."
|
|
27
|
+
oninput="queueSessionSearch()"
|
|
28
|
+
/>
|
|
29
|
+
<select
|
|
30
|
+
id="session-sort-by"
|
|
31
|
+
class="filter-select"
|
|
32
|
+
onchange="loadSessions()">
|
|
33
|
+
<option value="accessed">Last Accessed</option>
|
|
34
|
+
<option value="id">Session ID</option>
|
|
35
|
+
<option value="keys">Persisted Keys</option>
|
|
36
|
+
</select>
|
|
37
|
+
<select
|
|
38
|
+
id="session-sort-order"
|
|
39
|
+
class="filter-select"
|
|
40
|
+
onchange="loadSessions()">
|
|
41
|
+
<option value="DESC">Newest First</option>
|
|
42
|
+
<option value="ASC">Oldest First</option>
|
|
43
|
+
</select>
|
|
44
|
+
<select
|
|
45
|
+
id="session-page-size"
|
|
46
|
+
class="filter-select"
|
|
47
|
+
onchange="changeSessionPageSize()">
|
|
48
|
+
<option value="10">10 rows</option>
|
|
49
|
+
<option value="25" selected>
|
|
50
|
+
25 rows
|
|
51
|
+
</option>
|
|
52
|
+
<option value="50">50 rows</option>
|
|
53
|
+
<option value="100">100 rows</option>
|
|
54
|
+
<option value="500">500 rows</option>
|
|
55
|
+
</select>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="session-browser-pagination pagination-bar compact">
|
|
59
|
+
<div class="page-nav">
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
id="session-page-prev"
|
|
63
|
+
class="btn btn-secondary"
|
|
64
|
+
onclick="prevSessionPage()">
|
|
65
|
+
<iconify-icon icon="lucide:chevron-left"></iconify-icon>
|
|
66
|
+
<span>Prev</span>
|
|
67
|
+
</button>
|
|
68
|
+
<span id="session-page-info">Page 1 of 1</span>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
id="session-page-next"
|
|
72
|
+
class="btn btn-secondary"
|
|
73
|
+
onclick="nextSessionPage()">
|
|
74
|
+
<span>Next</span>
|
|
75
|
+
<iconify-icon icon="lucide:chevron-right"></iconify-icon>
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="rows-meta">
|
|
79
|
+
<span id="session-rows-meta">0 sessions matching filters</span>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="session-grid" id="session-container">
|
|
84
|
+
<div class="results-empty">
|
|
85
|
+
<span>Loading active sessions...</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
export function renderStatsPanel() {
|
|
2
|
+
return (
|
|
3
|
+
<div id="panel-stats" class="panel active">
|
|
4
|
+
<div class="grid-stats">
|
|
5
|
+
<div class="card glass-effect">
|
|
6
|
+
<span class="card-title">Process Uptime</span>
|
|
7
|
+
<span class="card-value" id="stat-uptime">
|
|
8
|
+
0s
|
|
9
|
+
</span>
|
|
10
|
+
<span class="card-sub" id="stat-pid">
|
|
11
|
+
PID: -
|
|
12
|
+
</span>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="card glass-effect">
|
|
15
|
+
<span class="card-title">Ping Latency</span>
|
|
16
|
+
<span class="card-value" id="stat-ping">
|
|
17
|
+
0 ms
|
|
18
|
+
</span>
|
|
19
|
+
<span class="card-sub">Client Latency</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="card glass-effect">
|
|
22
|
+
<span class="card-title">Memory Allocation</span>
|
|
23
|
+
<span class="card-value" id="stat-memory">
|
|
24
|
+
0 MB
|
|
25
|
+
</span>
|
|
26
|
+
<span class="card-sub" id="stat-mem-total">
|
|
27
|
+
Total Alloc
|
|
28
|
+
</span>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="card glass-effect">
|
|
31
|
+
<span class="card-title">Active Loggers</span>
|
|
32
|
+
<span class="card-value" id="stat-loggers">
|
|
33
|
+
0
|
|
34
|
+
</span>
|
|
35
|
+
<span class="card-sub">Connections</span>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="card glass-effect">
|
|
38
|
+
<span class="card-title">Active Sessions</span>
|
|
39
|
+
<span class="card-value" id="stat-sessions">
|
|
40
|
+
0
|
|
41
|
+
</span>
|
|
42
|
+
<span class="card-sub">In-Memory Store</span>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="card glass-effect">
|
|
45
|
+
<span class="card-title">Bun Runtime</span>
|
|
46
|
+
<span class="card-value" id="stat-bun-version">
|
|
47
|
+
v-
|
|
48
|
+
</span>
|
|
49
|
+
<span class="card-sub" id="stat-arch">
|
|
50
|
+
Architecture
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="section-header">
|
|
56
|
+
<h2>
|
|
57
|
+
<iconify-icon icon="lucide:area-chart"></iconify-icon>
|
|
58
|
+
<span>Performance History</span>
|
|
59
|
+
</h2>
|
|
60
|
+
<div class="timescale-selector">
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
class="timescale-btn active"
|
|
64
|
+
id="timescale-1m"
|
|
65
|
+
onclick="changeTimescale('1m')">
|
|
66
|
+
1m
|
|
67
|
+
</button>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
class="timescale-btn"
|
|
71
|
+
id="timescale-1h"
|
|
72
|
+
onclick="changeTimescale('1h')">
|
|
73
|
+
1h
|
|
74
|
+
</button>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
class="timescale-btn"
|
|
78
|
+
id="timescale-1d"
|
|
79
|
+
onclick="changeTimescale('1d')">
|
|
80
|
+
1d
|
|
81
|
+
</button>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
class="timescale-btn"
|
|
85
|
+
id="timescale-7d"
|
|
86
|
+
onclick="changeTimescale('7d')">
|
|
87
|
+
7d
|
|
88
|
+
</button>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
class="timescale-btn"
|
|
92
|
+
id="timescale-30d"
|
|
93
|
+
onclick="changeTimescale('30d')">
|
|
94
|
+
30d
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div class="grid-charts">
|
|
100
|
+
<div class="chart-card glass-effect">
|
|
101
|
+
<span class="card-title">Ping Latency History</span>
|
|
102
|
+
<span class="card-sub">
|
|
103
|
+
Client-to-server connection latency (last 1 min, 1s resolution)
|
|
104
|
+
</span>
|
|
105
|
+
<canvas id="canvas-ping" class="big-chart"></canvas>
|
|
106
|
+
<div class="chart-stats">
|
|
107
|
+
<span>
|
|
108
|
+
MIN: <strong id="ping-min">-</strong>
|
|
109
|
+
</span>
|
|
110
|
+
<span>
|
|
111
|
+
MAX: <strong id="ping-max">-</strong>
|
|
112
|
+
</span>
|
|
113
|
+
<span>
|
|
114
|
+
AVG: <strong id="ping-avg">-</strong>
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="chart-card glass-effect">
|
|
119
|
+
<span class="card-title">Memory History</span>
|
|
120
|
+
<span class="card-sub">
|
|
121
|
+
Heap/RSS RAM consumption (last 1 min, 1s resolution)
|
|
122
|
+
</span>
|
|
123
|
+
<canvas id="canvas-memory" class="big-chart"></canvas>
|
|
124
|
+
<div class="chart-stats">
|
|
125
|
+
<span>
|
|
126
|
+
MIN: <strong id="memory-min">-</strong>
|
|
127
|
+
</span>
|
|
128
|
+
<span>
|
|
129
|
+
MAX: <strong id="memory-max">-</strong>
|
|
130
|
+
</span>
|
|
131
|
+
<span>
|
|
132
|
+
AVG: <strong id="memory-avg">-</strong>
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="chart-card glass-effect">
|
|
137
|
+
<span class="card-title">WebSocket Connections</span>
|
|
138
|
+
<span class="card-sub">
|
|
139
|
+
Active client logger tunnels (last 1 min, 1s resolution)
|
|
140
|
+
</span>
|
|
141
|
+
<canvas id="canvas-loggers" class="big-chart"></canvas>
|
|
142
|
+
<div class="chart-stats">
|
|
143
|
+
<span>
|
|
144
|
+
MIN: <strong id="loggers-min">-</strong>
|
|
145
|
+
</span>
|
|
146
|
+
<span>
|
|
147
|
+
MAX: <strong id="loggers-max">-</strong>
|
|
148
|
+
</span>
|
|
149
|
+
<span>
|
|
150
|
+
AVG: <strong id="loggers-avg">-</strong>
|
|
151
|
+
</span>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="chart-card glass-effect">
|
|
155
|
+
<span class="card-title">Active Sessions</span>
|
|
156
|
+
<span class="card-sub">
|
|
157
|
+
In-memory active user sessions (last 1 min, 1s resolution)
|
|
158
|
+
</span>
|
|
159
|
+
<canvas id="canvas-sessions" class="big-chart"></canvas>
|
|
160
|
+
<div class="chart-stats">
|
|
161
|
+
<span>
|
|
162
|
+
MIN: <strong id="sessions-min">-</strong>
|
|
163
|
+
</span>
|
|
164
|
+
<span>
|
|
165
|
+
MAX: <strong id="sessions-max">-</strong>
|
|
166
|
+
</span>
|
|
167
|
+
<span>
|
|
168
|
+
AVG: <strong id="sessions-avg">-</strong>
|
|
169
|
+
</span>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="chart-card glass-effect" id="chart-route-hits">
|
|
173
|
+
<span class="card-title">Page Route Hits History</span>
|
|
174
|
+
<span class="card-sub">
|
|
175
|
+
Application page requests (last 1 min, 1s resolution)
|
|
176
|
+
</span>
|
|
177
|
+
<canvas id="canvas-route-hits" class="big-chart"></canvas>
|
|
178
|
+
<div class="chart-stats">
|
|
179
|
+
<span>
|
|
180
|
+
MIN: <strong id="pageHits-min">-</strong>
|
|
181
|
+
</span>
|
|
182
|
+
<span>
|
|
183
|
+
MAX: <strong id="pageHits-max">-</strong>
|
|
184
|
+
</span>
|
|
185
|
+
<span>
|
|
186
|
+
AVG: <strong id="pageHits-avg">-</strong>
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
<div class="chart-card glass-effect" id="chart-api-hits">
|
|
191
|
+
<span class="card-title">API Hits History</span>
|
|
192
|
+
<span class="card-sub">
|
|
193
|
+
API endpoint requests (last 1 min, 1s resolution)
|
|
194
|
+
</span>
|
|
195
|
+
<canvas id="canvas-api-hits" class="big-chart"></canvas>
|
|
196
|
+
<div class="chart-stats">
|
|
197
|
+
<span>
|
|
198
|
+
MIN: <strong id="apiHits-min">-</strong>
|
|
199
|
+
</span>
|
|
200
|
+
<span>
|
|
201
|
+
MAX: <strong id="apiHits-max">-</strong>
|
|
202
|
+
</span>
|
|
203
|
+
<span>
|
|
204
|
+
AVG: <strong id="apiHits-avg">-</strong>
|
|
205
|
+
</span>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
<div class="chart-card glass-effect" id="chart-unique-requests">
|
|
209
|
+
<span class="card-title">Unique Requests History</span>
|
|
210
|
+
<span class="card-sub">
|
|
211
|
+
Distinct request signatures (last 1 min, 1s resolution)
|
|
212
|
+
</span>
|
|
213
|
+
<canvas id="canvas-unique-requests" class="big-chart"></canvas>
|
|
214
|
+
<div class="chart-stats">
|
|
215
|
+
<span>
|
|
216
|
+
MIN: <strong id="uniqueRequests-min">-</strong>
|
|
217
|
+
</span>
|
|
218
|
+
<span>
|
|
219
|
+
MAX: <strong id="uniqueRequests-max">-</strong>
|
|
220
|
+
</span>
|
|
221
|
+
<span>
|
|
222
|
+
AVG: <strong id="uniqueRequests-avg">-</strong>
|
|
223
|
+
</span>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
<div class="chart-card glass-effect" id="chart-db-hits">
|
|
227
|
+
<span class="card-title">DB Hits History</span>
|
|
228
|
+
<span class="card-sub">
|
|
229
|
+
Database query executions (last 1 min, 1s resolution)
|
|
230
|
+
</span>
|
|
231
|
+
<canvas id="canvas-db-hits" class="big-chart"></canvas>
|
|
232
|
+
<div class="chart-stats">
|
|
233
|
+
<span>
|
|
234
|
+
MIN: <strong id="dbHits-min">-</strong>
|
|
235
|
+
</span>
|
|
236
|
+
<span>
|
|
237
|
+
MAX: <strong id="dbHits-max">-</strong>
|
|
238
|
+
</span>
|
|
239
|
+
<span>
|
|
240
|
+
AVG: <strong id="dbHits-avg">-</strong>
|
|
241
|
+
</span>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
<div class="chart-card glass-effect" id="chart-error-page-hits">
|
|
245
|
+
<span class="card-title">Error Page Hits History</span>
|
|
246
|
+
<span class="card-sub">
|
|
247
|
+
Custom error page renders (last 1 min, 1s resolution)
|
|
248
|
+
</span>
|
|
249
|
+
<canvas id="canvas-error-page-hits" class="big-chart"></canvas>
|
|
250
|
+
<div class="chart-stats">
|
|
251
|
+
<span>
|
|
252
|
+
MIN: <strong id="errorPageHits-min">-</strong>
|
|
253
|
+
</span>
|
|
254
|
+
<span>
|
|
255
|
+
MAX: <strong id="errorPageHits-max">-</strong>
|
|
256
|
+
</span>
|
|
257
|
+
<span>
|
|
258
|
+
AVG: <strong id="errorPageHits-avg">-</strong>
|
|
259
|
+
</span>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function renderTopPagesPanel() {
|
|
2
|
+
return (
|
|
3
|
+
<div id="panel-top-pages" class="panel">
|
|
4
|
+
<div class="section-header">
|
|
5
|
+
<h2>
|
|
6
|
+
<iconify-icon icon="lucide:file-text"></iconify-icon>
|
|
7
|
+
<span>Top Visited Pages</span>
|
|
8
|
+
</h2>
|
|
9
|
+
<div class="timescale-selector">
|
|
10
|
+
<button
|
|
11
|
+
type="button"
|
|
12
|
+
class="pages-filter-btn"
|
|
13
|
+
id="pages-filter-1m"
|
|
14
|
+
onclick="changePagesFilter('1m')">
|
|
15
|
+
1m
|
|
16
|
+
</button>
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
class="pages-filter-btn"
|
|
20
|
+
id="pages-filter-1h"
|
|
21
|
+
onclick="changePagesFilter('1h')">
|
|
22
|
+
1h
|
|
23
|
+
</button>
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
class="pages-filter-btn active"
|
|
27
|
+
id="pages-filter-1d"
|
|
28
|
+
onclick="changePagesFilter('1d')">
|
|
29
|
+
1d
|
|
30
|
+
</button>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
class="pages-filter-btn"
|
|
34
|
+
id="pages-filter-7d"
|
|
35
|
+
onclick="changePagesFilter('7d')">
|
|
36
|
+
7d
|
|
37
|
+
</button>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
class="pages-filter-btn"
|
|
41
|
+
id="pages-filter-30d"
|
|
42
|
+
onclick="changePagesFilter('30d')">
|
|
43
|
+
30d
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="card glass-effect top-pages-card">
|
|
49
|
+
<div id="top-pages-list-container">
|
|
50
|
+
<div class="results-empty">
|
|
51
|
+
<span>Loading top pages...</span>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { getElapsed } from '@bakery-framework/core/logger'
|
|
2
|
+
import { processBody } from '@bakery-framework/core/utils'
|
|
3
|
+
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
4
|
+
import { is, Try } from '@bakery-framework/core/utils/common'
|
|
5
|
+
import { response } from '@bakery-framework/core/utils/http'
|
|
6
|
+
import { connection } from '@bakery-framework/orm/connection'
|
|
7
|
+
|
|
8
|
+
export async function handleSchema(): Promise<JsonResponseData<unknown>> {
|
|
9
|
+
return await Try.return(
|
|
10
|
+
async () => response.json.success('success', await connection.getSchema()),
|
|
11
|
+
() => response.json.error(500, 'Failed to retrieve schema details'),
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function handleTableData(
|
|
16
|
+
url: URL,
|
|
17
|
+
): Promise<JsonResponseData<unknown>> {
|
|
18
|
+
const tableName = url.searchParams.get('tableName')
|
|
19
|
+
if (!tableName || !/^[a-zA-Z0-9_]+$/.test(tableName))
|
|
20
|
+
return response.json.error(400, 'Invalid table name')
|
|
21
|
+
|
|
22
|
+
return await Try.return(
|
|
23
|
+
async () => {
|
|
24
|
+
const data = await connection.getData(tableName, {
|
|
25
|
+
page: parseInt(url.searchParams.get('page') || '1', 10),
|
|
26
|
+
pageSize: parseInt(url.searchParams.get('pageSize') || '50', 10),
|
|
27
|
+
sortBy: url.searchParams.get('sortBy'),
|
|
28
|
+
sortOrder: url.searchParams.get('sortOrder') || 'ASC',
|
|
29
|
+
filters: JSON.parse(url.searchParams.get('filters') || '{}'),
|
|
30
|
+
})
|
|
31
|
+
return response.json.success('success', data)
|
|
32
|
+
},
|
|
33
|
+
(error: any) => response.json.error(400, error.message),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Statements that reach outside the database itself. SQLite's `ATTACH` plus
|
|
39
|
+
* `VACUUM INTO` is an arbitrary file write, which turns any dashboard session
|
|
40
|
+
* (or any XSS in the dashboard origin) into host filesystem access.
|
|
41
|
+
*/
|
|
42
|
+
const RX_OUT_OF_BAND_SQL = /\b(attach|detach|vacuum\s+into)\b/i
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Writes from the dashboard need an explicit opt-in.
|
|
46
|
+
*
|
|
47
|
+
* This gate used to sit only in `handleQuery`, so `DELETE FROM t` typed into
|
|
48
|
+
* the SQL box was refused while the grid's Delete and Truncate buttons — which
|
|
49
|
+
* reach the same rows through `execute-action`, with no SQL to inspect — were
|
|
50
|
+
* not. Half a control is worse than none: the production and security
|
|
51
|
+
* checklists both tell operators that leaving `DASHBOARD_ALLOW_WRITES` unset
|
|
52
|
+
* keeps the console read-only, and that was only ever true of one of its two
|
|
53
|
+
* write paths.
|
|
54
|
+
*
|
|
55
|
+
* Read at call time, not at module load: tests set and restore it, and the
|
|
56
|
+
* flag is a deployment decision rather than a build one.
|
|
57
|
+
*/
|
|
58
|
+
function writesEnabled(): boolean {
|
|
59
|
+
return process.env.DASHBOARD_ALLOW_WRITES === '1'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function handleQuery(
|
|
63
|
+
req: Request,
|
|
64
|
+
): Promise<JsonResponseData<unknown>> {
|
|
65
|
+
const body = await processBody(req)
|
|
66
|
+
if (!is.string(body?.sql))
|
|
67
|
+
return response.json.error(400, 'Invalid SQL query')
|
|
68
|
+
|
|
69
|
+
if (RX_OUT_OF_BAND_SQL.test(body.sql)) {
|
|
70
|
+
return response.json.error(
|
|
71
|
+
403,
|
|
72
|
+
'ATTACH / DETACH / VACUUM INTO are not permitted from the dashboard.',
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sqlLower = body.sql.trim().toLowerCase()
|
|
77
|
+
const isSelect = /^(select|with|show|describe|pragma|explain)/.test(sqlLower)
|
|
78
|
+
|
|
79
|
+
// Writes require an explicit opt-in; the browser console should not be a
|
|
80
|
+
// one-keystroke path to DROP TABLE on a production database.
|
|
81
|
+
if (!isSelect && !writesEnabled()) {
|
|
82
|
+
return response.json.error(
|
|
83
|
+
403,
|
|
84
|
+
'Write statements are disabled. Set DASHBOARD_ALLOW_WRITES=1 to enable them.',
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const start = Bun.nanoseconds()
|
|
89
|
+
|
|
90
|
+
return await Try.return(
|
|
91
|
+
async () => {
|
|
92
|
+
const result = isSelect
|
|
93
|
+
? { rows: await connection.query(body.sql).all(), isSelect: true }
|
|
94
|
+
: {
|
|
95
|
+
rows: [
|
|
96
|
+
(({ lastInsertRowid, changes }) => ({
|
|
97
|
+
lastInsertRowid,
|
|
98
|
+
changes,
|
|
99
|
+
}))(await connection.query(body.sql).run()),
|
|
100
|
+
],
|
|
101
|
+
isSelect: false,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return response.json.success('success', {
|
|
105
|
+
...result,
|
|
106
|
+
time: getElapsed(start),
|
|
107
|
+
})
|
|
108
|
+
},
|
|
109
|
+
(error: any) =>
|
|
110
|
+
response.json.error(400, error.message, { time: getElapsed(start) }),
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Every branch answers with the JSON envelope — never a `Response` — so the
|
|
116
|
+
* table is typed as such. `data` stays `unknown`: these payloads are arbitrary
|
|
117
|
+
* database rows, and `unknown` says that honestly where `any` would have let
|
|
118
|
+
* the lie back in.
|
|
119
|
+
*/
|
|
120
|
+
const executeActionHandlers: Record<
|
|
121
|
+
string,
|
|
122
|
+
(body: any, connection: any) => Promise<JsonResponseData<unknown>>
|
|
123
|
+
> = {
|
|
124
|
+
'delete-row': async (body, connection) => {
|
|
125
|
+
if (body.rowid == null) return response.json.error(400, 'Invalid row ID')
|
|
126
|
+
return await Try.return(
|
|
127
|
+
async () => {
|
|
128
|
+
await connection.remove(body.tableName, body.rowid)
|
|
129
|
+
return response.json.success('Row deleted')
|
|
130
|
+
},
|
|
131
|
+
(e: any) => response.json.error(400, e.message),
|
|
132
|
+
)
|
|
133
|
+
},
|
|
134
|
+
truncate: async (body, connection) => {
|
|
135
|
+
return await Try.return(
|
|
136
|
+
async () => {
|
|
137
|
+
await connection.truncate(body.tableName)
|
|
138
|
+
return response.json.success('Table truncated')
|
|
139
|
+
},
|
|
140
|
+
(e: any) => response.json.error(400, e.message),
|
|
141
|
+
)
|
|
142
|
+
},
|
|
143
|
+
'insert-row': async (body, connection) => {
|
|
144
|
+
if (!body.row || typeof body.row !== 'object')
|
|
145
|
+
return response.json.error(400, 'Invalid row data')
|
|
146
|
+
return await Try.return(
|
|
147
|
+
async () => {
|
|
148
|
+
await connection.insert(body.tableName, body.row)
|
|
149
|
+
return response.json.success('Row inserted')
|
|
150
|
+
},
|
|
151
|
+
(e: any) => response.json.error(400, e.message),
|
|
152
|
+
)
|
|
153
|
+
},
|
|
154
|
+
'update-row': async (body, connection) => {
|
|
155
|
+
if (
|
|
156
|
+
!body.row ||
|
|
157
|
+
!is.object(body.row) ||
|
|
158
|
+
Array.isArray(body.row) ||
|
|
159
|
+
body.rowid == null
|
|
160
|
+
) {
|
|
161
|
+
return response.json.error(400, 'Invalid data or row ID')
|
|
162
|
+
}
|
|
163
|
+
return await Try.return(
|
|
164
|
+
async () => {
|
|
165
|
+
await connection.update(body.tableName, body.rowid, body.row)
|
|
166
|
+
return response.json.success('Row updated')
|
|
167
|
+
},
|
|
168
|
+
(e: any) => response.json.error(400, e.message),
|
|
169
|
+
)
|
|
170
|
+
},
|
|
171
|
+
'import-csv': async (body, connection) => {
|
|
172
|
+
if (typeof body.csvContent !== 'string')
|
|
173
|
+
return response.json.error(400, 'Invalid CSV')
|
|
174
|
+
return await Try.return(
|
|
175
|
+
async () => {
|
|
176
|
+
const info = await connection.importCSV(body.tableName, body.csvContent)
|
|
177
|
+
return response.json.success(`Imported ${info.changes} rows`, {
|
|
178
|
+
info,
|
|
179
|
+
})
|
|
180
|
+
},
|
|
181
|
+
(e: any) => response.json.error(400, e.message),
|
|
182
|
+
)
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function handleExecuteAction(
|
|
187
|
+
req: Request,
|
|
188
|
+
): Promise<JsonResponseData<unknown>> {
|
|
189
|
+
// Every entry in `executeActionHandlers` writes — truncate and delete-row
|
|
190
|
+
// destroy data outright — so the gate covers the endpoint rather than being
|
|
191
|
+
// repeated per action. Checked before the body is even read.
|
|
192
|
+
if (!writesEnabled()) {
|
|
193
|
+
return response.json.error(
|
|
194
|
+
403,
|
|
195
|
+
'Dashboard writes are disabled. Set DASHBOARD_ALLOW_WRITES=1 to enable them.',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const body = await processBody(req)
|
|
200
|
+
const { action, tableName } = body || {}
|
|
201
|
+
|
|
202
|
+
if (!is.string(tableName) || !/^[a-zA-Z0-9_]+$/.test(tableName)) {
|
|
203
|
+
return response.json.error(400, 'Invalid table name')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const handler = executeActionHandlers[action]
|
|
207
|
+
if (handler) {
|
|
208
|
+
return await handler(body, connection)
|
|
209
|
+
}
|
|
210
|
+
return response.json.error(400, 'Unknown action')
|
|
211
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { isReservedSessionKey, Session } from '@bakery-framework/core/session'
|
|
2
|
+
import { processBody } from '@bakery-framework/core/utils'
|
|
3
|
+
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
4
|
+
import { response } from '@bakery-framework/core/utils/http'
|
|
5
|
+
|
|
6
|
+
export async function handleGetSessions(
|
|
7
|
+
url: URL,
|
|
8
|
+
): Promise<JsonResponseData<unknown>> {
|
|
9
|
+
const search = url.searchParams.get('search')?.trim().toLowerCase() || ''
|
|
10
|
+
const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10))
|
|
11
|
+
const pageSize = Math.min(
|
|
12
|
+
500,
|
|
13
|
+
Math.max(1, parseInt(url.searchParams.get('pageSize') || '25', 10)),
|
|
14
|
+
)
|
|
15
|
+
const sortBy = url.searchParams.get('sortBy') || 'accessed'
|
|
16
|
+
const sortOrder = url.searchParams.get('sortOrder') === 'ASC' ? 'ASC' : 'DESC'
|
|
17
|
+
|
|
18
|
+
const result = await Session.list({
|
|
19
|
+
search,
|
|
20
|
+
page,
|
|
21
|
+
pageSize,
|
|
22
|
+
sortBy,
|
|
23
|
+
sortOrder,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return response.json.success('success', result)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function handleDeleteSession(
|
|
30
|
+
req: Request,
|
|
31
|
+
): Promise<JsonResponseData<unknown>> {
|
|
32
|
+
const body = await processBody(req)
|
|
33
|
+
const deleted = body?.id && Session.delete(body.id)
|
|
34
|
+
return deleted
|
|
35
|
+
? response.json.success('Session deleted')
|
|
36
|
+
: response.json.error(404, 'Session not found')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function handleUpdateSession(
|
|
40
|
+
req: Request,
|
|
41
|
+
): Promise<JsonResponseData<unknown>> {
|
|
42
|
+
const body = await processBody(req)
|
|
43
|
+
const { id, key, value, remove } = body || {}
|
|
44
|
+
|
|
45
|
+
if (typeof id !== 'string' || typeof key !== 'string')
|
|
46
|
+
return response.json.error(400, 'Invalid payload')
|
|
47
|
+
|
|
48
|
+
// Without this, editing a session could set the dashboard's own auth marker
|
|
49
|
+
// on any user's session — a permanent backdoor.
|
|
50
|
+
if (isReservedSessionKey(key)) {
|
|
51
|
+
return response.json.error(403, 'Reserved session key')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const session = await Session.get(id)
|
|
55
|
+
if (!session) return response.json.error(404, 'Session not found')
|
|
56
|
+
|
|
57
|
+
remove ? session.delete(key) : session.set(key, value)
|
|
58
|
+
return response.json.success('Session updated', { id, key, value, remove })
|
|
59
|
+
}
|