fix(config-timeline): implement the missing chart hook
ConfigTimelineLive's chart panel was rendering a bare 'Loading chart...'
placeholder forever — the phx-hook="ConfigTimelineChart" had no JS
implementation registered anywhere, so the whole point of the page
(correlating QoE regressions with config edits) was lost.
Add a focused lazy hook in hooks/config_timeline_chart.ts that:
* Plots Preseem latency / throughput / loss across three Y-axes.
* Overlays vertical dashed markers at each config-change timestamp
with a small badge showing how many lines changed.
* Drops a red triangle on the time axis for every failed check.
* Reuses the Chart.js vendor chunk (esbuild --splitting factors it
into a shared chunk with sensor_chart, so visiting either page
primes the other).
Bundle delta:
chunk-RSDHLN2L.js (Chart.js, shared) 314.8 KB
config_timeline_chart 7.2 KB
sensor_chart (was 325 KB standalone) 10.6 KB after dedup
This commit is contained in:
parent
7ffc4ebd2b
commit
98da879399
2 changed files with 271 additions and 1 deletions
|
|
@ -203,6 +203,10 @@ const MultiCoverageMap = lazyHook(() => import("./hooks/coverage_hooks"), "Multi
|
|||
// Sensor chart hook (Chart.js — ~200KB) — only sensor pages load it.
|
||||
const SensorChart = lazyHook(() => import("./hooks/sensor_chart"), "SensorChart")
|
||||
|
||||
// Config timeline chart — Chart.js (shared chunk with sensor_chart) plus
|
||||
// a small custom plugin that overlays config-change markers on QoE data.
|
||||
const ConfigTimelineChart = lazyHook(() => import("./hooks/config_timeline_chart"), "ConfigTimelineChart")
|
||||
|
||||
// Sites/Leaflet hooks — Leaflet itself loads externally via ensureLeaflet,
|
||||
// but the hook glue is still per-page.
|
||||
const SitesMap = lazyHook(() => import("./hooks/sites_map"), "SitesMap")
|
||||
|
|
@ -344,7 +348,7 @@ const ThemeSelector = {
|
|||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 5000,
|
||||
params: { _csrf_token: csrfToken, timezone: userTimezone },
|
||||
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, CoverageMap, MultiCoverageMap, CoverageLocationPicker, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
|
||||
hooks: { ...colocatedHooks, SensorChart, ConfigTimelineChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, CoverageMap, MultiCoverageMap, CoverageLocationPicker, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse },
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
|
|||
266
assets/js/hooks/config_timeline_chart.ts
Normal file
266
assets/js/hooks/config_timeline_chart.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
// Config-timeline chart: plots Preseem QoE (latency, throughput, loss)
|
||||
// over time and overlays vertical markers for each config change event,
|
||||
// so an operator can spot whether a metric regression coincides with a
|
||||
// device config edit.
|
||||
//
|
||||
// Lazy-loaded; reuses Chart.js with the sensor chart (esbuild factors
|
||||
// the shared Chart vendor blob into a common chunk).
|
||||
|
||||
import Chart from "../../vendor/chart"
|
||||
|
||||
interface QoePoint {
|
||||
t: string
|
||||
latency: number | null
|
||||
throughput: number | null
|
||||
loss: number | null
|
||||
}
|
||||
|
||||
interface ChangeEvent {
|
||||
id: string
|
||||
t: string
|
||||
sections: string[]
|
||||
size: number
|
||||
}
|
||||
|
||||
interface CheckPoint {
|
||||
t: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export const ConfigTimelineChart = {
|
||||
chart: null as any,
|
||||
|
||||
mounted(this: any) {
|
||||
this.render()
|
||||
},
|
||||
|
||||
updated(this: any) {
|
||||
if (this.chart) {
|
||||
this.chart.destroy()
|
||||
this.chart = null
|
||||
}
|
||||
this.render()
|
||||
},
|
||||
|
||||
destroyed(this: any) {
|
||||
if (this.chart) {
|
||||
this.chart.destroy()
|
||||
this.chart = null
|
||||
}
|
||||
},
|
||||
|
||||
render(this: any) {
|
||||
if (!this.el) return
|
||||
|
||||
const qoe: QoePoint[] = parseJSON(this.el.dataset.qoe)
|
||||
const changes: ChangeEvent[] = parseJSON(this.el.dataset.changes)
|
||||
const checks: CheckPoint[] = parseJSON(this.el.dataset.checks)
|
||||
|
||||
// Replace the loading placeholder with a fresh canvas every render.
|
||||
this.el.innerHTML = ""
|
||||
const canvas = document.createElement("canvas")
|
||||
this.el.appendChild(canvas)
|
||||
|
||||
if (qoe.length === 0 && checks.length === 0) {
|
||||
const empty = document.createElement("div")
|
||||
empty.className = "flex items-center justify-center h-full text-base-content/40 text-sm"
|
||||
empty.textContent = "No metrics in this period."
|
||||
this.el.innerHTML = ""
|
||||
this.el.appendChild(empty)
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const datasets: any[] = []
|
||||
if (qoe.some((p) => p.latency != null)) {
|
||||
datasets.push({
|
||||
label: "Latency (ms)",
|
||||
yAxisID: "y_latency",
|
||||
borderColor: "rgb(59, 130, 246)",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.15)",
|
||||
data: qoe.map((p) => ({ x: Date.parse(p.t), y: p.latency })),
|
||||
borderWidth: 2,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
spanGaps: true,
|
||||
})
|
||||
}
|
||||
if (qoe.some((p) => p.throughput != null)) {
|
||||
datasets.push({
|
||||
label: "Throughput (Mbps)",
|
||||
yAxisID: "y_throughput",
|
||||
borderColor: "rgb(34, 197, 94)",
|
||||
backgroundColor: "rgba(34, 197, 94, 0.15)",
|
||||
data: qoe.map((p) => ({ x: Date.parse(p.t), y: p.throughput })),
|
||||
borderWidth: 2,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
spanGaps: true,
|
||||
})
|
||||
}
|
||||
if (qoe.some((p) => p.loss != null)) {
|
||||
datasets.push({
|
||||
label: "Loss (%)",
|
||||
yAxisID: "y_loss",
|
||||
borderColor: "rgb(239, 68, 68)",
|
||||
backgroundColor: "rgba(239, 68, 68, 0.15)",
|
||||
data: qoe.map((p) => ({ x: Date.parse(p.t), y: p.loss })),
|
||||
borderWidth: 2,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
spanGaps: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Failed checks rendered as scatter points pinned along the x-axis,
|
||||
// separate dataset so the legend can toggle them off.
|
||||
const failedChecks = checks.filter((c) => c.status === "failed" || c.status === "down")
|
||||
if (failedChecks.length > 0) {
|
||||
datasets.push({
|
||||
label: "Check failed",
|
||||
yAxisID: "y_event",
|
||||
type: "scatter" as const,
|
||||
borderColor: "rgb(220, 38, 38)",
|
||||
backgroundColor: "rgb(220, 38, 38)",
|
||||
data: failedChecks.map((c) => ({ x: Date.parse(c.t), y: 0 })),
|
||||
pointStyle: "triangle",
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7,
|
||||
showLine: false,
|
||||
})
|
||||
}
|
||||
|
||||
const isDark = document.documentElement.getAttribute("data-theme") === "dark"
|
||||
const gridColor = isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)"
|
||||
const labelColor = isDark ? "#cbd5e1" : "#475569"
|
||||
const changeMarkerColor = isDark ? "rgba(168, 85, 247, 0.85)" : "rgba(124, 58, 237, 0.85)"
|
||||
|
||||
// Custom plugin: draw a vertical dashed line at each config change
|
||||
// timestamp, with a small badge showing how many sections changed.
|
||||
const changeMarkerPlugin = {
|
||||
id: "configChangeMarkers",
|
||||
afterDatasetsDraw(chart: any) {
|
||||
const xScale = chart.scales.x
|
||||
const { chartArea, ctx } = chart
|
||||
if (!xScale || !chartArea) return
|
||||
|
||||
ctx.save()
|
||||
ctx.strokeStyle = changeMarkerColor
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.setLineDash([4, 4])
|
||||
ctx.font = "10px ui-sans-serif, system-ui, sans-serif"
|
||||
ctx.fillStyle = changeMarkerColor
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "top"
|
||||
|
||||
for (const change of changes) {
|
||||
const tMs = Date.parse(change.t)
|
||||
if (Number.isNaN(tMs)) continue
|
||||
const x = xScale.getPixelForValue(tMs)
|
||||
if (x < chartArea.left || x > chartArea.right) continue
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, chartArea.top)
|
||||
ctx.lineTo(x, chartArea.bottom)
|
||||
ctx.stroke()
|
||||
|
||||
const label = `Δ ${change.size}`
|
||||
const padding = 4
|
||||
const metrics = ctx.measureText(label)
|
||||
const w = metrics.width + padding * 2
|
||||
ctx.fillStyle = changeMarkerColor
|
||||
ctx.fillRect(x - w / 2, chartArea.top - 18, w, 14)
|
||||
ctx.fillStyle = "#fff"
|
||||
ctx.fillText(label, x, chartArea.top - 16)
|
||||
}
|
||||
ctx.restore()
|
||||
},
|
||||
}
|
||||
|
||||
this.chart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: { datasets },
|
||||
plugins: [changeMarkerPlugin],
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
layout: { padding: { top: 20 } },
|
||||
interaction: { mode: "index", intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "bottom",
|
||||
labels: { usePointStyle: true, padding: 12, color: labelColor },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: (items: any) => {
|
||||
const t = items[0]?.parsed?.x
|
||||
return t ? new Date(t).toLocaleString() : ""
|
||||
},
|
||||
label: (item: any) => {
|
||||
if (item.dataset.label === "Check failed") return "Check failed"
|
||||
if (item.parsed.y == null) return `${item.dataset.label}: —`
|
||||
return `${item.dataset.label}: ${item.parsed.y.toFixed(2)}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "linear",
|
||||
ticks: {
|
||||
color: labelColor,
|
||||
callback: (v: number) => new Date(v).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }),
|
||||
maxTicksLimit: 8,
|
||||
},
|
||||
grid: { color: gridColor },
|
||||
},
|
||||
y_latency: {
|
||||
position: "left" as const,
|
||||
display: datasets.some((d: any) => d.yAxisID === "y_latency"),
|
||||
title: { display: true, text: "ms", color: labelColor },
|
||||
ticks: { color: labelColor },
|
||||
grid: { color: gridColor },
|
||||
},
|
||||
y_throughput: {
|
||||
position: "right" as const,
|
||||
display: datasets.some((d: any) => d.yAxisID === "y_throughput"),
|
||||
title: { display: true, text: "Mbps", color: labelColor },
|
||||
ticks: { color: labelColor },
|
||||
grid: { drawOnChartArea: false },
|
||||
},
|
||||
y_loss: {
|
||||
position: "right" as const,
|
||||
display: datasets.some((d: any) => d.yAxisID === "y_loss"),
|
||||
title: { display: true, text: "%", color: labelColor },
|
||||
ticks: { color: labelColor },
|
||||
grid: { drawOnChartArea: false },
|
||||
min: 0,
|
||||
},
|
||||
y_event: {
|
||||
position: "left" as const,
|
||||
display: false,
|
||||
min: -0.5,
|
||||
max: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
function parseJSON<T>(raw: string | undefined): T[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const v = JSON.parse(raw)
|
||||
return Array.isArray(v) ? v : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue