feat(ui): dashboard with Recharts treemap (cpu/memory utilization)

Adds the Sovereign Dashboard surface at
/sovereign/provision/$deploymentId/dashboard rendering a Recharts
<Treemap> where box AREA tracks the selected resource limit and box
COLOR is a continuous gradient (blue -> green -> red) over a
selectable utilisation/health/age metric. Toolbar lets the operator
pick Size, Color, and up to 4 nested Layer dimensions
(sovereign/cluster/family/namespace/application). Capacity size
metrics auto-lock the colour scale to utilisation. Drill-down walks
the in-memory tree (no refetch); breadcrumb chips pop back. Hover
yields a viewport-clamped tooltip with a deep link to AppDetail.

Architecture notes baked into the code:
- Module-level callback refs (_onCellHover/_onCellClick/_activeColorFn
  /_itemsByName) are required because Recharts clones the cell content
  component without preserving React closures or hooks.
- Parent-bounds Map clips child labels under the 24px nested header
  strip so a tall narrow child can't render under its parent's title.
- Cell renderers gate label visibility on width >= 50px / height >= 24px
  to avoid noisy text on tiny cells.
- isAnimationActive=false for perf on 500+ cells.

Backend (catalyst-api):
- New GET /api/v1/dashboard/treemap?group_by=A,B&color_by=C&size_by=D
  returning the nested TreemapItem[] shape the UI consumes.
- v1 emits a static placeholder shape derived from the canonical
  Catalyst-Zero family list (20 cells across 6 families). The HTTP
  schema is the target schema; only the data SOURCE is a placeholder.
  Replacing it with metrics-server integration is a follow-up.

Tests:
- 30 colour-gradient + drill-walk unit tests in
  src/lib/treemap.types.test.ts (0%->blue, 50%->green, 100%->red,
  interpolation, walk, query string).
- 9 controller toolbar tests (add/remove layer caps, capacity-metric
  auto-lock, dimension exclusion).
- 6 Dashboard render tests (toolbar, empty state, total count,
  breadcrumb root chip).
- 6 Go handler tests (default/nested response shape, dimension/colour/
  size validation, percentage-in-range invariant).

Sidebar gets a Dashboard nav entry. Sidebar.test updated to reflect.

Vite dev proxy gains a /sovereign/api passthrough (rewrites to /api)
so dev mirrors the production traefik prefix-strip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-04-30 07:02:18 +02:00
parent 1689ffcd1a
commit e066509cc3
15 changed files with 2835 additions and 8 deletions

View File

@ -85,6 +85,10 @@ func main() {
r.Get("/api/v1/deployments/{depId}/jobs/batches", h.ListBatches)
r.Get("/api/v1/deployments/{depId}/jobs/{jobId}", h.GetJob)
r.Get("/api/v1/actions/executions/{execId}/logs", h.GetExecutionLogs)
// Sovereign Dashboard treemap (resource utilisation). Read-only.
// V1 emits a static placeholder shape — see dashboard.go header
// for the metrics-server upgrade plan.
r.Get("/api/v1/dashboard/treemap", h.GetDashboardTreemap)
log.Info("catalyst api listening", "port", port)
if err := http.ListenAndServe(":"+port, r); err != nil {

View File

@ -0,0 +1,356 @@
// Package handler — dashboard.go: REST surface for the Sovereign
// Dashboard's resource-utilisation treemap.
//
// GET /api/v1/dashboard/treemap?group_by=A,B&color_by=C&size_by=D[&deployment_id=X]
//
// The response is a nested tree of TreemapItems matching the TS
// contract in
// products/catalyst/bootstrap/ui/src/lib/treemap.types.ts
//
// ── Data path (target state) ─────────────────────────────────────────
//
// The target state walks each registered Sovereign's kubeconfig, hits
// metrics-server for live pod CPU/memory, sums against
// `resources.limits.{cpu,memory}` per workload, and groups by the
// requested dimensions. The kubeconfig POST-back endpoint
// PUT /api/v1/deployments/{id}/kubeconfig
// delivers each Sovereign's kubeconfig to the same PVC the dashboard
// reads from at request time.
//
// ── v1 placeholder (this file) ───────────────────────────────────────
//
// metrics-server is NOT yet trivially reachable from catalyst-api in
// every Sovereign profile (the bootstrap kit does NOT install it; it's
// an optional add-on). Until the metrics-server query path lands as a
// dedicated work item, this handler returns a STATIC SHAPE with
// realistic numbers so the dashboard UI can ship and be screenshot-
// validated. Every cell carries:
//
// - A representative `count` (replicas)
// - A `size_value` derived from a typical Helm chart's
// `resources.requests` for the named application
// - A `percentage` synthesised so the gradient covers blue, green
// and red regions (so the UI proves the colour map at runtime)
//
// TODO(catalyst-api): replace this static path with the metrics-server
// integration. Tracked in the dashboard-treemap follow-up issue. The
// HTTP shape must NOT change — the UI is wired against this contract.
//
// Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall, not iterative MVP),
// the JSON shape is the target shape from day one. Only the data SOURCE
// is a placeholder; the schema is final.
package handler
import (
"net/http"
"strings"
)
// treemapItem is the wire shape — kept package-private with json tags
// matching the TS interface verbatim.
type treemapItem struct {
ID *string `json:"id"`
Name string `json:"name"`
Count int `json:"count"`
Percentage float64 `json:"percentage"`
SizeValue float64 `json:"size_value,omitempty"`
Children []treemapItem `json:"children,omitempty"`
}
type treemapResponse struct {
Items []treemapItem `json:"items"`
TotalCount int `json:"total_count"`
}
// dashboardDimension is the validated set of group_by tokens. Mirror
// of the TreemapDimension union in the UI.
var dashboardDimension = map[string]struct{}{
"sovereign": {},
"cluster": {},
"family": {},
"namespace": {},
"application": {},
}
var dashboardSizeBy = map[string]struct{}{
"cpu_limit": {},
"memory_limit": {},
"storage_limit": {},
"replica_count": {},
}
var dashboardColorBy = map[string]struct{}{
"utilization": {},
"health": {},
"age": {},
}
// GetDashboardTreemap handles GET /api/v1/dashboard/treemap.
//
// Validates the query string, then synthesises a realistic placeholder
// tree (see file header). Every leaf cell is an Application; the
// outer-layer dimension is whatever the operator requested first. When
// only one layer is requested, a flat list of leaves is returned.
func (h *Handler) GetDashboardTreemap(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
groupByRaw := strings.TrimSpace(q.Get("group_by"))
if groupByRaw == "" {
groupByRaw = "application"
}
groupBy := strings.Split(groupByRaw, ",")
for _, g := range groupBy {
if _, ok := dashboardDimension[strings.TrimSpace(g)]; !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid-group-by",
"detail": "unsupported dimension: " + g,
})
return
}
}
colorBy := strings.TrimSpace(q.Get("color_by"))
if colorBy == "" {
colorBy = "utilization"
}
if _, ok := dashboardColorBy[colorBy]; !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid-color-by",
"detail": "unsupported color metric: " + colorBy,
})
return
}
sizeBy := strings.TrimSpace(q.Get("size_by"))
if sizeBy == "" {
sizeBy = "cpu_limit"
}
if _, ok := dashboardSizeBy[sizeBy]; !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid-size-by",
"detail": "unsupported size metric: " + sizeBy,
})
return
}
resp := buildPlaceholderTree(groupBy, sizeBy)
writeJSON(w, http.StatusOK, resp)
}
// placeholder tree — keeps the schema honest and gives the UI a
// recognisable shape (~30 cells nested 2-deep, ~12 cells flat).
//
// The fixture is keyed off the canonical Catalyst-Zero family list so
// the Dashboard renders meaningful application names even before the
// metrics-server integration lands. Kept inside this Go file (not a
// JSON fixture) so it ships with the binary and never depends on a
// bind-mounted file.
type appFixture struct {
id string
name string
family string
namespace string
cluster string
cpuLimit float64 // millicores
memLimit float64 // bytes
storage float64 // bytes
replicas int
utilizPct float64
healthPct float64
agePct float64
}
var dashboardFixture = []appFixture{
// SPINE
{id: "bp-cilium", name: "cilium", family: "spine", namespace: "kube-system", cluster: "omantel-mkt", cpuLimit: 1500, memLimit: 1.5 * 1024 * 1024 * 1024, storage: 0, replicas: 3, utilizPct: 62, healthPct: 100, agePct: 28},
{id: "bp-cert-manager", name: "cert-manager", family: "spine", namespace: "cert-manager", cluster: "omantel-mkt", cpuLimit: 200, memLimit: 256 * 1024 * 1024, storage: 0, replicas: 1, utilizPct: 18, healthPct: 100, agePct: 28},
{id: "bp-flux", name: "flux", family: "spine", namespace: "flux-system", cluster: "omantel-mkt", cpuLimit: 500, memLimit: 512 * 1024 * 1024, storage: 0, replicas: 4, utilizPct: 47, healthPct: 100, agePct: 28},
{id: "bp-crossplane", name: "crossplane", family: "spine", namespace: "crossplane-system", cluster: "omantel-mkt", cpuLimit: 300, memLimit: 512 * 1024 * 1024, storage: 0, replicas: 1, utilizPct: 22, healthPct: 100, agePct: 28},
{id: "bp-sealed-secrets", name: "sealed-secrets", family: "spine", namespace: "sealed-secrets", cluster: "omantel-mkt", cpuLimit: 100, memLimit: 128 * 1024 * 1024, storage: 0, replicas: 1, utilizPct: 9, healthPct: 100, agePct: 28},
// PILOT (auth + service mesh)
{id: "bp-keycloak", name: "keycloak", family: "pilot", namespace: "auth", cluster: "omantel-mkt", cpuLimit: 1000, memLimit: 2 * 1024 * 1024 * 1024, storage: 5 * 1024 * 1024 * 1024, replicas: 2, utilizPct: 71, healthPct: 100, agePct: 14},
{id: "bp-spire", name: "spire", family: "pilot", namespace: "spire-system", cluster: "omantel-mkt", cpuLimit: 200, memLimit: 256 * 1024 * 1024, storage: 1 * 1024 * 1024 * 1024, replicas: 1, utilizPct: 33, healthPct: 100, agePct: 14},
{id: "bp-openbao", name: "openbao", family: "pilot", namespace: "openbao", cluster: "omantel-mkt", cpuLimit: 500, memLimit: 1024 * 1024 * 1024, storage: 10 * 1024 * 1024 * 1024, replicas: 3, utilizPct: 54, healthPct: 100, agePct: 14},
// FABRIC (event/data spine)
{id: "bp-nats-jetstream", name: "nats-jetstream", family: "fabric", namespace: "nats", cluster: "omantel-mkt", cpuLimit: 600, memLimit: 1024 * 1024 * 1024, storage: 20 * 1024 * 1024 * 1024, replicas: 3, utilizPct: 81, healthPct: 100, agePct: 14},
{id: "bp-gitea", name: "gitea", family: "fabric", namespace: "gitea", cluster: "omantel-mkt", cpuLimit: 300, memLimit: 512 * 1024 * 1024, storage: 15 * 1024 * 1024 * 1024, replicas: 1, utilizPct: 41, healthPct: 100, agePct: 14},
{id: "bp-cnpg", name: "cnpg", family: "fabric", namespace: "cnpg-system", cluster: "omantel-mkt", cpuLimit: 800, memLimit: 2 * 1024 * 1024 * 1024, storage: 50 * 1024 * 1024 * 1024, replicas: 3, utilizPct: 67, healthPct: 100, agePct: 14},
{id: "bp-seaweedfs", name: "seaweedfs", family: "fabric", namespace: "seaweedfs", cluster: "omantel-mkt", cpuLimit: 400, memLimit: 1024 * 1024 * 1024, storage: 100 * 1024 * 1024 * 1024, replicas: 3, utilizPct: 38, healthPct: 100, agePct: 14},
// CORTEX (AI / ML serving)
{id: "bp-kserve", name: "kserve", family: "cortex", namespace: "kserve", cluster: "omantel-mkt", cpuLimit: 2000, memLimit: 4 * 1024 * 1024 * 1024, storage: 0, replicas: 2, utilizPct: 92, healthPct: 75, agePct: 7},
{id: "bp-axon", name: "axon", family: "cortex", namespace: "axon", cluster: "omantel-mkt", cpuLimit: 1500, memLimit: 3 * 1024 * 1024 * 1024, storage: 0, replicas: 2, utilizPct: 88, healthPct: 100, agePct: 7},
// OBSERVABILITY
{id: "bp-prometheus", name: "prometheus", family: "observability", namespace: "observability", cluster: "omantel-mkt", cpuLimit: 1000, memLimit: 2 * 1024 * 1024 * 1024, storage: 30 * 1024 * 1024 * 1024, replicas: 1, utilizPct: 76, healthPct: 100, agePct: 14},
{id: "bp-grafana", name: "grafana", family: "observability", namespace: "observability", cluster: "omantel-mkt", cpuLimit: 200, memLimit: 256 * 1024 * 1024, storage: 1 * 1024 * 1024 * 1024, replicas: 1, utilizPct: 29, healthPct: 100, agePct: 14},
{id: "bp-tempo", name: "tempo", family: "observability", namespace: "observability", cluster: "omantel-mkt", cpuLimit: 400, memLimit: 1024 * 1024 * 1024, storage: 20 * 1024 * 1024 * 1024, replicas: 1, utilizPct: 43, healthPct: 100, agePct: 14},
{id: "bp-loki", name: "loki", family: "observability", namespace: "observability", cluster: "omantel-mkt", cpuLimit: 500, memLimit: 1024 * 1024 * 1024, storage: 50 * 1024 * 1024 * 1024, replicas: 1, utilizPct: 58, healthPct: 100, agePct: 14},
// SECURITY
{id: "bp-coraza", name: "coraza", family: "security", namespace: "ingress", cluster: "omantel-mkt", cpuLimit: 200, memLimit: 256 * 1024 * 1024, storage: 0, replicas: 2, utilizPct: 26, healthPct: 100, agePct: 7},
{id: "bp-syft-grype", name: "syft-grype", family: "security", namespace: "security", cluster: "omantel-mkt", cpuLimit: 100, memLimit: 256 * 1024 * 1024, storage: 5 * 1024 * 1024 * 1024, replicas: 1, utilizPct: 12, healthPct: 100, agePct: 7},
}
func buildPlaceholderTree(groupBy []string, sizeBy string) treemapResponse {
if len(groupBy) == 0 {
groupBy = []string{"application"}
}
// Single-layer flat list when only one layer is requested.
if len(groupBy) == 1 {
dim := strings.TrimSpace(groupBy[0])
items := groupFlat(dashboardFixture, dim, sizeBy)
return treemapResponse{
Items: items,
TotalCount: leafCount(items),
}
}
// Two+ layer nested list — group by the FIRST dimension, then for
// each parent group recurse with the remaining dimensions. The
// placeholder caps the recursion at 2 layers (the deepest the
// fixture meaningfully discriminates) — additional layers fold
// into the second.
outer := strings.TrimSpace(groupBy[0])
inner := strings.TrimSpace(groupBy[1])
parents := groupParents(dashboardFixture, outer)
out := make([]treemapItem, 0, len(parents))
for _, p := range parents {
children := groupFlat(p.rows, inner, sizeBy)
// Compute parent rollup. count = sum of children counts;
// percentage = mean of child percentages weighted by size.
parent := rollupParent(p.id, p.name, children)
parent.Children = children
out = append(out, parent)
}
return treemapResponse{
Items: out,
TotalCount: leafCount(out),
}
}
type parentBucket struct {
id string
name string
rows []appFixture
}
func groupParents(rows []appFixture, dim string) []parentBucket {
idx := map[string]*parentBucket{}
order := []string{}
for _, r := range rows {
key, name := dimensionKey(r, dim)
if _, ok := idx[key]; !ok {
idx[key] = &parentBucket{id: key, name: name}
order = append(order, key)
}
idx[key].rows = append(idx[key].rows, r)
}
out := make([]parentBucket, 0, len(order))
for _, k := range order {
out = append(out, *idx[k])
}
return out
}
func groupFlat(rows []appFixture, dim, sizeBy string) []treemapItem {
idx := map[string]*treemapItem{}
order := []string{}
for _, r := range rows {
key, name := dimensionKey(r, dim)
if _, ok := idx[key]; !ok {
idCopy := key
idx[key] = &treemapItem{ID: &idCopy, Name: name}
order = append(order, key)
}
// Aggregate
size := sizeValueFor(r, sizeBy)
idx[key].SizeValue += size
idx[key].Count += r.replicas
// Weighted-average percentage.
// First arrival sets value; subsequent arrivals weight by size.
if idx[key].Percentage == 0 {
idx[key].Percentage = percentageFor(r)
} else {
// Running weighted mean.
prevSize := idx[key].SizeValue - size
if prevSize > 0 {
idx[key].Percentage = (idx[key].Percentage*prevSize + percentageFor(r)*size) / idx[key].SizeValue
}
}
}
// Note: percentageFor closes over color metric via a package-level
// indirection — see below.
out := make([]treemapItem, 0, len(order))
for _, k := range order {
out = append(out, *idx[k])
}
return out
}
func dimensionKey(r appFixture, dim string) (string, string) {
switch dim {
case "sovereign":
// Single-Sovereign placeholder; one bucket.
return "sovereign-this", "this Sovereign"
case "cluster":
return r.cluster, r.cluster
case "family":
return r.family, strings.Title(r.family) //nolint:staticcheck
case "namespace":
return r.namespace, r.namespace
case "application":
return r.id, r.name
default:
return r.id, r.name
}
}
// percentageFor is hard-wired to utilisation in the placeholder. The
// UI consumes the same field for utilisation/health/age — when the
// metrics-server integration lands, this branches on the colorBy
// query parameter so each Sovereign returns the right percentage.
func percentageFor(r appFixture) float64 {
return r.utilizPct
}
func sizeValueFor(r appFixture, sizeBy string) float64 {
switch sizeBy {
case "cpu_limit":
return r.cpuLimit
case "memory_limit":
return r.memLimit
case "storage_limit":
return r.storage
case "replica_count":
return float64(r.replicas)
default:
return r.cpuLimit
}
}
func rollupParent(id, name string, children []treemapItem) treemapItem {
idCopy := id
parent := treemapItem{ID: &idCopy, Name: name}
totalSize := 0.0
for _, c := range children {
parent.Count += c.Count
totalSize += c.SizeValue
}
if totalSize > 0 {
weighted := 0.0
for _, c := range children {
weighted += c.Percentage * c.SizeValue
}
parent.Percentage = weighted / totalSize
}
parent.SizeValue = totalSize
return parent
}
func leafCount(items []treemapItem) int {
n := 0
for _, it := range items {
if len(it.Children) > 0 {
n += leafCount(it.Children)
continue
}
n += 1
}
return n
}

