Compare commits

...

7 Commits

Author SHA1 Message Date
hatiyildiz
652d796f4b test(ui): cosmetic guards for Infrastructure tabs + redirect (#227)
Three new @cosmetic-guard tests:

  1. /infrastructure redirects to /infrastructure/topology (default tab)
  2. Tabs are exactly Topology / Compute / Storage / Network in that
     order, with Topology aria-selected by default
  3. Sidebar exposes a sov-nav-infrastructure link to /infrastructure

Each test fails LOUD with the source-file pointer the next agent must
edit, matching the existing cosmetic-guard idiom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:57:49 +02:00
hatiyildiz
97eeb8fa59 feat(api): infrastructure REST surface (topology/compute/storage/network) (#227)
Four GET endpoints for the Sovereign Infrastructure page:

  /api/v1/deployments/{depId}/infrastructure/topology
  /api/v1/deployments/{depId}/infrastructure/compute
  /api/v1/deployments/{depId}/infrastructure/storage
  /api/v1/deployments/{depId}/infrastructure/network

Topology + Compute + Network compose from the deployment record's
Request + Result (always available post-Phase-0). Storage requires
the live cluster's kubeconfig; until that integration lands, the
handler returns the well-shaped empty response per the founder's
"no placeholder data, empty state instead" rule. JSON arrays serialise
as `[]` not `null` so the UI can iterate them safely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:57:41 +02:00
hatiyildiz
a19d6d1198 feat(ui): wire Infrastructure routes + sidebar nav item (#227)
Registers parent route /provision/$deploymentId/infrastructure with
four sub-routes (topology, compute, storage, network) plus an index
beforeLoad redirect that sends bare /infrastructure to /infrastructure/
topology. Adds the Infrastructure entry to the Sidebar nav with a
server-stack glyph distinct from Apps and Dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:57:33 +02:00
hatiyildiz
680ad2697b feat(ui): Infrastructure Compute/Storage/Network card grids (#227)
Three card-grid tabs in the canonical .app-card visual rhythm:

  Compute  — Clusters + Worker Nodes
  Storage  — Persistent Volume Claims + Object Buckets + Block Volumes
  Network  — Load Balancers + DRGs / VPC Gateways + Peerings

Each tab fetches its slice from /api/v1/deployments/<id>/infrastructure/
<tab> with React Query, shows a section heading + count chip, renders
status-aware cards. Empty state per tab is a typographic empty card —
no placeholder data per founder spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:57:25 +02:00
hatiyildiz
6f32fc7074 feat(ui): InfrastructureTopology SVG canvas + detail panel (#227)
Topology tab — pure-SVG layered-graph canvas using the deterministic
topologyLayout. Status colour comes from canonical --color-success /
warn / danger / text-dim CSS variables. Click a node opens a right-rail
detail panel listing the node's metadata; closing the panel returns
to the bare canvas. Empty state shows a "Provisioning…" overlay rather
than placeholder data — the canvas is the canonical empty state until
the cluster reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:57:15 +02:00
hatiyildiz
3a65212700 feat(ui): InfrastructurePage shell with 4 tabs (Topology default) (#227)
Page shell rendered at /sovereign/provision/$deploymentId/infrastructure.
Header + four-tab nav (Topology / Compute / Storage / Network) in the
canonical AppsPage tab style; active tab derived from the URL suffix
so back/forward keeps the active tab in sync. Founder spec verbatim:
"the infrastructure page must be opened by default with the topology
page" — Topology is the default and the bare URL redirects to it via
the router.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:57:07 +02:00
hatiyildiz
aa974f3a6b feat(ui): infrastructure.types — wire types + topology layout (#227)
Introduces the shared TypeScript contract the Infrastructure surface
consumes: TopologyNode/Edge, ComputeItem, StorageItem, NetworkItem,
fetchers keyed off API_BASE, and a deterministic layered topology
layout (cloud → region → cluster → node | lb → pvc | volume | network)
mirroring the depsLayout pattern from #206. Pure-function tests pin
the layer-by-NodeKind invariant, edge poly-line emission and
deterministic ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:57:00 +02:00
18 changed files with 3197 additions and 1 deletions

View File

@ -89,6 +89,15 @@ func main() {
// V1 emits a static placeholder shape — see dashboard.go header // V1 emits a static placeholder shape — see dashboard.go header
// for the metrics-server upgrade plan. // for the metrics-server upgrade plan.
r.Get("/api/v1/dashboard/treemap", h.GetDashboardTreemap) r.Get("/api/v1/dashboard/treemap", h.GetDashboardTreemap)
// Sovereign Infrastructure surface (issue #227) — Topology canvas
// + Compute / Storage / Network card grids. Each endpoint reads
// from the deployment record + (future) live cluster kubeconfig;
// see internal/handler/infrastructure.go for the data-source
// contract.
r.Get("/api/v1/deployments/{depId}/infrastructure/topology", h.GetInfrastructureTopology)
r.Get("/api/v1/deployments/{depId}/infrastructure/compute", h.GetInfrastructureCompute)
r.Get("/api/v1/deployments/{depId}/infrastructure/storage", h.GetInfrastructureStorage)
r.Get("/api/v1/deployments/{depId}/infrastructure/network", h.GetInfrastructureNetwork)
log.Info("catalyst api listening", "port", port) log.Info("catalyst api listening", "port", port)
if err := http.ListenAndServe(":"+port, r); err != nil { if err := http.ListenAndServe(":"+port, r); err != nil {

View File

@ -0,0 +1,605 @@
// Package handler — infrastructure.go: REST surface for the Sovereign
// Infrastructure page (issue #227).
//
// GET /api/v1/deployments/{depId}/infrastructure/topology
// GET /api/v1/deployments/{depId}/infrastructure/compute
// GET /api/v1/deployments/{depId}/infrastructure/storage
// GET /api/v1/deployments/{depId}/infrastructure/network
//
// Each endpoint reads from two data sources, merging in this order:
//
// 1. The deployment record's `provisioner.Result` — every Phase-0
// output that the OpenTofu module persisted (control-plane IP,
// load-balancer IP, region, etc.). This is always available the
// moment Phase 0 finishes; no live cluster is needed.
//
// 2. (Future) The new Sovereign's POST-back kubeconfig — used to query
// metrics-server / kubectl-equivalent state for live PVCs, services,
// nodes, etc. The kubeconfig path lives at `Result.KubeconfigPath`
// and is set by the cloud-init postback (issue #183).
//
// Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall, target-state shape):
// the JSON response shapes here are the FINAL shapes the UI consumes.
// When a piece of live data is unavailable today (live PVC list, live
// metrics) the handler returns a well-shaped EMPTY response — not
// placeholder data. The UI handles empty state gracefully via its
// "Provisioning…" overlay.
//
// Per #4 (never hardcode): the handler reads region / IP / FQDN from
// the deployment record's Request + Result; nothing is inlined.
package handler
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/provisioner"
)
/* ── Wire shapes — JSON tags must match the TS contract verbatim ─── */
type infraTopologyNode struct {
ID string `json:"id"`
Kind string `json:"kind"`
Label string `json:"label"`
Status string `json:"status"`
Metadata map[string]string `json:"metadata"`
}
type infraTopologyEdge struct {
From string `json:"from"`
To string `json:"to"`
Relation string `json:"relation"`
}
type infraTopologyResponse struct {
Nodes []infraTopologyNode `json:"nodes"`
Edges []infraTopologyEdge `json:"edges"`
}
type infraClusterItem struct {
ID string `json:"id"`
Name string `json:"name"`
ControlPlane string `json:"controlPlane"`
Version string `json:"version"`
Region string `json:"region"`
NodeCount int `json:"nodeCount"`
Status string `json:"status"`
}
type infraNodeItem struct {
ID string `json:"id"`
Name string `json:"name"`
SKU string `json:"sku"`
Region string `json:"region"`
Role string `json:"role"`
IP string `json:"ip"`
Status string `json:"status"`
}
type infraComputeResponse struct {
Clusters []infraClusterItem `json:"clusters"`
Nodes []infraNodeItem `json:"nodes"`
}
type infraPVCItem struct {
ID string `json:"id"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Capacity string `json:"capacity"`
Used string `json:"used"`
StorageClass string `json:"storageClass"`
Status string `json:"status"`
}
type infraBucketItem struct {
ID string `json:"id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Capacity string `json:"capacity"`
Used string `json:"used"`
RetentionDays string `json:"retentionDays"`
}
type infraVolumeItem struct {
ID string `json:"id"`
Name string `json:"name"`
Capacity string `json:"capacity"`
Region string `json:"region"`
AttachedTo string `json:"attachedTo"`
Status string `json:"status"`
}
type infraStorageResponse struct {
PVCs []infraPVCItem `json:"pvcs"`
Buckets []infraBucketItem `json:"buckets"`
Volumes []infraVolumeItem `json:"volumes"`
}
type infraLBItem struct {
ID string `json:"id"`
Name string `json:"name"`
PublicIP string `json:"publicIP"`
Ports string `json:"ports"`
TargetHealth string `json:"targetHealth"`
Region string `json:"region"`
Status string `json:"status"`
}
type infraDRGItem struct {
ID string `json:"id"`
Name string `json:"name"`
CIDR string `json:"cidr"`
Region string `json:"region"`
Peers string `json:"peers"`
Status string `json:"status"`
}
type infraPeeringItem struct {
ID string `json:"id"`
Name string `json:"name"`
VPCPair string `json:"vpcPair"`
Subnets string `json:"subnets"`
Status string `json:"status"`
}
type infraNetworkResponse struct {
LoadBalancers []infraLBItem `json:"loadBalancers"`
DRGs []infraDRGItem `json:"drgs"`
Peerings []infraPeeringItem `json:"peerings"`
}
/* ── HTTP handlers ────────────────────────────────────────────── */
// GetInfrastructureTopology handles
// GET /api/v1/deployments/{depId}/infrastructure/topology.
func (h *Handler) GetInfrastructureTopology(w http.ResponseWriter, r *http.Request) {
depID := chi.URLParam(r, "depId")
dep, ok := h.lookupDeploymentForInfra(depID)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "deployment-not-found",
"detail": "no deployment with id " + depID,
})
return
}
writeJSON(w, http.StatusOK, buildInfraTopology(dep))
}
// GetInfrastructureCompute handles
// GET /api/v1/deployments/{depId}/infrastructure/compute.
func (h *Handler) GetInfrastructureCompute(w http.ResponseWriter, r *http.Request) {
depID := chi.URLParam(r, "depId")
dep, ok := h.lookupDeploymentForInfra(depID)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "deployment-not-found",
"detail": "no deployment with id " + depID,
})
return
}
writeJSON(w, http.StatusOK, buildInfraCompute(dep))
}
// GetInfrastructureStorage handles
// GET /api/v1/deployments/{depId}/infrastructure/storage.
func (h *Handler) GetInfrastructureStorage(w http.ResponseWriter, r *http.Request) {
depID := chi.URLParam(r, "depId")
_, ok := h.lookupDeploymentForInfra(depID)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "deployment-not-found",
"detail": "no deployment with id " + depID,
})
return
}
// Storage queries require the new Sovereign's kubeconfig + a live
// kubectl call. Per the file-header contract, until that integration
// lands we return the well-shaped empty response so the UI's empty
// state activates instead of placeholder data.
writeJSON(w, http.StatusOK, infraStorageResponse{
PVCs: []infraPVCItem{},
Buckets: []infraBucketItem{},
Volumes: []infraVolumeItem{},
})
}
// GetInfrastructureNetwork handles
// GET /api/v1/deployments/{depId}/infrastructure/network.
func (h *Handler) GetInfrastructureNetwork(w http.ResponseWriter, r *http.Request) {
depID := chi.URLParam(r, "depId")
dep, ok := h.lookupDeploymentForInfra(depID)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "deployment-not-found",
"detail": "no deployment with id " + depID,
})
return
}
writeJSON(w, http.StatusOK, buildInfraNetwork(dep))
}
/* ── Helpers ─────────────────────────────────────────────────── */
// lookupDeploymentForInfra resolves a deployment by id from the
// in-memory map, mirroring GetDeployment's lookup. Returns nil + false
// on miss so the caller can write a 404 with a contextual error body.
func (h *Handler) lookupDeploymentForInfra(id string) (*Deployment, bool) {
val, ok := h.deployments.Load(id)
if !ok {
return nil, false
}
dep, ok := val.(*Deployment)
if !ok {
return nil, false
}
return dep, true
}
// statusForDeployment maps the Deployment.Status string to the canonical
// TopologyStatus vocabulary the UI consumes (healthy / degraded / failed
// / unknown). Pre-Phase-0 deployments return "unknown".
func statusForDeployment(dep *Deployment) string {
switch dep.Status {
case "ready":
return "healthy"
case "failed":
return "failed"
case "":
return "unknown"
default:
// pending / provisioning / tofu-applying / phase1-watching all
// surface as unknown — the topology renderer paints these grey.
return "unknown"
}
}
// firstRegion returns the cloud region of the first regional spec, or
// the legacy singular region if Regions is empty. Empty when neither
// is set (e.g. a freshly-created deployment that hasn't reached
// Validate() yet).
func firstRegion(req provisioner.Request) string {
if len(req.Regions) > 0 {
return req.Regions[0].CloudRegion
}
return req.Region
}
// firstProvider returns the cloud provider of the first regional spec.
// Defaults to "hetzner" when no Regions slot is set (the legacy
// singular path is Hetzner-only).
func firstProvider(req provisioner.Request) string {
if len(req.Regions) > 0 && req.Regions[0].Provider != "" {
return req.Regions[0].Provider
}
return "hetzner"
}
// totalWorkerCount sums every Regions slot's WorkerCount, falling back
// to the legacy singular field. Used for the Cluster card's nodeCount.
func totalWorkerCount(req provisioner.Request) int {
if len(req.Regions) > 0 {
n := 0
for _, rg := range req.Regions {
n += rg.WorkerCount
}
return n
}
return req.WorkerCount
}
// buildInfraTopology composes a layered topology graph from the
// deployment record. The layers (cloud → region → cluster → node | lb)
// are deterministic so the UI's force-free layered layout reads
// top-down without guesswork.
func buildInfraTopology(dep *Deployment) infraTopologyResponse {
dep.mu.Lock()
defer dep.mu.Unlock()
provider := firstProvider(dep.Request)
region := firstRegion(dep.Request)
status := statusForDeployment(dep)
fqdn := dep.Request.SovereignFQDN
cloudID := "cloud-" + provider
regionID := "region-" + region
clusterID := "cluster-" + dep.ID
lbID := "lb-" + dep.ID
nodes := []infraTopologyNode{
{
ID: cloudID,
Kind: "cloud",
Label: provider,
Status: status,
Metadata: map[string]string{
"provider": provider,
},
},
}
if region != "" {
nodes = append(nodes, infraTopologyNode{
ID: regionID,
Kind: "region",
Label: region,
Status: status,
Metadata: map[string]string{
"cloudRegion": region,
"provider": provider,
},
})
}
clusterMeta := map[string]string{
"sovereignFQDN": fqdn,
"deploymentID": dep.ID,
}
if dep.Result != nil {
if dep.Result.ControlPlaneIP != "" {
clusterMeta["controlPlaneIP"] = dep.Result.ControlPlaneIP
}
if dep.Result.ConsoleURL != "" {
clusterMeta["consoleURL"] = dep.Result.ConsoleURL
}
}
clusterLabel := fqdn
if clusterLabel == "" {
clusterLabel = "cluster-" + dep.ID[:minLen(dep.ID, 8)]
}
nodes = append(nodes, infraTopologyNode{
ID: clusterID,
Kind: "cluster",
Label: clusterLabel,
Status: status,
Metadata: clusterMeta,
})
// Edges that always exist.
edges := []infraTopologyEdge{}
if region != "" {
edges = append(edges, infraTopologyEdge{From: cloudID, To: regionID, Relation: "contains"})
edges = append(edges, infraTopologyEdge{From: regionID, To: clusterID, Relation: "contains"})
} else {
edges = append(edges, infraTopologyEdge{From: cloudID, To: clusterID, Relation: "contains"})
}
// Worker nodes: synthesise one per Regions slot's WorkerCount + the
// control-plane SKU. The OpenTofu module names them deterministically
// via cloud-init; until the kubeconfig postback exposes the actual
// node list, we surface the requested topology so the canvas mirrors
// what was provisioned.
for ri, rg := range dep.Request.Regions {
// Control plane node for this region.
cpID := "node-cp-" + rg.CloudRegion
nodes = append(nodes, infraTopologyNode{
ID: cpID,
Kind: "node",
Label: cpID,
Status: status,
Metadata: map[string]string{
"role": "control-plane",
"sku": rg.ControlPlaneSize,
"region": rg.CloudRegion,
},
})
edges = append(edges, infraTopologyEdge{From: clusterID, To: cpID, Relation: "contains"})
for i := 0; i < rg.WorkerCount; i++ {
nID := "node-w" + itoa(ri) + "-" + itoa(i) + "-" + rg.CloudRegion
nodes = append(nodes, infraTopologyNode{
ID: nID,
Kind: "node",
Label: "worker-" + itoa(i+1),
Status: status,
Metadata: map[string]string{
"role": "worker",
"sku": rg.WorkerSize,
"region": rg.CloudRegion,
},
})
edges = append(edges, infraTopologyEdge{From: clusterID, To: nID, Relation: "contains"})
}
}
// Legacy singular path — when Regions is empty.
if len(dep.Request.Regions) == 0 {
cpID := "node-cp-" + dep.ID
nodes = append(nodes, infraTopologyNode{
ID: cpID,
Kind: "node",
Label: "control-plane",
Status: status,
Metadata: map[string]string{
"role": "control-plane",
"sku": dep.Request.ControlPlaneSize,
"region": region,
},
})
edges = append(edges, infraTopologyEdge{From: clusterID, To: cpID, Relation: "contains"})
for i := 0; i < dep.Request.WorkerCount; i++ {
nID := "node-w-" + itoa(i) + "-" + dep.ID
nodes = append(nodes, infraTopologyNode{
ID: nID,
Kind: "node",
Label: "worker-" + itoa(i+1),
Status: status,
Metadata: map[string]string{
"role": "worker",
"sku": dep.Request.WorkerSize,
"region": region,
},
})
edges = append(edges, infraTopologyEdge{From: clusterID, To: nID, Relation: "contains"})
}
}
// Load balancer — surface when the OpenTofu module has reported its
// public IP. Pre-LB-reconcile deployments will simply not have an LB
// node on the canvas yet.
if dep.Result != nil && dep.Result.LoadBalancerIP != "" {
nodes = append(nodes, infraTopologyNode{
ID: lbID,
Kind: "lb",
Label: "ingress-lb",
Status: status,
Metadata: map[string]string{
"publicIP": dep.Result.LoadBalancerIP,
"region": region,
},
})
edges = append(edges, infraTopologyEdge{From: clusterID, To: lbID, Relation: "attached-to"})
}
return infraTopologyResponse{Nodes: nodes, Edges: edges}
}
func buildInfraCompute(dep *Deployment) infraComputeResponse {
dep.mu.Lock()
defer dep.mu.Unlock()
region := firstRegion(dep.Request)
status := statusForDeployment(dep)
fqdn := dep.Request.SovereignFQDN
clusterName := fqdn
if clusterName == "" {
clusterName = "cluster-" + dep.ID[:minLen(dep.ID, 8)]
}
cluster := infraClusterItem{
ID: "cluster-" + dep.ID,
Name: clusterName,
ControlPlane: "k3s",
Version: "v1.30",
Region: region,
NodeCount: totalWorkerCount(dep.Request) + 1, // +1 for control-plane
Status: status,
}
nodes := []infraNodeItem{}
if len(dep.Request.Regions) > 0 {
for ri, rg := range dep.Request.Regions {
cpIP := ""
if dep.Result != nil && ri == 0 {
cpIP = dep.Result.ControlPlaneIP
}
nodes = append(nodes, infraNodeItem{
ID: "node-cp-" + rg.CloudRegion,
Name: "control-plane-" + rg.CloudRegion,
SKU: rg.ControlPlaneSize,
Region: rg.CloudRegion,
Role: "control-plane",
IP: cpIP,
Status: status,
})
for i := 0; i < rg.WorkerCount; i++ {
nodes = append(nodes, infraNodeItem{
ID: "node-w" + itoa(ri) + "-" + itoa(i) + "-" + rg.CloudRegion,
Name: "worker-" + itoa(i+1) + "-" + rg.CloudRegion,
SKU: rg.WorkerSize,
Region: rg.CloudRegion,
Role: "worker",
IP: "",
Status: status,
})
}
}
} else {
cpIP := ""
if dep.Result != nil {
cpIP = dep.Result.ControlPlaneIP
}
nodes = append(nodes, infraNodeItem{
ID: "node-cp-" + dep.ID,
Name: "control-plane",
SKU: dep.Request.ControlPlaneSize,
Region: region,
Role: "control-plane",
IP: cpIP,
Status: status,
})
for i := 0; i < dep.Request.WorkerCount; i++ {
nodes = append(nodes, infraNodeItem{
ID: "node-w-" + itoa(i) + "-" + dep.ID,
Name: "worker-" + itoa(i+1),
SKU: dep.Request.WorkerSize,
Region: region,
Role: "worker",
IP: "",
Status: status,
})
}
}
return infraComputeResponse{
Clusters: []infraClusterItem{cluster},
Nodes: nodes,
}
}
func buildInfraNetwork(dep *Deployment) infraNetworkResponse {
dep.mu.Lock()
defer dep.mu.Unlock()
region := firstRegion(dep.Request)
status := statusForDeployment(dep)
fqdn := dep.Request.SovereignFQDN
lbs := []infraLBItem{}
if dep.Result != nil && dep.Result.LoadBalancerIP != "" {
lbName := fqdn
if lbName == "" {
lbName = "ingress-lb"
}
lbs = append(lbs, infraLBItem{
ID: "lb-" + dep.ID,
Name: lbName,
PublicIP: dep.Result.LoadBalancerIP,
Ports: "80,443,6443",
TargetHealth: "—",
Region: region,
Status: status,
})
}
// DRGs and peerings require live cloud-API state — the OpenTofu
// module records them but we don't surface them through the
// catalyst-api persistence today. Per the file-header contract we
// return well-shaped empty arrays rather than placeholder data.
return infraNetworkResponse{
LoadBalancers: lbs,
DRGs: []infraDRGItem{},
Peerings: []infraPeeringItem{},
}
}
func minLen(s string, max int) int {
if len(s) < max {
return len(s)
}
return max
}
// itoa avoids strconv just for the int→string formatting in id
// composition (handler is small, fmt.Sprintf would be fine but this
// keeps the hot path allocation-free).
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := false
if n < 0 {
neg = true
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}

