diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 77713989a..304044fc0 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -188,6 +188,10 @@ CPU fallback points use host-capacity-normalized percent, while Docker metadata may expose runtime-native per-core CPU percent plus the host CPU count as raw diagnostic evidence. API consumers must not treat raw per-core CPU as the threshold, chart, or canonical resource CPU value. +Proxmox legacy model CPU fields have the inverse compatibility shape: +`models.Node.CPU`, `models.VM.CPU`, and `models.Container.CPU` remain 0..1 +ratios, but `/api/metrics-store/history` live fallback points must convert +those ratios to 0..100 chart percentages before serialization. Candidate import plans for PVE/PBS/PMG onboarding are API-contract consumers, not local UI estimates. Probe and Discovery candidates must build their @@ -2459,8 +2463,10 @@ a new API state machine, queue contract, or verification-accounting field. and `POST /api/connections/probe` stay on one canonical payload shape instead of re-deriving state from per-type config stores in the frontend. State must remain a derived field sourced from in-memory scheduler health - (`monitoring.Monitor.SchedulerHealth()`) plus agent `Host.LastSeen`; the - endpoint must not introduce new persisted per-connection state. The probe + (`monitoring.Monitor.SchedulerHealth()`) plus agent `Host.LastSeen`; for + agent rows, `Host.LastSeen` is the monitoring-owned server receipt time, + not the agent payload timestamp. The endpoint must not introduce new + persisted per-connection state. The probe endpoint must remain admin-gated (`RequireAdmin` + `ScopeSettingsWrite`) to block unauthenticated SSRF against internal hosts. That same probe path must also validate user-supplied addresses before probing, reject metadata, diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 02e88cbe7..25a311f5f 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "math" "net/http" "net/http/httptest" "net/url" @@ -10802,6 +10803,57 @@ func TestContract_MetricsHistoryLiveFallbackJSONSnapshot(t *testing.T) { assertJSONSnapshot(t, got, want) } +func TestContract_MetricsHistoryProxmoxNodeCPUFallbackConvertsRatioToPercent(t *testing.T) { + state := models.NewState() + state.UpdateNodes([]models.Node{{ + ID: "pve1:node1", + Name: "node1", + Instance: "pve1", + Status: "online", + CPU: 0.37, + }}) + + monitor := &monitoring.Monitor{} + setUnexportedField(t, monitor, "state", state) + setUnexportedField(t, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour)) + + tempDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(tempDir) + if _, err := mtp.GetPersistence("default"); err != nil { + t.Fatalf("failed to init persistence: %v", err) + } + + router := &Router{ + monitor: monitor, + licenseHandlers: NewLicenseHandlers(mtp, false), + } + + req := httptest.NewRequest( + http.MethodGet, + "/api/metrics-store/history?resourceType=node&resourceId=pve1:node1&metric=cpu&range=24h", + nil, + ) + rec := httptest.NewRecorder() + router.handleMetricsHistory(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp metricsHistoryResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal metrics history response: %v", err) + } + if resp.Source != "live" { + t.Fatalf("source = %q, want live", resp.Source) + } + if len(resp.Points) != 1 { + t.Fatalf("points = %d, want 1", len(resp.Points)) + } + if math.Abs(resp.Points[0].Value-37) > 0.001 { + t.Fatalf("cpu fallback value = %f, want 37", resp.Points[0].Value) + } +} + func TestContract_MetricsHistoryDockerContainerCPUFallbackUsesCapacityPercent(t *testing.T) { state := models.NewState() state.UpsertDockerHost(models.DockerHost{ diff --git a/internal/api/router.go b/internal/api/router.go index 44b840d65..89deb21a7 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -7540,6 +7540,10 @@ func clampWorkloadPercent(value float64) float64 { return value } +func proxmoxModelCPURatioPercent(value float64) float64 { + return clampWorkloadPercent(value * 100) +} + func clampNonNegativeWorkloadValue(value float64) float64 { if value != value { return 0 @@ -9010,7 +9014,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) if vm == nil { return points } - points["cpu"] = monitoring.MetricPoint{Timestamp: now, Value: vm.CPU} + points["cpu"] = monitoring.MetricPoint{Timestamp: now, Value: proxmoxModelCPURatioPercent(vm.CPU)} points["memory"] = monitoring.MetricPoint{Timestamp: now, Value: vm.Memory.Usage} if vm.Disk.Usage >= 0 { points["disk"] = monitoring.MetricPoint{Timestamp: now, Value: vm.Disk.Usage} @@ -9024,7 +9028,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) if ct == nil { return points } - points["cpu"] = monitoring.MetricPoint{Timestamp: now, Value: ct.CPU} + points["cpu"] = monitoring.MetricPoint{Timestamp: now, Value: proxmoxModelCPURatioPercent(ct.CPU)} points["memory"] = monitoring.MetricPoint{Timestamp: now, Value: ct.Memory.Usage} if ct.Disk.Usage >= 0 { points["disk"] = monitoring.MetricPoint{Timestamp: now, Value: ct.Disk.Usage} @@ -9038,7 +9042,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) if node == nil { return points } - points["cpu"] = monitoring.MetricPoint{Timestamp: now, Value: node.CPU} + points["cpu"] = monitoring.MetricPoint{Timestamp: now, Value: proxmoxModelCPURatioPercent(node.CPU)} points["memory"] = monitoring.MetricPoint{Timestamp: now, Value: node.Memory.Usage} points["disk"] = monitoring.MetricPoint{Timestamp: now, Value: node.Disk.Usage} if temperature := primaryNodeTemperatureCelsius(node.Temperature); temperature != nil { @@ -9093,7 +9097,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) if node == nil { return points } - points["cpu"] = monitoring.MetricPoint{Timestamp: now, Value: node.CPU} + points["cpu"] = monitoring.MetricPoint{Timestamp: now, Value: proxmoxModelCPURatioPercent(node.CPU)} points["memory"] = monitoring.MetricPoint{Timestamp: now, Value: node.Memory.Usage} points["disk"] = monitoring.MetricPoint{Timestamp: now, Value: node.Disk.Usage} if temperature := primaryNodeTemperatureCelsius(node.Temperature); temperature != nil { diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 777a25670..5271c5d7d 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2917,7 +2917,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 1168, + "line": 1172, "heading_line": 137, } ],