From 42f5bb745f0d281dd10c9d23fbb8ece309784cea Mon Sep 17 00:00:00 2001 From: "sas.fajri" Date: Thu, 30 Apr 2026 16:35:20 +0700 Subject: [PATCH] Live-update all dashboard charts via SSE --- cpone-dashboard/menu/dashboard/sse.go | 81 +++++++++++++++++++ .../templates/dashboard/index.html | 41 +++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/cpone-dashboard/menu/dashboard/sse.go b/cpone-dashboard/menu/dashboard/sse.go index 9d59968..cf5f1c8 100644 --- a/cpone-dashboard/menu/dashboard/sse.go +++ b/cpone-dashboard/menu/dashboard/sse.go @@ -12,6 +12,8 @@ type pollState struct { kpiHash string stationsHash string arrivalsHash string + tatHash string + trendHash string } func formatSSE(event, html string) string { @@ -19,6 +21,10 @@ func formatSSE(event, html string) string { return fmt.Sprintf("event: %s\ndata: %s\n\n", event, data) } +func formatSSEData(event, data string) string { + return fmt.Sprintf("event: %s\ndata: %s\n\n", event, strings.TrimSpace(data)) +} + func renderPartial(name string, data interface{}) string { t := parse("templates/dashboard/partials/" + name + ".html") var buf bytes.Buffer @@ -75,10 +81,48 @@ func SSEStream(w http.ResponseWriter, r *http.Request) { } } + pushTrend := func(force bool) { + trend, _ := GetPeriodTrend(project.McuID, dateFrom, dateTo, dateFrom != dateTo) + labels, checkedIn, checkedOut := []string{}, []int{}, []int{} + for _, p := range trend { + labels = append(labels, p.Label) + checkedIn = append(checkedIn, p.CheckedIn) + checkedOut = append(checkedOut, p.CheckedOut) + } + // Keep stable hash based on rendered arrays. + key := fmt.Sprintf("%v|%v|%v", labels, checkedIn, checkedOut) + if force || key != prev.trendHash { + // Build valid JSON without extra deps. + payload := fmt.Sprintf(`{"labels":[%s],"checkedIn":[%s],"checkedOut":[%s]}`, + joinQuoted(labels), joinInts(checkedIn), joinInts(checkedOut)) + fmt.Fprint(w, formatSSEData("trend", payload)) + prev.trendHash = key + } + } + + pushTAT := func(force bool) { + points, _ := GetPeriodTAT(project.McuID, dateFrom, dateTo, dateFrom != dateTo) + labels := []string{} + values := []float64{} + for _, p := range points { + labels = append(labels, p.Label) + values = append(values, p.Value) + } + key := fmt.Sprintf("%v|%v", labels, values) + if force || key != prev.tatHash { + payload := fmt.Sprintf(`{"labels":[%s],"values":[%s]}`, + joinQuoted(labels), joinFloats(values)) + fmt.Fprint(w, formatSSEData("tat", payload)) + prev.tatHash = key + } + } + // Kirim data langsung saat connect (force=true) pushKPI(true) pushStations(true) pushArrivals(true) + pushTAT(true) + pushTrend(true) flusher.Flush() ticker := time.NewTicker(3 * time.Second) @@ -90,6 +134,8 @@ func SSEStream(w http.ResponseWriter, r *http.Request) { pushKPI(false) pushStations(false) pushArrivals(false) + pushTAT(false) + pushTrend(false) flusher.Flush() case <-r.Context().Done(): // Browser disconnect — goroutine ini langsung berhenti @@ -97,3 +143,38 @@ func SSEStream(w http.ResponseWriter, r *http.Request) { } } } + +func joinQuoted(items []string) string { + if len(items) == 0 { + return "" + } + escaped := make([]string, 0, len(items)) + for _, s := range items { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + escaped = append(escaped, `"`+s+`"`) + } + return strings.Join(escaped, ",") +} + +func joinInts(items []int) string { + if len(items) == 0 { + return "" + } + out := make([]string, 0, len(items)) + for _, v := range items { + out = append(out, fmt.Sprintf("%d", v)) + } + return strings.Join(out, ",") +} + +func joinFloats(items []float64) string { + if len(items) == 0 { + return "" + } + out := make([]string, 0, len(items)) + for _, v := range items { + out = append(out, fmt.Sprintf("%.2f", v)) + } + return strings.Join(out, ",") +} diff --git a/cpone-dashboard/templates/dashboard/index.html b/cpone-dashboard/templates/dashboard/index.html index 0e7e4d2..bdf5664 100644 --- a/cpone-dashboard/templates/dashboard/index.html +++ b/cpone-dashboard/templates/dashboard/index.html @@ -638,10 +638,12 @@ function openPatientsModal() { const palette = ['#3b50a0', '#6677d6', '#10b981', '#f59e0b']; const tatData = {{.TATChart}}; const trendData = {{.TrendChart}}; + let tatChart = null; + let trendChart = null; const tatEl = document.getElementById('tat-chart'); if (tatEl && tatData.labels && tatData.labels.length) { - const tatChart = echarts.init(tatEl); + tatChart = echarts.init(tatEl); tatChart.setOption({ color: palette, tooltip: { @@ -668,7 +670,7 @@ function openPatientsModal() { const trendEl = document.getElementById('trend-chart'); if (trendEl && trendData.labels && trendData.labels.length) { - const trendChart = echarts.init(trendEl); + trendChart = echarts.init(trendEl); trendChart.setOption({ color: palette, tooltip: { trigger: 'axis' }, @@ -689,6 +691,41 @@ function openPatientsModal() { }); window.addEventListener('resize', () => trendChart.resize()); } + + document.body.addEventListener('htmx:sseMessage', function (evt) { + const eventName = evt.detail && evt.detail.event ? evt.detail.event : ''; + if (eventName !== 'trend' || !trendChart) return; + const raw = evt.detail && typeof evt.detail.data === 'string' ? evt.detail.data : ''; + if (!raw) return; + try { + const next = JSON.parse(raw); + if (!next || !Array.isArray(next.labels) || !Array.isArray(next.checkedIn) || !Array.isArray(next.checkedOut)) { + return; + } + trendChart.setOption({ + xAxis: { data: next.labels }, + series: [ + { name: 'Checked In', data: next.checkedIn }, + { name: 'Checked Out', data: next.checkedOut } + ] + }); + } catch (_) {} + }); + + document.body.addEventListener('htmx:sseMessage', function (evt) { + const eventName = evt.detail && evt.detail.event ? evt.detail.event : ''; + if (eventName !== 'tat' || !tatChart) return; + const raw = evt.detail && typeof evt.detail.data === 'string' ? evt.detail.data : ''; + if (!raw) return; + try { + const next = JSON.parse(raw); + if (!next || !Array.isArray(next.labels) || !Array.isArray(next.values)) return; + tatChart.setOption({ + xAxis: { data: next.labels }, + series: [{ name: 'Avg TAT', data: next.values }] + }); + } catch (_) {} + }); })(); {{end}}