@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,957 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatUptime,
|
|
3
|
+
getWebSocketUrl,
|
|
4
|
+
SegmentedProgress,
|
|
5
|
+
setEmpty,
|
|
6
|
+
setText,
|
|
7
|
+
} from './utils'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One row per sparkline. The nine metrics were previously written out
|
|
11
|
+
* longhand at seven separate sites — the history arrays, the tracker record,
|
|
12
|
+
* the tooltip configs, `drawAllSparklines`, the two incremental update paths,
|
|
13
|
+
* the full-history path and `changeTimescale` — so adding or renaming one
|
|
14
|
+
* meant seven coordinated edits, and the two update paths had already drifted
|
|
15
|
+
* apart in how they coerce missing values.
|
|
16
|
+
*
|
|
17
|
+
* That drift is preserved deliberately, not flattened: `zero` says whether a
|
|
18
|
+
* history point's missing value reads as `0`, and `live` covers the two
|
|
19
|
+
* metrics whose live payload differs from their history payload (`ping` uses
|
|
20
|
+
* `??`, and `memoryUsed` arrives as a `"12 MB"` string over the socket).
|
|
21
|
+
*/
|
|
22
|
+
interface Metric {
|
|
23
|
+
/** Tracker key, and the prefix of the `-min` / `-max` / `-avg` element ids. */
|
|
24
|
+
key: string
|
|
25
|
+
/** Canvas element id. */
|
|
26
|
+
canvas: string
|
|
27
|
+
/** `.chart-card` id, for the metrics that blur when analytics is inactive. */
|
|
28
|
+
card?: string
|
|
29
|
+
/** `.card-sub` caption, before the timescale label is appended. */
|
|
30
|
+
sub: string
|
|
31
|
+
/** Property name on a history point and on the live stats payload. */
|
|
32
|
+
field: string
|
|
33
|
+
/** Suffix in the hover tooltip; trimmed for the min/max/avg readout. */
|
|
34
|
+
unit: string
|
|
35
|
+
stroke: string
|
|
36
|
+
fill: string
|
|
37
|
+
/** Whether a missing history value reads as `0`. */
|
|
38
|
+
zero: boolean
|
|
39
|
+
/** Live-payload reader, where it differs from the history reader. */
|
|
40
|
+
live?: (s: any) => number
|
|
41
|
+
history: number[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const METRICS: Metric[] = [
|
|
45
|
+
{
|
|
46
|
+
key: 'ping',
|
|
47
|
+
canvas: 'canvas-ping',
|
|
48
|
+
sub: 'Server self-check ping latency',
|
|
49
|
+
field: 'ping',
|
|
50
|
+
unit: 'ms',
|
|
51
|
+
stroke: '#f43f5e',
|
|
52
|
+
fill: 'rgba(244, 63, 94, 0.25)',
|
|
53
|
+
zero: true,
|
|
54
|
+
live: s => s.ping ?? 0,
|
|
55
|
+
history: [],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: 'memory',
|
|
59
|
+
canvas: 'canvas-memory',
|
|
60
|
+
sub: 'Heap/RSS RAM consumption',
|
|
61
|
+
field: 'memoryUsed',
|
|
62
|
+
unit: ' MB',
|
|
63
|
+
stroke: '#3b82f6',
|
|
64
|
+
fill: 'rgba(59, 130, 246, 0.25)',
|
|
65
|
+
zero: false,
|
|
66
|
+
live: s => parseFloat(s.memoryUsed) || 0,
|
|
67
|
+
history: [],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
key: 'loggers',
|
|
71
|
+
canvas: 'canvas-loggers',
|
|
72
|
+
sub: 'Active client logger tunnels',
|
|
73
|
+
field: 'activeLoggers',
|
|
74
|
+
unit: '',
|
|
75
|
+
stroke: '#10b981',
|
|
76
|
+
fill: 'rgba(16, 185, 129, 0.25)',
|
|
77
|
+
zero: false,
|
|
78
|
+
history: [],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: 'sessions',
|
|
82
|
+
canvas: 'canvas-sessions',
|
|
83
|
+
sub: 'In-memory active user sessions',
|
|
84
|
+
field: 'activeSessions',
|
|
85
|
+
unit: '',
|
|
86
|
+
stroke: '#fbbf24',
|
|
87
|
+
fill: 'rgba(251, 191, 36, 0.25)',
|
|
88
|
+
zero: false,
|
|
89
|
+
history: [],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: 'pageHits',
|
|
93
|
+
canvas: 'canvas-route-hits',
|
|
94
|
+
card: 'chart-route-hits',
|
|
95
|
+
sub: 'Application page requests',
|
|
96
|
+
field: 'pageHits',
|
|
97
|
+
unit: '',
|
|
98
|
+
stroke: '#06b6d4',
|
|
99
|
+
fill: 'rgba(6, 182, 212, 0.25)',
|
|
100
|
+
zero: true,
|
|
101
|
+
history: [],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: 'apiHits',
|
|
105
|
+
canvas: 'canvas-api-hits',
|
|
106
|
+
card: 'chart-api-hits',
|
|
107
|
+
sub: 'API endpoint requests',
|
|
108
|
+
field: 'apiHits',
|
|
109
|
+
unit: '',
|
|
110
|
+
stroke: '#8b5cf6',
|
|
111
|
+
fill: 'rgba(139, 92, 246, 0.25)',
|
|
112
|
+
zero: true,
|
|
113
|
+
history: [],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
key: 'uniqueRequests',
|
|
117
|
+
canvas: 'canvas-unique-requests',
|
|
118
|
+
card: 'chart-unique-requests',
|
|
119
|
+
sub: 'Distinct request signatures',
|
|
120
|
+
field: 'uniqueRequests',
|
|
121
|
+
unit: '',
|
|
122
|
+
stroke: '#f97316',
|
|
123
|
+
fill: 'rgba(249, 115, 22, 0.25)',
|
|
124
|
+
zero: true,
|
|
125
|
+
history: [],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: 'dbHits',
|
|
129
|
+
canvas: 'canvas-db-hits',
|
|
130
|
+
card: 'chart-db-hits',
|
|
131
|
+
sub: 'Database query executions',
|
|
132
|
+
field: 'dbHits',
|
|
133
|
+
unit: '',
|
|
134
|
+
stroke: '#a78bfa',
|
|
135
|
+
fill: 'rgba(167, 139, 250, 0.25)',
|
|
136
|
+
zero: true,
|
|
137
|
+
history: [],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
key: 'errorPageHits',
|
|
141
|
+
canvas: 'canvas-error-page-hits',
|
|
142
|
+
card: 'chart-error-page-hits',
|
|
143
|
+
sub: 'Custom error page renders',
|
|
144
|
+
field: 'errorPageHits',
|
|
145
|
+
unit: '',
|
|
146
|
+
stroke: '#ef4444',
|
|
147
|
+
fill: 'rgba(239, 68, 68, 0.25)',
|
|
148
|
+
zero: true,
|
|
149
|
+
history: [],
|
|
150
|
+
},
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
const METRIC_BY_KEY = new Map(METRICS.map(m => [m.key, m]))
|
|
154
|
+
|
|
155
|
+
/** The "have we loaded any history yet" probe; every metric fills together. */
|
|
156
|
+
const memoryHistory = METRIC_BY_KEY.get('memory')!.history
|
|
157
|
+
|
|
158
|
+
/** A history point's value for this metric. */
|
|
159
|
+
function readPoint(m: Metric, point: any): number {
|
|
160
|
+
return m.zero ? point[m.field] || 0 : point[m.field]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The live per-second payload's value for this metric. */
|
|
164
|
+
function readLive(m: Metric, s: any): number {
|
|
165
|
+
return m.live ? m.live(s) : s[m.field] || 0
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export let activeTimescale = '1m'
|
|
169
|
+
export let lastProcessedHistoryTimestamp = 0
|
|
170
|
+
export let lastServerPid = 0
|
|
171
|
+
export let connectionLost = false
|
|
172
|
+
|
|
173
|
+
function setConnectionStatus(online: boolean) {
|
|
174
|
+
const dot = document.getElementById('server-status-dot')
|
|
175
|
+
const text = document.getElementById('server-status-text')
|
|
176
|
+
if (!dot || !text) return
|
|
177
|
+
|
|
178
|
+
if (online) {
|
|
179
|
+
dot.style.background = '#10b981'
|
|
180
|
+
dot.style.boxShadow = '0 0 10px rgba(16, 185, 129, 0.4)'
|
|
181
|
+
text.innerText = 'Online (DEV)'
|
|
182
|
+
text.style.color = 'var(--text-main)'
|
|
183
|
+
} else {
|
|
184
|
+
dot.style.background = '#ef4444'
|
|
185
|
+
dot.style.boxShadow = '0 0 10px rgba(239, 68, 68, 0.4)'
|
|
186
|
+
text.innerText = 'Offline'
|
|
187
|
+
text.style.color = '#ef4444'
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export let activePagesFilter = '1d'
|
|
191
|
+
export let activeTopPagesProgressBars: SegmentedProgress[] = []
|
|
192
|
+
|
|
193
|
+
export function changePagesFilter(newFilter: string) {
|
|
194
|
+
activePagesFilter = newFilter
|
|
195
|
+
document.querySelectorAll('.pages-filter-btn').forEach(btn => {
|
|
196
|
+
btn.classList.toggle('active', btn.id === `pages-filter-${newFilter}`)
|
|
197
|
+
})
|
|
198
|
+
loadStats(true)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function resetAnalytics() {
|
|
202
|
+
if (
|
|
203
|
+
!confirm(
|
|
204
|
+
'Are you sure you want to reset all analytics data? This will clear all history and page visit records.',
|
|
205
|
+
)
|
|
206
|
+
) {
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const res = await fetch('/api/_analytics/reset', {
|
|
211
|
+
method: 'POST',
|
|
212
|
+
})
|
|
213
|
+
if (res.status === 200) {
|
|
214
|
+
alert('Analytics data reset successfully.')
|
|
215
|
+
loadStats(true)
|
|
216
|
+
} else {
|
|
217
|
+
alert('Failed to reset analytics data.')
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error('Reset analytics error:', err)
|
|
221
|
+
alert('An error occurred while resetting analytics data.')
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getTimescaleIntervalMs(timescale: string): number {
|
|
226
|
+
switch (timescale) {
|
|
227
|
+
case '30d':
|
|
228
|
+
return 86400000
|
|
229
|
+
case '7d':
|
|
230
|
+
return 21600000
|
|
231
|
+
case '1d':
|
|
232
|
+
return 1800000
|
|
233
|
+
case '1h':
|
|
234
|
+
return 60000
|
|
235
|
+
default:
|
|
236
|
+
return 1000
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface Tracker {
|
|
241
|
+
min: number
|
|
242
|
+
max: number
|
|
243
|
+
sum: number
|
|
244
|
+
count: number
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function emptyTracker(): Tracker {
|
|
248
|
+
return { min: Infinity, max: -Infinity, sum: 0, count: 0 }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const trackers: Record<string, Tracker> = Object.fromEntries(
|
|
252
|
+
METRICS.map(m => [m.key, emptyTracker()]),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
export function updateTracker(key: string, val: number) {
|
|
256
|
+
if (val === null || val === undefined || Number.isNaN(val)) return
|
|
257
|
+
const t = trackers[key]
|
|
258
|
+
if (val < t.min) t.min = val
|
|
259
|
+
if (val > t.max) t.max = val
|
|
260
|
+
t.sum += val
|
|
261
|
+
t.count += 1
|
|
262
|
+
const avg = t.sum / t.count
|
|
263
|
+
|
|
264
|
+
const suffix = (METRIC_BY_KEY.get(key)?.unit ?? '').trim()
|
|
265
|
+
setText(`${key}-min`, `${t.min.toFixed(0)} ${suffix}`)
|
|
266
|
+
setText(`${key}-max`, `${t.max.toFixed(0)} ${suffix}`)
|
|
267
|
+
setText(`${key}-avg`, `${avg.toFixed(1)} ${suffix}`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function drawSparklineGrid(
|
|
271
|
+
ctx: CanvasRenderingContext2D,
|
|
272
|
+
width: number,
|
|
273
|
+
height: number,
|
|
274
|
+
min: number,
|
|
275
|
+
range: number,
|
|
276
|
+
) {
|
|
277
|
+
ctx.save()
|
|
278
|
+
ctx.beginPath()
|
|
279
|
+
ctx.setLineDash([4, 4])
|
|
280
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)'
|
|
281
|
+
ctx.lineWidth = 1
|
|
282
|
+
|
|
283
|
+
const gridLines = [0.25, 0.5, 0.75]
|
|
284
|
+
gridLines.forEach(ratio => {
|
|
285
|
+
const y = height - 12 - ratio * (height - 24)
|
|
286
|
+
ctx.moveTo(0, y)
|
|
287
|
+
ctx.lineTo(width - 50, y)
|
|
288
|
+
|
|
289
|
+
const val = min + ratio * range
|
|
290
|
+
const roundedVal = range < 5 ? Math.round(val * 10) / 10 : Math.round(val)
|
|
291
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'
|
|
292
|
+
ctx.font = '9px monospace'
|
|
293
|
+
ctx.fillText(roundedVal.toString(), width - 42, y + 3)
|
|
294
|
+
})
|
|
295
|
+
ctx.stroke()
|
|
296
|
+
ctx.restore()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function getSparklineSegments(dataPoints: number[]) {
|
|
300
|
+
const segments: { start: number; end: number }[] = []
|
|
301
|
+
let inSegment = false
|
|
302
|
+
let segmentStart = 0
|
|
303
|
+
|
|
304
|
+
for (let i = 0; i < dataPoints.length; i++) {
|
|
305
|
+
const isValValid =
|
|
306
|
+
dataPoints[i] !== null &&
|
|
307
|
+
dataPoints[i] !== undefined &&
|
|
308
|
+
!Number.isNaN(dataPoints[i])
|
|
309
|
+
if (isValValid) {
|
|
310
|
+
if (!inSegment) {
|
|
311
|
+
inSegment = true
|
|
312
|
+
segmentStart = i
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
if (inSegment) {
|
|
316
|
+
segments.push({ start: segmentStart, end: i - 1 })
|
|
317
|
+
inSegment = false
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (inSegment) {
|
|
322
|
+
segments.push({ start: segmentStart, end: dataPoints.length - 1 })
|
|
323
|
+
}
|
|
324
|
+
return segments
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function drawSinglePointSegment(
|
|
328
|
+
ctx: CanvasRenderingContext2D,
|
|
329
|
+
start: number,
|
|
330
|
+
dataPoints: number[],
|
|
331
|
+
min: number,
|
|
332
|
+
max: number,
|
|
333
|
+
range: number,
|
|
334
|
+
width: number,
|
|
335
|
+
height: number,
|
|
336
|
+
L: number,
|
|
337
|
+
M: number,
|
|
338
|
+
colorStart: string,
|
|
339
|
+
) {
|
|
340
|
+
const val = Math.max(min, Math.min(dataPoints[start], max))
|
|
341
|
+
const j = L - M + start
|
|
342
|
+
const x = (j / (L - 1)) * (width - 50)
|
|
343
|
+
const y = height - 12 - ((val - min) / range) * (height - 24)
|
|
344
|
+
|
|
345
|
+
ctx.beginPath()
|
|
346
|
+
ctx.arc(x, y, 2.5, 0, Math.PI * 2)
|
|
347
|
+
ctx.fillStyle = colorStart
|
|
348
|
+
ctx.fill()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function drawLineSegment(
|
|
352
|
+
ctx: CanvasRenderingContext2D,
|
|
353
|
+
start: number,
|
|
354
|
+
end: number,
|
|
355
|
+
dataPoints: number[],
|
|
356
|
+
min: number,
|
|
357
|
+
max: number,
|
|
358
|
+
range: number,
|
|
359
|
+
width: number,
|
|
360
|
+
height: number,
|
|
361
|
+
L: number,
|
|
362
|
+
M: number,
|
|
363
|
+
colorStart: string,
|
|
364
|
+
) {
|
|
365
|
+
ctx.beginPath()
|
|
366
|
+
for (let i = start; i <= end; i++) {
|
|
367
|
+
const val = Math.max(min, Math.min(dataPoints[i], max))
|
|
368
|
+
const j = L - M + i
|
|
369
|
+
const x = (j / (L - 1)) * (width - 50)
|
|
370
|
+
const y = height - 12 - ((val - min) / range) * (height - 24)
|
|
371
|
+
if (i === start) ctx.moveTo(x, y)
|
|
372
|
+
else ctx.lineTo(x, y)
|
|
373
|
+
}
|
|
374
|
+
ctx.lineWidth = 2.5
|
|
375
|
+
ctx.strokeStyle = colorStart
|
|
376
|
+
ctx.lineCap = 'round'
|
|
377
|
+
ctx.lineJoin = 'round'
|
|
378
|
+
ctx.stroke()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function drawFillSegment(
|
|
382
|
+
ctx: CanvasRenderingContext2D,
|
|
383
|
+
start: number,
|
|
384
|
+
end: number,
|
|
385
|
+
dataPoints: number[],
|
|
386
|
+
min: number,
|
|
387
|
+
max: number,
|
|
388
|
+
range: number,
|
|
389
|
+
width: number,
|
|
390
|
+
height: number,
|
|
391
|
+
L: number,
|
|
392
|
+
M: number,
|
|
393
|
+
colorEnd: string,
|
|
394
|
+
) {
|
|
395
|
+
ctx.beginPath()
|
|
396
|
+
let firstX = 0
|
|
397
|
+
let lastX = 0
|
|
398
|
+
for (let i = start; i <= end; i++) {
|
|
399
|
+
const val = Math.max(min, Math.min(dataPoints[i], max))
|
|
400
|
+
const j = L - M + i
|
|
401
|
+
const x = (j / (L - 1)) * (width - 50)
|
|
402
|
+
const y = height - 12 - ((val - min) / range) * (height - 24)
|
|
403
|
+
if (i === start) {
|
|
404
|
+
ctx.moveTo(x, y)
|
|
405
|
+
firstX = x
|
|
406
|
+
} else {
|
|
407
|
+
ctx.lineTo(x, y)
|
|
408
|
+
}
|
|
409
|
+
if (i === end) {
|
|
410
|
+
lastX = x
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
ctx.lineTo(lastX, height)
|
|
414
|
+
ctx.lineTo(firstX, height)
|
|
415
|
+
ctx.closePath()
|
|
416
|
+
|
|
417
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, height)
|
|
418
|
+
gradient.addColorStop(0, colorEnd)
|
|
419
|
+
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)')
|
|
420
|
+
ctx.fillStyle = gradient
|
|
421
|
+
ctx.fill()
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function drawSparkline(
|
|
425
|
+
canvasId: string,
|
|
426
|
+
dataPoints: number[],
|
|
427
|
+
colorStart: string,
|
|
428
|
+
colorEnd: string,
|
|
429
|
+
) {
|
|
430
|
+
const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null
|
|
431
|
+
if (!canvas) return
|
|
432
|
+
const ctx = canvas.getContext('2d')
|
|
433
|
+
if (!ctx) return
|
|
434
|
+
|
|
435
|
+
const dpr = window.devicePixelRatio || 1
|
|
436
|
+
const rect = canvas.getBoundingClientRect()
|
|
437
|
+
|
|
438
|
+
// Assigning to width/height reallocates the backing store and resets the
|
|
439
|
+
// whole context, so the old unconditional resize threw away and rebuilt nine
|
|
440
|
+
// canvases every second even when nothing had moved. Only resize on an
|
|
441
|
+
// actual size change, and set the DPR transform outright rather than
|
|
442
|
+
// relying on the reset to make a cumulative `scale` safe.
|
|
443
|
+
const targetW = Math.trunc(rect.width * dpr)
|
|
444
|
+
const targetH = Math.trunc(rect.height * dpr)
|
|
445
|
+
if (canvas.width !== targetW || canvas.height !== targetH) {
|
|
446
|
+
canvas.width = targetW
|
|
447
|
+
canvas.height = targetH
|
|
448
|
+
}
|
|
449
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
450
|
+
|
|
451
|
+
const width = rect.width
|
|
452
|
+
const height = rect.height
|
|
453
|
+
ctx.clearRect(0, 0, width, height)
|
|
454
|
+
|
|
455
|
+
if (dataPoints.length === 0) return
|
|
456
|
+
|
|
457
|
+
const { min, max, range } = getSparklineScale(dataPoints)
|
|
458
|
+
drawSparklineGrid(ctx, width, height, min, range)
|
|
459
|
+
|
|
460
|
+
const L = getTimescaleLimit(activeTimescale)
|
|
461
|
+
const M = dataPoints.length
|
|
462
|
+
const segments = getSparklineSegments(dataPoints)
|
|
463
|
+
|
|
464
|
+
if (segments.length === 0) return
|
|
465
|
+
|
|
466
|
+
segments.forEach(segment => {
|
|
467
|
+
if (segment.start === segment.end) {
|
|
468
|
+
drawSinglePointSegment(
|
|
469
|
+
ctx,
|
|
470
|
+
segment.start,
|
|
471
|
+
dataPoints,
|
|
472
|
+
min,
|
|
473
|
+
max,
|
|
474
|
+
range,
|
|
475
|
+
width,
|
|
476
|
+
height,
|
|
477
|
+
L,
|
|
478
|
+
M,
|
|
479
|
+
colorStart,
|
|
480
|
+
)
|
|
481
|
+
} else {
|
|
482
|
+
drawLineSegment(
|
|
483
|
+
ctx,
|
|
484
|
+
segment.start,
|
|
485
|
+
segment.end,
|
|
486
|
+
dataPoints,
|
|
487
|
+
min,
|
|
488
|
+
max,
|
|
489
|
+
range,
|
|
490
|
+
width,
|
|
491
|
+
height,
|
|
492
|
+
L,
|
|
493
|
+
M,
|
|
494
|
+
colorStart,
|
|
495
|
+
)
|
|
496
|
+
drawFillSegment(
|
|
497
|
+
ctx,
|
|
498
|
+
segment.start,
|
|
499
|
+
segment.end,
|
|
500
|
+
dataPoints,
|
|
501
|
+
min,
|
|
502
|
+
max,
|
|
503
|
+
range,
|
|
504
|
+
width,
|
|
505
|
+
height,
|
|
506
|
+
L,
|
|
507
|
+
M,
|
|
508
|
+
colorEnd,
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
interface SparklineHoverState {
|
|
515
|
+
visible: boolean
|
|
516
|
+
clientX: number
|
|
517
|
+
clientY: number
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export const sparklineHoverStates: Record<string, SparklineHoverState> = {}
|
|
521
|
+
|
|
522
|
+
function getSparklineScale(dataPoints: number[]) {
|
|
523
|
+
const validPoints = dataPoints.filter(
|
|
524
|
+
p =>
|
|
525
|
+
typeof p === 'number' &&
|
|
526
|
+
!Number.isNaN(p) &&
|
|
527
|
+
p !== null &&
|
|
528
|
+
p !== undefined,
|
|
529
|
+
)
|
|
530
|
+
if (validPoints.length === 0) {
|
|
531
|
+
return { min: 0, max: 0, range: 1 }
|
|
532
|
+
}
|
|
533
|
+
const sum = validPoints.reduce((a, b) => a + b, 0)
|
|
534
|
+
const avg = sum / validPoints.length || 1
|
|
535
|
+
const actualMax = Math.max(...validPoints)
|
|
536
|
+
const min = 0
|
|
537
|
+
const max = Math.max(avg * 2, actualMax, 50)
|
|
538
|
+
const range = max - min === 0 ? 1 : max - min
|
|
539
|
+
return { min, max, range }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function ensureSparklineTooltip(canvas: HTMLCanvasElement) {
|
|
543
|
+
const chartCard = canvas.closest('.chart-card') as HTMLElement | null
|
|
544
|
+
if (!chartCard) return null
|
|
545
|
+
|
|
546
|
+
let tooltip = chartCard.querySelector('.chart-tooltip') as HTMLElement | null
|
|
547
|
+
if (!tooltip) {
|
|
548
|
+
tooltip = document.createElement('div')
|
|
549
|
+
tooltip.className = 'chart-tooltip'
|
|
550
|
+
chartCard.appendChild(tooltip)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return tooltip
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function formatSparklineTooltipValue(value: number, unitSuffix: string) {
|
|
557
|
+
return Math.round(value).toString() + unitSuffix
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function formatAge30d(agePoints: number): string {
|
|
561
|
+
return agePoints === 1 ? '1 day ago' : `${agePoints} days ago`
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function formatAge7d(agePoints: number): string {
|
|
565
|
+
const hours = agePoints * 6
|
|
566
|
+
if (hours >= 24) {
|
|
567
|
+
const days = Math.floor(hours / 24)
|
|
568
|
+
const remHours = hours % 24
|
|
569
|
+
return remHours > 0 ? `${days}d ${remHours}h ago` : `${days}d ago`
|
|
570
|
+
}
|
|
571
|
+
return `${hours}h ago`
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function formatAge1d(agePoints: number): string {
|
|
575
|
+
const mins = agePoints * 30
|
|
576
|
+
if (mins >= 60) {
|
|
577
|
+
const hours = Math.floor(mins / 60)
|
|
578
|
+
const remMins = mins % 60
|
|
579
|
+
return remMins > 0 ? `${hours}h ${remMins}m ago` : `${hours}h ago`
|
|
580
|
+
}
|
|
581
|
+
return `${mins}m ago`
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function formatAgeOther(agePoints: number, activeTimescale: string): string {
|
|
585
|
+
if (activeTimescale === '1h') {
|
|
586
|
+
return agePoints === 1 ? '1 min ago' : `${agePoints} mins ago`
|
|
587
|
+
}
|
|
588
|
+
return agePoints === 1 ? '1s ago' : `${agePoints}s ago`
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function formatSparklineAge(index: number, length: number) {
|
|
592
|
+
const agePoints = Math.max(length - 1 - index, 0)
|
|
593
|
+
if (agePoints === 0) return 'now'
|
|
594
|
+
|
|
595
|
+
if (activeTimescale === '30d') return formatAge30d(agePoints)
|
|
596
|
+
if (activeTimescale === '7d') return formatAge7d(agePoints)
|
|
597
|
+
if (activeTimescale === '1d') return formatAge1d(agePoints)
|
|
598
|
+
return formatAgeOther(agePoints, activeTimescale)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function updateSparklineTooltip(config: Metric) {
|
|
602
|
+
const state = sparklineHoverStates[config.canvas]
|
|
603
|
+
if (!state?.visible) return
|
|
604
|
+
|
|
605
|
+
const canvas = document.getElementById(
|
|
606
|
+
config.canvas,
|
|
607
|
+
) as HTMLCanvasElement | null
|
|
608
|
+
if (!canvas) return
|
|
609
|
+
|
|
610
|
+
const tooltip = ensureSparklineTooltip(canvas)
|
|
611
|
+
if (!tooltip) return
|
|
612
|
+
|
|
613
|
+
const data = config.history
|
|
614
|
+
if (data.length === 0) {
|
|
615
|
+
tooltip.classList.remove('visible')
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const rect = canvas.getBoundingClientRect()
|
|
620
|
+
const chartCard = canvas.closest('.chart-card') as HTMLElement | null
|
|
621
|
+
const chartRect = chartCard?.getBoundingClientRect() || rect
|
|
622
|
+
const { min, max, range } = getSparklineScale(data)
|
|
623
|
+
const graphWidth = Math.max(rect.width - 50, 1)
|
|
624
|
+
const graphHeight = Math.max(rect.height - 24, 1)
|
|
625
|
+
const localX = Math.min(Math.max(state.clientX - rect.left, 0), graphWidth)
|
|
626
|
+
|
|
627
|
+
const L = getTimescaleLimit(activeTimescale)
|
|
628
|
+
const M = data.length
|
|
629
|
+
const j = L === 1 ? 0 : Math.round((localX / graphWidth) * (L - 1))
|
|
630
|
+
const index = j - (L - M)
|
|
631
|
+
|
|
632
|
+
if (index < 0 || index >= M) {
|
|
633
|
+
tooltip.classList.remove('visible')
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const value = data[index]
|
|
638
|
+
if (value === null || value === undefined || Number.isNaN(value)) {
|
|
639
|
+
tooltip.classList.remove('visible')
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
const safeValue = Math.max(min, Math.min(value, max))
|
|
643
|
+
const pointX = L === 1 ? 0 : (j / (L - 1)) * graphWidth
|
|
644
|
+
const pointY = rect.height - 12 - ((safeValue - min) / range) * graphHeight
|
|
645
|
+
|
|
646
|
+
tooltip.textContent = `${formatSparklineTooltipValue(value, config.unit)} (${formatSparklineAge(index, data.length)})`
|
|
647
|
+
tooltip.dataset.placement = pointY < 28 ? 'below' : 'above'
|
|
648
|
+
tooltip.style.left = `${rect.left - chartRect.left + pointX}px`
|
|
649
|
+
tooltip.style.top = `${rect.top - chartRect.top + pointY}px`
|
|
650
|
+
tooltip.classList.add('visible')
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function refreshSparklineTooltips() {
|
|
654
|
+
for (const config of METRICS) {
|
|
655
|
+
updateSparklineTooltip(config)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export function bindSparklineTooltips() {
|
|
660
|
+
for (const config of METRICS) {
|
|
661
|
+
const canvas = document.getElementById(
|
|
662
|
+
config.canvas,
|
|
663
|
+
) as HTMLCanvasElement | null
|
|
664
|
+
if (!canvas || canvas.dataset.sparklineTooltipBound === 'true') continue
|
|
665
|
+
|
|
666
|
+
canvas.dataset.sparklineTooltipBound = 'true'
|
|
667
|
+
sparklineHoverStates[config.canvas] = {
|
|
668
|
+
visible: false,
|
|
669
|
+
clientX: 0,
|
|
670
|
+
clientY: 0,
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const state = sparklineHoverStates[config.canvas]
|
|
674
|
+
|
|
675
|
+
canvas.addEventListener('pointermove', event => {
|
|
676
|
+
state.visible = true
|
|
677
|
+
state.clientX = event.clientX
|
|
678
|
+
state.clientY = event.clientY
|
|
679
|
+
updateSparklineTooltip(config)
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
canvas.addEventListener('pointerleave', () => {
|
|
683
|
+
state.visible = false
|
|
684
|
+
const tooltip = ensureSparklineTooltip(canvas)
|
|
685
|
+
if (tooltip) tooltip.classList.remove('visible')
|
|
686
|
+
})
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
window.addEventListener('resize', refreshSparklineTooltips, {
|
|
690
|
+
passive: true,
|
|
691
|
+
})
|
|
692
|
+
window.addEventListener('scroll', refreshSparklineTooltips, {
|
|
693
|
+
passive: true,
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function getTimescaleLimit(timescale: string): number {
|
|
698
|
+
switch (timescale) {
|
|
699
|
+
case '30d':
|
|
700
|
+
return 30
|
|
701
|
+
case '7d':
|
|
702
|
+
return 28
|
|
703
|
+
case '1d':
|
|
704
|
+
return 48
|
|
705
|
+
case '1h':
|
|
706
|
+
return 60
|
|
707
|
+
default:
|
|
708
|
+
return 60
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export function drawAllSparklines() {
|
|
713
|
+
for (const m of METRICS) {
|
|
714
|
+
drawSparkline(m.canvas, m.history, m.stroke, m.fill)
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export let analyticsWs: WebSocket | null = null
|
|
719
|
+
let reconnectTimer: any = null
|
|
720
|
+
|
|
721
|
+
export function initAnalyticsWebSocket() {
|
|
722
|
+
if (analyticsWs) return
|
|
723
|
+
analyticsWs = new WebSocket(getWebSocketUrl('/_analytics_ws'))
|
|
724
|
+
|
|
725
|
+
analyticsWs.onopen = () => {
|
|
726
|
+
setConnectionStatus(true)
|
|
727
|
+
connectionLost = false
|
|
728
|
+
loadStats(true)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
analyticsWs.onmessage = event => {
|
|
732
|
+
try {
|
|
733
|
+
const data = JSON.parse(event.data)
|
|
734
|
+
if (data.status === 200) {
|
|
735
|
+
processStatsData(data.data, data.excludeHistory)
|
|
736
|
+
} else if (data.status === 401) {
|
|
737
|
+
window.location.reload()
|
|
738
|
+
}
|
|
739
|
+
} catch (e) {
|
|
740
|
+
console.error('WebSocket Error:', e)
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
analyticsWs.onclose = () => {
|
|
745
|
+
analyticsWs = null
|
|
746
|
+
if (!connectionLost) {
|
|
747
|
+
connectionLost = true
|
|
748
|
+
setConnectionStatus(false)
|
|
749
|
+
}
|
|
750
|
+
clearTimeout(reconnectTimer)
|
|
751
|
+
reconnectTimer = setTimeout(initAnalyticsWebSocket, 3000)
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export function loadStats(forceFullHistory = false) {
|
|
756
|
+
const excludeHistory = !forceFullHistory && memoryHistory.length > 0
|
|
757
|
+
if (analyticsWs && analyticsWs.readyState === WebSocket.OPEN) {
|
|
758
|
+
analyticsWs.send(
|
|
759
|
+
JSON.stringify({
|
|
760
|
+
type: 'subscribe',
|
|
761
|
+
timescale: activeTimescale,
|
|
762
|
+
pagesFilter: activePagesFilter,
|
|
763
|
+
excludeHistory,
|
|
764
|
+
}),
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function updateStatsUIElements(s: any) {
|
|
770
|
+
setText('stat-uptime', formatUptime(s.uptimeSeconds || 0))
|
|
771
|
+
setText('stat-pid', `PID: ${s.pid}`)
|
|
772
|
+
setText('stat-memory', s.memoryUsed)
|
|
773
|
+
setText('stat-mem-total', `External: ${s.memoryExternal}`)
|
|
774
|
+
setText('stat-bun-version', s.bunVersion)
|
|
775
|
+
setText('stat-arch', `${s.platform} (${s.arch})`)
|
|
776
|
+
setText('stat-loggers', s.activeLoggers)
|
|
777
|
+
setText('stat-sessions', s.activeSessions)
|
|
778
|
+
setText('stat-ping', `${s.ping ?? 0} ms`)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function updateAnalyticsActiveState(isAnalyticsActive: boolean) {
|
|
782
|
+
for (const m of METRICS) {
|
|
783
|
+
if (!m.card) continue
|
|
784
|
+
const el = document.getElementById(m.card)
|
|
785
|
+
if (el) el.classList.toggle('blurred-stats', !isAnalyticsActive)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function resetTrackers() {
|
|
790
|
+
for (const key in trackers) trackers[key] = emptyTracker()
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function processStatsHistoryList(history: any[]) {
|
|
794
|
+
for (const m of METRICS) m.history.length = 0
|
|
795
|
+
resetTrackers()
|
|
796
|
+
|
|
797
|
+
history.forEach((item: any) => {
|
|
798
|
+
for (const m of METRICS) {
|
|
799
|
+
const val = readPoint(m, item)
|
|
800
|
+
m.history.push(val)
|
|
801
|
+
updateTracker(m.key, val)
|
|
802
|
+
}
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
lastProcessedHistoryTimestamp = history[history.length - 1].timestamp
|
|
806
|
+
drawAllSparklines()
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function updateHistoryField(m: Metric, val: number, limit: number) {
|
|
810
|
+
m.history.push(val)
|
|
811
|
+
while (m.history.length > limit) {
|
|
812
|
+
m.history.shift()
|
|
813
|
+
}
|
|
814
|
+
updateTracker(m.key, val)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function processStatsIncrementalMinute(s: any) {
|
|
818
|
+
const limit = getTimescaleLimit('1m')
|
|
819
|
+
for (const m of METRICS) updateHistoryField(m, readLive(m, s), limit)
|
|
820
|
+
|
|
821
|
+
if (s.latestHistoryPoint?.timestamp) {
|
|
822
|
+
lastProcessedHistoryTimestamp = s.latestHistoryPoint.timestamp
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
drawAllSparklines()
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function processStatsIncrementalStandard(s: any) {
|
|
829
|
+
const lp = s.latestHistoryPoint
|
|
830
|
+
if (lp && lp.timestamp > lastProcessedHistoryTimestamp) {
|
|
831
|
+
const limit = getTimescaleLimit(activeTimescale)
|
|
832
|
+
for (const m of METRICS) updateHistoryField(m, readPoint(m, lp), limit)
|
|
833
|
+
|
|
834
|
+
lastProcessedHistoryTimestamp = lp.timestamp
|
|
835
|
+
drawAllSparklines()
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function updateTopPagesList(topPages: any[]) {
|
|
840
|
+
const topPagesListContainer = document.getElementById(
|
|
841
|
+
'top-pages-list-container',
|
|
842
|
+
)
|
|
843
|
+
if (!topPagesListContainer) return
|
|
844
|
+
|
|
845
|
+
activeTopPagesProgressBars.forEach(bar => {
|
|
846
|
+
bar.destroy()
|
|
847
|
+
})
|
|
848
|
+
activeTopPagesProgressBars = []
|
|
849
|
+
|
|
850
|
+
if (topPages.length === 0) {
|
|
851
|
+
setEmpty(topPagesListContainer, 'No page hits recorded for this period.')
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const maxHits = Math.max(...topPages.map((p: any) => p.hits), 1)
|
|
856
|
+
let html = `
|
|
857
|
+
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
|
|
858
|
+
<div style="display: grid; grid-template-columns: 1fr auto; font-weight: 600; font-size: 0.8rem; color: var(--text-muted); border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
|
|
859
|
+
<span>Page Path</span>
|
|
860
|
+
<span style="text-align: right; min-width: 80px;">Hits</span>
|
|
861
|
+
</div>
|
|
862
|
+
`
|
|
863
|
+
|
|
864
|
+
topPages.forEach((p: any) => {
|
|
865
|
+
const percent = Math.round((p.hits / maxHits) * 100)
|
|
866
|
+
html += `
|
|
867
|
+
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; font-size: 0.85rem; padding: 0.25rem 0;">
|
|
868
|
+
<div style="display: flex; flex-direction: column; gap: 0.4rem; overflow: hidden; padding-right: 1rem;">
|
|
869
|
+
<span style="font-family: var(--font-mono); color: var(--text-main); text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${escapeHTML(p.page)}</span>
|
|
870
|
+
<div class="segmented-progress-bar-pages" data-percent="${percent}"></div>
|
|
871
|
+
</div>
|
|
872
|
+
<span style="text-align: right; font-weight: 600; font-family: var(--font-mono); color: var(--text-main); min-width: 80px;">${p.hits.toLocaleString()}</span>
|
|
873
|
+
</div>
|
|
874
|
+
`
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
html += '</div>'
|
|
878
|
+
topPagesListContainer.innerHTML = html
|
|
879
|
+
|
|
880
|
+
topPagesListContainer
|
|
881
|
+
.querySelectorAll('.segmented-progress-bar-pages')
|
|
882
|
+
.forEach((el: any) => {
|
|
883
|
+
const pct = parseFloat(el.getAttribute('data-percent') || '0')
|
|
884
|
+
activeTopPagesProgressBars.push(new SegmentedProgress(el, pct))
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
export function processStatsData(s: any, excludeHistory: boolean) {
|
|
889
|
+
let shouldForceFull = false
|
|
890
|
+
if (connectionLost) {
|
|
891
|
+
shouldForceFull = true
|
|
892
|
+
connectionLost = false
|
|
893
|
+
setConnectionStatus(true)
|
|
894
|
+
}
|
|
895
|
+
if (lastServerPid && lastServerPid !== s.pid) {
|
|
896
|
+
shouldForceFull = true
|
|
897
|
+
}
|
|
898
|
+
lastServerPid = s.pid
|
|
899
|
+
|
|
900
|
+
const newTimestamp = s.latestHistoryPoint?.timestamp || 0
|
|
901
|
+
if (lastProcessedHistoryTimestamp && newTimestamp) {
|
|
902
|
+
const interval = getTimescaleIntervalMs(activeTimescale)
|
|
903
|
+
if (newTimestamp - lastProcessedHistoryTimestamp > interval * 2.5) {
|
|
904
|
+
shouldForceFull = true
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (shouldForceFull && excludeHistory) {
|
|
909
|
+
loadStats(true)
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
updateStatsUIElements(s)
|
|
914
|
+
updateAnalyticsActiveState(s.analyticsActive !== false)
|
|
915
|
+
|
|
916
|
+
if (s.history && s.history.length > 0) {
|
|
917
|
+
processStatsHistoryList(s.history)
|
|
918
|
+
} else if (activeTimescale === '1m') {
|
|
919
|
+
processStatsIncrementalMinute(s)
|
|
920
|
+
} else {
|
|
921
|
+
processStatsIncrementalStandard(s)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
refreshSparklineTooltips()
|
|
925
|
+
updateTopPagesList(s.topPages)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export function changeTimescale(newTimescale: string) {
|
|
929
|
+
activeTimescale = newTimescale
|
|
930
|
+
|
|
931
|
+
document.querySelectorAll('.timescale-btn').forEach(btn => {
|
|
932
|
+
btn.classList.toggle('active', btn.id === `timescale-${newTimescale}`)
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
const labelMap: Record<string, string> = {
|
|
936
|
+
'1m': '(last 1 min, 1s resolution)',
|
|
937
|
+
'1h': '(last 60 min, 1m resolution)',
|
|
938
|
+
'1d': '(last 24 hours, 30m resolution)',
|
|
939
|
+
'7d': '(last 7 days, 6h resolution)',
|
|
940
|
+
'30d': '(last 30 days, 1d resolution)',
|
|
941
|
+
}
|
|
942
|
+
for (const m of METRICS) {
|
|
943
|
+
const canvas = document.getElementById(m.canvas)
|
|
944
|
+
const subEl = canvas?.closest('.chart-card')?.querySelector('.card-sub')
|
|
945
|
+
if (subEl) subEl.textContent = `${m.sub} ${labelMap[newTimescale]}`
|
|
946
|
+
|
|
947
|
+
m.history.length = 0
|
|
948
|
+
setText(`${m.key}-min`, '-')
|
|
949
|
+
setText(`${m.key}-max`, '-')
|
|
950
|
+
setText(`${m.key}-avg`, '-')
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
resetTrackers()
|
|
954
|
+
lastProcessedHistoryTimestamp = 0
|
|
955
|
+
drawAllSparklines()
|
|
956
|
+
loadStats(true)
|
|
957
|
+
}
|