Unify mock fixtures across supported platforms

This commit is contained in:
rcourtman
2026-03-31 14:38:55 +01:00
parent a09f61d214
commit cbfc7ce750
14 changed files with 943 additions and 172 deletions
@@ -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
@@ -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
@@ -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
+326
View File
@@ -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
}
+11 -35
View File
@@ -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 {
+34 -32
View File
@@ -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")
}
}
+50 -32
View File
@@ -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
+17 -3
View File
@@ -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 {
+185 -44
View File
@@ -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
+125
View File
@@ -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")
}
}
+6 -5
View File
@@ -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())
}
}
}()
@@ -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")
@@ -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)
}
}
@@ -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<string> {
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<Record<string, string>> {
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<void> {
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);
}
});
});