@bakery-framework/plugin-dashboard 2.0.0-alpha.12 → 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 +6 -4
- package/src/client/dashboard.ts +1 -8
- package/src/client/parts/logs.ts +9 -10
- package/src/client/parts/metrics.ts +251 -0
- package/src/client/parts/sessions.ts +26 -124
- package/src/client/parts/sparkline-tooltip.ts +158 -0
- package/src/client/parts/sparkline.ts +383 -0
- package/src/client/parts/stats.ts +33 -758
- package/src/client/parts/utils.ts +18 -64
- package/src/components/DBBrowser.tsx +0 -2
- package/src/components/LogsPanel.tsx +0 -2
- package/src/components/SessionsPanel.tsx +0 -3
- package/src/components/StatsPanel.tsx +0 -19
- package/src/components/TopPagesPanel.tsx +0 -1
- package/src/setup.ts +63 -2
- package/src/shell.tsx +35 -2
- package/src/client/parts/effects.ts +0 -327
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/plugin-dashboard",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
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.
|
|
37
|
-
"@bakery-framework/
|
|
38
|
-
|
|
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"
|
package/src/client/dashboard.ts
CHANGED
|
@@ -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
|
package/src/client/parts/logs.ts
CHANGED
|
@@ -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
|
|
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-
|
|
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-
|
|
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(--
|
|
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;"><
|
|
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(--
|
|
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
|
-
? '<
|
|
122
|
-
: '<
|
|
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-
|
|
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
|
|
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-
|
|
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
|
|
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(--
|
|
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-
|
|
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
|
|
134
|
-
toggle.onmouseleave = () => (toggle.style.color = 'var(--text-
|
|
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-
|
|
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-
|
|
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
|
|
185
|
-
<span>Expires: <strong style="color:var(--text
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|