Files
pulse/internal/api/router_helpers.go
T
rcourtman 415c09dafc Judge resources API freshness by the monitor's thresholds
The resources API rebuilds its registry from the monitor's unified
seed, but built it with NewRegistry and plain ingests, so its stale
pass used the registry defaults while the monitor judges each source
by its configured polling cadence. A Proxmox node polled every two
minutes read stale, with warning status, in /api/resources ninety
seconds after its poll while the websocket called it online.

Presentation coalescing had the same gap in both views. When it merges
an agent row the registry kept apart into its Proxmox node by hostname,
it picked each reading's source by the default thresholds, so the row
showed a silent agent's readings over a poll the registry calls
current.

The seed now carries the thresholds its registry generation was judged
by. The monitor adapter records them per generation: thresholds set
since the last rebuild wait for the rebuild that applies them, and the
read-state overlays and live supplemental refreshes of a generation are
judged by its thresholds, since re-judging a listing cannot clear a
stale verdict it already carries. The monitor lists a generation with
its thresholds from one registry pointer, and a view built from mock
fixtures or a bare snapshot reports none (the defaults it was judged
by). The monitor hands seed, freshness and thresholds over from one
view, per tenant too. The resources API builds
its registry with them (NewRegistryWithStaleThresholds: ingests and
stale passes that pass none use them) and keys its cache on its own
copy. The coalesce judges metric freshness with metricSourceStale, the
rule the stale pass and the merge already share, and both the
broadcast and ListForPresentation pass their view's thresholds. The
broadcast coalesce also honours the operator's merge exclusions, so the
adapter's CoalesceForPresentation takes the listing's thresholds next
to them.

Patrol's scoped runtime state still rebuilds registries with the
defaults.
2026-10-08 08:53:19 +01:00

172 lines
5.6 KiB
Go

