towerops/assets/js/hooks/sensor_chart.ts
Graham McIntire 3bd4ed303c perf(assets): split big hooks into per-page chunks
app.js was bundling Chart.js (~200 KB), Cytoscape (~500 KB) and four
heavy hooks (SensorChart, NetworkMap, WeathermapViewer, SitesMap)
into a single 1.25 MB blob that every page paid for.

Extract each into its own module under assets/js/hooks/ and reference
them through the existing lazyHook() wrapper so the implementation is
fetched only when a hook actually mounts. esbuild --splitting then
factors the shared cytoscape vendor blob into a chunk that's only
loaded on topology / weathermap pages.

After-bundle layout (minified):
  app.js                  297.8 KB   (was ~1250 KB; -76%)
  chunk-LJGWK7RH (cyto)   555.1 KB   loaded on topology pages only
  sensor_chart            325.3 KB   loaded on sensor pages only
  network_map              24.4 KB   topology hook glue
  weathermap               13.0 KB   weathermap hook glue
  sites_map                 4.1 KB   site map hook glue
  coverage_hooks           25.0 KB   coverage hook glue

LeafletMap also moved to sites_map.ts and now uses ensureLeaflet
instead of polling for window.L with setTimeout.
2026-05-07 07:58:38 -05:00

342 lines
11 KiB
TypeScript

// 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')
}
}