View File

@ -0,0 +1,233 @@
// infrastructure_test.go — coverage for the Sovereign Infrastructure
// REST surface. Pins the wire shape every endpoint emits + the 404
// path so the UI's contract stays stable as the data sources evolve
// (today: deployment record only; future: live-cluster kubeconfig).
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/go-chi/chi/v5"
"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/provisioner"
)
// installInfraDeployment seeds a synthetic deployment into the
// handler's in-memory map and returns the id. Tests mutate
// dep.Result fields to exercise the LB / no-LB branches.
func installInfraDeployment(t *testing.T, h *Handler, status string) (*Deployment, string) {
t.Helper()
id := "dep-infra-test"
dep := &Deployment{
ID: id,
Status: status,
Request: provisioner.Request{
SovereignFQDN: "omantel.omani.works",
Region: "fsn1",
ControlPlaneSize: "cpx21",
WorkerSize: "cpx41",
WorkerCount: 2,
},
mu: sync.Mutex{},
}
h.deployments.Store(id, dep)
return dep, id
}
// callInfra wires a chi router with the depId path param, executes
// the request through it (so chi.URLParam("depId") resolves), and
// returns the recorder.
func callInfra(t *testing.T, h *Handler, method, suffix, depID string, handler http.HandlerFunc) *httptest.ResponseRecorder {
t.Helper()
r := chi.NewRouter()
r.Method(method, "/api/v1/deployments/{depId}/infrastructure/"+suffix, handler)
req := httptest.NewRequest(method, "/api/v1/deployments/"+depID+"/infrastructure/"+suffix, nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec
}
func TestInfrastructureTopology_NotFound(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
rec := callInfra(t, h, http.MethodGet, "topology", "ghost", h.GetInfrastructureTopology)
if rec.Code != http.StatusNotFound {
t.Fatalf("status: got %d want 404; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "deployment-not-found") {
t.Fatalf("expected deployment-not-found in body; got %s", rec.Body.String())
}
}
func TestInfrastructureTopology_OKShape(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
dep, id := installInfraDeployment(t, h, "ready")
dep.Result = &provisioner.Result{
SovereignFQDN: "omantel.omani.works",
ControlPlaneIP: "5.6.7.8",
LoadBalancerIP: "203.0.113.10",
}
rec := callInfra(t, h, http.MethodGet, "topology", id, h.GetInfrastructureTopology)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d want 200; body=%s", rec.Code, rec.Body.String())
}
var out infraTopologyResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.Nodes) == 0 {
t.Fatalf("expected non-empty nodes")
}
if len(out.Edges) == 0 {
t.Fatalf("expected non-empty edges")
}
// Cloud + cluster + LB + workers must all surface. Spot-check kinds.
kinds := map[string]int{}
for _, n := range out.Nodes {
kinds[n.Kind]++
}
if kinds["cloud"] != 1 {
t.Fatalf("expected 1 cloud node; got %d", kinds["cloud"])
}
if kinds["cluster"] != 1 {
t.Fatalf("expected 1 cluster node; got %d", kinds["cluster"])
}
if kinds["lb"] != 1 {
t.Fatalf("expected 1 lb node when LoadBalancerIP set; got %d", kinds["lb"])
}
// At least the workers + control plane.
if kinds["node"] < 3 {
t.Fatalf("expected >=3 node entries (1 cp + 2 workers); got %d", kinds["node"])
}
}
func TestInfrastructureTopology_NoLBWhenAbsent(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
_, id := installInfraDeployment(t, h, "provisioning")
rec := callInfra(t, h, http.MethodGet, "topology", id, h.GetInfrastructureTopology)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d want 200; body=%s", rec.Code, rec.Body.String())
}
var out infraTopologyResponse
_ = json.Unmarshal(rec.Body.Bytes(), &out)
for _, n := range out.Nodes {
if n.Kind == "lb" {
t.Fatalf("expected no lb node before LoadBalancerIP is reported; got %+v", n)
}
}
}
func TestInfrastructureCompute_NotFound(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
rec := callInfra(t, h, http.MethodGet, "compute", "ghost", h.GetInfrastructureCompute)
if rec.Code != http.StatusNotFound {
t.Fatalf("status: got %d want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestInfrastructureCompute_OK(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
_, id := installInfraDeployment(t, h, "ready")
rec := callInfra(t, h, http.MethodGet, "compute", id, h.GetInfrastructureCompute)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d want 200; body=%s", rec.Code, rec.Body.String())
}
var out infraComputeResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.Clusters) != 1 {
t.Fatalf("expected exactly 1 cluster; got %d", len(out.Clusters))
}
if len(out.Nodes) != 3 { // 1 cp + 2 workers
t.Fatalf("expected 3 nodes (cp + 2 workers); got %d", len(out.Nodes))
}
c := out.Clusters[0]
if c.Name != "omantel.omani.works" {
t.Fatalf("cluster name: got %q want omantel.omani.works", c.Name)
}
if c.NodeCount != 3 {
t.Fatalf("cluster node count: got %d want 3", c.NodeCount)
}
}
func TestInfrastructureStorage_OKEmpty(t *testing.T) {
// Storage queries the live cluster, which isn't wired yet. The
// handler MUST return a well-shaped empty response (not placeholder
// data) per the file-header contract.
h := NewWithPDM(silentLogger(), &fakePDM{})
_, id := installInfraDeployment(t, h, "ready")
rec := callInfra(t, h, http.MethodGet, "storage", id, h.GetInfrastructureStorage)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d want 200; body=%s", rec.Code, rec.Body.String())
}
var out infraStorageResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.PVCs) != 0 || len(out.Buckets) != 0 || len(out.Volumes) != 0 {
t.Fatalf("expected empty arrays for live-cluster sourced data; got %+v", out)
}
// JSON arrays MUST be `[]` not `null` so the UI can iterate them.
body := rec.Body.String()
if !strings.Contains(body, `"pvcs":[]`) {
t.Fatalf("pvcs field must serialise as `[]`, got body=%s", body)
}
}
func TestInfrastructureStorage_NotFound(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
rec := callInfra(t, h, http.MethodGet, "storage", "ghost", h.GetInfrastructureStorage)
if rec.Code != http.StatusNotFound {
t.Fatalf("status: got %d want 404; body=%s", rec.Code, rec.Body.String())
}
}
func TestInfrastructureNetwork_OKWithLB(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
dep, id := installInfraDeployment(t, h, "ready")
dep.Result = &provisioner.Result{LoadBalancerIP: "203.0.113.10"}
rec := callInfra(t, h, http.MethodGet, "network", id, h.GetInfrastructureNetwork)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d want 200; body=%s", rec.Code, rec.Body.String())
}
var out infraNetworkResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.LoadBalancers) != 1 {
t.Fatalf("expected 1 LB; got %d", len(out.LoadBalancers))
}
if out.LoadBalancers[0].PublicIP != "203.0.113.10" {
t.Fatalf("LB publicIP: got %q want 203.0.113.10", out.LoadBalancers[0].PublicIP)
}
}
func TestInfrastructureNetwork_OKEmpty(t *testing.T) {
h := NewWithPDM(silentLogger(), &fakePDM{})
_, id := installInfraDeployment(t, h, "provisioning")
rec := callInfra(t, h, http.MethodGet, "network", id, h.GetInfrastructureNetwork)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d want 200; body=%s", rec.Code, rec.Body.String())
}
var out infraNetworkResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.LoadBalancers) != 0 {
t.Fatalf("expected 0 LBs before LB IP reported; got %d", len(out.LoadBalancers))
}
body := rec.Body.String()
if !strings.Contains(body, `"loadBalancers":[]`) ||
!strings.Contains(body, `"drgs":[]`) ||
!strings.Contains(body, `"peerings":[]`) {
t.Fatalf("network arrays must serialise as `[]`, got body=%s", body)
}
}

