Files
pulse/pkg/reporting/fleet_narrative.go
T
rcourtman e6c0c4d385 fix(reporting): resolve unified resource IDs to metrics targets and render honest report subjects
Performance reports were structurally disconnected from the v6 ID
space: the UI (and any API caller working from /api/state) addresses
resources by canonical unified ID, while the metrics store is keyed by
each platform's native source ID (the resource's metricsTarget). The
engine queried the store with the unified ID verbatim, so every report
rendered 'Data Points: 0' regardless of how much history existed, and
covers showed raw hash IDs a report reader cannot map to a machine.

- MetricReportRequest gains MetricsResourceID: handlers resolve the
  unified ID through the tenant monitor's resource store (new
  Monitor.MetricsTargetForResource accessor; the registry computes
  targets on demand, they are not persisted on snapshot structs) and
  the engine uses it for store queries only. Recovery points and
  Patrol findings stay keyed by the unified ID.
- Legacy snapshot models and their alerts are keyed by the metrics
  target ID, so enrichment now matches either ID space and resource
  names/status resolve again on covers, headers, and fleet rows.
- Fleet summaries mirror the single-report guard: zero data points
  across the fleet renders a muted NO DATA card instead of a green
  HEALTHY 'All systems operating normally' - false reassurance is the
  worst failure mode for a client-facing stability report.
- Em dashes in PDF-bound literals become hyphens; fpdf core fonts are
  cp1252 and rendered them as mojibake.
2026-06-10 17:10:30 +01:00

492 lines
15 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
}
// 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,
})
}
if len(out) == 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")
}
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
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.ResolvedTime != nil {
summary.ResolvedAlerts++
in.Aggregate.TotalResolvedAlerts++
continue
}
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
}