mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
1373 lines
34 KiB
Go
1373 lines
34 KiB
Go
package resources
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
)
|
|
|
|
// Store maintains a unified collection of all resources with deduplication.
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
resources map[string]*Resource // Keyed by Resource.ID
|
|
|
|
// Index by identity for deduplication
|
|
byHostname map[string][]string // hostname (lower) -> resource IDs
|
|
byMachineID map[string]string // machine-id -> resource ID
|
|
byIP map[string][]string // IP -> resource IDs
|
|
|
|
// Track merged resources (one source is preferred over another)
|
|
mergedFrom map[string]string // suppressed ID -> preferred ID
|
|
}
|
|
|
|
// NewStore creates a new resource store.
|
|
func NewStore() *Store {
|
|
return &Store{
|
|
resources: make(map[string]*Resource),
|
|
byHostname: make(map[string][]string),
|
|
byMachineID: make(map[string]string),
|
|
byIP: make(map[string][]string),
|
|
mergedFrom: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// Upsert adds or updates a resource in the store, performing deduplication.
|
|
// Returns the ID of the resource (may differ if merged with existing).
|
|
func (s *Store) Upsert(r Resource) string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Check for duplicates
|
|
if r.Identity != nil {
|
|
if existingID := s.findDuplicate(&r); existingID != "" {
|
|
// Found a duplicate - determine which to prefer
|
|
existing := s.resources[existingID]
|
|
preferred := s.preferredResource(existing, &r)
|
|
|
|
if preferred == &r {
|
|
// New resource is preferred, replace the old one
|
|
s.removeFromIndexes(existing)
|
|
delete(s.resources, existingID)
|
|
s.mergedFrom[existingID] = r.ID
|
|
} else {
|
|
// Existing resource is preferred, mark this as merged
|
|
s.mergedFrom[r.ID] = existingID
|
|
return existingID
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add/update the resource
|
|
s.resources[r.ID] = &r
|
|
s.addToIndexes(&r)
|
|
|
|
return r.ID
|
|
}
|
|
|
|
// Get retrieves a resource by ID.
|
|
func (s *Store) Get(id string) (*Resource, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Check if this ID was merged into another
|
|
if preferredID, merged := s.mergedFrom[id]; merged {
|
|
r, ok := s.resources[preferredID]
|
|
return r, ok
|
|
}
|
|
|
|
r, ok := s.resources[id]
|
|
return r, ok
|
|
}
|
|
|
|
// GetAll returns all resources (excluding suppressed duplicates).
|
|
func (s *Store) GetAll() []Resource {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
result := make([]Resource, 0, len(s.resources))
|
|
for _, r := range s.resources {
|
|
result = append(result, *r)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetByType returns all resources of a specific type.
|
|
func (s *Store) GetByType(t ResourceType) []Resource {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []Resource
|
|
for _, r := range s.resources {
|
|
if r.Type == t {
|
|
result = append(result, *r)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetByPlatform returns all resources from a specific platform.
|
|
func (s *Store) GetByPlatform(p PlatformType) []Resource {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []Resource
|
|
for _, r := range s.resources {
|
|
if r.PlatformType == p {
|
|
result = append(result, *r)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetInfrastructure returns all infrastructure resources (nodes, hosts).
|
|
func (s *Store) GetInfrastructure() []Resource {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []Resource
|
|
for _, r := range s.resources {
|
|
if r.IsInfrastructure() {
|
|
result = append(result, *r)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetWorkloads returns all workload resources (VMs, containers).
|
|
func (s *Store) GetWorkloads() []Resource {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []Resource
|
|
for _, r := range s.resources {
|
|
if r.IsWorkload() {
|
|
result = append(result, *r)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetChildren returns all resources with the specified parent ID.
|
|
func (s *Store) GetChildren(parentID string) []Resource {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []Resource
|
|
for _, r := range s.resources {
|
|
if r.ParentID == parentID {
|
|
result = append(result, *r)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// FindContainerHost looks up a Docker container by name or ID and returns the
|
|
// hostname of its parent DockerHost. This is used by AI routing to automatically
|
|
// determine which host should execute commands for a container.
|
|
// Returns empty string if not found.
|
|
func (s *Store) FindContainerHost(containerNameOrID string) string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if containerNameOrID == "" {
|
|
return ""
|
|
}
|
|
|
|
containerNameLower := strings.ToLower(containerNameOrID)
|
|
|
|
// Find the container
|
|
var container *Resource
|
|
for _, r := range s.resources {
|
|
if r.Type != ResourceTypeDockerContainer {
|
|
continue
|
|
}
|
|
// Match by name or ID (case-insensitive)
|
|
if strings.EqualFold(r.Name, containerNameOrID) ||
|
|
strings.EqualFold(r.ID, containerNameOrID) ||
|
|
strings.Contains(strings.ToLower(r.Name), containerNameLower) ||
|
|
strings.Contains(strings.ToLower(r.ID), containerNameLower) {
|
|
container = r
|
|
break
|
|
}
|
|
}
|
|
|
|
if container == nil || container.ParentID == "" {
|
|
return ""
|
|
}
|
|
|
|
// Find the parent DockerHost
|
|
parent := s.resources[container.ParentID]
|
|
if parent == nil {
|
|
return ""
|
|
}
|
|
|
|
// Return the hostname from identity, or the name
|
|
if parent.Identity != nil && parent.Identity.Hostname != "" {
|
|
return parent.Identity.Hostname
|
|
}
|
|
return parent.Name
|
|
}
|
|
|
|
// Remove removes a resource from the store.
|
|
func (s *Store) Remove(id string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if r, ok := s.resources[id]; ok {
|
|
s.removeFromIndexes(r)
|
|
delete(s.resources, id)
|
|
}
|
|
|
|
// Also clean up any merge references
|
|
delete(s.mergedFrom, id)
|
|
for k, v := range s.mergedFrom {
|
|
if v == id {
|
|
delete(s.mergedFrom, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// IsSuppressed returns true if the resource ID has been merged/suppressed by another.
|
|
func (s *Store) IsSuppressed(id string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
_, suppressed := s.mergedFrom[id]
|
|
return suppressed
|
|
}
|
|
|
|
// GetPreferredID returns the preferred resource ID if this one is suppressed.
|
|
func (s *Store) GetPreferredID(id string) string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if preferredID, ok := s.mergedFrom[id]; ok {
|
|
return preferredID
|
|
}
|
|
return id
|
|
}
|
|
|
|
// GetStats returns statistics about the store.
|
|
func (s *Store) GetStats() StoreStats {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
stats := StoreStats{
|
|
TotalResources: len(s.resources),
|
|
SuppressedResources: len(s.mergedFrom),
|
|
ByType: make(map[ResourceType]int),
|
|
ByPlatform: make(map[PlatformType]int),
|
|
ByStatus: make(map[ResourceStatus]int),
|
|
WithAlerts: 0,
|
|
LastUpdated: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
for _, r := range s.resources {
|
|
stats.ByType[r.Type]++
|
|
stats.ByPlatform[r.PlatformType]++
|
|
stats.ByStatus[r.Status]++
|
|
if len(r.Alerts) > 0 {
|
|
stats.WithAlerts++
|
|
}
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
// StoreStats contains statistics about the resource store.
|
|
type StoreStats struct {
|
|
TotalResources int `json:"totalResources"`
|
|
SuppressedResources int `json:"suppressedResources"`
|
|
ByType map[ResourceType]int `json:"byType"`
|
|
ByPlatform map[PlatformType]int `json:"byPlatform"`
|
|
ByStatus map[ResourceStatus]int `json:"byStatus"`
|
|
WithAlerts int `json:"withAlerts"`
|
|
LastUpdated string `json:"lastUpdated"`
|
|
}
|
|
|
|
// GetPreferredResourceFor returns the preferred resource for a given ID.
|
|
// If the ID was merged into another resource, returns that preferred resource.
|
|
// If not merged, returns the resource itself. Returns nil if not found.
|
|
func (s *Store) GetPreferredResourceFor(resourceID string) *Resource {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Check if this ID was merged
|
|
if preferredID, merged := s.mergedFrom[resourceID]; merged {
|
|
if r, ok := s.resources[preferredID]; ok {
|
|
return r
|
|
}
|
|
}
|
|
|
|
// Return the resource itself if it exists
|
|
if r, ok := s.resources[resourceID]; ok {
|
|
return r
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// IsSamePhysicalMachine checks if two resource IDs represent the same physical machine.
|
|
// This is useful for alert deduplication between different source types (API vs Agent).
|
|
func (s *Store) IsSamePhysicalMachine(id1, id2 string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
// Check if they're literally the same
|
|
if id1 == id2 {
|
|
return true
|
|
}
|
|
|
|
// Check if both map to the same preferred resource
|
|
preferred1 := id1
|
|
if pid, merged := s.mergedFrom[id1]; merged {
|
|
preferred1 = pid
|
|
}
|
|
|
|
preferred2 := id2
|
|
if pid, merged := s.mergedFrom[id2]; merged {
|
|
preferred2 = pid
|
|
}
|
|
|
|
return preferred1 == preferred2
|
|
}
|
|
|
|
// HasPreferredSourceForHostname checks if there's a resource with a preferred source
|
|
// (like host-agent) monitoring a machine with the given hostname.
|
|
// This helps alert managers determine if they should skip alerts from API sources.
|
|
func (s *Store) HasPreferredSourceForHostname(hostname string) bool {
|
|
if hostname == "" {
|
|
return false
|
|
}
|
|
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
hostnameLower := strings.ToLower(hostname)
|
|
resourceIDs, exists := s.byHostname[hostnameLower]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
// Check if any resource with this hostname has a preferred source type
|
|
for _, id := range resourceIDs {
|
|
if r, ok := s.resources[id]; ok {
|
|
// Host agent and Docker agent sources are preferred over API
|
|
if r.SourceType == SourceAgent || r.SourceType == SourceHybrid {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ============================================================================
|
|
// Polling Optimization Methods (Phase 3)
|
|
// These methods help reduce redundant API polling when agents are active
|
|
// ============================================================================
|
|
|
|
// ShouldSkipAPIPolling returns true if API polling should be skipped for the
|
|
// given hostname because an agent is providing richer, more frequent data.
|
|
// This is useful for reducing load when both Proxmox API and host agents monitor
|
|
// the same machine.
|
|
func (s *Store) ShouldSkipAPIPolling(hostname string) bool {
|
|
return s.HasPreferredSourceForHostname(hostname)
|
|
}
|
|
|
|
// GetAgentMonitoredHostnames returns a list of hostnames that are being monitored
|
|
// by agents (host-agent, docker-agent). This can be used by the monitoring loop
|
|
// to adjust polling behavior for these hosts.
|
|
func (s *Store) GetAgentMonitoredHostnames() []string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var hostnames []string
|
|
seen := make(map[string]bool)
|
|
|
|
for _, r := range s.resources {
|
|
if r.SourceType != SourceAgent && r.SourceType != SourceHybrid {
|
|
continue
|
|
}
|
|
if r.Identity == nil || r.Identity.Hostname == "" {
|
|
continue
|
|
}
|
|
hostnameLower := strings.ToLower(r.Identity.Hostname)
|
|
if !seen[hostnameLower] {
|
|
seen[hostnameLower] = true
|
|
hostnames = append(hostnames, r.Identity.Hostname)
|
|
}
|
|
}
|
|
|
|
return hostnames
|
|
}
|
|
|
|
// GetPollingRecommendations returns recommendations for optimizing polling.
|
|
// Returns a map of hostname -> recommended polling interval multiplier.
|
|
// - Value of 0 means skip entirely (agent provides all needed data)
|
|
// - Value > 1 means reduce frequency (poll less often)
|
|
// - Value of 1 means normal polling
|
|
func (s *Store) GetPollingRecommendations() map[string]float64 {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
recommendations := make(map[string]float64)
|
|
|
|
for _, r := range s.resources {
|
|
if r.Identity == nil || r.Identity.Hostname == "" {
|
|
continue
|
|
}
|
|
hostname := strings.ToLower(r.Identity.Hostname)
|
|
|
|
switch r.SourceType {
|
|
case SourceAgent:
|
|
// Agent provides all data - skip API polling for metrics
|
|
// (we may still want to poll for cluster-wide info)
|
|
recommendations[hostname] = 0
|
|
case SourceHybrid:
|
|
// Hybrid mode - reduce frequency but don't skip
|
|
recommendations[hostname] = 0.5 // Poll at half frequency
|
|
}
|
|
}
|
|
|
|
return recommendations
|
|
}
|
|
|
|
// findDuplicate looks for an existing resource that represents the same machine.
|
|
// Must be called with the lock held.
|
|
func (s *Store) findDuplicate(r *Resource) string {
|
|
if r.Identity == nil {
|
|
return ""
|
|
}
|
|
|
|
// 1. Machine ID match (most reliable) - but only for same type
|
|
// A node and host agent on the same machine should coexist as different data sources
|
|
if r.Identity.MachineID != "" && r.IsInfrastructure() {
|
|
if existingID, ok := s.byMachineID[r.Identity.MachineID]; ok && existingID != r.ID {
|
|
existing := s.resources[existingID]
|
|
// Only match if same type
|
|
if existing != nil && existing.Type == r.Type {
|
|
return existingID
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Hostname match (case-insensitive) - only for same infrastructure type
|
|
// Workloads (VMs, containers) can have duplicate names across clusters
|
|
if r.Identity.Hostname != "" && r.IsInfrastructure() {
|
|
hostnameLower := strings.ToLower(r.Identity.Hostname)
|
|
if existingIDs, ok := s.byHostname[hostnameLower]; ok {
|
|
for _, existingID := range existingIDs {
|
|
if existingID != r.ID {
|
|
existing := s.resources[existingID]
|
|
// Only match same infrastructure type (e.g., host with host, node with node)
|
|
// Different types represent different data sources and should coexist
|
|
if existing != nil && existing.Type == r.Type {
|
|
return existingID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. IP overlap (if same non-localhost IP, likely same machine) - only for same infrastructure type
|
|
if r.IsInfrastructure() {
|
|
for _, ip := range r.Identity.IPs {
|
|
if isNonUniqueIP(ip) {
|
|
continue
|
|
}
|
|
if existingIDs, ok := s.byIP[ip]; ok {
|
|
for _, existingID := range existingIDs {
|
|
if existingID != r.ID {
|
|
existing := s.resources[existingID]
|
|
// Only match same infrastructure type
|
|
if existing != nil && existing.Type == r.Type {
|
|
return existingID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// preferredResource determines which of two duplicate resources should be kept.
|
|
// Agent data is preferred over API data.
|
|
func (s *Store) preferredResource(a, b *Resource) *Resource {
|
|
// Prefer agent over API
|
|
aScore := s.sourceScore(a.SourceType)
|
|
bScore := s.sourceScore(b.SourceType)
|
|
|
|
if aScore > bScore {
|
|
return a
|
|
}
|
|
if bScore > aScore {
|
|
return b
|
|
}
|
|
|
|
// Same source type - prefer the one with more recent data
|
|
if a.LastSeen.After(b.LastSeen) {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (s *Store) sourceScore(st SourceType) int {
|
|
switch st {
|
|
case SourceAgent:
|
|
return 3 // Agent data is most preferred
|
|
case SourceHybrid:
|
|
return 2 // Hybrid is second
|
|
case SourceAPI:
|
|
return 1 // API is least preferred
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (s *Store) addToIndexes(r *Resource) {
|
|
if r.Identity == nil {
|
|
return
|
|
}
|
|
|
|
if r.Identity.MachineID != "" {
|
|
s.byMachineID[r.Identity.MachineID] = r.ID
|
|
}
|
|
|
|
if r.Identity.Hostname != "" {
|
|
hostnameLower := strings.ToLower(r.Identity.Hostname)
|
|
s.byHostname[hostnameLower] = append(s.byHostname[hostnameLower], r.ID)
|
|
}
|
|
|
|
for _, ip := range r.Identity.IPs {
|
|
if !isNonUniqueIP(ip) {
|
|
s.byIP[ip] = append(s.byIP[ip], r.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Store) removeFromIndexes(r *Resource) {
|
|
if r.Identity == nil {
|
|
return
|
|
}
|
|
|
|
if r.Identity.MachineID != "" {
|
|
if s.byMachineID[r.Identity.MachineID] == r.ID {
|
|
delete(s.byMachineID, r.Identity.MachineID)
|
|
}
|
|
}
|
|
|
|
if r.Identity.Hostname != "" {
|
|
hostnameLower := strings.ToLower(r.Identity.Hostname)
|
|
s.byHostname[hostnameLower] = removeFromSlice(s.byHostname[hostnameLower], r.ID)
|
|
if len(s.byHostname[hostnameLower]) == 0 {
|
|
delete(s.byHostname, hostnameLower)
|
|
}
|
|
}
|
|
|
|
for _, ip := range r.Identity.IPs {
|
|
s.byIP[ip] = removeFromSlice(s.byIP[ip], r.ID)
|
|
if len(s.byIP[ip]) == 0 {
|
|
delete(s.byIP, ip)
|
|
}
|
|
}
|
|
}
|
|
|
|
func removeFromSlice(slice []string, item string) []string {
|
|
result := make([]string, 0, len(slice))
|
|
for _, s := range slice {
|
|
if s != item {
|
|
result = append(result, s)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// isNonUniqueIP returns true if the IP address is not useful for machine identification.
|
|
// This includes localhost addresses and Docker bridge IPs that exist on every Docker host.
|
|
func isNonUniqueIP(ip string) bool {
|
|
// Localhost addresses
|
|
if ip == "127.0.0.1" || ip == "::1" || strings.HasPrefix(ip, "127.") {
|
|
return true
|
|
}
|
|
|
|
// Docker bridge network - 172.17.0.1/16 exists on every Docker host
|
|
// Also filter other Docker-assigned bridge networks (172.17-31.x.x)
|
|
if strings.HasPrefix(ip, "172.17.") || strings.HasPrefix(ip, "172.18.") ||
|
|
strings.HasPrefix(ip, "172.19.") || strings.HasPrefix(ip, "172.20.") ||
|
|
strings.HasPrefix(ip, "172.21.") || strings.HasPrefix(ip, "172.22.") {
|
|
return true
|
|
}
|
|
|
|
// Link-local addresses (fe80::)
|
|
if strings.HasPrefix(strings.ToLower(ip), "fe80:") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// MarkStale marks resources that haven't been updated recently.
|
|
func (s *Store) MarkStale(threshold time.Duration) []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
var staleIDs []string
|
|
|
|
for id, r := range s.resources {
|
|
if now.Sub(r.LastSeen) > threshold {
|
|
// Mark as offline/degraded
|
|
if r.Status == StatusOnline || r.Status == StatusRunning {
|
|
r.Status = StatusDegraded
|
|
staleIDs = append(staleIDs, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
return staleIDs
|
|
}
|
|
|
|
// PruneStale removes resources that have been stale for too long.
|
|
func (s *Store) PruneStale(staleThreshold, removeThreshold time.Duration) []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
var removedIDs []string
|
|
|
|
for id, r := range s.resources {
|
|
if now.Sub(r.LastSeen) > removeThreshold {
|
|
s.removeFromIndexes(r)
|
|
delete(s.resources, id)
|
|
removedIDs = append(removedIDs, id)
|
|
}
|
|
}
|
|
|
|
return removedIDs
|
|
}
|
|
|
|
// Query provides a fluent interface for querying resources.
|
|
func (s *Store) Query() *ResourceQuery {
|
|
return &ResourceQuery{store: s}
|
|
}
|
|
|
|
// ResourceQuery provides a fluent query interface.
|
|
type ResourceQuery struct {
|
|
store *Store
|
|
types []ResourceType
|
|
platforms []PlatformType
|
|
statuses []ResourceStatus
|
|
parentID *string
|
|
clusterID *string
|
|
hasAlerts *bool
|
|
sortBy string
|
|
sortDesc bool
|
|
limit int
|
|
offset int
|
|
}
|
|
|
|
// OfType filters by resource types.
|
|
func (q *ResourceQuery) OfType(types ...ResourceType) *ResourceQuery {
|
|
q.types = types
|
|
return q
|
|
}
|
|
|
|
// FromPlatform filters by platform types.
|
|
func (q *ResourceQuery) FromPlatform(platforms ...PlatformType) *ResourceQuery {
|
|
q.platforms = platforms
|
|
return q
|
|
}
|
|
|
|
// WithStatus filters by resource status.
|
|
func (q *ResourceQuery) WithStatus(statuses ...ResourceStatus) *ResourceQuery {
|
|
q.statuses = statuses
|
|
return q
|
|
}
|
|
|
|
// WithParent filters by parent ID.
|
|
func (q *ResourceQuery) WithParent(parentID string) *ResourceQuery {
|
|
q.parentID = &parentID
|
|
return q
|
|
}
|
|
|
|
// InCluster filters by cluster ID.
|
|
func (q *ResourceQuery) InCluster(clusterID string) *ResourceQuery {
|
|
q.clusterID = &clusterID
|
|
return q
|
|
}
|
|
|
|
// WithAlerts filters to resources that have active alerts.
|
|
func (q *ResourceQuery) WithAlerts() *ResourceQuery {
|
|
hasAlerts := true
|
|
q.hasAlerts = &hasAlerts
|
|
return q
|
|
}
|
|
|
|
// SortBy sets the sort field.
|
|
func (q *ResourceQuery) SortBy(field string, desc bool) *ResourceQuery {
|
|
q.sortBy = field
|
|
q.sortDesc = desc
|
|
return q
|
|
}
|
|
|
|
// Limit sets the maximum number of results.
|
|
func (q *ResourceQuery) Limit(n int) *ResourceQuery {
|
|
q.limit = n
|
|
return q
|
|
}
|
|
|
|
// Offset sets the offset for pagination.
|
|
func (q *ResourceQuery) Offset(n int) *ResourceQuery {
|
|
q.offset = n
|
|
return q
|
|
}
|
|
|
|
// Execute runs the query and returns matching resources.
|
|
func (q *ResourceQuery) Execute() []Resource {
|
|
q.store.mu.RLock()
|
|
defer q.store.mu.RUnlock()
|
|
|
|
var results []Resource
|
|
|
|
for _, r := range q.store.resources {
|
|
if q.matches(r) {
|
|
results = append(results, *r)
|
|
}
|
|
}
|
|
|
|
// Apply sorting if specified
|
|
if q.sortBy != "" {
|
|
sortResources(results, q.sortBy, q.sortDesc)
|
|
}
|
|
|
|
// Apply pagination
|
|
if q.offset > 0 {
|
|
if q.offset >= len(results) {
|
|
return []Resource{}
|
|
}
|
|
results = results[q.offset:]
|
|
}
|
|
|
|
if q.limit > 0 && q.limit < len(results) {
|
|
results = results[:q.limit]
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// Count returns the number of matching resources without fetching them.
|
|
func (q *ResourceQuery) Count() int {
|
|
q.store.mu.RLock()
|
|
defer q.store.mu.RUnlock()
|
|
|
|
count := 0
|
|
for _, r := range q.store.resources {
|
|
if q.matches(r) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (q *ResourceQuery) matches(r *Resource) bool {
|
|
// Type filter
|
|
if len(q.types) > 0 {
|
|
found := false
|
|
for _, t := range q.types {
|
|
if r.Type == t {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Platform filter
|
|
if len(q.platforms) > 0 {
|
|
found := false
|
|
for _, p := range q.platforms {
|
|
if r.PlatformType == p {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Status filter
|
|
if len(q.statuses) > 0 {
|
|
found := false
|
|
for _, s := range q.statuses {
|
|
if r.Status == s {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Parent filter
|
|
if q.parentID != nil && r.ParentID != *q.parentID {
|
|
return false
|
|
}
|
|
|
|
// Cluster filter
|
|
if q.clusterID != nil && r.ClusterID != *q.clusterID {
|
|
return false
|
|
}
|
|
|
|
// Alerts filter
|
|
if q.hasAlerts != nil && *q.hasAlerts && len(r.Alerts) == 0 {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// sortResources sorts a slice of resources by the given field.
|
|
// Supported fields: name, type, status, cpu, memory, disk, last_seen
|
|
func sortResources(resources []Resource, field string, desc bool) {
|
|
if len(resources) < 2 {
|
|
return
|
|
}
|
|
|
|
// Simple bubble sort for consistency (stable sort)
|
|
// For production, could use sort.Slice with a compare function
|
|
for i := 0; i < len(resources)-1; i++ {
|
|
for j := i + 1; j < len(resources); j++ {
|
|
shouldSwap := false
|
|
|
|
switch strings.ToLower(field) {
|
|
case "name":
|
|
if desc {
|
|
shouldSwap = resources[i].EffectiveDisplayName() < resources[j].EffectiveDisplayName()
|
|
} else {
|
|
shouldSwap = resources[i].EffectiveDisplayName() > resources[j].EffectiveDisplayName()
|
|
}
|
|
case "type":
|
|
if desc {
|
|
shouldSwap = string(resources[i].Type) < string(resources[j].Type)
|
|
} else {
|
|
shouldSwap = string(resources[i].Type) > string(resources[j].Type)
|
|
}
|
|
case "status":
|
|
if desc {
|
|
shouldSwap = string(resources[i].Status) < string(resources[j].Status)
|
|
} else {
|
|
shouldSwap = string(resources[i].Status) > string(resources[j].Status)
|
|
}
|
|
case "cpu":
|
|
if desc {
|
|
shouldSwap = resources[i].CPUPercent() < resources[j].CPUPercent()
|
|
} else {
|
|
shouldSwap = resources[i].CPUPercent() > resources[j].CPUPercent()
|
|
}
|
|
case "memory", "mem":
|
|
if desc {
|
|
shouldSwap = resources[i].MemoryPercent() < resources[j].MemoryPercent()
|
|
} else {
|
|
shouldSwap = resources[i].MemoryPercent() > resources[j].MemoryPercent()
|
|
}
|
|
case "disk":
|
|
if desc {
|
|
shouldSwap = resources[i].DiskPercent() < resources[j].DiskPercent()
|
|
} else {
|
|
shouldSwap = resources[i].DiskPercent() > resources[j].DiskPercent()
|
|
}
|
|
case "last_seen", "lastseen":
|
|
if desc {
|
|
shouldSwap = resources[i].LastSeen.Before(resources[j].LastSeen)
|
|
} else {
|
|
shouldSwap = resources[i].LastSeen.After(resources[j].LastSeen)
|
|
}
|
|
}
|
|
|
|
if shouldSwap {
|
|
resources[i], resources[j] = resources[j], resources[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Cross-Platform Analysis Methods
|
|
// These methods support AI queries like "what's using the most CPU"
|
|
// ============================================================================
|
|
|
|
// GetTopByCPU returns resources sorted by CPU usage (highest first).
|
|
// Optionally filter by resource types. Pass nil to include all types.
|
|
func (s *Store) GetTopByCPU(limit int, types []ResourceType) []Resource {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var resources []Resource
|
|
for _, r := range s.resources {
|
|
if r.CPU == nil || r.CPU.Current == 0 {
|
|
continue
|
|
}
|
|
if len(types) > 0 {
|
|
matched := false
|
|
for _, t := range types {
|
|
if r.Type == t {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
}
|
|
resources = append(resources, *r)
|
|
}
|
|
|
|
// Sort by CPU usage descending
|
|
for i := 0; i < len(resources)-1; i++ {
|
|
for j := i + 1; j < len(resources); j++ {
|
|
if resources[j].CPUPercent() > resources[i].CPUPercent() {
|
|
resources[i], resources[j] = resources[j], resources[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
if limit > 0 && limit < len(resources) {
|
|
return resources[:limit]
|
|
}
|
|
return resources
|
|
}
|
|
|
|
// GetTopByMemory returns resources sorted by memory usage (highest first).
|
|
// Optionally filter by resource types. Pass nil to include all types.
|
|
func (s *Store) GetTopByMemory(limit int, types []ResourceType) []Resource {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var resources []Resource
|
|
for _, r := range s.resources {
|
|
if r.Memory == nil || r.Memory.Current == 0 {
|
|
continue
|
|
}
|
|
if len(types) > 0 {
|
|
matched := false
|
|
for _, t := range types {
|
|
if r.Type == t {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
}
|
|
resources = append(resources, *r)
|
|
}
|
|
|
|
// Sort by memory usage descending
|
|
for i := 0; i < len(resources)-1; i++ {
|
|
for j := i + 1; j < len(resources); j++ {
|
|
if resources[j].MemoryPercent() > resources[i].MemoryPercent() {
|
|
resources[i], resources[j] = resources[j], resources[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
if limit > 0 && limit < len(resources) {
|
|
return resources[:limit]
|
|
}
|
|
return resources
|
|
}
|
|
|
|
// GetTopByDisk returns resources sorted by disk usage (highest first).
|
|
// Optionally filter by resource types. Pass nil to include all types.
|
|
func (s *Store) GetTopByDisk(limit int, types []ResourceType) []Resource {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var resources []Resource
|
|
for _, r := range s.resources {
|
|
if r.Disk == nil || r.Disk.Current == 0 {
|
|
continue
|
|
}
|
|
if len(types) > 0 {
|
|
matched := false
|
|
for _, t := range types {
|
|
if r.Type == t {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
}
|
|
resources = append(resources, *r)
|
|
}
|
|
|
|
// Sort by disk usage descending
|
|
for i := 0; i < len(resources)-1; i++ {
|
|
for j := i + 1; j < len(resources); j++ {
|
|
if resources[j].DiskPercent() > resources[i].DiskPercent() {
|
|
resources[i], resources[j] = resources[j], resources[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
if limit > 0 && limit < len(resources) {
|
|
return resources[:limit]
|
|
}
|
|
return resources
|
|
}
|
|
|
|
// GetRelated returns resources that are related to the given resource.
|
|
// This includes: parent, children, siblings (same parent), and co-located resources (same cluster).
|
|
func (s *Store) GetRelated(resourceID string) map[string][]Resource {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
result := make(map[string][]Resource)
|
|
|
|
r, ok := s.resources[resourceID]
|
|
if !ok {
|
|
return result
|
|
}
|
|
|
|
// Get parent
|
|
if r.ParentID != "" {
|
|
if parent, ok := s.resources[r.ParentID]; ok {
|
|
result["parent"] = []Resource{*parent}
|
|
}
|
|
}
|
|
|
|
// Get children
|
|
var children []Resource
|
|
for _, other := range s.resources {
|
|
if other.ParentID == resourceID {
|
|
children = append(children, *other)
|
|
}
|
|
}
|
|
if len(children) > 0 {
|
|
result["children"] = children
|
|
}
|
|
|
|
// Get siblings (same parent)
|
|
if r.ParentID != "" {
|
|
var siblings []Resource
|
|
for _, other := range s.resources {
|
|
if other.ID != resourceID && other.ParentID == r.ParentID {
|
|
siblings = append(siblings, *other)
|
|
}
|
|
}
|
|
if len(siblings) > 0 {
|
|
result["siblings"] = siblings
|
|
}
|
|
}
|
|
|
|
// Get co-located resources (same cluster)
|
|
if r.ClusterID != "" {
|
|
var colocated []Resource
|
|
for _, other := range s.resources {
|
|
if other.ID != resourceID && other.ClusterID == r.ClusterID {
|
|
colocated = append(colocated, *other)
|
|
}
|
|
}
|
|
if len(colocated) > 0 {
|
|
result["cluster_members"] = colocated
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetResourceSummary returns a summary of resource utilization across the infrastructure.
|
|
// This is useful for AI to get a quick overview of the infrastructure state.
|
|
func (s *Store) GetResourceSummary() ResourceSummary {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
summary := ResourceSummary{
|
|
ByType: make(map[ResourceType]TypeSummary),
|
|
ByPlatform: make(map[PlatformType]PlatformSummary),
|
|
}
|
|
|
|
for _, r := range s.resources {
|
|
summary.TotalResources++
|
|
|
|
// Count by status
|
|
switch r.Status {
|
|
case StatusOnline, StatusRunning:
|
|
summary.Healthy++
|
|
case StatusDegraded:
|
|
summary.Degraded++
|
|
case StatusOffline, StatusStopped, StatusUnknown:
|
|
summary.Offline++
|
|
}
|
|
|
|
// Track alerts
|
|
if len(r.Alerts) > 0 {
|
|
summary.WithAlerts++
|
|
}
|
|
|
|
// Aggregate by type
|
|
ts := summary.ByType[r.Type]
|
|
ts.Count++
|
|
if r.CPU != nil {
|
|
ts.TotalCPUPercent += r.CPUPercent()
|
|
}
|
|
if r.Memory != nil {
|
|
ts.TotalMemoryPercent += r.MemoryPercent()
|
|
}
|
|
summary.ByType[r.Type] = ts
|
|
|
|
// Aggregate by platform
|
|
ps := summary.ByPlatform[r.PlatformType]
|
|
ps.Count++
|
|
summary.ByPlatform[r.PlatformType] = ps
|
|
}
|
|
|
|
// Calculate averages
|
|
for t, ts := range summary.ByType {
|
|
if ts.Count > 0 {
|
|
ts.AvgCPUPercent = ts.TotalCPUPercent / float64(ts.Count)
|
|
ts.AvgMemoryPercent = ts.TotalMemoryPercent / float64(ts.Count)
|
|
summary.ByType[t] = ts
|
|
}
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
// ResourceSummary provides an overview of resource utilization.
|
|
type ResourceSummary struct {
|
|
TotalResources int
|
|
Healthy int
|
|
Degraded int
|
|
Offline int
|
|
WithAlerts int
|
|
ByType map[ResourceType]TypeSummary
|
|
ByPlatform map[PlatformType]PlatformSummary
|
|
}
|
|
|
|
// TypeSummary aggregates metrics for a resource type.
|
|
type TypeSummary struct {
|
|
Count int
|
|
TotalCPUPercent float64
|
|
TotalMemoryPercent float64
|
|
AvgCPUPercent float64
|
|
AvgMemoryPercent float64
|
|
}
|
|
|
|
// PlatformSummary aggregates counts for a platform.
|
|
type PlatformSummary struct {
|
|
Count int
|
|
}
|
|
|
|
// PopulateFromSnapshot converts all resources from a StateSnapshot to the unified store.
|
|
// This should be called whenever the state is updated (e.g., before WebSocket broadcasts).
|
|
// It also removes resources that are no longer present in the snapshot (except agent-sourced
|
|
// resources which persist independently of the Proxmox API polling).
|
|
func (s *Store) PopulateFromSnapshot(snapshot models.StateSnapshot) {
|
|
// Track which resource IDs we see in this snapshot
|
|
seenIDs := make(map[string]bool)
|
|
|
|
// Convert nodes
|
|
for _, node := range snapshot.Nodes {
|
|
r := FromNode(node)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
}
|
|
|
|
// Convert VMs
|
|
for _, vm := range snapshot.VMs {
|
|
r := FromVM(vm)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
}
|
|
|
|
// Convert containers
|
|
for _, ct := range snapshot.Containers {
|
|
r := FromContainer(ct)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
}
|
|
|
|
// Convert hosts
|
|
for _, host := range snapshot.Hosts {
|
|
r := FromHost(host)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
}
|
|
|
|
// Convert docker hosts and their containers
|
|
for _, dh := range snapshot.DockerHosts {
|
|
r := FromDockerHost(dh)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
|
|
// Convert containers within the docker host
|
|
for _, dc := range dh.Containers {
|
|
r := FromDockerContainer(dc, dh.ID, dh.Hostname)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
}
|
|
}
|
|
|
|
// Convert Kubernetes clusters and their resources
|
|
for _, cluster := range snapshot.KubernetesClusters {
|
|
r := FromKubernetesCluster(cluster)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
|
|
for _, node := range cluster.Nodes {
|
|
r := FromKubernetesNode(node, cluster)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
}
|
|
|
|
for _, pod := range cluster.Pods {
|
|
r := FromKubernetesPod(pod, cluster)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
}
|
|
|
|
for _, dep := range cluster.Deployments {
|
|
r := FromKubernetesDeployment(dep, cluster)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
}
|
|
}
|
|
|
|
// Convert PBS instances
|
|
for _, pbs := range snapshot.PBSInstances {
|
|
r := FromPBSInstance(pbs)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
}
|
|
|
|
// Convert storage
|
|
for _, storage := range snapshot.Storage {
|
|
r := FromStorage(storage)
|
|
id := s.Upsert(r)
|
|
seenIDs[id] = true
|
|
}
|
|
|
|
// Remove resources that were NOT in this snapshot.
|
|
// The snapshot is the authoritative source for the in-memory store (including agent-sourced data).
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
var toRemove []string
|
|
for id := range s.resources {
|
|
if !seenIDs[id] {
|
|
toRemove = append(toRemove, id)
|
|
}
|
|
}
|
|
|
|
for _, id := range toRemove {
|
|
if r, ok := s.resources[id]; ok {
|
|
s.removeFromIndexes(r)
|
|
delete(s.resources, id)
|
|
}
|
|
}
|
|
|
|
// Rebuild alert attachments from the current snapshot.
|
|
for _, resource := range s.resources {
|
|
resource.Alerts = nil
|
|
}
|
|
|
|
for _, alert := range snapshot.ActiveAlerts {
|
|
resourceID := s.resolveAlertResourceIDLocked(alert.ResourceID)
|
|
if resourceID == "" {
|
|
continue
|
|
}
|
|
|
|
resource, ok := s.resources[resourceID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
resource.Alerts = append(resource.Alerts, ResourceAlert{
|
|
ID: alert.ID,
|
|
Type: alert.Type,
|
|
Level: alert.Level,
|
|
Message: alert.Message,
|
|
Value: alert.Value,
|
|
Threshold: alert.Threshold,
|
|
StartTime: alert.StartTime,
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *Store) resolveAlertResourceIDLocked(resourceID string) string {
|
|
for _, candidate := range alertResourceCandidates(resourceID) {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
if preferredID, ok := s.mergedFrom[candidate]; ok {
|
|
candidate = preferredID
|
|
}
|
|
if _, ok := s.resources[candidate]; ok {
|
|
return candidate
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func alertResourceCandidates(resourceID string) []string {
|
|
resourceID = strings.TrimSpace(resourceID)
|
|
if resourceID == "" {
|
|
return nil
|
|
}
|
|
|
|
candidates := []string{resourceID}
|
|
|
|
if strings.HasPrefix(resourceID, "host:") {
|
|
candidates = append(candidates, strings.TrimPrefix(resourceID, "host:"))
|
|
}
|
|
if strings.HasPrefix(resourceID, "docker:") {
|
|
candidates = append(candidates, strings.TrimPrefix(resourceID, "docker:"))
|
|
}
|
|
|
|
for _, marker := range []string{"/disk:", "/disk_temp:", "/service/"} {
|
|
if idx := strings.Index(resourceID, marker); idx > 0 {
|
|
base := resourceID[:idx]
|
|
candidates = append(candidates, base)
|
|
if strings.HasPrefix(base, "host:") {
|
|
candidates = append(candidates, strings.TrimPrefix(base, "host:"))
|
|
}
|
|
if strings.HasPrefix(base, "docker:") {
|
|
candidates = append(candidates, strings.TrimPrefix(base, "docker:"))
|
|
}
|
|
}
|
|
}
|
|
|
|
if idx := strings.Index(resourceID, "-disk-"); idx > 0 {
|
|
candidates = append(candidates, resourceID[:idx])
|
|
}
|
|
|
|
return uniqueStrings(candidates)
|
|
}
|
|
|
|
func uniqueStrings(values []string) []string {
|
|
seen := make(map[string]struct{}, len(values))
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
result = append(result, value)
|
|
}
|
|
return result
|
|
}
|