View File

@ -1080,3 +1080,90 @@ test.describe('@cosmetic-guard StepComponents card description', () => {
} }
}) })
}) })
/*
* Infrastructure page (issue #227)
*
* Founder spec lock-in:
* Bare /infrastructure URL redirects to /infrastructure/topology
* Topology tab is the active default landing
* Tabs are exactly Topology / Compute / Storage / Network (in that
* order, no accordions, no extras)
* */
test.describe('@cosmetic-guard infrastructure page', () => {
test('Infrastructure page redirects /infrastructure → /infrastructure/topology', async ({ page }) => {
await page.goto('provision/test-deployment-id/infrastructure')
await page.waitForLoadState('domcontentloaded')
// Wait for the redirect to settle. TanStack-router's beforeLoad
// redirect fires synchronously on first paint so the URL should
// already carry the topology suffix by the time domcontentloaded
// resolves; we still poll briefly to allow the SPA shell to
// hydrate.
await page.waitForFunction(
() => window.location.pathname.endsWith('/infrastructure/topology'),
{ timeout: 5000 },
)
const url = new URL(page.url())
expect(
url.pathname.endsWith('/infrastructure/topology'),
`Expected /infrastructure to redirect to /infrastructure/topology; got pathname=${url.pathname}. The redirect lives in src/app/router.tsx (provisionInfrastructureIndexRoute beforeLoad). Founder spec: "the infrastructure page must be opened by default with the topology page".`,
).toBe(true)
})
test('Topology tab is the active default and tabs are Topology / Compute / Storage / Network', async ({ page }) => {
await page.goto('provision/test-deployment-id/infrastructure/topology')
await page.waitForLoadState('domcontentloaded')
const tablist = page.getByTestId('infrastructure-tabs')
await expect(
tablist,
'Infrastructure page does not expose a [data-testid=infrastructure-tabs] tablist. Add the testid to the <nav role=tablist> in InfrastructurePage.tsx.',
).toBeVisible()
// Tab order + labels.
const expected = ['Topology', 'Compute', 'Storage', 'Network']
for (let i = 0; i < expected.length; i++) {
const tab = tablist.getByRole('tab').nth(i)
const label = (await tab.textContent())?.trim()
expect(
label,
`Infrastructure tab #${i} label = "${label}"; expected "${expected[i]}". Founder spec verbatim: "tabs of compute (clusters and worker nodes), storage (pvcs, buckets etc) and network (lbs, drgs, peerings etc)" — Topology is the canonical default landing.`,
).toBe(expected[i])
}
// Topology tab is the active default.
const topologyTab = page.getByTestId('infra-tab-topology')
const ariaSelected = await topologyTab.getAttribute('aria-selected')
expect(
ariaSelected,
'Topology tab is not aria-selected by default. The default landing for /sovereign/provision/$deploymentId/infrastructure must be the topology view per founder spec.',
).toBe('true')
// Topology canvas mounts (loading, error, empty, or populated state — any of those is acceptable here).
const canvas = page.getByTestId('infrastructure-topology-canvas')
await expect(
canvas,
'Topology tab is the default but the canvas frame is missing. Add data-testid=infrastructure-topology-canvas to the canvas wrapper.',
).toBeVisible()
})
test('Sidebar exposes an Infrastructure nav item that links to /infrastructure', async ({ page }) => {
await page.goto('provision/test-deployment-id')
await page.waitForLoadState('domcontentloaded')
const navItem = page.getByTestId('sov-nav-infrastructure')
await expect(
navItem,
'Sidebar is missing the Infrastructure nav item. Add a NAV entry with id=infrastructure pointing at /provision/$deploymentId/infrastructure (see Sidebar.tsx).',
).toBeVisible()
const href = await navItem.getAttribute('href')
expect(
href ?? '',
`Infrastructure nav item href = "${href}"; expected to contain /infrastructure. The link target lives in Sidebar.tsx NAV[].to.`,
).toMatch(/\/infrastructure/)
})
})

View File

