From 5d46e7d0e76ddd069a12f62fb670fd3ec0356e9e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 26 Aug 2026 12:07:53 +0100 Subject: [PATCH] Complete mock drawer history coverage Contract-Neutral: restores existing metricsTarget identity and complete mock history fallback contracts without changing their public shape --- frontend-modern/browser-verification.json | 31 ++--- .../__tests__/dockerHostDrawerModel.test.ts | 14 +++ .../features/docker/dockerHostDrawerModel.ts | 8 +- internal/api/metrics_history_fallback_test.go | 118 ++++++++++++++++++ internal/api/router.go | 46 ++++++- internal/monitoring/mock_chart_history.go | 11 +- internal/monitoring/mock_metrics_history.go | 66 +++++++++- .../monitoring/mock_metrics_history_test.go | 57 ++++++++- internal/monitoring/monitor_metrics.go | 29 +++-- .../monitor_metrics_chart_fallback_test.go | 60 +++++++++ ...temperature_buffer_branchcov0723am_test.go | 2 +- 11 files changed, 404 insertions(+), 38 deletions(-) diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 892ad781f..981caf957 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,19 +1,19 @@ { "version": 1, - "base_sha": "4d457159fc7684e7e63c9fd68b85db313300f2ed", - "verified_at": "2026-08-26T11:03:57Z", + "base_sha": "18b74c0adb07749cdd00caf9fc01ec09849029cd", + "verified_at": "2026-08-26T11:10:47Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/utils/alertWebhookPresentation.ts" + "frontend-modern/src/features/docker/dockerHostDrawerModel.ts" ], "content_sha256": { - "frontend-modern/src/utils/alertWebhookPresentation.ts": "aaf1bc2327b6b232470569f9110159a88222e7c8cd7b0bf4e473ff0c34eb5147" + "frontend-modern/src/features/docker/dockerHostDrawerModel.ts": "af67dd2e6ae26a4c220ba77342842a5782a52dae77d664b5b56d37e996abdb79" }, - "routes": ["/alerts/notifications"], + "routes": ["/docker/overview", "/proxmox/overview"], "viewports": [ { - "width": 1280, - "height": 800 + "width": 1230, + "height": 1234 }, { "width": 390, @@ -21,14 +21,17 @@ } ], "states": [ - "new Generic webhook editor with its default custom-payload placeholder visible", - "translation-ready variable list showing Event, MessageKey, ResourceType and NodeDisplayName", - "narrow webhook editor with no page-level horizontal overflow" + "Docker host History at 24 hours and 7 days with utilization, network I/O, disk I/O, and thermals populated", + "Docker app-container History at 24 hours and 7 days with utilization, network I/O, and disk I/O populated", + "Proxmox guest History with utilization, network I/O, and disk I/O populated", + "Proxmox node History at 24 hours and 7 days with utilization, network I/O, disk I/O, and thermals populated", + "narrow Docker host History stacked without document-level horizontal overflow" ], "interactions": [ - "signed into the current local mock build and opened Alerts, Notifications, then Add Webhook", - "confirmed the Generic payload placeholder includes event, message_key and resource_type at desktop width", - "confirmed the available-variable help exposes MessageKey, ResourceType and NodeDisplayName", - "rechecked the open editor at 390 by 844 and measured document scroll width equal to the 390-pixel viewport" + "opened Edge Apps 01 from the Docker hosts table and selected History; confirmed four chart groups and zero Collecting history placeholders", + "opened nextcloud from the Docker containers table and selected History; confirmed three chart groups and zero Collecting history placeholders", + "switched Docker host, Docker container, and Proxmox node History selectors to 7 days; every expected group remained populated", + "opened checkout-web-265 and Analytics A on the Proxmox overview; guest and node History drawers rendered their full chart catalogs", + "rechecked Edge Apps 01 at 390 by 844; confirmed four stacked groups, no horizontal overflow, and row focus return after collapse" ] } diff --git a/frontend-modern/src/features/docker/__tests__/dockerHostDrawerModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerHostDrawerModel.test.ts index fb162679e..8531214a4 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerHostDrawerModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerHostDrawerModel.test.ts @@ -12,6 +12,7 @@ type HostOverrides = { agent?: { agentId?: string } | undefined; id?: string; name?: string; + metricsTarget?: Resource['metricsTarget']; temperature?: number; docker?: { temperature?: number } | undefined; }; @@ -25,12 +26,25 @@ const makeHost = (over: HostOverrides = {}): Resource => name: over.name ?? '', type: 'docker-host', agent: over.agent, + metricsTarget: over.metricsTarget, temperature: over.temperature, docker: over.docker, }) as unknown as Resource; describe('dockerHostDrawerModel', () => { describe('getDockerHostDrawerHistoryTarget', () => { + it('prefers the canonical backend metrics target over display and agent identities', () => { + expect( + getDockerHostDrawerHistoryTarget( + makeHost({ + metricsTarget: { resourceType: 'docker-host', resourceId: ' runtime-host-9 ' }, + agent: { agentId: 'agent-9' }, + id: 'display-row-9', + }), + ), + ).toEqual({ resourceType: 'docker-host', resourceId: 'runtime-host-9' }); + }); + it('builds an agent-scoped target from the agent agentId', () => { expect(getDockerHostDrawerHistoryTarget(makeHost({ agent: { agentId: 'agent-9' } }))).toEqual( { diff --git a/frontend-modern/src/features/docker/dockerHostDrawerModel.ts b/frontend-modern/src/features/docker/dockerHostDrawerModel.ts index 539e00cee..3c1e31e8b 100644 --- a/frontend-modern/src/features/docker/dockerHostDrawerModel.ts +++ b/frontend-modern/src/features/docker/dockerHostDrawerModel.ts @@ -8,7 +8,7 @@ import type { } from '@/components/Workloads/guestDrawerModel'; export interface DockerHostDrawerHistoryTarget extends GuestDrawerHistoryTarget { - resourceType: Extract; + resourceType: Extract; } const stripAgentPrefix = (value: string): string => @@ -22,6 +22,12 @@ export const DOCKER_HOST_DRAWER_HISTORY_GROUPS: GuestDrawerHistoryGroupConfig[] export const getDockerHostDrawerHistoryTarget = ( host: Resource, ): DockerHostDrawerHistoryTarget | null => { + const explicitType = host.metricsTarget?.resourceType; + const explicitId = host.metricsTarget?.resourceId.trim(); + if ((explicitType === 'agent' || explicitType === 'docker-host') && explicitId) { + return { resourceType: explicitType, resourceId: explicitId }; + } + const candidate = host.agent?.agentId || host.id || host.name || ''; const resourceId = stripAgentPrefix(candidate.trim()); if (!resourceId) return null; diff --git a/internal/api/metrics_history_fallback_test.go b/internal/api/metrics_history_fallback_test.go index 1cc148fef..17d3e7ad4 100644 --- a/internal/api/metrics_history_fallback_test.go +++ b/internal/api/metrics_history_fallback_test.go @@ -201,6 +201,124 @@ func TestMetricsHistoryFallbackSynthesizesUnseededMockWorkloadHistory(t *testing } } +func TestMetricsHistoryFallbackSynthesizesMockHostAndNodeDrawerSeries(t *testing.T) { + setMockModeForTest(t, true) + + monitor := &monitoring.Monitor{} + setUnexportedField(t, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour)) + router := &Router{monitor: monitor} + + for _, tc := range []struct { + name string + resourceType string + resourceID string + metrics []string + }{ + { + name: "docker host", + resourceType: "docker-host", + resourceID: "runtime-host-9", + metrics: []string{"cpu", "netin", "diskread", "temperature"}, + }, + { + name: "Proxmox node", + resourceType: "node", + resourceID: "cluster-1-node-9", + metrics: []string{"cpu", "netin", "temperature"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest( + http.MethodGet, + "/api/metrics-store/history?resourceType="+tc.resourceType+"&resourceId="+tc.resourceID+"&range=24h", + nil, + ) + rec := httptest.NewRecorder() + router.handleMetricsHistory(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Source string `json:"source"` + Metrics map[string][]json.RawMessage `json:"metrics"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Source != "mock_synthetic" { + t.Fatalf("expected source mock_synthetic, got %q", resp.Source) + } + for _, metric := range tc.metrics { + if len(resp.Metrics[metric]) < 2 { + t.Fatalf("expected drawable %s history, got %d points", metric, len(resp.Metrics[metric])) + } + } + }) + } +} + +func TestMetricsHistoryFallbackSupplementsPartialMockStoreSeries(t *testing.T) { + setMockModeForTest(t, true) + + store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir())) + if err != nil { + t.Fatalf("metrics.NewStore() error = %v", err) + } + defer func() { _ = store.Close() }() + + resourceID := "container-partial-1" + now := time.Now().UTC() + for _, sample := range []struct { + offset time.Duration + value float64 + }{ + {-50 * time.Minute, 15}, + {-25 * time.Minute, 25}, + {-time.Minute, 35}, + } { + store.Write("dockerContainer", resourceID, "cpu", sample.value, now.Add(sample.offset)) + } + store.Flush() + + monitor := &monitoring.Monitor{} + setUnexportedField(t, monitor, "metricsHistory", monitoring.NewMetricsHistory(10, time.Hour)) + setUnexportedField(t, monitor, "metricsStore", store) + router := &Router{monitor: monitor} + + req := httptest.NewRequest( + http.MethodGet, + "/api/metrics-store/history?resourceType=app-container&resourceId="+resourceID+"&range=1h", + nil, + ) + rec := httptest.NewRecorder() + router.handleMetricsHistory(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Source string `json:"source"` + Metrics map[string][]json.RawMessage `json:"metrics"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Source != "mock_synthetic" { + t.Fatalf("expected supplemented source mock_synthetic, got %q", resp.Source) + } + if len(resp.Metrics["cpu"]) != 3 { + t.Fatalf("expected the three stored CPU points to be preserved, got %d", len(resp.Metrics["cpu"])) + } + for _, metricType := range []string{"netin", "netout", "diskread", "diskwrite"} { + if len(resp.Metrics[metricType]) < 2 { + t.Fatalf("expected missing %s store history to be supplemented, got %d points", metricType, len(resp.Metrics[metricType])) + } + } +} + func TestMetricsHistoryFallbackMockDiskSynthesizesSeries(t *testing.T) { setMockModeForTest(t, true) diff --git a/internal/api/router.go b/internal/api/router.go index 93702391c..20fb6b3b2 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -6827,7 +6827,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) } return buildHistoryPoints(points, stepSecs), guestChartSource(), true case "node": - points := monitor.GetNodeMetrics(resourceID, metricType, duration) + points := monitor.GetNodeMetricsForChart(resourceID, metricType, duration) if len(points) == 0 { livePoints := liveMetricPoints(runtimeResourceType, resourceID) if live, ok := livePoints[metricType]; ok { @@ -6835,7 +6835,11 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) } return nil, "", false } - return buildHistoryPoints(points, stepSecs), historySourceMemory, true + source := historySourceMemory + if mock.IsMockEnabled() { + source = historySourceMock + } + return buildHistoryPoints(points, stepSecs), source, true case "storage": metrics := monitor.GetStorageMetrics(resourceID, duration) points := metrics[queryMetric] @@ -6873,6 +6877,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) var metrics map[string][]monitoring.MetricPoint guestHistory := false + source := historySourceMemory switch runtimeResourceType { case "vm", "system-container", "oci-container", "k8s", "docker-host": metrics, guestHistory = guestChartMetrics() @@ -6899,9 +6904,15 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) default: if runtimeResourceType == "node" { metrics = map[string][]monitoring.MetricPoint{ - "cpu": monitor.GetNodeMetrics(resourceID, "cpu", duration), - "memory": monitor.GetNodeMetrics(resourceID, "memory", duration), - "disk": monitor.GetNodeMetrics(resourceID, "disk", duration), + "cpu": monitor.GetNodeMetricsForChart(resourceID, "cpu", duration), + "memory": monitor.GetNodeMetricsForChart(resourceID, "memory", duration), + "disk": monitor.GetNodeMetricsForChart(resourceID, "disk", duration), + "netin": monitor.GetNodeMetricsForChart(resourceID, "netin", duration), + "netout": monitor.GetNodeMetricsForChart(resourceID, "netout", duration), + "temperature": monitor.GetNodeMetricsForChart(resourceID, "temperature", duration), + } + if mock.IsMockEnabled() { + source = historySourceMock } } else { return nil, "", false @@ -6909,7 +6920,6 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) } apiData := make(map[string][]map[string]interface{}) - source := historySourceMemory if guestHistory { source = guestChartSource() } @@ -7165,6 +7175,30 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) apiData[metric] = apiPoints } + // QueryAll can return a non-empty but incomplete mock metric map. Fill + // only the absent series from the deterministic chart fallback so one + // populated metric cannot suppress entire drawer groups. + if mock.IsMockEnabled() { + if fallbackData, fallbackSource, ok := fallbackAll(); ok { + supplemented := false + for metric, points := range fallbackData { + if len(apiData[metric]) > 0 || len(points) == 0 { + continue + } + apiData[metric] = points + supplemented = true + } + if supplemented { + source = fallbackSource + log.Info(). + Str("resourceType", runtimeResourceType). + Str("resourceId", resourceID). + Str("source", source). + Msg("Metrics store incomplete; supplementing missing mock history series") + } + } + } + response = map[string]interface{}{ "resourceType": responseResourceType, "resourceId": resourceID, diff --git a/internal/monitoring/mock_chart_history.go b/internal/monitoring/mock_chart_history.go index 586d24fae..7fceca8c4 100644 --- a/internal/monitoring/mock_chart_history.go +++ b/internal/monitoring/mock_chart_history.go @@ -10,7 +10,8 @@ import ( var ( mockGuestChartMetricTypes = []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout"} - mockNodeChartMetricTypes = []string{"cpu", "memory", "disk", "netin", "netout"} + mockHostChartMetricTypes = []string{"cpu", "memory", "disk", "diskread", "diskwrite", "netin", "netout", "temperature"} + mockNodeChartMetricTypes = []string{"cpu", "memory", "disk", "netin", "netout", "temperature"} mockDiskChartMetricTypes = []string{"disk", "diskread", "diskwrite", "smart_temp"} ) @@ -77,8 +78,12 @@ func mockGuestMetricsForChart(resourceType, resourceID string, duration time.Dur } timestamps := mockChartTimestamps(duration) - result := make(map[string][]MetricPoint, len(mockGuestChartMetricTypes)+1) - for _, metricType := range mockGuestChartMetricTypes { + metricTypes := mockGuestChartMetricTypes + if resourceType == "agent" || resourceType == "dockerHost" { + metricTypes = mockHostChartMetricTypes + } + result := make(map[string][]MetricPoint, len(metricTypes)+1) + for _, metricType := range metricTypes { result[metricType] = mockCanonicalMetricSeries(resourceType, resourceID, metricType, timestamps) } if series := mockGuestMemoryUsedSeries(resourceType, resourceID, result["memory"]); len(series) > 0 { diff --git a/internal/monitoring/mock_metrics_history.go b/internal/monitoring/mock_metrics_history.go index 37f9a48eb..6c3b36042 100644 --- a/internal/monitoring/mock_metrics_history.go +++ b/internal/monitoring/mock_metrics_history.go @@ -700,11 +700,17 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, graph mock.Fi cpuSeries := canonicalMetricSeriesWithSampler(sampler, "node", node.ID, "cpu", seedTimestamps) memSeries := canonicalMetricSeriesWithSampler(sampler, "node", node.ID, "memory", seedTimestamps) diskSeries := canonicalMetricSeriesWithSampler(sampler, "node", node.ID, "disk", seedTimestamps) + netInSeries := canonicalMetricSeriesWithSampler(sampler, "node", node.ID, "netin", seedTimestamps) + netOutSeries := canonicalMetricSeriesWithSampler(sampler, "node", node.ID, "netout", seedTimestamps) + temperatureSeries := canonicalMetricSeriesWithSampler(sampler, "node", node.ID, "temperature", seedTimestamps) mh.addNodeMetricSeries(node.ID, "cpu", cpuSeries, seedTimestamps) mh.addNodeMetricSeries(node.ID, "memory", memSeries, seedTimestamps) mh.addNodeMetricSeries(node.ID, "disk", diskSeries, seedTimestamps) - seedStoreSeries("node", node.ID, "cpu", "memory", "disk") + mh.addNodeMetricSeries(node.ID, "netin", netInSeries, seedTimestamps) + mh.addNodeMetricSeries(node.ID, "netout", netOutSeries, seedTimestamps) + mh.addNodeMetricSeries(node.ID, "temperature", temperatureSeries, seedTimestamps) + seedStoreSeries("node", node.ID, "cpu", "memory", "disk", "netin", "netout", "temperature") } recordGuest := func( @@ -777,6 +783,24 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, graph mock.Fi } seedStoreSeries(storeType, storeID, storeMetrics...) } + recordGuestTemperature := func(metricIDs []string, storeType, storeID string) { + if len(metricIDs) == 0 || strings.TrimSpace(storeID) == "" { + return + } + temperatureSeries := canonicalMetricSeriesWithSampler( + sampler, + storeType, + storeID, + "temperature", + seedTimestamps, + ) + for _, metricID := range metricIDs { + if id := strings.TrimSpace(metricID); id != "" { + mh.addGuestMetricSeries(id, "temperature", temperatureSeries, seedTimestamps) + } + } + seedStoreSeries(storeType, storeID, "temperature") + } recordGuestMemoryUsed := func(metricIDs []string, storeType, storeID string, memoryTotal float64) { if len(metricIDs) == 0 || strings.TrimSpace(storeID) == "" || memoryTotal <= 0 { return @@ -947,6 +971,7 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, graph mock.Fi true, true, ) + recordGuestTemperature([]string{"dockerHost:" + host.ID}, "dockerHost", host.ID) for _, container := range host.Containers { if container.ID == "" { @@ -957,8 +982,8 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, graph mock.Fi "dockerContainer", container.ID, true, - false, - false, + true, + true, ) } } @@ -977,6 +1002,7 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, graph mock.Fi true, true, ) + recordGuestTemperature([]string{"agent:" + host.ID}, "agent", host.ID) } platformFixtures := graph.PlatformFixtures @@ -996,6 +1022,7 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, graph mock.Fi true, true, ) + recordGuestTemperature(systemMetricIDs, "agent", trueNASFixtures.System.Hostname) for _, pool := range trueNASFixtures.Pools { poolKey := mock.TrueNASPoolMetricID(trueNASFixtures.System.Hostname, pool.Name) @@ -1465,9 +1492,13 @@ func recordMockStateToMetricsHistory(mh *MetricsHistory, ms *metrics.Store, grap cpu := sampler.SampleMetric("node", node.ID, "cpu", ts) memory := sampler.SampleMetric("node", node.ID, "memory", ts) disk := sampler.SampleMetric("node", node.ID, "disk", ts) + netIn := sampler.SampleMetric("node", node.ID, "netin", ts) + netOut := sampler.SampleMetric("node", node.ID, "netout", ts) mh.AddNodeMetric(node.ID, "cpu", cpu, ts) mh.AddNodeMetric(node.ID, "memory", memory, ts) mh.AddNodeMetric(node.ID, "disk", disk, ts) + mh.AddNodeMetric(node.ID, "netin", netIn, ts) + mh.AddNodeMetric(node.ID, "netout", netOut, ts) if temperature := nodePrimaryTemperatureCelsius(node.Temperature); temperature != nil { mh.AddNodeMetric(node.ID, "temperature", *temperature, ts) } @@ -1476,6 +1507,8 @@ func recordMockStateToMetricsHistory(mh *MetricsHistory, ms *metrics.Store, grap ms.Write("node", node.ID, "cpu", cpu, ts) ms.Write("node", node.ID, "memory", memory, ts) ms.Write("node", node.ID, "disk", disk, ts) + ms.Write("node", node.ID, "netin", netIn, ts) + ms.Write("node", node.ID, "netout", netOut, ts) if temperature := nodePrimaryTemperatureCelsius(node.Temperature); temperature != nil { ms.Write("node", node.ID, "temperature", *temperature, ts) } @@ -1622,14 +1655,29 @@ func recordMockStateToMetricsHistory(mh *MetricsHistory, ms *metrics.Store, grap cpu := sampler.SampleMetric("dockerHost", host.ID, "cpu", ts) memory := sampler.SampleMetric("dockerHost", host.ID, "memory", ts) disk := sampler.SampleMetric("dockerHost", host.ID, "disk", ts) + diskRead := sampler.SampleMetric("dockerHost", host.ID, "diskread", ts) + diskWrite := sampler.SampleMetric("dockerHost", host.ID, "diskwrite", ts) + netIn := sampler.SampleMetric("dockerHost", host.ID, "netin", ts) + netOut := sampler.SampleMetric("dockerHost", host.ID, "netout", ts) + temperature := sampler.SampleMetric("dockerHost", host.ID, "temperature", ts) mh.AddGuestMetric(hostKey, "cpu", cpu, ts) mh.AddGuestMetric(hostKey, "memory", memory, ts) mh.AddGuestMetric(hostKey, "disk", disk, ts) + mh.AddGuestMetric(hostKey, "diskread", diskRead, ts) + mh.AddGuestMetric(hostKey, "diskwrite", diskWrite, ts) + mh.AddGuestMetric(hostKey, "netin", netIn, ts) + mh.AddGuestMetric(hostKey, "netout", netOut, ts) + mh.AddGuestMetric(hostKey, "temperature", temperature, ts) if ms != nil { ms.Write("dockerHost", host.ID, "cpu", cpu, ts) ms.Write("dockerHost", host.ID, "memory", memory, ts) ms.Write("dockerHost", host.ID, "disk", disk, ts) + ms.Write("dockerHost", host.ID, "diskread", diskRead, ts) + ms.Write("dockerHost", host.ID, "diskwrite", diskWrite, ts) + ms.Write("dockerHost", host.ID, "netin", netIn, ts) + ms.Write("dockerHost", host.ID, "netout", netOut, ts) + ms.Write("dockerHost", host.ID, "temperature", temperature, ts) } for _, container := range host.Containers { @@ -1641,14 +1689,26 @@ func recordMockStateToMetricsHistory(mh *MetricsHistory, ms *metrics.Store, grap cpu := sampler.SampleMetric("dockerContainer", container.ID, "cpu", ts) memory := sampler.SampleMetric("dockerContainer", container.ID, "memory", ts) disk := sampler.SampleMetric("dockerContainer", container.ID, "disk", ts) + diskRead := sampler.SampleMetric("dockerContainer", container.ID, "diskread", ts) + diskWrite := sampler.SampleMetric("dockerContainer", container.ID, "diskwrite", ts) + netIn := sampler.SampleMetric("dockerContainer", container.ID, "netin", ts) + netOut := sampler.SampleMetric("dockerContainer", container.ID, "netout", ts) mh.AddGuestMetric(metricKey, "cpu", cpu, ts) mh.AddGuestMetric(metricKey, "memory", memory, ts) mh.AddGuestMetric(metricKey, "disk", disk, ts) + mh.AddGuestMetric(metricKey, "diskread", diskRead, ts) + mh.AddGuestMetric(metricKey, "diskwrite", diskWrite, ts) + mh.AddGuestMetric(metricKey, "netin", netIn, ts) + mh.AddGuestMetric(metricKey, "netout", netOut, ts) if ms != nil { ms.Write("dockerContainer", container.ID, "cpu", cpu, ts) ms.Write("dockerContainer", container.ID, "memory", memory, ts) ms.Write("dockerContainer", container.ID, "disk", disk, ts) + ms.Write("dockerContainer", container.ID, "diskread", diskRead, ts) + ms.Write("dockerContainer", container.ID, "diskwrite", diskWrite, ts) + ms.Write("dockerContainer", container.ID, "netin", netIn, ts) + ms.Write("dockerContainer", container.ID, "netout", netOut, ts) } } } diff --git a/internal/monitoring/mock_metrics_history_test.go b/internal/monitoring/mock_metrics_history_test.go index b6aa65860..15a7cae99 100644 --- a/internal/monitoring/mock_metrics_history_test.go +++ b/internal/monitoring/mock_metrics_history_test.go @@ -1898,6 +1898,9 @@ func TestRecordMockStateToMetricsHistory_UsesCanonicalMetricModelForStateBackedR if got, want := mh.GetNodeMetrics("node-live", "cpu", lookback)[0].Value, mock.SampleMetric("node", "node-live", "cpu", ts); math.Abs(got-want) > 1e-9 { t.Fatalf("expected node cpu live tick to use canonical metric model: got=%f want=%f", got, want) } + if got, want := mh.GetNodeMetrics("node-live", "netin", lookback)[0].Value, mock.SampleMetric("node", "node-live", "netin", ts); math.Abs(got-want) > 1e-9 { + t.Fatalf("expected node netin live tick to use canonical metric model: got=%f want=%f", got, want) + } if got, want := mh.GetGuestMetrics("vm-live", "diskread", lookback)[0].Value, mock.SampleMetric("vm", "vm-live", "diskread", ts); math.Abs(got-want) > 1e-9 { t.Fatalf("expected vm diskread live tick to use canonical metric model: got=%f want=%f", got, want) } @@ -1921,9 +1924,15 @@ func TestRecordMockStateToMetricsHistory_UsesCanonicalMetricModelForStateBackedR if got, want := mh.GetGuestMetrics("dockerHost:docker-host-live", "disk", lookback)[0].Value, mock.SampleMetric("dockerHost", "docker-host-live", "disk", ts); math.Abs(got-want) > 1e-9 { t.Fatalf("expected docker host disk live tick to use canonical metric model: got=%f want=%f", got, want) } + if got, want := mh.GetGuestMetrics("dockerHost:docker-host-live", "temperature", lookback)[0].Value, mock.SampleMetric("dockerHost", "docker-host-live", "temperature", ts); math.Abs(got-want) > 1e-9 { + t.Fatalf("expected docker host temperature live tick to use canonical metric model: got=%f want=%f", got, want) + } if got, want := mh.GetGuestMetrics("docker:docker-cont-live", "cpu", lookback)[0].Value, mock.SampleMetric("dockerContainer", "docker-cont-live", "cpu", ts); math.Abs(got-want) > 1e-9 { t.Fatalf("expected docker container cpu live tick to use canonical metric model: got=%f want=%f", got, want) } + if got, want := mh.GetGuestMetrics("docker:docker-cont-live", "netin", lookback)[0].Value, mock.SampleMetric("dockerContainer", "docker-cont-live", "netin", ts); math.Abs(got-want) > 1e-9 { + t.Fatalf("expected docker container netin live tick to use canonical metric model: got=%f want=%f", got, want) + } if got, want := mh.GetGuestMetrics("agent:agent-live", "netin", lookback)[0].Value, mock.SampleMetric("agent", "agent-live", "netin", ts); math.Abs(got-want) > 1e-9 { t.Fatalf("expected agent netin live tick to use canonical metric model: got=%f want=%f", got, want) } @@ -2058,10 +2067,56 @@ func TestMockDockerHostSeedCoversTheSameSeriesAsTheSyntheticGenerator(t *testing seedMockMetricsHistory(history, nil, graph, now, 2*time.Hour, time.Minute) hostID := graph.State.DockerHosts[0].ID - for _, metricType := range mockGuestChartMetricTypes { + for _, metricType := range mockHostChartMetricTypes { points := history.GetGuestMetrics("dockerHost:"+hostID, metricType, 2*time.Hour) if len(points) == 0 { t.Fatalf("seeded docker host %s is missing the %s series", hostID, metricType) } } } + +func TestMockDockerContainerSeedCoversDrawerHistorySeries(t *testing.T) { + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, true) + defer mustSetMockEnabled(t, previous) + + history := NewMetricsHistory(4096, 24*time.Hour) + graph := mock.CurrentFixtureGraph() + if len(graph.State.DockerHosts) == 0 || len(graph.State.DockerHosts[0].Containers) == 0 { + t.Fatal("mock fixture graph has no Docker containers") + } + + now := time.Now().UTC() + seedMockMetricsHistory(history, nil, graph, now, 2*time.Hour, time.Minute) + + containerID := graph.State.DockerHosts[0].Containers[0].ID + for _, metricType := range mockGuestChartMetricTypes { + points := history.GetGuestMetrics("docker:"+containerID, metricType, 2*time.Hour) + if len(points) == 0 { + t.Fatalf("seeded Docker container %s is missing the %s series", containerID, metricType) + } + } +} + +func TestMockNodeSeedCoversDrawerHistorySeries(t *testing.T) { + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, true) + defer mustSetMockEnabled(t, previous) + + history := NewMetricsHistory(4096, 24*time.Hour) + graph := mock.CurrentFixtureGraph() + if len(graph.State.Nodes) == 0 { + t.Fatal("mock fixture graph has no Proxmox nodes") + } + + now := time.Now().UTC() + seedMockMetricsHistory(history, nil, graph, now, 2*time.Hour, time.Minute) + + nodeID := graph.State.Nodes[0].ID + for _, metricType := range mockNodeChartMetricTypes { + points := history.GetNodeMetrics(nodeID, metricType, 2*time.Hour) + if len(points) == 0 { + t.Fatalf("seeded node %s is missing the %s series", nodeID, metricType) + } + } +} diff --git a/internal/monitoring/monitor_metrics.go b/internal/monitoring/monitor_metrics.go index 86f5a8026..0e8d37a4d 100644 --- a/internal/monitoring/monitor_metrics.go +++ b/internal/monitoring/monitor_metrics.go @@ -1319,17 +1319,28 @@ func (m *Monitor) mockGuestMetricsForChart( return cached } - var computed map[string][]MetricPoint - switch { - case hasSufficientChartMapCoverage(inMemoryResult, duration): - computed = downsampleMetricMapForMockChart(inMemoryResult, duration) - case len(sqlResourceID) > 0: - if synthetic := mockGuestMetricsForChart(sqlResourceType, sqlResourceID, duration); len(synthetic) > 0 { - computed = downsampleMetricMapForMockChart(synthetic, duration) + // Resolve mock coverage per series, not per resource. A resource can have a + // full window of CPU/memory/disk while I/O or thermal series are absent; a + // resource-level coverage check would then return the partial map unchanged + // and leave whole drawer groups stuck in "Collecting history". + synthetic := mockGuestMetricsForChart(sqlResourceType, sqlResourceID, duration) + computed := make(map[string][]MetricPoint, len(inMemoryResult)+len(synthetic)) + for metricType, points := range inMemoryResult { + if hasSufficientChartSeriesCoverage(points, duration) { + computed[metricType] = downsampleMetricSeriesForMockChart(points, duration) + continue } + if fallback := synthetic[metricType]; len(fallback) > 0 { + computed[metricType] = downsampleMetricSeriesForMockChart(fallback, duration) + continue + } + computed[metricType] = downsampleMetricSeriesForMockChart(points, duration) } - if computed == nil { - computed = downsampleMetricMapForMockChart(inMemoryResult, duration) + for metricType, points := range synthetic { + if len(computed[metricType]) > 0 { + continue + } + computed[metricType] = downsampleMetricSeriesForMockChart(points, duration) } return m.writeMockChartMetricMapCache(key, computed) } diff --git a/internal/monitoring/monitor_metrics_chart_fallback_test.go b/internal/monitoring/monitor_metrics_chart_fallback_test.go index b161a7b80..df1525dbe 100644 --- a/internal/monitoring/monitor_metrics_chart_fallback_test.go +++ b/internal/monitoring/monitor_metrics_chart_fallback_test.go @@ -548,6 +548,66 @@ func TestGetGuestMetricsForChart_UsesCanonicalMockSamplerInMockMode(t *testing.T assertFollowsCanonicalMockSeries(t, result["memory"], "vm", "vm-1", "memory") } +func TestGetGuestMetricsForChart_SynthesizesMockHostTemperature(t *testing.T) { + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, true) + defer mustSetMockEnabled(t, previous) + + monitor := newChartFallbackTestMonitor(t) + for _, tc := range []struct { + name string + inMemoryKey string + sqlResourceType string + resourceID string + }{ + {name: "agent", inMemoryKey: "agent:host-1", sqlResourceType: "agent", resourceID: "host-1"}, + {name: "docker host", inMemoryKey: "dockerHost:runtime-1", sqlResourceType: "dockerHost", resourceID: "runtime-1"}, + } { + t.Run(tc.name, func(t *testing.T) { + result := monitor.GetGuestMetricsForChart(tc.inMemoryKey, tc.sqlResourceType, tc.resourceID, time.Hour) + if len(result["temperature"]) < 2 { + t.Fatalf("expected drawable mock temperature history, got %+v", result["temperature"]) + } + assertFollowsCanonicalMockSeries(t, result["temperature"], tc.sqlResourceType, tc.resourceID, "temperature") + }) + } +} + +func TestGetGuestMetricsForChart_SupplementsPartialMockHistoryPerSeries(t *testing.T) { + previous := mock.IsMockEnabled() + mustSetMockEnabled(t, true) + defer mustSetMockEnabled(t, previous) + + monitor := newChartFallbackTestMonitor(t) + now := time.Now().UTC().Truncate(time.Second) + duration := time.Hour + for _, sample := range []struct { + offset time.Duration + value float64 + }{ + {-58 * time.Minute, 12.5}, + {-30 * time.Minute, 37.25}, + {-1 * time.Minute, 81.75}, + } { + monitor.metricsHistory.AddGuestMetric("docker:container-1", "cpu", sample.value, now.Add(sample.offset)) + } + + result := monitor.GetGuestMetricsForChart( + "docker:container-1", + "dockerContainer", + "container-1", + duration, + ) + if len(result["cpu"]) != 3 || result["cpu"][0].Value != 12.5 || result["cpu"][2].Value != 81.75 { + t.Fatalf("expected seeded CPU history to win, got %+v", result["cpu"]) + } + for _, metricType := range []string{"netin", "netout", "diskread", "diskwrite"} { + if len(result[metricType]) < 2 { + t.Fatalf("expected missing %s history to be synthesized, got %+v", metricType, result[metricType]) + } + } +} + func TestGetGuestMetricsForChart_PrefersSeededMockHistoryInMockMode(t *testing.T) { previous := mock.IsMockEnabled() mustSetMockEnabled(t, true) diff --git a/internal/monitoring/temperature_buffer_branchcov0723am_test.go b/internal/monitoring/temperature_buffer_branchcov0723am_test.go index ad028f44c..8043e42d1 100644 --- a/internal/monitoring/temperature_buffer_branchcov0723am_test.go +++ b/internal/monitoring/temperature_buffer_branchcov0723am_test.go @@ -322,7 +322,7 @@ func TestBranchcov0723Am_MockNodeMetricsForChart(t *testing.T) { // Comprehensive ordering check across the default set + a custom type, // exercising every codepath that materialises MetricPoint slices. types := append([]string(nil), mockNodeChartMetricTypes...) - types = append(types, "temperature") + types = append(types, "custom_ordered_metric") got := mockNodeMetricsForChart(nodeID, types, 2*time.Hour) expectKeysExact(t, got, types) for k, pts := range got {