Compare commits
7 Commits
chore/310-
...
feat/infra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
652d796f4b | ||
|
|
97eeb8fa59 | ||
|
|
a19d6d1198 | ||
|
|
680ad2697b | ||
|
|
6f32fc7074 | ||
|
|
3a65212700 | ||
|
|
aa974f3a6b |
@ -89,6 +89,15 @@ func main() {
|
||||
// V1 emits a static placeholder shape — see dashboard.go header
|
||||
// for the metrics-server upgrade plan.
|
||||
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)
|
||||
if err := http.ListenAndServe(":"+port, r); err != nil {
|
||||
|
||||
@ -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:])
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,6 +24,11 @@ import { JobDetail } from '@/pages/sovereign/JobDetail'
|
||||
import { JobsTimeline } from '@/pages/sovereign/JobsTimeline'
|
||||
import { Dashboard } from '@/pages/sovereign/Dashboard'
|
||||
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
|
||||
const rootRoute = createRootRoute({ component: RootLayout })
|
||||
@ -119,6 +124,52 @@ const provisionDashboardRoute = createRoute({
|
||||
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
|
||||
// progress card at the top + a JobsTable filtered to that batch's
|
||||
// rows. Reachable from the batch chip in any JobsTable row (both
|
||||
@ -179,6 +230,13 @@ const routeTree = rootRoute.addChildren([
|
||||
provisionJobsTimelineRoute,
|
||||
provisionJobDetailRoute,
|
||||
provisionDashboardRoute,
|
||||
provisionInfrastructureRoute.addChildren([
|
||||
provisionInfrastructureIndexRoute,
|
||||
provisionInfrastructureTopologyRoute,
|
||||
provisionInfrastructureComputeRoute,
|
||||
provisionInfrastructureStorageRoute,
|
||||
provisionInfrastructureNetworkRoute,
|
||||
]),
|
||||
provisionBatchDetailRoute,
|
||||
legacyProvisionRoute,
|
||||
designsRoute,
|
||||
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
373
products/catalyst/bootstrap/ui/src/lib/infrastructure.types.ts
Normal file
373
products/catalyst/bootstrap/ui/src/lib/infrastructure.types.ts
Normal 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 }
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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’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;
|
||||
}
|
||||
`
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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’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…</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)) + '…'
|
||||
}
|
||||
@ -42,13 +42,14 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
id: 'apps' | 'jobs' | 'dashboard' | 'settings'
|
||||
id: 'apps' | 'jobs' | 'dashboard' | 'infrastructure' | 'settings'
|
||||
label: string
|
||||
/** Tanstack-router target — `null` for static external/non-tanstack routes. */
|
||||
to:
|
||||
| '/provision/$deploymentId'
|
||||
| '/provision/$deploymentId/jobs'
|
||||
| '/provision/$deploymentId/dashboard'
|
||||
| '/provision/$deploymentId/infrastructure'
|
||||
| '/wizard'
|
||||
/** SVG path data — same `d` strings as core/console for visual parity. */
|
||||
icon: string
|
||||
@ -75,6 +76,15 @@ const NAV: NavItem[] = [
|
||||
// 4-square Apps icon (Dashboard's quadrants are unequal).
|
||||
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',
|
||||
label: 'Settings',
|
||||
@ -85,6 +95,7 @@ const NAV: NavItem[] = [
|
||||
|
||||
/** Compute the active nav item from the current pathname. */
|
||||
function deriveActive(pathname: string): NavItem['id'] {
|
||||
if (pathname.includes('/infrastructure')) return 'infrastructure'
|
||||
if (pathname.endsWith('/dashboard')) return 'dashboard'
|
||||
if (pathname.endsWith('/jobs')) return 'jobs'
|
||||
if (pathname.startsWith('/sovereign/wizard') || pathname.startsWith('/wizard')) return 'settings'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user