package api import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "sort" "strings" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/mock" "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/internal/vmware" ) func newTestMonitor(t *testing.T) (*monitoring.Monitor, *models.State, *monitoring.MetricsHistory) { t.Helper() monitor := &monitoring.Monitor{} state := models.NewState() metricsHistory := monitoring.NewMetricsHistory(10, time.Hour) setUnexportedField(t, monitor, "state", state) setUnexportedField(t, monitor, "metricsHistory", metricsHistory) return monitor, state, metricsHistory } // syncTestResourceStore populates a MonitorAdapter (ResourceRegistry) from the // legacy state so that GetUnifiedReadState() returns a valid ReadState. // Call this after setting state.VMs, state.Nodes, etc. func syncTestResourceStore(t *testing.T, monitor *monitoring.Monitor, state *models.State) { t.Helper() adapter := unifiedresources.NewMonitorAdapter(nil) adapter.PopulateFromSnapshot(state.GetSnapshot()) setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(adapter)) } func mapKeys[V any](values map[string]V) []string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) return keys } func TestNormalizeMetricsHistoryResourceType_ContainerCanonicalTypes(t *testing.T) { tests := []struct { name string input string wantResponse string wantRuntime string wantStore []string }{ { name: "system container", input: "system-container", wantResponse: "system-container", wantRuntime: "system-container", wantStore: []string{"container"}, }, { name: "oci container", input: "oci-container", wantResponse: "oci-container", wantRuntime: "oci-container", wantStore: []string{"container"}, }, { name: "app container", input: "app-container", wantResponse: "app-container", wantRuntime: "app-container", wantStore: []string{"dockerContainer", "docker"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { responseType, runtimeType, storeTypes, err := normalizeMetricsHistoryResourceType(tt.input) if err != nil { t.Fatalf("normalizeMetricsHistoryResourceType(%q) error = %v", tt.input, err) } if responseType != tt.wantResponse { t.Fatalf("responseType = %q, want %q", responseType, tt.wantResponse) } if runtimeType != tt.wantRuntime { t.Fatalf("runtimeType = %q, want %q", runtimeType, tt.wantRuntime) } if len(storeTypes) != len(tt.wantStore) { t.Fatalf("storeTypes len = %d, want %d (%v)", len(storeTypes), len(tt.wantStore), storeTypes) } for i := range storeTypes { if storeTypes[i] != tt.wantStore[i] { t.Fatalf("storeTypes[%d] = %q, want %q (all=%v)", i, storeTypes[i], tt.wantStore[i], storeTypes) } } }) } } func TestNormalizeMetricsHistoryResourceType_RejectsLegacyContainerAlias(t *testing.T) { _, _, _, err := normalizeMetricsHistoryResourceType("container") if err == nil { t.Fatal("expected error for legacy container alias") } if !strings.Contains(err.Error(), `unsupported resourceType "container"`) { t.Fatalf("unexpected error: %v", err) } } func TestNormalizeMetricsHistoryResourceType_DockerHostCanonicalType(t *testing.T) { responseType, runtimeType, storeTypes, err := normalizeMetricsHistoryResourceType("docker-host") if err != nil { t.Fatalf("normalizeMetricsHistoryResourceType(docker-host) error = %v", err) } if responseType != "docker-host" { t.Fatalf("responseType = %q, want %q", responseType, "docker-host") } if runtimeType != "docker-host" { t.Fatalf("runtimeType = %q, want %q", runtimeType, "docker-host") } if len(storeTypes) != 1 || storeTypes[0] != "dockerHost" { t.Fatalf("storeTypes = %v, want [dockerHost]", storeTypes) } } func TestNormalizeMetricsHistoryResourceType_AgentQueriesNodeHistoryAsFallback(t *testing.T) { responseType, runtimeType, storeTypes, err := normalizeMetricsHistoryResourceType("agent") if err != nil { t.Fatalf("normalizeMetricsHistoryResourceType(agent) error = %v", err) } if responseType != "agent" { t.Fatalf("responseType = %q, want %q", responseType, "agent") } if runtimeType != "agent" { t.Fatalf("runtimeType = %q, want %q", runtimeType, "agent") } if len(storeTypes) != 2 || storeTypes[0] != "agent" || storeTypes[1] != "node" { t.Fatalf("storeTypes = %v, want [agent node]", storeTypes) } } func TestNormalizeMetricsHistoryResourceType_RejectsLegacyAliases(t *testing.T) { legacyTypes := []string{"host", "guest", "docker", "dockerhost", "dockercontainer", "system_container"} for _, legacyType := range legacyTypes { _, _, _, err := normalizeMetricsHistoryResourceType(legacyType) if err == nil { t.Fatalf("expected error for legacy alias %q", legacyType) } if !strings.Contains(err.Error(), fmt.Sprintf(`unsupported resourceType %q`, legacyType)) { t.Fatalf("unexpected error for %q: %v", legacyType, err) } } } func TestHandleSchedulerHealth_MethodNotAllowed(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodPost, "/api/scheduler/health", nil) rec := httptest.NewRecorder() router.handleSchedulerHealth(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) } } func TestHandleSchedulerHealth_NoMonitor(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodGet, "/api/scheduler/health", nil) rec := httptest.NewRecorder() router.handleSchedulerHealth(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code) } } func TestHandleSchedulerHealth_UsesCanonicalEmptyCollections(t *testing.T) { router := &Router{monitor: &monitoring.Monitor{}} req := httptest.NewRequest(http.MethodGet, "/api/scheduler/health", nil) rec := httptest.NewRecorder() router.handleSchedulerHealth(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), `"breakers":[]`) { t.Fatalf("expected breakers array to be retained, got %s", rec.Body.String()) } if !strings.Contains(rec.Body.String(), `"staleness":[]`) { t.Fatalf("expected staleness array to be retained, got %s", rec.Body.String()) } } func TestHandleHealth_NoMonitor(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodGet, "/api/health", nil) rec := httptest.NewRecorder() router.handleHealth(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code) } var response HealthResponse if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { t.Fatalf("decode response: %v", err) } if response.Status != "unhealthy" { t.Fatalf("expected unhealthy status, got %q", response.Status) } if response.Dependencies["monitor"] { t.Fatalf("expected monitor dependency to be unhealthy") } } func TestHandleHealth_WithMonitor(t *testing.T) { monitor, _, _ := newTestMonitor(t) router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/health", nil) rec := httptest.NewRecorder() router.handleHealth(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } var response HealthResponse if err := json.NewDecoder(rec.Body).Decode(&response); err != nil { t.Fatalf("decode response: %v", err) } if response.Status != "healthy" { t.Fatalf("expected healthy status, got %q", response.Status) } if !response.Dependencies["monitor"] { t.Fatalf("expected monitor dependency to be healthy") } if !response.Dependencies["scheduler"] { t.Fatalf("expected scheduler dependency to be healthy") } if response.Dependencies["websocket"] { t.Fatalf("expected websocket dependency to be false when ws hub is not configured") } } func TestHealthResponse_UsesCanonicalEmptyDependencies(t *testing.T) { payload, err := json.Marshal(EmptyHealthResponse()) if err != nil { t.Fatalf("marshal empty health response: %v", err) } if !strings.Contains(string(payload), `"dependencies":{}`) { t.Fatalf("expected empty health response to retain dependencies map, got %s", payload) } } func TestHandleChangePassword_MethodNotAllowed(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodGet, "/api/change-password", nil) rec := httptest.NewRecorder() router.handleChangePassword(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) } } func TestHandleLogout_MethodNotAllowed(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodGet, "/api/logout", nil) rec := httptest.NewRecorder() router.handleLogout(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) } } func TestHandleLogout_Post(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodPost, "/api/logout", nil) rec := httptest.NewRecorder() router.handleLogout(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Fatalf("expected application/json, got %q", ct) } var payload map[string]interface{} if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode response: %v", err) } if ok, _ := payload["success"].(bool); !ok { t.Fatalf("expected success=true, got %#v", payload["success"]) } } func TestHandleAgentVersion_MethodNotAllowed(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodPost, "/api/agent/version", nil) rec := httptest.NewRecorder() router.handleAgentVersion(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) } } func TestHandleAgentVersion_Get(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodGet, "/api/agent/version", nil) rec := httptest.NewRecorder() router.handleAgentVersion(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } var payload map[string]interface{} if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode response: %v", err) } if payload["version"] == "" { t.Fatalf("expected version in response, got %#v", payload) } } func TestHandleStorage_MissingID(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodGet, "/api/storage/", nil) rec := httptest.NewRecorder() router.handleStorage(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) } } func TestHandleStorage_NotFound(t *testing.T) { monitor, _, _ := newTestMonitor(t) router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/storage/store-1", nil) rec := httptest.NewRecorder() router.handleStorage(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code) } } func TestHandleStorage_Success(t *testing.T) { monitor, state, _ := newTestMonitor(t) state.Storage = []models.Storage{{ID: "store-1", Name: "Store One"}} router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/storage/store-1", nil) rec := httptest.NewRecorder() router.handleStorage(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } var payload map[string]interface{} if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { t.Fatalf("decode response: %v", err) } data, ok := payload["data"].(map[string]interface{}) if !ok { t.Fatalf("expected data field, got %#v", payload) } if data["id"] != "store-1" { t.Fatalf("expected storage id store-1, got %#v", data["id"]) } } func TestHandleCharts_Success(t *testing.T) { monitor, state, _ := newTestMonitor(t) state.VMs = []models.VM{{ID: "vm-1", Name: "vm-one", CPU: 0.2}} syncTestResourceStore(t, monitor, state) router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts?range=5m", nil) rec := httptest.NewRecorder() router.handleCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Fatalf("expected application/json, got %q", ct) } } func TestHandleCharts_StatsDebugMetadata(t *testing.T) { monitor, state, _ := newTestMonitor(t) state.VMs = []models.VM{{ID: "vm-1", Name: "vm-one", CPU: 0.2}} state.Nodes = []models.Node{{ID: "node-1", Name: "node-one", CPU: 0.1}} state.Storage = []models.Storage{{ID: "store-1", Name: "Store One", Used: 50, Total: 100}} syncTestResourceStore(t, monitor, state) router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts?range=5m", nil) rec := httptest.NewRecorder() router.handleCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } body := rec.Body.Bytes() var decoded ChartResponse if err := json.Unmarshal(body, &decoded); err != nil { t.Fatalf("unmarshal ChartResponse: %v", err) } if decoded.Stats.Range != "5m" { t.Fatalf("expected stats.range=5m, got %q", decoded.Stats.Range) } if decoded.Stats.RangeSeconds != 300 { t.Fatalf("expected stats.rangeSeconds=300, got %d", decoded.Stats.RangeSeconds) } if decoded.Stats.MetricsStoreEnabled { t.Fatalf("expected stats.metricsStoreEnabled=false in test monitor, got true") } if decoded.Stats.PrimarySourceHint != "memory" { t.Fatalf("expected stats.primarySourceHint=memory, got %q", decoded.Stats.PrimarySourceHint) } if decoded.Stats.InMemoryThresholdSecs != 7200 { t.Fatalf("expected stats.inMemoryThresholdSecs=7200, got %d", decoded.Stats.InMemoryThresholdSecs) } if decoded.Stats.OldestDataTimestamp <= 0 { t.Fatalf("expected stats.oldestDataTimestamp to be set, got %d", decoded.Stats.OldestDataTimestamp) } if decoded.Stats.OldestDataTimestamp > decoded.Timestamp { t.Fatalf( "expected stats.oldestDataTimestamp <= timestamp, got oldest=%d timestamp=%d", decoded.Stats.OldestDataTimestamp, decoded.Timestamp, ) } // With no history in the test monitor, handleCharts falls back to synthetic points: // guests: cpu/memory/disk/netin/netout (5 — diskread/diskwrite excluded from sparkline payloads) // nodes: cpu/memory/disk (3) // storage: disk (1) if decoded.Stats.PointCounts.Guests != 5 { t.Fatalf("expected stats.pointCounts.guests=5, got %d", decoded.Stats.PointCounts.Guests) } if decoded.Stats.PointCounts.Nodes != 3 { t.Fatalf("expected stats.pointCounts.nodes=3, got %d", decoded.Stats.PointCounts.Nodes) } if decoded.Stats.PointCounts.Storage != 1 { t.Fatalf("expected stats.pointCounts.storage=1, got %d", decoded.Stats.PointCounts.Storage) } if decoded.Stats.PointCounts.DockerContainers != 0 || decoded.Stats.PointCounts.DockerHosts != 0 || decoded.Stats.PointCounts.Agents != 0 { t.Fatalf( "expected dockerContainers/dockerHosts/agents all 0, got dc=%d dh=%d agents=%d", decoded.Stats.PointCounts.DockerContainers, decoded.Stats.PointCounts.DockerHosts, decoded.Stats.PointCounts.Agents, ) } sum := decoded.Stats.PointCounts.Guests + decoded.Stats.PointCounts.Nodes + decoded.Stats.PointCounts.Storage + decoded.Stats.PointCounts.DockerContainers + decoded.Stats.PointCounts.DockerHosts + decoded.Stats.PointCounts.Agents if decoded.Stats.PointCounts.Total != sum { t.Fatalf("expected stats.pointCounts.total=%d, got %d", sum, decoded.Stats.PointCounts.Total) } var raw map[string]interface{} if err := json.Unmarshal(body, &raw); err != nil { t.Fatalf("unmarshal raw JSON: %v", err) } stats, ok := raw["stats"].(map[string]interface{}) if !ok { t.Fatalf("expected stats object in JSON response") } if _, ok := stats["pointCounts"]; !ok { t.Fatalf("expected stats.pointCounts to be present in JSON response") } } func TestHandleCharts_UsesCanonicalMockUnifiedReadStateForVMwareHosts(t *testing.T) { prevMock := mock.IsMockEnabled() mock.SetEnabled(true) t.Cleanup(func() { mock.SetEnabled(prevMock) }) fixtures := vmware.DefaultFixtures() if len(fixtures.Hosts) == 0 { t.Fatal("expected default VMware fixtures to include at least one host") } expectedHostID := vmware.SourceID(fixtures.ConnectionID, "host", fixtures.Hosts[0].Host) monitor, state, _ := newTestMonitor(t) state.Hosts = []models.Host{{ ID: "live-store-host-1", Hostname: "live-store-host-1", CPUUsage: 11.0, Memory: models.Memory{Usage: 22.0}, Disks: []models.Disk{{Usage: 33.0}}, Status: "online", }} syncTestResourceStore(t, monitor, state) router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts?range=5m", nil) rec := httptest.NewRecorder() router.handleCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) } var decoded ChartResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal ChartResponse: %v", err) } vmwareSeries, ok := decoded.AgentData[expectedHostID] if !ok { t.Fatalf("expected VMware host %q in full charts payload, got keys=%v", expectedHostID, mapKeys(decoded.AgentData)) } if len(vmwareSeries["cpu"]) == 0 { t.Fatalf("expected VMware host %q cpu series", expectedHostID) } if _, ok := decoded.AgentData["live-store-host-1"]; ok { t.Fatalf("expected mock-aware full charts to ignore live store-only host, got keys=%v", mapKeys(decoded.AgentData)) } } func TestHandleInfrastructureCharts_Lightweight(t *testing.T) { monitor, state, _ := newTestMonitor(t) state.Nodes = []models.Node{{ ID: "node-1", Name: "node-one", Status: "online", CPU: 0.1, Memory: models.Memory{Usage: 12.0}, Disk: models.Disk{Usage: 34.0}, }} state.DockerHosts = []models.DockerHost{{ ID: "docker-host-1", Runtime: "docker", CPUUsage: 23.0, Memory: models.Memory{Usage: 45.0}, Disks: []models.Disk{{Usage: 67.0}}, Status: "online", }} state.Hosts = []models.Host{{ ID: "host-1", Hostname: "host-one", CPUUsage: 11.0, Memory: models.Memory{Usage: 22.0}, Disks: []models.Disk{{Usage: 33.0}}, Status: "online", }} syncTestResourceStore(t, monitor, state) router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/infrastructure?range=5m", nil) rec := httptest.NewRecorder() router.handleInfrastructureCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Fatalf("expected application/json, got %q", ct) } body := rec.Body.Bytes() var decoded InfrastructureChartsResponse if err := json.Unmarshal(body, &decoded); err != nil { t.Fatalf("unmarshal InfrastructureChartsResponse: %v", err) } if decoded.Stats.Range != "5m" { t.Fatalf("expected stats.range=5m, got %q", decoded.Stats.Range) } if decoded.Stats.RangeSeconds != 300 { t.Fatalf("expected stats.rangeSeconds=300, got %d", decoded.Stats.RangeSeconds) } // With no history in the test monitor, handler falls back to synthetic points: // nodes: cpu/memory/disk (3) // dockerHosts: cpu/memory/disk (3) // agents: cpu/memory/disk (3) if decoded.Stats.PointCounts.Nodes != 3 { t.Fatalf("expected stats.pointCounts.nodes=3, got %d", decoded.Stats.PointCounts.Nodes) } if decoded.Stats.PointCounts.DockerHosts != 3 { t.Fatalf("expected stats.pointCounts.dockerHosts=3, got %d", decoded.Stats.PointCounts.DockerHosts) } if decoded.Stats.PointCounts.Agents != 3 { t.Fatalf("expected stats.pointCounts.agents=3, got %d", decoded.Stats.PointCounts.Agents) } sum := decoded.Stats.PointCounts.Nodes + decoded.Stats.PointCounts.DockerHosts + decoded.Stats.PointCounts.Agents if decoded.Stats.PointCounts.Total != sum { t.Fatalf("expected stats.pointCounts.total=%d, got %d", sum, decoded.Stats.PointCounts.Total) } var raw map[string]interface{} if err := json.Unmarshal(body, &raw); err != nil { t.Fatalf("unmarshal raw JSON: %v", err) } for _, forbidden := range []string{"data", "storageData", "dockerData", "guestTypes"} { if _, ok := raw[forbidden]; ok { t.Fatalf("expected %q to be absent from infra summary response", forbidden) } } } func TestHandleInfrastructureCharts_MetricFilter(t *testing.T) { monitor, state, _ := newTestMonitor(t) state.Nodes = []models.Node{{ ID: "node-1", Name: "node-one", Status: "online", CPU: 0.1, Memory: models.Memory{Usage: 12.0}, Disk: models.Disk{Usage: 34.0}, }} state.DockerHosts = []models.DockerHost{{ ID: "docker-host-1", Runtime: "docker", CPUUsage: 23.0, Memory: models.Memory{Usage: 45.0}, Disks: []models.Disk{{Usage: 67.0}}, Status: "online", }} state.Hosts = []models.Host{{ ID: "host-1", Hostname: "host-one", CPUUsage: 11.0, Memory: models.Memory{Usage: 22.0}, Disks: []models.Disk{{Usage: 33.0}}, Status: "online", }} syncTestResourceStore(t, monitor, state) router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/infrastructure?range=5m&metrics=cpu,memory", nil) rec := httptest.NewRecorder() router.handleInfrastructureCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } var decoded InfrastructureChartsResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal InfrastructureChartsResponse: %v", err) } if decoded.Stats.PointCounts.Nodes != 2 { t.Fatalf("expected stats.pointCounts.nodes=2, got %d", decoded.Stats.PointCounts.Nodes) } if decoded.Stats.PointCounts.DockerHosts != 2 { t.Fatalf("expected stats.pointCounts.dockerHosts=2, got %d", decoded.Stats.PointCounts.DockerHosts) } if decoded.Stats.PointCounts.Agents != 2 { t.Fatalf("expected stats.pointCounts.agents=2, got %d", decoded.Stats.PointCounts.Agents) } if _, ok := decoded.NodeData["node-1"]["disk"]; ok { t.Fatalf("expected disk series to be filtered out of node payload") } if _, ok := decoded.DockerHostData["docker-host-1"]["disk"]; ok { t.Fatalf("expected disk series to be filtered out of docker host payload") } if _, ok := decoded.AgentData["host-1"]["disk"]; ok { t.Fatalf("expected disk series to be filtered out of agent payload") } } func TestHandleInfrastructureCharts_UsesCanonicalMockUnifiedReadStateForVMwareHosts(t *testing.T) { prevMock := mock.IsMockEnabled() mock.SetEnabled(true) t.Cleanup(func() { mock.SetEnabled(prevMock) }) fixtures := vmware.DefaultFixtures() if len(fixtures.Hosts) == 0 { t.Fatal("expected default VMware fixtures to include at least one host") } expectedHostID := vmware.SourceID(fixtures.ConnectionID, "host", fixtures.Hosts[0].Host) monitor, state, _ := newTestMonitor(t) state.Hosts = []models.Host{{ ID: "live-store-host-1", Hostname: "live-store-host-1", CPUUsage: 11.0, Memory: models.Memory{Usage: 22.0}, Disks: []models.Disk{{Usage: 33.0}}, Status: "online", }} syncTestResourceStore(t, monitor, state) router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/infrastructure?range=5m", nil) rec := httptest.NewRecorder() router.handleInfrastructureCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) } var decoded InfrastructureChartsResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal InfrastructureChartsResponse: %v", err) } vmwareSeries, ok := decoded.AgentData[expectedHostID] if !ok { t.Fatalf("expected VMware host %q in infrastructure charts, got keys=%v", expectedHostID, mapKeys(decoded.AgentData)) } if len(vmwareSeries["cpu"]) == 0 { t.Fatalf("expected VMware host %q cpu series", expectedHostID) } if _, ok := decoded.AgentData["live-store-host-1"]; ok { t.Fatalf("expected mock-aware infrastructure charts to ignore live store-only host, got keys=%v", mapKeys(decoded.AgentData)) } } func TestHandleWorkloadsSummaryCharts_AggregatesAndCounts(t *testing.T) { monitor, state, _ := newTestMonitor(t) state.Nodes = []models.Node{{ ID: "node-pve-1", Name: "pve-1", Instance: "pve", }} state.VMs = []models.VM{{ ID: "vm-101", Name: "vm-101", Node: "pve-1", Instance: "pve", Status: "running", CPU: 0.25, Memory: models.Memory{Usage: 40.0}, Disk: models.Disk{Usage: 55.0}, NetworkIn: 1200, NetworkOut: 800, }} state.Containers = []models.Container{{ ID: "ct-201", Name: "ct-201", Node: "pve-1", Instance: "pve", Status: "stopped", CPU: 0.10, Memory: models.Memory{Usage: 30.0}, Disk: models.Disk{Usage: 45.0}, NetworkIn: 400, NetworkOut: 600, }} state.DockerHosts = []models.DockerHost{{ ID: "docker-host-1", Runtime: "docker", Containers: []models.DockerContainer{{ ID: "docker-1", Name: "docker-1", State: "running", CPUPercent: 35.0, MemoryPercent: 60.0, WritableLayerBytes: 10, RootFilesystemBytes: 100, }}, }} router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads-summary?range=5m", nil) rec := httptest.NewRecorder() router.handleWorkloadsSummaryCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Fatalf("expected application/json, got %q", ct) } body := rec.Body.Bytes() var decoded WorkloadsSummaryChartsResponse if err := json.Unmarshal(body, &decoded); err != nil { t.Fatalf("unmarshal WorkloadsSummaryChartsResponse: %v", err) } if decoded.Stats.Range != "5m" { t.Fatalf("expected stats.range=5m, got %q", decoded.Stats.Range) } if decoded.Stats.RangeSeconds != 300 { t.Fatalf("expected stats.rangeSeconds=300, got %d", decoded.Stats.RangeSeconds) } if decoded.GuestCounts.Total != 3 { t.Fatalf("expected guestCounts.total=3, got %d", decoded.GuestCounts.Total) } if decoded.GuestCounts.Running != 2 { t.Fatalf("expected guestCounts.running=2, got %d", decoded.GuestCounts.Running) } if decoded.GuestCounts.Stopped != 1 { t.Fatalf("expected guestCounts.stopped=1, got %d", decoded.GuestCounts.Stopped) } for metricName, metric := range map[string]WorkloadsSummaryMetricData{ "cpu": decoded.CPU, "memory": decoded.Memory, "disk": decoded.Disk, "network": decoded.Network, } { if len(metric.P50) == 0 { t.Fatalf("expected %s p50 points to be present", metricName) } if len(metric.P95) == 0 { t.Fatalf("expected %s p95 points to be present", metricName) } } if decoded.Stats.PointCounts.Total <= 0 { t.Fatalf("expected stats.pointCounts.total to be > 0, got %d", decoded.Stats.PointCounts.Total) } if decoded.Stats.PointCounts.Guests <= 0 { t.Fatalf("expected stats.pointCounts.guests to be > 0, got %d", decoded.Stats.PointCounts.Guests) } if decoded.BlastRadius.CPU.Scope != "concentrated" { t.Fatalf("expected cpu blast radius concentrated, got %q", decoded.BlastRadius.CPU.Scope) } if decoded.BlastRadius.Network.Scope != "concentrated" { t.Fatalf("expected network blast radius concentrated, got %q", decoded.BlastRadius.Network.Scope) } // Node-scoped request should only include workloads that match the selected node. nodeReq := httptest.NewRequest( http.MethodGet, "/api/charts/workloads-summary?range=5m&node=node-pve-1", nil, ) nodeRec := httptest.NewRecorder() router.handleWorkloadsSummaryCharts(nodeRec, nodeReq) if nodeRec.Code != http.StatusOK { t.Fatalf("node-scoped expected status %d, got %d", http.StatusOK, nodeRec.Code) } var nodeScoped WorkloadsSummaryChartsResponse if err := json.Unmarshal(nodeRec.Body.Bytes(), &nodeScoped); err != nil { t.Fatalf("unmarshal node-scoped WorkloadsSummaryChartsResponse: %v", err) } if nodeScoped.GuestCounts.Total != 2 { t.Fatalf("expected node-scoped guestCounts.total=2, got %d", nodeScoped.GuestCounts.Total) } if nodeScoped.GuestCounts.Running != 1 { t.Fatalf("expected node-scoped guestCounts.running=1, got %d", nodeScoped.GuestCounts.Running) } if nodeScoped.GuestCounts.Stopped != 1 { t.Fatalf("expected node-scoped guestCounts.stopped=1, got %d", nodeScoped.GuestCounts.Stopped) } if nodeScoped.BlastRadius.CPU.Scope != "concentrated" { t.Fatalf("expected node-scoped cpu blast radius concentrated, got %q", nodeScoped.BlastRadius.CPU.Scope) } } func TestHandleWorkloadCharts_WorkloadOnlyPayloadAndNodeFilter(t *testing.T) { monitor, state, _ := newTestMonitor(t) state.Nodes = []models.Node{{ ID: "node-pve-1", Name: "pve-1", Instance: "pve", }} state.VMs = []models.VM{ { ID: "vm-101", Name: "vm-101", Node: "pve-1", Instance: "pve", CPU: 0.2, Memory: models.Memory{Usage: 55}, Disk: models.Disk{Usage: 40}, }, { ID: "vm-202", Name: "vm-202", Node: "other", Instance: "other", CPU: 0.4, Memory: models.Memory{Usage: 65}, Disk: models.Disk{Usage: 70}, }, } state.Containers = []models.Container{ { ID: "ct-301", Name: "ct-301", Node: "pve-1", Instance: "pve", CPU: 0.3, Memory: models.Memory{Usage: 40}, Disk: models.Disk{Usage: 25}, }, } state.DockerHosts = []models.DockerHost{{ ID: "docker-host-1", Hostname: "pve-1", DisplayName: "pve-1", Containers: []models.DockerContainer{{ ID: "docker-401", Name: "docker-401", CPUPercent: 22, MemoryPercent: 30, }}, }} readState := monitor.GetUnifiedReadStateOrSnapshot() if readState == nil { t.Fatal("expected unified read state") } vmResponseKey := "" containerResponseKey := "" excludedResponseKey := "" for _, vm := range readState.VMs() { if vm == nil { continue } switch vm.Name() { case "vm-101": vmResponseKey, _, _ = vmChartRequest(vm) case "vm-202": excludedResponseKey, _, _ = vmChartRequest(vm) } } for _, ct := range readState.Containers() { if ct == nil { continue } if ct.Name() == "ct-301" { containerResponseKey, _, _ = systemContainerChartRequest(ct) } } if vmResponseKey == "" || containerResponseKey == "" || excludedResponseKey == "" { t.Fatalf( "expected canonical response keys for vm/container test fixtures, got vm=%q ct=%q excluded=%q", vmResponseKey, containerResponseKey, excludedResponseKey, ) } router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads?range=5m", nil) rec := httptest.NewRecorder() router.handleWorkloadCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Fatalf("expected application/json, got %q", ct) } var decoded WorkloadChartsResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal WorkloadChartsResponse: %v", err) } if decoded.Stats.Range != "5m" { t.Fatalf("expected stats.range=5m, got %q", decoded.Stats.Range) } if decoded.Stats.RangeSeconds != 300 { t.Fatalf("expected stats.rangeSeconds=300, got %d", decoded.Stats.RangeSeconds) } if len(decoded.ChartData) != 3 { t.Fatalf("expected 3 workload chart entries, got %d", len(decoded.ChartData)) } if len(decoded.DockerData) != 1 { t.Fatalf("expected 1 docker chart entry, got %d", len(decoded.DockerData)) } if decoded.GuestTypes[vmResponseKey] != "vm" { t.Fatalf("expected vm guest type for %s, got %q", vmResponseKey, decoded.GuestTypes[vmResponseKey]) } if decoded.GuestTypes[containerResponseKey] != "system-container" { t.Fatalf( "expected system-container guest type for %s, got %q", containerResponseKey, decoded.GuestTypes[containerResponseKey], ) } if decoded.Stats.PointCounts.Total <= 0 { t.Fatalf("expected stats.pointCounts.total > 0, got %d", decoded.Stats.PointCounts.Total) } // Node-scoped request should only include workloads linked to node-pve-1 // plus docker containers running on matching docker hosts. nodeReq := httptest.NewRequest( http.MethodGet, "/api/charts/workloads?range=5m&node=node-pve-1", nil, ) nodeRec := httptest.NewRecorder() router.handleWorkloadCharts(nodeRec, nodeReq) if nodeRec.Code != http.StatusOK { t.Fatalf("node-scoped expected status %d, got %d", http.StatusOK, nodeRec.Code) } var scoped WorkloadChartsResponse if err := json.Unmarshal(nodeRec.Body.Bytes(), &scoped); err != nil { t.Fatalf("unmarshal node-scoped WorkloadChartsResponse: %v", err) } if len(scoped.ChartData) != 2 { t.Fatalf("expected 2 scoped workload chart entries, got %d", len(scoped.ChartData)) } if _, ok := scoped.ChartData[excludedResponseKey]; ok { t.Fatalf("expected %s to be excluded by node scope", excludedResponseKey) } if len(scoped.DockerData) != 1 { t.Fatalf("expected 1 scoped docker chart entry, got %d", len(scoped.DockerData)) } } func TestHandleWorkloadCharts_UsesCanonicalWorkloadIDsForVMwareVMs(t *testing.T) { monitor, state, history := newTestMonitor(t) now := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Minute) metricID := "vc-1:vm:vm-201" resourceID := "vm-vmware-1" history.AddGuestMetric(metricID, "cpu", 37, now.Add(-10*time.Minute)) history.AddGuestMetric(metricID, "memory", 62, now.Add(-5*time.Minute)) adapter := unifiedresources.NewMonitorAdapter(nil) adapter.PopulateSnapshotAndSupplemental(state.GetSnapshot(), map[unifiedresources.DataSource][]unifiedresources.IngestRecord{ unifiedresources.SourceVMware: { { SourceID: metricID, Resource: unifiedresources.Resource{ ID: resourceID, Type: unifiedresources.ResourceTypeVM, Name: "warehouse-api-01", Status: unifiedresources.StatusOnline, LastSeen: now, MetricsTarget: &unifiedresources.MetricsTarget{ ResourceType: "vm", ResourceID: metricID, }, VMware: &unifiedresources.VMwareData{ ConnectionID: "vc-1", EntityType: "vm", ManagedObjectID: "vm-201", }, }, }, }, }) setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(adapter)) readState := monitor.GetUnifiedReadStateOrSnapshot() if readState == nil || len(readState.VMs()) != 1 || readState.VMs()[0] == nil { t.Fatalf("expected one VMware VM in unified read state, got %+v", readState) } resourceID, _, ok := vmChartRequest(readState.VMs()[0]) if !ok { t.Fatal("expected canonical VMware vm chart request") } router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads?range=1h", nil) rec := httptest.NewRecorder() router.handleWorkloadCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) } var decoded WorkloadChartsResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal WorkloadChartsResponse: %v", err) } series, ok := decoded.ChartData[resourceID] if !ok { t.Fatalf("expected VMware workload chart keyed by canonical workload id %q, got %v", resourceID, decoded.ChartData) } if _, ok := decoded.ChartData[metricID]; ok { t.Fatalf("expected VMware metrics target id %q to stay out of workload chart response keys", metricID) } if decoded.GuestTypes[resourceID] != "vm" { t.Fatalf("expected guest type vm for %q, got %q", resourceID, decoded.GuestTypes[resourceID]) } if len(series["cpu"]) == 0 { t.Fatalf("expected VMware cpu series for %q", resourceID) } if got := series["cpu"][len(series["cpu"])-1].Value; got != 37 { t.Fatalf("expected latest VMware cpu value 37, got %v", got) } } func TestHandleWorkloadsSummaryCharts_UsesCanonicalWorkloadIDsForVMwareVMs(t *testing.T) { monitor, state, history := newTestMonitor(t) now := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Minute) metricID := "vc-1:vm:vm-201" resourceID := "vm-vmware-1" history.AddGuestMetric(metricID, "cpu", 51, now.Add(-10*time.Minute)) history.AddGuestMetric(metricID, "memory", 64, now.Add(-5*time.Minute)) history.AddGuestMetric(metricID, "disk", 43, now.Add(-3*time.Minute)) history.AddGuestMetric(metricID, "netin", 1200, now.Add(-2*time.Minute)) history.AddGuestMetric(metricID, "netout", 800, now.Add(-2*time.Minute)) adapter := unifiedresources.NewMonitorAdapter(nil) adapter.PopulateSnapshotAndSupplemental(state.GetSnapshot(), map[unifiedresources.DataSource][]unifiedresources.IngestRecord{ unifiedresources.SourceVMware: { { SourceID: metricID, Resource: unifiedresources.Resource{ ID: resourceID, Type: unifiedresources.ResourceTypeVM, Name: "warehouse-api-01", Status: unifiedresources.StatusOnline, LastSeen: now, MetricsTarget: &unifiedresources.MetricsTarget{ ResourceType: "vm", ResourceID: metricID, }, VMware: &unifiedresources.VMwareData{ ConnectionID: "vc-1", EntityType: "vm", ManagedObjectID: "vm-201", }, }, }, }, }) setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(adapter)) readState := monitor.GetUnifiedReadStateOrSnapshot() if readState == nil || len(readState.VMs()) != 1 || readState.VMs()[0] == nil { t.Fatalf("expected one VMware VM in unified read state, got %+v", readState) } resourceID, _, ok := vmChartRequest(readState.VMs()[0]) if !ok { t.Fatal("expected canonical VMware vm chart request") } router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads-summary?range=1h", nil) rec := httptest.NewRecorder() router.handleWorkloadsSummaryCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) } var decoded WorkloadsSummaryChartsResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal WorkloadsSummaryChartsResponse: %v", err) } if decoded.GuestCounts.Total != 1 || decoded.GuestCounts.Running != 1 { t.Fatalf("expected guestCounts total/running = 1/1, got %+v", decoded.GuestCounts) } if len(decoded.TopContributors.CPU) == 0 { t.Fatal("expected at least one cpu top contributor") } if decoded.TopContributors.CPU[0].ID != resourceID { t.Fatalf("expected cpu top contributor id %q, got %+v", resourceID, decoded.TopContributors.CPU[0]) } if decoded.TopContributors.CPU[0].ID == metricID { t.Fatalf("expected workloads summary contributor id to avoid raw metrics target %q", metricID) } if got := decoded.TopContributors.CPU[0].Name; got != "warehouse-api-01" { t.Fatalf("expected cpu top contributor name warehouse-api-01, got %q", got) } } func TestHandleWorkloadCharts_IncludesKubernetesPods(t *testing.T) { prevMock := mock.IsMockEnabled() mock.SetEnabled(false) t.Cleanup(func() { mock.SetEnabled(prevMock) }) monitor, state, _ := newTestMonitor(t) state.Nodes = []models.Node{{ ID: "node-pve-1", Name: "pve-1", Instance: "pve", }} state.KubernetesClusters = []models.KubernetesCluster{{ ID: "cluster-k8s-summary-test", Name: "cluster-k8s-summary-test", Status: "online", Pods: []models.KubernetesPod{ { UID: "pod-001", Name: "api-0", Namespace: "default", NodeName: "pve-1", Phase: "Running", Containers: []models.KubernetesPodContainer{ {Name: "api", Ready: true}, }, }, }, }} router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads?range=5m", nil) rec := httptest.NewRecorder() router.handleWorkloadCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } var decoded WorkloadChartsResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal WorkloadChartsResponse: %v", err) } var metricID string for id, typ := range decoded.GuestTypes { if typ == "k8s" { metricID = id break } } if metricID == "" { t.Fatal("expected at least one kubernetes pod series") } series, ok := decoded.ChartData[metricID] if !ok { t.Fatalf("expected kubernetes pod series for %s", metricID) } if len(series["cpu"]) == 0 { t.Fatalf("expected kubernetes cpu points for %s", metricID) } if got := len(series["disk"]); got != 1 || series["disk"][0].Value != 0 { t.Fatalf("expected unsupported kubernetes pod disk metric fallback to single zero point; got len=%d value=%v", got, series["disk"]) } if got := len(series["netin"]); got != 1 || series["netin"][0].Value != 0 { t.Fatalf("expected unsupported kubernetes pod network metric fallback to single zero point; got len=%d value=%v", got, series["netin"]) } } func TestHandleWorkloadsSummaryCharts_IncludesKubernetesPods(t *testing.T) { prevMock := mock.IsMockEnabled() mock.SetEnabled(false) t.Cleanup(func() { mock.SetEnabled(prevMock) }) monitor, state, _ := newTestMonitor(t) state.KubernetesClusters = []models.KubernetesCluster{{ ID: "cluster-alpha", Name: "cluster-alpha", Status: "online", Pods: []models.KubernetesPod{ { UID: "pod-001", Name: "api-0", Namespace: "default", Phase: "Running", Containers: []models.KubernetesPodContainer{ {Name: "api", Ready: true}, }, }, { UID: "pod-002", Name: "batch-0", Namespace: "ops", Phase: "Failed", Containers: []models.KubernetesPodContainer{ {Name: "worker", Ready: false}, }, }, }, }} router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads-summary?range=5m", nil) rec := httptest.NewRecorder() router.handleWorkloadsSummaryCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } var decoded WorkloadsSummaryChartsResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal WorkloadsSummaryChartsResponse: %v", err) } if decoded.GuestCounts.Total != 2 { t.Fatalf("expected guestCounts.total=2, got %d", decoded.GuestCounts.Total) } if decoded.GuestCounts.Running != 1 { t.Fatalf("expected guestCounts.running=1, got %d", decoded.GuestCounts.Running) } if decoded.GuestCounts.Stopped != 1 { t.Fatalf("expected guestCounts.stopped=1, got %d", decoded.GuestCounts.Stopped) } if len(decoded.CPU.P50) == 0 { t.Fatal("expected cpu summary points for kubernetes workloads") } } func TestHandleWorkloadCharts_UnknownNodeFilterFallsBackToGlobalScope(t *testing.T) { monitor, state, _ := newTestMonitor(t) state.Nodes = []models.Node{{ ID: "node-pve-1", Name: "pve-1", Instance: "pve", }} state.VMs = []models.VM{{ ID: "vm-101", Name: "vm-101", Node: "pve-1", Instance: "pve", CPU: 0.3, Memory: models.Memory{Usage: 42}, Disk: models.Disk{Usage: 55}, }} router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads?range=5m&node=missing-node-id", nil) rec := httptest.NewRecorder() router.handleWorkloadCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } var decoded WorkloadChartsResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal WorkloadChartsResponse: %v", err) } if len(decoded.ChartData) == 0 { t.Fatalf("expected fallback to global scope when node filter is stale") } } func TestHandleWorkloadsSummaryCharts_UnknownNodeFilterFallsBackToGlobalScope(t *testing.T) { monitor, state, _ := newTestMonitor(t) state.Nodes = []models.Node{{ ID: "node-pve-1", Name: "pve-1", Instance: "pve", }} state.VMs = []models.VM{{ ID: "vm-101", Name: "vm-101", Node: "pve-1", Instance: "pve", CPU: 0.3, Memory: models.Memory{Usage: 42}, Disk: models.Disk{Usage: 55}, Status: "running", }} router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/workloads-summary?range=5m&node=missing-node-id", nil) rec := httptest.NewRecorder() router.handleWorkloadsSummaryCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } var decoded WorkloadsSummaryChartsResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal WorkloadsSummaryChartsResponse: %v", err) } if decoded.GuestCounts.Total == 0 { t.Fatalf("expected fallback to global scope when summary node filter is stale") } } func TestHandleStorageCharts_Success(t *testing.T) { monitor, state, metricsHistory := newTestMonitor(t) state.Storage = []models.Storage{{ID: "store-1", Name: "Store One"}} metricsHistory.AddStorageMetric("store-1", "usage", 0.4, time.Now()) syncTestResourceStore(t, monitor, state) router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/storage/charts?range=30", nil) rec := httptest.NewRecorder() router.handleStorageCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Fatalf("expected application/json, got %q", ct) } } func TestHandleStorageCharts_IncludesSupplementalStorageAndResolvesUnifiedNodeFilter(t *testing.T) { monitor, state, metricsHistory := newTestMonitor(t) now := time.Now() metricsHistory.AddStorageMetric("vc-1:datastore:datastore-202", "usage", 0.25, now) metricsHistory.AddStorageMetric("vc-1:datastore:datastore-202", "used", 3.57*1024*1024*1024*1024, now) metricsHistory.AddStorageMetric("vc-1:datastore:datastore-202", "avail", 11.03*1024*1024*1024*1024, now) metricsHistory.AddStorageMetric("pool:archive", "usage", 0.36, now) adapter := unifiedresources.NewMonitorAdapter(nil) adapter.PopulateSnapshotAndSupplemental(state.GetSnapshot(), map[unifiedresources.DataSource][]unifiedresources.IngestRecord{ unifiedresources.SourceVMware: { { SourceID: "vc-1:host:host-101", Resource: unifiedresources.Resource{ ID: "agent-vmware-1", Type: unifiedresources.ResourceTypeAgent, Name: "esxi-01.lab.local", Identity: unifiedresources.ResourceIdentity{ Hostnames: []string{"esxi-01.lab.local"}, }, MetricsTarget: &unifiedresources.MetricsTarget{ ResourceType: "agent", ResourceID: "vc-1:host:host-101", }, VMware: &unifiedresources.VMwareData{ ConnectionID: "vc-1", EntityType: "host", ManagedObjectID: "host-101", }, }, }, { SourceID: "vc-1:datastore:datastore-202", ParentSourceID: "vc-1:host:host-101", Resource: unifiedresources.Resource{ ID: "storage-vmware-1", Type: unifiedresources.ResourceTypeStorage, Name: "archive-tier", ParentName: "esxi-01.lab.local", MetricsTarget: &unifiedresources.MetricsTarget{ ResourceType: "storage", ResourceID: "vc-1:datastore:datastore-202", }, Storage: &unifiedresources.StorageMeta{ Type: "datastore", Platform: "vmware", Nodes: []string{"esxi-01.lab.local", "esxi-02.lab.local"}, }, VMware: &unifiedresources.VMwareData{ ConnectionID: "vc-1", EntityType: "datastore", ManagedObjectID: "datastore-202", RuntimeHostName: "esxi-01.lab.local", }, }, }, }, unifiedresources.SourceTrueNAS: { { SourceID: "system:truenas-main", Resource: unifiedresources.Resource{ ID: "agent-truenas-1", Type: unifiedresources.ResourceTypeAgent, Name: "truenas-main", Identity: unifiedresources.ResourceIdentity{ Hostnames: []string{"truenas-main"}, }, TrueNAS: &unifiedresources.TrueNASData{ Hostname: "truenas-main", }, }, }, { SourceID: "pool:archive", ParentSourceID: "system:truenas-main", Resource: unifiedresources.Resource{ ID: "storage-truenas-1", Type: unifiedresources.ResourceTypeStorage, Name: "archive", ParentName: "truenas-main", MetricsTarget: &unifiedresources.MetricsTarget{ ResourceType: "storage", ResourceID: "pool:archive", }, Storage: &unifiedresources.StorageMeta{ Type: "zfs-pool", Platform: "truenas", }, TrueNAS: &unifiedresources.TrueNASData{ Hostname: "truenas-main", }, }, }, }, }) selectedNodeID := "" for _, resource := range adapter.GetAll() { if resource.Type == unifiedresources.ResourceTypeAgent && resource.Name == "esxi-01.lab.local" { selectedNodeID = resource.ID break } } if selectedNodeID == "" { t.Fatal("expected canonical VMware host resource id in adapter") } setUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(adapter)) router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/storage-charts?range=30&node="+selectedNodeID, nil) rec := httptest.NewRecorder() router.handleStorageCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } var decoded StorageChartsResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal StorageChartsResponse: %v", err) } if len(decoded.Pools) != 1 { t.Fatalf("expected unified node filter to keep 1 VMware pool, got %d (%v)", len(decoded.Pools), decoded.Pools) } if _, ok := decoded.Pools["vc-1:datastore:datastore-202"]; !ok { t.Fatalf("expected VMware datastore chart keyed by canonical metrics target, got %v", decoded.Pools) } if _, ok := decoded.Pools["pool:archive"]; ok { t.Fatalf("expected TrueNAS pool to be filtered out by VMware host selection, got %v", decoded.Pools) } } func TestHandleStorageSummaryCharts_AggregatesCapacityAcrossPools(t *testing.T) { monitor, state, metricsHistory := newTestMonitor(t) now := time.Now() state.Storage = []models.Storage{ {ID: "store-1", Name: "Store One"}, {ID: "store-2", Name: "Store Two"}, } metricsHistory.AddStorageMetric("store-1", "used", 400, now) metricsHistory.AddStorageMetric("store-1", "avail", 600, now) metricsHistory.AddStorageMetric("store-2", "used", 100, now) metricsHistory.AddStorageMetric("store-2", "avail", 900, now) syncTestResourceStore(t, monitor, state) router := &Router{monitor: monitor} req := httptest.NewRequest(http.MethodGet, "/api/charts/storage-summary?range=1h", nil) rec := httptest.NewRecorder() router.handleStorageSummaryCharts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Fatalf("expected application/json, got %q", ct) } var decoded StorageSummaryTrendResponse if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal StorageSummaryTrendResponse: %v", err) } if len(decoded.Capacity) != 1 { t.Fatalf("expected 1 aggregate capacity point, got %d (%+v)", len(decoded.Capacity), decoded.Capacity) } if decoded.Capacity[0].Value != 25 { t.Fatalf("expected aggregate capacity of 25%%, got %+v", decoded.Capacity[0]) } if decoded.Stats.PointCounts.Total != 1 { t.Fatalf("expected summary point count 1, got %+v", decoded.Stats.PointCounts) } } func TestEstablishSession(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() if err := router.establishSession(rec, req, "admin"); err != nil { t.Fatalf("establishSession error: %v", err) } cookies := rec.Result().Cookies() if len(cookies) < 2 { t.Fatalf("expected session and csrf cookies, got %d", len(cookies)) } } func TestEstablishOIDCSession(t *testing.T) { router := &Router{} req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() oidc := &OIDCTokenInfo{ RefreshToken: "refresh", AccessTokenExp: time.Now().Add(1 * time.Hour), Issuer: "issuer", ClientID: "client", } if err := router.establishOIDCSession(rec, req, "admin", oidc); err != nil { t.Fatalf("establishOIDCSession error: %v", err) } cookies := rec.Result().Cookies() if len(cookies) < 2 { t.Fatalf("expected session and csrf cookies, got %d", len(cookies)) } } func TestLearnBaselines_WithData(t *testing.T) { monitor, state, history := newTestMonitor(t) state.Nodes = []models.Node{{ID: "node-1", Name: "node"}} state.VMs = []models.VM{{ID: "vm-1", Name: "vm", Status: "running"}} state.Containers = []models.Container{{ID: "ct-1", Name: "ct", Status: "running"}} // Sync ReadState from legacy state — learnBaselines uses ReadState exclusively. syncTestResourceStore(t, monitor, state) now := time.Now() history.AddNodeMetric("node-1", "cpu", 0.5, now) history.AddGuestMetric("vm-1", "cpu", 0.2, now) history.AddGuestMetric("ct-1", "cpu", 0.3, now) router := &Router{monitor: monitor} store := ai.NewBaselineStore(ai.BaselineConfig{MinSamples: 1}) router.learnBaselines(store, history) if store.ResourceCount() == 0 { t.Fatalf("expected baselines to be learned") } }