mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
4f14ad6ab9
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.
7495 lines
229 KiB
Go
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 ""
|
|
}
|