carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Console-side read path for host/process monitoring: assembling what the
2// operator dashboard (Task 11) and owner app-metrics page (Task 12) render
3// out of what the box agent publishes (internal/metrics). Nothing here
4// writes anything — it is a bucket sweep plus an in-process cache, and the
5// wire shapes it reads (metrics.RawChunk, metrics.AlertState) are read
6// defensively: an unparsable object is skipped, never fatal to the sweep,
7// mirroring alertwatch.go's "never go silent" posture on the same bucket
8// prefixes.
9package console
10
11import (
12 "context"
13 "encoding/json"
14 "errors"
15 "fmt"
16 "log"
17 "os"
18 "sort"
19 "strconv"
20 "strings"
21 "sync"
22 "time"
23
24 "github.com/carlosframework/platform/internal/activator"
25 "github.com/carlosframework/platform/internal/metrics"
26)
27
28// UnitRow is one per-instance reading, ready to render, plus its own 48h
29// sparklines (the owner app-metrics view renders a unit on its own, with no
30// host row to borrow a shape from). Keyed by sample name, which is the
31// reading's IDENTITY, not always a systemd unit: an exec-backed instance has
32// no unit at all — it is a child of `carlos agent`, accounted in a delegated
33// cgroup — and reports under carlos-exec@<host> (no .service; exec-child
34// accounting spec). Boxes running the superseded pid sampler (issue #118,
35// retired by that spec) published exec children under the
36// carlos-app@<host>.service name they WOULD have had, and those keys survive
37// in raw chunks for the 48h retention window. Either way the route host
38// inside the name is the thing to grep the agent's journal for.
39type UnitRow struct {
40 Unit string // full unit name
41 CPUFrac *float64
42 MemBytes *int64
43 ReadBytes, WriteBytes *int64
44 SparkCPU, SparkMem string // SVG polyline points, "" when no data
45}
46
47// BoxMetricsView is one box's assembled dashboard row: latest host readings
48// plus a 48h sparkline per metric plus its units, sorted.
49type BoxMetricsView struct {
50 Box string
51 At time.Time // latest sample time
52 CPU, Mem, Disk *float64
53 SparkCPU, SparkMem, SparkDisk string // SVG polyline points, "" when no data
54 Units []UnitRow // sorted by CPUFrac desc, nil-CPU rows last
55}
56
57// AlertRow is one currently-firing rule on one box. LoadMetrics only ever
58// returns firing rules — a quiet rule has nothing to show.
59type AlertRow struct {
60 Box, Rule string
61 Value float64
62 FiredAt time.Time
63}
64
65// metricsCacheTTL is how soon after a successful sweep LoadMetrics considers
66// the cache FRESH and skips even trying to refresh it. It does NOT bound how
67// long a result may be served overall — see metricsStaleServeCeiling for
68// that outer bound. Past metricsCacheTTL the cache becomes STALE (still
69// served immediately, but a background refresh is triggered) rather than
70// unusable. All callers within the FRESH window share the one sweep — the
71// operator dashboard and every owner's app-metrics page render off the same
72// read.
73const metricsCacheTTL = 60 * time.Second
74
75// metricsStaleServeCeiling bounds how old the cache may be and still be
76// handed out immediately while a background refresh runs (LoadMetrics's
77// STALE-WITHIN-CEILING state). It exists because nothing keeps the cache
78// warm between requests — its age tracks the gap since the last REQUEST,
79// not a timer — so without a ceiling, the first request after a quiet
80// stretch would serve data that is arbitrarily old (a healthy box's actual
81// reporting gap, PLUS however long nobody happened to ask), and a
82// persistent bucket outage would let LoadMetrics return stale data with a
83// nil error forever, since a background sweep's failure is only logged (see
84// startBackgroundSweep) and nothing else ever revisits it. Past this
85// ceiling, LoadMetrics falls through to the same blocking single-flight
86// path the COLD case uses — costing on the order of a second now that
87// sweepBox parallelises its GETs, rather than the several seconds a
88// sequential sweep used to cost — in exchange for two things a stale answer
89// can't give: data no older than one fresh sweep, and a sweep error
90// actually reaching a caller instead of vanishing into a log line. No
91// separate failure backoff is needed on top of this: a sweep that keeps
92// failing never advances c.at, so the cache's age crosses the ceiling on its
93// own within a couple of minutes regardless of how long the outage lasts.
94const metricsStaleServeCeiling = 2 * time.Minute
95
96// metricsSweepHours is how far back LoadMetrics reads raw chunks: the last
97// 48 UTC hours, days/ rollups untouched (a later task's job).
98const metricsSweepHours = 48
99
100// metricsFreshWindow is how old a box's newest sample may be before its
101// readings stop being anyone's CURRENT figure. The value is derived, not
102// chosen freehand, from three terms:
103//
104// - Flush cadence, 5 minutes — a FLOOR, not an exact figure: metricsLoop
105// sleeps 60s per tick and does per-tick work on top of that (alertTick
106// every tick, metricsFlush's own bucket writes every 5th), so the actual
107// gap between flushes only ever drifts upward from 5:00, never down.
108// - metricsStaleServeCeiling, 2 minutes — the oldest a cache
109// LoadMetrics hands back under stale-while-revalidate may be, i.e. how
110// far behind the true "latest flush" a box.At a caller sees can sit even
111// with a store that is never slow.
112// - One more minute of slack, covering both the flush-cadence drift above
113// and the ≤metricsSweepTimeout (15s) a background refresh can still be
114// mid-flight for when its result gets read.
115//
116// 5 + 2 + 1 = 8 minutes. This window must exceed that sum or a perfectly
117// healthy, perfectly reachable box flaps between reporting and
118// no-recent-readings for the tail of every cycle.
119//
120// It still has to do the job it always did on top of that: the 48h sweep
121// keeps serving a dead box's final sample for two days, so without SOME
122// bound an instance that vanished on Monday still reads "11% / 1.0 KB,
123// current" on Wednesday. Owners get the no-recent-readings state instead
124// (they have no timestamp on the page to judge staleness for themselves; the
125// operator dashboard prints "Latest <time>" and so is deliberately left
126// unbounded).
127//
128// What this window does NOT promise: it does not survive even ONE missed
129// flush — miss a single 5-minute flush and the gap to the NEXT successful
130// one is already ~10 minutes, past this window, and the box drops out
131// exactly as it would have before this fix. That was true of the old 5-minute window too; widening
132// far enough to also cover a missed flush would mean a truly dead box's
133// stale reading survives even longer before it stops being anyone's
134// "current" figure — not a trade worth making for one skipped beat.
135const metricsFreshWindow = 8 * time.Minute
136
137// metricsBoxesPrefix is where a box publishes its raw chunks and day
138// rollups (internal/metrics.RawKey / DayKey); box discovery lists this
139// prefix and reads the path segment right after "boxes/".
140const metricsBoxesPrefix = "metrics/boxes/"
141
142// metricsSweepBoxes and metricsSweepReads bound how much of a sweep is in
143// flight at once: boxes swept in parallel, and hourly chunks read in parallel
144// within a box. A cold sweep is boxes x 48 round trips to the bucket and is
145// latency-bound, not CPU-bound — serially that is the whole "Instances page
146// is slow" complaint — but it is still someone else's rate limit at the far
147// end, so the fan-out is bounded rather than "one goroutine per key". The
148// worst case is metricsSweepBoxes x metricsSweepReads requests outstanding.
149const (
150 metricsSweepBoxes = 4
151 metricsSweepReads = 12
152)
153
154// metricsSweepTimeout bounds one whole bucket sweep: a List plus 48 Gets per
155// box plus the alerts pass. It exists because the sweep no longer runs on the
156// requesting page's context (see LoadMetrics) and so has no deadline of its
157// own to inherit — a store that accepts connections and then hangs would
158// otherwise pin the in-flight flag forever and every later caller with it.
159// Fifteen seconds is far beyond a healthy sweep of a fleet this size and far
160// short of any browser's patience.
161const metricsSweepTimeout = 15 * time.Second
162
163// metricsCache holds the result of the last successful bucket sweep. A
164// failed sweep never reaches here — a failed BLOCKING sweep (c.at still
165// zero, OR the cache is stale past metricsStaleServeCeiling) returns the
166// error to its caller and leaves the cache exactly as it was; a failed
167// BACKGROUND sweep (c.at set and still within the ceiling) is only logged,
168// since nobody is waiting on it and the stale cache it leaves behind is
169// still valid data, just not fresher.
170//
171// No separate failure backoff sits on top of the ceiling: a sweep that keeps
172// failing never advances c.at, so the cache's age crosses
173// metricsStaleServeCeiling on its own within a couple of minutes regardless
174// of how long an outage lasts, and every caller after that lands back on the
175// blocking path where the failure is visible again — see
176// metricsStaleServeCeiling's own comment.
177//
178// mu guards the fields and is deliberately NOT held across the sweep's bucket
179// IO; sweeping/done are what serialise the IO instead, so that a cold sweep
180// delays only the callers that actually need its result rather than every
181// caller that touches the cache. sweeping is true while one goroutine (either
182// a blocking caller's own, or the detached refresh goroutine LoadMetrics
183// launches for stale-while-revalidate) is out at the bucket, and done is the
184// channel it closes when it comes back — the SAME channel and flag serve
185// both shapes of sweep, so a caller arriving mid-refresh, whichever kind
186// started it, coalesces onto it rather than starting a second one.
187type metricsCache struct {
188 mu sync.Mutex
189 at time.Time
190 boxes []BoxMetricsView
191 alerts []AlertRow
192 sweeping bool
193 done chan struct{}
194}
195
196// LoadMetrics reads every box under metrics/boxes/, the raw chunks of the
197// last 48 hours (missing hours skipped silently), and alerts/boxes/*.json,
198// caching the result in-process so concurrent callers — the dashboard page
199// and every owner's app-metrics page — usually share one sweep.
200//
201// Three cache states:
202//
203// - FRESH (c.at within metricsCacheTTL): the cached answer, immediately,
204// no bucket touched at all.
205// - STALE-WITHIN-CEILING (c.at is set, older than metricsCacheTTL, but no
206// older than metricsStaleServeCeiling): the cached answer is STILL
207// returned immediately — stale-while-revalidate — and, if no sweep is
208// already in flight, a fresh one is kicked off on a detached goroutine
209// (startBackgroundSweep) for the NEXT caller to benefit from. This is
210// the fix for the sweep's own cost: sweepBox's 48 hourly GETs for a
211// single box were measured at ~2.5s run sequentially, and that cost
212// used to land inline on whichever request's bad luck it was to arrive
213// right after a cache expiry — the dashboard page and every tenant's
214// app page alike.
215// - COLD (c.at is zero — nothing has ever swept successfully) or STALE
216// PAST THE CEILING (c.at is set but older than
217// metricsStaleServeCeiling — nothing has kept the cache warm for a
218// while, e.g. a quiet console between requests, or every sweep since
219// has been failing): the ORIGINAL blocking behaviour. There is either no
220// servable stale answer (COLD) or the one that exists is too old to
221// hand out one more time (STALE-PAST-CEILING) — either way this caller
222// (or whichever caller's sweep is already in flight) waits for a real
223// sweep and gets ITS error if the sweep fails. The ceiling is what keeps
224// a persistent bucket outage visible: without it, stale-while-revalidate
225// alone would let LoadMetrics return stale data with a nil error
226// forever regardless of how long the store stays down, because a
227// background sweep's failure is only logged (see startBackgroundSweep)
228// — crossing the ceiling forces the next caller back onto the path that
229// actually surfaces the error. Blocking here now costs on the order of
230// a second for a fleet this size rather than the several seconds it
231// cost before sweepBox parallelised its GETs, which is what makes
232// paying that cost here — instead of serving indefinitely-stale data —
233// an acceptable trade.
234//
235// Two properties matter beyond the cache states themselves, both learned
236// from the shape this replaced (mutex held across the whole sweep, on the
237// request's own context):
238//
239// - The cache mutex is never held across bucket IO. Only the goroutine that
240// actually sweeps is slow; everyone else either takes the cached answer
241// immediately or waits on the in-flight sweep's channel (only the
242// blocking case waits at all), and neither blocks a caller whose cache
243// is fresh or stale-but-servable. handleApp calls this on every tenant's
244// app page, so a cold sweep must not become a queue every page on the
245// console stands in.
246// - Every sweep — blocking or background — runs on a context detached from
247// any particular caller's: context.WithoutCancel of whatever was handed
248// in (context.Background() for the background case, since there is no
249// caller to derive from), plus metricsSweepTimeout. A request cancelled
250// mid-sweep (a reload, a navigate-away) would otherwise abort a sweep
251// other callers are waiting on AND, since a failed blocking sweep is
252// never cached, leave the next request to start from scratch: reload
253// impatiently enough and the cache never populates at all. The caller's
254// own context still governs WAITING in the blocking case (a cancelled
255// waiter gives up rather than hanging), it just never governs the sweep
256// itself.
257func (s *Server) LoadMetrics(ctx context.Context) ([]BoxMetricsView, []AlertRow, error) {
258 c := &s.metricsCache
259 for {
260 c.mu.Lock()
261 if !c.at.IsZero() && time.Since(c.at) < metricsCacheTTL {
262 // FRESH.
263 boxes, alerts := c.boxes, c.alerts
264 c.mu.Unlock()
265 return boxes, alerts, nil
266 }
267 if !c.at.IsZero() && time.Since(c.at) <= metricsStaleServeCeiling {
268 // STALE-WITHIN-CEILING: hand back what we have right now — it
269 // is still a real sweep's result, just older than
270 // metricsCacheTTL — and let a refresh happen off to the side
271 // rather than making this caller pay for it. If one is already
272 // running (background OR, in principle, another blocking
273 // caller's), don't start a second; this caller just doesn't
274 // wait for it either way.
275 boxes, alerts := c.boxes, c.alerts
276 if !c.sweeping {
277 s.startBackgroundSweep(c)
278 }
279 c.mu.Unlock()
280 return boxes, alerts, nil
281 }
282 // COLD, or STALE-PAST-THE-CEILING: no servable stale answer exists
283 // (COLD) or the one that does is too old to hand out again
284 // (past metricsStaleServeCeiling) — this caller must wait for real
285 // data and see its error if the sweep fails, exactly as the
286 // original blocking behaviour did.
287 if c.sweeping {
288 // Someone is already at the bucket: wait for their result
289 // rather than duplicating the sweep, then re-check (their
290 // sweep may have failed, in which case this iteration takes
291 // the work over).
292 wait := c.done
293 c.mu.Unlock()
294 select {
295 case <-wait:
296 continue
297 case <-ctx.Done():
298 return nil, nil, ctx.Err()
299 }
300 }
301 c.sweeping = true
302 c.done = make(chan struct{})
303 done := c.done
304 c.mu.Unlock()
305
306 // err starts non-nil so that if sweepAll panics (unwinding past the
307 // assignment below), the deferred bookkeeping still sees a failed
308 // sweep: nothing gets cached and the in-flight flag is cleared
309 // instead of wedging every future caller. net/http's per-connection
310 // recover keeps the process up; this keeps the cache usable.
311 var (
312 boxes []BoxMetricsView
313 alerts []AlertRow
314 err = errors.New("console: metrics sweep did not complete")
315 )
316 func() {
317 defer func() {
318 c.mu.Lock()
319 if err == nil {
320 // Only a fully successful sweep is cached; a failed
321 // one leaves the previous cache exactly as it was.
322 c.at = time.Now()
323 c.boxes = boxes
324 c.alerts = alerts
325 }
326 c.sweeping = false
327 close(done)
328 c.mu.Unlock()
329 }()
330 boxes, alerts, err = s.sweepAll(ctx)
331 }()
332
333 if err != nil {
334 return nil, nil, err
335 }
336 return boxes, alerts, nil
337 }
338}
339
340// startBackgroundSweep launches sweepAll on its own goroutine to refresh a
341// cache that has swept successfully at least once and is still within
342// metricsStaleServeCeiling, without making any caller wait on it — see
343// LoadMetrics's STALE-WITHIN-CEILING case. "Swept successfully at least
344// once" is exactly c.at being set: an empty-bucket console (c.at set,
345// c.boxes nil) takes this path just like a populated one, since nil boxes
346// is itself a complete, valid swept result, not an absent one. Must be
347// called with c.mu held: it sets c.sweeping and installs c.done itself, the
348// very same fields the blocking path uses, so a caller arriving while this
349// is in flight (whether via the STALE-WITHIN-CEILING branch or the blocking
350// one) coalesces onto it instead of starting a second sweep. sweepAll is
351// handed context.Background() rather than any request's
352// context because there IS no request behind this goroutine — it already
353// detaches internally via context.WithoutCancel and bounds itself with
354// metricsSweepTimeout, so Background() costs nothing extra.
355//
356// Unlike the blocking path, this goroutine has no net/http per-connection
357// recover sitting above it — an unrecovered panic here would take the whole
358// process down, not just one request — so it recovers explicitly. A failed
359// or panicked background sweep is only logged: no caller is waiting on its
360// error, and the stale cache it leaves untouched is still valid data.
361func (s *Server) startBackgroundSweep(c *metricsCache) {
362 c.sweeping = true
363 c.done = make(chan struct{})
364 done := c.done
365 go func() {
366 var (
367 boxes []BoxMetricsView
368 alerts []AlertRow
369 err = errors.New("console: background metrics sweep did not complete")
370 )
371 defer func() {
372 rec := recover()
373 c.mu.Lock()
374 if err == nil {
375 c.at = time.Now()
376 c.boxes = boxes
377 c.alerts = alerts
378 }
379 c.sweeping = false
380 close(done)
381 c.mu.Unlock()
382 switch {
383 case rec != nil:
384 log.Printf("console: background metrics sweep panic, cache left at its last good sweep: %v", rec)
385 case err != nil:
386 log.Printf("console: background metrics sweep: %v", err)
387 }
388 }()
389 boxes, alerts, err = s.sweepAll(context.Background())
390 }()
391}
392
393// sweepAll is the bucket half of LoadMetrics: discovery, every box's 48h raw
394// sweep (metricsSweepBoxes of them at a time), then the alerts pass, on a
395// context detached from the caller's (WithoutCancel) and bounded by
396// metricsSweepTimeout. It holds no lock — see LoadMetrics's doc comment for
397// why that is the point.
398//
399// Boxes are swept concurrently but land in boxNames order, never completion
400// order: the dashboard's row order must not depend on which box's bucket
401// answered first. Any box failing fails the whole sweep, with the
402// lowest-ordered box's error — the same error the serial sweep returned, so a
403// flaky box reads the same in the logs however the reads interleaved.
404func (s *Server) sweepAll(ctx context.Context) ([]BoxMetricsView, []AlertRow, error) {
405 sweepCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), metricsSweepTimeout)
406 defer cancel()
407
408 boxNames, err := s.discoverBoxes(sweepCtx)
409 if err != nil {
410 return nil, nil, err
411 }
412
413 views := make([]BoxMetricsView, len(boxNames))
414 errs := make([]error, len(boxNames))
415 var (
416 wg sync.WaitGroup
417 sem = make(chan struct{}, metricsSweepBoxes)
418 once sync.Once
419 failed = make(chan struct{})
420 )
421 for i, box := range boxNames {
422 wg.Add(1)
423 go func() {
424 defer wg.Done()
425 select {
426 case sem <- struct{}{}:
427 case <-failed:
428 // Another box has already failed the sweep; nothing this one
429 // could read would be returned. Boxes already at the bucket
430 // still finish — they run on sweepCtx, so the error a failed
431 // sweep reports is always a box's own, never one this
432 // function inflicted.
433 return
434 }
435 defer func() { <-sem }()
436 view, err := s.sweepBox(sweepCtx, box)
437 if err != nil {
438 errs[i] = err
439 once.Do(func() { close(failed) })
440 return
441 }
442 views[i] = view
443 }()
444 }
445 wg.Wait()
446 for _, err := range errs {
447 if err != nil {
448 return nil, nil, err
449 }
450 }
451
452 alerts, err := s.sweepAlerts(sweepCtx)
453 if err != nil {
454 return nil, nil, err
455 }
456 return views, alerts, nil
457}
458
459// discoverBoxes lists metrics/boxes/ and returns the deduped, sorted set of
460// box names — the path segment right after "boxes/".
461func (s *Server) discoverBoxes(ctx context.Context) ([]string, error) {
462 keys, err := s.cfg.Releases.List(ctx, metricsBoxesPrefix)
463 if err != nil {
464 return nil, fmt.Errorf("list %s: %w", metricsBoxesPrefix, err)
465 }
466 seen := make(map[string]bool)
467 var boxes []string
468 for _, key := range keys {
469 rest := strings.TrimPrefix(key, metricsBoxesPrefix)
470 idx := strings.Index(rest, "/")
471 if idx <= 0 {
472 continue // malformed/unexpected key shape directly under the prefix
473 }
474 box := rest[:idx]
475 if seen[box] {
476 continue
477 }
478 seen[box] = true
479 boxes = append(boxes, box)
480 }
481 sort.Strings(boxes)
482 return boxes, nil
483}
484
485// sweepBox reads box's raw chunks for the last 48 UTC hours, metricsSweepReads
486// at a time, and assembles its BoxMetricsView. A missing hour (os.ErrNotExist)
487// or an unparsable chunk is skipped silently — a box that has been up for
488// under 48h, or whose agent hiccuped on one hour, still renders on what it
489// does have. Any other read error fails the whole sweep (and so is never
490// cached), reporting the newest failing hour's error as the serial read did.
491//
492// Each hour lands in its own slot and the slots are concatenated in hour
493// order before sorting, because the sort below is NOT stable: samples sharing
494// a timestamp keep whatever relative order they were appended in, so appending
495// as the reads complete would let a box's current reading flap between
496// renders.
497func (s *Server) sweepBox(ctx context.Context, box string) (BoxMetricsView, error) {
498 now := time.Now().UTC()
499 hourly := make([][]metrics.Sample, metricsSweepHours)
500 errs := make([]error, metricsSweepHours)
501 var (
502 wg sync.WaitGroup
503 sem = make(chan struct{}, metricsSweepReads)
504 once sync.Once
505 failed = make(chan struct{})
506 )
507 for i := range metricsSweepHours {
508 wg.Add(1)
509 go func() {
510 defer wg.Done()
511 select {
512 case sem <- struct{}{}:
513 case <-failed:
514 return // an earlier hour already failed the sweep
515 }
516 defer func() { <-sem }()
517 hour := metrics.HourOf(now.Add(-time.Duration(i) * time.Hour))
518 key := metrics.RawKey(box, hour)
519 body, _, err := s.cfg.Releases.Get(ctx, key)
520 if err != nil {
521 if errors.Is(err, os.ErrNotExist) {
522 return
523 }
524 errs[i] = fmt.Errorf("read %s: %w", key, err)
525 once.Do(func() { close(failed) })
526 return
527 }
528 var chunk metrics.RawChunk
529 if err := json.Unmarshal(body, &chunk); err != nil {
530 return // unparsable chunk: skip, don't fail the sweep
531 }
532 hourly[i] = chunk.Samples
533 }()
534 }
535 wg.Wait()
536 for _, err := range errs {
537 if err != nil {
538 return BoxMetricsView{}, err
539 }
540 }
541
542 var samples []metrics.Sample
543 for _, h := range hourly {
544 samples = append(samples, h...)
545 }
546 sort.Slice(samples, func(i, j int) bool { return samples[i].At.Before(samples[j].At) })
547 return buildBoxView(box, samples), nil
548}
549
550// buildBoxView assembles one box's view from its swept samples, oldest
551// first. The latest sample's own values are used as-is for CPU/Mem/Disk/At
552// — nil stays nil; this is deliberately NOT "each field's newest non-nil
553// reading independently", which would quietly mix readings from different
554// points in time.
555func buildBoxView(box string, samples []metrics.Sample) BoxMetricsView {
556 view := BoxMetricsView{Box: box}
557 if len(samples) == 0 {
558 return view
559 }
560 latest := samples[len(samples)-1]
561 view.At = latest.At
562 view.CPU = latest.Host.CPUFrac
563 view.Mem = latest.Host.MemFrac
564 view.Disk = latest.Host.DiskFrac
565
566 cpu := make([]*float64, len(samples))
567 mem := make([]*float64, len(samples))
568 disk := make([]*float64, len(samples))
569 for i, sm := range samples {
570 cpu[i] = sm.Host.CPUFrac
571 mem[i] = sm.Host.MemFrac
572 disk[i] = sm.Host.DiskFrac
573 }
574 view.SparkCPU = sparkPoints(cpu, 240, 40)
575 view.SparkMem = sparkPoints(mem, 240, 40)
576 view.SparkDisk = sparkPoints(disk, 240, 40)
577
578 view.Units = buildUnitRows(samples)
579 return view
580}
581
582// buildUnitRows turns the latest sample's Units map into rows sorted by
583// CPUFrac descending, nil-CPU rows last, stable by unit name within ties,
584// each carrying its own sparklines drawn across the whole swept window. A
585// unit absent from the latest sample's map simply isn't in this slice —
586// never rendered, never aggregated as a zero (Task 4's "missing map key is
587// no reading" ruling, same map shape here) — even if it appears in older
588// samples: the row set is "what is running now", not "what ever ran".
589func buildUnitRows(samples []metrics.Sample) []UnitRow {
590 if len(samples) == 0 {
591 return nil
592 }
593 units := samples[len(samples)-1].Units
594 rows := make([]UnitRow, 0, len(units))
595 for name, u := range units {
596 rows = append(rows, UnitRow{
597 Unit: name,
598 CPUFrac: u.CPUFrac,
599 MemBytes: u.MemBytes,
600 ReadBytes: u.ReadBytes,
601 WriteBytes: u.WriteBytes,
602 SparkCPU: sparkPoints(unitCPUSeries(samples, name), 240, 40),
603 SparkMem: sparkPoints(unitMemSeries(samples, name), 240, 40),
604 })
605 }
606 // Name order first so the CPU-desc sort below, being stable, ties out
607 // alphabetically rather than on map iteration order.
608 sort.Slice(rows, func(i, j int) bool { return rows[i].Unit < rows[j].Unit })
609 sort.SliceStable(rows, func(i, j int) bool {
610 a, b := rows[i].CPUFrac, rows[j].CPUFrac
611 if a == nil {
612 return false // nil never sorts before anything, including another nil
613 }
614 if b == nil {
615 return true
616 }
617 return *a > *b
618 })
619 return rows
620}
621
622// unitCPUSeries is one unit's CPU fraction at each swept sample index. A
623// sample where the unit is absent, or present without a CPU reading, stays
624// nil — sparkPoints draws a gap, never a zero.
625func unitCPUSeries(samples []metrics.Sample, unit string) []*float64 {
626 out := make([]*float64, len(samples))
627 for i, sm := range samples {
628 if u, ok := sm.Units[unit]; ok {
629 out[i] = u.CPUFrac
630 }
631 }
632 return out
633}
634
635// unitMemSeries is one unit's memory at each swept sample index, scaled to
636// its OWN 48h peak. Unlike CPU, a unit's memory has no 0..1 ceiling the
637// console can know — the cgroup limit isn't published, and the box's total
638// RAM is deliberately not in an owner's view — so the sparkline shows the
639// shape of this instance's memory against its own high-water mark, and the
640// number beside it stays the absolute byte count. Absent/nil readings stay
641// nil; a unit that never reported memory gets no line at all.
642func unitMemSeries(samples []metrics.Sample, unit string) []*float64 {
643 var peak int64
644 for _, sm := range samples {
645 if u, ok := sm.Units[unit]; ok && u.MemBytes != nil && *u.MemBytes > peak {
646 peak = *u.MemBytes
647 }
648 }
649 if peak <= 0 {
650 return nil
651 }
652 out := make([]*float64, len(samples))
653 for i, sm := range samples {
654 u, ok := sm.Units[unit]
655 if !ok || u.MemBytes == nil {
656 continue
657 }
658 f := float64(*u.MemBytes) / float64(peak)
659 out[i] = &f
660 }
661 return out
662}
663
664// InstanceMetrics is one of an APP OWNER's instances, named the way owners
665// name their instances: by route host. It carries that instance's unit
666// readings and nothing else — no host totals, no sibling units, no alerts.
667// Box is here for internal use (picking the freshest reporting box when a
668// host has moved); the app page must not print it, because which box a
669// tenant sits on is not an owner's business.
670type InstanceMetrics struct {
671 ObservedAt time.Time
672
673 Host string // the route host — how owners name their instances
674 Box string
675 CPUFrac *float64
676 MemBytes *int64
677
678 SparkCPU, SparkMem string
679
680 // Hibernate is the route's hibernation setting, copied in by the caller
681 // (handleApp) — the sweep cannot see it. It only matters when Reporting
682 // is false, where it decides what the absence of readings may claim: a
683 // hibernating instance is presumed asleep, anything else is NOT — it may
684 // be serving right now from outside per-instance accounting (a bespoke
685 // unit like keymail-relay.service, or an exec child on a box whose agent
686 // has no delegated subtree yet), and the template must not assert a
687 // sleep state nobody observed.
688 Hibernate bool
689}
690
691// Reporting is the template's one question: did any box report this
692// instance's unit in the last sweep? False is the no-readings case, whose
693// rendering splits on Hibernate: hibernating instances read "asleep — no
694// recent readings", everything else the state-neutral "no per-instance
695// readings" — never zeros either way. (Reporting with
696// nil readings is a different, honest state: the unit is up but that metric
697// was unreadable, which renders as the usual em-dash.)
698func (m InstanceMetrics) Reporting() bool { return m.Box != "" }
699
700// OwnerAppMetrics filters the cached 48h sweep down to EXACTLY the given
701// hosts' own per-instance readings — carlos-app@<host>.service for a
702// unit-backed tenant, carlos-exec@<host> for an exec-backed one, or the
703// operator-stamped bespoke unit named in unitByHost[host] (registry
704// Route.Unit, #110) when the caller supplies one. It returns unit-level
705// rows only — no host totals, no other units, no alert data. The allowlist
706// is the hosts slice the caller derived from the app's OWN routes, plus
707// unitByHost entries drawn from those same routes; nothing else can enter
708// the view.
709//
710// Every allowlisted host gets exactly one row, in host order, whether or not
711// the sweep saw it: a host with no unit data is an UNKNOWN row (nil
712// readings, Reporting false), never zeros and never silently dropped — an
713// owner whose instance is asleep must still see it listed.
714func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string, unitByHost map[string]string) ([]InstanceMetrics, error) {
715 // The allowlist, keyed by the unit names each host can legally match.
716 // Matching is exact-string on this map: a unit the caller did not ask
717 // for cannot be reached by any path below.
718 want := make(map[string]string, 2*len(hosts))
719 for _, h := range hosts {
720 if h == "" {
721 continue
722 }
723 // Two legal keys per host: the systemd unit a unit-backed tenant
724 // reports under, and the delegated exec cgroup an exec-backed one
725 // does. One route, one backing — a host can't legally report under
726 // both at once; if both ever appear in a sweep, the freshest-box
727 // rule below picks deterministically, no special case.
728 want[activator.UnitName(h)] = h
729 want[activator.ExecName(h)] = h
730 // The third legal key: an operator-stamped bespoke unit (registry
731 // Route.Unit, #110). Opt-in per host — the caller maps host →
732 // stamped unit from the route rows it already holds; an empty map
733 // costs nothing and matches nothing new. (A nil map indexes fine
734 // in Go — no guard needed.)
735 if u := unitByHost[h]; u != "" {
736 want[u] = h
737 }
738 }
739 if len(want) == 0 {
740 return nil, nil
741 }
742
743 boxes, _, err := s.LoadMetrics(ctx)
744 if err != nil {
745 return nil, err
746 }
747
748 found := make(map[string]InstanceMetrics, len(want))
749 at := make(map[string]time.Time, len(want))
750 now := time.Now()
751 for _, box := range boxes {
752 // A box that stopped publishing keeps its final sample inside the
753 // 48h sweep for two days; those numbers are history, not this
754 // instance's current figure, so the whole box drops out of the
755 // owner view and its instances read as no-recent-readings.
756 if box.At.IsZero() || now.Sub(box.At) > metricsFreshWindow {
757 continue
758 }
759 for _, u := range box.Units {
760 host, ok := want[u.Unit]
761 if !ok {
762 continue // not this app's — the whole boundary is this line
763 }
764 // A host that reports on two boxes at once (a move in flight)
765 // shows the freshest box's reading, not whichever sorted first.
766 if prev, taken := at[host]; taken && !box.At.After(prev) {
767 continue
768 }
769 at[host] = box.At
770 found[host] = InstanceMetrics{
771 Host: host,
772 ObservedAt: box.At,
773 Box: box.Box,
774 CPUFrac: u.CPUFrac,
775 MemBytes: u.MemBytes,
776 SparkCPU: u.SparkCPU,
777 SparkMem: u.SparkMem,
778 }
779 }
780 }
781
782 // Iterate the callers' hosts, not want's values: want now carries TWO
783 // keys per host, and "every allowlisted host gets exactly one row" is a
784 // promise about hosts, not keys. seen collapses duplicate input hosts,
785 // which the old unique-keyed map did implicitly.
786 rows := make([]InstanceMetrics, 0, len(hosts))
787 seen := make(map[string]bool, len(hosts))
788 for _, h := range hosts {
789 if h == "" || seen[h] {
790 continue
791 }
792 seen[h] = true
793 if row, ok := found[h]; ok {
794 rows = append(rows, row)
795 } else {
796 rows = append(rows, InstanceMetrics{Host: h})
797 }
798 }
799 sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host })
800 return rows, nil
801}
802
803// sweepAlerts reads every alerts/boxes/*.json and returns one AlertRow per
804// currently-firing rule. Unreadable-but-present keys fail the sweep (so it
805// is never cached half-done); an unparsable object is skipped, matching
806// alertPass's posture on the same prefix.
807func (s *Server) sweepAlerts(ctx context.Context) ([]AlertRow, error) {
808 keys, err := s.cfg.Releases.List(ctx, alertPrefix)
809 if err != nil {
810 return nil, fmt.Errorf("list %s: %w", alertPrefix, err)
811 }
812 var rows []AlertRow
813 for _, key := range keys {
814 if !strings.HasSuffix(key, ".json") {
815 continue
816 }
817 body, _, err := s.cfg.Releases.Get(ctx, key)
818 if err != nil {
819 if errors.Is(err, os.ErrNotExist) {
820 continue
821 }
822 return nil, fmt.Errorf("read %s: %w", key, err)
823 }
824 var st metrics.AlertState
825 if err := json.Unmarshal(body, &st); err != nil {
826 continue // unparsable: skip, don't fail the sweep
827 }
828 box := st.Box
829 if box == "" {
830 // Trust the key over an empty body field, same as alertPass.
831 box = strings.TrimSuffix(strings.TrimPrefix(key, alertPrefix), ".json")
832 }
833 names := make([]string, 0, len(st.Rules))
834 for name := range st.Rules {
835 names = append(names, name)
836 }
837 sort.Strings(names)
838 for _, name := range names {
839 rs := st.Rules[name]
840 if !rs.Firing {
841 continue
842 }
843 rows = append(rows, AlertRow{Box: box, Rule: name, Value: rs.Value, FiredAt: rs.FiredAt})
844 }
845 }
846 return rows, nil
847}
848
849// sparkPoints maps vals (up to 48h of minute samples, one slot per sample
850// index) onto an SVG polyline "points" string on a w x h viewBox. Values are
851// fractions and clamp into [0,1] if out of range; y is inverted (SVG's y
852// axis points down, so a higher fraction plots at a SMALLER y — visually
853// "up"); x is spread evenly across the width by the sample's index, not by
854// elapsed time. A nil sample contributes no pair at all — its x slot is
855// simply skipped, so the polyline draws a straight line across the gap;
856// breaking the line at gaps is not attempted in v1 (documented here per the
857// brief). Empty input or input that is nil throughout returns "".
858//
859// Input longer than w is bucketed down to w points first (see downsample):
860// 48h of minute samples is ~2880 readings into a 240px box — twelve points
861// per pixel of detail nobody can see, paid for in a coordinate string held
862// in the shared cache for every metric of every unit on every box.
863func sparkPoints(vals []*float64, w, h int) string {
864 if len(vals) == 0 {
865 return ""
866 }
867 vals = downsample(vals, w)
868 var b strings.Builder
869 n := len(vals)
870 for i, v := range vals {
871 if v == nil {
872 continue
873 }
874 f := *v
875 if f < 0 {
876 f = 0
877 } else if f > 1 {
878 f = 1
879 }
880 x := 0.0
881 if n > 1 {
882 x = float64(i) / float64(n-1) * float64(w)
883 }
884 y := float64(h) - f*float64(h)
885 if b.Len() > 0 {
886 b.WriteByte(' ')
887 }
888 b.WriteString(strconv.FormatFloat(x, 'f', 2, 64))
889 b.WriteByte(',')
890 b.WriteString(strconv.FormatFloat(y, 'f', 2, 64))
891 }
892 return b.String()
893}
894
895// downsample buckets vals into at most max evenly-sized buckets, each
896// bucket's value being the MEAN of its non-nil readings — a spike lifts its
897// bucket rather than disappearing, and a bucket with nothing readable in it
898// stays nil, so "unknown" survives bucketing as a gap and never becomes a
899// zero. Input at or under max is returned untouched (the common case: a box
900// that has been up for minutes, and every unit test's handful of samples).
901func downsample(vals []*float64, max int) []*float64 {
902 if max <= 0 || len(vals) <= max {
903 return vals
904 }
905 n := len(vals)
906 out := make([]*float64, max)
907 for b := 0; b < max; b++ {
908 lo := b * n / max
909 hi := (b + 1) * n / max
910 if hi > n {
911 hi = n
912 }
913 var sum float64
914 var cnt int
915 for _, v := range vals[lo:hi] {
916 if v == nil {
917 continue
918 }
919 sum += *v
920 cnt++
921 }
922 if cnt == 0 {
923 continue // nothing readable in this bucket: a gap, not a zero
924 }
925 mean := sum / float64(cnt)
926 out[b] = &mean
927 }
928 return out
929}
930