phase 1: add unified resources to WebSocket state broadcasts

- Extended StateFrontend with Resources field containing unified resource data
- Added ResourceFrontend and related types for frontend-compatible resource data
- Extended ResourceStoreInterface to include GetAll() method
- Monitor now injects resources into WebSocket broadcasts
- Added helper method getResourcesForBroadcast() to convert resources to frontend format
- All existing tests pass

This enables the frontend to access unified resources via WebSocket state.
This commit is contained in:
rcourtman 2025-12-07 23:02:56 +00:00
parent ef35120afb
commit cdd2249e00
3 changed files with 330 additions and 3 deletions

View file

@ -732,3 +732,141 @@ func zeroIfNegative(val int64) int64 {
}
return val
}
// ResourceToFrontend converts a resources.Resource to ResourceFrontend.
// This function is in models package to avoid circular imports.
// It takes individual fields rather than the whole Resource to avoid importing resources package.
type ResourceConvertInput struct {
ID string
Type string
Name string
DisplayName string
PlatformID string
PlatformType string
SourceType string
ParentID string
ClusterID string
Status string
CPU *ResourceMetricInput
Memory *ResourceMetricInput
Disk *ResourceMetricInput
NetworkRX int64
NetworkTX int64
HasNetwork bool
Temperature *float64
Uptime *int64
Tags []string
Labels map[string]string
LastSeenUnix int64
Alerts []ResourceAlertInput
Identity *ResourceIdentityInput
PlatformData map[string]any
}
// ResourceMetricInput represents a metric value for resource conversion.
type ResourceMetricInput struct {
Current float64
Total *int64
Used *int64
Free *int64
}
type ResourceAlertInput struct {
ID string
Type string
Level string
Message string
Value float64
Threshold float64
StartTimeUnix int64
}
type ResourceIdentityInput struct {
Hostname string
MachineID string
IPs []string
}
// ConvertResourceToFrontend converts input to ResourceFrontend.
func ConvertResourceToFrontend(input ResourceConvertInput) ResourceFrontend {
rf := ResourceFrontend{
ID: input.ID,
Type: input.Type,
Name: input.Name,
DisplayName: input.DisplayName,
PlatformID: input.PlatformID,
PlatformType: input.PlatformType,
SourceType: input.SourceType,
ParentID: input.ParentID,
ClusterID: input.ClusterID,
Status: input.Status,
Temperature: input.Temperature,
Uptime: input.Uptime,
Tags: input.Tags,
Labels: input.Labels,
LastSeen: input.LastSeenUnix,
PlatformData: input.PlatformData,
}
// Convert metrics
if input.CPU != nil {
rf.CPU = &ResourceMetricFrontend{
Current: input.CPU.Current,
Total: input.CPU.Total,
Used: input.CPU.Used,
Free: input.CPU.Free,
}
}
if input.Memory != nil {
rf.Memory = &ResourceMetricFrontend{
Current: input.Memory.Current,
Total: input.Memory.Total,
Used: input.Memory.Used,
Free: input.Memory.Free,
}
}
if input.Disk != nil {
rf.Disk = &ResourceMetricFrontend{
Current: input.Disk.Current,
Total: input.Disk.Total,
Used: input.Disk.Used,
Free: input.Disk.Free,
}
}
if input.HasNetwork {
rf.Network = &ResourceNetworkFrontend{
RXBytes: input.NetworkRX,
TXBytes: input.NetworkTX,
}
}
// Convert alerts
if len(input.Alerts) > 0 {
rf.Alerts = make([]ResourceAlertFrontend, len(input.Alerts))
for i, a := range input.Alerts {
rf.Alerts[i] = ResourceAlertFrontend{
ID: a.ID,
Type: a.Type,
Level: a.Level,
Message: a.Message,
Value: a.Value,
Threshold: a.Threshold,
StartTime: a.StartTimeUnix,
}
}
}
// Convert identity
if input.Identity != nil {
rf.Identity = &ResourceIdentityFrontend{
Hostname: input.Identity.Hostname,
MachineID: input.Identity.MachineID,
IPs: input.Identity.IPs,
}
}
return rf
}

