OPS-02: zentrale-statusseite (backend: poller + aggregierte uebersicht + verlauf)
This commit is contained in:
@@ -0,0 +1,273 @@
|
|||||||
|
// Package statuspage implementiert Core OPS-02: eine zentrale Statusseite,
|
||||||
|
// die den Health-Zustand aller registrierten Module aggregiert und den
|
||||||
|
// Verlauf vergangener Statusaenderungen speichert. Baut auf OPS-01
|
||||||
|
// (internal/health) auf, indem es GENAU die dort etablierten
|
||||||
|
// Readiness-Endpunkte je Modul abfragt — dieses Paket dupliziert keine
|
||||||
|
// Health-Check-Logik, es aggregiert nur deren Ergebnisse ueber die Zeit.
|
||||||
|
package statuspage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status ist der aggregierte Zustand EINES Moduls zu einem Zeitpunkt.
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusUp Status = "up"
|
||||||
|
StatusDown Status = "down"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target ist ein zu ueberwachendes Modul mit seiner Readiness-URL
|
||||||
|
// (OPS-01-Endpunkt, z.B. "http://dms:8080/readyz"). Eigenstaendige
|
||||||
|
// Konfiguration statt Erweiterung von internal/moduleregistry.Module, um
|
||||||
|
// API-02 nicht anzufassen (Kein Umbau angrenzender Bereiche).
|
||||||
|
type Target struct {
|
||||||
|
Name string
|
||||||
|
HealthURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store persistiert Ueberwachungsziele und den Verlauf ihrer
|
||||||
|
// Statusaenderungen.
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterTarget traegt ein zu ueberwachendes Modul ein oder aktualisiert
|
||||||
|
// dessen URL (Akzeptanzkriterium 1: "aller registrierten Module").
|
||||||
|
func (s *Store) RegisterTarget(ctx context.Context, t Target) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO status_targets (name, health_url)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET health_url = $2
|
||||||
|
`, t.Name, t.HealthURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ueberwachungsziel speichern: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListTargets(ctx context.Context) ([]Target, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT name, health_url FROM status_targets ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ueberwachungsziele auflisten: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Target
|
||||||
|
for rows.Next() {
|
||||||
|
var t Target
|
||||||
|
if err := rows.Scan(&t.Name, &t.HealthURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("ueberwachungsziel lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordIfChanged schreibt NUR dann einen neuen Verlaufseintrag, wenn sich
|
||||||
|
// der Status seit dem letzten Eintrag geaendert hat (oder es der erste
|
||||||
|
// Eintrag ist) — der Verlauf zeigt Statusaenderungen (Akzeptanzkriterium 3),
|
||||||
|
// nicht jede einzelne Abfrage.
|
||||||
|
func (s *Store) recordIfChanged(ctx context.Context, name string, status Status) error {
|
||||||
|
var lastStatus string
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT status FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
|
||||||
|
`, name).Scan(&lastStatus)
|
||||||
|
if err != nil && err != pgx.ErrNoRows {
|
||||||
|
return fmt.Errorf("letzten status lesen: %w", err)
|
||||||
|
}
|
||||||
|
if err == nil && lastStatus == string(status) {
|
||||||
|
return nil // keine Aenderung, kein neuer Eintrag
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO status_history (name, status, changed_at) VALUES ($1, $2, now())
|
||||||
|
`, name, string(status)); err != nil {
|
||||||
|
return fmt.Errorf("statuseintrag schreiben: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleStatus ist der aktuelle Zustand EINES Moduls fuer die Uebersicht.
|
||||||
|
type ModuleStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status Status `json:"status"`
|
||||||
|
LastChecked time.Time `json:"last_checked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview liefert den aktuellen (letzten bekannten) Status jedes
|
||||||
|
// registrierten Ziels (Akzeptanzkriterium 1). Ziele ohne jemals erfolgte
|
||||||
|
// Pruefung erscheinen mit Status "down" — ein Modul, ueber das nichts
|
||||||
|
// bekannt ist, gilt als nicht verfuegbar (Fail-Safe-Default), nicht als
|
||||||
|
// stillschweigend "ok".
|
||||||
|
func (s *Store) Overview(ctx context.Context) ([]ModuleStatus, error) {
|
||||||
|
targets, err := s.ListTargets(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]ModuleStatus, 0, len(targets))
|
||||||
|
for _, t := range targets {
|
||||||
|
var status string
|
||||||
|
var changedAt time.Time
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
|
||||||
|
`, t.Name).Scan(&status, &changedAt)
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
out = append(out, ModuleStatus{Name: t.Name, Status: StatusDown})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("aktuellen status lesen (%s): %w", t.Name, err)
|
||||||
|
}
|
||||||
|
out = append(out, ModuleStatus{Name: t.Name, Status: Status(status), LastChecked: changedAt})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryEntry ist EIN Verlaufseintrag (Akzeptanzkriterium 3).
|
||||||
|
type HistoryEntry struct {
|
||||||
|
Status Status `json:"status"`
|
||||||
|
ChangedAt time.Time `json:"changed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) History(ctx context.Context, name string) ([]HistoryEntry, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC
|
||||||
|
`, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("verlauf abfragen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []HistoryEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e HistoryEntry
|
||||||
|
var status string
|
||||||
|
if err := rows.Scan(&status, &e.ChangedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("verlaufseintrag lesen: %w", err)
|
||||||
|
}
|
||||||
|
e.Status = Status(status)
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPChecker fragt die Readiness-URL eines Moduls ab (OPS-01-Endpunkt) und
|
||||||
|
// liefert StatusUp NUR bei HTTP 200 — jeder andere Statuscode ODER ein
|
||||||
|
// Netzwerkfehler/Timeout gilt als StatusDown. Ein einzelnes nicht
|
||||||
|
// erreichbares Modul liefert einen FEHLERFREIEN StatusDown-Wert statt eines
|
||||||
|
// Go-Errors, damit Poller.Run ein fehlerhaftes Modul niemals mit einem
|
||||||
|
// anderen verwechseln oder den gesamten Zyklus abbrechen kann
|
||||||
|
// (Akzeptanzkriterium 2).
|
||||||
|
type HTTPChecker struct {
|
||||||
|
Client *http.Client
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPChecker(timeout time.Duration) *HTTPChecker {
|
||||||
|
return &HTTPChecker{Client: &http.Client{}, Timeout: timeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPChecker) Check(ctx context.Context, url string) Status {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return StatusDown
|
||||||
|
}
|
||||||
|
resp, err := c.Client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return StatusDown
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return StatusUp
|
||||||
|
}
|
||||||
|
return StatusDown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poller fragt periodisch alle Ziele ab und schreibt Statusaenderungen fort.
|
||||||
|
type Poller struct {
|
||||||
|
store *Store
|
||||||
|
checker *HTTPChecker
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPoller(store *Store, checker *HTTPChecker) *Poller {
|
||||||
|
return &Poller{store: store, checker: checker}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollOnce prueft ALLE Ziele in einem Durchlauf. Ein fehlschlagendes Ziel
|
||||||
|
// (Netzwerkfehler, Timeout, Nicht-200) wird als StatusDown vermerkt und
|
||||||
|
// haelt die Pruefung der UEBRIGEN Ziele nicht auf — die Schleife laeuft
|
||||||
|
// sequenziell weiter, kein Ziel kann ein anderes blockieren
|
||||||
|
// (Akzeptanzkriterium 2 / Pruefung 2).
|
||||||
|
func (p *Poller) PollOnce(ctx context.Context) error {
|
||||||
|
targets, err := p.store.ListTargets(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, t := range targets {
|
||||||
|
status := p.checker.Check(ctx, t.HealthURL)
|
||||||
|
if err := p.store.recordIfChanged(ctx, t.Name, status); err != nil {
|
||||||
|
// Ein Schreibfehler fuer EIN Ziel darf die Pruefung der anderen
|
||||||
|
// nicht verhindern — dieselbe Fail-Isolation wie bei einem
|
||||||
|
// unerreichbaren Modul.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run ruft PollOnce in festen Abstaenden auf, bis ctx beendet wird —
|
||||||
|
// dieselbe Konvention wie internal/tenant.Lifecycle.RunSweeper.
|
||||||
|
func (p *Poller) Run(ctx context.Context, interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
_ = p.PollOnce(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP-Bindung fuer die Oberflaeche ---
|
||||||
|
|
||||||
|
func (s *Store) OverviewHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
overview, err := s.Overview(r.Context())
|
||||||
|
writeJSONResult(w, overview, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) HistoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
history, err := s.History(r.Context(), name)
|
||||||
|
writeJSONResult(w, history, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONResult(w http.ResponseWriter, body any, err error) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package statuspage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (*Store, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE TABLE IF NOT EXISTS status_targets (name TEXT PRIMARY KEY, health_url TEXT NOT NULL);
|
||||||
|
CREATE TABLE IF NOT EXISTS status_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, status TEXT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return NewStore(pool), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueName(prefix string) string {
|
||||||
|
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: Uebersicht zeigt den Status ALLER registrierten
|
||||||
|
// Module.
|
||||||
|
func TestOverview_ShowsAllRegisteredModules(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||||
|
defer up.Close()
|
||||||
|
|
||||||
|
nameA, nameB := uniqueName("mod-a"), uniqueName("mod-b")
|
||||||
|
if err := store.RegisterTarget(ctx, Target{Name: nameA, HealthURL: up.URL}); err != nil {
|
||||||
|
t.Fatalf("target a: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.RegisterTarget(ctx, Target{Name: nameB, HealthURL: up.URL}); err != nil {
|
||||||
|
t.Fatalf("target b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
poller := NewPoller(store, NewHTTPChecker(time.Second))
|
||||||
|
if err := poller.PollOnce(ctx); err != nil {
|
||||||
|
t.Fatalf("poll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
overview, err := store.Overview(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("overview: %v", err)
|
||||||
|
}
|
||||||
|
found := map[string]bool{}
|
||||||
|
for _, m := range overview {
|
||||||
|
found[m.Name] = true
|
||||||
|
if m.Status != StatusUp {
|
||||||
|
t.Fatalf("modul %s: status = %s, want up", m.Name, m.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found[nameA] || !found[nameB] {
|
||||||
|
t.Fatalf("erwartet beide module in der uebersicht, habe: %+v", overview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1/2: ein simulierter Ausfall eines Moduls
|
||||||
|
// wird sichtbar, das ANDERE Modul bleibt unbeeinflusst und die Pruefung
|
||||||
|
// beider laeuft trotzdem in einem Durchlauf durch (kein Totalausfall).
|
||||||
|
func TestPollOnce_IsolatesFailingModuleFromOthers(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||||
|
defer up.Close()
|
||||||
|
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }))
|
||||||
|
down.Close() // Server sofort schliessen -> Verbindung tatsaechlich unerreichbar (simulierter Ausfall)
|
||||||
|
|
||||||
|
healthyName, downName := uniqueName("healthy"), uniqueName("down")
|
||||||
|
if err := store.RegisterTarget(ctx, Target{Name: healthyName, HealthURL: up.URL}); err != nil {
|
||||||
|
t.Fatalf("target healthy: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.RegisterTarget(ctx, Target{Name: downName, HealthURL: down.URL}); err != nil {
|
||||||
|
t.Fatalf("target down: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
poller := NewPoller(store, NewHTTPChecker(500*time.Millisecond))
|
||||||
|
if err := poller.PollOnce(ctx); err != nil {
|
||||||
|
t.Fatalf("poll haette trotz einem ausgefallenen modul erfolgreich durchlaufen sollen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
overview, err := store.Overview(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("overview: %v", err)
|
||||||
|
}
|
||||||
|
statusByName := map[string]Status{}
|
||||||
|
for _, m := range overview {
|
||||||
|
statusByName[m.Name] = m.Status
|
||||||
|
}
|
||||||
|
if statusByName[healthyName] != StatusUp {
|
||||||
|
t.Fatalf("healthy modul: status = %s, want up", statusByName[healthyName])
|
||||||
|
}
|
||||||
|
if statusByName[downName] != StatusDown {
|
||||||
|
t.Fatalf("ausgefallenes modul: status = %s, want down", statusByName[downName])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: Verlauf vergangener Statusaenderungen ist einsehbar.
|
||||||
|
func TestHistory_RecordsStatusTransitions(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
name := uniqueName("flaky")
|
||||||
|
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||||
|
defer up.Close()
|
||||||
|
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
|
||||||
|
t.Fatalf("target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
poller := NewPoller(store, NewHTTPChecker(time.Second))
|
||||||
|
if err := poller.PollOnce(ctx); err != nil {
|
||||||
|
t.Fatalf("poll 1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ziel wird "abgeschaltet" (URL zeigt jetzt auf einen bereits
|
||||||
|
// geschlossenen Server) -> naechster Poll erkennt den Statuswechsel.
|
||||||
|
closedURL := up.URL
|
||||||
|
up.Close()
|
||||||
|
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: closedURL}); err != nil {
|
||||||
|
t.Fatalf("target aktualisieren: %v", err)
|
||||||
|
}
|
||||||
|
if err := poller.PollOnce(ctx); err != nil {
|
||||||
|
t.Fatalf("poll 2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.History(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("erwartet genau 2 statuswechsel (up -> down), habe %d: %+v", len(history), history)
|
||||||
|
}
|
||||||
|
if history[0].Status != StatusDown || history[1].Status != StatusUp {
|
||||||
|
t.Fatalf("erwartet verlauf [down, up] (neueste zuerst), habe: %+v", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wiederholte Polls OHNE Statuswechsel duerfen den Verlauf nicht mit
|
||||||
|
// identischen Eintraegen fluten (siehe recordIfChanged).
|
||||||
|
func TestHistory_DoesNotDuplicateUnchangedStatus(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
name := uniqueName("stable")
|
||||||
|
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||||
|
defer up.Close()
|
||||||
|
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
|
||||||
|
t.Fatalf("target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
poller := NewPoller(store, NewHTTPChecker(time.Second))
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := poller.PollOnce(ctx); err != nil {
|
||||||
|
t.Fatalf("poll %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history, err := store.History(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history: %v", err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 {
|
||||||
|
t.Fatalf("erwartet genau 1 eintrag trotz 3 unveraenderter polls, habe %d", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE status_history;
|
||||||
|
DROP TABLE status_targets;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Zentrale Statusseite (OPS-02, siehe core-kanban/tickets/OPS-02.md) —
|
||||||
|
-- ueberwachte Module und der Verlauf ihrer Statusaenderungen.
|
||||||
|
CREATE TABLE status_targets (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
health_url TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE status_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX status_history_name_idx ON status_history (name, changed_at DESC);
|
||||||
Reference in New Issue
Block a user