Pulse/internal/api/resource_handlers.go
rcourtman 5f000b7974 feat: Complete Unified Resource Architecture (Phases 1-3)
This commit implements the Unified Resource Architecture for AI-first
infrastructure management. Key features:

Phase 1 - Backend Unification:
- New unified Resource type with 9 resource types, 7 platforms, 7 statuses
- Resource store with identity-based deduplication (hostname, machineID, IP)
- 8 converter functions (FromNode, FromVM, FromContainer, etc.)
- REST API endpoints: /api/resources, /api/resources/stats, /api/resources/{id}
- 28 comprehensive unit tests

Phase 2 - AI Context Enhancement:
- Unified context builder for AI system prompts
- Cross-platform query methods: GetTopByCPU, GetTopByMemory, GetTopByDisk
- Resource correlation: GetRelated (parent, children, siblings, cluster)
- Infrastructure summary: GetResourceSummary with health status counts
- AI context now includes top consumers and infrastructure overview

Phase 3 - Agent Preference & Hybrid Mode:
- Polling optimization methods in resource store
- ResourceStoreInterface added to Monitor
- SetResourceStore() and shouldSkipNodeMetrics() helper methods
- Store automatically wired into Monitor via Router.SetMonitor()
- Foundation ready for reduced API polling when agents are active

Files added:
- internal/resources/resource.go - Core Resource type
- internal/resources/store.go - Store with deduplication
- internal/resources/converters.go - Type converters
- internal/resources/platform_data.go - Platform-specific data
- internal/resources/store_test.go - 28 tests
- internal/resources/converters_test.go - Converter tests
- internal/api/resource_handlers.go - REST API handlers
- internal/ai/resource_context.go - AI context builder
- .gemini/docs/unified-resource-architecture.md - Architecture docs

All tests pass.
2025-12-07 13:49:00 +00:00

237 lines
6.1 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/resources"
)
// ResourceHandlers provides HTTP handlers for the unified resource API.
type ResourceHandlers struct {
store *resources.Store
}
// NewResourceHandlers creates resource handlers with a new store.
func NewResourceHandlers() *ResourceHandlers {
return &ResourceHandlers{
store: resources.NewStore(),
}
}
// Store returns the underlying resource store for populating from the monitor.
func (h *ResourceHandlers) Store() *resources.Store {
return h.store
}
// HandleGetResources returns all resources, optionally filtered by query params.
// GET /api/resources
// Query params:
// - type: filter by resource type (comma-separated)
// - platform: filter by platform type (comma-separated)
// - status: filter by status (comma-separated)
// - parent: filter by parent ID
// - infrastructure: if "true", only return infrastructure resources
// - workloads: if "true", only return workload resources
func (h *ResourceHandlers) HandleGetResources(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
query := h.store.Query()
// Parse type filter
if typeParam := r.URL.Query().Get("type"); typeParam != "" {
types := parseResourceTypes(typeParam)
if len(types) > 0 {
query = query.OfType(types...)
}
}
// Parse platform filter
if platformParam := r.URL.Query().Get("platform"); platformParam != "" {
platforms := parsePlatformTypes(platformParam)
if len(platforms) > 0 {
query = query.FromPlatform(platforms...)
}
}
// Parse status filter
if statusParam := r.URL.Query().Get("status"); statusParam != "" {
statuses := parseStatuses(statusParam)
if len(statuses) > 0 {
query = query.WithStatus(statuses...)
}
}
// Parse parent filter
if parentID := r.URL.Query().Get("parent"); parentID != "" {
query = query.WithParent(parentID)
}
// Parse alerts filter
if r.URL.Query().Get("alerts") == "true" {
query = query.WithAlerts()
}
// Execute query
var result []resources.Resource
// Handle infrastructure/workloads shortcut
if r.URL.Query().Get("infrastructure") == "true" {
result = h.store.GetInfrastructure()
} else if r.URL.Query().Get("workloads") == "true" {
result = h.store.GetWorkloads()
} else {
result = query.Execute()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ResourcesResponse{
Resources: result,
Count: len(result),
Stats: h.store.GetStats(),
})
}
// HandleGetResource returns a single resource by ID.
// GET /api/resources/{id}
func (h *ResourceHandlers) HandleGetResource(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract ID from path: /api/resources/{id}
path := strings.TrimPrefix(r.URL.Path, "/api/resources/")
if path == "" || path == "/" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
resource, ok := h.store.Get(path)
if !ok {
http.Error(w, "Resource not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resource)
}
// HandleGetResourceStats returns statistics about the resource store.
// GET /api/resources/stats
func (h *ResourceHandlers) HandleGetResourceStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
stats := h.store.GetStats()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
// ResourcesResponse is the response for /api/resources.
type ResourcesResponse struct {
Resources []resources.Resource `json:"resources"`
Count int `json:"count"`
Stats resources.StoreStats `json:"stats"`
}
// PopulateFromState converts all resources from a StateSnapshot to the unified store.
// This should be called whenever the state is updated.
func (h *ResourceHandlers) PopulateFromSnapshot(snapshot models.StateSnapshot) {
// Convert nodes
for _, node := range snapshot.Nodes {
r := resources.FromNode(node)
h.store.Upsert(r)
}
// Convert VMs
for _, vm := range snapshot.VMs {
r := resources.FromVM(vm)
h.store.Upsert(r)
}
// Convert containers
for _, ct := range snapshot.Containers {
r := resources.FromContainer(ct)
h.store.Upsert(r)
}
// Convert hosts
for _, host := range snapshot.Hosts {
r := resources.FromHost(host)
h.store.Upsert(r)
}
// Convert docker hosts and their containers
for _, dh := range snapshot.DockerHosts {
r := resources.FromDockerHost(dh)
h.store.Upsert(r)
// Convert containers within the docker host
for _, dc := range dh.Containers {
r := resources.FromDockerContainer(dc, dh.ID, dh.Hostname)
h.store.Upsert(r)
}
}
// Convert PBS instances
for _, pbs := range snapshot.PBSInstances {
r := resources.FromPBSInstance(pbs)
h.store.Upsert(r)
}
// Convert storage
for _, storage := range snapshot.Storage {
r := resources.FromStorage(storage)
h.store.Upsert(r)
}
}
// Helper functions for parsing query parameters
func parseResourceTypes(s string) []resources.ResourceType {
parts := strings.Split(s, ",")
var result []resources.ResourceType
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
result = append(result, resources.ResourceType(p))
}
return result
}
func parsePlatformTypes(s string) []resources.PlatformType {
parts := strings.Split(s, ",")
var result []resources.PlatformType
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
result = append(result, resources.PlatformType(p))
}
return result
}
func parseStatuses(s string) []resources.ResourceStatus {
parts := strings.Split(s, ",")
var result []resources.ResourceStatus
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
result = append(result, resources.ResourceStatus(p))
}
return result
}