@ -24,6 +24,11 @@ import { JobDetail } from '@/pages/sovereign/JobDetail'
import { JobsTimeline } from '@/pages/sovereign/JobsTimeline' import { JobsTimeline } from '@/pages/sovereign/JobsTimeline'
import { Dashboard } from '@/pages/sovereign/Dashboard' import { Dashboard } from '@/pages/sovereign/Dashboard'
import { BatchDetail } from '@/pages/sovereign/BatchDetail' import { BatchDetail } from '@/pages/sovereign/BatchDetail'
import { InfrastructurePage } from '@/pages/sovereign/InfrastructurePage'
import { InfrastructureTopology } from '@/pages/sovereign/InfrastructureTopology'
import { InfrastructureCompute } from '@/pages/sovereign/InfrastructureCompute'
import { InfrastructureStorage } from '@/pages/sovereign/InfrastructureStorage'
import { InfrastructureNetwork } from '@/pages/sovereign/InfrastructureNetwork'
// Root // Root
const rootRoute = createRootRoute({ component: RootLayout }) const rootRoute = createRootRoute({ component: RootLayout })
@ -119,6 +124,52 @@ const provisionDashboardRoute = createRoute({
component: Dashboard, component: Dashboard,
}) })
// Sovereign Infrastructure surface (issue #227) — Topology canvas is
// the DEFAULT tab per founder spec ("the infrastructure page must be
// opened by default with the topology page"). The shell renders
// header + tabs and an <Outlet />; bare /infrastructure redirects to
// the topology sub-route so the URL shape is always explicit.
const provisionInfrastructureRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/infrastructure',
component: InfrastructurePage,
})
const provisionInfrastructureIndexRoute = createRoute({
getParentRoute: () => provisionInfrastructureRoute,
path: '/',
beforeLoad: ({ params }) => {
throw redirect({
to: '/provision/$deploymentId/infrastructure/topology',
params,
})
},
})
const provisionInfrastructureTopologyRoute = createRoute({
getParentRoute: () => provisionInfrastructureRoute,
path: '/topology',
component: InfrastructureTopology,
})
const provisionInfrastructureComputeRoute = createRoute({
getParentRoute: () => provisionInfrastructureRoute,
path: '/compute',
component: InfrastructureCompute,
})
const provisionInfrastructureStorageRoute = createRoute({
getParentRoute: () => provisionInfrastructureRoute,
path: '/storage',
component: InfrastructureStorage,
})
const provisionInfrastructureNetworkRoute = createRoute({
getParentRoute: () => provisionInfrastructureRoute,
path: '/network',
component: InfrastructureNetwork,
})
// Per-Batch detail page (epic #204 item #4) — surfaces a single batch // Per-Batch detail page (epic #204 item #4) — surfaces a single batch
// progress card at the top + a JobsTable filtered to that batch's // progress card at the top + a JobsTable filtered to that batch's
// rows. Reachable from the batch chip in any JobsTable row (both // rows. Reachable from the batch chip in any JobsTable row (both
@ -179,6 +230,13 @@ const routeTree = rootRoute.addChildren([
provisionJobsTimelineRoute, provisionJobsTimelineRoute,
provisionJobDetailRoute, provisionJobDetailRoute,
provisionDashboardRoute, provisionDashboardRoute,
provisionInfrastructureRoute.addChildren([
provisionInfrastructureIndexRoute,
provisionInfrastructureTopologyRoute,
provisionInfrastructureComputeRoute,
provisionInfrastructureStorageRoute,
provisionInfrastructureNetworkRoute,
]),
provisionBatchDetailRoute, provisionBatchDetailRoute,
legacyProvisionRoute, legacyProvisionRoute,
designsRoute, designsRoute,

View File

@ -0,0 +1,114 @@
/**
* infrastructure.types.test.ts pure-function tests for the topology
* layered layout. Mirrors the depsLayout.test.ts pattern: no jsdom,
* no React render just assert the layout function's invariants.
*/
import { describe, it, expect } from 'vitest'
import {
topologyLayout,
type TopologyEdge,
type TopologyNode,
} from './infrastructure.types'
describe('topologyLayout', () => {
it('returns an empty graph for empty input', () => {
const result = topologyLayout([], [])
expect(result.nodes).toEqual([])
expect(result.edges).toEqual([])
// padding * 2 = 64, paddingY*2 + maxRow*rowHeight + nodeHeight = 64+0+64 = 128
expect(result.width).toBe(64)
expect(result.height).toBe(128)
})
it('places nodes on layers keyed off NodeKind', () => {
const nodes: TopologyNode[] = [
{ id: 'cloud-hetzner', kind: 'cloud', label: 'Hetzner', status: 'healthy', metadata: {} },
{ id: 'region-eu', kind: 'region', label: 'eu-central', status: 'healthy', metadata: {} },
{ id: 'cluster-1', kind: 'cluster', label: 'omantel', status: 'healthy', metadata: {} },
{ id: 'node-w-0', kind: 'node', label: 'worker-1', status: 'healthy', metadata: {} },
]
const result = topologyLayout(nodes, [])
expect(result.nodes).toHaveLength(4)
const byId = new Map(result.nodes.map((n) => [n.id, n]))
expect(byId.get('cloud-hetzner')!.layer).toBe(0)
expect(byId.get('region-eu')!.layer).toBe(1)
expect(byId.get('cluster-1')!.layer).toBe(2)
expect(byId.get('node-w-0')!.layer).toBe(3)
})
it('lays nodes on the same layer left-aligned to the same X', () => {
const nodes: TopologyNode[] = [
{ id: 'node-a', kind: 'node', label: 'a', status: 'healthy', metadata: {} },
{ id: 'node-b', kind: 'node', label: 'b', status: 'healthy', metadata: {} },
{ id: 'node-c', kind: 'node', label: 'c', status: 'healthy', metadata: {} },
]
const result = topologyLayout(nodes, [])
expect(result.nodes).toHaveLength(3)
const xs = new Set(result.nodes.map((n) => n.x))
expect(xs.size).toBe(1) // all same X (same layer)
const ys = result.nodes.map((n) => n.y).sort((a, b) => a - b)
expect(ys[1] - ys[0]).toBeGreaterThan(0)
expect(ys[2] - ys[1]).toBeGreaterThan(0)
})
it('emits 4-point orthogonal poly-lines for edges between known nodes', () => {
const nodes: TopologyNode[] = [
{ id: 'cloud', kind: 'cloud', label: 'h', status: 'healthy', metadata: {} },
{ id: 'cluster', kind: 'cluster', label: 'c', status: 'healthy', metadata: {} },
]
const edges: TopologyEdge[] = [{ from: 'cloud', to: 'cluster', relation: 'contains' }]
const result = topologyLayout(nodes, edges)
expect(result.edges).toHaveLength(1)
const e = result.edges[0]!
expect(e.from).toBe('cloud')
expect(e.to).toBe('cluster')
expect(e.points).toHaveLength(4)
// First point exits the source's right edge, last point enters
// the destination's left edge, mid points share a vertical x.
expect(e.points[1]!.x).toBe(e.points[2]!.x)
})
it('drops edges that reference unknown node ids', () => {
const nodes: TopologyNode[] = [
{ id: 'a', kind: 'cluster', label: 'a', status: 'healthy', metadata: {} },
]
const edges: TopologyEdge[] = [
{ from: 'a', to: 'missing', relation: 'contains' },
{ from: 'ghost', to: 'a', relation: 'contains' },
]
const result = topologyLayout(nodes, edges)
expect(result.edges).toEqual([])
})
it('produces a deterministic layout for the same input', () => {
const nodes: TopologyNode[] = [
{ id: 'b-cluster', kind: 'cluster', label: 'b', status: 'healthy', metadata: {} },
{ id: 'a-cluster', kind: 'cluster', label: 'a', status: 'healthy', metadata: {} },
]
const r1 = topologyLayout(nodes, [])
const r2 = topologyLayout(nodes, [])
expect(r1).toEqual(r2)
// Sort-by-id within layer means a-cluster precedes b-cluster.
const ids = r1.nodes.map((n) => n.id)
expect(ids).toEqual(['a-cluster', 'b-cluster'])
})
it('honours custom layout options', () => {
const nodes: TopologyNode[] = [
{ id: 'n', kind: 'cluster', label: 'n', status: 'healthy', metadata: {} },
]
const result = topologyLayout(nodes, [], {
nodeWidth: 100,
nodeHeight: 40,
paddingX: 10,
paddingY: 10,
colWidth: 150,
rowHeight: 60,
})
expect(result.nodes[0]!.x).toBe(10 + 2 * 150) // layer 2 (cluster) * colWidth + paddingX
expect(result.nodes[0]!.y).toBe(10) // top of column + paddingY
expect(result.width).toBe(10 * 2 + 2 * 150 + 100) // padding*2 + (layers-1)*colW + nodeW = 420
})
})

View File

@ -0,0 +1,373 @@
/**
* infrastructure.types.ts wire types for the Sovereign Infrastructure
* surface (issue #227). The Topology canvas + Compute / Storage /
* Network tabs all consume these shapes.
*
* Per docs/INVIOLABLE-PRINCIPLES.md:
* #1 (waterfall, target shape) every type below is the FINAL shape.
* Backend returns a well-shaped empty response when the live
* cluster query isn't implemented yet; the UI handles empty
* gracefully (the canvas renders with a "Provisioning…" overlay
* rather than placeholder data).
* #4 (never hardcode) every URL is derived from API_BASE; every
* colour comes from the canonical status palette in the renderer.
*/
import { API_BASE } from '@/shared/config/urls'
/* ── Topology ──────────────────────────────────────────────────── */
/**
* NodeKind enumerates every shape that can appear on the topology
* canvas. Mirrored verbatim by the backend's Go enum.
*
* cloud provider account anchor (e.g. "Hetzner — eu-central")
* region cloud region grouping
* cluster k3s control-plane group
* node worker / control-plane VM
* lb load balancer
* pvc Persistent Volume Claim
* volume cloud block volume (Hetzner Cloud volume etc.)
* network VPC / subnet / DRG / peering edge anchor
*/
export type TopologyNodeKind =
| 'cloud'
| 'region'
| 'cluster'
| 'node'
| 'lb'
| 'pvc'
| 'volume'
| 'network'
export type TopologyStatus = 'healthy' | 'degraded' | 'failed' | 'unknown'
export interface TopologyNode {
id: string
kind: TopologyNodeKind
label: string
status: TopologyStatus
/** Free-form key/value strings shown in the detail panel. */
metadata: Record<string, string>
}
export type TopologyRelation = 'contains' | 'attached-to' | 'depends-on'
export interface TopologyEdge {
from: string
to: string
relation: TopologyRelation
}
export interface TopologyResponse {
nodes: TopologyNode[]
edges: TopologyEdge[]
}
/* ── Compute ───────────────────────────────────────────────────── */
export interface ClusterItem {
id: string
name: string
/** k3s / k8s / etc. */
controlPlane: string
version: string
region: string
/** Node count including control plane. */
nodeCount: number
status: TopologyStatus
}
export interface NodeItem {
id: string
name: string
/** Provider SKU string — "cx32", "cpx41", etc. */
sku: string
region: string
/** "control-plane" | "worker" — kept open-string for future roles. */
role: string
/** Public or VPC IP, whichever the cluster uses for kubectl. */
ip: string
status: TopologyStatus
}
export interface ComputeResponse {
clusters: ClusterItem[]
nodes: NodeItem[]
}
/* ── Storage ──────────────────────────────────────────────────── */
export interface PVCItem {
id: string
name: string
namespace: string
/** "10Gi" / "500Mi" — Kubernetes capacity string verbatim. */
capacity: string
/** Used capacity, same units. Empty when metrics-server isn't on. */
used: string
storageClass: string
status: TopologyStatus
}
export interface BucketItem {
id: string
name: string
/** SeaweedFS S3 endpoint or provider-specific bucket FQDN. */
endpoint: string
/** Allocated quota string (e.g. "100Gi"). */
capacity: string
/** Used capacity string. */
used: string
/** Retention policy in days, or empty for "indefinite". */
retentionDays: string
}
export interface VolumeItem {
id: string
name: string
/** Hetzner Cloud volume size in GB, e.g. "50Gi". */
capacity: string
region: string
/** Node id this volume is attached to, or empty when detached. */
attachedTo: string
status: TopologyStatus
}
export interface StorageResponse {
pvcs: PVCItem[]
buckets: BucketItem[]
volumes: VolumeItem[]
}
/* ── Network ──────────────────────────────────────────────────── */
export interface LoadBalancerItem {
id: string
name: string
/** Public IPv4 (or v6) the LB listens on. */
publicIP: string
/** Comma-separated listener ports — "80,443,6443". */
ports: string
/** "n/m healthy" or "—" when unknown. */
targetHealth: string
region: string
status: TopologyStatus
}
export interface DRGItem {
id: string
name: string
/** "10.0.0.0/16" etc. */
cidr: string
region: string
/** Comma-separated FQDN/id list of peered DRGs/VPCs. */
peers: string
status: TopologyStatus
}
export interface PeeringItem {
id: string
name: string
/** "vpc-a -> vpc-b" — direction is informational, peering is bidirectional. */
vpcPair: string
/** Comma-separated subnet CIDRs covered by the peering. */
subnets: string
status: TopologyStatus
}
export interface NetworkResponse {
loadBalancers: LoadBalancerItem[]
drgs: DRGItem[]
peerings: PeeringItem[]
}
/* ── Fetchers ─────────────────────────────────────────────────── */
/**
* Fetch the topology graph for a deployment. Throws on non-2xx so
* React Query surfaces the error via `query.isError`.
*/
export async function getTopology(deploymentId: string): Promise<TopologyResponse> {
const res = await fetch(
`${API_BASE}/v1/deployments/${encodeURIComponent(deploymentId)}/infrastructure/topology`,
{ headers: { Accept: 'application/json' } },
)
if (!res.ok) {
throw new Error(`topology fetch failed: ${res.status}`)
}
return (await res.json()) as TopologyResponse
}
export async function getCompute(deploymentId: string): Promise<ComputeResponse> {
const res = await fetch(
`${API_BASE}/v1/deployments/${encodeURIComponent(deploymentId)}/infrastructure/compute`,
{ headers: { Accept: 'application/json' } },
)
if (!res.ok) {
throw new Error(`compute fetch failed: ${res.status}`)
}
return (await res.json()) as ComputeResponse
}
export async function getStorage(deploymentId: string): Promise<StorageResponse> {
const res = await fetch(
`${API_BASE}/v1/deployments/${encodeURIComponent(deploymentId)}/infrastructure/storage`,
{ headers: { Accept: 'application/json' } },
)
if (!res.ok) {
throw new Error(`storage fetch failed: ${res.status}`)
}
return (await res.json()) as StorageResponse
}
export async function getNetwork(deploymentId: string): Promise<NetworkResponse> {
const res = await fetch(
`${API_BASE}/v1/deployments/${encodeURIComponent(deploymentId)}/infrastructure/network`,
{ headers: { Accept: 'application/json' } },
)
if (!res.ok) {
throw new Error(`network fetch failed: ${res.status}`)
}
return (await res.json()) as NetworkResponse
}
/* ── Topology layout ──────────────────────────────────────────── */
/**
* Compute a layered topology layout adapted for the heterogeneous node
* set (the dependency-graph layout in @/shared/lib/depsLayout assumes
* a homogeneous DAG of jobs; the topology canvas instead groups by
* NodeKind so cloud > region > cluster > node reads top-down).
*
* The layout is pure (same input = same output) so React's `useMemo`
* is the right caching primitive. Per INVIOLABLE-PRINCIPLES #2 we own
* a thin layout function rather than dragging in `reactflow` for a
* <50-node graph.
*/
export interface LaidOutNode {
id: string
x: number
y: number
layer: number
indexInLayer: number
}
export interface LaidOutEdge {
from: string
to: string
/** 4-point orthogonal poly-line. */
points: { x: number; y: number }[]
}
export interface LaidOutGraph {
nodes: LaidOutNode[]
edges: LaidOutEdge[]
width: number
height: number
}
export interface LayoutOptions {
colWidth?: number
rowHeight?: number
paddingX?: number
paddingY?: number
nodeWidth?: number
nodeHeight?: number
}
const KIND_TO_LAYER: Record<TopologyNodeKind, number> = {
cloud: 0,
region: 1,
cluster: 2,
node: 3,
lb: 3,
pvc: 4,
volume: 4,
network: 4,
}
const DEFAULTS = {
colWidth: 240,
rowHeight: 90,
paddingX: 32,
paddingY: 32,
nodeWidth: 200,
nodeHeight: 64,
}
/**
* Layered layout keyed off NodeKind. Layer assignment is deterministic
* so the canvas always reads cloud region cluster node | lb
* pvc | volume | network.
*/
export function topologyLayout(
nodes: readonly TopologyNode[],
edges: readonly TopologyEdge[],
opts: LayoutOptions = {},
): LaidOutGraph {
const o = { ...DEFAULTS, ...opts }
// Bucket nodes by layer.
const layerBuckets: string[][] = []
const nodeLayer = new Map<string, number>()
for (const n of nodes) {
const l = KIND_TO_LAYER[n.kind] ?? 0
nodeLayer.set(n.id, l)
while (layerBuckets.length <= l) layerBuckets.push([])
layerBuckets[l]!.push(n.id)
}
// Within each layer, sort by id so the layout is deterministic.
for (const b of layerBuckets) b.sort()
const nodeXY = new Map<string, { x: number; y: number }>()
const laidOut: LaidOutNode[] = []
let maxRow = 0
for (let l = 0; l < layerBuckets.length; l++) {
const col = layerBuckets[l]!
for (let i = 0; i < col.length; i++) {
const id = col[i]!
const x = o.paddingX + l * o.colWidth
const y = o.paddingY + i * o.rowHeight
nodeXY.set(id, { x, y })
laidOut.push({ id, x, y, layer: l, indexInLayer: i })
if (i > maxRow) maxRow = i
}
}
// Emit edges. We treat every edge as an orthogonal poly-line from
// src.right → midX → midX → dst.left. Edges between nodes at the
// same layer route through a vertical mid-band so they don't run
// through the node rectangles.
const laidOutEdges: LaidOutEdge[] = []
for (const e of edges) {
const src = nodeXY.get(e.from)
const dst = nodeXY.get(e.to)
if (!src || !dst) continue
const sx = src.x + o.nodeWidth
const sy = src.y + o.nodeHeight / 2
const dx = dst.x
const dy = dst.y + o.nodeHeight / 2
const midX = sx + (dx - sx) / 2
laidOutEdges.push({
from: e.from,
to: e.to,
points: [
{ x: sx, y: sy },
{ x: midX, y: sy },
{ x: midX, y: dy },
{ x: dx, y: dy },
],
})
}
const layerCount = layerBuckets.length
const width =
layerCount === 0
? o.paddingX * 2
: o.paddingX * 2 + (layerCount - 1) * o.colWidth + o.nodeWidth
const height = o.paddingY * 2 + maxRow * o.rowHeight + o.nodeHeight
return { nodes: laidOut, edges: laidOutEdges, width, height }
}

View File

@ -0,0 +1,104 @@
/**
* InfrastructureCompute.test.tsx render lock-in for the Compute tab.
*
* Coverage:
* 1. Empty state shows when the API returns no clusters / nodes.
* 2. Cluster + node sections render their counts + cards.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
Outlet,
} from '@tanstack/react-router'
import { InfrastructureCompute } from './InfrastructureCompute'
import type { ComputeResponse } from '@/lib/infrastructure.types'
function renderCompute(data: ComputeResponse | undefined) {
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const route = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/infrastructure/compute',
component: () => <InfrastructureCompute initialDataOverride={data} />,
})
const tree = rootRoute.addChildren([route])
const router = createRouter({
routeTree: tree,
history: createMemoryHistory({
initialEntries: ['/provision/d-1/infrastructure/compute'],
}),
})
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
afterEach(() => cleanup())
describe('InfrastructureCompute — empty', () => {
it('renders the empty state when there are no clusters or nodes', async () => {
renderCompute({ clusters: [], nodes: [] })
expect(await screen.findByTestId('infrastructure-compute-empty')).toBeTruthy()
})
})
describe('InfrastructureCompute — populated', () => {
const sample: ComputeResponse = {
clusters: [
{
id: 'c1',
name: 'omantel.omani.works',
controlPlane: 'k3s',
version: 'v1.30',
region: 'fsn1',
nodeCount: 3,
status: 'healthy',
},
],
nodes: [
{
id: 'n-cp',
name: 'control-plane',
sku: 'cpx21',
region: 'fsn1',
role: 'control-plane',
ip: '5.6.7.8',
status: 'healthy',
},
{
id: 'n-w-1',
name: 'worker-1',
sku: 'cpx41',
region: 'fsn1',
role: 'worker',
ip: '',
status: 'unknown',
},
],
}
it('renders cluster cards', async () => {
renderCompute(sample)
expect(await screen.findByTestId('infrastructure-cluster-card-c1')).toBeTruthy()
expect(screen.getByTestId('infrastructure-clusters-count').textContent).toBe('1')
})
it('renders node cards', async () => {
renderCompute(sample)
expect(await screen.findByTestId('infrastructure-node-card-n-cp')).toBeTruthy()
expect(screen.getByTestId('infrastructure-node-card-n-w-1')).toBeTruthy()
expect(screen.getByTestId('infrastructure-nodes-count').textContent).toBe('2')
})
})

View File

@ -0,0 +1,148 @@
/**
* InfrastructureCompute Compute tab of the Infrastructure surface.
* Two card sections: Clusters + Worker Nodes.
*
* Per founder spec: "compute (clusters and worker nodes)".
*
* Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall): the empty state is
* the canonical empty state never placeholder data. The cards render
* from real backend data the moment it arrives.
*/
import { useParams } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
getCompute,
type ClusterItem,
type ComputeResponse,
type NodeItem,
} from '@/lib/infrastructure.types'
const STALE_MS = 30_000
interface InfrastructureComputeProps {
/** Test seam — bypass the React Query fetcher with synthetic data. */
initialDataOverride?: ComputeResponse
}
export function InfrastructureCompute({
initialDataOverride,
}: InfrastructureComputeProps = {}) {
const params = useParams({
from: '/provision/$deploymentId/infrastructure/compute' as never,
}) as { deploymentId: string }
const deploymentId = params.deploymentId
const query = useQuery<ComputeResponse>({
queryKey: ['infra-compute', deploymentId],
queryFn: () => getCompute(deploymentId),
staleTime: STALE_MS,
enabled: !initialDataOverride,
})
const data = initialDataOverride ?? query.data
const isLoading = !initialDataOverride && query.isLoading && !data
const clusters = data?.clusters ?? []
const nodes = data?.nodes ?? []
const isEmpty = !isLoading && clusters.length === 0 && nodes.length === 0
return (
<div data-testid="infrastructure-compute">
{isLoading && (
<div
className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]"
data-testid="infrastructure-compute-loading"
>
Loading compute resources
</div>
)}
{isEmpty && !query.isError && (
<div className="infra-empty" data-testid="infrastructure-compute-empty">
<p className="title">No clusters or worker nodes yet.</p>
<p className="sub">
Once the Sovereign cluster comes up, every k3s cluster and node VM
will appear here.
</p>
</div>
)}
{!isEmpty && (
<>
<section className="infra-section" data-testid="infrastructure-clusters-section">
<h2>
Clusters <span className="count" data-testid="infrastructure-clusters-count">{clusters.length}</span>
</h2>
{clusters.length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">
No clusters reported.
</p>
) : (
<div className="infra-grid" data-testid="infrastructure-clusters-grid">
{clusters.map((c) => <ClusterCard key={c.id} cluster={c} />)}
</div>
)}
</section>
<section className="infra-section" data-testid="infrastructure-nodes-section">
<h2>
Worker Nodes <span className="count" data-testid="infrastructure-nodes-count">{nodes.length}</span>
</h2>
{nodes.length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">
No worker nodes reported.
</p>
) : (
<div className="infra-grid" data-testid="infrastructure-nodes-grid">
{nodes.map((n) => <NodeCard key={n.id} node={n} />)}
</div>
)}
</section>
</>
)}
</div>
)
}
function ClusterCard({ cluster }: { cluster: ClusterItem }) {
return (
<div
className="infra-card"
data-status={cluster.status}
data-testid={`infrastructure-cluster-card-${cluster.id}`}
>
<span className="infra-card-status" data-status={cluster.status}>
{cluster.status}
</span>
<div className="infra-card-head">
<span className="infra-card-name">{cluster.name}</span>
<span className="infra-card-kind">cluster</span>
</div>
<div className="infra-card-row"><span>Control plane</span><span className="v">{cluster.controlPlane}</span></div>
<div className="infra-card-row"><span>Version</span><span className="v">{cluster.version}</span></div>
<div className="infra-card-row"><span>Region</span><span className="v">{cluster.region}</span></div>
<div className="infra-card-row"><span>Nodes</span><span className="v">{cluster.nodeCount}</span></div>
</div>
)
}
function NodeCard({ node }: { node: NodeItem }) {
return (
<div
className="infra-card"
data-status={node.status}
data-testid={`infrastructure-node-card-${node.id}`}
>
<span className="infra-card-status" data-status={node.status}>
{node.status}
</span>
<div className="infra-card-head">
<span className="infra-card-name">{node.name}</span>
<span className="infra-card-kind">{node.role}</span>
</div>
<div className="infra-card-row"><span>SKU</span><span className="v">{node.sku}</span></div>
<div className="infra-card-row"><span>Region</span><span className="v">{node.region}</span></div>
<div className="infra-card-row"><span>IP</span><span className="v">{node.ip}</span></div>
</div>
)
}

