diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 62d519f9d..a3262388d 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -370,6 +370,15 @@ TrueNAS and VMware inventory plus mock metrics-history seeding must derive from one shared platform fixture owner in `internal/mock/` so settings payloads, supplemental ingest, unified read-state, and seeded charts cannot drift from each other when the v6 runtime runs in mock mode. +That same fixture authority now also includes legacy snapshot-backed platforms. +`internal/monitoring/monitor.go` and +`internal/monitoring/mock_metrics_history.go` must treat the canonical +`internal/mock/fixture_graph.go` runtime graph as the one mock owner for +legacy Proxmox/Docker/Kubernetes/agent/PBS/PMG snapshot state plus +provider-backed TrueNAS and VMware fixtures. Monitoring must not rebuild mock +provider context from standalone defaults or mix a legacy `GenerateMockData` +snapshot with separate provider fixtures when seeding read-state or metrics +history. That same boundary now also owns native disk-history fallback when Pulse's own history is shallow. `internal/truenas/client.go`, `internal/truenas/provider.go`, `internal/monitoring/truenas_poller.go`, and diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 19e143a07..8fbee13f7 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -148,12 +148,13 @@ querying, and the operator-facing storage health presentation layer. data remains inventory-only context and must not be treated as proof of restore capability, recovery artifacts, or widened platform recovery support. -11. Keep runtime mock platform context derived from one shared fixture owner. - When shared `internal/api/` and monitoring wiring surface mock TrueNAS or - VMware storage/recovery-adjacent inventory, that data must come from the - canonical `internal/mock/` platform fixture layer so settings payloads, - unified inventory, and recovery/storage context stay aligned instead of - drifting through recovery-local fixture assembly. +11. Keep runtime mock platform context derived from one shared fixture graph. + When shared `internal/api/` and monitoring wiring surface mock + storage/recovery-adjacent inventory or recovery artifacts, that data must + come from the canonical `internal/mock/fixture_graph.go` owner so legacy + snapshot-backed platforms, provider-backed fixtures, unified inventory, + and recovery/storage context stay aligned instead of drifting through + recovery-local fixture assembly. ## Current State @@ -200,6 +201,13 @@ The recovery backend is a real product boundary, not just a helper package: `internal/recovery/` owns per-tenant SQLite persistence, rollup derivation, query filtering, and recovery-point indexing for the `/api/recovery/*` surfaces. +That same recovery boundary now also assumes mock recovery context is projected +from one canonical mock graph. `internal/mock/recovery_points.go` may synthesize +inventory-only recovery artifacts for supported mock platforms, but those +subjects must derive from the shared `internal/mock/fixture_graph.go` owner +instead of a separate hardcoded recovery cache, so recovery filters, rollups, +and shared route handoffs see the same platform set as settings and +infrastructure. That same shared `internal/api/` dependency also assumes auth-persistence teardown is synchronous when recovery-adjacent runtimes reinitialize. Session, CSRF, and recovery-token workers may not leave stale background goroutines or diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index d43a4b915..26c93890b 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -262,6 +262,14 @@ mode. When mock-backed TrueNAS or VMware supplemental records are present, they must enter the shared resource graph through the same canonical source/type rules as live providers instead of introducing a mock-only source family or resource kind. +That same mock seed contract now also includes legacy snapshot-backed sources. +`internal/mock/fixture_graph.go` must own the runtime mock snapshot and the +provider-backed TrueNAS/VMware fixtures together, and +`internal/mock/platform_fixtures.go` must project unified resources from that +one graph instead of combining a legacy snapshot read with standalone provider +defaults. The shared resource graph must therefore see one coherent mock +platform set regardless of whether a platform is snapshot-backed or +supplemental-provider-backed. TrueNAS-managed applications now follow the same canonical workload rule. One TrueNAS app instance from `app.query` must project as one canonical `app-container` resource under `SourceTrueNAS`, reusing the shared workload and diff --git a/internal/mock/fixture_graph.go b/internal/mock/fixture_graph.go new file mode 100644 index 000000000..bbf5c9e1a --- /dev/null +++ b/internal/mock/fixture_graph.go @@ -0,0 +1,326 @@ +package mock + +import ( + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/truenas" + "github.com/rcourtman/pulse-go-rewrite/internal/vmware" +) + +// FixtureGraph is the canonical mock runtime owner for snapshot-backed and +// provider-backed platform fixtures. All mock projections should derive from +// this graph rather than mixing independent snapshot and provider helpers. +type FixtureGraph struct { + State models.StateSnapshot + AlertHistory []models.Alert + PlatformFixtures PlatformFixtures +} + +func emptyFixtureGraph() FixtureGraph { + return FixtureGraph{ + State: models.EmptyStateSnapshot(), + } +} + +func buildFixtureGraph(cfg MockConfig, now time.Time) FixtureGraph { + state := GenerateMockData(cfg) + state.LastUpdate = now + + return FixtureGraph{ + State: state, + AlertHistory: GenerateAlertHistory(state.Nodes, state.VMs, state.Containers), + PlatformFixtures: DefaultPlatformFixtures(), + } +} + +func cloneFixtureGraph(in FixtureGraph) FixtureGraph { + return FixtureGraph{ + State: cloneState(in.State), + AlertHistory: append([]models.Alert(nil), in.AlertHistory...), + PlatformFixtures: clonePlatformFixtures(in.PlatformFixtures), + } +} + +func (g *FixtureGraph) UpdateMetrics(cfg MockConfig, now time.Time) { + if g == nil { + return + } + UpdateMetrics(&g.State, cfg) + g.State.LastUpdate = now +} + +func (g *FixtureGraph) UpdateAlertSnapshots(active []alerts.Alert, resolved []models.ResolvedAlert) { + if g == nil { + return + } + + converted := make([]models.Alert, 0, len(active)) + for _, alert := range active { + converted = append(converted, models.Alert{ + ID: alert.ID, + Type: alert.Type, + Level: string(alert.Level), + ResourceID: alert.ResourceID, + ResourceName: alert.ResourceName, + Node: alert.Node, + Instance: alert.Instance, + Message: alert.Message, + Value: alert.Value, + Threshold: alert.Threshold, + StartTime: alert.StartTime, + Acknowledged: alert.Acknowledged, + }) + } + + g.State.ActiveAlerts = converted + g.State.RecentlyResolved = append([]models.ResolvedAlert(nil), resolved...) +} + +func CurrentFixtureGraph() FixtureGraph { + if !IsMockEnabled() { + return emptyFixtureGraph() + } + + dataMu.RLock() + defer dataMu.RUnlock() + + return cloneFixtureGraph(mockGraph) +} + +func GetPlatformFixtures() PlatformFixtures { + if !IsMockEnabled() { + return DefaultPlatformFixtures() + } + + dataMu.RLock() + defer dataMu.RUnlock() + + return clonePlatformFixtures(mockGraph.PlatformFixtures) +} + +func clonePlatformFixtures(in PlatformFixtures) PlatformFixtures { + return PlatformFixtures{ + TrueNAS: cloneTrueNASFixtureSnapshot(in.TrueNAS), + VMware: cloneVMwareInventorySnapshot(in.VMware), + } +} + +func cloneTrueNASFixtureSnapshot(in truenas.FixtureSnapshot) truenas.FixtureSnapshot { + out := in + out.System = cloneTrueNASSystemInfo(in.System) + out.Pools = append([]truenas.Pool(nil), in.Pools...) + out.Datasets = append([]truenas.Dataset(nil), in.Datasets...) + out.Disks = append([]truenas.Disk(nil), in.Disks...) + out.Alerts = append([]truenas.Alert(nil), in.Alerts...) + out.Apps = cloneTrueNASApps(in.Apps) + out.ZFSSnapshots = cloneTrueNASZFSSnapshots(in.ZFSSnapshots) + out.ReplicationTasks = cloneTrueNASReplicationTasks(in.ReplicationTasks) + return out +} + +func cloneTrueNASSystemInfo(in truenas.SystemInfo) truenas.SystemInfo { + out := in + if len(in.TemperatureCelsius) > 0 { + out.TemperatureCelsius = make(map[string]float64, len(in.TemperatureCelsius)) + for key, value := range in.TemperatureCelsius { + out.TemperatureCelsius[key] = value + } + } + return out +} + +func cloneTrueNASApps(in []truenas.App) []truenas.App { + if in == nil { + return nil + } + + out := make([]truenas.App, len(in)) + for i := range in { + out[i] = in[i] + out[i].UsedHostIPs = append([]string(nil), in[i].UsedHostIPs...) + out[i].UsedPorts = cloneTrueNASAppPorts(in[i].UsedPorts) + out[i].Containers = cloneTrueNASAppContainers(in[i].Containers) + out[i].Volumes = append([]truenas.AppVolume(nil), in[i].Volumes...) + out[i].Images = append([]string(nil), in[i].Images...) + out[i].Networks = cloneTrueNASAppNetworks(in[i].Networks) + out[i].Stats = cloneTrueNASAppStats(in[i].Stats) + } + + return out +} + +func cloneTrueNASAppPorts(in []truenas.AppPort) []truenas.AppPort { + if in == nil { + return nil + } + + out := make([]truenas.AppPort, len(in)) + for i := range in { + out[i] = in[i] + out[i].HostPorts = append([]truenas.AppHostPort(nil), in[i].HostPorts...) + } + return out +} + +func cloneTrueNASAppContainers(in []truenas.AppContainer) []truenas.AppContainer { + if in == nil { + return nil + } + + out := make([]truenas.AppContainer, len(in)) + for i := range in { + out[i] = in[i] + out[i].PortConfig = cloneTrueNASAppPorts(in[i].PortConfig) + out[i].VolumeMounts = append([]truenas.AppVolume(nil), in[i].VolumeMounts...) + } + return out +} + +func cloneTrueNASAppNetworks(in []truenas.AppNetwork) []truenas.AppNetwork { + if in == nil { + return nil + } + + out := make([]truenas.AppNetwork, len(in)) + for i := range in { + out[i] = in[i] + out[i].Labels = cloneStringMap(in[i].Labels) + } + return out +} + +func cloneTrueNASAppStats(in *truenas.AppStats) *truenas.AppStats { + if in == nil { + return nil + } + + out := *in + out.Interfaces = append([]truenas.AppInterfaceStats(nil), in.Interfaces...) + return &out +} + +func cloneTrueNASZFSSnapshots(in []truenas.ZFSSnapshot) []truenas.ZFSSnapshot { + if in == nil { + return nil + } + + out := make([]truenas.ZFSSnapshot, len(in)) + for i := range in { + out[i] = in[i] + out[i].CreatedAt = cloneTimePtr(in[i].CreatedAt) + out[i].UsedBytes = cloneInt64Ptr(in[i].UsedBytes) + out[i].Referenced = cloneInt64Ptr(in[i].Referenced) + } + return out +} + +func cloneTrueNASReplicationTasks(in []truenas.ReplicationTask) []truenas.ReplicationTask { + if in == nil { + return nil + } + + out := make([]truenas.ReplicationTask, len(in)) + for i := range in { + out[i] = in[i] + out[i].SourceDatasets = append([]string(nil), in[i].SourceDatasets...) + out[i].LastRun = cloneTimePtr(in[i].LastRun) + } + return out +} + +func cloneVMwareInventorySnapshot(in vmware.InventorySnapshot) vmware.InventorySnapshot { + out := in + out.Hosts = cloneVMwareInventoryHosts(in.Hosts) + out.VMs = cloneVMwareInventoryVMs(in.VMs) + out.Datastores = cloneVMwareInventoryDatastores(in.Datastores) + out.EnrichmentIssues = append([]vmware.InventoryEnrichmentIssue(nil), in.EnrichmentIssues...) + return out +} + +func cloneVMwareInventoryHosts(in []vmware.InventoryHost) []vmware.InventoryHost { + if in == nil { + return nil + } + + out := make([]vmware.InventoryHost, len(in)) + for i := range in { + out[i] = in[i] + out[i].DatastoreIDs = append([]string(nil), in[i].DatastoreIDs...) + out[i].DatastoreNames = append([]string(nil), in[i].DatastoreNames...) + out[i].TriggeredAlarms = append([]vmware.InventoryAlarm(nil), in[i].TriggeredAlarms...) + out[i].RecentTasks = append([]vmware.InventoryTask(nil), in[i].RecentTasks...) + out[i].RecentEvents = append([]vmware.InventoryEvent(nil), in[i].RecentEvents...) + out[i].Metrics = cloneVMwareInventoryMetrics(in[i].Metrics) + } + + return out +} + +func cloneVMwareInventoryVMs(in []vmware.InventoryVM) []vmware.InventoryVM { + if in == nil { + return nil + } + + out := make([]vmware.InventoryVM, len(in)) + for i := range in { + out[i] = in[i] + out[i].DatastoreIDs = append([]string(nil), in[i].DatastoreIDs...) + out[i].DatastoreNames = append([]string(nil), in[i].DatastoreNames...) + out[i].GuestIPAddresses = append([]string(nil), in[i].GuestIPAddresses...) + out[i].TriggeredAlarms = append([]vmware.InventoryAlarm(nil), in[i].TriggeredAlarms...) + out[i].RecentTasks = append([]vmware.InventoryTask(nil), in[i].RecentTasks...) + out[i].RecentEvents = append([]vmware.InventoryEvent(nil), in[i].RecentEvents...) + out[i].Metrics = cloneVMwareInventoryMetrics(in[i].Metrics) + } + + return out +} + +func cloneVMwareInventoryDatastores(in []vmware.InventoryDatastore) []vmware.InventoryDatastore { + if in == nil { + return nil + } + + out := make([]vmware.InventoryDatastore, len(in)) + for i := range in { + out[i] = in[i] + out[i].HostIDs = append([]string(nil), in[i].HostIDs...) + out[i].HostNames = append([]string(nil), in[i].HostNames...) + out[i].VMIDs = append([]string(nil), in[i].VMIDs...) + out[i].VMNames = append([]string(nil), in[i].VMNames...) + out[i].Accessible = cloneBoolPtr(in[i].Accessible) + out[i].MultipleHostAccess = cloneBoolPtr(in[i].MultipleHostAccess) + out[i].TriggeredAlarms = append([]vmware.InventoryAlarm(nil), in[i].TriggeredAlarms...) + out[i].RecentTasks = append([]vmware.InventoryTask(nil), in[i].RecentTasks...) + out[i].RecentEvents = append([]vmware.InventoryEvent(nil), in[i].RecentEvents...) + } + + return out +} + +func cloneVMwareInventoryMetrics(in *vmware.InventoryMetrics) *vmware.InventoryMetrics { + if in == nil { + return nil + } + + out := *in + out.CPUPercent = cloneFloat64Ptr(in.CPUPercent) + out.MemoryPercent = cloneFloat64Ptr(in.MemoryPercent) + out.MemoryUsedBytes = cloneInt64Ptr(in.MemoryUsedBytes) + out.MemoryTotalBytes = cloneInt64Ptr(in.MemoryTotalBytes) + out.NetInBytesPerSecond = cloneFloat64Ptr(in.NetInBytesPerSecond) + out.NetOutBytesPerSecond = cloneFloat64Ptr(in.NetOutBytesPerSecond) + out.DiskReadBytesPerSecond = cloneFloat64Ptr(in.DiskReadBytesPerSecond) + out.DiskWriteBytesPerSecond = cloneFloat64Ptr(in.DiskWriteBytesPerSecond) + return &out +} + +func cloneFloat64Ptr(n *float64) *float64 { + if n == nil { + return nil + } + value := *n + return &value +} diff --git a/internal/mock/integration.go b/internal/mock/integration.go index 2c5378218..c7539931d 100644 --- a/internal/mock/integration.go +++ b/internal/mock/integration.go @@ -18,8 +18,7 @@ var ( dataMu sync.RWMutex setEnabledMu sync.Mutex updateLoopMu sync.Mutex - mockData = models.EmptyStateSnapshot() - mockAlerts []models.Alert + mockGraph = emptyFixtureGraph() mockConfig = DefaultConfig enabled atomic.Bool updateTicker *time.Ticker @@ -105,12 +104,11 @@ func readMockEnv(name string) (string, bool) { func enableMockMode(fromInit bool) { config := LoadMockConfig() + now := time.Now() dataMu.Lock() mockConfig = config - mockData = GenerateMockData(config) - mockAlerts = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers) - mockData.LastUpdate = time.Now() + mockGraph = buildFixtureGraph(config, now) enabled.Store(true) dataMu.Unlock() startUpdateLoop() @@ -143,8 +141,7 @@ func disableMockMode() { stopUpdateLoop() dataMu.Lock() - mockData = models.EmptyStateSnapshot() - mockAlerts = nil + mockGraph = emptyFixtureGraph() dataMu.Unlock() log.Info().Msg("mock mode disabled") @@ -209,8 +206,7 @@ func updateMetrics(cfg MockConfig) { dataMu.Lock() defer dataMu.Unlock() - UpdateMetrics(&mockData, cfg) - mockData.LastUpdate = time.Now() + mockGraph.UpdateMetrics(cfg, time.Now()) } // GetConfig returns the current mock configuration. @@ -412,9 +408,7 @@ func SetMockConfig(cfg MockConfig) { dataMu.Lock() mockConfig = normalized if enabled.Load() { - mockData = GenerateMockData(normalized) - mockAlerts = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers) - mockData.LastUpdate = time.Now() + mockGraph = buildFixtureGraph(normalized, time.Now()) } dataMu.Unlock() @@ -443,7 +437,7 @@ func GetMockState() models.StateSnapshot { dataMu.RLock() defer dataMu.RUnlock() - return cloneState(mockData) + return cloneState(mockGraph.State) } // UpdateAlertSnapshots replaces the active and recently resolved alert lists used for mock mode. @@ -454,25 +448,7 @@ func UpdateAlertSnapshots(active []alerts.Alert, resolved []models.ResolvedAlert dataMu.Lock() defer dataMu.Unlock() - converted := make([]models.Alert, 0, len(active)) - for _, alert := range active { - converted = append(converted, models.Alert{ - ID: alert.ID, - Type: alert.Type, - Level: string(alert.Level), - ResourceID: alert.ResourceID, - ResourceName: alert.ResourceName, - Node: alert.Node, - Instance: alert.Instance, - Message: alert.Message, - Value: alert.Value, - Threshold: alert.Threshold, - StartTime: alert.StartTime, - Acknowledged: alert.Acknowledged, - }) - } - mockData.ActiveAlerts = converted - mockData.RecentlyResolved = append([]models.ResolvedAlert(nil), resolved...) + mockGraph.UpdateAlertSnapshots(active, resolved) } // GetMockAlertHistory returns mock alert history. @@ -484,10 +460,10 @@ func GetMockAlertHistory(limit int) []models.Alert { dataMu.RLock() defer dataMu.RUnlock() - if limit > 0 && limit < len(mockAlerts) { - return append([]models.Alert(nil), mockAlerts[:limit]...) + if limit > 0 && limit < len(mockGraph.AlertHistory) { + return append([]models.Alert(nil), mockGraph.AlertHistory[:limit]...) } - return append([]models.Alert(nil), mockAlerts...) + return append([]models.Alert(nil), mockGraph.AlertHistory...) } func cloneState(state models.StateSnapshot) models.StateSnapshot { diff --git a/internal/mock/integration_coverage_test.go b/internal/mock/integration_coverage_test.go index d1717a5bd..d9c63d1f9 100644 --- a/internal/mock/integration_coverage_test.go +++ b/internal/mock/integration_coverage_test.go @@ -30,8 +30,7 @@ func resetMockIntegrationState(t *testing.T) { stopUpdateLoopLocked() dataMu.Lock() - mockData = models.EmptyStateSnapshot() - mockAlerts = nil + mockGraph = emptyFixtureGraph() mockConfig = DefaultConfig dataMu.Unlock() enabled.Store(false) @@ -43,8 +42,7 @@ func resetMockIntegrationState(t *testing.T) { t.Cleanup(func() { stopUpdateLoopLocked() dataMu.Lock() - mockData = models.EmptyStateSnapshot() - mockAlerts = nil + mockGraph = emptyFixtureGraph() mockConfig = DefaultConfig dataMu.Unlock() enabled.Store(false) @@ -185,10 +183,12 @@ func TestDisableMockModeResetsToNormalizedEmptySnapshot(t *testing.T) { enabled.Store(true) dataMu.Lock() - mockData = models.StateSnapshot{ - Nodes: []models.Node{{ID: "node-1", Name: "node-1"}}, + mockGraph = FixtureGraph{ + State: models.StateSnapshot{ + Nodes: []models.Node{{ID: "node-1", Name: "node-1"}}, + }, + AlertHistory: []models.Alert{{ID: "alert-1"}}, } - mockAlerts = []models.Alert{{ID: "alert-1"}} dataMu.Unlock() disableMockMode() @@ -200,17 +200,17 @@ func TestDisableMockModeResetsToNormalizedEmptySnapshot(t *testing.T) { dataMu.RLock() defer dataMu.RUnlock() - if mockAlerts != nil { - t.Fatalf("expected alert cache to be cleared, got %+v", mockAlerts) + if mockGraph.AlertHistory != nil { + t.Fatalf("expected alert cache to be cleared, got %+v", mockGraph.AlertHistory) } - if mockData.Nodes == nil { + if mockGraph.State.Nodes == nil { t.Fatal("expected nodes slice to be normalized") } - if mockData.ConnectionHealth == nil { + if mockGraph.State.ConnectionHealth == nil { t.Fatal("expected connection health map to be normalized") } - if len(mockData.Nodes) != 0 { - t.Fatalf("expected mock data to reset to empty snapshot, got %d nodes", len(mockData.Nodes)) + if len(mockGraph.State.Nodes) != 0 { + t.Fatalf("expected mock data to reset to empty snapshot, got %d nodes", len(mockGraph.State.Nodes)) } } @@ -218,7 +218,9 @@ func TestSetMockConfigOnlyRegeneratesWhenEnabled(t *testing.T) { resetMockIntegrationState(t) dataMu.Lock() - mockData = models.StateSnapshot{Nodes: []models.Node{{ID: "node-existing", Name: "existing"}}} + mockGraph = FixtureGraph{ + State: models.StateSnapshot{Nodes: []models.Node{{ID: "node-existing", Name: "existing"}}}, + } dataMu.Unlock() cfg := DefaultConfig @@ -234,7 +236,7 @@ func TestSetMockConfigOnlyRegeneratesWhenEnabled(t *testing.T) { } dataMu.RLock() - nodesWhenDisabled := len(mockData.Nodes) + nodesWhenDisabled := len(mockGraph.State.Nodes) dataMu.RUnlock() if nodesWhenDisabled != 1 { t.Fatalf("expected data unchanged while disabled, got %d nodes", nodesWhenDisabled) @@ -247,13 +249,13 @@ func TestSetMockConfigOnlyRegeneratesWhenEnabled(t *testing.T) { dataMu.RLock() defer dataMu.RUnlock() - if len(mockData.Nodes) != 3 { - t.Fatalf("expected regenerated node count, got %d", len(mockData.Nodes)) + if len(mockGraph.State.Nodes) != 3 { + t.Fatalf("expected regenerated node count, got %d", len(mockGraph.State.Nodes)) } - if mockData.LastUpdate.IsZero() { + if mockGraph.State.LastUpdate.IsZero() { t.Fatalf("expected regenerated state to update last-update timestamp") } - if len(mockAlerts) == 0 { + if len(mockGraph.AlertHistory) == 0 { t.Fatalf("expected regenerated alert history when enabled") } } @@ -271,15 +273,15 @@ func TestUpdateMetricsGuardAndTimestamp(t *testing.T) { cfg.RandomMetrics = false dataMu.Lock() - mockData = GenerateMockData(cfg) - mockData.LastUpdate = time.Time{} + mockGraph = buildFixtureGraph(cfg, time.Time{}) + mockGraph.State.LastUpdate = time.Time{} dataMu.Unlock() enabled.Store(false) updateMetrics(cfg) dataMu.RLock() - lastWhenDisabled := mockData.LastUpdate + lastWhenDisabled := mockGraph.State.LastUpdate dataMu.RUnlock() if !lastWhenDisabled.IsZero() { t.Fatalf("expected no update while disabled") @@ -289,7 +291,7 @@ func TestUpdateMetricsGuardAndTimestamp(t *testing.T) { updateMetrics(cfg) dataMu.RLock() - lastWhenEnabled := mockData.LastUpdate + lastWhenEnabled := mockGraph.State.LastUpdate dataMu.RUnlock() if lastWhenEnabled.IsZero() { t.Fatalf("expected update timestamp while enabled") @@ -352,11 +354,11 @@ func TestUpdateAlertSnapshotsAndHistoryAccessors(t *testing.T) { UpdateAlertSnapshots(active, resolved) dataMu.RLock() - if len(mockData.ActiveAlerts) != 1 { + if len(mockGraph.State.ActiveAlerts) != 1 { dataMu.RUnlock() - t.Fatalf("expected one active alert snapshot, got %d", len(mockData.ActiveAlerts)) + t.Fatalf("expected one active alert snapshot, got %d", len(mockGraph.State.ActiveAlerts)) } - snapshot := mockData.ActiveAlerts[0] + snapshot := mockGraph.State.ActiveAlerts[0] if snapshot.Level != "critical" { dataMu.RUnlock() t.Fatalf("expected level conversion to string, got %q", snapshot.Level) @@ -365,9 +367,9 @@ func TestUpdateAlertSnapshotsAndHistoryAccessors(t *testing.T) { dataMu.RUnlock() t.Fatalf("unexpected converted active alert snapshot: %+v", snapshot) } - if len(mockData.RecentlyResolved) != 1 || mockData.RecentlyResolved[0].ID != "r-1" { + if len(mockGraph.State.RecentlyResolved) != 1 || mockGraph.State.RecentlyResolved[0].ID != "r-1" { dataMu.RUnlock() - t.Fatalf("unexpected resolved snapshot data: %+v", mockData.RecentlyResolved) + t.Fatalf("unexpected resolved snapshot data: %+v", mockGraph.State.RecentlyResolved) } dataMu.RUnlock() @@ -375,18 +377,18 @@ func TestUpdateAlertSnapshotsAndHistoryAccessors(t *testing.T) { resolved[0].ID = "mutated" dataMu.RLock() - if mockData.ActiveAlerts[0].Message != "CPU critical" { + if mockGraph.State.ActiveAlerts[0].Message != "CPU critical" { dataMu.RUnlock() t.Fatalf("expected active alert snapshot to be independent from source slice") } - if mockData.RecentlyResolved[0].ID != "r-1" { + if mockGraph.State.RecentlyResolved[0].ID != "r-1" { dataMu.RUnlock() t.Fatalf("expected resolved alert snapshot to be copied") } dataMu.RUnlock() dataMu.Lock() - mockAlerts = []models.Alert{{ID: "h-1"}, {ID: "h-2"}, {ID: "h-3"}} + mockGraph.AlertHistory = []models.Alert{{ID: "h-1"}, {ID: "h-2"}, {ID: "h-3"}} dataMu.Unlock() enabled.Store(false) @@ -406,7 +408,7 @@ func TestUpdateAlertSnapshotsAndHistoryAccessors(t *testing.T) { dataMu.RLock() defer dataMu.RUnlock() - if mockAlerts[0].ID != "h-1" { + if mockGraph.AlertHistory[0].ID != "h-1" { t.Fatalf("expected history accessor to return defensive copies") } } diff --git a/internal/mock/platform_fixtures.go b/internal/mock/platform_fixtures.go index 957a9a11c..95edccc9d 100644 --- a/internal/mock/platform_fixtures.go +++ b/internal/mock/platform_fixtures.go @@ -59,32 +59,11 @@ func DefaultPlatformFixtures() PlatformFixtures { } func DefaultTrueNASConnectionFixture() TrueNASConnectionFixture { - fixtures := DefaultPlatformFixtures().TrueNAS - collectedAt := trueNASCollectedAt(fixtures) - host := strings.TrimSpace(fixtures.System.Hostname) - - return TrueNASConnectionFixture{ - ID: "truenas-mock-1", - Name: "Archive NAS", - Host: host, - Port: 443, - APIKey: "mock-truenas-api-key", - UseHTTPS: true, - Enabled: true, - PollIntervalSeconds: DefaultPlatformPollIntervalSeconds, - CollectedAt: collectedAt, - ResourceID: host, - Systems: 1, - StoragePools: len(fixtures.Pools), - Datasets: len(fixtures.Datasets), - Apps: len(fixtures.Apps), - Disks: len(fixtures.Disks), - RecoveryArtifacts: len(fixtures.ZFSSnapshots) + len(fixtures.ReplicationTasks), - } + return defaultTrueNASConnectionFixture(GetPlatformFixtures()) } func DefaultVMwareConnectionFixture() VMwareConnectionFixture { - fixtures := DefaultPlatformFixtures().VMware + fixtures := GetPlatformFixtures().VMware return VMwareConnectionFixture{ ID: strings.TrimSpace(fixtures.ConnectionID), @@ -103,12 +82,38 @@ func DefaultVMwareConnectionFixture() VMwareConnectionFixture { } } +func defaultTrueNASConnectionFixture(fixtures PlatformFixtures) TrueNASConnectionFixture { + snapshot := fixtures.TrueNAS + collectedAt := trueNASCollectedAt(snapshot) + host := strings.TrimSpace(snapshot.System.Hostname) + + return TrueNASConnectionFixture{ + ID: "truenas-mock-1", + Name: "Archive NAS", + Host: host, + Port: 443, + APIKey: "mock-truenas-api-key", + UseHTTPS: true, + Enabled: true, + PollIntervalSeconds: DefaultPlatformPollIntervalSeconds, + CollectedAt: collectedAt, + ResourceID: host, + Systems: 1, + StoragePools: len(snapshot.Pools), + Datasets: len(snapshot.Datasets), + Apps: len(snapshot.Apps), + Disks: len(snapshot.Disks), + RecoveryArtifacts: len(snapshot.ZFSSnapshots) + len(snapshot.ReplicationTasks), + } +} + func SupplementalRecords(source unifiedresources.DataSource) []unifiedresources.IngestRecord { + fixtures := GetPlatformFixtures() switch normalizePlatformSource(source) { case unifiedresources.SourceTrueNAS: - return truenas.FixtureRecords(DefaultPlatformFixtures().TrueNAS) + return truenas.FixtureRecords(fixtures.TrueNAS) case unifiedresources.SourceVMware: - return vmware.FixtureRecords(DefaultPlatformFixtures().VMware) + return vmware.FixtureRecords(fixtures.VMware) default: return nil } @@ -126,23 +131,25 @@ func UnifiedResourceSnapshot() ([]unifiedresources.Resource, time.Time) { return nil, time.Time{} } - fixtures := DefaultPlatformFixtures() - snapshot := GetMockState() + return CurrentFixtureGraph().UnifiedResourceSnapshot() +} +func (g FixtureGraph) UnifiedResourceSnapshot() ([]unifiedresources.Resource, time.Time) { registry := unifiedresources.NewRegistry(nil) - registry.IngestSnapshot(unifiedresources.SnapshotWithoutSources(snapshot, PlatformOwnedSources())) + registry.IngestSnapshot(unifiedresources.SnapshotWithoutSources(g.State, PlatformOwnedSources())) + for _, source := range PlatformOwnedSources() { - records := SupplementalRecords(source) + records := g.SupplementalRecords(source) if len(records) == 0 { continue } registry.IngestRecords(source, records) } - freshness := snapshot.LastUpdate + freshness := g.State.LastUpdate for _, candidate := range []time.Time{ - trueNASCollectedAt(fixtures.TrueNAS), - fixtures.VMware.CollectedAt, + trueNASCollectedAt(g.PlatformFixtures.TrueNAS), + g.PlatformFixtures.VMware.CollectedAt, } { if candidate.IsZero() { continue @@ -155,6 +162,17 @@ func UnifiedResourceSnapshot() ([]unifiedresources.Resource, time.Time) { return registry.List(), freshness } +func (g FixtureGraph) SupplementalRecords(source unifiedresources.DataSource) []unifiedresources.IngestRecord { + switch normalizePlatformSource(source) { + case unifiedresources.SourceTrueNAS: + return truenas.FixtureRecords(g.PlatformFixtures.TrueNAS) + case unifiedresources.SourceVMware: + return vmware.FixtureRecords(g.PlatformFixtures.VMware) + default: + return nil + } +} + func trueNASCollectedAt(fixtures truenas.FixtureSnapshot) time.Time { if !fixtures.CollectedAt.IsZero() { return fixtures.CollectedAt diff --git a/internal/mock/platform_fixtures_test.go b/internal/mock/platform_fixtures_test.go index e5dc3197b..1427426c8 100644 --- a/internal/mock/platform_fixtures_test.go +++ b/internal/mock/platform_fixtures_test.go @@ -11,6 +11,20 @@ func TestUnifiedResourceSnapshotIncludesPlatformFixtures(t *testing.T) { SetEnabled(true) t.Cleanup(func() { SetEnabled(previous) }) + graph := CurrentFixtureGraph() + legacyName := "" + if len(graph.State.VMs) > 0 { + legacyName = graph.State.VMs[0].Name + } else if len(graph.State.Containers) > 0 { + legacyName = graph.State.Containers[0].Name + } + if legacyName == "" { + t.Fatal("expected canonical mock graph to include at least one legacy resource name") + } + if len(graph.PlatformFixtures.VMware.Hosts) == 0 { + t.Fatal("expected canonical mock graph to include VMware host fixtures") + } + resources, freshness := UnifiedResourceSnapshot() if len(resources) == 0 { t.Fatal("expected unified resources in mock mode") @@ -20,9 +34,9 @@ func TestUnifiedResourceSnapshotIncludesPlatformFixtures(t *testing.T) { } wantNames := map[string]bool{ - "truenas-main": false, - "esxi-01.lab.local": false, - "orders-api-01": false, + graph.PlatformFixtures.TrueNAS.System.Hostname: false, + graph.PlatformFixtures.VMware.Hosts[0].Name: false, + legacyName: false, } for _, resource := range resources { if _, ok := wantNames[resource.Name]; ok { diff --git a/internal/mock/recovery_points.go b/internal/mock/recovery_points.go index 70fe3f589..ed455bc87 100644 --- a/internal/mock/recovery_points.go +++ b/internal/mock/recovery_points.go @@ -5,36 +5,30 @@ import ( "encoding/hex" "sort" "strings" - "sync" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/recovery" ) -var ( - mockRecoveryPointsOnce sync.Once - mockRecoveryPoints []recovery.RecoveryPoint -) - func GetMockRecoveryPoints() []recovery.RecoveryPoint { - mockRecoveryPointsOnce.Do(func() { - mockRecoveryPoints = generateMockRecoveryPoints() - }) - return cloneRecoveryPoints(mockRecoveryPoints) + if !IsMockEnabled() { + return nil + } + + return CurrentFixtureGraph().RecoveryPoints() } -func generateMockRecoveryPoints() []recovery.RecoveryPoint { +func (g FixtureGraph) RecoveryPoints() []recovery.RecoveryPoint { + return generateMockRecoveryPoints(g.State, g.PlatformFixtures) +} + +func generateMockRecoveryPoints(snapshot models.StateSnapshot, fixtures PlatformFixtures) []recovery.RecoveryPoint { // Anchor timestamps to midnight UTC so results are stable across requests // (pagination, sorting) while still staying within the "last 30 days" window. anchor := time.Now().UTC().Truncate(24 * time.Hour) - clusters := []struct { - id string - name string - }{ - {id: "k8s-mock-cluster-1", name: "dev-cluster"}, - {id: "k8s-mock-cluster-2", name: "prod-cluster"}, - } + clusters := recoveryKubernetesClusters(snapshot) points := make([]recovery.RecoveryPoint, 0, 64) @@ -42,17 +36,7 @@ func generateMockRecoveryPoints() []recovery.RecoveryPoint { int64Ptr := func(v int64) *int64 { return &v } // Kubernetes PVC snapshot subjects: 3 PVCs with multiple points each (success/running/failed). - k8sPVCSubjects := []struct { - clusterID string - clusterName string - namespace string - pvc string - class string - }{ - {clusterID: clusters[0].id, clusterName: clusters[0].name, namespace: "default", pvc: "postgres-pvc", class: "csi-ceph-rbd"}, - {clusterID: clusters[0].id, clusterName: clusters[0].name, namespace: "monitoring", pvc: "prometheus-pvc", class: "csi-local-path"}, - {clusterID: clusters[1].id, clusterName: clusters[1].name, namespace: "media", pvc: "nextcloud-pvc", class: "csi-ebs-gp3"}, - } + k8sPVCSubjects := recoveryKubernetesPVCSubjects(clusters) for si, s := range k8sPVCSubjects { for i := 0; i < 6; i++ { ageDays := 2 + (si*7+i*4)%28 @@ -249,19 +233,7 @@ func generateMockRecoveryPoints() []recovery.RecoveryPoint { // Proxmox: a few guest subjects with multiple backup points each. // This keeps the Backups page platform-agnostic while still showing familiar PVE/PBS-like artifacts. - proxmoxSubjects := []struct { - instance string - node string - vmid int - typ string // "vm" or "lxc" - name string - storage string - isPBS bool - }{ - {instance: "pve-1", node: "pve-a", vmid: 101, typ: "vm", name: "web-01", storage: "pbs-1", isPBS: true}, - {instance: "pve-1", node: "pve-a", vmid: 102, typ: "vm", name: "db-01", storage: "local-zfs", isPBS: false}, - {instance: "pve-2", node: "pve-b", vmid: 201, typ: "lxc", name: "cache-01", storage: "pbs-1", isPBS: true}, - } + proxmoxSubjects := recoveryProxmoxSubjects(snapshot) for si, s := range proxmoxSubjects { for i := 0; i < 5; i++ { ageDays := 2 + (si*8+i*6)%27 @@ -376,11 +348,11 @@ func generateMockRecoveryPoints() []recovery.RecoveryPoint { } // TrueNAS: 3 dataset subjects with multiple points over time. - truenasConnection := DefaultTrueNASConnectionFixture() + truenasConnection := defaultTrueNASConnectionFixture(fixtures) truenasConnID := truenasConnection.ID truenasHost := truenasConnection.Host truenasDatasets := make([]string, 0, 3) - for _, dataset := range DefaultPlatformFixtures().TrueNAS.Datasets { + for _, dataset := range fixtures.TrueNAS.Datasets { name := strings.TrimSpace(dataset.Name) if name == "" { continue @@ -586,6 +558,175 @@ func generateMockRecoveryPoints() []recovery.RecoveryPoint { return points } +type mockRecoveryCluster struct { + id string + name string +} + +type mockRecoveryPVCSubject struct { + clusterID string + clusterName string + namespace string + pvc string + class string +} + +type mockProxmoxRecoverySubject struct { + instance string + node string + vmid int + typ string + name string + storage string + isPBS bool +} + +func recoveryKubernetesClusters(snapshot models.StateSnapshot) []mockRecoveryCluster { + clusters := make([]mockRecoveryCluster, 0, len(snapshot.KubernetesClusters)) + seen := make(map[string]struct{}, len(snapshot.KubernetesClusters)) + + for _, cluster := range snapshot.KubernetesClusters { + id := strings.TrimSpace(cluster.ID) + if id == "" { + id = strings.TrimSpace(cluster.AgentID) + } + name := strings.TrimSpace(cluster.DisplayName) + if name == "" { + name = strings.TrimSpace(cluster.CustomDisplayName) + } + if name == "" { + name = strings.TrimSpace(cluster.Name) + } + if name == "" { + name = strings.TrimSpace(cluster.Context) + } + if name == "" { + name = strings.TrimSpace(cluster.Server) + } + if id == "" && name == "" { + continue + } + if id == "" { + id = rpStableID("k8s", "cluster", name) + } + if name == "" { + name = id + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + clusters = append(clusters, mockRecoveryCluster{id: id, name: name}) + } + + if len(clusters) > 0 { + return clusters + } + + return []mockRecoveryCluster{ + {id: "k8s-mock-cluster-1", name: "dev-cluster"}, + {id: "k8s-mock-cluster-2", name: "prod-cluster"}, + } +} + +func recoveryKubernetesPVCSubjects(clusters []mockRecoveryCluster) []mockRecoveryPVCSubject { + if len(clusters) == 0 { + return nil + } + + templates := []struct { + namespace string + pvc string + class string + }{ + {namespace: "default", pvc: "postgres-pvc", class: "csi-ceph-rbd"}, + {namespace: "monitoring", pvc: "prometheus-pvc", class: "csi-local-path"}, + {namespace: "media", pvc: "nextcloud-pvc", class: "csi-ebs-gp3"}, + } + + subjects := make([]mockRecoveryPVCSubject, 0, len(templates)) + for i := 0; i < len(templates); i++ { + cluster := clusters[i%len(clusters)] + template := templates[i] + subjects = append(subjects, mockRecoveryPVCSubject{ + clusterID: cluster.id, + clusterName: cluster.name, + namespace: template.namespace, + pvc: template.pvc, + class: template.class, + }) + } + + return subjects +} + +func recoveryProxmoxSubjects(snapshot models.StateSnapshot) []mockProxmoxRecoverySubject { + subjects := make([]mockProxmoxRecoverySubject, 0, 3) + seen := map[string]struct{}{} + + remoteStorage := "pbs-1" + if len(snapshot.PBSInstances) > 0 { + if candidate := strings.TrimSpace(snapshot.PBSInstances[0].Name); candidate != "" { + remoteStorage = candidate + } + } + localStorage := "local-zfs" + + appendSubject := func(instance, node string, vmid int, typ, name string, preferPBS bool) { + if len(subjects) >= 3 { + return + } + key := strings.TrimSpace(typ) + ":" + strings.TrimSpace(instance) + ":" + strings.TrimSpace(node) + ":" + rpItoa(vmid) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + + storage := localStorage + isPBS := false + if preferPBS { + storage = remoteStorage + isPBS = true + } + + subjects = append(subjects, mockProxmoxRecoverySubject{ + instance: strings.TrimSpace(instance), + node: strings.TrimSpace(node), + vmid: vmid, + typ: strings.TrimSpace(typ), + name: strings.TrimSpace(name), + storage: storage, + isPBS: isPBS, + }) + } + + for index, vm := range snapshot.VMs { + appendSubject(vm.Instance, vm.Node, vm.VMID, "vm", firstNonEmptyTrimmed(vm.Name, vm.ID), index%2 == 0) + } + for index, container := range snapshot.Containers { + appendSubject(container.Instance, container.Node, container.VMID, "lxc", firstNonEmptyTrimmed(container.Name, container.ID), index%2 == 0) + } + + if len(subjects) > 0 { + return subjects + } + + return []mockProxmoxRecoverySubject{ + {instance: "pve-1", node: "pve-a", vmid: 101, typ: "vm", name: "web-01", storage: "pbs-1", isPBS: true}, + {instance: "pve-1", node: "pve-a", vmid: 102, typ: "vm", name: "db-01", storage: "local-zfs", isPBS: false}, + {instance: "pve-2", node: "pve-b", vmid: 201, typ: "lxc", name: "cache-01", storage: "pbs-1", isPBS: true}, + } +} + +func firstNonEmptyTrimmed(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + func cloneRecoveryPoints(src []recovery.RecoveryPoint) []recovery.RecoveryPoint { if len(src) == 0 { return nil diff --git a/internal/mock/recovery_points_test.go b/internal/mock/recovery_points_test.go new file mode 100644 index 000000000..ebdf4ace6 --- /dev/null +++ b/internal/mock/recovery_points_test.go @@ -0,0 +1,125 @@ +package mock + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/recovery" +) + +func TestCurrentFixtureGraphReturnsDefensiveCopies(t *testing.T) { + previous := IsMockEnabled() + SetEnabled(true) + t.Cleanup(func() { SetEnabled(previous) }) + + graph := CurrentFixtureGraph() + if len(graph.State.Nodes) == 0 { + t.Fatal("expected legacy snapshot nodes in canonical mock graph") + } + if len(graph.PlatformFixtures.VMware.Hosts) == 0 { + t.Fatal("expected VMware fixtures in canonical mock graph") + } + + originalNodeName := graph.State.Nodes[0].Name + originalHostName := graph.PlatformFixtures.VMware.Hosts[0].Name + + graph.State.Nodes[0].Name = "mutated-node" + graph.PlatformFixtures.VMware.Hosts[0].Name = "mutated-host" + if len(graph.AlertHistory) > 0 { + graph.AlertHistory[0].ID = "mutated-alert" + } + + current := CurrentFixtureGraph() + if current.State.Nodes[0].Name != originalNodeName { + t.Fatalf("expected canonical graph to protect state snapshot, got %q", current.State.Nodes[0].Name) + } + if current.PlatformFixtures.VMware.Hosts[0].Name != originalHostName { + t.Fatalf("expected canonical graph to protect platform fixtures, got %q", current.PlatformFixtures.VMware.Hosts[0].Name) + } + if len(graph.AlertHistory) > 0 && len(current.AlertHistory) > 0 && current.AlertHistory[0].ID == "mutated-alert" { + t.Fatal("expected canonical graph to protect alert history") + } +} + +func TestGetMockRecoveryPointsDerivesSubjectsFromCurrentGraph(t *testing.T) { + previous := IsMockEnabled() + SetEnabled(true) + t.Cleanup(func() { SetEnabled(previous) }) + + graph := CurrentFixtureGraph() + if len(graph.State.KubernetesClusters) == 0 { + t.Fatal("expected Kubernetes clusters in canonical mock graph") + } + if len(graph.State.VMs) == 0 && len(graph.State.Containers) == 0 { + t.Fatal("expected Proxmox guests in canonical mock graph") + } + if len(graph.PlatformFixtures.TrueNAS.Datasets) == 0 { + t.Fatal("expected TrueNAS datasets in canonical mock graph") + } + + clusterNames := make(map[string]struct{}, len(graph.State.KubernetesClusters)) + for _, cluster := range graph.State.KubernetesClusters { + if name := firstNonEmptyTrimmed(cluster.DisplayName, cluster.CustomDisplayName, cluster.Name); name != "" { + clusterNames[name] = struct{}{} + } + } + + guestNames := make(map[string]struct{}, len(graph.State.VMs)+len(graph.State.Containers)) + for _, guest := range graph.State.VMs { + if guest.Name != "" { + guestNames[guest.Name] = struct{}{} + } + } + for _, guest := range graph.State.Containers { + if guest.Name != "" { + guestNames[guest.Name] = struct{}{} + } + } + + datasetNames := make(map[string]struct{}, len(graph.PlatformFixtures.TrueNAS.Datasets)) + for _, dataset := range graph.PlatformFixtures.TrueNAS.Datasets { + if dataset.Name != "" { + datasetNames[dataset.Name] = struct{}{} + } + } + + points := GetMockRecoveryPoints() + if len(points) == 0 { + t.Fatal("expected mock recovery points") + } + + foundKubernetes := false + foundProxmox := false + foundTrueNAS := false + + for _, point := range points { + if point.SubjectRef == nil { + continue + } + switch point.Provider { + case recovery.ProviderKubernetes: + if point.SubjectRef.Type == "k8s-cluster" { + if _, ok := clusterNames[point.SubjectRef.Name]; ok { + foundKubernetes = true + } + } + case recovery.ProviderProxmoxPVE: + if _, ok := guestNames[point.SubjectRef.Name]; ok { + foundProxmox = true + } + case recovery.ProviderTrueNAS: + if _, ok := datasetNames[point.SubjectRef.Name]; ok { + foundTrueNAS = true + } + } + } + + if !foundKubernetes { + t.Fatal("expected recovery points to derive Kubernetes subjects from canonical mock graph") + } + if !foundProxmox { + t.Fatal("expected recovery points to derive Proxmox subjects from canonical mock graph") + } + if !foundTrueNAS { + t.Fatal("expected recovery points to derive TrueNAS subjects from canonical mock graph") + } +} diff --git a/internal/monitoring/mock_metrics_history.go b/internal/monitoring/mock_metrics_history.go index c9c6cb294..35d7c099f 100644 --- a/internal/monitoring/mock_metrics_history.go +++ b/internal/monitoring/mock_metrics_history.go @@ -875,7 +875,7 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, state models. time.Sleep(50 * time.Millisecond) } - platformFixtures := mock.DefaultPlatformFixtures() + platformFixtures := mock.GetPlatformFixtures() trueNASFixtures := platformFixtures.TrueNAS log.Debug().Int("pools", len(trueNASFixtures.Pools)).Int("datasets", len(trueNASFixtures.Datasets)).Msg("mock seeding: processing TrueNAS fixtures") @@ -1007,7 +1007,7 @@ func seedMockMetricsHistory(mh *MetricsHistory, ms *metrics.Store, state models. // recordTrueNASFixturesMetrics records disk usage metrics for TrueNAS pools and datasets. func recordTrueNASFixturesMetrics(mh *MetricsHistory, ms *metrics.Store, ts time.Time) { - fixtures := mock.DefaultPlatformFixtures().TrueNAS + fixtures := mock.GetPlatformFixtures().TrueNAS totalCap, totalUsed := int64(0), int64(0) for _, pool := range fixtures.Pools { @@ -1048,7 +1048,7 @@ func recordTrueNASFixturesMetrics(mh *MetricsHistory, ms *metrics.Store, ts time } func recordVMwareFixturesMetrics(mh *MetricsHistory, ms *metrics.Store, ts time.Time) { - snapshot := mock.DefaultPlatformFixtures().VMware + snapshot := mock.GetPlatformFixtures().VMware datastoreUsage := vmwareDatastoreUsageByID(snapshot.Datastores) for _, host := range snapshot.Hosts { @@ -1504,7 +1504,8 @@ func (m *Monitor) startMockMetricsSampler(ctx context.Context) { m.metricsHistory = NewMetricsHistory(maxPoints, seedDuration) m.mu.Unlock() - state := mock.GetMockState() + graph := mock.CurrentFixtureGraph() + state := graph.State log.Info(). Int("nodes", len(state.Nodes)). Int("vms", len(state.VMs)). @@ -1532,7 +1533,7 @@ func (m *Monitor) startMockMetricsSampler(ctx context.Context) { if !mock.IsMockEnabled() { continue } - recordMockStateToMetricsHistory(m.metricsHistory, nil, mock.GetMockState(), time.Now()) + recordMockStateToMetricsHistory(m.metricsHistory, nil, mock.CurrentFixtureGraph().State, time.Now()) } } }() diff --git a/internal/monitoring/mock_metrics_history_test.go b/internal/monitoring/mock_metrics_history_test.go index 1b935ea06..b2647a8fb 100644 --- a/internal/monitoring/mock_metrics_history_test.go +++ b/internal/monitoring/mock_metrics_history_test.go @@ -356,6 +356,86 @@ func TestSeedMockMetricsHistory_SeedsTrueNASMetricsStore(t *testing.T) { } } +func TestSeedMockMetricsHistory_UsesCanonicalMockFixtureGraphForLegacyAndProviderFixtures(t *testing.T) { + previous := mock.IsMockEnabled() + previousConfig := mock.GetConfig() + t.Cleanup(func() { + mock.SetEnabled(false) + mock.SetMockConfig(previousConfig) + if previous { + mock.SetEnabled(true) + mock.SetMockConfig(previousConfig) + } + }) + + t.Setenv("PULSE_MOCK_NODES", "1") + t.Setenv("PULSE_MOCK_VMS_PER_NODE", "0") + t.Setenv("PULSE_MOCK_LXCS_PER_NODE", "0") + t.Setenv("PULSE_MOCK_DOCKER_HOSTS", "0") + t.Setenv("PULSE_MOCK_DOCKER_CONTAINERS", "0") + t.Setenv("PULSE_MOCK_GENERIC_HOSTS", "0") + t.Setenv("PULSE_MOCK_K8S_CLUSTERS", "0") + t.Setenv("PULSE_MOCK_K8S_NODES", "0") + t.Setenv("PULSE_MOCK_K8S_PODS", "0") + t.Setenv("PULSE_MOCK_K8S_DEPLOYMENTS", "0") + + mock.SetEnabled(false) + mock.SetEnabled(true) + + graph := mock.CurrentFixtureGraph() + if len(graph.State.Nodes) == 0 { + t.Fatal("expected canonical mock graph to include legacy nodes") + } + if strings.TrimSpace(graph.PlatformFixtures.TrueNAS.System.Hostname) == "" { + t.Fatal("expected canonical mock graph to include TrueNAS fixtures") + } + if len(graph.PlatformFixtures.VMware.Hosts) == 0 { + t.Fatal("expected canonical mock graph to include VMware fixtures") + } + + now := time.Now() + cfg := metrics.DefaultConfig(t.TempDir()) + cfg.RetentionRaw = 90 * 24 * time.Hour + cfg.RetentionMinute = 90 * 24 * time.Hour + cfg.RetentionHourly = 90 * 24 * time.Hour + cfg.RetentionDaily = 90 * 24 * time.Hour + cfg.WriteBufferSize = 500 + + store, err := metrics.NewStore(cfg) + if err != nil { + t.Fatalf("failed to create metrics store: %v", err) + } + defer store.Close() + + mh := NewMetricsHistory(1000, 7*24*time.Hour) + seedMockMetricsHistory(mh, store, graph.State, now, 7*24*time.Hour, time.Minute) + + nodePoints, err := store.Query("node", graph.State.Nodes[0].ID, "cpu", now.Add(-7*24*time.Hour), now, 3600) + if err != nil { + t.Fatalf("failed to query legacy mock node cpu metrics: %v", err) + } + if len(nodePoints) == 0 { + t.Fatal("expected seeded legacy mock node cpu metrics from canonical graph state") + } + + truenasPoints, err := store.Query("truenas", graph.PlatformFixtures.TrueNAS.System.Hostname, "disk", now.Add(-7*24*time.Hour), now, 3600) + if err != nil { + t.Fatalf("failed to query TrueNAS mock disk metrics: %v", err) + } + if len(truenasPoints) == 0 { + t.Fatal("expected seeded TrueNAS metrics from canonical graph fixtures") + } + + vmwareHostID := "vc-mock-1:host:host-101" + vmwarePoints, err := store.Query("agent", vmwareHostID, "cpu", now.Add(-7*24*time.Hour), now, 3600) + if err != nil { + t.Fatalf("failed to query VMware mock host cpu metrics: %v", err) + } + if len(vmwarePoints) == 0 { + t.Fatal("expected seeded VMware metrics from canonical graph fixtures") + } +} + func TestStartMockMetricsSampler_DoesNotClearExistingMetricsStoreData(t *testing.T) { t.Setenv("PULSE_MOCK_NODES", "1") t.Setenv("PULSE_MOCK_VMS_PER_NODE", "0") diff --git a/internal/monitoring/monitor_unified_state_test.go b/internal/monitoring/monitor_unified_state_test.go index f4b7d3026..f5da9d523 100644 --- a/internal/monitoring/monitor_unified_state_test.go +++ b/internal/monitoring/monitor_unified_state_test.go @@ -181,6 +181,17 @@ func TestMonitorGetUnifiedReadStateOrSnapshotUsesCanonicalMockUnifiedResources(t mock.SetEnabled(true) t.Cleanup(func() { mock.SetEnabled(false) }) + graph := mock.CurrentFixtureGraph() + legacyName := "" + if len(graph.State.VMs) > 0 { + legacyName = graph.State.VMs[0].Name + } else if len(graph.State.Containers) > 0 { + legacyName = graph.State.Containers[0].Name + } + if legacyName == "" { + t.Fatal("expected canonical mock graph to include at least one legacy resource") + } + store := &resourceOnlyStore{ resources: []unifiedresources.Resource{ { @@ -217,4 +228,7 @@ func TestMonitorGetUnifiedReadStateOrSnapshotUsesCanonicalMockUnifiedResources(t if !hasUnifiedResourceName(resources, "esxi-01.lab.local") { t.Fatalf("expected mock-mode read-state to include VMware mock resources, got %#v", resources) } + if !hasUnifiedResourceName(resources, legacyName) { + t.Fatalf("expected mock-mode read-state to include legacy mock resource %q, got %#v", legacyName, resources) + } } diff --git a/tests/integration/tests/43-platform-mock-runtime.spec.ts b/tests/integration/tests/43-platform-mock-runtime.spec.ts index 9e650d4cd..5ad5ebd75 100644 --- a/tests/integration/tests/43-platform-mock-runtime.spec.ts +++ b/tests/integration/tests/43-platform-mock-runtime.spec.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { expect, test as base } from '@playwright/test'; -import { createAuthenticatedStorageState, getMockMode, setMockMode } from './helpers'; +import { apiRequest, createAuthenticatedStorageState, getMockMode, setMockMode } from './helpers'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -11,6 +11,12 @@ type WorkerFixtures = { authStorageStatePath: string; }; +type ResourceSummary = { + name?: string; + type?: string; + sources?: string[]; +}; + const TRUENAS_SCREENSHOT_PATH = path.resolve( __dirname, '..', @@ -61,6 +67,56 @@ async function ensureMockModeEnabled(page: import('@playwright/test').Page): Pro } } +async function fetchMockResourceName( + page: import('@playwright/test').Page, + source: string, + queryTypes: string[] = [], +): Promise { + const searchParams = new URLSearchParams({ + source, + limit: '200', + }); + if (queryTypes.length > 0) { + searchParams.set('type', queryTypes.join(',')); + } + const response = await apiRequest( + page, + `/api/resources?${searchParams.toString()}`, + ); + expect(response.ok()).toBeTruthy(); + + const payload = (await response.json()) as { data?: ResourceSummary[] }; + const resources = Array.isArray(payload.data) ? payload.data : []; + const chosen = resources.find((resource) => resource.name?.trim()); + if (!chosen?.name?.trim()) { + throw new Error(`Expected at least one mock resource for source ${source}`); + } + return chosen.name.trim(); +} + +async function fetchMockResourceNames(page: import('@playwright/test').Page): Promise> { + return { + 'proxmox-pve': await fetchMockResourceName(page, 'proxmox', ['agent']), + docker: await fetchMockResourceName(page, 'docker', ['docker-host']), + kubernetes: await fetchMockResourceName(page, 'kubernetes', ['k8s-cluster']), + 'proxmox-pbs': await fetchMockResourceName(page, 'pbs', ['pbs']), + 'proxmox-pmg': await fetchMockResourceName(page, 'pmg', ['pmg']), + }; +} + +async function expectInfrastructureSource( + page: import('@playwright/test').Page, + source: string, + resourceName: string, +): Promise { + await page.goto(`/infrastructure?source=${encodeURIComponent(source)}`, { + waitUntil: 'domcontentloaded', + }); + await expect(page.getByTestId('infrastructure-page')).toBeVisible(); + await expect(page.locator('#infra-source-filter')).toHaveValue(source); + await expect(page.getByText(resourceName).first()).toBeVisible(); +} + test.describe.serial('Platform mock runtime', () => { test.setTimeout(180_000); @@ -81,10 +137,11 @@ test.describe.serial('Platform mock runtime', () => { } }); - test('renders TrueNAS and VMware mock data from the live runtime', async ({ page }, testInfo) => { + test('renders canonical legacy and provider-backed mock data from the live runtime', async ({ page }, testInfo) => { test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop runtime proof'); await ensureMockModeEnabled(page); + const resourceNames = await fetchMockResourceNames(page); await page.goto('/settings/infrastructure/platforms/truenas', { waitUntil: 'domcontentloaded', @@ -97,13 +154,7 @@ test.describe.serial('Platform mock runtime', () => { await expect(page.getByText('5 datasets')).toBeVisible(); fs.mkdirSync(path.dirname(TRUENAS_SCREENSHOT_PATH), { recursive: true }); await page.screenshot({ path: TRUENAS_SCREENSHOT_PATH, fullPage: true }); - - await page.goto('/infrastructure?source=truenas', { - waitUntil: 'domcontentloaded', - }); - await expect(page.getByTestId('infrastructure-page')).toBeVisible(); - await expect(page.locator('#infra-source-filter')).toHaveValue('truenas'); - await expect(page.getByText('truenas-main').first()).toBeVisible(); + await expectInfrastructureSource(page, 'truenas', 'truenas-main'); await page.goto('/settings/infrastructure/platforms/vmware', { waitUntil: 'domcontentloaded', @@ -117,12 +168,10 @@ test.describe.serial('Platform mock runtime', () => { await expect(page.getByText('2 datastores')).toBeVisible(); fs.mkdirSync(path.dirname(VMWARE_SCREENSHOT_PATH), { recursive: true }); await page.screenshot({ path: VMWARE_SCREENSHOT_PATH, fullPage: true }); + await expectInfrastructureSource(page, 'vmware-vsphere', 'esxi-01.lab.local'); - await page.goto('/infrastructure?source=vmware-vsphere', { - waitUntil: 'domcontentloaded', - }); - await expect(page.getByTestId('infrastructure-page')).toBeVisible(); - await expect(page.locator('#infra-source-filter')).toHaveValue('vmware-vsphere'); - await expect(page.getByText('esxi-01.lab.local').first()).toBeVisible(); + for (const [source, resourceName] of Object.entries(resourceNames)) { + await expectInfrastructureSource(page, source, resourceName); + } }); });