console: operator metrics dashboard — needles, sparklines, noisy-neighbour table
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+202
−0
internal/console/server.go+68 −0internal/console/server_test.go+72 −0internal/console/templates/metrics.html+62 −0
diff --git a/internal/console/server.go b/internal/console/server.go| index 76b06778d..54ca6ba9e 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -64,6 +64,39 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| } |
| return s |
| }, |
| + // pct renders a fraction (0..1, as CPU/Mem/Disk readings and unit CPU |
| + // fractions are stored — internal/metrics.HostSample/UnitSample) as a |
| + // whole-percent string; nil means no reading, never zero, so it renders |
| + // the same em-dash every other "unknown" field on this console does. |
| + "pct": func(v *float64) string { |
| + if v == nil { |
| + return "—" |
| + } |
| + return fmt.Sprintf("%.0f%%", *v*100) |
| + }, |
| + // pctVal is pct's non-pointer twin for AlertRow.Value: LoadMetrics only |
| + // ever returns firing rules, so an alert row always carries a value — |
| + // there is no nil case to dash out. |
| + "pctVal": func(v float64) string { return fmt.Sprintf("%.0f%%", v*100) }, |
| + // bytesOrDash renders a byte count in the smallest unit that keeps it |
| + // readable; nil (no reading, from a unit that reported CPU but not |
| + // memory/IO that tick) is "—", never a fabricated 0 B. |
| + "bytesOrDash": func(v *int64) string { |
| + if v == nil { |
| + return "—" |
| + } |
| + b := float64(*v) |
| + switch { |
| + case b >= 1<<30: |
| + return fmt.Sprintf("%.1f GB", b/(1<<30)) |
| + case b >= 1<<20: |
| + return fmt.Sprintf("%.1f MB", b/(1<<20)) |
| + case b >= 1<<10: |
| + return fmt.Sprintf("%.1f KB", b/(1<<10)) |
| + default: |
| + return fmt.Sprintf("%d B", *v) |
| + } |
| + }, |
| // mapGet reads one named field out of Fleet.Instances — a map so a |
| // template range would iterate it key-sorted, alphabetically, which |
| // scrambles the static/resident/hibernating/awake/asleep order the |
| @@ -165,6 +198,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /auth/confirm", s.handleConfirm) |
| s.mux.HandleFunc("POST /auth/signout", s.handleSignout) |
| s.mux.HandleFunc("GET /fleet", s.handleFleet) |
| + s.mux.HandleFunc("GET /fleet/metrics", s.handleFleetMetrics) |
| // CLI identity: the device-code login `carlos auth login` drives, the |
| // approval page, and the tokens an owner can see and kill (authcli.go). |
| s.registerCLIRoutes() |
| @@ -254,6 +288,12 @@ type page struct { |
| Invites []Invite // account home, owners only: pending invitations |
| Fleet *status.Fleet // /fleet: nil means no snapshot published yet |
| + |
| + // /fleet/metrics: the operator dashboard (Task 11), both from LoadMetrics |
| + // (metricsview.go) and both nil-safe to render — no boxes and no firing |
| + // alerts are the normal, healthy, empty state, not an error. |
| + Metrics []BoxMetricsView |
| + MetricAlerts []AlertRow |
| } |
| // IsOwner is the template's one permission question. |
| @@ -925,6 +965,34 @@ func (s *Server) handleFleet(w http.ResponseWriter, r *http.Request) { |
| s.render(w, http.StatusOK, "fleet", p) |
| } |
| +// handleFleetMetrics serves the operator host/process monitoring dashboard — |
| +// same gate as handleFleet, same reasoning: every non-operator path is a |
| +// plain http.NotFound so the route's existence is not probeable. |
| +func (s *Server) handleFleetMetrics(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.session(r) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + isOp, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: fleet metrics: is-operator: %v", err) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !isOp { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + boxes, alerts, err := s.LoadMetrics(r.Context()) |
| + if err != nil { |
| + log.Printf("console: fleet metrics: %v", err) |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Unavailable", "Metrics could not be read.") |
| + return |
| + } |
| + s.render(w, http.StatusOK, "metrics", page{Title: "Fleet metrics", Address: addr, CSRF: raw, Metrics: boxes, MetricAlerts: alerts}) |
| +} |
| + |
| // ---- app pages -------------------------------------------------------------- |
| // handleAccountHome is the account's app list, its switcher, its members and |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 1803992fc..9e67ef982 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -17,6 +17,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/metrics" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| @@ -1129,3 +1130,74 @@ func TestFleetKeylessReadable(t *testing.T) { |
| t.Fatalf("keyless /fleet = %d %s, want 200 with Fleet status", status, body) |
| } |
| } |
| + |
| +// TestFleetMetricsOperatorOnly is Task 11's gate, mirroring TestFleetOperatorOnly |
| +// for /fleet/metrics: signed out and signed in-but-not-operator both get |
| +// exactly the same 404 an unrouted URL gets; an operator gets 200 with a |
| +// seeded box label and unit name in the body. |
| +func TestFleetMetricsOperatorOnly(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + |
| + ctx := context.Background() |
| + at := time.Now().UTC().Add(-5 * time.Minute) |
| + chunk := metrics.RawChunk{ |
| + V: 1, |
| + Box: "box-metrics-a", |
| + Hour: metrics.HourOf(at), |
| + Samples: []metrics.Sample{ |
| + { |
| + At: at, |
| + Host: metrics.HostSample{CPUFrac: p(0.42), MemFrac: p(0.33), DiskFrac: p(0.5)}, |
| + Units: map[string]metrics.UnitSample{ |
| + "carlos-app@widget.service": {CPUFrac: p(0.11), MemBytes: pi64(1024)}, |
| + }, |
| + }, |
| + }, |
| + } |
| + if err := metrics.WriteRaw(ctx, fx.rel, chunk); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + out := newBrowser(t) |
| + statusGhost, _, bodyGhost := get(t, out, fx.ts.URL+"/definitely-not-a-route") |
| + if statusGhost != http.StatusNotFound { |
| + t.Fatalf("sanity: a missing route = %d, want 404", statusGhost) |
| + } |
| + if status, _, body := get(t, out, fx.ts.URL+"/fleet/metrics"); status != http.StatusNotFound || body != bodyGhost { |
| + t.Fatalf("signed-out /fleet/metrics = %d %q, want the same 404 as a missing route (%d %q)", status, body, statusGhost, bodyGhost) |
| + } |
| + |
| + // Signed in and owns kass, but is not an operator: still the same 404. |
| + owner := signInPaul(t, fx) |
| + if status, _, body := get(t, owner, fx.ts.URL+"/fleet/metrics"); status != http.StatusNotFound || body != bodyGhost { |
| + t.Fatalf("non-operator /fleet/metrics = %d %q, want the same 404 as a missing route", status, body) |
| + } |
| + |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, _, body := get(t, owner, fx.ts.URL+"/fleet/metrics") |
| + if status != 200 { |
| + t.Fatalf("operator /fleet/metrics = %d, want 200: %s", status, body) |
| + } |
| + for _, want := range []string{"box-metrics-a", "carlos-app@widget.service"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("/fleet/metrics missing %q in:\n%s", want, body) |
| + } |
| + } |
| +} |
| + |
| +// TestFleetMetricsEmptyState: no box has ever published metrics — the normal |
| +// state right after a deployment — renders the page's empty state, never a |
| +// 500. |
| +func TestFleetMetricsEmptyState(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, _, body := get(t, c, fx.ts.URL+"/fleet/metrics") |
| + if status != 200 || !strings.Contains(body, "Fleet metrics") { |
| + t.Fatalf("empty /fleet/metrics: %d %s", status, body) |
| + } |
| +} |
diff --git a/internal/console/templates/metrics.html b/internal/console/templates/metrics.html| new file mode 100644 |
| index 000000000..978724047 |
| --- /dev/null |
| +++ b/internal/console/templates/metrics.html |
| @@ -0,0 +1,62 @@ |
| +{{define "metrics"}}{{template "top" .}} |
| +<h1>Fleet metrics</h1> |
| + |
| +{{if .MetricAlerts}} |
| +<h2 class="error">Alerts</h2> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Box</th><th>Rule</th><th>Value</th><th>Fired</th></tr> |
| + {{range .MetricAlerts}} |
| + <tr> |
| + <td>{{.Box}}</td> |
| + <td>{{.Rule}}</td> |
| + <td class="error">{{pctVal .Value}}</td> |
| + <td>{{fmtTime .FiredAt}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{end}} |
| + |
| +{{if .Metrics}} |
| +{{range .Metrics}} |
| +<h2>{{.Box}}</h2> |
| +<p class="small">Latest {{fmtTime .At}}</p> |
| +<section class="tiles" aria-label="{{.Box}} headline metrics"> |
| + <div class="tile"> |
| + <h2>CPU</h2> |
| + <p class="tile-value">{{pct .CPU}}</p> |
| + <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkCPU}}"/></svg> |
| + </div> |
| + <div class="tile"> |
| + <h2>Memory</h2> |
| + <p class="tile-value">{{pct .Mem}}</p> |
| + <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkMem}}"/></svg> |
| + </div> |
| + <div class="tile"> |
| + <h2>Disk</h2> |
| + <p class="tile-value">{{pct .Disk}}</p> |
| + <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkDisk}}"/></svg> |
| + </div> |
| +</section> |
| + |
| +{{if .Units}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Unit</th><th>CPU</th><th>Memory</th><th>IO</th></tr> |
| + {{range .Units}} |
| + <tr> |
| + <td>{{.Unit}}</td> |
| + <td>{{pct .CPUFrac}}</td> |
| + <td>{{bytesOrDash .MemBytes}}</td> |
| + <td>R {{bytesOrDash .ReadBytes}} · W {{bytesOrDash .WriteBytes}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{else}}<p class="small">No units reporting for this box.</p>{{end}} |
| +{{end}} |
| +{{else}} |
| +<p>No boxes have published metrics yet.</p> |
| +{{end}} |
| +{{template "bottom" .}}{{end}} |