Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
291 lines
8 KiB
Go
291 lines
8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
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) {
|
|
records := store.List()
|
|
wantConnectivity := strings.TrimSpace(r.URL.Query().Get("server_connectivity"))
|
|
wantStale := strings.TrimSpace(r.URL.Query().Get("stale"))
|
|
updatedSince, err := parseOptionalRFC3339(r.URL.Query().Get("updated_since"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_updated_since", "updated_since ist kein gueltiger RFC3339-Zeitstempel", nil)
|
|
return
|
|
}
|
|
limit, err := parseOptionalPositiveInt(r.URL.Query().Get("limit"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_limit", "limit muss eine positive Ganzzahl sein", nil)
|
|
return
|
|
}
|
|
filtered := make([]playerStatusRecord, 0, len(records))
|
|
summary := map[string]int{
|
|
"total": 0,
|
|
"online": 0,
|
|
"degraded": 0,
|
|
"offline": 0,
|
|
"stale": 0,
|
|
}
|
|
for i := range records {
|
|
records[i].Stale = isStale(records[i], store.Now())
|
|
records[i].DerivedState = deriveState(records[i])
|
|
summary["total"]++
|
|
summary[records[i].DerivedState]++
|
|
if records[i].Stale {
|
|
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
|
|
}
|
|
}
|
|
filtered = append(filtered, records[i])
|
|
}
|
|
|
|
sort.Slice(filtered, func(i, j int) bool {
|
|
leftPriority := derivedStatePriority(filtered[i].DerivedState)
|
|
rightPriority := derivedStatePriority(filtered[j].DerivedState)
|
|
if leftPriority != rightPriority {
|
|
return leftPriority < rightPriority
|
|
}
|
|
|
|
return filtered[i].ScreenID < filtered[j].ScreenID
|
|
})
|
|
|
|
if limit > 0 && len(filtered) > limit {
|
|
filtered = filtered[:limit]
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"summary": summary,
|
|
"screens": filtered,
|
|
})
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|