mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
f48c806718
Missing block I/O and container image sizes could become false evidence for diagnosis. Preserve per-direction counter presence and measured zero through collection, resource conversion and browser rendering. Separate new observed history from ambiguous retained disk series without deleting old rows or changing public metric names. Keep partial host rates distinct and persist a newly enabled Disk I/O column across the first preference reload.
319 lines
11 KiB
Go
319 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestRouter_HandleState_MockIsolation(t *testing.T) {
|
|
// Enable mock mode to control GetState output
|
|
setMockModeForTest(t, true)
|
|
|
|
dataPath := t.TempDir()
|
|
hp, _ := auth.HashPassword("password")
|
|
cfg := &config.Config{
|
|
DataPath: dataPath,
|
|
MultiTenantEnabled: true,
|
|
AuthUser: "admin",
|
|
AuthPass: hp,
|
|
}
|
|
|
|
// Initialize persistent stores to avoid permission issues with /etc/pulse
|
|
InitSessionStore(dataPath)
|
|
InitCSRFStore(dataPath)
|
|
|
|
// Create a router with a real monitor
|
|
// Since monitor.ReadSnapshot() checks IsMockEnabled(), it will return mock data.
|
|
monitor, _ := monitoring.New(cfg)
|
|
router := &Router{
|
|
config: cfg,
|
|
monitor: monitor,
|
|
persistence: config.NewConfigPersistence(dataPath),
|
|
}
|
|
|
|
t.Run("basic state access", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/state", nil)
|
|
// Set credentials for CheckAuth (Basic Auth)
|
|
req.SetBasicAuth("admin", "password")
|
|
|
|
w := httptest.NewRecorder()
|
|
router.handleState(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var payload map[string]any
|
|
err := json.Unmarshal(w.Body.Bytes(), &payload)
|
|
assert.NoError(t, err)
|
|
|
|
// Verify v6 contract: legacy per-type arrays are omitted from /api/state.
|
|
for _, key := range []string{"nodes", "vms", "containers", "dockerHosts", "hosts", "storage"} {
|
|
if _, ok := payload[key]; ok {
|
|
t.Fatalf("expected %q to be omitted from /api/state payload", key)
|
|
}
|
|
}
|
|
|
|
var state models.StateFrontend
|
|
err = json.Unmarshal(w.Body.Bytes(), &state)
|
|
assert.NoError(t, err)
|
|
assert.Greater(t, state.LastUpdate, int64(0))
|
|
assert.NotNil(t, state.RecentlyResolved)
|
|
assert.NotNil(t, state.Metrics)
|
|
assert.NotNil(t, state.ConnectionHealth)
|
|
|
|
metricsPayload, ok := payload["metrics"].([]any)
|
|
if !ok {
|
|
t.Fatalf("expected metrics payload to be an array, got %T", payload["metrics"])
|
|
}
|
|
if metricsPayload == nil {
|
|
t.Fatal("expected metrics payload array to be non-nil")
|
|
}
|
|
|
|
if _, ok := payload["performance"].(map[string]any); !ok {
|
|
t.Fatalf("expected performance payload to be an object, got %T", payload["performance"])
|
|
}
|
|
if _, ok := payload["stats"].(map[string]any); !ok {
|
|
t.Fatalf("expected stats payload to be an object, got %T", payload["stats"])
|
|
}
|
|
if _, ok := payload["resources"].([]any); !ok {
|
|
t.Fatalf("expected resources payload to be an array, got %T", payload["resources"])
|
|
}
|
|
if _, ok := payload["connectedInfrastructure"].([]any); !ok {
|
|
t.Fatalf("expected connectedInfrastructure payload to be an array, got %T", payload["connectedInfrastructure"])
|
|
}
|
|
})
|
|
|
|
t.Run("invalid method", func(t *testing.T) {
|
|
req := httptest.NewRequest("POST", "/api/state", nil)
|
|
w := httptest.NewRecorder()
|
|
router.handleState(w, req)
|
|
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
|
|
})
|
|
|
|
t.Run("unauthorized", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/api/state", nil)
|
|
w := httptest.NewRecorder()
|
|
router.handleState(w, req)
|
|
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
})
|
|
}
|
|
|
|
func TestRouterHandleStatePreservesNumericIdleRatesAndOmitsUnknownRates(t *testing.T) {
|
|
dataPath := t.TempDir()
|
|
hp, err := auth.HashPassword("password")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
cfg := &config.Config{
|
|
DataPath: dataPath,
|
|
AuthUser: "admin",
|
|
AuthPass: hp,
|
|
}
|
|
InitSessionStore(dataPath)
|
|
InitCSRFStore(dataPath)
|
|
|
|
monitor, state, _ := newTestMonitor(t)
|
|
state.VMs = []models.VM{
|
|
{
|
|
ID: "site-a:pve-a:100",
|
|
VMID: 100,
|
|
Name: "idle-vm",
|
|
Node: "pve-a",
|
|
Instance: "site-a",
|
|
Status: "running",
|
|
IORateValidity: models.IORateValidity{
|
|
Explicit: true,
|
|
DiskRead: true,
|
|
DiskWrite: true,
|
|
NetworkIn: true,
|
|
NetworkOut: true,
|
|
},
|
|
},
|
|
{
|
|
ID: "site-a:pve-a:101",
|
|
VMID: 101,
|
|
Name: "unknown-vm",
|
|
Node: "pve-a",
|
|
Instance: "site-a",
|
|
Status: "running",
|
|
IORateValidity: models.IORateValidity{
|
|
Explicit: true,
|
|
},
|
|
},
|
|
}
|
|
syncTestResourceStore(t, monitor, state)
|
|
|
|
router := &Router{config: cfg, monitor: monitor}
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
|
req.SetBasicAuth("admin", "password")
|
|
router.handleState(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("/api/state status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var payload struct {
|
|
Resources []models.ResourceFrontend `json:"resources"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode /api/state: %v", err)
|
|
}
|
|
|
|
byName := make(map[string]models.ResourceFrontend, len(payload.Resources))
|
|
for _, resource := range payload.Resources {
|
|
byName[resource.Name] = resource
|
|
}
|
|
idle := byName["idle-vm"]
|
|
if idle.DiskIO == nil || idle.DiskIO.ReadRate == nil || idle.DiskIO.WriteRate == nil || *idle.DiskIO.ReadRate != 0 || *idle.DiskIO.WriteRate != 0 {
|
|
t.Fatalf("valid idle rates were not emitted as numeric zero: %+v", idle.DiskIO)
|
|
}
|
|
if unknown := byName["unknown-vm"]; unknown.DiskIO != nil {
|
|
t.Fatalf("unknown rates projected a disk I/O object: %+v", unknown.DiskIO)
|
|
}
|
|
if strings.Contains(rec.Body.String(), `"readRate":null`) || strings.Contains(rec.Body.String(), `"writeRate":null`) {
|
|
t.Fatalf("/api/state emitted unstable null rates: %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRouter_HandleStateSummary(t *testing.T) {
|
|
dataPath := t.TempDir()
|
|
hp, _ := auth.HashPassword("password")
|
|
cfg := &config.Config{
|
|
DataPath: dataPath,
|
|
AuthUser: "admin",
|
|
AuthPass: hp,
|
|
}
|
|
InitSessionStore(dataPath)
|
|
InitCSRFStore(dataPath)
|
|
monitor, state, _ := newTestMonitor(t)
|
|
lastUpdate := time.Date(2026, 5, 24, 10, 11, 12, 0, time.UTC)
|
|
state.Nodes = []models.Node{{ID: "node-1"}, {ID: "node-2"}}
|
|
state.VMs = []models.VM{{ID: "vm-1"}}
|
|
state.Containers = []models.Container{{ID: "ct-1"}, {ID: "ct-2"}, {ID: "ct-3"}}
|
|
state.ActiveAlerts = []models.Alert{{ID: "alert-1"}}
|
|
state.DockerHosts = []models.DockerHost{
|
|
{
|
|
ID: "docker-1",
|
|
Hostname: "docker.local",
|
|
DisplayName: "Docker Host",
|
|
UptimeSeconds: 3600,
|
|
CPUUsage: 12.5,
|
|
Containers: []models.DockerContainer{
|
|
{ID: "container-1"},
|
|
{ID: "container-2"},
|
|
},
|
|
},
|
|
}
|
|
state.LastUpdate = lastUpdate
|
|
syncTestResourceStore(t, monitor, state)
|
|
|
|
router := &Router{config: cfg, monitor: monitor}
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state/summary", nil)
|
|
req.SetBasicAuth("admin", "password")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.handleStateSummary(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var summary stateSummaryResponse
|
|
err := json.Unmarshal(rec.Body.Bytes(), &summary)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 1, summary.ActiveAlerts)
|
|
assert.Equal(t, 2, summary.Nodes)
|
|
assert.Equal(t, 1, summary.VMs)
|
|
assert.Equal(t, 3, summary.Containers)
|
|
assert.Equal(t, lastUpdate, summary.LastUpdate)
|
|
if assert.Len(t, summary.DockerHosts, 1) {
|
|
assert.Equal(t, "Docker Host", summary.DockerHosts[0].Name)
|
|
assert.Equal(t, 2, summary.DockerHosts[0].Containers)
|
|
assert.Equal(t, int64(3600), summary.DockerHosts[0].UptimeSeconds)
|
|
assert.Equal(t, 12.5, summary.DockerHosts[0].CPUUsagePercent)
|
|
}
|
|
|
|
if rec.Body.Len() >= 5*1024 {
|
|
t.Fatalf("expected /api/state/summary body under 5 KiB, got %d bytes", rec.Body.Len())
|
|
}
|
|
|
|
var payload map[string]any
|
|
err = json.Unmarshal(rec.Body.Bytes(), &payload)
|
|
assert.NoError(t, err)
|
|
for _, key := range []string{"resources", "metrics", "performance", "stats", "connectedInfrastructure", "pveTagStyles", "pveTagColors"} {
|
|
if _, ok := payload[key]; ok {
|
|
t.Fatalf("expected %q to be omitted from /api/state/summary payload", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildStateSummaryCountsSnapshotResources(t *testing.T) {
|
|
lastUpdate := time.Date(2026, 5, 24, 10, 11, 12, 0, time.UTC)
|
|
snapshot := models.StateSnapshot{
|
|
ActiveAlerts: []models.Alert{{ID: "alert-1"}},
|
|
Nodes: []models.Node{{ID: "node-1"}, {ID: "node-2"}},
|
|
VMs: []models.VM{{ID: "vm-1"}},
|
|
Containers: []models.Container{{ID: "ct-1"}, {ID: "ct-2"}, {ID: "ct-3"}},
|
|
DockerHosts: []models.DockerHost{{
|
|
ID: "docker-1",
|
|
Hostname: "docker.local",
|
|
DisplayName: "Docker Host",
|
|
CustomDisplayName: "Custom Docker Host",
|
|
UptimeSeconds: 3600,
|
|
CPUUsage: 12.5,
|
|
Containers: []models.DockerContainer{{ID: "container-1"}, {ID: "container-2"}},
|
|
}},
|
|
LastUpdate: lastUpdate,
|
|
}
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
registry.IngestSnapshot(snapshot)
|
|
|
|
summary := buildStateSummary(registry, registry.List(), snapshot.ActiveAlerts, snapshot.LastUpdate)
|
|
|
|
assert.Equal(t, 1, summary.ActiveAlerts)
|
|
assert.Equal(t, 2, summary.Nodes)
|
|
assert.Equal(t, 1, summary.VMs)
|
|
assert.Equal(t, 3, summary.Containers)
|
|
assert.Equal(t, lastUpdate, summary.LastUpdate)
|
|
if assert.Len(t, summary.DockerHosts, 1) {
|
|
assert.Equal(t, "Custom Docker Host", summary.DockerHosts[0].Name)
|
|
assert.Equal(t, 2, summary.DockerHosts[0].Containers)
|
|
assert.Equal(t, int64(3600), summary.DockerHosts[0].UptimeSeconds)
|
|
assert.Equal(t, 12.5, summary.DockerHosts[0].CPUUsagePercent)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeResourceHealthKeepsStaleVisibleAndCapsAttention(t *testing.T) {
|
|
resources := make([]unifiedresources.Resource, 0, 54)
|
|
resources = append(resources,
|
|
unifiedresources.Resource{ID: "critical", Name: "Critical", Type: unifiedresources.ResourceTypeAgent, Health: &unifiedresources.ResourceHealth{Verdict: unifiedresources.HealthCritical, Reasons: []unifiedresources.ResourceHealthReason{{Code: "offline"}}}},
|
|
unifiedresources.Resource{ID: "stale", Name: "Stale", Type: unifiedresources.ResourceTypeAgent, Health: &unifiedresources.ResourceHealth{Verdict: unifiedresources.HealthStale, Reasons: []unifiedresources.ResourceHealthReason{{Code: "telemetry_stale"}}}},
|
|
unifiedresources.Resource{ID: "healthy", Name: "Healthy", Type: unifiedresources.ResourceTypeAgent, Health: &unifiedresources.ResourceHealth{Verdict: unifiedresources.HealthOK, Reasons: []unifiedresources.ResourceHealthReason{}}},
|
|
unifiedresources.Resource{ID: "off", Name: "Off", Type: unifiedresources.ResourceTypeVM, Health: &unifiedresources.ResourceHealth{Verdict: unifiedresources.HealthOff, Reasons: []unifiedresources.ResourceHealthReason{{Code: "powered_off"}}}},
|
|
)
|
|
for i := 0; i < 50; i++ {
|
|
resources = append(resources, unifiedresources.Resource{
|
|
ID: fmt.Sprintf("unknown-%02d", i), Name: fmt.Sprintf("Unknown %02d", i), Type: unifiedresources.ResourceTypeAgent,
|
|
Health: &unifiedresources.ResourceHealth{Verdict: unifiedresources.HealthUnknown, Reasons: []unifiedresources.ResourceHealthReason{{Code: "telemetry_missing"}}},
|
|
})
|
|
}
|
|
|
|
counts, attention := summarizeResourceHealth(resources)
|
|
assert.Equal(t, stateSummaryVerdicts{OK: 1, Critical: 1, Stale: 1, Off: 1, Unknown: 50}, counts)
|
|
assert.Len(t, attention, stateSummaryAttentionLimit)
|
|
assert.Equal(t, "critical", attention[0].ID)
|
|
assert.Equal(t, "stale", attention[1].ID)
|
|
payload, err := json.Marshal(stateSummaryResponse{Verdicts: counts, Attention: attention})
|
|
assert.NoError(t, err)
|
|
assert.Less(t, len(payload), 5*1024)
|
|
}
|