181 lines
4.8 KiB
Go
181 lines
4.8 KiB
Go
package dashboard
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type pollState struct {
|
|
kpiHash string
|
|
stationsHash string
|
|
arrivalsHash string
|
|
tatHash string
|
|
trendHash string
|
|
}
|
|
|
|
func formatSSE(event, html string) string {
|
|
data := strings.ReplaceAll(strings.TrimSpace(html), "\n", " ")
|
|
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
|
|
t.ExecuteTemplate(&buf, name, data)
|
|
return buf.String()
|
|
}
|
|
|
|
func SSEStream(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no") // penting untuk nginx reverse proxy
|
|
|
|
project, _ := GetProject(activeMcuID(r))
|
|
if project.McuID == 0 {
|
|
return
|
|
}
|
|
|
|
availableDates, _ := GetCheckinDates(project.McuID)
|
|
mode, dateFrom, dateTo := activeDateRange(r, project, availableDates)
|
|
isLive := mode == "daily" && dateFrom == time.Now().Format("2006-01-02")
|
|
var prev pollState
|
|
|
|
pushKPI := func(force bool) {
|
|
kpi, _ := GetKPI(project.McuID, dateFrom, dateTo)
|
|
key := fmt.Sprintf("%d|%d|%d", kpi.TotalStaff, kpi.CheckedIn, kpi.CheckedOut)
|
|
if force || key != prev.kpiHash {
|
|
fmt.Fprint(w, formatSSE("kpi", renderPartial("kpi", kpi)))
|
|
prev.kpiHash = key
|
|
}
|
|
}
|
|
|
|
pushStations := func(force bool) {
|
|
rows, _ := GetStations(project.McuID, dateFrom, dateTo)
|
|
key := fmt.Sprintf("%v", rows)
|
|
if force || key != prev.stationsHash {
|
|
fmt.Fprint(w, formatSSE("stations", renderPartial("stations", StationsPartial{Rows: rows, IsLive: isLive})))
|
|
prev.stationsHash = key
|
|
}
|
|
}
|
|
|
|
pushArrivals := func(force bool) {
|
|
rows, _ := GetArrivals(project.McuID, dateFrom, dateTo, 8)
|
|
key := fmt.Sprintf("%v", rows)
|
|
if force || key != prev.arrivalsHash {
|
|
fmt.Fprint(w, formatSSE("arrivals", renderPartial("arrivals", ArrivalsPartial{Rows: rows, IsLive: isLive})))
|
|
prev.arrivalsHash = key
|
|
}
|
|
}
|
|
|
|
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)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
pushKPI(false)
|
|
pushStations(false)
|
|
pushArrivals(false)
|
|
pushTAT(false)
|
|
pushTrend(false)
|
|
flusher.Flush()
|
|
case <-r.Context().Done():
|
|
// Browser disconnect — goroutine ini langsung berhenti
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
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, ",")
|
|
}
|