GET /api/v1/screens/status und GET /status akzeptieren jetzt derived_state=online|degraded|offline zum direkten Filtern nach der serverseitig abgeleiteten Diagnoseeinschaetzung. Erlaubte Werte sind online, degraded und offline; unknown ist explizit nicht erlaubt, da derived_state immer auf einen der drei Werte abgebildet wird. Abgrenzung zu server_connectivity: derived_state filtert nach dem zusammengefassten Zustand (stale + connectivity + status), waehrend server_connectivity nur den gemeldeten Connectivity-Wert betrifft. Beide Filter koennen kombiniert werden. Tests: FiltersByDerivedState, RejectsInvalidDerivedState Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
397 lines
11 KiB
Go
397 lines
11 KiB
Go
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()
|
|
|
|
screenIDFilter := strings.ToLower(strings.TrimSpace(query.Get("q")))
|
|
|
|
wantConnectivity := strings.TrimSpace(query.Get("server_connectivity"))
|
|
switch wantConnectivity {
|
|
case "", "online", "degraded", "offline", "unknown":
|
|
// valid
|
|
default:
|
|
return screenStatusOverview{}, errInvalidServerConnectivity
|
|
}
|
|
|
|
wantStale := strings.TrimSpace(query.Get("stale"))
|
|
switch wantStale {
|
|
case "", "true", "false":
|
|
// valid
|
|
default:
|
|
return screenStatusOverview{}, errInvalidStale
|
|
}
|
|
|
|
wantDerivedState := strings.TrimSpace(query.Get("derived_state"))
|
|
switch wantDerivedState {
|
|
case "", "online", "degraded", "offline":
|
|
// valid
|
|
default:
|
|
return screenStatusOverview{}, errInvalidDerivedState
|
|
}
|
|
|
|
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 screenIDFilter != "" && !strings.Contains(strings.ToLower(records[i].ScreenID), screenIDFilter) {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
}
|
|
if wantDerivedState != "" && records[i].DerivedState != wantDerivedState {
|
|
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")
|
|
errInvalidServerConnectivity = errors.New("invalid server_connectivity")
|
|
errInvalidStale = errors.New("invalid stale")
|
|
errInvalidDerivedState = errors.New("invalid derived_state")
|
|
)
|
|
|
|
// overviewQueryErrorCode returns the machine-readable error code for query
|
|
// validation errors. It is used by both the JSON and HTML error paths.
|
|
func overviewQueryErrorCode(err error) string {
|
|
switch err {
|
|
case errInvalidUpdatedSince:
|
|
return "invalid_updated_since"
|
|
case errInvalidLimit:
|
|
return "invalid_limit"
|
|
case errInvalidServerConnectivity:
|
|
return "invalid_server_connectivity"
|
|
case errInvalidStale:
|
|
return "invalid_stale"
|
|
case errInvalidDerivedState:
|
|
return "invalid_derived_state"
|
|
default:
|
|
return "invalid_query"
|
|
}
|
|
}
|
|
|
|
// overviewQueryErrorMessage returns the human-readable message for query
|
|
// validation errors. It is used by both the JSON and HTML error paths so
|
|
// that the wording stays consistent regardless of response format.
|
|
func overviewQueryErrorMessage(err error) string {
|
|
switch err {
|
|
case errInvalidUpdatedSince:
|
|
return "updated_since ist kein gueltiger RFC3339-Zeitstempel"
|
|
case errInvalidLimit:
|
|
return "limit muss eine positive Ganzzahl sein"
|
|
case errInvalidServerConnectivity:
|
|
return "server_connectivity muss online, offline, degraded oder unknown sein"
|
|
case errInvalidStale:
|
|
return "stale muss true oder false sein"
|
|
case errInvalidDerivedState:
|
|
return "derived_state muss online, degraded oder offline sein"
|
|
default:
|
|
return "ungueltige Query-Parameter"
|
|
}
|
|
}
|
|
|
|
func writeOverviewQueryError(w http.ResponseWriter, err error) {
|
|
writeError(w, http.StatusBadRequest, overviewQueryErrorCode(err), overviewQueryErrorMessage(err), 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
|
|
}
|
|
}
|