@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.
@@ -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
+ }