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