// Lazy-loaded sensor chart hook (CPU, Memory, Storage, Traffic). // Imports Chart.js (~200KB) so the main bundle stays lean — only pages // rendering a sensor chart pull this chunk in. import Chart from "../../vendor/chart" import type { ChartData, ChartDataset, ChartDataPoint } from "../../vendor/chart" import type { SensorChartHook } from "../types/liveview" function getTimeRangeMs(range: string): number { switch (range) { case '1h': return 1 * 60 * 60 * 1000 case '6h': return 6 * 60 * 60 * 1000 case '12h': return 12 * 60 * 60 * 1000 case '24h': return 24 * 60 * 60 * 1000 case '7d': return 7 * 24 * 60 * 60 * 1000 case '30d': return 30 * 24 * 60 * 60 * 1000 default: return 24 * 60 * 60 * 1000 } } export const SensorChart: SensorChartHook = { mounted() { this.isLiveMode = this.el?.dataset.range === 'live' this.createChart() this.handleEvent?.("live_data_update", (data: any) => { this.handleLiveDataUpdate(data) }) }, updated() { const newRange = this.el?.dataset.range const wasLiveMode = this.isLiveMode this.isLiveMode = newRange === 'live' if (wasLiveMode !== this.isLiveMode) { if (this.chart) { this.chart.destroy() } this.createChart() } else if (!this.isLiveMode) { if (this.chart) { this.chart.destroy() } this.createChart() } }, destroyed() { if (this.chart) { this.chart.destroy() this.chart = null } }, createChart() { if (!this.el) return const dataAttr = this.el.dataset.chart if (!dataAttr) return const data: ChartData = JSON.parse(dataAttr) const canvas = this.el.querySelector('canvas') if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return const unit = this.el.dataset.unit ?? '%' const autoScale = this.el.dataset.autoScale === 'true' const showZeroLine = this.el.dataset.showZeroLine === 'true' const dualAxis = this.el.dataset.dualAxis === 'true' const range = this.el.dataset.range || '24h' const isTrafficChart = unit === 'bps' const colors = [ 'rgb(59, 130, 246)', 'rgb(239, 68, 68)', 'rgb(34, 197, 94)', 'rgb(234, 179, 8)', 'rgb(168, 85, 247)', 'rgb(236, 72, 153)', 'rgb(14, 165, 233)', 'rgb(249, 115, 22)', ] const datasets: ChartDataset[] = data.datasets.map((dataset, index) => { const color = colors[index % colors.length] const baseConfig: ChartDataset = { ...dataset, borderColor: color, backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.2)'), borderWidth: 2, tension: 0.4, pointRadius: 0, pointHoverRadius: 4, } if ((dataset as any).yAxisID) { (baseConfig as any).yAxisID = (dataset as any).yAxisID } if (dualAxis && (dataset as any).yAxisID === 'y1') { (baseConfig as any).stepped = 'before' baseConfig.tension = 0 baseConfig.fill = 'origin' baseConfig.borderWidth = 1 baseConfig.borderColor = 'rgb(34, 197, 94)' baseConfig.backgroundColor = 'rgba(34, 197, 94, 0.1)' } else if (isTrafficChart) { baseConfig.fill = 'origin' } else { baseConfig.fill = false } return baseConfig }) const formatBps = (bps: number): string => { const absBps = Math.abs(bps) if (absBps >= 1_000_000_000) return (absBps / 1_000_000_000).toFixed(2) + ' Gbps' if (absBps >= 1_000_000) return (absBps / 1_000_000).toFixed(2) + ' Mbps' if (absBps >= 1_000) return (absBps / 1_000).toFixed(2) + ' Kbps' return absBps.toFixed(2) + ' bps' } let yAxisConfig: any = {} if (autoScale) { if (showZeroLine && unit === 'bps') { let maxAbsTrafficValue = 0 let maxAbsCapacityValue = 0 datasets.forEach(dataset => { if (dataset.data && dataset.data.length > 0) { dataset.data.forEach((point: ChartDataPoint) => { if (point && typeof point.y === 'number') { const absValue = Math.abs(point.y) if (dataset.label?.startsWith('Capacity')) { maxAbsCapacityValue = Math.max(maxAbsCapacityValue, absValue) } else { maxAbsTrafficValue = Math.max(maxAbsTrafficValue, absValue) } } }) } }) const maxAbsValue = maxAbsTrafficValue > 0 ? maxAbsTrafficValue : maxAbsCapacityValue const paddedMaxAbsValue = Math.max(maxAbsValue * 1.1, 1000) yAxisConfig = { min: -paddedMaxAbsValue, max: paddedMaxAbsValue, ticks: { callback: function(value: number) { return formatBps(value) } }, grid: { color: function(context: any) { return context.tick.value === 0 ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.05)' }, lineWidth: function(context: any) { return context.tick.value === 0 ? 2 : 1 } } } } else { yAxisConfig = { beginAtZero: showZeroLine, ticks: { callback: function(value: number) { if (unit === 'bps') return formatBps(value) if (unit === '') return Math.round(value) return value.toFixed(1) + ' ' + unit } }, grid: { color: function(context: any) { if (showZeroLine && context.tick.value === 0) return 'rgba(0, 0, 0, 0.3)' return 'rgba(0, 0, 0, 0.05)' }, lineWidth: function(context: any) { return showZeroLine && context.tick.value === 0 ? 2 : 1 } } } } } else { yAxisConfig = { min: 0, max: 100, ticks: { callback: function(value: number) { if (unit === 'bps') return formatBps(value) if (unit === '') return Math.round(value) return value.toFixed(1) + ' ' + unit } }, grid: { color: 'rgba(0, 0, 0, 0.05)' } } } const now = Date.now() let xAxisConfig: any if (this.isLiveMode) { xAxisConfig = { type: 'linear', min: now - (5 * 60 * 1000), max: now, ticks: { callback: function(value: number) { const date = new Date(value) return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) }, maxTicksLimit: 10 }, grid: { display: false } } } else { const timeRangeMs = getTimeRangeMs(range) const rangeStart = now - timeRangeMs xAxisConfig = { type: 'linear', min: rangeStart, max: now, ticks: { callback: function(value: number) { const date = new Date(value) if (timeRangeMs > 24 * 60 * 60 * 1000) { return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }) } else { return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) } }, maxTicksLimit: 12 }, grid: { display: false } } } this.chart = new Chart(ctx, { type: 'line', data: { datasets }, options: { responsive: true, maintainAspectRatio: false, animation: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: 'bottom', labels: { usePointStyle: true, padding: 15 } }, tooltip: { callbacks: { title: function(context: any) { const timestamp = context[0].parsed.x const date = new Date(timestamp) return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }) }, label: function(context: any) { if (dualAxis && context.dataset.yAxisID === 'y1') { return context.dataset.label + ': ' + (context.parsed.y === 1 ? 'Up' : 'Down') } if (unit === 'bps') return context.dataset.label + ': ' + formatBps(context.parsed.y) if (unit === '') return context.dataset.label + ': ' + Math.round(context.parsed.y) return context.dataset.label + ': ' + context.parsed.y.toFixed(1) + ' ' + unit } } } }, scales: { x: xAxisConfig, y: yAxisConfig, ...(dualAxis ? { y1: { position: 'right' as const, min: -0.1, max: 1.1, grid: { drawOnChartArea: false }, ticks: { stepSize: 1, callback: function(value: number) { if (value === 1) return 'Up' if (value === 0) return 'Down' return '' } } } } : {}) } } }) const loadingEl = this.el.querySelector('[data-loading]') if (loadingEl) loadingEl.remove() }, handleLiveDataUpdate(event: any) { if (!this.chart || !this.isLiveMode) return const { points } = event const now = Date.now() points.forEach((point: any) => { const dataset = this.chart!.data.datasets.find((ds: any) => ds.label === point.label) if (dataset && dataset.data) { (dataset.data as any[]).push({ x: point.timestamp, y: point.value }) const cutoff = now - (5 * 60 * 1000) dataset.data = (dataset.data as any[]).filter((p: any) => p.x >= cutoff) } else if (!dataset) { const colors = [ 'rgb(59, 130, 246)', 'rgb(239, 68, 68)', 'rgb(34, 197, 94)', 'rgb(234, 179, 8)', 'rgb(168, 85, 247)', 'rgb(236, 72, 153)', 'rgb(14, 165, 233)', 'rgb(249, 115, 22)', ] const index = this.chart!.data.datasets.length const color = colors[index % colors.length] this.chart!.data.datasets.push({ label: point.label, data: [{ x: point.timestamp, y: point.value }], borderColor: color, backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.2)'), borderWidth: 2, tension: 0.4, pointRadius: 0, pointHoverRadius: 4, fill: false } as any) } }) if (this.chart.options.scales?.x) { this.chart.options.scales.x.min = now - (5 * 60 * 1000) this.chart.options.scales.x.max = now } this.chart.update('none') } }