diff --git a/api/cmd/main.go b/api/cmd/main.go index 3f5e94d0..ba9f8fcb 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -254,6 +254,7 @@ func main() { batchHandler := handlers.NewBatchHandler(database) monitoringHandler := handlers.NewMonitoringHandler(database) quotasHandler := handlers.NewQuotasHandler(database) + nodeHandler := handlers.NewNodeHandler(database, k8sClient) // NOTE: WebSocket routes now use wsManager directly (see ws.GET routes below) consoleHandler := handlers.NewConsoleHandler(database) collaborationHandler := handlers.NewCollaborationHandler(database) @@ -273,7 +274,7 @@ func main() { } // Setup routes - setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, wsManager, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, setupHandler, jwtManager, userDB, redisCache, webhookSecret) + setupRoutes(router, apiHandler, userHandler, groupHandler, authHandler, activityHandler, catalogHandler, sharingHandler, pluginHandler, dashboardHandler, sessionActivityHandler, apiKeyHandler, teamHandler, preferencesHandler, notificationsHandler, searchHandler, sessionTemplatesHandler, batchHandler, monitoringHandler, quotasHandler, nodeHandler, wsManager, consoleHandler, collaborationHandler, integrationsHandler, loadBalancingHandler, schedulingHandler, securityHandler, templateVersioningHandler, setupHandler, jwtManager, userDB, redisCache, webhookSecret) // Create HTTP server with security timeouts srv := &http.Server{ @@ -354,7 +355,7 @@ func main() { log.Println("Graceful shutdown completed") } -func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, wsManager *internalWebsocket.Manager, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, setupHandler *handlers.SetupHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, redisCache *cache.Cache, webhookSecret string) { +func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserHandler, groupHandler *handlers.GroupHandler, authHandler *auth.AuthHandler, activityHandler *handlers.ActivityHandler, catalogHandler *handlers.CatalogHandler, sharingHandler *handlers.SharingHandler, pluginHandler *handlers.PluginHandler, dashboardHandler *handlers.DashboardHandler, sessionActivityHandler *handlers.SessionActivityHandler, apiKeyHandler *handlers.APIKeyHandler, teamHandler *handlers.TeamHandler, preferencesHandler *handlers.PreferencesHandler, notificationsHandler *handlers.NotificationsHandler, searchHandler *handlers.SearchHandler, sessionTemplatesHandler *handlers.SessionTemplatesHandler, batchHandler *handlers.BatchHandler, monitoringHandler *handlers.MonitoringHandler, quotasHandler *handlers.QuotasHandler, nodeHandler *handlers.NodeHandler, wsManager *internalWebsocket.Manager, consoleHandler *handlers.ConsoleHandler, collaborationHandler *handlers.CollaborationHandler, integrationsHandler *handlers.IntegrationsHandler, loadBalancingHandler *handlers.LoadBalancingHandler, schedulingHandler *handlers.SchedulingHandler, securityHandler *handlers.SecurityHandler, templateVersioningHandler *handlers.TemplateVersioningHandler, setupHandler *handlers.SetupHandler, jwtManager *auth.JWTManager, userDB *db.UserDB, redisCache *cache.Cache, webhookSecret string) { // SECURITY: Create authentication middleware authMiddleware := auth.Middleware(jwtManager, userDB) adminMiddleware := auth.RequireRole("admin") @@ -783,6 +784,22 @@ func setupRoutes(router *gin.Engine, h *api.Handler, userHandler *handlers.UserH // Resource quotas and limits enforcement - using dedicated handler (operators/admins only) quotasHandler.RegisterRoutes(protected.Group("", operatorMiddleware)) + // Node Management (admin only) + admin := protected.Group("/admin") + admin.Use(adminMiddleware) + { + admin.GET("/nodes", nodeHandler.ListNodes) + admin.GET("/nodes/stats", nodeHandler.GetClusterStats) + admin.GET("/nodes/:name", nodeHandler.GetNode) + admin.PUT("/nodes/:name/labels", nodeHandler.AddNodeLabel) + admin.DELETE("/nodes/:name/labels/:key", nodeHandler.RemoveNodeLabel) + admin.POST("/nodes/:name/taints", nodeHandler.AddNodeTaint) + admin.DELETE("/nodes/:name/taints/:key", nodeHandler.RemoveNodeTaint) + admin.POST("/nodes/:name/cordon", nodeHandler.CordonNode) + admin.POST("/nodes/:name/uncordon", nodeHandler.UncordonNode) + admin.POST("/nodes/:name/drain", nodeHandler.DrainNode) + } + // NOTE: Billing is now handled by the streamspace-billing plugin // Install it via: Admin → Plugins → streamspace-billing diff --git a/api/internal/api/stubs.go b/api/internal/api/stubs.go index bef10c8c..d40a0b33 100644 --- a/api/internal/api/stubs.go +++ b/api/internal/api/stubs.go @@ -3,6 +3,7 @@ package api import ( "bufio" "context" + "fmt" "io" "log" "net/http" @@ -64,7 +65,7 @@ var upgrader = websocket.Upgrader{ // Health returns health status func (h *Handler) Health(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ - "status": "healthy", + "status": "healthy", "service": "streamspace-api", }) } @@ -73,8 +74,8 @@ func (h *Handler) Health(c *gin.Context) { func (h *Handler) Version(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "version": "v0.1.0", - "api": "v1", - "phase": "2.2", + "api": "v1", + "phase": "2.2", }) } @@ -373,7 +374,7 @@ func (h *Handler) UpdateResource(c *gin.Context) { // DeleteResource deletes a K8s resource func (h *Handler) DeleteResource(c *gin.Context) { - resourceType := c.Param("type") // e.g., "deployment", "service" + resourceType := c.Param("type") // e.g., "deployment", "service" resourceName := c.Param("name") apiVersion := c.Query("apiVersion") // e.g., "apps/v1" kind := c.Query("kind") // e.g., "Deployment" @@ -529,7 +530,7 @@ func (h *Handler) GetConfig(c *gin.Context) { "namespace": h.namespace, "ingressDomain": os.Getenv("INGRESS_DOMAIN"), "hibernation": gin.H{ - "enabled": true, + "enabled": true, "defaultIdleTimeout": "30m", }, "resources": gin.H{ @@ -601,10 +602,152 @@ func (h *Handler) UpdateConfig(c *gin.Context) { // are fully implemented in api/internal/handlers/users.go by UserHandler. // Those should be used instead of stub implementations. -// GetMetrics returns metrics +// GetMetrics returns cluster metrics including nodes, sessions, resources, and users func (h *Handler) GetMetrics(c *gin.Context) { - stats := h.connTracker.GetStats() - c.JSON(http.StatusOK, stats) + ctx := c.Request.Context() + + // Get cluster nodes + nodes, err := h.k8sClient.GetNodes(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get cluster nodes"}) + return + } + + // Count ready nodes + readyNodes := 0 + totalCPU := int64(0) + totalMemory := int64(0) + usedPods := 0 + totalPods := 0 + + for _, node := range nodes { + // Check if node is ready + for _, condition := range node.Status.Conditions { + if condition.Type == corev1.NodeReady && condition.Status == corev1.ConditionTrue { + readyNodes++ + break + } + } + + // Sum up allocatable resources + if cpu, ok := node.Status.Allocatable[corev1.ResourceCPU]; ok { + totalCPU += cpu.MilliValue() + } + if memory, ok := node.Status.Allocatable[corev1.ResourceMemory]; ok { + totalMemory += memory.Value() + } + if pods, ok := node.Status.Allocatable[corev1.ResourcePods]; ok { + totalPods += int(pods.Value()) + } + } + + // Get all pods to calculate resource usage + pods, err := h.k8sClient.GetPods(ctx, h.namespace) + if err == nil { + usedPods = len(pods) + } + + // Get session counts from database + var sessionCounts struct { + Total int + Running int + Hibernated int + Terminated int + } + + err = h.db.DB().QueryRowContext(ctx, ` + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE state = 'running') as running, + COUNT(*) FILTER (WHERE state = 'hibernated') as hibernated, + COUNT(*) FILTER (WHERE state = 'terminated') as terminated + FROM sessions + `).Scan(&sessionCounts.Total, &sessionCounts.Running, &sessionCounts.Hibernated, &sessionCounts.Terminated) + + if err != nil { + log.Printf("Failed to get session counts: %v", err) + // Use zeros if query fails + sessionCounts = struct { + Total, Running, Hibernated, Terminated int + }{0, 0, 0, 0} + } + + // Get user counts from database + var userCounts struct { + Total int + Active int + } + + err = h.db.DB().QueryRowContext(ctx, ` + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE last_login > NOW() - INTERVAL '24 hours') as active + FROM users + `).Scan(&userCounts.Total, &userCounts.Active) + + if err != nil { + log.Printf("Failed to get user counts: %v", err) + // Use zeros if query fails + userCounts = struct{ Total, Active int }{0, 0} + } + + // Calculate resource usage (simplified - in production you'd query metrics-server) + // For now, estimate based on running sessions + usedCPU := int64(sessionCounts.Running * 1000) // 1000m per session estimate + usedMemory := int64(sessionCounts.Running * 2 * 1024 * 1024 * 1024) // 2GiB per session estimate + + cpuPercent := float64(0) + if totalCPU > 0 { + cpuPercent = float64(usedCPU) / float64(totalCPU) * 100 + } + + memoryPercent := float64(0) + if totalMemory > 0 { + memoryPercent = float64(usedMemory) / float64(totalMemory) * 100 + } + + podsPercent := float64(0) + if totalPods > 0 { + podsPercent = float64(usedPods) / float64(totalPods) * 100 + } + + // Return cluster metrics in the format expected by AdminDashboard + c.JSON(http.StatusOK, gin.H{ + "cluster": gin.H{ + "nodes": gin.H{ + "total": len(nodes), + "ready": readyNodes, + "notReady": len(nodes) - readyNodes, + }, + "sessions": gin.H{ + "total": sessionCounts.Total, + "running": sessionCounts.Running, + "hibernated": sessionCounts.Hibernated, + "terminated": sessionCounts.Terminated, + }, + "resources": gin.H{ + "cpu": gin.H{ + "total": fmt.Sprintf("%dm", totalCPU), + "used": fmt.Sprintf("%dm", usedCPU), + "percent": cpuPercent, + }, + "memory": gin.H{ + "total": fmt.Sprintf("%d", totalMemory), + "used": fmt.Sprintf("%d", usedMemory), + "percent": memoryPercent, + }, + "pods": gin.H{ + "total": totalPods, + "used": usedPods, + "percent": podsPercent, + }, + }, + "users": gin.H{ + "total": userCounts.Total, + "active": userCounts.Active, + }, + }, + }) } // ============================================================================ diff --git a/api/internal/handlers/nodes.go b/api/internal/handlers/nodes.go new file mode 100644 index 00000000..c23aa087 --- /dev/null +++ b/api/internal/handlers/nodes.go @@ -0,0 +1,547 @@ +// Package handlers provides HTTP handlers for the StreamSpace API. +// This file implements Kubernetes node management for administrators. +// +// NODE MANAGEMENT OVERVIEW: +// +// The node management system allows administrators to: +// - View all cluster nodes and their health status +// - Monitor resource capacity and usage +// - Add/remove node labels for scheduling +// - Add/remove node taints to control pod placement +// - Cordon nodes to prevent new pod scheduling +// - Drain nodes to safely evict pods for maintenance +// +// FEATURES: +// +// 1. Node Listing: +// - View all cluster nodes with status +// - Resource capacity (CPU, memory, storage, pods) +// - Allocatable resources (after system reservations) +// - Current usage statistics +// - Node metadata (OS, kernel, kubelet version, container runtime) +// +// 2. Cluster Statistics: +// - Total nodes (ready vs not ready) +// - Aggregate capacity and allocatable resources +// - Overall cluster utilization percentages +// +// 3. Node Labeling: +// - Add labels for node selection (e.g., gpu=true, tier=premium) +// - Remove labels when no longer needed +// - Labels used in session pod affinity rules +// +// 4. Node Tainting: +// - Add taints to repel pods (NoSchedule, PreferNoSchedule, NoExecute) +// - Remove taints to allow normal scheduling +// - Taints used for dedicated workloads or maintenance +// +// 5. Node Operations: +// - Cordon: Mark node as unschedulable (existing pods continue) +// - Uncordon: Allow scheduling again +// - Drain: Evict all pods gracefully with grace period +// +// SECURITY: +// +// - Admin-only access required for all node operations +// - Audit logging for all node changes +// - Validation of node names and operations +// +// EXAMPLE WORKFLOWS: +// +// Maintenance workflow: +// 1. Cordon node to prevent new sessions +// 2. Drain node to move existing sessions elsewhere +// 3. Perform maintenance (OS updates, hardware changes) +// 4. Uncordon node to resume normal operation +// +// GPU node labeling: +// 1. Add label: gpu=nvidia-v100 +// 2. Create template with nodeSelector matching the label +// 3. GPU sessions only schedule on labeled nodes +package handlers + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/streamspace/streamspace/api/internal/db" + "github.com/streamspace/streamspace/api/internal/k8s" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// NodeHandler handles node management operations +type NodeHandler struct { + db *db.Database + k8sClient *k8s.Client +} + +// NewNodeHandler creates a new node management handler +func NewNodeHandler(database *db.Database, k8sClient *k8s.Client) *NodeHandler { + return &NodeHandler{ + db: database, + k8sClient: k8sClient, + } +} + +// NodeInfo represents detailed node information +type NodeInfo struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` + Taints []corev1.Taint `json:"taints"` + Status string `json:"status"` // Ready, NotReady, Unknown + Capacity corev1.ResourceList `json:"capacity"` + Allocatable corev1.ResourceList `json:"allocatable"` + Usage *NodeUsage `json:"usage,omitempty"` + Info NodeSystemInfo `json:"info"` + Conditions []corev1.NodeCondition `json:"conditions"` + Pods int `json:"pods"` + Age string `json:"age"` + Provider string `json:"provider,omitempty"` + Region string `json:"region,omitempty"` + Zone string `json:"zone,omitempty"` +} + +// NodeUsage represents resource usage on a node +type NodeUsage struct { + CPU string `json:"cpu"` + Memory string `json:"memory"` + CPUPercent float64 `json:"cpuPercent"` + MemoryPercent float64 `json:"memoryPercent"` +} + +// NodeSystemInfo represents system information +type NodeSystemInfo struct { + OSImage string `json:"osImage"` + KernelVersion string `json:"kernelVersion"` + KubeletVersion string `json:"kubeletVersion"` + ContainerRuntime string `json:"containerRuntime"` +} + +// ClusterStats represents aggregate cluster statistics +type ClusterStats struct { + TotalNodes int `json:"totalNodes"` + ReadyNodes int `json:"readyNodes"` + NotReadyNodes int `json:"notReadyNodes"` + TotalCapacity corev1.ResourceList `json:"totalCapacity"` + TotalAllocatable corev1.ResourceList `json:"totalAllocatable"` + TotalUsage *ClusterUsage `json:"totalUsage,omitempty"` +} + +// ClusterUsage represents aggregate cluster usage +type ClusterUsage struct { + CPU string `json:"cpu"` + Memory string `json:"memory"` + CPUPercent float64 `json:"cpuPercent"` + MemoryPercent float64 `json:"memoryPercent"` +} + +// ListNodes returns all cluster nodes +// GET /admin/nodes +func (h *NodeHandler) ListNodes(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Get nodes from Kubernetes + nodeList, err := h.k8sClient.GetNodes(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to list nodes: %v", err), + }) + return + } + + // Convert to NodeInfo structs + nodes := make([]NodeInfo, 0, len(nodeList.Items)) + for _, node := range nodeList.Items { + nodeInfo := h.nodeToNodeInfo(&node) + nodes = append(nodes, nodeInfo) + } + + c.JSON(http.StatusOK, nodes) +} + +// GetNode returns detailed information about a specific node +// GET /admin/nodes/:name +func (h *NodeHandler) GetNode(c *gin.Context) { + nodeName := c.Param("name") + if nodeName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Node name is required"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + // Get node from Kubernetes + node, err := h.k8sClient.GetNode(ctx, nodeName) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": fmt.Sprintf("Node not found: %v", err), + }) + return + } + + nodeInfo := h.nodeToNodeInfo(node) + c.JSON(http.StatusOK, nodeInfo) +} + +// GetClusterStats returns aggregate cluster statistics +// GET /admin/nodes/stats +func (h *NodeHandler) GetClusterStats(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + // Get nodes from Kubernetes + nodeList, err := h.k8sClient.GetNodes(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to get cluster stats: %v", err), + }) + return + } + + stats := h.calculateClusterStats(nodeList) + c.JSON(http.StatusOK, stats) +} + +// AddNodeLabel adds a label to a node +// PUT /admin/nodes/:name/labels +func (h *NodeHandler) AddNodeLabel(c *gin.Context) { + nodeName := c.Param("name") + if nodeName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Node name is required"}) + return + } + + var req struct { + Key string `json:"key" binding:"required"` + Value string `json:"value" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + // Add label using patch + patchData := fmt.Sprintf(`{"metadata":{"labels":{"%s":"%s"}}}`, req.Key, req.Value) + if err := h.k8sClient.PatchNode(ctx, nodeName, []byte(patchData)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to add label: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Label added successfully"}) +} + +// RemoveNodeLabel removes a label from a node +// DELETE /admin/nodes/:name/labels/:key +func (h *NodeHandler) RemoveNodeLabel(c *gin.Context) { + nodeName := c.Param("name") + labelKey := c.Param("key") + + if nodeName == "" || labelKey == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Node name and label key are required"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + // Remove label using JSON patch + patchData := fmt.Sprintf(`{"metadata":{"labels":{"%s":null}}}`, labelKey) + if err := h.k8sClient.PatchNode(ctx, nodeName, []byte(patchData)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to remove label: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Label removed successfully"}) +} + +// AddNodeTaint adds a taint to a node +// POST /admin/nodes/:name/taints +func (h *NodeHandler) AddNodeTaint(c *gin.Context) { + nodeName := c.Param("name") + if nodeName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Node name is required"}) + return + } + + var taint corev1.Taint + if err := c.ShouldBindJSON(&taint); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + // Get current node to append taint + node, err := h.k8sClient.GetNode(ctx, nodeName) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Node not found"}) + return + } + + // Check if taint already exists + for _, t := range node.Spec.Taints { + if t.Key == taint.Key && t.Effect == taint.Effect { + c.JSON(http.StatusConflict, gin.H{"error": "Taint already exists"}) + return + } + } + + // Add taint using strategic merge patch + patchData := fmt.Sprintf(`{"spec":{"taints":[{"key":"%s","value":"%s","effect":"%s"}]}}`, + taint.Key, taint.Value, taint.Effect) + if err := h.k8sClient.PatchNode(ctx, nodeName, []byte(patchData)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to add taint: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Taint added successfully"}) +} + +// RemoveNodeTaint removes a taint from a node +// DELETE /admin/nodes/:name/taints/:key +func (h *NodeHandler) RemoveNodeTaint(c *gin.Context) { + nodeName := c.Param("name") + taintKey := c.Param("key") + + if nodeName == "" || taintKey == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Node name and taint key are required"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + // Get current node + node, err := h.k8sClient.GetNode(ctx, nodeName) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Node not found"}) + return + } + + // Filter out the taint + newTaints := []corev1.Taint{} + found := false + for _, t := range node.Spec.Taints { + if t.Key != taintKey { + newTaints = append(newTaints, t) + } else { + found = true + } + } + + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "Taint not found"}) + return + } + + // Update node with new taints + if err := h.k8sClient.UpdateNodeTaints(ctx, nodeName, newTaints); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to remove taint: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Taint removed successfully"}) +} + +// CordonNode marks a node as unschedulable +// POST /admin/nodes/:name/cordon +func (h *NodeHandler) CordonNode(c *gin.Context) { + nodeName := c.Param("name") + if nodeName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Node name is required"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + if err := h.k8sClient.CordonNode(ctx, nodeName); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to cordon node: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Node cordoned successfully"}) +} + +// UncordonNode marks a node as schedulable +// POST /admin/nodes/:name/uncordon +func (h *NodeHandler) UncordonNode(c *gin.Context) { + nodeName := c.Param("name") + if nodeName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Node name is required"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + if err := h.k8sClient.UncordonNode(ctx, nodeName); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to uncordon node: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Node uncordoned successfully"}) +} + +// DrainNode evicts all pods from a node +// POST /admin/nodes/:name/drain +func (h *NodeHandler) DrainNode(c *gin.Context) { + nodeName := c.Param("name") + if nodeName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Node name is required"}) + return + } + + var req struct { + GracePeriodSeconds *int64 `json:"grace_period_seconds"` + } + if err := c.ShouldBindJSON(&req); err == nil && req.GracePeriodSeconds == nil { + defaultGracePeriod := int64(30) + req.GracePeriodSeconds = &defaultGracePeriod + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute) + defer cancel() + + if err := h.k8sClient.DrainNode(ctx, nodeName, req.GracePeriodSeconds); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to drain node: %v", err), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Node drained successfully"}) +} + +// Helper function to convert K8s Node to NodeInfo +func (h *NodeHandler) nodeToNodeInfo(node *corev1.Node) NodeInfo { + // Determine node status + status := "Unknown" + for _, condition := range node.Status.Conditions { + if condition.Type == corev1.NodeReady { + if condition.Status == corev1.ConditionTrue { + status = "Ready" + } else { + status = "NotReady" + } + break + } + } + + // Calculate age + age := time.Since(node.CreationTimestamp.Time).Round(time.Hour).String() + + // Get cloud provider info from labels + provider := node.Labels["cloud.google.com/gke-nodepool"] + if provider == "" { + provider = node.Labels["eks.amazonaws.com/nodegroup"] + } + if provider == "" { + provider = node.Labels["node.kubernetes.io/instance-type"] + } + + return NodeInfo{ + Name: node.Name, + Labels: node.Labels, + Taints: node.Spec.Taints, + Status: status, + Capacity: node.Status.Capacity, + Allocatable: node.Status.Allocatable, + Info: NodeSystemInfo{ + OSImage: node.Status.NodeInfo.OSImage, + KernelVersion: node.Status.NodeInfo.KernelVersion, + KubeletVersion: node.Status.NodeInfo.KubeletVersion, + ContainerRuntime: node.Status.NodeInfo.ContainerRuntimeVersion, + }, + Conditions: node.Status.Conditions, + Age: age, + Provider: provider, + Region: node.Labels["topology.kubernetes.io/region"], + Zone: node.Labels["topology.kubernetes.io/zone"], + } +} + +// Helper function to calculate cluster statistics +func (h *NodeHandler) calculateClusterStats(nodeList *corev1.NodeList) ClusterStats { + stats := ClusterStats{ + TotalNodes: len(nodeList.Items), + ReadyNodes: 0, + NotReadyNodes: 0, + TotalCapacity: corev1.ResourceList{ + corev1.ResourceCPU: *newQuantity(0), + corev1.ResourceMemory: *newQuantity(0), + corev1.ResourcePods: *newQuantity(0), + }, + TotalAllocatable: corev1.ResourceList{ + corev1.ResourceCPU: *newQuantity(0), + corev1.ResourceMemory: *newQuantity(0), + corev1.ResourcePods: *newQuantity(0), + }, + } + + for _, node := range nodeList.Items { + // Count ready vs not ready nodes + for _, condition := range node.Status.Conditions { + if condition.Type == corev1.NodeReady { + if condition.Status == corev1.ConditionTrue { + stats.ReadyNodes++ + } else { + stats.NotReadyNodes++ + } + break + } + } + + // Aggregate capacity + if cpu, ok := node.Status.Capacity[corev1.ResourceCPU]; ok { + stats.TotalCapacity[corev1.ResourceCPU].Add(cpu) + } + if mem, ok := node.Status.Capacity[corev1.ResourceMemory]; ok { + stats.TotalCapacity[corev1.ResourceMemory].Add(mem) + } + if pods, ok := node.Status.Capacity[corev1.ResourcePods]; ok { + stats.TotalCapacity[corev1.ResourcePods].Add(pods) + } + + // Aggregate allocatable + if cpu, ok := node.Status.Allocatable[corev1.ResourceCPU]; ok { + stats.TotalAllocatable[corev1.ResourceCPU].Add(cpu) + } + if mem, ok := node.Status.Allocatable[corev1.ResourceMemory]; ok { + stats.TotalAllocatable[corev1.ResourceMemory].Add(mem) + } + if pods, ok := node.Status.Allocatable[corev1.ResourcePods]; ok { + stats.TotalAllocatable[corev1.ResourcePods].Add(pods) + } + } + + return stats +} + +// Helper function to create a new Quantity +func newQuantity(value int64) *corev1.Quantity { + return &corev1.Quantity{} +} diff --git a/api/internal/k8s/client.go b/api/internal/k8s/client.go index 1f9e5a72..6c70ac93 100644 --- a/api/internal/k8s/client.go +++ b/api/internal/k8s/client.go @@ -18,15 +18,22 @@ // - Auto-configuration (in-cluster or kubeconfig) // // Custom Resource Definitions: +// // - Sessions (stream.streamspace.io/v1alpha1) -// - Represents a user's containerized workspace session -// - States: running, hibernated, terminated -// - Includes resource limits, idle timeout, persistence settings +// +// - Represents a user's containerized workspace session +// +// - States: running, hibernated, terminated +// +// - Includes resource limits, idle timeout, persistence settings // // - Templates (stream.streamspace.io/v1alpha1) -// - Defines application templates (Firefox, VS Code, etc.) -// - Contains container image, VNC/webapp config, resources -// - Categorized for catalog organization +// +// - Defines application templates (Firefox, VS Code, etc.) +// +// - Contains container image, VNC/webapp config, resources +// +// - Categorized for catalog organization // // Implementation Details: // - Uses Kubernetes dynamic client for CRD operations @@ -106,12 +113,12 @@ type Session struct { Memory string CPU string } - PersistentHome bool - IdleTimeout string - MaxSessionDuration string - Tags []string - Status SessionStatus - CreatedAt time.Time + PersistentHome bool + IdleTimeout string + MaxSessionDuration string + Tags []string + Status SessionStatus + CreatedAt time.Time } // SessionStatus represents the status of a Session @@ -129,14 +136,14 @@ type SessionStatus struct { // Template represents a StreamSpace Template CRD type Template struct { - Name string - Namespace string - DisplayName string - Description string - Category string - Icon string - BaseImage string - AppType string // desktop, webapp + Name string + Namespace string + DisplayName string + Description string + Category string + Icon string + BaseImage string + AppType string // desktop, webapp DefaultResources struct { Memory string CPU string @@ -152,8 +159,8 @@ type Template struct { WebApp *WebAppConfig Capabilities []string Tags []string - Featured bool // Whether template is featured in catalog - UsageCount int // Number of times template has been used + Featured bool // Whether template is featured in catalog + UsageCount int // Number of times template has been used CreatedAt time.Time } @@ -850,3 +857,111 @@ func (c *Client) GetNamespaces(ctx context.Context) (*corev1.NamespaceList, erro return namespaces, nil } + +// ============================================================================ +// Node Management Operations +// ============================================================================ + +// GetNode returns a specific node by name +func (c *Client) GetNode(ctx context.Context, name string) (*corev1.Node, error) { + node, err := c.clientset.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get node %s: %w", name, err) + } + + return node, nil +} + +// PatchNode applies a patch to a node +func (c *Client) PatchNode(ctx context.Context, name string, patchData []byte) error { + _, err := c.clientset.CoreV1().Nodes().Patch( + ctx, + name, + types.StrategicMergePatchType, + patchData, + metav1.PatchOptions{}, + ) + if err != nil { + return fmt.Errorf("failed to patch node %s: %w", name, err) + } + + return nil +} + +// UpdateNodeTaints updates the taints on a node +func (c *Client) UpdateNodeTaints(ctx context.Context, name string, taints []corev1.Taint) error { + node, err := c.GetNode(ctx, name) + if err != nil { + return err + } + + node.Spec.Taints = taints + + _, err = c.clientset.CoreV1().Nodes().Update(ctx, node, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update node taints: %w", err) + } + + return nil +} + +// CordonNode marks a node as unschedulable +func (c *Client) CordonNode(ctx context.Context, name string) error { + patchData := []byte(`{"spec":{"unschedulable":true}}`) + return c.PatchNode(ctx, name, patchData) +} + +// UncordonNode marks a node as schedulable +func (c *Client) UncordonNode(ctx context.Context, name string) error { + patchData := []byte(`{"spec":{"unschedulable":false}}`) + return c.PatchNode(ctx, name, patchData) +} + +// DrainNode evicts all pods from a node +func (c *Client) DrainNode(ctx context.Context, name string, gracePeriodSeconds *int64) error { + // First cordon the node + if err := c.CordonNode(ctx, name); err != nil { + return fmt.Errorf("failed to cordon node: %w", err) + } + + // Get all pods on the node + pods, err := c.clientset.CoreV1().Pods("").List(ctx, metav1.ListOptions{ + FieldSelector: fmt.Sprintf("spec.nodeName=%s", name), + }) + if err != nil { + return fmt.Errorf("failed to list pods on node: %w", err) + } + + // Evict each pod + for _, pod := range pods.Items { + // Skip daemonset pods and system pods + if pod.OwnerReferences != nil { + for _, owner := range pod.OwnerReferences { + if owner.Kind == "DaemonSet" { + continue + } + } + } + + // Skip static pods + if pod.Annotations != nil { + if _, ok := pod.Annotations["kubernetes.io/config.mirror"]; ok { + continue + } + } + + // Create eviction object + eviction := &metav1.DeleteOptions{ + GracePeriodSeconds: gracePeriodSeconds, + } + + // Evict the pod + err := c.clientset.CoreV1().Pods(pod.Namespace).Delete(ctx, pod.Name, *eviction) + if err != nil { + // Log error but continue with other pods + fmt.Printf("Warning: failed to evict pod %s/%s: %v\n", pod.Namespace, pod.Name, err) + } + } + + return nil +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 855d7c98..b6594565 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -29,6 +29,7 @@ import { AdminPanelSettings as AdminIcon, Storage as StorageIcon, People as PeopleIcon, + Groups as GroupsIcon, Schedule as ScheduleIcon, Security as SecurityIcon, Hub as IntegrationIcon, @@ -114,6 +115,8 @@ function Layout({ children }: LayoutProps) { const adminMenuItems = [ { text: 'Admin Dashboard', icon: , path: '/admin/dashboard' }, + { text: 'Users', icon: , path: '/admin/users' }, + { text: 'Groups', icon: , path: '/admin/groups' }, { text: 'Cluster Nodes', icon: , path: '/admin/nodes' }, { text: 'User Quotas', icon: , path: '/admin/quotas' }, { text: 'Plugin Management', icon: , path: '/admin/plugins' }, diff --git a/ui/src/pages/EnhancedCatalog.tsx b/ui/src/pages/EnhancedCatalog.tsx index bb6abdfc..8ec6a738 100644 --- a/ui/src/pages/EnhancedCatalog.tsx +++ b/ui/src/pages/EnhancedCatalog.tsx @@ -111,8 +111,8 @@ function EnhancedCatalogContent() { useEffect(() => { // Extract unique categories and app types from templates - const uniqueCategories = Array.from(new Set(templates.map(t => t.category).filter(Boolean))); - const uniqueAppTypes = Array.from(new Set(templates.map(t => t.appType).filter(Boolean))); + const uniqueCategories = Array.from(new Set(templates?.map(t => t?.category).filter(Boolean) || [])); + const uniqueAppTypes = Array.from(new Set(templates?.map(t => t?.appType).filter(Boolean) || [])); setCategories(uniqueCategories); setAppTypes(uniqueAppTypes); }, [templates]); @@ -121,10 +121,14 @@ function EnhancedCatalogContent() { setLoading(true); try { const data = await api.listCatalogTemplates(filters); - setTemplates(data.templates); - setTotalPages(data.totalPages); + // Ensure templates is always an array to prevent undefined errors + setTemplates(Array.isArray(data?.templates) ? data.templates : []); + setTotalPages(data?.totalPages || 1); } catch (error) { console.error('Failed to load templates:', error); + // Set empty array on error to prevent undefined + setTemplates([]); + setTotalPages(1); } finally { setLoading(false); } diff --git a/ui/src/pages/admin/Compliance.tsx b/ui/src/pages/admin/Compliance.tsx index f04d5c21..15f94c3f 100644 --- a/ui/src/pages/admin/Compliance.tsx +++ b/ui/src/pages/admin/Compliance.tsx @@ -218,7 +218,7 @@ function ComplianceContent() { // Refresh violations and metrics loadViolations(); - loadMetrics(); + loadDashboard(); }); const [frameworkDialog, setFrameworkDialog] = useState(false); @@ -251,27 +251,36 @@ function ComplianceContent() { const loadFrameworks = async () => { try { const response = await api.listComplianceFrameworks(); - setFrameworks(response.frameworks); + // Ensure frameworks is always an array to prevent undefined errors + setFrameworks(Array.isArray(response?.frameworks) ? response.frameworks : []); } catch (error) { console.error('Failed to load frameworks:', error); + // Set empty array on error to prevent undefined + setFrameworks([]); } }; const loadPolicies = async () => { try { const response = await api.listCompliancePolicies(); - setPolicies(response.policies); + // Ensure policies is always an array to prevent undefined errors + setPolicies(Array.isArray(response?.policies) ? response.policies : []); } catch (error) { console.error('Failed to load policies:', error); + // Set empty array on error to prevent undefined + setPolicies([]); } }; const loadViolations = async () => { try { const response = await api.listComplianceViolations(); - setViolations(response.violations); + // Ensure violations is always an array to prevent undefined errors + setViolations(Array.isArray(response?.violations) ? response.violations : []); } catch (error) { console.error('Failed to load violations:', error); + // Set empty array on error to prevent undefined + setViolations([]); } }; @@ -279,13 +288,30 @@ function ComplianceContent() { try { const dashboard = await api.getComplianceDashboard(); setMetrics({ - total_policies: dashboard.total_policies, - active_policies: dashboard.active_policies, - total_open_violations: dashboard.total_open_violations, - violations_by_severity: dashboard.violations_by_severity, + total_policies: dashboard?.total_policies ?? 0, + active_policies: dashboard?.active_policies ?? 0, + total_open_violations: dashboard?.total_open_violations ?? 0, + violations_by_severity: dashboard?.violations_by_severity ?? { + critical: 0, + high: 0, + medium: 0, + low: 0, + }, }); } catch (error) { console.error('Failed to load dashboard:', error); + // Set default metrics on error to prevent undefined + setMetrics({ + total_policies: 0, + active_policies: 0, + total_open_violations: 0, + violations_by_severity: { + critical: 0, + high: 0, + medium: 0, + low: 0, + }, + }); } }; diff --git a/ui/src/pages/admin/Dashboard.tsx b/ui/src/pages/admin/Dashboard.tsx index 54f19029..9f344ebc 100644 --- a/ui/src/pages/admin/Dashboard.tsx +++ b/ui/src/pages/admin/Dashboard.tsx @@ -168,6 +168,9 @@ export default function AdminDashboard() { if (metricsData?.cluster) { setMetrics(metricsData.cluster); prevMetricsRef.current = metricsData.cluster; + } else if (metricsData && !metricsData.cluster) { + // API returned data but wrong structure - log for debugging + console.warn('Metrics API returned unexpected structure:', metricsData); } }, [metricsData]); @@ -302,7 +305,7 @@ export default function AdminDashboard() { {error && ( - setError('')}> + {error} )} diff --git a/ui/src/pages/admin/Integrations.tsx b/ui/src/pages/admin/Integrations.tsx index cb1489f8..fd971901 100644 --- a/ui/src/pages/admin/Integrations.tsx +++ b/ui/src/pages/admin/Integrations.tsx @@ -234,18 +234,24 @@ function IntegrationsContent() { const loadWebhooks = async () => { try { const response = await api.listWebhooks(); - setWebhooks(response.webhooks); + // Ensure webhooks is always an array to prevent undefined errors + setWebhooks(Array.isArray(response?.webhooks) ? response.webhooks : []); } catch (error) { console.error('Failed to load webhooks:', error); + // Set empty array on error to prevent undefined + setWebhooks([]); } }; const loadIntegrations = async () => { try { const response = await api.listIntegrations(); - setIntegrations(response.integrations); + // Ensure integrations is always an array to prevent undefined errors + setIntegrations(Array.isArray(response?.integrations) ? response.integrations : []); } catch (error) { console.error('Failed to load integrations:', error); + // Set empty array on error to prevent undefined + setIntegrations([]); } }; diff --git a/ui/src/pages/admin/Nodes.tsx b/ui/src/pages/admin/Nodes.tsx index b599ec80..6f024ac5 100644 --- a/ui/src/pages/admin/Nodes.tsx +++ b/ui/src/pages/admin/Nodes.tsx @@ -174,15 +174,19 @@ export default function AdminNodes() { try { const [nodesData, statsData] = await Promise.all([ - api.listNodes(), - api.getClusterStats(), + api.listNodes().catch(() => []), // Return empty array if API not implemented + api.getClusterStats().catch(() => null), // Return null if API not implemented ]); - setNodes(nodesData); - setStats(statsData); + // Ensure nodesData is always an array to prevent undefined errors + setNodes(Array.isArray(nodesData) ? nodesData : []); + setStats(statsData || null); } catch (err: any) { console.error('Failed to load nodes:', err); setError(err.response?.data?.message || 'Failed to load node information'); + // Set empty array on error to prevent undefined + setNodes([]); + setStats(null); } finally { setLoading(false); } diff --git a/ui/src/pages/admin/Plugins.tsx b/ui/src/pages/admin/Plugins.tsx index 424f9e44..ab107c2e 100644 --- a/ui/src/pages/admin/Plugins.tsx +++ b/ui/src/pages/admin/Plugins.tsx @@ -193,10 +193,13 @@ export default function AdminPlugins() { setLoading(true); try { const data = await api.listInstalledPlugins(); - setPlugins(data); + // Ensure plugins is always an array to prevent undefined errors + setPlugins(Array.isArray(data) ? data : []); } catch (error) { console.error('Failed to load plugins:', error); toast.error('Failed to load plugins'); + // Set empty array on error to prevent undefined + setPlugins([]); } finally { setLoading(false); } @@ -260,15 +263,15 @@ export default function AdminPlugins() { }; const stats = { - total: plugins.length, - enabled: plugins.filter(p => p.enabled).length, - disabled: plugins.filter(p => !p.enabled).length, + total: plugins?.length ?? 0, + enabled: plugins?.filter(p => p.enabled).length ?? 0, + disabled: plugins?.filter(p => !p.enabled).length ?? 0, byType: { - extension: plugins.filter(p => p.pluginType === 'extension').length, - webhook: plugins.filter(p => p.pluginType === 'webhook').length, - api: plugins.filter(p => p.pluginType === 'api').length, - ui: plugins.filter(p => p.pluginType === 'ui').length, - theme: plugins.filter(p => p.pluginType === 'theme').length, + extension: plugins?.filter(p => p.pluginType === 'extension').length ?? 0, + webhook: plugins?.filter(p => p.pluginType === 'webhook').length ?? 0, + api: plugins?.filter(p => p.pluginType === 'api').length ?? 0, + ui: plugins?.filter(p => p.pluginType === 'ui').length ?? 0, + theme: plugins?.filter(p => p.pluginType === 'theme').length ?? 0, }, }; diff --git a/ui/src/pages/admin/Quotas.tsx b/ui/src/pages/admin/Quotas.tsx index d6edb484..a573c6be 100644 --- a/ui/src/pages/admin/Quotas.tsx +++ b/ui/src/pages/admin/Quotas.tsx @@ -176,10 +176,13 @@ export default function AdminQuotas() { try { const quotasData = await api.listAllUserQuotas(); - setQuotas(quotasData); + // Ensure quotasData is always an array to prevent undefined errors + setQuotas(Array.isArray(quotasData) ? quotasData : []); } catch (err: any) { console.error('Failed to load quotas:', err); setError(err.response?.data?.message || 'Failed to load user quotas'); + // Set empty array on error to prevent undefined + setQuotas([]); } finally { setLoading(false); } @@ -329,18 +332,18 @@ export default function AdminQuotas() { ) : ( quotas.map((quota) => { - const sessionPercent = calculatePercentage(quota.usedSessions, quota.maxSessions); + const sessionPercent = calculatePercentage(quota?.usedSessions ?? 0, quota?.maxSessions ?? 0); const cpuPercent = calculatePercentage( - parseResourceString(quota.usedCpu), - parseResourceString(quota.maxCpu) + parseResourceString(quota?.usedCpu || '0'), + parseResourceString(quota?.maxCpu || '0') ); const memoryPercent = calculatePercentage( - parseResourceString(quota.usedMemory), - parseResourceString(quota.maxMemory) + parseResourceString(quota?.usedMemory || '0'), + parseResourceString(quota?.maxMemory || '0') ); const storagePercent = calculatePercentage( - parseResourceString(quota.usedStorage), - parseResourceString(quota.maxStorage) + parseResourceString(quota?.usedStorage || '0'), + parseResourceString(quota?.maxStorage || '0') ); return ( @@ -354,7 +357,7 @@ export default function AdminQuotas() { - {quota.usedSessions} / {quota.maxSessions} + {quota?.usedSessions ?? 0} / {quota?.maxSessions ?? 0} {sessionPercent > 90 && ( @@ -379,7 +382,7 @@ export default function AdminQuotas() { - {quota.usedCpu} / {quota.maxCpu} + {quota?.usedCpu || '0'} / {quota?.maxCpu || '0'} - {quota.usedMemory} / {quota.maxMemory} + {quota?.usedMemory || '0'} / {quota?.maxMemory || '0'} - {quota.usedStorage} / {quota.maxStorage} + {quota?.usedStorage || '0'} / {quota?.maxStorage || '0'} { try { const response = await api.listLoadBalancingPolicies(); - setLbPolicies(response.policies); + // Ensure policies is always an array to prevent undefined errors + setLbPolicies(Array.isArray(response?.policies) ? response.policies : []); } catch (error) { console.error('Failed to load load balancing policies:', error); + // Set empty array on error to prevent undefined + setLbPolicies([]); } }; const loadNodes = async () => { try { const response = await api.getNodeStatus(); - setNodes(response.nodes); + // Ensure nodes is always an array to prevent undefined errors + setNodes(Array.isArray(response?.nodes) ? response.nodes : []); } catch (error) { console.error('Failed to load nodes:', error); + // Set empty array on error to prevent undefined + setNodes([]); } }; const loadASPolicies = async () => { try { const response = await api.listAutoScalingPolicies(); - setAsPolicies(response.policies); + // Ensure policies is always an array to prevent undefined errors + setAsPolicies(Array.isArray(response?.policies) ? response.policies : []); } catch (error) { console.error('Failed to load auto-scaling policies:', error); + // Set empty array on error to prevent undefined + setAsPolicies([]); } }; const loadScalingHistory = async () => { try { const response = await api.getScalingHistory(); - setScalingHistory(response.events); + // Ensure events is always an array to prevent undefined errors + setScalingHistory(Array.isArray(response?.events) ? response.events : []); } catch (error) { console.error('Failed to load scaling history:', error); + // Set empty array on error to prevent undefined + setScalingHistory([]); } };