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
266 lines
8.1 KiB
TypeScript
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 []
|
|
}
|
|
}
|