carlos / platform Public

console: metrics view assembly — 48h sweep, 60s cache, server-side sparklines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 93c163bba2a400629e2dcfb3fa3238be61b910ac parent 5cb1999
3 files changed, +560 −0
  • internal/console/metricsview.go +319 −0
  • internal/console/metricsview_test.go +236 −0
  • internal/console/server.go +5 −0
diff --git a/internal/console/metricsview.go b/internal/console/metricsview.go
new file mode 100644
index 000000000..92a4bafea
--- /dev/null
+++ b/internal/console/metricsview.go
@@ -0,0 +1,319 @@
+// 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"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/metrics"
+)
+
+// UnitRow is one systemd unit's latest reading, ready to render.
+type UnitRow struct {
+ Unit string // full unit name
+ CPUFrac *float64
+ MemBytes *int64
+ ReadBytes, WriteBytes *int64
+}
+
+// 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 bounds how long LoadMetrics reuses its last successful
+// sweep before touching the bucket again. All callers within the 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
+
+// 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
+
+// metricsBoxesPrefix is where a box publishes its raw chunks and day
+// rollups (internal/metrics.RawKey / DayKey); box discovery lists this
+// prefix and reads the path segment right after "boxes/".
+const metricsBoxesPrefix = "metrics/boxes/"
+
+// metricsCache holds the result of the last successful bucket sweep. A
+// failed sweep never reaches here — LoadMetrics returns the error to its
+// caller and leaves the previous (possibly stale-but-valid, possibly zero)
+// cache exactly as it was.
+type metricsCache struct {
+ mu sync.Mutex
+ at time.Time
+ boxes []BoxMetricsView
+ alerts []AlertRow
+}
+
+// LoadMetrics reads every box under metrics/boxes/, the raw chunks of the
+// last 48 hours (missing hours skipped silently), and alerts/boxes/*.json,
+// caching the result in-process for 60s so concurrent callers — the
+// dashboard page and every owner's app-metrics page — share one sweep.
+func (s *Server) LoadMetrics(ctx context.Context) ([]BoxMetricsView, []AlertRow, error) {
+ s.metricsCache.mu.Lock()
+ defer s.metricsCache.mu.Unlock()
+
+ if !s.metricsCache.at.IsZero() && time.Since(s.metricsCache.at) < metricsCacheTTL {
+ return s.metricsCache.boxes, s.metricsCache.alerts, nil
+ }
+
+ boxNames, err := s.discoverBoxes(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ views := make([]BoxMetricsView, 0, len(boxNames))
+ for _, box := range boxNames {
+ view, err := s.sweepBox(ctx, box)
+ if err != nil {
+ return nil, nil, err
+ }
+ views = append(views, view)
+ }
+
+ alerts, err := s.sweepAlerts(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Only a fully successful sweep is cached — a partial or errored one
+ // falls through to the returns above without ever reaching this line.
+ s.metricsCache.at = time.Now()
+ s.metricsCache.boxes = views
+ s.metricsCache.alerts = alerts
+ return views, alerts, nil
+}
+
+// discoverBoxes lists metrics/boxes/ and returns the deduped, sorted set of
+// box names — the path segment right after "boxes/".
+func (s *Server) discoverBoxes(ctx context.Context) ([]string, error) {
+ keys, err := s.cfg.Releases.List(ctx, metricsBoxesPrefix)
+ if err != nil {
+ return nil, fmt.Errorf("list %s: %w", metricsBoxesPrefix, err)
+ }
+ seen := make(map[string]bool)
+ var boxes []string
+ for _, key := range keys {
+ rest := strings.TrimPrefix(key, metricsBoxesPrefix)
+ idx := strings.Index(rest, "/")
+ if idx <= 0 {
+ continue // malformed/unexpected key shape directly under the prefix
+ }
+ box := rest[:idx]
+ if seen[box] {
+ continue
+ }
+ seen[box] = true
+ boxes = append(boxes, box)
+ }
+ sort.Strings(boxes)
+ return boxes, nil
+}
+
+// sweepBox reads box's raw chunks for the last 48 UTC hours and assembles
+// its BoxMetricsView. A missing hour (os.ErrNotExist) or an unparsable chunk
+// is skipped silently — a box that has been up for under 48h, or whose
+// agent hiccuped on one hour, still renders on what it does have. Any other
+// read error fails the whole sweep (and so is never cached).
+func (s *Server) sweepBox(ctx context.Context, box string) (BoxMetricsView, error) {
+ now := time.Now().UTC()
+ var samples []metrics.Sample
+ for i := 0; i < metricsSweepHours; i++ {
+ hour := metrics.HourOf(now.Add(-time.Duration(i) * time.Hour))
+ key := metrics.RawKey(box, hour)
+ body, _, err := s.cfg.Releases.Get(ctx, key)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ return BoxMetricsView{}, fmt.Errorf("read %s: %w", key, err)
+ }
+ var chunk metrics.RawChunk
+ if err := json.Unmarshal(body, &chunk); err != nil {
+ continue // unparsable chunk: skip, don't fail the sweep
+ }
+ samples = append(samples, chunk.Samples...)
+ }
+ sort.Slice(samples, func(i, j int) bool { return samples[i].At.Before(samples[j].At) })
+ return buildBoxView(box, samples), nil
+}
+
+// buildBoxView assembles one box's view from its swept samples, oldest
+// first. The latest sample's own values are used as-is for CPU/Mem/Disk/At
+// — nil stays nil; this is deliberately NOT "each field's newest non-nil
+// reading independently", which would quietly mix readings from different
+// points in time.
+func buildBoxView(box string, samples []metrics.Sample) BoxMetricsView {
+ view := BoxMetricsView{Box: box}
+ if len(samples) == 0 {
+ return view
+ }
+ latest := samples[len(samples)-1]
+ view.At = latest.At
+ view.CPU = latest.Host.CPUFrac
+ view.Mem = latest.Host.MemFrac
+ view.Disk = latest.Host.DiskFrac
+
+ cpu := make([]*float64, len(samples))
+ mem := make([]*float64, len(samples))
+ disk := make([]*float64, len(samples))
+ for i, sm := range samples {
+ cpu[i] = sm.Host.CPUFrac
+ mem[i] = sm.Host.MemFrac
+ disk[i] = sm.Host.DiskFrac
+ }
+ view.SparkCPU = sparkPoints(cpu, 240, 40)
+ view.SparkMem = sparkPoints(mem, 240, 40)
+ view.SparkDisk = sparkPoints(disk, 240, 40)
+
+ view.Units = buildUnitRows(latest.Units)
+ return view
+}
+
+// buildUnitRows turns the latest sample's Units map into rows sorted by
+// CPUFrac descending, nil-CPU rows last, stable by unit name within ties. A
+// unit absent from the latest sample's map simply isn't in this slice —
+// never rendered, never aggregated as a zero (Task 4's "missing map key is
+// no reading" ruling, same map shape here).
+func buildUnitRows(units map[string]metrics.UnitSample) []UnitRow {
+ rows := make([]UnitRow, 0, len(units))
+ for name, u := range units {
+ rows = append(rows, UnitRow{
+ Unit: name,
+ CPUFrac: u.CPUFrac,
+ MemBytes: u.MemBytes,
+ ReadBytes: u.ReadBytes,
+ WriteBytes: u.WriteBytes,
+ })
+ }
+ // Name order first so the CPU-desc sort below, being stable, ties out
+ // alphabetically rather than on map iteration order.
+ sort.Slice(rows, func(i, j int) bool { return rows[i].Unit < rows[j].Unit })
+ sort.SliceStable(rows, func(i, j int) bool {
+ a, b := rows[i].CPUFrac, rows[j].CPUFrac
+ if a == nil {
+ return false // nil never sorts before anything, including another nil
+ }
+ if b == nil {
+ return true
+ }
+ return *a > *b
+ })
+ return rows
+}
+
+// sweepAlerts reads every alerts/boxes/*.json and returns one AlertRow per
+// currently-firing rule. Unreadable-but-present keys fail the sweep (so it
+// is never cached half-done); an unparsable object is skipped, matching
+// alertPass's posture on the same prefix.
+func (s *Server) sweepAlerts(ctx context.Context) ([]AlertRow, error) {
+ keys, err := s.cfg.Releases.List(ctx, alertPrefix)
+ if err != nil {
+ return nil, fmt.Errorf("list %s: %w", alertPrefix, err)
+ }
+ var rows []AlertRow
+ for _, key := range keys {
+ if !strings.HasSuffix(key, ".json") {
+ continue
+ }
+ body, _, err := s.cfg.Releases.Get(ctx, key)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ return nil, fmt.Errorf("read %s: %w", key, err)
+ }
+ var st metrics.AlertState
+ if err := json.Unmarshal(body, &st); err != nil {
+ continue // unparsable: skip, don't fail the sweep
+ }
+ box := st.Box
+ if box == "" {
+ // Trust the key over an empty body field, same as alertPass.
+ box = strings.TrimSuffix(strings.TrimPrefix(key, alertPrefix), ".json")
+ }
+ names := make([]string, 0, len(st.Rules))
+ for name := range st.Rules {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ rs := st.Rules[name]
+ if !rs.Firing {
+ continue
+ }
+ rows = append(rows, AlertRow{Box: box, Rule: name, Value: rs.Value, FiredAt: rs.FiredAt})
+ }
+ }
+ return rows, nil
+}
+
+// sparkPoints maps vals (up to 48h of minute samples, one slot per sample
+// index) onto an SVG polyline "points" string on a w x h viewBox. Values are
+// fractions and clamp into [0,1] if out of range; y is inverted (SVG's y
+// axis points down, so a higher fraction plots at a SMALLER y — visually
+// "up"); x is spread evenly across the width by the sample's index in vals,
+// not by elapsed time. A nil sample contributes no pair at all — its x slot
+// is simply skipped, so the polyline draws a straight line across the gap;
+// breaking the line at gaps is not attempted in v1 (documented here per the
+// brief). Empty input or input that is nil throughout returns "".
+func sparkPoints(vals []*float64, w, h int) string {
+ if len(vals) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ n := len(vals)
+ for i, v := range vals {
+ if v == nil {
+ continue
+ }
+ f := *v
+ if f < 0 {
+ f = 0
+ } else if f > 1 {
+ f = 1
+ }
+ x := 0.0
+ if n > 1 {
+ x = float64(i) / float64(n-1) * float64(w)
+ }
+ y := float64(h) - f*float64(h)
+ if b.Len() > 0 {
+ b.WriteByte(' ')
+ }
+ b.WriteString(strconv.FormatFloat(x, 'f', 2, 64))
+ b.WriteByte(',')
+ b.WriteString(strconv.FormatFloat(y, 'f', 2, 64))
+ }
+ return b.String()
+}
diff --git a/internal/console/metricsview_test.go b/internal/console/metricsview_test.go
new file mode 100644
index 000000000..36c67dea3
--- /dev/null
+++ b/internal/console/metricsview_test.go
@@ -0,0 +1,236 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/metrics"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// newMetricsFixture builds the minimal Server LoadMetrics's tests need: a
+// console Store (NewServer's SigningKey dance requires one) and a bare
+// release.DirStore, with none of consoleFixture's registry/keymail/HTTP
+// scaffolding — mirrors server_test.go's minimal Config shape, trimmed to
+// what a bucket-only read path touches.
+func newMetricsFixture(t *testing.T) (*Server, release.Store) {
+ t.Helper()
+ st, err := Open(filepath.Join(t.TempDir(), "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { st.Close() })
+ rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket"))
+ srv, err := NewServer(Config{Releases: rel}, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return srv, rel
+}
+
+func p(f float64) *float64 { return &f }
+func pi64(i int64) *int64 { return &i }
+
+// writeAlertState publishes an AlertState the way the box agent would,
+// create-only since the fixture never re-publishes the same box twice.
+func writeAlertState(t *testing.T, rel release.Store, st metrics.AlertState) {
+ t.Helper()
+ b, err := json.Marshal(st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := rel.Put(context.Background(), metrics.AlertKey(st.Box), b, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestLoadMetricsSweepsAndCaches pins the whole read path in one pass:
+// discovery, the 48h raw sweep landing on the newest sample's values,
+// non-empty sparklines, CPU-desc unit ordering with nil-CPU last, firing-only
+// alerts — and then that a second call inside the 60s TTL returns the SAME
+// result without touching the store again (proven by deleting every object
+// LoadMetrics would need and asserting the second call doesn't notice).
+func TestLoadMetricsSweepsAndCaches(t *testing.T) {
+ srv, rel := newMetricsFixture(t)
+ ctx := context.Background()
+
+ olderAt := time.Now().UTC().Add(-90 * time.Minute)
+ latestAt := time.Now().UTC().Add(-5 * time.Minute)
+
+ older := metrics.RawChunk{
+ V: 1,
+ Box: "box-a",
+ Hour: metrics.HourOf(olderAt),
+ Samples: []metrics.Sample{
+ {
+ At: olderAt,
+ Host: metrics.HostSample{CPUFrac: p(0.2), MemFrac: p(0.3), DiskFrac: p(0.4)},
+ Units: map[string]metrics.UnitSample{
+ "carlos-app@a.service": {CPUFrac: p(0.05), MemBytes: pi64(1000)},
+ },
+ },
+ },
+ }
+ latest := metrics.RawChunk{
+ V: 1,
+ Box: "box-a",
+ Hour: metrics.HourOf(latestAt),
+ Samples: []metrics.Sample{
+ {
+ At: latestAt,
+ Host: metrics.HostSample{CPUFrac: p(0.5), MemFrac: p(0.6), DiskFrac: p(0.7)},
+ Units: map[string]metrics.UnitSample{
+ "carlos-app@a.service": {CPUFrac: p(0.9), MemBytes: pi64(2000)},
+ "carlos-app@b.service": {CPUFrac: p(0.1), MemBytes: pi64(500)},
+ "carlos-app@nilcpu.service": {MemBytes: pi64(100)},
+ },
+ },
+ },
+ }
+ for _, c := range []metrics.RawChunk{older, latest} {
+ if err := metrics.WriteRaw(ctx, rel, c); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ writeAlertState(t, rel, metrics.AlertState{
+ V: 1,
+ Box: "box-a",
+ Rules: map[string]metrics.RuleState{
+ "disk": {Firing: true, Value: 0.95, FiredAt: time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)},
+ "cpu": {Firing: false, Value: 0.5},
+ },
+ })
+
+ boxes, alerts, err := srv.LoadMetrics(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes) != 1 {
+ t.Fatalf("boxes = %d, want 1: %+v", len(boxes), boxes)
+ }
+ bv := boxes[0]
+ if bv.Box != "box-a" {
+ t.Fatalf("box = %q, want box-a", bv.Box)
+ }
+ if bv.CPU == nil || *bv.CPU != 0.5 {
+ t.Fatalf("CPU = %v, want 0.5 (the newest sample's value)", bv.CPU)
+ }
+ if !bv.At.Equal(latestAt) {
+ t.Fatalf("At = %v, want %v (the newest sample's time)", bv.At, latestAt)
+ }
+ if bv.SparkCPU == "" {
+ t.Fatal("SparkCPU is empty despite two samples")
+ }
+ if len(bv.Units) != 3 {
+ t.Fatalf("units = %d, want 3: %+v", len(bv.Units), bv.Units)
+ }
+ if bv.Units[0].Unit != "carlos-app@a.service" || bv.Units[0].CPUFrac == nil || *bv.Units[0].CPUFrac != 0.9 {
+ t.Fatalf("units[0] = %+v, want carlos-app@a.service at 0.9 (CPU desc)", bv.Units[0])
+ }
+ if bv.Units[1].Unit != "carlos-app@b.service" {
+ t.Fatalf("units[1] = %+v, want carlos-app@b.service", bv.Units[1])
+ }
+ if bv.Units[2].Unit != "carlos-app@nilcpu.service" || bv.Units[2].CPUFrac != nil {
+ t.Fatalf("units[2] = %+v, want the nil-CPU unit sorted last", bv.Units[2])
+ }
+ if len(alerts) != 1 {
+ t.Fatalf("alerts = %d, want 1 (firing rules only, cpu excluded): %+v", len(alerts), alerts)
+ }
+ if alerts[0].Box != "box-a" || alerts[0].Rule != "disk" || alerts[0].Value != 0.95 {
+ t.Fatalf("alert = %+v", alerts[0])
+ }
+ if !alerts[0].FiredAt.Equal(time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC)) {
+ t.Fatalf("alert.FiredAt = %v, want the state's FiredAt as-is", alerts[0].FiredAt)
+ }
+
+ // Poison the store: delete everything a fresh sweep would need to read.
+ if err := rel.Delete(ctx, metrics.RawKey("box-a", metrics.HourOf(latestAt))); err != nil {
+ t.Fatal(err)
+ }
+ if err := rel.Delete(ctx, metrics.RawKey("box-a", metrics.HourOf(olderAt))); err != nil {
+ t.Fatal(err)
+ }
+ if err := rel.Delete(ctx, metrics.AlertKey("box-a")); err != nil {
+ t.Fatal(err)
+ }
+
+ boxes2, alerts2, err := srv.LoadMetrics(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes2) != 1 || boxes2[0].Box != "box-a" || boxes2[0].CPU == nil || *boxes2[0].CPU != 0.5 {
+ t.Fatalf("second call within TTL re-read a poisoned store: %+v", boxes2)
+ }
+ if len(alerts2) != 1 {
+ t.Fatalf("second call within TTL re-read alerts from a poisoned store: %+v", alerts2)
+ }
+}
+
+// TestLoadMetricsEmptyBucket: no box has ever published anything — the
+// normal state of a freshly deployed console — is a clean empty result, not
+// an error.
+func TestLoadMetricsEmptyBucket(t *testing.T) {
+ srv, _ := newMetricsFixture(t)
+ boxes, alerts, err := srv.LoadMetrics(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(boxes) != 0 || len(alerts) != 0 {
+ t.Fatalf("boxes=%+v alerts=%+v, want both empty", boxes, alerts)
+ }
+}
+
+// TestSparkPointsInvertsYAndSkipsNil pins the coordinate math the brief
+// calls out explicitly: a nil sample contributes no pair at all (not a
+// zero), and a higher fraction plots at a SMALLER y (SVG's y axis points
+// down, so "up" on screen means a smaller number).
+func TestSparkPointsInvertsYAndSkipsNil(t *testing.T) {
+ pts := sparkPoints([]*float64{p(0.1), nil, p(0.9)}, 240, 40)
+ fields := strings.Fields(pts)
+ if len(fields) != 2 {
+ t.Fatalf("got %d coordinate pairs, want 2 (the nil sample skipped): %q", len(fields), pts)
+ }
+ xy := func(pair string) (x, y float64) {
+ parts := strings.SplitN(pair, ",", 2)
+ if len(parts) != 2 {
+ t.Fatalf("malformed pair %q", pair)
+ }
+ xf, err := strconv.ParseFloat(parts[0], 64)
+ if err != nil {
+ t.Fatalf("bad x in %q: %v", pair, err)
+ }
+ yf, err := strconv.ParseFloat(parts[1], 64)
+ if err != nil {
+ t.Fatalf("bad y in %q: %v", pair, err)
+ }
+ return xf, yf
+ }
+ x0, y0 := xy(fields[0])
+ x1, y1 := xy(fields[1])
+ if x1 <= x0 {
+ t.Fatalf("x did not advance across the skipped nil slot: x0=%v x1=%v", x0, x1)
+ }
+ if y1 >= y0 {
+ t.Fatalf("0.9 (fields[1]) should plot ABOVE 0.1 (fields[0]) — smaller y: y0=%v y1=%v", y0, y1)
+ }
+}
+
+// TestSparkPointsEmptyOrAllNil: no readable data renders no line, per the
+// brief ("" when no data) — never a fabricated flat line at zero.
+func TestSparkPointsEmptyOrAllNil(t *testing.T) {
+ if got := sparkPoints(nil, 240, 40); got != "" {
+ t.Fatalf("nil input = %q, want empty", got)
+ }
+ if got := sparkPoints([]*float64{}, 240, 40); got != "" {
+ t.Fatalf("empty slice = %q, want empty", got)
+ }
+ if got := sparkPoints([]*float64{nil, nil, nil}, 240, 40); got != "" {
+ t.Fatalf("all-nil input = %q, want empty", got)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 8f7391309..76b06778d 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -124,6 +124,11 @@ type Server struct {
// km.SendApp in production, a capture in tests. A field rather than a
// direct call so an alertPass assertion needs no keymail server.
sendApp func(ctx context.Context, token, to, subject, body string) (string, error)
+ // metricsCache holds the result of LoadMetrics's last successful bucket
+ // sweep (metricsview.go), shared across callers for metricsCacheTTL so
+ // the operator dashboard and every owner's app-metrics page don't each
+ // re-sweep 48h of chunks per request.
+ metricsCache metricsCache
}
// NewServer wires a server over an opened Store, loading (or minting) the