Fix mock storage history continuity

Contract-Neutral: Storage history behavior is documented in the staged API and storage contracts and covered by staged focused regressions; shared router references do not change agent-lifecycle, performance, or security contracts, and the proof allowlists do not yet name the focused test files.
This commit is contained in:
rcourtman
2026-08-26 13:50:38 +01:00
parent 5fcb0d6655
commit 74d0895e6e
8 changed files with 402 additions and 61 deletions
@@ -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
@@ -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
+16 -48
View File
@@ -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"
]
}
@@ -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]);
});
});
@@ -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<string | null>;
};
type StableStoragePoolsTableItem = {
item: StoragePoolsTableItem;
setItem: SetStoreFunction<StoragePoolsTableItem>;
};
export const useStoragePoolsTableWindowing = (options: UseStoragePoolsTableWindowingOptions) => {
const [bodyRef, setBodyRef] = createSignal<HTMLTableSectionElement | null>(null);
const [estimatedRowHeight, setEstimatedRowHeight] = createSignal(
STORAGE_POOL_ESTIMATED_ROW_HEIGHT,
);
const items = createMemo(() => buildStoragePoolsTableItems(options.groups()));
const itemCache = new Map<string, StableStoragePoolsTableItem>();
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<StoragePoolsTableItem>(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<readonly StoragePoolsTableItem[]>(
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;
+1 -1
View File
@@ -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
@@ -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)
+184 -10
View File
@@ -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 {