mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
+39
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user