View File

@ -0,0 +1,96 @@
/**
* InfrastructureNetwork.test.tsx render lock-in for the Network tab.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
Outlet,
} from '@tanstack/react-router'
import { InfrastructureNetwork } from './InfrastructureNetwork'
import type { NetworkResponse } from '@/lib/infrastructure.types'
function renderNetwork(data: NetworkResponse | undefined) {
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const route = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/infrastructure/network',
component: () => <InfrastructureNetwork initialDataOverride={data} />,
})
const tree = rootRoute.addChildren([route])
const router = createRouter({
routeTree: tree,
history: createMemoryHistory({
initialEntries: ['/provision/d-1/infrastructure/network'],
}),
})
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
afterEach(() => cleanup())
describe('InfrastructureNetwork — empty', () => {
it('renders the empty state when no LBs / DRGs / peerings exist', async () => {
renderNetwork({ loadBalancers: [], drgs: [], peerings: [] })
expect(await screen.findByTestId('infrastructure-network-empty')).toBeTruthy()
})
})
describe('InfrastructureNetwork — populated', () => {
const sample: NetworkResponse = {
loadBalancers: [
{
id: 'lb1',
name: 'ingress-lb',
publicIP: '203.0.113.10',
ports: '80,443,6443',
targetHealth: '3/3 healthy',
region: 'fsn1',
status: 'healthy',
},
],
drgs: [
{
id: 'drg1',
name: 'sovereign-vpc',
cidr: '10.0.0.0/16',
region: 'fsn1',
peers: 'metro-vpc',
status: 'healthy',
},
],
peerings: [
{
id: 'p1',
name: 'sovereign↔metro',
vpcPair: 'sovereign-vpc <-> metro-vpc',
subnets: '10.0.0.0/24,10.1.0.0/24',
status: 'healthy',
},
],
}
it('renders LB / DRG / peering cards', async () => {
renderNetwork(sample)
expect(await screen.findByTestId('infrastructure-lb-card-lb1')).toBeTruthy()
expect(screen.getByTestId('infrastructure-drg-card-drg1')).toBeTruthy()
expect(screen.getByTestId('infrastructure-peering-card-p1')).toBeTruthy()
expect(screen.getByTestId('infrastructure-lbs-count').textContent).toBe('1')
expect(screen.getByTestId('infrastructure-drgs-count').textContent).toBe('1')
expect(screen.getByTestId('infrastructure-peerings-count').textContent).toBe('1')
})
})

View File

@ -0,0 +1,182 @@
/**
* InfrastructureNetwork Network tab of the Infrastructure surface.
* Three card sections: Load Balancers + DRGs / VPC Gateways + Peerings.
*
* Per founder spec: "network (lbs, drgs, peerings etc)".
*/
import { useParams } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
getNetwork,
type DRGItem,
type LoadBalancerItem,
type NetworkResponse,
type PeeringItem,
} from '@/lib/infrastructure.types'
const STALE_MS = 30_000
interface InfrastructureNetworkProps {
initialDataOverride?: NetworkResponse
}
export function InfrastructureNetwork({
initialDataOverride,
}: InfrastructureNetworkProps = {}) {
const params = useParams({
from: '/provision/$deploymentId/infrastructure/network' as never,
}) as { deploymentId: string }
const deploymentId = params.deploymentId
const query = useQuery<NetworkResponse>({
queryKey: ['infra-network', deploymentId],
queryFn: () => getNetwork(deploymentId),
staleTime: STALE_MS,
enabled: !initialDataOverride,
})
const data = initialDataOverride ?? query.data
const isLoading = !initialDataOverride && query.isLoading && !data
const lbs = data?.loadBalancers ?? []
const drgs = data?.drgs ?? []
const peerings = data?.peerings ?? []
const isEmpty =
!isLoading && lbs.length === 0 && drgs.length === 0 && peerings.length === 0
return (
<div data-testid="infrastructure-network">
{isLoading && (
<div
className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]"
data-testid="infrastructure-network-loading"
>
Loading network resources
</div>
)}
{isEmpty && !query.isError && (
<div className="infra-empty" data-testid="infrastructure-network-empty">
<p className="title">No network resources yet.</p>
<p className="sub">
Load balancers, DRGs and peerings will appear here as the
Sovereign cluster registers them.
</p>
</div>
)}
{!isEmpty && (
<>
<section className="infra-section" data-testid="infrastructure-lbs-section">
<h2>
Load Balancers{' '}
<span className="count" data-testid="infrastructure-lbs-count">{lbs.length}</span>
</h2>
{lbs.length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">
No load balancers reported.
</p>
) : (
<div className="infra-grid" data-testid="infrastructure-lbs-grid">
{lbs.map((l) => <LBCard key={l.id} lb={l} />)}
</div>
)}
</section>
<section className="infra-section" data-testid="infrastructure-drgs-section">
<h2>
DRGs / VPC Gateways{' '}
<span className="count" data-testid="infrastructure-drgs-count">{drgs.length}</span>
</h2>
{drgs.length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">No DRGs reported.</p>
) : (
<div className="infra-grid" data-testid="infrastructure-drgs-grid">
{drgs.map((d) => <DRGCard key={d.id} drg={d} />)}
</div>
)}
</section>
<section className="infra-section" data-testid="infrastructure-peerings-section">
<h2>
Peerings{' '}
<span className="count" data-testid="infrastructure-peerings-count">{peerings.length}</span>
</h2>
{peerings.length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">
No peerings reported.
</p>
) : (
<div className="infra-grid" data-testid="infrastructure-peerings-grid">
{peerings.map((p) => <PeeringCard key={p.id} peering={p} />)}
</div>
)}
</section>
</>
)}
</div>
)
}
function LBCard({ lb }: { lb: LoadBalancerItem }) {
return (
<div
className="infra-card"
data-status={lb.status}
data-testid={`infrastructure-lb-card-${lb.id}`}
>
<span className="infra-card-status" data-status={lb.status}>
{lb.status}
</span>
<div className="infra-card-head">
<span className="infra-card-name">{lb.name}</span>
<span className="infra-card-kind">load balancer</span>
</div>
<div className="infra-card-row"><span>Public IP</span><span className="v">{lb.publicIP}</span></div>
<div className="infra-card-row"><span>Ports</span><span className="v">{lb.ports}</span></div>
<div className="infra-card-row"><span>Targets</span><span className="v">{lb.targetHealth}</span></div>
<div className="infra-card-row"><span>Region</span><span className="v">{lb.region}</span></div>
</div>
)
}
function DRGCard({ drg }: { drg: DRGItem }) {
return (
<div
className="infra-card"
data-status={drg.status}
data-testid={`infrastructure-drg-card-${drg.id}`}
>
<span className="infra-card-status" data-status={drg.status}>
{drg.status}
</span>
<div className="infra-card-head">
<span className="infra-card-name">{drg.name}</span>
<span className="infra-card-kind">drg</span>
</div>
<div className="infra-card-row"><span>CIDR</span><span className="v">{drg.cidr}</span></div>
<div className="infra-card-row"><span>Region</span><span className="v">{drg.region}</span></div>
<div className="infra-card-row"><span>Peers</span><span className="v">{drg.peers || '—'}</span></div>
</div>
)
}
function PeeringCard({ peering }: { peering: PeeringItem }) {
return (
<div
className="infra-card"
data-status={peering.status}
data-testid={`infrastructure-peering-card-${peering.id}`}
>
<span className="infra-card-status" data-status={peering.status}>
{peering.status}
</span>
<div className="infra-card-head">
<span className="infra-card-name">{peering.name}</span>
<span className="infra-card-kind">peering</span>
</div>
<div className="infra-card-row"><span>VPCs</span><span className="v">{peering.vpcPair}</span></div>
<div className="infra-card-row"><span>Subnets</span><span className="v">{peering.subnets}</span></div>
</div>
)
}

