Files
pulse/internal/api/reporting_availability.go
T
rcourtman 37454ef478 Schedule the existing VM inventory CSV export
Reuse the canonical inventory exporter and report delivery workflow so
operators can email or save a current organization-wide inventory weekly
or monthly. Keep delivery checks, bounded retries and recovery states
shared, with existing pricing and customer entitlements unchanged.

Refresh the shared inventory for unattended reads and rebind reporting
after monitor reload so schedules use the current infrastructure state.
2026-10-17 14:24:56 +01:00

166 lines
5.1 KiB
Go

package api
import (
"sort"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
)
// reportAvailabilityChangeLimit bounds how many timeline rows a single
// report subject pulls. Production resources record a handful of state
// transitions per month; a resource that exceeds this is flapping hard
// enough that the truncated window still tells the honest story.
const reportAvailabilityChangeLimit = 20000
// availabilityState classifies a recorded resource state for uptime math.
type availabilityState int
const (
availabilityStateUnobserved availabilityState = iota
availabilityStateUp
availabilityStateDown
)
// classifyAvailabilityState maps the canonical resource state vocabulary
// (online / warning / offline / unknown plus the synthetic "absent" emitted
// when a resource enters or leaves the registry) onto uptime semantics.
// Warning counts as up: the resource is reachable and serving, just
// unhealthy. Absent and unknown are unobserved - a monitoring gap is not an
// outage. Unrecognized future states default to up because a client-facing
// stability report claiming false downtime is worse than missing an exotic
// down state.
func classifyAvailabilityState(state string) availabilityState {
switch strings.ToLower(strings.TrimSpace(state)) {
case "", "absent", "unknown":
return availabilityStateUnobserved
case "offline":
return availabilityStateDown
default:
return availabilityStateUp
}
}
func availabilityChangeTime(change unifiedresources.ResourceChange) time.Time {
if change.OccurredAt != nil && !change.OccurredAt.IsZero() {
return *change.OccurredAt
}
return change.ObservedAt
}
// computeReportAvailability derives an availability summary for one report
// subject from its recorded state timeline. The walk reconstructs the state
// for every moment of [start, end]: the state before the first in-window
// transition is that transition's From; with no transitions at all the
// resource sat in currentState for the whole window (any change would have
// been journaled).
func computeReportAvailability(changes []unifiedresources.ResourceChange, currentState string, start, end time.Time) *reporting.AvailabilityInfo {
if !end.After(start) {
return nil
}
transitions := make([]unifiedresources.ResourceChange, 0, len(changes))
for _, change := range changes {
if change.Kind != unifiedresources.ChangeStateTransition {
continue
}
at := availabilityChangeTime(change)
if at.Before(start) || at.After(end) {
continue
}
transitions = append(transitions, change)
}
sort.SliceStable(transitions, func(i, j int) bool {
return availabilityChangeTime(transitions[i]).Before(availabilityChangeTime(transitions[j]))
})
initialState := currentState
if len(transitions) > 0 {
initialState = transitions[0].From
}
var up, down time.Duration
var downIncidents int
var longestOutage, currentOutage time.Duration
accumulate := func(state availabilityState, d time.Duration) {
if d <= 0 {
return
}
switch state {
case availabilityStateUp:
up += d
case availabilityStateDown:
down += d
currentOutage += d
}
if state != availabilityStateDown {
if currentOutage > longestOutage {
longestOutage = currentOutage
}
currentOutage = 0
}
}
cursor := start
state := classifyAvailabilityState(initialState)
for _, change := range transitions {
at := availabilityChangeTime(change)
accumulate(state, at.Sub(cursor))
next := classifyAvailabilityState(change.To)
if next == availabilityStateDown && state != availabilityStateDown {
downIncidents++
}
state = next
cursor = at
}
accumulate(state, end.Sub(cursor))
if currentOutage > longestOutage {
longestOutage = currentOutage
}
window := end.Sub(start)
observed := up + down
info := &reporting.AvailabilityInfo{
ObservedPercent: 100 * float64(observed) / float64(window),
TotalDowntime: down,
LongestOutage: longestOutage,
DownIncidents: downIncidents,
}
if observed > 0 {
info.UptimePercent = 100 * float64(up) / float64(observed)
}
return info
}
// resolveReportAvailability attaches the availability summary for the report
// subject. The change timeline is keyed by the canonical unified resource ID
// (not the metrics-target ID), so this runs against req.ResourceID.
func (h *ReportingHandlers) resolveReportAvailability(orgID string, req *reporting.MetricReportRequest, snapshot reportingEnrichmentSnapshot, start, end time.Time) {
manager := h.reportMonitorManager()
if manager == nil || req == nil {
return
}
currentState := ""
for i := range snapshot.Resources {
if snapshot.Resources[i].ID == req.ResourceID {
currentState = string(snapshot.Resources[i].Status)
break
}
}
if currentState == "" {
// Unknown to the unified registry: no timeline to report against.
return
}
monitor, err := manager.GetMonitor(orgID)
if err != nil || monitor == nil {
return
}
changes := monitor.RecentResourceChanges(req.ResourceID, start, reportAvailabilityChangeLimit)
req.Availability = computeReportAvailability(changes, currentState, start, end)
}