Erweitere Statusuebersicht um Zeit- und Mengengrenzen
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
cbe0c40f45
commit
5a109f95cb
3 changed files with 124 additions and 0 deletions
|
|
@ -77,6 +77,13 @@ Zusätzlich zur Write-Route gibt es in dieser Stufe:
|
||||||
`GET /api/v1/screens/status` liefert eine kleine Uebersicht aller bisher berichtenden Screens mit ihrem jeweils letzten bekannten Datensatz.
|
`GET /api/v1/screens/status` liefert eine kleine Uebersicht aller bisher berichtenden Screens mit ihrem jeweils letzten bekannten Datensatz.
|
||||||
Die Rueckgabe wird aktuell fuer Diagnosezwecke priorisiert sortiert: zuerst `offline`, dann `degraded`, dann `online`, innerhalb derselben Gruppe nach `screen_id`.
|
Die Rueckgabe wird aktuell fuer Diagnosezwecke priorisiert sortiert: zuerst `offline`, dann `degraded`, dann `online`, innerhalb derselben Gruppe nach `screen_id`.
|
||||||
|
|
||||||
|
Aktuell unterstuetzte Query-Parameter fuer die Uebersicht:
|
||||||
|
|
||||||
|
- `server_connectivity=<value>` zum Filtern nach Reachability-Zustand
|
||||||
|
- `stale=true|false` zum Filtern nach serverseitiger Veraltet-Einschaetzung
|
||||||
|
- `updated_since=<RFC3339>` zum Filtern nach `received_at`
|
||||||
|
- `limit=<positive integer>` zum Begrenzen der Anzahl zurueckgelieferter Screens
|
||||||
|
|
||||||
`GET /api/v1/screens/{screenId}/status` liefert den zuletzt akzeptierten Status fuer einen einzelnen Screen zurueck.
|
`GET /api/v1/screens/{screenId}/status` liefert den zuletzt akzeptierten Status fuer einen einzelnen Screen zurueck.
|
||||||
Wenn fuer den Screen noch kein Status vorliegt, liefert das Backend `404` mit dem gemeinsamen Fehlerumschlag.
|
Wenn fuer den Screen noch kein Status vorliegt, liefert das Backend `404` mit dem gemeinsamen Fehlerumschlag.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package httpapi
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -132,10 +133,23 @@ func handleListLatestPlayerStatuses(store playerStatusStore) http.HandlerFunc {
|
||||||
records := store.List()
|
records := store.List()
|
||||||
wantConnectivity := strings.TrimSpace(r.URL.Query().Get("server_connectivity"))
|
wantConnectivity := strings.TrimSpace(r.URL.Query().Get("server_connectivity"))
|
||||||
wantStale := strings.TrimSpace(r.URL.Query().Get("stale"))
|
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))
|
filtered := make([]playerStatusRecord, 0, len(records))
|
||||||
for i := range records {
|
for i := range records {
|
||||||
records[i].Stale = isStale(records[i], store.Now())
|
records[i].Stale = isStale(records[i], store.Now())
|
||||||
records[i].DerivedState = deriveState(records[i])
|
records[i].DerivedState = deriveState(records[i])
|
||||||
|
if updatedSince != nil && !isUpdatedSince(records[i], *updatedSince) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if wantConnectivity != "" && records[i].ServerConnectivity != wantConnectivity {
|
if wantConnectivity != "" && records[i].ServerConnectivity != wantConnectivity {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +174,10 @@ func handleListLatestPlayerStatuses(store playerStatusStore) http.HandlerFunc {
|
||||||
return filtered[i].ScreenID < filtered[j].ScreenID
|
return filtered[i].ScreenID < filtered[j].ScreenID
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if limit > 0 && len(filtered) > limit {
|
||||||
|
filtered = filtered[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"screens": filtered,
|
"screens": filtered,
|
||||||
})
|
})
|
||||||
|
|
@ -175,6 +193,32 @@ func validateOptionalRFC3339(value string) error {
|
||||||
return err
|
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 {
|
func isStale(record playerStatusRecord, now time.Time) bool {
|
||||||
if strings.TrimSpace(record.ReceivedAt) == "" {
|
if strings.TrimSpace(record.ReceivedAt) == "" {
|
||||||
return false
|
return false
|
||||||
|
|
@ -209,6 +253,19 @@ func deriveState(record playerStatusRecord) string {
|
||||||
return "online"
|
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 {
|
func derivedStatePriority(state string) int {
|
||||||
switch state {
|
switch state {
|
||||||
case "offline":
|
case "offline":
|
||||||
|
|
|
||||||
|
|
@ -511,3 +511,63 @@ func TestHandleListLatestPlayerStatusesFiltersByConnectivityAndStale(t *testing.
|
||||||
t.Fatalf("response.Screens[0].ScreenID = %q, want %q", got, want)
|
t.Fatalf("response.Screens[0].ScreenID = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleListLatestPlayerStatusesAppliesLimit(t *testing.T) {
|
||||||
|
store := newInMemoryPlayerStatusStore()
|
||||||
|
store.now = func() time.Time {
|
||||||
|
return time.Date(2026, 3, 22, 16, 10, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
store.Save(playerStatusRecord{ScreenID: "screen-online", Timestamp: "2026-03-22T16:09:30Z", Status: "running", ServerConnectivity: "online", ReceivedAt: "2026-03-22T16:09:30Z", HeartbeatEverySeconds: 30})
|
||||||
|
store.Save(playerStatusRecord{ScreenID: "screen-offline", Timestamp: "2026-03-22T16:00:00Z", Status: "running", ServerConnectivity: "offline", ReceivedAt: "2026-03-22T16:00:00Z", HeartbeatEverySeconds: 30})
|
||||||
|
store.Save(playerStatusRecord{ScreenID: "screen-degraded", Timestamp: "2026-03-22T16:09:00Z", Status: "running", ServerConnectivity: "degraded", ReceivedAt: "2026-03-22T16:09:00Z", HeartbeatEverySeconds: 30})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/status?limit=2", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handleListLatestPlayerStatuses(store)(w, req)
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Screens []playerStatusRecord `json:"screens"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := len(response.Screens), 2; got != want {
|
||||||
|
t.Fatalf("len(response.Screens) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := response.Screens[0].ScreenID, "screen-offline"; got != want {
|
||||||
|
t.Fatalf("response.Screens[0].ScreenID = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := response.Screens[1].ScreenID, "screen-degraded"; got != want {
|
||||||
|
t.Fatalf("response.Screens[1].ScreenID = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListLatestPlayerStatusesFiltersByUpdatedSince(t *testing.T) {
|
||||||
|
store := newInMemoryPlayerStatusStore()
|
||||||
|
store.Save(playerStatusRecord{ScreenID: "screen-old", Timestamp: "2026-03-22T16:00:00Z", Status: "running", ServerConnectivity: "online", ReceivedAt: "2026-03-22T16:00:00Z", HeartbeatEverySeconds: 30})
|
||||||
|
store.Save(playerStatusRecord{ScreenID: "screen-new", Timestamp: "2026-03-22T16:06:00Z", Status: "running", ServerConnectivity: "online", ReceivedAt: "2026-03-22T16:06:00Z", HeartbeatEverySeconds: 30})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/status?updated_since=2026-03-22T16:05:00Z", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handleListLatestPlayerStatuses(store)(w, req)
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Screens []playerStatusRecord `json:"screens"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := len(response.Screens), 1; got != want {
|
||||||
|
t.Fatalf("len(response.Screens) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := response.Screens[0].ScreenID, "screen-new"; got != want {
|
||||||
|
t.Fatalf("response.Screens[0].ScreenID = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue