Files
pulse/pkg/reporting/fleet_narrative.go
T
rcourtman 112ce030d7 Stop reports and resource history calling a moved alert recovered
A node alert that moves to its linked Pulse agent closes with a
moved_to_agent resolution (#2580), and #2591 taught Patrol, alert
history and the assistant to say so. Two readers still showed the
close as a recovery.

Performance reports dropped the resolution when copying recently
resolved alerts into reporting.AlertInfo. A node report generated in
the five-minute window after a handover read HEALTHY, "All systems
operating normally", counted the alert as triggered and resolved, and
told the AI narrator resolved: true, although nothing had seen the
condition clear. Report rows now carry the resolution and the alert's
resource. pkg/reporting treats an alert that closed without recovering
as still live unless the report also shows its successor's reading for
that metric (an open alert, or a recovery since the handover), so a
handover never reads as healthy and one condition is counted once;
fleet reports match handovers across all their resources. The PDF
card, Quick Stats, active list and heuristic narrative say where the
alert went and to check the agent, the alerts table shows "Moved" with
the engine's summary, fleets count it as active and name the agent,
and the narrator gets resolved: false, the summary and whether the
successor's alert is listed, with prompt rules for both.

Resource change readers labelled every alert_resolved change "Alert
resolved" in green and prefixed the headline with it, so a moved close
read "Alert resolved: Alert moved to pve1 (Host Agent). This is not a
recovery...". getResourceChangePresentation now reads the
alert_resolution metadata: such a change is "Alert moved" in the
neutral blue of the history badge, and its summary is the whole
headline in the drawer, ResourceChangeSummary and Patrol's assessment
handoff.

Reports requested as agent, which is how the Reports page addresses
every Proxmox node, still carry no alert rows at all, and node alert
history is stored under legacy node IDs that the drawer never queries;
both are separate fixes.
2026-10-06 20:11:04 +01:00

516 lines
16 KiB
Go

package reporting
import (
"context"
"fmt"
"sort"
)
// FleetNarrativeInput is the cross-resource view passed to a FleetNarrator.
// It is denormalised from MultiReportData so AI implementations do not
// need to traverse internal structures.
type FleetNarrativeInput struct {
Title string
Period TimeRange
PriorPeriod *TimeRange
Resources []FleetResourceSummary
Aggregate FleetAggregate
}
// FleetResourceSummary is a compact per-resource snapshot — the same
// numbers the deterministic fleet table renders, plus alert/finding
// counts. Heavy time-series data is deliberately not included; the
// renderer keeps the chart data, the narrator only needs the rollups.
type FleetResourceSummary struct {
ResourceID string
ResourceName string
ResourceType string
Status string
AvgCPU float64
MaxCPU float64
AvgMemory float64
MaxMemory float64
AvgDisk float64
MaxDisk float64
ActiveAlerts int
CriticalAlerts int
ResolvedAlerts int
UnhealthyDisks int
StoragePoolsHigh int
Findings int
// MovedTo names the Pulse agents this resource's unresolved alerts
// moved to without recovering; those alerts also count as active.
MovedTo []string
}
// FleetAggregate is the fleet-wide rollup. Means are means-of-means:
// each resource contributes one observation per metric, regardless of
// its weight or sample count. Sysadmins reading a fleet report want
// "is anything in this fleet hot?" not a sample-weighted mean.
type FleetAggregate struct {
ResourceCount int
TotalActiveAlerts int
TotalCriticalAlerts int
TotalResolvedAlerts int
TotalFindings int
AvgCPUMean float64
AvgMemoryMean float64
AvgDiskMean float64
MaxCPUSeen float64
MaxMemorySeen float64
MaxDiskSeen float64
}
// FleetNarrative is the cross-resource interpretation rendered into
// the fleet summary cover. Source values mirror Narrative. JSON tags
// keep the shape consistent with NarrativeBullet — without them the
// chat tool returns capital-letter keys for the same fields that
// appear lowercase in the AI narrator's system prompt schema.
type FleetNarrative struct {
Source string `json:"source"`
HealthStatus string `json:"health_status"`
HealthMessage string `json:"health_message"`
ExecutiveSummary string `json:"executive_summary,omitempty"`
Outliers []FleetOutlier `json:"outliers,omitempty"`
Patterns []NarrativeBullet `json:"patterns,omitempty"`
Recommendations []string `json:"recommendations,omitempty"`
PeriodComparison string `json:"period_comparison,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
}
// FleetOutlier names a single resource the operator should pay
// attention to and why. The Reason is rendered verbatim in the PDF.
type FleetOutlier struct {
ResourceID string `json:"resource_id"`
ResourceName string `json:"resource_name,omitempty"`
Reason string `json:"reason"`
Severity string `json:"severity"`
}
// FleetNarrator produces a FleetNarrative from a FleetNarrativeInput.
type FleetNarrator interface {
NarrateFleet(ctx context.Context, in FleetNarrativeInput) (FleetNarrative, error)
}
// narrateFleet is the helper used by the engine: tries the supplied
// fleet narrator (typically AI), falls back to the heuristic on
// nil/error so a fleet report always has narrative.
func narrateFleet(ctx context.Context, n FleetNarrator, in FleetNarrativeInput) FleetNarrative {
if n != nil {
out, err := n.NarrateFleet(ctx, in)
if err == nil {
if out.Source == "" {
out.Source = NarrativeSourceAI
}
return out
}
}
heuristic := HeuristicFleetNarrator{}
out, _ := heuristic.NarrateFleet(ctx, in)
out.Source = NarrativeSourceHeuristic
return out
}
// HeuristicFleetNarrator is the deterministic fleet fallback. It does
// not invent synthesis — it picks the worst outliers by hard threshold,
// counts cross-cutting patterns, and emits aggregated recommendations.
type HeuristicFleetNarrator struct{}
// NarrateFleet implements FleetNarrator. It never returns an error.
func (HeuristicFleetNarrator) NarrateFleet(_ context.Context, in FleetNarrativeInput) (FleetNarrative, error) {
return FleetNarrative{
Source: NarrativeSourceHeuristic,
HealthStatus: fleetHeuristicHealthStatus(in),
HealthMessage: fleetHeuristicHealthMessage(in),
Outliers: fleetHeuristicOutliers(in),
Patterns: fleetHeuristicPatterns(in),
Recommendations: fleetHeuristicRecommendations(in),
}, nil
}
func fleetHeuristicHealthStatus(in FleetNarrativeInput) string {
switch {
case in.Aggregate.TotalCriticalAlerts > 0:
return "CRITICAL"
case in.Aggregate.TotalActiveAlerts > 0:
return "WARNING"
default:
return "HEALTHY"
}
}
func fleetHeuristicHealthMessage(in FleetNarrativeInput) string {
a := in.Aggregate
switch {
case a.TotalCriticalAlerts == 1:
return "1 critical alert across the fleet requires immediate attention"
case a.TotalCriticalAlerts > 1:
return fmt.Sprintf("%d critical alerts across the fleet require immediate attention", a.TotalCriticalAlerts)
case a.TotalActiveAlerts == 1:
return "1 active alert across the fleet - review recommended"
case a.TotalActiveAlerts > 1:
return fmt.Sprintf("%d active alerts across the fleet - review recommended", a.TotalActiveAlerts)
case a.ResourceCount == 0:
return "No resources in this fleet report"
default:
return fmt.Sprintf("All %d resources operating normally", a.ResourceCount)
}
}
// fleetHeuristicOutliers picks up to fleetMaxOutliers resources that
// most warrant operator attention, ordered by severity then magnitude.
const fleetMaxOutliers = 5
func fleetHeuristicOutliers(in FleetNarrativeInput) []FleetOutlier {
type scored struct {
outlier FleetOutlier
rank int // higher = render first
}
var pool []scored
for _, r := range in.Resources {
if r.CriticalAlerts > 0 {
pool = append(pool, scored{
outlier: FleetOutlier{
ResourceID: r.ResourceID,
ResourceName: displayResourceName(r),
Reason: fmt.Sprintf("%d critical alert(s) active", r.CriticalAlerts),
Severity: NarrativeSeverityCritical,
},
rank: 1000 + r.CriticalAlerts,
})
}
if r.UnhealthyDisks > 0 {
pool = append(pool, scored{
outlier: FleetOutlier{
ResourceID: r.ResourceID,
ResourceName: displayResourceName(r),
Reason: fmt.Sprintf("%d disk(s) failing or near end of life", r.UnhealthyDisks),
Severity: NarrativeSeverityCritical,
},
rank: 900 + r.UnhealthyDisks,
})
}
if r.StoragePoolsHigh > 0 {
pool = append(pool, scored{
outlier: FleetOutlier{
ResourceID: r.ResourceID,
ResourceName: displayResourceName(r),
Reason: fmt.Sprintf("%d storage pool(s) above 90%% capacity", r.StoragePoolsHigh),
Severity: NarrativeSeverityWarning,
},
rank: 700 + r.StoragePoolsHigh,
})
}
if r.AvgMemory > 85 {
pool = append(pool, scored{
outlier: FleetOutlier{
ResourceID: r.ResourceID,
ResourceName: displayResourceName(r),
Reason: fmt.Sprintf("Memory averaging %.1f%% - sustained pressure", r.AvgMemory),
Severity: NarrativeSeverityWarning,
},
rank: 500 + int(r.AvgMemory),
})
}
if r.MaxCPU > 90 {
pool = append(pool, scored{
outlier: FleetOutlier{
ResourceID: r.ResourceID,
ResourceName: displayResourceName(r),
Reason: fmt.Sprintf("CPU peaked at %.1f%%", r.MaxCPU),
Severity: NarrativeSeverityWarning,
},
rank: 400 + int(r.MaxCPU),
})
}
if r.AvgDisk > 85 {
pool = append(pool, scored{
outlier: FleetOutlier{
ResourceID: r.ResourceID,
ResourceName: displayResourceName(r),
Reason: fmt.Sprintf("Disk usage averaging %.1f%%", r.AvgDisk),
Severity: NarrativeSeverityWarning,
},
rank: 300 + int(r.AvgDisk),
})
}
if r.ActiveAlerts > 0 && r.CriticalAlerts == 0 {
pool = append(pool, scored{
outlier: FleetOutlier{
ResourceID: r.ResourceID,
ResourceName: displayResourceName(r),
Reason: fmt.Sprintf("%d non-critical alert(s) active", r.ActiveAlerts),
Severity: NarrativeSeverityInfo,
},
rank: 100 + r.ActiveAlerts,
})
}
}
sort.SliceStable(pool, func(i, j int) bool { return pool[i].rank > pool[j].rank })
if len(pool) > fleetMaxOutliers {
pool = pool[:fleetMaxOutliers]
}
out := make([]FleetOutlier, 0, len(pool))
seen := make(map[string]struct{}, len(pool))
for _, s := range pool {
key := s.outlier.ResourceID + "|" + s.outlier.Reason
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, s.outlier)
}
return out
}
// fleetHeuristicPatterns expresses cross-cutting trends as count-based
// observations — "X of Y resources show memory pressure," not synthesis.
// These are the kind of summary lines the AI narrator can do better,
// but the heuristic must produce something useful when AI is off.
func fleetHeuristicPatterns(in FleetNarrativeInput) []NarrativeBullet {
if in.Aggregate.ResourceCount == 0 {
return []NarrativeBullet{{
Text: "No resources reported metrics in the selected window",
Severity: NarrativeSeverityInfo,
}}
}
var out []NarrativeBullet
memHigh := 0
cpuHigh := 0
diskHigh := 0
for _, r := range in.Resources {
if r.AvgMemory > 85 {
memHigh++
}
if r.MaxCPU > 90 {
cpuHigh++
}
if r.AvgDisk > 85 {
diskHigh++
}
}
if memHigh > 0 {
out = append(out, NarrativeBullet{
Text: fmt.Sprintf("%d of %d resources show sustained memory pressure (>85%% avg)", memHigh, in.Aggregate.ResourceCount),
Severity: severityFromCount(memHigh, in.Aggregate.ResourceCount),
})
}
if cpuHigh > 0 {
out = append(out, NarrativeBullet{
Text: fmt.Sprintf("%d of %d resources hit CPU peaks above 90%%", cpuHigh, in.Aggregate.ResourceCount),
Severity: severityFromCount(cpuHigh, in.Aggregate.ResourceCount),
})
}
if diskHigh > 0 {
out = append(out, NarrativeBullet{
Text: fmt.Sprintf("%d of %d resources are above 85%% disk usage", diskHigh, in.Aggregate.ResourceCount),
Severity: severityFromCount(diskHigh, in.Aggregate.ResourceCount),
})
}
if in.Aggregate.TotalResolvedAlerts > 0 {
out = append(out, NarrativeBullet{
Text: fmt.Sprintf("%d alerts triggered and resolved across the fleet during this period", in.Aggregate.TotalResolvedAlerts),
Severity: NarrativeSeverityInfo,
})
}
// Open alerts, including ones that moved to an agent without
// recovering, rule out the all-clear even when no metric pattern fired.
if len(out) == 0 && in.Aggregate.TotalActiveAlerts == 0 {
out = append(out, NarrativeBullet{
Text: fmt.Sprintf("Fleet of %d resources operating within nominal thresholds", in.Aggregate.ResourceCount),
Severity: NarrativeSeverityOK,
})
}
return out
}
func severityFromCount(hot, total int) string {
if total <= 0 {
return NarrativeSeverityInfo
}
ratio := float64(hot) / float64(total)
switch {
case ratio >= 0.5:
return NarrativeSeverityCritical
case ratio >= 0.25:
return NarrativeSeverityWarning
default:
return NarrativeSeverityInfo
}
}
func fleetHeuristicRecommendations(in FleetNarrativeInput) []string {
var recs []string
a := in.Aggregate
if a.TotalCriticalAlerts > 0 {
recs = append(recs, "Investigate and resolve critical alerts across the fleet immediately")
}
memHigh := 0
cpuHigh := 0
diskHigh := 0
disksHigh := 0
for _, r := range in.Resources {
if r.AvgMemory > 85 {
memHigh++
}
if r.MaxCPU > 90 {
cpuHigh++
}
if r.AvgDisk > 85 {
diskHigh++
}
if r.UnhealthyDisks > 0 {
disksHigh++
}
}
if memHigh > 0 {
recs = append(recs, "Review memory allocation on resources with sustained pressure (>85% avg)")
}
if cpuHigh > 0 {
recs = append(recs, "Profile CPU-intensive workloads on resources peaking above 90%")
}
if diskHigh > 0 {
recs = append(recs, "Plan capacity expansion for resources above 85% disk usage")
}
if disksHigh > 0 {
recs = append(recs, "Replace failing or end-of-life disks before they cause outage")
}
seenSuccessors := make(map[string]struct{})
for _, r := range in.Resources {
for _, successor := range r.MovedTo {
if _, ok := seenSuccessors[successor]; ok {
continue
}
seenSuccessors[successor] = struct{}{}
recs = append(recs, fmt.Sprintf("Check the current reading on %s - an alert moved there without recovering", successor))
}
}
if len(recs) == 0 && a.TotalActiveAlerts > 0 {
recs = append(recs, "Review the active alerts on the resources listed above")
}
if len(recs) == 0 {
recs = append(recs, "No fleet-wide action required - continue routine monitoring")
}
return recs
}
func displayResourceName(r FleetResourceSummary) string {
if r.ResourceName != "" {
return r.ResourceName
}
return r.ResourceID
}
// buildFleetNarrativeInput collapses MultiReportData into the compact
// FleetNarrativeInput the narrator consumes. Per-resource MetricStats
// are reduced to the means/maxes the renderer surfaces; raw time-series
// stays in MultiReportData for the deterministic charts.
func buildFleetNarrativeInput(data *MultiReportData) FleetNarrativeInput {
if data == nil {
return FleetNarrativeInput{}
}
in := FleetNarrativeInput{
Title: data.Title,
Period: TimeRange{Start: data.Start, End: data.End},
Resources: make([]FleetResourceSummary, 0, len(data.Resources)),
}
var sumCPU, sumMem, sumDisk float64
var countCPU, countMem, countDisk int
fleetAlerts := data.fleetAlerts()
for _, rd := range data.Resources {
if rd == nil {
continue
}
summary := FleetResourceSummary{
ResourceID: rd.ResourceID,
ResourceType: rd.ResourceType,
Findings: len(rd.Findings),
}
if rd.Resource != nil {
summary.ResourceName = rd.Resource.Name
summary.Status = rd.Resource.Status
}
if stats, ok := rd.Summary.ByMetric["cpu"]; ok {
summary.AvgCPU = stats.Avg
summary.MaxCPU = stats.Max
sumCPU += stats.Avg
countCPU++
if stats.Max > in.Aggregate.MaxCPUSeen {
in.Aggregate.MaxCPUSeen = stats.Max
}
}
if stats, ok := rd.Summary.ByMetric["memory"]; ok {
summary.AvgMemory = stats.Avg
summary.MaxMemory = stats.Max
sumMem += stats.Avg
countMem++
if stats.Max > in.Aggregate.MaxMemorySeen {
in.Aggregate.MaxMemorySeen = stats.Max
}
}
diskKey := "disk"
if _, ok := rd.Summary.ByMetric["disk"]; !ok {
diskKey = "usage"
}
if stats, ok := rd.Summary.ByMetric[diskKey]; ok {
summary.AvgDisk = stats.Avg
summary.MaxDisk = stats.Max
sumDisk += stats.Avg
countDisk++
if stats.Max > in.Aggregate.MaxDiskSeen {
in.Aggregate.MaxDiskSeen = stats.Max
}
}
for _, alert := range rd.Alerts {
if alert.Recovered() {
summary.ResolvedAlerts++
in.Aggregate.TotalResolvedAlerts++
}
}
// An alert that moved without recovering stays active until the
// fleet report lists its successor's own alert.
unresolved := unresolvedAlertsAmong(rd.Alerts, fleetAlerts)
summary.MovedTo = movedAlertSuccessors(unresolved)
for _, alert := range unresolved {
summary.ActiveAlerts++
in.Aggregate.TotalActiveAlerts++
if alert.Level == "critical" {
summary.CriticalAlerts++
in.Aggregate.TotalCriticalAlerts++
}
}
for _, disk := range rd.Disks {
if disk.Health == "FAILED" || (disk.WearLevel > 0 && disk.WearLevel <= 30) {
summary.UnhealthyDisks++
}
}
for _, st := range rd.Storage {
if st.UsagePerc >= 90 {
summary.StoragePoolsHigh++
}
}
in.Aggregate.TotalFindings += summary.Findings
in.Resources = append(in.Resources, summary)
}
in.Aggregate.ResourceCount = len(in.Resources)
if countCPU > 0 {
in.Aggregate.AvgCPUMean = sumCPU / float64(countCPU)
}
if countMem > 0 {
in.Aggregate.AvgMemoryMean = sumMem / float64(countMem)
}
if countDisk > 0 {
in.Aggregate.AvgDiskMean = sumDisk / float64(countDisk)
}
return in
}