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([]);
}
};