@bakery-framework/plugin-dashboard 2.0.0-alpha.11 → 2.0.0-alpha.13

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.11",
3
+ "version": "2.0.0-alpha.13",
4
4
  "description": "Bakery dashboard plugin.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -33,9 +33,11 @@
33
33
  "!src/tests"
34
34
  ],
35
35
  "dependencies": {
36
- "@bakery-framework/core": "^2.0.0-alpha.11",
37
- "@bakery-framework/orm": "^2.0.0-alpha.11",
38
- "@bakery-framework/plugin-analytics": "^2.0.0-alpha.11"
36
+ "@bakery-framework/core": "^2.0.0-alpha.13",
37
+ "@bakery-framework/plugin-analytics": "^2.0.0-alpha.13"
38
+ },
39
+ "devDependencies": {
40
+ "@bakery-framework/orm": "^2.0.0-alpha.13"
39
41
  },
40
42
  "engines": {
41
43
  "bun": ">=1.4.0"
@@ -1,24 +1,21 @@
1
- import { refreshShimmerCache } from './parts/effects'
2
1
  import { clearLogs, initLogsWebSocket, toggleLogsPlay } from './parts/logs'
3
2
  import {
4
3
  changeSessionPageSize,
5
4
  loadSessions,
6
5
  nextSessionPage,
7
- openSessionKeyEditor,
8
6
  prevSessionPage,
9
7
  queueSessionSearch,
10
8
  revokeSession,
11
9
  sessionKeyAction,
12
10
  } from './parts/sessions'
11
+ import { bindSparklineTooltips } from './parts/sparkline-tooltip'
13
12
  import {
14
- bindSparklineTooltips,
15
13
  changePagesFilter,
16
14
  changeTimescale,
17
15
  initAnalyticsWebSocket,
18
16
  loadStats,
19
17
  resetAnalytics,
20
18
  } from './parts/stats'
21
- import { SegmentedProgress } from './parts/utils'
22
19
 
23
20
  declare const match: any
24
21
 
@@ -58,8 +55,6 @@ function switchTab(tabId: string) {
58
55
  logs: initLogsWebSocket,
59
56
  'top-pages': () => loadStats(true),
60
57
  })
61
-
62
- refreshShimmerCache()
63
58
  }
64
59
 
