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