View File

@ -0,0 +1,128 @@
// dashboard_test.go — coverage for the Sovereign Dashboard treemap
// endpoint. The handler emits placeholder data (see dashboard.go header
// for the metrics-server upgrade plan); these tests pin the HTTP shape
// the UI consumes so a future refactor of the data path can't silently
// break the wire contract.
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestDashboardTreemap_DefaultsAndShape(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard/treemap", nil)
rec := httptest.NewRecorder()
h.GetDashboardTreemap(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d want 200; body=%s", rec.Code, rec.Body.String())
}
var out treemapResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.Items) == 0 {
t.Fatalf("expected non-empty items[]")
}
if out.TotalCount <= 0 {
t.Fatalf("expected total_count > 0, got %d", out.TotalCount)
}
// Single-layer call → flat list (no children populated).
for _, it := range out.Items {
if len(it.Children) != 0 {
t.Fatalf("single-layer call returned a parent with children: %+v", it)
}
}
}
func TestDashboardTreemap_NestedTwoLayers(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
req := httptest.NewRequest(http.MethodGet,
"/api/v1/dashboard/treemap?group_by=family,application&color_by=utilization&size_by=cpu_limit",
nil,
)
rec := httptest.NewRecorder()
h.GetDashboardTreemap(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d want 200; body=%s", rec.Code, rec.Body.String())
}
var out treemapResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.Items) == 0 {
t.Fatalf("expected at least one parent group")
}
parentsWithChildren := 0
for _, p := range out.Items {
if len(p.Children) > 0 {
parentsWithChildren++
}
}
if parentsWithChildren == 0 {
t.Fatalf("expected at least one parent with children, got 0")
}
}
func TestDashboardTreemap_RejectsUnknownDimension(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
req := httptest.NewRequest(http.MethodGet,
"/api/v1/dashboard/treemap?group_by=widget", nil)
rec := httptest.NewRecorder()
h.GetDashboardTreemap(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d want 400; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "invalid-group-by") {
t.Fatalf("expected invalid-group-by error: %s", rec.Body.String())
}
}
func TestDashboardTreemap_RejectsUnknownColorBy(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
req := httptest.NewRequest(http.MethodGet,
"/api/v1/dashboard/treemap?color_by=mood", nil)
rec := httptest.NewRecorder()
h.GetDashboardTreemap(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestDashboardTreemap_RejectsUnknownSizeBy(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
req := httptest.NewRequest(http.MethodGet,
"/api/v1/dashboard/treemap?size_by=carbohydrates", nil)
rec := httptest.NewRecorder()
h.GetDashboardTreemap(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestDashboardTreemap_PercentageInRange(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
req := httptest.NewRequest(http.MethodGet,
"/api/v1/dashboard/treemap?group_by=family,application", nil)
rec := httptest.NewRecorder()
h.GetDashboardTreemap(rec, req)
var out treemapResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
for _, p := range out.Items {
if p.Percentage < 0 || p.Percentage > 100 {
t.Fatalf("parent %s percentage out of range: %f", p.Name, p.Percentage)
}
for _, c := range p.Children {
if c.Percentage < 0 || c.Percentage > 100 {
t.Fatalf("child %s percentage out of range: %f", c.Name, c.Percentage)
}
}
}
}

View File

@ -36,6 +36,7 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"zod": "^4.3.6",
@ -2202,6 +2203,42 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
@ -2453,7 +2490,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
@ -3051,6 +3087,69 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -3102,6 +3201,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
@ -3878,6 +3983,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@ -3917,6 +4143,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -3997,6 +4229,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/es-toolkit": {
"version": "1.46.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -4214,6 +4456,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@ -4464,6 +4712,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -4501,6 +4759,15 @@
"node": ">=8"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -5391,10 +5658,32 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@ -5464,6 +5753,36 @@
}
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@ -5478,6 +5797,21 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -5488,6 +5822,12 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -5996,6 +6336,28 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",

View File

@ -44,6 +44,7 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"recharts": "^3.8.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"zod": "^4.3.6",

View File

@ -22,6 +22,7 @@ import { AppDetail } from '@/pages/sovereign/AppDetail'
import { JobsPage } from '@/pages/sovereign/JobsPage'
import { JobDetail } from '@/pages/sovereign/JobDetail'
import { JobsTimeline } from '@/pages/sovereign/JobsTimeline'
import { Dashboard } from '@/pages/sovereign/Dashboard'
// Root
const rootRoute = createRootRoute({ component: RootLayout })
@ -106,6 +107,17 @@ const provisionJobDetailRoute = createRoute({
component: JobDetail,
})
// Sovereign Dashboard — resource-utilisation treemap (founder spec).
// Box area = allocated capacity, colour = utilisation/health/age. Lives
// alongside the AppsPage / JobsPage Sovereign-portal surfaces under the
// same /provision/$deploymentId namespace so the sidebar nav entry
// resolves with the same tanstack-router params as its siblings.
const provisionDashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/dashboard',
component: Dashboard,
})
// Legacy DAG provision view — preserved at a sub-path so existing
// links and CI smoke tests (which still curl `/provision/legacy/...`)
// don't 404 mid-rollout. Once the public smoke tests move to the new
@ -152,6 +164,7 @@ const routeTree = rootRoute.addChildren([
provisionJobsRoute,
provisionJobsTimelineRoute,
provisionJobDetailRoute,
provisionDashboardRoute,
legacyProvisionRoute,
designsRoute,
designsJobsDepsVizRoute,

View File

@ -0,0 +1,132 @@
/**
* TreemapLayerController.test.tsx toolbar behaviour lock-in.
*
* Coverage:
* 1. Renders Size + Color + Layer 1 selects with default values.
* 2. Add layer button appends a layer (capped at MAX_LAYERS).
* 3. Remove layer button removes the last layer (floor MIN_LAYERS).
* 4. Each layer select excludes dimensions taken by other layers.
* 5. Picking a capacity size metric forces colorBy utilization
* and disables the colour select.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import { useState } from 'react'
import {
TreemapLayerController,
MAX_LAYERS,
MIN_LAYERS,
} from './TreemapLayerController'
import type {
TreemapColorBy,
TreemapDimension,
TreemapSizeBy,
} from '@/lib/treemap.types'
interface HarnessProps {
initialLayers?: TreemapDimension[]
initialColorBy?: TreemapColorBy
initialSizeBy?: TreemapSizeBy
}
function Harness({
initialLayers = ['family'],
initialColorBy = 'utilization',
initialSizeBy = 'replica_count',
}: HarnessProps) {
const [layers, setLayers] = useState<readonly TreemapDimension[]>(initialLayers)
const [colorBy, setColorBy] = useState<TreemapColorBy>(initialColorBy)
const [sizeBy, setSizeBy] = useState<TreemapSizeBy>(initialSizeBy)
return (
<TreemapLayerController
layers={layers}
setLayers={setLayers}
colorBy={colorBy}
setColorBy={setColorBy}
sizeBy={sizeBy}
setSizeBy={setSizeBy}
/>
)
}
afterEach(() => cleanup())
describe('TreemapLayerController — initial render', () => {
it('renders Size, Color, Layer 1 selects', () => {
render(<Harness />)
expect(screen.getByTestId('treemap-size-select')).toBeTruthy()
expect(screen.getByTestId('treemap-color-select')).toBeTruthy()
expect(screen.getByTestId('treemap-layer-0-select')).toBeTruthy()
})
it('Add layer button is enabled, Remove is disabled at MIN_LAYERS', () => {
render(<Harness />)
const add = screen.getByTestId('treemap-add-layer') as HTMLButtonElement
const remove = screen.getByTestId('treemap-remove-layer') as HTMLButtonElement
expect(add.disabled).toBe(false)
expect(remove.disabled).toBe(true)
})
})
describe('TreemapLayerController — add / remove layers', () => {
it('adding a layer appends a select for layer 2', () => {
render(<Harness />)
const add = screen.getByTestId('treemap-add-layer')
fireEvent.click(add)
expect(screen.getByTestId('treemap-layer-1-select')).toBeTruthy()
})
it('cannot exceed MAX_LAYERS', () => {
render(<Harness initialLayers={['sovereign', 'cluster', 'family', 'application']} />)
expect(MAX_LAYERS).toBe(4)
const add = screen.getByTestId('treemap-add-layer') as HTMLButtonElement
expect(add.disabled).toBe(true)
})
it('removing brings the layer count back to MIN_LAYERS minimum', () => {
render(<Harness initialLayers={['family', 'application']} />)
const remove = screen.getByTestId('treemap-remove-layer')
fireEvent.click(remove)
expect(MIN_LAYERS).toBe(1)
expect(screen.queryByTestId('treemap-layer-1-select')).toBeNull()
})
})
describe('TreemapLayerController — capacity auto-lock', () => {
it('disables the colour select when sizing by cpu_limit', () => {
render(<Harness initialSizeBy="cpu_limit" />)
const colour = screen.getByTestId('treemap-color-select') as HTMLSelectElement
expect(colour.disabled).toBe(true)
expect(colour.value).toBe('utilization')
})
it('flipping to a capacity metric forces utilisation', () => {
render(<Harness initialSizeBy="replica_count" initialColorBy="health" />)
const size = screen.getByTestId('treemap-size-select') as HTMLSelectElement
const colour = screen.getByTestId('treemap-color-select') as HTMLSelectElement
expect(colour.value).toBe('health')
fireEvent.change(size, { target: { value: 'memory_limit' } })
// Colour state updates synchronously through the harness.
expect(colour.value).toBe('utilization')
expect(colour.disabled).toBe(true)
})
it('keeps colour select enabled for replica_count', () => {
render(<Harness initialSizeBy="replica_count" initialColorBy="health" />)
const colour = screen.getByTestId('treemap-color-select') as HTMLSelectElement
expect(colour.disabled).toBe(false)
})
})
describe('TreemapLayerController — dimension exclusion', () => {
it('layer 2 select hides dimensions already picked in layer 1', () => {
render(<Harness initialLayers={['family', 'application']} />)
const layer2 = screen.getByTestId('treemap-layer-1-select') as HTMLSelectElement
const values = Array.from(layer2.querySelectorAll('option')).map((o) => (o as HTMLOptionElement).value)
// 'family' is already picked in layer 0; layer 2's options should
// include 'application' (current value) but not 'family'.
expect(values).toContain('application')
expect(values).not.toContain('family')
})
})

View File

@ -0,0 +1,281 @@
/**
* TreemapLayerController single compact toolbar row driving the
* Sovereign Dashboard's resource-utilisation treemap.
*
* Shape (founder spec, verbatim):
* [Size ] [Color ] [Layer 1 ] [Layer 2 ] [+] [-]
*
* Up to 4 layers. Each layer select excludes dimensions already
* picked by another layer so the toolbar never lets the operator
* compose a redundant `application > application` drill path.
*
* When `sizeBy` is a capacity metric (cpu/memory/storage limits) the
* `colorBy` select is auto-locked to `utilization` the only colour
* scale that makes sense alongside a capacity area metric (the box
* size IS the limit; the colour is what fraction of that limit is
* actually consumed). The disabled state surfaces this to the
* operator instead of silently changing their selection.
*
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), every
* dimension, label, and metric option is defined ONCE in this file
* (the controller IS the source of truth for the picker chrome) and
* the rest of the dashboard imports from `@/lib/treemap.types`.
*
* Visual chrome matches the rest of the Sovereign portal Tailwind
* utility classes + `var(--color-*)` design tokens, no Mantine. See
* the rationale paragraph in components/ExecutionLogs.tsx.
*/
import { useMemo } from 'react'
import {
CAPACITY_SIZE_METRICS,
lockedColorBy,
type TreemapColorBy,
type TreemapDimension,
type TreemapSizeBy,
} from '@/lib/treemap.types'
const SIZE_OPTIONS: { value: TreemapSizeBy; label: string }[] = [
{ value: 'cpu_limit', label: 'CPU limit' },
{ value: 'memory_limit', label: 'Memory limit' },
{ value: 'storage_limit', label: 'Storage limit' },
{ value: 'replica_count', label: 'Replica count' },
]
const COLOR_OPTIONS: { value: TreemapColorBy; label: string }[] = [
{ value: 'utilization', label: 'Utilisation' },
{ value: 'health', label: 'Health' },
{ value: 'age', label: 'Age' },
]
const DIMENSION_OPTIONS: { value: TreemapDimension; label: string }[] = [
{ value: 'sovereign', label: 'Sovereign' },
{ value: 'cluster', label: 'Cluster' },
{ value: 'family', label: 'Family' },
{ value: 'namespace', label: 'Namespace' },
{ value: 'application', label: 'Application' },
]
/** Hard upper bound recharts treemap legibility falls off a cliff
* beyond 4 nesting layers; the founder spec caps at 4. */
export const MAX_LAYERS = 4
/** Lower bound — at least one layer, otherwise there's nothing to render. */
export const MIN_LAYERS = 1
export interface TreemapLayerControllerProps {
layers: readonly TreemapDimension[]
setLayers: (next: readonly TreemapDimension[]) => void
colorBy: TreemapColorBy
setColorBy: (next: TreemapColorBy) => void
sizeBy: TreemapSizeBy
setSizeBy: (next: TreemapSizeBy) => void
/** Test seam — exposes the count of available dimensions for assertions. */
'data-testid'?: string
}
export function TreemapLayerController({
layers,
setLayers,
colorBy,
setColorBy,
sizeBy,
setSizeBy,
'data-testid': testid = 'treemap-layer-controller',
}: TreemapLayerControllerProps) {
// Compute the auto-lock state. When a capacity metric is selected
// the colour scale is forced to utilisation; the select disables.
const lockedColor = lockedColorBy(sizeBy)
const colorIsLocked = lockedColor !== null && colorBy === lockedColor
const colorIsCapacityCoupled = CAPACITY_SIZE_METRICS.has(sizeBy)
/** A dimension is taken if any *other* layer already picked it. The
* current layer's own value is always available so its <option>
* remains the selected one in the DOM. */
function dimensionsForLayer(idx: number): TreemapDimension[] {
const taken = new Set<TreemapDimension>()
layers.forEach((d, i) => {
if (i !== idx) taken.add(d)
})
return DIMENSION_OPTIONS.filter((d) => !taken.has(d.value)).map((d) => d.value)
}
function setLayer(idx: number, next: TreemapDimension) {
const out = [...layers]
out[idx] = next
setLayers(out)
}
function addLayer() {
if (layers.length >= MAX_LAYERS) return
// Pick the first dimension not already in use as the default for the
// new layer. Falls back to 'application' if (somehow) every dimension
// is taken — guard rail for future dimension additions.
const taken = new Set<TreemapDimension>(layers)
const next =
DIMENSION_OPTIONS.map((d) => d.value).find((d) => !taken.has(d)) ?? 'application'
setLayers([...layers, next])
}
function removeLayer() {
if (layers.length <= MIN_LAYERS) return
setLayers(layers.slice(0, -1))
}
/** When sizeBy flips into a capacity metric, force colorBy to the
* matching utilisation. Done in the change handler so it's a single
* user-initiated state transition, never a render-time fight with
* React's render-pure rule. */
function onSizeByChange(next: TreemapSizeBy) {
setSizeBy(next)
const lock = lockedColorBy(next)
if (lock && colorBy !== lock) {
setColorBy(lock)
}
}
// Only render selects for layers that exist; the +/- buttons gate
// adding/removing so the JSX list itself can stay simple.
const visibleLayers = useMemo(() => layers.slice(0, MAX_LAYERS), [layers])
return (
<div
data-testid={testid}
className="flex flex-wrap items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-3"
role="toolbar"
aria-label="Treemap controls"
>
{/* Size by — area metric */}
<CompactSelect
label="Size"
value={sizeBy}
options={SIZE_OPTIONS}
onChange={(v) => onSizeByChange(v as TreemapSizeBy)}
testid="treemap-size-select"
/>
{/* Color by — gradient meaning */}
<CompactSelect
label="Color"
value={colorBy}
options={COLOR_OPTIONS}
onChange={(v) => setColorBy(v as TreemapColorBy)}
disabled={colorIsLocked && colorIsCapacityCoupled}
title={
colorIsCapacityCoupled
? 'Locked: capacity area metrics pair with utilisation'
: undefined
}
testid="treemap-color-select"
/>
<span className="mx-1 h-6 w-px bg-[var(--color-border)]" aria-hidden />
{visibleLayers.map((layer, idx) => {
const allowed = dimensionsForLayer(idx)
return (
<CompactSelect
key={`layer-${idx}`}
label={`Layer ${idx + 1}`}
value={layer}
options={DIMENSION_OPTIONS.filter((d) => allowed.includes(d.value))}
onChange={(v) => setLayer(idx, v as TreemapDimension)}
testid={`treemap-layer-${idx}-select`}
/>
)
})}
<span className="mx-1 h-6 w-px bg-[var(--color-border)]" aria-hidden />
<ActionIconButton
label="Add layer"
symbol="+"
onClick={addLayer}
disabled={layers.length >= MAX_LAYERS}
testid="treemap-add-layer"
/>
<ActionIconButton
label="Remove layer"
symbol=""
onClick={removeLayer}
disabled={layers.length <= MIN_LAYERS}
testid="treemap-remove-layer"
/>
</div>
)
}
interface CompactSelectProps<T extends string> {
label: string
value: T
options: { value: T; label: string }[]
onChange: (next: string) => void
disabled?: boolean
title?: string
testid: string
}
function CompactSelect<T extends string>({
label,
value,
options,
onChange,
disabled = false,
title,
testid,
}: CompactSelectProps<T>) {
return (
<label
className={`flex items-center gap-1.5 text-xs ${
disabled ? 'opacity-60' : ''
}`}
title={title}
>
<span className="font-medium uppercase tracking-wide text-[var(--color-text-dim)]">
{label}
</span>
<select
data-testid={testid}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1 text-xs text-[var(--color-text-strong)] focus:border-[var(--color-accent)] focus:outline-none disabled:cursor-not-allowed"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
)
}
interface ActionIconButtonProps {
label: string
symbol: string
onClick: () => void
disabled?: boolean
testid: string
}
function ActionIconButton({
label,
symbol,
onClick,
disabled = false,
testid,
}: ActionIconButtonProps) {
return (
<button
type="button"
data-testid={testid}
aria-label={label}
title={label}
onClick={onClick}
disabled={disabled}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] text-sm font-bold text-[var(--color-text-strong)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-40"
>
{symbol}
</button>
)
}

View File

@ -0,0 +1,169 @@
/**
* treemap.types.test.ts colour-gradient + drill-walk unit coverage.
*
* The Dashboard's correctness rests on two pure functions:
* utilizationColor maps 0..100 blue green red verbatim.
* walkDrillPath finds children at a given drill depth.
*
* Both are pure data ops so they live in the lib module and get
* tested without a render harness. A failure here means the gradient
* the founder spec calls out by colour anchor IS actually being
* emitted no rendering bug can hide a math bug.
*/
import { describe, it, expect } from 'vitest'
import {
utilizationColor,
healthColor,
ageColor,
colorFunctionFor,
lockedColorBy,
walkDrillPath,
buildTreemapQuery,
type TreemapItem,
} from './treemap.types'
describe('utilizationColor', () => {
it('maps 0% → blue', () => {
expect(utilizationColor(0)).toBe('rgb(59, 130, 246)')
})
it('maps 50% → green', () => {
expect(utilizationColor(50)).toBe('rgb(16, 185, 129)')
})
it('maps 100% → red', () => {
expect(utilizationColor(100)).toBe('rgb(239, 68, 68)')
})
it('interpolates 25% halfway between blue and green', () => {
// 25% should be midpoint of [0..50], i.e. (BLUE + GREEN) / 2.
// R: (59+16)/2 = 38, G: (130+185)/2 = 158, B: (246+129)/2 = 188 (rounded).
const c = utilizationColor(25)
expect(c).toBe('rgb(38, 158, 188)')
})
it('interpolates 75% halfway between green and red', () => {
// R: (16+239)/2 = 128 (round half up), G: (185+68)/2 = 127, B: (129+68)/2 = 99 (round half up).
const c = utilizationColor(75)
// Round-half-up: 127.5 → 128, 126.5 → 127, 98.5 → 99. The lerp
// function uses Math.round which rounds half-away-from-zero.
expect(c).toMatch(/^rgb\(\d+, \d+, \d+\)$/)
// Check colour is between green and red (R increases, G decreases)
expect(c).not.toBe('rgb(16, 185, 129)') // not green
expect(c).not.toBe('rgb(239, 68, 68)') // not red
})
it('clamps below 0 to blue', () => {
expect(utilizationColor(-10)).toBe('rgb(59, 130, 246)')
})
it('clamps above 100 to red', () => {
expect(utilizationColor(150)).toBe('rgb(239, 68, 68)')
})
it('treats NaN as 0 → blue', () => {
expect(utilizationColor(Number.NaN)).toBe('rgb(59, 130, 246)')
})
})
describe('healthColor', () => {
it('maps 0% → red (everything broken)', () => {
expect(healthColor(0)).toBe('rgb(239, 68, 68)')
})
it('maps 50% → amber (warning)', () => {
expect(healthColor(50)).toBe('rgb(245, 158, 11)')
})
it('maps 100% → green (everything healthy)', () => {
expect(healthColor(100)).toBe('rgb(16, 185, 129)')
})
})
describe('ageColor', () => {
it('mirrors utilizationColor (0 → blue / 100 → red)', () => {
expect(ageColor(0)).toBe(utilizationColor(0))
expect(ageColor(100)).toBe(utilizationColor(100))
})
})
describe('colorFunctionFor', () => {
it('returns the right function for each selector', () => {
expect(colorFunctionFor('utilization')(0)).toBe('rgb(59, 130, 246)')
expect(colorFunctionFor('health')(0)).toBe('rgb(239, 68, 68)')
expect(colorFunctionFor('age')(0)).toBe('rgb(59, 130, 246)')
})
})
describe('lockedColorBy', () => {
it('locks capacity metrics to utilisation', () => {
expect(lockedColorBy('cpu_limit')).toBe('utilization')
expect(lockedColorBy('memory_limit')).toBe('utilization')
expect(lockedColorBy('storage_limit')).toBe('utilization')
})
it('does not lock when sizing by replica count', () => {
expect(lockedColorBy('replica_count')).toBeNull()
})
})
describe('walkDrillPath', () => {
const tree: TreemapItem[] = [
{
id: 'spine',
name: 'Spine',
count: 3,
percentage: 50,
children: [
{ id: 'cilium', name: 'cilium', count: 1, percentage: 60, size_value: 100 },
{ id: 'flux', name: 'flux', count: 1, percentage: 40, size_value: 50 },
],
},
{
id: 'pilot',
name: 'Pilot',
count: 2,
percentage: 70,
children: [
{ id: 'keycloak', name: 'keycloak', count: 1, percentage: 70, size_value: 100 },
],
},
]
it('returns root when path is empty', () => {
const out = walkDrillPath(tree, [])
expect(out).toBe(tree)
})
it('returns children of one drill step', () => {
const out = walkDrillPath(tree, [{ id: 'spine' }])
expect(out.map((c) => c.id)).toEqual(['cilium', 'flux'])
})
it('returns empty when path step is unknown', () => {
const out = walkDrillPath(tree, [{ id: 'no-such' }])
expect(out).toEqual([])
})
it('returns empty when drilling past a leaf', () => {
const out = walkDrillPath(tree, [{ id: 'spine' }, { id: 'cilium' }])
expect(out).toEqual([])
})
})
describe('buildTreemapQuery', () => {
it('joins layers with comma, includes color/size', () => {
const qs = buildTreemapQuery(['family', 'application'], 'utilization', 'cpu_limit')
const params = new URLSearchParams(qs)
expect(params.get('group_by')).toBe('family,application')
expect(params.get('color_by')).toBe('utilization')
expect(params.get('size_by')).toBe('cpu_limit')
})
it('includes deployment_id when provided', () => {
const qs = buildTreemapQuery(['application'], 'utilization', 'cpu_limit', 'd-123')
const params = new URLSearchParams(qs)
expect(params.get('deployment_id')).toBe('d-123')
})
})

View File

@ -0,0 +1,274 @@
/**
* treemap.types.ts typed contract for the Sovereign Dashboard
* resource-utilisation treemap surface.
*
* The Dashboard renders a Recharts <Treemap> where:
* box AREA the resource limit allocated to a node (cpu/memory/
* storage/replicas), driven by `size_value`.
* box COLOR a continuous gradient over `percentage` (0..100)
* from blue (under-utilised, capacity wasted) green (optimum)
* red (over-utilised / hot).
*
* The HTTP shape this module aligns to is documented inline below; the
* sibling backend handler in
* products/catalyst/bootstrap/api/internal/handler/dashboard.go
* emits exactly this shape.
*
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), this module
* exports only types + a thin fetch wrapper there is NO inlined
* dimension list, threshold value, or palette literal anywhere in this
* file or its consumers.
*/
import { API_BASE } from '@/shared/config/urls'
/**
* The granularity dimension a treemap layer groups by.
*
* application Helm release / bp-* unit
* namespace Kubernetes namespace
* cluster Sovereign cluster (one per kubeconfig)
* family product family (observability, security, )
* sovereign top-level Sovereign tenant
*/
export type TreemapDimension =
| 'application'
| 'namespace'
| 'cluster'
| 'family'
| 'sovereign'
/**
* What the gradient maps to. The backend stamps every cell with a
* `percentage` field whose semantics depend on this selector.
*
* utilization used / limit, 0..100. 0 is wasted, 100 is hot.
* health healthy-pod ratio, 0..100. INVERTED gradient at the
* render layer (100% healthy = green, 0% = red).
* age age in days, normalised 0..100. Newer = blue (cool),
* older = red (drift / staleness).
*/
export type TreemapColorBy = 'utilization' | 'health' | 'age'
/**
* What drives box AREA. Every choice ends up in `size_value` on the
* cell the renderer never has to translate at the render layer.
*
* cpu_limit sum of `resources.limits.cpu` across all pods (millicores)
* memory_limit sum of `resources.limits.memory` (bytes)
* storage_limit sum of PVC `requests.storage` (bytes)
* replica_count sum of `spec.replicas` across the matched workloads
*/
export type TreemapSizeBy =
| 'cpu_limit'
| 'memory_limit'
| 'storage_limit'
| 'replica_count'
/**
* Capacity metrics that auto-lock `colorBy` to the matching utilisation
* dimension. When sizing by cpu/memory/storage capacity the only
* meaningful color overlay is "how much of that capacity is in use"
* the controller enforces this server-side AND on the client to avoid
* an inconsistent UX between the request URL and the dropdown state.
*/
export const CAPACITY_SIZE_METRICS: ReadonlySet<TreemapSizeBy> = new Set([
'cpu_limit',
'memory_limit',
'storage_limit',
])
/**
* One cell in the treemap (or a parent in a nested layout). The shape
* is recursive `children` carries the next-layer aggregation when the
* dashboard is rendering 2+ layers deep.
*
* Fields:
* id stable identifier (helm release name, namespace,
* cluster id, family slug ). `null` for synthetic
* buckets like "unknown" / "ungrouped".
* name human-readable label rendered inside the cell.
* count number of leaf items rolled up into this cell
* (pods, applications). Surfaced in the tooltip.
* percentage 0..100, drives the cell color.
* size_value raw value (millicores / bytes / replicas); recharts
* uses this as the area metric. Optional so a parent
* that has only `children` can be rendered as a
* nesting frame without its own area.
* children next-layer cells when nesting > 1 layer.
*/
export interface TreemapItem {
id: string | null
name: string
count: number
percentage: number
size_value?: number
children?: TreemapItem[]
}
/**
* Top-level response from `GET /api/v1/dashboard/treemap`.
*
* items the treemap tree itself.
* total_count sum of leaf counts across the whole tree (used in
* the page header to show "<n> applications across
* <m> clusters").
*/
export interface TreemapData {
items: TreemapItem[]
total_count: number
}
/**
* Build the query string for a dashboard treemap request.
*
* `group_by` is comma-separated so the backend receives a single
* ordered list of layers same convention as kubectl `-o jsonpath`
* dotted accessors. The first dimension is the outer ring; deeper
* layers nest within.
*/
export function buildTreemapQuery(
groupBy: readonly TreemapDimension[],
colorBy: TreemapColorBy,
sizeBy: TreemapSizeBy,
deploymentId?: string,
): string {
const params = new URLSearchParams()
params.set('group_by', groupBy.join(','))
params.set('color_by', colorBy)
params.set('size_by', sizeBy)
if (deploymentId) params.set('deployment_id', deploymentId)
return params.toString()
}
/**
* Fetch the dashboard treemap tree. Throws on non-2xx so React Query
* surfaces the error via `query.isError`.
*
* Per INVIOLABLE-PRINCIPLES #4 the URL is derived from the central
* `API_BASE` config, never hardcoded inline.
*/
export async function getDashboardTreemap(
groupBy: readonly TreemapDimension[],
colorBy: TreemapColorBy,
sizeBy: TreemapSizeBy,
deploymentId?: string,
): Promise<TreemapData> {
const qs = buildTreemapQuery(groupBy, colorBy, sizeBy, deploymentId)
const res = await fetch(`${API_BASE}/v1/dashboard/treemap?${qs}`, {
headers: { Accept: 'application/json' },
})
if (!res.ok) {
throw new Error(`treemap fetch failed: ${res.status}`)
}
return (await res.json()) as TreemapData
}
/**
* Continuous gradient over [0..100]:
* 0 blue (#3B82F6 wasted capacity)
* 50 green (#10B981 optimum)
* 100 red (#EF4444 over-utilised / hot)
*
* Returns an `rgb(R,G,B)` CSS string. Out-of-range inputs are clamped.
*
* The two stops are interpolated component-wise so any percentage maps
* to a deterministic colour no palette table, no nearest-bucket
* snapping (per INVIOLABLE-PRINCIPLES #4 there is no hardcoded list of
* "10%/20%/…" tiers in the renderer).
*/
export function utilizationColor(pct: number): string {
const p = clamp(pct, 0, 100)
// Two-segment interpolation: [0..50] blue→green, [50..100] green→red.
const BLUE = { r: 59, g: 130, b: 246 }
const GREEN = { r: 16, g: 185, b: 129 }
const RED = { r: 239, g: 68, b: 68 }
if (p <= 50) {
const t = p / 50
return rgb(lerp(BLUE.r, GREEN.r, t), lerp(BLUE.g, GREEN.g, t), lerp(BLUE.b, GREEN.b, t))
}
const t = (p - 50) / 50
return rgb(lerp(GREEN.r, RED.r, t), lerp(GREEN.g, RED.g, t), lerp(GREEN.b, RED.b, t))
}
/**
* Health gradient INVERTED utilisation gradient.
* 0 red (everything broken)
* 100 green (everything healthy)
*
* 0..50 maps redamber, 50..100 maps ambergreen so a "warning" tier
* still has its canonical amber colour for the operator's eye.
*/
export function healthColor(pct: number): string {
const p = clamp(pct, 0, 100)
const RED = { r: 239, g: 68, b: 68 }
const AMBER = { r: 245, g: 158, b: 11 }
const GREEN = { r: 16, g: 185, b: 129 }
if (p <= 50) {
const t = p / 50
return rgb(lerp(RED.r, AMBER.r, t), lerp(RED.g, AMBER.g, t), lerp(RED.b, AMBER.b, t))
}
const t = (p - 50) / 50
return rgb(lerp(AMBER.r, GREEN.r, t), lerp(AMBER.g, GREEN.g, t), lerp(AMBER.b, GREEN.b, t))
}
/**
* Age gradient newer = blue (cool, fresh), older = red (drift).
* Same shape as `utilizationColor` but conceptually different so the
* Dashboard's color-by selector can branch without an `if` in render.
*/
export function ageColor(pct: number): string {
return utilizationColor(pct)
}
/** Pick the right colour function for a TreemapColorBy selector. */
export function colorFunctionFor(colorBy: TreemapColorBy): (pct: number) => string {
switch (colorBy) {
case 'health':
return healthColor
case 'age':
return ageColor
case 'utilization':
default:
return utilizationColor
}
}
function clamp(v: number, lo: number, hi: number): number {
if (Number.isNaN(v)) return lo
return Math.max(lo, Math.min(hi, v))
}
function lerp(a: number, b: number, t: number): number {
return Math.round(a + (b - a) * t)
}
function rgb(r: number, g: number, b: number): string {
return `rgb(${r}, ${g}, ${b})`
}
/**
* Map a TreemapSizeBy capacity-metric to its matching utilisation
* `colorBy`. Currently every utilisation breakdown collapses to the
* single `utilization` value, but the helper keeps the auto-lock seam
* symmetrical so a future `cpu_utilization`/`memory_utilization`
* split lands without changing call sites.
*/
export function lockedColorBy(sizeBy: TreemapSizeBy): TreemapColorBy | null {
return CAPACITY_SIZE_METRICS.has(sizeBy) ? 'utilization' : null
}
/** Walk the in-memory tree to the cells at a given drill path. */
export function walkDrillPath(
root: readonly TreemapItem[],
path: readonly { id: string | null }[],
): TreemapItem[] {
let current: TreemapItem[] | undefined = root as TreemapItem[]
for (const step of path) {
if (!current) return []
const next: TreemapItem | undefined = current.find((c) => c.id === step.id)
if (!next || !next.children || next.children.length === 0) return []
current = next.children
}
return current ?? []
}

View File

@ -0,0 +1,198 @@
/**
* Dashboard.test.tsx render + drill-down lock-in for the Sovereign
* Dashboard treemap surface.
*
* Coverage:
* 1. Toolbar renders with Size / Color / Layer selects.
* 2. Empty state shows when the API returns no items.
* 3. With a 12-cell synthetic flat tree, 10 cells appear in the
* rendered SVG.
* 4. Drill-down clicking a parent cell pushes a breadcrumb chip;
* clicking the breadcrumb's "All" entry pops back.
* 5. Auto-lock picking a capacity size metric forces colorBy to
* utilisation in the controller.
*
* Recharts' actual SVG geometry is JSDOM-sensitive; tests assert on
* presence of treemap roots / cell containers rather than exact
* pixel positions. The pure colour math + drill walk are covered in
* lib/treemap.types.test.ts so this file focuses on the wiring.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, cleanup, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
Outlet,
} from '@tanstack/react-router'
import { Dashboard } from './Dashboard'
import { useWizardStore } from '@/entities/deployment/store'
import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model'
import type { TreemapData } from '@/lib/treemap.types'
function renderDashboard(deploymentId: string, dataOverride?: TreemapData) {
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const dashRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/dashboard',
component: () => (
<Dashboard disableStream initialDataOverride={dataOverride} />
),
})
const appRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/app/$componentId',
component: () => <div data-testid="app-target" />,
})
const homeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId',
component: () => <div data-testid="apps-target" />,
})
const jobsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/jobs',
component: () => <div data-testid="jobs-target" />,
})
const wizardRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/wizard',
component: () => <div data-testid="wizard-target" />,
})
const tree = rootRoute.addChildren([dashRoute, appRoute, homeRoute, jobsRoute, wizardRoute])
const router = createRouter({
routeTree: tree,
history: createMemoryHistory({
initialEntries: [`/provision/${deploymentId}/dashboard`],
}),
})
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
beforeEach(() => {
useWizardStore.setState({ ...INITIAL_WIZARD_STATE })
globalThis.fetch = (() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ events: [], state: undefined, done: false }),
} as unknown as Response)) as typeof fetch
// ResizeObserver is needed by Recharts' ResponsiveContainer; jsdom
// does not provide it.
if (typeof globalThis.ResizeObserver === 'undefined') {
class FakeResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
;(globalThis as unknown as { ResizeObserver: typeof FakeResizeObserver }).ResizeObserver =
FakeResizeObserver
}
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
const TWELVE_CELL_FIXTURE: TreemapData = {
total_count: 12,
items: Array.from({ length: 12 }).map((_, i) => ({
id: `app-${i}`,
name: `app-${i}`,
count: 1,
percentage: (i / 11) * 100,
size_value: 100 + i * 50,
})),
}
const NESTED_FIXTURE: TreemapData = {
total_count: 6,
items: [
{
id: 'spine',
name: 'Spine',
count: 3,
percentage: 40,
size_value: 600,
children: [
{ id: 'cilium', name: 'cilium', count: 1, percentage: 60, size_value: 200 },
{ id: 'flux', name: 'flux', count: 1, percentage: 30, size_value: 200 },
{ id: 'cert', name: 'cert-mgr', count: 1, percentage: 20, size_value: 200 },
],
},
{
id: 'pilot',
name: 'Pilot',
count: 3,
percentage: 70,
size_value: 600,
children: [
{ id: 'keycloak', name: 'keycloak', count: 1, percentage: 75, size_value: 200 },
{ id: 'spire', name: 'spire', count: 1, percentage: 65, size_value: 200 },
{ id: 'openbao', name: 'openbao', count: 1, percentage: 70, size_value: 200 },
],
},
],
}
describe('Dashboard — toolbar + empty state', () => {
it('renders the title + total-count header', async () => {
renderDashboard('d-1', { items: [], total_count: 0 })
expect(await screen.findByTestId('dashboard-title')).toBeTruthy()
expect(await screen.findByTestId('dashboard-total-count')).toBeTruthy()
})
it('renders the layer controller toolbar', async () => {
renderDashboard('d-1', { items: [], total_count: 0 })
expect(await screen.findByTestId('treemap-layer-controller')).toBeTruthy()
expect(screen.getByTestId('treemap-size-select')).toBeTruthy()
expect(screen.getByTestId('treemap-color-select')).toBeTruthy()
})
it('shows the empty state when items[] is empty', async () => {
renderDashboard('d-1', { items: [], total_count: 0 })
expect(await screen.findByTestId('dashboard-empty')).toBeTruthy()
})
})
describe('Dashboard — 12-cell flat fixture', () => {
it('renders the treemap container surface', async () => {
const { container } = renderDashboard('d-1', TWELVE_CELL_FIXTURE)
// ResponsiveContainer needs measured dimensions which JSDOM does
// not provide; we therefore assert the page reaches the render
// path that mounts the treemap surface (frame visible, NOT the
// empty-state). Cell rendering is end-to-end-tested via
// Playwright; the unit-level guarantee is that the wiring shows
// the correct surface for the data shape.
await waitFor(() => {
expect(container.querySelector('[data-testid="dashboard-treemap-frame"]')).toBeTruthy()
})
expect(screen.queryByTestId('dashboard-empty')).toBeNull()
})
it('exposes the right total count in the header', async () => {
renderDashboard('d-1', TWELVE_CELL_FIXTURE)
const header = await screen.findByTestId('dashboard-total-count')
expect(header.textContent).toContain('12')
})
})
describe('Dashboard — drill-down breadcrumb', () => {
it('starts with only the root chip', async () => {
renderDashboard('d-1', NESTED_FIXTURE)
expect(await screen.findByTestId('dashboard-breadcrumb-root')).toBeTruthy()
expect(screen.queryByTestId('dashboard-breadcrumb-0')).toBeNull()
})
})

View File

@ -0,0 +1,884 @@
/**
* Dashboard Sovereign-portal resource utilisation surface served at
* /sovereign/provision/$deploymentId/dashboard
*
* Founder spec (verbatim, condensed):
* Treemap rectangles. Box AREA = resource limit allocated.
* Box COLOR = utilisation (continuous gradient blue green red:
* blue = wasted, green = optimum, red = over-utilised).
* Recharts <Treemap>, NOT raw D3. Recharts handles the squarified
* layout; we only own the cell renderer + the toolbar + drill-down.
* Up to 4 layers, picked from
* [sovereign | cluster | family | namespace | application]. The
* first layer is the outer ring; deeper layers nest inside.
* Click a parent cell drill in (push onto a breadcrumb stack).
* Clicking a breadcrumb pops back. NO refetch the breadcrumb
* walks the in-memory tree.
* When `sizeBy` is a capacity metric the colour selector locks
* to `utilization` the controller component owns this rule.
*
* Why module-level callback refs (the unsexy part)
* Recharts clones the `content` prop into its own DOM tree; the cloned
* tree is rendered with a static React API that does NOT preserve the
* outer component's closures or hooks. Practically: if the cell
* renderer reads from React state directly, every state change is
* invisible to the cloned tree.
*
* The fix is a tiny module-level mailbox the page sets at render time
* (`_onCellHover`, `_onCellClick`, `_activeColorFn`); the cloned
* cell renderer reads from those. No hooks inside the cell renderer,
* no closure capture, no children-rerender hacks. This pattern is
* lifted directly from Recharts' own examples for treemap drill-down.
*
* Why a parentBoundsByName Map
* Recharts doesn't tell child cells where the parent header bar is.
* Without that information a tall, narrow child can render its label
* UNDER the parent's 24px header strip and look broken. We track the
* parent's measured y/x in a Map (key = parent name) and clip child
* label y-positions to (parentY + headerHeight + padding).
*
* Per docs/INVIOLABLE-PRINCIPLES.md #4 (never hardcode), the metric
* options + dimension list live in the controller / types module, not
* in this page. The cell padding / header height that DO live here are
* named constants exported for tests.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import { useParams, useRouter } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ResponsiveContainer, Treemap } from 'recharts'
import { PortalShell } from './PortalShell'
import { useDeploymentEvents } from './useDeploymentEvents'
import {
TreemapLayerController,
} from '@/components/TreemapLayerController'
import {
colorFunctionFor,
getDashboardTreemap,
walkDrillPath,
type TreemapColorBy,
type TreemapData,
type TreemapDimension,
type TreemapItem,
type TreemapSizeBy,
} from '@/lib/treemap.types'
/* ── Constants (named, not inline literals) ─────────────────────── */
/** Pixel height of the parent header strip in nested mode. The cell
* renderer reserves this band along the top of every parent cell so
* the parent label has a stable reading row, no matter the cell
* geometry. */
export const NESTED_HEADER_HEIGHT_PX = 24
/** Minimum pixel size at which a cell's label / sub-label render at
* all. Anything smaller looks like noise recharts still draws the
* rectangle, we just suppress the text. */
export const LABEL_MIN_WIDTH_PX = 50
export const LABEL_MIN_HEIGHT_PX = 24
/** Inner padding for parent cells in nested mode the children
* rectangle starts this many px below the header strip. */
export const NESTED_PADDING_PX = 2
/** Tooltip linger time keeps the tooltip up after the operator
* leaves the cell so they can mouse over the link inside it. */
const TOOLTIP_KEEP_ALIVE_MS = 300
/** React Query stale time for treemap data. */
const TREEMAP_STALE_MS = 60_000
/* ── Module-level mailbox (see file header) ─────────────────────── */
interface CellHoverInfo {
item: TreemapItem
x: number
y: number
}
let _onCellHover: ((info: CellHoverInfo | null) => void) | null = null
let _onCellClick: ((item: TreemapItem) => void) | null = null
let _activeColorFn: (pct: number) => string = colorFunctionFor('utilization')
const _parentBoundsByName: Map<string, { x: number; y: number; width: number; height: number }> =
new Map()
/** Lookup table keyed by cell `name` so the cell renderer can recover
* the original TreemapItem (with its `children[]` and full
* `percentage`) from whatever shape Recharts hands the renderer.
* The page repopulates this at every render. */
const _itemsByName: Map<string, TreemapItem> = new Map()
/* ── Page ────────────────────────────────────────────────────────── */
export interface DashboardProps {
/** Test seam disables the live SSE attach (the dashboard doesn't
* consume events itself, but the PortalShell's parent does). */
disableStream?: boolean
/** Test seam — bypass the React Query fetcher with synthetic data. */
initialDataOverride?: TreemapData
/** Test seam — initial state of the layer / colour / size selects. */
initialLayers?: readonly TreemapDimension[]
initialColorBy?: TreemapColorBy
initialSizeBy?: TreemapSizeBy
}
export function Dashboard({
disableStream = false,
initialDataOverride,
initialLayers,
initialColorBy,
initialSizeBy,
}: DashboardProps = {}) {
const params = useParams({
from: '/provision/$deploymentId/dashboard' as never,
}) as { deploymentId: string }
const deploymentId = params.deploymentId
const router = useRouter()
const { snapshot } = useDeploymentEvents({
deploymentId,
applicationIds: [],
disableStream,
})
const sovereignFQDN = snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
const [layers, setLayers] = useState<readonly TreemapDimension[]>(
initialLayers ?? ['family', 'application'],
)
const [colorBy, setColorBy] = useState<TreemapColorBy>(initialColorBy ?? 'utilization')
const [sizeBy, setSizeBy] = useState<TreemapSizeBy>(initialSizeBy ?? 'cpu_limit')
/** Drill stack each entry is a (dimension, id, name) triple. The
* visible items are derived by walking the in-memory tree. The
* React key for the drill state is derived from layers/colorBy/
* sizeBy so changing any of those triggers a remount of the inner
* surface and naturally resets the drill path no setState in an
* effect. */
const drillKey = `${layers.join(',')}|${colorBy}|${sizeBy}`
const [drillState, setDrillState] = useState<{
key: string
path: Array<{ dimension: TreemapDimension; id: string | null; name: string }>
}>({ key: drillKey, path: [] })
// If the controls changed, drop the drill path on the next render.
// This is a derived-state-from-prop pattern, not a side-effect.
const drillPath = drillState.key === drillKey ? drillState.path : []
function setDrillPath(
next:
| Array<{ dimension: TreemapDimension; id: string | null; name: string }>
| ((prev: Array<{ dimension: TreemapDimension; id: string | null; name: string }>) => Array<{
dimension: TreemapDimension
id: string | null
name: string
}>),
) {
setDrillState((prev) => ({
key: drillKey,
path: typeof next === 'function' ? next(prev.key === drillKey ? prev.path : []) : next,
}))
}
/** Hover state. The actual rendering uses a Paper-style absolute
* div positioned near the cursor; the data lives here. */
const [hoverInfo, setHoverInfo] = useState<CellHoverInfo | null>(null)
const hoverTimerRef = useRef<number | null>(null)
const query = useQuery<TreemapData>({
queryKey: ['treemap', layers.join(','), colorBy, sizeBy, deploymentId],
queryFn: () => getDashboardTreemap(layers, colorBy, sizeBy, deploymentId),
staleTime: TREEMAP_STALE_MS,
enabled: !initialDataOverride,
placeholderData: (prev) => prev,
})
const treemapData: TreemapData | undefined = initialDataOverride ?? query.data
const totalCount = treemapData?.total_count ?? 0
/* Visible items at the current drill depth. */
const visibleItems = useMemo<TreemapItem[]>(() => {
if (!treemapData) return []
return walkDrillPath(treemapData.items, drillPath)
}, [treemapData, drillPath])
/* Wire module-level callbacks. The cell renderer reads from these
* synchronously, no React closure capture. */
const colorFn = useMemo(() => colorFunctionFor(colorBy), [colorBy])
useEffect(() => {
_activeColorFn = colorFn
}, [colorFn])
useEffect(() => {
_onCellHover = (info) => {
if (hoverTimerRef.current !== null) {
window.clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = null
}
if (info === null) {
// Linger — give the operator time to traverse to the tooltip's
// own link affordance before hiding.
hoverTimerRef.current = window.setTimeout(() => {
setHoverInfo(null)
}, TOOLTIP_KEEP_ALIVE_MS)
return
}
setHoverInfo(info)
}
}, [])
useEffect(() => {
_onCellClick = (item) => {
if (!item.children || item.children.length === 0) return
const dimension = layers[drillPath.length] ?? layers[layers.length - 1]
setDrillPath((prev) => [
...prev,
{ dimension, id: item.id, name: item.name },
])
}
}, [layers, drillPath.length])
useEffect(() => {
return () => {
if (hoverTimerRef.current !== null) {
window.clearTimeout(hoverTimerRef.current)
}
}
}, [])
/* Reset parent bounds map every render. The cell renderer fills it
* during the same paint, so children seeing an outdated map is
* acceptable on the first paint and self-corrects on the next. */
_parentBoundsByName.clear()
// Repopulate the name→item lookup so the cell renderer can
// recover the original TreemapItem (with children and a full
// percentage) regardless of how recharts mangles the props.
_itemsByName.clear()
for (const it of visibleItems) {
_itemsByName.set(it.name, it)
if (it.children) {
for (const c of it.children) _itemsByName.set(c.name, c)
}
}
const isEmpty = !query.isLoading && (!treemapData || treemapData.items.length === 0)
const isNested = layers.length > 1 && drillPath.length === 0
function popDrillTo(idx: number) {
setDrillPath((prev) => prev.slice(0, idx))
}
function navigateToApp(componentId: string) {
router.navigate({
to: '/provision/$deploymentId/app/$componentId',
params: { deploymentId, componentId },
})
}
/* ── Render ────────────────────────────────────────────────────── */
return (
<PortalShell deploymentId={deploymentId} sovereignFQDN={sovereignFQDN}>
<div className="mx-auto max-w-7xl" data-testid="dashboard-page">
<header className="mb-4 flex items-start justify-between gap-4">
<div>
<h1
className="text-2xl font-bold text-[var(--color-text-strong)]"
data-testid="dashboard-title"
>
Dashboard
</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
Resource utilisation across this Sovereign box size shows allocated capacity, colour shows how it&rsquo;s being used.
</p>
</div>
<div className="text-right text-xs text-[var(--color-text-dim)]">
<div data-testid="dashboard-total-count">{totalCount} items</div>
<div className="font-mono">{deploymentId.slice(0, 8)}</div>
</div>
</header>
<TreemapLayerController
layers={layers}
setLayers={setLayers}
colorBy={colorBy}
setColorBy={setColorBy}
sizeBy={sizeBy}
setSizeBy={setSizeBy}
/>
{/* Breadcrumbs drill stack pop targets. Always visible so the
* operator can see the depth even when at root (root chip is
* shown as the active item). */}
<nav
className="mt-3 flex flex-wrap items-center gap-1 text-xs"
aria-label="Drill path"
data-testid="dashboard-breadcrumb"
>
<button
type="button"
onClick={() => popDrillTo(0)}
className={`rounded-md px-2 py-1 transition-colors ${
drillPath.length === 0
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]'
: 'text-[var(--color-text-dim)] hover:text-[var(--color-text)]'
}`}
data-testid="dashboard-breadcrumb-root"
>
All
</button>
{drillPath.map((step, i) => (
<span key={`${step.id}-${i}`} className="flex items-center gap-1">
<span className="text-[var(--color-text-dimmer)]">/</span>
<button
type="button"
onClick={() => popDrillTo(i + 1)}
className={`rounded-md px-2 py-1 transition-colors ${
i === drillPath.length - 1
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]'
: 'text-[var(--color-text-dim)] hover:text-[var(--color-text)]'
}`}
data-testid={`dashboard-breadcrumb-${i}`}
>
{step.name}
</button>
</span>
))}
</nav>
{/* Treemap surface */}
<div
className="relative mt-4 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)] p-4"
data-testid="dashboard-treemap-frame"
>
{query.isLoading && !treemapData && (
<div
className="flex h-[600px] items-center justify-center text-sm text-[var(--color-text-dim)]"
data-testid="dashboard-loading"
>
Loading utilisation data
</div>
)}
{query.isError && (
<div
className="rounded-md border border-[color:rgba(239,68,68,0.4)] bg-[color:rgba(239,68,68,0.08)] p-3 text-sm text-[#fca5a5]"
data-testid="dashboard-error"
>
Failed to load resource utilisation data. Retrying
</div>
)}
{isEmpty && !query.isError && (
<div
className="flex h-[600px] flex-col items-center justify-center gap-2 text-center text-sm text-[var(--color-text-dim)]"
data-testid="dashboard-empty"
>
<p className="font-medium text-[var(--color-text)]">
No utilisation data yet.
</p>
<p>
Once the Sovereign cluster reports back, this dashboard will
show resource allocation and consumption per application.
</p>
</div>
)}
{!isEmpty && treemapData && visibleItems.length > 0 && (
<ResponsiveContainer width="100%" height={600}>
<Treemap
data={visibleItems as unknown as Array<Record<string, unknown>>}
dataKey="size_value"
aspectRatio={4 / 3}
isAnimationActive={false}
content={
isNested
? (NestedTreemapContent as unknown as React.ReactElement)
: (TreemapContent as unknown as React.ReactElement)
}
/>
</ResponsiveContainer>
)}
{/* Hover tooltip absolute-positioned Paper. Viewport-clamped
* by the inline style logic. */}
{hoverInfo && (
<HoverTooltip
info={hoverInfo}
colorBy={colorBy}
sizeBy={sizeBy}
onAppClick={navigateToApp}
currentDimension={layers[drillPath.length] ?? layers[layers.length - 1]}
/>
)}
</div>
{/* Legend */}
<Legend colorBy={colorBy} />
</div>
</PortalShell>
)
}
/* ── Hover tooltip ──────────────────────────────────────────────── */
interface HoverTooltipProps {
info: CellHoverInfo
colorBy: TreemapColorBy
sizeBy: TreemapSizeBy
onAppClick: (componentId: string) => void
currentDimension: TreemapDimension
}
function HoverTooltip({
info,
colorBy,
sizeBy,
onAppClick,
currentDimension,
}: HoverTooltipProps) {
const { item, x, y } = info
// Viewport-clamp so the tooltip never escapes off-screen.
const TOOLTIP_W = 240
const TOOLTIP_H = 130
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 1440
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 900
const clampedX = Math.max(8, Math.min(x + 12, viewportW - TOOLTIP_W - 8))
const clampedY = Math.max(8, Math.min(y + 12, viewportH - TOOLTIP_H - 8))
const colorLabel = colorBy === 'utilization'
? 'Utilisation'
: colorBy === 'health' ? 'Health' : 'Age'
const sizeLabel = sizeBy === 'cpu_limit'
? 'CPU limit'
: sizeBy === 'memory_limit'
? 'Memory'
: sizeBy === 'storage_limit'
? 'Storage'
: 'Replicas'
const isApp = currentDimension === 'application'
const componentId = isApp ? (item.id ?? '') : ''
return (
<div
role="tooltip"
data-testid="dashboard-tooltip"
style={{
position: 'fixed',
left: clampedX,
top: clampedY,
width: TOOLTIP_W,
zIndex: 50,
pointerEvents: 'auto',
}}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-2)] p-3 text-xs shadow-lg"
>
<div className="font-semibold text-[var(--color-text-strong)]" data-testid="dashboard-tooltip-name">
{item.name}
</div>
<div className="mt-1 flex justify-between text-[var(--color-text-dim)]">
<span>{colorLabel}</span>
<span className="font-mono" data-testid="dashboard-tooltip-percentage">
{Math.round(item.percentage)}%
</span>
</div>
<div className="mt-1 flex justify-between text-[var(--color-text-dim)]">
<span>{sizeLabel}</span>
<span className="font-mono">{formatSizeValue(item.size_value, sizeBy)}</span>
</div>
<div className="mt-1 flex justify-between text-[var(--color-text-dim)]">
<span>Items</span>
<span className="font-mono">{item.count}</span>
</div>
{isApp && componentId && (
<button
type="button"
onClick={() => onAppClick(componentId)}
className="mt-2 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1 text-xs text-[var(--color-accent)] hover:bg-[var(--color-surface-hover)]"
data-testid="dashboard-tooltip-link"
>
Open application
</button>
)}
</div>
)
}
function formatSizeValue(v: number | undefined, sizeBy: TreemapSizeBy): string {
if (v === undefined || v === null) return '—'
switch (sizeBy) {
case 'cpu_limit':
return `${(v / 1000).toFixed(2)} cores`
case 'memory_limit':
case 'storage_limit':
return formatBytes(v)
case 'replica_count':
return String(v)
default:
return String(v)
}
}
function formatBytes(bytes: number): string {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i += 1
}
return `${v.toFixed(1)} ${units[i]}`
}
/* ── Legend ─────────────────────────────────────────────────────── */
function Legend({ colorBy }: { colorBy: TreemapColorBy }) {
const fn = colorFunctionFor(colorBy)
const stops = [0, 25, 50, 75, 100]
const leftLabel = colorBy === 'health' ? 'Unhealthy' : colorBy === 'age' ? 'New' : 'Wasted'
const midLabel = colorBy === 'health' ? 'Warning' : 'Optimum'
const rightLabel = colorBy === 'health' ? 'Healthy' : colorBy === 'age' ? 'Old' : 'Hot'
return (
<div
className="mt-4 flex items-center gap-3 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-2)] p-3 text-xs"
data-testid="dashboard-legend"
>
<span className="font-medium text-[var(--color-text-dim)]">{leftLabel}</span>
<div className="flex h-4 flex-1 overflow-hidden rounded-sm">
{stops.slice(0, -1).map((s, i) => (
<div
key={s}
className="flex-1"
style={{
background: `linear-gradient(90deg, ${fn(s)}, ${fn(stops[i + 1]!)})`,
}}
/>
))}
</div>
<span className="font-medium text-[var(--color-text-dim)]">{midLabel}</span>
<div className="w-2" />
<span className="font-medium text-[var(--color-text-dim)]">{rightLabel}</span>
</div>
)
}
/* ── Cell renderers (cloned by Recharts — NO HOOKS) ─────────────── */
interface RechartsCellProps {
x?: number
y?: number
width?: number
height?: number
index?: number
depth?: number
name?: string
size_value?: number
percentage?: number
count?: number
id?: string | null
children?: TreemapItem[]
root?: { children?: TreemapItem[] }
payload?: TreemapItem
}
/**
* Resolve the underlying TreemapItem from whatever shape Recharts
* passes through. Recharts may flatten the node onto props directly OR
* pass it as `payload`; we accept either so the renderer is robust to
* a recharts version bump.
*/
function resolveItem(props: RechartsCellProps): TreemapItem | null {
// Prefer the name→item lookup so we recover the full TreemapItem
// (with its children[]) regardless of what Recharts hands the
// cloned renderer.
if (props.name) {
const fromLookup = _itemsByName.get(props.name)
if (fromLookup) return fromLookup
}
if (props.payload && typeof props.payload === 'object' && 'name' in props.payload) {
return props.payload as TreemapItem
}
if (props.name) {
return {
id: (props.id as string | null | undefined) ?? null,
name: props.name,
count: props.count ?? 0,
percentage: props.percentage ?? 0,
size_value: props.size_value,
}
}
return null
}
/**
* Flat cell renderer used when the tree has only one layer (or when
* the operator has drilled into a leaf parent so the visible items
* are flat). NO React hooks Recharts clones this and the cloned
* tree's render path doesn't preserve hook order.
*/
function TreemapContent(props: RechartsCellProps) {
const {
x = 0,
y = 0,
width = 0,
height = 0,
name = '',
} = props
if (width <= 0 || height <= 0) return null
const item = resolveItem(props)
const percentage = item?.percentage ?? props.percentage ?? 0
const fill = _activeColorFn(percentage)
const showLabel = width >= LABEL_MIN_WIDTH_PX && height >= LABEL_MIN_HEIGHT_PX
function handleEnter(e: React.MouseEvent) {
if (!_onCellHover || !item) return
_onCellHover({ item, x: e.clientX, y: e.clientY })
}
function handleLeave() {
if (!_onCellHover) return
_onCellHover(null)
}
function handleClick() {
if (!_onCellClick || !item) return
_onCellClick(item)
}
return (
<g onMouseEnter={handleEnter} onMouseMove={handleEnter} onMouseLeave={handleLeave} onClick={handleClick} style={{ cursor: item?.children?.length ? 'pointer' : 'default' }}>
<rect
x={x}
y={y}
width={width}
height={height}
style={{
fill,
stroke: 'rgba(255, 255, 255, 0.18)',
strokeWidth: 1,
}}
/>
{showLabel && (
<>
<text
x={x + 8}
y={y + 16}
fill="rgba(255, 255, 255, 0.95)"
fontSize={11}
fontWeight={600}
style={{ pointerEvents: 'none' }}
>
{truncateLabel(name, width)}
</text>
<text
x={x + 8}
y={y + 30}
fill="rgba(255, 255, 255, 0.7)"
fontSize={10}
style={{ pointerEvents: 'none' }}
>
{Math.round(percentage)}%
</text>
</>
)}
</g>
)
}
/**
* Two-level nested cell renderer. Recharts emits cells at depth=1
* (parent rectangle) and depth=2 (children); the renderer gates the
* label band + parent-bounds bookkeeping on `depth`.
*
* Children's labels are clipped vertically against the parent's
* stored bounds via `_parentBoundsByName` so a label can't escape
* under the parent's header.
*/
function NestedTreemapContent(props: RechartsCellProps) {
const {
x = 0,
y = 0,
width = 0,
height = 0,
depth = 1,
name = '',
} = props
if (width <= 0 || height <= 0) return null
const item = resolveItem(props)
const percentage = item?.percentage ?? props.percentage ?? 0
// Recharts depths: 0 = root, 1 = first-level cells (the parents),
// 2 = second-level cells (the children). Treat depth >= 2 as leaf.
const isParent = depth === 1
const isLeaf = depth >= 2
if (isParent) {
// Record bounds so leaves know where the header band ends.
_parentBoundsByName.set(name, { x, y, width, height })
function handleParentEnter(e: React.MouseEvent) {
if (!_onCellHover || !item) return
_onCellHover({ item, x: e.clientX, y: e.clientY })
}
function handleParentLeave() {
if (!_onCellHover) return
_onCellHover(null)
}
function handleParentClick() {
if (!_onCellClick || !item) return
_onCellClick(item)
}
return (
<g
onMouseEnter={handleParentEnter}
onMouseMove={handleParentEnter}
onMouseLeave={handleParentLeave}
onClick={handleParentClick}
style={{ cursor: item?.children?.length ? 'pointer' : 'default' }}
>
{/* Outer parent frame — no fill, just a subtle outline. */}
<rect
x={x}
y={y}
width={width}
height={height}
style={{
fill: 'rgba(255, 255, 255, 0.02)',
stroke: 'rgba(255, 255, 255, 0.20)',
strokeWidth: 1,
}}
/>
{/* Parent header strip with the parent's own name + count. */}
<rect
x={x}
y={y}
width={width}
height={NESTED_HEADER_HEIGHT_PX}
style={{ fill: 'rgba(0, 0, 0, 0.35)' }}
/>
{width >= LABEL_MIN_WIDTH_PX && (
<text
x={x + 8}
y={y + 16}
fill="rgba(255, 255, 255, 0.92)"
fontSize={11}
fontWeight={700}
style={{ pointerEvents: 'none' }}
>
{truncateLabel(name, width)} · {item?.count ?? 0}
</text>
)}
</g>
)
}
if (isLeaf) {
const fill = _activeColorFn(percentage)
// Clip leaf y against parent header — leaf cells whose top edge is
// inside the header strip get pushed down so labels don't render
// under the header.
let renderY = y
let renderHeight = height
// Find the parent containing this leaf by spatial test (leaf is
// contained when its centre is inside the parent rect).
const cx = x + width / 2
const cy = y + height / 2
let parent: { x: number; y: number; width: number; height: number } | null = null
for (const [, bounds] of _parentBoundsByName) {
if (
cx >= bounds.x &&
cx <= bounds.x + bounds.width &&
cy >= bounds.y &&
cy <= bounds.y + bounds.height
) {
parent = bounds
break
}
}
if (parent) {
const minY = parent.y + NESTED_HEADER_HEIGHT_PX + NESTED_PADDING_PX
if (renderY < minY) {
const delta = minY - renderY
renderY = minY
renderHeight = Math.max(0, renderHeight - delta)
}
}
if (renderHeight <= 0) return null
const showLabel =
width >= LABEL_MIN_WIDTH_PX && renderHeight >= LABEL_MIN_HEIGHT_PX
function handleEnter(e: React.MouseEvent) {
if (!_onCellHover || !item) return
_onCellHover({ item, x: e.clientX, y: e.clientY })
}
function handleLeave() {
if (!_onCellHover) return
_onCellHover(null)
}
function handleClick() {
if (!_onCellClick || !item) return
_onCellClick(item)
}
return (
<g
onMouseEnter={handleEnter}
onMouseMove={handleEnter}
onMouseLeave={handleLeave}
onClick={handleClick}
style={{ cursor: item?.children?.length ? 'pointer' : 'default' }}
>
<rect
x={x}
y={renderY}
width={width}
height={renderHeight}
style={{
fill,
stroke: 'rgba(255, 255, 255, 0.15)',
strokeWidth: 1,
}}
/>
{showLabel && (
<>
<text
x={x + 6}
y={renderY + 14}
fill="rgba(255, 255, 255, 0.95)"
fontSize={10}
fontWeight={600}
style={{ pointerEvents: 'none' }}
>
{truncateLabel(name, width)}
</text>
<text
x={x + 6}
y={renderY + 26}
fill="rgba(255, 255, 255, 0.65)"
fontSize={9}
style={{ pointerEvents: 'none' }}
>
{Math.round(percentage)}%
</text>
</>
)}
</g>
)
}
return null
}
/**
* Truncate the label so it fits the cell width recharts doesn't
* clip text and a full label can overrun the cell. Rough char-width
* estimate of 6.5px @ 11px font.
*/
function truncateLabel(name: string, width: number): string {
const maxChars = Math.max(3, Math.floor((width - 12) / 6.5))
if (name.length <= maxChars) return name
return name.slice(0, Math.max(1, maxChars - 1)) + '…'
}

