From 98da879399fedacdf9a539f765d4329d140a95bb Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 7 May 2026 08:11:48 -0500 Subject: [PATCH] fix(config-timeline): implement the missing chart hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/js/app.ts | 6 +- assets/js/hooks/config_timeline_chart.ts | 266 +++++++++++++++++++++++ 2 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 assets/js/hooks/config_timeline_chart.ts diff --git a/assets/js/app.ts b/assets/js/app.ts index 2dacbc0f..b541662b 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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 diff --git a/assets/js/hooks/config_timeline_chart.ts b/assets/js/hooks/config_timeline_chart.ts new file mode 100644 index 00000000..338c4a3d --- /dev/null +++ b/assets/js/hooks/config_timeline_chart.ts @@ -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(raw: string | undefined): T[] { + if (!raw) return [] + try { + const v = JSON.parse(raw) + return Array.isArray(v) ? v : [] + } catch { + return [] + } +}