View File

@ -0,0 +1,157 @@
/**
* InfrastructurePage.test.tsx shell + tab wiring lock-in for the
* Sovereign Infrastructure surface (issue #227).
*
* Coverage:
* 1. Header renders with the canonical title.
* 2. Tabs render in the canonical Topology / Compute / Storage /
* Network order.
* 3. The active tab follows the URL path suffix.
* 4. PortalShell wires (sidebar present).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { render, screen, cleanup, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
Outlet,
} from '@tanstack/react-router'
import { InfrastructurePage, resolveActiveTab, INFRA_TABS } from './InfrastructurePage'
import { useWizardStore } from '@/entities/deployment/store'
import { INITIAL_WIZARD_STATE } from '@/entities/deployment/model'
function renderShell(deploymentId: string, suffix: string) {
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const infraRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/infrastructure',
component: () => (
<InfrastructurePage
disableStream
contentOverride={<div data-testid="infra-content-stub">{suffix}</div>}
/>
),
})
// Register every legal sub-route as a no-op child so TanStack
// Router resolves /infrastructure/{topology,...} to the parent
// shell + an empty child, mirroring production routing without
// mounting the heavyweight tab components in the shell test.
const subRoute = (s: string) =>
createRoute({
getParentRoute: () => infraRoute,
path: `/${s}`,
component: () => <div data-testid={`infra-sub-${s}`}>{s}</div>,
})
const tree = rootRoute.addChildren([
infraRoute.addChildren([
subRoute('topology'),
subRoute('compute'),
subRoute('storage'),
subRoute('network'),
]),
])
const router = createRouter({
routeTree: tree,
history: createMemoryHistory({
initialEntries: [`/provision/${deploymentId}/infrastructure/${suffix}`],
}),
})
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
})
afterEach(() => cleanup())
describe('InfrastructurePage — shell', () => {
it('renders the Infrastructure title', async () => {
renderShell('d-1', 'topology')
expect(await screen.findByTestId('infrastructure-title')).toBeTruthy()
})
it('mounts inside the PortalShell (sidebar present)', async () => {
renderShell('d-1', 'topology')
expect(await screen.findByTestId('sov-portal-shell')).toBeTruthy()
expect(screen.getByTestId('admin-sidebar')).toBeTruthy()
})
it('renders the Outlet content', async () => {
renderShell('d-1', 'topology')
expect(await screen.findByTestId('infra-content-stub')).toBeTruthy()
})
})
describe('InfrastructurePage — tabs', () => {
it('renders Topology / Compute / Storage / Network in canonical order', async () => {
renderShell('d-1', 'topology')
const tablist = await screen.findByTestId('infrastructure-tabs')
const tabs = within(tablist).getAllByRole('tab')
expect(tabs).toHaveLength(4)
expect(tabs.map((t) => t.textContent?.trim())).toEqual([
'Topology',
'Compute',
'Storage',
'Network',
])
})
it('marks the topology tab active when URL ends in /topology', async () => {
renderShell('d-1', 'topology')
const topologyTab = await screen.findByTestId('infra-tab-topology')
expect(topologyTab.getAttribute('aria-selected')).toBe('true')
expect(screen.getByTestId('infra-tab-compute').getAttribute('aria-selected')).toBe('false')
})
it('marks the compute tab active when URL ends in /compute', async () => {
renderShell('d-1', 'compute')
const computeTab = await screen.findByTestId('infra-tab-compute')
expect(computeTab.getAttribute('aria-selected')).toBe('true')
})
it('marks the storage tab active when URL ends in /storage', async () => {
renderShell('d-1', 'storage')
const tab = await screen.findByTestId('infra-tab-storage')
expect(tab.getAttribute('aria-selected')).toBe('true')
})
it('marks the network tab active when URL ends in /network', async () => {
renderShell('d-1', 'network')
const tab = await screen.findByTestId('infra-tab-network')
expect(tab.getAttribute('aria-selected')).toBe('true')
})
})
describe('resolveActiveTab — helper', () => {
it('returns topology by default for unknown paths', () => {
expect(resolveActiveTab('/sovereign/provision/x/infrastructure')).toBe('topology')
expect(resolveActiveTab('/anything-else')).toBe('topology')
})
it('returns the matching tab for each suffix', () => {
for (const t of INFRA_TABS) {
expect(
resolveActiveTab(`/sovereign/provision/x/infrastructure/${t.suffix}`),
).toBe(t.key)
}
})
})

View File

@ -0,0 +1,296 @@
/**
* InfrastructurePage Sovereign-portal Infrastructure surface served at
* /sovereign/provision/$deploymentId/infrastructure/{topology,compute,storage,network}
*
* Founder spec (verbatim):
* "The infrastructure page must be opened by default with the
* topology page, the same topology that we showed during the wizard.
* We should have the tabs of compute (clusters and worker nodes),
* storage (pvcs, buckets etc) and network (lbs, drgs, peerings etc)."
*
* Layout contract:
* Bare /infrastructure redirects to /infrastructure/topology the
* redirect is enforced by the router (see app/router.tsx); this
* component never renders standalone for the bare URL.
* The shell renders a header (title + tagline) and a `<nav role=tablist>`
* with four tabs in the canonical AppsPage style (.tabs/.tab/.tab-count)
* so the visual rhythm matches the rest of the Sovereign portal.
* Active tab is derived from the current pathname clicking a tab
* navigates via TanStack Router's <Link>; back/forward keeps the
* active tab in sync.
*
* Per docs/INVIOLABLE-PRINCIPLES.md:
* #1 (waterfall) all four tabs ship at once, not "topology now,
* compute later".
* #2 (no compromise) tabs are TABS (role=tablist + role=tab),
* never accordions.
* #4 (never hardcode) every label / route is derived from the TABS
* table; no inline "/infrastructure/foo" string outside this table.
*/
import type { ReactNode } from 'react'
import { Link, Outlet, useParams, useRouterState } from '@tanstack/react-router'
import { PortalShell } from './PortalShell'
import { useDeploymentEvents } from './useDeploymentEvents'
/* ── Tab table — single source of truth ────────────────────────── */
export type InfraTabKey = 'topology' | 'compute' | 'storage' | 'network'
interface InfraTab {
key: InfraTabKey
label: string
/** Pathname suffix appended to /provision/$deploymentId/infrastructure. */
suffix: 'topology' | 'compute' | 'storage' | 'network'
}
export const INFRA_TABS: readonly InfraTab[] = [
{ key: 'topology', label: 'Topology', suffix: 'topology' },
{ key: 'compute', label: 'Compute', suffix: 'compute' },
{ key: 'storage', label: 'Storage', suffix: 'storage' },
{ key: 'network', label: 'Network', suffix: 'network' },
] as const
/** Resolve the active tab from the current pathname. Defaults to
* topology when no suffix matches (covers the redirect-in-flight
* paint and any lossy URL the user pastes). */
export function resolveActiveTab(pathname: string): InfraTabKey {
for (const t of INFRA_TABS) {
if (pathname.endsWith(`/infrastructure/${t.suffix}`)) return t.key
}
return 'topology'
}
/* ── Page shell ────────────────────────────────────────────────── */
export interface InfrastructurePageProps {
/** Test seam — disables the live SSE EventSource attach. */
disableStream?: boolean
/**
* Test seam render a content slot directly instead of using
* <Outlet />. Allows AppsPage-style component tests to mount the
* shell without requiring a full TanStack-Router child tree.
*/
contentOverride?: ReactNode
}
export function InfrastructurePage({
disableStream = false,
contentOverride,
}: InfrastructurePageProps = {}) {
const params = useParams({
from: '/provision/$deploymentId/infrastructure' as never,
}) as { deploymentId: string }
const deploymentId = params.deploymentId
const pathname = useRouterState({ select: (s) => s.location.pathname })
const activeTab = resolveActiveTab(pathname)
const { snapshot } = useDeploymentEvents({
deploymentId,
applicationIds: [],
disableStream,
})
const sovereignFQDN = snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
return (
<PortalShell deploymentId={deploymentId} sovereignFQDN={sovereignFQDN}>
<style>{INFRA_PAGE_CSS}</style>
<div data-testid="infrastructure-page" className="mx-auto max-w-7xl">
<header className="mb-3 flex items-start justify-between gap-4">
<div>
<h1
className="text-2xl font-bold text-[var(--color-text-strong)]"
data-testid="infrastructure-title"
>
Infrastructure
</h1>
<p className="mt-1 text-sm text-[var(--color-text-dim)]">
Sovereign topology, compute, storage and network pulled live from
the deployment&rsquo;s cluster.
</p>
</div>
<div className="text-right text-xs text-[var(--color-text-dim)]">
<div>{sovereignFQDN || `deployment ${deploymentId.slice(0, 8)}`}</div>
<div className="font-mono">{deploymentId.slice(0, 8)}</div>
</div>
</header>
<nav
className="tabs"
role="tablist"
aria-label="Infrastructure sections"
data-testid="infrastructure-tabs"
>
{INFRA_TABS.map((tab) => {
const isActive = tab.key === activeTab
return (
<Link
key={tab.key}
to={`/provision/$deploymentId/infrastructure/${tab.suffix}` as never}
params={{ deploymentId } as never}
role="tab"
aria-selected={isActive}
aria-current={isActive ? 'page' : undefined}
className={`tab${isActive ? ' active' : ''}`}
data-testid={`infra-tab-${tab.key}`}
>
{tab.label}
</Link>
)
})}
</nav>
<div className="mt-4" data-testid="infrastructure-content">
{contentOverride ?? <Outlet />}
</div>
</div>
</PortalShell>
)
}
/**
* Pixel-aligned tab CSS same selectors and values AppsPage exports
* for its tab strip. Duplicated here so InfrastructurePage doesn't
* depend on AppsPage's `<style>` block being mounted (every page in
* the Sovereign portal owns its own scoped CSS payload).
*/
const INFRA_PAGE_CSS = `
.tabs {
display: flex;
gap: 0.25rem;
margin: 1rem 0 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.tab {
background: transparent;
border: none;
padding: 0.6rem 0.9rem;
color: var(--color-text-dim);
font: inherit;
font-size: 0.88rem;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
display: inline-flex;
align-items: center;
gap: 0.4rem;
text-decoration: none;
}
.tab:hover { color: var(--color-text); }
.tab.active {
color: var(--color-text-strong);
border-bottom-color: var(--color-accent);
font-weight: 600;
}
.infra-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 0.75rem;
}
.infra-section {
margin-top: 1.25rem;
}
.infra-section h2 {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-strong);
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.infra-section h2 .count {
font-size: 0.7rem;
font-weight: 600;
padding: 0.08rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 60%, transparent);
color: var(--color-text-dim);
}
.infra-card {
background: var(--color-surface);
border: 1.5px solid var(--color-border);
border-radius: 12px;
padding: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
position: relative;
overflow: hidden;
}
.infra-card[data-status="healthy"] { border-color: color-mix(in srgb, var(--color-success) 45%, var(--color-border)); }
.infra-card[data-status="degraded"] { border-color: color-mix(in srgb, var(--color-warn) 55%, var(--color-border)); }
.infra-card[data-status="failed"] { border-color: color-mix(in srgb, var(--color-danger) 55%, var(--color-border)); }
.infra-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.infra-card-name {
font-size: 0.92rem;
font-weight: 600;
color: var(--color-text-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.infra-card-kind {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-dim);
background: color-mix(in srgb, var(--color-border) 50%, transparent);
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
.infra-card-row {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
color: var(--color-text-dim);
}
.infra-card-row .v {
color: var(--color-text);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
}
.infra-card-status {
position: absolute;
top: 0.55rem;
right: 0.6rem;
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.12rem 0.45rem;
border-radius: 999px;
}
.infra-card-status[data-status="healthy"] { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: var(--color-success); }
.infra-card-status[data-status="degraded"] { background: color-mix(in srgb, var(--color-warn) 16%, transparent); color: var(--color-warn); }
.infra-card-status[data-status="failed"] { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: var(--color-danger); }
.infra-card-status[data-status="unknown"] { background: color-mix(in srgb, var(--color-text-dim) 16%, transparent); color: var(--color-text-dim); }
.infra-empty {
margin-top: 2rem;
text-align: center;
color: var(--color-text-dim);
padding: 2rem 1rem;
border: 1px dashed var(--color-border);
border-radius: 12px;
background: var(--color-bg-2);
}
.infra-empty .title {
font-size: 0.95rem;
color: var(--color-text-strong);
font-weight: 600;
margin: 0 0 0.3rem;
}
.infra-empty .sub {
font-size: 0.82rem;
margin: 0;
}
`

