// Console-side read path for host/process monitoring: assembling what the // operator dashboard (Task 11) and owner app-metrics page (Task 12) render // out of what the box agent publishes (internal/metrics). Nothing here // writes anything — it is a bucket sweep plus an in-process cache, and the // wire shapes it reads (metrics.RawChunk, metrics.AlertState) are read // defensively: an unparsable object is skipped, never fatal to the sweep, // mirroring alertwatch.go's "never go silent" posture on the same bucket // prefixes. package console import ( "context" "encoding/json" "errors" "fmt" "log" "os" "sort" "strconv" "strings" "sync" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/metrics" ) // UnitRow is one per-instance reading, ready to render, plus its own 48h // sparklines (the owner app-metrics view renders a unit on its own, with no // host row to borrow a shape from). Keyed by sample name, which is the // reading's IDENTITY, not always a systemd unit: an exec-backed instance has // no unit at all — it is a child of `carlos agent`, accounted in a delegated // cgroup — and reports under carlos-exec@ (no .service; exec-child // accounting spec). Boxes running the superseded pid sampler (issue #118, // retired by that spec) published exec children under the // carlos-app@.service name they WOULD have had, and those keys survive // in raw chunks for the 48h retention window. Either way the route host // inside the name is the thing to grep the agent's journal for. type UnitRow struct { Unit string // full unit name CPUFrac *float64 MemBytes *int64 ReadBytes, WriteBytes *int64 SparkCPU, SparkMem string // SVG polyline points, "" when no data } // BoxMetricsView is one box's assembled dashboard row: latest host readings // plus a 48h sparkline per metric plus its units, sorted. type BoxMetricsView struct { Box string At time.Time // latest sample time CPU, Mem, Disk *float64 SparkCPU, SparkMem, SparkDisk string // SVG polyline points, "" when no data Units []UnitRow // sorted by CPUFrac desc, nil-CPU rows last } // AlertRow is one currently-firing rule on one box. LoadMetrics only ever // returns firing rules — a quiet rule has nothing to show. type AlertRow struct { Box, Rule string Value float64 FiredAt time.Time } // metricsCacheTTL is how soon after a successful sweep LoadMetrics considers // the cache FRESH and skips even trying to refresh it. It does NOT bound how // long a result may be served overall — see metricsStaleServeCeiling for // that outer bound. Past metricsCacheTTL the cache becomes STALE (still // served immediately, but a background refresh is triggered) rather than // unusable. All callers within the FRESH window share the one sweep — the // operator dashboard and every owner's app-metrics page render off the same // read. const metricsCacheTTL = 60 * time.Second // metricsStaleServeCeiling bounds how old the cache may be and still be // handed out immediately while a background refresh runs (LoadMetrics's // STALE-WITHIN-CEILING state). It exists because nothing keeps the cache // warm between requests — its age tracks the gap since the last REQUEST, // not a timer — so without a ceiling, the first request after a quiet // stretch would serve data that is arbitrarily old (a healthy box's actual // reporting gap, PLUS however long nobody happened to ask), and a // persistent bucket outage would let LoadMetrics return stale data with a // nil error forever, since a background sweep's failure is only logged (see // startBackgroundSweep) and nothing else ever revisits it. Past this // ceiling, LoadMetrics falls through to the same blocking single-flight // path the COLD case uses — costing on the order of a second now that // sweepBox parallelises its GETs, rather than the several seconds a // sequential sweep used to cost — in exchange for two things a stale answer // can't give: data no older than one fresh sweep, and a sweep error // actually reaching a caller instead of vanishing into a log line. No // separate failure backoff is needed on top of this: a sweep that keeps // failing never advances c.at, so the cache's age crosses the ceiling on its // own within a couple of minutes regardless of how long the outage lasts. const metricsStaleServeCeiling = 2 * time.Minute // metricsSweepHours is how far back LoadMetrics reads raw chunks: the last // 48 UTC hours, days/ rollups untouched (a later task's job). const metricsSweepHours = 48 // metricsFreshWindow is how old a box's newest sample may be before its // readings stop being anyone's CURRENT figure. The value is derived, not // chosen freehand, from three terms: // // - Flush cadence, 5 minutes — a FLOOR, not an exact figure: metricsLoop // sleeps 60s per tick and does per-tick work on top of that (alertTick // every tick, metricsFlush's own bucket writes every 5th), so the actual // gap between flushes only ever drifts upward from 5:00, never down. // - metricsStaleServeCeiling, 2 minutes — the oldest a cache // LoadMetrics hands back under stale-while-revalidate may be, i.e. how // far behind the true "latest flush" a box.At a caller sees can sit even // with a store that is never slow. // - One more minute of slack, covering both the flush-cadence drift above // and the ≤metricsSweepTimeout (15s) a background refresh can still be // mid-flight for when its result gets read. // // 5 + 2 + 1 = 8 minutes. This window must exceed that sum or a perfectly // healthy, perfectly reachable box flaps between reporting and // no-recent-readings for the tail of every cycle. // // It still has to do the job it always did on top of that: the 48h sweep // keeps serving a dead box's final sample for two days, so without SOME // bound an instance that vanished on Monday still reads "11% / 1.0 KB, // current" on Wednesday. Owners get the no-recent-readings state instead // (they have no timestamp on the page to judge staleness for themselves; the // operator dashboard prints "Latest