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
|
// V1 emits a static placeholder shape — see dashboard.go header
|
||||||
// for the metrics-server upgrade plan.
|
// for the metrics-server upgrade plan.
|
||||||
r.Get("/api/v1/dashboard/treemap", h.GetDashboardTreemap)
|
r.Get("/api/v1/dashboard/treemap", h.GetDashboardTreemap)
|
||||||
|
// Sovereign Infrastructure surface (issue #227) — Topology canvas
|
||||||
|
// + Compute / Storage / Network card grids. Each endpoint reads
|
||||||
|
// from the deployment record + (future) live cluster kubeconfig;
|
||||||
|
// see internal/handler/infrastructure.go for the data-source
|
||||||
|
// contract.
|
||||||
|
r.Get("/api/v1/deployments/{depId}/infrastructure/topology", h.GetInfrastructureTopology)
|
||||||
|
r.Get("/api/v1/deployments/{depId}/infrastructure/compute", h.GetInfrastructureCompute)
|
||||||
|
r.Get("/api/v1/deployments/{depId}/infrastructure/storage", h.GetInfrastructureStorage)
|
||||||
|
r.Get("/api/v1/deployments/{depId}/infrastructure/network", h.GetInfrastructureNetwork)
|
||||||
|
|
||||||
log.Info("catalyst api listening", "port", port)
|
log.Info("catalyst api listening", "port", port)
|
||||||
if err := http.ListenAndServe(":"+port, r); err != nil {
|
if err := http.ListenAndServe(":"+port, r); err != nil {
|
||||||
|
|||||||
@ -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 { JobsTimeline } from '@/pages/sovereign/JobsTimeline'
|
||||||
import { Dashboard } from '@/pages/sovereign/Dashboard'
|
import { Dashboard } from '@/pages/sovereign/Dashboard'
|
||||||
import { BatchDetail } from '@/pages/sovereign/BatchDetail'
|
import { BatchDetail } from '@/pages/sovereign/BatchDetail'
|
||||||
|
import { InfrastructurePage } from '@/pages/sovereign/InfrastructurePage'
|
||||||
|
import { InfrastructureTopology } from '@/pages/sovereign/InfrastructureTopology'
|
||||||
|
import { InfrastructureCompute } from '@/pages/sovereign/InfrastructureCompute'
|
||||||
|
import { InfrastructureStorage } from '@/pages/sovereign/InfrastructureStorage'
|
||||||
|
import { InfrastructureNetwork } from '@/pages/sovereign/InfrastructureNetwork'
|
||||||
|
|
||||||
// Root
|
// Root
|
||||||
const rootRoute = createRootRoute({ component: RootLayout })
|
const rootRoute = createRootRoute({ component: RootLayout })
|
||||||
@ -119,6 +124,52 @@ const provisionDashboardRoute = createRoute({
|
|||||||
component: Dashboard,
|
component: Dashboard,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sovereign Infrastructure surface (issue #227) — Topology canvas is
|
||||||
|
// the DEFAULT tab per founder spec ("the infrastructure page must be
|
||||||
|
// opened by default with the topology page"). The shell renders
|
||||||
|
// header + tabs and an <Outlet />; bare /infrastructure redirects to
|
||||||
|
// the topology sub-route so the URL shape is always explicit.
|
||||||
|
const provisionInfrastructureRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/provision/$deploymentId/infrastructure',
|
||||||
|
component: InfrastructurePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const provisionInfrastructureIndexRoute = createRoute({
|
||||||
|
getParentRoute: () => provisionInfrastructureRoute,
|
||||||
|
path: '/',
|
||||||
|
beforeLoad: ({ params }) => {
|
||||||
|
throw redirect({
|
||||||
|
to: '/provision/$deploymentId/infrastructure/topology',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const provisionInfrastructureTopologyRoute = createRoute({
|
||||||
|
getParentRoute: () => provisionInfrastructureRoute,
|
||||||
|
path: '/topology',
|
||||||
|
component: InfrastructureTopology,
|
||||||
|
})
|
||||||
|
|
||||||
|
const provisionInfrastructureComputeRoute = createRoute({
|
||||||
|
getParentRoute: () => provisionInfrastructureRoute,
|
||||||
|
path: '/compute',
|
||||||
|
component: InfrastructureCompute,
|
||||||
|
})
|
||||||
|
|
||||||
|
const provisionInfrastructureStorageRoute = createRoute({
|
||||||
|
getParentRoute: () => provisionInfrastructureRoute,
|
||||||
|
path: '/storage',
|
||||||
|
component: InfrastructureStorage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const provisionInfrastructureNetworkRoute = createRoute({
|
||||||
|
getParentRoute: () => provisionInfrastructureRoute,
|
||||||
|
path: '/network',
|
||||||
|
component: InfrastructureNetwork,
|
||||||
|
})
|
||||||
|
|
||||||
// Per-Batch detail page (epic #204 item #4) — surfaces a single batch
|
// Per-Batch detail page (epic #204 item #4) — surfaces a single batch
|
||||||
// progress card at the top + a JobsTable filtered to that batch's
|
// progress card at the top + a JobsTable filtered to that batch's
|
||||||
// rows. Reachable from the batch chip in any JobsTable row (both
|
// rows. Reachable from the batch chip in any JobsTable row (both
|
||||||
@ -179,6 +230,13 @@ const routeTree = rootRoute.addChildren([
|
|||||||
provisionJobsTimelineRoute,
|
provisionJobsTimelineRoute,
|
||||||
provisionJobDetailRoute,
|
provisionJobDetailRoute,
|
||||||
provisionDashboardRoute,
|
provisionDashboardRoute,
|
||||||
|
provisionInfrastructureRoute.addChildren([
|
||||||
|
provisionInfrastructureIndexRoute,
|
||||||
|
provisionInfrastructureTopologyRoute,
|
||||||
|
provisionInfrastructureComputeRoute,
|
||||||
|
provisionInfrastructureStorageRoute,
|
||||||
|
provisionInfrastructureNetworkRoute,
|
||||||
|
]),
|
||||||
provisionBatchDetailRoute,
|
provisionBatchDetailRoute,
|
||||||
legacyProvisionRoute,
|
legacyProvisionRoute,
|
||||||
designsRoute,
|
designsRoute,
|
||||||
|
|||||||
@ -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 {
|
interface NavItem {
|
||||||
id: 'apps' | 'jobs' | 'dashboard' | 'settings'
|
id: 'apps' | 'jobs' | 'dashboard' | 'infrastructure' | 'settings'
|
||||||
label: string
|
label: string
|
||||||
/** Tanstack-router target — `null` for static external/non-tanstack routes. */
|
/** Tanstack-router target — `null` for static external/non-tanstack routes. */
|
||||||
to:
|
to:
|
||||||
| '/provision/$deploymentId'
|
| '/provision/$deploymentId'
|
||||||
| '/provision/$deploymentId/jobs'
|
| '/provision/$deploymentId/jobs'
|
||||||
| '/provision/$deploymentId/dashboard'
|
| '/provision/$deploymentId/dashboard'
|
||||||
|
| '/provision/$deploymentId/infrastructure'
|
||||||
| '/wizard'
|
| '/wizard'
|
||||||
/** SVG path data — same `d` strings as core/console for visual parity. */
|
/** SVG path data — same `d` strings as core/console for visual parity. */
|
||||||
icon: string
|
icon: string
|
||||||
@ -75,6 +76,15 @@ const NAV: NavItem[] = [
|
|||||||
// 4-square Apps icon (Dashboard's quadrants are unequal).
|
// 4-square Apps icon (Dashboard's quadrants are unequal).
|
||||||
icon: 'M3 3h7v9H3V3zm11 0h7v5h-7V3zM14 10h7v11h-7V10zM3 14h7v7H3v-7z',
|
icon: 'M3 3h7v9H3V3zm11 0h7v5h-7V3zM14 10h7v11h-7V10zM3 14h7v7H3v-7z',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'infrastructure',
|
||||||
|
label: 'Infrastructure',
|
||||||
|
to: '/provision/$deploymentId/infrastructure',
|
||||||
|
// Server-stack icon — three horizontal bars suggesting clusters /
|
||||||
|
// nodes, distinct from the dashboard's quadrant grid and the apps
|
||||||
|
// 4-square shape.
|
||||||
|
icon: 'M5 12H3m18 0h-2M5 7h14M5 12h14M5 17h14M5 7a2 2 0 00-2 2v6a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5z',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
@ -85,6 +95,7 @@ const NAV: NavItem[] = [
|
|||||||
|
|
||||||
/** Compute the active nav item from the current pathname. */
|
/** Compute the active nav item from the current pathname. */
|
||||||
function deriveActive(pathname: string): NavItem['id'] {
|
function deriveActive(pathname: string): NavItem['id'] {
|
||||||
|
if (pathname.includes('/infrastructure')) return 'infrastructure'
|
||||||
if (pathname.endsWith('/dashboard')) return 'dashboard'
|
if (pathname.endsWith('/dashboard')) return 'dashboard'
|
||||||
if (pathname.endsWith('/jobs')) return 'jobs'
|
if (pathname.endsWith('/jobs')) return 'jobs'
|
||||||
if (pathname.startsWith('/sovereign/wizard') || pathname.startsWith('/wizard')) return 'settings'
|
if (pathname.startsWith('/sovereign/wizard') || pathname.startsWith('/wizard')) return 'settings'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user