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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,171 +1,36 @@
1
- import {
2
- formatUptime,
3
- getWebSocketUrl,
4
- SegmentedProgress,
5
- setEmpty,
6
- setText,
7
- } from './utils'
8
-
9
1
  /**
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.
2
+ * The live feed: the socket, the polling fallback, and everything that writes
3
+ * a number into the page.
4
+ *
5
+ * This file was 999 lines and held four jobs. Three of them left, along the
6
+ * seams they already read as: `metrics.ts` is what the charts *are*,
7
+ * `sparkline.ts` is how one is drawn, `sparkline-tooltip.ts` is the tooltip
8
+ * over it. What is left here is the part that changes over time, which is the
9
+ * only part with any state a request can move.
16
10
  *
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).
11
+ * The dependency runs one way through all four tooltip and feed both reach
12
+ * for drawing, drawing reaches for the catalogue, and the catalogue reaches for
13
+ * nothing so no pair of them can close a cycle.
21
14
  */
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
15
 
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
- }
16
+ import { formatUptime, getWebSocketUrl, setEmpty, setText } from './utils'
17
+ import {
18
+ activeTimescale,
19
+ emptyTracker,
20
+ getTimescaleIntervalMs,
21
+ getTimescaleLimit,
22
+ memoryHistory,
23
+ METRICS,
24
+ type Metric,
25
+ readLive,
26
+ readPoint,
27
+ setActiveTimescale,
28
+ trackers,
29
+ updateTracker,
30
+ } from './metrics'
31
+ import { drawAllSparklines } from './sparkline'
32
+ import { refreshSparklineTooltips } from './sparkline-tooltip'
167
33
 
168
- export let activeTimescale = '1m'
169
34
  export let lastProcessedHistoryTimestamp = 0
170
35
  export let lastServerPid = 0
171
36
  export let connectionLost = false
@@ -179,7 +44,7 @@ function setConnectionStatus(online: boolean) {
179
44
  dot.style.background = '#10b981'
180
45
  dot.style.boxShadow = '0 0 10px rgba(16, 185, 129, 0.4)'
181
46
  text.innerText = 'Online (DEV)'
182
- text.style.color = 'var(--text-main)'
47
+ text.style.color = 'var(--text)'
183
48
  } else {
184
49
  dot.style.background = '#ef4444'
185
50
  dot.style.boxShadow = '0 0 10px rgba(239, 68, 68, 0.4)'
@@ -188,7 +53,6 @@ function setConnectionStatus(online: boolean) {
188
53
  }
189
54
  }
190
55
  export let activePagesFilter = '1d'
191
- export let activeTopPagesProgressBars: SegmentedProgress[] = []
192
56
 
193
57
  export function changePagesFilter(newFilter: string) {
194
58
  activePagesFilter = newFilter
@@ -222,581 +86,6 @@ export async function resetAnalytics() {
222
86
  }
223
87
  }
