Files
pulse/internal/mock/generator.go
T
rcourtman 4f14ad6ab9 Use one primary CPU temperature rule everywhere
Three rules chose a node's single CPU temperature. Node history took
CPUMax first, agent history took cpu_package first, and the node drawer
recomputed the maximum of package, CPUMax and every core in the
browser. On an agent-fed node CPUMax is built from cores alone and can
sit below the package, and elsewhere a core can run above the package,
so the drawer's CPU temperature headline could disagree with the line
drawn beneath it and with the History tab. Node alerts and the CPU
low/record tracking used yet another package-first rule.

models.PrimaryCPUTemperatureCelsius is now the one rule: the hottest
usable package, CPU-maximum or core reading, with a sensor-map form for
agents. Node and agent history, the router's live fallbacks, the
unified resource temperature, node temperature alerts and the CPU
low/record tracking all call it. The drawer reads the resource value
through a new Node.cpuTemperature field instead of recomputing it, and
getCpuTemperature is removed.

Mock mode wrote a second package reading under a cpu.package key into
every agent host, including node-linked ones, from a series unrelated
to the node's static reading. A node and its linked agent now carry one
reading, sampled from the agent's series under the agent's real keys,
and both histories seed from that series, so the current reading
continues the line.
2026-10-13 20:15:36 +01:00

7495 lines
229 KiB
Go

