package api import ( "encoding/json" "io" "net/http" "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) // MonitoredSystemLedgerEntry represents a single counted top-level monitored // system. type MonitoredSystemLedgerEntry struct { Name string `json:"name"` Type string `json:"type"` Status string `json:"status"` // "online", "warning", "offline", "unknown" StatusExplanation MonitoredSystemLedgerStatusExplanation `json:"status_explanation"` LatestIncludedSignal MonitoredSystemLedgerLatestSignal `json:"latest_included_signal"` Source string `json:"source"` Explanation MonitoredSystemLedgerExplanation `json:"explanation"` } type MonitoredSystemLedgerLatestSignal struct { Name string `json:"name"` Type string `json:"type"` Source string `json:"source"` At string `json:"at"` } type MonitoredSystemLedgerStatusExplanation struct { Summary string `json:"summary"` Reasons []MonitoredSystemLedgerStatusReason `json:"reasons"` } type MonitoredSystemLedgerStatusReason struct { Kind string `json:"kind"` Name string `json:"name"` Type string `json:"type"` Source string `json:"source"` Status string `json:"status"` ReportedAt string `json:"reported_at"` Summary string `json:"summary"` } type MonitoredSystemLedgerExplanation struct { Summary string `json:"summary"` Reasons []MonitoredSystemLedgerExplanationReason `json:"reasons"` Surfaces []MonitoredSystemLedgerExplanationSurface `json:"surfaces"` } type MonitoredSystemLedgerExplanationReason struct { Kind string `json:"kind"` Signal string `json:"signal"` Summary string `json:"summary"` } type MonitoredSystemLedgerExplanationSurface struct { Name string `json:"name"` Type string `json:"type"` Source string `json:"source"` } // MonitoredSystemLedgerResponse is the response for GET /api/license/monitored-system-ledger. type MonitoredSystemLedgerResponse struct { Systems []MonitoredSystemLedgerEntry `json:"systems"` Total int `json:"total"` } // MonitoredSystemLedgerExplainRequest describes an optional monitored-system // preview to explain alongside the current counted ledger. type MonitoredSystemLedgerExplainRequest struct { Candidate *MonitoredSystemLedgerPreviewCandidate `json:"candidate"` Replacement *MonitoredSystemLedgerPreviewReplacement `json:"replacement"` } // MonitoredSystemLedgerExplainResponse is the response for // POST /api/license/monitored-system-ledger/explain. type MonitoredSystemLedgerExplainResponse struct { Ledger MonitoredSystemLedgerResponse `json:"ledger"` Preview *MonitoredSystemLedgerPreviewResponse `json:"preview"` } // MonitoredSystemLedgerPreviewRequest describes one prospective monitored-system // add or replacement to project through the canonical grouped ledger. type MonitoredSystemLedgerPreviewRequest struct { Candidate MonitoredSystemLedgerPreviewCandidate `json:"candidate"` Replacement *MonitoredSystemLedgerPreviewReplacement `json:"replacement"` } type MonitoredSystemLedgerPreviewCandidate struct { Source string `json:"source"` Type string `json:"type"` Name string `json:"name"` Hostname string `json:"hostname"` HostURL string `json:"host_url"` AgentID string `json:"agent_id"` MachineID string `json:"machine_id"` ResourceID string `json:"resource_id"` Active *bool `json:"active,omitempty"` } type MonitoredSystemLedgerPreviewReplacement struct { Source string `json:"source"` Name string `json:"name"` Hostname string `json:"hostname"` HostURL string `json:"host_url"` AgentID string `json:"agent_id"` MachineID string `json:"machine_id"` ResourceID string `json:"resource_id"` } // MonitoredSystemLedgerPreviewResponse is the response for // POST /api/license/monitored-system-ledger/preview. type MonitoredSystemLedgerPreviewResponse struct { CurrentCount int `json:"current_count"` ProjectedCount int `json:"projected_count"` AdditionalCount int `json:"additional_count"` Effect string `json:"effect"` CurrentSystems []MonitoredSystemLedgerEntry `json:"current_systems"` ProjectedSystems []MonitoredSystemLedgerEntry `json:"projected_systems"` CurrentSystem *MonitoredSystemLedgerEntry `json:"current_system"` ProjectedSystem *MonitoredSystemLedgerEntry `json:"projected_system"` } func EmptyMonitoredSystemLedgerResponse() MonitoredSystemLedgerResponse { return MonitoredSystemLedgerResponse{}.NormalizeCollections() } func (r MonitoredSystemLedgerResponse) NormalizeCollections() MonitoredSystemLedgerResponse { if r.Systems == nil { r.Systems = []MonitoredSystemLedgerEntry{} } for i := range r.Systems { r.Systems[i] = r.Systems[i].NormalizeCollections() } return r } func (r MonitoredSystemLedgerExplainResponse) NormalizeCollections() MonitoredSystemLedgerExplainResponse { r.Ledger = r.Ledger.NormalizeCollections() if r.Preview != nil { preview := r.Preview.NormalizeCollections() r.Preview = &preview } return r } func (e MonitoredSystemLedgerEntry) NormalizeCollections() MonitoredSystemLedgerEntry { if e.LatestIncludedSignal.Name == "" { e.LatestIncludedSignal.Name = "Unnamed source" } if e.LatestIncludedSignal.Type == "" { e.LatestIncludedSignal.Type = "system" } if e.StatusExplanation.Reasons == nil { e.StatusExplanation.Reasons = []MonitoredSystemLedgerStatusReason{} } if e.Explanation.Reasons == nil { e.Explanation.Reasons = []MonitoredSystemLedgerExplanationReason{} } if e.Explanation.Surfaces == nil { e.Explanation.Surfaces = []MonitoredSystemLedgerExplanationSurface{} } return e } func (r *Router) handleMonitoredSystemLedger(w http.ResponseWriter, req *http.Request) { usage := monitoredSystemUsage(r.getTenantMonitor(req.Context())) if !usage.available { writeMonitoredSystemUsageUnavailable(w, usage.unavailableReason) return } resp := monitoredSystemLedgerResponseFromReadState(usage.readState) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp.NormalizeCollections()) } func (r *Router) handleMonitoredSystemLedgerPreview(w http.ResponseWriter, req *http.Request) { var previewReq MonitoredSystemLedgerPreviewRequest decoder := json.NewDecoder(req.Body) if err := decoder.Decode(&previewReq); err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()}) return } candidate, err := previewReq.Candidate.toUnifiedCandidate() if err != nil { writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) return } monitor := r.getTenantMonitor(req.Context()) usage := monitoredSystemUsage(monitor) if !usage.available { writeMonitoredSystemUsageUnavailable(w, usage.unavailableReason) return } var preview unifiedresources.MonitoredSystemProjectionPreview if previewReq.Replacement != nil { replacement, err := previewReq.Replacement.toUnifiedReplacement(candidate.Source) if err != nil { writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) return } preview = unifiedresources.PreviewMonitoredSystemCandidateReplacement(usage.readState, replacement, candidate) } else { preview = unifiedresources.PreviewMonitoredSystemCandidate(usage.readState, candidate) } if candidate.CountsTowardMonitoredSystems() && len(preview.ProjectedSystems) == 0 { writeErrorResponse(w, http.StatusBadRequest, "validation_error", "Candidate did not resolve to a canonical monitored system preview", nil) return } resp := monitoredSystemLedgerPreviewResponse(previewReq.Replacement != nil, preview) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp.NormalizeCollections()) } func (r *Router) handleMonitoredSystemLedgerExplain(w http.ResponseWriter, req *http.Request) { var explainReq MonitoredSystemLedgerExplainRequest decoder := json.NewDecoder(req.Body) if err := decoder.Decode(&explainReq); err != nil && err != io.EOF { writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", map[string]string{"error": err.Error()}) return } if explainReq.Candidate == nil && explainReq.Replacement != nil { writeErrorResponse(w, http.StatusBadRequest, "validation_error", "candidate is required when replacement is provided", nil) return } usage := monitoredSystemUsage(r.getTenantMonitor(req.Context())) if !usage.available { writeMonitoredSystemUsageUnavailable(w, usage.unavailableReason) return } resp := MonitoredSystemLedgerExplainResponse{ Ledger: monitoredSystemLedgerResponseFromReadState(usage.readState), } if explainReq.Candidate != nil { candidate, err := explainReq.Candidate.toUnifiedCandidate() if err != nil { writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) return } var preview unifiedresources.MonitoredSystemProjectionPreview if explainReq.Replacement != nil { replacement, err := explainReq.Replacement.toUnifiedReplacement(candidate.Source) if err != nil { writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) return } preview = unifiedresources.PreviewMonitoredSystemCandidateReplacement( usage.readState, replacement, candidate, ) } else { preview = unifiedresources.PreviewMonitoredSystemCandidate(usage.readState, candidate) } if candidate.CountsTowardMonitoredSystems() && len(preview.ProjectedSystems) == 0 { writeErrorResponse(w, http.StatusBadRequest, "validation_error", "Candidate did not resolve to a canonical monitored system preview", nil) return } previewResp := monitoredSystemLedgerPreviewResponse( explainReq.Replacement != nil, preview, ).NormalizeCollections() resp.Preview = &previewResp } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp.NormalizeCollections()) } func (r MonitoredSystemLedgerPreviewResponse) NormalizeCollections() MonitoredSystemLedgerPreviewResponse { if r.CurrentSystems == nil { r.CurrentSystems = []MonitoredSystemLedgerEntry{} } if r.ProjectedSystems == nil { r.ProjectedSystems = []MonitoredSystemLedgerEntry{} } for i := range r.CurrentSystems { r.CurrentSystems[i] = r.CurrentSystems[i].NormalizeCollections() } for i := range r.ProjectedSystems { r.ProjectedSystems[i] = r.ProjectedSystems[i].NormalizeCollections() } if r.CurrentSystem != nil { entry := r.CurrentSystem.NormalizeCollections() r.CurrentSystem = &entry } if r.ProjectedSystem != nil { entry := r.ProjectedSystem.NormalizeCollections() r.ProjectedSystem = &entry } return r } func monitoredSystemLedgerEntry(system unifiedresources.MonitoredSystemRecord) MonitoredSystemLedgerEntry { status := normalizeStatus(string(system.Status)) latestIncludedSignal := monitoredSystemLedgerLatestSignal(system.LatestIncludedSignal) return MonitoredSystemLedgerEntry{ Name: system.Name, Type: system.Type, Status: status, StatusExplanation: monitoredSystemLedgerStatusExplanation(system.StatusExplanation, status), LatestIncludedSignal: latestIncludedSignal, Source: system.Source, Explanation: monitoredSystemLedgerExplanation(system.Explanation), } } func monitoredSystemLedgerEntries( systems []unifiedresources.MonitoredSystemRecord, ) []MonitoredSystemLedgerEntry { if len(systems) == 0 { return []MonitoredSystemLedgerEntry{} } entries := make([]MonitoredSystemLedgerEntry, 0, len(systems)) for _, system := range systems { entries = append(entries, monitoredSystemLedgerEntry(system)) } return entries } func monitoredSystemLedgerResponseFromReadState( rs unifiedresources.ReadState, ) MonitoredSystemLedgerResponse { systems := unifiedresources.MonitoredSystems(rs) entries := monitoredSystemLedgerEntries(systems) return MonitoredSystemLedgerResponse{ Systems: entries, Total: len(entries), } } func monitoredSystemLedgerPreviewResponse( hasReplacement bool, preview unifiedresources.MonitoredSystemProjectionPreview, ) MonitoredSystemLedgerPreviewResponse { currentSystems := monitoredSystemLedgerEntries(preview.CurrentSystems) projectedSystems := monitoredSystemLedgerEntries(preview.ProjectedSystems) resp := MonitoredSystemLedgerPreviewResponse{ CurrentCount: preview.CurrentCount, ProjectedCount: preview.ProjectedCount, AdditionalCount: preview.AdditionalCount, Effect: monitoredSystemLedgerPreviewEffect(hasReplacement, preview), CurrentSystems: currentSystems, ProjectedSystems: projectedSystems, } if len(currentSystems) == 1 { entry := currentSystems[0] resp.CurrentSystem = &entry } if len(projectedSystems) == 1 { entry := projectedSystems[0] resp.ProjectedSystem = &entry } return resp } // --------------------------------------------------------------------------- // Status helpers // --------------------------------------------------------------------------- func normalizeStatus(s string) string { switch s { case "online", "warning", "offline", "unknown": return s default: return "unknown" } } func monitoredSystemLedgerStatusExplanation( explanation unifiedresources.MonitoredSystemStatusExplanation, status string, ) MonitoredSystemLedgerStatusExplanation { reasons := make([]MonitoredSystemLedgerStatusReason, 0, len(explanation.Reasons)) for _, reason := range explanation.Reasons { reasons = append(reasons, MonitoredSystemLedgerStatusReason{ Kind: reason.Kind, Name: reason.Name, Type: reason.Type, Source: reason.Source, Status: normalizeMonitoredSystemLedgerReasonStatus(reason.Status), ReportedAt: formatMonitoredSystemTime(reason.ReportedAt), Summary: reason.Summary, }) } summary := explanation.Summary if summary == "" { summary = defaultMonitoredSystemLedgerStatusSummary(status) } return MonitoredSystemLedgerStatusExplanation{ Summary: summary, Reasons: reasons, } } func defaultMonitoredSystemLedgerStatusSummary(status string) string { switch status { case "online": return "All included top-level collection paths currently report online status." case "warning": return "At least one included top-level collection path is degraded, so Pulse marks this monitored system as warning." case "offline": return "At least one included source is offline or disconnected, so Pulse marks this monitored system as offline." default: return "Pulse cannot determine a canonical runtime status for this monitored system yet." } } func normalizeMonitoredSystemLedgerReasonStatus(status string) string { switch status { case "online", "stale", "offline", "unknown": return status default: return "unknown" } } func normalizeMonitoredSystemLedgerSource(source string) string { switch source { case "agent", "availability", "docker", "kubernetes", "pbs", "pmg", "proxmox", "truenas", "vmware": return source default: return "" } } func monitoredSystemLedgerLatestSignal( signal unifiedresources.MonitoredSystemLatestSignal, ) MonitoredSystemLedgerLatestSignal { return MonitoredSystemLedgerLatestSignal{ Name: signal.Name, Type: signal.Type, Source: normalizeMonitoredSystemLedgerSource(signal.Source), At: formatMonitoredSystemTime(signal.At), } } func formatMonitoredSystemTime(t time.Time) string { if t.IsZero() { return "" } return t.UTC().Format(time.RFC3339) } func monitoredSystemLedgerExplanation( explanation unifiedresources.MonitoredSystemGroupingExplanation, ) MonitoredSystemLedgerExplanation { reasons := make([]MonitoredSystemLedgerExplanationReason, 0, len(explanation.Reasons)) for _, reason := range explanation.Reasons { reasons = append(reasons, MonitoredSystemLedgerExplanationReason{ Kind: reason.Kind, Signal: reason.Signal, Summary: reason.Summary, }) } surfaces := make([]MonitoredSystemLedgerExplanationSurface, 0, len(explanation.Surfaces)) for _, surface := range explanation.Surfaces { surfaces = append(surfaces, MonitoredSystemLedgerExplanationSurface{ Name: surface.Name, Type: surface.Type, Source: surface.Source, }) } return MonitoredSystemLedgerExplanation{ Summary: explanation.Summary, Reasons: reasons, Surfaces: surfaces, } } func (c MonitoredSystemLedgerPreviewCandidate) toUnifiedCandidate() (unifiedresources.MonitoredSystemCandidate, error) { source := normalizedMonitoredSystemLedgerDataSource(c.Source) if source == "" { return unifiedresources.MonitoredSystemCandidate{}, errMonitoredSystemLedgerValidation("candidate.source is required") } if strings.TrimSpace(c.Name) == "" && strings.TrimSpace(c.Hostname) == "" && strings.TrimSpace(c.HostURL) == "" && strings.TrimSpace(c.ResourceID) == "" && strings.TrimSpace(c.AgentID) == "" && strings.TrimSpace(c.MachineID) == "" { return unifiedresources.MonitoredSystemCandidate{}, errMonitoredSystemLedgerValidation("candidate must include at least one canonical identifier or display field") } return unifiedresources.MonitoredSystemCandidate{ Source: source, Type: unifiedresources.ResourceType(strings.TrimSpace(c.Type)), Name: strings.TrimSpace(c.Name), Hostname: strings.TrimSpace(c.Hostname), HostURL: strings.TrimSpace(c.HostURL), AgentID: strings.TrimSpace(c.AgentID), MachineID: strings.TrimSpace(c.MachineID), ResourceID: strings.TrimSpace(c.ResourceID), State: monitoredSystemLedgerCandidateState(c.Active), }, nil } func monitoredSystemLedgerCandidateState( active *bool, ) unifiedresources.MonitoredSystemCandidateState { if active != nil && !*active { return unifiedresources.MonitoredSystemCandidateStateInactive } return unifiedresources.MonitoredSystemCandidateStateActive } func (r MonitoredSystemLedgerPreviewReplacement) toUnifiedReplacement( fallbackSource unifiedresources.DataSource, ) (unifiedresources.MonitoredSystemReplacement, error) { source := normalizedMonitoredSystemLedgerDataSource(r.Source) if source == "" { source = fallbackSource } if source == "" { return unifiedresources.MonitoredSystemReplacement{}, errMonitoredSystemLedgerValidation("replacement.source is required") } if strings.TrimSpace(r.Name) == "" && strings.TrimSpace(r.Hostname) == "" && strings.TrimSpace(r.HostURL) == "" && strings.TrimSpace(r.ResourceID) == "" && strings.TrimSpace(r.AgentID) == "" && strings.TrimSpace(r.MachineID) == "" { return unifiedresources.MonitoredSystemReplacement{}, errMonitoredSystemLedgerValidation("replacement must include at least one canonical selector field") } return unifiedresources.MonitoredSystemReplacement{ Source: source, Selector: unifiedresources.MonitoredSystemReplacementSelector{ Name: strings.TrimSpace(r.Name), Hostname: strings.TrimSpace(r.Hostname), HostURL: strings.TrimSpace(r.HostURL), AgentID: strings.TrimSpace(r.AgentID), MachineID: strings.TrimSpace(r.MachineID), ResourceID: strings.TrimSpace(r.ResourceID), }, }, nil } func normalizedMonitoredSystemLedgerDataSource(source string) unifiedresources.DataSource { normalized := normalizeMonitoredSystemLedgerSource(strings.ToLower(strings.TrimSpace(source))) if normalized == "" { return "" } return unifiedresources.DataSource(normalized) } func monitoredSystemLedgerPreviewEffect( hasReplacement bool, preview unifiedresources.MonitoredSystemProjectionPreview, ) string { currentCount := len(preview.CurrentSystems) projectedCount := len(preview.ProjectedSystems) if !hasReplacement { if currentCount == 0 && projectedCount == 0 { return "no_change" } if preview.AdditionalCount == 0 && currentCount > 0 { return "attaches_existing" } if projectedCount > 1 && currentCount > 0 { return "mixed_existing_and_new" } if projectedCount > 1 { return "creates_multiple" } return "creates_new" } if currentCount > 0 && projectedCount == 0 { if currentCount > 1 { return "removes_multiple" } return "removes_existing" } if currentCount == 0 { if projectedCount == 0 { return "no_change" } if projectedCount > 1 { return "creates_multiple" } return "creates_new" } if preview.AdditionalCount > 0 { return "splits_existing" } if currentCount > 1 || projectedCount > 1 { return "replaces_multiple" } return "replaces_existing" } func errMonitoredSystemLedgerValidation(message string) error { return monitoredSystemLedgerValidationError(message) } type monitoredSystemLedgerValidationError string func (e monitoredSystemLedgerValidationError) Error() string { return string(e) }