package httpapi import ( "errors" "net/http" "net/url" "sort" "strconv" "strings" "time" ) type screenStatusSummary struct { Total int `json:"total"` Online int `json:"online"` Degraded int `json:"degraded"` Offline int `json:"offline"` Stale int `json:"stale"` } type screenStatusOverview struct { Summary screenStatusSummary `json:"summary"` Screens []playerStatusRecord `json:"screens"` } var allowedStatuses = map[string]struct{}{ "starting": {}, "running": {}, "stopped": {}, } var allowedServerConnectivity = map[string]struct{}{ "": {}, "unknown": {}, "online": {}, "degraded": {}, "offline": {}, } type playerStatusRequest struct { ScreenID string `json:"screen_id"` Timestamp string `json:"ts"` Status string `json:"status"` ServerConnectivity string `json:"server_connectivity"` ServerURL string `json:"server_url"` MQTTBroker string `json:"mqtt_broker"` HeartbeatEverySeconds int `json:"heartbeat_every_seconds"` StartedAt string `json:"started_at"` LastHeartbeatAt string `json:"last_heartbeat_at"` } func handlePlayerStatus(store playerStatusStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var request playerStatusRequest if err := decodeJSON(r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", "ungueltiger JSON-Body", nil) return } if strings.TrimSpace(request.ScreenID) == "" { writeError(w, http.StatusBadRequest, "screen_id_required", "screen_id ist erforderlich", nil) return } request.ScreenID = strings.TrimSpace(request.ScreenID) if strings.TrimSpace(request.Timestamp) == "" { writeError(w, http.StatusBadRequest, "timestamp_required", "ts ist erforderlich", nil) return } if strings.TrimSpace(request.Status) == "" { writeError(w, http.StatusBadRequest, "status_required", "status ist erforderlich", nil) return } request.Status = strings.TrimSpace(request.Status) if _, ok := allowedStatuses[request.Status]; !ok { writeError(w, http.StatusBadRequest, "invalid_status", "status ist ungueltig", nil) return } request.ServerConnectivity = strings.TrimSpace(request.ServerConnectivity) if _, ok := allowedServerConnectivity[request.ServerConnectivity]; !ok { writeError(w, http.StatusBadRequest, "invalid_server_connectivity", "server_connectivity ist ungueltig", nil) return } if request.HeartbeatEverySeconds <= 0 { writeError(w, http.StatusBadRequest, "invalid_heartbeat_interval", "heartbeat_every_seconds muss positiv sein", nil) return } if err := validateOptionalRFC3339(request.Timestamp); err != nil { writeError(w, http.StatusBadRequest, "invalid_timestamp", "ts ist kein gueltiger RFC3339-Zeitstempel", nil) return } if err := validateOptionalRFC3339(request.StartedAt); err != nil { writeError(w, http.StatusBadRequest, "invalid_started_at", "started_at ist kein gueltiger RFC3339-Zeitstempel", nil) return } if err := validateOptionalRFC3339(request.LastHeartbeatAt); err != nil { writeError(w, http.StatusBadRequest, "invalid_last_heartbeat_at", "last_heartbeat_at ist kein gueltiger RFC3339-Zeitstempel", nil) return } store.Save(playerStatusRecord{ ScreenID: request.ScreenID, Timestamp: request.Timestamp, Status: request.Status, ServerConnectivity: request.ServerConnectivity, ServerURL: request.ServerURL, MQTTBroker: request.MQTTBroker, HeartbeatEverySeconds: request.HeartbeatEverySeconds, StartedAt: request.StartedAt, LastHeartbeatAt: request.LastHeartbeatAt, }) writeJSON(w, http.StatusOK, map[string]string{ "status": "accepted", }) } } func handleGetLatestPlayerStatus(store playerStatusStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { screenID := strings.TrimSpace(r.PathValue("screenId")) if screenID == "" { writeError(w, http.StatusBadRequest, "screen_id_required", "screenId ist erforderlich", nil) return } record, ok := store.Get(screenID) if !ok { writeError(w, http.StatusNotFound, "screen_status_not_found", "Fuer diesen Screen liegt noch kein Status vor", nil) return } record.Stale = isStale(record, store.Now()) record.DerivedState = deriveState(record) writeJSON(w, http.StatusOK, record) } } func handleListLatestPlayerStatuses(store playerStatusStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { overview, err := buildScreenStatusOverview(store, r.URL.Query()) if err != nil { writeOverviewQueryError(w, err) return } writeJSON(w, http.StatusOK, overview) } } func buildScreenStatusOverview(store playerStatusStore, query url.Values) (screenStatusOverview, error) { records := store.List() wantConnectivity := strings.TrimSpace(query.Get("server_connectivity")) wantStale := strings.TrimSpace(query.Get("stale")) updatedSince, err := parseOptionalRFC3339(query.Get("updated_since")) if err != nil { return screenStatusOverview{}, errInvalidUpdatedSince } limit, err := parseOptionalPositiveInt(query.Get("limit")) if err != nil { return screenStatusOverview{}, errInvalidLimit } overview := screenStatusOverview{ Screens: make([]playerStatusRecord, 0, len(records)), } for i := range records { records[i].Stale = isStale(records[i], store.Now()) records[i].DerivedState = deriveState(records[i]) overview.Summary.Total++ switch records[i].DerivedState { case "offline": overview.Summary.Offline++ case "degraded": overview.Summary.Degraded++ default: overview.Summary.Online++ } if records[i].Stale { overview.Summary.Stale++ } if updatedSince != nil && !isUpdatedSince(records[i], *updatedSince) { continue } if wantConnectivity != "" && records[i].ServerConnectivity != wantConnectivity { continue } if wantStale != "" { if wantStale == "true" && !records[i].Stale { continue } if wantStale == "false" && records[i].Stale { continue } } overview.Screens = append(overview.Screens, records[i]) } sort.Slice(overview.Screens, func(i, j int) bool { leftPriority := derivedStatePriority(overview.Screens[i].DerivedState) rightPriority := derivedStatePriority(overview.Screens[j].DerivedState) if leftPriority != rightPriority { return leftPriority < rightPriority } return overview.Screens[i].ScreenID < overview.Screens[j].ScreenID }) if limit > 0 && len(overview.Screens) > limit { overview.Screens = overview.Screens[:limit] } return overview, nil } var ( errInvalidUpdatedSince = errors.New("invalid updated_since") errInvalidLimit = errors.New("invalid limit") ) func writeOverviewQueryError(w http.ResponseWriter, err error) { switch err { case errInvalidUpdatedSince: writeError(w, http.StatusBadRequest, "invalid_updated_since", "updated_since ist kein gueltiger RFC3339-Zeitstempel", nil) case errInvalidLimit: writeError(w, http.StatusBadRequest, "invalid_limit", "limit muss eine positive Ganzzahl sein", nil) default: writeError(w, http.StatusBadRequest, "invalid_query", "ungueltige Query-Parameter", nil) } } func validateOptionalRFC3339(value string) error { if strings.TrimSpace(value) == "" { return nil } _, err := time.Parse(time.RFC3339, value) return err } func parseOptionalRFC3339(value string) (*time.Time, error) { if strings.TrimSpace(value) == "" { return nil, nil } parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) if err != nil { return nil, err } return &parsed, nil } func parseOptionalPositiveInt(value string) (int, error) { if strings.TrimSpace(value) == "" { return 0, nil } parsed, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || parsed <= 0 { return 0, strconv.ErrSyntax } return parsed, nil } func isStale(record playerStatusRecord, now time.Time) bool { if strings.TrimSpace(record.ReceivedAt) == "" { return false } receivedAt, err := time.Parse(time.RFC3339, record.ReceivedAt) if err != nil { return false } threshold := staleThresholdFor(record) return now.Sub(receivedAt) > threshold } func staleThresholdFor(record playerStatusRecord) time.Duration { if record.HeartbeatEverySeconds > 0 { return time.Duration(record.HeartbeatEverySeconds*2) * time.Second } return 2 * time.Minute } func deriveState(record playerStatusRecord) string { if record.Stale || record.ServerConnectivity == "offline" { return "offline" } if record.ServerConnectivity == "degraded" || record.ServerConnectivity == "unknown" || record.Status != "running" { return "degraded" } return "online" } func isUpdatedSince(record playerStatusRecord, threshold time.Time) bool { if strings.TrimSpace(record.ReceivedAt) == "" { return false } receivedAt, err := time.Parse(time.RFC3339, record.ReceivedAt) if err != nil { return false } return !receivedAt.Before(threshold) } func derivedStatePriority(state string) int { switch state { case "offline": return 0 case "degraded": return 1 default: return 2 } }