65
60
  window.addEventListener('click', e => {
@@ -76,7 +71,6 @@ window.addEventListener('click', e => {
76
71
  })
77
72
 
78
73
  const w = window as any
79
- w.SegmentedProgress = SegmentedProgress
80
74
  w.switchTab = switchTab
81
75
  w.resetAnalytics = resetAnalytics
82
76
  w.changePagesFilter = changePagesFilter
@@ -90,7 +84,6 @@ w.prevSessionPage = prevSessionPage
90
84
  w.nextSessionPage = nextSessionPage
91
85
  w.changeSessionPageSize = changeSessionPageSize
92
86
  w.sessionKeyAction = sessionKeyAction
93
- w.openSessionKeyEditor = openSessionKeyEditor
94
87
 
95
88
  w.initLogsWebSocket = initLogsWebSocket
96
89
  w.toggleLogsPlay = toggleLogsPlay
@@ -6,8 +6,7 @@ export let logsPaused = false
6
6
  /**
7
7
  * The console keeps the most recent rows only. It previously appended
8
8
  * without ever removing, so a long-lived dashboard tab on a chatty server
9
- * grew the DOM without bound — and every append also triggered a shimmer
10
- * cache refresh, so the per-line cost climbed with the backlog.
9
+ * grew the DOM without bound.
11
10
  */
12
11
  const MAX_LOG_ROWS = 500
13
12
 
@@ -53,7 +52,7 @@ function renderLogEntry(cEl: HTMLElement, parsed: any) {
53
52
  // client_log frames from any connected client, so both are untrusted —
54
53
  // `payload` was already escaped by colorizeHtml, these were not.
55
54
  logRow.innerHTML = `
56
- <span style="color: var(--text-secondary); margin-right: 0.5rem;">[${escapeHTML(String(timestamp))}]</span>
55
+ <span style="color: var(--text-muted); margin-right: 0.5rem;">[${escapeHTML(String(timestamp))}]</span>
57
56
  <span style="color: ${levelColor}; font-weight: bold; margin-right: 0.5rem;">[${escapeHTML(String(level))}]</span>
58
57
  <span style="color: #60a5fa; font-weight: 500; margin-right: 0.5rem;">${escapeHTML(String(by))}:</span>
59
58
  <span style="color: #f1f5f9; white-space: pre-wrap;">${colorizeHtml(payload)}</span>
@@ -75,14 +74,14 @@ export function initLogsWebSocket() {
75
74
  const consoleEl = document.getElementById('logs-console')
76
75
  if (!consoleEl) return
77
76
  consoleEl.innerHTML =
78
- '<div style="color: var(--text-secondary);">Connecting to server log stream...</div>'
77
+ '<div style="color: var(--text-muted);">Connecting to server log stream...</div>'
79
78
 
80
79
  try {
81
80
  logsWs = new WebSocket(getWebSocketUrl('/_livereload'))
82
81
 
83
82
  logsWs.onopen = () => {
84
83
  consoleEl.innerHTML =
85
- '<div style="color: var(--accent-green); display: flex; align-items: center; gap: 0.25rem;"><iconify-icon icon="lucide:check-circle-2" style="font-size: 1.1rem;"></iconify-icon><span>Connected to logs pipeline. Listening for events...</span></div>'
84
+ '<div style="color: var(--ok); display: flex; align-items: center; gap: 0.25rem;"><span>Connected to logs pipeline. Listening for events...</span></div>'
86
85
  logsWs?.send(JSON.stringify({ type: 'subscribe_logger' }))
87
86
  }
88
87
 
@@ -103,13 +102,13 @@ export function initLogsWebSocket() {
103
102
  const logRow = document.createElement('div')
104
103
  logRow.style.color = '#f59e0b'
105
104
  logRow.innerHTML =
106
- '<span style="display: flex; align-items: center; gap: 0.25rem;"><iconify-icon icon="lucide:alert-triangle" style="font-size: 1rem;"></iconify-icon><span>Logs pipeline disconnected. Reconnecting in 3s...</span></span>'
105
+ '<span style="display: flex; align-items: center; gap: 0.25rem;"><span>Logs pipeline disconnected. Reconnecting in 3s...</span></span>'
107
106
  consoleEl.appendChild(logRow)
108
107
  setTimeout(initLogsWebSocket, 3000)
109
108
  }
110
109
  } catch (_err) {
111
110
  consoleEl.innerHTML =
112
- '<div style="color: var(--accent-red);">Failed to establish log stream connection.</div>'
111
+ '<div style="color: var(--danger);">Failed to establish log stream connection.</div>'
113
112
  }
114
113
  }
115
114
 
@@ -118,8 +117,8 @@ export function toggleLogsPlay() {
118
117
  const btn = document.getElementById('btn-logs-play')
119
118
  if (btn) {
120
119
  btn.innerHTML = logsPaused
121
- ? '<iconify-icon icon="lucide:play" style="font-size: 1.1rem;"></iconify-icon><span>Resume</span>'
122
- : '<iconify-icon icon="lucide:pause" style="font-size: 1.1rem;"></iconify-icon><span>Pause</span>'
120
+ ? '<span>Resume</span>'
121
+ : '<span>Pause</span>'
123
122
  btn.classList.toggle('btn-success', logsPaused)
124
123
  }
125
124
  }
@@ -128,5 +127,5 @@ export function clearLogs() {
128
127
  const consoleEl = document.getElementById('logs-console')
129
128
  if (consoleEl)
130
129
  consoleEl.innerHTML =
131
- '<div style="color: var(--text-secondary);">Console cleared.</div>'
130
+ '<div style="color: var(--text-muted);">Console cleared.</div>'
132
131
  }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * What the console charts are, and the state that says how they are being
3
+ * shown.
4
+ *
5
+ * Split out of `stats.ts`, which was 999 lines holding four separate jobs: this
6
+ * catalogue, the canvas drawing, the tooltip DOM, and the live feed that drives
7
+ * all three. Nothing here touches a canvas or a socket, which is what makes it
8
+ * the piece the other three can each depend on without depending on each other.
9
+ */
10
+
11
+ import { setText } from './utils'
12
+
13
+ /**
14
+ * One row per sparkline. The nine metrics were previously written out
15
+ * longhand at seven separate sites — the history arrays, the tracker record,
16
+ * the tooltip configs, `drawAllSparklines`, the two incremental update paths,
17
+ * the full-history path and `changeTimescale` — so adding or renaming one
18
+ * meant seven coordinated edits, and the two update paths had already drifted
19
+ * apart in how they coerce missing values.
20
+ *
21
+ * That drift is preserved deliberately, not flattened: `zero` says whether a
22
+ * history point's missing value reads as `0`, and `live` covers the two
23
+ * metrics whose live payload differs from their history payload (`ping` uses
24
+ * `??`, and `memoryUsed` arrives as a `"12 MB"` string over the socket).
25
+ */
26
+ export interface Metric {
27
+ /** Tracker key, and the prefix of the `-min` / `-max` / `-avg` element ids. */
28
+ key: string
29
+ /** Canvas element id. */
30
+ canvas: string
31
+ /** `.chart-card` id, for the metrics that blur when analytics is inactive. */
32
+ card?: string
33
+ /** `.card-sub` caption, before the timescale label is appended. */
34
+ sub: string
35
+ /** Property name on a history point and on the live stats payload. */
36
+ field: string
37
+ /** Suffix in the hover tooltip; trimmed for the min/max/avg readout. */
38
+ unit: string
39
+ stroke: string
40
+ fill: string
41
+ /** Whether a missing history value reads as `0`. */
42
+ zero: boolean
43
+ /** Live-payload reader, where it differs from the history reader. */
44
+ live?: (s: any) => number
45
+ history: number[]
46
+ }
47
+
48
+ export const METRICS: Metric[] = [
49
+ {
50
+ key: 'ping',
51
+ canvas: 'canvas-ping',
52
+ sub: 'Server self-check ping latency',
53
+ field: 'ping',
54
+ unit: 'ms',
55
+ stroke: '#f43f5e',
56
+ fill: 'rgba(244, 63, 94, 0.25)',
57
+ zero: true,
58
+ live: s => s.ping ?? 0,
59
+ history: [],
60
+ },
61
+ {
62
+ key: 'memory',
63
+ canvas: 'canvas-memory',
64
+ sub: 'Heap/RSS RAM consumption',
65
+ field: 'memoryUsed',
66
+ unit: ' MB',
67
+ stroke: '#3b82f6',
68
+ fill: 'rgba(59, 130, 246, 0.25)',
69
+ zero: false,
70
+ live: s => parseFloat(s.memoryUsed) || 0,
71
+ history: [],
72
+ },
73
+ {
74
+ key: 'loggers',
75
+ canvas: 'canvas-loggers',
76
+ sub: 'Active client logger tunnels',
77
+ field: 'activeLoggers',
78
+ unit: '',
79
+ stroke: '#10b981',
80
+ fill: 'rgba(16, 185, 129, 0.25)',
81
+ zero: false,
82
+ history: [],
83
+ },
84
+ {
85
+ key: 'sessions',
86
+ canvas: 'canvas-sessions',
87
+ sub: 'In-memory active user sessions',
88
+ field: 'activeSessions',
89
+ unit: '',
90
+ stroke: '#fbbf24',
91
+ fill: 'rgba(251, 191, 36, 0.25)',
92
+ zero: false,
93
+ history: [],
94
+ },
95
+ {
96
+ key: 'pageHits',
97
+ canvas: 'canvas-route-hits',
98
+ card: 'chart-route-hits',
99
+ sub: 'Application page requests',
100
+ field: 'pageHits',
101
+ unit: '',
102
+ stroke: '#06b6d4',
103
+ fill: 'rgba(6, 182, 212, 0.25)',
104
+ zero: true,
105
+ history: [],
106
+ },
107
+ {
108
+ key: 'apiHits',
109
+ canvas: 'canvas-api-hits',
110
+ card: 'chart-api-hits',
111
+ sub: 'API endpoint requests',
112
+ field: 'apiHits',
113
+ unit: '',
114
+ stroke: '#8b5cf6',
115
+ fill: 'rgba(139, 92, 246, 0.25)',
116
+ zero: true,
117
+ history: [],
118
+ },
119
+ {
120
+ key: 'uniqueRequests',
121
+ canvas: 'canvas-unique-requests',
122
+ card: 'chart-unique-requests',
123
+ sub: 'Distinct request signatures',
124
+ field: 'uniqueRequests',
125
+ unit: '',
126
+ stroke: '#f97316',
127
+ fill: 'rgba(249, 115, 22, 0.25)',
128
+ zero: true,
129
+ history: [],
130
+ },
131
+ {
132
+ key: 'errorPageHits',
133
+ canvas: 'canvas-error-page-hits',
134
+ card: 'chart-error-page-hits',
135
+ sub: 'Custom error page renders',
136
+ field: 'errorPageHits',
137
+ unit: '',
138
+ stroke: '#ef4444',
139
+ fill: 'rgba(239, 68, 68, 0.25)',
140
+ zero: true,
141
+ history: [],
142
+ },
143
+ ]
144
+
145
+ export const METRIC_BY_KEY = new Map(METRICS.map(m => [m.key, m]))
146
+
147
+ /** The "have we loaded any history yet" probe; every metric fills together. */
148
+ export const memoryHistory = METRIC_BY_KEY.get('memory')!.history
149
+
150
+ /** A history point's value for this metric. */
151
+ export function readPoint(m: Metric, point: any): number {
152
+ return m.zero ? point[m.field] || 0 : point[m.field]
153
+ }
154
+
155
+ /** The live per-second payload's value for this metric. */
156
+ export function readLive(m: Metric, s: any): number {
157
+ return m.live ? m.live(s) : s[m.field] || 0
158
+ }
159
+
160
+ /**
161
+ * Which window the charts are showing.
162
+ *
163
+ * Here rather than in `stats.ts` because two modules read it — the live feed
164
+ * and the tooltip's age labels — and only one writes it. An `export let`
165
+ * cannot be assigned from outside the module that declares it, so the write
166
+ * goes through `setActiveTimescale` and every reader still sees the live
167
+ * binding.
168
+ */
169
+ export let activeTimescale = '1m'
170
+
171
+ export function setActiveTimescale(next: string): void {
172
+ activeTimescale = next
173
+ }
174
+
175
+ /**
176
+ * The timescale shape, as the browser sees it.
177
+ *
178
+ * **This is a second copy of `analytics/src/timescale.ts`, and it has to be.**
179
+ * That module is the one source for the server, and importing it here compiles
180
+ * — `tsconfig` paths resolve it — but does not *run*: this file is bundled for
181
+ * the browser and served as a classic `<script>`, so a cross-package specifier
182
+ * survives as a bare `import` that the page cannot execute. It took the whole
183
+ * console down silently, because `Bun.build` reports success and nothing
184
+ * requested the page.
185
+ *
186
+ * So the duplication is deliberate and the agreement is a test rather than a
187
+ * hope: `timescale-agreement.test.ts` imports both and fails if they diverge.
188
+ * That is most of what collapsing the five original copies bought, and it is
189
+ * the part that survives a boundary the bundler genuinely cannot cross.
190
+ *
191
+ * Same derivation as the other side: a bucket is the window divided by the
192
+ * points, and a sample is a second of the bucket.
193
+ */
194
+ const DAY_MS = 86_400_000
195
+
196
+ export const TIMESCALE_SHAPE: Record<
197
+ string,
198
+ { windowMs: number; points: number }
199
+ > = {
200
+ '1m': { windowMs: 60_000, points: 60 },
201
+ '1h': { windowMs: 3_600_000, points: 60 },
202
+ '1d': { windowMs: DAY_MS, points: 48 },
203
+ '7d': { windowMs: 7 * DAY_MS, points: 28 },
204
+ '30d': { windowMs: 30 * DAY_MS, points: 30 },
205
+ }
206
+
207
+ /** Unknown falls back to the narrowest window, as the server's copy does. */
208
+ function shapeOf(timescale: string) {
209
+ return TIMESCALE_SHAPE[timescale] ?? TIMESCALE_SHAPE['1m']!
210
+ }
211
+
212
+ /** How much time one chart point covers. */
213
+ export function getTimescaleIntervalMs(timescale: string): number {
214
+ const { windowMs, points } = shapeOf(timescale)
215
+ return windowMs / points
216
+ }
217
+
218
+ /** How many points a timescale is drawn as. See `TIMESCALE_SHAPE`. */
219
+ export function getTimescaleLimit(timescale: string): number {
220
+ return shapeOf(timescale).points
221
+ }
222
+
223
+ export interface Tracker {
224
+ min: number
225
+ max: number
226
+ sum: number
227
+ count: number
228
+ }
229
+
230
+ export function emptyTracker(): Tracker {
231
+ return { min: Infinity, max: -Infinity, sum: 0, count: 0 }
232
+ }
233
+
234
+ export const trackers: Record<string, Tracker> = Object.fromEntries(
235
+ METRICS.map(m => [m.key, emptyTracker()]),
236
+ )
237
+
238
+ export function updateTracker(key: string, val: number) {
239
+ if (val === null || val === undefined || Number.isNaN(val)) return
240
+ const t = trackers[key]
241
+ if (val < t.min) t.min = val
242
+ if (val > t.max) t.max = val
243
+ t.sum += val
244
+ t.count += 1
245
+ const avg = t.sum / t.count
246
+
247
+ const suffix = (METRIC_BY_KEY.get(key)?.unit ?? '').trim()
248
+ setText(`${key}-min`, `${t.min.toFixed(0)} ${suffix}`)
249
+ setText(`${key}-max`, `${t.max.toFixed(0)} ${suffix}`)
250
+ setText(`${key}-avg`, `${avg.toFixed(1)} ${suffix}`)
251
+ }
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  ICON_DELETE,
3
- ICON_EDIT,
4
3
  icon,
5
4
  postJson,
6
5
  setEmpty,
@@ -77,36 +76,21 @@ function renderKVRows(
77
76
  visible.forEach(([k, v]) => {
78
77
  const row = document.createElement('div')
79
78
  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;'
79
+ 'display:flex;align-items:center;gap:0.5rem;background:rgba(255,255,255,0.04);border:1px solid var(--border);border-radius:0.375rem;padding:0.3rem 0.6rem;'
81
80
 
82
81
  const keyEl = document.createElement('span')
83
82
  keyEl.style.cssText =
84
- 'font-size:0.8rem;font-weight:600;color:var(--text-secondary);min-width:120px;font-family:monospace;'
83
+ 'font-size:0.8rem;font-weight:600;color:var(--text-muted);min-width:120px;font-family:monospace;'
85
84
  keyEl.innerText = k
86
85
 
87
86
  const valEl = document.createElement('span')
88
87
  valEl.style.cssText =
89
- 'font-size:0.8rem;color:var(--text-primary);flex:1;font-family:monospace;word-break:break-all;'
88
+ 'font-size:0.8rem;color:var(--text);flex:1;font-family:monospace;word-break:break-all;'
90
89
  valEl.innerText = is.object(v) ? JSON.stringify(v) : String(v)
91
90
 
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
91
  const delBtn = document.createElement('button')
108
92
  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;'
93
+ 'background:none;border:none;cursor:pointer;color:var(--danger);font-size:0.85rem;padding:0.1rem 0.25rem;border-radius:0.25rem;opacity:0.7;transition:opacity 0.15s;'
110
94
  delBtn.title = 'Delete key'
111
95
  delBtn.innerHTML = icon(ICON_DELETE, '0.95rem')
112
96
  delBtn.onmouseenter = () => (delBtn.style.opacity = '1')
@@ -118,7 +102,6 @@ function renderKVRows(
118
102
 
119
103
  row.appendChild(keyEl)
120
104
  row.appendChild(valEl)
121
- row.appendChild(editBtn)
122
105
  row.appendChild(delBtn)
123
106
  kvSection.appendChild(row)
124
107
  })
@@ -126,12 +109,12 @@ function renderKVRows(
126
109
  if (entries.length > SHOW_LIMIT) {
127
110
  const toggle = document.createElement('button')
128
111
  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;'
112
+ 'font-size:0.75rem;color:var(--text-muted);background:none;border:none;cursor:pointer;text-align:left;padding:0.1rem 0;margin-top:0.1rem;transition:color 0.15s;'
130
113
  toggle.innerText = showAll
131
114
  ? `▲ Show fewer`
132
115
  : `▼ Show all ${entries.length} keys`
133
- toggle.onmouseenter = () => (toggle.style.color = 'var(--text-primary)')
134
- toggle.onmouseleave = () => (toggle.style.color = 'var(--text-secondary)')
116
+ toggle.onmouseenter = () => (toggle.style.color = 'var(--text)')
117
+ toggle.onmouseleave = () => (toggle.style.color = 'var(--text-muted)')
135
118
  toggle.onclick = () => renderKVRows(kvSection, entries, sId, !showAll)
136
119
  kvSection.appendChild(toggle)
137
120
  }
@@ -139,21 +122,11 @@ function renderKVRows(
139
122
  if (entries.length === 0) {
140
123
  const empty = document.createElement('span')
141
124
  empty.style.cssText =
142
- 'font-size:0.8rem;color:var(--text-secondary);font-style:italic;'
125
+ 'font-size:0.8rem;color:var(--text-muted);font-style:italic;'
143
126
  empty.innerText = 'No data stored in this session.'
144
127
  kvSection.appendChild(empty)
145
128
  }
146
129
 
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
130
  }
158
131
 
159
132
  function renderSessionCard(s: any): HTMLElement {
@@ -179,10 +152,10 @@ function renderSessionCard(s: any): HTMLElement {
179
152
  const expiresAt = new Date((s.accessedAt || Date.now()) + ttl)
180
153
  const info = document.createElement('div')
181
154
  info.style.cssText =
182
- 'font-size:0.8rem;color:var(--text-secondary);display:flex;gap:1.5rem;margin-bottom:0.5rem;'
155
+ 'font-size:0.8rem;color:var(--text-muted);display:flex;gap:1.5rem;margin-bottom:0.5rem;'
183
156
  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>
157
+ <span>Last Accessed: <strong style="color:var(--text)">${accessedAt.toLocaleTimeString()}</strong></span>
158
+ <span>Expires: <strong style="color:var(--text)">${expiresAt.toLocaleString()}</strong></span>
186
159
  `
187
160
 
188
161
  const kvSection = document.createElement('div')
@@ -278,92 +251,21 @@ export async function sessionKeyAction(
278
251
  }
279
252
  }
280
253
 
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
- }
254
+ /*
255
+ * `openSessionKeyEditor` was here, and it could not be opened.
256
+ *
257
+ * It built its overlay with `className = 'modal-overlay'`, and the sheet
258
+ * gives that rule `display: none` - only `.modal-overlay.active` is
259
+ * `display: flex`, and nothing in this package ever added `active`. Checked
260
+ * against a running console: clicking Edit created the element, computed
261
+ * `display: none`, rendered 0x0. So the editor has never opened, and the
262
+ * two buttons that called it did nothing at all.
263
+ *
264
+ * Restoring it would mean deciding what a session-key editor should be,
265
+ * which is a design question rather than a missing class name, and 2.0.0 is
266
+ * not the release to answer it in. What is left on this panel is what works:
267
+ * listing sessions, revoking one, and deleting a key.
268
+ */
367
269
 
368
270
  export async function revokeSession(sessionId: string) {
369
271
  if (!confirm('Are you sure you want to revoke this session?')) return