Complete mock drawer history coverage

Contract-Neutral: restores existing metricsTarget identity and complete mock history fallback contracts without changing their public shape
This commit is contained in:
rcourtman
2026-08-26 12:07:53 +01:00
parent 18b74c0adb
commit 5d46e7d0e7
11 changed files with 404 additions and 38 deletions
+17 -14
View File
@@ -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"
]
}
@@ -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(
{
@@ -8,7 +8,7 @@ import type {
} from '@/components/Workloads/guestDrawerModel';
export interface DockerHostDrawerHistoryTarget extends GuestDrawerHistoryTarget {
resourceType: Extract<HistoryResourceType, 'agent'>;
resourceType: Extract<HistoryResourceType, 'agent' | 'docker-host'>;
}
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;
@@ -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)
+40 -6
View File
@@ -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,
+8 -3
View File
@@ -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 {
+63 -3
View File
@@ -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)
}
}
}
@@ -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)
}
}
}
+20 -9
View File
@@ -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)
}
@@ -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)
@@ -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 {