towerops/assets/js/hooks/config_timeline_chart.ts
Graham McIntire 98da879399 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
2026-05-07 08:11:48 -05:00

266 lines
8.1 KiB
TypeScript

// 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 []
}
}