carlos / platform Public

console: operator metrics dashboard — needles, sparklines, noisy-neighbour table

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 551db2c65fb6981c1ec9102d3210226c8c7e50ca parent 93c163b
3 files changed, +202 −0
  • internal/console/server.go +68 −0
  • internal/console/server_test.go +72 −0
  • internal/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}} &middot; 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}}