@bakery-framework/plugin-dashboard 2.0.0-alpha.12 → 2.0.0-alpha.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/src/client/dashboard.ts +1 -8
- package/src/client/parts/logs.ts +9 -10
- package/src/client/parts/metrics.ts +251 -0
- package/src/client/parts/sessions.ts +26 -124
- package/src/client/parts/sparkline-tooltip.ts +158 -0
- package/src/client/parts/sparkline.ts +383 -0
- package/src/client/parts/stats.ts +33 -758
- package/src/client/parts/utils.ts +18 -64
- package/src/components/DBBrowser.tsx +0 -2
- package/src/components/LogsPanel.tsx +0 -2
- package/src/components/SessionsPanel.tsx +0 -3
- package/src/components/StatsPanel.tsx +0 -19
- package/src/components/TopPagesPanel.tsx +0 -1
- package/src/setup.ts +63 -2
- package/src/shell.tsx +35 -2
- package/src/client/parts/effects.ts +0 -327
|
@@ -1,171 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
formatUptime,
|
|
3
|
-
getWebSocketUrl,
|
|
4
|
-
SegmentedProgress,
|
|
5
|
-
setEmpty,
|
|
6
|
-
setText,
|
|
7
|
-
} from './utils'
|
|
8
|
-
|
|
9
1
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
|
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(--
|
|
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(--
|
|
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
|
-
|
|
286
|
+
setActiveTimescale(newTimescale)
|
|
1012
287
|
|
|
1013
288
|
document.querySelectorAll('.timescale-btn').forEach(btn => {
|
|
1014
289
|
btn.classList.toggle('active', btn.id === `timescale-${newTimescale}`)
|