224
88
 
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
- // Last, so the marker sits above the fill rather than under it.
514
- const hovered = resolveHoverPoint(canvasId, dataPoints, {
515
- left: rect.left,
516
- width,
517
- height,
518
- })
519
- if (hovered) drawHoverMarker(ctx, hovered, height, colorStart)
520
- }
521
-
522
- interface SparklineHoverState {
523
- visible: boolean
524
- clientX: number
525
- clientY: number
526
- }
527
-
528
- export const sparklineHoverStates: Record<string, SparklineHoverState> = {}
529
-
530
- export interface HoverPoint {
531
- index: number
532
- value: number
533
- /** Where the point sits on the canvas, in CSS pixels within its box. */
534
- x: number
535
- y: number
536
- }
537
-
538
- /**
539
- * Which sample the pointer is over, and where that sample is drawn.
540
- *
541
- * Shared by the two things that must agree about it: the marker painted on the
542
- * canvas and the tooltip positioned over the card. This arithmetic — the 50px
543
- * reserved for the axis labels, the 24 and 12 of vertical padding, and the
544
- * `L - M` offset for a series shorter than the window — used to live only in
545
- * the tooltip. Copying it into the draw path would have worked exactly until
546
- * one copy was adjusted, at which point the dot and its label would point at
547
- * different samples and look like a rounding bug.
548
- */
549
- export function resolveHoverPoint(
550
- canvasId: string,
551
- data: number[],
552
- rect: { left: number; width: number; height: number },
553
- ): HoverPoint | null {
554
- const state = sparklineHoverStates[canvasId]
555
- if (!state?.visible || data.length === 0) return null
556
-
557
- const { min, max, range } = getSparklineScale(data)
558
- const graphWidth = Math.max(rect.width - 50, 1)
559
- const graphHeight = Math.max(rect.height - 24, 1)
560
- const localX = Math.min(Math.max(state.clientX - rect.left, 0), graphWidth)
561
-
562
- const L = getTimescaleLimit(activeTimescale)
563
- const M = data.length
564
- const j = L === 1 ? 0 : Math.round((localX / graphWidth) * (L - 1))
565
- const index = j - (L - M)
566
- if (index < 0 || index >= M) return null
567
-
568
- const value = data[index]
569
- if (value === null || value === undefined || Number.isNaN(value)) return null
570
-
571
- const safeValue = Math.max(min, Math.min(value, max))
572
- return {
573
- index,
574
- value,
575
- x: L === 1 ? 0 : (j / (L - 1)) * graphWidth,
576
- y: rect.height - 12 - ((safeValue - min) / range) * graphHeight,
577
- }
578
- }
579
-
580
- /**
581
- * The hover marker: a guide line down the chart and a ringed dot on the sample.
582
- *
583
- * The ring is drawn in the card's own background rather than left transparent,
584
- * so the dot reads as sitting *on* the line instead of merging into it wherever
585
- * the series is dense.
586
- */
587
- function drawHoverMarker(
588
- ctx: CanvasRenderingContext2D,
589
- point: HoverPoint,
590
- height: number,
591
- color: string,
592
- ) {
593
- ctx.save()
594
-
595
- ctx.beginPath()
596
- ctx.moveTo(point.x, 8)
597
- ctx.lineTo(point.x, height - 10)
598
- ctx.strokeStyle = 'rgba(148, 163, 184, 0.35)'
599
- ctx.lineWidth = 1
600
- ctx.setLineDash([3, 3])
601
- ctx.stroke()
602
- ctx.setLineDash([])
603
-
604
- ctx.beginPath()
605
- ctx.arc(point.x, point.y, 4.5, 0, Math.PI * 2)
606
- ctx.fillStyle = color
607
- ctx.fill()
608
- ctx.lineWidth = 2
609
- ctx.strokeStyle = 'rgba(15, 17, 21, 0.9)'
610
- ctx.stroke()
611
-
612
- ctx.restore()
613
- }
614
-
615
- function getSparklineScale(dataPoints: number[]) {
616
- const validPoints = dataPoints.filter(
617
- p =>
618
- typeof p === 'number' &&
619
- !Number.isNaN(p) &&
620
- p !== null &&
621
- p !== undefined,
622
- )
623
- if (validPoints.length === 0) {
624
- return { min: 0, max: 0, range: 1 }
625
- }
626
- const sum = validPoints.reduce((a, b) => a + b, 0)
627
- const avg = sum / validPoints.length || 1
628
- const actualMax = Math.max(...validPoints)
629
- const min = 0
630
- const max = Math.max(avg * 2, actualMax, 50)
631
- const range = max - min === 0 ? 1 : max - min
632
- return { min, max, range }
633
- }
634
-
635
- function ensureSparklineTooltip(canvas: HTMLCanvasElement) {
636
- const chartCard = canvas.closest('.chart-card') as HTMLElement | null
637
- if (!chartCard) return null
638
-
639
- let tooltip = chartCard.querySelector('.chart-tooltip') as HTMLElement | null
640
- if (!tooltip) {
641
- tooltip = document.createElement('div')
642
- tooltip.className = 'chart-tooltip'
643
- chartCard.appendChild(tooltip)
644
- }
645
-
646
- return tooltip
647
- }
648
-
649
- function formatSparklineTooltipValue(value: number, unitSuffix: string) {
650
- return Math.round(value).toString() + unitSuffix
651
- }
652
-
653
- function formatAge30d(agePoints: number): string {
654
- return agePoints === 1 ? '1 day ago' : `${agePoints} days ago`
655
- }
656
-
657
- function formatAge7d(agePoints: number): string {
658
- const hours = agePoints * 6
659
- if (hours >= 24) {
660
- const days = Math.floor(hours / 24)
661
- const remHours = hours % 24
662
- return remHours > 0 ? `${days}d ${remHours}h ago` : `${days}d ago`
663
- }
664
- return `${hours}h ago`
665
- }
666
-
667
- function formatAge1d(agePoints: number): string {
668
- const mins = agePoints * 30
669
- if (mins >= 60) {
670
- const hours = Math.floor(mins / 60)
671
- const remMins = mins % 60
672
- return remMins > 0 ? `${hours}h ${remMins}m ago` : `${hours}h ago`
673
- }
674
- return `${mins}m ago`
675
- }
676
-
677
- function formatAgeOther(agePoints: number, activeTimescale: string): string {
678
- if (activeTimescale === '1h') {
679
- return agePoints === 1 ? '1 min ago' : `${agePoints} mins ago`
680
- }
681
- return agePoints === 1 ? '1s ago' : `${agePoints}s ago`
682
- }
683
-
684
- function formatSparklineAge(index: number, length: number) {
685
- const agePoints = Math.max(length - 1 - index, 0)
686
- if (agePoints === 0) return 'now'
687
-
688
- if (activeTimescale === '30d') return formatAge30d(agePoints)
689
- if (activeTimescale === '7d') return formatAge7d(agePoints)
690
- if (activeTimescale === '1d') return formatAge1d(agePoints)
691
- return formatAgeOther(agePoints, activeTimescale)
692
- }
693
-
694
- export function updateSparklineTooltip(config: Metric) {
695
- const state = sparklineHoverStates[config.canvas]
696
- if (!state?.visible) return
697
-
698
- const canvas = document.getElementById(
699
- config.canvas,
700
- ) as HTMLCanvasElement | null
701
- if (!canvas) return
702
-
703
- const tooltip = ensureSparklineTooltip(canvas)
704
- if (!tooltip) return
705
-
706
- const data = config.history
707
- if (data.length === 0) {
708
- tooltip.classList.remove('visible')
709
- return
710
- }
711
-
712
- const rect = canvas.getBoundingClientRect()
713
- const chartCard = canvas.closest('.chart-card') as HTMLElement | null
714
- const chartRect = chartCard?.getBoundingClientRect() || rect
715
-
716
- const point = resolveHoverPoint(config.canvas, data, rect)
717
- if (!point) {
718
- tooltip.classList.remove('visible')
719
- return
720
- }
721
-
722
- tooltip.textContent = `${formatSparklineTooltipValue(point.value, config.unit)} (${formatSparklineAge(point.index, data.length)})`
723
- tooltip.dataset.placement = point.y < 28 ? 'below' : 'above'
724
- tooltip.style.left = `${rect.left - chartRect.left + point.x}px`
725
- tooltip.style.top = `${rect.top - chartRect.top + point.y}px`
726
- tooltip.classList.add('visible')
727
- }
728
-
729
- export function refreshSparklineTooltips() {
730
- for (const config of METRICS) {
731
- updateSparklineTooltip(config)
732
- }
733
- }
734
-
735
- export function bindSparklineTooltips() {
736
- for (const config of METRICS) {
737
- const canvas = document.getElementById(
738
- config.canvas,
739
- ) as HTMLCanvasElement | null
740
- if (!canvas || canvas.dataset.sparklineTooltipBound === 'true') continue
741
-
742
- canvas.dataset.sparklineTooltipBound = 'true'
743
- sparklineHoverStates[config.canvas] = {
744
- visible: false,
745
- clientX: 0,
746
- clientY: 0,
747
- }
748
-
749
- const state = sparklineHoverStates[config.canvas]
750
-
751
- canvas.addEventListener('pointermove', event => {
752
- state.visible = true
753
- state.clientX = event.clientX
754
- state.clientY = event.clientY
755
- updateSparklineTooltip(config)
756
- // The marker is painted *into* the canvas, so it only moves when the
757
- // canvas is repainted. Without this it would lag the pointer by up to a
758
- // second — the polling redraw's interval — and read as a stuck dot.
759
- drawSparkline(config.canvas, config.history, config.stroke, config.fill)
760
- })
761
-
762
- canvas.addEventListener('pointerleave', () => {
763
- state.visible = false
764
- const tooltip = ensureSparklineTooltip(canvas)
765
- if (tooltip) tooltip.classList.remove('visible')
766
- // Repaint to clear the marker, for the same reason.
767
- drawSparkline(config.canvas, config.history, config.stroke, config.fill)
768
- })
769
- }
770
-
771
- window.addEventListener('resize', refreshSparklineTooltips, {
772
- passive: true,
773
- })
774
- window.addEventListener('scroll', refreshSparklineTooltips, {
775
- passive: true,
776
- })
777
- }
778
-
779
- function getTimescaleLimit(timescale: string): number {
780
- switch (timescale) {
781
- case '30d':
782
- return 30
783
- case '7d':
784
- return 28
785
- case '1d':
786
- return 48
787
- case '1h':
788
- return 60
789
- default:
790
- return 60
791
- }
792
- }
793
-
794
- export function drawAllSparklines() {
795
- for (const m of METRICS) {
796
- drawSparkline(m.canvas, m.history, m.stroke, m.fill)
797
- }
798
- }
799
-
800
89
  export let analyticsWs: WebSocket | null = null