View file

@ -446,4 +446,78 @@ type StateFrontend struct {
Stats map[string]any `json:"stats"` // Empty object for now
LastUpdate int64 `json:"lastUpdate"` // Unix timestamp
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` // Global temperature monitoring setting
// Unified resources - the new way to access all monitored entities
Resources []ResourceFrontend `json:"resources,omitempty"`
}
// ResourceFrontend is the frontend representation of a unified Resource.
// This mirrors resources.Resource but with time.Time converted to Unix milliseconds.
type ResourceFrontend struct {
// Identity
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
// Platform/Source
PlatformID string `json:"platformId"`
PlatformType string `json:"platformType"`
SourceType string `json:"sourceType"`
// Hierarchy
ParentID string `json:"parentId,omitempty"`
ClusterID string `json:"clusterId,omitempty"`
// Universal Metrics
Status string `json:"status"`
CPU *ResourceMetricFrontend `json:"cpu,omitempty"`
Memory *ResourceMetricFrontend `json:"memory,omitempty"`
Disk *ResourceMetricFrontend `json:"disk,omitempty"`
Network *ResourceNetworkFrontend `json:"network,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Uptime *int64 `json:"uptime,omitempty"`
// Metadata
Tags []string `json:"tags,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
LastSeen int64 `json:"lastSeen"` // Unix milliseconds
Alerts []ResourceAlertFrontend `json:"alerts,omitempty"`
// Identity for deduplication
Identity *ResourceIdentityFrontend `json:"identity,omitempty"`
// Platform-specific data (JSON blob)
PlatformData map[string]any `json:"platformData,omitempty"`
}
// ResourceMetricFrontend represents a metric value for the frontend.
type ResourceMetricFrontend struct {
Current float64 `json:"current"`
Total *int64 `json:"total,omitempty"`
Used *int64 `json:"used,omitempty"`
Free *int64 `json:"free,omitempty"`
}
// ResourceNetworkFrontend represents network metrics for the frontend.
type ResourceNetworkFrontend struct {
RXBytes int64 `json:"rxBytes"`
TXBytes int64 `json:"txBytes"`
}
// ResourceAlertFrontend represents an alert on a resource.
type ResourceAlertFrontend struct {
ID string `json:"id"`
Type string `json:"type"`
Level string `json:"level"`
Message string `json:"message"`
Value float64 `json:"value"`
Threshold float64 `json:"threshold"`
StartTime int64 `json:"startTime"` // Unix milliseconds
}
// ResourceIdentityFrontend contains identity info for deduplication.
type ResourceIdentityFrontend struct {
Hostname string `json:"hostname,omitempty"`
MachineID string `json:"machineId,omitempty"`
IPs []string `json:"ips,omitempty"`
}

View file

@ -4,6 +4,7 @@ import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
stderrors "errors"
"fmt"
"math"
@ -28,6 +29,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rcourtman/pulse-go-rewrite/internal/resources"
"github.com/rcourtman/pulse-go-rewrite/internal/system"
"github.com/rcourtman/pulse-go-rewrite/internal/tempproxy"
"github.com/rcourtman/pulse-go-rewrite/internal/types"
@ -80,7 +82,7 @@ type PVEClientInterface interface {
GetCephDF(ctx context.Context) (*proxmox.CephDF, error)
}
// ResourceStoreInterface provides methods for polling optimization.
// ResourceStoreInterface provides methods for polling optimization and resource access.
// When an agent is monitoring a node, we can reduce API polling for that node.
type ResourceStoreInterface interface {
// ShouldSkipAPIPolling returns true if API polling should be skipped for the hostname
@ -89,6 +91,8 @@ type ResourceStoreInterface interface {
// GetPollingRecommendations returns a map of hostname -> polling multiplier.
// 0 = skip entirely, 0.5 = half frequency, 1 = normal
GetPollingRecommendations() map[string]float64
// GetAll returns all resources in the store (for WebSocket broadcasts)
GetAll() []resources.Resource
}
func getNodeDisplayName(instance *config.PVEInstance, nodeName string) string {
@ -3285,7 +3289,10 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
Int("physicalDisks", len(state.PhysicalDisks)).
Msg("Broadcasting state update (ticker)")
// Convert to frontend format before broadcasting (converts time.Time to int64, etc.)
wsHub.BroadcastState(state.ToFrontend())
frontendState := state.ToFrontend()
// Inject unified resources if resource store is available
frontendState.Resources = m.getResourcesForBroadcast()
wsHub.BroadcastState(frontendState)
case <-ctx.Done():
log.Info().Msg("Monitoring loop stopped")
@ -6894,7 +6901,9 @@ func (m *Monitor) SetMockMode(enable bool) {
m.mu.RUnlock()
if hub != nil {
hub.BroadcastState(m.GetState().ToFrontend())
frontendState := m.GetState().ToFrontend()
frontendState.Resources = m.getResourcesForBroadcast()
hub.BroadcastState(frontendState)
}
if !enable && ctx != nil && hub != nil {
@ -7048,6 +7057,112 @@ func (m *Monitor) shouldSkipNodeMetrics(nodeName string) bool {
return should
}
// getResourcesForBroadcast retrieves all resources from the store and converts them to frontend format.
// Returns nil if no resource store is configured.
func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend {
m.mu.RLock()
store := m.resourceStore
m.mu.RUnlock()
if store == nil {
return nil
}
allResources := store.GetAll()
if len(allResources) == 0 {
return nil
}
result := make([]models.ResourceFrontend, len(allResources))
for i, r := range allResources {
input := models.ResourceConvertInput{
ID: r.ID,
Type: string(r.Type),
Name: r.Name,
DisplayName: r.DisplayName,
PlatformID: r.PlatformID,
PlatformType: string(r.PlatformType),
SourceType: string(r.SourceType),
ParentID: r.ParentID,
ClusterID: r.ClusterID,
Status: string(r.Status),
Temperature: r.Temperature,
Uptime: r.Uptime,
Tags: r.Tags,
Labels: r.Labels,
LastSeenUnix: r.LastSeen.UnixMilli(),
}
// Convert metrics
if r.CPU != nil {
input.CPU = &models.ResourceMetricInput{
Current: r.CPU.Current,
Total: r.CPU.Total,
Used: r.CPU.Used,
Free: r.CPU.Free,
}
}
if r.Memory != nil {
input.Memory = &models.ResourceMetricInput{
Current: r.Memory.Current,
Total: r.Memory.Total,
Used: r.Memory.Used,
Free: r.Memory.Free,
}
}
if r.Disk != nil {
input.Disk = &models.ResourceMetricInput{
Current: r.Disk.Current,
Total: r.Disk.Total,
Used: r.Disk.Used,
Free: r.Disk.Free,
}
}
if r.Network != nil {
input.HasNetwork = true
input.NetworkRX = r.Network.RXBytes
input.NetworkTX = r.Network.TXBytes
}
// Convert alerts
if len(r.Alerts) > 0 {
input.Alerts = make([]models.ResourceAlertInput, len(r.Alerts))
for j, a := range r.Alerts {
input.Alerts[j] = models.ResourceAlertInput{
ID: a.ID,
Type: a.Type,
Level: a.Level,
Message: a.Message,
Value: a.Value,
Threshold: a.Threshold,
StartTimeUnix: a.StartTime.UnixMilli(),
}
}
}
// Convert identity
if r.Identity != nil {
input.Identity = &models.ResourceIdentityInput{
Hostname: r.Identity.Hostname,
MachineID: r.Identity.MachineID,
IPs: r.Identity.IPs,
}
}
// Convert platform data from json.RawMessage to map
if len(r.PlatformData) > 0 {
var platformMap map[string]any
if err := json.Unmarshal(r.PlatformData, &platformMap); err == nil {
input.PlatformData = platformMap
}
}
result[i] = models.ConvertResourceToFrontend(input)
}
return result
}
// pollStorageBackupsWithNodes polls backups using a provided nodes list to avoid duplicate GetNodes calls
func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName string, client PVEClientInterface, nodes []proxmox.Node, nodeEffectiveStatus map[string]string) {