Fix Proxmox live chart CPU fallback

Refs #1525
This commit is contained in:
rcourtman 2026-07-06 15:05:18 +01:00
parent ebd61ee109
commit 6f582671b0
4 changed files with 69 additions and 7 deletions

View file

@ -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,

View file

@ -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{

View file

@ -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 {

View file

@ -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,
}
],