package mock
import (
"fmt"
"hash/fnv"
"math"
"math/rand"
"sort"
"strings"
"time"
"unicode"
"github.com/rcourtman/pulse-go-rewrite/internal/mockmodel"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
// titleCase capitalizes the first letter of each word (simple ASCII-safe version)
func titleCase(s string) string {
var result strings.Builder
capitalizeNext := true
for _, r := range s {
if unicode.IsSpace(r) || r == '-' {
capitalizeNext = true
if r == '-' {
result.WriteRune(' ')
} else {
result.WriteRune(r)
}
} else if capitalizeNext {
result.WriteRune(unicode.ToUpper(r))
capitalizeNext = false
} else {
result.WriteRune(unicode.ToLower(r))
}
}
return result.String()
}
type MockConfig struct {
NodeCount int
VMsPerNode int
LXCsPerNode int
DockerHostCount int
DockerContainersPerHost int
GenericHostCount int
K8sClusterCount int
K8sNodesPerCluster int
K8sPodsPerCluster int
K8sDeploymentsPerCluster int
RandomMetrics bool
HighLoadNodes []string // Specific nodes to simulate high load
StoppedPercent float64 // Fraction of guests that should be stopped (0.0-1.0)
UpdateInterval time.Duration
}
const defaultMockUpdateInterval = 2 * time.Second
const (
dockerConnectionPrefix = "docker-"
kubernetesConnectionPrefix = "kubernetes-"
hostConnectionPrefix = "host-"
)
// Churn pacing for the mock estate. Restart and state-change events are
// expressed as expected events per day per entity and converted to a
// per-update probability against the configured update interval, so the
// pacing does not depend on how often the mock loop ticks. The default
// 2-second tick runs 43,200 times a day: a raw per-tick probability like
// 0.03 means thousands of events per container per day, which floods
// resource_changes and makes the demo Changes timeline unreadable. These
// rates target what a real homelab produces: a few restarts and a few
// state changes per day across the whole fleet, while per-tick metric
// wiggle keeps the demo feeling alive.
const (
// One container restart every ~12 days per container; across the
// ~56 running demo containers that is 4-5 restarts a day fleet-wide.
mockDockerRestartsPerDay = 0.08
// Health-check blips (healthy -> unhealthy / starting).
mockDockerUnhealthyPerDay = 0.05
mockDockerStartingPerDay = 0.05
// A stopped container being started again.
mockDockerStoppedStartsPerDay = 0.25
// An offline docker host coming back.
mockDockerHostRecoveriesPerDay = 0.25
// Kubernetes background churn per cluster.
mockK8sClusterRecoveriesPerDay = 0.5
mockK8sNodeReadyFlipsPerDay = 0.3
mockK8sPodCrashFlipsPerDay = 0.5
// Standalone host status churn per host.
mockHostDegradedPerDay = 0.1
mockHostRecoveriesPerDay = 0.25
mockDiskWearoutStepsPerDay = 0.1
)
// Mean dwell times for transient states, converted to a per-update recovery
// probability. Long enough to be seen on the dashboard, short enough that
// the estate does not look permanently broken.
const (
mockDockerUnhealthyMeanRecovery = 12 * time.Minute
mockDockerStartingMeanRecovery = 90 * time.Second
mockHostDegradedMeanRecovery = 15 * time.Minute
)
// mockEventsPerDayChance converts an expected events-per-day rate for a
// single entity into a probability for one mock update tick.
func mockEventsPerDayChance(eventsPerDay float64) float64 {
return eventsPerDay * currentMockUpdateStepSeconds() / (24 * 60 * 60)
}
// mockRecoveryChance converts a mean time-to-recovery into a probability
// for one mock update tick.
func mockRecoveryChance(meanRecovery time.Duration) float64 {
if meanRecovery <= 0 {
return 1
}
return currentMockUpdateStepSeconds() / meanRecovery.Seconds()
}
// Default fixture sizes target a large, believable mixed estate so platform
// pages prove fleet-scale grouping, filtering, windowing, drawers, and
// responsive layout out of the box:
// - 50 Proxmox nodes across eight six-node clusters plus a standalone tail,
// with enough VMs and LXCs to exercise the workload table's bounded
// row-window threshold. The curated demo scenario in
// `internal/mock/demo_scenarios.go` owns the human-facing site, cluster,
// workload, and storage story layered over these graph-native fixtures.
// - 5 Docker/Podman hosts with 14 containers each
// - 4 standalone Pulse-managed hosts
// - 3 Kubernetes clusters (production + staging + edge) with 5 nodes,
// 40 pods, and 14 deployments each so the Kubernetes platform-page
// overview, nodes, pods, and deployments tabs all exercise grouped
// and flat layouts against a real multi-cluster footprint
var DefaultConfig = MockConfig{
NodeCount: 50,
VMsPerNode: 10,
LXCsPerNode: 8,
DockerHostCount: 5,
DockerContainersPerHost: 14,
GenericHostCount: 4,
K8sClusterCount: 3,
K8sNodesPerCluster: 5,
K8sPodsPerCluster: 40,
K8sDeploymentsPerCluster: 14,
RandomMetrics: true,
StoppedPercent: 0.06,
UpdateInterval: defaultMockUpdateInterval,
}
var appNames = []string{
"jellyfin", "plex", "nextcloud", "pihole", "homeassistant",
"gitlab", "postgres", "mysql", "redis", "nginx",
"traefik", "portainer", "grafana", "prometheus", "influxdb",
"sonarr", "radarr", "transmission", "deluge", "sabnzbd",
"bitwarden", "vaultwarden", "wireguard", "openvpn", "cloudflare",
"minecraft", "valheim", "terraria", "factorio", "csgo",
"webserver", "database", "cache", "loadbalancer", "firewall",
"docker", "kubernetes", "rancher", "jenkins", "gitea",
"syncthing", "seafile", "owncloud", "minio", "sftp",
}
var dockerHostPrefixes = []string{
"nebula", "orion", "aurora", "atlas", "zephyr",
"draco", "phoenix", "hydra", "pegasus", "lyra",
}
type podDefinition struct {
Name string
ID string
ComposeProject string
ComposeWorkdir string
ComposeConfigHash string
AutoUpdatePolicy string
AutoUpdateRestart string
UserNamespace string
}
var dockerOperatingSystems = []string{
"Debian GNU/Linux 12 (bookworm)",
"Ubuntu 24.04.1 LTS",
"Ubuntu 22.04.4 LTS",
"Fedora Linux 40",
"Alpine Linux 3.19",
}
var dockerKernelVersions = []string{
"6.8.12-1-amd64",
"6.6.32-1-lts",
"6.5.0-27-generic",
"6.1.55-1-arm64",
}
var dockerArchitectures = []string{"x86_64", "aarch64"}
var dockerVersions = []string{
"27.3.1",
"26.1.3",
"25.0.6",
}
var podmanVersions = []string{
"5.0.2",
"4.9.3",
"4.8.1",
"4.7.2",
}
var dockerAgentVersions = []string{
"0.1.0",
"0.1.0-dev",
}
var dockerImageTags = []string{
"1.0.0",
"1.1.2",
"2025.09.1",
"2025.10.4",
"latest",
"stable",
}
var genericHostProfiles = []struct {
Platform string
OSName string
OSVersion string
Kernel string
Architecture string
}{
{"linux", "Debian GNU/Linux", "12 (bookworm)", "6.8.12-1-amd64", "x86_64"},
{"linux", "Ubuntu Server", "24.04 LTS", "6.8.0-31-generic", "x86_64"},
{"linux", "Rocky Linux", "9.3", "5.14.0-427.22.1.el9_4.x86_64", "x86_64"},
{"linux", "Alpine Linux", "3.20.1", "6.6.32-0-lts", "x86_64"},
{"windows", "Windows Server", "2022 Datacenter", "10.0.20348.2244", "x86_64"},
{"windows", "Windows 11 Pro", "23H2", "10.0.22631.3737", "x86_64"},
{"macos", "macOS Ventura", "13.6.8", "22.6.0", "arm64"},
{"macos", "macOS Sonoma", "14.6.1", "23G93", "arm64"},
}
var genericHostPrefixes = []string{
"apollo", "centauri", "ceres", "europa", "hyperion",
"kepler", "meridian", "orion", "polaris", "spectrum",
"vega", "zenith", "halcyon", "icarus", "rigel",
}
var hostAgentVersions = []string{
"0.1.0",
"0.1.1",
"0.2.0-alpha",
}
var k8sClusterNames = []string{
"production",
"staging",
"development",
"edge",
"internal",
"platform",
}
var k8sNamespaces = []string{
"default",
"kube-system",
"monitoring",
"logging",
"ingress-nginx",
"cert-manager",
"argocd",
"apps",
"services",
"databases",
"cache",
}
var k8sPodPrefixes = []string{
"nginx",
"redis",
"postgres",
"mysql",
"mongodb",
"prometheus",
"grafana",
"loki",
"jaeger",
"api",
"auth",
"worker",
"cron",
"coredns",
"metrics-server",
"cert-manager",
"ingress-controller",
"fluentd",
}
var k8sVersions = []string{
"v1.31.2",
"v1.30.4",
"v1.29.8",
}
var k8sImages = []string{
"nginx:1.27",
"redis:7.4",
"postgres:16",
"mysql:8.4",
"mongo:7.0",
"prom/prometheus:v2.54",
"grafana/grafana:11.3",
"grafana/loki:3.0",
"busybox:1.36",
"alpine:3.20",
}
var k8sNodeOS = []string{
"Ubuntu 24.04.1 LTS",
"Ubuntu 22.04.5 LTS",
"Debian GNU/Linux 12 (bookworm)",
"Fedora CoreOS 40",
"Talos Linux v1.8",
}
// Common tags used for VMs and containers
var commonTags = []string{
"production", "staging", "development", "testing",
"web", "database", "cache", "queue", "storage",
"frontend", "backend", "api", "microservice",
"docker", "kubernetes", "monitoring", "logging",
"backup", "critical", "important", "experimental",
"media", "gaming", "home", "automation",
"public", "private", "dmz", "internal",
"linux", "windows", "debian", "ubuntu", "alpine",
"managed", "unmanaged", "legacy", "deprecated",
"team-a", "team-b", "customer-1", "project-x",
}
var vmMountpoints = []string{
"/",
"/var",
"/home",
"/srv",
"/opt",
"/data",
"/backup",
"/logs",
"/mnt/data",
"/mnt/backup",
}
var vmFilesystemTypes = []string{"ext4", "xfs", "btrfs", "zfs"}
var vmDevices = []string{"vda", "vdb", "vdc", "sda", "sdb", "sdc", "nvme0n1", "nvme1n1"}
func generateVirtualDisks() ([]models.Disk, models.Disk) {
diskCount := 1 + rand.Intn(3)
disks := make([]models.Disk, 0, diskCount)
usedMounts := make(map[string]struct{})
var total int64
var used int64
for i := 0; i < diskCount; i++ {
mount := "/"
if i == 0 {
usedMounts[mount] = struct{}{}
} else {
candidate := vmMountpoints[rand.Intn(len(vmMountpoints))]
for _, taken := usedMounts[candidate]; taken && len(usedMounts) < len(vmMountpoints); _, taken = usedMounts[candidate] {
candidate = vmMountpoints[rand.Intn(len(vmMountpoints))]
}
mount = candidate
usedMounts[mount] = struct{}{}
}
sizeGiB := 40 + rand.Intn(360) // 40 - 400 GiB
totalBytes := int64(sizeGiB) * 1024 * 1024 * 1024
usage := 0.25 + rand.Float64()*0.6
usedBytes := int64(float64(totalBytes) * usage)
device := vmDevices[i%len(vmDevices)]
fsType := vmFilesystemTypes[rand.Intn(len(vmFilesystemTypes))]
disks = append(disks, models.Disk{
Total: totalBytes,
Used: usedBytes,
Free: totalBytes - usedBytes,
Usage: usage * 100,
Mountpoint: mount,
Type: fsType,
Device: fmt.Sprintf("/dev/%s", device),
})
total += totalBytes
used += usedBytes
}
aggregated := models.Disk{
Total: total,
Used: used,
Free: total - used,
}
if total > 0 {
aggregated.Usage = float64(used) / float64(total) * 100
}
return disks, aggregated
}
// ensureFreshFilesystemFixture guarantees the fixture graph always contains
// at least one running guest with a zero-used filesystem. Freshly
// provisioned mounts serialize with their zero-valued numerics omitted, a
// wire shape consumers must tolerate (#1663), so every mock-backed surface
// keeps exercising it.
func ensureFreshFilesystemFixture(data *models.StateSnapshot) {
const freshBytes = int64(64) * 1024 * 1024 * 1024
fresh := models.Disk{
Total: freshBytes,
Used: 0,
Free: freshBytes,
Usage: 0,
Mountpoint: "/srv/fresh",
Type: "ext4",
Device: "/dev/vdz",
}
for i := range data.VMs {
vm := &data.VMs[i]
if vm.Status != "running" || len(vm.Disks) == 0 {
continue
}
vm.Disks = append(vm.Disks, fresh)
return
}
for i := range data.Containers {
ct := &data.Containers[i]
if ct.Status != "running" || len(ct.Disks) == 0 {
continue
}
ct.Disks = append(ct.Disks, fresh)
return
}
}
// ensureConfigOnlyMountFixture guarantees at least one running container
// carries a mount known only from its config: capacity from the size
// parameter, live usage unavailable (Usage -1). Stock Proxmox reports no
// per-mount usage through the status API, so API-polled containers surface
// their mpX mounts in exactly this shape (#1477). The rootfs entry is seeded
// from the aggregate the same way the poller's ensureContainerRootDiskEntry
// does when only aggregate stats exist.
func ensureConfigOnlyMountFixture(data *models.StateSnapshot) {
const archiveBytes = int64(20) * 1024 * 1024 * 1024
for i := range data.Containers {
ct := &data.Containers[i]
if ct.Status != "running" {
continue
}
if len(ct.Disks) == 0 {
if ct.Disk.Total <= 0 {
continue
}
ct.Disks = []models.Disk{{
Total: ct.Disk.Total,
Used: ct.Disk.Used,
Free: ct.Disk.Free,
Usage: ct.Disk.Usage,
Mountpoint: "/",
Type: "rootfs",
}}
}
ct.Disks = append(ct.Disks, models.Disk{
Total: archiveBytes,
Usage: -1,
Mountpoint: "/srv/archive",
Type: "mp0",
Device: fmt.Sprintf("tank:subvol-%d-disk-1", ct.VMID),
})
return
}
}
// buildFixtureState synthesizes the snapshot-backed portion of the canonical
// fixture graph for demo and test environments.
func buildFixtureState(config MockConfig) models.StateSnapshot {
// rand is automatically seeded in Go 1.20+
config = normalizeMockConfig(config)
data := models.StateSnapshot{
Nodes: generateNodes(config),
DockerHosts: generateDockerHosts(config),
RemovedDockerHosts: []models.RemovedDockerHost{},
KubernetesClusters: generateKubernetesClusters(config),
RemovedKubernetesClusters: []models.RemovedKubernetesCluster{},
Hosts: generateHosts(config),
RemovedHostAgents: []models.RemovedHostAgent{},
VMs: []models.VM{},
Containers: []models.Container{},
PhysicalDisks: []models.PhysicalDisk{},
ReplicationJobs: []models.ReplicationJob{},
LastUpdate: time.Now(),
ConnectionHealth: make(map[string]bool),
Stats: models.Stats{},
ActiveAlerts: []models.Alert{},
}
data.NormalizeCollections()
ensureMockNodeHostLinks(&data)
ensureMockKubernetesNodeHostLinks(&data)
ensureMockFixtureHostIORates(&data)
// Generate physical disks for each node
for _, node := range data.Nodes {
data.PhysicalDisks = append(data.PhysicalDisks, generateDisksForNode(node)...)
}
for _, host := range data.DockerHosts {
data.ConnectionHealth[dockerConnectionPrefix+host.ID] = host.Status != "offline"
}
for _, cluster := range data.KubernetesClusters {
data.ConnectionHealth[kubernetesConnectionPrefix+cluster.ID] = cluster.Status != "offline"
}
for _, host := range data.Hosts {
data.ConnectionHealth[hostConnectionPrefix+host.ID] = host.Status != "offline"
}
// Generate VMs and containers for each node
vmidCounter := 100
for nodeIdx, node := range data.Nodes {
nodeRole := deterministicNodeRole(node, nodeIdx)
vmCount, lxcCount := computeGuestCountsForNode(config, nodeRole, node)
// Ensure at least some activity on most nodes
if nodeIdx < 3 && vmCount == 0 && lxcCount == 0 {
if mockStableChoice(2, node.ID, node.Instance, node.Name, "minimum-activity") == 0 {
vmCount = 1 + mockStableChoice(2, node.ID, "minimum-vm-count")
} else {
lxcCount = 2 + mockStableChoice(3, node.ID, "minimum-lxc-count")
}
}
if node.Status == "offline" {
// Create placeholder offline guests with zeroed metrics
if vmCount == 0 {
vmCount = 1
}
for i := 0; i < vmCount; i++ {
vm := generateVM(node.Name, node.Instance, vmidCounter, config)
vm.Status = "stopped"
vm.CPU = 0
vm.Memory.Used = 0
vm.Memory.Usage = 0
vm.Memory.Free = vm.Memory.Total
vm.Memory.SwapUsed = 0
mockSettleGuestMemoryObservation(&vm.Memory, false, "")
vm.Disk.Used = 0
vm.Disk.Free = vm.Disk.Total
vm.Disk.Usage = -1
vm.NetworkIn = 0
vm.NetworkOut = 0
vm.DiskRead = 0
vm.DiskWrite = 0
vm.Uptime = 0
data.VMs = append(data.VMs, vm)
vmidCounter++
}
if lxcCount == 0 {
lxcCount = 1
}
for i := 0; i < lxcCount; i++ {
lxc := generateContainer(node.Name, node.Instance, vmidCounter, config)
lxc.Status = "stopped"
lxc.CPU = 0
lxc.Memory.Used = 0
lxc.Memory.Usage = 0
lxc.Memory.Free = lxc.Memory.Total
lxc.Memory.SwapUsed = 0
mockSettleGuestMemoryObservation(&lxc.Memory, false, "")
lxc.Disk.Used = 0
lxc.Disk.Free = lxc.Disk.Total
lxc.Disk.Usage = -1
lxc.NetworkIn = 0
lxc.NetworkOut = 0
lxc.DiskRead = 0
lxc.DiskWrite = 0
lxc.Uptime = 0
data.Containers = append(data.Containers, lxc)
vmidCounter++
}
continue
}
// Generate VMs
for i := 0; i < vmCount; i++ {
vm := generateVM(node.Name, node.Instance, vmidCounter, config)
data.VMs = append(data.VMs, vm)
vmidCounter++
}
// Generate containers
for i := 0; i < lxcCount; i++ {
lxc := generateContainer(node.Name, node.Instance, vmidCounter, config)
data.Containers = append(data.Containers, lxc)
vmidCounter++
}
// Set connection health
data.ConnectionHealth[fmt.Sprintf("pve-%s", node.Name)] = true
}
// Generate storage for each node
data.Storage = generateStorage(data.Nodes)
// Generate Ceph cluster data if Ceph-backed storage is present
data.CephClusters = generateCephClusters(data.Nodes, data.Storage)
// Generate PBS instances and backups
data.PBSInstances = generatePBSInstances()
data.PBSBackups = generatePBSBackups(data.VMs, data.Containers)
// Set PBS connection health
for _, pbs := range data.PBSInstances {
data.ConnectionHealth[fmt.Sprintf("pbs-%s", pbs.Name)] = true
}
// Generate PMG instances and mail data
data.PMGInstances = generatePMGInstances()
for _, pmg := range data.PMGInstances {
data.ConnectionHealth[fmt.Sprintf("pmg-%s", pmg.Name)] = true
}
// Generate backups for VMs and containers
data.PVEBackups = models.PVEBackups{
BackupTasks: generateBackupTasks(data.VMs, data.Containers),
StorageBackups: generateBackups(data.VMs, data.Containers),
GuestSnapshots: generateSnapshots(data.VMs, data.Containers),
}
data.PMGBackups = extractPMGBackups(data.PVEBackups.StorageBackups)
data.Backups = models.Backups{
PVE: data.PVEBackups,
PBS: append([]models.PBSBackup(nil), data.PBSBackups...),
PMG: append([]models.PMGBackup(nil), data.PMGBackups...),
}
data.ReplicationJobs = generateReplicationJobs(data.Nodes, data.VMs)
ensureFreshFilesystemFixture(&data)
ensureConfigOnlyMountFixture(&data)
// Docker-in-LXC nested hosts are added by ensureMockDockerInLXCFixture
// from the demo scenario layer, after guests get their final names,
// instances, and states.
// Calculate stats
data.Stats.StartTime = time.Now()
data.Stats.Uptime = 0
data.Stats.Version = "v4.9.0-mock"
return data
}
func computeGuestCounts(config MockConfig, nodeRole string) (int, int) {
vmBase := config.VMsPerNode
if vmBase < 0 {
vmBase = 0
}
lxcBase := config.LXCsPerNode
if lxcBase < 0 {
lxcBase = 0
}
var vmCount, lxcCount int
switch nodeRole {
case "vm-heavy":
vmCount = vmBase + randIntnSafe(vmBase) // 100-200% of base
lxcHalf := lxcBase / 2
lxcCount = lxcHalf + randIntnSafe(lxcHalf) // 50-75% of base
case "container-heavy":
vmCount = randIntnSafe(vmBase/2 + 1) // 0-50% of base
lxcCount = lxcBase*2 + randIntnSafe(lxcBase) // 200-300% of base
case "light":
vmCount = randIntnSafe(vmBase/2 + 1) // 0-50% of base
lxcCount = randIntnSafe(lxcBase/2 + 1) // 0-50% of base
default: // mixed
vmCount = vmBase + randIntnSafe(5) - 2 // +/- 2
lxcCount = lxcBase + randIntnSafe(7) - 3 // +/- 3
}
if vmCount < 0 {
vmCount = 0
}
if lxcCount < 0 {
lxcCount = 0
}
return vmCount, lxcCount
}
func randIntnSafe(n int) int {
if n <= 0 {
return 0
}
return rand.Intn(n)
}
func mockStableChoice(length int, parts ...string) int {
if length <= 0 {
return 0
}
return int(mockStableHash64(parts...) % uint64(length))
}
func mockStableHexString(n int, parts ...string) string {
if n <= 0 {
return ""
}
var out strings.Builder
for counter := 0; out.Len() < n; counter++ {
token := fmt.Sprintf("%016x", mockStableHash64(append(parts, fmt.Sprintf("%d", counter))...))
out.WriteString(token)
}
return out.String()[:n]
}
func mockStableDecimalString(width int, parts ...string) string {
if width <= 0 {
return fmt.Sprintf("%d", mockStableHash64(parts...))
}
modulus := uint64(1)
for i := 0; i < width; i++ {
modulus *= 10
}
return fmt.Sprintf("%0*d", width, mockStableHash64(parts...)%modulus)
}
func mockHostDiskMetricID(hostID string, diskIndex int) string {
return fmt.Sprintf("%s-disk-%d", strings.TrimSpace(hostID), diskIndex)
}
func deterministicNodeRole(node models.Node, nodeIdx int) string {
if nodeIdx == 0 {
return "mixed"
}
roleRoll := mockStableChoice(10, strings.TrimSpace(node.ID), strings.TrimSpace(node.Instance), strings.TrimSpace(node.Name), "node-role")
switch {
case roleRoll < 3:
return "vm-heavy"
case roleRoll < 5:
return "container-heavy"
case roleRoll == 5:
return "light"
default:
return "mixed"
}
}
func computeGuestCountsForNode(config MockConfig, nodeRole string, node models.Node) (int, int) {
vmBase := config.VMsPerNode
if vmBase < 0 {
vmBase = 0
}
lxcBase := config.LXCsPerNode
if lxcBase < 0 {
lxcBase = 0
}
stableN := func(max int, parts ...string) int {
if max <= 0 {
return 0
}
return int(mockStableHash64(append([]string{strings.TrimSpace(node.ID), strings.TrimSpace(node.Instance), strings.TrimSpace(node.Name)}, parts...)...) % uint64(max))
}
var vmCount, lxcCount int
switch nodeRole {
case "vm-heavy":
vmCount = vmBase + stableN(vmBase+1, "vm-heavy", "vm")
lxcHalf := lxcBase / 2
lxcCount = lxcHalf + stableN(lxcHalf+1, "vm-heavy", "lxc")
case "container-heavy":
vmCount = stableN(vmBase/2+1, "container-heavy", "vm")
lxcCount = lxcBase*2 + stableN(lxcBase+1, "container-heavy", "lxc")
case "light":
vmCount = stableN(vmBase/2+1, "light", "vm")
lxcCount = stableN(lxcBase/2+1, "light", "lxc")
default:
vmCount = vmBase + stableN(5, "mixed", "vm") - 2
lxcCount = lxcBase + stableN(7, "mixed", "lxc") - 3
}
if vmCount < 0 {
vmCount = 0
}
if lxcCount < 0 {
lxcCount = 0
}
return vmCount, lxcCount
}
// mockReplicationInterval matches the "*/15" schedule every mock job carries.
const mockReplicationInterval = 15 * time.Minute
// mockReplicationTargetNode picks a replication target for a guest. Replication
// copies a guest to a different node, so the source is never a candidate; an
// estate with no second node has nothing to replicate to.
func mockReplicationTargetNode(nodes []models.Node, sourceNode string, start int) (string, bool) {
for offset := 0; offset < len(nodes); offset++ {
candidate := nodes[(start+offset)%len(nodes)].Name
if candidate != "" && candidate != sourceNode {
return candidate, true
}
}
return "", false
}
// rebaseMockReplicationJobs keeps replication timing on the fixture clock.
// The generator stamps last and next sync once, so without a rebase every job
// on a long-running demo reads as hours overdue while still reporting ok. Each
// job keeps a stable phase inside its schedule window: a healthy job last
// synced less than one interval ago and is next due in the future, and a
// failing job's last good sync falls further behind with each failure. A job
// is rewritten only when its phase passes, so a refresh does not manufacture a
// change for jobs that did not run.
func rebaseMockReplicationJobs(jobs []models.ReplicationJob, now time.Time) {
windowSeconds := uint64(mockReplicationInterval / time.Second)
for i := range jobs {
job := &jobs[i]
phase := time.Duration(mockStableHash64("replication-phase", job.ID)%windowSeconds) * time.Second
lastRun := now.Truncate(mockReplicationInterval).Add(phase)
if lastRun.After(now) {
lastRun = lastRun.Add(-mockReplicationInterval)
}
nextSync := lastRun.Add(mockReplicationInterval)
lastSync := lastRun.Add(-time.Duration(job.FailCount) * mockReplicationInterval)
if job.LastSyncTime != nil && job.LastSyncUnix == lastSync.Unix() && job.NextSyncUnix == nextSync.Unix() {
continue
}
job.LastSyncTime = ptrTime(lastSync)
job.LastSyncUnix = lastSync.Unix()
job.NextSyncTime = ptrTime(nextSync)
job.NextSyncUnix = nextSync.Unix()
job.LastPolled = now
}
}
func generateReplicationJobs(nodes []models.Node, vms []models.VM) []models.ReplicationJob {
if len(nodes) == 0 || len(vms) == 0 {
return []models.ReplicationJob{}
}
maxJobs := len(vms)
if maxJobs > 8 {
maxJobs = 8
}
jobs := make([]models.ReplicationJob, 0, maxJobs)
now := time.Now()
nodeCount := len(nodes)
for i := 0; i < maxJobs; i++ {
vm := vms[i%len(vms)]
instance := vm.Instance
if instance == "" {
instance = vm.Node
}
targetNode, ok := mockReplicationTargetNode(nodes, vm.Node, (i+1)%nodeCount)
if !ok {
continue
}
jobNumber := i % 3
jobID := fmt.Sprintf("%d-%d", vm.VMID, jobNumber)
durationSeconds := 90 + rand.Intn(240)
durationHuman := formatSecondsAsClock(durationSeconds)
status := "idle"
lastStatus := "ok"
errorMessage := ""
failCount := 0
roll := rand.Float64()
if roll < 0.1 {
status = "error"
lastStatus = "error"
errorMessage = "last sync timed out"
failCount = 1 + rand.Intn(2)
} else if roll < 0.35 {
status = "syncing"
}
rate := 80.0 + rand.Float64()*140.0
job := models.ReplicationJob{
ID: fmt.Sprintf("%s-%s", instance, jobID),
Instance: instance,
JobID: jobID,
JobNumber: jobNumber,
Guest: fmt.Sprintf("%d", vm.VMID),
GuestID: vm.VMID,
GuestName: vm.Name,
GuestType: vm.Type,
GuestNode: vm.Node,
SourceNode: vm.Node,
SourceStorage: "local-zfs",
TargetNode: targetNode,
TargetStorage: "replica-zfs",
Schedule: "*/15",
Type: "local",
Enabled: true,
State: status,
Status: status,
LastSyncStatus: lastStatus,
LastSyncDurationSeconds: durationSeconds,
LastSyncDurationHuman: durationHuman,
DurationSeconds: durationSeconds,
DurationHuman: durationHuman,
FailCount: failCount,
Error: errorMessage,
RateLimitMbps: ptrFloat64(rate),
LastPolled: now,
}
jobs = append(jobs, job)
}
rebaseMockReplicationJobs(jobs, now)
return jobs
}
func formatSecondsAsClock(totalSeconds int) string {
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
seconds := totalSeconds % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
func ptrTime(t time.Time) *time.Time {
return &t
}
func ptrFloat64(v float64) *float64 {
return &v
}
func generateNodes(config MockConfig) []models.Node {
if config.NodeCount < minMockNodeCount {
config.NodeCount = minMockNodeCount
}
if config.NodeCount > maxMockNodeCount {
config.NodeCount = maxMockNodeCount
}
// Capacity is fixed at the product maximum so request-derived configuration
// never controls an allocation size. The normalized count still controls how
// many nodes are generated.
nodes := make([]models.Node, 0, maxMockNodeCount)
clusterNodeCount := mockProxmoxClusteredNodeCount(config.NodeCount)
// Generate clustered nodes in realistic six-node failure domains. Keeping
// cluster identity on the generic graph means every downstream fixture
// (guests, storage, Ceph, recovery, and unified resources) inherits the same
// topology instead of relying on presentation-only demo rewrites.
for i := 0; i < clusterNodeCount; i++ {
nodeName := fmt.Sprintf("pve%d", i+1)
clusterOrdinal := i/mockProxmoxClusterSize + 1
clusterName := fmt.Sprintf("mock-cluster-%d", clusterOrdinal)
isHighLoad := false
for _, n := range config.HighLoadNodes {
if n == nodeName {
isHighLoad = true
break
}
}
node := generateNode(nodeName, isHighLoad, config)
node.Instance = clusterName
node.DisplayName = fmt.Sprintf("%s (%s)", node.Instance, nodeName)
node.IsClusterMember = true
node.ClusterName = clusterName
// ID format matches real system: instance-nodename
node.ID = fmt.Sprintf("%s-%s", node.Instance, nodeName)
node.CPU = sampleNaturalMetric("node", node.ID, "cpu", 0.05, 0.85, 1.0, time.Now())
applyMemoryUsage(&node.Memory, sampleNaturalMetric("node", node.ID, "memory", 10, 85, 0.5, time.Now()))
applyDiskUsage(&node.Disk, sampleNaturalMetric("node", node.ID, "disk", 10, 90, 0.12, time.Now()))
node.ConnectionHealth = "healthy"
nodes = append(nodes, node)
}
// Generate standalone nodes (if we have more than 5 nodes)
for i := clusterNodeCount; i < config.NodeCount; i++ {
nodeName := fmt.Sprintf("standalone%d", i-clusterNodeCount+1)
isHighLoad := false
for _, n := range config.HighLoadNodes {
if n == nodeName {
isHighLoad = true
break
}
}
node := generateNode(nodeName, isHighLoad, config)
node.Instance = nodeName // Standalone - instance matches name
node.DisplayName = node.Instance
node.IsClusterMember = false
node.ClusterName = "" // Empty for standalone
node.ConnectionHealth = "healthy" // Standalone nodes are healthy if online
// ID format matches real system: instance-nodename (same when standalone)
node.ID = fmt.Sprintf("%s-%s", node.Instance, nodeName)
node.CPU = sampleNaturalMetric("node", node.ID, "cpu", 0.05, 0.85, 1.0, time.Now())
applyMemoryUsage(&node.Memory, sampleNaturalMetric("node", node.ID, "memory", 10, 85, 0.5, time.Now()))
applyDiskUsage(&node.Disk, sampleNaturalMetric("node", node.ID, "disk", 10, 90, 0.12, time.Now()))
nodes = append(nodes, node)
}
return nodes
}
const (
mockProxmoxClusterSize = 6
maxMockProxmoxClusterCount = 8
)
// mockProxmoxClusteredNodeCount assigns nodes to complete or useful cluster
// groups while leaving a one- or two-node tail as standalone infrastructure.
// Tiny requested estates also stay standalone: calling one or two nodes a
// cluster makes the fixture less representative, not more.
func mockProxmoxClusteredNodeCount(nodeCount int) int {
if nodeCount < 3 {
return 0
}
maxClustered := mockProxmoxClusterSize * maxMockProxmoxClusterCount
clustered := nodeCount
if clustered > maxClustered {
clustered = maxClustered
}
remainder := clustered % mockProxmoxClusterSize
if remainder > 0 && remainder < 3 {
clustered -= remainder
}
return clustered
}
func generateNode(name string, highLoad bool, config MockConfig) models.Node {
baseLoad := 0.15
if highLoad {
baseLoad = 0.75
}
cpu := baseLoad + rand.Float64()*0.2
if !config.RandomMetrics {
cpu = baseLoad
}
// Memory in GB
totalMem := int64(32 + rand.Intn(96)) // 32-128 GB
usedMem := int64(float64(totalMem) * (baseLoad + rand.Float64()*0.3))
// Disk in GB
totalDisk := int64(500 + rand.Intn(2000)) // 500-2500 GB
usedDisk := int64(float64(totalDisk) * (0.3 + rand.Float64()*0.4))
// Generate CPU info
coreCounts := []int{4, 8, 12, 16, 24, 32, 48, 64}
cores := coreCounts[rand.Intn(len(coreCounts))]
// Generate realistic version information
pveVersions := []string{"8.2.4", "8.2.2", "8.1.10", "8.0.12", "7.4-18"}
kernelVersions := []string{
"6.8.12-1-pve",
"6.8.8-2-pve",
"6.5.13-5-pve",
"6.2.16-20-pve",
"5.15.143-1-pve",
}
// Generate temperature data
temp := generateNodeTemperature(cores)
return models.Node{
Name: name,
DisplayName: name,
Instance: "", // Set by generateNodes based on cluster/standalone
Type: "pve",
Status: "online",
Uptime: int64(86400 * (1 + rand.Intn(30))), // 1-30 days
CPU: cpu,
PVEVersion: pveVersions[rand.Intn(len(pveVersions))],
KernelVersion: kernelVersions[rand.Intn(len(kernelVersions))],
Memory: models.Memory{
Total: totalMem * 1024 * 1024 * 1024, // Convert to bytes
Used: usedMem * 1024 * 1024 * 1024,
// Roughly a third of the non-used pages read as reclaimable
// buff/cache so the node memory split is exercisable in mock mode.
Cache: (totalMem - usedMem) / 3 * 1024 * 1024 * 1024,
Free: ((totalMem - usedMem) - (totalMem-usedMem)/3) * 1024 * 1024 * 1024,
Usage: float64(usedMem) / float64(totalMem) * 100,
},
Disk: models.Disk{
Total: totalDisk * 1024 * 1024 * 1024, // Convert to bytes
Used: usedDisk * 1024 * 1024 * 1024,
Free: (totalDisk - usedDisk) * 1024 * 1024 * 1024,
Usage: float64(usedDisk) / float64(totalDisk) * 100,
},
CPUInfo: models.CPUInfo{
Model: "Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz",
Cores: cores,
Sockets: cores / 4, // Assume 4 cores per socket
MHz: "2400",
},
Temperature: temp,
NetworkInterfaces: []models.HostNetworkInterface{
{Name: "eno1", Addresses: []string{}},
{Name: "vmbr0", Addresses: []string{"192.168.10.21/24", "fd42:7065:6c73::21/64"}},
},
Host: fmt.Sprintf("https://%s.local:8006", name),
ID: "", // Will be set by generateNodes to match real format: instance-nodename
}
}
// generateNodeTemperature generates realistic temperature data for a node
func generateNodeTemperature(cores int) *models.Temperature {
// Keep missing temperatures uncommon in mock mode so Infrastructure has broad coverage.
if rand.Float64() < 0.1 {
return &models.Temperature{
Available: false,
HasCPU: false,
HasNVMe: false,
}
}
// Generate CPU package temperature (40-75°C normal range)
cpuPackage := 40.0 + rand.Float64()*35.0
if rand.Float64() < 0.1 { // 10% chance of high temp
cpuPackage = 75.0 + rand.Float64()*15.0 // 75-90°C
}
// Generate core temperatures (similar to package, with variation)
coreTemps := make([]models.CoreTemp, cores)
maxTemp := cpuPackage
for i := 0; i < cores; i++ {
coreTemp := cpuPackage + (rand.Float64()-0.5)*10.0 // ±5°C from package
if coreTemp < 30.0 {
coreTemp = 30.0
}
if coreTemp > maxTemp {
maxTemp = coreTemp
}
coreTemps[i] = models.CoreTemp{
Core: i,
Temp: coreTemp,
}
}
// Generate NVMe temperatures (0-2 NVMe drives)
numNVMe := rand.Intn(3)
nvmeTemps := make([]models.NVMeTemp, numNVMe)
for i := 0; i < numNVMe; i++ {
nvmeTemp := 35.0 + rand.Float64()*40.0 // 35-75°C normal range
if rand.Float64() < 0.05 { // 5% chance of high temp
nvmeTemp = 75.0 + rand.Float64()*10.0 // 75-85°C
}
nvmeTemps[i] = models.NVMeTemp{
Device: fmt.Sprintf("nvme%d", i),
Temp: nvmeTemp,
}
}
return &models.Temperature{
CPUPackage: cpuPackage,
CPUMax: maxTemp,
Cores: coreTemps,
NVMe: nvmeTemps,
Available: true,
HasCPU: true,
HasNVMe: len(nvmeTemps) > 0,
LastUpdate: time.Now(),
}
}
// generateRealisticIO generates more realistic I/O values
// Most systems are idle or have very low I/O
func generateRealisticIO(ioType string) int64 {
chance := rand.Float64()
switch ioType {
case "disk-read":
if chance < 0.60 { // 60% are idle
return 0
} else if chance < 0.85 { // 25% have low activity
return int64(rand.Intn(5)) * 1024 * 1024 // 0-5 MB/s
} else if chance < 0.95 { // 10% moderate
return int64(5+rand.Intn(20)) * 1024 * 1024 // 5-25 MB/s
} else { // 5% high activity
return int64(25+rand.Intn(75)) * 1024 * 1024 // 25-100 MB/s
}
case "disk-write":
if chance < 0.70 { // 70% are idle (writes are less common)
return 0
} else if chance < 0.90 { // 20% have low activity
return int64(rand.Intn(3)) * 1024 * 1024 // 0-3 MB/s
} else if chance < 0.97 { // 7% moderate
return int64(3+rand.Intn(15)) * 1024 * 1024 // 3-18 MB/s
} else { // 3% high activity
return int64(18+rand.Intn(32)) * 1024 * 1024 // 18-50 MB/s
}
case "network-in":
if chance < 0.50 { // 50% are idle
return 0
} else if chance < 0.80 { // 30% have low activity
return int64(rand.Intn(10)) * 1024 * 1024 / 8 // 0-10 Mbps
} else if chance < 0.93 { // 13% moderate
return int64(10+rand.Intn(90)) * 1024 * 1024 / 8 // 10-100 Mbps
} else { // 7% high activity
return int64(100+rand.Intn(400)) * 1024 * 1024 / 8 // 100-500 Mbps
}
case "network-out":
if chance < 0.55 { // 55% are idle
return 0
} else if chance < 0.82 { // 27% have low activity
return int64(rand.Intn(5)) * 1024 * 1024 / 8 // 0-5 Mbps
} else if chance < 0.94 { // 12% moderate
return int64(5+rand.Intn(45)) * 1024 * 1024 / 8 // 5-50 Mbps
} else { // 6% high activity
return int64(50+rand.Intn(200)) * 1024 * 1024 / 8 // 50-250 Mbps
}
// Container I/O (generally lower than VMs)
case "disk-read-ct":
if chance < 0.65 { // 65% are idle
return 0
} else if chance < 0.90 { // 25% have low activity
return int64(rand.Intn(3)) * 1024 * 1024 // 0-3 MB/s
} else if chance < 0.97 { // 7% moderate
return int64(3+rand.Intn(12)) * 1024 * 1024 // 3-15 MB/s
} else { // 3% high activity
return int64(15+rand.Intn(35)) * 1024 * 1024 // 15-50 MB/s
}
case "disk-write-ct":
if chance < 0.75 { // 75% are idle
return 0
} else if chance < 0.92 { // 17% have low activity
return int64(rand.Intn(2)) * 1024 * 1024 // 0-2 MB/s
} else if chance < 0.98 { // 6% moderate
return int64(2+rand.Intn(8)) * 1024 * 1024 // 2-10 MB/s
} else { // 2% high activity
return int64(10+rand.Intn(20)) * 1024 * 1024 // 10-30 MB/s
}
case "network-in-ct":
if chance < 0.55 { // 55% are idle
return 0
} else if chance < 0.85 { // 30% have low activity
return int64(rand.Intn(5)) * 1024 * 1024 / 8 // 0-5 Mbps
} else if chance < 0.96 { // 11% moderate
return int64(5+rand.Intn(25)) * 1024 * 1024 / 8 // 5-30 Mbps
} else { // 4% high activity
return int64(30+rand.Intn(70)) * 1024 * 1024 / 8 // 30-100 Mbps
}
case "network-out-ct":
if chance < 0.60 { // 60% are idle
return 0
} else if chance < 0.87 { // 27% have low activity
return int64(rand.Intn(3)) * 1024 * 1024 / 8 // 0-3 Mbps
} else if chance < 0.96 { // 9% moderate
return int64(3+rand.Intn(17)) * 1024 * 1024 / 8 // 3-20 Mbps
} else { // 4% high activity
return int64(20+rand.Intn(80)) * 1024 * 1024 / 8 // 20-100 Mbps
}
}
return 0
}
// mockGuestMemoryObservation gives a running guest the provenance a real
// estate reports for its memory reading, so the UI qualifies mock readings the
// way it qualifies live ones instead of treating every guest as unannotated.
func mockGuestMemoryObservation(source string) models.MemoryObservation {
return models.MemoryObservation{State: "current", Source: source, ObservedAt: time.Now()}
}
// applyMockVMMemoryProvenance picks the source a running VM's reading comes
// from. A quarter of VMs have nothing better than Proxmox's cache-inclusive
// value, which carries no used | cache | free split. Pulse reads guest memory
// through the QEMU guest agent from Linux /proc/meminfo only, so a Windows VM
// is cache-aware only through a Pulse agent inside it.
func applyMockVMMemoryProvenance(mem *models.Memory, osName string) {
switch {
case rand.Float64() < 0.25:
mem.Free += mem.Cache
mem.Cache = 0
mem.Observation = mockGuestMemoryObservation("status-mem")
case strings.Contains(strings.ToLower(osName), "windows"):
mem.Observation = mockGuestMemoryObservation("agent")
default:
mem.Observation = mockGuestMemoryObservation("guest-agent-meminfo")
}
}
// refreshMockMemoryObservation moves a current reading's observation time with
// the sample just taken; a retained or unavailable observation keeps its own.
func refreshMockMemoryObservation(mem *models.Memory, at time.Time) {
if mem.Observation.State == "current" {
mem.Observation.ObservedAt = at
}
}
// mockSettleGuestMemoryObservation keeps a guest's memory provenance in step
// with a power state a scenario forces after generation: a stopped guest has
// no reading to qualify, and a guest started later needs a current source. A
// retained or current observation on a running guest is left as it is.
func mockSettleGuestMemoryObservation(memory *models.Memory, running bool, runningSource string) {
switch {
case !running:
memory.Observation = models.MemoryObservation{State: "unavailable", Source: "powered-off"}
case memory.Observation.State == "" || memory.Observation.State == "unavailable":
memory.Observation = mockGuestMemoryObservation(runningSource)
}
}
func generateVM(nodeName string, instance string, vmid int, config MockConfig) models.VM {
name := generateGuestName("vm", fmt.Sprintf("%s:%s:%d", instance, nodeName, vmid))
status := "running"
if rand.Float64() < config.StoppedPercent {
status = "stopped"
}
cpu := float64(0)
totalMem := int64((4 + rand.Intn(28)) * 1024 * 1024 * 1024) // 4-32 GB
mem := models.Memory{
Total: totalMem,
Free: totalMem,
}
uptime := int64(0)
if status == "running" {
// More realistic CPU usage: mostly low with occasional spikes
cpuRand := rand.Float64()
if cpuRand < 0.85 { // 85% of VMs have low CPU
cpu = rand.Float64() * 0.25 // 0-25%
} else if cpuRand < 0.97 { // 12% moderate CPU
cpu = 0.25 + rand.Float64()*0.35 // 25-60%
} else { // 3% high CPU (can trigger alerts at 80%)
cpu = 0.60 + rand.Float64()*0.25 // 60-85%
}
// More realistic memory usage: most VMs use 30-65% memory
var memUsage float64
memRand := rand.Float64()
if memRand < 0.85 { // 85% typical usage
memUsage = 0.3 + rand.Float64()*0.35 // 30-65%
} else if memRand < 0.97 { // 12% moderate usage
memUsage = 0.65 + rand.Float64()*0.15 // 65-80%
} else { // 3% high memory (can trigger alerts at 85%)
memUsage = 0.80 + rand.Float64()*0.1 // 80-90%
}
usedMem := int64(float64(totalMem) * memUsage)
balloon := int64(0)
if rand.Float64() < 0.2 {
// Simulate ballooning active (10-30% of total memory)
balloon = int64(float64(totalMem) * (0.1 + rand.Float64()*0.2))
// Ensure balloon doesn't exceed used memory (which would result in 0 active)
if balloon > usedMem {
balloon = int64(float64(usedMem) * 0.8)
}
}
swapTotal := int64(0)
swapUsed := int64(0)
if rand.Float64() < 0.6 {
swapTotal = int64((1 + rand.Intn(5)) * 1024 * 1024 * 1024) // 1-5 GB
swapUsed = int64(float64(swapTotal) * (0.1 + rand.Float64()*0.4))
}
mem = models.Memory{
Total: totalMem,
Used: usedMem,
Free: totalMem - usedMem,
Usage: memUsage * 100,
Balloon: balloon,
SwapTotal: swapTotal,
SwapUsed: swapUsed,
}
// Carve reclaimable buff/cache out of the free pages so the
// used | cache | free memory split is exercisable in mock mode.
if mem.Free > 0 {
mem.Cache = int64(float64(mem.Free) * (0.2 + rand.Float64()*0.4))
mem.Free -= mem.Cache
}
uptime = int64(3600 * (1 + rand.Intn(720))) // 1-720 hours
} else {
mem.Observation = models.MemoryObservation{State: "unavailable", Source: "powered-off"}
}
// Disk stats
virtualDisks, aggregatedDisk := generateVirtualDisks()
diskStatusReason := ""
if status != "running" {
diskStatusReason = "vm-stopped"
aggregatedDisk.Usage = -1
aggregatedDisk.Used = 0
aggregatedDisk.Free = aggregatedDisk.Total
virtualDisks = nil
} else if rand.Float64() < 0.1 {
// Simulate agent issues where detailed disks are unavailable
diskStatusReason = "agent-not-running"
aggregatedDisk.Usage = -1
aggregatedDisk.Used = 0
aggregatedDisk.Free = aggregatedDisk.Total
virtualDisks = nil
}
// Generate ID matching production logic: standalone uses "node-vmid", cluster uses "instance-node-vmid"
var vmID string
if instance == nodeName {
vmID = fmt.Sprintf("%s-%d", nodeName, vmid)
} else {
vmID = fmt.Sprintf("%s-%s-%d", instance, nodeName, vmid)
}
if status == "running" {
now := time.Now()
cpu = SampleMetric("vm", vmID, "cpu", now) / 100.0
applyMemoryUsage(&mem, SampleMetric("vm", vmID, "memory", now))
if aggregatedDisk.Usage >= 0 {
applyDiskUsage(&aggregatedDisk, SampleMetric("vm", vmID, "disk", now))
}
}
osName, osVersion := generateGuestOSMetadata()
ipAddresses, networkIfaces := generateGuestNetworkInfo()
if status == "running" {
applyMockVMMemoryProvenance(&mem, osName)
}
vm := models.VM{
Name: name,
VMID: vmid,
Node: nodeName,
Instance: instance,
Type: "qemu",
Status: status,
CPU: cpu,
CPUs: 2 + rand.Intn(6), // 2-8 cores
Memory: mem,
Disk: aggregatedDisk,
Disks: virtualDisks,
DiskStatusReason: diskStatusReason,
DiskRead: SampleMetricInt("vm", vmID, "diskread", time.Now()),
DiskWrite: SampleMetricInt("vm", vmID, "diskwrite", time.Now()),
NetworkIn: SampleMetricInt("vm", vmID, "netin", time.Now()),
NetworkOut: SampleMetricInt("vm", vmID, "netout", time.Now()),
Uptime: uptime,
ID: vmID,
Tags: generateTags(),
IPAddresses: ipAddresses,
OSName: osName,
OSVersion: osVersion,
NetworkInterfaces: networkIfaces,
LastBackup: generateLastBackupTime(),
}
if status != "running" {
vm.CPU = 0
vm.Memory.Usage = 0
vm.Memory.SwapUsed = 0
vm.Memory.Used = 0
vm.Memory.Free = vm.Memory.Total
vm.Disk.Used = 0
vm.Disk.Free = vm.Disk.Total
vm.Disk.Usage = -1
vm.NetworkIn = 0
vm.NetworkOut = 0
vm.DiskRead = 0
vm.DiskWrite = 0
vm.Uptime = 0
}
return vm
}
func generateGuestNetworkInfo() ([]string, []models.GuestNetworkInterface) {
ifaceCount := 1 + rand.Intn(2)
ipSet := make(map[string]struct{})
ipAddresses := make([]string, 0, ifaceCount*2)
interfaces := make([]models.GuestNetworkInterface, 0, ifaceCount)
for i := 0; i < ifaceCount; i++ {
name := fmt.Sprintf("eth%d", i)
if rand.Float64() < 0.3 {
name = fmt.Sprintf("ens%d", i+3)
}
mac := fmt.Sprintf("52:54:%02x:%02x:%02x:%02x",
rand.Intn(256), rand.Intn(256), rand.Intn(256), rand.Intn(256))
addrCount := 1 + rand.Intn(2)
addresses := make([]string, 0, addrCount)
for len(addresses) < addrCount {
var ip string
if rand.Float64() < 0.2 {
ip = fmt.Sprintf("fd00:%x:%x::%x", rand.Intn(1<<16), rand.Intn(1<<16), rand.Intn(1<<16))
} else {
ip = fmt.Sprintf("10.%d.%d.%d", rand.Intn(200)+1, rand.Intn(254), rand.Intn(254))
}
if _, exists := ipSet[ip]; exists {
continue
}
ipSet[ip] = struct{}{}
addresses = append(addresses, ip)
ipAddresses = append(ipAddresses, ip)
}
rxBytes := rand.Int63n(8*1024*1024*1024) + rand.Int63n(256*1024*1024)
txBytes := rand.Int63n(6*1024*1024*1024) + rand.Int63n(256*1024*1024)
interfaces = append(interfaces, models.GuestNetworkInterface{
Name: name,
MAC: mac,
Addresses: addresses,
RXBytes: rxBytes,
TXBytes: txBytes,
})
}
sort.Strings(ipAddresses)
return ipAddresses, interfaces
}
func generateGuestOSMetadata() (string, string) {
variants := []struct {
Name string
Version string
}{
{"Ubuntu", "22.04 LTS"},
{"Ubuntu", "24.04 LTS"},
{"Debian GNU/Linux", "12 (Bookworm)"},
{"Debian GNU/Linux", "11 (Bullseye)"},
{"CentOS Stream", "9"},
{"Rocky Linux", "9.3"},
{"AlmaLinux", "8.10"},
{"Fedora", fmt.Sprintf("%d", 38+rand.Intn(3))},
{"Windows Server", "2019"},
{"Windows Server", "2022"},
{"Arch Linux", "rolling"},
}
choice := variants[rand.Intn(len(variants))]
return choice.Name, choice.Version
}
func generateKubernetesClusters(config MockConfig) []models.KubernetesCluster {
clusterCount := config.K8sClusterCount
if clusterCount <= 0 {
return []models.KubernetesCluster{}
}
nodeCount := config.K8sNodesPerCluster
if nodeCount <= 0 {
nodeCount = 4
}
podCount := config.K8sPodsPerCluster
if podCount < 0 {
podCount = 0
}
deploymentCount := config.K8sDeploymentsPerCluster
if deploymentCount < 0 {
deploymentCount = 0
}
now := time.Now()
clusters := make([]models.KubernetesCluster, 0, clusterCount)
for i := 0; i < clusterCount; i++ {
name := k8sClusterNames[i%len(k8sClusterNames)]
clusterID := fmt.Sprintf("k8s-%s-%d", strings.ToLower(name), i+1)
server := fmt.Sprintf("https://%s.k8s.local:6443", strings.ToLower(name))
context := fmt.Sprintf("%s-context", strings.ToLower(name))
nodes := generateKubernetesNodes(clusterID, nodeCount)
pods := generateKubernetesPods(clusterID, nodes, podCount)
deployments := generateKubernetesDeployments(clusterID, deploymentCount, now)
services, ingresses, endpointSlices := generateKubernetesNetworkingInventory(clusterID, now)
storageClasses, persistentVolumes, persistentVolumeClaims := generateKubernetesStorageInventory(clusterID, now)
lastSeen := now.Add(-time.Duration(rand.Intn(20)) * time.Second)
status := "online"
if clusterHasIssues(nodes, pods, deployments) {
status = "degraded"
}
cluster := models.KubernetesCluster{
ID: clusterID,
AgentID: fmt.Sprintf("%s-agent", clusterID),
Name: name,
DisplayName: titleCase(name),
Server: server,
Context: context,
Version: k8sVersions[rand.Intn(len(k8sVersions))],
Status: status,
LastSeen: lastSeen,
IntervalSeconds: 30,
AgentVersion: "0.1.0-mock",
Nodes: nodes,
Pods: pods,
Deployments: deployments,
Services: services,
Ingresses: ingresses,
EndpointSlices: endpointSlices,
PersistentVolumes: persistentVolumes,
PersistentVolumeClaims: persistentVolumeClaims,
StorageClasses: storageClasses,
Hidden: false,
PendingUninstall: false,
}
initializeMockKubernetesClusterUsage(&cluster, now, true)
clusters = append(clusters, cluster)
}
return clusters
}
func generateKubernetesNetworkingInventory(
clusterID string,
now time.Time,
) ([]models.KubernetesService, []models.KubernetesIngress, []models.KubernetesEndpointSlice) {
createdAt := now.Add(-68 * time.Hour)
apiLabels := map[string]string{
"app.kubernetes.io/name": "checkout-api",
"app.kubernetes.io/part-of": "pulse-demo-estate",
}
webLabels := map[string]string{
"app.kubernetes.io/name": "checkout-web",
"app.kubernetes.io/part-of": "pulse-demo-estate",
}
services := []models.KubernetesService{
{
UID: clusterID + "-svc-checkout-api",
Name: "checkout-api",
Namespace: "services",
ServiceType: "ClusterIP",
ClusterIP: "10.96.18.24",
Ports: []models.KubernetesServicePort{
{Name: "http", Protocol: "TCP", Port: 8080, TargetPort: "8080"},
},
Selector: map[string]string{"app.kubernetes.io/name": "checkout-api"},
CreatedAt: createdAt,
Labels: cloneStringMap(apiLabels),
},
{
UID: clusterID + "-svc-checkout-public",
Name: "checkout-public",
Namespace: "apps",
ServiceType: "NodePort",
ClusterIP: "10.96.12.20",
ExternalIPs: []string{"203.0.113.24"},
Ports: []models.KubernetesServicePort{
{Name: "https", Protocol: "TCP", Port: 443, TargetPort: "8443", NodePort: 30443},
},
Selector: map[string]string{"app.kubernetes.io/name": "checkout-web"},
CreatedAt: createdAt.Add(5 * time.Minute),
Labels: cloneStringMap(webLabels),
},
}
ingresses := []models.KubernetesIngress{
{
UID: clusterID + "-ing-checkout-api",
Name: "checkout-api",
Namespace: "services",
ClassName: "nginx",
Hosts: []string{"checkout.example.test"},
Addresses: []string{"198.51.100.24"},
CreatedAt: createdAt.Add(10 * time.Minute),
Labels: cloneStringMap(apiLabels),
},
}
endpointSlices := []models.KubernetesEndpointSlice{
{
UID: clusterID + "-eps-checkout-api",
Name: "checkout-api-abc12",
Namespace: "services",
AddressType: "IPv4",
ServiceName: "checkout-api",
EndpointCount: 3,
ReadyEndpointCount: 3,
Ports: []models.KubernetesEndpointPort{
{Name: "http", Protocol: "TCP", Port: 8080, AppProtocol: "kubernetes.io/http"},
},
CreatedAt: createdAt.Add(15 * time.Minute),
Labels: cloneStringMap(apiLabels),
},
}
return services, ingresses, endpointSlices
}
func generateKubernetesStorageInventory(
clusterID string,
now time.Time,
) ([]models.KubernetesStorageClass, []models.KubernetesPersistentVolume, []models.KubernetesPersistentVolumeClaim) {
createdAt := now.Add(-72 * time.Hour)
allowExpansion := true
storageClassName := "fast-ssd"
pvName := fmt.Sprintf("%s-pv-postgres-data", clusterID)
claimName := "postgres-data"
claimNamespace := "services"
storageClasses := []models.KubernetesStorageClass{
{
UID: clusterID + "-sc-fast-ssd",
Name: storageClassName,
Provisioner: "csi.pulse-demo.local",
ReclaimPolicy: "Delete",
VolumeBindingMode: "WaitForFirstConsumer",
AllowVolumeExpansion: &allowExpansion,
ParameterKeys: []string{"type", "iops", "encrypted"},
CreatedAt: createdAt,
Labels: map[string]string{
"app.kubernetes.io/part-of": "pulse-demo-estate",
"storage.pulse.local/tier": "fast",
},
},
}
persistentVolumes := []models.KubernetesPersistentVolume{
{
UID: clusterID + "-pv-postgres-data",
Name: pvName,
Phase: "Bound",
StorageClass: storageClassName,
CapacityBytes: int64(80) << 30,
AccessModes: []string{"ReadWriteOnce"},
ReclaimPolicy: "Delete",
ClaimNamespace: claimNamespace,
ClaimName: claimName,
CreatedAt: createdAt.Add(15 * time.Minute),
Labels: map[string]string{
"app.kubernetes.io/name": "postgres",
"app.kubernetes.io/part-of": "pulse-demo-estate",
},
},
}
persistentVolumeClaims := []models.KubernetesPersistentVolumeClaim{
{
UID: clusterID + "-pvc-postgres-data",
Name: claimName,
Namespace: claimNamespace,
Phase: "Bound",
StorageClass: storageClassName,
RequestedBytes: int64(80) << 30,
CapacityBytes: int64(80) << 30,
AccessModes: []string{"ReadWriteOnce"},
VolumeName: pvName,
CreatedAt: createdAt.Add(30 * time.Minute),
Labels: map[string]string{
"app.kubernetes.io/name": "postgres",
"app.kubernetes.io/part-of": "pulse-demo-estate",
},
},
}
return storageClasses, persistentVolumes, persistentVolumeClaims
}
func initializeMockKubernetesClusterUsage(cluster *models.KubernetesCluster, now time.Time, randomize bool) {
if cluster == nil {
return
}
reconcileMockKubernetesPodScheduling(cluster, now, randomize)
nodeByName := make(map[string]*models.KubernetesNode, len(cluster.Nodes))
for i := range cluster.Nodes {
node := &cluster.Nodes[i]
normalizeMockKubernetesNodeCapacity(node)
name := strings.TrimSpace(node.Name)
if name != "" {
nodeByName[name] = node
}
}
activePodsByNode := make(map[string]int, len(cluster.Nodes))
for i := range cluster.Pods {
pod := &cluster.Pods[i]
nodeName := strings.TrimSpace(pod.NodeName)
if mockKubernetesPodIsActive(pod, nodeByName[nodeName]) {
activePodsByNode[nodeName]++
}
}
clusterOffline := strings.EqualFold(strings.TrimSpace(cluster.Status), "offline")
for i := range cluster.Pods {
pod := &cluster.Pods[i]
nodeName := strings.TrimSpace(pod.NodeName)
updateMockKubernetesPodUsage(pod, nodeByName[nodeName], activePodsByNode[nodeName], now, randomize, clusterOffline)
}
capMockKubernetesNodePodMemory(cluster, nodeByName)
recomputeMockKubernetesNodeUsage(cluster, now)
}
// mockKubernetesNodePodMemoryBudget is how many single-pod memory footprints
// the active pods on one node share. A pod's memory percent is measured
// against its node's allocatable memory, as the live agent reports it, so
// giving every pod a whole footprint (roughly 10-83%) committed any node
// running four or more pods past its allocatable memory. The node, and the
// host agent linked to it, then read a constant 100%. The largest footprint
// is about 83% (a Guaranteed StatefulSet pod at full burst), so a budget of
// 0.7 footprints keeps a node's pods at or below 58% in steady state, under
// mockKubernetesNodePodMemoryCeiling.
const mockKubernetesNodePodMemoryBudget = 0.7
// mockKubernetesPodMemoryShare scales one pod's memory target by the number
// of active pods on its node, so the node total stays within the budget plus
// system overhead however many pods are scheduled there.
func mockKubernetesPodMemoryShare(activePodsOnNode int) float64 {
if activePodsOnNode < 1 {
activePodsOnNode = 1
}
return mockKubernetesNodePodMemoryBudget / float64(activePodsOnNode)
}
// mockKubernetesNodePodMemoryCeiling bounds the fraction of a node's
// allocatable memory its active pods may hold together. Pod readings smooth
// toward their share of the budget, so they lag a change in the node's pod
// count: a pod rescheduled off a failed node arrives with a reading sized for
// its old node. Share-scaled targets stay under the ceiling, so it binds only
// on that transient, keeping the node, with its system overhead of up to 26%,
// below allocatable memory while readings settle.
const mockKubernetesNodePodMemoryCeiling = 0.6
// capMockKubernetesNodePodMemory applies the ceiling and reports how many
// nodes it clipped.
func capMockKubernetesNodePodMemory(cluster *models.KubernetesCluster, nodeByName map[string]*models.KubernetesNode) int {
usedByNode := make(map[string]int64, len(cluster.Nodes))
for i := range cluster.Pods {
pod := &cluster.Pods[i]
nodeName := strings.TrimSpace(pod.NodeName)
if nodeByName[nodeName] != nil && mockKubernetesPodIsActive(pod, nodeByName[nodeName]) {
usedByNode[nodeName] += pod.UsageMemoryBytes
}
}
scaleByNode := make(map[string]float64, len(usedByNode))
for nodeName, used := range usedByNode {
node := nodeByName[nodeName]
allocMemory := node.AllocMemoryBytes
if allocMemory <= 0 {
allocMemory = node.CapacityMemoryBytes
}
ceiling := float64(allocMemory) * mockKubernetesNodePodMemoryCeiling
if allocMemory > 0 && float64(used) > ceiling {
scaleByNode[nodeName] = ceiling / float64(used)
}
}
if len(scaleByNode) == 0 {
return 0
}
for i := range cluster.Pods {
pod := &cluster.Pods[i]
nodeName := strings.TrimSpace(pod.NodeName)
scale, ok := scaleByNode[nodeName]
if !ok || !mockKubernetesPodIsActive(pod, nodeByName[nodeName]) {
continue
}
pod.UsageMemoryPercent *= scale
pod.UsageMemoryBytes = int64(float64(pod.UsageMemoryBytes) * scale)
}
return len(scaleByNode)
}
// reconcileMockKubernetesPodScheduling keeps pod placement coherent with
// node readiness. It runs on every mock update tick, both mid-tick (with
// randomize) and again after the demo scenario re-pins pod profiles, so it
// must be idempotent: the same inputs must produce the same pod state, and
// it must never fabricate a fresh StartTime for a pod that already has one.
// A regenerated StartTime moves the pod's derived uptime backwards, which
// unified resources records as a restart — an earlier version of this
// function re-anchored StartTime with rand on every recovery and flooded
// resource_changes with tens of thousands of phantom pod restarts per day.
func reconcileMockKubernetesPodScheduling(cluster *models.KubernetesCluster, now time.Time, randomize bool) {
if cluster == nil {
return
}
if now.IsZero() {
now = time.Now()
}
clusterOffline := strings.EqualFold(strings.TrimSpace(cluster.Status), "offline")
if clusterOffline {
for i := range cluster.Nodes {
cluster.Nodes[i].Ready = false
}
for i := range cluster.Pods {
pod := &cluster.Pods[i]
if strings.EqualFold(strings.TrimSpace(pod.Phase), "running") {
pod.Phase = "Unknown"
pod.Reason = "ClusterOffline"
pod.Message = "Cluster telemetry temporarily unavailable"
for j := range pod.Containers {
if strings.EqualFold(strings.TrimSpace(pod.Containers[j].State), "terminated") {
continue
}
pod.Containers[j].Ready = false
pod.Containers[j].State = "unknown"
pod.Containers[j].Reason = "ClusterOffline"
pod.Containers[j].Message = "Cluster telemetry temporarily unavailable"
}
}
}
return
}
readyNodes := make([]string, 0, len(cluster.Nodes))
readyLookup := make(map[string]struct{}, len(cluster.Nodes))
for _, node := range cluster.Nodes {
name := strings.TrimSpace(node.Name)
if name == "" {
continue
}
if node.Ready && !node.Unschedulable {
readyNodes = append(readyNodes, name)
readyLookup[name] = struct{}{}
}
}
for i := range cluster.Pods {
pod := &cluster.Pods[i]
phase := strings.ToLower(strings.TrimSpace(pod.Phase))
nodeName := strings.TrimSpace(pod.NodeName)
_, nodeReady := readyLookup[nodeName]
switch phase {
case "running":
if nodeReady {
continue
}
if len(readyNodes) == 0 {
pod.Phase = "Pending"
pod.Reason = "Unschedulable"
pod.Message = "No ready nodes available"
pod.StartTime = nil
continue
}
// A stable per-pod choice, not a fresh roll per tick: the same
// pods on a dead node always park as Pending and the rest always
// reschedule, so repeated reconciles cannot flap a pod between
// the two outcomes.
if randomize && mockStableHash64(strings.TrimSpace(pod.UID), pod.Name, "notready-park")%4 == 0 {
pod.Phase = "Pending"
pod.Reason = "NodeNotReady"
pod.Message = "Waiting for node recovery"
continue
}
assignIndex := int(mockStableHash64(strings.TrimSpace(pod.UID), pod.Name, "ready-node") % uint64(len(readyNodes)))
pod.NodeName = readyNodes[assignIndex]
pod.Reason = ""
pod.Message = ""
if pod.StartTime == nil {
start := now
pod.StartTime = &start
}
case "pending", "unknown":
if len(readyNodes) == 0 {
continue
}
// Only un-park pods this reconciler parked itself. Curated
// fixture stories (Unschedulable capacity pressure,
// ImagePullBackOff, CrashLoopBackOff) must stay put, or the
// scenario re-pins them every tick and the recover/re-pin cycle
// churns phantom state transitions and restarts.
if !mockReconcilerParkedPod(*pod) {
continue
}
assignIndex := int(mockStableHash64(strings.TrimSpace(pod.UID), pod.Name, "recover-node") % uint64(len(readyNodes)))
pod.NodeName = readyNodes[assignIndex]
pod.Phase = "Running"
pod.Reason = ""
pod.Message = ""
if pod.StartTime == nil {
start := now
pod.StartTime = &start
}
for j := range pod.Containers {
if strings.EqualFold(pod.Containers[j].State, "terminated") {
continue
}
pod.Containers[j].State = "running"
pod.Containers[j].Reason = ""
pod.Containers[j].Message = ""
pod.Containers[j].Ready = true
}
}
}
}
// mockReconcilerParkedPod reports whether a pending/unknown pod is in a
// transient state that the scheduling reconciler (or a node/cluster outage)
// produced, as opposed to a curated fixture story that must persist.
func mockReconcilerParkedPod(pod models.KubernetesPod) bool {
switch strings.TrimSpace(pod.Reason) {
case "NodeNotReady", "ClusterOffline", "NodeLost":
return true
case "Unschedulable":
// The reconciler's own capacity parking; the curated Unschedulable
// story uses "0/3 nodes available".
return strings.TrimSpace(pod.Message) == "No ready nodes available"
default:
return false
}
}
func normalizeMockKubernetesNodeCapacity(node *models.KubernetesNode) {
if node == nil {
return
}
if node.CapacityCPU <= 0 {
node.CapacityCPU = 4
}
if node.AllocCPU <= 0 || node.AllocCPU > node.CapacityCPU {
node.AllocCPU = node.CapacityCPU
}
if node.CapacityMemoryBytes <= 0 {
node.CapacityMemoryBytes = int64(16) * 1024 * 1024 * 1024
}
if node.AllocMemoryBytes <= 0 || node.AllocMemoryBytes > node.CapacityMemoryBytes {
node.AllocMemoryBytes = node.CapacityMemoryBytes
}
if node.CapacityPods <= 0 {
node.CapacityPods = 110
}
if node.AllocPods <= 0 || node.AllocPods > node.CapacityPods {
node.AllocPods = node.CapacityPods
}
}
func updateMockKubernetesPodUsage(
pod *models.KubernetesPod,
node *models.KubernetesNode,
activePodsOnNode int,
now time.Time,
randomize bool,
clusterOffline bool,
) {
if pod == nil {
return
}
if clusterOffline || !mockKubernetesPodIsActive(pod, node) {
applyMockKubernetesPodZeroUsage(pod)
return
}
allocCPU := int64(2)
allocMemory := int64(8) * 1024 * 1024 * 1024
if node != nil {
if node.AllocCPU > 0 {
allocCPU = node.AllocCPU
} else if node.CapacityCPU > 0 {
allocCPU = node.CapacityCPU
}
if node.AllocMemoryBytes > 0 {
allocMemory = node.AllocMemoryBytes
} else if node.CapacityMemoryBytes > 0 {
allocMemory = node.CapacityMemoryBytes
}
}
if allocCPU <= 0 {
allocCPU = 2
}
if allocMemory <= 0 {
allocMemory = int64(8) * 1024 * 1024 * 1024
}
seedID := strings.TrimSpace(pod.UID)
if seedID == "" {
seedID = strings.TrimSpace(pod.Namespace) + "/" + strings.TrimSpace(pod.Name)
}
ownerKind := strings.ToLower(strings.TrimSpace(pod.OwnerKind))
ownerScale := 1.0
switch ownerKind {
case "statefulset":
ownerScale = 1.28
case "daemonset":
ownerScale = 0.82
case "job":
ownerScale = 0.74
}
switch strings.ToLower(strings.TrimSpace(pod.QoSClass)) {
case "guaranteed":
ownerScale *= 1.12
case "besteffort":
ownerScale *= 0.78
}
readyContainers := 0
for _, container := range pod.Containers {
if container.Ready {
readyContainers++
}
}
readyRatio := 1.0
if len(pod.Containers) > 0 {
readyRatio = float64(readyContainers) / float64(len(pod.Containers))
if readyRatio < 0.3 {
readyRatio = 0.3
}
}
activity := mockKubernetesActivity(seedID, now, 5*60, randomize)
activity *= 0.65 + (readyRatio * 0.45)
activity = clampFloat(activity, 0.05, 0.98)
baseCPU := 1.5 + float64(mockStableHash64(seedID, "cpu-base")%10)
burstCPU := 18.0 + float64(mockStableHash64(seedID, "cpu-burst")%52)
targetCPU := (baseCPU + activity*burstCPU) * ownerScale
pod.UsageCPUPercent = clampFloat(smoothMetricToward(pod.UsageCPUPercent, targetCPU, 0.34), 0.1, 96)
cpuMilli := int((pod.UsageCPUPercent / 100.0) * float64(allocCPU*1000))
if cpuMilli < 1 {
cpuMilli = 1
}
pod.UsageCPUMilliCores = cpuMilli
baseMemory := 9.0 + float64(mockStableHash64(seedID, "mem-base")%20)
burstMemory := 14.0 + float64(mockStableHash64(seedID, "mem-burst")%36)
memoryShare := mockKubernetesPodMemoryShare(activePodsOnNode)
targetMemory := (baseMemory + activity*burstMemory) * (0.92 + ownerScale*0.12) * memoryShare
pod.UsageMemoryPercent = clampFloat(smoothMetricToward(pod.UsageMemoryPercent, targetMemory, 0.08), 3*memoryShare, 97)
pod.UsageMemoryBytes = int64(float64(allocMemory) * (pod.UsageMemoryPercent / 100.0))
baseNetIn := float64((24 + int(mockStableHash64(seedID, "netin-base")%180)) * 1024)
burstNetIn := float64((128 + int(mockStableHash64(seedID, "netin-burst")%4096)) * 1024)
baseNetOut := float64((20 + int(mockStableHash64(seedID, "netout-base")%160)) * 1024)
burstNetOut := float64((96 + int(mockStableHash64(seedID, "netout-burst")%3072)) * 1024)
if ownerKind == "statefulset" {
baseNetOut *= 0.75
burstNetOut *= 0.75
}
targetNetIn := (baseNetIn + activity*burstNetIn) * (0.85 + ownerScale*0.25)
targetNetOut := (baseNetOut + activity*burstNetOut) * (0.8 + ownerScale*0.2)
pod.NetInRate = clampFloat(smoothMetricToward(pod.NetInRate, targetNetIn, 0.36), 4*1024, 180*1024*1024)
pod.NetOutRate = clampFloat(smoothMetricToward(pod.NetOutRate, targetNetOut, 0.36), 4*1024, 150*1024*1024)
seconds := currentMockUpdateStepSeconds()
if pod.NetworkRxBytes <= 0 {
seedSeconds := int64(180 + (mockStableHash64(seedID, "rx-seed") % 1800))
pod.NetworkRxBytes = int64(pod.NetInRate * float64(seedSeconds))
} else {
pod.NetworkRxBytes += int64(pod.NetInRate * seconds)
}
if pod.NetworkTxBytes <= 0 {
seedSeconds := int64(180 + (mockStableHash64(seedID, "tx-seed") % 1800))
pod.NetworkTxBytes = int64(pod.NetOutRate * float64(seedSeconds))
} else {
pod.NetworkTxBytes += int64(pod.NetOutRate * seconds)
}
capacityGiB := int64(8 + (mockStableHash64(seedID, "ephemeral-cap") % 180))
if ownerKind == "statefulset" {
capacityGiB += 64
}
if ownerKind == "daemonset" {
capacityGiB += 16
}
if capacityGiB < 4 {
capacityGiB = 4
}
capacityBytes := capacityGiB * 1024 * 1024 * 1024
if pod.EphemeralStorageCapacityBytes > 0 && pod.EphemeralStorageCapacityBytes > capacityBytes/2 {
capacityBytes = pod.EphemeralStorageCapacityBytes
}
pod.EphemeralStorageCapacityBytes = capacityBytes
baseDisk := 8.0 + float64(mockStableHash64(seedID, "disk-base")%18)
burstDisk := 12.0 + float64(mockStableHash64(seedID, "disk-burst")%40)
targetDisk := (baseDisk + activity*burstDisk) * (0.95 + ownerScale*0.08)
if ownerKind == "statefulset" {
targetDisk += 8
}
pod.DiskUsagePercent = clampFloat(smoothMetricToward(pod.DiskUsagePercent, targetDisk, 0.18), 1, 95)
pod.EphemeralStorageUsedBytes = int64(float64(capacityBytes) * (pod.DiskUsagePercent / 100.0))
}
func mockKubernetesPodIsActive(pod *models.KubernetesPod, node *models.KubernetesNode) bool {
if pod == nil {
return false
}
if !strings.EqualFold(strings.TrimSpace(pod.Phase), "running") {
return false
}
if strings.TrimSpace(pod.NodeName) == "" {
return false
}
if node != nil && !node.Ready {
return false
}
return true
}
func applyMockKubernetesPodZeroUsage(pod *models.KubernetesPod) {
if pod == nil {
return
}
pod.UsageCPUMilliCores = 0
pod.UsageMemoryBytes = 0
pod.UsageCPUPercent = 0
pod.UsageMemoryPercent = 0
pod.NetInRate = 0
pod.NetOutRate = 0
pod.DiskUsagePercent = 0
pod.EphemeralStorageUsedBytes = 0
}
func recomputeMockKubernetesNodeUsage(cluster *models.KubernetesCluster, now time.Time) {
if cluster == nil {
return
}
type aggregate struct {
cpuMilli int64
memBytes int64
}
podTotals := make(map[string]aggregate, len(cluster.Nodes))
for _, pod := range cluster.Pods {
if !strings.EqualFold(strings.TrimSpace(pod.Phase), "running") {
continue
}
nodeName := strings.TrimSpace(pod.NodeName)
if nodeName == "" {
continue
}
sum := podTotals[nodeName]
if pod.UsageCPUMilliCores > 0 {
sum.cpuMilli += int64(pod.UsageCPUMilliCores)
}
if pod.UsageMemoryBytes > 0 {
sum.memBytes += pod.UsageMemoryBytes
}
podTotals[nodeName] = sum
}
clusterOffline := strings.EqualFold(strings.TrimSpace(cluster.Status), "offline")
for i := range cluster.Nodes {
node := &cluster.Nodes[i]
normalizeMockKubernetesNodeCapacity(node)
if clusterOffline || !node.Ready {
node.UsageCPUMilliCores = 0
node.UsageMemoryBytes = 0
node.UsageCPUPercent = 0
node.UsageMemoryPercent = 0
continue
}
allocCPU := node.AllocCPU
if allocCPU <= 0 {
allocCPU = node.CapacityCPU
}
allocMemory := node.AllocMemoryBytes
if allocMemory <= 0 {
allocMemory = node.CapacityMemoryBytes
}
if allocCPU <= 0 || allocMemory <= 0 {
continue
}
name := strings.TrimSpace(node.Name)
sum := podTotals[name]
activity := mockKubernetesActivity(name+"-node", now, 7*60, true)
overheadCPU := int64(float64(allocCPU*1000) * clampFloat(0.04+activity*0.08, 0.03, 0.18))
overheadMemory := int64(float64(allocMemory) * clampFloat(0.1+activity*0.12, 0.08, 0.26))
usedCPU := sum.cpuMilli + overheadCPU
maxCPU := allocCPU * 1000
if usedCPU < 0 {
usedCPU = 0
}
if usedCPU > maxCPU {
usedCPU = maxCPU
}
usedMemory := sum.memBytes + overheadMemory
if usedMemory < 0 {
usedMemory = 0
}
if usedMemory > allocMemory {
usedMemory = allocMemory
}
node.UsageCPUMilliCores = usedCPU
node.UsageMemoryBytes = usedMemory
node.UsageCPUPercent = clampFloat((float64(usedCPU)/float64(maxCPU))*100, 0, 100)
node.UsageMemoryPercent = clampFloat((float64(usedMemory)/float64(allocMemory))*100, 0, 100)
}
}
func mockStableHash64(parts ...string) uint64 {
h := fnv.New64a()
for _, part := range parts {
if part == "" {
continue
}
_, _ = h.Write([]byte(part))
_, _ = h.Write([]byte{0})
}
return h.Sum64()
}
func mockKubernetesActivity(seed string, now time.Time, windowSeconds int64, randomize bool) float64 {
if windowSeconds <= 0 {
windowSeconds = 5 * 60
}
hash := mockStableHash64(seed)
bucket := (now.Unix() / windowSeconds) + int64(hash%19)
phase := bucket % 9
base := 0.2
switch phase {
case 0, 1, 2, 3:
base = 0.16
case 4, 5:
base = 0.42
case 6:
base = 0.74
case 7:
base = 0.55
default:
base = 0.3
}
progress := float64(now.Unix()%windowSeconds) / float64(windowSeconds)
phaseOffset := float64(hash%628) / 100.0
wave := 0.1 * math.Sin((progress*2*math.Pi)+phaseOffset)
if randomize {
wave += (rand.Float64() - 0.5) * 0.08
}
return clampFloat(base+wave, 0.05, 0.98)
}
func smoothMetricToward(current, target, weight float64) float64 {
if weight <= 0 || weight > 1 {
weight = 0.25
}
weight = normalizeMockBlendWeight(weight, currentMockUpdateInterval(), time.Minute)
if current <= 0 {
current = target * (0.68 + rand.Float64()*0.22)
}
return current + ((target - current) * weight)
}
func generateKubernetesNodes(clusterID string, count int) []models.KubernetesNode {
if count <= 0 {
return []models.KubernetesNode{}
}
nodes := make([]models.KubernetesNode, 0, count)
architectures := []string{"amd64", "arm64"}
runtimes := []string{"containerd://1.7.21", "containerd://1.7.20", "cri-o://1.30.4"}
for i := 0; i < count; i++ {
name := fmt.Sprintf("%s-node-%d", clusterID, i+1)
ready := rand.Float64() > 0.08
unschedulable := rand.Float64() < 0.08
roles := []string{"worker"}
if i == 0 {
roles = []string{"control-plane"}
} else if i == 1 && count > 3 {
roles = []string{"worker", "gpu"}
}
cpu := int64(2 + rand.Intn(30))
memGiB := int64(8 + rand.Intn(248))
pods := int64(110 + rand.Intn(50))
capacityMemory := memGiB * 1024 * 1024 * 1024
allocCPU := cpu - int64(rand.Intn(2))
if allocCPU < 1 {
allocCPU = 1
}
allocMem := capacityMemory - int64(rand.Intn(3))*1024*1024*1024
if allocMem < 1 {
allocMem = capacityMemory
}
nodes = append(nodes, models.KubernetesNode{
UID: fmt.Sprintf("%s-%s", name, mockStableHexString(8, clusterID, name, "k8s-node-uid")),
Name: name,
Ready: ready,
Unschedulable: unschedulable,
KubeletVersion: k8sVersions[rand.Intn(len(k8sVersions))],
ContainerRuntimeVersion: runtimes[rand.Intn(len(runtimes))],
OSImage: k8sNodeOS[rand.Intn(len(k8sNodeOS))],
KernelVersion: dockerKernelVersions[rand.Intn(len(dockerKernelVersions))],
Architecture: architectures[rand.Intn(len(architectures))],
CapacityCPU: cpu,
CapacityMemoryBytes: capacityMemory,
CapacityPods: pods,
AllocCPU: allocCPU,
AllocMemoryBytes: allocMem,
AllocPods: pods - int64(rand.Intn(10)),
Roles: roles,
})
}
// Ensure at least one node issue sometimes.
if count > 2 && rand.Float64() < 0.35 {
idx := 1 + rand.Intn(count-1)
nodes[idx].Ready = false
}
// A non-empty demo cluster must retain at least one node that can run
// workloads. Independent readiness and scheduling rolls can otherwise
// produce a fully unavailable estate, making pod placement and the metric
// story nondeterministic even when no outage scenario was requested.
hasSchedulableReadyNode := false
for _, node := range nodes {
if node.Ready && !node.Unschedulable {
hasSchedulableReadyNode = true
break
}
}
if !hasSchedulableReadyNode {
nodes[0].Ready = true
nodes[0].Unschedulable = false
}
return nodes
}
func generateKubernetesPods(clusterID string, nodes []models.KubernetesNode, count int) []models.KubernetesPod {
if count <= 0 {
return []models.KubernetesPod{}
}
now := time.Now()
pods := make([]models.KubernetesPod, 0, count)
for i := 0; i < count; i++ {
namespace := k8sNamespaces[mockStableChoice(len(k8sNamespaces), clusterID, fmt.Sprintf("%d", i), "k8s-pod-namespace")]
prefix := k8sPodPrefixes[mockStableChoice(len(k8sPodPrefixes), clusterID, fmt.Sprintf("%d", i), "k8s-pod-prefix")]
name := fmt.Sprintf("%s-%s-%d", prefix, mockStableHexString(5, clusterID, namespace, prefix, fmt.Sprintf("%d", i), "k8s-pod-name"), i+1)
nodeName := ""
if len(nodes) > 0 && rand.Float64() > 0.08 {
nodeName = nodes[rand.Intn(len(nodes))].Name
}
createdAt := now.Add(-time.Duration(30+rand.Intn(7200)) * time.Second)
startTime := createdAt.Add(time.Duration(10+rand.Intn(120)) * time.Second)
qos := []string{"Guaranteed", "Burstable", "BestEffort"}[rand.Intn(3)]
phase := "Running"
reason := ""
message := ""
containerState := "running"
containerReason := ""
containerMessage := ""
containerReady := true
restarts := 0
roll := rand.Float64()
switch {
case roll < 0.08:
phase = "Pending"
reason = "Unschedulable"
message = "0/3 nodes available"
containerState = "waiting"
containerReason = "PodInitializing"
containerReady = false
case roll < 0.14:
phase = "Running"
containerState = "waiting"
containerReason = "CrashLoopBackOff"
containerMessage = "Back-off restarting failed container"
containerReady = false
restarts = 3 + rand.Intn(20)
case roll < 0.18:
phase = "Failed"
reason = "Error"
message = "Pod terminated with non-zero exit code"
containerState = "terminated"
containerReason = "Error"
containerReady = false
restarts = 1 + rand.Intn(6)
case roll < 0.22:
phase = "Unknown"
reason = "NodeLost"
message = "Node status is unknown"
containerState = "unknown"
containerReady = false
}
containerCount := 1
if rand.Float64() < 0.12 {
containerCount = 2
}
containers := make([]models.KubernetesPodContainer, 0, containerCount)
for c := 0; c < containerCount; c++ {
image := k8sImages[rand.Intn(len(k8sImages))]
containers = append(containers, models.KubernetesPodContainer{
Name: fmt.Sprintf("%s-%d", prefix, c+1),
Image: image,
Ready: containerReady,
RestartCount: int32(restarts),
State: containerState,
Reason: containerReason,
Message: containerMessage,
})
}
ownerKind := []string{"Deployment", "StatefulSet", "DaemonSet", "Job"}[rand.Intn(4)]
ownerName := fmt.Sprintf("%s-%s", strings.ToLower(prefix), mockStableHexString(4, clusterID, namespace, prefix, fmt.Sprintf("%d", i), "k8s-pod-owner"))
labels := map[string]string{
"app.kubernetes.io/name": prefix,
"app.kubernetes.io/instance": ownerName,
"app.kubernetes.io/managed-by": "mock",
}
pod := models.KubernetesPod{
UID: fmt.Sprintf("%s-%s", name, mockStableHexString(10, clusterID, namespace, name, fmt.Sprintf("%d", i), "k8s-pod-uid")),
Name: name,
Namespace: namespace,
NodeName: nodeName,
Phase: phase,
Reason: reason,
Message: message,
QoSClass: qos,
CreatedAt: createdAt,
StartTime: &startTime,
Restarts: restarts,
Labels: labels,
OwnerKind: ownerKind,
OwnerName: ownerName,
Containers: containers,
}
if phase == "Pending" {
pod.StartTime = nil
}
pods = append(pods, pod)
}
return pods
}
func generateKubernetesDeployments(clusterID string, count int, now time.Time) []models.KubernetesDeployment {
if count <= 0 {
return []models.KubernetesDeployment{}
}
deployments := make([]models.KubernetesDeployment, 0, count)
for i := 0; i < count; i++ {
namespace := k8sNamespaces[mockStableChoice(len(k8sNamespaces), clusterID, fmt.Sprintf("%d", i), "k8s-deployment-namespace")]
prefix := k8sPodPrefixes[mockStableChoice(len(k8sPodPrefixes), clusterID, fmt.Sprintf("%d", i), "k8s-deployment-prefix")]
name := fmt.Sprintf("%s-%s", prefix, mockStableHexString(4, clusterID, namespace, prefix, fmt.Sprintf("%d", i), "k8s-deployment-name"))
desired := int32(1 + rand.Intn(6))
updated := desired
ready := desired
available := desired
// Degrade some deployments for UI coverage.
if rand.Float64() < 0.20 && desired > 0 {
ready = int32(rand.Intn(int(desired)))
available = ready
updated = int32(rand.Intn(int(desired) + 1))
}
deployments = append(deployments, models.KubernetesDeployment{
UID: fmt.Sprintf("%s-%s", name, mockStableHexString(10, clusterID, namespace, name, fmt.Sprintf("%d", i), "k8s-deployment-uid")),
Name: name,
Namespace: namespace,
CreatedAt: now.Add(-time.Duration(6+i*3+rand.Intn(48)) * time.Hour),
DesiredReplicas: desired,
UpdatedReplicas: updated,
ReadyReplicas: ready,
AvailableReplicas: available,
ObservedGeneration: int64(1 + i + rand.Intn(20)),
Labels: map[string]string{
"app.kubernetes.io/name": prefix,
"cluster": clusterID,
},
})
}
return deployments
}
func clusterHasIssues(nodes []models.KubernetesNode, pods []models.KubernetesPod, deployments []models.KubernetesDeployment) bool {
for _, node := range nodes {
if !node.Ready || node.Unschedulable {
return true
}
}
for _, pod := range pods {
if !kubernetesPodHealthy(pod) {
return true
}
}
for _, d := range deployments {
if !kubernetesDeploymentHealthy(d) {
return true
}
}
return false
}
func kubernetesPodHealthy(pod models.KubernetesPod) bool {
if strings.ToLower(strings.TrimSpace(pod.Phase)) != "running" {
return false
}
for _, c := range pod.Containers {
if !c.Ready {
return false
}
state := strings.ToLower(strings.TrimSpace(c.State))
if state != "" && state != "running" {
return false
}
}
return true
}
func kubernetesDeploymentHealthy(d models.KubernetesDeployment) bool {
desired := d.DesiredReplicas
if desired <= 0 {
return true
}
return d.ReadyReplicas >= desired && d.AvailableReplicas >= desired && d.UpdatedReplicas >= desired
}
func generateDockerHosts(config MockConfig) []models.DockerHost {
hostCount := config.DockerHostCount
if hostCount <= 0 {
return []models.DockerHost{}
}
now := time.Now()
hosts := make([]models.DockerHost, 0, hostCount)
swarmLeaderAssigned := false
for i := 0; i < hostCount; i++ {
agentVersion := dockerAgentVersions[rand.Intn(len(dockerAgentVersions))]
prefix := dockerHostPrefixes[i%len(dockerHostPrefixes)]
hostname := fmt.Sprintf("%s-%d", prefix, i+1)
hostID := fmt.Sprintf("%s-mock", hostname)
cpus := []int{4, 6, 8, 12, 16}[rand.Intn(5)]
totalMemoryBytes := int64((16 + rand.Intn(64)) * 1024 * 1024 * 1024) // 16-79 GB
interval := 30
uptime := int64(86400*(3+rand.Intn(25))) + int64(rand.Intn(3600))
isPodman := hostCount > 1 && i%3 == 0
runtime := "docker"
runtimeVersion := dockerVersions[rand.Intn(len(dockerVersions))]
dockerVersion := runtimeVersion
if isPodman {
runtime = "podman"
runtimeVersion = podmanVersions[rand.Intn(len(podmanVersions))]
dockerVersion = ""
}
containers := generateDockerContainers(hostname, i, config, isPodman)
// Keep one host mildly degraded so the demo still shows issue handling.
if i == 1 && len(containers) > 0 && hostCount > 1 {
idx := rand.Intn(len(containers))
containers[idx].Health = "unhealthy"
containers[idx].CPUPercent = clampFloat(containers[idx].CPUPercent+18, 5, 190)
}
status := "online"
lastSeen := now.Add(-time.Duration(rand.Intn(20)) * time.Second)
running := 0
unhealthy := 0
for _, ct := range containers {
if strings.ToLower(ct.State) == "running" {
running++
healthState := strings.ToLower(ct.Health)
if healthState == "unhealthy" || healthState == "starting" {
unhealthy++
}
}
}
if unhealthy > 0 || (len(containers) > 0 && float64(len(containers)-running)/float64(len(containers)) > 0.45) {
status = "degraded"
if lastSeen.After(now.Add(-30 * time.Second)) {
lastSeen = now.Add(-35 * time.Second)
}
}
var swarmInfo *models.DockerSwarmInfo
var services []models.DockerService
var tasks []models.DockerTask
var secrets []models.DockerSecret
var configs []models.DockerConfig
if !isPodman {
// ClusterID/ClusterName are required for the unified resource
// adapter to project Swarm services as docker-service rows
// (`dockerSwarmClusterKey` returns empty without them). Anchor
// the mock estate to a single named cluster so all manager and
// worker hosts share Swarm identity.
swarmInfo = &models.DockerSwarmInfo{
NodeID: fmt.Sprintf("%s-node", hostID),
NodeRole: "worker",
LocalState: "active",
ControlAvailable: false,
ClusterID: "mock-swarm-cluster-1",
ClusterName: "edge-swarm",
Scope: "node",
}
// The smallest useful mock estates often have one Podman host and
// one Docker host. Make the first Docker host a manager regardless
// of its absolute index so those estates still exercise the Swarm
// services, tasks, nodes, secrets, and configs surfaces.
if !swarmLeaderAssigned || i%2 == 0 {
swarmInfo.NodeRole = "manager"
}
// Cluster-scoped Swarm objects (services, tasks, secrets,
// configs) come from exactly one control plane, mirroring how a
// real deployment queries them through a single manager. When
// several mock hosts each fabricated their own service list from
// their own containers under the shared cluster key, the
// registry's cross-host dedupe alternated between the divergent
// candidates on every poll and flooded resource_changes with
// phantom service renames and status flips.
if !swarmLeaderAssigned && strings.EqualFold(swarmInfo.NodeRole, "manager") {
swarmLeaderAssigned = true
swarmInfo.ControlAvailable = true
swarmInfo.Scope = "cluster"
services, tasks = generateDockerServicesAndTasks(hostname, containers, now)
secrets, configs = generateDockerSwarmSecretsAndConfigs(hostname, now)
if len(services) == 0 {
swarmInfo.Scope = "node"
}
}
}
cpuUsage := SampleMetric("dockerHost", hostID, "cpu", now)
loadAverage := []float64{
clampFloat(rand.Float64()*float64(cpus), 0, float64(cpus)+1),
clampFloat(rand.Float64()*float64(cpus), 0, float64(cpus)+1),
clampFloat(rand.Float64()*float64(cpus), 0, float64(cpus)+1),
}
memUsageRatio := SampleMetric("dockerHost", hostID, "memory", now) / 100.0
usedMemoryBytes := int64(float64(totalMemoryBytes) * memUsageRatio)
if usedMemoryBytes > totalMemoryBytes {
usedMemoryBytes = totalMemoryBytes
}
freeMemoryBytes := totalMemoryBytes - usedMemoryBytes
memory := models.Memory{
Total: totalMemoryBytes,
Used: usedMemoryBytes,
Free: freeMemoryBytes,
}
if totalMemoryBytes > 0 {
memory.Usage = clampFloat((float64(usedMemoryBytes)/float64(totalMemoryBytes))*100, 0, 100)
}
diskTotal := int64((250 + rand.Intn(750)) * 1024 * 1024 * 1024) // 250-999 GB
diskUsed := int64(float64(diskTotal) * clampFloat(0.35+rand.Float64()*0.5, 0.1, 0.97))
if diskUsed > diskTotal {
diskUsed = diskTotal
}
diskFree := diskTotal - diskUsed
diskUsage := SampleMetric("dockerHost", mockHostDiskMetricID(hostID, 0), "disk", now)
diskUsed = int64(float64(diskTotal) * (diskUsage / 100.0))
if diskUsed > diskTotal {
diskUsed = diskTotal
}
diskFree = diskTotal - diskUsed
disks := []models.Disk{
{
Total: diskTotal,
Used: diskUsed,
Free: diskFree,
Usage: diskUsage,
Mountpoint: "/",
Type: "ext4",
Device: "/dev/sda1",
},
}
networkInterfaces := []models.HostNetworkInterface{
{
Name: "eth0",
Addresses: []string{fmt.Sprintf("10.10.%d.%d/24", i%20, rand.Intn(200)+10)},
RXBytes: uint64(rand.Int63n(5_000_000_000) + 500_000_000),
TXBytes: uint64(rand.Int63n(4_000_000_000) + 400_000_000),
},
}
if rand.Intn(4) == 0 {
networkInterfaces = append(networkInterfaces, models.HostNetworkInterface{
Name: "eth1",
Addresses: []string{fmt.Sprintf("172.16.%d.%d/24", i%16, rand.Intn(200)+20)},
RXBytes: uint64(rand.Int63n(2_000_000_000) + 200_000_000),
TXBytes: uint64(rand.Int63n(1_500_000_000) + 150_000_000),
})
}
temperatureValue := SampleMetric("dockerHost", hostID, "temperature", now)
temperature := &temperatureValue
if status == "offline" {
temperature = nil
}
host := models.DockerHost{
ID: hostID,
AgentID: fmt.Sprintf("agent-%s", mockStableHexString(16, "docker-agent", hostID)),
Hostname: hostname,
DisplayName: humanizeHostDisplayName(hostname),
MachineID: mockStableHexString(32, "docker-host-machine", hostID),
OS: dockerOperatingSystems[rand.Intn(len(dockerOperatingSystems))],
KernelVersion: dockerKernelVersions[rand.Intn(len(dockerKernelVersions))],
Architecture: dockerArchitectures[rand.Intn(len(dockerArchitectures))],
Runtime: runtime,
RuntimeVersion: runtimeVersion,
DockerVersion: dockerVersion,
CPUs: cpus,
TotalMemoryBytes: totalMemoryBytes,
UptimeSeconds: uptime,
CPUUsage: cpuUsage,
LoadAverage: loadAverage,
Memory: memory,
Disks: disks,
NetworkInterfaces: networkInterfaces,
Status: status,
LastSeen: lastSeen,
IntervalSeconds: interval,
AgentVersion: agentVersion,
Containers: containers,
Services: services,
Tasks: tasks,
Secrets: secrets,
Configs: configs,
Swarm: swarmInfo,
Temperature: temperature,
NetInRate: SampleMetric("dockerHost", hostID, "netin", now),
NetOutRate: SampleMetric("dockerHost", hostID, "netout", now),
DiskReadRate: SampleMetric("dockerHost", hostID, "diskread", now),
DiskWriteRate: SampleMetric("dockerHost", hostID, "diskwrite", now),
}
if status == "offline" {
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
}
ensureMockDockerNativeInventory(&host, i, now)
hosts = append(hosts, host)
}
populateMockDockerSwarmNodeInventories(hosts, now)
return hosts
}
// mockProxmoxLXCDockerHostSourcePrefix mirrors the production Docker host
// identity prefix from monitoring.proxmoxGuestDockerAgentID (and the platform
// scope match in unifiedresources): hosts whose source ID carries it are
// treated as Docker runtimes discovered inside a Proxmox LXC guest.
const mockProxmoxLXCDockerHostSourcePrefix = "proxmox-lxc-docker:"
// ensureMockDockerInLXCFixture adds Proxmox-side Docker-in-LXC discovery to
// the fixture estate: the first running LXC guests are marked HasDocker (as
// the node's pct exec socket probe would) and each gains a nested Docker host
// whose identity follows the production proxmoxGuestDockerAgentID convention,
// "proxmox-lxc-docker:" + the guest's instance:node:vmid coordinates. That
// prefix is what grants the nested workloads the proxmox-pve platform scope
// and lets the Proxmox page match them back to their parent guest for the
// row cue and drawer card, so without this fixture the whole surface has no
// local reproduction.
//
// It runs AFTER the demo scenario layer (from applyDemoScenarioGraph), because
// the scenario renames guests, aliases cluster instances, and forces guest
// states; a nested host bound to pre-scenario coordinates would never match
// its parent again. The scenario reapplies on every metric tick, so this is
// idempotent: an existing nested host is re-synced from its guest instead of
// duplicated, and one whose guest stopped running goes offline the way a real
// probe target would.
func ensureMockDockerInLXCFixture(data *models.StateSnapshot, config MockConfig) {
if data == nil {
return
}
if data.ConnectionHealth == nil {
data.ConnectionHealth = make(map[string]bool)
}
existing := make(map[string]*models.DockerHost)
for i := range data.DockerHosts {
host := &data.DockerHosts[i]
if strings.HasPrefix(host.ID, mockProxmoxLXCDockerHostSourcePrefix) {
existing[host.ID] = host
}
}
if config.DockerHostCount <= 0 {
for _, host := range existing {
host.Status = "offline"
}
return
}
now := time.Now()
// One single-container guest and one busier guest, so the row cue
// renders both its singular and plural counts.
nestedCounts := []int{1, 4}
selected := make(map[string]bool, len(nestedCounts))
nested := 0
for i := range data.Containers {
if nested >= len(nestedCounts) {
break
}
ct := &data.Containers[i]
if ct.Status != "running" || ct.IsOCI {
continue
}
nestedIdx := nested
nested++
ct.HasDocker = true
if ct.DockerCheckedAt.IsZero() {
ct.DockerCheckedAt = now
}
sourceID := fmt.Sprintf("%s%s:%s:%d", mockProxmoxLXCDockerHostSourcePrefix, ct.Instance, ct.Node, ct.VMID)
selected[sourceID] = true
if host, ok := existing[sourceID]; ok {
syncMockDockerInLXCHostFromGuest(host, *ct, now)
data.ConnectionHealth[dockerConnectionPrefix+host.ID] = true
continue
}
containers := generateDockerContainers(ct.Name, i, config, false)
if len(containers) > nestedCounts[nestedIdx] {
containers = containers[:nestedCounts[nestedIdx]]
}
version := dockerVersions[rand.Intn(len(dockerVersions))]
kernelVersion := "6.8.12-4-pve"
for _, node := range data.Nodes {
if node.Name == ct.Node && node.KernelVersion != "" {
kernelVersion = node.KernelVersion
break
}
}
host := models.DockerHost{
ID: sourceID,
OS: "linux",
KernelVersion: kernelVersion,
Architecture: "x86_64",
Runtime: "docker",
RuntimeVersion: version,
DockerVersion: version,
Containers: containers,
}
syncMockDockerInLXCHostFromGuest(&host, *ct, now)
data.DockerHosts = append(data.DockerHosts, host)
data.ConnectionHealth[dockerConnectionPrefix+host.ID] = true
}
// A nested host whose guest is no longer among the running targets stops
// reporting, exactly like a real probe target whose LXC was stopped.
for id, host := range existing {
if !selected[id] {
host.Status = "offline"
data.ConnectionHealth[dockerConnectionPrefix+id] = false
}
}
}
// mockBackupRunningIDPrefix marks the in-flight backup artifacts owned by
// ensureMockBackupRunningFixture so a reapplied pass replaces them instead of
// stacking duplicates.
const mockBackupRunningIDPrefix = "mock-backup-running-"
// ensureMockBackupRunningFixture flags a small slice of guests (roughly one
// in twenty-five per age bucket) as having a backup running right now, and
// gives each the evidence a live run actually produces: an in-flight PBS
// snapshot shaped the way the PBS API lists one still being written (no size,
// no verification, only the guest config blob in files, InProgress set) and a
// running vzdump task with no end time. Guests are drawn from every
// last-backup age bucket so the blue Running badge appears alongside fresh,
// stale, overdue, and never-backed-up rows instead of clustering in one
// story.
//
// It runs AFTER applyDemoBackupScenario: every guest is scenario-curated, so
// each scenario pass drops the previous fixture artifacts along with the rest
// of the curated guests' history and this fixture re-adds them, keeping the
// per-tick reapplication idempotent. The fixture owns BackupInProgress for
// the whole estate - cleared everywhere before selecting - so a guest that
// falls out of the selection cannot keep a stale Running badge.
func ensureMockBackupRunningFixture(state *models.StateSnapshot, now time.Time) {
if state == nil {
return
}
for i := range state.VMs {
state.VMs[i].BackupInProgress = false
}
for i := range state.Containers {
state.Containers[i].BackupInProgress = false
}
// Buckets mirror the badge stories: never backed up, fresh (<=1d),
// stale (<=7d), overdue (>7d).
bucketOf := func(lastBackup time.Time) int {
if lastBackup.IsZero() {
return 0
}
age := now.Sub(lastBackup)
switch {
case age <= 24*time.Hour:
return 1
case age <= 7*24*time.Hour:
return 2
default:
return 3
}
}
// The first profile cycle is the demo's hand-curated showcase: those
// exact rows are pinned by posture tests and walkthroughs (a failed run
// three hours ago, a guest with no recovery evidence at all, ...), and a
// backup running right now would mask exactly the states they exist to
// demonstrate. Later cycles repeat the same stories under suffixed names,
// so the fixture draws its running guests from them instead.
showcase := make(map[string]struct{}, len(demoVMProfiles)+len(demoContainerProfiles))
for _, profile := range demoVMProfiles {
showcase[profile.Name] = struct{}{}
}
for _, profile := range demoContainerProfiles {
showcase[profile.Name] = struct{}{}
}
type guestRef struct {
isVM bool
index int
}
var buckets [4][]guestRef
for i := range state.VMs {
vm := &state.VMs[i]
// Templates are never vzdump targets, and a node the scenario keeps
// offline cannot be running a backup task.
if vm.Template || isDemoOfflineProxmoxNode(vm.Node) {
continue
}
if _, curated := showcase[vm.Name]; curated {
continue
}
bucket := bucketOf(vm.LastBackup)
buckets[bucket] = append(buckets[bucket], guestRef{isVM: true, index: i})
}
for i := range state.Containers {
ct := &state.Containers[i]
if isDemoOfflineProxmoxNode(ct.Node) {
continue
}
if _, curated := showcase[ct.Name]; curated {
continue
}
bucket := bucketOf(ct.LastBackup)
buckets[bucket] = append(buckets[bucket], guestRef{index: i})
}
var inflightPBS []models.PBSBackup
var runningTasks []models.BackupTask
markRunning := func(kind, guestType, node, instance string, vmid int) {
// Stagger start times a few minutes so the running rows don't all
// share one timestamp; stable per guest across ticks.
startedAt := now.Add(-time.Duration(3+mockStableChoice(9, "backup-running-start", kind, fmt.Sprintf("%d", vmid))) * time.Minute)
configBlob := "qemu-server.conf.blob"
if kind == "ct" {
configBlob = "pct.conf.blob"
}
inflightPBS = append(inflightPBS, models.PBSBackup{
ID: fmt.Sprintf("%spbs-%s-%d", mockBackupRunningIDPrefix, kind, vmid),
Instance: "backup-vault",
Datastore: "primary-vault",
Namespace: "root",
BackupType: kind,
VMID: fmt.Sprintf("%d", vmid),
BackupTime: startedAt,
Size: 0,
InProgress: true,
Files: []string{configBlob},
Owner: "automation@pbs",
})
runningTasks = append(runningTasks, models.BackupTask{
ID: fmt.Sprintf("%stask-%s-%d", mockBackupRunningIDPrefix, kind, vmid),
Node: node,
Instance: instance,
Type: guestType,
VMID: vmid,
Status: "running",
StartTime: startedAt,
ObservedAt: now,
})
}
for _, bucket := range buckets {
if len(bucket) == 0 {
continue
}
count := len(bucket) / 25
if count < 1 {
count = 1
}
step := len(bucket) / count
for j := 0; j < count; j++ {
ref := bucket[j*step]
if ref.isVM {
vm := &state.VMs[ref.index]
vm.BackupInProgress = true
markRunning("vm", "qemu", vm.Node, vm.Instance, vm.VMID)
} else {
ct := &state.Containers[ref.index]
ct.BackupInProgress = true
markRunning("ct", "lxc", ct.Node, ct.Instance, ct.VMID)
}
}
}
// Prepend so the newest-first ordering applyDemoBackupScenario just
// established still holds: in-flight artifacts are minutes old, the
// curated history starts at ninety. Strip any leftover fixture entries
// first in case a pass runs without the scenario's curated sweep.
keptPBS := make([]models.PBSBackup, 0, len(inflightPBS)+len(state.PBSBackups))
keptPBS = append(keptPBS, inflightPBS...)
for _, backup := range state.PBSBackups {
if strings.HasPrefix(backup.ID, mockBackupRunningIDPrefix) {
continue
}
keptPBS = append(keptPBS, backup)
}
state.PBSBackups = keptPBS
keptTasks := make([]models.BackupTask, 0, len(runningTasks)+len(state.PVEBackups.BackupTasks))
keptTasks = append(keptTasks, runningTasks...)
for _, task := range state.PVEBackups.BackupTasks {
if strings.HasPrefix(task.ID, mockBackupRunningIDPrefix) {
continue
}
keptTasks = append(keptTasks, task)
}
state.PVEBackups.BackupTasks = keptTasks
state.Backups.PVE = state.PVEBackups
state.Backups.PBS = append([]models.PBSBackup(nil), state.PBSBackups...)
}
// syncMockDockerInLXCHostFromGuest keeps a nested Docker host mirroring its
// parent LXC guest, matching what parseProxmoxGuestDockerInventory plus
// enrichGuestDockerReportFromContainer report for a real guest: the guest's
// own naming, sizing, and usage, one rootfs disk, and none of the native
// agent surfaces (engine inventory, Swarm, host I/O rates, temperature,
// machine ID, agent version, network interfaces). Clearing those every pass
// also repairs any enrichment a demo cycle may have applied before the
// scenario learned to skip nested hosts.
func syncMockDockerInLXCHostFromGuest(host *models.DockerHost, guest models.Container, now time.Time) {
if host == nil {
return
}
host.AgentID = host.ID
host.Hostname = guest.Name
host.DisplayName = guest.Name
host.Status = "online"
host.CPUs = guest.CPUs
host.TotalMemoryBytes = guest.Memory.Total
host.UptimeSeconds = guest.Uptime
host.CPUUsage = clampFloat(guest.CPU*100, 0, 100)
host.Memory = models.Memory{
Total: guest.Memory.Total,
Used: guest.Memory.Used,
Free: guest.Memory.Free,
Usage: guest.Memory.Usage,
}
host.Disks = []models.Disk{{
Total: guest.Disk.Total,
Used: guest.Disk.Used,
Free: guest.Disk.Free,
Usage: guest.Disk.Usage,
Mountpoint: "/",
Type: "rootfs",
}}
host.LastSeen = now
host.IntervalSeconds = 30
host.MachineID = ""
host.AgentVersion = ""
host.NetworkInterfaces = nil
host.Images = nil
host.Volumes = nil
host.Networks = nil
host.StorageUsage = nil
host.Services = nil
host.Tasks = nil
host.Secrets = nil
host.Configs = nil
host.Nodes = nil
host.Swarm = nil
host.Temperature = nil
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
}
func ensureMockDockerNativeInventory(host *models.DockerHost, hostIndex int, now time.Time) {
if host == nil {
return
}
host.Images = generateMockDockerImages(*host, hostIndex, now)
host.Volumes = generateMockDockerVolumes(*host, hostIndex, now)
host.Networks = generateMockDockerEngineNetworks(*host, hostIndex, now)
host.StorageUsage = generateMockDockerStorageUsage(*host)
}
func mockDockerSafeName(parts ...string) string {
joined := strings.ToLower(strings.TrimSpace(strings.Join(parts, "-")))
if joined == "" {
return "runtime"
}
var out strings.Builder
lastWasDash := false
for _, r := range joined {
allowed := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '.'
if allowed {
out.WriteRune(r)
lastWasDash = false
continue
}
if !lastWasDash {
out.WriteRune('-')
lastWasDash = true
}
}
safe := strings.Trim(out.String(), "-")
if safe == "" {
return "runtime"
}
return safe
}
func normalizeMockDockerImageReference(image string) string {
image = strings.TrimSpace(image)
if image == "" {
return "ghcr.io/pulse-demo/runtime:latest"
}
if strings.Contains(image, "@sha256:") {
return image
}
lastSlash := strings.LastIndex(image, "/")
lastColon := strings.LastIndex(image, ":")
if lastColon > lastSlash {
return image
}
return image + ":latest"
}
func mockDockerRepositoryForDigest(image string) string {
image = strings.TrimSpace(image)
if image == "" {
return "ghcr.io/pulse-demo/runtime"
}
if before, _, ok := strings.Cut(image, "@"); ok {
image = before
}
lastSlash := strings.LastIndex(image, "/")
lastColon := strings.LastIndex(image, ":")
if lastColon > lastSlash {
image = image[:lastColon]
}
return image
}
func generateMockDockerImages(host models.DockerHost, hostIndex int, now time.Time) []models.DockerImage {
imageCounts := make(map[string]int64)
imageOrder := make([]string, 0)
for _, container := range host.Containers {
image := normalizeMockDockerImageReference(container.Image)
if image == "" {
continue
}
if _, exists := imageCounts[image]; !exists {
imageOrder = append(imageOrder, image)
}
imageCounts[image]++
}
if len(imageOrder) == 0 {
fallback := normalizeMockDockerImageReference(fmt.Sprintf("ghcr.io/pulse-demo/%s-base:2026.05", mockDockerSafeName(host.Runtime, host.Hostname)))
imageOrder = append(imageOrder, fallback)
imageCounts[fallback] = 0
}
images := make([]models.DockerImage, 0, len(imageOrder))
for index, image := range imageOrder {
sizeBytes := int64(180+mockStableChoice(900, host.ID, image, "image-size")) * 1024 * 1024
imageID := "sha256:" + mockStableHexString(64, host.ID, image, "image")
digest := fmt.Sprintf(
"%s@sha256:%s",
mockDockerRepositoryForDigest(image),
mockStableHexString(64, host.ID, image, "digest"),
)
images = append(images, models.DockerImage{
ID: imageID,
RepoTags: []string{image},
RepoDigests: []string{digest},
SizeBytes: sizeBytes,
SharedSizeBytes: sizeBytes / 8,
Containers: imageCounts[image],
CreatedAt: now.Add(-time.Duration(12+hostIndex*3+index*5) * time.Hour),
Labels: map[string]string{
"com.pulse.mock.host": host.Hostname,
"com.pulse.mock.runtime": firstNonEmpty(host.Runtime, "docker"),
},
})
}
return images
}
func generateMockDockerVolumes(host models.DockerHost, hostIndex int, now time.Time) []models.DockerVolume {
prefix := mockDockerSafeName(host.Hostname)
basePath := "/var/lib/docker/volumes"
if strings.EqualFold(host.Runtime, "podman") {
basePath = "/var/lib/containers/storage/volumes"
}
profiles := []struct {
suffix string
sizeGiB int64
refCount int64
}{
{"config", 2, 1},
{"data", 32 + int64(hostIndex%4)*8, 2},
{"cache", 8 + int64(hostIndex%3)*4, 1},
}
volumes := make([]models.DockerVolume, 0, len(profiles))
for _, profile := range profiles {
name := fmt.Sprintf("%s_%s", prefix, profile.suffix)
volumes = append(volumes, models.DockerVolume{
Name: name,
Driver: "local",
Mountpoint: fmt.Sprintf("%s/%s/_data", basePath, name),
Scope: "local",
CreatedAt: now.Add(-time.Duration(96+hostIndex*6) * time.Hour).Format(time.RFC3339),
SizeBytes: profile.sizeGiB << 30,
RefCount: profile.refCount,
Labels: map[string]string{
"com.pulse.mock.host": host.Hostname,
"com.pulse.mock.role": profile.suffix,
},
Options: map[string]string{},
})
}
return volumes
}
func generateMockDockerEngineNetworks(host models.DockerHost, hostIndex int, now time.Time) []models.DockerNetwork {
prefix := mockDockerSafeName(host.Hostname)
runtime := firstNonEmpty(host.Runtime, "docker")
networks := []models.DockerNetwork{
{
ID: mockStableHexString(12, host.ID, "network", "bridge"),
Name: "bridge",
Driver: "bridge",
Scope: "local",
CreatedAt: now.Add(-120 * time.Hour),
EnableIPv4: true,
Subnets: []models.DockerNetworkSubnet{{
Subnet: fmt.Sprintf("172.%d.0.0/16", 18+hostIndex%10),
Gateway: fmt.Sprintf("172.%d.0.1", 18+hostIndex%10),
}},
Labels: map[string]string{"com.pulse.mock.runtime": runtime},
},
{
ID: mockStableHexString(12, host.ID, "network", "app"),
Name: fmt.Sprintf("%s_app", prefix),
Driver: "bridge",
Scope: "local",
CreatedAt: now.Add(-72 * time.Hour),
EnableIPv4: true,
Attachable: true,
Subnets: []models.DockerNetworkSubnet{{
Subnet: fmt.Sprintf("10.%d.%d.0/24", 40+hostIndex%20, hostIndex%200),
Gateway: fmt.Sprintf("10.%d.%d.1", 40+hostIndex%20, hostIndex%200),
}},
Labels: map[string]string{"com.pulse.mock.host": host.Hostname},
},
}
if host.Swarm != nil && strings.EqualFold(host.Swarm.LocalState, "active") {
networks = append(networks, models.DockerNetwork{
ID: mockStableHexString(12, host.ID, "network", "ingress"),
Name: "ingress",
Driver: "overlay",
Scope: "swarm",
CreatedAt: now.Add(-168 * time.Hour),
EnableIPv4: true,
Attachable: true,
Ingress: true,
Subnets: []models.DockerNetworkSubnet{{
Subnet: fmt.Sprintf("10.%d.0.0/24", 200+hostIndex%30),
Gateway: fmt.Sprintf("10.%d.0.1", 200+hostIndex%30),
}},
Labels: map[string]string{
"com.docker.swarm.scope": "ingress",
"com.pulse.mock.cluster": firstNonEmpty(host.Swarm.ClusterName, "edge-swarm"),
},
})
}
return networks
}
func generateMockDockerStorageUsage(host models.DockerHost) *models.DockerStorageUsage {
var imageBytes int64
var activeImages int64
for _, image := range host.Images {
imageBytes += image.SizeBytes
if image.Containers > 0 {
activeImages++
}
}
var volumeBytes int64
var activeVolumes int64
for _, volume := range host.Volumes {
volumeBytes += volume.SizeBytes
if volume.RefCount > 0 {
activeVolumes++
}
}
var containerBytes int64
var activeContainers int64
for _, container := range host.Containers {
if container.RootFilesystemBytes > 0 {
containerBytes += container.RootFilesystemBytes
}
if strings.EqualFold(container.State, "running") {
activeContainers++
}
}
if containerBytes == 0 {
containerBytes = int64(len(host.Containers)) * 2 << 30
}
buildCacheBytes := int64(1+mockStableChoice(10, host.ID, "build-cache")) << 30
return &models.DockerStorageUsage{
Images: models.DockerStorageUsageBucket{
TotalCount: int64(len(host.Images)),
ActiveCount: activeImages,
TotalSizeBytes: imageBytes,
ReclaimableBytes: imageBytes / 5,
},
Containers: models.DockerStorageUsageBucket{
TotalCount: int64(len(host.Containers)),
ActiveCount: activeContainers,
TotalSizeBytes: containerBytes,
ReclaimableBytes: containerBytes / 10,
},
Volumes: models.DockerStorageUsageBucket{
TotalCount: int64(len(host.Volumes)),
ActiveCount: activeVolumes,
TotalSizeBytes: volumeBytes,
ReclaimableBytes: volumeBytes / 12,
},
BuildCache: models.DockerStorageUsageBucket{
TotalCount: 4 + int64(mockStableChoice(9, host.ID, "build-cache-count")),
ActiveCount: 1,
TotalSizeBytes: buildCacheBytes,
ReclaimableBytes: buildCacheBytes * 3 / 4,
},
}
}
func firstMockDockerHostAddress(host models.DockerHost) string {
for _, iface := range host.NetworkInterfaces {
for _, address := range iface.Addresses {
address = strings.TrimSpace(address)
if address == "" {
continue
}
if before, _, ok := strings.Cut(address, "/"); ok {
return before
}
return address
}
}
return ""
}
func cloneMockDockerNodes(nodes []models.DockerNode) []models.DockerNode {
if len(nodes) == 0 {
return []models.DockerNode{}
}
out := make([]models.DockerNode, len(nodes))
copy(out, nodes)
for i := range out {
out[i].Labels = cloneStringMap(out[i].Labels)
out[i].EngineLabels = cloneStringMap(out[i].EngineLabels)
}
return out
}
func populateMockDockerSwarmNodeInventories(hosts []models.DockerHost, now time.Time) {
managerLeaderID := ""
for _, host := range hosts {
if host.Swarm == nil || !strings.EqualFold(host.Swarm.NodeRole, "manager") {
continue
}
managerLeaderID = host.Swarm.NodeID
break
}
nodes := make([]models.DockerNode, 0, len(hosts))
for _, host := range hosts {
if host.Swarm == nil || strings.EqualFold(host.Runtime, "podman") {
continue
}
nodeID := strings.TrimSpace(host.Swarm.NodeID)
if nodeID == "" {
nodeID = fmt.Sprintf("%s-node", host.ID)
}
role := firstNonEmpty(host.Swarm.NodeRole, "worker")
state := "ready"
availability := "active"
message := ""
if strings.EqualFold(host.Status, "offline") {
state = "down"
availability = "pause"
message = "Host agent offline"
}
reachability := ""
managerAddress := ""
leader := false
if strings.EqualFold(role, "manager") {
reachability = "reachable"
managerAddress = firstMockDockerHostAddress(host)
if managerAddress != "" {
managerAddress += ":2377"
}
leader = nodeID == managerLeaderID
}
node := models.DockerNode{
ID: nodeID,
Hostname: host.Hostname,
Role: role,
Availability: availability,
State: state,
Message: message,
Address: firstMockDockerHostAddress(host),
ManagerReachability: reachability,
ManagerAddress: managerAddress,
Leader: leader,
EngineVersion: firstNonEmpty(host.DockerVersion, host.RuntimeVersion),
OS: host.OS,
Architecture: host.Architecture,
NanoCPUs: int64(host.CPUs) * 1_000_000_000,
MemoryBytes: host.TotalMemoryBytes,
Labels: map[string]string{
"com.pulse.mock.cluster": firstNonEmpty(host.Swarm.ClusterName, "edge-swarm"),
"com.pulse.mock.host": host.Hostname,
},
EngineLabels: map[string]string{
"pulse.runtime": firstNonEmpty(host.Runtime, "docker"),
},
CreatedAt: now.Add(-168 * time.Hour),
}
nodes = append(nodes, node)
}
for i := range hosts {
host := &hosts[i]
if host.Swarm == nil || strings.EqualFold(host.Runtime, "podman") {
host.Nodes = []models.DockerNode{}
continue
}
// Only the control-plane manager carries the node inventory, the
// same way `docker node ls` only answers on a manager. When every
// host repeated its own node entry, the registry saw one node from
// several reporting hosts and re-parented it to whichever host was
// polled last, spamming relationship_change rows.
if strings.EqualFold(host.Swarm.NodeRole, "manager") && host.Swarm.ControlAvailable {
host.Nodes = cloneMockDockerNodes(nodes)
continue
}
host.Nodes = []models.DockerNode{}
}
}
func generateHosts(config MockConfig) []models.Host {
count := config.GenericHostCount
if count <= 0 {
return nil
}
now := time.Now()
hosts := make([]models.Host, 0, count)
usedNames := make(map[string]struct{}, count)
for i := 0; i < count; i++ {
profile := genericHostProfiles[mockStableChoice(len(genericHostProfiles), fmt.Sprintf("%d", i), "generic-host-profile")]
baseName := genericHostPrefixes[mockStableChoice(len(genericHostPrefixes), fmt.Sprintf("%d", i), "generic-host-prefix")]
suffix := 1 + mockStableChoice(900, fmt.Sprintf("%d", i), "generic-host-suffix")
hostname := fmt.Sprintf("%s-%d", baseName, suffix)
for {
if _, exists := usedNames[hostname]; !exists {
usedNames[hostname] = struct{}{}
break
}
suffix++
hostname = fmt.Sprintf("%s-%d", baseName, suffix)
}
displayName := strings.ToUpper(hostname[:1]) + hostname[1:]
hostID := fmt.Sprintf("host-%s-%d", profile.Platform, i+1)
cpuCount := 4 + rand.Intn(28) // 4-32 cores
if profile.Platform == "macos" {
cpuCount = 8 + rand.Intn(10)
}
cpuUsage := SampleMetric("agent", hostID, "cpu", now)
memTotalGiB := 16 + rand.Intn(192)
if profile.Platform == "macos" {
memTotalGiB = 16 + rand.Intn(64)
}
memTotal := int64(memTotalGiB) << 30
memUsage := SampleMetric("agent", hostID, "memory", now)
memUsed := int64(float64(memTotal) * (memUsage / 100.0))
// Roughly a third of the non-used pages read as reclaimable buff/cache
// so the machine memory split is exercisable in mock mode.
memCache := (memTotal - memUsed) / 3
memFree := memTotal - memUsed - memCache
swapTotal := int64(rand.Intn(32)) << 30
swapUsed := int64(float64(swapTotal) * rand.Float64())
rootDiskTotal := int64(120+rand.Intn(680)) << 30
rootDiskUsage := SampleMetric("agent", mockHostDiskMetricID(hostID, 0), "disk", now)
rootDiskUsed := int64(float64(rootDiskTotal) * (rootDiskUsage / 100.0))
rootDisk := models.Disk{
Total: rootDiskTotal,
Used: rootDiskUsed,
Free: rootDiskTotal - rootDiskUsed,
Usage: rootDiskUsage,
Mountpoint: "/",
Type: "ext4",
Device: "/dev/sda1",
}
if profile.Platform == "windows" {
rootDisk.Mountpoint = "C:"
rootDisk.Type = "ntfs"
rootDisk.Device = `\\.\PHYSICALDRIVE0`
}
if profile.Platform == "macos" {
rootDisk.Type = "apfs"
rootDisk.Device = "/dev/disk1s1"
}
disks := []models.Disk{rootDisk}
if rand.Float64() < 0.70 {
dataDiskTotal := int64(200+rand.Intn(1400)) << 30
dataDiskUsage := SampleMetric("agent", mockHostDiskMetricID(hostID, 1), "disk", now)
dataDiskUsed := int64(float64(dataDiskTotal) * (dataDiskUsage / 100.0))
mount := "/data"
device := "/dev/sdb1"
fsType := "xfs"
if profile.Platform == "windows" {
mount = "D:"
device = `\\.\PHYSICALDRIVE1`
fsType = "ntfs"
}
if profile.Platform == "macos" {
mount = "/Volumes/Data"
device = "/dev/disk3s1"
fsType = "apfs"
}
disks = append(disks, models.Disk{
Total: dataDiskTotal,
Used: dataDiskUsed,
Free: dataDiskTotal - dataDiskUsed,
Usage: dataDiskUsage,
Mountpoint: mount,
Type: fsType,
Device: device,
})
}
if len(disks) > 1 && rand.Float64() < 0.35 {
backupDiskTotal := int64(500+rand.Intn(3000)) << 30
backupDiskUsage := SampleMetric("agent", mockHostDiskMetricID(hostID, 2), "disk", now)
backupDiskUsed := int64(float64(backupDiskTotal) * (backupDiskUsage / 100.0))
mount := "/backup"
device := "/dev/sdc1"
fsType := "btrfs"
if profile.Platform == "windows" {
mount = "E:"
device = `\\.\PHYSICALDRIVE2`
fsType = "ntfs"
}
disks = append(disks, models.Disk{
Total: backupDiskTotal,
Used: backupDiskUsed,
Free: backupDiskTotal - backupDiskUsed,
Usage: backupDiskUsage,
Mountpoint: mount,
Type: fsType,
Device: device,
})
}
primaryIP := fmt.Sprintf("192.168.%d.%d", 10+rand.Intn(60), 10+rand.Intn(200))
network := []models.HostNetworkInterface{
{
Name: "eth0",
MAC: fmt.Sprintf("02:42:%02x:%02x:%02x:%02x", rand.Intn(256), rand.Intn(256), rand.Intn(256), rand.Intn(256)),
Addresses: []string{primaryIP},
RXBytes: uint64(256+rand.Intn(4096)) * 1024 * 1024,
TXBytes: uint64(256+rand.Intn(4096)) * 1024 * 1024,
},
}
if rand.Float64() < 0.32 {
network[0].Addresses = append(network[0].Addresses, fmt.Sprintf("10.%d.%d.%d", 10+rand.Intn(90), rand.Intn(200), rand.Intn(200)))
}
var loadAverage []float64
if profile.Platform == "linux" {
loadAverage = []float64{
clampFloat(rand.Float64()*float64(cpuCount)/4, 0.05, float64(cpuCount)*0.8),
clampFloat(rand.Float64()*float64(cpuCount)/4, 0.05, float64(cpuCount)*0.8),
clampFloat(rand.Float64()*float64(cpuCount)/4, 0.05, float64(cpuCount)*0.8),
}
}
sensors := models.HostSensorSummary{}
if profile.Platform == "linux" || profile.Platform == "macos" {
sensors.TemperatureCelsius = map[string]float64{
"cpu_package": SampleMetric("agent", hostID, "temperature", now),
}
if rand.Float64() < 0.4 {
sensors.Additional = map[string]float64{
"nvme0": SampleMetric("agent", hostID+":nvme0", "temperature", now),
}
}
}
status := "online"
if rand.Float64() < 0.04 {
status = "degraded"
}
lastSeen := now.Add(-time.Duration(rand.Intn(60)) * time.Second)
if status == "offline" {
lastSeen = now.Add(-time.Duration(300+rand.Intn(2400)) * time.Second)
}
uptimeSeconds := int64(3600*(12+rand.Intn(720))) + int64(rand.Intn(3600))
intervalSeconds := 30 + rand.Intn(45)
tags := make([]string, 0, 2)
for _, candidate := range []string{"production", "lab", "edge", "backup", "database", "web"} {
if rand.Float64() < 0.18 {
tags = append(tags, candidate)
}
}
var tokenID, tokenName, tokenHint string
var tokenLastUsed *time.Time
if rand.Float64() < 0.8 {
tokenID = fmt.Sprintf("hst_%s", randomHexString(8))
tokenName = fmt.Sprintf("%s agent", displayName)
tokenHint = fmt.Sprintf("%s…%s", tokenID[:3], tokenID[len(tokenID)-2:])
lastUsed := now.Add(-time.Duration(rand.Intn(720)) * time.Minute)
tokenLastUsed = &lastUsed
}
if rand.Float64() < 0.1 {
// Simulate a revoked token that hasn't been rotated yet
tokenID = ""
tokenHint = fmt.Sprintf("%s…%s", randomHexString(3), randomHexString(2))
tokenLastUsed = nil
}
host := models.Host{
ID: hostID,
Hostname: hostname,
DisplayName: displayName,
Platform: profile.Platform,
OSName: profile.OSName,
OSVersion: profile.OSVersion,
KernelVersion: profile.Kernel,
Architecture: profile.Architecture,
CPUCount: cpuCount,
CPUUsage: cpuUsage,
LoadAverage: loadAverage,
Memory: models.Memory{Total: memTotal, Used: memUsed, Free: memFree, Cache: memCache, Usage: memUsage, SwapTotal: swapTotal, SwapUsed: swapUsed},
Disks: disks,
NetworkInterfaces: network,
Sensors: sensors,
Status: status,
UptimeSeconds: uptimeSeconds,
IntervalSeconds: intervalSeconds,
LastSeen: lastSeen,
AgentVersion: hostAgentVersions[rand.Intn(len(hostAgentVersions))],
Tags: tags,
TokenID: tokenID,
TokenName: tokenName,
TokenHint: tokenHint,
TokenLastUsedAt: tokenLastUsed,
NetInRate: SampleMetric("agent", hostID, "netin", now),
NetOutRate: SampleMetric("agent", hostID, "netout", now),
DiskReadRate: SampleMetric("agent", hostID, "diskread", now),
DiskWriteRate: SampleMetric("agent", hostID, "diskwrite", now),
}
if status == "offline" {
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
}
hosts = append(hosts, host)
}
sort.Slice(hosts, func(i, j int) bool {
return hosts[i].Hostname < hosts[j].Hostname
})
return hosts
}
func ensureMockNodeHostLinks(data *models.StateSnapshot) {
if data == nil || len(data.Nodes) == 0 {
return
}
now := time.Now()
hostsByLinkedNodeID := make(map[string]int, len(data.Hosts))
for i := range data.Hosts {
linkedNodeID := strings.TrimSpace(data.Hosts[i].LinkedNodeID)
if linkedNodeID != "" {
hostsByLinkedNodeID[linkedNodeID] = i
}
}
for i := range data.Hosts {
host := &data.Hosts[i]
host.LinkedNodeID = ""
host.LinkedVMID = ""
host.LinkedContainerID = ""
if host.Status == "offline" {
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
continue
}
if host.NetInRate <= 0 {
host.NetInRate = SampleMetric("agent", host.ID, "netin", now)
}
if host.NetOutRate <= 0 {
host.NetOutRate = SampleMetric("agent", host.ID, "netout", now)
}
if host.DiskReadRate <= 0 {
host.DiskReadRate = SampleMetric("agent", host.ID, "diskread", now)
}
if host.DiskWriteRate <= 0 {
host.DiskWriteRate = SampleMetric("agent", host.ID, "diskwrite", now)
}
}
nextHostIndex := len(data.Hosts)
for i := range data.Nodes {
node := &data.Nodes[i]
hostIndex, exists := hostsByLinkedNodeID[node.ID]
exists = exists && hostIndex >= 0 && hostIndex < len(data.Hosts)
// The linked agent's sensors are built from the node's reading, so
// the node takes the agent's sampled value before the agent is built.
node.LinkedAgentID = mockLinkedHostIDForNode(node.ID)
if exists {
node.LinkedAgentID = data.Hosts[hostIndex].ID
}
refreshMockNodeCPUTemperature(node, now)
if exists {
linkedHost := buildMockLinkedHostFromNode(*node, node.LinkedAgentID, hostIndex, now)
data.Hosts[hostIndex] = linkedHost
node.LinkedAgentID = linkedHost.ID
} else {
linkedHost := buildMockLinkedHostFromNode(*node, node.LinkedAgentID, nextHostIndex, now)
data.Hosts = append(data.Hosts, linkedHost)
hostsByLinkedNodeID[node.ID] = len(data.Hosts) - 1
nextHostIndex++
node.LinkedAgentID = linkedHost.ID
}
}
}
func ensureMockKubernetesNodeHostLinks(data *models.StateSnapshot) {
if data == nil || len(data.KubernetesClusters) == 0 {
return
}
now := time.Now()
hostsByName := make(map[string]int, len(data.Hosts))
for i := range data.Hosts {
name := strings.ToLower(strings.TrimSpace(data.Hosts[i].Hostname))
if name != "" {
hostsByName[name] = i
}
}
nextHostIndex := len(data.Hosts)
for clusterIndex := range data.KubernetesClusters {
cluster := &data.KubernetesClusters[clusterIndex]
for nodeIndex := range cluster.Nodes {
node := &cluster.Nodes[nodeIndex]
hostname := strings.ToLower(strings.TrimSpace(node.Name))
if hostname == "" {
continue
}
hostIndex, exists := hostsByName[hostname]
hostID := ""
if exists && hostIndex >= 0 && hostIndex < len(data.Hosts) {
hostID = data.Hosts[hostIndex].ID
}
host := buildMockLinkedHostFromKubernetesNode(*cluster, *node, hostID, nextHostIndex, now)
if exists && hostIndex >= 0 && hostIndex < len(data.Hosts) {
data.Hosts[hostIndex] = host
} else {
data.Hosts = append(data.Hosts, host)
hostsByName[hostname] = len(data.Hosts) - 1
nextHostIndex++
}
}
}
}
func buildMockLinkedHostFromKubernetesNode(
cluster models.KubernetesCluster,
node models.KubernetesNode,
hostID string,
hostIndex int,
now time.Time,
) models.Host {
if hostID == "" {
hostID = fmt.Sprintf("host-k8s-%s-%s", strings.TrimSpace(cluster.ID), strings.TrimSpace(node.UID))
if strings.TrimSpace(node.UID) == "" {
hostID = fmt.Sprintf("host-k8s-%s-%d", strings.TrimSpace(cluster.ID), hostIndex+1)
}
}
allocCPU := node.AllocCPU
if allocCPU <= 0 {
allocCPU = node.CapacityCPU
}
if allocCPU <= 0 {
allocCPU = 4
}
allocMemory := node.AllocMemoryBytes
if allocMemory <= 0 {
allocMemory = node.CapacityMemoryBytes
}
if allocMemory <= 0 {
allocMemory = int64(16) * 1024 * 1024 * 1024
}
cpuUsage := node.UsageCPUPercent
if cpuUsage <= 0 && node.UsageCPUMilliCores > 0 {
cpuUsage = (float64(node.UsageCPUMilliCores) / float64(allocCPU*1000)) * 100
}
cpuUsage = clampFloat(cpuUsage, 4, 96)
memUsage := node.UsageMemoryPercent
if memUsage <= 0 && node.UsageMemoryBytes > 0 {
memUsage = (float64(node.UsageMemoryBytes) / float64(allocMemory)) * 100
}
memUsage = clampFloat(memUsage, 12, 94)
memUsed := int64(float64(allocMemory) * (memUsage / 100.0))
memFree := allocMemory - memUsed
if memFree < 0 {
memFree = 0
}
status := "online"
if !node.Ready {
status = "degraded"
}
if strings.EqualFold(strings.TrimSpace(cluster.Status), "offline") {
status = "offline"
}
lastSeen := now.Add(-time.Duration(rand.Intn(20)) * time.Second)
if status == "offline" {
lastSeen = now.Add(-time.Duration(180+rand.Intn(1200)) * time.Second)
}
diskTotal := int64(128+(mockStableHash64(cluster.ID, node.Name, "disk-total")%1024)) * 1024 * 1024 * 1024
diskUsage := SampleMetric("agent", mockHostDiskMetricID(hostID, 0), "disk", now)
diskUsed := int64(float64(diskTotal) * (diskUsage / 100.0))
diskFree := diskTotal - diskUsed
if diskFree < 0 {
diskFree = 0
}
sensors := models.HostSensorSummary{}
sensors.TemperatureCelsius = map[string]float64{
"cpu_package": SampleMetric("agent", hostID, "temperature", now),
}
tags := []string{"mock", "kubernetes-node"}
for _, role := range node.Roles {
role = strings.TrimSpace(role)
if role == "" {
continue
}
already := false
for _, existing := range tags {
if existing == role {
already = true
break
}
}
if !already {
tags = append(tags, role)
}
}
host := models.Host{
ID: hostID,
Hostname: node.Name,
DisplayName: humanizeHostDisplayName(node.Name),
Platform: "linux",
OSName: node.OSImage,
KernelVersion: node.KernelVersion,
Architecture: node.Architecture,
CPUCount: int(allocCPU),
CPUUsage: cpuUsage,
Memory: models.Memory{
Total: allocMemory,
Used: memUsed,
Free: memFree,
Usage: memUsage,
},
Disks: []models.Disk{
{
Total: diskTotal,
Used: diskUsed,
Free: diskFree,
Usage: diskUsage,
Mountpoint: "/",
Type: "ext4",
Device: "/dev/nvme0n1p1",
},
},
NetworkInterfaces: []models.HostNetworkInterface{
{
Name: "eth0",
MAC: fmt.Sprintf("02:6f:%02x:%02x:%02x:%02x", hostIndex%255, (hostIndex*17)%255, (hostIndex*31)%255, (hostIndex*41)%255),
Addresses: []string{
fmt.Sprintf("10.%d.%d.%d", 20+(hostIndex%90), 10+rand.Intn(120), 20+rand.Intn(200)),
},
},
},
Sensors: sensors,
Status: status,
UptimeSeconds: int64(3600 * (24 + int(mockStableHash64(cluster.ID, node.Name, "uptime")%720))),
IntervalSeconds: 30,
LastSeen: lastSeen,
AgentVersion: hostAgentVersions[rand.Intn(len(hostAgentVersions))],
MachineID: mockStableHexString(32, "linked-host-machine", hostID),
Tags: tags,
}
host.NetInRate = SampleMetric("agent", host.ID, "netin", now)
host.NetOutRate = SampleMetric("agent", host.ID, "netout", now)
host.DiskReadRate = SampleMetric("agent", host.ID, "diskread", now)
host.DiskWriteRate = SampleMetric("agent", host.ID, "diskwrite", now)
if status == "offline" {
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
}
return host
}
func syncMockKubernetesNodeHosts(data *models.StateSnapshot) {
if data == nil || len(data.Hosts) == 0 || len(data.KubernetesClusters) == 0 {
return
}
hostsByName := make(map[string]*models.Host, len(data.Hosts))
for i := range data.Hosts {
host := &data.Hosts[i]
hostname := strings.ToLower(strings.TrimSpace(host.Hostname))
if hostname != "" {
hostsByName[hostname] = host
}
}
for _, cluster := range data.KubernetesClusters {
for _, node := range cluster.Nodes {
host := hostsByName[strings.ToLower(strings.TrimSpace(node.Name))]
if host == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(cluster.Status), "offline") {
host.Status = "offline"
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
continue
}
if !node.Ready {
host.Status = "degraded"
} else if strings.EqualFold(host.Status, "offline") {
host.Status = "online"
}
if node.UsageCPUPercent > 0 {
host.CPUUsage = clampFloat(smoothMetricToward(host.CPUUsage, node.UsageCPUPercent, 0.42), 2, 99)
}
totalMemory := node.AllocMemoryBytes
if totalMemory <= 0 {
totalMemory = node.CapacityMemoryBytes
}
if totalMemory > 0 {
usedMemory := node.UsageMemoryBytes
if usedMemory <= 0 && node.UsageMemoryPercent > 0 {
usedMemory = int64(float64(totalMemory) * (node.UsageMemoryPercent / 100.0))
}
if usedMemory < 0 {
usedMemory = 0
}
if usedMemory > totalMemory {
usedMemory = totalMemory
}
host.Memory.Total = totalMemory
host.Memory.Used = usedMemory
host.Memory.Free = totalMemory - usedMemory
if totalMemory > 0 {
host.Memory.Usage = clampFloat((float64(usedMemory)/float64(totalMemory))*100, 0, 100)
}
}
if host.Sensors.TemperatureCelsius == nil {
host.Sensors.TemperatureCelsius = make(map[string]float64)
}
host.Sensors.TemperatureCelsius["cpu_package"] = SampleMetric("agent", host.ID, "temperature", time.Now())
}
}
}
func ensureMockFixtureHostIORates(data *models.StateSnapshot) {
if data == nil {
return
}
for i := range data.Hosts {
host := &data.Hosts[i]
if strings.EqualFold(host.Status, "offline") {
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
continue
}
if host.NetInRate <= 0 {
host.NetInRate = generateMockHostRate("network-in")
}
if host.NetOutRate <= 0 {
host.NetOutRate = generateMockHostRate("network-out")
}
if host.DiskReadRate <= 0 {
host.DiskReadRate = generateMockHostRate("disk-read")
}
if host.DiskWriteRate <= 0 {
host.DiskWriteRate = generateMockHostRate("disk-write")
}
}
for i := range data.DockerHosts {
host := &data.DockerHosts[i]
if strings.EqualFold(host.Status, "offline") {
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
continue
}
if host.NetInRate <= 0 {
host.NetInRate = generateMockHostRate("network-in")
}
if host.NetOutRate <= 0 {
host.NetOutRate = generateMockHostRate("network-out")
}
if host.DiskReadRate <= 0 {
host.DiskReadRate = generateMockHostRate("disk-read")
}
if host.DiskWriteRate <= 0 {
host.DiskWriteRate = generateMockHostRate("disk-write")
}
}
}
func mockLinkedHostIDForNode(nodeID string) string {
return fmt.Sprintf("host-node-%s", nodeID)
}
func buildMockLinkedHostFromNode(node models.Node, hostID string, hostIndex int, now time.Time) models.Host {
if hostID == "" {
hostID = mockLinkedHostIDForNode(node.ID)
}
cpuCount := node.CPUInfo.Cores
if cpuCount <= 0 {
cpuCount = 8
}
cpuUsage := node.CPU
if cpuUsage <= 1.0 {
cpuUsage = cpuUsage * 100
}
cpuUsage = clampFloat(cpuUsage, 0, 100)
memory := node.Memory
if memory.Total <= 0 {
memory.Total = int64(32) * 1024 * 1024 * 1024
memory.Used = int64(float64(memory.Total) * 0.5)
}
if memory.Used <= 0 && memory.Usage > 0 {
memory.Used = int64(float64(memory.Total) * (memory.Usage / 100.0))
}
if memory.Usage <= 0 && memory.Total > 0 {
memory.Usage = float64(memory.Used) / float64(memory.Total) * 100.0
}
memory.Free = memory.Total - memory.Used
if memory.Free < 0 {
memory.Free = 0
}
// The node memory carries a reclaimable-cache split; keep the
// used + cache + free invariant after recomputing free.
if memory.Cache > memory.Free {
memory.Cache = memory.Free
}
memory.Free -= memory.Cache
// Add swap for PVE node hosts
memory.SwapTotal = int64(8+rand.Intn(24)) << 30
memory.SwapUsed = int64(float64(memory.SwapTotal) * rand.Float64() * 0.3)
disk := node.Disk
if disk.Total <= 0 {
disk.Total = int64(512) * 1024 * 1024 * 1024
disk.Used = int64(float64(disk.Total) * 0.45)
}
if disk.Used <= 0 && disk.Usage > 0 {
disk.Used = int64(float64(disk.Total) * (disk.Usage / 100.0))
}
if disk.Usage <= 0 && disk.Total > 0 {
disk.Usage = float64(disk.Used) / float64(disk.Total) * 100.0
}
disk.Free = disk.Total - disk.Used
if disk.Free < 0 {
disk.Free = 0
}
disk.Mountpoint = "/"
disk.Type = "zfs"
disk.Device = "rpool"
disks := []models.Disk{disk}
// Add a data partition for variety
if hostIndex%2 == 0 {
dataDiskTotal := int64(500+rand.Intn(1500)) << 30
dataDiskUsage := SampleMetric("agent", mockHostDiskMetricID(hostID, 1), "disk", now)
dataDiskUsed := int64(float64(dataDiskTotal) * (dataDiskUsage / 100.0))
disks = append(disks, models.Disk{
Total: dataDiskTotal,
Used: dataDiskUsed,
Free: dataDiskTotal - dataDiskUsed,
Usage: dataDiskUsage,
Mountpoint: "/data",
Type: "ext4",
Device: "/dev/sdb1",
})
}
if hostIndex%3 == 0 {
logDiskTotal := int64(100+rand.Intn(400)) << 30
logDiskUsage := SampleMetric("agent", mockHostDiskMetricID(hostID, 2), "disk", now)
logDiskUsed := int64(float64(logDiskTotal) * (logDiskUsage / 100.0))
disks = append(disks, models.Disk{
Total: logDiskTotal,
Used: logDiskUsed,
Free: logDiskTotal - logDiskUsed,
Usage: logDiskUsage,
Mountpoint: "/var/log",
Type: "ext4",
Device: "/dev/sdc1",
})
}
status := nodeStatusToHostStatus(node.Status)
lastSeen := now.Add(-time.Duration(rand.Intn(12)) * time.Second)
if status == "offline" {
lastSeen = now.Add(-time.Duration(240+rand.Intn(1200)) * time.Second)
}
displayName := node.DisplayName
if strings.TrimSpace(displayName) == "" {
displayName = humanizeHostDisplayName(node.Name)
}
host := models.Host{
ID: hostID,
Hostname: node.Name,
DisplayName: displayName,
Platform: "linux",
OSName: "Proxmox VE",
OSVersion: node.PVEVersion,
KernelVersion: node.KernelVersion,
Architecture: "x86_64",
CPUCount: cpuCount,
CPUUsage: cpuUsage,
LoadAverage: []float64{
clampFloat((cpuUsage/100.0)*float64(cpuCount)*0.8, 0.05, float64(cpuCount)*1.2),
clampFloat((cpuUsage/100.0)*float64(cpuCount)*0.7, 0.05, float64(cpuCount)*1.2),
clampFloat((cpuUsage/100.0)*float64(cpuCount)*0.6, 0.05, float64(cpuCount)*1.2),
},
Memory: memory,
Disks: disks,
NetworkInterfaces: []models.HostNetworkInterface{
{
Name: "eth0",
MAC: fmt.Sprintf("02:50:%02x:%02x:%02x:%02x", hostIndex%255, (hostIndex*13)%255, (hostIndex*29)%255, (hostIndex*37)%255),
Addresses: []string{
fmt.Sprintf("192.168.%d.%d", 80+(hostIndex%10), 20+(hostIndex%200)),
},
RXBytes: uint64(1_000_000_000 + rand.Int63n(5_000_000_000)),
TXBytes: uint64(750_000_000 + rand.Int63n(4_000_000_000)),
},
},
Sensors: nodeTemperatureToHostSensors(node.Temperature),
Status: status,
UptimeSeconds: node.Uptime,
IntervalSeconds: 30,
LastSeen: lastSeen,
AgentVersion: hostAgentVersions[rand.Intn(len(hostAgentVersions))],
MachineID: mockStableHexString(32, "linked-host-machine", hostID),
Tags: []string{"mock", "proxmox-node"},
LinkedNodeID: node.ID,
}
host.NetInRate = SampleMetric("agent", host.ID, "netin", now)
host.NetOutRate = SampleMetric("agent", host.ID, "netout", now)
host.DiskReadRate = SampleMetric("agent", host.ID, "diskread", now)
host.DiskWriteRate = SampleMetric("agent", host.ID, "diskwrite", now)
if status == "offline" {
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
}
return host
}
func nodeStatusToHostStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "offline":
return "offline"
case "degraded":
return "degraded"
default:
return "online"
}
}
// A mock machine's CPU temperature follows one sampler series, the same
// series that seeds its history, so the current reading continues the line
// drawn beneath it. A node with a linked agent follows the agent's series:
// the poller merges the agent's sensors into the node, and the node's drawer
// and history read the agent. The package carries the sampled value and each
// core sits a stable amount below it, so models.PrimaryCPUTemperatureCelsius
// returns exactly the sample for the node and for its agent.
func CPUTemperatureSeries(node models.Node) (resourceClass, resourceID string) {
if linkedAgentID := strings.TrimSpace(node.LinkedAgentID); linkedAgentID != "" {
return "agent", linkedAgentID
}
return "node", node.ID
}
func mockCoreTemperature(primary float64, seriesID string, core int) float64 {
offset := 0.5 + float64(mockStableHash64(seriesID, fmt.Sprintf("core-%d", core), "temperature-offset")%56)/10
return math.Max(primary-offset, 1)
}
// refreshMockNodeCPUTemperature replaces the node's CPU readings (not the
// low/record tracking or other sensors) with the current sample. The
// Temperature is copied, never mutated in place, because published snapshots
// may still share the old pointer.
func refreshMockNodeCPUTemperature(node *models.Node, now time.Time) {
if node == nil || node.Temperature == nil || !node.Temperature.Available || !node.Temperature.HasCPU {
return
}
resourceClass, seriesID := CPUTemperatureSeries(*node)
primary := SampleMetric(resourceClass, seriesID, "temperature", now)
temperature := *node.Temperature
temperature.CPUPackage = primary
temperature.CPUMax = primary
temperature.Cores = make([]models.CoreTemp, len(node.Temperature.Cores))
for i, core := range node.Temperature.Cores {
temperature.Cores[i] = models.CoreTemp{Core: core.Core, Temp: mockCoreTemperature(primary, seriesID, core.Core)}
}
node.Temperature = &temperature
}
// refreshMockHostCPUSensors writes the agent's current sample under the keys a
// real agent reports: cpu_package, plus cpu_core_N for the cores it already
// carries. A linked agent's keys come from its node, so both read one value.
func refreshMockHostCPUSensors(host *models.Host, now time.Time) {
if host == nil || host.Sensors.TemperatureCelsius == nil {
return
}
primary := SampleMetric("agent", host.ID, "temperature", now)
sensors := make(map[string]float64, len(host.Sensors.TemperatureCelsius)+1)
for key, value := range host.Sensors.TemperatureCelsius {
var core int
if _, err := fmt.Sscanf(key, "cpu_core_%d", &core); err == nil && key == fmt.Sprintf("cpu_core_%d", core) {
sensors[key] = mockCoreTemperature(primary, host.ID, core)
continue
}
sensors[key] = value
}
sensors["cpu_package"] = primary
host.Sensors.TemperatureCelsius = sensors
}
func nodeTemperatureToHostSensors(temperature *models.Temperature) models.HostSensorSummary {
if temperature == nil || !temperature.Available {
return models.HostSensorSummary{}
}
sensors := models.HostSensorSummary{
TemperatureCelsius: map[string]float64{},
}
if temperature.CPUPackage > 0 {
sensors.TemperatureCelsius["cpu_package"] = temperature.CPUPackage
}
for _, core := range temperature.Cores {
sensors.TemperatureCelsius[fmt.Sprintf("cpu_core_%d", core.Core)] = core.Temp
}
if len(temperature.NVMe) > 0 {
sensors.Additional = make(map[string]float64, len(temperature.NVMe))
for _, nvme := range temperature.NVMe {
if nvme.Device == "" {
continue
}
sensors.Additional[nvme.Device] = nvme.Temp
}
}
if len(sensors.TemperatureCelsius) == 0 {
sensors.TemperatureCelsius = nil
}
if len(sensors.Additional) == 0 {
sensors.Additional = nil
}
return sensors
}
func generateMockHostRate(ioType string) float64 {
rate := float64(generateRealisticIO(ioType))
if rate > 0 {
return rate
}
switch ioType {
case "network-in":
return float64((128 + rand.Intn(4096)) * 1024) // 128 KiB/s - 4 MiB/s
case "network-out":
return float64((96 + rand.Intn(3072)) * 1024) // 96 KiB/s - 3 MiB/s
case "disk-read":
return float64((64 + rand.Intn(2048)) * 1024) // 64 KiB/s - 2 MiB/s
case "disk-write":
return float64((32 + rand.Intn(1536)) * 1024) // 32 KiB/s - 1.5 MiB/s
default:
return float64((32 + rand.Intn(512)) * 1024)
}
}
func generateDockerContainers(hostName string, hostIdx int, config MockConfig, podman bool) []models.DockerContainer {
base := config.DockerContainersPerHost
if base < 1 {
base = 6
}
variation := mockStableChoice(5, hostName, fmt.Sprintf("%d", hostIdx), "docker-container-count") - 2
count := base + variation
if count < 3 {
count = 3
}
now := time.Now()
containers := make([]models.DockerContainer, 0, count)
nameUsage := make(map[string]int)
var podDefs []podDefinition
infraAssigned := make(map[string]bool)
if podman {
maxPods := 1
if count > 1 {
if count < 3 {
maxPods = count
} else {
maxPods = 3
}
}
podCount := maxPods
if maxPods > 1 {
podCount = 1 + mockStableChoice(maxPods, hostName, fmt.Sprintf("%d", hostIdx), "docker-pod-count")
}
podDefs = make([]podDefinition, podCount)
baseProject := strings.ReplaceAll(hostName, "-", "")
for idx := 0; idx < podCount; idx++ {
composeProject := ""
composeWorkdir := ""
if rand.Float64() < 0.7 {
composeProject = fmt.Sprintf("%s-stack", baseProject)
composeWorkdir = fmt.Sprintf("/srv/%s", baseProject)
}
autoUpdatePolicy := ""
autoUpdateRestart := ""
if rand.Float64() < 0.35 {
autoUpdatePolicy = []string{"image", "registry"}[rand.Intn(2)]
if rand.Float64() < 0.5 {
autoUpdateRestart = []string{"rolling", "daily"}[rand.Intn(2)]
}
}
userNamespace := []string{"keep-id", "host", "private"}[rand.Intn(3)]
podDefs[idx] = podDefinition{
Name: fmt.Sprintf("%s-pod-%d", baseProject, idx+1),
ID: mockStableHexString(24, hostName, fmt.Sprintf("%d", hostIdx), fmt.Sprintf("%d", idx), "docker-pod-id"),
ComposeProject: composeProject,
ComposeWorkdir: composeWorkdir,
ComposeConfigHash: mockStableHexString(16, hostName, fmt.Sprintf("%d", hostIdx), fmt.Sprintf("%d", idx), "docker-pod-compose-config"),
AutoUpdatePolicy: autoUpdatePolicy,
AutoUpdateRestart: autoUpdateRestart,
UserNamespace: userNamespace,
}
}
}
for i := 0; i < count; i++ {
baseName := appNames[mockStableChoice(len(appNames), hostName, fmt.Sprintf("%d", hostIdx), fmt.Sprintf("%d", i), "docker-container-name")]
nameUsage[baseName]++
containerName := baseName
if nameUsage[baseName] > 1 {
containerName = fmt.Sprintf("%s-%d", baseName, nameUsage[baseName])
}
var pod *podDefinition
isInfra := false
if podman && len(podDefs) > 0 {
pod = &podDefs[rand.Intn(len(podDefs))]
if !infraAssigned[pod.ID] {
isInfra = true
infraAssigned[pod.ID] = true
}
}
containerID := fmt.Sprintf(
"%s-%s",
hostName,
mockStableHexString(12, hostName, fmt.Sprintf("%d", hostIdx), fmt.Sprintf("%d", i), containerName, "docker-container-id"),
)
running := rand.Float64() >= config.StoppedPercent
if running && rand.Float64() < 0.05 {
running = false // small chance of paused/exited container regardless of stopped percent
}
memLimit := int64((256 + rand.Intn(4096)) * 1024 * 1024) // 256MB - ~4.25GB
if memLimit < 256*1024*1024 {
memLimit = 256 * 1024 * 1024
}
memPercent := clampFloat(20+rand.Float64()*65, 2, 96)
if !running {
memPercent = 0
} else {
memPercent = sampleNaturalMetric("dockerContainer", containerID, "memory", 1, 97, 0.6, now)
}
memUsage := int64(float64(memLimit) * (memPercent / 100.0))
cpuPercent := clampFloat(rand.Float64()*70, 0.2, 180)
if !running {
cpuPercent = 0
} else {
cpuPercent = sampleNaturalMetric("dockerContainer", containerID, "cpu", 0, 190, 1.0, now)
}
restartCount := rand.Intn(4)
exitCode := 0
health := "healthy"
if rand.Float64() < 0.08 {
health = "starting"
}
if rand.Float64() < 0.08 {
health = "unhealthy"
}
var startedAt *time.Time
var finishedAt *time.Time
uptime := int64(0)
state := "running"
statusText := ""
createdAt := now.Add(-time.Duration(rand.Intn(60*24)) * time.Hour)
if running {
uptime = int64(3600 + rand.Intn(86400*14)) // 1 hour to ~14 days
start := now.Add(-time.Duration(uptime) * time.Second)
startedAt = &start
statusText = fmt.Sprintf("Up %s", formatDurationForStatus(time.Duration(uptime)*time.Second))
} else {
stateOptions := []string{"exited", "paused"}
state = stateOptions[rand.Intn(len(stateOptions))]
if state == "exited" {
exitCode = []int{0, 1, 137, 139}[rand.Intn(4)]
restartCount = 1 + rand.Intn(4)
finished := now.Add(-time.Duration(rand.Intn(72)+1) * time.Hour)
finishedAt = &finished
statusText = fmt.Sprintf("Exited (%d) %s ago", exitCode, formatDurationForStatus(now.Sub(finished)))
health = ""
} else {
statusText = "Paused"
if rand.Float64() < 0.3 {
health = ""
}
}
}
labels := generateDockerLabels(containerName, hostName, podman && pod != nil, pod, isInfra)
container := models.DockerContainer{
ID: containerID,
Name: containerName,
Image: fmt.Sprintf("ghcr.io/mock/%s:%d.%d.%d", containerName, 1+rand.Intn(2), rand.Intn(10), rand.Intn(10)),
State: state,
Status: statusText,
Health: health,
CPUPercent: cpuPercent,
MemoryUsage: memUsage,
MemoryLimit: memLimit,
MemoryPercent: memPercent,
UptimeSeconds: uptime,
RestartCount: restartCount,
ExitCode: exitCode,
CreatedAt: createdAt,
StartedAt: startedAt,
FinishedAt: finishedAt,
Ports: generateDockerPorts(),
Labels: labels,
Networks: generateDockerNetworks(hostIdx, i),
}
container.UpdateStatus = generateDockerContainerUpdateStatus(now)
if pod != nil {
container.Podman = &models.DockerPodmanContainer{
PodName: pod.Name,
PodID: pod.ID,
Infra: isInfra,
ComposeProject: pod.ComposeProject,
ComposeService: containerName,
ComposeWorkdir: pod.ComposeWorkdir,
ComposeConfigHash: pod.ComposeConfigHash,
AutoUpdatePolicy: pod.AutoUpdatePolicy,
AutoUpdateRestart: pod.AutoUpdateRestart,
UserNamespace: pod.UserNamespace,
}
}
containers = append(containers, container)
}
return containers
}
// generateDockerContainerUpdateStatus mirrors the states the docker agent's
// registry checker reports: unchecked, current, update available, and the
// digest-pinned sentinel for image@sha256 references (no error states, since
// mock data also feeds the public demo).
func generateDockerContainerUpdateStatus(now time.Time) *models.DockerContainerUpdateStatus {
roll := rand.Float64()
if roll < 0.45 {
return nil
}
status := &models.DockerContainerUpdateStatus{
CurrentDigest: fmt.Sprintf("sha256:%016x%016x%016x%016x", rand.Uint64(), rand.Uint64(), rand.Uint64(), rand.Uint64()),
LastChecked: now.Add(-time.Duration(10+rand.Intn(170)) * time.Minute),
}
switch {
case roll < 0.50:
status.Error = "digest-pinned image"
case roll < 0.65:
status.UpdateAvailable = true
status.LatestDigest = fmt.Sprintf("sha256:%016x%016x%016x%016x", rand.Uint64(), rand.Uint64(), rand.Uint64(), rand.Uint64())
default:
status.LatestDigest = status.CurrentDigest
}
return status
}
func generateDockerPorts() []models.DockerContainerPort {
if rand.Float64() < 0.45 {
return nil
}
portChoices := []int{80, 443, 3000, 3306, 5432, 6379, 8080, 9000}
count := 1 + rand.Intn(2)
ports := make([]models.DockerContainerPort, 0, count)
used := make(map[int]bool)
for len(ports) < count && len(used) < len(portChoices) {
private := portChoices[rand.Intn(len(portChoices))]
if used[private] {
continue
}
used[private] = true
port := models.DockerContainerPort{
PrivatePort: private,
Protocol: []string{"tcp", "udp"}[rand.Intn(2)],
}
if rand.Float64() < 0.75 {
port.PublicPort = 20000 + rand.Intn(20000)
port.IP = "0.0.0.0"
}
ports = append(ports, port)
}
return ports
}
func generateDockerNetworks(hostIdx, containerIdx int) []models.DockerContainerNetworkLink {
networks := []models.DockerContainerNetworkLink{{
Name: "bridge",
IPv4: fmt.Sprintf("172.18.%d.%d", hostIdx+10, containerIdx+20),
}}
if rand.Float64() < 0.3 {
networks = append(networks, models.DockerContainerNetworkLink{
Name: "frontend",
IPv4: fmt.Sprintf("10.%d.%d.%d", hostIdx+20, containerIdx+10, rand.Intn(200)+20),
})
}
if rand.Float64() < 0.2 {
networks[len(networks)-1].IPv6 = fmt.Sprintf("fd00:%x:%x::%x", hostIdx+1, containerIdx+1, rand.Intn(4000))
}
return networks
}
func generateDockerLabels(serviceName, hostName string, podman bool, pod *podDefinition, infra bool) map[string]string {
if podman && pod != nil {
labels := map[string]string{
"io.podman.annotations.pod.name": pod.Name,
"io.podman.annotations.pod.id": pod.ID,
}
if infra {
labels["io.podman.annotations.pod.infra"] = "true"
} else {
labels["io.podman.annotations.pod.infra"] = "false"
}
if pod.ComposeProject != "" {
labels["io.podman.compose.project"] = pod.ComposeProject
labels["io.podman.compose.service"] = serviceName
if pod.ComposeWorkdir != "" {
labels["io.podman.compose.working_dir"] = pod.ComposeWorkdir
}
if pod.ComposeConfigHash != "" {
labels["io.podman.compose.config-hash"] = pod.ComposeConfigHash
}
}
if pod.AutoUpdatePolicy != "" {
labels["io.containers.autoupdate"] = pod.AutoUpdatePolicy
}
if pod.AutoUpdateRestart != "" {
labels["io.containers.autoupdate.restart"] = pod.AutoUpdateRestart
}
if pod.UserNamespace != "" {
labels["io.podman.annotations.userns"] = pod.UserNamespace
}
if rand.Float64() < 0.25 {
labels["io.containers.capabilities"] = "CHOWN,DAC_OVERRIDE,SETUID,SETGID"
}
if rand.Float64() < 0.2 {
labels["environment"] = []string{"production", "staging", "development"}[rand.Intn(3)]
}
return labels
}
labels := map[string]string{
"com.docker.compose.project": hostName,
"com.docker.compose.service": serviceName,
}
if rand.Float64() < 0.25 {
labels["environment"] = []string{"production", "staging", "development"}[rand.Intn(3)]
}
if rand.Float64() < 0.2 {
labels["traefik.enable"] = "true"
}
return labels
}
func formatDurationForStatus(d time.Duration) string {
if d < 0 {
d = -d
}
if d < time.Minute {
return "less than a minute"
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
if d < 24*time.Hour {
hours := int(d / time.Hour)
minutes := int((d % time.Hour) / time.Minute)
if minutes == 0 {
return fmt.Sprintf("%dh", hours)
}
return fmt.Sprintf("%dh%dm", hours, minutes)
}
days := int(d / (24 * time.Hour))
hours := int((d % (24 * time.Hour)) / time.Hour)
if hours == 0 {
return fmt.Sprintf("%dd", days)
}
return fmt.Sprintf("%dd%dh", days, hours)
}
func clampFloat(value, min, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}
// naturalMetricUpdate produces realistic time-driven metric values that mimic
// real infrastructure behavior. CPU-like metrics (speed >= 0.8) sit at a low
// baseline with occasional sharp spikes — matching how real servers behave
// (mostly idle, with bursts of activity). Memory uses stable plateaus with
// slow drift. Disk changes very gradually.
//
// speed controls metric character: 1.0 = CPU (low baseline + spikes),
// ~0.5 = memory (stable plateau), ~0.12 = disk (very gradual drift).
func normalizeMockBlendWeight(weight float64, step, reference time.Duration) float64 {
return mockmodel.NormalizeBlendWeight(weight, step, reference)
}
func sampleNaturalMetric(resourceClass, resourceID, metric string, min, max float64, speed float64, at time.Time) float64 {
seed := MetricSeed(resourceClass, resourceID, metric)
return mockmodel.ValueAtMetricWithRole(seed, min, max, metric, speed, MetricRole(resourceClass, resourceID), at)
}
func applyMemoryUsage(memory *models.Memory, usage float64) {
if memory == nil || memory.Total <= 0 {
return
}
memory.Usage = clampFloat(usage, 0, 100)
memory.Used = int64(float64(memory.Total) * (memory.Usage / 100.0))
if memory.Used > memory.Total {
memory.Used = memory.Total
}
memory.Free = memory.Total - memory.Used
if memory.Free < 0 {
memory.Free = 0
}
// Keep the reclaimable-cache invariant (used + cache + free == total) as
// the sampled usage drifts: cache can never exceed the non-used pages.
if memory.Cache > memory.Free {
memory.Cache = memory.Free
}
memory.Free -= memory.Cache
}
func applyDiskUsage(disk *models.Disk, usage float64) {
if disk == nil || disk.Total <= 0 {
return
}
disk.Usage = clampFloat(usage, 0, 100)
disk.Used = int64(float64(disk.Total) * (disk.Usage / 100.0))
if disk.Used > disk.Total {
disk.Used = disk.Total
}
disk.Free = disk.Total - disk.Used
if disk.Free < 0 {
disk.Free = 0
}
}
func randomHexString(n int) string {
const hexChars = "0123456789abcdef"
if n <= 0 {
return ""
}
b := make([]byte, n)
for i := range b {
b[i] = hexChars[rand.Intn(len(hexChars))]
}
return string(b)
}
func humanizeHostDisplayName(hostname string) string {
parts := strings.Split(hostname, "-")
for i, part := range parts {
if part == "" {
continue
}
runes := []rune(part)
runes[0] = unicode.ToUpper(runes[0])
parts[i] = string(runes)
}
return strings.Join(parts, " ")
}
func generateContainer(nodeName string, instance string, vmid int, config MockConfig) models.Container {
name := generateGuestName("lxc", fmt.Sprintf("%s:%s:%d", instance, nodeName, vmid))
status := "running"
if rand.Float64() < config.StoppedPercent {
status = "stopped"
}
cpu := float64(0)
totalMem := int64((512 + rand.Intn(7680)) * 1024 * 1024) // 512 MB - 8 GB
mem := models.Memory{
Total: totalMem,
Free: totalMem,
}
uptime := int64(0)
if status == "running" {
// More realistic CPU for containers: mostly very low
cpuRand := rand.Float64()
if cpuRand < 0.90 { // 90% of containers have minimal CPU
cpu = rand.Float64() * 0.12 // 0-12%
} else if cpuRand < 0.98 { // 8% moderate CPU
cpu = 0.12 + rand.Float64()*0.28 // 12-40%
} else { // 2% higher CPU (can trigger alerts at 80%)
cpu = 0.40 + rand.Float64()*0.35 // 40-75%
}
// More realistic memory for containers
var memUsage float64
memRand := rand.Float64()
if memRand < 0.90 { // 90% typical usage
memUsage = 0.35 + rand.Float64()*0.35 // 35-70%
} else if memRand < 0.98 { // 8% moderate usage
memUsage = 0.70 + rand.Float64()*0.12 // 70-82%
} else { // 2% high memory (can trigger alerts at 85%)
memUsage = 0.82 + rand.Float64()*0.08 // 82-90%
}
usedMem := int64(float64(totalMem) * memUsage)
balloon := int64(0)
// LXC containers typically don't use ballooning in the same way, keep it 0 for clear "Active" usage
swapTotal := int64(0)
swapUsed := int64(0)
if rand.Float64() < 0.4 {
swapTotal = int64((256 + rand.Intn(1024)) * 1024 * 1024) // 256MB - 1.25GB
swapUsed = int64(float64(swapTotal) * (0.1 + rand.Float64()*0.4))
}
mem = models.Memory{
Total: totalMem,
Used: usedMem,
Free: totalMem - usedMem,
Usage: memUsage * 100,
Balloon: balloon,
SwapTotal: swapTotal,
SwapUsed: swapUsed,
}
// Proxmox reports container memory from the cgroup, cache included;
// only a linked Pulse agent supplies a cache-aware figure.
if rand.Float64() < 0.15 {
mem.Observation = mockGuestMemoryObservation("agent")
} else {
mem.Observation = mockGuestMemoryObservation("cluster-resources")
}
uptime = int64(3600 * (1 + rand.Intn(1440))) // 1-1440 hours (up to 60 days)
} else {
mem.Observation = models.MemoryObservation{State: "unavailable", Source: "powered-off"}
}
// Disk stats - containers typically smaller
totalDisk := int64((8 + rand.Intn(120)) * 1024 * 1024 * 1024) // 8-128 GB
usedDisk := int64(float64(totalDisk) * (0.1 + rand.Float64()*0.6))
// Generate ID matching production logic: standalone uses "node-vmid", cluster uses "instance-node-vmid"
var ctID string
if instance == nodeName {
ctID = fmt.Sprintf("%s-%d", nodeName, vmid)
} else {
ctID = fmt.Sprintf("%s-%s-%d", instance, nodeName, vmid)
}
if status == "running" {
now := time.Now()
cpu = SampleMetric("container", ctID, "cpu", now) / 100.0
applyMemoryUsage(&mem, SampleMetric("container", ctID, "memory", now))
}
ct := models.Container{
Name: name,
VMID: vmid,
Node: nodeName,
Instance: instance,
Type: "lxc",
Status: status,
CPU: cpu,
CPUs: 1 + rand.Intn(4), // 1-4 cores
Memory: mem,
Disk: models.Disk{
Total: totalDisk,
Used: usedDisk,
Free: totalDisk - usedDisk,
Usage: float64(usedDisk) / float64(totalDisk) * 100,
},
DiskRead: SampleMetricInt("container", ctID, "diskread", time.Now()),
DiskWrite: SampleMetricInt("container", ctID, "diskwrite", time.Now()),
NetworkIn: SampleMetricInt("container", ctID, "netin", time.Now()),
NetworkOut: SampleMetricInt("container", ctID, "netout", time.Now()),
Uptime: uptime,
ID: ctID,
Tags: generateTags(),
LastBackup: generateLastBackupTime(),
}
if status == "running" {
applyDiskUsage(&ct.Disk, SampleMetric("container", ctID, "disk", time.Now()))
}
if status != "running" {
ct.CPU = 0
ct.Memory.Usage = 0
ct.Memory.SwapUsed = 0
ct.Memory.Used = 0
ct.Memory.Free = ct.Memory.Total
ct.Disk.Used = 0
ct.Disk.Free = ct.Disk.Total
ct.Disk.Usage = -1
ct.NetworkIn = 0
ct.NetworkOut = 0
ct.DiskRead = 0
ct.DiskWrite = 0
ct.Uptime = 0
}
return ct
}
func generateGuestName(prefix string, key string) string {
name := appNames[mockStableChoice(len(appNames), prefix, key, "guest-name")]
suffix := 10 + mockStableChoice(90, prefix, key, "guest-suffix")
return fmt.Sprintf("%s-%s-%d", prefix, name, suffix)
}
// generateLastBackupTime generates a realistic last backup timestamp.
// Distribution: 70% within 24h (fresh), 15% within 72h (stale), 10% older (critical), 5% never backed up
func generateLastBackupTime() time.Time {
r := rand.Float64()
now := time.Now()
if r < 0.05 {
// 5% never backed up - return zero time
return time.Time{}
} else if r < 0.15 {
// 10% critical - backup 4-30 days ago
hoursAgo := 96 + rand.Intn(624) // 4-30 days in hours
return now.Add(-time.Duration(hoursAgo) * time.Hour)
} else if r < 0.30 {
// 15% stale - backup 24-72 hours ago
hoursAgo := 24 + rand.Intn(48)
return now.Add(-time.Duration(hoursAgo) * time.Hour)
} else {
// 70% fresh - backup within last 24 hours
hoursAgo := rand.Intn(24)
return now.Add(-time.Duration(hoursAgo) * time.Hour)
}
}
// generateTags generates random tags for a guest
func generateTags() []string {
// 30% chance of no tags
if rand.Float64() < 0.3 {
return []string{}
}
// Generate 1-4 tags
numTags := 1 + rand.Intn(4)
tags := make([]string, 0, numTags)
usedTags := make(map[string]bool)
for len(tags) < numTags {
tag := commonTags[rand.Intn(len(commonTags))]
// Avoid duplicate tags
if !usedTags[tag] {
tags = append(tags, tag)
usedTags[tag] = true
}
}
return tags
}
// generateZFSPoolWithIssues creates a ZFS pool with various issues for testing
func generateZFSPoolWithIssues(poolName string) *models.ZFSPool {
scenarios := []func() *models.ZFSPool{
// Degraded pool with device errors
func() *models.ZFSPool {
return &models.ZFSPool{
Name: poolName,
State: "DEGRADED",
Status: "Degraded",
Scan: "resilver in progress",
ReadErrors: 12,
WriteErrors: 0,
ChecksumErrors: 3,
Devices: []models.ZFSDevice{
{
Name: "sda2",
Type: "disk",
State: "ONLINE",
ReadErrors: 0,
WriteErrors: 0,
ChecksumErrors: 0,
},
{
Name: "sdb2",
Type: "disk",
State: "DEGRADED",
ReadErrors: 12,
WriteErrors: 0,
ChecksumErrors: 3,
},
},
}
},
// Pool with checksum errors
func() *models.ZFSPool {
return &models.ZFSPool{
Name: poolName,
State: "ONLINE",
Status: "Healthy",
Scan: "scrub in progress",
ReadErrors: 0,
WriteErrors: 0,
ChecksumErrors: 7,
Devices: []models.ZFSDevice{
{
Name: "sda2",
Type: "disk",
State: "ONLINE",
ReadErrors: 0,
WriteErrors: 0,
ChecksumErrors: 7,
},
},
}
},
// Faulted device
func() *models.ZFSPool {
return &models.ZFSPool{
Name: poolName,
State: "DEGRADED",
Status: "Degraded",
Scan: "none",
ReadErrors: 0,
WriteErrors: 0,
ChecksumErrors: 0,
Devices: []models.ZFSDevice{
{
Name: "mirror-0",
Type: "mirror",
State: "DEGRADED",
ReadErrors: 0,
WriteErrors: 0,
ChecksumErrors: 0,
},
{
Name: "sda2",
Type: "disk",
State: "ONLINE",
ReadErrors: 0,
WriteErrors: 0,
ChecksumErrors: 0,
},
{
Name: "sdb2",
Type: "disk",
State: "FAULTED",
ReadErrors: 0,
WriteErrors: 0,
ChecksumErrors: 0,
},
},
}
},
}
// Pick a random scenario
return scenarios[rand.Intn(len(scenarios))]()
}
// generateStorage generates mock storage data for nodes
func generateStorage(nodes []models.Node) []models.Storage {
var storage []models.Storage
now := time.Now()
for _, node := range nodes {
isOffline := node.Status != "online" || node.ConnectionHealth == "offline" || node.Uptime <= 0
// Storage on an offline node hasn't been freshly observed; backdate
// its sighting so the stale path renders instead of fake freshness.
nodeStorageSeen := now
if isOffline {
nodeStorageSeen = now.Add(-10 * time.Minute)
}
// Local storage (always present)
localTotal := int64(500 * 1024 * 1024 * 1024) // 500GB
localID := fmt.Sprintf("%s-%s-local", node.Instance, node.Name)
localUsage := SampleMetric("storage", localID, "usage", now)
localUsed := int64(float64(localTotal) * (localUsage / 100.0))
if isOffline {
localUsed = 0
}
localFree := localTotal - localUsed
if localTotal > 0 {
localUsage = float64(localUsed) / float64(localTotal) * 100
}
localStatus := "available"
localEnabled := true
localActive := true
if isOffline {
localStatus = "offline"
localEnabled = false
localActive = false
}
storage = append(storage, models.Storage{
ID: localID,
Name: "local",
Node: node.Name,
Instance: node.Instance,
Type: "dir",
Status: localStatus,
Total: localTotal,
Used: localUsed,
Free: localFree,
Usage: localUsage,
Content: "vztmpl,iso",
Shared: false,
Enabled: localEnabled,
Active: localActive,
LastSeen: nodeStorageSeen,
})
// Local-zfs (common)
zfsTotal := int64(2 * 1024 * 1024 * 1024 * 1024) // 2TB
zfsID := fmt.Sprintf("%s-%s-local-zfs", node.Instance, node.Name)
zfsUsage := SampleMetric("storage", zfsID, "usage", now)
zfsUsed := int64(float64(zfsTotal) * (zfsUsage / 100.0))
if isOffline {
zfsUsed = 0
}
zfsFree := zfsTotal - zfsUsed
if zfsTotal > 0 {
zfsUsage = float64(zfsUsed) / float64(zfsTotal) * 100
}
// Generate ZFS pool status
var zfsPool *models.ZFSPool
if rand.Float64() < 0.15 { // 15% chance of ZFS pool with issues
zfsPool = generateZFSPoolWithIssues("local-zfs")
} else if rand.Float64() < 0.95 { // Most ZFS pools are healthy
zfsPool = &models.ZFSPool{
Name: "rpool/data",
State: "ONLINE",
Status: "Healthy",
Scan: "none",
ReadErrors: 0,
WriteErrors: 0,
ChecksumErrors: 0,
Devices: []models.ZFSDevice{
{
Name: "sda2",
Type: "disk",
State: "ONLINE",
ReadErrors: 0,
WriteErrors: 0,
ChecksumErrors: 0,
},
},
}
}
zfsStatus := "available"
zfsEnabled := true
zfsActive := true
if isOffline {
zfsStatus = "offline"
zfsEnabled = false
zfsActive = false
}
storage = append(storage, models.Storage{
ID: zfsID,
Name: "local-zfs",
Node: node.Name,
Instance: node.Instance,
Type: "zfspool",
Status: zfsStatus,
Total: zfsTotal,
Used: zfsUsed,
Free: zfsFree,
Usage: zfsUsage,
Content: "images,rootdir",
Shared: false,
Enabled: zfsEnabled,
Active: zfsActive,
LastSeen: nodeStorageSeen,
ZFSPool: zfsPool,
})
// Keep per-node storage intentionally compact in demo mode. A broad
// random storage fan-out makes the Storage page read like synthetic lab
// noise rather than a curated estate.
}
clusterNodesByInstance := make(map[string][]models.Node)
for _, node := range nodes {
if !node.IsClusterMember || strings.TrimSpace(node.Instance) == "" {
continue
}
clusterNodesByInstance[node.Instance] = append(clusterNodesByInstance[node.Instance], node)
}
clusterInstances := make([]string, 0, len(clusterNodesByInstance))
for instance := range clusterNodesByInstance {
clusterInstances = append(clusterInstances, instance)
}
sort.Strings(clusterInstances)
// Each cluster has two shared PBS storage definitions. Every node reports
// them to the API, but the poller merges a shared storage into one
// cluster-scoped entry (issue 1049), so the fixture carries that merged
// shape: one row per store with the reporting nodes listed, never one row
// per node with a different usage figure on each.
for _, clusterInstance := range clusterInstances {
clusterNodes := clusterNodesByInstance[clusterInstance]
sort.Slice(clusterNodes, func(i, j int) bool { return clusterNodes[i].Name < clusterNodes[j].Name })
nodeNames := make([]string, 0, len(clusterNodes))
nodeIDs := make([]string, 0, len(clusterNodes))
anyNodeOnline := false
for _, node := range clusterNodes {
nodeNames = append(nodeNames, node.Name)
nodeIDs = append(nodeIDs, fmt.Sprintf("%s-%s", node.Instance, node.Name))
if node.Status == "online" && node.ConnectionHealth != "offline" && node.Uptime > 0 {
anyNodeOnline = true
}
}
for storeIndex, storeName := range []string{"pbs-primary", "pbs-offsite"} {
pbsTotal := int64(48+storeIndex*16) * 1024 * 1024 * 1024 * 1024
pbsID := fmt.Sprintf("%s-cluster-%s", clusterInstance, storeName)
pbsUsage := SampleMetric("pbsDatastore", pbsID, "usage", now)
pbsUsed := int64(float64(pbsTotal) * (pbsUsage / 100.0))
status := "available"
enabled := true
active := true
lastSeen := now
if !anyNodeOnline {
status = "offline"
enabled = false
active = false
pbsUsed = 0
pbsUsage = 0
lastSeen = now.Add(-10 * time.Minute)
}
storage = append(storage, models.Storage{
ID: pbsID,
Name: storeName,
Node: "cluster",
Nodes: append([]string(nil), nodeNames...),
NodeIDs: append([]string(nil), nodeIDs...),
NodeCount: len(nodeNames),
Instance: clusterInstance,
Type: "pbs",
Status: status,
Total: pbsTotal,
Used: pbsUsed,
Free: pbsTotal - pbsUsed,
Usage: pbsUsage,
Content: "backup",
Shared: true,
Enabled: enabled,
Active: active,
LastSeen: lastSeen,
})
}
sharedTotal := int64(20 * 1024 * 1024 * 1024 * 1024)
sharedID := fmt.Sprintf("%s-shared-storage", clusterInstance)
sharedUsage := SampleMetric("storage", sharedID, "usage", now)
sharedUsed := int64(float64(sharedTotal) * (sharedUsage / 100.0))
storage = append(storage, models.Storage{
ID: sharedID,
Name: "shared-storage",
Node: "shared",
Instance: clusterInstance,
Type: "nfs",
Status: "available",
Total: sharedTotal,
Used: sharedUsed,
Free: sharedTotal - sharedUsed,
Usage: sharedUsage,
Content: "images,rootdir,backup",
Shared: true,
Enabled: true,
Active: true,
LastSeen: now,
})
// Cluster-wide Ceph storage (RBD + CephFS) keeps every PVE failure
// domain visible through the canonical Ceph topology surface.
cephRbdTotal := int64(48 * 1024 * 1024 * 1024 * 1024) // 48TB raw
cephRbdID := fmt.Sprintf("%s-ceph-rbd", clusterInstance)
cephRbdUsage := SampleMetric("storage", cephRbdID, "usage", now)
cephRbdUsed := int64(float64(cephRbdTotal) * (cephRbdUsage / 100.0))
storage = append(storage, models.Storage{
ID: cephRbdID,
Name: "ceph-rbd",
Node: "shared",
Instance: clusterInstance,
Type: "rbd",
Status: "available",
Total: cephRbdTotal,
Used: cephRbdUsed,
Free: cephRbdTotal - cephRbdUsed,
Usage: cephRbdUsage,
Content: "images,rootdir",
Shared: true,
Enabled: true,
Active: true,
LastSeen: now,
})
cephFsTotal := int64(24 * 1024 * 1024 * 1024 * 1024) // 24TB
cephFsID := fmt.Sprintf("%s-cephfs", clusterInstance)
cephFsUsage := SampleMetric("storage", cephFsID, "usage", now)
cephFsUsed := int64(float64(cephFsTotal) * (cephFsUsage / 100.0))
storage = append(storage, models.Storage{
ID: cephFsID,
Name: "cephfs-data",
Node: "shared",
Instance: clusterInstance,
Type: "cephfs",
Status: "available",
Total: cephFsTotal,
Used: cephFsUsed,
Free: cephFsTotal - cephFsUsed,
Usage: cephFsUsage,
Content: "vztmpl,iso,backup",
Shared: true,
Enabled: true,
Active: true,
LastSeen: now,
})
}
return storage
}
func generateCephClusters(nodes []models.Node, storage []models.Storage) []models.CephCluster {
cephStorageByInstance := make(map[string][]models.Storage)
for _, st := range storage {
typeLower := strings.ToLower(strings.TrimSpace(st.Type))
if typeLower == "cephfs" || typeLower == "rbd" || typeLower == "ceph" {
cephStorageByInstance[st.Instance] = append(cephStorageByInstance[st.Instance], st)
}
}
if len(cephStorageByInstance) == 0 {
return nil
}
nodesByInstance := make(map[string][]models.Node)
for _, node := range nodes {
nodesByInstance[node.Instance] = append(nodesByInstance[node.Instance], node)
}
var clusters []models.CephCluster
for instanceName, cephStorages := range cephStorageByInstance {
instanceNodes := nodesByInstance[instanceName]
uniqueNodeNames := make(map[string]struct{})
for _, node := range instanceNodes {
if node.Name != "" {
uniqueNodeNames[node.Name] = struct{}{}
}
}
var totalBytes int64
var usedBytes int64
for _, st := range cephStorages {
totalBytes += st.Total
usedBytes += st.Used
}
if totalBytes == 0 {
totalBytes = int64(120 * 1024 * 1024 * 1024 * 1024)
}
if usedBytes == 0 {
usedBytes = int64(float64(totalBytes) * 0.52)
}
availableBytes := totalBytes - usedBytes
usagePercent := float64(usedBytes) / float64(totalBytes) * 100
health := "HEALTH_OK"
healthMessage := ""
if rand.Float64() < 0.2 {
health = "HEALTH_WARN"
healthMessage = "1 PG degraded"
}
pools := make([]models.CephPool, 0, len(cephStorages))
for idx, st := range cephStorages {
percent := 0.0
if st.Total > 0 {
percent = float64(st.Used) / float64(st.Total) * 100
}
poolName := st.Name
if poolName == "" {
if idx == 0 {
poolName = "rbd"
} else if idx == 1 {
poolName = "cephfs-data"
} else {
poolName = fmt.Sprintf("pool-%d", idx+1)
}
}
pools = append(pools, models.CephPool{
ID: idx + 1,
Name: poolName,
StoredBytes: st.Used,
AvailableBytes: st.Free,
Objects: int64(1_200_000 + rand.Intn(800_000)),
PercentUsed: percent,
})
}
numMons := 1
if len(uniqueNodeNames) >= 3 {
numMons = 3
} else if len(uniqueNodeNames) == 2 {
numMons = 2
}
numMgrs := 1
if len(uniqueNodeNames) > 1 {
numMgrs = 2
}
numOSDs := maxInt(len(uniqueNodeNames)*3, 6)
numOSDsUp := numOSDs
if health != "HEALTH_OK" && numOSDsUp > 0 {
numOSDsUp--
}
services := []models.CephServiceStatus{
{Type: "mon", Running: numMons, Total: numMons},
{Type: "mgr", Running: numMgrs, Total: numMgrs},
}
if len(uniqueNodeNames) > 1 {
mdsRunning := 2
mdsTotal := 2
if rand.Float64() < 0.25 {
mdsRunning = 1
}
mds := models.CephServiceStatus{Type: "mds", Running: mdsRunning, Total: mdsTotal}
if mdsRunning < mdsTotal {
mds.Message = "1 standby"
}
services = append(services, mds)
}
cluster := models.CephCluster{
ID: fmt.Sprintf("%s-ceph", instanceName),
Instance: instanceName,
Name: fmt.Sprintf("%s Ceph", titleCase(instanceName)),
FSID: fmt.Sprintf("00000000-0000-4000-8000-%s", mockStableDecimalString(12, instanceName, "ceph-fsid")),
Health: health,
HealthMessage: healthMessage,
TotalBytes: totalBytes,
UsedBytes: usedBytes,
AvailableBytes: availableBytes,
UsagePercent: usagePercent,
NumMons: numMons,
NumMgrs: numMgrs,
NumOSDs: numOSDs,
NumOSDsUp: numOSDsUp,
NumOSDsIn: numOSDs,
NumPGs: 512 + rand.Intn(256),
Pools: pools,
Services: services,
LastUpdated: time.Now().Add(-time.Duration(rand.Intn(180)) * time.Second),
}
clusters = append(clusters, cluster)
}
return clusters
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// generateBackups generates mock backup data for VMs and containers
func generateBackups(vms []models.VM, containers []models.Container) []models.StorageBackup {
var backups []models.StorageBackup
backupFormats := []string{"vma.zst", "vma.lzo", "tar.zst", "tar.gz"}
// Generate backups for ~60% of VMs
for _, vm := range vms {
if rand.Float64() > 0.4 {
continue
}
// Generate 4-10 backups per VM spread across the past year
numBackups := 4 + rand.Intn(7)
for i := 0; i < numBackups; i++ {
backupTime := time.Now().Add(-time.Duration(rand.Intn(365*24)) * time.Hour)
backupSize := vm.Disk.Total/10 + rand.Int63n(vm.Disk.Total/5) // 10-30% of disk size
backup := models.StorageBackup{
ID: fmt.Sprintf("backup-%s-vm-%d-%d", vm.Node, vm.VMID, i),
Storage: "local",
Node: vm.Node,
Instance: vm.Instance,
Type: "qemu",
VMID: vm.VMID,
Time: backupTime,
CTime: backupTime.Unix(),
Size: backupSize,
Format: backupFormats[rand.Intn(len(backupFormats))],
Notes: fmt.Sprintf("Backup of %s", vm.Name),
Protected: rand.Float64() > 0.8, // 20% protected
Volid: fmt.Sprintf("local:backup/vzdump-qemu-%d-%s.%s", vm.VMID, backupTime.Format("2006_01_02-15_04_05"), backupFormats[0]),
IsPBS: false,
Verified: rand.Float64() > 0.3, // 70% verified
}
if backup.Verified {
backup.Verification = "OK"
}
backups = append(backups, backup)
}
}
// Generate backups for ~70% of containers
for _, ct := range containers {
if rand.Float64() > 0.3 {
continue
}
// Generate 3-8 backups per container spread across the past year
numBackups := 3 + rand.Intn(6)
for i := 0; i < numBackups; i++ {
backupTime := time.Now().Add(-time.Duration(rand.Intn(365*24)) * time.Hour)
backupSize := ct.Disk.Total/20 + rand.Int63n(ct.Disk.Total/10) // 5-15% of disk size
backup := models.StorageBackup{
ID: fmt.Sprintf("backup-%s-ct-%d-%d", ct.Node, ct.VMID, i),
Storage: "local",
Node: ct.Node,
Instance: ct.Instance,
Type: "lxc",
VMID: ct.VMID,
Time: backupTime,
CTime: backupTime.Unix(),
Size: backupSize,
Format: "tar.zst",
Notes: fmt.Sprintf("Backup of %s", ct.Name),
Protected: rand.Float64() > 0.9, // 10% protected
Volid: fmt.Sprintf("local:backup/vzdump-lxc-%d-%s.tar.zst", ct.VMID, backupTime.Format("2006_01_02-15_04_05")),
IsPBS: false,
Verified: rand.Float64() > 0.2, // 80% verified
}
if backup.Verified {
backup.Verification = "OK"
}
backups = append(backups, backup)
}
}
// Generate PMG host config backups (VMID=0)
// Add 5-12 PMG host backups spread across the past year
numPMGBackups := 5 + rand.Intn(8)
pmgNodes := []string{"pmg-01", "pmg-02", "mail-gateway"}
for i := 0; i < numPMGBackups; i++ {
backupTime := time.Now().Add(-time.Duration(rand.Intn(365*24)) * time.Hour)
nodeIdx := rand.Intn(len(pmgNodes))
backup := models.StorageBackup{
ID: fmt.Sprintf("backup-pmg-host-%d", i),
Storage: "local",
Node: pmgNodes[nodeIdx],
Type: "host", // This will now display as "Host" in the UI
VMID: 0, // Host backups have VMID=0
Time: backupTime,
CTime: backupTime.Unix(),
Size: int64(50*1024*1024 + rand.Intn(200*1024*1024)), // 50-250 MB
Format: "pmgbackup.tar.zst",
Notes: fmt.Sprintf("PMG host config backup - %s", pmgNodes[nodeIdx]),
Protected: rand.Float64() > 0.7, // 30% protected
Volid: fmt.Sprintf("local:backup/pmgbackup-%s-%s.tar.zst", pmgNodes[nodeIdx], backupTime.Format("2006_01_02-15_04_05")),
IsPBS: false,
Verified: rand.Float64() > 0.2, // 80% verified
}
if backup.Verified {
backup.Verification = "OK"
}
backups = append(backups, backup)
}
// Sort backups by time (newest first)
sort.Slice(backups, func(i, j int) bool {
return backups[i].Time.After(backups[j].Time)
})
return backups
}
func extractPMGBackups(storageBackups []models.StorageBackup) []models.PMGBackup {
pmgBackups := make([]models.PMGBackup, 0)
for _, backup := range storageBackups {
if !strings.EqualFold(backup.Type, "host") {
continue
}
format := strings.ToLower(backup.Format)
notes := strings.ToLower(backup.Notes)
if !strings.Contains(format, "pmg") && !strings.Contains(notes, "pmg") {
continue
}
filename := backup.Volid
if filename == "" {
filename = backup.Notes
}
instance := backup.Instance
if instance == "" && backup.Node != "" {
instance = fmt.Sprintf("PMG:%s", backup.Node)
}
if instance == "" {
instance = "PMG:mock"
}
pmgBackups = append(pmgBackups, models.PMGBackup{
ID: backup.ID,
Instance: instance,
Node: backup.Node,
Filename: filename,
BackupTime: backup.Time,
Size: backup.Size,
})
}
sort.Slice(pmgBackups, func(i, j int) bool {
return pmgBackups[i].BackupTime.After(pmgBackups[j].BackupTime)
})
return pmgBackups
}
// generatePBSInstances generates mock PBS instances
func generatePBSInstances() []models.PBSInstance {
pbsInstances := []models.PBSInstance{
{
ID: "pbs-main",
Name: "pbs-main",
Host: "198.51.100.10:8007",
Status: "online",
Version: "3.2.1",
CPU: 15.5 + rand.Float64()*10,
Memory: 45.2 + rand.Float64()*20,
MemoryUsed: int64(8 * 1024 * 1024 * 1024), // 8GB
MemoryTotal: int64(16 * 1024 * 1024 * 1024), // 16GB
Uptime: int64(86400 * 30), // 30 days
Datastores: []models.PBSDatastore{
{
Name: "backup-store",
Total: int64(10 * 1024 * 1024 * 1024 * 1024), // 10TB
Used: int64(4 * 1024 * 1024 * 1024 * 1024), // 4TB
Free: int64(6 * 1024 * 1024 * 1024 * 1024), // 6TB
Usage: 40.0,
Status: "available",
},
{
Name: "offsite-backup",
Total: int64(20 * 1024 * 1024 * 1024 * 1024), // 20TB
Used: int64(12 * 1024 * 1024 * 1024 * 1024), // 12TB
Free: int64(8 * 1024 * 1024 * 1024 * 1024), // 8TB
Usage: 60.0,
Status: "available",
},
},
ConnectionHealth: "healthy",
LastSeen: time.Now(),
},
}
// Always seed a secondary PBS so the demo PBS page can render a multi-instance posture
// with one healthy and one degraded vault.
{
pbsInstances = append(pbsInstances, models.PBSInstance{
ID: "pbs-secondary",
Name: "pbs-secondary",
Host: "198.51.100.11:8007",
Status: "online",
Version: "3.2.0",
CPU: 10.2 + rand.Float64()*8,
Memory: 35.5 + rand.Float64()*15,
MemoryUsed: int64(4 * 1024 * 1024 * 1024), // 4GB
MemoryTotal: int64(8 * 1024 * 1024 * 1024), // 8GB
Uptime: int64(86400 * 15), // 15 days
Datastores: []models.PBSDatastore{
{
Name: "replica-store",
Total: int64(5 * 1024 * 1024 * 1024 * 1024), // 5TB
Used: int64(2 * 1024 * 1024 * 1024 * 1024), // 2TB
Free: int64(3 * 1024 * 1024 * 1024 * 1024), // 3TB
Usage: 40.0,
Status: "available",
},
},
ConnectionHealth: "healthy",
LastSeen: time.Now(),
})
}
return pbsInstances
}
// generatePBSBackups generates mock PBS backup data
func generatePBSBackups(vms []models.VM, containers []models.Container) []models.PBSBackup {
var backups []models.PBSBackup
pbsInstances := []string{"pbs-main"}
datastores := []string{"backup-store", "offsite-backup"}
owners := []string{"admin@pbs", "backup@pbs", "root@pam", "automation@pbs", "user1@pve", "service@pbs"}
// Add secondary PBS to list if it might exist
if rand.Float64() > 0.4 {
pbsInstances = append(pbsInstances, "pbs-secondary")
datastores = append(datastores, "replica-store")
}
// Generate PBS backups for ~50% of VMs
for _, vm := range vms {
if rand.Float64() > 0.5 {
continue
}
// Generate 5-12 PBS backups per VM spread across the past year
numBackups := 5 + rand.Intn(8)
for i := 0; i < numBackups; i++ {
backupTime := time.Now().Add(-time.Duration(rand.Intn(365*24)) * time.Hour)
backup := models.PBSBackup{
ID: fmt.Sprintf("pbs-backup-vm-%d-%d", vm.VMID, i),
Instance: pbsInstances[rand.Intn(len(pbsInstances))],
Datastore: datastores[rand.Intn(len(datastores))],
Namespace: "root",
BackupType: "vm",
VMID: fmt.Sprintf("%d", vm.VMID),
BackupTime: backupTime,
Size: vm.Disk.Total/8 + rand.Int63n(vm.Disk.Total/4),
Protected: rand.Float64() > 0.85, // 15% protected
Verified: rand.Float64() > 0.2, // 80% verified
Comment: fmt.Sprintf("Automated backup of %s", vm.Name),
Owner: owners[rand.Intn(len(owners))],
}
backups = append(backups, backup)
}
}
// Generate PBS backups for ~60% of containers
for _, ct := range containers {
if rand.Float64() > 0.4 {
continue
}
// Generate 4-10 PBS backups per container spread across the past year
numBackups := 4 + rand.Intn(7)
for i := 0; i < numBackups; i++ {
backupTime := time.Now().Add(-time.Duration(rand.Intn(365*24)) * time.Hour)
backup := models.PBSBackup{
ID: fmt.Sprintf("pbs-backup-ct-%d-%d", ct.VMID, i),
Instance: pbsInstances[rand.Intn(len(pbsInstances))],
Datastore: datastores[rand.Intn(len(datastores))],
Namespace: "root",
BackupType: "ct",
VMID: fmt.Sprintf("%d", ct.VMID),
BackupTime: backupTime,
Size: ct.Disk.Total/15 + rand.Int63n(ct.Disk.Total/8),
Protected: rand.Float64() > 0.9, // 10% protected
Verified: rand.Float64() > 0.15, // 85% verified
Comment: fmt.Sprintf("Daily backup of %s", ct.Name),
Owner: owners[rand.Intn(len(owners))],
}
backups = append(backups, backup)
}
}
// Generate host config backups (VMID 0) - PMG/PVE host configs
// These are common when backing up Proxmox Mail Gateway hosts
hostBackupCount := 5 + rand.Intn(6) // 5-10 host backups
for i := 0; i < hostBackupCount; i++ {
backupTime := time.Now().Add(-time.Duration(rand.Intn(365*24)) * time.Hour)
backup := models.PBSBackup{
ID: fmt.Sprintf("pbs-backup-host-0-%d", i),
Instance: pbsInstances[rand.Intn(len(pbsInstances))],
Datastore: datastores[rand.Intn(len(datastores))],
Namespace: "root",
BackupType: "ct", // Host configs are stored as 'ct' type in PBS
VMID: "0", // VMID 0 indicates host config
BackupTime: backupTime,
Size: 50*1024*1024 + rand.Int63n(100*1024*1024), // 50-150MB for host configs
Protected: rand.Float64() > 0.7, // 30% protected
Verified: rand.Float64() > 0.1, // 90% verified
Comment: "PMG host configuration backup",
Owner: "root@pam",
}
backups = append(backups, backup)
}
// Sort by time (newest first)
sort.Slice(backups, func(i, j int) bool {
return backups[i].BackupTime.After(backups[j].BackupTime)
})
return backups
}
func generatePMGInstances() []models.PMGInstance {
now := time.Now()
mailStats := models.PMGMailStats{
Timeframe: "day",
CountTotal: 2800 + rand.Float64()*400,
CountIn: 1800 + rand.Float64()*200,
CountOut: 900 + rand.Float64()*100,
SpamIn: 320 + rand.Float64()*40,
SpamOut: 45 + rand.Float64()*10,
VirusIn: 12 + rand.Float64()*3,
VirusOut: 3 + rand.Float64()*2,
BouncesIn: 18 + rand.Float64()*5,
BouncesOut: 6 + rand.Float64()*3,
BytesIn: 9.6e9 + rand.Float64()*1.5e9,
BytesOut: 3.2e9 + rand.Float64()*0.8e9,
GreylistCount: 210 + rand.Float64()*40,
JunkIn: 120 + rand.Float64()*20,
AverageProcessTimeMs: 480 + rand.Float64()*120,
RBLRejects: 140 + rand.Float64()*30,
PregreetRejects: 60 + rand.Float64()*15,
UpdatedAt: now,
}
mailPoints := make([]models.PMGMailCountPoint, 0, 24)
for i := 0; i < 24; i++ {
pointTime := now.Add(-time.Duration(23-i) * time.Hour)
baseCount := 80 + rand.Float64()*25
mailPoints = append(mailPoints, models.PMGMailCountPoint{
Timestamp: pointTime,
Count: baseCount + rand.Float64()*20,
CountIn: baseCount*0.65 + rand.Float64()*10,
CountOut: baseCount*0.35 + rand.Float64()*5,
SpamIn: baseCount*0.12 + rand.Float64()*4,
SpamOut: baseCount*0.02 + rand.Float64()*1,
VirusIn: rand.Float64() * 2,
VirusOut: rand.Float64(),
RBLRejects: rand.Float64() * 5,
Pregreet: rand.Float64() * 3,
BouncesIn: rand.Float64() * 4,
BouncesOut: rand.Float64() * 2,
Greylist: rand.Float64() * 6,
Index: i,
Timeframe: "hour",
WindowStart: pointTime,
WindowEnd: pointTime.Add(time.Hour),
})
}
spamBuckets := []models.PMGSpamBucket{
{Score: "0-2", Count: 950 + rand.Float64()*40},
{Score: "3-5", Count: 420 + rand.Float64()*25},
{Score: "6-8", Count: 180 + rand.Float64()*15},
{Score: "9-10", Count: 70 + rand.Float64()*10},
}
quarantine := models.PMGQuarantineTotals{
Spam: 25 + rand.Intn(30),
Virus: 2 + rand.Intn(6),
Attachment: 4 + rand.Intn(6),
Blacklisted: rand.Intn(8),
}
nodes := []models.PMGNodeStatus{
{
Name: "pmg-primary",
Status: "online",
Role: "master",
Uptime: int64(86400 * 18),
LoadAvg: fmt.Sprintf("%.2f", 0.75+rand.Float64()*0.25),
QueueStatus: &models.PMGQueueStatus{
Active: rand.Intn(5),
Deferred: rand.Intn(15),
Hold: rand.Intn(3),
Incoming: rand.Intn(8),
Total: 0, // Will be calculated below
OldestAge: int64(rand.Intn(3600)),
UpdatedAt: now,
},
},
}
// Calculate total for primary node
nodes[0].QueueStatus.Total = nodes[0].QueueStatus.Active + nodes[0].QueueStatus.Deferred +
nodes[0].QueueStatus.Hold + nodes[0].QueueStatus.Incoming
if rand.Float64() > 0.5 {
queueStatus := &models.PMGQueueStatus{
Active: rand.Intn(3),
Deferred: rand.Intn(10),
Hold: rand.Intn(2),
Incoming: rand.Intn(5),
Total: 0,
OldestAge: int64(rand.Intn(1800)),
UpdatedAt: now,
}
queueStatus.Total = queueStatus.Active + queueStatus.Deferred + queueStatus.Hold + queueStatus.Incoming
nodes = append(nodes, models.PMGNodeStatus{
Name: "pmg-secondary",
Status: "online",
Role: "node",
Uptime: int64(86400 * 9),
LoadAvg: fmt.Sprintf("%.2f", 0.55+rand.Float64()*0.2),
QueueStatus: queueStatus,
})
}
primary := models.PMGInstance{
ID: "pmg-main",
Name: "pmg-main",
Host: "https://pmg.mock.lan:8006",
Status: "online",
Version: "8.1-2",
Nodes: nodes,
MailStats: &mailStats,
MailCount: mailPoints,
SpamDistribution: spamBuckets,
Quarantine: &quarantine,
ConnectionHealth: "healthy",
LastSeen: now,
LastUpdated: now,
}
instances := []models.PMGInstance{primary}
// Always seed a secondary PMG so the demo PMG page can render a multi-instance posture
// with one healthy and one degraded gateway.
{
backupNow := now.Add(-6 * time.Hour)
secondaryStats := mailStats
secondaryStats.CountTotal *= 0.6
secondaryStats.CountIn *= 0.6
secondaryStats.CountOut *= 0.6
secondaryStats.UpdatedAt = backupNow
edgeQueue := &models.PMGQueueStatus{
Active: rand.Intn(2),
Deferred: rand.Intn(5),
Hold: rand.Intn(2), // rand.Intn(1) always returns 0
Incoming: rand.Intn(3),
Total: 0,
OldestAge: int64(rand.Intn(600)),
UpdatedAt: backupNow,
}
edgeQueue.Total = edgeQueue.Active + edgeQueue.Deferred + edgeQueue.Hold + edgeQueue.Incoming
instances = append(instances, models.PMGInstance{
ID: "pmg-edge",
Name: "pmg-edge",
Host: "https://pmg-edge.mock.lan:8006",
Status: "online",
Version: "8.0-7",
Nodes: []models.PMGNodeStatus{{
Name: "pmg-edge",
Status: "online",
Role: "standalone",
Uptime: int64(86400 * 4),
QueueStatus: edgeQueue,
}},
MailStats: &secondaryStats,
MailCount: mailPoints[:12],
SpamDistribution: spamBuckets,
Quarantine: &models.PMGQuarantineTotals{Spam: 8 + rand.Intn(5), Virus: rand.Intn(3)},
ConnectionHealth: "healthy",
LastSeen: backupNow,
LastUpdated: backupNow,
})
}
return instances
}
// generateBackupTasks synthesizes a rolling backup-job task history
// for the last 7 days. Real PVE backup-task history is bounded by the
// scheduler's retention, so we generate 2-6 recent runs per backed-up
// guest, weighted toward successful completions with the occasional
// failure or in-progress run to keep the table interesting.
func generateBackupTasks(vms []models.VM, containers []models.Container) []models.BackupTask {
tasks := make([]models.BackupTask, 0, len(vms)+len(containers))
addRun := func(node, instance, guestType string, vmid int, sizeBytes int64) {
// Skip ~30% of guests so not every guest appears in recent task
// history — matches what a partial backup schedule looks like.
if rand.Float64() < 0.3 {
return
}
runs := 2 + rand.Intn(5) // 2-6 recent runs
for i := 0; i < runs; i++ {
// Spread runs across the past 7 days.
startOffset := time.Duration(rand.Intn(7*24*60)) * time.Minute
start := time.Now().Add(-startOffset)
// Larger guests take longer; floor at 30s, cap at 4h.
durationSec := int64(30 + (sizeBytes / (50 * 1024 * 1024)))
if durationSec > 4*3600 {
durationSec = 4 * 3600
}
durationSec += int64(rand.Intn(60))
end := start.Add(time.Duration(durationSec) * time.Second)
status := "OK"
errMsg := ""
roll := rand.Float64()
switch {
case roll < 0.05:
status = "failed"
errMsg = pickBackupError()
case roll < 0.08 && i == 0:
status = "running"
end = time.Time{}
}
task := models.BackupTask{
ID: fmt.Sprintf("backup-task-%s-%s-%d-%d", node, guestType, vmid, i),
Node: node,
Instance: instance,
Type: guestType,
VMID: vmid,
Status: status,
StartTime: start,
EndTime: end,
Size: sizeBytes - rand.Int63n(sizeBytes/8),
Error: errMsg,
}
if status == "running" {
task.Size = 0
}
tasks = append(tasks, task)
}
}
for _, vm := range vms {
size := vm.Disk.Total/10 + rand.Int63n(vm.Disk.Total/5)
if size <= 0 {
size = 1 << 30
}
addRun(vm.Node, vm.Instance, "qemu", vm.VMID, size)
}
for _, ct := range containers {
size := ct.Disk.Total/20 + rand.Int63n(ct.Disk.Total/10)
if size <= 0 {
size = 256 << 20
}
addRun(ct.Node, ct.Instance, "lxc", ct.VMID, size)
}
return tasks
}
func pickBackupError() string {
errs := []string{
"backup of VM failed - VM is locked (backup)",
"unable to open file '/var/lib/vz/dump/.tmp.vma' - No space left on device",
"job errors - check task log",
"backup write failed: connection timed out",
"snapshot failed - storage 'local-zfs' does not support snapshots",
}
return errs[rand.Intn(len(errs))]
}
// generateSnapshots generates mock snapshot data for VMs and containers
func generateSnapshots(vms []models.VM, containers []models.Container) []models.GuestSnapshot {
var snapshots []models.GuestSnapshot
snapshotNames := []string{"before-upgrade", "production", "testing", "stable", "rollback-point", "pre-maintenance", "weekly-snapshot"}
// Generate snapshots for ~40% of VMs
for _, vm := range vms {
if rand.Float64() > 0.6 {
continue
}
// Generate 3-8 snapshots per VM spread across the past year
numSnapshots := 3 + rand.Intn(6)
for i := 0; i < numSnapshots; i++ {
snapshotTime := time.Now().Add(-time.Duration(rand.Intn(365*24)) * time.Hour)
snapshot := models.GuestSnapshot{
ID: fmt.Sprintf("snapshot-%s-vm-%d-%d", vm.Node, vm.VMID, i),
Name: snapshotNames[rand.Intn(len(snapshotNames))],
Node: vm.Node,
Instance: vm.Instance,
Type: "qemu",
VMID: vm.VMID,
Time: snapshotTime,
Description: fmt.Sprintf("Snapshot of %s taken on %s", vm.Name, snapshotTime.Format("2006-01-02")),
VMState: rand.Float64() > 0.5, // 50% include VM state
SizeBytes: int64(10+rand.Intn(90)) << 30, // 10-99 GiB
}
// Add parent relationship for some snapshots
if i > 0 && rand.Float64() > 0.5 {
snapshot.Parent = fmt.Sprintf("snapshot-%s-vm-%d-%d", vm.Node, vm.VMID, i-1)
}
snapshots = append(snapshots, snapshot)
}
}
// Generate snapshots for ~30% of containers
for _, ct := range containers {
if rand.Float64() > 0.7 {
continue
}
// Generate 2-6 snapshots per container spread across the past year
numSnapshots := 2 + rand.Intn(5)
for i := 0; i < numSnapshots; i++ {
snapshotTime := time.Now().Add(-time.Duration(rand.Intn(365*24)) * time.Hour)
snapshot := models.GuestSnapshot{
ID: fmt.Sprintf("snapshot-%s-ct-%d-%d", ct.Node, ct.VMID, i),
Name: snapshotNames[rand.Intn(len(snapshotNames))],
Node: ct.Node,
Instance: ct.Instance,
Type: "lxc",
VMID: ct.VMID,
Time: snapshotTime,
Description: fmt.Sprintf("Container snapshot for %s", ct.Name),
VMState: false, // Containers don't have VM state
SizeBytes: int64(5+rand.Intn(40)) << 30, // 5-44 GiB
}
snapshots = append(snapshots, snapshot)
}
}
// Sort by time (newest first)
sort.Slice(snapshots, func(i, j int) bool {
return snapshots[i].Time.After(snapshots[j].Time)
})
return snapshots
}
// UpdateMetrics simulates changing metrics over time
func updateFixtureStateMetrics(data *models.StateSnapshot, config MockConfig) {
updateFixtureStateMetricsAt(data, config, time.Now())
}
func updateFixtureStateMetricsAt(data *models.StateSnapshot, config MockConfig, refreshNow time.Time) {
updateFixtureStateMetricsSelectedAt(data, config, refreshNow, fixtureMetricSelection{
includeSupplemental: true,
uptimeStep: currentMockUpdateStepInt64(),
})
}
type fixtureMetricSelection struct {
// A nil map means every Proxmox node. A non-nil map limits node-owned
// metrics, sightings, storage, and physical disks to the named cohort.
proxmoxNodeNames map[string]struct{}
includeSupplemental bool
uptimeStep int64
// supplementalUptimeStep covers supplemental (non-PVE) uptime counters,
// whose refresh cadence can differ from the node cohort's rotation. Zero
// falls back to uptimeStep.
supplementalUptimeStep int64
}
func (selection fixtureMetricSelection) includesProxmoxNode(nodeName string) bool {
if selection.proxmoxNodeNames == nil {
return true
}
_, ok := selection.proxmoxNodeNames[nodeName]
return ok
}
func updateFixtureStateMetricsSelectedAt(
data *models.StateSnapshot,
config MockConfig,
refreshNow time.Time,
selection fixtureMetricSelection,
) {
if refreshNow.IsZero() {
refreshNow = time.Now()
}
if selection.uptimeStep <= 0 {
selection.uptimeStep = currentMockUpdateStepInt64()
}
if selection.supplementalUptimeStep <= 0 {
selection.supplementalUptimeStep = selection.uptimeStep
}
if selection.includeSupplemental {
updateDockerHosts(data, config, refreshNow)
updateKubernetesClusters(data, config, refreshNow)
updateHosts(data, config, refreshNow)
syncMockKubernetesNodeHosts(data)
}
step := selection.uptimeStep
if selection.includeSupplemental {
for i := range data.PBSInstances {
inst := &data.PBSInstances[i]
inst.Status = "online"
inst.ConnectionHealth = "healthy"
inst.LastSeen = refreshNow.Add(-time.Duration(randIntnSafe(12)) * time.Second)
inst.Uptime += selection.supplementalUptimeStep
if data.ConnectionHealth != nil {
data.ConnectionHealth[fmt.Sprintf("pbs-%s", inst.Name)] = true
}
if !config.RandomMetrics {
continue
}
inst.CPU = SampleMetric("pbs", inst.ID, "cpu", refreshNow)
inst.Memory = SampleMetric("pbs", inst.ID, "memory", refreshNow)
if inst.MemoryTotal > 0 {
inst.MemoryUsed = int64(float64(inst.MemoryTotal) * (inst.Memory / 100.0))
if inst.MemoryUsed < 0 {
inst.MemoryUsed = 0
}
}
for j := range inst.Datastores {
datastore := &inst.Datastores[j]
if datastore.Total <= 0 {
continue
}
datastore.Usage = SampleMetric("pbsDatastore", inst.ID+":"+datastore.Name, "usage", refreshNow)
datastore.Used = int64(float64(datastore.Total) * (datastore.Usage / 100.0))
if datastore.Used < 0 {
datastore.Used = 0
}
if datastore.Used > datastore.Total {
datastore.Used = datastore.Total
}
datastore.Free = datastore.Total - datastore.Used
datastore.Status = "available"
}
}
for i := range data.PMGInstances {
inst := &data.PMGInstances[i]
inst.LastSeen = refreshNow
inst.LastUpdated = refreshNow
}
}
// Sighting stamps simulate each poll delivering the PVE inventory; they
// are deliberately not gated on RandomMetrics so fixtures stay fresh in
// static-metrics mode too. Guests and storage on an offline node keep
// their old stamp (an offline node's poll delivers nothing); a zero
// stamp is backdated so the UI shows a stale sighting, not "never".
staleSighting := refreshNow.Add(-10 * time.Minute)
offlineNodes := make(map[string]struct{})
for i := range data.Nodes {
node := &data.Nodes[i]
if node.Status != "online" || node.ConnectionHealth == "offline" || node.Uptime <= 0 {
offlineNodes[node.Name] = struct{}{}
if node.LastSeen.IsZero() {
node.LastSeen = staleSighting
}
continue
}
if !selection.includesProxmoxNode(node.Name) {
continue
}
node.LastSeen = refreshNow
}
stampGuestSighting := func(nodeName string, lastSeen *time.Time) {
if !selection.includesProxmoxNode(nodeName) {
return
}
if _, offline := offlineNodes[nodeName]; offline {
if lastSeen.IsZero() {
*lastSeen = staleSighting
}
return
}
*lastSeen = refreshNow
}
// A current memory reading is observed on the same poll that sights the
// guest, so its time moves with the sighting, in static-metrics mode too.
stampGuestMemoryObservation := func(nodeName, status string, memory *models.Memory) {
if !selection.includesProxmoxNode(nodeName) || status != "running" {
return
}
if _, offline := offlineNodes[nodeName]; offline {
return
}
refreshMockMemoryObservation(memory, refreshNow)
}
for i := range data.VMs {
stampGuestSighting(data.VMs[i].Node, &data.VMs[i].LastSeen)
stampGuestMemoryObservation(data.VMs[i].Node, data.VMs[i].Status, &data.VMs[i].Memory)
}
for i := range data.Containers {
stampGuestSighting(data.Containers[i].Node, &data.Containers[i].LastSeen)
stampGuestMemoryObservation(data.Containers[i].Node, data.Containers[i].Status, &data.Containers[i].Memory)
}
for i := range data.Storage {
stampGuestSighting(data.Storage[i].Node, &data.Storage[i].LastSeen)
}
if !config.RandomMetrics {
data.LastUpdate = refreshNow
return
}
// Update node metrics
for i := range data.Nodes {
node := &data.Nodes[i]
if !selection.includesProxmoxNode(node.Name) ||
node.Status != "online" || node.ConnectionHealth == "offline" || node.Uptime <= 0 {
continue
}
node.CPU = SampleMetric("node", node.ID, "cpu", refreshNow) / 100.0
applyMemoryUsage(&node.Memory, SampleMetric("node", node.ID, "memory", refreshNow))
applyDiskUsage(&node.Disk, SampleMetric("node", node.ID, "disk", refreshNow))
refreshMockNodeCPUTemperature(node, refreshNow)
}
// Update VM metrics
for i := range data.VMs {
vm := &data.VMs[i]
if !selection.includesProxmoxNode(vm.Node) || vm.Status != "running" {
continue
}
vm.CPU = SampleMetric("vm", vm.ID, "cpu", refreshNow) / 100.0
applyMemoryUsage(&vm.Memory, SampleMetric("vm", vm.ID, "memory", refreshNow))
applyDiskUsage(&vm.Disk, SampleMetric("vm", vm.ID, "disk", refreshNow))
vm.NetworkIn = SampleMetricInt("vm", vm.ID, "netin", refreshNow)
vm.NetworkOut = SampleMetricInt("vm", vm.ID, "netout", refreshNow)
vm.DiskRead = SampleMetricInt("vm", vm.ID, "diskread", refreshNow)
vm.DiskWrite = SampleMetricInt("vm", vm.ID, "diskwrite", refreshNow)
// Update uptime
vm.Uptime += step
}
// Update container metrics
for i := range data.Containers {
ct := &data.Containers[i]
if !selection.includesProxmoxNode(ct.Node) || ct.Status != "running" {
continue
}
ct.CPU = SampleMetric("container", ct.ID, "cpu", refreshNow) / 100.0
applyMemoryUsage(&ct.Memory, SampleMetric("container", ct.ID, "memory", refreshNow))
applyDiskUsage(&ct.Disk, SampleMetric("container", ct.ID, "disk", refreshNow))
ct.NetworkIn = SampleMetricInt("container", ct.ID, "netin", refreshNow)
ct.NetworkOut = SampleMetricInt("container", ct.ID, "netout", refreshNow)
ct.DiskRead = SampleMetricInt("container", ct.ID, "diskread", refreshNow)
ct.DiskWrite = SampleMetricInt("container", ct.ID, "diskwrite", refreshNow)
// Update uptime
ct.Uptime += step
}
for i := range data.Storage {
storage := &data.Storage[i]
if !selection.includesProxmoxNode(storage.Node) || storage.ID == "" || storage.Total <= 0 || storage.Status != "available" {
continue
}
ApplyStorageUsage(storage, SampleMetric("storage", storage.ID, "usage", refreshNow))
}
// Update disk metrics.
for i := range data.PhysicalDisks {
disk := &data.PhysicalDisks[i]
if !selection.includesProxmoxNode(disk.Node) {
continue
}
resourceID := disk.Serial
if strings.TrimSpace(resourceID) == "" {
resourceID = disk.ID
}
if strings.TrimSpace(resourceID) == "" {
resourceID = fmt.Sprintf("%s-%s-%s", disk.Instance, disk.Node, disk.DevPath)
}
disk.Temperature = int(math.Round(SampleMetric("disk", resourceID, "smart_temp", refreshNow)))
// Occasionally degrade SSD life
if disk.Wearout > 0 && rand.Float64() < mockEventsPerDayChance(mockDiskWearoutStepsPerDay) {
disk.Wearout = disk.Wearout - 1
if disk.Wearout < 0 {
disk.Wearout = 0
}
}
disk.LastChecked = refreshNow
}
// Update PMG metrics with gentle fluctuations
if selection.includeSupplemental {
for i := range data.PMGInstances {
inst := &data.PMGInstances[i]
inst.LastSeen = refreshNow
inst.LastUpdated = refreshNow
if inst.MailStats != nil {
inst.MailStats.CountTotal = fluctuateFloat(inst.MailStats.CountTotal, 0.05, 0, math.MaxFloat64)
inst.MailStats.CountIn = fluctuateFloat(inst.MailStats.CountIn, 0.05, 0, math.MaxFloat64)
inst.MailStats.CountOut = fluctuateFloat(inst.MailStats.CountOut, 0.05, 0, math.MaxFloat64)
inst.MailStats.SpamIn = fluctuateFloat(inst.MailStats.SpamIn, 0.08, 0, math.MaxFloat64)
inst.MailStats.SpamOut = fluctuateFloat(inst.MailStats.SpamOut, 0.08, 0, math.MaxFloat64)
inst.MailStats.VirusIn = fluctuateFloat(inst.MailStats.VirusIn, 0.1, 0, math.MaxFloat64)
inst.MailStats.VirusOut = fluctuateFloat(inst.MailStats.VirusOut, 0.1, 0, math.MaxFloat64)
inst.MailStats.RBLRejects = fluctuateFloat(inst.MailStats.RBLRejects, 0.07, 0, math.MaxFloat64)
inst.MailStats.PregreetRejects = fluctuateFloat(inst.MailStats.PregreetRejects, 0.07, 0, math.MaxFloat64)
inst.MailStats.GreylistCount = fluctuateFloat(inst.MailStats.GreylistCount, 0.05, 0, math.MaxFloat64)
inst.MailStats.AverageProcessTimeMs = fluctuateFloat(inst.MailStats.AverageProcessTimeMs, 0.05, 200, 2000)
inst.MailStats.UpdatedAt = refreshNow
}
if len(inst.MailCount) > 0 {
// Drop oldest point if we already have 24
if len(inst.MailCount) >= 24 {
inst.MailCount = inst.MailCount[1:]
}
base := 60 + rand.Float64()*30
newPoint := models.PMGMailCountPoint{
Timestamp: refreshNow,
Count: base + rand.Float64()*15,
CountIn: base*0.6 + rand.Float64()*10,
CountOut: base*0.4 + rand.Float64()*8,
SpamIn: base*0.1 + rand.Float64()*5,
SpamOut: base*0.02 + rand.Float64()*1,
VirusIn: rand.Float64() * 2,
VirusOut: rand.Float64(),
RBLRejects: rand.Float64() * 4,
Pregreet: rand.Float64() * 3,
BouncesIn: rand.Float64() * 3,
BouncesOut: rand.Float64() * 2,
Greylist: rand.Float64() * 5,
Index: len(inst.MailCount),
Timeframe: "hour",
}
inst.MailCount = append(inst.MailCount, newPoint)
}
if len(inst.SpamDistribution) > 0 {
for j := range inst.SpamDistribution {
inst.SpamDistribution[j].Count = fluctuateFloat(inst.SpamDistribution[j].Count, 0.05, 0, math.MaxFloat64)
}
}
if inst.Quarantine != nil {
inst.Quarantine.Spam = fluctuateInt(inst.Quarantine.Spam, 5, 0, 500)
inst.Quarantine.Virus = fluctuateInt(inst.Quarantine.Virus, 2, 0, 200)
inst.Quarantine.Attachment = fluctuateInt(inst.Quarantine.Attachment, 2, 0, 200)
inst.Quarantine.Blacklisted = fluctuateInt(inst.Quarantine.Blacklisted, 1, 0, 100)
}
if len(inst.Nodes) > 0 {
for j := range inst.Nodes {
if inst.Nodes[j].Status == "online" {
inst.Nodes[j].Uptime += currentMockUpdateStepInt64()
}
}
}
}
}
data.LastUpdate = refreshNow
}
func updateKubernetesClusters(data *models.StateSnapshot, config MockConfig, now time.Time) {
if len(data.KubernetesClusters) == 0 {
return
}
for i := range data.KubernetesClusters {
cluster := &data.KubernetesClusters[i]
if cluster.Status != "offline" {
cluster.LastSeen = now.Add(-time.Duration(rand.Intn(12)) * time.Second)
} else if config.RandomMetrics && rand.Float64() < mockEventsPerDayChance(mockK8sClusterRecoveriesPerDay) {
cluster.Status = "online"
cluster.LastSeen = now
for nodeIdx := range cluster.Nodes {
cluster.Nodes[nodeIdx].Ready = rand.Float64() > 0.12
}
}
if config.RandomMetrics {
// Small chance to flip a node Ready state.
if len(cluster.Nodes) > 0 && rand.Float64() < mockEventsPerDayChance(mockK8sNodeReadyFlipsPerDay) {
idx := rand.Intn(len(cluster.Nodes))
cluster.Nodes[idx].Ready = !cluster.Nodes[idx].Ready
}
// Small chance to flip a pod into/out of CrashLoopBackOff.
if len(cluster.Pods) > 0 && rand.Float64() < mockEventsPerDayChance(mockK8sPodCrashFlipsPerDay) {
idx := rand.Intn(len(cluster.Pods))
pod := &cluster.Pods[idx]
if kubernetesPodHealthy(*pod) {
pod.Phase = "Running"
pod.Reason = ""
pod.Message = ""
pod.Restarts += 1 + rand.Intn(3)
for j := range pod.Containers {
pod.Containers[j].Ready = false
pod.Containers[j].State = "waiting"
pod.Containers[j].Reason = "CrashLoopBackOff"
pod.Containers[j].Message = "Back-off restarting failed container"
pod.Containers[j].RestartCount += int32(1 + rand.Intn(3))
}
} else {
pod.Phase = "Running"
pod.Reason = ""
pod.Message = ""
for j := range pod.Containers {
pod.Containers[j].Ready = true
pod.Containers[j].State = "running"
pod.Containers[j].Reason = ""
pod.Containers[j].Message = ""
}
}
}
}
if cluster.Status != "offline" {
if clusterHasIssues(cluster.Nodes, cluster.Pods, cluster.Deployments) {
cluster.Status = "degraded"
} else {
cluster.Status = "online"
}
}
initializeMockKubernetesClusterUsage(cluster, now, config.RandomMetrics)
if data.ConnectionHealth != nil {
data.ConnectionHealth[kubernetesConnectionPrefix+cluster.ID] = cluster.Status != "offline"
}
}
}
func updateDockerHosts(data *models.StateSnapshot, config MockConfig, now time.Time) {
if len(data.DockerHosts) == 0 {
return
}
step := currentMockUpdateStepInt64()
for i := range data.DockerHosts {
host := &data.DockerHosts[i]
if host.Status != "offline" {
host.LastSeen = now.Add(-time.Duration(rand.Intn(6)) * time.Second)
host.UptimeSeconds += step
if config.RandomMetrics {
host.CPUUsage = SampleMetric("dockerHost", host.ID, "cpu", now)
applyMemoryUsage(&host.Memory, SampleMetric("dockerHost", host.ID, "memory", now))
for diskIndex := range host.Disks {
applyDiskUsage(&host.Disks[diskIndex], SampleMetric("dockerHost", mockHostDiskMetricID(host.ID, diskIndex), "disk", now))
}
host.NetInRate = SampleMetric("dockerHost", host.ID, "netin", now)
host.NetOutRate = SampleMetric("dockerHost", host.ID, "netout", now)
host.DiskReadRate = SampleMetric("dockerHost", host.ID, "diskread", now)
host.DiskWriteRate = SampleMetric("dockerHost", host.ID, "diskwrite", now)
}
temperature := SampleMetric("dockerHost", host.ID, "temperature", now)
host.Temperature = &temperature
} else if config.RandomMetrics && rand.Float64() < mockEventsPerDayChance(mockDockerHostRecoveriesPerDay) {
// Occasionally bring an offline host back online
host.Status = "online"
host.LastSeen = now
host.CPUUsage = SampleMetric("dockerHost", host.ID, "cpu", now)
applyMemoryUsage(&host.Memory, SampleMetric("dockerHost", host.ID, "memory", now))
for diskIndex := range host.Disks {
applyDiskUsage(&host.Disks[diskIndex], SampleMetric("dockerHost", mockHostDiskMetricID(host.ID, diskIndex), "disk", now))
}
host.NetInRate = SampleMetric("dockerHost", host.ID, "netin", now)
host.NetOutRate = SampleMetric("dockerHost", host.ID, "netout", now)
host.DiskReadRate = SampleMetric("dockerHost", host.ID, "diskread", now)
host.DiskWriteRate = SampleMetric("dockerHost", host.ID, "diskwrite", now)
temperature := SampleMetric("dockerHost", host.ID, "temperature", now)
host.Temperature = &temperature
if len(host.Containers) == 0 {
isPodman := strings.EqualFold(host.Runtime, "podman")
host.Containers = generateDockerContainers(host.Hostname, i, config, isPodman)
}
} else {
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
host.Temperature = nil
}
running := 0
flagged := 0
for j := range host.Containers {
container := &host.Containers[j]
state := strings.ToLower(container.State)
health := strings.ToLower(container.Health)
if state != "running" {
if health == "unhealthy" || health == "starting" {
flagged++
}
if config.RandomMetrics && (state == "exited" || state == "paused") && rand.Float64() < mockEventsPerDayChance(mockDockerStoppedStartsPerDay) {
container.State = "running"
container.Status = "Up a few seconds"
container.Health = "starting"
container.CPUPercent = SampleMetric("dockerContainer", container.ID, "cpu", now)
container.MemoryPercent = SampleMetric("dockerContainer", container.ID, "memory", now)
if container.MemoryLimit > 0 {
container.MemoryUsage = int64(float64(container.MemoryLimit) * (container.MemoryPercent / 100.0))
}
if container.RootFilesystemBytes > 0 {
diskPercent := SampleMetric("dockerContainer", container.ID, "disk", now)
container.WritableLayerBytes = int64(float64(container.RootFilesystemBytes) * (diskPercent / 100.0))
}
container.UptimeSeconds = step
start := now.Add(-time.Duration(container.UptimeSeconds) * time.Second)
container.StartedAt = &start
container.FinishedAt = nil
state = "running"
health = "starting"
} else {
continue
}
}
if state == "running" {
running++
if config.RandomMetrics {
container.CPUPercent = SampleMetric("dockerContainer", container.ID, "cpu", now)
container.MemoryPercent = SampleMetric("dockerContainer", container.ID, "memory", now)
if container.MemoryLimit > 0 {
container.MemoryUsage = int64(float64(container.MemoryLimit) * (container.MemoryPercent / 100.0))
}
if container.RootFilesystemBytes > 0 {
diskPercent := SampleMetric("dockerContainer", container.ID, "disk", now)
container.WritableLayerBytes = int64(float64(container.RootFilesystemBytes) * (diskPercent / 100.0))
if container.WritableLayerBytes > container.RootFilesystemBytes {
container.WritableLayerBytes = container.RootFilesystemBytes
}
}
container.UptimeSeconds += step
switch health {
case "unhealthy":
if rand.Float64() < mockRecoveryChance(mockDockerUnhealthyMeanRecovery) {
container.Health = "healthy"
health = "healthy"
}
case "starting":
if rand.Float64() < mockRecoveryChance(mockDockerStartingMeanRecovery) {
container.Health = "healthy"
health = "healthy"
}
default:
if rand.Float64() < mockEventsPerDayChance(mockDockerUnhealthyPerDay) {
container.Health = "unhealthy"
health = "unhealthy"
} else if rand.Float64() < mockEventsPerDayChance(mockDockerStartingPerDay) {
container.Health = "starting"
health = "starting"
}
}
if rand.Float64() < mockEventsPerDayChance(mockDockerRestartsPerDay) {
container.RestartCount++
}
}
if health == "unhealthy" || health == "starting" {
flagged++
}
}
}
if data.ConnectionHealth != nil {
data.ConnectionHealth[dockerConnectionPrefix+host.ID] = host.Status != "offline"
}
if len(host.Services) > 0 || len(host.Tasks) > 0 {
recalculateDockerServiceHealth(host, now)
}
ensureDockerSwarmInfo(host)
if host.Status == "offline" {
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
host.Temperature = nil
continue
}
total := len(host.Containers)
if total == 0 {
host.Status = "offline"
host.Temperature = nil
if data.ConnectionHealth != nil {
data.ConnectionHealth[dockerConnectionPrefix+host.ID] = false
}
continue
}
if running == 0 {
host.Status = "offline"
host.LastSeen = now.Add(-90 * time.Second)
host.Temperature = nil
if data.ConnectionHealth != nil {
data.ConnectionHealth[dockerConnectionPrefix+host.ID] = false
}
continue
}
stopped := total - running
if flagged > 0 || float64(stopped)/float64(total) > 0.35 {
host.Status = "degraded"
} else {
host.Status = "online"
}
temperature := SampleMetric("dockerHost", host.ID, "temperature", now)
host.Temperature = &temperature
}
}
func serviceKey(id, name string) string {
if id != "" {
return id
}
return name
}
func recalculateDockerServiceHealth(host *models.DockerHost, now time.Time) {
if host == nil {
return
}
containerByID := make(map[string]models.DockerContainer, len(host.Containers))
for _, container := range host.Containers {
containerByID[container.ID] = container
if len(container.ID) >= 12 {
containerByID[container.ID[:12]] = container
}
}
tasksByService := make(map[string][]int, len(host.Services))
for idx := range host.Tasks {
task := &host.Tasks[idx]
key := serviceKey(task.ServiceID, task.ServiceName)
tasksByService[key] = append(tasksByService[key], idx)
container, ok := containerByID[task.ContainerID]
if !ok {
if host.Status == "offline" {
task.CurrentState = "shutdown"
if task.CompletedAt == nil {
completed := now.Add(-time.Minute)
task.CompletedAt = &completed
}
}
continue
}
state := strings.ToLower(container.State)
switch state {
case "running":
task.CurrentState = "running"
if container.StartedAt != nil {
started := *container.StartedAt
task.StartedAt = &started
}
task.CompletedAt = nil
case "paused":
task.CurrentState = "paused"
if container.StartedAt != nil {
started := *container.StartedAt
task.StartedAt = &started
}
default:
task.CurrentState = state
if container.FinishedAt != nil {
finished := *container.FinishedAt
task.CompletedAt = &finished
} else if host.Status == "offline" {
if task.CompletedAt == nil {
finished := now.Add(-2 * time.Minute)
task.CompletedAt = &finished
}
}
}
}
for idx := range host.Services {
service := &host.Services[idx]
key := serviceKey(service.ID, service.Name)
taskIdxs := tasksByService[key]
if service.DesiredTasks <= 0 {
service.DesiredTasks = len(taskIdxs)
}
running := 0
completed := 0
for _, taskIndex := range taskIdxs {
task := host.Tasks[taskIndex]
state := strings.ToLower(task.CurrentState)
if state == "running" {
running++
}
if task.CompletedAt != nil || state == "shutdown" || state == "failed" {
completed++
}
}
service.RunningTasks = running
service.CompletedTasks = completed
if service.DesiredTasks > 0 && running < service.DesiredTasks {
if service.UpdateStatus == nil {
service.UpdateStatus = &models.DockerServiceUpdate{}
}
service.UpdateStatus.State = "rollback_started"
service.UpdateStatus.Message = "Service replicas below desired threshold"
service.UpdateStatus.CompletedAt = nil
} else if service.UpdateStatus != nil && running >= service.DesiredTasks {
service.UpdateStatus = nil
}
}
}
func ensureDockerSwarmInfo(host *models.DockerHost) {
if host == nil {
return
}
if host.Swarm == nil {
host.Swarm = &models.DockerSwarmInfo{
NodeID: fmt.Sprintf("%s-node", host.ID),
NodeRole: "worker",
LocalState: "active",
ControlAvailable: false,
Scope: "node",
}
}
if host.Swarm.NodeID == "" {
host.Swarm.NodeID = fmt.Sprintf("%s-node", host.ID)
}
if host.Swarm.NodeRole == "" {
host.Swarm.NodeRole = "worker"
}
if host.Status == "offline" {
host.Swarm.LocalState = "inactive"
} else {
host.Swarm.LocalState = "active"
}
if host.Swarm.NodeRole == "manager" {
if host.Swarm.ControlAvailable && len(host.Services) > 0 {
host.Swarm.Scope = "cluster"
} else {
host.Swarm.Scope = "node"
}
} else {
host.Swarm.Scope = "node"
host.Swarm.ControlAvailable = false
}
}
func updateHosts(data *models.StateSnapshot, config MockConfig, now time.Time) {
if len(data.Hosts) == 0 {
return
}
step := currentMockUpdateStepInt64()
for i := range data.Hosts {
host := &data.Hosts[i]
if data.ConnectionHealth != nil {
data.ConnectionHealth[hostConnectionPrefix+host.ID] = host.Status != "offline"
}
if host.Status == "offline" {
host.NetInRate = 0
host.NetOutRate = 0
host.DiskReadRate = 0
host.DiskWriteRate = 0
if config.RandomMetrics && rand.Float64() < mockEventsPerDayChance(mockHostRecoveriesPerDay) {
host.Status = "online"
host.LastSeen = now
host.UptimeSeconds = int64(120 + rand.Intn(3600))
host.NetInRate = SampleMetric("agent", host.ID, "netin", now)
host.NetOutRate = SampleMetric("agent", host.ID, "netout", now)
host.DiskReadRate = SampleMetric("agent", host.ID, "diskread", now)
host.DiskWriteRate = SampleMetric("agent", host.ID, "diskwrite", now)
}
continue
}
host.LastSeen = now.Add(-time.Duration(rand.Intn(25)) * time.Second)
host.UptimeSeconds += step
if !config.RandomMetrics {
continue
}
host.NetInRate = SampleMetric("agent", host.ID, "netin", now)
host.NetOutRate = SampleMetric("agent", host.ID, "netout", now)
host.DiskReadRate = SampleMetric("agent", host.ID, "diskread", now)
host.DiskWriteRate = SampleMetric("agent", host.ID, "diskwrite", now)
host.CPUUsage = SampleMetric("agent", host.ID, "cpu", now)
applyMemoryUsage(&host.Memory, SampleMetric("agent", host.ID, "memory", now))
for j := range host.Disks {
diskID := mockHostDiskMetricID(host.ID, j)
applyDiskUsage(&host.Disks[j], SampleMetric("agent", diskID, "disk", now))
}
refreshMockHostCPUSensors(host, now)
if len(host.LoadAverage) == 3 {
for j := range host.LoadAverage {
host.LoadAverage[j] = clampFloat(host.LoadAverage[j]+(rand.Float64()-0.5)*0.4, 0.05, float64(host.CPUCount))
}
}
if host.Status == "degraded" {
if rand.Float64() < mockRecoveryChance(mockHostDegradedMeanRecovery) {
host.Status = "online"
}
} else if rand.Float64() < mockEventsPerDayChance(mockHostDegradedPerDay) {
host.Status = "degraded"
}
}
}
func fluctuateFloat(value, variance, min, max float64) float64 {
change := (rand.Float64()*2 - 1) * variance
newValue := value * (1 + change)
if newValue < min {
newValue = min
}
if newValue > max {
newValue = max
}
return newValue
}
func fluctuateInt(value, delta, min, max int) int {
if delta <= 0 {
return value
}
change := rand.Intn(delta*2+1) - delta
newValue := value + change
if newValue < min {
newValue = min
}
if newValue > max {
newValue = max
}
return newValue
}
func generateDisksForNode(node models.Node) []models.PhysicalDisk {
disks := []models.PhysicalDisk{}
now := time.Now()
// Generate 1-3 disks per node
diskCount := 1 + mockStableChoice(3, strings.TrimSpace(node.ID), strings.TrimSpace(node.Instance), strings.TrimSpace(node.Name), "physical-disk-count")
diskModels := []struct {
model string
diskType string
size int64
reportsWearout bool
}{
{"Samsung SSD 970 EVO Plus 1TB", "nvme", 1000204886016, true},
{"WD Blue SN570 500GB", "nvme", 500107862016, true},
{"Crucial MX500 2TB", "sata", 2000398934016, true},
{"Seagate BarraCuda 4TB", "sata", 4000787030016, false},
{"Kingston NV2 250GB", "nvme", 250059350016, true},
{"WD Red Pro 8TB", "sata", 8001563222016, false},
{"Samsung 980 PRO 2TB", "nvme", 2000398934016, true},
{"Intel SSD 660p 1TB", "nvme", 1000204886016, true},
{"Toshiba X300 6TB", "sata", 6001175126016, false},
}
for i := 0; i < diskCount; i++ {
diskModel := diskModels[mockStableChoice(len(diskModels), strings.TrimSpace(node.ID), strings.TrimSpace(node.Instance), strings.TrimSpace(node.Name), fmt.Sprintf("%d", i), "physical-disk-model")]
seedParts := []string{
strings.TrimSpace(node.ID),
strings.TrimSpace(node.Instance),
strings.TrimSpace(node.Name),
fmt.Sprintf("%d", i),
}
// Keep alert-producing health evidence stable across mock restarts.
// Most disks are healthy, while a small deterministic cohort exercises
// unknown and failed states.
health := "PASSED"
healthRoll := mockStableChoice(100, append(seedParts, "physical-disk-health")...)
if healthRoll < 5 {
health = "FAILED"
} else if healthRoll < 15 {
health = "UNKNOWN"
}
// Wearout is percentage life remaining. -1 is the canonical unreported
// sentinel; 0 is real evidence that an SSD/NVMe has no life remaining
// and would correctly render as "Replace Now". Rotational disks never
// receive synthetic endurance data.
wearout := -1
if diskModel.reportsWearout &&
mockStableChoice(100, append(seedParts, "physical-disk-wearout-reported")...) < 70 {
wearout = 50 + mockStableChoice(51, append(seedParts, "physical-disk-wearout-normal")...)
if mockStableChoice(100, append(seedParts, "physical-disk-wearout-low-cohort")...) < 10 {
wearout = 5 + mockStableChoice(16, append(seedParts, "physical-disk-wearout-low")...)
}
}
devPath := fmt.Sprintf("/dev/%s%d", []string{"nvme", "sd"}[i%2], i)
diskID := fmt.Sprintf("%s-%s-%s", node.Instance, node.Name, devPath)
serial := "SERIAL" + mockStableDecimalString(12, strings.TrimSpace(node.ID), strings.TrimSpace(node.Instance), strings.TrimSpace(node.Name), devPath, "physical-disk-serial")
temp := int(math.Round(SampleMetric("disk", serial, "smart_temp", now)))
disk := models.PhysicalDisk{
ID: diskID,
Node: node.Name,
Instance: node.Instance,
DevPath: devPath,
Model: diskModel.model,
Serial: serial,
Type: diskModel.diskType,
Size: diskModel.size,
Health: health,
Wearout: wearout,
Temperature: temp,
Used: []string{"ext4", "zfs", "btrfs", "xfs"}[rand.Intn(4)],
LastChecked: time.Now(),
}
disks = append(disks, disk)
}
return disks
}
func generateDockerServicesAndTasks(hostname string, containers []models.DockerContainer, now time.Time) ([]models.DockerService, []models.DockerTask) {
if len(containers) == 0 {
return nil, nil
}
type svcAgg struct {
service models.DockerService
tasks []models.DockerTask
}
aggregates := make(map[string]*svcAgg)
stackNames := []string{"frontend", "backend", "ops", "infra"}
for idx, container := range containers {
baseName := strings.Split(container.Name, "-")[0]
stack := stackNames[idx%len(stackNames)]
serviceName := fmt.Sprintf("%s-%s", stack, baseName)
serviceID := fmt.Sprintf("svc-%s-%d", stack, idx)
agg, exists := aggregates[serviceID]
if !exists {
agg = &svcAgg{
service: models.DockerService{
ID: serviceID,
Name: serviceName,
Mode: []string{"replicated", "global"}[rand.Intn(2)],
Labels: map[string]string{
"com.docker.stack.namespace": stack,
},
EndpointPorts: []models.DockerServicePort{
{
Protocol: "tcp",
TargetPort: uint32(8000 + idx%10),
PublishedPort: uint32(18000 + idx%10),
PublishMode: "ingress",
},
},
},
}
if rand.Float64() < 0.5 {
agg.service.Image = fmt.Sprintf("registry.example.com/%s:%s", serviceName, dockerImageTags[rand.Intn(len(dockerImageTags))])
}
aggregates[serviceID] = agg
}
desired := 1 + rand.Intn(4)
agg.service.DesiredTasks += desired
slots := desired
if agg.service.Mode == "global" {
slots = 1
}
for slot := 0; slot < slots; slot++ {
currentState := "running"
if rand.Float64() < 0.15 {
currentState = []string{"failed", "shutdown", "pending", "starting"}[rand.Intn(4)]
}
// Create a unique container name for each task
taskContainerName := container.Name
if slots > 1 {
taskContainerName = fmt.Sprintf("%s.%d", container.Name, slot+1)
}
taskID := fmt.Sprintf("%s-task-%d", serviceID, slot)
task := models.DockerTask{
ID: taskID,
ServiceID: serviceID,
ServiceName: serviceName,
Slot: slot + 1,
NodeID: fmt.Sprintf("node-%s", hostname),
NodeName: hostname,
DesiredState: "running",
CurrentState: currentState,
ContainerID: fmt.Sprintf("%s-%d", container.ID, slot),
ContainerName: taskContainerName,
CreatedAt: now.Add(-time.Duration(rand.Intn(48)) * time.Hour),
}
// Set varied start times for each task (not all identical)
if currentState == "running" {
startTime := now.Add(-time.Duration(30+rand.Intn(3600*24)) * time.Second)
task.StartedAt = &startTime
} else if container.StartedAt != nil && (currentState == "failed" || currentState == "shutdown") {
started := *container.StartedAt
task.StartedAt = &started
}
if currentState == "failed" || currentState == "shutdown" {
task.Error = "container exit"
task.Message = "Replica exited unexpectedly"
if container.FinishedAt != nil {
finished := *container.FinishedAt
task.CompletedAt = &finished
} else {
completedTime := now.Add(-time.Duration(rand.Intn(3600)) * time.Second)
task.CompletedAt = &completedTime
}
}
agg.tasks = append(agg.tasks, task)
}
}
services := make([]models.DockerService, 0, len(aggregates))
tasks := make([]models.DockerTask, 0, len(containers))
for _, agg := range aggregates {
running := 0
completed := 0
for _, task := range agg.tasks {
if strings.EqualFold(task.CurrentState, "running") {
running++
}
if task.CompletedAt != nil && strings.EqualFold(task.CurrentState, "shutdown") {
completed++
}
}
agg.service.RunningTasks = running
agg.service.CompletedTasks = completed
if running < agg.service.DesiredTasks {
agg.service.UpdateStatus = &models.DockerServiceUpdate{
State: "rollback_started",
Message: "Service replicas below desired",
CompletedAt: nil,
}
}
services = append(services, agg.service)
tasks = append(tasks, agg.tasks...)
}
sort.Slice(services, func(i, j int) bool {
if services[i].Name == services[j].Name {
return services[i].ID < services[j].ID
}
return services[i].Name < services[j].Name
})
sort.Slice(tasks, func(i, j int) bool {
if tasks[i].ServiceName == tasks[j].ServiceName {
if tasks[i].Slot == tasks[j].Slot {
return tasks[i].ID < tasks[j].ID
}
return tasks[i].Slot < tasks[j].Slot
}
return tasks[i].ServiceName < tasks[j].ServiceName
})
return services, tasks
}
func generateDockerSwarmSecretsAndConfigs(hostname string, now time.Time) ([]models.DockerSecret, []models.DockerConfig) {
if now.IsZero() {
now = time.Now()
}
createdBase := now.Add(-72 * time.Hour)
updated := now.Add(-6 * time.Hour)
secretUpdated := updated.Add(20 * time.Minute)
configUpdated := updated.Add(45 * time.Minute)
secrets := []models.DockerSecret{
{
ID: fmt.Sprintf("secret-%s-db-password", hostname),
Name: "db-password",
DriverName: "builtin",
Labels: map[string]string{
"com.docker.stack.namespace": "backend",
"pulse.demo.scope": "database",
},
CreatedAt: createdBase,
UpdatedAt: &secretUpdated,
},
{
ID: fmt.Sprintf("secret-%s-api-token", hostname),
Name: "api-token",
DriverName: "vault",
Labels: map[string]string{
"com.docker.stack.namespace": "ops",
},
CreatedAt: createdBase.Add(2 * time.Hour),
UpdatedAt: &secretUpdated,
},
}
configs := []models.DockerConfig{
{
ID: fmt.Sprintf("config-%s-nginx", hostname),
Name: "nginx-conf",
TemplatingDriver: "golang",
Labels: map[string]string{
"com.docker.stack.namespace": "frontend",
},
CreatedAt: createdBase.Add(30 * time.Minute),
UpdatedAt: &configUpdated,
},
{
ID: fmt.Sprintf("config-%s-app-env", hostname),
Name: "app-env",
Labels: map[string]string{
"com.docker.stack.namespace": "backend",
},
CreatedAt: createdBase.Add(90 * time.Minute),
UpdatedAt: &configUpdated,
},
}
return secrets, configs
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}