@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,382 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ICON_DELETE,
|
|
3
|
+
ICON_EDIT,
|
|
4
|
+
icon,
|
|
5
|
+
postJson,
|
|
6
|
+
setEmpty,
|
|
7
|
+
setPager,
|
|
8
|
+
setText,
|
|
9
|
+
} from './utils'
|
|
10
|
+
|
|
11
|
+
export let sessionCurrentPage = 1
|
|
12
|
+
export let sessionPageSize = 25
|
|
13
|
+
export let sessionTotalPages = 1
|
|
14
|
+
export let sessionSearchDebounce: number | undefined
|
|
15
|
+
|
|
16
|
+
function buildSessionRequestParams(): URLSearchParams {
|
|
17
|
+
const searchEl = document.getElementById(
|
|
18
|
+
'session-search-input',
|
|
19
|
+
) as HTMLInputElement | null
|
|
20
|
+
const sortByEl = document.getElementById(
|
|
21
|
+
'session-sort-by',
|
|
22
|
+
) as HTMLSelectElement | null
|
|
23
|
+
const sortOrderEl = document.getElementById(
|
|
24
|
+
'session-sort-order',
|
|
25
|
+
) as HTMLSelectElement | null
|
|
26
|
+
|
|
27
|
+
const params = new URLSearchParams({
|
|
28
|
+
page: sessionCurrentPage.toString(),
|
|
29
|
+
pageSize: sessionPageSize.toString(),
|
|
30
|
+
sortBy: sortByEl?.value || 'accessed',
|
|
31
|
+
sortOrder: (sortOrderEl?.value as 'ASC' | 'DESC') || 'DESC',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const searchValue = searchEl?.value?.trim()
|
|
35
|
+
if (searchValue) params.set('search', searchValue)
|
|
36
|
+
return params
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function updateSessionsPaginationUI(
|
|
40
|
+
totalRows: number,
|
|
41
|
+
page: number,
|
|
42
|
+
pageSize: number,
|
|
43
|
+
totalPages: number,
|
|
44
|
+
) {
|
|
45
|
+
sessionCurrentPage = page
|
|
46
|
+
sessionPageSize = pageSize
|
|
47
|
+
sessionTotalPages = totalPages || 1
|
|
48
|
+
|
|
49
|
+
setText('session-rows-meta', `${totalRows} sessions matching filters`)
|
|
50
|
+
setPager(SESSION_PAGER_IDS, page, sessionTotalPages)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SESSION_PAGER_IDS = {
|
|
54
|
+
info: 'session-page-info',
|
|
55
|
+
prev: 'session-page-prev',
|
|
56
|
+
next: 'session-page-next',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resetSessionsUIOnError() {
|
|
60
|
+
sessionCurrentPage = 1
|
|
61
|
+
sessionTotalPages = 1
|
|
62
|
+
setText('session-rows-meta', '0 sessions matching filters')
|
|
63
|
+
setPager(SESSION_PAGER_IDS, 1, 1)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const SHOW_LIMIT = 3
|
|
67
|
+
|
|
68
|
+
function renderKVRows(
|
|
69
|
+
kvSection: HTMLElement,
|
|
70
|
+
entries: [string, any][],
|
|
71
|
+
sId: string,
|
|
72
|
+
showAll: boolean,
|
|
73
|
+
) {
|
|
74
|
+
kvSection.innerHTML = ''
|
|
75
|
+
const visible = showAll ? entries : entries.slice(0, SHOW_LIMIT)
|
|
76
|
+
|
|
77
|
+
visible.forEach(([k, v]) => {
|
|
78
|
+
const row = document.createElement('div')
|
|
79
|
+
row.style.cssText =
|
|
80
|
+
'display:flex;align-items:center;gap:0.5rem;background:rgba(255,255,255,0.04);border:1px solid var(--border-color);border-radius:0.375rem;padding:0.3rem 0.6rem;'
|
|
81
|
+
|
|
82
|
+
const keyEl = document.createElement('span')
|
|
83
|
+
keyEl.style.cssText =
|
|
84
|
+
'font-size:0.8rem;font-weight:600;color:var(--text-secondary);min-width:120px;font-family:monospace;'
|
|
85
|
+
keyEl.innerText = k
|
|
86
|
+
|
|
87
|
+
const valEl = document.createElement('span')
|
|
88
|
+
valEl.style.cssText =
|
|
89
|
+
'font-size:0.8rem;color:var(--text-primary);flex:1;font-family:monospace;word-break:break-all;'
|
|
90
|
+
valEl.innerText = is.object(v) ? JSON.stringify(v) : String(v)
|
|
91
|
+
|
|
92
|
+
const editBtn = document.createElement('button')
|
|
93
|
+
editBtn.style.cssText =
|
|
94
|
+
'background:none;border:none;cursor:pointer;color:var(--text-secondary);font-size:0.85rem;padding:0.1rem 0.25rem;border-radius:0.25rem;transition:color 0.15s;'
|
|
95
|
+
editBtn.title = 'Edit value'
|
|
96
|
+
editBtn.innerHTML = icon(ICON_EDIT, '0.95rem')
|
|
97
|
+
editBtn.onmouseenter = () => (editBtn.style.color = 'var(--text-primary)')
|
|
98
|
+
editBtn.onmouseleave = () => (editBtn.style.color = 'var(--text-secondary)')
|
|
99
|
+
editBtn.onclick = () =>
|
|
100
|
+
openSessionKeyEditor(
|
|
101
|
+
sId,
|
|
102
|
+
k,
|
|
103
|
+
String(is.object(v) ? JSON.stringify(v) : v),
|
|
104
|
+
() => loadSessions(),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const delBtn = document.createElement('button')
|
|
108
|
+
delBtn.style.cssText =
|
|
109
|
+
'background:none;border:none;cursor:pointer;color:var(--accent-red);font-size:0.85rem;padding:0.1rem 0.25rem;border-radius:0.25rem;opacity:0.7;transition:opacity 0.15s;'
|
|
110
|
+
delBtn.title = 'Delete key'
|
|
111
|
+
delBtn.innerHTML = icon(ICON_DELETE, '0.95rem')
|
|
112
|
+
delBtn.onmouseenter = () => (delBtn.style.opacity = '1')
|
|
113
|
+
delBtn.onmouseleave = () => (delBtn.style.opacity = '0.7')
|
|
114
|
+
delBtn.onclick = async () => {
|
|
115
|
+
await sessionKeyAction(sId, k, null, true)
|
|
116
|
+
await loadSessions()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
row.appendChild(keyEl)
|
|
120
|
+
row.appendChild(valEl)
|
|
121
|
+
row.appendChild(editBtn)
|
|
122
|
+
row.appendChild(delBtn)
|
|
123
|
+
kvSection.appendChild(row)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
if (entries.length > SHOW_LIMIT) {
|
|
127
|
+
const toggle = document.createElement('button')
|
|
128
|
+
toggle.style.cssText =
|
|
129
|
+
'font-size:0.75rem;color:var(--text-secondary);background:none;border:none;cursor:pointer;text-align:left;padding:0.1rem 0;margin-top:0.1rem;transition:color 0.15s;'
|
|
130
|
+
toggle.innerText = showAll
|
|
131
|
+
? `▲ Show fewer`
|
|
132
|
+
: `▼ Show all ${entries.length} keys`
|
|
133
|
+
toggle.onmouseenter = () => (toggle.style.color = 'var(--text-primary)')
|
|
134
|
+
toggle.onmouseleave = () => (toggle.style.color = 'var(--text-secondary)')
|
|
135
|
+
toggle.onclick = () => renderKVRows(kvSection, entries, sId, !showAll)
|
|
136
|
+
kvSection.appendChild(toggle)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (entries.length === 0) {
|
|
140
|
+
const empty = document.createElement('span')
|
|
141
|
+
empty.style.cssText =
|
|
142
|
+
'font-size:0.8rem;color:var(--text-secondary);font-style:italic;'
|
|
143
|
+
empty.innerText = 'No data stored in this session.'
|
|
144
|
+
kvSection.appendChild(empty)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const addRow = document.createElement('div')
|
|
148
|
+
addRow.style.cssText = 'margin-top:0.35rem;'
|
|
149
|
+
const addBtn = document.createElement('button')
|
|
150
|
+
addBtn.className = 'btn btn-secondary'
|
|
151
|
+
addBtn.style.cssText = 'font-size:0.75rem;padding:0.25rem 0.65rem;'
|
|
152
|
+
addBtn.innerText = '+ Add Key'
|
|
153
|
+
addBtn.onclick = () =>
|
|
154
|
+
openSessionKeyEditor(sId, '', '', () => loadSessions(), true)
|
|
155
|
+
addRow.appendChild(addBtn)
|
|
156
|
+
kvSection.appendChild(addRow)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderSessionCard(s: any): HTMLElement {
|
|
160
|
+
const card = document.createElement('div')
|
|
161
|
+
card.className = 'session-card glass-effect'
|
|
162
|
+
|
|
163
|
+
const header = document.createElement('div')
|
|
164
|
+
header.className = 'session-card-header'
|
|
165
|
+
header.innerHTML = `<span class="session-id">${escapeHTML(String(s.id))}</span>`
|
|
166
|
+
|
|
167
|
+
const revokeBtn = document.createElement('button')
|
|
168
|
+
revokeBtn.className = 'btn btn-secondary btn-danger'
|
|
169
|
+
revokeBtn.style.cssText = 'padding:0.25rem 0.5rem;font-size:0.75rem;'
|
|
170
|
+
revokeBtn.innerText = 'Revoke'
|
|
171
|
+
revokeBtn.onclick = () => revokeSession(s.id)
|
|
172
|
+
header.appendChild(revokeBtn)
|
|
173
|
+
|
|
174
|
+
const accessedAt = new Date(s.accessedAt || Date.now())
|
|
175
|
+
const ttl =
|
|
176
|
+
Array.isArray(s.persistKeys) && s.persistKeys.length > 0
|
|
177
|
+
? 30 * 24 * 60 * 60 * 1000
|
|
178
|
+
: 24 * 60 * 60 * 1000
|
|
179
|
+
const expiresAt = new Date((s.accessedAt || Date.now()) + ttl)
|
|
180
|
+
const info = document.createElement('div')
|
|
181
|
+
info.style.cssText =
|
|
182
|
+
'font-size:0.8rem;color:var(--text-secondary);display:flex;gap:1.5rem;margin-bottom:0.5rem;'
|
|
183
|
+
info.innerHTML = `
|
|
184
|
+
<span>Last Accessed: <strong style="color:var(--text-primary)">${accessedAt.toLocaleTimeString()}</strong></span>
|
|
185
|
+
<span>Expires: <strong style="color:var(--text-primary)">${expiresAt.toLocaleString()}</strong></span>
|
|
186
|
+
`
|
|
187
|
+
|
|
188
|
+
const kvSection = document.createElement('div')
|
|
189
|
+
kvSection.style.cssText =
|
|
190
|
+
'display:flex;flex-direction:column;gap:0.35rem;margin-top:0.5rem;'
|
|
191
|
+
|
|
192
|
+
const entries = Object.entries(s.data as Record<string, any>)
|
|
193
|
+
renderKVRows(kvSection, entries, s.id, false)
|
|
194
|
+
|
|
195
|
+
card.appendChild(header)
|
|
196
|
+
card.appendChild(info)
|
|
197
|
+
card.appendChild(kvSection)
|
|
198
|
+
return card
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function loadSessions() {
|
|
202
|
+
const container = document.getElementById('session-container')
|
|
203
|
+
if (!container) return
|
|
204
|
+
setEmpty(container, 'Fetching sessions...')
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const params = buildSessionRequestParams()
|
|
208
|
+
const res = await fetch(`/api/_dashboard/sessions?${params.toString()}`)
|
|
209
|
+
const json = await res.json()
|
|
210
|
+
|
|
211
|
+
const rows = Array.isArray(json.data?.rows) ? json.data.rows : []
|
|
212
|
+
|
|
213
|
+
if (json.status !== 200 || !json.data || rows.length === 0) {
|
|
214
|
+
setEmpty(container, 'No active sessions found in memory.')
|
|
215
|
+
resetSessionsUIOnError()
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const { totalRows, page, pageSize, totalPages } = json.data
|
|
220
|
+
updateSessionsPaginationUI(totalRows, page, pageSize, totalPages)
|
|
221
|
+
|
|
222
|
+
container.innerHTML = ''
|
|
223
|
+
rows.forEach((s: any) => {
|
|
224
|
+
container.appendChild(renderSessionCard(s))
|
|
225
|
+
})
|
|
226
|
+
} catch (_err) {
|
|
227
|
+
setEmpty(container, 'Error loading sessions.')
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function queueSessionSearch() {
|
|
232
|
+
if (sessionSearchDebounce) window.clearTimeout(sessionSearchDebounce)
|
|
233
|
+
sessionCurrentPage = 1
|
|
234
|
+
sessionSearchDebounce = window.setTimeout(() => {
|
|
235
|
+
void loadSessions()
|
|
236
|
+
}, 250)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function prevSessionPage() {
|
|
240
|
+
if (sessionCurrentPage <= 1) return
|
|
241
|
+
sessionCurrentPage -= 1
|
|
242
|
+
void loadSessions()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function nextSessionPage() {
|
|
246
|
+
if (sessionCurrentPage >= sessionTotalPages) return
|
|
247
|
+
sessionCurrentPage += 1
|
|
248
|
+
void loadSessions()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function changeSessionPageSize() {
|
|
252
|
+
const pageSizeEl = document.getElementById(
|
|
253
|
+
'session-page-size',
|
|
254
|
+
) as HTMLSelectElement | null
|
|
255
|
+
if (!pageSizeEl) return
|
|
256
|
+
|
|
257
|
+
sessionPageSize = parseInt(pageSizeEl.value, 10)
|
|
258
|
+
sessionCurrentPage = 1
|
|
259
|
+
void loadSessions()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function sessionKeyAction(
|
|
263
|
+
sessionId: string,
|
|
264
|
+
key: string,
|
|
265
|
+
value: any,
|
|
266
|
+
remove = false,
|
|
267
|
+
) {
|
|
268
|
+
try {
|
|
269
|
+
const json = await postJson('/api/_dashboard/sessions/update', {
|
|
270
|
+
id: sessionId,
|
|
271
|
+
key,
|
|
272
|
+
value,
|
|
273
|
+
remove,
|
|
274
|
+
})
|
|
275
|
+
if (json.status !== 200) alert(`Failed: ${json.message}`)
|
|
276
|
+
} catch {
|
|
277
|
+
alert('Connection error.')
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function openSessionKeyEditor(
|
|
282
|
+
sessionId: string,
|
|
283
|
+
key: string,
|
|
284
|
+
currentValue: string,
|
|
285
|
+
onDone: () => void,
|
|
286
|
+
isNew = false,
|
|
287
|
+
) {
|
|
288
|
+
document.getElementById('session-key-editor-overlay')?.remove()
|
|
289
|
+
|
|
290
|
+
const overlay = document.createElement('div')
|
|
291
|
+
overlay.id = 'session-key-editor-overlay'
|
|
292
|
+
overlay.className = 'modal-overlay'
|
|
293
|
+
overlay.style.zIndex = '200'
|
|
294
|
+
|
|
295
|
+
const card = document.createElement('div')
|
|
296
|
+
card.className = 'modal-card'
|
|
297
|
+
card.style.maxWidth = '420px'
|
|
298
|
+
|
|
299
|
+
// Session keys are written by application code, so their names can carry
|
|
300
|
+
// whatever that code derived them from. Both the heading and the prefilled
|
|
301
|
+
// input reach innerHTML unescaped before this — a stored payload in a
|
|
302
|
+
// session key ran as the dashboard operator the moment they opened it.
|
|
303
|
+
const safeKey = escapeHTML(String(key))
|
|
304
|
+
card.innerHTML = `
|
|
305
|
+
<div class="modal-header">
|
|
306
|
+
<h3>${isNew ? 'Add Session Key' : `Edit Key: <code style="font-size:0.85em;font-weight:400;">${safeKey}</code>`}</h3>
|
|
307
|
+
<button class="modal-close" id="skey-close">×</button>
|
|
308
|
+
</div>
|
|
309
|
+
<div style="display:flex;flex-direction:column;gap:0.75rem;">
|
|
310
|
+
${
|
|
311
|
+
isNew
|
|
312
|
+
? `
|
|
313
|
+
<div class="form-group">
|
|
314
|
+
<label class="label">Key</label>
|
|
315
|
+
<input class="input-field" id="skey-key-input" type="text" placeholder="e.g. userId" value="${safeKey}" />
|
|
316
|
+
</div>
|
|
317
|
+
`
|
|
318
|
+
: ''
|
|
319
|
+
}
|
|
320
|
+
<div class="form-group">
|
|
321
|
+
<label class="label">Value <span style="font-size:0.7rem;color:var(--text-secondary);">(string)</span></label>
|
|
322
|
+
<input class="input-field" id="skey-val-input" type="text" placeholder="value" value="${escapeHTML(currentValue)}" />
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
<div class="modal-actions">
|
|
326
|
+
<button class="btn btn-secondary" id="skey-cancel">Cancel</button>
|
|
327
|
+
<button class="btn" id="skey-save">Save</button>
|
|
328
|
+
</div>
|
|
329
|
+
`
|
|
330
|
+
|
|
331
|
+
overlay.appendChild(card)
|
|
332
|
+
document.body.appendChild(overlay)
|
|
333
|
+
|
|
334
|
+
const close = () => overlay.remove()
|
|
335
|
+
document.getElementById('skey-close')!.onclick = close
|
|
336
|
+
document.getElementById('skey-cancel')!.onclick = close
|
|
337
|
+
overlay.addEventListener('click', e => {
|
|
338
|
+
if (e.target === overlay) close()
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
document.getElementById('skey-save')!.onclick = async () => {
|
|
342
|
+
const finalKey = isNew
|
|
343
|
+
? (
|
|
344
|
+
document.getElementById('skey-key-input') as HTMLInputElement
|
|
345
|
+
)?.value?.trim()
|
|
346
|
+
: key
|
|
347
|
+
const val =
|
|
348
|
+
(document.getElementById('skey-val-input') as HTMLInputElement)?.value ??
|
|
349
|
+
''
|
|
350
|
+
if (!finalKey) {
|
|
351
|
+
alert('Key cannot be empty.')
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
await sessionKeyAction(sessionId, finalKey, val)
|
|
355
|
+
close()
|
|
356
|
+
onDone()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
setTimeout(() => {
|
|
360
|
+
const el = document.getElementById(
|
|
361
|
+
isNew ? 'skey-key-input' : 'skey-val-input',
|
|
362
|
+
) as HTMLInputElement | null
|
|
363
|
+
el?.focus()
|
|
364
|
+
el?.select()
|
|
365
|
+
}, 50)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function revokeSession(sessionId: string) {
|
|
369
|
+
if (!confirm('Are you sure you want to revoke this session?')) return
|
|
370
|
+
try {
|
|
371
|
+
const data = await postJson('/api/_dashboard/sessions/delete', {
|
|
372
|
+
id: sessionId,
|
|
373
|
+
})
|
|
374
|
+
if (data.status === 200) {
|
|
375
|
+
await loadSessions()
|
|
376
|
+
} else {
|
|
377
|
+
alert(`Failed to revoke session: ${data.message}`)
|
|
378
|
+
}
|
|
379
|
+
} catch (_err) {
|
|
380
|
+
alert('Error revoking session.')
|
|
381
|
+
}
|
|
382
|
+
}
|