package api
import (
"errors"
"net/http"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/api/resourceapi"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
)
var errTenantMonitorUnavailable = errors.New("tenant monitor unavailable")
// getMonitor returns the tenant-specific monitor instance for the request.
// It uses the OrgID from the context (injected by TenantMiddleware).
// Non-default orgs fail closed when tenant monitor resolution is unavailable.
func (r *Router) getMonitor(req *http.Request) (*monitoring.Monitor, error) {
orgID := GetOrgID(req.Context())
if orgID == "" || orgID == "default" {
return r.monitor, nil
}
if r.mtMonitor == nil {
return nil, errTenantMonitorUnavailable
}
return r.mtMonitor.GetMonitor(orgID)
}
// MultiTenantStateProvider wraps a MultiTenantMonitor to provide state for specific tenants.
type MultiTenantStateProvider struct {
mtMonitor *monitoring.MultiTenantMonitor
defaultMonitor *monitoring.Monitor
}
// NewMultiTenantStateProvider creates a new tenant state provider.
func NewMultiTenantStateProvider(mtm *monitoring.MultiTenantMonitor, defaultM *monitoring.Monitor) *MultiTenantStateProvider {
return &MultiTenantStateProvider{
mtMonitor: mtm,
defaultMonitor: defaultM,
}
}
func (p *MultiTenantStateProvider) monitorForTenant(orgID string) *monitoring.Monitor {
if orgID == "" || orgID == "default" {
return p.defaultMonitor
}
if p.mtMonitor != nil {
monitor, err := p.mtMonitor.GetMonitor(orgID)
if err != nil {
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to get tenant monitor")
return nil
}
return monitor
}
return nil
}
// UnifiedReadStateForTenant returns the canonical typed unified read-state for a
// specific tenant, falling back to a snapshot-backed adapter only when the
// monitor has not been wired with a resource store yet.
func (p *MultiTenantStateProvider) UnifiedReadStateForTenant(orgID string) unifiedresources.ReadState {
monitor := p.monitorForTenant(orgID)
if monitor == nil {
return nil
}
return monitor.GetUnifiedReadStateOrSnapshot()
}
// UnifiedResourceSnapshotForTenant returns the canonical unified-resource seed
// for a specific tenant, along with its freshness marker.
func (p *MultiTenantStateProvider) UnifiedResourceSnapshotForTenant(orgID string) ([]unifiedresources.Resource, time.Time) {
monitor := p.monitorForTenant(orgID)
if monitor == nil {
return nil, time.Time{}
}
return monitor.UnifiedResourceSnapshot()
}
// The resources API reads both seeds-with-thresholds through optional
// interface checks, so a signature drift would silently restore the registry
// defaults.
var (
_ resourceapi.UnifiedResourceSnapshotWithStaleThresholdsProvider = (*monitoring.Monitor)(nil)
_ resourceapi.TenantUnifiedResourceSnapshotWithStaleThresholdsProvider = (*MultiTenantStateProvider)(nil)
)
// UnifiedResourceSnapshotWithStaleThresholdsForTenant returns a tenant's
// unified-resource seed with the stale thresholds its registry judged it by,
// read from one monitor in one call.
func (p *MultiTenantStateProvider) UnifiedResourceSnapshotWithStaleThresholdsForTenant(orgID string) ([]unifiedresources.Resource, time.Time, map[unifiedresources.DataSource]time.Duration) {
monitor := p.monitorForTenant(orgID)
if monitor == nil {
return nil, time.Time{}, nil
}
return monitor.UnifiedResourceSnapshotWithStaleThresholds()
}
// ResourceHealthAlertsForTenant returns only the minimal alert facts needed by
// the resource health projection, resolved from the same tenant monitor as the
// resource seed.
func (p *MultiTenantStateProvider) ResourceHealthAlertsForTenant(orgID string) []unifiedresources.ResourceHealthAlert {
monitor := p.monitorForTenant(orgID)
if monitor == nil {
return nil
}
active := monitor.ActiveAlertsSnapshot()
out := make([]unifiedresources.ResourceHealthAlert, 0, len(active))
for _, alert := range active {
out = append(out, unifiedresources.ResourceHealthAlert{
ResourceID: alert.ResourceID,
Level: alert.Level,
Type: alert.Type,
})
}
return out
}
// SetMultiTenantMonitor updates the multi-tenant monitor manager.
// Used during reload.
func (r *Router) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) {
r.mtMonitor = mtm
if r.configHandlers != nil {
r.configHandlers.SetMultiTenantMonitor(mtm)
r.configHandlers.SetConfig(r.config)
}
if r.alertHandlers != nil {
r.alertHandlers.SetMultiTenantMonitor(mtm)
}
if r.notificationHandlers != nil {
r.notificationHandlers.SetMultiTenantMonitor(mtm)
}
if r.dockerAgentHandlers != nil {
r.dockerAgentHandlers.SetMultiTenantMonitor(mtm)
}
if r.unifiedAgentHandlers != nil {
r.unifiedAgentHandlers.SetMultiTenantMonitor(mtm)
}
if r.kubernetesAgentHandlers != nil {
r.kubernetesAgentHandlers.SetMultiTenantMonitor(mtm)
}
if r.systemSettingsHandler != nil {
r.systemSettingsHandler.SetMultiTenantMonitor(mtm)
}
if r.aiSettingsHandler != nil {
r.aiSettingsHandler.SetMultiTenantMonitor(mtm)
}
if r.aiHandler != nil {
r.aiHandler.SetMultiTenantMonitor(mtm)
}
if mtm != nil {
if m, err := mtm.GetMonitor("default"); err == nil {
r.monitor = m
if r.notificationQueueHandlers != nil {
r.notificationQueueHandlers.SetMonitor(m)
}
r.bindDefaultMetadataStores(m)
}
mtm.SetMonitorInitializer(r.configureMonitorDependencies)
}
r.configureMetadataProviderFactory()
// Wire tenant state provider to resource handlers
if r.resourceHandlers != nil {
r.resourceHandlers.SetTenantStateProvider(NewMultiTenantStateProvider(mtm, r.monitor))
}
}