Files
pulse/internal/mock/integration.go
T
rcourtman f69860b153 fix(mock): keep PVE guest sightings fresh on slow mock ticks
The mock update loop sights PVE nodes and their guests through ten fixed
cohorts, sized for the default two-second tick (every node within 20s).
The count did not follow PULSE_MOCK_UPDATE_INTERVAL, so the public demo's
15s tick sighted each node's guests only every 150s while the registry
judges Proxmox sightings stale after 60s. Within minutes most running
guests read as warning/telemetry_stale with proxmox source "stale", and
the Proxmox overview's Attention chip climbed to 64 of 77 guests.

Rotate the PVE estate through the largest divisor of the cohort count
that still sights every node within 30s, down to every node per tick. A
divisor keeps the loop's cohort index mapping each node to one tick per
rotation, and per-second churn never exceeds the two-second default. For
ticks slower than that budget, derive the mock Proxmox stale threshold
from the actual sighting cadence (mock.ProxmoxRefreshInterval), never
below the configured PVE derivation, the same way 1d9e66b24c derived the
supplemental provider thresholds.

Mock-only: real PVE polling rebuilds every guest of an instance with the
poll's sample time, and its threshold already derives from the poll
interval or the adaptive scheduler's maximum.
2026-10-22 11:10:48 +01:00

759 lines
26 KiB
Go

