Live-update all dashboard charts via SSE

This commit is contained in:
sas.fajri
2026-04-30 16:35:20 +07:00
parent 428817e23a
commit 42f5bb745f
2 changed files with 120 additions and 2 deletions

View File

@@ -12,6 +12,8 @@ type pollState struct {
kpiHash string kpiHash string
stationsHash string stationsHash string
arrivalsHash string arrivalsHash string
tatHash string
trendHash string
} }
func formatSSE(event, html string) 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) 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 { func renderPartial(name string, data interface{}) string {
t := parse("templates/dashboard/partials/" + name + ".html") t := parse("templates/dashboard/partials/" + name + ".html")
var buf bytes.Buffer 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) // Kirim data langsung saat connect (force=true)
pushKPI(true) pushKPI(true)
pushStations(true) pushStations(true)
pushArrivals(true) pushArrivals(true)
pushTAT(true)
pushTrend(true)
flusher.Flush() flusher.Flush()
ticker := time.NewTicker(3 * time.Second) ticker := time.NewTicker(3 * time.Second)
@@ -90,6 +134,8 @@ func SSEStream(w http.ResponseWriter, r *http.Request) {
pushKPI(false) pushKPI(false)
pushStations(false) pushStations(false)
pushArrivals(false) pushArrivals(false)
pushTAT(false)
pushTrend(false)
flusher.Flush() flusher.Flush()
case <-r.Context().Done(): case <-r.Context().Done():
// Browser disconnect — goroutine ini langsung berhenti // 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, ",")
}

View File

@@ -638,10 +638,12 @@ function openPatientsModal() {
const palette = ['#3b50a0', '#6677d6', '#10b981', '#f59e0b']; const palette = ['#3b50a0', '#6677d6', '#10b981', '#f59e0b'];
const tatData = {{.TATChart}}; const tatData = {{.TATChart}};
const trendData = {{.TrendChart}}; const trendData = {{.TrendChart}};
let tatChart = null;
let trendChart = null;
const tatEl = document.getElementById('tat-chart'); const tatEl = document.getElementById('tat-chart');
if (tatEl && tatData.labels && tatData.labels.length) { if (tatEl && tatData.labels && tatData.labels.length) {
const tatChart = echarts.init(tatEl); tatChart = echarts.init(tatEl);
tatChart.setOption({ tatChart.setOption({
color: palette, color: palette,
tooltip: { tooltip: {
@@ -668,7 +670,7 @@ function openPatientsModal() {
const trendEl = document.getElementById('trend-chart'); const trendEl = document.getElementById('trend-chart');
if (trendEl && trendData.labels && trendData.labels.length) { if (trendEl && trendData.labels && trendData.labels.length) {
const trendChart = echarts.init(trendEl); trendChart = echarts.init(trendEl);
trendChart.setOption({ trendChart.setOption({
color: palette, color: palette,
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
@@ -689,6 +691,41 @@ function openPatientsModal() {
}); });
window.addEventListener('resize', () => trendChart.resize()); 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 (_) {}
});
})(); })();
</script> </script>
{{end}} {{end}}