diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 05cd3ed0d..0fac683e6 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3096,6 +3096,13 @@ a new API state machine, queue contract, or verification-accounting field. safe remediation workflows. 29. Keep storage chart identity canonical on that same shared API surface: the shared storage charts endpoint must key pool and physical-disk series by the resolved unified-resource `MetricsTarget.ResourceID`, not by canonical resource IDs or page-local aliases, so storage rows, focused summary cards, sticky summary shells, and detail charts all address the same history series in live and mock mode. 30. Keep synthetic summary-chart fallback identity canonical on that same shared API surface: when `internal/api/chartapi/service.go` has to synthesize mock summary history for infrastructure, workloads, or storage cards, it must derive the fallback from canonical `resourceType`, `resourceID`, and `metricType` ownership instead of raw min/max seed-prefix helpers, so range changes and runtime mock updates stay on one governed timeline. + Mock storage follows the same rule at `/api/metrics-store/history`: when a + storage or Ceph resource has current capacity in canonical live state, the + handler must return its in-memory or deterministic `usage` series for both + metric-specific and all-metrics requests before querying the persistent + demo store. This includes provider-derived targets with no legacy history + row and legacy rows whose persistent series is still sparse while fleet + backfill is running. The same compact chart boundary also owns aggregate-only storage summary transport. `/api/charts/storage-summary` may batch only the canonical `used` and `avail` storage series required for the aggregate capacity diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index af2ba8901..d671119ab 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2447,6 +2447,19 @@ metrics-history retention and recovery-ingest ownership: increasing backup polling or server-side recovery enumeration must not expand the browser cache outside the fixed remount budget. +### Expanded storage history is poll-stable + +`useStoragePoolsTableWindowing.ts` reconciles flattened group and pool items by +their stable logical key. A refreshed unified-resource snapshot updates the +existing row proxy instead of remounting `StoragePoolDetail`, so the selected +Overview/History tab and history range survive normal polling. Provider-derived +mock storage and Ceph rows resolve capacity history through their canonical +metrics target and receive deterministic `usage` fallback from current unified +capacity when no legacy history row exists. The row-identity regression and +the metric-specific plus all-metrics backend regression are the required proof; +direct detail-component tests alone are insufficient because they do not cross +the polling/remount boundary. + ### vSphere member composition is inventory, not protection evidence ESXi host members projected under a vCenter connection in the grouped diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 8249cfe11..006031f9e 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,50 +1,21 @@ { "version": 1, - "base_sha": "3414a5abdad055f99192d44fc4d11becc6d73d7a", - "verified_at": "2026-08-26T12:08:15Z", + "base_sha": "5fcb0d665585802a94fa4a462eac877439e45088", + "verified_at": "2026-08-26T12:53:43Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestRow.tsx", - "frontend-modern/src/components/Workloads/WorkloadPanel.tsx", - "frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx", - "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx", - "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx", - "frontend-modern/src/components/Workloads/WorkloadsTable.tsx", - "frontend-modern/src/components/Workloads/guestRowModel.tsx", - "frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts", - "frontend-modern/src/components/Workloads/useWorkloadsState.ts", - "frontend-modern/src/components/Workloads/workloadColumnLayoutUrl.ts", - "frontend-modern/src/components/Workloads/workloadColumnWidths.ts", - "frontend-modern/src/components/Workloads/workloadsFilterModel.ts", - "frontend-modern/src/components/shared/ColumnPicker.tsx", - "frontend-modern/src/components/shared/columnPickerModel.ts", - "frontend-modern/src/components/shared/useColumnPickerState.ts", - "frontend-modern/src/index.css", - "frontend-modern/src/utils/localStorage.ts" + "frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestRow.tsx": "ca36c0adf46aacefe7f2d40c54fbb682077095e8754b6d6c3322ca73ecdfd310", - "frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "867aaa1c71471385f3a26d4aeb535e063f33fe32402d44e2695933ee45663416", - "frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx": "6a3b4e9e7efd702d1f9a9a549865808a1cfb80a259cfb65130beb8779655cc76", - "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "564caa9752878f3b416e0953a895ed2113b93090438da64f6c32524336c45c79", - "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "73e31b9b3c4ac4d28687b71c2f029ae4605c5697ddec19d604e4706c0184cebd", - "frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "955090567fd1302828d7e156e04b43e2c2841ac71f50ee5cd6dac8f3eabece50", - "frontend-modern/src/components/Workloads/guestRowModel.tsx": "fb6324c436f3b160d4655fb0d758956ed856c0b811a07c49bdf1a49d7375bb21", - "frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts": "e40a0a099092a7578f39b433b989c61219a01351f41009ea8fed6ab279f9ea78", - "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "9a6fd576040ec74f7e45d9af1622a0fad401e18c249ad93449dfa7e808b8250e", - "frontend-modern/src/components/Workloads/workloadColumnLayoutUrl.ts": "6df93e2f9ca5b7db0d2230cb4786700fe337cf6974630fa1f334e755fa63cc40", - "frontend-modern/src/components/Workloads/workloadColumnWidths.ts": "bb8db7a4891ec714e0ec7c42c821f6106f4a2e52f38bc885b47954d23873eb8a", - "frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "fe413753be3f22165e991630598f89952dfee65f2bdb8d4012e181d308b6f057", - "frontend-modern/src/components/shared/ColumnPicker.tsx": "93570c648a3fd2f8b085019a868715f00e0cb2bf41cdcb89c1fdcc6dafd468b0", - "frontend-modern/src/components/shared/columnPickerModel.ts": "374a27ad36f65531df322f424e8df813260df4d7ad0595b7afced661fcc1aa23", - "frontend-modern/src/components/shared/useColumnPickerState.ts": "548bb62622c4e81728435a3c93536df58d2664afc6bbc3b0a2576f30bbdd3ba6", - "frontend-modern/src/index.css": "7b28bd7056b4f846a9c71cc17f3fdef1f9dfaa2edb4491c91181ebd475129710", - "frontend-modern/src/utils/localStorage.ts": "bd1b11bf5aa0fbf216faf1c7df3bc1b6e6238c607c7ea46e65a69a876dabec60" + "frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts": "c98956a094139a8d32484c547d201f6413c7cf9a6c1d84d3ffe68e5f379321c6" }, - "routes": ["/proxmox/guests"], + "routes": [ + "/proxmox/storage?q=offsite-vault", + "/proxmox/storage?q=ceph-rbd" + ], "viewports": [ { - "width": 1280, + "width": 1440, "height": 900 }, { @@ -53,17 +24,14 @@ } ], "states": [ - "desktop responsive default with eight fitted columns and no horizontal overflow", - "desktop manual layout with all eleven selected columns and horizontal overflow", - "clean shared-link restoration with URL-owned widths and no stored preference", - "column picker explaining the active manual layout and offering Reset widths", - "narrow shared-link fallback with five responsive columns, no resize handles, and no page overflow" + "expanded PBS datastore History with populated 24-hour and 7-day capacity series", + "expanded Ceph pool History with populated 7-day capacity series", + "History tab and selected 24-hour range retained after live storage polling", + "narrow expanded PBS datastore History with chart, range control, row, and navigation visible" ], "interactions": [ - "dragged the Name header edge from 324 to 172 pixels; confirmed the cols URL, stored widths, eleven resize handles, and a 1420-pixel table inside a 1173-pixel scroll shell", - "cleared the workload-width preference and reloaded the shared cols URL; confirmed the 172-pixel Name width and complete manual layout without adopting the URL as a stored preference", - "opened View then Columns and selected Reset widths; confirmed the clean route, 324-pixel responsive Name width, eight handles, and no horizontal overflow", - "loaded the same shared cols URL at 390 by 844; confirmed the responsive five-column table, zero resize handles, and a document width equal to the viewport", - "inspected the browser console after desktop and narrow checks; no errors were reported" + "expanded offsite-vault, selected History, switched from 7 days to 24 hours, and confirmed the chart remained populated and selected across repeated live refreshes", + "rechecked the same expanded History state at 390 by 844 and confirmed the chart and selector remained visible, unclipped, and usable after polling", + "searched for ceph-rbd, expanded the row, selected History, and confirmed the populated 7-day chart and selected tab survived a live refresh cycle" ] } diff --git a/frontend-modern/src/components/Storage/__tests__/useStoragePoolsTableWindowing.test.ts b/frontend-modern/src/components/Storage/__tests__/useStoragePoolsTableWindowing.test.ts index 76e28e30b..c18585839 100644 --- a/frontend-modern/src/components/Storage/__tests__/useStoragePoolsTableWindowing.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useStoragePoolsTableWindowing.test.ts @@ -71,4 +71,43 @@ describe('useStoragePoolsTableWindowing', () => { expect(result.visibleItems()).toHaveLength(72); expect(result.visibleItems().some((item) => item.key === 'record:storage-150')).toBe(true); }); + + it('preserves row identity while live storage snapshots refresh', () => { + const [groups, setGroups] = createSignal([ + makeGroup('estate', [makeRecord(1)], { showHeader: false }), + ]); + const { result } = renderHook(() => + useStoragePoolsTableWindowing({ groups, expandedPoolId: () => 'storage-1' }), + ); + const firstItem = result.visibleItems()[0]; + const firstRecord = firstItem?.kind === 'record' ? firstItem.record : null; + + setGroups([ + makeGroup('estate', [{ ...makeRecord(1), name: 'pool-1 refreshed' }], { showHeader: false }), + ]); + + const refreshedItem = result.visibleItems()[0]; + expect(refreshedItem).toBe(firstItem); + expect(refreshedItem?.kind).toBe('record'); + if (refreshedItem?.kind !== 'record') throw new Error('expected storage record item'); + expect(refreshedItem.record).toBe(firstRecord); + expect(refreshedItem.record.name).toBe('pool-1 refreshed'); + }); + + it('preserves distinct row identity when the ordered snapshot reverses', () => { + const [groups, setGroups] = createSignal([ + makeGroup('estate', [makeRecord(1), makeRecord(2)], { showHeader: false }), + ]); + const { result } = renderHook(() => + useStoragePoolsTableWindowing({ groups, expandedPoolId: () => null }), + ); + const firstItems = [...result.visibleItems()]; + + setGroups([makeGroup('estate', [makeRecord(2), makeRecord(1)], { showHeader: false })]); + + const reversedItems = result.visibleItems(); + expect(reversedItems.map((item) => item.key)).toEqual(['record:storage-2', 'record:storage-1']); + expect(reversedItems[0]).toBe(firstItems[1]); + expect(reversedItems[1]).toBe(firstItems[0]); + }); }); diff --git a/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts b/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts index 043253ecf..4fb4578de 100644 --- a/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts +++ b/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts @@ -1,4 +1,5 @@ -import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js'; +import { batch, createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js'; +import { createStore, reconcile, type SetStoreFunction } from 'solid-js/store'; import { useTableWindowing } from '@/components/Infrastructure/useTableWindowing'; import { @@ -49,12 +50,41 @@ type UseStoragePoolsTableWindowingOptions = { expandedPoolId: Accessor; }; +type StableStoragePoolsTableItem = { + item: StoragePoolsTableItem; + setItem: SetStoreFunction; +}; + export const useStoragePoolsTableWindowing = (options: UseStoragePoolsTableWindowingOptions) => { const [bodyRef, setBodyRef] = createSignal(null); const [estimatedRowHeight, setEstimatedRowHeight] = createSignal( STORAGE_POOL_ESTIMATED_ROW_HEIGHT, ); - const items = createMemo(() => buildStoragePoolsTableItems(options.groups())); + const itemCache = new Map(); + const stabilizeItems = (nextItems: readonly StoragePoolsTableItem[]) => { + const liveKeys = new Set(nextItems.map((item) => item.key)); + const stableItems = nextItems.map((nextItem) => { + const cached = itemCache.get(nextItem.key); + if (cached && cached.item.kind === nextItem.kind) { + cached.setItem(reconcile(nextItem)); + return cached.item; + } + const [item, setItem] = createStore(nextItem); + itemCache.set(nextItem.key, { item, setItem }); + return item; + }); + for (const key of itemCache.keys()) { + if (!liveKeys.has(key)) itemCache.delete(key); + } + return stableItems; + }; + const [items, setItems] = createSignal( + stabilizeItems(buildStoragePoolsTableItems(options.groups())), + ); + createEffect(() => { + const nextItems = buildStoragePoolsTableItems(options.groups()); + batch(() => setItems(stabilizeItems(nextItems))); + }); const expandedRecordIndex = createMemo(() => { const expandedId = options.expandedPoolId(); if (!expandedId) return null; diff --git a/internal/api/chartapi/service.go b/internal/api/chartapi/service.go index 3cde9594e..df37457a7 100644 --- a/internal/api/chartapi/service.go +++ b/internal/api/chartapi/service.go @@ -1322,7 +1322,7 @@ func buildSyntheticMetricHistorySeries( current float64, ) []monitoring.MetricPoint { switch metricType { - case "disk", "diskread", "diskwrite": + case "disk", "diskread", "diskwrite", "usage": case "smart_temp": if current <= 0 { return nil diff --git a/internal/api/metrics_history_fallback_test.go b/internal/api/metrics_history_fallback_test.go index 17d3e7ad4..2ca0b1ada 100644 --- a/internal/api/metrics_history_fallback_test.go +++ b/internal/api/metrics_history_fallback_test.go @@ -372,6 +372,116 @@ func TestMetricsHistoryFallbackMockDiskSynthesizesSeries(t *testing.T) { } } +func TestMetricsHistoryFallbackMockUnifiedStorageSynthesizesSeries(t *testing.T) { + setMockModeForTest(t, true) + + used := int64(7_140_000_000_000) + total := int64(20_000_000_000_000) + resourceID := "offsite-vault" + registry := unifiedresources.NewRegistry(nil) + registry.IngestResources([]unifiedresources.Resource{ + { + ID: "storage-offsite-vault", + Type: unifiedresources.ResourceTypeStorage, + Name: "Offsite Vault", + Status: unifiedresources.StatusOnline, + LastSeen: time.Now().UTC(), + MetricsTarget: &unifiedresources.MetricsTarget{ + ResourceType: "storage", + ResourceID: resourceID, + }, + Metrics: &unifiedresources.ResourceMetrics{ + Disk: &unifiedresources.MetricValue{ + Used: &used, + Total: &total, + Percent: 35.7, + }, + }, + }, + }) + + monitor := &monitoring.Monitor{} + monitor.SetResourceStore(unifiedresources.NewMonitorAdapter(registry)) + router := &Router{monitor: monitor} + + for _, query := range []string{ + "resourceType=storage&resourceId=" + resourceID + "&metric=usage&range=24h", + "resourceType=storage&resourceId=" + resourceID + "&range=24h", + } { + req := httptest.NewRequest(http.MethodGet, "/api/metrics-store/history?"+query, 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"` + Points []struct { + Value float64 `json:"value"` + } `json:"points"` + Metrics map[string][]struct { + Value float64 `json:"value"` + } `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: %s", resp.Source, rec.Body.String()) + } + points := resp.Points + if len(points) == 0 { + points = resp.Metrics["usage"] + } + if len(points) < 24 { + t.Fatalf("expected dense synthetic storage history, got %d points", len(points)) + } + if math.Abs(points[len(points)-1].Value-35.7) > 0.001 { + t.Fatalf("expected last point 35.7, got %f", points[len(points)-1].Value) + } + } +} + +func TestMetricsHistoryFallbackMockStorageBypassesSparseStoreSeries(t *testing.T) { + setMockModeForTest(t, true) + + resourceID := "pbs-main/offsite-vault" + store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir())) + if err != nil { + t.Fatalf("metrics.NewStore() error = %v", err) + } + defer func() { _ = store.Close() }() + store.Write("storage", resourceID, "usage", 35.7, time.Now().UTC().Add(-time.Minute)) + store.Flush() + + monitor := &monitoring.Monitor{} + setUnexportedField(t, monitor, "metricsStore", store) + router := &Router{monitor: monitor} + + req := httptest.NewRequest( + http.MethodGet, + "/api/metrics-store/history?resourceType=storage&resourceId="+resourceID+"&metric=usage&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 metricsHistoryResponse + 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 sparse store series to be bypassed, got %q: %s", resp.Source, rec.Body.String()) + } + if len(resp.Points) < 24 { + t.Fatalf("expected dense synthetic storage history, got %d points", len(resp.Points)) + } +} + func TestMetricsHistoryDiskIOMetricsUseMemoryFallback(t *testing.T) { mh := monitoring.NewMetricsHistory(1000, time.Hour) now := time.Now().UTC().Truncate(time.Second) diff --git a/internal/api/router.go b/internal/api/router.go index 20fb6b3b2..ace8efcd4 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -6367,17 +6367,29 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) } // Most requests are served from the metrics store. Keep the fallback/read-state // snapshots lazy so store-backed history does not pay an O(fleet-size) copy cost. + type storageMetricFallback struct { + ID string + ResourceID string + Used int64 + Total int64 + Percent float64 + } var ( fallbackStateOnce sync.Once vms []models.VM containers []models.Container nodes []models.Node - storagePools []models.Storage dockerHosts []models.DockerHost hosts []models.Host + legacyStorageFallbackOnce sync.Once + storagePools []models.Storage + diskFallbackOnce sync.Once physicalDisks []unifiedresources.Resource + + storageFallbackOnce sync.Once + unifiedStorage []storageMetricFallback ) loadFallbackState := func() { @@ -6385,12 +6397,17 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) vms = monitor.VMsSnapshot() containers = monitor.ContainersSnapshot() nodes = monitor.NodesSnapshot() - storagePools = monitor.StorageSnapshot() dockerHosts = monitor.DockerHostsSnapshot() hosts = monitor.HostsSnapshot() }) } + loadLegacyStorage := func() { + legacyStorageFallbackOnce.Do(func() { + storagePools = monitor.StorageSnapshot() + }) + } + loadPhysicalDisks := func() { diskFallbackOnce.Do(func() { for _, resource := range monitor.GetUnifiedResources() { @@ -6401,6 +6418,57 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) }) } + loadUnifiedStorage := func() { + storageFallbackOnce.Do(func() { + readState := monitor.GetUnifiedReadStateOrSnapshot() + seen := make(map[string]struct{}) + if readState != nil { + for _, pool := range readState.StoragePools() { + if pool == nil { + continue + } + unifiedStorage = append(unifiedStorage, storageMetricFallback{ + ID: pool.ID(), + ResourceID: pool.SourceID(), + Used: pool.DiskUsed(), + Total: pool.DiskTotal(), + Percent: pool.DiskPercent(), + }) + seen[pool.ID()] = struct{}{} + } + } + resolver, _ := readState.(monitoring.MetricsTargetResourceStore) + for _, resource := range monitor.GetUnifiedResources() { + if (resource.Type == unifiedresources.ResourceTypeStorage || resource.Type == unifiedresources.ResourceTypeCeph) && + resource.Metrics != nil && resource.Metrics.Disk != nil { + if _, ok := seen[resource.ID]; ok { + continue + } + if resource.MetricsTarget == nil && resolver != nil { + resource.MetricsTarget = resolver.MetricsTargetForResource(resource.ID) + } + targetID := "" + if resource.MetricsTarget != nil { + targetID = strings.TrimSpace(resource.MetricsTarget.ResourceID) + } + disk := resource.Metrics.Disk + fallback := storageMetricFallback{ + ID: resource.ID, + ResourceID: targetID, + Percent: disk.Percent, + } + if disk.Used != nil { + fallback.Used = *disk.Used + } + if disk.Total != nil { + fallback.Total = *disk.Total + } + unifiedStorage = append(unifiedStorage, fallback) + } + } + }) + } + parseGuestID := func(id string) (string, string, int, bool) { parts := strings.Split(id, ":") if len(parts) != 3 { @@ -6460,7 +6528,7 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) } findStorage := func(id string) *models.Storage { - loadFallbackState() + loadLegacyStorage() for i := range storagePools { if storagePools[i].ID == id { return &storagePools[i] @@ -6469,6 +6537,33 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) return nil } + findUnifiedStorage := func(id string) *storageMetricFallback { + loadUnifiedStorage() + target := strings.TrimSpace(id) + if target == "" { + return nil + } + for i := range unifiedStorage { + if strings.EqualFold(unifiedStorage[i].ID, target) { + return &unifiedStorage[i] + } + } + matchIndex := -1 + for i := range unifiedStorage { + if !strings.EqualFold(strings.TrimSpace(unifiedStorage[i].ResourceID), target) { + continue + } + if matchIndex >= 0 { + return nil + } + matchIndex = i + } + if matchIndex < 0 { + return nil + } + return &unifiedStorage[matchIndex] + } + findDockerHost := func(id string) *models.DockerHost { loadFallbackState() for i := range dockerHosts { @@ -6596,18 +6691,33 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) } case "storage": storage := findStorage(resourceID) - if storage == nil { + if storage != nil { + usagePercent := float64(0) + if storage.Total > 0 { + usagePercent = (float64(storage.Used) / float64(storage.Total)) * 100 + } + points["disk"] = monitoring.MetricPoint{Timestamp: now, Value: usagePercent} + points["usage"] = monitoring.MetricPoint{Timestamp: now, Value: usagePercent} + points["used"] = monitoring.MetricPoint{Timestamp: now, Value: float64(storage.Used)} + points["total"] = monitoring.MetricPoint{Timestamp: now, Value: float64(storage.Total)} + points["avail"] = monitoring.MetricPoint{Timestamp: now, Value: float64(storage.Free)} return points } - usagePercent := float64(0) - if storage.Total > 0 { - usagePercent = (float64(storage.Used) / float64(storage.Total)) * 100 + resource := findUnifiedStorage(resourceID) + if resource == nil { + return points + } + usagePercent := resource.Percent + if resource.Total > 0 { + usagePercent = (float64(resource.Used) / float64(resource.Total)) * 100 } points["disk"] = monitoring.MetricPoint{Timestamp: now, Value: usagePercent} points["usage"] = monitoring.MetricPoint{Timestamp: now, Value: usagePercent} - points["used"] = monitoring.MetricPoint{Timestamp: now, Value: float64(storage.Used)} - points["total"] = monitoring.MetricPoint{Timestamp: now, Value: float64(storage.Total)} - points["avail"] = monitoring.MetricPoint{Timestamp: now, Value: float64(storage.Free)} + if resource.Total > 0 { + points["used"] = monitoring.MetricPoint{Timestamp: now, Value: float64(resource.Used)} + points["total"] = monitoring.MetricPoint{Timestamp: now, Value: float64(resource.Total)} + points["avail"] = monitoring.MetricPoint{Timestamp: now, Value: float64(resource.Total - resource.Used)} + } case "docker-host": host := findDockerHost(resourceID) if host == nil { @@ -6767,6 +6877,23 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) } } + if mock.IsMockEnabled() && runtimeResourceType == "storage" && queryMetric == "usage" { + if current, ok := liveMetricPoints(runtimeResourceType, resourceID)["usage"]; ok { + series := chartapi.BuildSyntheticMetricHistorySeries( + end, + duration, + historyMaxPoints, + "storage", + resourceID, + "usage", + current.Value, + ) + if len(series) > 0 { + return buildHistoryPoints(series, stepSecs), historySourceMock, true + } + } + } + switch runtimeResourceType { case "vm", "system-container", "oci-container": metrics, _ := guestChartMetrics() @@ -6894,6 +7021,22 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) metrics, guestHistory = guestChartMetrics() case "storage": metrics = monitor.GetStorageMetrics(resourceID, duration) + if mock.IsMockEnabled() && len(metrics["usage"]) == 0 { + if current, ok := liveMetricPoints(runtimeResourceType, resourceID)["usage"]; ok { + metrics = map[string][]monitoring.MetricPoint{ + "usage": chartapi.BuildSyntheticMetricHistorySeries( + end, + duration, + historyMaxPoints, + "storage", + resourceID, + "usage", + current.Value, + ), + } + source = historySourceMock + } + } case "disk": metrics = map[string][]monitoring.MetricPoint{ "disk": monitor.GetDiskMetricsForChart(resourceID, "disk", duration), @@ -6942,6 +7085,37 @@ func (r *Router) handleMetricsHistory(w http.ResponseWriter, req *http.Request) return apiData, source, true } + // Mock storage targets with current capacity are fully answerable from the + // in-memory history/live fallback. Serve that path before entering the + // persistent store: provider-derived targets may have no stored series, and + // a legacy row can still expose only a sparse point while demo backfill keeps + // the store busy. + if mock.IsMockEnabled() && runtimeResourceType == "storage" { + response := map[string]interface{}{ + "resourceType": responseResourceType, + "resourceId": resourceID, + "range": timeRange, + "start": start.UnixMilli(), + "end": end.UnixMilli(), + } + if metricType != "" { + if apiPoints, source, ok := fallbackSingle(); ok { + response["metric"] = metricType + response["points"] = apiPoints + response["source"] = source + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + } else if apiData, source, ok := fallbackAll(); ok { + response["metrics"] = apiData + response["source"] = source + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + } + store := monitor.GetMetricsStore() queryStoreMetric := func(metric string) ([]metricstore.MetricPoint, string, error) { if len(storeResourceTypes) == 0 {