package mock
import (
"fmt"
"math"
"os"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/mockruntime"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rs/zerolog/log"
)
var (
dataMu sync.RWMutex
setEnabledMu sync.Mutex
updateLoopMu sync.Mutex
mockGraph = emptyFixtureGraph()
mockConfig = DefaultConfig
enabled atomic.Bool
fixtureRevision atomic.Uint64
// fixtureDataVersion advances on EVERY observable mock-graph change:
// metric ticks as well as the structural changes that bump
// fixtureRevision. Caches of snapshots derived from the graph key on
// this; fixtureRevision stays structural-only so the expensive seeded
// trend history can be reused across monitor restarts within a process.
fixtureDataVersion atomic.Uint64
metricCohort atomic.Uint64
updateEveryNS atomic.Int64
updateTicker *time.Ticker
stopUpdatesCh chan struct{}
updateLoopWg sync.WaitGroup
)
// Ten two-second cohorts keep every PVE node and its guests fresh within twenty
// seconds while bounding each realtime delta to roughly one tenth of a large
// demo estate. The WebSocket publisher may coalesce several sampler ticks into
// one broadcast, so this leaves enough headroom for that union to remain small.
// Slower ticks rotate the PVE estate through fewer of them; see
// proxmoxCohortCount.
const mockMetricCohortCount = 10
func init() {
loadedConfig := normalizeMockConfig(LoadMockConfig())
setMockUpdateInterval(loadedConfig.UpdateInterval)
dataMu.Lock()
mockConfig = loadedConfig
dataMu.Unlock()
initialEnabled := mockruntime.IsEnabled()
if initialEnabled {
log.Info().Msg("mock mode enabled at startup")
}
if err := setEnabled(initialEnabled, true); err != nil {
log.Warn().Err(err).Msg("failed to enable mock mode at startup")
}
}
func setMockUpdateInterval(interval time.Duration) {
updateEveryNS.Store(int64(normalizeMockUpdateInterval(interval)))
}
func currentMockUpdateInterval() time.Duration {
if interval := time.Duration(updateEveryNS.Load()); interval > 0 {
return interval
}
return defaultMockUpdateInterval
}
func currentMockUpdateStepSeconds() float64 {
seconds := currentMockUpdateInterval().Seconds()
if seconds <= 0 {
return defaultMockUpdateInterval.Seconds()
}
return seconds
}
func currentMockUpdateStepInt64() int64 {
step := int64(currentMockUpdateStepSeconds())
if step <= 0 {
return int64(defaultMockUpdateInterval.Seconds())
}
return step
}
// supplementalFreshnessBudget mirrors the unified registry's default platform
// stale threshold. Provider-backed fixture timestamps (TrueNAS, VMware,
// availability) that age past it read as stale sources, which flips every row
// those sources own to a degraded status. The cohort rotation must therefore
// never let supplemental fixtures age beyond this budget between rebases.
const supplementalFreshnessBudget = 120 * time.Second
// proxmoxSightingBudget bounds how long a PVE node and its guests go between
// mock sightings. It is half the unified registry's minimum Proxmox stale
// threshold, so a rotation inside it never brushes against that threshold.
const proxmoxSightingBudget = 30 * time.Second
// proxmoxCohortCount returns how many of the update loop's cohorts the PVE
// estate rotates through at a tick interval. The ten two-second cohorts sight
// every node within twenty seconds, but the count is fixed, so a slower tick
// stretches the rotation (150s at the public demo's 15s interval) past the
// 60s Proxmox stale threshold and running guests read as stale sources for
// most of it. The PVE rotation therefore uses the largest divisor of
// cohortCount whose rotation fits proxmoxSightingBudget, down to sighting
// every node each tick. A divisor keeps the loop's cohort index mapping each
// node to exactly one tick of every PVE rotation, and the per-second delta
// stays no larger than the default configuration's.
func proxmoxCohortCount(interval time.Duration, cohortCount int) int {
for count := cohortCount; count > 1; count-- {
if cohortCount%count == 0 && interval*time.Duration(count) <= proxmoxSightingBudget {
return count
}
}
return 1
}
// ProxmoxRefreshInterval returns the wall-clock cadence at which the mock
// update loop sights each PVE node and its guests. Stale-threshold derivation
// uses it the same way the Proxmox poller's interval is used, which matters
// once a tick alone is slower than proxmoxSightingBudget.
func ProxmoxRefreshInterval() time.Duration {
interval := currentMockUpdateInterval()
return interval * time.Duration(proxmoxCohortCount(interval, mockMetricCohortCount))
}
// supplementalRefreshEveryTick reports whether the update loop must rebase
// provider-backed fixtures on every tick. At fast tick rates one rebase per
// cohort rotation keeps them comfortably fresh while bounding per-tick socket
// deltas; once the rotation would outlive the freshness budget (e.g. the
// public demo's 60s PULSE_MOCK_UPDATE_INTERVAL makes a 600s rotation), the
// per-tick rebase is both required for freshness and cheaper per wall-clock
// second than the default configuration's cadence.
func supplementalRefreshEveryTick(interval time.Duration, cohortCount int) bool {
if cohortCount <= 1 {
return true
}
return interval*time.Duration(cohortCount) > supplementalFreshnessBudget
}
// SupplementalRefreshInterval returns the wall-clock cadence at which the mock
// update loop rebases provider-backed (TrueNAS, VMware, availability) fixture
// timestamps. Stale-threshold derivation uses it the same way real providers
// use their polling intervals.
func SupplementalRefreshInterval() time.Duration {
interval := currentMockUpdateInterval()
if supplementalRefreshEveryTick(interval, mockMetricCohortCount) {
return interval
}
return interval * mockMetricCohortCount
}
// IsMockEnabled returns whether mock mode is enabled.
func IsMockEnabled() bool {
return enabled.Load()
}
// ErrReleaseFixturesUnauthorized is returned when a release build attempts to
// enable mock fixtures without an explicit entitled runtime authorization.
var ErrReleaseFixturesUnauthorized = mockruntime.ErrReleaseFixturesUnauthorized
// SetReleaseFixturesAuthorized sets whether the current runtime may enable mock
// fixtures in a release build.
func SetReleaseFixturesAuthorized(authorized bool) {
mockruntime.SetReleaseFixturesAuthorized(authorized)
}
// SetEnabled enables or disables mock mode.
func SetEnabled(enable bool) error {
return setEnabled(enable, false)
}
func setEnabled(enable bool, fromInit bool) error {
setEnabledMu.Lock()
defer setEnabledMu.Unlock()
current := enabled.Load()
if current == enable {
// Still update env so other processes see the latest value when not invoked from init.
if !fromInit && shouldSyncEnvFlag() {
setEnvFlag(enable)
}
mockruntime.SetEnabled(enable)
return nil
}
if err := mockruntime.ValidateEnablement(enable); err != nil {
return err
}
dataMu.RLock()
config := mockConfig
dataMu.RUnlock()
if enable {
enableMockMode(config, fromInit)
} else {
disableMockMode()
}
mockruntime.SetEnabled(enable)
if !fromInit && shouldSyncEnvFlag() {
setEnvFlag(enable)
}
return nil
}
func setEnvFlag(enable bool) {
value := "false"
if enable {
value = "true"
}
if err := os.Setenv("PULSE_MOCK_MODE", value); err != nil {
log.Warn().
Err(err).
Str("env_var", "PULSE_MOCK_MODE").
Str("value", value).
Msg("Failed to synchronize mock mode environment flag")
}
}
func readMockEnv(name string) (string, bool) {
raw, ok := os.LookupEnv(name)
if !ok {
return "", false
}
value := strings.TrimSpace(raw)
if value == "" {
log.Warn().
Str("env", name).
Msg("Ignoring empty mock configuration value")
return "", false
}
return value, true
}
func enableMockMode(config MockConfig, fromInit bool) {
config = normalizeMockConfig(config)
now := time.Now()
setMockUpdateInterval(config.UpdateInterval)
dataMu.Lock()
mockConfig = config
mockGraph = buildFixtureGraph(config, now)
advanceFixtureStructure()
metricCohort.Store(0)
enabled.Store(true)
dataMu.Unlock()
startUpdateLoop()
log.Info().
Int("nodes", config.NodeCount).
Int("vms_per_node", config.VMsPerNode).
Int("lxcs_per_node", config.LXCsPerNode).
Int("agent_hosts", config.GenericHostCount).
Int("docker_hosts", config.DockerHostCount).
Int("docker_containers_per_host", config.DockerContainersPerHost).
Int("k8s_clusters", config.K8sClusterCount).
Int("k8s_nodes_per_cluster", config.K8sNodesPerCluster).
Int("k8s_pods_per_cluster", config.K8sPodsPerCluster).
Int("k8s_deployments_per_cluster", config.K8sDeploymentsPerCluster).
Bool("random_metrics", config.RandomMetrics).
Float64("stopped_percent", config.StoppedPercent).
Str("update_interval", config.UpdateInterval.String()).
Msg("mock mode enabled")
if !fromInit {
log.Info().Msg("mock data generator started")
}
}
func disableMockMode() {
if !enabled.Load() {
return
}
enabled.Store(false)
stopUpdateLoop()
dataMu.Lock()
mockGraph = emptyFixtureGraph()
advanceFixtureStructure()
dataMu.Unlock()
log.Info().Msg("mock mode disabled")
}
func startUpdateLoop() {
updateLoopMu.Lock()
defer updateLoopMu.Unlock()
stopUpdateLoopLocked()
stopCh := make(chan struct{})
ticker := time.NewTicker(currentMockUpdateInterval())
stopUpdatesCh = stopCh
updateTicker = ticker
updateLoopWg.Add(1)
go func(stop <-chan struct{}, tick *time.Ticker) {
defer updateLoopWg.Done()
for {
select {
case <-tick.C:
cfg := GetConfig()
updateMetrics(cfg)
case <-stop:
return
}
}
}(stopCh, ticker)
}
func stopUpdateLoop() {
updateLoopMu.Lock()
defer updateLoopMu.Unlock()
stopUpdateLoopLocked()
}
func stopUpdateLoopLocked() {
stopUpdateLoopSignalLocked()
waitForUpdateLoopStop()
}
func stopUpdateLoopSignalLocked() {
if ch := stopUpdatesCh; ch != nil {
close(ch)
stopUpdatesCh = nil
}
if ticker := updateTicker; ticker != nil {
ticker.Stop()
updateTicker = nil
}
}
func waitForUpdateLoopStop() {
updateLoopWg.Wait()
}
func updateMetrics(cfg MockConfig) {
if !IsMockEnabled() {
return
}
dataMu.Lock()
defer dataMu.Unlock()
cohort := int(metricCohort.Add(1)-1) % mockMetricCohortCount
mockGraph.UpdateMetricCohort(cfg, time.Now(), cohort, mockMetricCohortCount)
fixtureDataVersion.Add(1)
}
// FixtureDataVersion returns a token that advances on every observable
// mock-graph change (metric ticks and structural changes alike). Derived
// snapshots cached against this token are current for as long as it is
// unchanged.
func FixtureDataVersion() uint64 {
return fixtureDataVersion.Load()
}
// FixtureStructureRevision returns a token that advances only when the mock
// estate itself changes (mock mode enabled or disabled, or a configuration
// that rebuilds the graph), never on a metric tick. Which resources exist,
// their identities and their source mappings are fixed between two reads of
// the same revision, so a lookup that depends only on those, such as a
// resource's metrics target, may be served from a view built at any data
// version of that revision.
//
// A cache that tags a snapshot with the revision must read the revision
// BEFORE the data version it memoizes the snapshot under. The two counters
// advance under dataMu, data version first (advanceFixtureStructure), so a
// reader that sees a new revision is guaranteed the new data version after
// it and cannot be handed the previous estate's memoized snapshot under the
// new revision; the other interleaving only tags a newer snapshot with an
// older revision, which costs one rebuild.
func FixtureStructureRevision() uint64 {
return fixtureRevision.Load()
}
// advanceFixtureStructure records a structural change of the fixture graph.
// Callers hold dataMu for writing. The data version moves before the
// structure revision; see FixtureStructureRevision for the ordering contract.
func advanceFixtureStructure() {
fixtureDataVersion.Add(1)
fixtureRevision.Add(1)
}
// GetConfig returns the current mock configuration.
func GetConfig() MockConfig {
dataMu.RLock()
defer dataMu.RUnlock()
return mockConfig
}
// LoadMockConfig loads mock configuration from environment variables.
func LoadMockConfig() MockConfig {
config := DefaultConfig
if raw, ok := readMockEnv("PULSE_MOCK_NODES"); ok {
n, err := strconv.Atoi(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_NODES").Str("value", raw).Msg("Invalid mock config value, using default")
} else if n <= 0 {
log.Warn().Str("env", "PULSE_MOCK_NODES").Str("value", raw).Msg("Invalid mock config value, expected integer > 0")
} else {
config.NodeCount = n
}
}
if raw, ok := readMockEnv("PULSE_MOCK_VMS_PER_NODE"); ok {
n, err := strconv.Atoi(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_VMS_PER_NODE").Str("value", raw).Msg("Invalid mock config value, using default")
} else if n < 0 {
log.Warn().Str("env", "PULSE_MOCK_VMS_PER_NODE").Str("value", raw).Msg("Invalid mock config value, expected integer >= 0")
} else {
config.VMsPerNode = n
}
}
if raw, ok := readMockEnv("PULSE_MOCK_LXCS_PER_NODE"); ok {
n, err := strconv.Atoi(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_LXCS_PER_NODE").Str("value", raw).Msg("Invalid mock config value, using default")
} else if n < 0 {
log.Warn().Str("env", "PULSE_MOCK_LXCS_PER_NODE").Str("value", raw).Msg("Invalid mock config value, expected integer >= 0")
} else {
config.LXCsPerNode = n
}
}
if raw, ok := readMockEnv("PULSE_MOCK_DOCKER_HOSTS"); ok {
n, err := strconv.Atoi(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_DOCKER_HOSTS").Str("value", raw).Msg("Invalid mock config value, using default")
} else if n < 0 {
log.Warn().Str("env", "PULSE_MOCK_DOCKER_HOSTS").Str("value", raw).Msg("Invalid mock config value, expected integer >= 0")
} else {
config.DockerHostCount = n
}
}
if raw, ok := readMockEnv("PULSE_MOCK_DOCKER_CONTAINERS"); ok {
n, err := strconv.Atoi(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_DOCKER_CONTAINERS").Str("value", raw).Msg("Invalid mock config value, using default")
} else if n < 0 {
log.Warn().Str("env", "PULSE_MOCK_DOCKER_CONTAINERS").Str("value", raw).Msg("Invalid mock config value, expected integer >= 0")
} else {
config.DockerContainersPerHost = n
}
}
if raw, ok := readMockEnv("PULSE_MOCK_GENERIC_HOSTS"); ok {
n, err := strconv.Atoi(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_GENERIC_HOSTS").Str("value", raw).Msg("Invalid mock config value, using default")
} else if n < 0 {
log.Warn().Str("env", "PULSE_MOCK_GENERIC_HOSTS").Str("value", raw).Msg("Invalid mock config value, expected integer >= 0")
} else {
config.GenericHostCount = n
}
}
if raw, ok := readMockEnv("PULSE_MOCK_K8S_CLUSTERS"); ok {
n, err := strconv.Atoi(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_K8S_CLUSTERS").Str("value", raw).Msg("Invalid mock config value, using default")
} else if n < 0 {
log.Warn().Str("env", "PULSE_MOCK_K8S_CLUSTERS").Str("value", raw).Msg("Invalid mock config value, expected integer >= 0")
} else {
config.K8sClusterCount = n
}
}
if raw, ok := readMockEnv("PULSE_MOCK_K8S_NODES"); ok {
n, err := strconv.Atoi(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_K8S_NODES").Str("value", raw).Msg("Invalid mock config value, using default")
} else if n < 0 {
log.Warn().Str("env", "PULSE_MOCK_K8S_NODES").Str("value", raw).Msg("Invalid mock config value, expected integer >= 0")
} else {
config.K8sNodesPerCluster = n
}
}
if raw, ok := readMockEnv("PULSE_MOCK_K8S_PODS"); ok {
n, err := strconv.Atoi(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_K8S_PODS").Str("value", raw).Msg("Invalid mock config value, using default")
} else if n < 0 {
log.Warn().Str("env", "PULSE_MOCK_K8S_PODS").Str("value", raw).Msg("Invalid mock config value, expected integer >= 0")
} else {
config.K8sPodsPerCluster = n
}
}
if raw, ok := readMockEnv("PULSE_MOCK_K8S_DEPLOYMENTS"); ok {
n, err := strconv.Atoi(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_K8S_DEPLOYMENTS").Str("value", raw).Msg("Invalid mock config value, using default")
} else if n < 0 {
log.Warn().Str("env", "PULSE_MOCK_K8S_DEPLOYMENTS").Str("value", raw).Msg("Invalid mock config value, expected integer >= 0")
} else {
config.K8sDeploymentsPerCluster = n
}
}
if raw, ok := readMockEnv("PULSE_MOCK_RANDOM_METRICS"); ok {
enabled, err := strconv.ParseBool(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_RANDOM_METRICS").Str("value", raw).Msg("Invalid mock config value, using default")
} else {
config.RandomMetrics = enabled
}
}
if raw, ok := readMockEnv("PULSE_MOCK_STOPPED_PERCENT"); ok {
percent, err := strconv.ParseFloat(raw, 64)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_STOPPED_PERCENT").Str("value", raw).Msg("Invalid mock config value, using default")
} else if math.IsNaN(percent) || math.IsInf(percent, 0) || percent < 0 {
log.Warn().Str("env", "PULSE_MOCK_STOPPED_PERCENT").Str("value", raw).Msg("Invalid mock config value, expected percentage in range 0..100")
} else {
// Convert percentage to ratio; normalizeStoppedPercent will clamp >1 to 1.
config.StoppedPercent = percent / 100.0
}
}
if raw, ok := readMockEnv("PULSE_MOCK_UPDATE_INTERVAL"); ok {
interval, err := time.ParseDuration(raw)
if err != nil {
log.Warn().Err(err).Str("env", "PULSE_MOCK_UPDATE_INTERVAL").Str("value", raw).Msg("Invalid mock config value, using default")
} else if interval <= 0 {
log.Warn().Str("env", "PULSE_MOCK_UPDATE_INTERVAL").Str("value", raw).Msg("Invalid mock config value, expected duration > 0")
} else {
config.UpdateInterval = interval
}
}
return normalizeMockConfig(config)
}
// SetMockConfig updates the mock configuration dynamically and regenerates data when enabled.
func SetMockConfig(cfg MockConfig) {
normalized := normalizeMockConfig(cfg)
if normalized.NodeCount != cfg.NodeCount {
log.Warn().Int("provided", cfg.NodeCount).Int("applied", normalized.NodeCount).Msg("Normalized invalid mock NodeCount")
}
if normalized.VMsPerNode != cfg.VMsPerNode {
log.Warn().Int("provided", cfg.VMsPerNode).Int("applied", normalized.VMsPerNode).Msg("Normalized invalid mock VMsPerNode")
}
if normalized.LXCsPerNode != cfg.LXCsPerNode {
log.Warn().Int("provided", cfg.LXCsPerNode).Int("applied", normalized.LXCsPerNode).Msg("Normalized invalid mock LXCsPerNode")
}
if normalized.DockerHostCount != cfg.DockerHostCount {
log.Warn().Int("provided", cfg.DockerHostCount).Int("applied", normalized.DockerHostCount).Msg("Normalized invalid mock DockerHostCount")
}
if normalized.DockerContainersPerHost != cfg.DockerContainersPerHost {
log.Warn().Int("provided", cfg.DockerContainersPerHost).Int("applied", normalized.DockerContainersPerHost).Msg("Normalized invalid mock DockerContainersPerHost")
}
if normalized.GenericHostCount != cfg.GenericHostCount {
log.Warn().Int("provided", cfg.GenericHostCount).Int("applied", normalized.GenericHostCount).Msg("Normalized invalid mock GenericHostCount")
}
if normalized.K8sClusterCount != cfg.K8sClusterCount {
log.Warn().Int("provided", cfg.K8sClusterCount).Int("applied", normalized.K8sClusterCount).Msg("Normalized invalid mock K8sClusterCount")
}
if normalized.K8sNodesPerCluster != cfg.K8sNodesPerCluster {
log.Warn().Int("provided", cfg.K8sNodesPerCluster).Int("applied", normalized.K8sNodesPerCluster).Msg("Normalized invalid mock K8sNodesPerCluster")
}
if normalized.K8sPodsPerCluster != cfg.K8sPodsPerCluster {
log.Warn().Int("provided", cfg.K8sPodsPerCluster).Int("applied", normalized.K8sPodsPerCluster).Msg("Normalized invalid mock K8sPodsPerCluster")
}
if normalized.K8sDeploymentsPerCluster != cfg.K8sDeploymentsPerCluster {
log.Warn().Int("provided", cfg.K8sDeploymentsPerCluster).Int("applied", normalized.K8sDeploymentsPerCluster).Msg("Normalized invalid mock K8sDeploymentsPerCluster")
}
if normalized.StoppedPercent != cfg.StoppedPercent {
log.Warn().Float64("provided", cfg.StoppedPercent).Float64("applied", normalized.StoppedPercent).Msg("Normalized invalid mock StoppedPercent")
}
if normalized.UpdateInterval != cfg.UpdateInterval {
log.Warn().Str("provided", cfg.UpdateInterval.String()).Str("applied", normalized.UpdateInterval.String()).Msg("Normalized invalid mock UpdateInterval")
}
var configChanged bool
var restartTicker bool
dataMu.Lock()
configChanged = !mockConfigsEqual(mockConfig, normalized)
restartTicker = enabled.Load() && mockConfig.UpdateInterval != normalized.UpdateInterval
mockConfig = normalized
setMockUpdateInterval(normalized.UpdateInterval)
if configChanged && enabled.Load() {
mockGraph = buildFixtureGraph(normalized, time.Now())
advanceFixtureStructure()
}
dataMu.Unlock()
if !configChanged {
log.Debug().Msg("Mock configuration unchanged")
return
}
log.Info().
Int("nodes", normalized.NodeCount).
Int("vms_per_node", normalized.VMsPerNode).
Int("lxcs_per_node", normalized.LXCsPerNode).
Int("agent_hosts", normalized.GenericHostCount).
Int("docker_hosts", normalized.DockerHostCount).
Int("docker_containers_per_host", normalized.DockerContainersPerHost).
Int("k8s_clusters", normalized.K8sClusterCount).
Int("k8s_nodes_per_cluster", normalized.K8sNodesPerCluster).
Int("k8s_pods_per_cluster", normalized.K8sPodsPerCluster).
Int("k8s_deployments_per_cluster", normalized.K8sDeploymentsPerCluster).
Bool("random_metrics", normalized.RandomMetrics).
Float64("stopped_percent", normalized.StoppedPercent).
Str("update_interval", normalized.UpdateInterval.String()).
Msg("Mock configuration updated")
if restartTicker {
startUpdateLoop()
}
}
// UpdateAlertSnapshots replaces the active and recently resolved alert lists used for mock mode.
// This lets other components read alert data without querying the live alert manager, which can
// be locked while alerts are being generated. Keeping a snapshot here prevents any blocking when
// the API serves /api/state or WebSocket clients request the initial state.
func UpdateAlertSnapshots(active []alerts.Alert, resolved []models.ResolvedAlert) {
dataMu.Lock()
defer dataMu.Unlock()
mockGraph.UpdateAlertSnapshots(active, resolved)
}
// GetMockAlertHistory returns mock alert history.
func GetMockAlertHistory(limit int) []models.Alert {
if !IsMockEnabled() {
return []models.Alert{}
}
dataMu.RLock()
defer dataMu.RUnlock()
if limit > 0 && limit < len(mockGraph.AlertHistory) {
return cloneMockAlerts(mockGraph.AlertHistory[:limit])
}
return cloneMockAlerts(mockGraph.AlertHistory)
}
// GetMockAlertIncidentTimeline returns the occurrence-qualified incident
// fixture for a mock alert. Callers receive a defensive copy so UI filtering
// and note rendering cannot mutate the canonical fixture graph.
func GetMockAlertIncidentTimeline(alertIdentifier string, startedAt time.Time) *memory.Incident {
if !IsMockEnabled() || strings.TrimSpace(alertIdentifier) == "" {
return nil
}
dataMu.RLock()
defer dataMu.RUnlock()
for _, incident := range mockGraph.AlertIncidents {
if incident == nil || incident.AlertIdentifier != alertIdentifier {
continue
}
if !startedAt.IsZero() {
delta := incident.OpenedAt.Sub(startedAt)
if delta < 0 {
delta = -delta
}
if delta > mockIncidentStartTolerance {
continue
}
}
return cloneMockIncident(incident)
}
return nil
}
// GetMockAlertIncidentsForResource returns newest-first incident fixtures for
// the resource-level timeline panel.
func GetMockAlertIncidentsForResource(resourceID string, limit int) []*memory.Incident {
if !IsMockEnabled() || strings.TrimSpace(resourceID) == "" {
return []*memory.Incident{}
}
dataMu.RLock()
matches := make([]*memory.Incident, 0)
for _, incident := range mockGraph.AlertIncidents {
if incident != nil && incident.ResourceID == resourceID {
matches = append(matches, cloneMockIncident(incident))
}
}
dataMu.RUnlock()
sort.Slice(matches, func(i, j int) bool {
return matches[i].OpenedAt.After(matches[j].OpenedAt)
})
if limit > 0 && len(matches) > limit {
matches = matches[:limit]
}
return matches
}
// AddMockAlertIncidentNote persists a note for the lifetime of the current
// mock fixture graph so the demo note workflow behaves like the real timeline.
func AddMockAlertIncidentNote(alertIdentifier, incidentID, note, user string) bool {
if !IsMockEnabled() || strings.TrimSpace(note) == "" {
return false
}
dataMu.Lock()
defer dataMu.Unlock()
for _, incident := range mockGraph.AlertIncidents {
if incident == nil {
continue
}
if incidentID != "" && incident.ID != incidentID {
continue
}
if incidentID == "" && incident.AlertIdentifier != alertIdentifier {
continue
}
noteAt := time.Now().UTC()
if noteAt.Before(incident.OpenedAt) {
noteAt = incident.OpenedAt
}
summary := "Note added"
if strings.TrimSpace(user) != "" {
summary = "Note added by " + strings.TrimSpace(user)
}
incident.Events = append(incident.Events, memory.IncidentEvent{
ID: fmt.Sprintf("mock-event-note-%s-%d", incident.AlertIdentifier, len(incident.Events)+1),
Type: memory.IncidentEventNote,
Timestamp: noteAt,
Summary: summary,
Details: map[string]interface{}{
"note": strings.TrimSpace(note),
"user": strings.TrimSpace(user),
},
})
sort.SliceStable(incident.Events, func(i, j int) bool {
return incident.Events[i].Timestamp.Before(incident.Events[j].Timestamp)
})
fixtureDataVersion.Add(1)
return true
}
return false
}
func cloneState(state models.StateSnapshot) models.StateSnapshot {
return state.Clone()
}