801
90
  let reconnectTimer: any = null
802
91
 
@@ -924,34 +213,26 @@ function updateTopPagesList(topPages: any[]) {
924
213
  )
925
214
  if (!topPagesListContainer) return
926
215
 
927
- activeTopPagesProgressBars.forEach(bar => {
928
- bar.destroy()
929
- })
930
- activeTopPagesProgressBars = []
931
-
932
216
  if (topPages.length === 0) {
933
217
  setEmpty(topPagesListContainer, 'No page hits recorded for this period.')
934
218
  return
935
219
  }
936
220
 
937
- const maxHits = Math.max(...topPages.map((p: any) => p.hits), 1)
938
221
  let html = `
939
222
  <div style="display: flex; flex-direction: column; gap: 0.75rem;">
940
- <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;">
223
+ <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); padding-bottom: 0.5rem;">
941
224
  <span>Page Path</span>
942
225
  <span style="text-align: right; min-width: 80px;">Hits</span>
943
226
  </div>
944
227
  `
945
228
 
946
229
  topPages.forEach((p: any) => {
947
- const percent = Math.round((p.hits / maxHits) * 100)
948
230
  html += `
949
231
  <div style="display: grid; grid-template-columns: 1fr auto; align-items: center; font-size: 0.85rem; padding: 0.25rem 0;">
950
232
  <div style="display: flex; flex-direction: column; gap: 0.4rem; overflow: hidden; padding-right: 1rem;">
951
- <span style="font-family: var(--font-mono); color: var(--text-main); text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${escapeHTML(p.page)}</span>
952
- <div class="segmented-progress-bar-pages" data-percent="${percent}"></div>
233
+ <span style="font-family: var(--mono); color: var(--text); text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${escapeHTML(p.page)}</span>
953
234
  </div>
954
- <span style="text-align: right; font-weight: 600; font-family: var(--font-mono); color: var(--text-main); min-width: 80px;">${p.hits.toLocaleString()}</span>
235
+ <span style="text-align: right; font-weight: 600; font-family: var(--mono); color: var(--text); min-width: 80px;">${p.hits.toLocaleString()}</span>
955
236
  </div>
956
237
  `
957
238
  })
@@ -959,12 +240,6 @@ function updateTopPagesList(topPages: any[]) {
959
240
  html += '</div>'
960
241
  topPagesListContainer.innerHTML = html
961
242
 
962
- topPagesListContainer
963
- .querySelectorAll('.segmented-progress-bar-pages')
964
- .forEach((el: any) => {
965
- const pct = parseFloat(el.getAttribute('data-percent') || '0')
966
- activeTopPagesProgressBars.push(new SegmentedProgress(el, pct))
967
- })
968
243
  }
969
244
 
970
245
  export function processStatsData(s: any, excludeHistory: boolean) {
@@ -1008,7 +283,7 @@ export function processStatsData(s: any, excludeHistory: boolean) {
1008
283
  }
1009
284
 
1010
285
  export function changeTimescale(newTimescale: string) {
1011
- activeTimescale = newTimescale
286
+ setActiveTimescale(newTimescale)
1012
287
 
1013
288
  document.querySelectorAll('.timescale-btn').forEach(btn => {
1014
289
  btn.classList.toggle('active', btn.id === `timescale-${newTimescale}`)