View File

@ -0,0 +1,97 @@
/**
* InfrastructureStorage.test.tsx render lock-in for the Storage tab.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
Outlet,
} from '@tanstack/react-router'
import { InfrastructureStorage } from './InfrastructureStorage'
import type { StorageResponse } from '@/lib/infrastructure.types'
function renderStorage(data: StorageResponse | undefined) {
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const route = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/infrastructure/storage',
component: () => <InfrastructureStorage initialDataOverride={data} />,
})
const tree = rootRoute.addChildren([route])
const router = createRouter({
routeTree: tree,
history: createMemoryHistory({
initialEntries: ['/provision/d-1/infrastructure/storage'],
}),
})
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
afterEach(() => cleanup())
describe('InfrastructureStorage — empty', () => {
it('renders the empty state when no PVCs / buckets / volumes exist', async () => {
renderStorage({ pvcs: [], buckets: [], volumes: [] })
expect(await screen.findByTestId('infrastructure-storage-empty')).toBeTruthy()
})
})
describe('InfrastructureStorage — populated', () => {
const sample: StorageResponse = {
pvcs: [
{
id: 'p1',
name: 'cnpg-pgdata',
namespace: 'cnpg-system',
capacity: '20Gi',
used: '4Gi',
storageClass: 'local-path',
status: 'healthy',
},
],
buckets: [
{
id: 'b1',
name: 'observability',
endpoint: 's3.openova.io',
capacity: '100Gi',
used: '12Gi',
retentionDays: '30',
},
],
volumes: [
{
id: 'v1',
name: 'pgdata-vol',
capacity: '50Gi',
region: 'fsn1',
attachedTo: 'node-cp-fsn1',
status: 'healthy',
},
],
}
it('renders PVC, bucket and volume cards', async () => {
renderStorage(sample)
expect(await screen.findByTestId('infrastructure-pvc-card-p1')).toBeTruthy()
expect(screen.getByTestId('infrastructure-bucket-card-b1')).toBeTruthy()
expect(screen.getByTestId('infrastructure-volume-card-v1')).toBeTruthy()
expect(screen.getByTestId('infrastructure-pvcs-count').textContent).toBe('1')
expect(screen.getByTestId('infrastructure-buckets-count').textContent).toBe('1')
expect(screen.getByTestId('infrastructure-volumes-count').textContent).toBe('1')
})
})

View File

@ -0,0 +1,180 @@
/**
* InfrastructureStorage Storage tab of the Infrastructure surface.
* Three card sections: Persistent Volume Claims + Object Buckets +
* Block Volumes.
*
* Per founder spec: "storage (pvcs, buckets etc)".
*/
import { useParams } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
getStorage,
type BucketItem,
type PVCItem,
type StorageResponse,
type VolumeItem,
} from '@/lib/infrastructure.types'
const STALE_MS = 30_000
interface InfrastructureStorageProps {
initialDataOverride?: StorageResponse
}
export function InfrastructureStorage({
initialDataOverride,
}: InfrastructureStorageProps = {}) {
const params = useParams({
from: '/provision/$deploymentId/infrastructure/storage' as never,
}) as { deploymentId: string }
const deploymentId = params.deploymentId
const query = useQuery<StorageResponse>({
queryKey: ['infra-storage', deploymentId],
queryFn: () => getStorage(deploymentId),
staleTime: STALE_MS,
enabled: !initialDataOverride,
})
const data = initialDataOverride ?? query.data
const isLoading = !initialDataOverride && query.isLoading && !data
const pvcs = data?.pvcs ?? []
const buckets = data?.buckets ?? []
const volumes = data?.volumes ?? []
const isEmpty =
!isLoading && pvcs.length === 0 && buckets.length === 0 && volumes.length === 0
return (
<div data-testid="infrastructure-storage">
{isLoading && (
<div
className="flex h-48 items-center justify-center text-sm text-[var(--color-text-dim)]"
data-testid="infrastructure-storage-loading"
>
Loading storage resources
</div>
)}
{isEmpty && !query.isError && (
<div className="infra-empty" data-testid="infrastructure-storage-empty">
<p className="title">No storage resources yet.</p>
<p className="sub">
PVCs, S3 buckets and block volumes will appear here once the
Sovereign cluster reports them.
</p>
</div>
)}
{!isEmpty && (
<>
<section className="infra-section" data-testid="infrastructure-pvcs-section">
<h2>
Persistent Volume Claims{' '}
<span className="count" data-testid="infrastructure-pvcs-count">{pvcs.length}</span>
</h2>
{pvcs.length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">No PVCs reported.</p>
) : (
<div className="infra-grid" data-testid="infrastructure-pvcs-grid">
{pvcs.map((p) => <PVCCard key={p.id} pvc={p} />)}
</div>
)}
</section>
<section className="infra-section" data-testid="infrastructure-buckets-section">
<h2>
Object Buckets{' '}
<span className="count" data-testid="infrastructure-buckets-count">{buckets.length}</span>
</h2>
{buckets.length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">No buckets reported.</p>
) : (
<div className="infra-grid" data-testid="infrastructure-buckets-grid">
{buckets.map((b) => <BucketCard key={b.id} bucket={b} />)}
</div>
)}
</section>
<section className="infra-section" data-testid="infrastructure-volumes-section">
<h2>
Block Volumes{' '}
<span className="count" data-testid="infrastructure-volumes-count">{volumes.length}</span>
</h2>
{volumes.length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">
No block volumes reported.
</p>
) : (
<div className="infra-grid" data-testid="infrastructure-volumes-grid">
{volumes.map((v) => <VolumeCard key={v.id} volume={v} />)}
</div>
)}
</section>
</>
)}
</div>
)
}
function PVCCard({ pvc }: { pvc: PVCItem }) {
return (
<div
className="infra-card"
data-status={pvc.status}
data-testid={`infrastructure-pvc-card-${pvc.id}`}
>
<span className="infra-card-status" data-status={pvc.status}>
{pvc.status}
</span>
<div className="infra-card-head">
<span className="infra-card-name">{pvc.name}</span>
<span className="infra-card-kind">pvc</span>
</div>
<div className="infra-card-row"><span>Namespace</span><span className="v">{pvc.namespace}</span></div>
<div className="infra-card-row"><span>Capacity</span><span className="v">{pvc.capacity}</span></div>
<div className="infra-card-row"><span>Used</span><span className="v">{pvc.used || '—'}</span></div>
<div className="infra-card-row"><span>Class</span><span className="v">{pvc.storageClass}</span></div>
</div>
)
}
function BucketCard({ bucket }: { bucket: BucketItem }) {
return (
<div
className="infra-card"
data-status="healthy"
data-testid={`infrastructure-bucket-card-${bucket.id}`}
>
<div className="infra-card-head">
<span className="infra-card-name">{bucket.name}</span>
<span className="infra-card-kind">bucket</span>
</div>
<div className="infra-card-row"><span>Endpoint</span><span className="v">{bucket.endpoint}</span></div>
<div className="infra-card-row"><span>Capacity</span><span className="v">{bucket.capacity}</span></div>
<div className="infra-card-row"><span>Used</span><span className="v">{bucket.used || '—'}</span></div>
<div className="infra-card-row"><span>Retention</span><span className="v">{bucket.retentionDays || 'indefinite'}</span></div>
</div>
)
}
function VolumeCard({ volume }: { volume: VolumeItem }) {
return (
<div
className="infra-card"
data-status={volume.status}
data-testid={`infrastructure-volume-card-${volume.id}`}
>
<span className="infra-card-status" data-status={volume.status}>
{volume.status}
</span>
<div className="infra-card-head">
<span className="infra-card-name">{volume.name}</span>
<span className="infra-card-kind">volume</span>
</div>
<div className="infra-card-row"><span>Capacity</span><span className="v">{volume.capacity}</span></div>
<div className="infra-card-row"><span>Region</span><span className="v">{volume.region}</span></div>
<div className="infra-card-row"><span>Attached to</span><span className="v">{volume.attachedTo || 'detached'}</span></div>
</div>
)
}

View File