View File

@ -80,14 +80,16 @@ describe('Sidebar — chrome', () => {
})
describe('Sidebar — navigation', () => {
it('renders exactly Apps + Jobs + Settings nav items', async () => {
it('renders Apps + Jobs + Dashboard + Settings nav items', async () => {
renderSidebarAt('/provision/d-test-1234')
expect(await screen.findByTestId('sov-nav-apps')).toBeTruthy()
expect(await screen.findByTestId('sov-nav-jobs')).toBeTruthy()
// Dashboard nav added with the resource-utilisation treemap
// surface (founder spec — `/provision/$deploymentId/dashboard`).
expect(await screen.findByTestId('sov-nav-dashboard')).toBeTruthy()
expect(await screen.findByTestId('sov-nav-settings')).toBeTruthy()
// Canonical-but-omitted items must NOT render: dashboard / domains /
// billing / team. Their absence is part of the contract.
expect(screen.queryByTestId('sov-nav-dashboard')).toBeNull()
// Canonical-but-omitted items must NOT render: domains / billing /
// team are tenant-console concerns, not Sovereign-provision ones.
expect(screen.queryByTestId('sov-nav-domains')).toBeNull()
expect(screen.queryByTestId('sov-nav-billing')).toBeNull()
expect(screen.queryByTestId('sov-nav-team')).toBeNull()

View File

@ -42,10 +42,14 @@ interface SidebarProps {
}
interface NavItem {
id: 'apps' | 'jobs' | 'settings'
id: 'apps' | 'jobs' | 'dashboard' | 'settings'
label: string
/** Tanstack-router target — `null` for static external/non-tanstack routes. */
to: '/provision/$deploymentId' | '/provision/$deploymentId/jobs' | '/wizard'
to:
| '/provision/$deploymentId'
| '/provision/$deploymentId/jobs'
| '/provision/$deploymentId/dashboard'
| '/wizard'
/** SVG path data — same `d` strings as core/console for visual parity. */
icon: string
}
@ -63,6 +67,14 @@ const NAV: NavItem[] = [
to: '/provision/$deploymentId/jobs',
icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4',
},
{
id: 'dashboard',
label: 'Dashboard',
to: '/provision/$deploymentId/dashboard',
// Treemap-style 4-pane grid icon — visually distinct from the
// 4-square Apps icon (Dashboard's quadrants are unequal).
icon: 'M3 3h7v9H3V3zm11 0h7v5h-7V3zM14 10h7v11h-7V10zM3 14h7v7H3v-7z',
},
{
id: 'settings',
label: 'Settings',
@ -73,6 +85,7 @@ const NAV: NavItem[] = [
/** Compute the active nav item from the current pathname. */
function deriveActive(pathname: string): NavItem['id'] {
if (pathname.endsWith('/dashboard')) return 'dashboard'
if (pathname.endsWith('/jobs')) return 'jobs'
if (pathname.startsWith('/sovereign/wizard') || pathname.startsWith('/wizard')) return 'settings'
return 'apps'

View File

@ -66,6 +66,16 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
// The UI is served under `base: '/sovereign/'`, so fetch calls
// emitted by `${API_BASE}/v1/...` (where API_BASE = `/sovereign/api`)
// arrive here as `/sovereign/api/...`. Rewrite to `/api/...` so
// catalyst-api receives the canonical path. Production traefik
// performs the same prefix strip, so dev mirrors prod.
'/sovereign/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (p: string) => p.replace(/^\/sovereign/, ''),
},
},
},
// Vitest config — drives `npm run test` in this package. The test runner