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.
342 lines
11 KiB
TypeScript
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')
|
|
}
|
|
}
|