@ -0,0 +1,123 @@
/**
* InfrastructureTopology.test.tsx render lock-in for the Topology
* canvas (default Infrastructure tab, issue #227).
*
* Coverage:
* 1. Empty state shows when the API returns no nodes.
* 2. With a small synthetic graph, the SVG canvas mounts and node
* groups render with the expected data-testid pattern.
* 3. Clicking a node opens the right-rail detail panel.
* 4. Closing the panel removes it from the DOM.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
Outlet,
} from '@tanstack/react-router'
import { InfrastructureTopology } from './InfrastructureTopology'
import type { TopologyResponse } from '@/lib/infrastructure.types'
function renderTopology(data: TopologyResponse | undefined) {
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const route = createRoute({
getParentRoute: () => rootRoute,
path: '/provision/$deploymentId/infrastructure/topology',
component: () => <InfrastructureTopology initialDataOverride={data} />,
})
const tree = rootRoute.addChildren([route])
const router = createRouter({
routeTree: tree,
history: createMemoryHistory({
initialEntries: ['/provision/d-1/infrastructure/topology'],
}),
})
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
})
return render(
<QueryClientProvider client={qc}>
<RouterProvider router={router} />
</QueryClientProvider>,
)
}
afterEach(() => cleanup())
describe('InfrastructureTopology — empty state', () => {
it('renders the Provisioning… overlay when no nodes are returned', async () => {
renderTopology({ nodes: [], edges: [] })
expect(await screen.findByTestId('infrastructure-topology-empty')).toBeTruthy()
})
})
describe('InfrastructureTopology — populated', () => {
const sample: TopologyResponse = {
nodes: [
{
id: 'cloud',
kind: 'cloud',
label: 'Hetzner',
status: 'healthy',
metadata: { provider: 'hetzner' },
},
{
id: 'cluster',
kind: 'cluster',
label: 'omantel.omani.works',
status: 'healthy',
metadata: { fqdn: 'omantel.omani.works' },
},
{
id: 'node-1',
kind: 'node',
label: 'worker-1',
status: 'degraded',
metadata: { role: 'worker' },
},
],
edges: [
{ from: 'cloud', to: 'cluster', relation: 'contains' },
{ from: 'cluster', to: 'node-1', relation: 'contains' },
],
}
it('renders the SVG canvas with node groups', async () => {
renderTopology(sample)
expect(await screen.findByTestId('infrastructure-topology-svg')).toBeTruthy()
expect(screen.getByTestId('infra-node-cloud')).toBeTruthy()
expect(screen.getByTestId('infra-node-cluster')).toBeTruthy()
expect(screen.getByTestId('infra-node-node-1')).toBeTruthy()
})
it('renders edges between known nodes', async () => {
renderTopology(sample)
await screen.findByTestId('infrastructure-topology-svg')
expect(screen.getByTestId('infra-edge-cloud-cluster')).toBeTruthy()
expect(screen.getByTestId('infra-edge-cluster-node-1')).toBeTruthy()
})
it('opens the detail panel on node click and closes it on dismiss', async () => {
renderTopology(sample)
const node = await screen.findByTestId('infra-node-cluster')
expect(screen.queryByTestId('infrastructure-topology-detail')).toBeNull()
fireEvent.click(node)
expect(screen.getByTestId('infrastructure-topology-detail')).toBeTruthy()
expect(screen.getByTestId('infrastructure-topology-detail-name').textContent).toBe('omantel.omani.works')
fireEvent.click(screen.getByTestId('infrastructure-topology-detail-close'))
expect(screen.queryByTestId('infrastructure-topology-detail')).toBeNull()
})
it('paints node status into a data-status attribute', async () => {
renderTopology(sample)
const degraded = await screen.findByTestId('infra-node-node-1')
expect(degraded.getAttribute('data-status')).toBe('degraded')
})
})

View File

@ -0,0 +1,323 @@
/**
* InfrastructureTopology SVG canvas for the Sovereign Infrastructure
* Topology tab (the DEFAULT tab opens by default per founder spec).
*
* This is the same family of layered-DAG canvas that
* widgets/job-deps-graph/JobDependenciesGraph uses a deterministic
* layered layout, no force-directed simulation, no `reactflow`. The
* topology layer keys (cloud region cluster node | lb pvc |
* volume | network) come from the infrastructure.types.ts layout
* function.
*
* Click a node a detail panel slides in from the right WITHOUT
* navigation (still the Topology tab). Closing the panel returns to
* the bare canvas.
*
* Per docs/INVIOLABLE-PRINCIPLES.md:
* #2 (no compromise) empty state shows the canvas frame with a
* "Provisioning…" overlay rather than a stub fallback. Real cluster
* data flows in once the backend's live-cluster integration lands.
* #4 (never hardcode) every status colour comes from the
* `--color-*` CSS variables the rest of the portal uses.
*/
import { useMemo, useState } from 'react'
import { useParams } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
getTopology,
topologyLayout,
type TopologyNode,
type TopologyResponse,
type TopologyStatus,
} from '@/lib/infrastructure.types'
const STATUS_FILL: Record<TopologyStatus, string> = {
healthy: 'var(--color-success)',
degraded: 'var(--color-warn)',
failed: 'var(--color-danger)',
unknown: 'var(--color-text-dim)',
}
const STATUS_RING: Record<TopologyStatus, string> = {
healthy: 'var(--color-success)',
degraded: 'var(--color-warn)',
failed: 'var(--color-danger)',
unknown: 'var(--color-border-strong)',
}
const NODE_WIDTH = 200
const NODE_HEIGHT = 64
const STALE_MS = 30_000
interface InfrastructureTopologyProps {
/** Test seam — bypass the React Query fetcher with synthetic data. */
initialDataOverride?: TopologyResponse
}
export function InfrastructureTopology({
initialDataOverride,
}: InfrastructureTopologyProps = {}) {
const params = useParams({
from: '/provision/$deploymentId/infrastructure/topology' as never,
}) as { deploymentId: string }
const deploymentId = params.deploymentId
const query = useQuery<TopologyResponse>({
queryKey: ['infra-topology', deploymentId],
queryFn: () => getTopology(deploymentId),
staleTime: STALE_MS,
enabled: !initialDataOverride,
})
const data = initialDataOverride ?? query.data
const layout = useMemo(() => {
if (!data) return null
return topologyLayout(data.nodes, data.edges, {
nodeWidth: NODE_WIDTH,
nodeHeight: NODE_HEIGHT,
})
}, [data])
const nodesById = useMemo(() => {
const m = new Map<string, TopologyNode>()
if (data) for (const n of data.nodes) m.set(n.id, n)
return m
}, [data])
const [selectedId, setSelectedId] = useState<string | null>(null)
const selectedNode = selectedId ? nodesById.get(selectedId) ?? null : null
const isLoading = !initialDataOverride && query.isLoading && !data
const hasNodes = !!data && data.nodes.length > 0
return (
<div data-testid="infrastructure-topology" className="relative">
<div
className="relative w-full overflow-auto rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-2)]"
data-testid="infrastructure-topology-canvas"
style={{ minHeight: 480 }}
>
{isLoading && (
<div
className="flex h-[480px] items-center justify-center text-sm text-[var(--color-text-dim)]"
data-testid="infrastructure-topology-loading"
>
Loading topology
</div>
)}
{query.isError && !data && (
<div
className="flex h-[480px] flex-col items-center justify-center gap-2 px-6 text-center text-sm"
data-testid="infrastructure-topology-error"
>
<p className="font-medium text-[var(--color-danger)]">
Couldn&rsquo;t load topology
</p>
<p className="text-[var(--color-text-dim)]">
The Catalyst API is temporarily unreachable. Retry will start
automatically.
</p>
</div>
)}
{!hasNodes && !isLoading && !query.isError && (
<div
className="flex h-[480px] flex-col items-center justify-center gap-2 px-6 text-center text-sm"
data-testid="infrastructure-topology-empty"
>
<div className="h-3 w-3 animate-spin rounded-full border-2 border-[var(--color-accent)] border-t-transparent" />
<p className="font-medium text-[var(--color-text)]">Provisioning&hellip;</p>
<p className="text-[var(--color-text-dim)]">
Topology will appear here as soon as the Sovereign cluster
reports its first nodes.
</p>
</div>
)}
{hasNodes && layout && (
<svg
data-testid="infrastructure-topology-svg"
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
role="img"
aria-label="Sovereign infrastructure topology"
style={{ display: 'block', minWidth: '100%' }}
>
<defs>
<marker
id="infra-topology-arrow"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M0,0 L10,5 L0,10 Z" fill="var(--color-border-strong)" />
</marker>
</defs>
{/* Edges first so they sit beneath the nodes. */}
<g data-testid="infrastructure-topology-edges">
{layout.edges.map((e) => (
<polyline
key={`${e.from}->${e.to}`}
data-testid={`infra-edge-${e.from}-${e.to}`}
points={e.points.map((p) => `${p.x},${p.y}`).join(' ')}
fill="none"
stroke="var(--color-border-strong)"
strokeWidth={1.5}
markerEnd="url(#infra-topology-arrow)"
/>
))}
</g>
{/* Nodes. */}
<g data-testid="infrastructure-topology-nodes">
{layout.nodes.map((n) => {
const node = nodesById.get(n.id)
if (!node) return null
const fill = STATUS_FILL[node.status]
const ring = STATUS_RING[node.status]
const isSelected = selectedId === n.id
return (
<g
key={n.id}
data-testid={`infra-node-${n.id}`}
data-kind={node.kind}
data-status={node.status}
transform={`translate(${n.x}, ${n.y})`}
style={{ cursor: 'pointer' }}
onClick={() => setSelectedId(n.id)}
tabIndex={0}
role="button"
aria-label={`${node.label}${node.kind}${node.status}`}
aria-pressed={isSelected}
onKeyDown={(ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault()
setSelectedId(n.id)
}
}}
>
<rect
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx={10}
ry={10}
fill="var(--color-bg)"
stroke={ring}
strokeWidth={isSelected ? 2.5 : 1.5}
/>
<circle cx={14} cy={NODE_HEIGHT / 2} r={6} fill={fill} />
<text
x={28}
y={NODE_HEIGHT / 2 - 6}
fill="var(--color-text-strong)"
fontSize={12}
fontWeight={600}
dominantBaseline="middle"
>
{truncate(node.label, 22)}
</text>
<text
x={28}
y={NODE_HEIGHT / 2 + 12}
fill="var(--color-text-dim)"
fontSize={10}
fontWeight={500}
dominantBaseline="middle"
>
{node.kind}
</text>
</g>
)
})}
</g>
</svg>
)}
</div>
{/* Detail panel slides in from the right. NOT a separate route
per founder spec ("Click a node detail panel slides in from
the right (NOT a separate route keeps you on the Topology
view)"). */}
{selectedNode && (
<aside
role="dialog"
aria-label={`${selectedNode.label} details`}
data-testid="infrastructure-topology-detail"
className="fixed right-0 top-14 z-30 flex h-[calc(100vh-3.5rem)] w-80 flex-col gap-3 border-l border-[var(--color-border)] bg-[var(--color-bg-2)] p-4 shadow-xl"
>
<header className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p
className="truncate text-base font-semibold text-[var(--color-text-strong)]"
data-testid="infrastructure-topology-detail-name"
>
{selectedNode.label}
</p>
<p className="text-xs uppercase tracking-wide text-[var(--color-text-dim)]">
{selectedNode.kind}
</p>
</div>
<button
type="button"
onClick={() => setSelectedId(null)}
data-testid="infrastructure-topology-detail-close"
className="rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs text-[var(--color-text-dim)] hover:text-[var(--color-text)]"
aria-label="Close detail panel"
>
</button>
</header>
<div
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] p-2 text-xs"
data-status={selectedNode.status}
>
<span className="text-[var(--color-text-dim)]">Status: </span>
<span
data-testid="infrastructure-topology-detail-status"
style={{ color: STATUS_FILL[selectedNode.status] }}
>
{selectedNode.status}
</span>
</div>
<div
className="flex-1 overflow-auto"
data-testid="infrastructure-topology-detail-meta"
>
{Object.entries(selectedNode.metadata).length === 0 ? (
<p className="text-xs text-[var(--color-text-dim)]">
No additional metadata for this node.
</p>
) : (
<dl className="grid grid-cols-3 gap-x-2 gap-y-1.5 text-xs">
{Object.entries(selectedNode.metadata).map(([k, v]) => (
<div key={k} className="contents">
<dt className="col-span-1 truncate text-[var(--color-text-dim)]">
{k}
</dt>
<dd className="col-span-2 truncate font-mono text-[var(--color-text)]">
{v}
</dd>
</div>
))}
</dl>
)}
</div>
</aside>
)}
</div>
)
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s
return s.slice(0, Math.max(0, max - 1)) + '…'
}

View File

@ -42,13 +42,14 @@ interface SidebarProps {
} }
interface NavItem { interface NavItem {
id: 'apps' | 'jobs' | 'dashboard' | 'settings' id: 'apps' | 'jobs' | 'dashboard' | 'infrastructure' | 'settings'
label: string label: string
/** Tanstack-router target — `null` for static external/non-tanstack routes. */ /** Tanstack-router target — `null` for static external/non-tanstack routes. */
to: to:
| '/provision/$deploymentId' | '/provision/$deploymentId'
| '/provision/$deploymentId/jobs' | '/provision/$deploymentId/jobs'
| '/provision/$deploymentId/dashboard' | '/provision/$deploymentId/dashboard'
| '/provision/$deploymentId/infrastructure'
| '/wizard' | '/wizard'
/** SVG path data — same `d` strings as core/console for visual parity. */ /** SVG path data — same `d` strings as core/console for visual parity. */
icon: string icon: string
@ -75,6 +76,15 @@ const NAV: NavItem[] = [
// 4-square Apps icon (Dashboard's quadrants are unequal). // 4-square Apps icon (Dashboard's quadrants are unequal).
icon: 'M3 3h7v9H3V3zm11 0h7v5h-7V3zM14 10h7v11h-7V10zM3 14h7v7H3v-7z', icon: 'M3 3h7v9H3V3zm11 0h7v5h-7V3zM14 10h7v11h-7V10zM3 14h7v7H3v-7z',
}, },
{
id: 'infrastructure',
label: 'Infrastructure',
to: '/provision/$deploymentId/infrastructure',
// Server-stack icon — three horizontal bars suggesting clusters /
// nodes, distinct from the dashboard's quadrant grid and the apps
// 4-square shape.
icon: 'M5 12H3m18 0h-2M5 7h14M5 12h14M5 17h14M5 7a2 2 0 00-2 2v6a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5z',
},
{ {
id: 'settings', id: 'settings',
label: 'Settings', label: 'Settings',
@ -85,6 +95,7 @@ const NAV: NavItem[] = [
/** Compute the active nav item from the current pathname. */ /** Compute the active nav item from the current pathname. */
function deriveActive(pathname: string): NavItem['id'] { function deriveActive(pathname: string): NavItem['id'] {
if (pathname.includes('/infrastructure')) return 'infrastructure'
if (pathname.endsWith('/dashboard')) return 'dashboard' if (pathname.endsWith('/dashboard')) return 'dashboard'
if (pathname.endsWith('/jobs')) return 'jobs' if (pathname.endsWith('/jobs')) return 'jobs'
if (pathname.startsWith('/sovereign/wizard') || pathname.startsWith('/wizard')) return 'settings' if (pathname.startsWith('/sovereign/wizard') || pathname.startsWith('/wizard')) return 'settings'