Live-update all dashboard charts via SSE
This commit is contained in:
@@ -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, ",")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user